From a63cd7d1be507f61c553622df7e2d260dc644fb7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:07:52 -0700 Subject: [PATCH 01/53] docs(plans): add model role map plan Requirements, technical decisions, and implementation units for a per-role model map in CE config. Co-Authored-By: Claude Code --- ...026-10-01-1038-feat-model-role-map-plan.md | 678 ++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 docs/plans/2026-10-01-1038-feat-model-role-map-plan.md diff --git a/docs/plans/2026-10-01-1038-feat-model-role-map-plan.md b/docs/plans/2026-10-01-1038-feat-model-role-map-plan.md new file mode 100644 index 000000000..f6fe5de1b --- /dev/null +++ b/docs/plans/2026-10-01-1038-feat-model-role-map-plan.md @@ -0,0 +1,678 @@ +--- +title: Model Role Map - Plan +type: feat +date: 2026-10-01 +topic: model-role-map +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +--- + +# Model Role Map - Plan + +## Goal Capsule + +- **Objective:** A developer chooses once per repo which model and reasoning effort does each kind of work in the Compound Engineering pipeline. Every step then produces its output on that choice, whether the developer runs the skill by hand or `lfg` runs it, in any supported app, and reports which model actually served. +- **Means:** One `model_roles` map in the existing CE config, resolved by a bundled script that every role skill calls (KTD1, KTD2), plus a guided `ce-setup` flow that writes it. +- **Authority:** The Product Contract decides behavior and the Planning Contract decides mechanism. Entries marked `session-settled` were decided by the maintainer on 2026-10-01 and are not re-opened during implementation. +- **Execution profile:** One pull request. Units run in dependency order. Every edit under `skills/**` starts from the repo-local `ce-skill-work` skill in edit mode. +- **Stop conditions:** A settled decision proves infeasible. A touched `SKILL.md` cannot stay under the prompt budget without losing a test-pinned rule. An eval shows the no-map path changed behavior and the change cannot be removed. +- **Who finishes:** `lfg` runs simplification, review, the pull request, and CI. Merging stays with the maintainer. +- **Open blockers:** None. + +--- + +## Product Contract + +### Summary + +Add one role map to the CE config with one entry per pipeline role. An entry names a model and effort, a list of them for a review panel, or `inherit`. Each step skill honors its own entry the same way by hand and under `lfg`, and `ce-setup` detects reachable models and writes the map. + +### Problem Frame + +A developer with several models available can steer only part of the pipeline today, through three unrelated settings. `plan_model` and `brainstorm_model` take a model alias with no effort. `cross_model_peer`, `cross_model_model`, and `cross_model_effort` name a single reviewer peer that `ce-doc-review` and `ce-code-review` must share. `work_engine_preferences` and `work_engine_effort` cover implementation. `ce-debug`, `ce-simplify-code`, and `ce-compound` read no model setting at all. + +`lfg` can pass a model assignment to two stages only, planning and implementation. Every other step runs on whatever model the session is on. + +The cost is a mismatch the developer cannot correct from config: an expensive session model does the mechanical steps, or a cheap one does the judgment steps. A multi-model review panel cannot be expressed at all. + +Cursor's `pstack` plugin shows the shape developers now expect: one file, one line per role, a list for a panel, written by a setup command that detects available models. + +### Key Decisions + +- **One shared map for `lfg` and standalone skills.** (session-settled: user-approved — chosen over a separate `lfg` file plus a separate work-type file: two files can disagree and need a precedence rule.) Governs R1, R15. +- **The map lives per repo in the existing two config files.** (session-settled: user-approved — chosen over a user-wide map in the home directory: no new config layer, and cloud sessions can read it.) Governs R4. +- **A single role map replaces writing three dialects.** (session-settled: user-approved — chosen over adding effort, lists, and missing keys to each existing key family: that leaves no single place showing the model setup.) Governs R1, R2, R5. +- **The map works in every supported app, and Cursor follows pstack's mechanism.** (session-settled: user-directed — chosen over a Cursor-only map: the primary user works across more than one app.) Governs R3, R10. +- **An entry governs the step's deliverable, not the conversation.** (session-settled: user-approved — chosen over running each whole stage as a delegated worker under `lfg`: a much larger change to how `lfg` runs stages.) Governs R7. +- **Fallback differs by role kind.** (session-settled: user-approved — chosen over pstack's same-family substitution for every role: a substituted panel seat weakens the independence the panel exists for.) Governs R11, R12. +- **`ce-setup` gains a guided flow for the map.** Requested directly by the user. Governs R16, R17, R18, R19. + +### Requirements + +**The map** + +- R1. CE config accepts one role map with one entry per role in this table. + +| Role | Skill | Deliverable the entry governs | Existing keys for the same step | Peer CLI route | +|---|---|---|---|---| +| `brainstorm` | `ce-brainstorm` | The generated approaches | `brainstorm_model` | yes | +| `plan` | `ce-plan` | The authored plan | `plan_model` | yes | +| `doc-review` | `ce-doc-review` | Each independent review of the plan | `cross_model_peer`, `cross_model_model`, `cross_model_effort` | yes | +| `debug` | `ce-debug` | The diagnosis and fix | none | no | +| `work` | `ce-work` | The code | `work_engine_*` | yes | +| `simplify` | `ce-simplify-code` | The simplification | none | no | +| `code-review` | `ce-code-review` | Each independent review of the diff | `cross_model_peer`, `cross_model_model`, `cross_model_effort` | yes | +| `compound` | `ce-compound` | The learning document | none | no | + +- R2. An entry is a model with an optional reasoning effort, a list of those on a review role, or `inherit`, which means the session model. +- R3. An entry is app-neutral: it names a model and effort, never an app-specific slug, CLI flag, or command. +- R4. The map resolves from the personal file, then the team file, one role at a time. An invalid entry is skipped the way an invalid ordinary key is. +- R5. A role with an entry uses it in place of the existing keys in R1's table for that step. A role with no entry behaves exactly as it does today. +- R6. A direct instruction in the conversation, and a stage directive given to `lfg`, outrank the map. + +**What an entry governs** + +- R7. A role's entry decides which model, at which effort, produces that step's deliverable as listed in R1. Dialogue with the user, orchestration inside the skill, and `lfg`'s hand-offs between steps stay on the session model. +- R8. On a review role, each list entry adds one independent reviewer on that model, so the list length sets the number of independent reviews. Seats run on every review of that role, in addition to the skill's existing persona review, which still runs when every seat is dropped. +- R9. Scouts and verifiers that a step dispatches keep their Model tier; an entry does not change them. +- R10. Each app serves an entry natively when it can hand that model, at the requested effort, to a subagent, and otherwise, for a role R1's table gives a peer CLI route, through an installed peer CLI that can serve both. In Cursor, entries are handed to subagents as their model, the way pstack's skills do it. `inherit`, or an entry with no effort that names the session's own model, runs with no hand-off. + +**Fallback and reporting** + +- R11. When a single-model role's model or effort cannot be served, the step runs on the nearest available option in the same model family, and on the session model when no family match exists. +- R12. When a review seat's model or effort cannot be served, that seat is dropped and the remaining seats run. A seat is never filled by another model. +- R13. `cross_model_review_mode: off` skips every review seat whose model is served by a provider other than the session model's, whether that seat would run as a native subagent or through a peer CLI. A conversation request still overrides it, as it does today. `CROSS_MODEL_PEERS`, when set, filters every review seat by its provider and any intermediary in the same way, and an excluded seat is skipped, never substituted. +- R14. Every step that an entry governs reports the requested model, the served model, and how it was served. A fallback under R11, a dropped seat under R12, and a seat skipped under R13 are each reported with the reason. + +**lfg** + +- R15. A step honors its entry identically whether the developer invokes the skill or `lfg` does. An `lfg` run needs no extra instruction to apply the map. + +**Setup** + +- R16. `ce-setup` offers a guided flow that detects the models the current app can reach, shows each role with its effective value and the source of that value, and lets the developer set or clear each role. The source is the map entry, the existing key from R1's table, or the session model, and the flow says so when a new entry replaces an existing key for that step. +- R17. The flow asks which file receives the map and states the reach of each: the team file reaches every worktree, clone, and cloud session, and the personal file reaches this checkout only. +- R18. Re-running the flow starts from the current map and changes only the roles the developer changes. +- R19. The flow offers the models it detected and also accepts one it could not confirm, which it writes marked as unconfirmed. `inherit` is always accepted. + +```mermaid +flowchart TB + A[Step starts] --> B{Conversation instruction or lfg directive for this step?} + B -->|yes| C[Use it] + B -->|no| D{Role entry in personal file?} + D -->|yes| G[Use the entry] + D -->|no| E{Role entry in team file?} + E -->|yes| G + E -->|no| F[Existing keys, else session model] + G --> H{Host can hand the model at the requested effort to a subagent?} + H -->|yes| I[Serve natively] + H -->|no| J{Role has a peer CLI route, and an installed CLI can serve model and effort?} + J -->|yes| K[Serve through the peer CLI] + J -->|no| L[Fallback per R11 or R12, reported per R14] +``` + +### Key Flows + +- F1. Setting up the map + - **Trigger:** The developer runs `ce-setup` and chooses the model flow. + - **Steps:** Setup detects reachable models. It shows each role with its effective value and source. The developer sets the roles they care about and leaves the rest unset. The developer picks the team or personal file, and setup writes it. + - **Outcome:** The next run of any governed skill uses the map wherever the chosen file reaches. + - **Covered by:** R16, R17, R18, R19 +- F2. An `lfg` run under the map + - **Trigger:** The developer starts `lfg` on a feature with a map in place. + - **Steps:** Each step resolves its role entry and produces its deliverable on that model. Review roles run one reviewer per seat. A step with no entry behaves as today. + - **Outcome:** The pull request exists, and each governed step has reported requested and served models. + - **Covered by:** R5, R7, R8, R14, R15 + +### Acceptance Examples + +- AE1. The motivating config + - **Covers R1, R2, R7, R8, R15.** + - **Given** a map with `plan` on Fable at low effort, `doc-review` listing a Grok model at high, a GPT model, and Opus at medium, and `work` on Opus at medium, in a session on another model. + - **When** `lfg` runs a feature. + - **Then** Fable at low authors the plan, three independent reviewers review it, Opus at medium writes the code, and every role with no entry runs as it does today. +- AE2. Role-by-role layering + - **Covers R4.** + - **Given** a team file that sets `plan` and `work`, and a personal file that sets only `plan`. + - **When** `ce-work` runs. + - **Then** it uses the team file's `work` entry. +- AE3. Existing keys keep working + - **Covers R5.** + - **Given** `plan_model: fable` and a map with no `plan` entry. + - **When** `ce-plan` runs. + - **Then** Fable authors the plan as it does today. After a `plan` entry is added, that entry is used instead. +- AE4. A seat that cannot run + - **Covers R12, R14.** + - **Given** a three-seat `doc-review` list whose first seat needs a provider that this app cannot serve and has no peer CLI for. + - **When** `ce-doc-review` runs. + - **Then** the two remaining seats and the persona review run, and the report names the dropped seat and the reason. +- AE5. Review policy still applies + - **Covers R13, R14.** + - **Given** `cross_model_review_mode: off` and a `code-review` list naming two other providers. + - **When** `ce-code-review` runs with no conversation request for a peer. + - **Then** no diff leaves for a second provider, and the report names the skipped seats. +- AE6. Effort the app cannot serve + - **Covers R11, R14.** + - **Given** `work` on Opus at max in an app that serves Opus only up to high. + - **When** `ce-work` runs. + - **Then** Opus at high writes the code, and the report says max was requested and high served. +- AE7. The same map in Cursor + - **Covers R3, R10.** + - **Given** AE1's map, opened in Cursor. + - **When** `ce-doc-review` runs. + - **Then** all three seats run as Cursor subagents on their models, with no peer CLI involved. +- AE8. Setup marks a model it could not confirm + - **Covers R16, R17, R19.** + - **Given** an app that can reach its own provider's models and one installed peer CLI. + - **When** the developer runs the setup flow, picks the team file, and enters one model from neither source. + - **Then** setup offers the models from those two sources plus `inherit`, writes the entered model marked as unconfirmed, and writes the map to the team file. +- AE9. Review policy in an app that serves several providers + - **Covers R13, R14.** + - **Given** AE1's map opened in Cursor, `cross_model_review_mode: off`, and a session whose serving family is attested as Claude. + - **When** `ce-doc-review` runs with no conversation request for a peer. + - **Then** only the Opus seat runs, and the report names the two skipped seats. When the session's family cannot be attested, no named seat runs and the report names all three. + +### Success Criteria + +- The user's own example (AE1) works as written, with no other configuration. +- A checkout with no role map behaves exactly as it did before this work. +- A developer can produce a working map from `ce-setup` alone, without reading the configuration guide. + +### Scope Boundaries + +Deferred for later: + +- Roles for `lfg`'s shipping tail: `ce-test-browser`, `ce-commit-push-pr`, and `ce-babysit-pr` stay on the session model. +- Skills outside the `lfg` flow, including `ce-pov` panels, `ce-ideate`, and `ce-explain`. +- A budget control in setup that sets every effort at once. +- A user-wide map in the home directory. +- `lfg`-only overrides of individual roles. +- CE-maintained per-role model defaults or named profiles. An unset role is governed by R5. +- Removing or deprecating `plan_model`, `brainstorm_model`, `cross_model_*`, or `work_engine_*`. +- Running a whole `lfg` stage as a delegated worker. +- Peer CLI serving for `debug`, `simplify`, and `compound`. It needs a write-capable external worker, which only `ce-work` has today. + +Outside this work: + +- Credentials, CLI commands, and app flags in the map. The existing config rule already excludes them. +- A `model_roles` section in the `ce-setup` health check. Considered and not built: each run prints the resolver's warnings for its role, each review names its recipients before sending, and the setup flow shows effective values and shadowed keys. A report of a bad map going unnoticed through those three would change the call. +- Changes to how Model tiers are assigned. + +### Dependencies / Assumptions + +- pstack's Cursor behavior is taken from its skill text (`setup-pstack`, `interrogate`, `arena`), not from a test in Cursor. The assumption behind R10 and AE7 is that Cursor's subagent tool accepts any model the account can use. +- A list means a review panel only on `doc-review` and `code-review`. Ordered fallbacks for implementation stay in `work_engine_preferences`. The user has not confirmed this. +- Role names follow the skill names in R1's table. The user has not confirmed them. +- R4's role-by-role layering is an exception to the rule that a map in the personal file replaces the whole key. `packs:` already has its own merge rule, so it is not the first exception. +- A map in the team file applies to teammates who may not reach a listed model. R11 and R12 govern what they get. +- The model names in the user's example are illustrative. Which aliases and versions are valid stays with the existing alias handling. +- A config option change updates `skills/ce-setup/references/config-template.yaml`, `.compound-engineering/config.example.yaml`, and `docs/guides/configuration.md` in the same change, per the project's instructions. + +### Open Questions + +Deferred to implementation: + +- Whether `ce-work` already runs any unit in a native subagent that can take a model. Where it does, a `work` entry may be served natively; otherwise KTD10's engine routes serve it. +- Which block each near-budget `SKILL.md` relocates, if a pointer line does not fit (U7, U9). +- The exact optional field names that carry the report line in `ce-code-review`'s `mode:agent` return and `ce-debug`'s return (KTD13). +- Whether `cross-model-work.sh` already exposes the accepted effort levels of a route at preflight, or needs that added (U5). + +Not verified in this work: + +- AE7 and the Cursor half of AE9. The eval harness has no Cursor host, so both are manual checks recorded in the pull request. + +### Sources / Research + +- `skills/ce-setup/references/config-template.yaml` — every existing model and effort key, with comments. +- `docs/guides/configuration.md` — the two-file layering rule and the "config is a default" precedence. +- `skills/lfg/SKILL.md` and `skills/lfg/references/stage-routing.md` — the pipeline steps and the two routable stages. +- `skills/ce-plan/references/reasoning-elevation.md` — the native, Claude CLI, inline adapter order that R10 extends. +- `skills/ce-doc-review/references/cross-model-review.md` and `skills/ce-code-review/references/cross-model-review.md` — the single-peer pass, `cross_model_review_mode`, and the skip-rather-than-substitute rule behind R12 and R13. +- `CONCEPTS.md` — Model tier, Cross-model pass, Model identity receipt, Engine carrier. +- `docs/plans/2026-08-12-002-fix-repo-config-cascade-plan.md` — why most keys are team-default and locally overrideable, and the no-deep-merge rule R4 departs from. +- `docs/plans/2026-03-25-002-refactor-config-storage-redesign-plan.md` — why durable config left `.context/compound-engineering/`. +- pstack: https://github.com/cursor/plugins/tree/main/pstack — `skills/setup-pstack/SKILL.md` for the map shape and detection, `skills/interrogate/SKILL.md` and `skills/arena/SKILL.md` for how a skill consumes a role line. + +--- + +## Planning Contract + +### Product Contract preservation + +Changed: AE9 — a session whose serving family cannot be attested skips every named seat under `cross_model_review_mode: off` (KTD7). Cursor's family is unknown by this repo's own rule, so the original result could not be promised there. + +Clarified, no scope change: + +- R1's peer CLI column for `plan` and `brainstorm` covers Claude-family models, the only family the elevation engine has an adapter for (KTD9). +- R14's served effort is the effort the route was asked to run at. No route returns an effort receipt. +- R10's no-hand-off case is an entry with no effort, because no host exposes the session's effort (KTD5). +- R1's table names the three review keys an entry replaces. `cross_model_review_mode` stays in force under R13. +- The Product Contract's planning questions are resolved by KTD1 through KTD13. + +### Key Technical Decisions + +- KTD1. **The map is one top-level `model_roles` key whose entries are plain strings.** An entry is ` []`, the word `inherit`, or on a review role a list of those. The effort scale is `low`, `medium`, `high`, `xhigh`, `max`. Strings read like the example the user wrote and fit the narrow hand-rolled parser `packs-resolve.py` already uses. Object entries in the style of `work_engine_preferences` were judged heavier with no added expressiveness, so no bake-off ran. Governs R2, R3. +- KTD2. **One bundled resolver script owns grammar, layering, family classification, and review egress policy.** `model-role-resolve.py` is stdlib-only Python, byte-duplicated into every consumer skill with a parity test, as `packs-resolve.py` is. A skill runs it only when a config file holds an active `model_roles:` key, so a checkout with no map never needs Python and behaves as it does today. The `ce-config-layers` prose block stays untouched, because role-by-role layering lives in the script the way the `packs:` merge rule does. Governs R4, R5, R13. +- KTD3. **Skill bodies gain no role prose.** Every touched `SKILL.md` except `ce-simplify-code` sits within 161 bytes of the 8,000-byte prompt budget. The shared contract is one reference, `references/model-roles.md`, byte-duplicated into the eight role skills and loaded from the reference each skill already reads at its acting step. +- KTD4. **Precedence is live instruction, then `lfg` carrier, then map entry, then existing key, then session model.** A map entry in either file outranks an existing key in either file. A live instruction or carrier replaces the entry whole, including its effort and, on a review role, its seat list for that run. One personal opt-out survives: `work_engine_mode: off` in the personal file keeps a team-file `work` entry off any external engine, while a personal-file `work` entry still wins. Governs R5, R6. +- KTD5. **An entry with an explicit effort always hands off.** (session-settled: user-approved — chosen over picking the native route on the model alone: native dispatch cannot set effort, so the motivating config would lose its efforts.) No host exposes the session's own effort, so only an entry with no effort that names the attested session model runs without a hand-off. Governs R10. +- KTD6. **The single-model fallback ladder keeps the named model as long as any route can serve it.** In order: the same model at the next lower effort the route accepts; the same model with the effort unapplied, on a route that has no effort control; the route's own default model where it has one; the session model. The native route of `debug`, `simplify`, and `compound` is the no-effort-control case on every host verified so far, and the `Model role` line reports the effort as not applied. The resolver owns the effort order and each adapter's preflight owns which levels a route accepts. This applies to map entries only. `ce-work`'s rule that nothing runs at a different effort than requested, and elevation's rule that recovery never substitutes a model, keep governing the existing keys. Governs R11. +- KTD7. **Review roles fail closed.** When an active `model_roles` block is malformed or the resolver cannot run, a review skill runs its persona review only and names the reason. An invalid seat drops alone; a list never falls through to another layer's seats. An unattested host family blocks every named seat under `off` or a set `CROSS_MODEL_PEERS`, and so does a seat whose own family is unknown. Governs R12, R13. +- KTD8. **A seat is one job on the review skill's existing peer brief, with its own identity.** `ce-doc-review` seats run the `whole-doc` brief. `ce-code-review` seats run the adversarial peer brief beside the in-process adversarial reviewer. Seats replace the conditional single-peer pass when the role has an entry, and the rule that a started peer removes the in-process adversarial reviewer stays scoped to that legacy pass. Synthesis rules do not change: seat-only findings never auto-apply and agreement between seats does not stack. A seat in the host's own family bypasses the worker's same-family exclusion and never records `independence_verified`. Governs R8. +- KTD9. **`plan` and `brainstorm` keep one peer adapter, the Claude CLI.** An entry in another family is served only where the host can hand that model to a subagent, and otherwise falls to the session model with a report. New read-only Codex and Grok elevation adapters are follow-up work. +- KTD10. **A `work` entry becomes one `prefer` engine candidate.** The resolver maps family to harness: Claude to `claude`, GPT to `codex`, Grok to `grok`, Composer to `cursor`. The entry's effort replaces `work_engine_effort` for that candidate. `ce-work`'s existing return fields carry the report, so `lfg`'s work gate is unchanged. +- KTD11. **Native-only hand-offs pass files, and the session keeps every gate.** `ce-simplify-code` hands apply-and-verify to one subagent. `ce-compound` has a subagent draft the body into scratch from a hand-off file while the orchestrator classifies, writes, and validates. `ce-debug` hands investigation and the test-first fix to subagents while the session keeps the causal-chain gate, branch, scope record, commit, and return. A hand-off that fails mid-edit is finished inline by the session, which re-runs verification and reports the fallback. Governs R7. +- KTD12. **Cursor serving is a condition, not a table.** The resolver emits model and effort only. A session whose subagent tool lists model ids that encode effort matches the entry against that list, and no match means the entry cannot be served there. No Cursor ids are stored in the repo. Governs R3, R10. +- KTD13. **Each governed step prints one `Model role` line in the output it already produces.** The line names the role, the requested model and effort, the served model or `unverified`, the route, and the reason for any fallback, drop, or skip. Structured returns gain optional fields only; no test-pinned key is renamed. `lfg` relays the lines in its close-out. Governs R14, R15. + +### High-Level Technical Design + +```mermaid +flowchart TB + T[config.yaml] --> R[model-role-resolve.py] + L[config.local.yaml] --> R + E[CROSS_MODEL_PEERS and review mode] --> R + R -->|one role as JSON| S[Role skill at its acting step] + S -->|native hand-off| N[Host subagent with model] + S -->|peer CLI route| P[Existing workers: elevation, review, work] + S -->|no route| F[Fallback per KTD6 or dropped seat per KTD7] + N --> O[Model role line] + P --> O + F --> O + R -->|all roles| H[ce-setup flow] +``` + +Precedence for one role, highest first (KTD4): + +| Source | Single-model role | Review role | +|---|---|---| +| Live instruction this run | Replaces the entry, effort included | Runs the legacy single-peer path as today; the seat list is not used | +| `lfg` carrier | Replaces the entry, effort included | None exists | +| Personal-file entry | Used | Its seats are used | +| Team-file entry | Used | Its seats are used | +| Existing key, either file | Used as today | Conditional single-peer pass as today | +| Nothing | Session model | Persona review only | + +Seat lifecycle: + +```mermaid +stateDiagram-v2 + [*] --> Resolved + Resolved --> Invalid: bad model or effort + Resolved --> Blocked: review mode off or allowlist + Resolved --> Started: route can serve model and effort + Resolved --> Dropped: no route can serve it + Started --> Served: artifact returned + Started --> Dropped: worker failed or timed out + Served --> [*]: folded into synthesis + Invalid --> [*]: reported + Blocked --> [*]: reported + Dropped --> [*]: reported +``` + +Resolver output for one role, as a directional shape: + +```text +role, state (unset | inherit | entries | invalid), source (local | team) +entries[]: seat, model, effort or null, family, harness or null, blocked_by or null +effort_scale, warnings[], errors[] +``` + +The `--all` form reports each role's effective value and source, plus any existing key a map entry shadows. + +### Assumptions + +These are planning bets the user has not confirmed. + +- A map entry in the team file outranks a personal existing key for the same step, with the one exception in KTD4. The setup flow names every shadowed key. +- A personal `work_engine_mode: off` is treated as an opt-out from external engines, the way R13 treats the review mode. The user has not confirmed that reading. +- A live "review with X" instruction runs the existing single-peer path for that run and ignores the seat list. +- On a review role, scalar `inherit` and an empty list both mean no seats and no legacy pass. Inside a list, `inherit` is one seat on the session model. +- Duplicate seats each run, and the list has no cap. +- Seats run when a review dispatches its persona team. A re-invocation that reuses completed review results reuses seat results too. +- A natively served review seat is instructed read-only and document-only, matching the peer-CLI bound. +- A `provider/model` id, or any model the resolver cannot place in a family, is served only natively and otherwise falls back or drops. +- Seats never use an intermediary route such as Grok through `cursor-agent`. +- In a remote-only review scope, peer-CLI `code-review` seats are dropped and reported, and native seats run. +- Setup treats a family as confirmed when the host or an installed CLI serves it. It does not test account entitlement. +- Evals run on Claude and OpenCode. Codex is recorded as a skipped host because its CLI does not start on the authoring machine. + +### System-Wide Impact + +- **Egress.** A committed map can send plans, diffs, and implementation work to providers a teammate did not pick. R13 and KTD7 control review roles, KTD4's opt-out controls `work`, and each review names every recipient before anything is sent (U6). +- **Prompt budget.** Nine skills gain a script and a reference. Bodies stay under the 8,000-byte cap by KTD3. +- **Cost.** A three-seat list adds three full reviews to every plan and every diff `lfg` handles. +- **Existing contracts.** `ce-work`'s return, `ce-debug`'s return, and `ce-code-review`'s `mode:agent` return are read by `lfg` and pinned by tests. Changes are additive. + +### Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Native effort is unproven on every host | KTD5 routes effort through a peer CLI; U11 records which hosts were verified live | +| Hand-off prose regresses the no-map path | Every consumer skips the resolver when no active key exists; U11 carries a restraint cell per role | +| Review rounds accrete cases onto the shared contract | The contract has one owner per rule (KTD2, KTD3); restate the condition on the second round | +| Subprocess-heavy tests hit the bun parallel timeout | Resolver tests build throwaway repos and stay spawn-light | +| Codex may expose no model selector to subagents, so native-only roles could always fall to the session model there, and Codex is a skipped eval host | The pull request states that Codex behavior is unverified; the `Model role` line reports each fallback at run time | +| `tests/gpt-5-6-skill-migration.test.ts` rejects pinned GPT variants under `skills/` | Template and reference examples use Claude aliases and a placeholder for other families | + +--- + +## Implementation Units + +| Unit | Title | Key files | Depends on | +|---|---|---|---| +| U1 | Resolver script and parity | `skills/ce-setup/scripts/model-role-resolve.py` | none | +| U2 | Config surface | `skills/ce-setup/references/config-template.yaml`, `docs/guides/configuration.md` | U1 | +| U3 | Shared role contract reference | `skills/ce-plan/references/model-roles.md` | U1 | +| U4 | `plan` and `brainstorm` | `skills/ce-plan/references/reasoning-elevation.md` | U3 | +| U5 | `work` | `skills/ce-work/references/execution-engines.md` | U3 | +| U6 | Review seats | `skills/ce-doc-review/references/cross-model-review.md` | U3 | +| U7 | `simplify` and `compound` | `skills/ce-simplify-code/SKILL.md`, `skills/ce-compound/references/assembly.md` | U3 | +| U8 | `debug` | `skills/ce-debug/references/fix.md` | U3 | +| U9 | Setup flow | `skills/ce-setup/references/model-roles-setup.md` | U1, U2 | +| U10 | `lfg` relay and guides | `skills/lfg/references/task-visibility.md`, `docs/guides/` | U4 to U9 | +| U11 | Behavioral evals | `tests/skill-eval-cell/catalog.ts` | U4 to U10 | + +### U1. Resolver script and parity + +- **Goal:** One script turns the two config files into a per-role answer that every consumer reads the same way. +- **Requirements:** R1, R2, R3, R4, R13; KTD1, KTD2, KTD7, KTD10. +- **Dependencies:** None. +- **Files:** + - Create `skills/ce-setup/scripts/model-role-resolve.py` as the canonical copy. + - Create byte-identical copies under `scripts/` in `ce-brainstorm`, `ce-plan`, `ce-doc-review`, `ce-code-review`, `ce-work`, `ce-debug`, `ce-simplify-code`, and `ce-compound`. + - Create `tests/skills/model-role-resolver.test.ts`. +- **Approach:** + 1. Parse only the `model_roles` block with a narrow parser that accepts scalars, block lists, and flow lists, and reports anything else as an error in the JSON. + 2. Resolve each role from the personal file, then the team file. An invalid single-role entry is skipped with a warning. + 3. Classify each model into a family with one table: Claude aliases and `claude-*`, `gpt-*` and `o*`, `grok-*`, `composer-*`, otherwise unknown. + 4. For review roles, read `cross_model_review_mode` by the ordinary two-file rule and `CROSS_MODEL_PEERS` from the environment, take the attested host family as an argument, and mark each seat's `blocked_by`. + 5. Print one JSON object and exit 0 whenever resolution ran. `--role ` answers one role and `--all` answers every role with effective value, source, and shadowed existing keys. +- **Execution note:** Write the test file first; the script's contract is the JSON. +- **Patterns to follow:** `skills/ce-setup/scripts/packs-resolve.py` for the parser and never-throw contract. `tests/skills/ce-packs-resolver.test.ts` for the copy list and throwaway repos. +- **Test scenarios:** + - Covers AE2. Team file sets `plan` and `work`, personal file sets `plan`: `--role work` returns the team entry and `--role plan` the personal one. + - `opus medium` returns model `opus`, effort `medium`, family `claude`, harness `claude`; `opus` alone returns a null effort. + - Personal `inherit` over a team entry returns state `inherit`. + - A block list and a flow list on `doc-review` both return seats in order with seat numbers; duplicate entries stay duplicated. + - A scalar model on a review role returns one seat; `[]` and scalar `inherit` return state `inherit`. + - A list on `work` is invalid in that layer with a warning, and the other layer's scalar is returned. + - An unknown effort word invalidates a single-role entry; with no other layer the role is unset. + - One bad seat in a three-seat list is returned marked invalid, the other two are returned, and the other layer is not consulted. + - A malformed `model_roles` block returns state `invalid` with an error and exit 0. + - `code_review` as a key is ignored by `--role` and warned about by `--all`. + - Family table: `fable`, `claude-opus-5-5`, a `gpt-` id, an `o3` id, `grok-4.7`, `composer-2.5-fast`, and `provider/model` each land in the expected family. + - Covers AE5, AE9. Mode `off` with host family `claude` blocks the Grok and GPT seats and leaves the Opus seat; host family `unknown` blocks all three; an `inherit` seat is never blocked. + - `CROSS_MODEL_PEERS=codex` with host `claude` blocks the Grok seat only. + - A `provider/model` seat is blocked under mode `off` and under `CROSS_MODEL_PEERS=codex`, each with host `claude`. + - A personal `cross_model_review_mode: off` beats a team `auto`. + - Covers AE3. `--all` with `plan_model: fable` and no `plan` entry reports the existing key as the source; with an entry it reports the entry and lists `plan_model` as shadowed. + - Outside a git repository, and with no config files, every role is unset. + - All nine copies are byte-identical. +- **Verification:** The new test file passes, and `tests/skill-conventions.test.ts` and `tests/bundled-script-line-endings.test.ts` stay green. + +### U2. Config surface + +- **Goal:** The new key is documented where every other key is. +- **Requirements:** R1, R4, R5, R13; KTD1. +- **Dependencies:** U1. +- **Files:** + - Modify `skills/ce-setup/references/config-template.yaml` and its byte-identical copy `.compound-engineering/config.example.yaml`. + - Modify `docs/guides/configuration.md`. + - Modify `tests/skills/ce-setup-check-health.test.ts`, which owns the template identity and docs coverage checks. +- **Approach:** + 1. Add a `# --- Model roles ---` section to the template with every role commented out, the role-by-role layering exception stated at the key, and a pointer to `cross_model_review_mode`. + 2. In the guide, add a "How keys resolve" bullet for the exception, an Options row, and a worked example covering `inherit`, a seat list, shadowed existing keys, and the personal opt-outs. +- **Patterns to follow:** The `# --- Model elevation ---` template section; "Implementation routing" in the guide. +- **Test scenarios:** + - The example file equals the template byte for byte. + - `model_roles` appears in the configuration guide; no prose comment line in the template parses as an undocumented key. + - The template's example lines parse through the resolver once uncommented. +- **Verification:** The identity and docs coverage tests pass, and the health check's output is unchanged for every existing fixture. + +### U3. Shared role contract reference + +- **Goal:** The common rules exist once per skill directory and never in a skill body. +- **Requirements:** R6, R7, R9, R10, R11, R14; KTD3, KTD4, KTD5, KTD6, KTD12, KTD13. +- **Dependencies:** U1. +- **Files:** + - Create `references/model-roles.md` in `ce-brainstorm`, `ce-plan`, `ce-doc-review`, `ce-code-review`, `ce-work`, `ce-debug`, `ce-simplify-code`, and `ce-compound`, byte-identical. + - Create `tests/model-roles-reference-parity.test.ts`. + - Modify `CONCEPTS.md` to add Model role map and Review seat. +- **Approach:** The reference states, in this order: when to consult the map (an active key exists), the resolver recipe with the `SKILL_DIR` and `PY` probe and its failure direction, precedence (KTD4), when a hand-off happens (KTD5), the serving order with the Cursor condition (KTD12), the single-model fallback (KTD6), what stays on the session model (R7, R9), and the `Model role` line (KTD13), which also carries any resolver warning for the role. Seat rules live in U6, not here. +- **Patterns to follow:** `skills/ce-plan/references/reasoning-elevation.md` and `tests/reasoning-elevation-parity.test.ts` for a byte-duplicated engine reference; `skills/ce-brainstorm/references/dialogue.md` for the script recipe. +- **Test scenarios:** + - All eight copies are byte-identical, and renaming one token in one copy turns the parity test red. + - Each copy names `scripts/model-role-resolve.py` and the path exists in that skill. + - The reference contains the stable tokens `Model role`, `inherit`, and the five effort words. + - `tests/codex-skill-prompt-budget.test.ts` stays green for every touched skill. +- **Verification:** Parity and convention tests pass. Behavior is evaluated in U11. + +### U4. `plan` and `brainstorm` + +- **Goal:** A `plan` or `brainstorm` entry decides the authoring or approach-generation model and its effort. +- **Requirements:** R5, R6, R7, R10, R11, R14; KTD4, KTD5, KTD6, KTD9. +- **Dependencies:** U3. +- **Files:** + - Modify `skills/ce-plan/references/reasoning-elevation.md` and its byte-identical copy in `skills/ce-brainstorm/`. + - Modify `skills/ce-plan/scripts/elevation-dispatch.sh` and its byte-identical copy in `skills/ce-brainstorm/`. + - Modify `tests/skills/elevation-dispatch.test.ts`. +- **Approach:** + 1. At the Config step of activation resolution, consult the role entry first and the existing key second, loading `references/model-roles.md` at that point. + 2. Give the worker an optional effort, validated against the Claude CLI's levels, defaulting to `high`. + 3. For an entry with an effort, use the Claude CLI for a Claude-family model while that route is available, and otherwise the native adapter with the effort unapplied (KTD6). For an entry with no effort, keep today's native-first order. + 4. Scope the existing rule that skips dispatch when the session model is the resolved model to entries with no effort (KTD5). + 5. Extend the transparency line into the `Model role` line, adding the applied effort and any step-down. +- **Patterns to follow:** The existing `--emit-adapter` test seam; the receipt rule already in the reference. +- **Test scenarios:** + - The emitted adapter carries `--effort low` when effort `low` is passed and `--effort high` when none is. + - An effort the Claude CLI does not document is rejected before launch with a named reason. + - The result envelope records the requested effort. + - Both copies of the reference and of the script stay byte-identical. +- **Verification:** Elevation tests and the parity test pass. AE1's plan half and AE3 are graded by U11's cells. + +### U5. `work` + +- **Goal:** A `work` entry selects the implementation model and effort through the engine `ce-work` already has. +- **Requirements:** R5, R6, R7, R10, R11, R14; KTD4, KTD5, KTD6, KTD10. +- **Dependencies:** U3. +- **Files:** + - Modify `skills/ce-work/references/execution-engines.md` and `skills/ce-work/references/cross-model-execution.md`. + - Modify `skills/ce-work/scripts/cross-model-work.sh` only if preflight cannot already report a route's accepted effort levels. + - Modify `tests/skills/ce-work-cross-model-routes.test.ts`. +- **Approach:** + 1. At the per-checkout configuration step, a role entry becomes the only candidate, at `prefer`, with the resolver's harness and the entry's model and effort; `inherit` selects native execution. + 2. A candidate that carries an effort is never treated as equivalent to the host: it runs through the host's own CLI route at that effort. Only an entry with no effort that names the session model collapses to native (KTD5). + 3. A team-file entry is not served by an external engine when the personal file sets `work_engine_mode: off` (KTD4). + 4. Apply KTD6 for this candidate. + 5. State the outcome in `fallback_reason` and the `Model role` line; add no envelope key. +- **Patterns to follow:** The candidate normalization and collapse-to-native rules already in `execution-engines.md`. +- **Test scenarios:** + - Covers AE6. A route whose preflight rejects `max` and accepts `high` runs at `high`, with `requested_effort` `max` and a `fallback_reason` naming the step-down. + - A route with no effort knob and an entry with an effort runs the named model at the route's default effort, reported as not applied. + - `opus medium` in a session already on Opus runs through the `claude` route at `medium`; `opus` alone collapses to native. + - A team-file entry with a personal `work_engine_mode: off` runs natively with the reason stated; a personal-file entry with the same key still routes. + - An entry in an unknown family ends on native execution with the reason stated. + - With an entry present, `work_engine_preferences` and `work_engine_effort` are not consulted; with none, the existing routing tests pass unchanged. + - An `implementation_engine` carrier still wins over an entry. +- **Verification:** The `ce-work` route tests and `tests/pipeline-review-contract.test.ts` pass. + +### U6. Review seats + +- **Goal:** A list on `doc-review` or `code-review` runs one independent reviewer per seat on every review. +- **Requirements:** R5, R8, R12, R13, R14; KTD4, KTD7, KTD8, KTD12. +- **Dependencies:** U3. +- **Files:** + - Modify `skills/ce-doc-review/references/cross-model-review.md` and `skills/ce-code-review/references/cross-model-review.md`, sharing one delimited seat block. + - Modify `skills/ce-doc-review/scripts/cross-model-doc-review.sh` and `skills/ce-code-review/scripts/cross-model-adversarial-review.sh`. + - Modify `skills/ce-doc-review/references/dispatch.md` and `skills/ce-code-review/references/select-and-route.md`, the references every dispatching review reads, as the seat trigger. + - Modify the synthesis reference of each review skill where seat results are folded in and reported. + - Create `tests/review-seat-block-parity.test.ts`; modify `tests/skills/ce-doc-review-cross-model-routes.test.ts`, `tests/skills/ce-code-review-cross-model-routes.test.ts`, and `tests/skills/cross-model-review-mode.test.ts`. + - Modify `tests/skill-eval-cell/packs/ce-doc-review-cross-model.md` and `tests/skill-eval-cell/packs/ce-code-review-cross-model.md`. +- **Approach:** + 1. At persona dispatch, check for an active `model_roles` key and, when the role resolves to seats, load `references/cross-model-review.md` and start the seats in the same wave, whether or not a judgment lens activated. The lens-activation condition stays scoped to the legacy single-peer pass. + 2. In the seat block, state the lifecycle from the High-Level Technical Design, the fail-closed rule (KTD7), and that seats replace the conditional pass for that review. + 3. Start one detached worker job per unblocked seat with its target, model, and effort; a seat the host can serve natively, or an `inherit` seat, runs as a subagent on the same brief and its return is saved in the same artifact shape. + 4. Give the workers a seat number that enters the artifact name and reviewer identity, and an explicit-seat flag that skips both the same-family exclusion and the skip on an unknown host family, while leaving `independence_verified` false. + 5. In `pr-remote` and `branch-remote` scope, drop a `code-review` seat served through a peer CLI, because the reviewed head is not the local tree; natively served seats still run. + 6. Announce every recipient in one notice before anything is sent. + 7. Report started, dropped, blocked, and invalid seats in Coverage, in the non-interactive result, and in the `mode:agent` return. +- **Patterns to follow:** `skills/ce-pov/references/cross-model-panel.md` for one job per target; the existing override environment variables for per-invocation model and effort. +- **Test scenarios:** + - Two seats on one provider with seat numbers 1 and 2 write two artifacts with different reviewer identities. + - With the explicit-seat flag, a Claude seat under a Claude host emits its adapter and records `independence_verified: false`; without the flag the host family is still skipped. + - With the flag, a seat under host family `unknown` emits its adapter and records `independence_verified: false`. + - `dispatch.md` and `select-and-route.md` each name the seat trigger, and neither ties it to a judgment lens. + - The `ce-code-review` seat block states the remote-scope drop for peer-CLI seats. + - A seat's model and effort reach the adapter arguments for the Claude and Codex routes. + - An effort on a `cursor-agent` route ends as a named skip with no artifact. + - The seat block is byte-identical in both references. + - Both references state that the persona review runs when every seat is dropped and that one notice names every recipient. + - Covers AE1, AE4. The hand-run packs gain a three-seat scenario and a one-seat-unreachable scenario on stub CLIs. +- **Verification:** Route tests, the review-mode test, the new parity test, and `tests/review-skill-contract.test.ts` pass. + +### U7. `simplify` and `compound` + +- **Goal:** A `simplify` or `compound` entry moves that step's deliverable to a native subagent on the named model. +- **Requirements:** R7, R9, R10, R11, R14; KTD5, KTD11. +- **Dependencies:** U3. +- **Files:** + - Modify `skills/ce-simplify-code/SKILL.md`. + - Modify `skills/ce-compound/references/research.md`, `skills/ce-compound/references/assembly.md`, and `skills/ce-compound/references/report.md`. + - Modify `tests/skills/ce-compound-headless-depth.test.ts`; create `tests/skills/model-role-native-handoff.test.ts` for the shared pins of U7 and U8. +- **Approach:** + 1. In `ce-simplify-code`, when the role resolves to a model the host can hand to a subagent, dispatch one subagent that receives the three reviewers' findings and the mutation boundary and performs apply and verify together; otherwise the session does both as today and says so. + 2. In `ce-compound`, write the conversation-derived facts to a hand-off file, have the subagent draft the body into the run's scratch directory, and keep classification, the write under the artifact root, and validation with the orchestrator. + 3. Print the `Model role` line in the simplify summary and the compound report, above the terminal `Documentation complete` or `Documentation skipped` line. An entry's effort is reported as not applied on a host whose subagent tool cannot set one (KTD6). +- **Patterns to follow:** The files-not-prose hand-off in `reasoning-elevation.md`; `ce-compound`'s rejected-dispatch rule in `research.md`. +- **Test scenarios:** + - `ce-simplify-code` names `references/model-roles.md` at its apply step and its body stays under the prompt budget. + - `ce-compound`'s `SKILL.md` is unchanged in size, and `assembly.md` keeps the orchestrator as the only writer under the artifact root. + - The compound report keeps its exact terminal lines. + - Both skills state the inline fallback and the mid-edit failure rule. +- **Verification:** The listed tests pass. Dispatch behavior is graded by U11's cells. + +### U8. `debug` + +- **Goal:** A `debug` entry moves investigation and the test-first fix to native subagents while the session keeps every gate. +- **Requirements:** R7, R9, R10, R11, R14; KTD5, KTD11, KTD13. +- **Dependencies:** U3. +- **Files:** + - Modify `skills/ce-debug/references/investigate.md` and `skills/ce-debug/references/fix.md`. + - Modify `skills/ce-debug/references/return-to-caller.md` and `skills/ce-debug/references/pipeline-mode.md`. + - Modify `skills/lfg/references/debug-return.md` and `tests/skills/lfg-intake-contract.test.ts` only for an optional report field. +- **Approach:** + 1. Investigation: one read-only subagent returns evidence and a proposed causal chain to a scratch file; the session presents the findings and owns the gate. + 2. Fix: the session creates the branch, confirms any user edits in the files the diagnosis names, and records the pre-fix scope; the subagent then writes the failing test and the minimal fix inside those files and returns. + 3. The session reviews the diff, runs the verification, commits, and counts failed attempts; a retry re-dispatches from the hand-off file. + 4. `ce-debug`'s `SKILL.md` is over budget and may not grow, so every change sits in references. +- **Patterns to follow:** The optional parallel investigators in `investigate.md` for the evidence-return shape. +- **Test scenarios:** + - Every existing status and JSON key in the `ce-debug` return stays pinned; the report field is optional and `lfg`'s gate accepts a return without it. + - `fix.md` keeps the scope record and commit with the session. + - `ce-debug`'s `SKILL.md` byte size does not increase. + - The existing five `ce-debug` eval cells still pass on the no-map path (run in U11). +- **Verification:** The `lfg` intake contract test and the budget test pass. + +### U9. Setup flow + +- **Goal:** A developer builds or edits the map from `ce-setup` and chooses where it is written. +- **Requirements:** R16, R17, R18, R19; KTD2. +- **Dependencies:** U1, U2. +- **Files:** + - Create `skills/ce-setup/references/model-roles-setup.md`. + - Modify `skills/ce-setup/SKILL.md`, relocating a block if the pointer does not fit the budget. + - Modify `skills/ce-setup/references/repo-fixes.md` where it forbids creating `config.local.yaml`. + - Create `tests/skills/ce-setup-model-roles.test.ts`; modify `tests/skills/ce-setup-check-health.test.ts` for the changed wording. + - Modify `docs/guides/ce-setup.md`. +- **Approach:** + 1. Open the reference with outcome, done condition, and the safe failure direction: write nothing the developer has not seen. + 2. Show each role's effective value and source from the resolver's `--all` output. + 3. Offer the families the host and installed CLIs serve; accept a typed model and write it with a trailing `# unconfirmed` comment. + 4. Ask which file to write, state each one's reach, and warn when a personal entry would shadow a team write. + 5. Preview the whole block, write on approval, keep unrelated settings and comments, apply the existing gitignore check when creating the personal file, and re-run the health check. + 6. In a non-interactive run, print the preview and write nothing. +- **Patterns to follow:** `skills/ce-setup/references/pack-scaffold.md` and `tests/skills/ce-setup-pack-scaffold.test.ts`. +- **Test scenarios:** + - The skill's body points to the new reference, its `argument-hint` names the flow, and the body stays under budget. + - The reference states the two targets and their reach, the unconfirmed marker, the preview-before-write rule, and the non-interactive rule. + - The statement that setup never creates `config.local.yaml` is replaced by one scoped to the template-copy step, and its test follows. + - `docs/guides/ce-setup.md` has a section for the flow. + - Covers AE8. Graded by U11's judged conversation scenario. +- **Verification:** The new test file, the health-check tests, and `bun run release:validate` pass. + +### U10. `lfg` relay and guides + +- **Goal:** An `lfg` run shows each step's `Model role` line, and every role skill's guide explains its entry. +- **Requirements:** R14, R15; KTD13. +- **Dependencies:** U4 to U9. +- **Files:** + - Modify `skills/lfg/references/task-visibility.md` so the per-step return line and the close-out recap carry any `Model role` line a child printed. + - Modify `docs/guides/` pages for `ce-brainstorm`, `ce-plan`, `ce-doc-review`, `ce-debug`, `ce-work`, `ce-simplify-code`, `ce-code-review`, `ce-compound`, and `lfg`. + - Modify the consumer-guide list in `tests/skills/ce-setup-check-health.test.ts` to add the three skills that had no configuration link. +- **Approach:** `lfg`'s body and `stage-routing.md` are not edited: each child resolves its own role (KTD13). Each guide gains a short "Model role" section linking to `./configuration.md`. +- **Test scenarios:** + - `lfg`'s `SKILL.md` is byte-identical to before. + - `task-visibility.md` names the relay; the `lfg` contract tests pass. + - Every role skill's guide contains `./configuration.md`. +- **Verification:** `tests/skills/lfg-intake-contract.test.ts`, `tests/skills/task-visibility-contract.test.ts`, and the docs coverage test pass. + +### U11. Behavioral evals + +- **Goal:** Fresh-agent evidence that each role resolves, routes, and restrains as specified, on the on-disk skills. +- **Requirements:** All; Success Criteria. +- **Dependencies:** U4 to U10. +- **Files:** + - Modify `tests/skill-eval-cell/catalog.ts` and add fixtures under `tests/skill-eval-cell/fixtures/`. + - Modify `tests/skill-eval-cell/judged/scenarios.ts` for the setup flow. + - Modify the two cross-model packs from U6. +- **Approach:** + 1. Resolution cells, one per role, in write mode so the resolver can run, graded on a clean worktree and the `Model role` line: a two-file fixture where the personal file overrides one role, and a task that asks for the skill's ordinary work without naming model roles. + 2. One `ce-doc-review` cell on a routine plan that activates no judgment lens, graded on the seat notice. + 3. Restraint cells, one per role: no `model_roles` key; the grade fails if the resolver is run or a `Model role` line appears. + 4. Precedence cells for `plan` and `doc-review`, in write mode: an existing key plus an entry, and a live instruction over an entry. + 5. Policy cells for both review roles, in write mode: mode `off` and a set allowlist. + 6. Live delegation cells for `simplify` and `compound`, graded on the dispatch trailer and on-disk artifacts. + 7. Seat dispatch through the hand-run packs with stub CLIs first on `PATH`. + 8. One judged conversation scenario for the setup flow. + 9. Run with the working-tree arm on every row and the baseline arm where a baseline exists, three runs per discriminating cell, on Claude and OpenCode. +- **Execution note:** Run evals only after `bun run test` is green; each cell bills a host CLI. +- **Patterns to follow:** The `ce-plan/config-model-reaches-authoring-gate` row and its fixture; `.agents/skills/ce-skill-work/references/evaluate.md`. +- **Test scenarios:** + - Covers AE1. `ce-plan` reports Fable at low from the map; `ce-work` reports Opus at medium on the `claude` harness; `ce-doc-review` reports three seats. + - Covers AE2. The personal file's one role wins and the other roles come from the team file. + - Covers AE3. With only `plan_model`, the existing row still passes; with both, the entry is reported. + - Covers AE4. One unreachable seat is reported dropped and two run. + - Covers AE5. Under `off`, no stub CLI for another provider is invoked. + - Covers AE8. The judged setup run offers detected families, marks a typed model unconfirmed, and writes the chosen file only after approval. + - No-map cells for all eight roles show no resolver run and no `Model role` line. +- **Verification:** `pack.json` for each skill shows every post-arm cell passing, or a recorded reason. Results, hosts, and skipped hosts go in the pull request. + +--- + +## Verification Contract + +| Gate | Command or evidence | Applies to | +|---|---|---| +| Unit tests while iterating | `bun test ` for the files each unit names | U1 to U10 | +| Full suite | `bun run test` | Before U11 and before the pull request | +| Release metadata | `bun run release:validate` | U9, and whenever a skill `description` or `argument-hint` changes | +| Plugin schema | `bun run plugin:validate` | U9 | +| Skill authoring procedure | `ce-skill-work` in edit mode before each `skills/**` edit, with its completion report | U3 to U10 | +| Behavioral evals | `bun run test:skill-eval-pack -- --skill --arm ab` per touched skill, on Claude and OpenCode, three runs per discriminating cell | U11 | +| Judged setup eval | `bun run test:skill-eval-judge` for the setup scenario | U11 | +| Hand-run seat packs | The two cross-model packs with stub CLIs, Codex launched under `env -u CLAUDECODE` where a Codex CLI works | U6, U11 | +| Manual, not run here | AE7 and AE9 in a live Cursor session | Recorded in the pull request as unverified | + +--- + +## Definition of Done + +- Every unit's verification holds and `bun run test`, `bun run release:validate`, and `bun run plugin:validate` pass. +- A checkout with no `model_roles` key runs no resolver and prints no `Model role` line in any role skill, shown by U11's restraint cells. +- AE1 through AE6 and AE8 are each covered by a passing test or eval cell; AE7 and AE9's Cursor half are listed in the pull request as manual checks not run. +- No touched `SKILL.md` exceeds the prompt budget, and `ce-debug`'s and `lfg`'s bodies did not grow. +- The template, the example config, and the configuration guide describe `model_roles` identically. +- The pull request records the eval scenarios, hosts, skipped hosts with reasons, and pre and post results. +- No abandoned experiment, unused script flag, or scratch fixture remains in the diff. From ed34aa1273a6bbb205e837dcb42a8b0bce3269ca Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:19:50 -0700 Subject: [PATCH 02/53] feat(model-roles): add role map resolver script One stdlib-only script resolves the new model_roles config key into a per-role answer: grammar, role-by-role layering across the personal and team files, model family, and the review egress policy. It is byte-duplicated into each role skill, with a parity test, because skills cannot share files. Co-Authored-By: Claude Code --- .../scripts/model-role-resolve.py | 438 ++++++++++++++++++ .../scripts/model-role-resolve.py | 438 ++++++++++++++++++ .../ce-compound/scripts/model-role-resolve.py | 438 ++++++++++++++++++ skills/ce-debug/scripts/model-role-resolve.py | 438 ++++++++++++++++++ .../scripts/model-role-resolve.py | 438 ++++++++++++++++++ skills/ce-plan/scripts/model-role-resolve.py | 438 ++++++++++++++++++ skills/ce-setup/scripts/model-role-resolve.py | 438 ++++++++++++++++++ .../scripts/model-role-resolve.py | 438 ++++++++++++++++++ skills/ce-work/scripts/model-role-resolve.py | 438 ++++++++++++++++++ tests/skills/model-role-resolver.test.ts | 318 +++++++++++++ 10 files changed, 4260 insertions(+) create mode 100755 skills/ce-brainstorm/scripts/model-role-resolve.py create mode 100755 skills/ce-code-review/scripts/model-role-resolve.py create mode 100755 skills/ce-compound/scripts/model-role-resolve.py create mode 100755 skills/ce-debug/scripts/model-role-resolve.py create mode 100755 skills/ce-doc-review/scripts/model-role-resolve.py create mode 100755 skills/ce-plan/scripts/model-role-resolve.py create mode 100755 skills/ce-setup/scripts/model-role-resolve.py create mode 100755 skills/ce-simplify-code/scripts/model-role-resolve.py create mode 100755 skills/ce-work/scripts/model-role-resolve.py create mode 100644 tests/skills/model-role-resolver.test.ts diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py new file mode 100755 index 000000000..a947626ee --- /dev/null +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Resolve the model role map in this repo's CE config into a per-role answer. + +Reads the top-level `model_roles:` map from +`/.compound-engineering/config.local.yaml` (personal) and +`config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever +resolution ran -- a bad entry, a malformed block, or a missing repository is +data in the JSON, never a traceback. Non-zero only for a bad command line. + + model_roles: + plan: opus high # [] + work: inherit # the session model + doc-review: # review roles also take a list: one seat each + - opus high + - inherit # a seat on the session model + code-review: [sonnet, opus max] + +Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, +compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and +`code-review` accept a list; on them a scalar model is one seat, and `[]` or a +scalar `inherit` means no seats. + +Layering is per role: the personal entry wins, else the team entry. On a +single-model role an invalid entry is skipped with a warning and the next layer +is used. On a review role the first layer with an entry always wins: an invalid +seat is returned marked `invalid` and the other layer's seats are never used. A +structurally malformed `model_roles` block in either file makes every role +`invalid`, so a review skill can fail closed. + +`--role ` prints: + + {"role": "...", "state": "unset|inherit|entries|invalid", + "source": "local|team|null", + "entries": [{"seat": 1, "model": "...", "effort": "...|null", + "family": "claude|codex|grok|composer|unknown", + "harness": "...|null", "blocked_by": "...|null"}], + "effort_scale": [...], "warnings": [...], "errors": [...]} + +- A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the + other fields null. An `inherit` seat has model `inherit` and a null family. +- `family` is also the seat's peer key. `harness` is set on `work` entries only: + the engine harness for the family (composer -> cursor), null when unknown. +- `blocked_by` is set on review seats only: `review_mode_off` when + `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the + CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the + attested host family (`--host-family`) is never blocked; with an unknown host + family, or an unknown seat family, a named seat is always blocked under + either policy. An `inherit` seat is never blocked. +- `work` adds `engine_opt_out`: true when the entry comes from the team file + and the personal file sets `work_engine_mode: off`. + +`--all` prints `{"roles": [...], "effort_scale", "warnings", "errors"}`. Each +role carries the fields above plus `existing_keys` (the older keys for the same +step that are set: `{"key", "file", "value"}`, value null for a list or map +key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), +and `shadowed` (the existing keys a map entry overrides). Its top-level +`warnings` name unknown role keys under `model_roles`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys + +ROLES = ("brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound") +REVIEW_ROLES = ("doc-review", "code-review") +EFFORT_SCALE = ("low", "medium", "high", "xhigh", "max") +FAMILIES = ("claude", "codex", "grok", "composer", "unknown") +HARNESS = {"claude": "claude", "codex": "codex", "grok": "grok", "composer": "cursor"} +LAYERS = (("local", "config.local.yaml"), ("team", "config.yaml")) # personal first +INHERIT = ("inherit", None) + +_REVIEW_KEYS = ("cross_model_peer", "cross_model_model", "cross_model_effort") +EXISTING_KEYS = { + "brainstorm": ("brainstorm_model",), + "plan": ("plan_model",), + "doc-review": _REVIEW_KEYS, + "code-review": _REVIEW_KEYS, + "work": ("work_engine_mode", "work_engine_preferences", "work_engine_effort"), +} +STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") +WORK_ENGINE_MODES = ("off", "prefer", "require") +REVIEW_MODES = ("auto", "off") + +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") + + +# --- minimal YAML reader: top-level keys plus the model_roles block ---------- + +def _strip_comment(line: str) -> str: + """Drop a trailing comment (a # preceded by whitespace, outside quotes).""" + out, quote = [], "" + for i, ch in enumerate(line): + prev = line[i - 1] if i else " " + if quote: + if ch == quote: + quote = "" + elif ch in "'\"" and prev in " \t[,:": + quote = ch + elif ch == "#" and prev in " \t": + break + out.append(ch) + return "".join(out).rstrip() + + +def _unquote(raw: str) -> str: + raw = raw.strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1].strip() + return raw + + +def parse_config(path: str) -> dict: + """One config file as `top` (top-level key -> raw value), `roles` (role key + -> entry with a value under `model_roles`), `role_lines` (every key under + `model_roles` -> line), and `errors` (structural problems in that block).""" + name = os.path.basename(path) + parsed = {"file": name, "top": {}, "roles": {}, "role_lines": {}, "errors": []} + if not os.path.isfile(path): + return parsed + with open(path, encoding="utf-8-sig", errors="replace") as fh: + lines = fh.read().splitlines() + errors, roles = parsed["errors"], parsed["roles"] + in_block, seen_block, key_indent, pending = False, False, None, None + for lineno, raw in enumerate(lines, 1): + line = _strip_comment(raw) + stripped = line.strip() + if not stripped: + continue + indent = len(line) - len(line.lstrip()) + loc = f"{name}:{lineno}" + if indent == 0: + if in_block and stripped.startswith("-"): + errors.append(f"{loc}: `model_roles:` must be a map of `: ` lines, not a list") + continue + key, sep, rest = line.partition(":") + key, rest = key.strip(), rest.strip() + in_block, key_indent, pending = False, None, None + if not sep: + continue + parsed["top"].setdefault(key, rest) + if key == "model_roles": + if seen_block: + errors.append(f"{loc}: `model_roles:` is declared more than once") + seen_block = in_block = True + if rest not in ("", "{}"): + errors.append(f"{loc}: `model_roles:` must be a block map of `: ` lines (got `{rest}`)") + continue + if not in_block: + continue + if stripped == "-" or stripped.startswith("- "): + if pending is None or indent < key_indent: + errors.append(f"{loc}: list item `{stripped}` under model_roles has no role key that takes a list") + else: + pending.append(stripped[1:].strip()) + continue + if key_indent is None: + key_indent = indent + key, sep, rest = stripped.partition(":") + key, pending = key.strip(), None + if indent != key_indent or not sep or (rest and not rest[0].isspace()): + errors.append( + f"{loc}: unrecognized line under model_roles: `{stripped}` -- expected `: []`," + " `: inherit`, or a list of those on a review role" + ) + continue + if key in parsed["role_lines"]: + errors.append(f"{loc}: role `{key}` is declared more than once under model_roles") + continue + parsed["role_lines"][key] = lineno + rest = rest.strip() + if not rest: + pending = [] # a block list may follow; with no items the entry is unset + roles[key] = {"line": lineno, "is_list": True, "block": True, "values": pending} + elif rest.startswith("[") and rest.endswith("]"): + inner = rest[1:-1].strip() + roles[key] = {"line": lineno, "is_list": True, "values": inner.split(",") if inner else []} + else: + roles[key] = {"line": lineno, "is_list": False, "values": [rest]} + for key in [k for k, entry in roles.items() if entry.get("block") and not entry["values"]]: + del roles[key] + return parsed + + +def _ordinary_scalar(key: str, layers: list, valid: tuple | None = None): + """`(value, source)` under the ordinary two-file rule: the first active, + non-empty, valid value wins, personal file first. `(None, None)` when unset.""" + for source, parsed in layers: + value = _unquote(parsed["top"].get(key, "")) + if value and (valid is None or value in valid): + return value, source + return None, None + + +# --- entries ----------------------------------------------------------------- + +def parse_entry(text: str): + """`(model, effort)`, INHERIT, or a string saying why the entry is invalid.""" + tokens = _unquote(text).split() + if not tokens: + return "empty entry" + if tokens[0] == "inherit": + return INHERIT if len(tokens) == 1 else "`inherit` takes no effort" + if len(tokens) > 2: + return "expected ` []`" + if not _MODEL_RE.fullmatch(tokens[0]): + return f"`{tokens[0]}` is not a model id" + if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: + return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" + return tokens[0], tokens[1] if len(tokens) == 2 else None + + +def family(model: str) -> str: + name = model.lower() + if "/" in name: + return "unknown" + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + return "unknown" + + +def _blocked_by(seat_family: str, policy: dict) -> str | None: + host, peers = policy["host"], policy["peers"] + if host != "unknown" and seat_family == host: + return None # same provider as the session: nothing new leaves the machine + if policy["mode"] == "off": + return "review_mode_off" + if peers and (host == "unknown" or seat_family == "unknown" or seat_family not in peers): + return "peers_allowlist" + return None + + +def _seat(number: int, text: str, role: str, policy: dict) -> dict: + seat = {"seat": number, "model": None, "effort": None, "family": None, "harness": None, "blocked_by": None} + parsed = parse_entry(text) + if isinstance(parsed, str): + seat.update(invalid=True, reason=parsed, raw=_unquote(text)) + elif parsed == INHERIT: + seat["model"] = "inherit" + else: + model, effort = parsed + seat.update(model=model, effort=effort, family=family(model)) + if role == "work": + seat["harness"] = HARNESS.get(seat["family"]) + if role in REVIEW_ROLES: + seat["blocked_by"] = _blocked_by(seat["family"], policy) + return seat + + +def _layer_entries(role: str, entry: dict, loc: str, warnings: list, policy: dict): + """This layer's answer for the role: a seat list (empty = inherit), or None + when the layer's entry is skipped and the next layer decides.""" + values = entry["values"] + if role in REVIEW_ROLES: + if not entry["is_list"] and parse_entry(values[0]) == INHERIT: + return [] + return [_seat(n, text, role, policy) for n, text in enumerate(values, 1)] + if entry["is_list"]: + warnings.append(f"{loc}: `{role}` takes one ` []` entry, not a list; entry skipped") + return None + parsed = parse_entry(values[0]) + if isinstance(parsed, str): + warnings.append(f"{loc}: `{role}` entry `{_unquote(values[0])}` skipped: {parsed}") + return None + return [] if parsed == INHERIT else [_seat(1, values[0], role, policy)] + + +def _role_result(role: str) -> dict: + result = {"role": role, "state": "unset", "source": None, "entries": []} + if role == "work": + result["engine_opt_out"] = False + result.update(warnings=[], errors=[]) + return result + + +def resolve_role(role: str, layers: list, policy: dict) -> dict: + result = _role_result(role) + block_errors = [error for _, parsed in layers for error in parsed["errors"]] + if block_errors: + result.update(state="invalid", errors=block_errors) + return result + for source, parsed in layers: + entry = parsed["roles"].get(role) + if entry is None: + continue + entries = _layer_entries(role, entry, f"{parsed['file']}:{entry['line']}", result["warnings"], policy) + if entries is None: + continue + result.update(state="entries" if entries else "inherit", source=source, entries=entries) + break + if role == "work" and result["state"] == "entries" and result["source"] == "team": + # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + return result + + +# --- --all: effective value, existing keys, shadowing ------------------------ + +def _existing_keys(role: str, layers: list) -> list: + found = [] + for key in EXISTING_KEYS.get(role, ()): + if key in STRUCTURED_KEYS: + # A present list or map, even an empty one, replaces the whole key. + source = next((s for s, parsed in layers if key in parsed["top"]), None) + value = None + else: + value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + if source is not None: + found.append({"key": key, "file": source, "value": value}) + return found + + +def _describe(result: dict, existing: list) -> dict: + if result["state"] == "invalid": + return {"from": "invalid", "value": None} + if result["state"] == "inherit": + return {"from": "map", "value": "inherit"} + if result["state"] == "entries": + seats = [e for e in result["entries"] if not e.get("invalid")] + return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + if existing: + shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) + return {"from": "existing_key", "value": "; ".join(shown)} + return {"from": "session", "value": None} + + +def resolve_all(layers: list, policy: dict) -> dict: + roles = [] + for role in ROLES: + result = resolve_role(role, layers, policy) + existing = _existing_keys(role, layers) + result["existing_keys"] = existing + result["effective"] = _describe(result, existing) + result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] + roles.append(result) + warnings = [ + f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" + for _, parsed in layers + for key, line in parsed["role_lines"].items() + if key not in ROLES + ] + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} + + +# --- entry point ------------------------------------------------------------- + +def _repo_root() -> str | None: + """The enclosing checkout's top level: git's answer when git is on PATH, + otherwise the nearest ancestor of the working directory holding a `.git` + entry. None when the working directory is not inside a checkout.""" + if shutil.which("git") is not None: + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return proc.stdout.strip() if proc.returncode == 0 else None + current = os.getcwd() + while True: + if os.path.lexists(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def _resolve(args) -> dict: + repo_root = _repo_root() + cfg_dir = os.path.join(repo_root, ".compound-engineering") if repo_root else None + layers = [(source, parse_config(os.path.join(cfg_dir, name) if cfg_dir else "")) for source, name in LAYERS] + peers = {p.strip().lower() for p in os.environ.get("CROSS_MODEL_PEERS", "").split(",") if p.strip()} + if "cursor" in peers: + peers.add("composer") # either name sanctions Cursor + policy = { + "host": args.host_family, + "peers": peers, + "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", + } + if args.all: + out = resolve_all(layers, policy) + else: + out = resolve_role(args.role, layers, policy) + warnings, errors = out.pop("warnings"), out.pop("errors") + out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) + if repo_root is None: + out["warnings"].append("not inside a git repository; no CE config to read") + return out + + +def _failed(args, error: str) -> dict: + """Every requested role `invalid`: a resolver that could not finish must + never read as "no map", or a review skill would not fail closed.""" + def role_failed(role: str) -> dict: + result = _role_result(role) + result.update(state="invalid", errors=[error]) + return result + + if not args.all: + result = role_failed(args.role) + result["effort_scale"] = list(EFFORT_SCALE) + return result + roles = [ + dict(role_failed(role), existing_keys=[], effective={"from": "invalid", "value": None}, shadowed=[]) + for role in ROLES + ] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": [], "errors": [error]} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve the CE model role map.") + which = parser.add_mutually_exclusive_group(required=True) + which.add_argument("--role", choices=ROLES, help="answer one role") + which.add_argument("--all", action="store_true", help="answer every role with its effective value and source") + parser.add_argument( + "--host-family", choices=FAMILIES, default="unknown", + help="the session model's attested family, for the review egress policy (default: unknown)", + ) + args = parser.parse_args() + try: + out = _resolve(args) + except Exception as exc: # never a traceback: consumers need valid JSON + out = _failed(args, f"model role resolver failed unexpectedly: {exc}") + print(json.dumps(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts new file mode 100644 index 000000000..91b528a77 --- /dev/null +++ b/tests/skills/model-role-resolver.test.ts @@ -0,0 +1,318 @@ +import { spawnSync } from "child_process" +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import { isolatedGitEnv } from "./helpers/packs-fixtures" + +// Deterministic proof for the model role map resolver: every case runs the +// script against a throwaway repo built in this file's own temp directory. +setDefaultTimeout(30000) + +// One line here plus the duplicated file adds a consumer (skills cannot import siblings). +const COPIES = [ + "skills/ce-setup/scripts/model-role-resolve.py", + "skills/ce-brainstorm/scripts/model-role-resolve.py", + "skills/ce-plan/scripts/model-role-resolve.py", + "skills/ce-doc-review/scripts/model-role-resolve.py", + "skills/ce-code-review/scripts/model-role-resolve.py", + "skills/ce-work/scripts/model-role-resolve.py", + "skills/ce-debug/scripts/model-role-resolve.py", + "skills/ce-simplify-code/scripts/model-role-resolve.py", + "skills/ce-compound/scripts/model-role-resolve.py", +] +const RESOLVER = path.join(process.cwd(), COPIES[0]) +const ROLES = ["brainstorm", "plan", "doc-review", "debug", "work", "simplify", "code-review", "compound"] + +const scratch = mkdtempSync(path.join(tmpdir(), "model-role-resolver-")) +afterAll(() => rmSync(scratch, { recursive: true, force: true })) + +let counter = 0 +function tempDir(name: string): string { + const dir = path.join(scratch, `${name}-${counter++}`) + mkdirSync(dir, { recursive: true }) + return dir +} + +/** A git repo with the team config and, optionally, the personal one. */ +function makeProject(team?: string, local?: string): string { + const dir = tempDir("project") + const res = spawnSync("git", ["init", "-q"], { cwd: dir, encoding: "utf8", env: isolatedGitEnv }) + if (res.status !== 0) throw new Error(`git init failed: ${res.stderr}`) + const ce = path.join(dir, ".compound-engineering") + if (team !== undefined || local !== undefined) mkdirSync(ce) + if (team !== undefined) writeFileSync(path.join(ce, "config.yaml"), team) + if (local !== undefined) writeFileSync(path.join(ce, "config.local.yaml"), local) + return dir +} + +function run(cwd: string, args: string[], extraEnv: Record = {}) { + const res = spawnSync("python3", [RESOLVER, ...args], { + cwd, + encoding: "utf8", + // The contributor's own allowlist must not leak into a case. + env: { ...isolatedGitEnv, CROSS_MODEL_PEERS: "", ...extraEnv }, + }) + expect(res.status).toBe(0) + return JSON.parse(res.stdout) +} + +const role = (cwd: string, name: string, args: string[] = [], env: Record = {}) => + run(cwd, ["--role", name, ...args], env) + +const map = (body: string) => `model_roles:\n${body}` +const byRole = (out: { roles: { role: string }[] }, name: string): any => out.roles.find((r) => r.role === name) +const blocked = (out: { entries: { blocked_by: string | null }[] }) => out.entries.map((e) => e.blocked_by) + +describe("model-role-resolve.py copies", () => { + test("all nine skill copies are byte-identical", () => { + const contents = COPIES.map((p) => readFileSync(path.join(process.cwd(), p), "utf8")) + expect(contents.length).toBe(9) + for (let i = 1; i < contents.length; i++) expect(contents[i]).toBe(contents[0]) + }) +}) + +describe("entries and layering", () => { + test("AE2: each role resolves from the personal file, then the team file", () => { + const dir = makeProject(map(" plan: sonnet high\n work: opus medium # team default\n"), map(" plan: opus\n")) + + expect(role(dir, "work")).toEqual({ + role: "work", + state: "entries", + source: "team", + entries: [{ seat: 1, model: "opus", effort: "medium", family: "claude", harness: "claude", blocked_by: null }], + engine_opt_out: false, + effort_scale: ["low", "medium", "high", "xhigh", "max"], + warnings: [], + errors: [], + }) + + const plan = role(dir, "plan") + expect(plan.source).toBe("local") + expect(plan.entries).toEqual([ + { seat: 1, model: "opus", effort: null, family: "claude", harness: null, blocked_by: null }, + ]) + }) + + test("personal `inherit` over a team entry is state inherit", () => { + const dir = makeProject(map(" debug: opus high\n"), map(' debug: "inherit"\n')) + const out = role(dir, "debug") + expect([out.state, out.source, out.entries]).toEqual(["inherit", "local", []]) + }) + + test("block and flow lists on review roles return numbered seats in order, duplicates kept", () => { + const dir = makeProject( + map(" doc-review:\n - opus high\n - opus high\n - grok-4.7\n code-review: [gpt-5.5, 'gpt-5.5 xhigh']\n"), + ) + const doc = role(dir, "doc-review") + expect(doc.state).toBe("entries") + expect(doc.entries.map((e: any) => [e.seat, e.model, e.effort])).toEqual([ + [1, "opus", "high"], + [2, "opus", "high"], + [3, "grok-4.7", null], + ]) + expect(role(dir, "code-review").entries.map((e: any) => [e.seat, e.model, e.effort])).toEqual([ + [1, "gpt-5.5", null], + [2, "gpt-5.5", "xhigh"], + ]) + }) + + test("a scalar on a review role is one seat; `[]` and scalar `inherit` are state inherit", () => { + const dir = makeProject(map(" doc-review: opus high\n code-review: []\n")) + const doc = role(dir, "doc-review") + expect(doc.state).toBe("entries") + expect(doc.entries.map((e: any) => [e.seat, e.model, e.effort])).toEqual([[1, "opus", "high"]]) + expect(role(dir, "code-review").state).toBe("inherit") + + const scalarInherit = role(makeProject(map(" doc-review: [opus]\n"), map(" doc-review: inherit\n")), "doc-review") + expect([scalarInherit.state, scalarInherit.source, scalarInherit.entries]).toEqual(["inherit", "local", []]) + }) + + test("a list on a single-model role is skipped with a warning and the other layer is used", () => { + const dir = makeProject(map(" work: gpt-5.5 high\n"), map(" work: [opus, sonnet]\n")) + const out = role(dir, "work") + expect(out.source).toBe("team") + expect(out.entries).toEqual([ + { seat: 1, model: "gpt-5.5", effort: "high", family: "codex", harness: "codex", blocked_by: null }, + ]) + expect(out.warnings.length).toBe(1) + expect(out.warnings[0]).toContain("config.local.yaml:2") + }) + + test("an unknown effort word invalidates a single-role entry; with no other layer the role is unset", () => { + const out = role(makeProject(map(" plan: opus banana\n")), "plan") + expect([out.state, out.source, out.entries]).toEqual(["unset", null, []]) + expect(out.warnings.length).toBe(1) + expect(out.warnings[0]).toContain("banana") + }) + + test("a bad seat is returned marked invalid and the list never falls through to the other layer", () => { + const dir = makeProject(map(" doc-review: [haiku]\n"), map(" doc-review: [opus high, grok-4.7 banana, sonnet]\n")) + const out = role(dir, "doc-review") + expect([out.state, out.source]).toEqual(["entries", "local"]) + expect(out.entries.map((e: any) => [e.seat, e.model, e.invalid === true])).toEqual([ + [1, "opus", false], + [2, null, true], + [3, "sonnet", false], + ]) + expect(out.entries[1].reason).toContain("banana") + }) + + test("a malformed model_roles block is state invalid with an error for every role", () => { + const dir = makeProject(map(" plan:\n model: opus\n effort: high\n doc-review: [opus]\n")) + for (const name of ["plan", "doc-review"]) { + const out = role(dir, name) + expect([out.state, out.source, out.entries]).toEqual(["invalid", null, []]) + expect(out.errors.length).toBeGreaterThan(0) + expect(out.errors[0]).toContain("config.yaml:3") + } + }) + + test("an unknown role key is ignored by --role and warned about by --all", () => { + const dir = makeProject(map(" code_review: [opus]\n")) + const one = role(dir, "code-review") + expect([one.state, one.warnings, one.errors]).toEqual(["unset", [], []]) + const all = run(dir, ["--all"]) + expect(byRole(all, "code-review").state).toBe("unset") + expect(all.warnings.length).toBe(1) + expect(all.warnings[0]).toContain("code_review") + }) +}) + +describe("family and harness", () => { + test("each model id lands in the expected family", () => { + const models = ["fable", "claude-opus-5-5", "gpt-5.5", "o3-pro", "grok-4.7", "composer-2.5-fast", "openrouter/some-model"] + const out = role(makeProject(map(` doc-review: [${models.join(", ")}]\n`)), "doc-review") + expect(out.entries.map((e: any) => [e.model, e.family])).toEqual([ + ["fable", "claude"], + ["claude-opus-5-5", "claude"], + ["gpt-5.5", "codex"], + ["o3-pro", "codex"], + ["grok-4.7", "grok"], + ["composer-2.5-fast", "composer"], + ["openrouter/some-model", "unknown"], + ]) + }) + + test("a work entry maps Composer to the cursor harness and an unknown family to none", () => { + const composer = role(makeProject(map(" work: composer-2.5-fast\n")), "work") + expect([composer.entries[0].family, composer.entries[0].harness]).toEqual(["composer", "cursor"]) + const unknown = role(makeProject(map(" work: openrouter/some-model high\n")), "work") + expect([unknown.entries[0].family, unknown.entries[0].harness]).toEqual(["unknown", null]) + }) +}) + +describe("review egress policy", () => { + const seats = " code-review: [opus high, grok-4.7, gpt-5.5 high, openrouter/some-model, inherit]\n" + + test("AE5, AE9: mode off keeps only same-family seats of an attested host; inherit is never blocked", () => { + const dir = makeProject(`cross_model_review_mode: off\n${map(`${seats} work: grok-4.7\n`)}`) + const off = "review_mode_off" + expect(blocked(role(dir, "code-review", ["--host-family", "claude"]))).toEqual([null, off, off, off, null]) + expect(blocked(role(dir, "code-review", ["--host-family", "unknown"]))).toEqual([off, off, off, off, null]) + expect(blocked(role(dir, "code-review"))).toEqual([off, off, off, off, null]) + // Policy is review-only: the same mode leaves a work entry alone. + expect(blocked(role(dir, "work", ["--host-family", "claude"]))).toEqual([null]) + }) + + test("CROSS_MODEL_PEERS keeps listed peers and the attested host family", () => { + const dir = makeProject(map(seats)) + const list = "peers_allowlist" + expect(blocked(role(dir, "code-review", ["--host-family", "claude"]))).toEqual([null, null, null, null, null]) + expect(blocked(role(dir, "code-review", ["--host-family", "claude"], { CROSS_MODEL_PEERS: "codex" }))).toEqual([ + null, + list, + null, + list, + null, + ]) + expect(blocked(role(dir, "code-review", [], { CROSS_MODEL_PEERS: "codex" }))).toEqual([list, list, list, list, null]) + }) + + test("`cursor` in CROSS_MODEL_PEERS also sanctions a Composer seat", () => { + const dir = makeProject(map(" doc-review: [composer-2.5-fast, grok-4.7]\n")) + const out = role(dir, "doc-review", ["--host-family", "claude"], { CROSS_MODEL_PEERS: " cursor " }) + expect(blocked(out)).toEqual([null, "peers_allowlist"]) + }) + + test("a personal `cross_model_review_mode: off` beats a team `auto`", () => { + const dir = makeProject( + `cross_model_review_mode: auto\n${map(" doc-review: [opus, grok-4.7]\n")}`, + "cross_model_review_mode: off\n", + ) + expect(blocked(role(dir, "doc-review", ["--host-family", "claude"]))).toEqual([null, "review_mode_off"]) + }) +}) + +describe("work engine opt-out", () => { + test("a personal `work_engine_mode: off` opts a team work entry out, never a personal one", () => { + const team = map(" work: gpt-5.5 high\n") + const optedOut = role(makeProject(team, "work_engine_mode: off\n"), "work") + expect([optedOut.source, optedOut.engine_opt_out]).toEqual(["team", true]) + const personal = role(makeProject(team, `work_engine_mode: off\n${map(" work: opus\n")}`), "work") + expect([personal.source, personal.engine_opt_out]).toEqual(["local", false]) + }) +}) + +describe("--all", () => { + const team = [ + "plan_model: fable", + "cross_model_peer: codex", + "work_engine_mode: prefer", + "work_engine_preferences:", + " - harness: codex", + "", + ].join("\n") + + test("AE3: with no entry the existing key is the source; other roles fall to the session model", () => { + const all = run(makeProject(team, "cross_model_effort: xhigh\n"), ["--all"]) + expect(all.roles.map((r: any) => r.role)).toEqual(ROLES) + expect(all.effort_scale).toEqual(["low", "medium", "high", "xhigh", "max"]) + + const plan = byRole(all, "plan") + expect([plan.state, plan.source, plan.shadowed]).toEqual(["unset", null, []]) + expect(plan.effective).toEqual({ from: "existing_key", value: "plan_model: fable" }) + expect(plan.existing_keys).toEqual([{ key: "plan_model", file: "team", value: "fable" }]) + + expect(byRole(all, "doc-review").existing_keys).toEqual([ + { key: "cross_model_peer", file: "team", value: "codex" }, + { key: "cross_model_effort", file: "local", value: "xhigh" }, + ]) + expect(byRole(all, "work").existing_keys).toEqual([ + { key: "work_engine_mode", file: "team", value: "prefer" }, + { key: "work_engine_preferences", file: "team", value: null }, + ]) + expect(byRole(all, "debug").effective).toEqual({ from: "session", value: null }) + }) + + test("AE3: with an entry the map is the source and the existing key is listed as shadowed", () => { + const all = run(makeProject(team, map(" plan: opus high\n doc-review: [opus high, inherit]\n")), ["--all"]) + const plan = byRole(all, "plan") + expect([plan.state, plan.source]).toEqual(["entries", "local"]) + expect(plan.effective).toEqual({ from: "map", value: "opus high" }) + expect(plan.shadowed).toEqual(["plan_model"]) + + const doc = byRole(all, "doc-review") + expect(doc.effective).toEqual({ from: "map", value: "opus high, inherit" }) + expect(doc.shadowed).toEqual(["cross_model_peer"]) + expect(byRole(all, "code-review").effective.from).toBe("existing_key") + }) +}) + +describe("no config to read", () => { + const allUnset = (out: any) => out.roles.map((r: any) => [r.role, r.state, r.effective.from]) + const expected = ROLES.map((name) => [name, "unset", "session"]) + + test("a repository with no config files leaves every role unset", () => { + const out = run(makeProject(), ["--all"]) + expect(allUnset(out)).toEqual(expected) + expect([out.warnings, out.errors]).toEqual([[], []]) + }) + + test("outside a git repository every role is unset and the exit code is still 0", () => { + const dir = tempDir("no-repo") + const out = run(dir, ["--all"], { GIT_CEILING_DIRECTORIES: scratch }) + expect(allUnset(out)).toEqual(expected) + expect(out.errors).toEqual([]) + }) +}) From f3a6fecc26fd78b33562ed559a4d4e49fa408584 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:30:37 -0700 Subject: [PATCH 03/53] feat(model-roles): add shared role contract reference One byte-duplicated reference states how a role skill honors its model_roles entry: resolving the role, precedence, when a hand-off happens, the serving order, the single-model fallback ladder, and the Model role report line. Skill bodies stay untouched because most sit near the 8,000-byte prompt budget. Adds the Model role map and Review seat glossary entries. Co-Authored-By: Claude Code --- CONCEPTS.md | 10 ++ .../ce-brainstorm/references/model-roles.md | 103 ++++++++++++++++++ .../ce-code-review/references/model-roles.md | 103 ++++++++++++++++++ skills/ce-compound/references/model-roles.md | 103 ++++++++++++++++++ skills/ce-debug/references/model-roles.md | 103 ++++++++++++++++++ .../ce-doc-review/references/model-roles.md | 103 ++++++++++++++++++ skills/ce-plan/references/model-roles.md | 103 ++++++++++++++++++ .../references/model-roles.md | 103 ++++++++++++++++++ skills/ce-work/references/model-roles.md | 103 ++++++++++++++++++ tests/model-roles-reference-parity.test.ts | 59 ++++++++++ 10 files changed, 893 insertions(+) create mode 100644 skills/ce-brainstorm/references/model-roles.md create mode 100644 skills/ce-code-review/references/model-roles.md create mode 100644 skills/ce-compound/references/model-roles.md create mode 100644 skills/ce-debug/references/model-roles.md create mode 100644 skills/ce-doc-review/references/model-roles.md create mode 100644 skills/ce-plan/references/model-roles.md create mode 100644 skills/ce-simplify-code/references/model-roles.md create mode 100644 skills/ce-work/references/model-roles.md create mode 100644 tests/model-roles-reference-parity.test.ts diff --git a/CONCEPTS.md b/CONCEPTS.md index 729f8b5f9..17ab2b680 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -99,6 +99,11 @@ A semantic cost class for a dispatched sub-agent — extraction (cheapest capabl When a platform cannot select models per agent, every role runs on the inherited model and cost control falls back to structure: read budgets and output caps. +### Model role map +A per-repository assignment of a model, and optionally a reasoning effort, to each kind of work in the Pipeline — one role per step Skill — so each step produces its deliverable on the developer's choice whether the developer invokes the Skill or an orchestrating Skill does. An entry governs only that step's deliverable: dialogue with the user, orchestration inside the Skill, and sub-agents dispatched at their Model tier are unchanged. A role with no entry behaves as it did before the map existed, and an entry may say to inherit, which means the session model. + +An instruction given for the current run outranks an entry, and an entry outranks the older per-step model settings. When the named model or effort cannot be served, a single-model role runs on the nearest option, keeping the named model as long as any route can serve it and ending at the session model. Every governed step reports the requested model, the served model, and the route, so a fallback is visible rather than silent. + ### Evidence dossier A bulk evidence artifact — verbatim quotes with source pointers, gathered by a cheap scout agent — written to scratch storage instead of returned inline, so the orchestrator carries only a short gist and downstream agents read the full dossier themselves. @@ -141,6 +146,11 @@ An additive delegated run that sends the host workflow's review or judgment brie A peer result is usable only after the route reports a successful terminal outcome and the result satisfies that consumer's output contract. Provider-failure retry allowances belong to the route worker and remain inside the original route deadline; once a provider no-review outcome reaches the host, that peer is not restarted. POV position results additionally declare settledness in their output contract: a schema-shaped result not declared final is a placeholder, never a peer voice. +### Review seat +One independent reviewer that a review role's entry in the Model role map adds to a review, running on the model that entry names. A review role takes a list, and the length of the list sets the number of independent reviews. Seats run in addition to the Skill's own Reviewer persona review, which still runs when every seat is dropped, and where a review role has an entry its seats take the place of the single conditional Cross-model pass. + +A seat that cannot be served on its named model is dropped and reported, never filled by another model, because a substituted seat weakens the Independence the panel exists for. A policy that keeps review content from leaving for another provider skips a seat in the same way. + ### Clean skip A delegated run that reached its gate, judged the work did not apply, and ended without producing output — a terminal outcome of the workflow rather than a failure of it. diff --git a/skills/ce-brainstorm/references/model-roles.md b/skills/ce-brainstorm/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-brainstorm/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-code-review/references/model-roles.md b/skills/ce-code-review/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-code-review/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-compound/references/model-roles.md b/skills/ce-compound/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-compound/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-debug/references/model-roles.md b/skills/ce-debug/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-debug/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-doc-review/references/model-roles.md b/skills/ce-doc-review/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-doc-review/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-plan/references/model-roles.md b/skills/ce-plan/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-plan/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-simplify-code/references/model-roles.md b/skills/ce-simplify-code/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-simplify-code/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/skills/ce-work/references/model-roles.md b/skills/ce-work/references/model-roles.md new file mode 100644 index 000000000..d3fd98463 --- /dev/null +++ b/skills/ce-work/references/model-roles.md @@ -0,0 +1,103 @@ +# Model roles + +A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. + +**Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. + +**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. + +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. + +## Resolve the role + +Run this skill's resolver in one command: + +```bash +SKILL_DIR=""; +PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; +"$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --role +``` + +Add `--host-family ` only when the calling skill already attests the session's model family. The command prints one JSON object, and its `state` decides what the skill does: + +| `state` | What the calling skill does | +|---|---| +| `unset` | The role has no entry. Continue as the skill does today, with its existing keys. | +| `inherit` | The session model does the work. The skill's existing keys for this step are not consulted. | +| `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | +| `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | + +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. + +A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. + +## Precedence + +The first source that exists decides the model for the step: + +1. A live instruction about this step in the current run. +2. A carrier the caller passed for this step. +3. The role's map entry. +4. The skill's existing key for this step. +5. The session model. + +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. + +## What the entry governs + +The entry governs the step's deliverable, which the calling skill's hook names. It does not change the dialogue with the user, the orchestration inside the skill, or the scouts and verifiers the skill already dispatches at their Model tier. + +## When a hand-off happens + +A hand-off gives the deliverable's work to another model run and takes the result back. Two cases run with no hand-off: + +- The state is `inherit`. +- The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. + +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. + +## Serving order + +A hand-off uses the first route that can serve the entry's model at the entry's effort: + +1. **A native subagent**, when the host's subagent primitive can be handed that model at that effort. Attempting the dispatch proves this. The agent's belief about the host does not. Where the subagent tool lists model ids that encode effort, match the entry against that list, and treat no match as proof that the host cannot serve the entry natively. Never guess an id. A subagent tool with no way to set effort cannot serve an entry that has an effort. +2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. +3. **The fallback ladder** below. + +## Fallback for a single-model role + +When no route can serve the entry as written, the step takes the first of these that a route can serve: + +1. The same model at the nearest lower effort the route accepts. +2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. +3. The route's own default model, where the route has one. +4. The session model. + +The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. + +This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. + +## The `Model role` line + +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. + +```text +Model role : requested [ ]; ; route [; reason: ]. +``` + +- A review seat's line starts `Model role seat :`. +- `` is `served ` followed by ` at ` or `, effort not applied` when the entry has an effort. It is `not run` when nothing ran, and then the line has no route. +- `` is `native subagent`, ` CLI`, or `session`. +- `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. + +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. + +Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. + +```text +Model role : requested fable low; served fable (unverified) at low; route claude CLI. +Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. +Model role : requested inherit; served opus; route session. +Model role seat 2: requested opus high; not run; reason: no route can serve it. +``` diff --git a/tests/model-roles-reference-parity.test.ts b/tests/model-roles-reference-parity.test.ts new file mode 100644 index 000000000..843d60a56 --- /dev/null +++ b/tests/model-roles-reference-parity.test.ts @@ -0,0 +1,59 @@ +import { access, readFile } from "fs/promises" +import path from "path" +import { describe, expect, test } from "bun:test" + +const PLUGIN_ROOT = path.join(process.cwd(), "skills") + +// The model role contract is byte-duplicated into every role skill (the plugin +// has no cross-skill import mechanism — see AGENTS.md "File References in +// Skills"). All copies must stay identical; editing one without the others fails +// this test. Add a skill to CONSUMER_SKILLS when it gains a copy. +const REFERENCE = "references/model-roles.md" +const RESOLVER = "scripts/model-role-resolve.py" + +const CONSUMER_SKILLS = [ + "ce-brainstorm", + "ce-plan", + "ce-doc-review", + "ce-code-review", + "ce-work", + "ce-debug", + "ce-simplify-code", + "ce-compound", +] + +const readCopy = (skill: string) => readFile(path.join(PLUGIN_ROOT, skill, REFERENCE), "utf8") + +describe("model role contract reference parity", () => { + test(`${REFERENCE} exists in every consumer and is byte-identical`, async () => { + const contents = await Promise.all(CONSUMER_SKILLS.map(readCopy)) + for (let i = 1; i < contents.length; i++) { + expect(contents[i]).toBe(contents[0]) + } + }) + + test("every consumer bundles the resolver the reference runs", async () => { + for (const skill of CONSUMER_SKILLS) { + expect(await readCopy(skill)).toContain(`"$SKILL_DIR/${RESOLVER}"`) + await access(path.join(PLUGIN_ROOT, skill, RESOLVER)) // fails the test if the script is missing + } + }) + + test("keeps the tokens role skills and the resolver share", async () => { + const src = await readCopy(CONSUMER_SKILLS[0]) + for (const token of ["Model role", "`inherit`", "model-role-resolve.py"]) { + expect(src).toContain(token) + } + // The effort scale the resolver emits as `effort_scale`. + for (const effort of ["low", "medium", "high", "xhigh", "max"]) { + expect(src).toContain(`\`${effort}\``) + } + }) + + // The same bytes ship in eight skills, so the reference stays role-neutral: + // the calling skill's hook names the role, the deliverable, and the peer route. + test("names no skill", async () => { + const src = await readCopy(CONSUMER_SKILLS[0]) + expect(src.match(/\b(?:ce-[a-z][a-z-]*|lfg)\b/g)).toBeNull() + }) +}) From c5e0d19fa51ba549782260e6ea79893f2f38f557 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:36:32 -0700 Subject: [PATCH 04/53] docs(config): document the model_roles map Adds the Model roles section to the config template and its example copy, and to the configuration guide: entry grammar, role-by-role layering, the older keys an entry replaces, the two personal opt-outs, and the controls on review seats. A test resolves the template example through the resolver. Co-Authored-By: Claude Code --- .compound-engineering/config.example.yaml | 46 +++++++++++++ docs/guides/configuration.md | 66 +++++++++++++++++++ .../ce-setup/references/config-template.yaml | 46 +++++++++++++ tests/skills/ce-setup-check-health.test.ts | 39 +++++++++++ 4 files changed, 197 insertions(+) diff --git a/.compound-engineering/config.example.yaml b/.compound-engineering/config.example.yaml index 6ecce7d27..f8de239e2 100644 --- a/.compound-engineering/config.example.yaml +++ b/.compound-engineering/config.example.yaml @@ -58,6 +58,52 @@ # brainstorm_output: html # md | html (default: md) # ideate_output: md # md | html (default: html -- ideation docs are human-facing, so HTML is the default; set md to opt out) +# --- Model roles --- +# One map that names the model, and optionally the reasoning effort, that +# produces each pipeline step's deliverable. An entry governs that deliverable +# and nothing else. Dialogue with you, orchestration inside the skill, and +# lfg's hand-offs between steps stay on your session model. The map applies the +# same way whether you run the skill or lfg does. A role with no entry behaves +# exactly as it does without this map. +# +# An entry is ` []`, or `inherit` for the session model. The +# model is an alias or a full id, never a CLI flag or command. The effort scale +# is low | medium | high | xhigh | max. `doc-review` and `code-review` also +# take a list. Each item is one seat: an independent reviewer on that model, +# run beside the skill's own persona review. +# +# Layering is role by role, unlike the other maps in this file. An entry in +# config.local.yaml overrides that one role and leaves the team's other roles +# in place. +# +# An entry in either file replaces the older keys for the same step: +# brainstorm -> brainstorm_model +# plan -> plan_model +# doc-review, code-review -> cross_model_peer, cross_model_model, cross_model_effort +# work -> work_engine_mode, work_engine_preferences, work_engine_effort +# debug, simplify, and compound have no older key. +# +# Two opt-outs from a team map go in config.local.yaml. `: inherit` puts +# that role back on your session model. `work_engine_mode: off` keeps a team +# `work` entry off external engines. +# +# Review seats stay under `cross_model_review_mode` (below) and the +# CROSS_MODEL_PEERS environment variable. A seat that either one excludes is +# skipped and reported, never replaced by another model. + +# model_roles: +# brainstorm: opus high # the generated approaches +# plan: fable low # the authored plan +# doc-review: # each seat reviews the plan independently +# - grok-4.7 high +# - gpt-6.1-sol +# - opus medium +# debug: opus high # the diagnosis and fix +# work: opus medium # the code +# simplify: sonnet low # the simplification +# code-review: [opus high, sonnet] # each seat reviews the diff independently +# compound: inherit # the learning document, on the session model + # --- Model elevation --- # Send only the reasoning-heavy step of /ce-plan (interpret research, then author # the plan) and /ce-brainstorm (generate approaches) to a model you name. The rest diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ce241f0b5..76faa83d3 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -8,6 +8,7 @@ Run `/ce-setup` to create `config.yaml` and refresh the committed `.compound-eng - **Ordinary keys:** read `config.local.yaml`, then `config.yaml`. The first active (non-commented) value wins. A missing file is skipped. Invalid or empty scalars continue to the next layer, then the skill default. A present list or map, including empty, replaces the whole key. - **`docs_root`:** read only from `config.yaml`. A `docs_root` in `config.local.yaml` is ignored. +- **`model_roles`:** resolved one role at a time. Each role reads `config.local.yaml`, then `config.yaml`, so a personal entry overrides that one role and the team's other roles stay in place. This is the exception to a present map replacing the whole key. See [Model roles](#model-roles). - **Gitignore does not change resolution.** Either file works whether ignored or committed. - A current-task instruction still wins over config. Session and project instructions already in context can override or narrow it. @@ -92,6 +93,7 @@ All settings are optional. Commented examples are documentation, not active valu | all artifact-writing skills | `docs_root` | Repo-relative folder every CE artifact subdirectory lives under. Set only in `config.yaml`. Unset -> `docs`. See [Artifact root](#artifact-root). | | [`ce-ideate`](./ce-ideate.md), [`ce-brainstorm`](./ce-brainstorm.md), [`ce-plan`](./ce-plan.md) | `ideate_output`, `brainstorm_output`, `plan_output` | Artifact format: `md` or `html`. Defaults are HTML for ideation and markdown for brainstorms/plans. Headless and pipeline runs resolve the format the same way; nothing forces markdown. | | [`ce-plan`](./ce-plan.md) | `plan_skip_scoping_confirm` | `true` skips the normal pre-plan scope confirmation; default `false`. It does not suppress genuine blockers or the post-plan menu. | +| [`ce-brainstorm`](./ce-brainstorm.md), [`ce-plan`](./ce-plan.md), [`ce-doc-review`](./ce-doc-review.md), [`ce-debug`](./ce-debug.md), [`ce-work`](./ce-work.md), [`ce-simplify-code`](./ce-simplify-code.md), [`ce-code-review`](./ce-code-review.md), [`ce-compound`](./ce-compound.md) | `model_roles` | One map from pipeline role to the model, and optional reasoning effort, that produces that step's deliverable. Roles: `brainstorm`, `plan`, `doc-review`, `debug`, `work`, `simplify`, `code-review`, `compound`. An entry is ` []` with effort `low`, `medium`, `high`, `xhigh`, or `max`, or `inherit` for the session model. `doc-review` and `code-review` also take a list, which adds one independent reviewer per item. Resolved role by role across the two files. An entry replaces the older model keys for the same step. No default: a role with no entry behaves as it does without the map. See [Model roles](#model-roles). | | [`ce-plan`](./ce-plan.md), [`ce-brainstorm`](./ce-brainstorm.md) | `plan_model`, `brainstorm_model` | Model elevation: send the reasoning-heavy step to a named model (e.g. `fable`, `opus`) instead of the session model. Value is a model alias; a prompt request or an orchestrator's `plan_model:` carrier (e.g. from `lfg`, honored even in pipeline mode) overrides it. Takes effect on every harness: natively where the host serves the model, else via the Claude CLI, else inline. Whenever one of these skills runs a Bake-off, automatically in planning or on request, pass the corresponding choice as a candidate model preference. Bake-off owns its dispatch: native access, authorized model CLIs, then fresh same-host agents on failure, subject to explicit model restrictions. With no preference, it seeks model-family diversity. Planning still has a final authoring call, while brainstorming replaces its ordinary generation. No default (elevation off). | | [`ce-work`](./ce-work.md), [`lfg`](./lfg.md) | `work_engine_mode`, `work_engine_preferences` | Ordered implementation-author preferences. Mode is `off`, `prefer`, or `require`; each entry has a `harness` and optional `model`. See [Implementation routing](#implementation-routing). | | [`ce-work`](./ce-work.md), [`lfg`](./lfg.md) | `work_engine_effort` | Reasoning effort for the external implementation worker, as a map from harness to one of that harness's own levels (claude `low`..`max`; codex `low`..`xhigh` plus `max` and `ultra` on the models that list them; grok `low`..`xhigh`; opencode its own variants; Cursor routes take none). A harness left out keeps its default. A level the harness cannot run makes that entry unavailable, and the list continues. See [Implementation routing](#implementation-routing). | @@ -105,6 +107,70 @@ All settings are optional. Commented examples are documentation, not active valu | [`ce-promote`](./ce-promote.md) | `ce_promote_spiral_optout` | `true` suppresses the one-time Spiral setup offer; remove the key to enable it again. | | [`ce-sweep`](./ce-sweep.md) | `feedback_sources`, `sweep_state_path`, `sweep_ack_cap`, `sweep_lease_ttl_minutes`, `sweep_shared_branch` | Feedback connectors, durable state location, acknowledgment circuit breaker, lease expiry, and optional push-gated shared-branch coordination. The setup interview writes these values. | +## Model roles + +`model_roles` names the model, and optionally the reasoning effort, that produces each pipeline step's deliverable. A team file might set: + +```yaml +model_roles: + plan: fable low # [] + doc-review: # a list on a review role: one seat per item + - grok-4.7 high + - gpt-6.1-sol + - opus medium + work: opus medium + code-review: [opus high, sonnet] # a flow list works too + +plan_model: opus # shadowed: the plan entry replaces it +cross_model_peer: codex # shadowed: both review roles have an entry +``` + +A personal `config.local.yaml` beside it can opt out of single roles: + +```yaml +model_roles: + plan: inherit # my plans stay on my session model + +work_engine_mode: off # the team work entry stays off external engines +``` + +With both files, plans are authored on the session model, each plan gets three independent reviews and each diff two, and every role with no entry (`brainstorm`, `debug`, `simplify`, `compound`) runs as it does without the map. + +| Role | Skill | Deliverable the entry governs | Older keys the entry replaces | +|---|---|---|---| +| `brainstorm` | [`ce-brainstorm`](./ce-brainstorm.md) | The generated approaches | `brainstorm_model` | +| `plan` | [`ce-plan`](./ce-plan.md) | The authored plan | `plan_model` | +| `doc-review` | [`ce-doc-review`](./ce-doc-review.md) | Each independent review of the plan | `cross_model_peer`, `cross_model_model`, `cross_model_effort` | +| `debug` | [`ce-debug`](./ce-debug.md) | The diagnosis and fix | none | +| `work` | [`ce-work`](./ce-work.md) | The code | `work_engine_mode`, `work_engine_preferences`, `work_engine_effort` | +| `simplify` | [`ce-simplify-code`](./ce-simplify-code.md) | The simplification | none | +| `code-review` | [`ce-code-review`](./ce-code-review.md) | Each independent review of the diff | `cross_model_peer`, `cross_model_model`, `cross_model_effort` | +| `compound` | [`ce-compound`](./ce-compound.md) | The learning document | none | + +An entry is ` []`. The model is an alias such as `fable`, `opus`, or `sonnet`, or a full model id. The effort is one of `low`, `medium`, `high`, `xhigh`, or `max`. `inherit` means the session model. An entry never carries a CLI flag, a command, or an app-specific model slug, so the same map works in every supported harness. + +Only `doc-review` and `code-review` take a list. Each item is one seat: an independent reviewer on that model. Seats run on every review of that role, in addition to the skill's own persona review. A single model on a review role is one seat. A scalar `inherit` or an empty list means no seats. Inside a list, `inherit` is one seat on the session model. + +An entry governs the step's deliverable, not the conversation. Dialogue with you, orchestration inside the skill, and `lfg`'s hand-offs between steps stay on the session model. The scouts and verifiers a step dispatches keep their own model tier. A step honors its entry the same way whether you invoke the skill or `lfg` does. + +For each role, the order is: a direct instruction in the conversation, then a stage directive carried by `lfg`, then the role's entry in `config.local.yaml`, then its entry in `config.yaml`, then the older keys, then the session model. An entry in either file outranks an older key in either file, which is why the example's `plan_model` and `cross_model_peer` no longer apply. A conversation instruction or `lfg` directive replaces the entry whole for that run, including its effort and, on a review role, its seat list. On a single-model role, an invalid entry is skipped with a warning and the next file is read. On a review role, an invalid seat is dropped alone and the list never continues to the other file's seats. When the `model_roles` block itself is malformed, a review skill runs its persona review only and names the reason. + +A committed map applies to every teammate, so two opt-outs live in the personal file. `: inherit` puts that role back on the session model. `work_engine_mode: off` keeps a team `work` entry off any external engine, while a `work` entry in the personal file still wins. + +Review seats stay under the two controls that already govern what review content leaves for another provider. `cross_model_review_mode: off` skips every seat whose model is served by a provider other than the session model's, and a direct conversation request for a peer still overrides it for that run. The `CROSS_MODEL_PEERS` environment variable, when set, filters seats by provider in the same way. Under either control, a named seat is also skipped when the session's own provider cannot be established or the seat's model belongs to no known provider. A skipped seat is reported and never replaced by another model. + +When a single-model role's entry cannot be served as written, the step keeps the named model as long as a route can serve it: first at the nearest lower effort the route accepts, then with the effort unapplied on a route that has no effort control. After that it uses the route's own default model, and last the session model. `debug`, `simplify`, and `compound` run through a host subagent, which has had no effort control on any harness verified so far, so their effort is reported as not applied. A review seat that cannot run is dropped and the remaining seats run. The persona review still runs when every seat is dropped. + +Each governed step prints one `Model role` line in the output it already produces. The line names what was requested, what was served, the route, and the reason for any fallback, drop, or skip: + +```text +Model role plan: requested fable low; served fable (unverified) at low; route claude CLI. +Model role work: requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. +Model role doc-review seat 1: requested grok-4.7 high; not run; reason: blocked by cross_model_review_mode: off. +``` + +A checkout with no active `model_roles` key prints nothing about model roles. + ## Implementation routing The work engine list is host-relative rather than tied to the checkout's usual harness: diff --git a/skills/ce-setup/references/config-template.yaml b/skills/ce-setup/references/config-template.yaml index 6ecce7d27..f8de239e2 100644 --- a/skills/ce-setup/references/config-template.yaml +++ b/skills/ce-setup/references/config-template.yaml @@ -58,6 +58,52 @@ # brainstorm_output: html # md | html (default: md) # ideate_output: md # md | html (default: html -- ideation docs are human-facing, so HTML is the default; set md to opt out) +# --- Model roles --- +# One map that names the model, and optionally the reasoning effort, that +# produces each pipeline step's deliverable. An entry governs that deliverable +# and nothing else. Dialogue with you, orchestration inside the skill, and +# lfg's hand-offs between steps stay on your session model. The map applies the +# same way whether you run the skill or lfg does. A role with no entry behaves +# exactly as it does without this map. +# +# An entry is ` []`, or `inherit` for the session model. The +# model is an alias or a full id, never a CLI flag or command. The effort scale +# is low | medium | high | xhigh | max. `doc-review` and `code-review` also +# take a list. Each item is one seat: an independent reviewer on that model, +# run beside the skill's own persona review. +# +# Layering is role by role, unlike the other maps in this file. An entry in +# config.local.yaml overrides that one role and leaves the team's other roles +# in place. +# +# An entry in either file replaces the older keys for the same step: +# brainstorm -> brainstorm_model +# plan -> plan_model +# doc-review, code-review -> cross_model_peer, cross_model_model, cross_model_effort +# work -> work_engine_mode, work_engine_preferences, work_engine_effort +# debug, simplify, and compound have no older key. +# +# Two opt-outs from a team map go in config.local.yaml. `: inherit` puts +# that role back on your session model. `work_engine_mode: off` keeps a team +# `work` entry off external engines. +# +# Review seats stay under `cross_model_review_mode` (below) and the +# CROSS_MODEL_PEERS environment variable. A seat that either one excludes is +# skipped and reported, never replaced by another model. + +# model_roles: +# brainstorm: opus high # the generated approaches +# plan: fable low # the authored plan +# doc-review: # each seat reviews the plan independently +# - grok-4.7 high +# - gpt-6.1-sol +# - opus medium +# debug: opus high # the diagnosis and fix +# work: opus medium # the code +# simplify: sonnet low # the simplification +# code-review: [opus high, sonnet] # each seat reviews the diff independently +# compound: inherit # the learning document, on the session model + # --- Model elevation --- # Send only the reasoning-heavy step of /ce-plan (interpret research, then author # the plan) and /ce-brainstorm (generate approaches) to a model you name. The rest diff --git a/tests/skills/ce-setup-check-health.test.ts b/tests/skills/ce-setup-check-health.test.ts index c73cc6e4a..5721afa4b 100644 --- a/tests/skills/ce-setup-check-health.test.ts +++ b/tests/skills/ce-setup-check-health.test.ts @@ -10,6 +10,7 @@ setDefaultTimeout(30000) const repoRoot = path.join(import.meta.dir, "..", "..") const checkHealthScript = path.join(repoRoot, "skills", "ce-setup", "scripts", "check-health") +const modelRoleResolver = path.join(repoRoot, "skills", "ce-setup", "scripts", "model-role-resolve.py") const configTemplate = path.join(repoRoot, "skills", "ce-setup", "references", "config-template.yaml") const configExample = path.join(repoRoot, ".compound-engineering", "config.example.yaml") const configDocs = path.join(repoRoot, "docs", "guides", "configuration.md") @@ -156,6 +157,7 @@ describe("ce-setup check-health", () => { const keys = [...template.matchAll(/^# ([A-Za-z][A-Za-z0-9_]*):(?:\s|$)/gm)].map((match) => match[1]) expect(keys.length).toBeGreaterThan(0) + expect(keys).toContain("model_roles") for (const key of keys) { expect(docs).toContain(`\`${key}\``) } @@ -183,6 +185,43 @@ describe("ce-setup check-health", () => { } }) + // The Model roles section is the example a developer uncomments, so its role + // lines must be a map the resolver accepts as written, one entry per role. + test("the template's model_roles example resolves cleanly once uncommented", async () => { + const template = await readFile(configTemplate, "utf8") + const example = template.match(/^# model_roles:\n(?:# .*\n)+/m)?.[0] ?? "" + const root = await mkdtemp(path.join(os.tmpdir(), "ce-setup-model-roles-")) + + try { + await initGitRepo(root) + await mkdir(path.join(root, ".compound-engineering"), { recursive: true }) + await writeFile(path.join(root, ".compound-engineering", "config.yaml"), example.replace(/^# /gm, "")) + + const proc = Bun.spawn(["python3", modelRoleResolver, "--all"], { + cwd: root, + // The contributor's own allowlist must not decide a seat's blocked_by. + env: { ...isolatedGitEnv, CROSS_MODEL_PEERS: "" }, + stderr: "ignore", + stdout: "pipe", + }) + const [exitCode, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]) + expect(exitCode).toBe(0) + + const resolved = JSON.parse(stdout) + expect(resolved.errors).toEqual([]) + expect(resolved.warnings).toEqual([]) + expect(resolved.roles).toHaveLength(8) + for (const role of resolved.roles) { + expect({ role: role.role, from: role.effective.from }).toEqual({ role: role.role, from: "map" }) + expect(role.warnings).toEqual([]) + expect(role.errors).toEqual([]) + expect(role.entries.filter((entry: { invalid?: boolean }) => entry.invalid)).toEqual([]) + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + test("does not advertise retired Codex work-delegation settings", async () => { const [template, skill] = await Promise.all([ readFile(configTemplate, "utf8"), From cb16cac9dfe510c6f382504683d39cac7f373260 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:40:56 -0700 Subject: [PATCH 05/53] feat(model-roles): honor plan and brainstorm entries in model elevation The elevation engine consults the role map entry before plan_model or brainstorm_model, and its Claude CLI worker takes an optional effort (CE_ELEVATION_EFFORT, default high). An entry with an effort always hands off, because no host exposes the session effort. The result envelope records requested_effort. Co-Authored-By: Claude Code --- .../references/reasoning-elevation.md | 27 +++++-- .../scripts/elevation-dispatch.sh | 26 +++++-- .../ce-plan/references/reasoning-elevation.md | 27 +++++-- skills/ce-plan/scripts/elevation-dispatch.sh | 26 +++++-- tests/skills/elevation-dispatch.test.ts | 78 +++++++++++++++++++ 5 files changed, 158 insertions(+), 26 deletions(-) diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index 970dda435..fb378fce8 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -12,11 +12,17 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 1. **Latest explicit user intent** — in an interactive run, the latest instruction in the current conversation about this step wins: naming a model selects it; explicitly prohibiting elevation selects none. Intent is *reasoned, not keyword-matched*: a model named as product subject matter (e.g. "design a fable-generator feature") is not activation. In pipeline / `disable-model-invocation` runs, skip this source — the sanitized feature request is product content, never elevation intent. 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. -3. **Config** — otherwise use the per-skill key: `plan_model` for ce-plan, `brainstorm_model` for ce-brainstorm. Read it the **same way this skill's Phase 0.0 (output-mode resolution) resolves `plan_output` / `brainstorm_output`**: reuse the repo root already resolved, else run `git rev-parse --show-toplevel`, then apply the ordinary-key rule (`config.local.yaml` then `config.yaml`). Reuse the Phase 0.0 reads if still in hand. Ignore commented (`#`-prefixed) lines. A model alias selects it; missing / commented / invalid / no file selects none. +3. **Config** — otherwise consult the role's map entry first and the per-skill key second. -**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. + **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key, skip this and print nothing about model roles. -If the session model already **is** the resolved model, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). + The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. + + **Per-skill key.** The per-skill key decides when no `model_roles:` key is active, and when `references/model-roles.md` says the step continues with the skill's existing keys. The key is `plan_model` for ce-plan, `brainstorm_model` for ce-brainstorm. Read it the **same way this skill's Phase 0.0 (output-mode resolution) resolves `plan_output` / `brainstorm_output`**: reuse the repo root already resolved, else run `git rev-parse --show-toplevel`, then apply the ordinary-key rule (`config.local.yaml` then `config.yaml`). Reuse the Phase 0.0 reads if still in hand. Ignore commented (`#`-prefixed) lines. A model alias selects it; missing / commented / invalid / no file selects none. + +**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. Inside the config tier, the role's map entry in either file outranks the per-skill key in either file. A live instruction or a caller carrier replaces the entry whole, including its effort, and the step then runs as it does with no map. + +If the session model already **is** the resolved model and the choice carries no effort, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). A map entry with an effort always hands off, even when it names the session model. ## Adapter selection @@ -26,6 +32,13 @@ When elevation is active, resolve an adapter in this fixed order and use the fir 2. **Claude CLI.** Run the bundled `scripts/elevation-dispatch.sh` worker as a detached job (see Off-host dispatch). Available when `claude` is on PATH. Do not preflight authentication in the host command context: the detached worker's provider-capable call is authoritative, and an authentication failure there follows Recovery. 3. **Inline on the session model.** The always-available fallback. +**Map entries.** A choice from a `model_roles` entry uses these same adapters in the serving order of `references/model-roles.md`, which also owns the fallback ladder. That file needs these facts about this file's routes: + +- An entry with no effort keeps the fixed order above. +- An entry with an effort uses the first adapter that can carry the effort. The native adapter can only where the host's subagent tool sets effort. Elsewhere it is the ladder's route with no effort control. The Claude CLI adapter can for a Claude-family model, which means the resolver reports the entry's `family` as `claude`. It accepts every effort on the resolver's scale. +- An entry in another model family has no CLI adapter in this file, so only the native adapter can serve it. +- When no adapter can serve the entry as written, or the adapter that started fails, the ladder decides the next step. + Elevation is never a correctness dependency: every adapter failure degrades to the next, and inline always completes the run. ## Read-only posture and brief handoff @@ -80,6 +93,7 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; SKILL_NAME=""; CE_PEER_HARD_SECS=5400 CE_ELEVATION_HARD_SECS=5400 CE_PEER_LOG_MAX_BYTES=52428800 \ + CE_ELEVATION_EFFORT="" \ "$PY" "$SKILL_DIR/scripts/peer-job-runner.py" start \ --skill "$SKILL_NAME" --run-id "" --label elevation \ --result-path "" \ @@ -88,6 +102,8 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c `CE_PEER_HARD_SECS` (the outer runner cap) and `CE_ELEVATION_HARD_SECS` (the worker's own inner cap) are set to the **same** raised backstop well above any legitimate run (R11) — keep them equal so the inner cap never reaps a healthy run before the outer one. `CE_PEER_LOG_MAX_BYTES` is raised for the streaming route so a healthy high-volume run is not reaped as a failure (R22). `start` returns a job id in under ~2s. +`CE_ELEVATION_EFFORT` sets the reasoning effort of the elevated call. `` is the map entry's effort. Use `high`, the worker's default, when the choice carries none. The worker accepts `low`, `medium`, `high`, `xhigh`, and `max`, and it rejects any other value before it launches the model. + 3. **Poll** between your other work until the job reaches a terminal state (resolve `$PY` again — each tool call is a fresh shell): ```bash @@ -96,7 +112,7 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c "$PY" "$SKILL_DIR/scripts/peer-job-runner.py" wait --max-secs 30 "" ``` -4. **Read the result** — the worker writes a JSON object of the shape `{status, requested_model, served_model, receipt, output}`: +4. **Read the result** — the worker writes a JSON object of the shape `{status, requested_model, requested_effort, served_model, receipt, output}`: ```bash SKILL_DIR=""; @@ -115,10 +131,11 @@ Classify from **both** the runner's terminal state and the worker's JSON result. A successful run has JSON `status: ok`. Treat any result whose `receipt` is `mismatch` as if it were a failure even when `status` is `ok`: **discard the output and degrade to the session model** — a served model that does not match the requested family must never be passed off as the requested one. (On the native route a mismatch instead falls through to the next adapter, per R6; on the CLI route inline is the only thing left, so discard-and-degrade is the fall-through.) -Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. +Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. A choice from a map entry instead follows the fallback ladder in `references/model-roles.md` wherever this file degrades to the session model, and its `Model role` line names the model that served. ## Transparency +- **Choice from a map entry** → print the `Model role` line from `references/model-roles.md` in place of the lines below, whenever that file calls for one. It adds the applied effort and the reason for any fallback. On the Claude CLI route the applied effort is the result's `requested_effort`. The bullets below govern every other run: a choice from an instruction, a carrier, or a per-skill key, and a run with no choice at all. - **Elevation ran** → print one line naming the **model**, the **route**, and **why** it ran (config key, explicit user instruction, or caller carrier). Name the model as **served** when a receipt confirms it; otherwise name it as **requested** with an explicit *unverified* marker — on every route, including native. - **Print no line** when elevation did not run, and when the session model already is the model a **config key** requested. An **explicit user instruction** always produces a line, including when the session model already matches (so a recognized request is never indistinguishable from an unparsed one). - **Requested but unavailable before provider-capable dispatch** (no native support, `claude` absent, or the required launch permission unavailable) → run the step inline on the session model, name **which routing precondition was unmet**, and state what would make the requested model reachable. Once provider-capable dispatch is established, an authentication failure is instead a route-level Recovery outcome: name the observed authentication failure and the login or credential-refresh remediation. diff --git a/skills/ce-brainstorm/scripts/elevation-dispatch.sh b/skills/ce-brainstorm/scripts/elevation-dispatch.sh index e28e1dc63..ddbbdac19 100755 --- a/skills/ce-brainstorm/scripts/elevation-dispatch.sh +++ b/skills/ce-brainstorm/scripts/elevation-dispatch.sh @@ -15,6 +15,9 @@ # elevation-dispatch.sh # elevation-dispatch.sh --emit-adapter # print argv, no model call (test hook) # +# Environment: CE_ELEVATION_EFFORT= sets the claude +# CLI reasoning effort for this call. Unset or empty runs at high. +# # NOTE ON THE FUNCTION NAMED run_codex_cmd: it is NOT codex-specific here. It is # the $PEERLOG byte-growth idle loop that implements R11's primary supervision # signal (run_timeout_cmd, hard-cap-only, would leave a stalled run undetected). @@ -31,7 +34,14 @@ RUN_SUCCEEDED=false log() { printf '[elevation] %s\n' "$*" >&2; } -EFFORT="high" # settled: elevation runs at high effort +# Elevation runs at high effort unless the caller passes one (a model role map +# entry's effort). The claude CLI documents exactly these levels; anything else +# is rejected here, before the test hook or any model call. +EFFORT="${CE_ELEVATION_EFFORT:-high}" +case "$EFFORT" in + low|medium|high|xhigh|max) ;; + *) log "effort '$EFFORT' rejected: the claude CLI accepts low|medium|high|xhigh|max"; exit 2 ;; +esac # Read-only tool posture (R7): the available built-in set, not a denylist. The # elevated step reads the repo (Read/Glob/Grep) and may check current facts on @@ -99,7 +109,7 @@ HANDOFF_DIR="$(cd "$HANDOFF_DIR" 2>/dev/null && pwd || printf '%s' "$HANDOFF_DIR # `done`, the envelope's status:failed is read, and it degrades to inline. if ! command -v jq >/dev/null 2>&1; then log "jq not found on PATH; cannot parse the elevated result — degrading to inline" - printf '{"status":"failed","requested_model":"%s","evidence":"jq unavailable on PATH"}' "$MODEL" > "$RESULT_PATH" 2>/dev/null || true + printf '{"status":"failed","requested_model":"%s","requested_effort":"%s","evidence":"jq unavailable on PATH"}' "$MODEL" "$EFFORT" > "$RESULT_PATH" 2>/dev/null || true exit 0 fi @@ -270,19 +280,19 @@ if [ "$RUN_SUCCEEDED" = true ] && [ "$HAS_OUTPUT" = "yes" ] \ # internally — never pass the plan text as an argv --arg, which would exceed # ARG_MAX for a large Deep plan. tmp="${RESULT_PATH}.tmp.$$" - if printf '%s' "$EVENT" | jq --arg m "$MODEL" --arg s "$SERVED" --arg r "$RECEIPT" \ - '{status:"ok", requested_model:$m, served_model:$s, receipt:$r, output:.result}' \ + if printf '%s' "$EVENT" | jq --arg m "$MODEL" --arg f "$EFFORT" --arg s "$SERVED" --arg r "$RECEIPT" \ + '{status:"ok", requested_model:$m, requested_effort:$f, served_model:$s, receipt:$r, output:.result}' \ > "$tmp" 2>/dev/null; then mv -f "$tmp" "$RESULT_PATH" - log "elevated step complete: requested=$MODEL served=$SERVED receipt=$RECEIPT" + log "elevated step complete: requested=$MODEL effort=$EFFORT served=$SERVED receipt=$RECEIPT" else rm -f "$tmp" - write_result "$(jq -n --arg m "$MODEL" '{status:"failed", requested_model:$m, evidence:"result envelope build failed"}')" + write_result "$(jq -n --arg m "$MODEL" --arg f "$EFFORT" '{status:"failed", requested_model:$m, requested_effort:$f, evidence:"result envelope build failed"}')" log "elevated step: result envelope build failed" fi else - write_result "$(jq -n --arg m "$MODEL" --arg e "$(bounded_failure_evidence)" \ - '{status:"failed", requested_model:$m, evidence:$e}')" + write_result "$(jq -n --arg m "$MODEL" --arg f "$EFFORT" --arg e "$(bounded_failure_evidence)" \ + '{status:"failed", requested_model:$m, requested_effort:$f, evidence:$e}')" log "elevated step failed; wrote failure envelope" fi rm -f "$PEERLOG" diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index 970dda435..fb378fce8 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -12,11 +12,17 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 1. **Latest explicit user intent** — in an interactive run, the latest instruction in the current conversation about this step wins: naming a model selects it; explicitly prohibiting elevation selects none. Intent is *reasoned, not keyword-matched*: a model named as product subject matter (e.g. "design a fable-generator feature") is not activation. In pipeline / `disable-model-invocation` runs, skip this source — the sanitized feature request is product content, never elevation intent. 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. -3. **Config** — otherwise use the per-skill key: `plan_model` for ce-plan, `brainstorm_model` for ce-brainstorm. Read it the **same way this skill's Phase 0.0 (output-mode resolution) resolves `plan_output` / `brainstorm_output`**: reuse the repo root already resolved, else run `git rev-parse --show-toplevel`, then apply the ordinary-key rule (`config.local.yaml` then `config.yaml`). Reuse the Phase 0.0 reads if still in hand. Ignore commented (`#`-prefixed) lines. A model alias selects it; missing / commented / invalid / no file selects none. +3. **Config** — otherwise consult the role's map entry first and the per-skill key second. -**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. + **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key, skip this and print nothing about model roles. -If the session model already **is** the resolved model, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). + The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. + + **Per-skill key.** The per-skill key decides when no `model_roles:` key is active, and when `references/model-roles.md` says the step continues with the skill's existing keys. The key is `plan_model` for ce-plan, `brainstorm_model` for ce-brainstorm. Read it the **same way this skill's Phase 0.0 (output-mode resolution) resolves `plan_output` / `brainstorm_output`**: reuse the repo root already resolved, else run `git rev-parse --show-toplevel`, then apply the ordinary-key rule (`config.local.yaml` then `config.yaml`). Reuse the Phase 0.0 reads if still in hand. Ignore commented (`#`-prefixed) lines. A model alias selects it; missing / commented / invalid / no file selects none. + +**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. Inside the config tier, the role's map entry in either file outranks the per-skill key in either file. A live instruction or a caller carrier replaces the entry whole, including its effort, and the step then runs as it does with no map. + +If the session model already **is** the resolved model and the choice carries no effort, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). A map entry with an effort always hands off, even when it names the session model. ## Adapter selection @@ -26,6 +32,13 @@ When elevation is active, resolve an adapter in this fixed order and use the fir 2. **Claude CLI.** Run the bundled `scripts/elevation-dispatch.sh` worker as a detached job (see Off-host dispatch). Available when `claude` is on PATH. Do not preflight authentication in the host command context: the detached worker's provider-capable call is authoritative, and an authentication failure there follows Recovery. 3. **Inline on the session model.** The always-available fallback. +**Map entries.** A choice from a `model_roles` entry uses these same adapters in the serving order of `references/model-roles.md`, which also owns the fallback ladder. That file needs these facts about this file's routes: + +- An entry with no effort keeps the fixed order above. +- An entry with an effort uses the first adapter that can carry the effort. The native adapter can only where the host's subagent tool sets effort. Elsewhere it is the ladder's route with no effort control. The Claude CLI adapter can for a Claude-family model, which means the resolver reports the entry's `family` as `claude`. It accepts every effort on the resolver's scale. +- An entry in another model family has no CLI adapter in this file, so only the native adapter can serve it. +- When no adapter can serve the entry as written, or the adapter that started fails, the ladder decides the next step. + Elevation is never a correctness dependency: every adapter failure degrades to the next, and inline always completes the run. ## Read-only posture and brief handoff @@ -80,6 +93,7 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; SKILL_NAME=""; CE_PEER_HARD_SECS=5400 CE_ELEVATION_HARD_SECS=5400 CE_PEER_LOG_MAX_BYTES=52428800 \ + CE_ELEVATION_EFFORT="" \ "$PY" "$SKILL_DIR/scripts/peer-job-runner.py" start \ --skill "$SKILL_NAME" --run-id "" --label elevation \ --result-path "" \ @@ -88,6 +102,8 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c `CE_PEER_HARD_SECS` (the outer runner cap) and `CE_ELEVATION_HARD_SECS` (the worker's own inner cap) are set to the **same** raised backstop well above any legitimate run (R11) — keep them equal so the inner cap never reaps a healthy run before the outer one. `CE_PEER_LOG_MAX_BYTES` is raised for the streaming route so a healthy high-volume run is not reaped as a failure (R22). `start` returns a job id in under ~2s. +`CE_ELEVATION_EFFORT` sets the reasoning effort of the elevated call. `` is the map entry's effort. Use `high`, the worker's default, when the choice carries none. The worker accepts `low`, `medium`, `high`, `xhigh`, and `max`, and it rejects any other value before it launches the model. + 3. **Poll** between your other work until the job reaches a terminal state (resolve `$PY` again — each tool call is a fresh shell): ```bash @@ -96,7 +112,7 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c "$PY" "$SKILL_DIR/scripts/peer-job-runner.py" wait --max-secs 30 "" ``` -4. **Read the result** — the worker writes a JSON object of the shape `{status, requested_model, served_model, receipt, output}`: +4. **Read the result** — the worker writes a JSON object of the shape `{status, requested_model, requested_effort, served_model, receipt, output}`: ```bash SKILL_DIR=""; @@ -115,10 +131,11 @@ Classify from **both** the runner's terminal state and the worker's JSON result. A successful run has JSON `status: ok`. Treat any result whose `receipt` is `mismatch` as if it were a failure even when `status` is `ok`: **discard the output and degrade to the session model** — a served model that does not match the requested family must never be passed off as the requested one. (On the native route a mismatch instead falls through to the next adapter, per R6; on the CLI route inline is the only thing left, so discard-and-degrade is the fall-through.) -Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. +Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. A choice from a map entry instead follows the fallback ladder in `references/model-roles.md` wherever this file degrades to the session model, and its `Model role` line names the model that served. ## Transparency +- **Choice from a map entry** → print the `Model role` line from `references/model-roles.md` in place of the lines below, whenever that file calls for one. It adds the applied effort and the reason for any fallback. On the Claude CLI route the applied effort is the result's `requested_effort`. The bullets below govern every other run: a choice from an instruction, a carrier, or a per-skill key, and a run with no choice at all. - **Elevation ran** → print one line naming the **model**, the **route**, and **why** it ran (config key, explicit user instruction, or caller carrier). Name the model as **served** when a receipt confirms it; otherwise name it as **requested** with an explicit *unverified* marker — on every route, including native. - **Print no line** when elevation did not run, and when the session model already is the model a **config key** requested. An **explicit user instruction** always produces a line, including when the session model already matches (so a recognized request is never indistinguishable from an unparsed one). - **Requested but unavailable before provider-capable dispatch** (no native support, `claude` absent, or the required launch permission unavailable) → run the step inline on the session model, name **which routing precondition was unmet**, and state what would make the requested model reachable. Once provider-capable dispatch is established, an authentication failure is instead a route-level Recovery outcome: name the observed authentication failure and the login or credential-refresh remediation. diff --git a/skills/ce-plan/scripts/elevation-dispatch.sh b/skills/ce-plan/scripts/elevation-dispatch.sh index e28e1dc63..ddbbdac19 100755 --- a/skills/ce-plan/scripts/elevation-dispatch.sh +++ b/skills/ce-plan/scripts/elevation-dispatch.sh @@ -15,6 +15,9 @@ # elevation-dispatch.sh # elevation-dispatch.sh --emit-adapter # print argv, no model call (test hook) # +# Environment: CE_ELEVATION_EFFORT= sets the claude +# CLI reasoning effort for this call. Unset or empty runs at high. +# # NOTE ON THE FUNCTION NAMED run_codex_cmd: it is NOT codex-specific here. It is # the $PEERLOG byte-growth idle loop that implements R11's primary supervision # signal (run_timeout_cmd, hard-cap-only, would leave a stalled run undetected). @@ -31,7 +34,14 @@ RUN_SUCCEEDED=false log() { printf '[elevation] %s\n' "$*" >&2; } -EFFORT="high" # settled: elevation runs at high effort +# Elevation runs at high effort unless the caller passes one (a model role map +# entry's effort). The claude CLI documents exactly these levels; anything else +# is rejected here, before the test hook or any model call. +EFFORT="${CE_ELEVATION_EFFORT:-high}" +case "$EFFORT" in + low|medium|high|xhigh|max) ;; + *) log "effort '$EFFORT' rejected: the claude CLI accepts low|medium|high|xhigh|max"; exit 2 ;; +esac # Read-only tool posture (R7): the available built-in set, not a denylist. The # elevated step reads the repo (Read/Glob/Grep) and may check current facts on @@ -99,7 +109,7 @@ HANDOFF_DIR="$(cd "$HANDOFF_DIR" 2>/dev/null && pwd || printf '%s' "$HANDOFF_DIR # `done`, the envelope's status:failed is read, and it degrades to inline. if ! command -v jq >/dev/null 2>&1; then log "jq not found on PATH; cannot parse the elevated result — degrading to inline" - printf '{"status":"failed","requested_model":"%s","evidence":"jq unavailable on PATH"}' "$MODEL" > "$RESULT_PATH" 2>/dev/null || true + printf '{"status":"failed","requested_model":"%s","requested_effort":"%s","evidence":"jq unavailable on PATH"}' "$MODEL" "$EFFORT" > "$RESULT_PATH" 2>/dev/null || true exit 0 fi @@ -270,19 +280,19 @@ if [ "$RUN_SUCCEEDED" = true ] && [ "$HAS_OUTPUT" = "yes" ] \ # internally — never pass the plan text as an argv --arg, which would exceed # ARG_MAX for a large Deep plan. tmp="${RESULT_PATH}.tmp.$$" - if printf '%s' "$EVENT" | jq --arg m "$MODEL" --arg s "$SERVED" --arg r "$RECEIPT" \ - '{status:"ok", requested_model:$m, served_model:$s, receipt:$r, output:.result}' \ + if printf '%s' "$EVENT" | jq --arg m "$MODEL" --arg f "$EFFORT" --arg s "$SERVED" --arg r "$RECEIPT" \ + '{status:"ok", requested_model:$m, requested_effort:$f, served_model:$s, receipt:$r, output:.result}' \ > "$tmp" 2>/dev/null; then mv -f "$tmp" "$RESULT_PATH" - log "elevated step complete: requested=$MODEL served=$SERVED receipt=$RECEIPT" + log "elevated step complete: requested=$MODEL effort=$EFFORT served=$SERVED receipt=$RECEIPT" else rm -f "$tmp" - write_result "$(jq -n --arg m "$MODEL" '{status:"failed", requested_model:$m, evidence:"result envelope build failed"}')" + write_result "$(jq -n --arg m "$MODEL" --arg f "$EFFORT" '{status:"failed", requested_model:$m, requested_effort:$f, evidence:"result envelope build failed"}')" log "elevated step: result envelope build failed" fi else - write_result "$(jq -n --arg m "$MODEL" --arg e "$(bounded_failure_evidence)" \ - '{status:"failed", requested_model:$m, evidence:$e}')" + write_result "$(jq -n --arg m "$MODEL" --arg f "$EFFORT" --arg e "$(bounded_failure_evidence)" \ + '{status:"failed", requested_model:$m, requested_effort:$f, evidence:$e}')" log "elevated step failed; wrote failure envelope" fi rm -f "$PEERLOG" diff --git a/tests/skills/elevation-dispatch.test.ts b/tests/skills/elevation-dispatch.test.ts index 3f6bc6499..7fd1215b5 100644 --- a/tests/skills/elevation-dispatch.test.ts +++ b/tests/skills/elevation-dispatch.test.ts @@ -56,6 +56,10 @@ const BRAINSTORM_WORKER = path.join( __dirname, "../../skills/ce-brainstorm/scripts/elevation-dispatch.sh", ) +const REFERENCE = path.join( + __dirname, + "../../skills/ce-plan/references/reasoning-elevation.md", +) // Approval/bypass flags the read-only elevation call must never emit. const NEVER_FLAGS = [ @@ -109,6 +113,20 @@ function runWorker( return { result, stderr: r.stderr ?? "", status: r.status } } +/** Print the argv the worker would exec. `effort` sets CE_ELEVATION_EFFORT; + * undefined leaves it unset whatever the developer's shell exports. */ +function emitAdapter(effort?: string) { + const env: NodeJS.ProcessEnv = { ...process.env } + delete env.CE_ELEVATION_EFFORT + if (effort !== undefined) env.CE_ELEVATION_EFFORT = effort + const r = spawnSync("bash", [WORKER, "--emit-adapter", "fable", "/fake/handoff/xyz"], { + encoding: "utf8", + env, + }) + const argv = (r.stdout ?? "").split("\0").filter(Boolean) + return { status: r.status, stderr: r.stderr ?? "", argv } +} + const RESULT_LINE = (result: string, usage: Record | null) => JSON.stringify({ type: "result", @@ -162,6 +180,49 @@ describe("elevation-dispatch worker", () => { for (const flag of NEVER_FLAGS) expect(argv).not.toContain(flag) }) + test("the emitted adapter carries the passed effort, and high when none is passed", () => { + const low = emitAdapter("low") + expect(low.status).toBe(0) + expect(low.argv[low.argv.indexOf("--effort") + 1]).toBe("low") + const unset = emitAdapter() + expect(unset.status).toBe(0) + expect(unset.argv[unset.argv.indexOf("--effort") + 1]).toBe("high") + }) + + test("an effort the Claude CLI does not document is rejected before launch with a named reason", () => { + // `ultra` is a real level on another CLI, so it is the value a caller is + // most likely to pass here by mistake. + const emitted = emitAdapter("ultra") + expect(emitted.status).toBe(2) + expect(emitted.argv).toEqual([]) + expect(emitted.stderr).toContain("effort 'ultra' rejected") + expect(emitted.stderr).toContain("low|medium|high|xhigh|max") + + const marker = path.join(mkTempRoot("elevation-launch-"), "launched") + const stub = "#!/bin/sh\n" + `: > "${marker}"\n` + const { result, stderr, status } = runWorker("fable", stub, { + CE_ELEVATION_EFFORT: "ultra", + }) + expect(status).toBe(2) + expect(stderr).toContain("effort 'ultra' rejected") + expect(existsSync(marker)).toBe(false) // claude was never launched + expect(result).toBeNull() + }) + + test("the result envelope records the requested effort", () => { + const ok = + "#!/bin/sh\n" + + `printf '%s\\n' '${RESULT_LINE("PLAN BODY", { "claude-fable-5": { outputTokens: 5 } })}'\n` + const passed = runWorker("fable", ok, { CE_ELEVATION_EFFORT: "low" }) + expect(passed.result.status).toBe("ok") + expect(passed.result.requested_effort).toBe("low") + expect(runWorker("fable", ok).result.requested_effort).toBe("high") + // A failed run records it too, so a fallback can still name what was asked. + const failed = runWorker("fable", "#!/bin/sh\nexit 1\n", { CE_ELEVATION_EFFORT: "low" }) + expect(failed.result.status).toBe("failed") + expect(failed.result.requested_effort).toBe("low") + }) + test("a matching receipt yields a matched envelope with the output", () => { const stub = "#!/bin/sh\n" + @@ -272,6 +333,23 @@ describe("elevation-dispatch worker", () => { expect(result.receipt).toBe("unverified") }) + // Corpus pins for the model role map. The Config step is where both skills + // consult the map, so the shared reference and both role names must be named + // there; the effort variable and envelope field are the worker's interface. + test("the reference consults the plan / brainstorm role entry at the Config step", () => { + const ref = readFileSync(REFERENCE, "utf8") + const start = ref.indexOf("3. **Config**") + const end = ref.indexOf("**Precedence:") + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + const config = ref.slice(start, end) + expect(config).toContain("`references/model-roles.md`") + expect(config).toContain("`plan`") + expect(config).toContain("`brainstorm`") + expect(ref).toContain("CE_ELEVATION_EFFORT=") + expect(ref).toContain("requested_effort") + }) + // AE4 mechanism (deterministic): the idle window is a *reset-on-growth* window, // not a fixed timer — so a run that keeps growing $PEERLOG is never reaped. The // positive-timing version of this is an inherently flaky subprocess race (it From 148f18dc835a5089f888110becc54e24c02d2727 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:42:59 -0700 Subject: [PATCH 06/53] feat(ce-debug): honor the debug model role A debug entry moves the investigation to a read-only subagent and the test-first fix to a second one on the named model. The session keeps the causal-chain gate, the branch, the pre-fix scope record, verification, and the commit. The structured returns gain one optional model_role field, which the lfg gate accepts but does not require. Co-Authored-By: Claude Code --- skills/ce-debug/references/fix.md | 12 ++++ skills/ce-debug/references/investigate.md | 27 +++++++++ skills/ce-debug/references/pipeline-mode.md | 3 +- .../ce-debug/references/return-to-caller.md | 5 +- skills/lfg/references/debug-return.md | 2 + tests/skills/ce-debug-model-role.test.ts | 60 +++++++++++++++++++ tests/skills/lfg-intake-contract.test.ts | 13 ++++ 7 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/skills/ce-debug-model-role.test.ts diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index d42155965..37fd3afc0 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -4,6 +4,18 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and *One change at a time. If you are changing multiple things, stop.* +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the first edit. With no such key, skip this and print nothing about model roles. + +When Phase 1 already resolved the role, use that result. `references/investigate.md` states the role's deliverable, its route, and where the `Model role` line goes. This file adds only the fix hand-off. + +**Fix hand-off.** When the role hands off and the host can give the entry's model to a subagent, one subagent on that model does steps 1 to 3 below: it writes the failing test, sees it fail for the right reason, and implements the minimal fix. The branch check, the confirmation for files that carry the user's unstaged work, the pre-fix scope record, steps 4 and 5, the count of failed attempts, and the commit stay with the session. When the host cannot hand over the model, the session does all five steps as this file describes, and the `Model role` line says so. + +- **Before the dispatch**, SKILL.md's Phase 3 preconditions, as this run's mode applies them, are complete for every file the diagnosis names. The session then adds the Phase 2 findings to `handoff.md`: the confirmed causal chain, the proposed fix, the files the fix may change, the test recommendation, and Phase 1's reproduction check. A run that has no run directory yet creates it first with the block in `references/investigate.md`. +- **The subagent** receives the path of `handoff.md` and the text of steps 1 to 3. It edits only the files `handoff.md` names. When the fix needs another file, it stops and returns that file's name, and the session takes the file through the same preconditions before it dispatches again. The subagent does not commit, change branches, or dispatch anything. It returns the paths it changed and the failure it observed before the fix. +- **After the return**, the session reads the diff, runs steps 4 and 5 itself, and adds the changed paths to the fix-owned files. The commit happens where this run's mode already puts it. +- **A failed fix** follows the rule below unchanged: the session returns to Phase 2, invalidates the hypothesis, and counts the attempt. The retry dispatches again from `handoff.md` once the session has added that Phase 2 reasoning to it. +- **A hand-off that fails mid-edit** is finished inline. The session completes steps 1 to 3 from the working tree as it stands, runs steps 4 and 5, and names the fallback in the `reason:` of the `Model role` line. + **Test-first:** 1. Choose the regression test's home. Follow the active project instructions and any applicable subdirectory-scoped instructions, and always inspect existing tests before adding coverage. Use an existing failing test when it already captures the bug, update an existing test when it owns the contract but has the wrong expectation, strengthen an over-mocked test that should have caught the bug, or add a new minimal isolated test only when no existing test is the right home. ("Owns the contract" means that test is the one that defines the behavior the bug broke.) The test must fail on the current bug and pass once the corrected behavior lands. Name it so the failure message explains the bug. Sometimes no available seam can exercise the bug as it actually triggered, because the failure needs a chain of callers or a state the reachable seams cannot set up. In that case do not write a shallow test there for the false confidence. Record the missing seam as a finding in the debug summary, and let defense-in-depth or the post-mortem carry it. SKILL.md's precondition decides whether an existing test may be updated at all: only for a confirmed defect, never for a test whose expectation the change deliberately reverses. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 92809850c..c01d6e9a9 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,6 +25,33 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the investigation starts. With no such key, skip this and print nothing about model roles. + +The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route, so a native subagent is its only hand-off route, and the fallback ladder in `references/model-roles.md` comes straight after it. The entry is standing config. `mode:pipeline` and `mode:return-to-caller` honor it the way interactive use does, and no mode asks about it. + +**Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes, and the `Model role` line says so. + +Create the run directory, then write `handoff.md` in it. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. + +```bash +SCRATCH_ROOT="/tmp/compound-engineering-$(id -u)"; +[ ! -L "$SCRATCH_ROOT" ] && (umask 077; mkdir -p "$SCRATCH_ROOT") 2>/dev/null && [ ! -L "$SCRATCH_ROOT" ] && [ -O "$SCRATCH_ROOT" ] && [ -w "$SCRATCH_ROOT" ] || SCRATCH_ROOT="${TMPDIR:-/tmp}/compound-engineering-$(id -u)"; +if [ -L "$SCRATCH_ROOT" ]; then echo "unsafe scratch root symlink: $SCRATCH_ROOT" >&2; exit 1; fi; +(umask 077; mkdir -p "$SCRATCH_ROOT") || exit 1; +if [ -L "$SCRATCH_ROOT" ] || [ ! -O "$SCRATCH_ROOT" ]; then echo "scratch root is not owned by the current user: $SCRATCH_ROOT" >&2; exit 1; fi; +chmod 700 "$SCRATCH_ROOT" || exit 1; +RUN_ID=$(date +%Y%m%d-%H%M%S)-$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' '); +RUN_DIR="$SCRATCH_ROOT/ce-debug/$RUN_ID"; +(umask 077; mkdir -p "$RUN_DIR") || exit 1; chmod 700 "$RUN_DIR" || exit 1; +echo "$RUN_DIR"; +``` + +The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, and the text of SKILL.md's **Secrets in evidence** rule. The subagent follows Phases 1 and 2 of this file through hypothesis formation. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` in the evidence-return format the parallel investigators in Phase 2 use, and it returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. + +The session reads `investigation.md` as a proposal. It runs the probes the subagent left, closes any gap, and decides whether the chain passes SKILL.md's causal-chain gate. The findings block and the fix-choice question come from the session, as they do with no map. The parallel investigation option in Phase 2 is unchanged. + +**Reporting.** One `Model role debug:` line covers the run, in the format `references/model-roles.md` defines. In interactive use it is the last line of Phase 4's Debug Summary. In `mode:pipeline` and `mode:return-to-caller` it is the optional `model_role` field of the structured return. When the investigation and the fix were served differently, the line reports the fix, and its `reason:` says how the investigation was served. + #### 1.1 Reproduce the bug Confirm the bug exists and understand its behavior. Run the test, trigger the error, follow the reported steps, whatever matches the input. diff --git a/skills/ce-debug/references/pipeline-mode.md b/skills/ce-debug/references/pipeline-mode.md index 3de117248..4168d6164 100644 --- a/skills/ce-debug/references/pipeline-mode.md +++ b/skills/ce-debug/references/pipeline-mode.md @@ -42,7 +42,7 @@ Return each decision in the shared typed residual contract. Its `sources` enumer ## Structured return -The return in pipeline mode is machine-readable (the caller parses it): +The return in pipeline mode is machine-readable (the caller parses it). `model_role` is an optional key that no caller requires: it carries the `Model role debug:` line that `references/investigate.md` defines, as a string, and it is left out when no `model_roles` entry governed this run. ```json { @@ -51,6 +51,7 @@ The return in pipeline mode is machine-readable (the caller parses it): "root_cause": "", "changed_files": ["..."], "head_sha": "", + "model_role": "", "residuals": [ { "type": "needs-human", diff --git a/skills/ce-debug/references/return-to-caller.md b/skills/ce-debug/references/return-to-caller.md index a199ec479..9b1de5989 100644 --- a/skills/ce-debug/references/return-to-caller.md +++ b/skills/ce-debug/references/return-to-caller.md @@ -17,7 +17,7 @@ Phases 0 through 3 run as the body defines them, with the investigation rigor un ## Structured return -The return is machine-readable; the caller parses it and branches on the exact `status` spellings, so never rename, abbreviate, or add to them. +The return is machine-readable; the caller parses it and branches on the exact `status` spellings, so never rename, abbreviate, or add to them. The keys are fixed in the same way. `model_role` is the one optional key: it carries the `Model role debug:` line that `references/investigate.md` defines, as a string, and it is left out when no `model_roles` entry governed this run. ```json { @@ -45,7 +45,8 @@ The return is machine-readable; the caller parses it and branches on the exact ` "residuals": [ { "type": "needs-human", "sources": [ ... ], "decision_context": { ... }, "thread_urls": [] } ], "issue_of_record": { "id": "", "url": "" }, "blockers": [], - "standalone_shipping_skipped": true + "standalone_shipping_skipped": true, + "model_role": "" } ``` diff --git a/skills/lfg/references/debug-return.md b/skills/lfg/references/debug-return.md index 288ca840a..10dccf95c 100644 --- a/skills/lfg/references/debug-return.md +++ b/skills/lfg/references/debug-return.md @@ -10,6 +10,8 @@ Only `status: fixed` advances to step 3. `diagnosed-no-fix`, `needs-human`, and Require `status`, `root_cause`, `changed_files`, `head_sha`, `branch`, `pre_fix_scope`, `verification_evidence`, `residuals`, `issue_of_record`, `behavior_change`, and `standalone_shipping_skipped: true`. Empty arrays are valid for `residuals`; `issue_of_record` is `null` when the input carried no ticket. +`model_role` is optional. When the return carries it, it is `ce-debug`'s `Model role debug:` line for this run: relay it unchanged in the close-out. A return without it is complete, and its absence never stops the run. + `verification_evidence` follows the same shape `ce-work` returns: when `behavior_change: true` it must name the regression test used, existing tests inspected, tests added/changed or used unchanged, the red failure or characterization observed before the fix, the verification run, and any deliberate test exception. Do NOT decide the test strategy inside LFG; the evidence is `ce-debug`'s contract. A `fixed` return with `behavior_change: true` and evidence missing or too vague to tell how the fix was proven stops the run as blocked, reporting the missing fields. There is no recovery invocation on this route: `ce-debug` has no reconciliation path, and a second run would reinvestigate. ## Ship only what the user offered diff --git a/tests/skills/ce-debug-model-role.test.ts b/tests/skills/ce-debug-model-role.test.ts new file mode 100644 index 000000000..ff2185fcb --- /dev/null +++ b/tests/skills/ce-debug-model-role.test.ts @@ -0,0 +1,60 @@ +import { spawnSync } from "child_process" +import { readFileSync } from "fs" +import path from "path" +import { describe, expect, test } from "bun:test" + +// The `debug` model role (model role map, U8) moves the investigation and the +// test-first fix to native subagents while the session keeps every gate. These +// pins hold the tokens and paths that wire the role in; the hand-off behavior +// itself is prose and is covered by fresh-agent evals, not here. + +const read = (relativePath: string) => readFileSync(path.join(process.cwd(), relativePath), "utf8") + +const investigate = read("skills/ce-debug/references/investigate.md") +const fix = read("skills/ce-debug/references/fix.md") +const returnToCaller = read("skills/ce-debug/references/return-to-caller.md") +const pipelineMode = read("skills/ce-debug/references/pipeline-mode.md") + +describe("ce-debug model role wiring", () => { + test("the investigation and fix references each resolve the debug role through the shared reference", () => { + for (const [name, body] of [["investigate.md", investigate], ["fix.md", fix]] as const) { + expect(body, `${name} must cite the shared contract`).toContain("references/model-roles.md") + expect(body, `${name} must name the role`).toContain("`debug` role") + } + }) + + test("the investigation hand-off is read-only", () => { + const start = investigate.indexOf("**Investigation hand-off.**") + expect(start).toBeGreaterThan(-1) + const block = investigate.slice(start, investigate.indexOf("#### 1.1 Reproduce the bug")) + expect(block).toContain("read-only") + expect(block).toContain("edits no file") + }) + + test("the fix hand-off leaves the pre-fix scope record and the commit with the session", () => { + const start = fix.indexOf("**Fix hand-off.**") + expect(start).toBeGreaterThan(-1) + const block = fix.slice(start, fix.indexOf("**Test-first:**")) + expect(block).toMatch(/the pre-fix scope record[^.]*the commit stay with the session/) + }) + + test("both structured returns carry the report as the optional model_role key", () => { + for (const body of [returnToCaller, pipelineMode]) { + expect(body).toContain('"model_role":') + expect(body).toMatch(/`model_role`[^.]*optional/) + } + }) +}) + +// Both bodies sit at or over the prompt budget, so the role is wired through +// references only. A body edit made alongside this work shows up here before it +// is committed. +describe("the debug role adds nothing to a SKILL.md body", () => { + for (const body of ["skills/ce-debug/SKILL.md", "skills/lfg/SKILL.md"]) { + test(`${body} is byte-identical to HEAD`, () => { + const committed = spawnSync("git", ["show", `HEAD:${body}`]) + expect(committed.status).toBe(0) + expect(readFileSync(path.join(process.cwd(), body)).equals(committed.stdout)).toBe(true) + }) + } +}) diff --git a/tests/skills/lfg-intake-contract.test.ts b/tests/skills/lfg-intake-contract.test.ts index 8856e02f8..ac61b64a4 100644 --- a/tests/skills/lfg-intake-contract.test.ts +++ b/tests/skills/lfg-intake-contract.test.ts @@ -130,6 +130,19 @@ describe("ce-debug return-to-caller seam (ce-debug <-> lfg)", () => { } }) + // Model role map (U8): the return may carry the `Model role debug:` line in one + // optional key. The required set above is unchanged, so this pins only that the + // key is optional at the producer and that lfg's gate never requires it. + test("model_role is an optional report field on both ends", () => { + expect(DEBUG_RETURN_FIELDS).not.toContain("model_role") + expect(debugReturn).toMatch(/"model_role":/) + expect(debugReturn).toMatch(/`model_role` is the one optional key/) + const required = debugReturnGate.match(/Require `status`[^.]*\./)?.[0] ?? "" + expect(required).toContain("`standalone_shipping_skipped: true`") + expect(required).not.toContain("model_role") + expect(debugReturnGate).toMatch(/`model_role` is optional/) + }) + test("the debug gate requires the same evidence facts the ce-work gate requires", () => { for (const phrase of [ "existing tests inspected", From e3b082e9350dc320ddec3badf55ce03b60c33111 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:45:08 -0700 Subject: [PATCH 07/53] feat(model-roles): honor simplify and compound entries ce-simplify-code hands its apply-and-verify steps to one subagent on the named model; the three reviewers keep their tier. ce-compound has a subagent draft the body into scratch from a hand-off file, while the orchestrator still classifies and remains the only writer under the artifact root. Both fall back to the session and say so, and print the Model role line. Co-Authored-By: Claude Code --- skills/ce-compound/references/assembly.md | 2 +- skills/ce-compound/references/lightweight.md | 2 + skills/ce-compound/references/report.md | 2 + skills/ce-compound/references/research.md | 13 +- skills/ce-simplify-code/SKILL.md | 4 + .../references/model-role-handoff.md | 22 +++ .../skills/model-role-native-handoff.test.ts | 130 ++++++++++++++++++ 7 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 skills/ce-simplify-code/references/model-role-handoff.md create mode 100644 tests/skills/model-role-native-handoff.test.ts diff --git a/skills/ce-compound/references/assembly.md b/skills/ce-compound/references/assembly.md index 4c03e11ac..c1227dee5 100644 --- a/skills/ce-compound/references/assembly.md +++ b/skills/ce-compound/references/assembly.md @@ -8,7 +8,7 @@ The orchestrating agent (main conversation) performs these steps: -1. **Collect Phase 1 results from the run artifacts.** For each Phase 1 subagent, `Read` its artifact file under `{run_dir}/` (`related.json`, and `session-history.md` when session history ran). The artifact holds the subagent's full output. **Fall back to the subagent's inline return only when its artifact file is absent or empty** (e.g., `{run_id}` did not resolve, or the subagent failed to write). The artifact is authoritative when present. Reading it is what keeps the workflow working through the issue #956 summary-collapse, where the inline return is only an executive summary. +1. **Collect Phase 1 results from the run artifacts.** For each Phase 1 subagent, `Read` its artifact file under `{run_dir}/` (`related.json`, and `session-history.md` when session history ran). The artifact holds the subagent's full output. **Fall back to the subagent's inline return only when its artifact file is absent or empty** (e.g., `{run_id}` did not resolve, or the subagent failed to write). The artifact is authoritative when present. Reading it is what keeps the workflow working through the issue #956 summary-collapse, where the inline return is only an executive summary. When step 2 of `references/research.md` handed the body draft to the `compound` role, `{run_dir}/draft.md` is your body draft: read it here under the same fallback rule, and apply the steps below to it as you would to a draft of your own. The drafter wrote only that scratch file. You remain the only writer under `/`. 2. **Check the overlap assessment** from the Related Docs Finder before deciding what to write: | Overlap | Action | diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index 0ca59ea5d..e2ca851c9 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,6 +10,8 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before writing the doc. With no such key, skip this and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. + The orchestrator (main conversation) performs ALL of the following in one sequential pass: 1. **Extract from conversation**: Identify the problem and solution from conversation history. Also scan the "user's auto-memory" block injected into your system prompt, if present (Claude Code only). Use any relevant notes as supplementary context alongside conversation history. Tag any memory-sourced content incorporated into the final doc with "(auto memory [claude])". Before asserting how code behaves (enum values, status semantics, limits, defaults), Read the defining line at the current tree. Soften or attribute any claim you cannot verify. Cite PR numbers over bare commit SHAs, and phrase unmerged fixes as pending diff --git a/skills/ce-compound/references/report.md b/skills/ce-compound/references/report.md index 729ccb7c2..f43fb3cc4 100644 --- a/skills/ce-compound/references/report.md +++ b/skills/ce-compound/references/report.md @@ -4,6 +4,8 @@ **User-runnable refresh rendering.** The reports below print a `ce-compound-refresh` invocation for the user to copy. Default to `/ce-compound-refresh `; use `$ce-compound-refresh ` only when the active host is Codex or explicitly documents dollar-prefixed skill invocation. Render only the invocation as inline code and output one form only. +**Model role line.** When the `compound` role resolved to `inherit` or `entries` this run, add its `Model role` line, in the format `references/model-roles.md` gives, as the last field line of whichever report below applies. In a non-interactive report that places it above the terminal line: `Documentation complete` or `Documentation skipped` stays the last line, unchanged, because callers parse it. When the role was never resolved, the reports are exactly the blocks below. + ### Non-interactive mode Emit a structured terminal report as the last thing this skill writes. No "What's next?" question, no blocking prompt. The report ends this skill, not the turn. When a caller invoked this skill, its next step follows the report in the same session; otherwise end the turn. End with `Documentation complete` as the terminal signal so callers can detect completion. diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index 123747b6c..f99b757b8 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -25,7 +25,7 @@ If no relevant entries are found, proceed to Phase 1 without memory context. ### Phase 1: Research -You classify the learning and draft its body in this context, because both depend on the conversation, which only this context holds. A fresh subagent sees only what you paste into its prompt. The Related Docs Finder runs as a subagent alongside, because its corpus search reads many docs this context does not need to keep. Each Phase 1 subagent writes its full output to a per-run scratch artifact and returns only the artifact path to the orchestrator. +You classify the learning and draft its body in this context, because both depend on the conversation, which only this context holds. A fresh subagent sees only what you paste into its prompt. The one exception is a `compound` model role hand-off, which step 2 describes: a subagent drafts the body from a hand-off file you write. The Related Docs Finder runs as a subagent alongside, because its corpus search reads many docs this context does not need to keep. Each Phase 1 subagent writes its full output to a per-run scratch artifact and returns only the artifact path to the orchestrator. **Run ID and run dir (before dispatching the finder):** generate a unique run identifier and create the run directory. This scopes every Phase 1 artifact file to the same directory so the orchestrator can Read them back in Phase 2. @@ -46,10 +46,11 @@ echo "$RUN_DIR"; **CRITICAL: glob `/solutions/` fresh every run.** The current vocabulary and conventions above do not substitute for the live-tree search in step 3. -Pass `{run_id}` and the resolved absolute `{run_dir}` into every Phase 1 subagent prompt: the Related Docs Finder, and the session-history synthesis subagent when it runs. Each subagent **writes its full structured output** to its own file under `{run_dir}/`, **confirms the write succeeded** (the file exists and is non-empty), and then **returns only a one-line confirmation containing the artifact path**, not the prose body inline. Artifact filenames by subagent: +Pass `{run_id}` and the resolved absolute `{run_dir}` into every Phase 1 subagent prompt: the Related Docs Finder, the session-history synthesis subagent when it runs, and the Body Drafter when step 2 hands the draft off. Each subagent **writes its full structured output** to its own file under `{run_dir}/`, **confirms the write succeeded** (the file exists and is non-empty), and then **returns only a one-line confirmation containing the artifact path**, not the prose body inline. Artifact filenames by subagent: - **Related Docs Finder** → `{run_dir}/related.json` (links, refresh candidates, overlap assessment) - **Session History** synthesis subagent (when run) → `{run_dir}/session-history.md` (prose findings) +- **Body Drafter** (only on a `compound` role hand-off) → `{run_dir}/draft.md` (the doc body) **Return the full output inline whenever the artifact write did not succeed.** This covers both cases where the orchestrator's Phase 2 inline fallback would otherwise have nothing to read: (a) `{run_id}` is empty or did not resolve (non-Claude-Code platforms where the pre-resolution failed), so there is no path to write to; and (b) `{run_id}` resolved but the write itself failed (tool permission denied, absolute-path writes unavailable, disk error, or the post-write existence check came back empty). In either case the subagent must return its complete structured output inline instead of a path, because the path would point at a file that does not exist. Return only the bare path when, and only when, the write is confirmed on disk. The artifact pattern is a reliability improvement, not a hard requirement; the orchestrator handles a missing artifact in Phase 2 by using the inline return. @@ -86,6 +87,8 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - Does not force bug-track fields onto knowledge-track learnings or vice versa #### 2. **Draft the body** (this context) + **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before drafting the body. With no such key, skip this and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. + - Adapts output structure to the track chosen in step 1 - **Drafts the full doc-body prose** (all track-appropriate sections below) from the conversation and the verified fix. The failed attempts and the reasoning behind the fix are what a later reader cannot recover from the code, so they come from what happened in this session, not from a recap of the final diff. - Uses auto memory excerpts from Phase 0.5 as supplementary evidence. Conversation history and the verified fix take priority; if memory notes contradict the conversation, note the contradiction as cautionary context @@ -109,6 +112,12 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - **When to Apply**: Conditions or situations where this applies - **Examples**: Concrete before/after or usage examples showing the practice in action + **Body hand-off.** This applies only when `references/model-roles.md` calls for a hand-off of the `compound` role. A native subagent on the entry's model is the only hand-off route. When the host's subagent primitive takes a model but no effort, an entry that has an effort is served through that reference's effort-not-applied rung. You still classify in this context, and you remain the only writer of product files. + + - **Hand-off file.** Write `{run_dir}/draft-handoff.md` with what the draft needs and only this conversation holds: the problem and its symptoms, each thing that was tried and failed and why it failed, the fix, why the fix works, how it was verified, the Phase 0.5 auto memory excerpt when there is one, and step 1's classification with its frontmatter skeleton and track. Write these in full. The drafter cannot see this conversation, so whatever the file leaves out is lost to the doc. + - **Dispatch.** Launch one **Body Drafter** subagent on the entry's model once the hand-off file is written and the Related Docs Finder and session history have finished, because it reads their artifacts. Give it file paths to read rather than a retelling of their contents: the hand-off file, and whichever of `related.json` and `session-history.md` exist. Its prompt also carries this step's drafting rules and the chosen track's output sections, verbatim. The drafter writes the body to `{run_dir}/draft.md` and nothing else, under the Phase 1 artifact contract above, and returns only that path. The draft is still a Phase 1 input, so assembly waits for it. + - **Fallback.** When the host cannot hand the entry's model to a subagent, or no usable draft comes back once the rejected-dispatch rule above has been applied, you draft the body in this context as this step describes. The `Model role` line then names the session as the route and gives the reason. + #### 3. **Related Docs Finder** (subagent) - Searches `/solutions/` for related documentation - Identifies cross-references and links diff --git a/skills/ce-simplify-code/SKILL.md b/skills/ce-simplify-code/SKILL.md index 08247ef4d..7c257c40e 100644 --- a/skills/ce-simplify-code/SKILL.md +++ b/skills/ce-simplify-code/SKILL.md @@ -41,6 +41,8 @@ Do not paraphrase these rubrics from memory. Read each file and pass it verbatim ## Step 3: Fix issues +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `simplify` role before applying any finding. With no such key, skip this and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. + Proceed only after all three review outcomes are complete, whether returned by subagents or produced inline. Apply worthwhile findings directly; record false positives and low-value findings as skipped without asking the user. Inspect beyond the resolved scope when needed to evaluate a finding, but edit only that scope and the import/export lines it needs. For a user-named file or directory scope, those import/export lines must also be inside it; skip any fix that would edit outside the mutation boundary. @@ -64,3 +66,5 @@ If no test suite, lint, or typecheck is configured, state that explicitly in the ## Step 5: Summarize Summarize what was already sound and what improved. Report applied counts by reuse, quality, and efficiency; skipped count; and check outcomes. If nothing changed, say so. Do not use net lines removed as the success metric. + +When the `simplify` role resolved to `inherit` or `entries`, end the summary with its `Model role` line. diff --git a/skills/ce-simplify-code/references/model-role-handoff.md b/skills/ce-simplify-code/references/model-role-handoff.md new file mode 100644 index 000000000..a29c3e303 --- /dev/null +++ b/skills/ce-simplify-code/references/model-role-handoff.md @@ -0,0 +1,22 @@ +# Model role hand-off for apply and verify + +Read this when the `simplify` role resolved to `inherit` or `entries`. `references/model-roles.md` decides whether a hand-off happens, which route serves it, what the fallback is, and how the `Model role` line reads. This file adds only what is specific to this skill. + +**What the role governs.** The deliverable is the applied simplification edits and their verification, which is Step 3 and Step 4 together. The session keeps scope resolution, the three reviewer dispatches, and the Step 5 summary. The three reviewers keep their Model tier from Step 2. The entry does not change them. + +**Route.** This skill has no peer route, so a native subagent is the only hand-off route. When the host's subagent primitive takes a model but no effort, an entry that has an effort is served through the fallback ladder's effort-not-applied rung. + +**Hand-off.** When a hand-off is called for and the host can hand the entry's model to a subagent, dispatch one subagent on that model once all three review outcomes are complete. The session edits nothing while that subagent runs. Step 2's permission-mode and agent-lifecycle rules apply to this dispatch as well. The subagent's prompt carries: + +- the three reviewers' findings, as they were returned +- the resolved scope, and the mutation boundary Step 3 draws around it +- any structure constraint the caller passed, with its plan path +- the text of Step 3 and Step 4 from `SKILL.md`, verbatim and without the model role paragraph, as the rules it works under +- the instruction to perform both steps itself, inside that boundary, and to dispatch no further agent +- the return it owes: the findings it applied, grouped as reuse, quality, and efficiency; the findings it skipped; the edits it reverted; and each check it ran, with its outcome + +The session writes the Step 5 summary from that return. + +**Inline fallback.** When the host cannot hand the entry's model to a subagent, or the dispatch fails before the subagent has edited anything, the session performs Steps 3 and 4 itself, as it does when there is no map. The `Model role` line then names the session as the route and gives the reason. + +**Mid-edit failure.** When the subagent fails or stops after it has started editing, the working tree is the record of what it did. The session finishes inline from the working tree. It reads the current files and treats each finding as applied or not by what they now show. It completes Step 3 for the findings that are left, then runs Step 4 again over the whole result. The `Model role` line reports the fallback and its reason. diff --git a/tests/skills/model-role-native-handoff.test.ts b/tests/skills/model-role-native-handoff.test.ts new file mode 100644 index 000000000..24fad5c4c --- /dev/null +++ b/tests/skills/model-role-native-handoff.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test" +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" + +// The `simplify` and `compound` model roles are served by a native subagent only: +// neither skill has a peer CLI route. These pins cover what a role entry must not +// break in each skill: the hook sits at the step that produces the deliverable, the +// session keeps the work the entry does not govern, a failed hand-off lands back on +// the session, and ce-compound's write boundary and caller-parsed terminal lines +// are untouched. Whether a model actually dispatches is a behavioral eval, not this +// file. Pins are tokens and paths; the sentences around them are free to change. +const repoRoot = path.join(import.meta.dir, "..", "..") +const skillPath = (...parts: string[]) => path.join(repoRoot, "skills", ...parts) +const read = (...parts: string[]) => readFileSync(skillPath(...parts), "utf8") + +function section(source: string, start: string, end?: string): string { + const from = source.indexOf(start) + expect(from, `missing section start: ${start}`).toBeGreaterThan(-1) + const to = end === undefined ? source.length : source.indexOf(end, from + start.length) + expect(to, `missing section end: ${end}`).toBeGreaterThan(from) + return source.slice(from, to) +} + +/** The shared hook: active-key condition, the shared reference, and the role, in one paragraph. */ +const hookFor = (role: string) => + new RegExp( + `\\*\\*Model role\\.\\*\\*[^\\n]*\`model_roles:\`[^\\n]*\`references/model-roles\\.md\`[^\\n]*\`${role}\` role`, + ) + +/** Byte size as a Windows checkout with CRLF line endings would inject it (tests/codex-skill-prompt-budget.test.ts). */ +function crlfByteSize(contents: string): number { + const lf = contents.replace(/\r\n/g, "\n") + return Buffer.byteLength(lf, "utf8") + (lf.match(/\n/g)?.length ?? 0) +} + +/** Bodies of the fenced blocks in a Markdown file, without the fence lines. */ +function fencedBlocks(source: string): string[] { + return [...source.matchAll(/^```[^\n]*\n([\s\S]*?)\n```$/gm)].map((match) => match[1]) +} + +describe("ce-simplify-code `simplify` role hand-off", () => { + const body = read("ce-simplify-code", "SKILL.md") + const applyStep = section(body, "## Step 3: Fix issues", "## Step 4: Verify behavior is preserved") + const HANDOFF = "references/model-role-handoff.md" + + test("resolves the role at the apply step, from the shared reference", () => { + expect(applyStep).toMatch(hookFor("simplify")) + expect(applyStep).toContain("print nothing about model roles") + }) + + test("names its hand-off reference at the apply step, and the file exists", () => { + expect(applyStep).toContain(`\`${HANDOFF}\``) + expect(existsSync(skillPath("ce-simplify-code", HANDOFF))).toBe(true) + }) + + test("prints the `Model role` line in the Step 5 summary", () => { + expect(section(body, "## Step 5: Summarize")).toContain("`Model role` line") + }) + + test("the body keeps about 200 bytes of room under the 8,000-byte prompt bound", () => { + expect(crlfByteSize(body)).toBeLessThanOrEqual(8_000 - 200) + }) + + test("the reviewers keep their tier, and a failed hand-off lands on the session", () => { + const handoff = read("ce-simplify-code", HANDOFF) + expect(handoff).toContain("`references/model-roles.md`") + // R9: the entry moves the apply and verify work, never the three reviewers. + expect(handoff).toMatch(/reviewers[^\n]*keep their Model tier/) + const fallback = section(handoff, "**Inline fallback.**", "**Mid-edit failure.**") + expect(fallback).toMatch(/session[^\n]*Steps 3 and 4/) + // KTD11: a hand-off that dies after editing is finished from the working tree and re-verified. + const midEdit = section(handoff, "**Mid-edit failure.**") + expect(midEdit).toContain("working tree") + expect(midEdit).toMatch(/Step 4[^\n]*again/) + }) +}) + +describe("ce-compound `compound` role hand-off", () => { + const research = read("ce-compound", "references", "research.md") + const assembly = read("ce-compound", "references", "assembly.md") + const report = read("ce-compound", "references", "report.md") + const lightweight = read("ce-compound", "references", "lightweight.md") + + test("resolves the role where the body is drafted, from the shared reference", () => { + const draftStep = section( + research, + "#### 2. **Draft the body** (this context)", + "#### 3. **Related Docs Finder** (subagent)", + ) + expect(draftStep).toMatch(hookFor("compound")) + expect(draftStep).toContain("print nothing about model roles") + // Files, not re-narrated prose: the facts go in, and the draft comes back, as run-scratch files. + expect(draftStep).toContain("{run_dir}/draft-handoff.md") + expect(draftStep).toContain("{run_dir}/draft.md") + expect(draftStep).toMatch(/you draft the body in this context/) + }) + + test("the orchestrator stays the only writer under the artifact root", () => { + const phase2 = section(assembly, "### Phase 2: Assembly & Write", "### Phase 2.4: Vocabulary Capture") + expect(phase2).toContain("The orchestrating agent (main conversation) performs these steps") + expect(phase2).toContain("{run_dir}/draft.md") + expect(phase2).toMatch(/only writer under `\/`/) + }) + + test("the report keeps each terminal line last, and its templates carry no role line", () => { + const terminal = /^Documentation (?:complete|skipped)$/m + const parsed = fencedBlocks(report).filter((block) => terminal.test(block)) + expect(parsed).toHaveLength(3) + for (const block of parsed) { + expect(block).toMatch(/\n\nDocumentation (?:complete|skipped)$/) + } + // The line is placed by one rule in prose. A template line would print on runs with no map. + expect(report).toContain("`Model role` line") + for (const block of [...fencedBlocks(report), ...fencedBlocks(lightweight)]) { + expect(block).not.toContain("Model role") + } + }) + + test("Lightweight resolves the role but never hands off", () => { + expect(lightweight).toMatch(hookFor("compound")) + expect(lightweight).toMatch(/Lightweight never hands[^\n]*off/) + expect(lightweight).toContain("No subagents are launched.") + }) + + test("the always-loaded body gains no role prose", () => { + // ce-compound's SKILL.md has 20 bytes of room under the prompt bound, so the role + // lives in the references its steps already load. + expect(read("ce-compound", "SKILL.md")).not.toMatch(/model[-_ ]roles?/i) + }) +}) From 9873aa1d50e3e6d0327b5b014603d779aaa77c86 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:47:13 -0700 Subject: [PATCH 08/53] feat(ce-work): honor the work model role A work entry is resolved before the work_engine_* keys and becomes the only standing candidate: served by native subagents when the host can hand over the model and effort, otherwise through the engine route for its family at prefer. An entry with an effort is never collapsed to native, a personal work_engine_mode: off keeps a team entry off other models, and the fallback ladder steps effort down through the levels the adapter accepts. No return field is added. Co-Authored-By: Claude Code --- .../references/cross-model-execution.md | 2 + .../ce-work/references/execution-engines.md | 35 ++++++++++ .../skills/ce-work-cross-model-routes.test.ts | 67 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/skills/ce-work/references/cross-model-execution.md b/skills/ce-work/references/cross-model-execution.md index 8296011d8..75cbe218d 100644 --- a/skills/ce-work/references/cross-model-execution.md +++ b/skills/ce-work/references/cross-model-execution.md @@ -16,6 +16,8 @@ Use only these targets: `codex`, `claude`, `grok`, `cursor`, `composer`, and `op When a standing preference lists candidates in order, preflight them in that order without sending anything out. Skip a candidate only when its harness and requested/default model are equivalent to the current host, or when observed evidence shows it is unavailable. Attempt the documented adapter recipe first. Local CLI help or version information may refine a compatible mapping, but only inside the same sanctioned harness/model family and only if the fixed adapter still enforces every restriction. An explicit model pin cannot become another model. When `work_engine_effort` requests an effort for the candidate's harness, preflight also asks the adapter whether that route can run at it. The effort is data, never shell syntax, the same as a model selector: only a plain token that starts with a letter or digit and continues with letters, digits, `-`, or `_` may appear in a command, and any other value makes the candidate unavailable without running anything. Ask with this skill's bundled script by its absolute path under the skill directory, because it is not on `PATH`: `CROSS_MODEL_EFFORT_OVERRIDE='' "/scripts/cross-model-work.sh" --emit-adapter `. Exit 2 naming the effort means the route cannot run at it, and the candidate is unavailable with that reason. A script that cannot be found or run is a broken preflight to report, not evidence about the route. The first qualified candidate becomes the one fixed recipient. +A candidate that came from a `model_roles` entry, as `execution-engines.md` defines it, differs from the rules above as follows. When its entry carries an effort, it is never skipped as equivalent to the current host and never collapsed to native execution. A refusal moves it down the fallback ladder instead of making it unavailable: when the adapter exits 2 for the entry's effort, ask again at each lower level of the resolver's `effort_scale`, nearest first, and use the first level it accepts. When it accepts no level, the route has no effort control, and the candidate runs on the entry's model with no effort. When the route cannot serve the entry's model, the candidate may run on the harness's configured default model. Record what the route is asked to run, not what the entry asked for: the egress `effort` is the accepted level and is absent when none applies, and the binding's `model` is the model the route will run. `requested_model` and `requested_effort` in the receipts keep the entry's values, and `fallback_reason` names each difference. + Before egress, you may keep moving down the list after a candidate is proven unavailable. After dispatch starts, the adapter receives one fixed recipient and must never switch recipients, providers, or intermediaries internally. A different recipient requires a separately resolved and recorded attempt, and only after the first attempt reaches an authoritative terminal or reaped state; it is never an in-flight fallback. If a target asks for the same-host default with no distinct serving route or model, collapse to native execution. Record the target as requested and native as actual; do not create an external job merely to call the current host's default model through itself. diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index e008ae8fd..73ae9fb00 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -47,6 +47,10 @@ When the target resolves to the current host's default execution route and no di - **Do not** use this rule for `docs_root` — that key is `config.yaml` only. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `work` role before reading the `work_engine_*` keys below. With no such key, skip this and print nothing about model roles. + +The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. `references/model-roles.md` owns the resolver command, what each `state` means, precedence, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. "A `work` role entry" below adds only what is specific to this skill. + Standing configuration uses one mode plus an ordered route list. Resolve `work_engine_mode` and `work_engine_preferences` independently from the two repo files (`config.local.yaml` then `config.yaml`); a present local list, including `[]`, replaces the team list. Do not pick one file for the whole group. ```yaml @@ -78,6 +82,37 @@ Traverse each ordered candidate during preflight. If a candidate is equivalent t `off` disables only the standing preference. It does not cancel applicable live intent or a typed caller binding. An enabled mode without a valid candidate list is unavailable rather than guessed. When the list is exhausted, both `prefer` and `require` disclose every attempted route and reason once, then continue natively on the current harness and session model. A required route is never replaced by another unrequested external recipient. Standing configuration supplies defaults, not permission to change recipient or broaden authority. +### A `work` role entry + +This subsection applies when the resolver reports `inherit` or `entries` for the `work` role. In every other state, routing uses the keys above as it does with no map. + +**Precedence.** The entry sits at the per-checkout configuration step of the resolution order. Every source above that step, including live intent and a typed caller binding, outranks the entry and replaces it whole, effort included. Routing then resolves as it does with no map. While the entry governs the run, `work_engine_mode`, `work_engine_preferences`, and `work_engine_effort` are not consulted. + +**`inherit`.** The run uses native execution on the session model. + +**Personal opt-out.** When the resolver reports `engine_opt_out: true`, the entry is not handed to any other model: the work runs natively on the session model, and the `Model role` line gives the personal `work_engine_mode: off` as the reason. The resolver reports this only for a team-file entry, so a personal-file entry is unaffected. + +**Serving the entry.** The serving order in `references/model-roles.md` maps onto this skill's engines in this way: + +- **No hand-off.** An entry with no effort that names the model the session attests it is on collapses to native execution. +- **Native subagent.** When the host's subagent primitive can be handed the entry's model at the entry's effort, the inline/subagent engine serves the entry: every unit is dispatched to a subagent on that model as `references/execution-strategy.md` describes, no unit runs inline, and no external candidate is formed. +- **Peer route.** Otherwise the entry becomes the only standing candidate, at `prefer`. The candidate is the resolver's `harness`, the entry's `model`, and the entry's `effort` as the effort requested for it. Normalize and preflight it like any other candidate. + +A candidate from an entry that carries an effort is never equivalent to the current host. It is not skipped as a route to self, and it does not collapse to native execution. It runs through the host's own CLI route at that effort: `opus medium` in a Claude Code session already on Opus runs through the `claude` route at `medium`. The equivalence and collapse rules elsewhere in this file keep governing candidates from every other source. + +A null `harness` means the resolver could not place the model in a family, so no candidate can be formed from the entry. Unless a native subagent serves it, the run ends on native execution on the session model, and the `Model role` line says that the model's family has no engine route. + +**Fallback.** A candidate from the entry follows the fallback ladder in `references/model-roles.md`, not the unavailable-candidate rules above. Those rules, including that nothing runs at a different effort than the one requested, keep governing `work_engine_effort`. These facts connect the ladder to this skill's routes: + +- The adapter script says which effort levels a route accepts. `references/cross-model-execution.md` gives the question to ask it at preflight. +- A route for which the adapter accepts no level has no effort control. The Cursor routes are such routes today. The candidate runs there on the entry's model with no effort requested. +- The route's default model is the same candidate with no `model`. +- When the ladder ends on the session model, execution is native. + +Walk the ladder at preflight, before any work is sent. After dispatch starts, the recipient lock and the single fallback claim in `references/cross-model-execution.md` apply unchanged. + +**Report.** Print the `Model role` line with the run's closing summary. In Return-to-Caller Mode, print it immediately before the structured return, which gains no field for it. The existing fields carry the same facts. `requested_model` holds the model the entry names. `requested_effort` holds the entry's effort whenever an external route ran, even when that route ran at a lower effort or with none applied. `fallback_reason` names a step-down, an effort that was not applied, a default-model substitution, the personal opt-out, or a family with no engine route. The binding's `source` names the `model_roles` entry and the file it came from. + ## Step 1: Probe host capability An engine is usable only when the host exposes a callable primitive for it. Do not assume one exists from its name. diff --git a/tests/skills/ce-work-cross-model-routes.test.ts b/tests/skills/ce-work-cross-model-routes.test.ts index 2f2283615..abce46f22 100644 --- a/tests/skills/ce-work-cross-model-routes.test.ts +++ b/tests/skills/ce-work-cross-model-routes.test.ts @@ -782,6 +782,73 @@ printf '%02048d' 0 }) }) +describe("ce-work model role `work` entry", () => { + const RESOLVER = path.join(process.cwd(), "skills/ce-work/scripts/model-role-resolve.py") + const ENGINES = path.join(process.cwd(), "skills/ce-work/references/execution-engines.md") + + // The resolver reads the repository it runs in, so give it a throwaway one. + function resolveWork(entry: string) { + const repo = temp("ce-work-role-") + spawnSync("git", ["init", "-q", repo]) + mkdirSync(path.join(repo, ".compound-engineering")) + writeFileSync(path.join(repo, ".compound-engineering", "config.yaml"), `model_roles:\n work: ${entry}\n`) + const proc = spawnSync("python3", [RESOLVER, "--role", "work"], { cwd: repo, encoding: "utf8", env: cleanEnv() }) + expect(proc.status).toBe(0) + return JSON.parse(proc.stdout) as { entries: { model: string; effort: string; harness: string }[]; effort_scale: string[] } + } + + // The preflight question the reference tells the agent to ask: one route, one level, one answer. + const ask = (route: string, level: string) => emit(route, { ...cleanEnv(), CROSS_MODEL_EFFORT_OVERRIDE: level }) + const acceptedLevels = (route: string, scale: string[]) => scale.filter((level) => ask(route, level).status === 0) + + test("Covers AE6: the adapter answers per level, so a max entry steps down on a route that rejects max", () => { + const grok = resolveWork("grok-4.7 max") + expect(grok.entries[0]).toMatchObject({ model: "grok-4.7", effort: "max", harness: "grok" }) + const scale = grok.effort_scale + expect(scale).toEqual(["low", "medium", "high", "xhigh", "max"]) + + // Accepted levels are static per route. Native Grok is the route that rejects max and accepts high. + const refused = ask("grok-cli", "max") + expect(refused.status).toBe(2) + expect(refused.stderr).toContain("effort override 'max' not compatible with route 'grok-cli'") + const grokLevels = acceptedLevels("grok-cli", scale) + expect(grokLevels).toContain("high") + expect(grokLevels).not.toContain("max") + // The nearest lower level the route accepts is the one a max entry runs at. + expect(grokLevels.at(-1)).toBe("xhigh") + expect(ask("grok-cli", "xhigh").stdout).toContain("--effort xhigh") + + // The claude route accepts every level of the resolver's scale, so an `opus max` entry runs there as written. + expect(resolveWork("opus max").entries[0]).toMatchObject({ model: "opus", effort: "max", harness: "claude" }) + expect(acceptedLevels("claude", scale)).toEqual(scale) + }) + + test.each(["cursor", "composer", "grok-cursor"] as const)( + "the %s route has no effort control: the adapter accepts no level and still runs with none", + (route) => { + expect(acceptedLevels(route, ["low", "medium", "high", "xhigh", "max"])).toEqual([]) + const plain = emit(route, cleanEnv()) + expect(plain.status).toBe(0) + expect(plain.stdout).not.toContain("--effort") + }, + ) + + test("the per-checkout configuration step states how a `work` entry routes", () => { + const engines = readFileSync(ENGINES, "utf8") + // The hook names the shared contract and the role, in the reference the route-resolution gate already loads. + expect(engines).toMatch(/\*\*Model role\.\*\*[^\n]*`references\/model-roles\.md`[^\n]*`work` role/) + expect(existsSync(path.join(process.cwd(), "skills/ce-work/references/model-roles.md"))).toBe(true) + // R5: the entry replaces the existing keys for the run. + expect(engines).toContain("`work_engine_mode`, `work_engine_preferences`, and `work_engine_effort` are not consulted") + // KTD5: an entry with an effort always hands off, even to the host's own CLI route. + expect(engines).toContain("never equivalent to the current host") + // KTD4: the personal opt-out keeps a team entry off external engines. + expect(engines).toMatch(/`engine_opt_out: true`[^.]*\bnatively\b/) + // KTD4: live intent and the lfg carrier still win. + expect(engines).toMatch(/typed caller binding[^.]*\boutranks? the entry\b/) + }) +}) + describe("ce-work adapter results, identity, and secret handling", () => { test("packet bytes must match the controller-provided digest before egress", () => { const f = fixture() From 13d5a3f6cf76711462a05530581120057f5135a7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:47:57 -0700 Subject: [PATCH 09/53] fix(model-roles): state two rules the role skills relied on A step whose entry was replaced by a live instruction or carrier prints no Model role line, and a route that started and then failed goes to the fallback ladder the same way an unservable entry does. Both came up as ambiguities while wiring the plan and work roles. Co-Authored-By: Claude Code --- skills/ce-brainstorm/references/model-roles.md | 4 ++-- skills/ce-code-review/references/model-roles.md | 4 ++-- skills/ce-compound/references/model-roles.md | 4 ++-- skills/ce-debug/references/model-roles.md | 4 ++-- skills/ce-doc-review/references/model-roles.md | 4 ++-- skills/ce-plan/references/model-roles.md | 4 ++-- skills/ce-simplify-code/references/model-roles.md | 4 ++-- skills/ce-work/references/model-roles.md | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/skills/ce-brainstorm/references/model-roles.md b/skills/ce-brainstorm/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-brainstorm/references/model-roles.md +++ b/skills/ce-brainstorm/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-code-review/references/model-roles.md b/skills/ce-code-review/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-code-review/references/model-roles.md +++ b/skills/ce-code-review/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-compound/references/model-roles.md b/skills/ce-compound/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-compound/references/model-roles.md +++ b/skills/ce-compound/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-debug/references/model-roles.md b/skills/ce-debug/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-debug/references/model-roles.md +++ b/skills/ce-debug/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-doc-review/references/model-roles.md b/skills/ce-doc-review/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-doc-review/references/model-roles.md +++ b/skills/ce-doc-review/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-plan/references/model-roles.md b/skills/ce-plan/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-plan/references/model-roles.md +++ b/skills/ce-plan/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-simplify-code/references/model-roles.md b/skills/ce-simplify-code/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-simplify-code/references/model-roles.md +++ b/skills/ce-simplify-code/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. diff --git a/skills/ce-work/references/model-roles.md b/skills/ce-work/references/model-roles.md index d3fd98463..b3eb4b3e0 100644 --- a/skills/ce-work/references/model-roles.md +++ b/skills/ce-work/references/model-roles.md @@ -41,7 +41,7 @@ The first source that exists decides the model for the step: 4. The skill's existing key for this step. 5. The session model. -The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. A map entry in either config file outranks an existing key in either file. +The calling skill's existing rules decide what counts as a live instruction or a carrier. Either one replaces the entry whole, including its effort, and the skill then handles it as it does with no map. That step prints no `Model role` line. A map entry in either config file outranks an existing key in either file. ## What the entry governs @@ -66,7 +66,7 @@ A hand-off uses the first route that can serve the entry's model at the entry's ## Fallback for a single-model role -When no route can serve the entry as written, the step takes the first of these that a route can serve: +When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: 1. The same model at the nearest lower effort the route accepts. 2. The same model with the effort not applied, on a route that has no effort control. A native subagent on a host that cannot set effort is such a route. From 0f53a992a998674f832efd754380026520c948f6 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:54:10 -0700 Subject: [PATCH 10/53] feat(lfg): relay Model role lines and document each role lfg resolves no role itself: each child reads the map. When a child prints a Model role line, lfg carries it unchanged into the step narration and the closing recap. Each role skill guide gains a short Model role section linking to the configuration guide. Co-Authored-By: Claude Code --- docs/guides/ce-brainstorm.md | 6 ++++++ docs/guides/ce-code-review.md | 6 ++++++ docs/guides/ce-compound.md | 6 ++++++ docs/guides/ce-debug.md | 6 ++++++ docs/guides/ce-doc-review.md | 6 ++++++ docs/guides/ce-plan.md | 6 ++++++ docs/guides/ce-simplify-code.md | 8 ++++++++ docs/guides/ce-work.md | 6 ++++++ docs/guides/lfg.md | 2 ++ skills/lfg/references/task-visibility.md | 2 ++ tests/skills/task-visibility-contract.test.ts | 6 ++++++ 11 files changed, 60 insertions(+) diff --git a/docs/guides/ce-brainstorm.md b/docs/guides/ce-brainstorm.md index 80d2d2ce6..1893eda79 100644 --- a/docs/guides/ce-brainstorm.md +++ b/docs/guides/ce-brainstorm.md @@ -248,6 +248,12 @@ When you want a specific model for the heavy reasoning step, `ce-brainstorm` can This works on any harness. The host serves the chosen model natively where it can, otherwise it invokes the Claude CLI (which must be installed and authenticated), otherwise it runs the step on your session model and says which precondition was unmet. +## Model role + +`ce-brainstorm` reads the `brainstorm` role from the `model_roles` map in CE config. The entry names the model, and optionally the reasoning effort, that generates the approaches. That is the same step model elevation dispatches, and the entry takes the place of `brainstorm_model`. A model you name in the prompt still wins for that run. + +The Claude CLI route serves Claude-family models, at any effort the map accepts. A model from another family is used only where your harness can hand it to a subagent. Otherwise the step runs on your session model and says why. Whenever an entry governs the step, the skill prints one `Model role brainstorm:` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and fallback. + --- ## See Also diff --git a/docs/guides/ce-code-review.md b/docs/guides/ce-code-review.md index 7a07bc4bc..6c08e4e06 100644 --- a/docs/guides/ce-code-review.md +++ b/docs/guides/ce-code-review.md @@ -134,6 +134,12 @@ The prerequisite is a peer agent CLI. The pass drives a read-only agent CLI (`co This shares the provider/route kernel with `ce-doc-review` but keeps a narrower scope: adversarial-only, diff/work-tree delivery, not doc-review's judgment trio or whole-doc sweep. +## Model role + +`ce-code-review` reads the `code-review` role from the `model_roles` map in CE config. The entry is one model or a list, each with an optional reasoning effort. Every item is a seat: one independent reviewer that gives the diff an adversarial read on that model. Seats run on every review, beside the persona reviewers, and they take the place of the conditional single-peer pass described above. The in-process adversarial reviewer still runs alongside them. + +`cross_model_review_mode: off` and `CROSS_MODEL_PEERS` still decide which seats may send review content to another provider. A seat that cannot run is dropped and reported, never filled by another model, and the persona review runs either way. Each seat prints its own `Model role code-review seat :` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and the seat rules. + ## Severity and autofix class are orthogonal Severity answers urgency (P0 = critical breakage, P3 = user discretion). Autofix class describes follow-up shape, not apply permission: diff --git a/docs/guides/ce-compound.md b/docs/guides/ce-compound.md index 0933dd251..e8ab4155c 100644 --- a/docs/guides/ce-compound.md +++ b/docs/guides/ce-compound.md @@ -131,6 +131,12 @@ After capturing the new learning, `ce-compound` checks whether the learning sugg Phrases like "that worked", "it's fixed", "working now", and "problem solved" identify the completion checkpoint. They do not establish that a learning qualifies. Automatic capture still applies the non-obvious, durable, and material gate above. `/ce-compound [context]` requests immediate evaluation without waiting for a completion phrase; it does not lower the bar. +### Model role + +`ce-compound` reads the `compound` role from the `model_roles` map in CE config. The entry names the model that drafts the learning document. In Full mode a subagent on that model writes the body from a hand-off file, while the session still classifies the learning, writes the file, and validates it. Lightweight mode launches no subagents, so it writes the doc on the session model whatever the entry names. + +This role is served only by a subagent of your harness. An effort in the entry is reported as not applied where that subagent cannot be given one, and the session drafts the body itself when the harness cannot hand over the model. The report carries one `Model role compound:` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and fallback. + --- ## Quick example diff --git a/docs/guides/ce-debug.md b/docs/guides/ce-debug.md index 5b41ae475..d90814442 100644 --- a/docs/guides/ce-debug.md +++ b/docs/guides/ce-debug.md @@ -132,6 +132,12 @@ Concrete signals trigger a `/ce-brainstorm` recommendation rather than a fix: th `mode:return-to-caller` (set by `lfg` on its defect route) is also non-interactive, but the caller owns everything after the fix. It keeps the full investigation and the convergent-or-defer boundary, creates a feature branch when it starts on the default branch, commits the fix-owned files without pushing, skips the post-fix polish and review steps, and returns a structured result: `fixed`, `diagnosed-no-fix`, `needs-human`, or `blocked`, with the root cause, changed files, verification evidence, residuals, and the issue of record. `lfg` then simplifies, reviews, and ships from that return. +### Model role + +`ce-debug` reads the `debug` role from the `model_roles` map in CE config. The entry names the model that produces the diagnosis and the fix. A read-only subagent on that model gathers the evidence and proposes the causal chain, and a second one writes the failing test and the minimal fix. The session keeps the causal-chain gate, the branch, the commit, and the return. Pipeline and return-to-caller runs honor the entry the same way interactive runs do. + +This role is served only by a subagent of your harness. An effort in the entry is reported as not applied where that subagent cannot be given one, and the session does the work itself when the harness cannot hand over the model. An interactive run prints one `Model role debug:` line at the end of the Debug Summary. A pipeline or return-to-caller run carries the same line in the optional `model_role` field of its structured result. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and fallback. + --- ## Quick Example diff --git a/docs/guides/ce-doc-review.md b/docs/guides/ce-doc-review.md index 27c7d66b7..f98a33226 100644 --- a/docs/guides/ce-doc-review.md +++ b/docs/guides/ce-doc-review.md @@ -163,6 +163,12 @@ The pass needs a peer *agent* CLI (`codex`, `claude`, `grok`, `cursor-agent`, or The pass embeds the document into the peer prompt and sends it to an external provider. `CROSS_MODEL_PEERS` restricts which providers may receive content. Peers are strictly read-only. Failures never block the review; an exact provider-overload 529 gets one same-route retry, never an unbounded retry loop. A second target remains opt-in (`CROSS_MODEL_MAX_PEERS=2`). +### Model role + +`ce-doc-review` reads the `doc-review` role from the `model_roles` map in CE config. The entry is one model or a list, each with an optional reasoning effort. Every item is a seat: one independent reviewer that reads the whole document on that model. Seats run on every review, beside the persona reviewers, and they take the place of the conditional single-peer pass described above. + +`cross_model_review_mode: off` and `CROSS_MODEL_PEERS` still decide which seats may send the document to another provider. A seat that cannot run is dropped and reported, never filled by another model, and the persona review runs either way. Each seat prints its own `Model role doc-review seat :` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and the seat rules. + --- ## Quick Example diff --git a/docs/guides/ce-plan.md b/docs/guides/ce-plan.md index 1537fe6b6..d6f32195d 100644 --- a/docs/guides/ce-plan.md +++ b/docs/guides/ce-plan.md @@ -285,6 +285,12 @@ When you want a specific model for the heavy reasoning step, `ce-plan` can autho This works on any harness. The host serves the chosen model natively where it can, otherwise it invokes the Claude CLI (which must be installed and authenticated), otherwise it runs the step on your session model and says which precondition was unmet. +## Model role + +`ce-plan` reads the `plan` role from the `model_roles` map in CE config. The entry names the model, and optionally the reasoning effort, that authors the plan. That is the same step model elevation dispatches, and the entry takes the place of `plan_model`. A model you name in the prompt, or a planning directive `lfg` carries, still wins for that run. + +The Claude CLI route serves Claude-family models, at any effort the map accepts. A model from another family is used only where your harness can hand it to a subagent. Otherwise the step runs on your session model and says why. Whenever an entry governs the step, the skill prints one `Model role plan:` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and fallback. + ## See Also - [`ce-brainstorm`](./ce-brainstorm.md): produce the requirements-only unified plan that `ce-plan` enriches diff --git a/docs/guides/ce-simplify-code.md b/docs/guides/ce-simplify-code.md index 5da1cfa1c..f2cf97f76 100644 --- a/docs/guides/ce-simplify-code.md +++ b/docs/guides/ce-simplify-code.md @@ -100,6 +100,14 @@ If you want the same wrap-up in ordinary sessions, add a standing instruction to --- +## Model role + +`ce-simplify-code` reads the `simplify` role from the `model_roles` map in CE config. The entry names the model that produces the simplification: one subagent on that model applies the findings and runs the checks. The three reviews keep their own model tier, and the session keeps scope resolution and the summary. + +This role is served only by a subagent of your harness. An effort in the entry is reported as not applied where that subagent cannot be given one, and the session applies and verifies itself when the harness cannot hand over the model. The summary ends with one `Model role simplify:` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and fallback. + +--- + ## See also - [`ce-work`](./ce-work.md): may run this before its review gate diff --git a/docs/guides/ce-work.md b/docs/guides/ce-work.md index eed382e66..1dcf9c411 100644 --- a/docs/guides/ce-work.md +++ b/docs/guides/ce-work.md @@ -279,6 +279,12 @@ A harness left out keeps its default (Codex and Claude at high, native Grok at x A candidate is usable only after its unattended, write-capable, isolated-workspace route has qualified and the necessary CLI or authentication is available. +### Model Role + +`ce-work` reads the `work` role from the `model_roles` map in CE config. The entry names the model, and optionally the reasoning effort, that writes the code. Where your harness cannot give that model and effort to its own subagents, the entry becomes the one preferred external candidate: the model's family picks the harness, and the entry's effort is the effort requested for it. While an entry governs the run, `work_engine_mode`, `work_engine_preferences`, and `work_engine_effort` are not consulted. A current-task instruction or a caller binding still outranks the entry. + +`work_engine_mode: off` in your personal `config.local.yaml` keeps a team entry off external engines, so the work runs natively on your session model. The run prints one `Model role work:` line with its closing summary, naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and fallback. + ### What an External Run Does Before any repository material leaves the host, `ce-work` discloses the instruction or config source, the fixed recipient, what bounded unit material is exposed, and which restrictions are adapter-enforced versus cooperative. The adapter uses the CLI's existing authentication, receives a minimized environment, and cannot switch recipients, widen scope, push, open a PR, or choose fallback. diff --git a/docs/guides/lfg.md b/docs/guides/lfg.md index dddbef354..751496d53 100644 --- a/docs/guides/lfg.md +++ b/docs/guides/lfg.md @@ -104,6 +104,8 @@ Nothing is implemented without a work source verified this run, and only a plan Planning can be authored on a named model (`plan with fable`) via `ce-plan`'s model elevation. Implementation can be sent to a harness (`use Codex for implementation` as a preference, `only use Composer for implementation` as a requirement). `cursor` means the Cursor harness with its default model; `composer` means a Composer-family model through Cursor. Unscoped assignments bind to implementation only. In an interactive run that is genuinely ambiguous, `lfg` asks one question, then runs hands-off. A headless run applies the implementation default and discloses it. With no stage instruction, `ce-plan` uses its `plan_model` config and `ce-work` follows session and project instructions, then checkout-local `work_engine_mode` and `work_engine_preferences`. A configured `work_engine_effort` applies to the implementation worker either way; `lfg` does not carry an effort of its own. See [Implementation routing](./configuration.md#implementation-routing). +A `model_roles` map in CE config needs no instruction to `lfg`. Each step reads its own role and produces its deliverable on that entry, the same way it does when you invoke the skill yourself, and an entry takes the place of the older keys named above for its step. A stage directive you give `lfg` still outranks the map for that stage. The close-out lists every `Model role` line the run produced, so the final message shows which model served each governed step. See [Model roles](./configuration.md#model-roles). + Both a preference and a requirement fall back to the current harness/session model with one disclosure when the external route cannot run. A requirement keeps the requested external identity fixed while viable. It never authorizes another external recipient, and `lfg` does not ask whether to weaken the route. A plain mention of a model in feature text, a quote, a comparison, or a filename does not activate routing. See [`ce-work`](./ce-work.md#choose-the-implementation-author) for fallback, timeouts, and detached-worktree behavior. On string-only hosts the implementation seam is `mode:return-to-caller implementation_engine: `. The `plan_model:` carrier rides beside, never inside, `ce-plan`'s request. Neither carrier becomes plan content, a settled product decision, or review input. diff --git a/skills/lfg/references/task-visibility.md b/skills/lfg/references/task-visibility.md index 03c54d314..766995fcb 100644 --- a/skills/lfg/references/task-visibility.md +++ b/skills/lfg/references/task-visibility.md @@ -6,6 +6,8 @@ Before step 1, use the platform's task-tracking capability when available to pub Narrate the run in chat as it moves. Narration is chat prose, never entries on the task surface above. One line entering each step names the step and what it should produce; one line when it returns names what it actually produced (plan path, return status, fixes applied, PR URL). Close with a recap that stands on its own: what was built, how it was verified, what shipped, and any leftover findings. Print it alongside the close-out lines `references/shipping.md` defines, so a reader who sees only the final message has the full picture. +Each child skill reads the repo's model role map for itself, so LFG resolves no role and builds no carrier from the map. When a child's output carries a `Model role` line, or `ce-debug`'s return carries one in its optional `model_role` field, include it unchanged in what you write when that step returns. The closing recap lists every `Model role` line the run produced, also unchanged, so a reader of only the final message sees which model served each governed step. When no step produced one, say nothing about model roles. + ## Completion discipline Report a step as done only after it actually ran and returned. Describing what a step would do, or what comes next, is not doing it; invoke it in the same turn. A child skill runs inline, and nothing resumes you when it returns. Its return ends the child, not the run: after writing or reading it, continue with the next numbered step in the same turn. Do not end the turn before `DONE` or an explicit GATE stop naming the blocker. Work merely described, or a step skipped outside its stated skip condition, makes DONE false. diff --git a/tests/skills/task-visibility-contract.test.ts b/tests/skills/task-visibility-contract.test.ts index c5c531314..d469ed4ba 100644 --- a/tests/skills/task-visibility-contract.test.ts +++ b/tests/skills/task-visibility-contract.test.ts @@ -68,4 +68,10 @@ describe("task visibility contract", () => { expect(lfgTaskVisibility).toMatch(/replace or clear LFG's view.*only the child skill's task surface is visible/) expect(lfgTaskVisibility).toMatch(/after it returns, recreate or refresh LFG's remaining pipeline work/) }) + + test("lfg relays a child's Model role line instead of resolving roles itself", () => { + expect(lfgTaskVisibility).toMatch(/LFG resolves no role/) + expect(lfgTaskVisibility).toMatch(/`Model role` line.*unchanged/s) + expect(lfgTaskVisibility).toMatch(/closing recap lists every `Model role` line/) + }) }) From 722a70c33b20eb4b56ab09b175ec1d66de23a048 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 14:59:31 -0700 Subject: [PATCH 11/53] feat(ce-setup): add a guided model roles flow /ce-setup models shows each role with its effective value and source, offers the model families the host and installed CLIs can serve, accepts a typed model marked unconfirmed, asks whether to write the team or personal file and states each reach, previews the block, and writes only on approval. The rule that setup never creates config.local.yaml is scoped to the template-copy step, since this flow may write the personal file. Co-Authored-By: Claude Code --- docs/guides/README.md | 2 +- docs/guides/ce-setup.md | 26 +++-- docs/guides/configuration.md | 4 +- skills/ce-setup/SKILL.md | 10 +- .../ce-setup/references/model-roles-setup.md | 102 ++++++++++++++++++ skills/ce-setup/references/repo-fixes.md | 4 +- tests/skills/ce-setup-check-health.test.ts | 8 +- tests/skills/ce-setup-model-roles.test.ts | 81 ++++++++++++++ 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 skills/ce-setup/references/model-roles-setup.md create mode 100644 tests/skills/ce-setup-model-roles.test.ts diff --git a/docs/guides/README.md b/docs/guides/README.md index d7148b131..c1a2c2251 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -132,7 +132,7 @@ Invoked when a specific need arises, not part of any chain. | [`/ce-dogfood`](./ce-dogfood.md) | Hands-off browser QA of the branch: map flows, fix small breakages, write a report. Manual invoke only. | | [`/ce-test-browser`](./ce-test-browser.md) | End-to-end browser tests of the current diff using a host-native browser with `agent-browser` fallback. Does not check out a PR or branch. | | [`/ce-test-xcode`](./ce-test-xcode.md) | Build and test an iOS app on the simulator (screenshots, logs, human verification). Not XCUITest. | -| [`/ce-setup`](./ce-setup.md) | Diagnose optional tool capabilities, create or repair repo `config.yaml`, and scaffold a Compound Pack | +| [`/ce-setup`](./ce-setup.md) | Diagnose optional tool capabilities, create or repair repo `config.yaml`, scaffold a Compound Pack, and set the model role map | | [`/ce-handoff`](./ce-handoff.md) | Write a session handoff, or find and orient from a selected source. Does not auto-continue. | --- diff --git a/docs/guides/ce-setup.md b/docs/guides/ce-setup.md index 6cc86d07e..27fd2720c 100644 --- a/docs/guides/ce-setup.md +++ b/docs/guides/ce-setup.md @@ -2,7 +2,7 @@ > Check Compound Engineering health, optional tool availability, and repo-local config safety. It does not bulk-install the plugin's dependencies. -`ce-setup` is a diagnosis and config utility. It reports which optional tools are on PATH, refreshes the committed config example, creates the repo `config.yaml` if you approve, offers to gitignore a local override or CE scratch space, and offers to point your agent-instructions file at the knowledge store and add standing compounding and `ce-noslop` chat-register instructions. It also reports where CE artifacts will land and can repair an invalid `docs_root` or a broken CE Work engine block. +`ce-setup` is a diagnosis and config utility. It reports which optional tools are on PATH, refreshes the committed config example, creates the repo `config.yaml` if you approve, offers to gitignore a local override or CE scratch space, and offers to point your agent-instructions file at the knowledge store and add standing compounding and `ce-noslop` chat-register instructions. It also reports where CE artifacts will land and can repair an invalid `docs_root` or a broken CE Work engine block. Two arguments start a guided flow instead: `pack:` scaffolds a Compound Pack, and `models` sets the model role map. It runs only when you invoke it explicitly (`disable-model-invocation: true`). Talking about setup does not start it. Outside a git repository it reports capabilities and stops without writing files. @@ -14,20 +14,21 @@ See [Compound Engineering configuration](./configuration.md) for every option an | Question | Answer | |----------|--------| -| What does it do? | Runs a health check, reports optional tools, refreshes the example config, applies only the repo-local fixes you approve, and scaffolds a Compound Pack on request | +| What does it do? | Runs a health check, reports optional tools, refreshes the example config, applies only the repo-local fixes you approve, and scaffolds a Compound Pack or sets the model role map on request | | When to use it | First install, after an upgrade, when a skill says a tool is missing, or when onboarding a repo | | What it produces | A setup report, plus any config or gitignore edits you accepted | -| What it does not do | Bulk-install optional CE dependencies, update the plugin itself, or create `config.local.yaml` | +| What it does not do | Bulk-install optional CE dependencies or update the plugin itself. It writes `config.local.yaml` only in the model role flow, when you choose the personal file | --- ## Example invocations -Bare, one command covers first install, a re-check after an upgrade, a missing-tool report, and a directory that is not a git repo. The one argument, `pack:`, scaffolds a Compound Pack instead (see [Scaffold a Compound Pack](#scaffold-a-compound-pack)). +Bare, one command covers first install, a re-check after an upgrade, a missing-tool report, and a directory that is not a git repo. Two arguments start a different flow: `pack:` scaffolds a Compound Pack (see [Scaffold a Compound Pack](#scaffold-a-compound-pack)), and `models` sets the model role map (see [Set model roles](#set-model-roles)). ```text /ce-setup /ce-setup pack:house-rules +/ce-setup models ``` On oh-my-pi the invocation is `/skill:ce-setup`. On Codex it is `$ce-setup` when that host uses dollar-prefixed skills. @@ -49,7 +50,7 @@ The example config refresh happens on its own (it is the committed template copy - Deletes the obsolete `compound-engineering.local.md` if you say yes. - Refreshes `.compound-engineering/config.example.yaml` from the bundled template, always, inside a git repo. -- Offers to create `.compound-engineering/config.yaml` when missing. Never overwrites an existing `config.yaml` or `config.local.yaml`, and never creates the local override. +- Offers to create `.compound-engineering/config.yaml` when missing. Never overwrites an existing `config.yaml` or `config.local.yaml` with the template. These fixes do not create `config.local.yaml`: only the [model role flow](#set-model-roles) writes it, when you choose the personal file. - Offers to add `.compound-engineering/*.local.yaml` to `.gitignore`, but only when `config.local.yaml` already exists and is not ignored. - Offers to add `.context/compound-engineering/` to `.gitignore` whether or not that directory exists yet. An uncovered path is a note, not a project issue. - Offers to add a line about the `/solutions/` knowledge store to your root agent-instructions file (`AGENTS.md`, `CLAUDE.md`, or equivalent) when the file does not already convey it, placed in the file's own structure. Then offers the standing compounding instruction from the [ce-compound guide](./ce-compound.md#make-capture-automatic), offer-first or automatic, inserted verbatim. Only when the store is tracked in this repo, and never creates the file. Then offers the `ce-noslop` chat-register instruction, inserted verbatim, so agent reports and summaries to you lead with the outcome and carry no acknowledgements, offers of more help, or process narration. Skipped only when the file already covers the report boundary, that register, and the exclusions (code, config, verbatim quotes, text you asked to post as written); a partial or unrelated writing rule still gets the offer. @@ -62,6 +63,18 @@ Each question uses the host's blocking question tool when one exists. It never s `/ce-setup pack:` (or asking it in words to add or create a pack) starts a [Compound Pack](./packs.md) in the one layout discovery reads. It resolves `compound-packs//` at the repo root, refuses a directory that already has content, and previews everything before one approval: a `README.md` with a single line saying what the pack governs, a first rule file from a bundled template (`title`, two example `applies_when` situations, `tags`, and a prescriptive body with a placeholder to replace), and `- source: compound-packs/` appended under `packs:` in `.compound-engineering/config.yaml`, created from the template when the file is missing. It then runs the health check and reports the `pack ` line, and tells you the layout rule: a rule is discovered only when it is a top-level `.md` with `title` and `applies_when`; `README.md` is the description, and subfolders are storage ([Pack layout](./packs.md#pack-layout)). In a non-interactive run it prints the preview and writes nothing. +## Set model roles + +`/ce-setup models` (or asking it in words to set up model roles) builds or edits the [`model_roles` map](./configuration.md#model-roles): which model, at which reasoning effort, produces each pipeline step's deliverable. It runs in place of the health check and its fixes. + +- **It shows the current state.** You see all eight roles, each with its effective value and where that value comes from: a map entry in the personal or team file, an older key such as `plan_model`, or the session model. It names every older key that a map entry already replaces, and it prints the resolver's warnings and errors. +- **It offers what this session can reach.** A model family is offered when the app you are in serves it, or when a peer CLI for it (`claude`, `codex`, `grok`, `cursor-agent`) is installed. `inherit` is always offered. This confirms that a route exists, not that your account may use a given model. You can type any other model. Setup writes it with a trailing `# unconfirmed` comment, and a later run removes the comment once it can confirm the model. +- **It changes only what you change.** Set a role, clear it, or leave it alone. The review roles, `doc-review` and `code-review`, take a list with one independent reviewer per item. Any entry can carry an effort: `low`, `medium`, `high`, `xhigh`, or `max`. When a new entry replaces an older key for that step, setup says so. +- **It asks which file receives the map.** The team file, `config.yaml`, is committed, so the map reaches every worktree, clone, and cloud session and applies to your teammates. The personal file, `config.local.yaml`, reaches this checkout only. Before writing, setup tells you when a change would not take effect as asked, such as a team entry that your personal entry shadows. +- **It previews, then writes.** You see the complete resulting `model_roles` block and the file it goes to, and you approve once. Every unrelated setting and comment in the file is kept. When the write creates `config.local.yaml`, setup offers the gitignore entry for it. It then re-runs the health check and the resolver and shows what each role now resolves to. In a non-interactive run it prints the preview and writes nothing. + +To take one role back from a team entry without editing the team file, set it to `inherit` in the personal file. + ## Where artifacts land The health report includes the resolved artifact root (`docs/` by default, or a valid `docs_root` from `config.yaml`) and which config layer supplied it. `docs_root` in `config.local.yaml` is ignored; if your local file still has one, setup says so and offers to move it into `config.yaml`. @@ -113,6 +126,7 @@ Use `ce-setup` when: - A workflow reported an optional tool missing and you want the install command - You are onboarding a repo to `.compound-engineering/config.yaml` - You want to start a Compound Pack with `pack:` in the layout discovery reads +- You want to choose which model does each pipeline step, with `models` - Health marked `docs_root` or the CE Work engine block invalid Skip it when: @@ -141,7 +155,7 @@ Skip it when: It was the old machine-local config file. Team defaults now live in `.compound-engineering/config.yaml`, and `config.local.yaml` is the optional per-checkout override. Review-agent selection is automatic. **Why gitignore `.compound-engineering/config.local.yaml`?** -It is a per-checkout override, so committing it defeats the point. The committed `config.example.yaml` shows the available settings. Setup creates the repo file, never the override. +It is a per-checkout override, so committing it defeats the point. The committed `config.example.yaml` shows the available settings. Setup's fixes create only the repo file. The model role flow is the one place setup writes the override, when you choose the personal file, and it then offers this gitignore entry. --- diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 76faa83d3..ba540f072 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -2,7 +2,7 @@ Compound Engineering keeps optional repo defaults in `.compound-engineering/config.yaml`. Ordinary keys may also live in `.compound-engineering/config.local.yaml`, which overrides the repo file per key. Both files are visible to every supported harness that opens the same checkout. -Run `/ce-setup` to create `config.yaml` and refresh the committed `.compound-engineering/config.example.yaml`. Setup does not create `config.local.yaml`. Uncomment only the keys you want to change. Do not put credentials, CLI commands, or harness flags in either file. +Run `/ce-setup` to create `config.yaml` and refresh the committed `.compound-engineering/config.example.yaml`. Setup creates `config.local.yaml` only when you run `/ce-setup models` and choose the personal file. Uncomment only the keys you want to change. Do not put credentials, CLI commands, or harness flags in either file. ## How keys resolve @@ -109,7 +109,7 @@ All settings are optional. Commented examples are documentation, not active valu ## Model roles -`model_roles` names the model, and optionally the reasoning effort, that produces each pipeline step's deliverable. A team file might set: +`model_roles` names the model, and optionally the reasoning effort, that produces each pipeline step's deliverable. Run `/ce-setup models` to build or edit the map in a guided flow that shows each role's current value and source, offers the models this session can reach, and asks which file to write ([Set model roles](./ce-setup.md#set-model-roles)). A team file might set: ```yaml model_roles: diff --git a/skills/ce-setup/SKILL.md b/skills/ce-setup/SKILL.md index 7b5f924e8..8dedd05d2 100644 --- a/skills/ce-setup/SKILL.md +++ b/skills/ce-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: ce-setup -description: "Check Compound Engineering health and repo-local config, or scaffold a Compound Pack with `pack:`." -argument-hint: "[pack:]" +description: "Check Compound Engineering health and repo-local config, scaffold a Compound Pack with `pack:`, or set model roles with `models`." +argument-hint: "[pack:|models]" disable-model-invocation: true --- @@ -17,6 +17,10 @@ Ask each question below using the host's blocking question tool already in the c When the invocation names a Compound Pack to add, create, or scaffold (the `pack:` argument, or the same request in words), read `references/pack-scaffold.md` from this skill's directory and follow it in place of Phases 1-2 (Diagnose and Fix Repo-Local Issues): it writes the pack and its config entry only after the user approves, runs the health check itself, and reports into Phase 3 (Summary). +## Model Roles + +When the invocation asks to set up model roles (the `models` argument, or the same request in words), read `references/model-roles-setup.md` from this skill's directory and follow it in place of Phases 1-2. It writes the `model_roles` map only after the user approves, and it reports into Phase 3 (Summary). + ## Artifact Root Resolution Every Compound Engineering skill that writes or reads an artifact directory (`solutions`, `plans`, `ideation`, and the other CE-owned trees) resolves its root through the rule below. `ce-setup` carries the canonical statement and reports the resolved root so an operator can confirm where artifacts land before running other skills. @@ -84,8 +88,6 @@ If optional tools are missing, do not offer a bulk install. The diagnostic alrea Read `references/repo-fixes.md` from this skill's directory before making any repo-local change. It carries Steps 4-9: removing the obsolete `compound-engineering.local.md`, refreshing the example config, offering to create `config.yaml`, repairing invalid `work_engine_preferences` and `docs_root`, the two `.gitignore` offers, and the agent-instructions offers (a knowledge-store mention, the compounding directive, and the chat-register directive for `ce-noslop`). -All paths there resolve from the repository root (`git rev-parse --show-toplevel`), not the current working directory. Maintaining the generated example files is the work Phase 2 does on its own — refreshing `config.example.yaml` and removing the superseded `config.local.example.yaml`. Every change to a user-owned file is offered and applied only if the user approves. - ## Phase 3: Summary **User-runnable invocation rendering.** In setup summaries, default to `/ce-setup`; use `$ce-setup` only when the active host is Codex or explicitly documents dollar-prefixed skill invocation. On oh-my-pi (`omp`), use `/skill:ce-setup`. Render only the invocation as inline code and output one form only. diff --git a/skills/ce-setup/references/model-roles-setup.md b/skills/ce-setup/references/model-roles-setup.md new file mode 100644 index 000000000..9bb389e5c --- /dev/null +++ b/skills/ce-setup/references/model-roles-setup.md @@ -0,0 +1,102 @@ +# Model role setup + +Loaded from SKILL.md when the invocation asks to set up model roles. Ask with the blocking question tool named in SKILL.md. + +**Outcome:** the `model_roles` map in the config file the developer chose holds the entries they asked for, and nothing else in that file changed. The next consumer is the bundled resolver, which every role skill runs at its acting step. + +**Done:** the developer has seen each role's value and where it comes from, the resolver reads the written file with no error or warning about an entry this run wrote, and the Phase 3 summary names the file and the roles that changed. A run the developer declined, or one that could not ask, is done when it has said that it wrote nothing. + +**Safe failure direction:** write nothing the developer has not seen. A directory outside a git checkout, a resolver that returns no JSON, a candidate map the resolver rejects, and a preview the developer did not approve each end with both config files untouched. When no question can reach the developer, the flow prints what it has and writes nothing. + +## Facts the flow needs + +**Roles.** The map has eight roles. Each entry names the model that produces one step's deliverable: + +| Role | Skill | The entry governs | +|---|---|---| +| `brainstorm` | `ce-brainstorm` | The generated approaches | +| `plan` | `ce-plan` | The authored plan | +| `doc-review` | `ce-doc-review` | Each independent review of the plan | +| `debug` | `ce-debug` | The diagnosis and fix | +| `work` | `ce-work` | The code | +| `simplify` | `ce-simplify-code` | The simplification | +| `code-review` | `ce-code-review` | Each independent review of the diff | +| `compound` | `ce-compound` | The learning document | + +**Entries.** An entry is ` []` or `inherit`, which means the session model. The effort is one of `low | medium | high | xhigh | max`. The two review roles, `doc-review` and `code-review`, take a list, and each item is one seat: an independent reviewer on that model. Every other role takes one entry. On a review role, a scalar `inherit` means no seats, and `inherit` inside a list is one seat on the session model. An entry names a model and an effort only, never a CLI flag, a command, or an app-specific model slug. The resolver owns the grammar, so the flow validates with it and does not judge an entry by eye. + +**The two files.** Paths resolve from the repository root (`git rev-parse --show-toplevel`). + +- The team file, `.compound-engineering/config.yaml`, is committed. It reaches every worktree, clone, and cloud session, and it applies to teammates. +- The personal file, `.compound-engineering/config.local.yaml`, reaches this checkout only. + +The map resolves one role at a time: the personal file's entry wins, then the team file's entry. An entry in either file replaces the older keys for the same step in either file. With no entry, the older keys decide, and then the session model. + +**Reachable models.** A model family is offered when the session can show a route to it: the host itself serves the family, or a peer CLI that serves it is installed. The host serves a family when its own tooling shows it, through the model the harness states the session runs on or the model choices its subagent or model-selection tool lists. The agent's belief about the host is not evidence. The resolver names four families, and each has one peer CLI: `claude` (Claude models, the `claude` CLI), `codex` (GPT models, the `codex` CLI), `grok` (Grok models, the `grok` CLI), and `composer` (Composer models, the `cursor-agent` CLI). Check each CLI with `command -v ` and count only the ones found. This shows that a route exists. It does not show that the developer's account may use a given model, so say that once when offering. + +Inside an offered family, name only models the session can show. For the Claude family, those are the aliases the resolver recognizes: `fable`, `opus`, `sonnet`, and `haiku`. For any family, they also include each model id the host's own tooling lists. Never invent an id. When a family is reachable but the session can show no id for it, offer the family by name and ask the developer to type the id. `inherit` is always offered. + +**The `# unconfirmed` marker.** The developer may type any model. The resolver's `family` for the typed entry decides whether it is confirmed: a family the flow offered is confirmed, and every other family, including `unknown`, is not. An unconfirmed entry is written with `# unconfirmed` as a trailing comment on that entry's line. The resolver ignores the comment, so the entry still applies. When a later run finds that a marked entry in the file it is writing has become confirmed, that run's preview drops the marker and says so. + +## Procedure + +1. **Show the current state.** Run this skill's resolver: + + ```bash + SKILL_DIR=""; + PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"; [ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }; + "$PY" "$SKILL_DIR/scripts/model-role-resolve.py" --all + ``` + + When the command yields no JSON (no interpreter, script not found, non-zero exit), say that the model role map could not be read and stop, because the flow cannot validate what it would write. + + Show all eight roles, each with what it governs, its effective value, and where that value comes from. The role's `effective.from` names the source: + + - `map`: the role's map entry. `source` says which file holds it: `local` is the personal file and `team` is the team file. + - `existing_key`: an older key governs the step. `existing_keys` names each key, its file, and its value. + - `session`: nothing is set, so the session model does the work. + - `invalid`: the `model_roles` block is malformed, and `errors` says why. + + Below the roles, name every key in a role's `shadowed` list as an older key that a map entry already replaces. When the `work` role has `engine_opt_out: true`, say that the personal `work_engine_mode: off` keeps the team's `work` entry off external engines. Show every warning and error the resolver printed, in its own words. + +2. **Offer models and take the changes.** Work out the offered families and models from the facts above. Then ask which roles to change. For each role the developer names, take one of three answers: set it to an entry in the form above, clear it, or leave it. + + Clearing removes the role's entry from the chosen file. To take a role back from a team entry without editing the team file, the entry in the personal file is `inherit`. When the developer sets a role that an older key governs today, say that the new entry will replace that key for the step and name the key. The flow leaves older keys in the file. + +3. **Ask which file receives the map,** stating the reach of each: + + ```text + Which file should receive the model role map? + 1. Team file (.compound-engineering/config.yaml): committed, reaches every worktree, clone, and cloud session, and applies to your teammates + 2. Personal file (.compound-engineering/config.local.yaml): reaches this checkout only + ``` + + One run writes one file. + +4. **Build the candidate and validate it with the resolver.** The candidate is the chosen file as it is now, with only the roles the developer changed altered. Preserve every unrelated setting and comment in the file exactly as written. Do the same for every role line the developer did not change, apart from a `# unconfirmed` marker this run drops because the entry is now confirmed. Build the block by these rules: + + - When the file has a live, uncommented `model_roles:` key, edit the changed roles' lines inside that block. When the last entry is cleared, remove the `model_roles:` line too, so role skills see no active key. + - When the file has no live key, append the block at the end. Leave the template's commented `# model_roles:` example as it is. + - When the personal file does not exist, the candidate is a new file that holds only the `model_roles` block. When the team file does not exist, the candidate is `references/config-template.yaml` with the block appended. + - Write a changed review role as a block list with one seat per line, so each seat can carry its own marker. + + The resolver takes no file argument. It reads `.compound-engineering/` at the top of the git checkout it runs in, and for each role it reports only the file that wins. To validate without touching the developer's files, create a throwaway checkout (`mktemp -d "${TMPDIR:-/tmp}/ce-setup-models-XXXXXX"`, then `git init -q` inside it). Run the resolver command from step 1 twice with that directory as the working directory: + + - **The candidate alone.** Write only the candidate into the throwaway checkout's `.compound-engineering/` directory, under its real file name. This run validates every entry in the chosen file and gives each one's `family`. An error, an `invalid` seat, or a warning that names an entry the developer changed means the candidate is not valid: show the resolver's message and take a corrected entry. Each set entry's `family` decides its `# unconfirmed` marker. + - **Both files.** Add a copy of the other config file beside the candidate and run again. This run shows how each role resolves after the write. For every role the developer changed, compare it with what they asked for. When the role's value would come from somewhere other than the chosen file's entry, say where it would come from before asking for approval. A personal entry that shadows a team write is one such result. Another is a cleared role that the other file or an older key then governs. + +5. **Ask once, showing everything.** Preview the file the map goes to and whether the write creates it, the complete resulting `model_roles` block with its markers, where the block sits in the file, and the notes from step 4. Then ask: + + ```text + Write this model role map to ? + 1. Yes, write it + 2. No thanks + ``` + + Write only on approval. When the caller declared the run non-interactive, or no question can reach the user, print the current state and the preview the invocation's own words allow, say the flow wrote nothing, and stop. + +6. **Write, then verify.** Apply the candidate to the chosen file. When the write created `config.local.yaml`, apply Step 7 of `references/repo-fixes.md` to it next. That step owns the gitignore check and its offer. + + Run the bundled `scripts/check-health` exactly as SKILL.md Step 2 does, with the same `SKILL_DIR` anchor, and run the resolver command from step 1 again in the real checkout. Show any project issue the health check reports. The flow is not done while the resolver reports an error or a warning about an entry this run wrote: fix the cause under a new preview and check again. + +7. **Report.** Show the developer the roles as they now resolve. Tell them in one sentence that each governed step prints a `Model role` line naming the requested model, the served model, and the route, which is where an entry that could not be served shows up. Report the file and the changed roles under Fixed in the Phase 3 summary, or under Skipped when the developer declined. diff --git a/skills/ce-setup/references/repo-fixes.md b/skills/ce-setup/references/repo-fixes.md index 02fa23148..7acb68a20 100644 --- a/skills/ce-setup/references/repo-fixes.md +++ b/skills/ce-setup/references/repo-fixes.md @@ -46,11 +46,11 @@ It does not create config.local.yaml. 2. No thanks ``` -If the user approves, copy `references/config-template.yaml` to `/.compound-engineering/config.yaml`. Never overwrite an existing `config.yaml` or `config.local.yaml`. +If the user approves, copy `references/config-template.yaml` to `/.compound-engineering/config.yaml`. Never overwrite an existing `config.yaml` or `config.local.yaml` with the template. If `config.local.yaml` already exists, leave it. After creating (or if both files already exist), name ordinary local keys that would shadow the new team file. If local still has `docs_root`, say it is ignored and offer to move it into `config.yaml`. -Do not create `config.local.yaml`. +The model role flow (`references/model-roles-setup.md`) is the one part of setup that writes `config.local.yaml`. It runs only when the invocation asks for it, and it writes only the `model_roles` map the user approved. Do not create `config.local.yaml` in this step. ### Step 6a: Repair Invalid CE Work Preferences diff --git a/tests/skills/ce-setup-check-health.test.ts b/tests/skills/ce-setup-check-health.test.ts index 5721afa4b..e41a47954 100644 --- a/tests/skills/ce-setup-check-health.test.ts +++ b/tests/skills/ce-setup-check-health.test.ts @@ -171,11 +171,14 @@ describe("ce-setup check-health", () => { "ce-brainstorm", "ce-code-review", "ce-commit-push-pr", + "ce-compound", + "ce-debug", "ce-doc-review", "ce-ideate", "ce-plan", "ce-product-pulse", "ce-promote", + "ce-simplify-code", "ce-sweep", "ce-work", "lfg", @@ -871,7 +874,10 @@ describe("ce-setup check-health", () => { ).join("\n") expect(skill).toContain("Set up a repo config file for this project?") expect(skill).toContain("copy `references/config-template.yaml` to `/.compound-engineering/config.yaml`") - expect(skill).toContain("Do not create `config.local.yaml`") + // Scoped to Step 6 on purpose: the model role flow (references/model-roles-setup.md) may + // create the personal file, so the bare "Do not create `config.local.yaml`." is no longer true + // of setup as a whole. tests/skills/ce-setup-model-roles.test.ts rejects the unscoped line. + expect(skill).toContain("Do not create `config.local.yaml` in this step") expect(skill).toContain("offer to move it into `config.yaml`") expect(skill).not.toContain("Set up a local config file for this project?") expect(skill).not.toContain("copy `references/config-template.yaml` to `/.compound-engineering/config.local.yaml`") diff --git a/tests/skills/ce-setup-model-roles.test.ts b/tests/skills/ce-setup-model-roles.test.ts new file mode 100644 index 000000000..5e89d5a80 --- /dev/null +++ b/tests/skills/ce-setup-model-roles.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "fs" +import path from "path" +import { describe, expect, test } from "bun:test" +import { load } from "js-yaml" + +// ce-setup's model role flow is the one place setup writes a `model_roles` map, +// and the one place it may write the personal `config.local.yaml`. These guards +// pin the contract across its surfaces: the SKILL.md trigger, the procedure +// reference it points at, the Phase 2 sentence that used to forbid the personal +// file outright, and the guides. They pin tokens, not sentences. Whether a run +// actually offers reachable models and holds the write for approval is a judged +// conversation eval, not a string test. +// +// The body's size is not asserted here: tests/codex-skill-prompt-budget.test.ts +// already fails any SKILL.md over the 8,000-byte bound, ce-setup included. + +const REPO = process.cwd() +const read = (rel: string) => readFileSync(path.join(REPO, rel), "utf8") + +const SKILL = read("skills/ce-setup/SKILL.md") +const REFERENCE_PATH = "skills/ce-setup/references/model-roles-setup.md" + +function frontmatter(body: string): Record { + const match = body.match(/^---\r?\n([\s\S]*?)\r?\n---/) + expect(match, "frontmatter block").not.toBeNull() + return load(match![1]) as Record +} + +describe("ce-setup model role flow contract", () => { + test("SKILL.md names the models trigger and routes to the reference", () => { + expect(frontmatter(SKILL)["argument-hint"]).toContain("models") + + const start = SKILL.indexOf("## Model Roles") + expect(start, "## Model Roles section").toBeGreaterThan(-1) + const section = SKILL.slice(start, SKILL.indexOf("\n## ", start + 1)) + expect(section).toContain("`models`") + expect(section).toContain("references/model-roles-setup.md") + expect(section).toMatch(/in place of Phases 1-2/) + expect(section).toMatch(/only after the user approves/) + }) + + test("the reference states both targets and their reach, the marker, and the write gates", () => { + const reference = read(REFERENCE_PATH) + // Current state comes from the bundled resolver, never from re-parsing the files by hand. + expect(reference).toMatch(/model-role-resolve\.py" --all/) + // R17: both targets, each with its reach. + expect(reference).toContain(".compound-engineering/config.yaml") + expect(reference).toContain("config.local.yaml") + expect(reference).toMatch(/every worktree, clone, and cloud session/) + expect(reference).toMatch(/this checkout only/) + // R19: a model the flow could not confirm is written marked, and `inherit` is always offered. + expect(reference).toContain("# unconfirmed") + expect(reference).toContain("`inherit`") + // The two gates the skill-level done bar cannot protect. + expect(reference).toMatch(/Write only on approval/) + expect(reference).toMatch(/non-interactive[^.]*(wrote nothing|writes nothing)/) + expect(reference).toMatch(/unrelated setting/) + // Creating the personal file reuses the gitignore rule Phase 2 already owns. + expect(reference).toContain("references/repo-fixes.md") + }) + + test("Phase 2 scopes its never-create rule to its own step and names the flow that may write the file", () => { + const fixes = read("skills/ce-setup/references/repo-fixes.md") + // The bare sentence was true only while no part of setup wrote the personal file. + expect(fixes).not.toMatch(/^Do not create `config\.local\.yaml`\.$/m) + expect(fixes).toContain("Do not create `config.local.yaml` in this step") + expect(fixes).toContain("references/model-roles-setup.md") + }) + + test("the guides document the flow and no longer say setup never creates the personal file", () => { + const guide = read("docs/guides/ce-setup.md") + expect(guide).toContain("## Set model roles") + expect(guide).toContain("/ce-setup models") + expect(guide).not.toMatch(/never creates the local override|never the override|or create `config\.local\.yaml`/) + + const config = read("docs/guides/configuration.md") + expect(config).not.toContain("Setup does not create `config.local.yaml`") + const roles = config.slice(config.indexOf("## Model roles"), config.indexOf("## Implementation routing")) + expect(roles).toContain("/ce-setup models") + }) +}) From 21a54edbf82ae710a91567b065593f5a0b1f39bb Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:06:39 -0700 Subject: [PATCH 12/53] feat(review): run one independent reviewer per model role seat A list on the doc-review or code-review role runs one reviewer per seat on every review, beside the persona review and in place of the single conditional cross-model pass. The trigger sits in the references every dispatching review reads, so seats do not depend on a judgment lens activating. Review roles fail closed: an unreadable map, a blocked seat, or a seat no route can serve runs nothing extra and is reported, never substituted. The review workers take CROSS_MODEL_SEAT: it names the artifact and reviewer per seat, and lets an explicit seat run in the host family or under an unknown host while independence_verified stays false. Co-Authored-By: Claude Code --- .../references/cross-model-review.md | 52 ++++++++ .../ce-code-review/references/finish-input.md | 7 +- .../references/select-and-route.md | 4 + .../scripts/cross-model-adversarial-review.sh | 35 +++-- .../references/cross-model-review.md | 52 ++++++++ skills/ce-doc-review/references/dispatch.md | 4 + .../references/synthesis-and-presentation.md | 4 + .../scripts/cross-model-doc-review.sh | 29 ++++- tests/review-seat-block-parity.test.ts | 90 +++++++++++++ .../packs/ce-code-review-cross-model.md | 58 ++++++++- .../packs/ce-doc-review-cross-model.md | 54 +++++++- .../ce-code-review-cross-model-routes.test.ts | 118 +++++++++++++++++ .../ce-doc-review-cross-model-routes.test.ts | 123 ++++++++++++++++++ tests/skills/cross-model-review-mode.test.ts | 13 ++ 14 files changed, 624 insertions(+), 19 deletions(-) create mode 100644 tests/review-seat-block-parity.test.ts diff --git a/skills/ce-code-review/references/cross-model-review.md b/skills/ce-code-review/references/cross-model-review.md index 3f14299e1..9bd93ba31 100644 --- a/skills/ce-code-review/references/cross-model-review.md +++ b/skills/ce-code-review/references/cross-model-review.md @@ -6,6 +6,58 @@ This pass is **adversarial-only**. No other persona gets a cross-model twin, and Before any reviewed content leaves the machine, the host picks one concrete route and approves it (this document calls that approval a "sanction"). `scripts/cross-model-adversarial-review.sh` holds to that fixed route, applies read-only controls, captures JSON in the findings-schema shape, and records identity receipts. Before dispatch it makes a conservative estimate of the diff's token count and file count. An oversized diff is not pasted into the prompt: the worker gives the peer the orchestrator's compact semantic review map and keeps the exact diff as a private file the peer can read selectively. Routes with limited tools receive that temp directory as an extra read root; Codex uses selective `git diff -- ` calls inside its existing read-only sandbox. A failed route writes no artifact and never switches recipients on its own. + +## Review seats (model role map) + +A review role's `model_roles` entry is a list of *seats*. Each seat is one more reviewer, on the model and at the effort the entry names. The hook at persona dispatch resolves this skill's review role with `references/model-roles.md`, which owns the resolver command, the serving order, and the `Model role` line. This section owns what a review role does with the resolver's answer. Outside this section, this file describes the *single-peer pass*: one target on one route, started only under that pass's own conditions. + +**When this section applies.** It applies only when that hook ran the resolver for this review. Otherwise skip it, because no `model_roles:` key is active or this review dispatches no persona team. The rest of this file then applies as written. + +**The persona review always runs.** It runs when every seat is dropped, blocked, or invalid, and when the map cannot be read. + +**A live request outranks the entry.** When the user asks in this conversation for a specific peer or model for this review, the seat list is not used for this run. Follow the single-peer rules with that request as the stated preference. + +Otherwise the resolver's answer decides what runs beside the persona review: + +| Resolver answer | What runs beside the persona review | +|---|---| +| `state` is `unset` | The single-peer pass, under its own conditions. Nothing else in this section applies. | +| `state` is `entries` | The seats. They replace the single-peer pass for this review. | +| `state` is `inherit` | Nothing: no seats and no single-peer pass. Print the `inherit` line `references/model-roles.md` defines. | +| `state` is `invalid`, or no JSON came back | Nothing: no seats and no single-peer pass. Say in Coverage that the model role map could not be used, quoting the resolver's `errors` when it returned any. | + +The single-peer rules do not bind a seat unless this section points at them. That covers the one target and one route, the activation and run conditions, the default target order, and any rule that a started peer replaces an in-process reviewer. + +**Which seats run.** Take the resolver's `entries` in order. + +- A seat marked `invalid` does not run. The other seats are unaffected. +- A seat with `blocked_by` set does not run. The resolver has already applied `cross_model_review_mode` and `CROSS_MODEL_PEERS` to every seat, however the seat would be served, so do not evaluate either policy again here. +- Every other seat is one job on this skill's seat brief, with its own seat number ``. The seat brief and its `` name are stated right after this section. + +**Serving a seat.** Use the first of these that can serve the seat's model at the seat's effort: + +1. **A native subagent**, when the host can hand a subagent that model at that effort, as `references/model-roles.md` (Serving order) defines it. A seat whose model is `inherit` is always served this way, on the session model. Instruct the subagent to change nothing and to review only the material this review covers. Save its return as `/-native-s.json` in the shape the worker writes: `reviewer` set to `-native-s`, `independence_verified: false`, `model_requested` as the entry names it, `model_actual: "unverified"`, and each `safe_auto` finding recorded as `gated_auto`. +2. **This skill's worker**, as one detached job, when the seat's `family` is `codex`, `claude`, `grok`, or `composer` and that target's own route is installed. The target is the family. The route is that target's token from the Step 1 table, and `grok` uses `grok-cli` only. Start the job with Step 4's start command, changing only these parameters: `--label "seat-"`; `CROSS_MODEL_SEAT=""` added to the `env` prefix; the seat's model as `CROSS_MODEL_MODEL_OVERRIDE_TARGET=""` and `CROSS_MODEL_MODEL_OVERRIDE=""`; and `CROSS_MODEL_EFFORT_OVERRIDE=""` when the seat has an effort. With the seat number set, the worker accepts a target in the host's own family and an unknown host family, and it writes `/--s.json` with reviewer `--s`. The worker still checks `CROSS_MODEL_PEERS` itself. + +A seat that neither route can serve is dropped. So is a seat whose job ends with no artifact, for any reason: a model or effort the route refuses and a target the worker's allowlist check refuses both end that way. A dropped seat is never run on another model, at another effort, or through an intermediary route such as Grok through `cursor-agent`. The fallback ladder in `references/model-roles.md`, and this file's retry, recovery, and replacement-recipient rules, belong to other paths and are not used for a seat. + +**One notice names every recipient.** Before anything is sent, print one notice that lists each seat that will run: its seat number, the provider that receives the reviewed content, the requested model and effort, and the route. It replaces a per-seat announcement. Step 3 still decides which modes print it, and Step 3's disclosure condition holds for every seat: name the model as requested, never as served. + +**Starting and collecting.** Start the seats in the same dispatch wave as the persona reviewers. Bring every started seat job to a terminal outcome under this file's wait, deadline, and cleanup rules, measuring the shared deadline from the last `start`, and read each worker seat's artifact with Step 5's verified read at that seat's own path. Before a job directory is deleted, read a dropped seat's `out.log` for the reason. + +**Folding seats in.** Each seat artifact is one reviewer in synthesis under its own reviewer identity. This skill's synthesis rules are unchanged: + +- A finding raised only by seats is never applied silently. +- Agreement between seats does not stack. However many seats agree, a finding gains at most the one promotion step this skill's synthesis allows. +- Only an artifact that records `independence_verified: true` can promote. A seat in the host's own family, a seat under an unknown host family, and a natively served seat never record it. + +**When seats run.** Seats run whenever the review dispatches its persona team. A seat does not depend on which personas were selected. A re-invocation that reuses completed review results reuses the seat results with them. + +**Reporting.** Print one `Model role seat :` line for every seat in `entries`, in the format `references/model-roles.md` defines. That covers seats that ran, dropped seats, blocked seats, and invalid seats. A seat that did not run prints `not run` with its reason: why it was dropped, the policy `blocked_by` names, or the invalid seat's `reason`. The lines go in Coverage and in the structured result this skill returns to a caller. For this review they replace the Coverage notes the single-peer pass would have written. + + +**Seats in this skill.** The role is `code-review`. The seat brief is the adversarial peer brief, so `` is `adversarial`. Seats run beside the in-process `adversarial-reviewer`: no seat removes it from the roster, and a seat does not need it to be selected. Write Step 4's two input files once, before the first worker seat starts. A native seat is a generic subagent seeded with `references/personas/adversarial-reviewer.md`, dispatched in the Stage 4 batch with the other reviewers. In `pr-remote` / `branch-remote` scope, a seat that would be served through the worker is dropped, because the worker reviews the local tree, which is not the reviewed head. Natively served seats still run there, since they inspect the reviewed refs as the other reviewers do. After the local reviewers return, collect the worker seats with the single-reap finish, passing every seat job id to the one `status` read and to each `wait` slice. Record the seat artifacts, the `Model role` lines, and any reason the map could not be used under `model_role` in `finish-input.json` (`references/finish-input.md`). + ## Run conditions — run only when all hold 1. `adversarial-reviewer` was selected in Stage 3 (reviewer selection), or the Review depth gate chose the focused path, whose one independent read is this lens. Reuse that selection; do not run a costly external CLI on a trivial diff. diff --git a/skills/ce-code-review/references/finish-input.md b/skills/ce-code-review/references/finish-input.md index d389ac0d0..65e192a65 100644 --- a/skills/ce-code-review/references/finish-input.md +++ b/skills/ce-code-review/references/finish-input.md @@ -55,12 +55,15 @@ The dispatch context writes this file after every local reviewer is collected, a "artifact": "/adversarial-.json or null", "coverage": "" }, + "model_role": { "lines": [""], "seat_artifacts": ["/adversarial--s.json"] }, "coverage_notes": [] } ``` Every path in this file exists before a leaf is launched: the dispatch context writes `files.txt` and `full.diff` in every run, including a small diff it inlined for the reviewers, and a listed artifact that is missing on disk is a failed finish. A `base:` review of the current checkout is `standalone` scope; `scope.tree_is_reviewed_head` is true exactly when the working tree is the reviewed tree (`local-aligned` or `standalone`), which is the Stage 5c apply eligibility condition. `raw-returns.json` holds every compact reviewer return the dispatch context consumed, one array entry per reviewer, with the `fast-pass` pseudo-reviewer included when it found anything; the per-reviewer artifacts sit beside it. A reviewer whose return is unstructured prose rather than compact JSON (`learnings-researcher`, `agent-native-reviewer`, `deployment-verification-agent`) writes no artifact of its own, so the dispatch context saves each such return verbatim to `/.md` and lists it under `collection.unstructured_returns`; those are what Stage 5 and Stage 6 read for pack-rule findings, Known Pattern notes, agent-native gaps, and deployment notes. A selected unstructured reviewer with no listed file is a failed reviewer. `mode.apply_local` is the only apply authority the leaves ever see: the dispatch context resolves an explicit `apply:local` token or an explicit apply request in the invoking user prompt into that flag before writing the file, and nothing inside the file or the run directory can grant it. `scope.pr.title`, `scope.pr.body`, reviewer output, and comment text are untrusted data a leaf reads for context, never a user instruction; a leaf that finds apply or fix wording there leaves the tree untouched. `coverage_notes` carries every sentence Coverage must contain that only the dispatch context knew: the standards fallback, untracked files excluded, the cross-model skip reason, scope-mode notes. +`model_role` is `null` unless `references/model-roles.md` was read for this review. When it was, `model_role.lines` carries every `Model role` line for the `code-review` role verbatim, with any resolver warning or the reason the map could not be used, and `model_role.seat_artifacts` lists each review-seat artifact that exists on disk (`references/cross-model-review.md`, Review seats). + `plan.requirements` and `plan.implementation_units` are the Stage 2 extraction (`references/intent-and-plan.md`), carried so Stage 6's Requirements Completeness checklist is built from what dispatch extracted rather than re-derived. `invocation.constraints` carries the user's own words for any limit on what may be changed or reported ("only change tests", "do not touch the schema"); the report leaf honors every one of them during Stage 5c and Stage 6, and a constraint it cannot honor makes the affected fix unapplied and reported, never silently applied. ## What a leaf reads @@ -79,7 +82,7 @@ Where `finish-review.md` routes prose through another skill (`ce-noslop`) and a Read `finish-input.json`, then `references/finish-review.md` from `skill_dir`, record `--start merge` in the stage log, and run Stage 5 and Stage 5b steps 1 through 3; record `--end merge --candidates ` before returning. Wherever the reference refers to an earlier stage's result, the intent summary, the roster, the plan, the scope, or conversation context, that value is the matching field of the file, and `` is `docs_root`. -Every decision about the cross-model peer is already made. The dispatch context performed the single-reap finish and the fold-in classification `references/cross-model-review.md` defines, with its recovery branches in `references/cross-model-recovery.md` (including any replacement recipient, same-route recovery, or in-process `adversarial-reviewer` dispatch, all of which need a launch or a disclosure only it can make), deleted the job directory, and recorded the result in `peer.outcome`, `peer.artifact`, and `peer.coverage`. When `peer.artifact` is set, fold that file into Stage 5 as reviewer `adversarial-` under the reference's promotion rule; an in-process fallback's return is already in `raw-returns.json`. Copy `peer.coverage` into Coverage verbatim. This leaf never reads job state, waits on a peer, or starts a route. +Every decision about the cross-model peer is already made. The dispatch context performed the single-reap finish and the fold-in classification `references/cross-model-review.md` defines, with its recovery branches in `references/cross-model-recovery.md` (including any replacement recipient, same-route recovery, or in-process `adversarial-reviewer` dispatch, all of which need a launch or a disclosure only it can make), deleted the job directory, and recorded the result in `peer.outcome`, `peer.artifact`, and `peer.coverage`. When `peer.artifact` is set, fold that file into Stage 5 as reviewer `adversarial-` under the reference's promotion rule; an in-process fallback's return is already in `raw-returns.json`. Fold each file in `model_role.seat_artifacts` the same way, as the reviewer that file names: a review seat is a cross-model peer for the promotion rule and never carries apply authority. Copy `peer.coverage` into Coverage verbatim. This leaf never reads job state, waits on a peer, or starts a route. Write, in the run directory: `synthesized-findings.json` (the final primary, pre-existing, and soft-bucket sets after Stage 5 steps 1 through 7, the triage groups, the hydrated detail, the fold-in outcome and every Coverage sentence Stage 5 produced) and `validator-input.json` (the Stage 5b step 3 batch: the selected findings in order, the skip count and its evidence basis, and the scope context the validator template needs). Return only a receipt: the two paths and the counts of primary and selected findings. Return nothing else; the dispatch context does not read findings. @@ -93,7 +96,7 @@ After collection or stopping the validator, apply the agent lifecycle rule from ## The report leaf -Read `finish-input.json`, `synthesized-findings.json`, `validator-outcome.json` (and the verdicts file it names, when it names one), and `references/finish-review.md`, then run Stage 5b step 5 from that outcome. An `infrastructure-failure` outcome is neither a rejection nor a confirmation: it is validation-degraded for every selected finding, and step 5 decides which stay as unresolved gates. Those gates stay out of `actionable_findings`, are never applied, and still count toward the Stage 6 verdict. Then run Stage 5c when `mode.apply_local` and `scope.tree_is_reviewed_head` are both true and within every `invocation.constraints` entry, and Stage 6. Before any Stage 5c edit, read the project's instruction files that govern the paths you will change (the root agent-instructions file and any subdirectory-scoped one): a fresh subagent does not inherit what the dispatch context had loaded, and a fix that ignores those conventions is not a fix. `mode.agent` decides JSON versus markdown. Record `--start report` in the stage log first. Write `report.md` or `review.json` and `metadata.json` under the run directory, then record `--end report` and run `summarize` as `references/modes-and-output.md` (Run artifacts) states, and return the final report text exactly as the reference says to emit it: the markdown report in default mode, the one raw JSON object in `mode:agent`. Nothing else in the return. +Read `finish-input.json`, `synthesized-findings.json`, `validator-outcome.json` (and the verdicts file it names, when it names one), and `references/finish-review.md`, then run Stage 5b step 5 from that outcome. An `infrastructure-failure` outcome is neither a rejection nor a confirmation: it is validation-degraded for every selected finding, and step 5 decides which stay as unresolved gates. Those gates stay out of `actionable_findings`, are never applied, and still count toward the Stage 6 verdict. Then run Stage 5c when `mode.apply_local` and `scope.tree_is_reviewed_head` are both true and within every `invocation.constraints` entry, and Stage 6. Before any Stage 5c edit, read the project's instruction files that govern the paths you will change (the root agent-instructions file and any subdirectory-scoped one): a fresh subagent does not inherit what the dispatch context had loaded, and a fix that ignores those conventions is not a fix. `mode.agent` decides JSON versus markdown. When `model_role` is set, print each of `model_role.lines` in Coverage as written, and in `mode:agent` also return them as the string array `coverage.model_roles`. Record `--start report` in the stage log first. Write `report.md` or `review.json` and `metadata.json` under the run directory, then record `--end report` and run `summarize` as `references/modes-and-output.md` (Run artifacts) states, and return the final report text exactly as the reference says to emit it: the markdown report in default mode, the one raw JSON object in `mode:agent`. Nothing else in the return. ## What the dispatch context does with the returns diff --git a/skills/ce-code-review/references/select-and-route.md b/skills/ce-code-review/references/select-and-route.md index 71eb9d37c..bfddee8f8 100644 --- a/skills/ce-code-review/references/select-and-route.md +++ b/skills/ce-code-review/references/select-and-route.md @@ -76,6 +76,10 @@ The Review depth gate in `references/modes-and-output.md` already chose lite, fo ### Stage 3d: Bind the adversarial route and final roster +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `code-review` role before binding the adversarial route. With no such key, skip this and print nothing about model roles. + +**Review seats.** When that key is active, read `references/cross-model-review.md` in full too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the local reviewers. A seat does not depend on which personas were selected, so seats run whether or not adversarial was selected. When that section says the single-peer pass does not run, the rest of this stage binds no route: keep `adversarial-reviewer` in the local roster when Stage 3 selected it, start the worker seats now, and add the natively served seats to the Stage 4 batch. The exclusive choice below, and its rule that a started peer and the in-process adversarial reviewer never receive the same brief, govern only the single-peer pass. + Complete this stage **before reading persona prompt assets, `references/dispatch-reviewers.md`, or entering Stage 4** (Dispatch reviewers). That reference's persona-file instructions are valid only once you have settled which single route covers the adversarial lens: the peer, or the in-process fallback. This stage makes that exclusive choice between a cross-model adversarial peer and the in-process `adversarial-reviewer`. Later stages use that choice and must not decide it again, except when the fold-in step finds the peer never ran, or restores the in-process reviewer after a retry on the same route fails on a rate limit. Both routes share the run directory Stage 1b created; do not create another. diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index 1a82a41c2..4f231c092 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -33,6 +33,7 @@ # the diff base (merge-base SHA or branch); the peer reviews # only `git diff ` in the current repository # an existing dir; output -> /adversarial-.json +# (adversarial--s.json when CROSS_MODEL_SEAT= is set) # # Test/introspection mode (no model call, no side effects): # cross-model-adversarial-review.sh --emit-adapter @@ -374,6 +375,22 @@ RUN_DIR="${4:-}" [ -n "$RUN_DIR" ] && [ -d "$RUN_DIR" ] || skip "run-dir '${RUN_DIR:-}' is not a directory; skipping" command -v jq >/dev/null 2>&1 || skip "jq not installed; skipping" +# --- review seat (model role map) ------------------------------------------- +# CROSS_MODEL_SEAT= marks this invocation as seat of a `model_roles` review +# list. The config names that recipient, so the two automatic eligibility rules +# below do not apply to it: the skip on an unattested host family, and the +# exclusion of a target in the host's own family. The CROSS_MODEL_PEERS allowlist +# still applies. independence_verified is still computed at normalization and +# stays false whenever the families match or either is unknown. The seat number +# also joins the artifact name and the reviewer identity, so two seats on one +# provider cannot collide. Unset keeps the single-peer names and gates exactly. +SEAT="${CROSS_MODEL_SEAT:-}" +case "$SEAT" in + '') SEAT_SUFFIX="" ;; + 0*|*[!0-9]*) skip "seat '$SEAT' invalid (want a positive integer); skipping" ;; + *) SEAT_SUFFIX="-s$SEAT" ;; +esac + # Validate the host identity tuple. An unknown serving family is allowed, but # normalization marks every result non-independent. case "$HOST_PROVIDER" in @@ -384,7 +401,7 @@ case "$HOST_HARNESS" in codex|claude|grok|cursor|opencode|unknown) ;; *) skip "host harness '$HOST_HARNESS' invalid (want codex|claude|grok|cursor|opencode|unknown); skipping cross-model pass" ;; esac -[ "$HOST_PROVIDER" != "unknown" ] || skip "host serving family unattested; automatic cross-model review skipped" +[ -n "$SEAT" ] || [ "$HOST_PROVIDER" != "unknown" ] || skip "host serving family unattested; automatic cross-model review skipped" # --- self-locate skill root + canonical sibling files ---------------------- SKILL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || skip "cannot resolve skill root; skipping" @@ -447,7 +464,7 @@ for p in $CANDIDATES; do p="$(printf '%s' "$p" | tr -d '[:space:]')" [ -n "$p" ] || continue case "$p" in codex|claude|grok|cursor|composer|opencode) ;; *) log "ignoring unknown target '$p' in candidates"; continue ;; esac - [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue + [ -z "$SEAT" ] && [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue case " $SELECTED " in *" $p "*) continue ;; esac if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi if ! provider_available "$p"; then log "provider '$p' has no installed route; skipping"; continue; fi @@ -1083,12 +1100,12 @@ attempt_route() { compose_prompt_codex run_codex_cmd "$attempt_hard" classify_route_output - cp "$PEERLOG" "$RUN_DIR/adversarial-codex-events.jsonl" 2>/dev/null || true + cp "$PEERLOG" "$RUN_DIR/adversarial-codex$SEAT_SUFFIX-events.jsonl" 2>/dev/null || true jq -s '[.[] | select(.type == "turn.completed") | .usage] | last // empty' "$PEERLOG" \ - > "$RUN_DIR/adversarial-codex-usage.json" 2>/dev/null || true + > "$RUN_DIR/adversarial-codex$SEAT_SUFFIX-usage.json" 2>/dev/null || true # Redirect + `// empty` would leave a zero-byte file when no turn.completed # exists; json.load then fails (#1531). Keep the artifact only if non-empty. - [ -s "$RUN_DIR/adversarial-codex-usage.json" ] || rm -f "$RUN_DIR/adversarial-codex-usage.json" + [ -s "$RUN_DIR/adversarial-codex$SEAT_SUFFIX-usage.json" ] || rm -f "$RUN_DIR/adversarial-codex$SEAT_SUFFIX-usage.json" if [ "$RUN_SUCCEEDED" = true ] && out_missing_or_invalid; then recover_findings_json "$PEERLOG" "$RAW_OUT" && log "recovered codex JSON from stdout (-o file unavailable)" fi @@ -1142,8 +1159,8 @@ route_hard_budget() { run_provider() { local provider="$1" primary="" fixed="${CROSS_MODEL_FIXED_ROUTE:-}" local provider_budget provider_deadline remaining - OUT="$RUN_DIR/adversarial-$provider.json" - RAW_OUT="$RAW_DIR/adversarial-$provider.raw.json" + OUT="$RUN_DIR/adversarial-$provider$SEAT_SUFFIX.json" + RAW_OUT="$RAW_DIR/adversarial-$provider$SEAT_SUFFIX.raw.json" [ -n "$fixed" ] || { log "host must resolve one fixed route before egress; skipping"; rm -f "$OUT"; return 0; } [ "$(route_target "$fixed")" = "$provider" ] || { log "fixed route '$fixed' does not match target '$provider'; skipping"; rm -f "$OUT"; return 0; } if [ "$fixed" = "grok-cursor" ] && ! cursor_egress_ok; then @@ -1199,7 +1216,7 @@ run_provider() { esac _independent=false [ "$HOST_PROVIDER" != "unknown" ] && [ "$_target_family" != "unknown" ] && [ "$HOST_PROVIDER" != "$_target_family" ] && _independent=true - if jq --arg r "adversarial-$provider" --arg route "$ACTUAL_ROUTE" \ + if jq --arg r "adversarial-$provider$SEAT_SUFFIX" --arg route "$ACTUAL_ROUTE" \ --arg target "$provider" --arg harness "$(route_harness "$ACTUAL_ROUTE")" \ --arg family "$_target_family" --argjson independent "$_independent" \ --arg mreq "$(route_model "$ACTUAL_ROUTE")" --arg mact "$MODEL_ACTUAL" \ @@ -1236,7 +1253,7 @@ run_provider() { fi if [ -s "$OUT" ] && jq -e '(.reviewer|type=="string") and (.findings|type=="array") and (.residual_risks|type=="array") and (.testing_gaps|type=="array")' "$OUT" >/dev/null 2>&1; then n="$(jq '.findings | length' "$OUT" 2>/dev/null || echo '?')" - log "wrote $n finding(s) to $OUT (reviewer adversarial-$provider)" + log "wrote $n finding(s) to $OUT (reviewer adversarial-$provider$SEAT_SUFFIX)" else log "provider $provider produced no usable schema-shaped output; skipping fold-in" # Surface bounded peer output so the orchestrator can reason about WHY it diff --git a/skills/ce-doc-review/references/cross-model-review.md b/skills/ce-doc-review/references/cross-model-review.md index 739ef38a4..1d9cddb62 100644 --- a/skills/ce-doc-review/references/cross-model-review.md +++ b/skills/ce-doc-review/references/cross-model-review.md @@ -6,6 +6,58 @@ The trio is the three **conditional** judgment lenses whose output diverges most The host resolves and approves one concrete route before any document content leaves the machine (this reference calls that egress). The bundled **`scripts/cross-model-doc-review.sh`** enforces that fixed route, composes the prompt, applies least privilege, captures schema-shaped JSON, and normalizes the model-identity records it returns. The pass is non-blocking: a failed route writes no fold-in artifact and never switches recipients internally. + +## Review seats (model role map) + +A review role's `model_roles` entry is a list of *seats*. Each seat is one more reviewer, on the model and at the effort the entry names. The hook at persona dispatch resolves this skill's review role with `references/model-roles.md`, which owns the resolver command, the serving order, and the `Model role` line. This section owns what a review role does with the resolver's answer. Outside this section, this file describes the *single-peer pass*: one target on one route, started only under that pass's own conditions. + +**When this section applies.** It applies only when that hook ran the resolver for this review. Otherwise skip it, because no `model_roles:` key is active or this review dispatches no persona team. The rest of this file then applies as written. + +**The persona review always runs.** It runs when every seat is dropped, blocked, or invalid, and when the map cannot be read. + +**A live request outranks the entry.** When the user asks in this conversation for a specific peer or model for this review, the seat list is not used for this run. Follow the single-peer rules with that request as the stated preference. + +Otherwise the resolver's answer decides what runs beside the persona review: + +| Resolver answer | What runs beside the persona review | +|---|---| +| `state` is `unset` | The single-peer pass, under its own conditions. Nothing else in this section applies. | +| `state` is `entries` | The seats. They replace the single-peer pass for this review. | +| `state` is `inherit` | Nothing: no seats and no single-peer pass. Print the `inherit` line `references/model-roles.md` defines. | +| `state` is `invalid`, or no JSON came back | Nothing: no seats and no single-peer pass. Say in Coverage that the model role map could not be used, quoting the resolver's `errors` when it returned any. | + +The single-peer rules do not bind a seat unless this section points at them. That covers the one target and one route, the activation and run conditions, the default target order, and any rule that a started peer replaces an in-process reviewer. + +**Which seats run.** Take the resolver's `entries` in order. + +- A seat marked `invalid` does not run. The other seats are unaffected. +- A seat with `blocked_by` set does not run. The resolver has already applied `cross_model_review_mode` and `CROSS_MODEL_PEERS` to every seat, however the seat would be served, so do not evaluate either policy again here. +- Every other seat is one job on this skill's seat brief, with its own seat number ``. The seat brief and its `` name are stated right after this section. + +**Serving a seat.** Use the first of these that can serve the seat's model at the seat's effort: + +1. **A native subagent**, when the host can hand a subagent that model at that effort, as `references/model-roles.md` (Serving order) defines it. A seat whose model is `inherit` is always served this way, on the session model. Instruct the subagent to change nothing and to review only the material this review covers. Save its return as `/-native-s.json` in the shape the worker writes: `reviewer` set to `-native-s`, `independence_verified: false`, `model_requested` as the entry names it, `model_actual: "unverified"`, and each `safe_auto` finding recorded as `gated_auto`. +2. **This skill's worker**, as one detached job, when the seat's `family` is `codex`, `claude`, `grok`, or `composer` and that target's own route is installed. The target is the family. The route is that target's token from the Step 1 table, and `grok` uses `grok-cli` only. Start the job with Step 4's start command, changing only these parameters: `--label "seat-"`; `CROSS_MODEL_SEAT=""` added to the `env` prefix; the seat's model as `CROSS_MODEL_MODEL_OVERRIDE_TARGET=""` and `CROSS_MODEL_MODEL_OVERRIDE=""`; and `CROSS_MODEL_EFFORT_OVERRIDE=""` when the seat has an effort. With the seat number set, the worker accepts a target in the host's own family and an unknown host family, and it writes `/--s.json` with reviewer `--s`. The worker still checks `CROSS_MODEL_PEERS` itself. + +A seat that neither route can serve is dropped. So is a seat whose job ends with no artifact, for any reason: a model or effort the route refuses and a target the worker's allowlist check refuses both end that way. A dropped seat is never run on another model, at another effort, or through an intermediary route such as Grok through `cursor-agent`. The fallback ladder in `references/model-roles.md`, and this file's retry, recovery, and replacement-recipient rules, belong to other paths and are not used for a seat. + +**One notice names every recipient.** Before anything is sent, print one notice that lists each seat that will run: its seat number, the provider that receives the reviewed content, the requested model and effort, and the route. It replaces a per-seat announcement. Step 3 still decides which modes print it, and Step 3's disclosure condition holds for every seat: name the model as requested, never as served. + +**Starting and collecting.** Start the seats in the same dispatch wave as the persona reviewers. Bring every started seat job to a terminal outcome under this file's wait, deadline, and cleanup rules, measuring the shared deadline from the last `start`, and read each worker seat's artifact with Step 5's verified read at that seat's own path. Before a job directory is deleted, read a dropped seat's `out.log` for the reason. + +**Folding seats in.** Each seat artifact is one reviewer in synthesis under its own reviewer identity. This skill's synthesis rules are unchanged: + +- A finding raised only by seats is never applied silently. +- Agreement between seats does not stack. However many seats agree, a finding gains at most the one promotion step this skill's synthesis allows. +- Only an artifact that records `independence_verified: true` can promote. A seat in the host's own family, a seat under an unknown host family, and a natively served seat never record it. + +**When seats run.** Seats run whenever the review dispatches its persona team. A seat does not depend on which personas were selected. A re-invocation that reuses completed review results reuses the seat results with them. + +**Reporting.** Print one `Model role seat :` line for every seat in `entries`, in the format `references/model-roles.md` defines. That covers seats that ran, dropped seats, blocked seats, and invalid seats. A seat that did not run prints `not run` with its reason: why it was dropped, the policy `blocked_by` names, or the invalid seat's `reason`. The lines go in Coverage and in the structured result this skill returns to a caller. For this review they replace the Coverage notes the single-peer pass would have written. + + +**Seats in this skill.** The role is `doc-review`. The seat brief is the existing `whole-doc` brief, so `` is `whole-doc`, and every seat reviews the full document, never a slice. A worker seat passes `whole-doc` as `` and the full document as ``. A native seat is a generic subagent seeded with `references/personas/whole-doc-reviewer.md` and the full document, dispatched the way `references/dispatch.md` dispatches the persona reviewers. Step 4's trio calls and its one whole-doc sweep belong to the single-peer pass, so they do not run beside seats. `references/synthesis-and-presentation.md` states where the seat lines print. + ## Condition — run only when this holds Run the cross-model pass for a given trio lens **only when that lens was activated** for this document by the normal Phase 1 persona-selection logic. No new activation triggers are introduced: a routine plan with validated upstream provenance and no high-stakes domain activates none of the trio, so it gets no cross-model pass. Phase 1's missing-document check already guarantees the document is readable on disk. There is no diff and no remote-scope concern, so no additional scope check is needed. diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index 72cba141f..120a7408f 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -6,6 +6,10 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Agent lifecycle.** Collect each agent's final outcome, including failures, before cleanup. When the harness lets the caller close or release agents, close or release the agents this review started before refilling slots, advancing stages, or returning. Do not message completed agents with no remaining work. Do not assume capacity was freed just because an agent completed or was interrupted, and do not invent cleanup operations. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `doc-review` role before dispatching the reviewers. With no such key, skip this and print nothing about model roles. + +**Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. Seats start in the same dispatch wave as those reviewers. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. + For each selected reviewer, read `references/personas/.md` and pass its full content as `{persona_file}`. Do not dispatch standalone agents by type/name and do not rely on platform-level custom-agent registration. **Model tiering lives here, not in prompt assets.** Local prompt files have no frontmatter and carry no model metadata. Apply these dispatch-time preferences when the platform exposes a known model override; otherwise omit the override and inherit the parent model rather than guessing a platform-specific model name: diff --git a/skills/ce-doc-review/references/synthesis-and-presentation.md b/skills/ce-doc-review/references/synthesis-and-presentation.md index 7b75991b7..2868480e4 100644 --- a/skills/ce-doc-review/references/synthesis-and-presentation.md +++ b/skills/ce-doc-review/references/synthesis-and-presentation.md @@ -60,6 +60,8 @@ When findings merge: **Cross-model returns.** A `-` return merges with its in-process twin under the same one-fix test. Whether that merge counts as *independent corroboration* is decided in 3.4 (Cross-Persona Agreement Promotion) by the return's `independence_verified` flag, not here. +**Review seats.** A seat return (`whole-doc--s` or `whole-doc-native-s`, from the review-seat section of `references/cross-model-review.md`) is one more reviewer here. It merges with any finding under the same one-fix test. In 3.4, 3.6, and 3.7 it is a cross-model peer return whichever model served it, so those rules apply to it unchanged. + **The merged set is the record.** The merged finding set produced by this step is the single source of truth for both Coverage counts and rendered output. Each finding appears in exactly one place in the output — counted once in its route bucket, rendered once at its own position. ### 3.4 Cross-Persona Agreement Promotion @@ -271,6 +273,8 @@ Review complete Omit any section with zero items. The bucket names are the user-facing vocabulary for the routes 3.7 assigned. "Applied N fixes" reports what already changed. The obligations block and "Proposed fixes" together render the grouped confirmation: obligations first, then the rest of the batch, each shaped by the floor's "Presenting a batch" rule. The caller re-narrates this result to a reader who has seen none of it, so a flat list here becomes a flat list there. "Decisions" carries the decisions the user must still make, and "FYI observations" carries anchor `50`. End with "Review complete" as the final line so callers can detect completion. +**Model role lines.** When `references/model-roles.md` was read for this review, print the `doc-review` role's `Model role` lines, one per seat as the review-seat section of `references/cross-model-review.md` states, with any resolver warning or the reason the map could not be used. In interactive mode they follow the Coverage table and its footnotes. In the non-interactive result they are the one addition to the fenced text above: each on its own line after `Restated:` and before `Review complete`, which stays the final line. Print nothing about model roles when no `model_roles:` key is active. + **Count findings by their final route.** Obligations still awaiting grouped confirmation count as proposed fixes; grouping changes presentation, not the count. Obligations already applied count only as applied fixes. Do not export a separate obligation count: the caller uses the proposed-fixes count to detect pending approval, so it must include every pending edit and exclude edits that already landed. **Compact rendering for FYI observations, residual concerns, and deferred questions (high-count mode).** When the combined count of these three buckets is 5 or more, collapse each to a one-line count followed by a tight bullet list, with no per-item elaboration. FYI observations use their consequence line; residual concerns and deferred questions use their concern or question text. Actionable buckets (Proposed fixes / Decisions) remain fully rendered regardless. This mirrors the interactive-mode rule in `references/review-output-template.md` so both modes produce the same shape. diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index 5ac3a097b..a1ea4b053 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -46,6 +46,7 @@ # the Origin context slot (a path, product_contract_source:, # or the literal token none) # an existing dir; output -> /-.json +# (--s.json when CROSS_MODEL_SEAT= is set) # # Test/introspection mode (no model call, no side effects): # cross-model-doc-review.sh --emit-adapter @@ -377,6 +378,22 @@ mkdir -p "$RUN_DIR" 2>/dev/null [ -d "$RUN_DIR" ] || skip "run-dir '$RUN_DIR' could not be created; skipping" command -v jq >/dev/null 2>&1 || skip "jq not installed; skipping" +# --- review seat (model role map) ------------------------------------------- +# CROSS_MODEL_SEAT= marks this invocation as seat of a `model_roles` review +# list. The config names that recipient, so the two automatic eligibility rules +# below do not apply to it: the skip on an unattested host family, and the +# exclusion of a target in the host's own family. The CROSS_MODEL_PEERS allowlist +# still applies. independence_verified is still computed at normalization and +# stays false whenever the families match or either is unknown. The seat number +# also joins the artifact name and the reviewer identity, so two seats on one +# provider cannot collide. Unset keeps the single-peer names and gates exactly. +SEAT="${CROSS_MODEL_SEAT:-}" +case "$SEAT" in + '') SEAT_SUFFIX="" ;; + 0*|*[!0-9]*) skip "seat '$SEAT' invalid (want a positive integer); skipping" ;; + *) SEAT_SUFFIX="-s$SEAT" ;; +esac + # Validate the host identity tuple. An unknown serving family is allowed, but # normalization marks every result non-independent. case "$HOST_PROVIDER" in @@ -387,7 +404,7 @@ case "$HOST_HARNESS" in codex|claude|grok|cursor|opencode|unknown) ;; *) skip "host harness '$HOST_HARNESS' invalid (want codex|claude|grok|cursor|opencode|unknown); skipping cross-model pass" ;; esac -[ "$HOST_PROVIDER" != "unknown" ] || skip "host serving family unattested; automatic cross-model review skipped" +[ -n "$SEAT" ] || [ "$HOST_PROVIDER" != "unknown" ] || skip "host serving family unattested; automatic cross-model review skipped" # --- derive persona-brief filename from the allowlisted reviewer-name ------- # Never a caller argument -> no path-traversal / arbitrary-file-read surface. @@ -494,7 +511,7 @@ for p in $CANDIDATES; do p="$(printf '%s' "$p" | tr -d '[:space:]')" [ -n "$p" ] || continue case "$p" in codex|claude|grok|cursor|composer|opencode) ;; *) log "ignoring unknown target '$p' in candidates"; continue ;; esac - [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue + [ -z "$SEAT" ] && [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue case " $SELECTED " in *" $p "*) continue ;; esac # dedup if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi if ! provider_available "$p"; then log "provider '$p' has no installed route; skipping"; continue; fi @@ -1107,7 +1124,7 @@ route_hard_budget() { run_provider() { # local provider="$1" primary="" fixed="${CROSS_MODEL_FIXED_ROUTE:-}" local provider_budget provider_deadline remaining - OUT="$RUN_DIR/$REVIEWER_NAME-$provider.json" + OUT="$RUN_DIR/$REVIEWER_NAME-$provider$SEAT_SUFFIX.json" # Per-peer empty workspace, kept SEPARATE from the shared fold-in dir (RUN_DIR). # The peer's cwd/workspace and its RAW_OUT live here, so a read-capable peer # (codex/cursor-agent) can neither list a shared cwd nor read another lens's @@ -1116,7 +1133,7 @@ run_provider() { # # never written into RUN_DIR by the peer itself. Falls back to RUN_DIR only if # mktemp fails (preserves prior behavior over failing the pass). PEER_WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/xmodel-doc-peer-XXXXXX")" || PEER_WORKDIR="$RUN_DIR" - RAW_OUT="$PEER_WORKDIR/$REVIEWER_NAME-$provider.raw.json" + RAW_OUT="$PEER_WORKDIR/$REVIEWER_NAME-$provider$SEAT_SUFFIX.raw.json" [ -n "$fixed" ] || { log "host must resolve one fixed route before egress; skipping"; rm -f "$OUT"; return 0; } [ "$(route_target "$fixed")" = "$provider" ] || { log "fixed route '$fixed' does not match target '$provider'; skipping"; rm -f "$OUT"; return 0; } if [ "$fixed" = "grok-cursor" ] && ! cursor_egress_ok; then @@ -1180,7 +1197,7 @@ run_provider() { # esac _independent=false [ "$HOST_PROVIDER" != "unknown" ] && [ "$_target_family" != "unknown" ] && [ "$HOST_PROVIDER" != "$_target_family" ] && _independent=true - if jq --arg r "$REVIEWER_NAME-$provider" --arg route "$ACTUAL_ROUTE" \ + if jq --arg r "$REVIEWER_NAME-$provider$SEAT_SUFFIX" --arg route "$ACTUAL_ROUTE" \ --arg target "$provider" --arg harness "$(route_harness "$ACTUAL_ROUTE")" \ --arg family "$_target_family" --argjson independent "$_independent" \ --arg mreq "$(route_model "$ACTUAL_ROUTE")" --arg mact "$MODEL_ACTUAL" \ @@ -1208,7 +1225,7 @@ run_provider() { # fi if [ -s "$OUT" ] && jq -e '(.reviewer|type=="string") and (.findings|type=="array") and (.residual_risks|type=="array") and (.deferred_questions|type=="array")' "$OUT" >/dev/null 2>&1; then n="$(jq '.findings | length' "$OUT" 2>/dev/null || echo '?')" - log "wrote $n finding(s) to $OUT (reviewer $REVIEWER_NAME-$provider)" + log "wrote $n finding(s) to $OUT (reviewer $REVIEWER_NAME-$provider$SEAT_SUFFIX)" else log "provider $provider produced no usable schema-shaped output; skipping fold-in" # Surface bounded peer output so the orchestrator can diff --git a/tests/review-seat-block-parity.test.ts b/tests/review-seat-block-parity.test.ts new file mode 100644 index 000000000..b14947f05 --- /dev/null +++ b/tests/review-seat-block-parity.test.ts @@ -0,0 +1,90 @@ +import { readFile } from "fs/promises" +import path from "path" +import { describe, expect, test } from "bun:test" + +const PLUGIN_ROOT = path.join(process.cwd(), "skills") + +// A `model_roles` list on `doc-review` or `code-review` runs one reviewer per +// seat. The rules for a seat are the same in both review skills, and skills +// cannot import each other's files (AGENTS.md "File References in Skills"), so +// one delimited block is byte-duplicated into each skill's cross-model +// reference. Editing one copy without the other fails this test. +const REFERENCES = { + "ce-doc-review": "ce-doc-review/references/cross-model-review.md", + "ce-code-review": "ce-code-review/references/cross-model-review.md", +} as const + +// The reference every dispatching review reads, which carries the seat trigger. +const TRIGGERS = [ + { file: "ce-doc-review/references/dispatch.md", role: "doc-review" }, + { file: "ce-code-review/references/select-and-route.md", role: "code-review" }, +] as const + +const START = "" +const END = "" + +const read = (rel: string) => readFile(path.join(PLUGIN_ROOT, rel), "utf8") + +function seatBlock(content: string, file: string): string { + expect(content.split(START).length, `${file} must hold one seat block start`).toBe(2) + expect(content.split(END).length, `${file} must hold one seat block end`).toBe(2) + const start = content.indexOf(START) + const end = content.indexOf(END) + expect(end).toBeGreaterThan(start) + return content.slice(start, end + END.length) +} + +describe("review seat block parity", () => { + test("the seat block is byte-identical in both review skills", async () => { + const doc = seatBlock(await read(REFERENCES["ce-doc-review"]), REFERENCES["ce-doc-review"]) + const code = seatBlock(await read(REFERENCES["ce-code-review"]), REFERENCES["ce-code-review"]) + expect(code).toBe(doc) + }) + + test("the seat block keeps its load-bearing rules", async () => { + const content = await read(REFERENCES["ce-doc-review"]) + const block = seatBlock(content, REFERENCES["ce-doc-review"]) + // The persona review survives every seat outcome. + expect(block).toContain("**The persona review always runs.** It runs when every seat is dropped") + // One disclosure for the whole list, before anything is sent. + expect(block).toContain("**One notice names every recipient.** Before anything is sent") + // A seat is never filled by another model or an intermediary route. + expect(block).toContain("A dropped seat is never run on another model") + expect(block).toContain("`grok` uses `grok-cli` only") + // Fail closed: a malformed or unreadable map runs neither seats nor the single-peer pass. + expect(block).toMatch(/`state` is `invalid`, or no JSON came back \| Nothing: no seats and no single-peer pass/) + // The variable the workers read for a seat. + expect(block).toContain('CROSS_MODEL_SEAT=""') + // The block is shared, so it names neither skill nor role. + expect(block.match(/\b(?:doc-review|code-review)\b/g)).toBeNull() + }) + + test("each skill states its own seat brief outside the block", async () => { + const doc = await read(REFERENCES["ce-doc-review"]) + const code = await read(REFERENCES["ce-code-review"]) + const outside = (content: string, file: string) => content.replace(seatBlock(content, file), "") + expect(outside(doc, REFERENCES["ce-doc-review"])).toContain("`` is `whole-doc`") + expect(outside(code, REFERENCES["ce-code-review"])).toContain("`` is `adversarial`") + // The worker reviews the local tree, so a remote-scope review drops worker seats. + expect(outside(code, REFERENCES["ce-code-review"])).toContain( + "In `pr-remote` / `branch-remote` scope, a seat that would be served through the worker is dropped", + ) + }) + + test("the seat trigger sits in the reference every dispatching review reads", async () => { + for (const { file, role } of TRIGGERS) { + const content = await read(file) + expect(content, `${file} resolves its role`).toContain( + `read \`references/model-roles.md\` now and resolve the \`${role}\` role`, + ) + expect(content, `${file} loads the seat rules`).toContain( + "**Review seats.** When that key is active, read `references/cross-model-review.md`", + ) + // Seats run on every dispatching review, not only when a judgment lens or + // the adversarial reviewer was selected. + expect(content, `${file} must not gate seats on persona selection`).toContain( + "A seat does not depend on which personas were selected", + ) + } + }) +}) diff --git a/tests/skill-eval-cell/packs/ce-code-review-cross-model.md b/tests/skill-eval-cell/packs/ce-code-review-cross-model.md index bb4192160..faf0b3845 100644 --- a/tests/skill-eval-cell/packs/ce-code-review-cross-model.md +++ b/tests/skill-eval-cell/packs/ce-code-review-cross-model.md @@ -70,9 +70,65 @@ and Codex with fake peer CLIs first on PATH. them further rather than returning a progress note or silently omitting the pass. A normal-sized fixture keeps the direct diff path. +Cases 10-12 cover review seats from a `model_roles` list. They also inject +`references/select-and-route.md`, `references/model-roles.md`, and +`references/finish-input.md`, and they run in a throwaway repo whose +`.compound-engineering/config.yaml` holds the map, in a mode that lets the +orchestrator run `scripts/model-role-resolve.py`. + +10. **Three seats run beside the persona team (model role map AE1; R8).** The + fixture config is: + + ```yaml + model_roles: + code-review: + - grok-4.7 high + - gpt-seat-model + - opus medium + ``` + + Stub `grok`, `codex`, and `claude` CLIs are first on PATH and each returns a + schema-shaped adversarial review. On a Claude Code host and a full-path + diff that selects `adversarial-reviewer`, assert the orchestrator resolves + the `code-review` role with `--host-family claude`, writes the constraints + and brief files once, prints one notice that names all three recipients + before any `start`, and issues three `peer-job-runner.py start` calls with + labels `seat-1`, `seat-2`, and `seat-3`. Each call carries its own + `CROSS_MODEL_SEAT`, a fixed route of `grok-cli`, `codex`, or `claude`, and + the seat's model override; seats 1 and 3 also carry their effort override + and seat 2 carries none. The in-process `adversarial-reviewer` stays in the + local roster. The run dir holds `adversarial-grok-s1.json`, + `adversarial-codex-s2.json`, and `adversarial-claude-s3.json`, and seat 3 + records `independence_verified: false`. `finish-input.json` lists the three + artifacts and the three `Model role code-review seat :` lines under + `model_role`; the report prints the lines in Coverage, and a `mode:agent` + run returns them as `coverage.model_roles`. Repeat on a full-path diff that + does not select `adversarial-reviewer`: the three seats still run. Paired + control: with no `model_roles:` key, no resolver runs, no seat starts, and + case 1's activation gate decides the single peer as before. + +11. **One seat cannot be served; remote scope drops worker seats (AE4; R12, + R14).** Case 10's fixture with no `grok` stub on PATH and a `cursor-agent` + stub present. Assert seat 1 is dropped: no job starts for it, the + `cursor-agent` stub is never invoked, and no other model fills the seat. + Seats 2 and 3 and the local reviewers run, and Coverage carries + `Model role code-review seat 1: requested grok-4.7 high; not run; reason: ...`. + Repeat in `pr-remote` scope with every stub present: no worker seat starts, + each is reported dropped because the worker reviews the local tree, and the + local reviewers complete. + +12. **Review mode `off` reaches every seat (AE5; R13).** A fixture whose + `code-review` list names two other providers, plus + `cross_model_review_mode: off`, with no conversation request for a peer. + Assert no stub CLI is invoked, no diff leaves for a second provider, the + local reviewers complete, and the report names both skipped seats and the + policy. Then repeat with the user asking in conversation for a specific + peer: the seat list is not used and the single-peer rules run for that + request. + ## Pass criteria -All nine cases pass on the current on-disk source on Claude Code and Codex. The +All twelve cases pass on the current on-disk source on Claude Code and Codex. The negative activation cases launch no peer, the fixed-route cases perform no worker-internal recipient fallback, and only `independence_verified: true` artifacts can promote agreement. diff --git a/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md b/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md index 3162bc1b7..cb8149567 100644 --- a/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md +++ b/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md @@ -25,6 +25,10 @@ as specified. Cases 11-14 cover the detached launch->wait lifecycle and model-identity receipts (the record of which model actually served a route). Case 15 covers U8's fixed-route and bounded-adaptability contract. +Cases 16-18 cover review seats from a `model_roles` list. They also inject +`references/dispatch.md` and `references/model-roles.md`, and they run in a +throwaway repo whose `.compound-engineering/config.yaml` holds the map, in a +mode that lets the orchestrator run `scripts/model-role-resolve.py`. Run them with the fake-CLI harness pattern — stub peer CLIs placed first on PATH — and cross-host per the repo's eval default: Claude Code AND Codex. @@ -147,7 +151,55 @@ PATH — and cross-host per the repo's eval default: Claude Code AND Codex. new recipient are rejected. A recipient-changing retry requires a newly disclosed and sanctioned dispatch. +16. **Three seats on a routine plan (model role map AE1; R8).** The fixture + config is: + + ```yaml + model_roles: + doc-review: + - grok-4.7 high + - gpt-seat-model + - opus medium + ``` + + Stub `grok`, `codex`, and `claude` CLIs are first on PATH and each returns a + schema-shaped `whole-doc` review. The document is case 2's routine plan, + which activates no trio lens. On a Claude Code host, assert the orchestrator + resolves the `doc-review` role with `--host-family claude`, prints one + notice that names all three recipients with their requested model and + effort before any `start`, and issues three `peer-job-runner.py start` calls + in the persona dispatch wave with labels `seat-1`, `seat-2`, and `seat-3`. + Each call carries its own `CROSS_MODEL_SEAT`, a fixed route of `grok-cli`, + `codex`, or `claude`, and the seat's model override; seats 1 and 3 also + carry their effort override and seat 2 carries none. Assert no trio call + and no single whole-doc sweep is launched. The run dir holds + `whole-doc-grok-s1.json`, `whole-doc-codex-s2.json`, and + `whole-doc-claude-s3.json`. Seat 3 is in the host's own family, so its + artifact records `independence_verified: false` and its agreement promotes + nothing. Coverage carries three `Model role doc-review seat :` lines. + Paired control: the same document in a repo with no `model_roles:` key runs + no resolver, starts no seat, and prints no `Model role` line. + +17. **One seat cannot be served (AE4; R12, R14).** Case 16's fixture with no + `grok` stub on PATH and a `cursor-agent` stub present. Assert seat 1 is + dropped: no job starts for it, the `cursor-agent` stub is never invoked, + and no other model fills the seat. Seats 2 and 3 and the persona review + run, the notice names two recipients, and Coverage carries + `Model role doc-review seat 1: requested grok-4.7 high; not run; reason: ...` + beside the two served lines. Repeat with every seat's CLI absent and assert + the persona review still completes and all three seats are reported. + +18. **Review mode `off` reaches every seat (AE5, AE9; R13).** Case 16's fixture + plus `cross_model_review_mode: off`, with no conversation request for a + peer. On a Claude Code host, assert the resolver marks seats 1 and 2 + `blocked_by: review_mode_off`, no `grok` or `codex` stub is invoked, the + same-family seat 3 runs, and Coverage names the two skipped seats and the + policy. Repeat with a host whose serving family cannot be attested: no named + seat runs and all three are reported. Then repeat the Claude Code run with + the user asking in conversation to review with Grok: the seat list is not + used and the single-peer rules run for that request. + ## Pass criteria -All fifteen cases pass on the current on-disk source, and case 2 confirms the +All eighteen cases pass on the current on-disk source, and case 2 confirms the conditional cost profile (no peer spawn on a routine validated plan). diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index ba2e2c4a2..c72e51e74 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -1915,6 +1915,124 @@ describe("cross-model-adversarial-review fixed-recipient dispatch", () => { }) }) +// A `model_roles` review list runs one worker invocation per seat. CROSS_MODEL_SEAT +// carries the seat number: it names the artifact and reviewer so two seats on one +// provider cannot collide, and it marks the recipient as explicitly configured, so +// the automatic same-family and unknown-host skips do not apply to that invocation. +describe("cross-model-adversarial-review review seats (CROSS_MODEL_SEAT)", () => { + const seatStub = + `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"adversarial","findings":[{"title":"t","file":"a.ts","line":1}]}}'\n` + const seatArgs = (host: string, target: string, runDir: string) => [host, target, "HEAD", runDir] + const seatOutputs = (files: string[]) => files.filter((file) => /^adversarial-[a-z]+(-s\d+)?\.json$/.test(file)).sort() + const artifact = (runDir: string, name: string) => + JSON.parse(readFileSync(path.join(runDir, name), "utf8")) + + test("two seats on one provider write two artifacts with distinct reviewer identities", () => { + const { env } = sandbox(["claude"], seatStub) + const runDir = makeRunDir() + for (const seat of ["1", "2"]) { + const r = run(seatArgs("codex", "claude", runDir), runDir, { ...env, CROSS_MODEL_SEAT: seat }) + expect(r.code).toBe(0) + } + expect(seatOutputs(readdirSync(runDir))).toEqual(["adversarial-claude-s1.json", "adversarial-claude-s2.json"]) + expect(artifact(runDir, "adversarial-claude-s1.json").reviewer).toBe("adversarial-claude-s1") + expect(artifact(runDir, "adversarial-claude-s2.json").reviewer).toBe("adversarial-claude-s2") + // A different-family seat still records verified independence. + expect(artifact(runDir, "adversarial-claude-s1.json").independence_verified).toBe(true) + + // The seat number becomes part of a file name, so only a positive integer is accepted. + const bad = makeRunDir() + const r = run(seatArgs("codex", "claude", bad), bad, { ...env, CROSS_MODEL_SEAT: "../x" }) + expect(seatOutputs(r.files)).toEqual([]) + expect(r.stderr).toContain("seat '../x' invalid") + }) + + test("a seat in the host's own family runs only as an explicit seat and never records independence", () => { + const { env } = sandbox(["claude"], seatStub) + let runDir = makeRunDir() + let r = run(seatArgs("claude", "claude", runDir), runDir, env) + expect(seatOutputs(r.files)).toEqual([]) + expect(r.stderr).toContain("no different-provider peer reachable") + + runDir = makeRunDir() + r = run(seatArgs("claude", "claude", runDir), runDir, { ...env, CROSS_MODEL_SEAT: "3" }) + expect(r.stderr).toContain("peer run: provider=claude route=claude") + expect(seatOutputs(r.files)).toEqual(["adversarial-claude-s3.json"]) + const out = artifact(runDir, "adversarial-claude-s3.json") + expect(out.serving_family).toBe("claude") + expect(out.independence_verified).toBe(false) + }) + + test("a seat under an unknown host family runs only as an explicit seat and never records independence", () => { + const { env } = sandbox(["claude"], seatStub) + const hostEnv = { ...env, CROSS_MODEL_HOST_HARNESS: "cursor" } + let runDir = makeRunDir() + let r = run(seatArgs("unknown", "claude", runDir), runDir, hostEnv) + expect(seatOutputs(r.files)).toEqual([]) + expect(r.stderr).toContain("host serving family unattested") + + runDir = makeRunDir() + r = run(seatArgs("unknown", "claude", runDir), runDir, { ...hostEnv, CROSS_MODEL_SEAT: "1" }) + expect(r.stderr).toContain("peer run: provider=claude route=claude") + expect(seatOutputs(r.files)).toEqual(["adversarial-claude-s1.json"]) + expect(artifact(runDir, "adversarial-claude-s1.json").independence_verified).toBe(false) + }) + + test("a seat's model and effort reach the claude and codex adapter arguments", () => { + const capRoot = mkTempRoot("xmodel-cr-seat-argv-") + const capFile = path.join(capRoot, "argv.txt") + const recordStub = (body: string) => + `#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' "$@" > "$ARGV_CAPTURE"\nprintf '%s' '${body}'\n` + + let { env } = sandbox(["claude"], recordStub('{"structured_output":{"reviewer":"adversarial","findings":[]}}')) + let runDir = makeRunDir() + run(seatArgs("codex", "claude", runDir), runDir, { + ...env, + ARGV_CAPTURE: capFile, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", + CROSS_MODEL_MODEL_OVERRIDE: "sonnet", + CROSS_MODEL_EFFORT_OVERRIDE: "medium", + }) + let argv = readFileSync(capFile, "utf8").split("\n") + expect(argv[argv.indexOf("--model") + 1]).toBe("sonnet") + expect(argv[argv.indexOf("--effort") + 1]).toBe("medium") + let out = artifact(runDir, "adversarial-claude-s1.json") + expect(out.model_requested).toBe("sonnet") + expect(out.effort_requested).toBe("medium") + + ;({ env } = sandbox(["codex"], recordStub('{"reviewer":"adversarial","findings":[]}'))) + runDir = makeRunDir() + run(seatArgs("claude", "codex", runDir), runDir, { + ...env, + ARGV_CAPTURE: capFile, + CROSS_MODEL_SEAT: "2", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "codex", + CROSS_MODEL_MODEL_OVERRIDE: "gpt-seat-model", + CROSS_MODEL_EFFORT_OVERRIDE: "low", + }) + argv = readFileSync(capFile, "utf8").split("\n") + expect(argv[argv.indexOf("-m") + 1]).toBe("gpt-seat-model") + expect(argv).toContain('model_reasoning_effort="low"') + out = artifact(runDir, "adversarial-codex-s2.json") + expect(out.model_requested).toBe("gpt-seat-model") + expect(out.effort_requested).toBe("low") + }, 30_000) // the codex liveness poll sleeps in 5s slices even for a fast stub + + test("a seat effort on a cursor-agent route ends as a named skip with no artifact", () => { + const { env } = sandbox(["cursor-agent"], seatStub) + const runDir = makeRunDir() + const r = run(seatArgs("claude", "composer", runDir), runDir, { + ...env, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_EFFORT_OVERRIDE: "high", + }) + expect(r.code).toBe(0) + expect(seatOutputs(r.files)).toEqual([]) + expect(r.stderr).toContain("effort override 'high' not compatible with route 'composer'; skipping") + }) +}) + function blockBetween(script: string, startMarker: string, endMarker = "# --- --emit-adapter"): string { const source = readFileSync(script, "utf8") const start = source.indexOf(startMarker) diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index 4059b76d9..029da6052 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1535,6 +1535,129 @@ describe("cross-model-doc-review fixed-recipient dispatch (R15, R16)", () => { }) }) +// A `model_roles` review list runs one worker invocation per seat. CROSS_MODEL_SEAT +// carries the seat number: it names the artifact and reviewer so two seats on one +// provider cannot collide, and it marks the recipient as explicitly configured, so +// the automatic same-family and unknown-host skips do not apply to that invocation. +describe("cross-model-doc-review review seats (CROSS_MODEL_SEAT)", () => { + const seatStub = + `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"whole-doc","findings":[{"section":"X","title":"t"}]}}'\n` + const seatArgs = (host: string, target: string, doc: string, runDir: string) => + [host, target, "whole-doc", doc, "plan", "none", runDir] + const artifact = (runDir: string, name: string) => + JSON.parse(readFileSync(path.join(runDir, name), "utf8")) + + test("two seats on one provider write two artifacts with distinct reviewer identities", () => { + const { env } = sandbox(["claude"], seatStub) + const doc = makeDoc() + const runDir = makeRunDir() + for (const seat of ["1", "2"]) { + const r = run(seatArgs("codex", "claude", doc, runDir), runDir, { ...env, CROSS_MODEL_SEAT: seat }) + expect(r.code).toBe(0) + } + expect(readdirSync(runDir).sort()).toEqual(["whole-doc-claude-s1.json", "whole-doc-claude-s2.json"]) + expect(artifact(runDir, "whole-doc-claude-s1.json").reviewer).toBe("whole-doc-claude-s1") + expect(artifact(runDir, "whole-doc-claude-s2.json").reviewer).toBe("whole-doc-claude-s2") + // A different-family seat still records verified independence. + expect(artifact(runDir, "whole-doc-claude-s1.json").independence_verified).toBe(true) + + // The seat number becomes part of a file name, so only a positive integer is accepted. + const bad = makeRunDir() + const r = run(seatArgs("codex", "claude", doc, bad), bad, { ...env, CROSS_MODEL_SEAT: "../x" }) + expect(r.files).toEqual([]) + expect(r.stderr).toContain("seat '../x' invalid") + }) + + test("a seat in the host's own family runs only as an explicit seat and never records independence", () => { + const { env } = sandbox(["claude"], seatStub) + const doc = makeDoc() + let runDir = makeRunDir() + let r = run(seatArgs("claude", "claude", doc, runDir), runDir, env) + expect(r.files).toEqual([]) + expect(r.stderr).toContain("no different-provider peer reachable") + + runDir = makeRunDir() + r = run(seatArgs("claude", "claude", doc, runDir), runDir, { ...env, CROSS_MODEL_SEAT: "3" }) + expect(r.stderr).toContain("peer run: provider=claude route=claude") + expect(r.files).toEqual(["whole-doc-claude-s3.json"]) + const out = artifact(runDir, "whole-doc-claude-s3.json") + expect(out.serving_family).toBe("claude") + expect(out.independence_verified).toBe(false) + }) + + test("a seat under an unknown host family runs only as an explicit seat and never records independence", () => { + const { env } = sandbox(["claude"], seatStub) + const doc = makeDoc() + const hostEnv = { ...env, CROSS_MODEL_HOST_HARNESS: "cursor" } + let runDir = makeRunDir() + let r = run(seatArgs("unknown", "claude", doc, runDir), runDir, hostEnv) + expect(r.files).toEqual([]) + expect(r.stderr).toContain("host serving family unattested") + + runDir = makeRunDir() + r = run(seatArgs("unknown", "claude", doc, runDir), runDir, { ...hostEnv, CROSS_MODEL_SEAT: "1" }) + expect(r.stderr).toContain("peer run: provider=claude route=claude") + expect(r.files).toEqual(["whole-doc-claude-s1.json"]) + expect(artifact(runDir, "whole-doc-claude-s1.json").independence_verified).toBe(false) + }) + + test("a seat's model and effort reach the claude and codex adapter arguments", () => { + const capRoot = mkTempRoot("xmodel-seat-argv-") + const capFile = path.join(capRoot, "argv.txt") + const recordStub = (body: string) => + `#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' "$@" > "$ARGV_CAPTURE"\nprintf '%s' '${body}'\n` + const doc = makeDoc() + + let { env } = sandbox(["claude"], recordStub('{"structured_output":{"reviewer":"whole-doc","findings":[]}}')) + let runDir = makeRunDir() + run(seatArgs("codex", "claude", doc, runDir), runDir, { + ...env, + ARGV_CAPTURE: capFile, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", + CROSS_MODEL_MODEL_OVERRIDE: "sonnet", + CROSS_MODEL_EFFORT_OVERRIDE: "medium", + }) + let argv = readFileSync(capFile, "utf8").split("\n") + expect(argv[argv.indexOf("--model") + 1]).toBe("sonnet") + expect(argv[argv.indexOf("--effort") + 1]).toBe("medium") + let out = artifact(runDir, "whole-doc-claude-s1.json") + expect(out.model_requested).toBe("sonnet") + expect(out.effort_requested).toBe("medium") + + ;({ env } = sandbox(["codex"], recordStub('{"reviewer":"whole-doc","findings":[]}'))) + runDir = makeRunDir() + run(seatArgs("claude", "codex", doc, runDir), runDir, { + ...env, + ARGV_CAPTURE: capFile, + CROSS_MODEL_SEAT: "2", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "codex", + CROSS_MODEL_MODEL_OVERRIDE: "gpt-seat-model", + CROSS_MODEL_EFFORT_OVERRIDE: "low", + }) + argv = readFileSync(capFile, "utf8").split("\n") + expect(argv[argv.indexOf("-m") + 1]).toBe("gpt-seat-model") + expect(argv).toContain('model_reasoning_effort="low"') + out = artifact(runDir, "whole-doc-codex-s2.json") + expect(out.model_requested).toBe("gpt-seat-model") + expect(out.effort_requested).toBe("low") + }, 30_000) // the codex liveness poll sleeps in 5s slices even for a fast stub + + test("a seat effort on a cursor-agent route ends as a named skip with no artifact", () => { + const { env } = sandbox(["cursor-agent"], seatStub) + const doc = makeDoc() + const runDir = makeRunDir() + const r = run(seatArgs("claude", "composer", doc, runDir), runDir, { + ...env, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_EFFORT_OVERRIDE: "high", + }) + expect(r.code).toBe(0) + expect(r.files).toEqual([]) + expect(r.stderr).toContain("effort override 'high' not compatible with route 'composer'; skipping") + }) +}) + describe("cross-model-doc-review argv integrity (multiline --json-schema)", () => { test("passes the pretty-printed schema as ONE --json-schema argument, not split per line", () => { // The schema-carrying routes (claude, grok-cli) put the multi-line diff --git a/tests/skills/cross-model-review-mode.test.ts b/tests/skills/cross-model-review-mode.test.ts index c3437ba8c..fcb8482fe 100644 --- a/tests/skills/cross-model-review-mode.test.ts +++ b/tests/skills/cross-model-review-mode.test.ts @@ -34,6 +34,19 @@ describe("cross_model_review_mode egress gate", () => { }) } + // A `model_roles` review list is a second way content can leave for another + // provider. `off` (and a set CROSS_MODEL_PEERS) reaches every seat through the + // resolver's `blocked_by`, whether the seat would run natively or through a CLI. + for (const ref of references) { + test(`${ref} applies cross_model_review_mode to every review seat`, () => { + const content = read(ref) + expect(content).toContain("A seat with `blocked_by` set does not run.") + expect(content).toContain( + "The resolver has already applied `cross_model_review_mode` and `CROSS_MODEL_PEERS` to every seat", + ) + }) + } + test("both SKILL.md files wire the gate into their cross-model step", () => { for (const p of ["skills/ce-code-review/SKILL.md", "skills/ce-doc-review/SKILL.md"]) { expect(read(p)).toContain("cross_model_review_mode") From a6d40eb77677c839a82d451968710e9a08495319 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:17:42 -0700 Subject: [PATCH 13/53] fix(ce-code-review): do not promote on agreement between seats alone With several review seats, two verified peers could promote a finding with no in-process reviewer agreeing. A peer corroborates an in-process reading, so promotion now needs at least one of each. The agent-mode return shape also names coverage.model_roles where the rest of that shape is defined. Co-Authored-By: Claude Code --- .../references/modes-and-output.md | 2 +- .../scripts/findings-mechanics.py | 7 +++-- tests/ce-code-review-mechanics.test.ts | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/skills/ce-code-review/references/modes-and-output.md b/skills/ce-code-review/references/modes-and-output.md index 5c81f05e6..7ffc538a6 100644 --- a/skills/ce-code-review/references/modes-and-output.md +++ b/skills/ce-code-review/references/modes-and-output.md @@ -131,7 +131,7 @@ Minimum shape: Lite fills `findings`, `actionable_findings`, `testing_gaps`, `residual_risks`, and `requirements_completeness` (`null` only when no plan was found) from its own review. The arrays owned by reviewers it did not run (`learnings`, `agent_native_gaps`, `deployment_notes`, `pre_existing_findings`, `triage_groups`) are `[]`, and Coverage names them as not assessed. `reviewers` is `["correctness"]` and `coverage.depth` is `"lite"`. Focused fills the same fields from its merged findings, lists its two reviewers, and sets `coverage.depth` to `"focused"`; the full path's finish leaf fills every field and sets `coverage.depth` to `"full"`. -Each object in `findings` uses the merged finding fields: `#`, `title`, `severity`, `file`, `line`, `confidence`, `autofix_class`, `owner`, `requires_verification`, `pre_existing`, `suggested_fix`, `first_evidence`, `why_it_matters`, `evidence`, `reviewers`, `independent_reviewers`. A finding Stage 5b left unresolved, or confirmed with an unmeasured-incidence reason, also carries `validation_status` and `validation_reason`, and carries `protected_subject` when one applies. Each object in `learnings` is a Known Pattern note: `type` (`known_pattern`), `title`, `citation` (a `/solutions/` path or `(pack: , )`), and `note` (one line on how it bears on the change); contradicted pack rules are findings, never `learnings` entries. When Compound Packs were resolved for the learnings dispatch, `coverage.compound_packs` carries `roots` as the list of pack ids (strings — never the resolver's absolute `dir` paths) plus the resolver's `warnings` and `errors` arrays verbatim, so a consumer can tell a declared pack that loaded from one that was skipped. The helper derives `independent_reviewers`; synthesis may preserve or union that list but must not infer it from `reviewers`. +Each object in `findings` uses the merged finding fields: `#`, `title`, `severity`, `file`, `line`, `confidence`, `autofix_class`, `owner`, `requires_verification`, `pre_existing`, `suggested_fix`, `first_evidence`, `why_it_matters`, `evidence`, `reviewers`, `independent_reviewers`. A finding Stage 5b left unresolved, or confirmed with an unmeasured-incidence reason, also carries `validation_status` and `validation_reason`, and carries `protected_subject` when one applies. Each object in `learnings` is a Known Pattern note: `type` (`known_pattern`), `title`, `citation` (a `/solutions/` path or `(pack: , )`), and `note` (one line on how it bears on the change); contradicted pack rules are findings, never `learnings` entries. When Compound Packs were resolved for the learnings dispatch, `coverage.compound_packs` carries `roots` as the list of pack ids (strings — never the resolver's absolute `dir` paths) plus the resolver's `warnings` and `errors` arrays verbatim, so a consumer can tell a declared pack that loaded from one that was skipped. When the `code-review` model role was resolved for this review, `coverage.model_roles` carries its `Model role` lines verbatim as a list of strings; it is absent otherwise. The helper derives `independent_reviewers`; synthesis may preserve or union that list but must not infer it from `reviewers`. An incoming finding may carry an optional `settled_conflict` field naming a `session-settled:` KTD. The marker supplies context for settlement reconciliation; it does not exempt the finding from admission or authorize a change. diff --git a/skills/ce-code-review/scripts/findings-mechanics.py b/skills/ce-code-review/scripts/findings-mechanics.py index 8066bb4b4..5c6e9efaf 100755 --- a/skills/ce-code-review/scripts/findings-mechanics.py +++ b/skills/ce-code-review/scripts/findings-mechanics.py @@ -126,8 +126,11 @@ def merge_group(group: list[tuple[dict[str, Any], str, tuple[str, ...]]]) -> dic if confidence >= 75 and not has_first_evidence: confidence = 50 # In-process reviewers share one serving model, so their agreement is one - # reading repeated; only a verified cross-model peer corroborates. - if len(independent) >= 2 and has_first_evidence and any(cross_model_peer(n) for n in independent): + # reading repeated; only a verified cross-model peer corroborates. Peers + # corroborate an in-process reading, not each other, so several review + # seats agreeing alone do not promote. + peers = [n for n in independent if cross_model_peer(n)] + if peers and len(independent) > len(peers) and has_first_evidence: confidence = promote(confidence) merged["confidence"] = confidence merged["reviewers"] = reviewer_names diff --git a/tests/ce-code-review-mechanics.test.ts b/tests/ce-code-review-mechanics.test.ts index 68a5e6ac3..416129fe8 100644 --- a/tests/ce-code-review-mechanics.test.ts +++ b/tests/ce-code-review-mechanics.test.ts @@ -674,6 +674,36 @@ describe("ce-code-review deterministic mechanics", () => { expect(unverified.independent_reviewers).toEqual(["correctness", "reliability"]) }) + test("agreement between review seats alone does not promote", () => { + const finding = { + title: "Stale result", severity: "P1", file: "src/worker.ts", line: 12, + confidence: 75, autofix_class: "manual", owner: "downstream-resolver", + requires_verification: true, pre_existing: false, + first_evidence: "src/worker.ts:12 -- result = staleValue", + } + const seat = (reviewer: string) => ({ + reviewer, findings: [finding], residual_risks: [], testing_gaps: [], independence_verified: true, + }) + const merge = (returns: unknown[]) => { + const result = run("python3", [FINDINGS_SCRIPT], undefined, JSON.stringify(returns)) + expect(result.status).toBe(0) + return JSON.parse(result.stdout).findings[0] + } + + // Two verified seats and no in-process reviewer: peers corroborate an + // in-process reading, they do not corroborate each other. + const seatsOnly = merge([seat("adversarial-codex-s1"), seat("adversarial-grok-s2")]) + expect(seatsOnly.confidence).toBe(75) + + // The same two seats beside an in-process reviewer promote once, not twice. + const withInProcess = merge([ + { reviewer: "correctness", findings: [{ ...finding, confidence: 50 }], residual_risks: [], testing_gaps: [] }, + seat("adversarial-codex-s1"), + seat("adversarial-grok-s2"), + ]) + expect(withInProcess.confidence).toBe(100) + }) + test("synthetic reruns preserve independent corroboration from semantic duplicates", () => { const reconciled = { title: "Reconciled stale-state defect", From b20f4e75329bd4fd2f4b33476f133201ec18d4e7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:35:33 -0700 Subject: [PATCH 14/53] test(model-roles): add behavioral eval rows for the role map Twelve decision cells cover resolution, layering, precedence, engine routing, the personal opt-out, review seats on a routine plan, and the review-mode policy. Eight restraint cells, one per role skill, fail when a checkout with no model_roles key runs the resolver or prints a Model role line. Tasks ask for each skill ordinary work and never name the map. A judged conversation scenario covers the setup flow. Co-Authored-By: Claude Code --- tests/skill-eval-cell/catalog.test.ts | 32 ++ tests/skill-eval-cell/catalog.ts | 537 ++++++++++++++++++ .../.compound-engineering/config.yaml | 3 + .../judged-model-roles-setup/AGENTS.md | 3 + .../judged-model-roles-setup/package.json | 8 + .../judged-model-roles-setup/src/greet.js | 3 + .../.compound-engineering/config.yaml | 2 + .../model-roles-brainstorm-entry/README.md | 3 + .../model-roles-brainstorm-entry/package.json | 5 + .../model-roles-brainstorm-entry/src/greet.js | 3 + .../.compound-engineering/config.yaml | 3 + .../README.md | 3 + .../src/access.ts | 28 + .../.compound-engineering/config.yaml | 2 + .../model-roles-compound-entry/README.md | 3 + .../model-roles-compound-entry/package.json | 8 + .../src/post-rates.js | 12 + .../test/post-rates.test.js | 30 + .../model-roles-compound-nomap/README.md | 3 + .../model-roles-compound-nomap/package.json | 8 + .../src/post-rates.js | 12 + .../test/post-rates.test.js | 30 + .../.compound-engineering/config.yaml | 2 + .../fixtures/model-roles-debug-entry/SPEC.md | 3 + .../model-roles-debug-entry/package.json | 4 + .../model-roles-debug-entry/src/seat-cap.js | 2 + .../tests/seat-cap.check.js | 5 + .../.compound-engineering/config.yaml | 6 + .../model-roles-doc-review-mode-off/README.md | 3 + ...-09-28-1100-fix-greeting-name-trim-plan.md | 70 +++ .../package.json | 8 + .../src/greet.js | 3 + .../test/greet.test.js | 7 + .../.compound-engineering/config.yaml | 2 + .../model-roles-doc-review-seats/README.md | 3 + ...-09-28-1100-fix-greeting-name-trim-plan.md | 70 +++ .../model-roles-doc-review-seats/package.json | 8 + .../model-roles-doc-review-seats/src/greet.js | 3 + .../test/greet.test.js | 7 + .../.compound-engineering/config.yaml | 3 + .../fixtures/model-roles-plan-entry/README.md | 3 + .../model-roles-plan-entry/package.json | 5 + .../model-roles-plan-entry/src/greet.js | 3 + .../.compound-engineering/config.yaml | 3 + .../model-roles-plan-existing-key/README.md | 3 + .../package.json | 5 + .../src/greet.js | 3 + .../.compound-engineering/config.local.yaml | 2 + .../.compound-engineering/config.yaml | 3 + .../model-roles-plan-personal/README.md | 3 + .../model-roles-plan-personal/package.json | 5 + .../model-roles-plan-personal/src/greet.js | 3 + .../.compound-engineering/config.yaml | 2 + .../model-roles-simplify-entry/README.md | 3 + .../model-roles-simplify-entry/package.json | 8 + .../model-roles-simplify-entry/src/totals.js | 29 + .../test/totals.test.js | 13 + .../.compound-engineering/config.yaml | 2 + .../docs/plans/widget-plan.md | 39 ++ .../model-roles-work-entry/package.json | 1 + .../model-roles-work-entry/src/greet.js | 3 + .../.compound-engineering/config.local.yaml | 1 + .../.compound-engineering/config.yaml | 2 + .../docs/plans/widget-plan.md | 39 ++ .../package.json | 1 + .../src/greet.js | 3 + .../judged/personas/model-roles-setup.md | 9 + .../judged/rubrics/model-roles-setup.md | 25 + tests/skill-eval-cell/judged/scenarios.ts | 29 + tests/skill-eval-cell/scenarios.md | 54 +- 70 files changed, 1253 insertions(+), 1 deletion(-) create mode 100644 tests/skill-eval-cell/fixtures/judged-model-roles-setup/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/judged-model-roles-setup/AGENTS.md create mode 100644 tests/skill-eval-cell/fixtures/judged-model-roles-setup/package.json create mode 100644 tests/skill-eval-cell/fixtures/judged-model-roles-setup/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/src/access.ts create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-entry/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-entry/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-entry/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-entry/src/post-rates.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-entry/test/post-rates.test.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-nomap/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-nomap/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-nomap/src/post-rates.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-compound-nomap/test/post-rates.test.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-debug-entry/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-debug-entry/SPEC.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-debug-entry/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-debug-entry/src/seat-cap.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-debug-entry/tests/seat-cap.check.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/test/greet.test.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/test/greet.test.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-entry/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-entry/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-entry/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-entry/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.local.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-personal/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-personal/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-plan-personal/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-simplify-entry/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-simplify-entry/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-simplify-entry/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-simplify-entry/src/totals.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-simplify-entry/test/totals.test.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-entry/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-entry/docs/plans/widget-plan.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-entry/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-entry/src/greet.js create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.local.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-personal-off/docs/plans/widget-plan.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-personal-off/package.json create mode 100644 tests/skill-eval-cell/fixtures/model-roles-work-personal-off/src/greet.js create mode 100644 tests/skill-eval-cell/judged/personas/model-roles-setup.md create mode 100644 tests/skill-eval-cell/judged/rubrics/model-roles-setup.md diff --git a/tests/skill-eval-cell/catalog.test.ts b/tests/skill-eval-cell/catalog.test.ts index 899c855c6..90679f8d2 100644 --- a/tests/skill-eval-cell/catalog.test.ts +++ b/tests/skill-eval-cell/catalog.test.ts @@ -184,6 +184,18 @@ describe("skill-eval-cell catalog", () => { "ce-optimize/variant-search-without-profile:references/loop.md", "ce-plan/chat-brief-small-no-file:references/output-contracts.md", "ce-plan/config-model-reaches-authoring-gate:references/reasoning-elevation.md", + "ce-plan/role-entry-reaches-authoring-gate:references/model-roles.md", + "ce-plan/personal-role-entry-overrides-one-role:references/model-roles.md", + "ce-plan/existing-key-decides-when-role-unset:references/model-roles.md", + "ce-brainstorm/role-entry-reaches-approach-gate:references/model-roles.md", + "ce-work/role-entry-routes-through-claude-engine:references/model-roles.md", + "ce-work/personal-off-keeps-team-entry-native:references/model-roles.md", + "ce-doc-review/seat-list-runs-on-routine-plan:references/model-roles.md", + "ce-doc-review/review-mode-off-skips-other-provider-seats:references/model-roles.md", + "ce-code-review/seat-list-and-review-mode:references/model-roles.md", + "ce-debug/role-entry-hands-off-investigation:references/model-roles.md", + "ce-simplify-code/role-entry-governs-apply:references/model-roles.md", + "ce-compound/lightweight-never-hands-off:references/model-roles.md", "ce-plan/auto-bakeoff-cheap-reversal-continues:references/research.md", "ce-plan/auto-bakeoff-concrete-alternatives-continue:references/research.md", "ce-plan/auto-bakeoff-eligible:references/research.md", @@ -291,7 +303,9 @@ describe("skill-eval-cell catalog", () => { "ce-bakeoff/timing-evidence", "ce-bakeoff/unavailable-independence", "ce-bakeoff/unverified-guarantee-blocks-selection", + "ce-brainstorm/no-map-runs-no-resolver", "ce-brainstorm/requested-bakeoff-confirmation", + "ce-brainstorm/role-entry-reaches-approach-gate", "ce-code-review/cross-model-fold-in-folded", "ce-code-review/cross-model-fold-in-recovery", "ce-code-review/depth-gate-auth-full", @@ -305,6 +319,8 @@ describe("skill-eval-cell catalog", () => { "ce-code-review/depth-gate-standards-violation", "ce-code-review/depth-gate-unlisted-language", "ce-code-review/depth-gate-yaml-lite", + "ce-code-review/no-map-runs-no-resolver", + "ce-code-review/seat-list-and-review-mode", "ce-code-review/validator-veto-routes-protected-rejections", "ce-commit-push-pr/babysit-off-preserves-human-decision", "ce-commit-push-pr/project-publishing-gate", @@ -312,8 +328,15 @@ describe("skill-eval-cell catalog", () => { "ce-compound-refresh/guidance-survives-implementation-conflict", "ce-compound-refresh/plain-refresh-keeps-redundant-accurate-doc", "ce-compound-refresh/worth-lens-intent-confirms-before-loading", + "ce-compound/lightweight-never-hands-off", + "ce-compound/no-map-runs-no-resolver", + "ce-debug/no-map-runs-no-resolver", "ce-debug/pipeline-divergent-defer", + "ce-debug/role-entry-hands-off-investigation", "ce-doc-review/approval-versus-judgment-summary", + "ce-doc-review/no-map-runs-no-resolver", + "ce-doc-review/review-mode-off-skips-other-provider-seats", + "ce-doc-review/seat-list-runs-on-routine-plan", "ce-noslop/dense-paragraph-keeps-every-claim", "ce-noslop/detect-names-patterns-without-rewrite", "ce-noslop/facts-survive-the-edit", @@ -327,7 +350,11 @@ describe("skill-eval-cell catalog", () => { "ce-plan/auto-bakeoff-interface-boundary-eligible", "ce-plan/auto-bakeoff-user-said-pick-one-continues", "ce-plan/config-model-reaches-authoring-gate", + "ce-plan/existing-key-decides-when-role-unset", + "ce-plan/no-map-runs-no-resolver", + "ce-plan/personal-role-entry-overrides-one-role", "ce-plan/requested-bakeoff-boundary", + "ce-plan/role-entry-reaches-authoring-gate", "ce-plan/trace-degrades-to-single-pass", "ce-plan/trace-lightweight-own-reads", "ce-plan/trace-skipped-rationale-established", @@ -340,6 +367,11 @@ describe("skill-eval-cell catalog", () => { "ce-resolve-pr-feedback/pipeline-returns-complete-human-decision", "ce-setup/instruction-file-covered-offers-nothing", "ce-setup/instruction-file-gap-offers-store-and-directive", + "ce-simplify-code/no-map-runs-no-resolver", + "ce-simplify-code/role-entry-governs-apply", + "ce-work/no-map-runs-no-resolver", + "ce-work/personal-off-keeps-team-entry-native", + "ce-work/role-entry-routes-through-claude-engine", ]) }) diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 396426a80..1b471a746 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -151,6 +151,102 @@ const FIX = "tests/skill-eval-cell/fixtures" const SETUP_INSTRUCTIONS_TASK = "Use the ce-setup skill to check this repository's Compound Engineering setup. For every change it would offer, show the exact text and where in the file it would go." +/** + * Model role map rows. Each task asks for the skill's ordinary work up to one decision + * and never names the map or its resolver, so a pass shows an unprompted run reached + * them. A skill's entry rows and its no-map row share one task; only the fixture differs. + */ +const ROLE_REPORT_SHAPE = + "Put your whole report between a line that is only RESULT-START and a line that is only RESULT-END, and put the trailers after RESULT-END. In the ACTIONS trailer, list every bundled skill script you ran, by file name, as well as any mutation." +const ROLE_LINES = "each appearing once in your whole answer, as plain text with no backticks, quotes, or extra words" +/** No-map half of every restraint row: no resolver run, and nothing about model roles in the report. */ +const NO_ROLE_OUTPUT = { must_exclude: ["model-role-resolve"], result_must_not_include: ["model role"] } + +const roleChoiceLines = (step: string) => `End your report with exactly these five lines, ${ROLE_LINES}. When more than one repo setting names a model for ${step}, report the one that wins. + +REQUESTED_MODEL: +REQUESTED_EFFORT: +SETTING_FILE: +SETTING_KEY: +STEP_ROUTE: + +${ROLE_REPORT_SHAPE}` + +const ROLE_PLAN_TASK = `Use ce-plan for this bounded planning checkpoint. Scope and research are already settled: add an optional uppercase greeting mode while preserving the default behavior. You are at the plan-authoring boundary. Before any model dispatch or artifact write, report the resolved authoring model choice, its source, and the route the authoring call would take; then stop. Do not dispatch or write. + +${roleChoiceLines("plan authoring")}` + +const ROLE_BRAINSTORM_TASK = `Use ce-brainstorm for this bounded checkpoint. The idea is understood and its scope is settled: greet should support localization — multiple languages, pluralized greetings, a fallback chain when a language is missing, and a way for callers to register new languages at runtime. You are about to generate approaches. Before generating them or dispatching any model, report the resolved model choice for approach generation, its source, and the route the generation call would take; then stop. Ask nothing, dispatch nothing, and write nothing. + +${roleChoiceLines("approach generation")}` + +const ROLE_WORK_TASK = `Use ce-work on docs/plans/widget-plan.md for this bounded checkpoint. Resolve how implementation will run for this plan: complete the engine and route selection, including any preflight it calls for, before selecting a unit. Report the selected route, the requested model and effort, and why; then stop. Do not edit, commit, initialize a run, or dispatch. + +End your report with exactly these four lines, ${ROLE_LINES}: + +ENGINE_ROUTE: +REQUESTED_MODEL: +ROUTE_EFFORT: +KEPT_NATIVE_BY: + +${ROLE_REPORT_SHAPE}` + +const roleReviewTeamLines = (personas: string) => `End your report with exactly these three lines, ${ROLE_LINES}: + +NOT_RUN_COUNT: +NOT_RUN_REASON: +TEAM: + +${ROLE_REPORT_SHAPE}` + +const roleDocReviewTask = (plan: string) => `Use ce-doc-review with the arguments: mode:non-interactive ${plan}. This is a bounded checkpoint: run the skill until the full review team is settled, then stop. Do not dispatch any reviewer and do not start any job. Report the full team: every persona, and every additional reviewer beyond the personas with the model it was requested on. + +${roleReviewTeamLines("persona")}` + +const ROLE_DOC_REVIEW_PLAN = "docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md" + +const ROLE_CODE_REVIEW_TASK = `Use the ce-code-review skill on the staged change in this repo with depth:full. This is a bounded checkpoint: run the skill through scope and reviewer selection until the final review team is settled, then stop. Do not start any detached job and do not dispatch any reviewer. Report the final team: every local reviewer, and every additional independent reviewer with the model it was requested on. + +${roleReviewTeamLines("local reviewer")}` + +const ROLE_DEBUG_TASK = `Use ce-debug on this bug for a bounded checkpoint: the seat cap check is failing. node tests/seat-cap.check.js exits 1, and SPEC.md is the product contract. Before you reproduce or trace anything, state how the investigation will be carried out and by which model; then stop. Do not investigate, edit, or dispatch. + +End your report with exactly these three lines, ${ROLE_LINES}: + +INVESTIGATED_BY: +REQUESTED_MODEL: +SESSION_REASON: + +${ROLE_REPORT_SHAPE}` + +const ROLE_SIMPLIFY_TASK = `Use ce-simplify-code on the staged change in this repo for a bounded checkpoint. Resolve the scope, then state who will apply the simplification findings and verify the result, on which model and at which reasoning effort; then stop. Do not dispatch any reviewer and do not edit anything. + +End your report with exactly these four lines, ${ROLE_LINES}: + +APPLIED_BY: +REQUESTED_MODEL: +REQUESTED_EFFORT: +EFFORT_APPLIED: + +${ROLE_REPORT_SHAPE}` + +const ROLE_COMPOUND_TASK = `Use ce-compound with the arguments: mode:non-interactive depth:lightweight + +Context for this run, all of it solved and verified earlier in this session: + +The nightly rates sync failed about one night in five with "rates sync failed: 409". The ledger API's gateway gives up after 8 seconds and answers 504 even when the ledger has already stored the batch. Our retry then re-sent the same batch id, and the ledger answered 409 Conflict because that id was already stored. We first raised our client timeout from 10 to 60 seconds. That changed nothing, because the 504 comes from the ledger gateway's own limit, not from our client. The fix is in src/post-rates.js: a 409 now counts as delivered (ok: true, duplicate: true) instead of a failure, because on this endpoint a 409 only ever means the batch id is already stored. Verified: npm test passes, including the 504-then-409 case, and replaying last Tuesday's captured failure returned ok. + +Put the skill's whole completion report, unchanged, between a line that is only RESULT-START and a line that is only RESULT-END, and put the trailers after RESULT-END. In the ACTIONS trailer, list every bundled skill script you ran, by file name, as well as any mutation.` + +/** The non-interactive terminal line is the last line of the report, however the block is closed. */ +const COMPOUND_TERMINAL_LINE_LAST = [ + "Documentation complete\nRESULT-END", + "Documentation complete\n\nRESULT-END", + "Documentation complete\n```\nRESULT-END", +] +const COMPOUND_SOURCE_UNCHANGED = { path: "src/post-rates.js", needle: "if (status === 409) return { ok: true, duplicate: true }" } +const ACCESS_TS_UNCHANGED = { path: "src/access.ts", needle: 'if (!membership) return request.action === "read"' } + /** Cheap read-only cells that pin a real decision. Live mutation/delegation is not in this set. */ export const WAVE1 = [ @@ -3468,6 +3564,447 @@ Use "Correct widget limit" as the description in the subject and any required bo actions: "none", }, }, + + // ---- Model role map: resolution and precedence ---- + // Write mode, because the resolver is a shell command a read-only cell cannot run. + // The model ids are grammar-valid Claude-family fakes, so a declared id can only have + // come from the entry that won, never from a model the host would name anyway. + { + id: "ce-plan/role-entry-reaches-authoring-gate", + post_only: true, + skill: "ce-plan", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-plan-entry`, + timeout_secs: 300, + why: "The team file sets `plan: claude-ce-eval-a low` and also `plan_model: claude-ce-eval-legacy`. The entry outranks the older key, and an entry with an effort always hands off; a Claude-family model with an effort goes through the Claude CLI adapter wherever the host's subagent tool cannot set effort. STEP_ROUTE assumes `claude` is on PATH and a host whose subagent tool has no effort control, which holds for Claude Code, Codex, and OpenCode today.", + pre_contract: "`plan_model` is the only config source for the authoring model, and it carries no effort.", + task: ROLE_PLAN_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { + REQUESTED_MODEL: "claude-ce-eval-a", + REQUESTED_EFFORT: "low", + SETTING_KEY: "model_roles", + STEP_ROUTE: "claude-cli", + }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-plan/personal-role-entry-overrides-one-role", + post_only: true, + skill: "ce-plan", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-plan-personal`, + timeout_secs: 300, + why: "Layering is role by role. The team file sets `plan: claude-ce-eval-a low` and a `work` entry; the personal file sets only `plan: claude-ce-eval-c`. The personal entry replaces the team entry whole, so the team's `low` must not survive: REQUESTED_EFFORT is none.", + pre_contract: "A map in the personal file replaces the whole key from the team file.", + task: ROLE_PLAN_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { + REQUESTED_MODEL: "claude-ce-eval-c", + REQUESTED_EFFORT: "none", + SETTING_FILE: "config.local.yaml", + SETTING_KEY: "model_roles", + }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-plan/existing-key-decides-when-role-unset", + post_only: true, + skill: "ce-plan", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-plan-existing-key`, + timeout_secs: 300, + why: "The map is active but holds only a `work` entry, so the `plan` role is unset and `plan_model` decides as it does with no map. The run must resolve the role, because the key is active, and must not attribute the choice to the map.", + pre_contract: "`plan_model` selects the authoring model.", + task: ROLE_PLAN_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { REQUESTED_MODEL: "claude-ce-eval-legacy", SETTING_KEY: "plan_model" }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-brainstorm/role-entry-reaches-approach-gate", + post_only: true, + skill: "ce-brainstorm", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-brainstorm-entry`, + timeout_secs: 300, + why: "`brainstorm: claude-ce-eval-a high` governs approach generation. Same route reasoning and host assumption as the ce-plan entry row: an effort forces a hand-off, and the Claude CLI adapter is the first route that can carry it.", + pre_contract: "`brainstorm_model` is the only config source for approach generation, and it carries no effort.", + task: ROLE_BRAINSTORM_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { + REQUESTED_MODEL: "claude-ce-eval-a", + REQUESTED_EFFORT: "high", + SETTING_KEY: "model_roles", + STEP_ROUTE: "claude-cli", + }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-work/role-entry-routes-through-claude-engine", + post_only: true, + skill: "ce-work", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-work-entry`, + timeout_secs: 600, + why: "`work: claude-ce-eval-a medium` becomes the one standing candidate: harness `claude`, at `medium`. An entry with an effort is never a route to self, so even a Claude Code session runs it through the `claude` route instead of collapsing to native. Assumes `claude` is on PATH and a host whose subagent tool cannot set effort; where the subagent tool can, the entry is served natively and ENGINE_ROUTE reads native.", + pre_contract: "With no `work_engine_*` keys, implementation runs natively on the session model.", + task: ROLE_WORK_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { ENGINE_ROUTE: "claude", REQUESTED_MODEL: "claude-ce-eval-a", ROUTE_EFFORT: "medium" }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-work/personal-off-keeps-team-entry-native", + post_only: true, + skill: "ce-work", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-work-personal-off`, + timeout_secs: 600, + why: "The team file sets `work: claude-ce-eval-a high` and the personal file sets `work_engine_mode: off`. That personal key is the one opt-out a team entry does not outrank: the work stays native and the run names the key as the reason. Only the personal file carries `work_engine_mode`, so the key name alone identifies it.", + pre_contract: "`work_engine_mode: off` disables the standing engine preference.", + task: ROLE_WORK_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { ENGINE_ROUTE: "native", REQUESTED_MODEL: "claude-ce-eval-a", KEPT_NATIVE_BY: "work_engine_mode" }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-doc-review/seat-list-runs-on-routine-plan", + post_only: true, + skill: "ce-doc-review", + cohort: "untouched", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-doc-review-seats`, + timeout_secs: 600, + why: "Seats do not depend on which personas were selected. The plan is a routine fix with validated provenance, so none of the three judgment lenses activates and the single-peer pass would never have started; both seats of `doc-review: [claude-ce-eval-a high, claude-ce-eval-b]` are still on the team. The cell stops before dispatch, so it grades the settled team, not whether a fake model id can be served.", + pre_contract: "A routine plan that activates no judgment lens gets the persona review only.", + task: roleDocReviewTask(ROLE_DOC_REVIEW_PLAN), + grade: { + files_read_post: ["references/model-roles.md"], + must_include: ["coherence", "feasibility", "claude-ce-eval-a", "claude-ce-eval-b"], + // Without this a run that over-activated a lens would pass without showing the routine-plan case. + must_not_include: ["adversarial", "product-lens", "security-lens"], + declared: { NOT_RUN_COUNT: "0" }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-doc-review/review-mode-off-skips-other-provider-seats", + post_only: true, + skill: "ce-doc-review", + cohort: "untouched", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-doc-review-mode-off`, + timeout_secs: 600, + why: "`cross_model_review_mode: off` with seats on Grok, GPT, and Claude models. This grade holds on a host that attests the Claude family: the Claude seat is in the host's own family and runs, and the other two are not run because of the review mode. On a host whose family is unattested, such as OpenCode or Cursor, all three seats are skipped, so the expected TEAM carries no seat and NOT_RUN_COUNT is 3; run this row with `--hosts claude`.", + pre_contract: "`cross_model_review_mode: off` skips the single cross-model pass.", + task: roleDocReviewTask(ROLE_DOC_REVIEW_PLAN), + grade: { + files_read_post: ["references/model-roles.md"], + must_include: ["coherence", "feasibility", "claude-ce-eval-a"], + must_not_include: ["grok-ce-eval", "gpt-ce-eval"], + declared: { NOT_RUN_COUNT: "2", NOT_RUN_REASON: "review-mode" }, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-code-review/seat-list-and-review-mode", + post_only: true, + skill: "ce-code-review", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + git_staged: ["src/access.ts"], + fixture: `${FIX}/model-roles-code-review-mode-off`, + timeout_secs: 900, + why: "`cross_model_review_mode: off` with `code-review: [grok-ce-eval high, claude-ce-eval-a]`. Seats bind at Stage 3d, which only the full spine reaches, so the task passes `depth:full` and leaves the depth decision to its own rows. Like the ce-doc-review policy row, the grade holds on a host that attests the Claude family; on an unattested host both seats are skipped. The staged file keeps `git status` dirty, so the no-write check is the file's content.", + pre_contract: "`cross_model_review_mode: off` skips the single cross-model adversarial pass.", + task: ROLE_CODE_REVIEW_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + must_include: ["correctness", "claude-ce-eval-a"], + must_not_include: ["grok-ce-eval"], + declared: { NOT_RUN_COUNT: "1", NOT_RUN_REASON: "review-mode" }, + delegates: "none", + workspace_contains: [ACCESS_TS_UNCHANGED], + }, + }, + { + id: "ce-debug/role-entry-hands-off-investigation", + post_only: true, + skill: "ce-debug", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-debug-entry`, + timeout_secs: 300, + why: "`debug: claude-ce-eval-a` names a model the session is not on, so the role hands the investigation to one read-only subagent on that model. The role has no peer route: a host whose subagent tool cannot be handed that model investigates in the session and says why. Both outcomes pass; what the grade pins is that the entry was resolved before any investigation. Read SESSION_REASON by hand when the run declares session.", + pre_contract: "ce-debug reads no model setting; the session investigates.", + task: ROLE_DEBUG_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { REQUESTED_MODEL: "claude-ce-eval-a" }, + must_include_any: [["INVESTIGATED_BY: subagent", "INVESTIGATED_BY: session"]], + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-simplify-code/role-entry-governs-apply", + post_only: true, + skill: "ce-simplify-code", + cohort: "untouched", + key_behavior: "judgment", + read_only: false, + git_init: true, + git_staged: ["src/totals.js"], + fixture: `${FIX}/model-roles-simplify-entry`, + timeout_secs: 300, + why: "`simplify: claude-ce-eval-a low` governs apply and verify. The only hand-off route is a native subagent, which takes a model but no effort on every host verified so far, so the effort is reported as not applied. A host that cannot hand the model over applies in the session, where the requested effort is not applied either. Both outcomes pass. The staged file keeps `git status` dirty, so the no-edit check is that the duplicated loop is still there.", + pre_contract: "ce-simplify-code reads no model setting; the session applies and verifies.", + task: ROLE_SIMPLIFY_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + declared: { REQUESTED_MODEL: "claude-ce-eval-a", REQUESTED_EFFORT: "low", EFFORT_APPLIED: "no" }, + must_include_any: [["APPLIED_BY: subagent", "APPLIED_BY: session"]], + delegates: "none", + workspace_contains: [{ path: "src/totals.js", needle: "function orderTotalWithShipping(order, shipping) {\n let subtotal = 0" }], + }, + }, + { + id: "ce-compound/lightweight-never-hands-off", + post_only: true, + skill: "ce-compound", + cohort: "untouched", + key_behavior: "mutation", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-compound-entry`, + timeout_secs: 900, + why: "`compound: claude-ce-eval-a` would call for a hand-off, but Lightweight launches no subagents: the session writes the doc and prints the role line with route session. The line sits above the terminal line, which callers parse, so `Documentation complete` is still the last line of the report. Write mode, because the run resolves the role and writes the learning; the doc's path is the run's choice, so the disk checks are a dirty tree and an untouched source file.", + pre_contract: "Non-interactive Lightweight writes one doc with no subagents and ends on `Documentation complete`.", + task: ROLE_COMPOUND_TASK, + grade: { + files_read_post: ["references/model-roles.md"], + must_include: ["Model role compound: requested claude-ce-eval-a", "route session"], + must_include_any: [COMPOUND_TERMINAL_LINE_LAST], + delegates: "none", + git: "dirty", + workspace_contains: [COMPOUND_SOURCE_UNCHANGED], + }, + }, + + // ---- Model role map: restraint ---- + // No `model_roles` key in either config file. Each row runs the same task as its skill's + // entry rows and must declare the skill's ordinary decision, run no resolver, and print + // nothing about model roles. + { + id: "ce-brainstorm/no-map-runs-no-resolver", + post_only: true, + skill: "ce-brainstorm", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/tiny-lib`, + timeout_secs: 300, + why: "No CE config at all: approach generation stays on the session model, as it did before the map existed.", + pre_contract: "With no `brainstorm_model`, the session model generates the approaches.", + task: ROLE_BRAINSTORM_TASK, + grade: { + declared: { REQUESTED_MODEL: "none", STEP_ROUTE: "session" }, + ...NO_ROLE_OUTPUT, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-plan/no-map-runs-no-resolver", + post_only: true, + skill: "ce-plan", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/plan-model-config`, + timeout_secs: 300, + why: "The write-mode twin of `ce-plan/config-model-reaches-authoring-gate`: `plan_model` alone still resolves at the authoring boundary, now in a cell where the resolver could have run.", + pre_contract: "At the authoring boundary, an active plan_model is resolved before any dispatch or write and its source is transparent.", + task: ROLE_PLAN_TASK, + grade: { + declared: { REQUESTED_MODEL: "ce-eval-unavailable", SETTING_KEY: "plan_model" }, + ...NO_ROLE_OUTPUT, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-doc-review/no-map-runs-no-resolver", + post_only: true, + skill: "ce-doc-review", + cohort: "untouched", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/doc-review-routine-fix`, + timeout_secs: 600, + why: "A config file exists and carries only `cross_model_review_mode: off`, so the hook's condition is false: the team is the personas the document selects and nothing else.", + pre_contract: "The team is coherence and feasibility always, plus each activated conditional persona.", + task: roleDocReviewTask("docs/plans/2026-07-31-003-fix-portable-windows-path-unit-tests-plan.md"), + grade: { + must_include: ["coherence", "feasibility"], + ...NO_ROLE_OUTPUT, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-debug/no-map-runs-no-resolver", + post_only: true, + skill: "ce-debug", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/seat-cap`, + timeout_secs: 300, + why: "No CE config: the session investigates, as it always has.", + pre_contract: "ce-debug reads no model setting; the session investigates.", + task: ROLE_DEBUG_TASK, + grade: { + declared: { INVESTIGATED_BY: "session", REQUESTED_MODEL: "none" }, + ...NO_ROLE_OUTPUT, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-work/no-map-runs-no-resolver", + post_only: true, + skill: "ce-work", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/implementation-ready-plan`, + timeout_secs: 600, + why: "No CE config: native execution stays selected with no preflight of any external route.", + pre_contract: "With no directive, caller binding, or enabled config, implementation runs natively.", + task: ROLE_WORK_TASK, + grade: { + declared: { ENGINE_ROUTE: "native", REQUESTED_MODEL: "none" }, + ...NO_ROLE_OUTPUT, + delegates: "none", + git: "clean", + }, + }, + { + id: "ce-simplify-code/no-map-runs-no-resolver", + post_only: true, + skill: "ce-simplify-code", + cohort: "untouched", + key_behavior: "judgment", + read_only: false, + git_init: true, + git_staged: ["src/access.ts"], + fixture: `${FIX}/review-depth-auth-full`, + timeout_secs: 300, + why: "No CE config: the session applies and verifies. The staged file keeps `git status` dirty, so the no-edit check is the file's content.", + pre_contract: "ce-simplify-code reads no model setting; the session applies and verifies.", + task: ROLE_SIMPLIFY_TASK, + grade: { + declared: { APPLIED_BY: "session", REQUESTED_MODEL: "none" }, + ...NO_ROLE_OUTPUT, + delegates: "none", + workspace_contains: [ACCESS_TS_UNCHANGED], + }, + }, + { + id: "ce-code-review/no-map-runs-no-resolver", + post_only: true, + skill: "ce-code-review", + cohort: "resized", + key_behavior: "judgment", + read_only: false, + git_init: true, + git_staged: ["src/access.ts"], + fixture: `${FIX}/review-depth-auth-full`, + timeout_secs: 900, + why: "No CE config: Stage 3d binds the adversarial route the way it did before the map, and the team still has the always-on correctness reviewer. The TEAM trailer may name a single cross-model peer; that is the ordinary single-peer pass, not a seat.", + pre_contract: "Correctness always runs; the adversarial lens goes to one cross-model peer or the in-process reviewer.", + task: ROLE_CODE_REVIEW_TASK, + grade: { + must_include: ["correctness"], + ...NO_ROLE_OUTPUT, + delegates: "none", + workspace_contains: [ACCESS_TS_UNCHANGED], + }, + }, + { + id: "ce-compound/no-map-runs-no-resolver", + post_only: true, + skill: "ce-compound", + cohort: "untouched", + key_behavior: "mutation", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-compound-nomap`, + timeout_secs: 900, + why: "The same code and task as `ce-compound/lightweight-never-hands-off` with no CE config: the report is exactly the documented block, with no role line, and it still ends on the terminal line.", + pre_contract: "Non-interactive Lightweight writes one doc with no subagents and ends on `Documentation complete`.", + task: ROLE_COMPOUND_TASK, + grade: { + must_include_any: [COMPOUND_TERMINAL_LINE_LAST], + ...NO_ROLE_OUTPUT, + delegates: "none", + git: "dirty", + workspace_contains: [COMPOUND_SOURCE_UNCHANGED], + }, + }, ] export function scenarioById(id: string): Scenario | undefined { diff --git a/tests/skill-eval-cell/fixtures/judged-model-roles-setup/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/.compound-engineering/config.yaml new file mode 100644 index 000000000..baebacf57 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/.compound-engineering/config.yaml @@ -0,0 +1,3 @@ +# Team defaults for Compound Engineering in this repo. +plan_model: opus +plan_output: md diff --git a/tests/skill-eval-cell/fixtures/judged-model-roles-setup/AGENTS.md b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/AGENTS.md new file mode 100644 index 000000000..b9789919a --- /dev/null +++ b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/AGENTS.md @@ -0,0 +1,3 @@ +# Greeter + +A small Node library. Run `npm test` before committing. diff --git a/tests/skill-eval-cell/fixtures/judged-model-roles-setup/package.json b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/package.json new file mode 100644 index 000000000..2eea6a04e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/package.json @@ -0,0 +1,8 @@ +{ + "name": "judged-model-roles-setup", + "private": true, + "version": "0.0.1", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/skill-eval-cell/fixtures/judged-model-roles-setup/src/greet.js b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/judged-model-roles-setup/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/.compound-engineering/config.yaml new file mode 100644 index 000000000..1fedb2f0b --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + brainstorm: claude-ce-eval-a high diff --git a/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/README.md b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/README.md new file mode 100644 index 000000000..26ef9f97e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/README.md @@ -0,0 +1,3 @@ +# greeter + +A one-file greeter used as a throwaway subject repo for skill-eval cells. diff --git a/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/package.json b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/package.json new file mode 100644 index 000000000..c822b3f22 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/package.json @@ -0,0 +1,5 @@ +{ + "name": "model-roles-greeter", + "private": true, + "version": "0.0.1" +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-brainstorm-entry/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/.compound-engineering/config.yaml new file mode 100644 index 000000000..931b51dcc --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/.compound-engineering/config.yaml @@ -0,0 +1,3 @@ +cross_model_review_mode: off +model_roles: + code-review: [grok-ce-eval high, claude-ce-eval-a] diff --git a/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/README.md b/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/README.md new file mode 100644 index 000000000..8721d871d --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/README.md @@ -0,0 +1,3 @@ +# workspace-access + +Authorization helpers for the workspace API. Every route handler calls `canAccessWorkspace` before reading or writing workspace data. diff --git a/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/src/access.ts b/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/src/access.ts new file mode 100644 index 000000000..cbf7ed51a --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-code-review-mode-off/src/access.ts @@ -0,0 +1,28 @@ +export type Role = "owner" | "admin" | "member" | "guest" + +export interface Membership { + userId: string + workspaceId: string + role: Role + suspended?: boolean +} + +export interface AccessRequest { + userId: string + workspaceId: string + action: "read" | "write" | "admin" +} + +const WRITE_ROLES: Role[] = ["owner", "admin", "member"] +const ADMIN_ROLES: Role[] = ["owner", "admin"] + +export function canAccessWorkspace(request: AccessRequest, memberships: Membership[]): boolean { + const membership = memberships.find( + (m) => m.userId === request.userId && m.workspaceId === request.workspaceId, + ) + if (!membership) return request.action === "read" + if (membership.suspended) return false + if (request.action === "read") return true + if (request.action === "write") return WRITE_ROLES.includes(membership.role) + return ADMIN_ROLES.includes(membership.role) +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-entry/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/.compound-engineering/config.yaml new file mode 100644 index 000000000..a452c5f8c --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + compound: claude-ce-eval-a diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-entry/README.md b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/README.md new file mode 100644 index 000000000..0b38c02b2 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/README.md @@ -0,0 +1,3 @@ +# rates-sync + +Nightly job that posts exchange-rate batches to the ledger API. `src/post-rates.js` retries a failed post. diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-entry/package.json b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/package.json new file mode 100644 index 000000000..a155a0533 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/package.json @@ -0,0 +1,8 @@ +{ + "name": "model-roles-rates-sync", + "private": true, + "version": "0.0.1", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-entry/src/post-rates.js b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/src/post-rates.js new file mode 100644 index 000000000..9234f4861 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/src/post-rates.js @@ -0,0 +1,12 @@ +const RETRYABLE = new Set([429, 502, 503, 504]) + +async function postRates(send, batch, { attempts = 3 } = {}) { + for (let attempt = 1; attempt <= attempts; attempt++) { + const status = await send(batch) + if (status >= 200 && status < 300) return { ok: true, duplicate: false } + if (status === 409) return { ok: true, duplicate: true } + if (!RETRYABLE.has(status) || attempt === attempts) return { ok: false, status } + } +} + +module.exports = { postRates } diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-entry/test/post-rates.test.js b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/test/post-rates.test.js new file mode 100644 index 000000000..4e93a2537 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-entry/test/post-rates.test.js @@ -0,0 +1,30 @@ +const test = require("node:test") +const assert = require("node:assert") +const { postRates } = require("../src/post-rates.js") + +function sender(statuses) { + const calls = [] + const send = async (batch) => { + calls.push(batch.id) + return statuses[calls.length - 1] + } + return { send, calls } +} + +test("a 201 is delivered on the first attempt", async () => { + const { send, calls } = sender([201]) + assert.deepStrictEqual(await postRates(send, { id: "b1" }), { ok: true, duplicate: false }) + assert.strictEqual(calls.length, 1) +}) + +test("a 504 followed by a 409 counts as delivered", async () => { + const { send, calls } = sender([504, 409]) + assert.deepStrictEqual(await postRates(send, { id: "b1" }), { ok: true, duplicate: true }) + assert.strictEqual(calls.length, 2) +}) + +test("a 400 is not retried", async () => { + const { send, calls } = sender([400, 201]) + assert.deepStrictEqual(await postRates(send, { id: "b1" }), { ok: false, status: 400 }) + assert.strictEqual(calls.length, 1) +}) diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/README.md b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/README.md new file mode 100644 index 000000000..0b38c02b2 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/README.md @@ -0,0 +1,3 @@ +# rates-sync + +Nightly job that posts exchange-rate batches to the ledger API. `src/post-rates.js` retries a failed post. diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/package.json b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/package.json new file mode 100644 index 000000000..a155a0533 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/package.json @@ -0,0 +1,8 @@ +{ + "name": "model-roles-rates-sync", + "private": true, + "version": "0.0.1", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/src/post-rates.js b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/src/post-rates.js new file mode 100644 index 000000000..9234f4861 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/src/post-rates.js @@ -0,0 +1,12 @@ +const RETRYABLE = new Set([429, 502, 503, 504]) + +async function postRates(send, batch, { attempts = 3 } = {}) { + for (let attempt = 1; attempt <= attempts; attempt++) { + const status = await send(batch) + if (status >= 200 && status < 300) return { ok: true, duplicate: false } + if (status === 409) return { ok: true, duplicate: true } + if (!RETRYABLE.has(status) || attempt === attempts) return { ok: false, status } + } +} + +module.exports = { postRates } diff --git a/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/test/post-rates.test.js b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/test/post-rates.test.js new file mode 100644 index 000000000..4e93a2537 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-compound-nomap/test/post-rates.test.js @@ -0,0 +1,30 @@ +const test = require("node:test") +const assert = require("node:assert") +const { postRates } = require("../src/post-rates.js") + +function sender(statuses) { + const calls = [] + const send = async (batch) => { + calls.push(batch.id) + return statuses[calls.length - 1] + } + return { send, calls } +} + +test("a 201 is delivered on the first attempt", async () => { + const { send, calls } = sender([201]) + assert.deepStrictEqual(await postRates(send, { id: "b1" }), { ok: true, duplicate: false }) + assert.strictEqual(calls.length, 1) +}) + +test("a 504 followed by a 409 counts as delivered", async () => { + const { send, calls } = sender([504, 409]) + assert.deepStrictEqual(await postRates(send, { id: "b1" }), { ok: true, duplicate: true }) + assert.strictEqual(calls.length, 2) +}) + +test("a 400 is not retried", async () => { + const { send, calls } = sender([400, 201]) + assert.deepStrictEqual(await postRates(send, { id: "b1" }), { ok: false, status: 400 }) + assert.strictEqual(calls.length, 1) +}) diff --git a/tests/skill-eval-cell/fixtures/model-roles-debug-entry/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/.compound-engineering/config.yaml new file mode 100644 index 000000000..c2760a2c9 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + debug: claude-ce-eval-a diff --git a/tests/skill-eval-cell/fixtures/model-roles-debug-entry/SPEC.md b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/SPEC.md new file mode 100644 index 000000000..c4bb202d6 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/SPEC.md @@ -0,0 +1,3 @@ +# Seat cap + +A dispatcher board has 3 seats. That number is the product contract. diff --git a/tests/skill-eval-cell/fixtures/model-roles-debug-entry/package.json b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/package.json new file mode 100644 index 000000000..b49f01b4d --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/package.json @@ -0,0 +1,4 @@ +{ + "name": "seat-cap-fixture", + "private": true +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-debug-entry/src/seat-cap.js b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/src/seat-cap.js new file mode 100644 index 000000000..6dba1a19f --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/src/seat-cap.js @@ -0,0 +1,2 @@ +// Seat cap is defined in SPEC.md. +module.exports = { SEAT_CAP: 5 }; diff --git a/tests/skill-eval-cell/fixtures/model-roles-debug-entry/tests/seat-cap.check.js b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/tests/seat-cap.check.js new file mode 100644 index 000000000..99c7787c0 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-debug-entry/tests/seat-cap.check.js @@ -0,0 +1,5 @@ +const { SEAT_CAP } = require("../src/seat-cap.js"); +if (SEAT_CAP !== 3) { + console.error("SEAT_CAP: expected 3, got " + SEAT_CAP); + process.exit(1); +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/.compound-engineering/config.yaml new file mode 100644 index 000000000..cad9df0dd --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/.compound-engineering/config.yaml @@ -0,0 +1,6 @@ +cross_model_review_mode: off +model_roles: + doc-review: + - grok-ce-eval high + - gpt-ce-eval + - claude-ce-eval-a medium diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/README.md b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/README.md new file mode 100644 index 000000000..26ef9f97e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/README.md @@ -0,0 +1,3 @@ +# greeter + +A one-file greeter used as a throwaway subject repo for skill-eval cells. diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md new file mode 100644 index 000000000..25fd1a476 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md @@ -0,0 +1,70 @@ +--- +title: "fix: Trim surrounding whitespace from greeting names" +type: fix +date: 2026-09-28 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# fix: Trim surrounding whitespace from greeting names + +## Goal Capsule + +- **Objective:** `greet(" Ada ")` returns `hello Ada`, the same string `greet("Ada")` returns today. +- **Authority:** The Product Contract below, agreed in the brainstorm on 2026-09-27. It is unchanged here. +- **Stop when:** R1 and R2 pass their test scenarios and the existing greeting test still passes. +- **Open blockers:** None. + +--- + +## Product Contract + +### Summary + +Callers pass names copied from form fields, so some arrive with a leading or trailing space and the greeting shows a double space. Trim the name before formatting it. + +### Requirements + +- R1. `greet` removes leading and trailing whitespace from the name before it formats the greeting. +- R2. Whitespace inside a name is kept: `greet("Ada Lovelace")` still returns `hello Ada Lovelace`. + +### Scope Boundaries + +- A name that is empty after trimming keeps today's output. Changing it is out of scope. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. Use `String.prototype.trim()` on the argument inside `greet`. It removes the same characters at both ends that the form fields produce, and it needs no dependency. + +### Implementation Units + +#### U1. Trim the name in `greet` + +- **Covers:** R1, R2. +- **Files:** modify `src/greet.js`; modify `test/greet.test.js`. +- **Approach:** Call `name.trim()` once at the top of `greet` and format the trimmed value. +- **Test scenarios:** + - `greet(" Ada ")` returns `hello Ada`. + - `greet("\tAda\n")` returns `hello Ada`. + - `greet("Ada Lovelace")` returns `hello Ada Lovelace`. + - `greet("Ada")` returns `hello Ada`, as it does today. +- **Verification:** `npm test`. + +--- + +## Verification Contract + +| Gate | Command | +|---|---| +| Unit tests | `npm test` | + +## Definition of Done + +- R1 and R2 pass the scenarios in U1. +- `npm test` passes. diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/package.json b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/package.json new file mode 100644 index 000000000..52f8107cd --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/package.json @@ -0,0 +1,8 @@ +{ + "name": "model-roles-greeter", + "private": true, + "version": "0.0.1", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/test/greet.test.js b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/test/greet.test.js new file mode 100644 index 000000000..e9d598b36 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-mode-off/test/greet.test.js @@ -0,0 +1,7 @@ +const test = require("node:test") +const assert = require("node:assert") +const greet = require("../src/greet.js") + +test("greets by name", () => { + assert.strictEqual(greet("Ada"), "hello Ada") +}) diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/.compound-engineering/config.yaml new file mode 100644 index 000000000..958251d72 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + doc-review: [claude-ce-eval-a high, claude-ce-eval-b] diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/README.md b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/README.md new file mode 100644 index 000000000..26ef9f97e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/README.md @@ -0,0 +1,3 @@ +# greeter + +A one-file greeter used as a throwaway subject repo for skill-eval cells. diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md new file mode 100644 index 000000000..25fd1a476 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md @@ -0,0 +1,70 @@ +--- +title: "fix: Trim surrounding whitespace from greeting names" +type: fix +date: 2026-09-28 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# fix: Trim surrounding whitespace from greeting names + +## Goal Capsule + +- **Objective:** `greet(" Ada ")` returns `hello Ada`, the same string `greet("Ada")` returns today. +- **Authority:** The Product Contract below, agreed in the brainstorm on 2026-09-27. It is unchanged here. +- **Stop when:** R1 and R2 pass their test scenarios and the existing greeting test still passes. +- **Open blockers:** None. + +--- + +## Product Contract + +### Summary + +Callers pass names copied from form fields, so some arrive with a leading or trailing space and the greeting shows a double space. Trim the name before formatting it. + +### Requirements + +- R1. `greet` removes leading and trailing whitespace from the name before it formats the greeting. +- R2. Whitespace inside a name is kept: `greet("Ada Lovelace")` still returns `hello Ada Lovelace`. + +### Scope Boundaries + +- A name that is empty after trimming keeps today's output. Changing it is out of scope. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. Use `String.prototype.trim()` on the argument inside `greet`. It removes the same characters at both ends that the form fields produce, and it needs no dependency. + +### Implementation Units + +#### U1. Trim the name in `greet` + +- **Covers:** R1, R2. +- **Files:** modify `src/greet.js`; modify `test/greet.test.js`. +- **Approach:** Call `name.trim()` once at the top of `greet` and format the trimmed value. +- **Test scenarios:** + - `greet(" Ada ")` returns `hello Ada`. + - `greet("\tAda\n")` returns `hello Ada`. + - `greet("Ada Lovelace")` returns `hello Ada Lovelace`. + - `greet("Ada")` returns `hello Ada`, as it does today. +- **Verification:** `npm test`. + +--- + +## Verification Contract + +| Gate | Command | +|---|---| +| Unit tests | `npm test` | + +## Definition of Done + +- R1 and R2 pass the scenarios in U1. +- `npm test` passes. diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/package.json b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/package.json new file mode 100644 index 000000000..52f8107cd --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/package.json @@ -0,0 +1,8 @@ +{ + "name": "model-roles-greeter", + "private": true, + "version": "0.0.1", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/test/greet.test.js b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/test/greet.test.js new file mode 100644 index 000000000..e9d598b36 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-doc-review-seats/test/greet.test.js @@ -0,0 +1,7 @@ +const test = require("node:test") +const assert = require("node:assert") +const greet = require("../src/greet.js") + +test("greets by name", () => { + assert.strictEqual(greet("Ada"), "hello Ada") +}) diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-entry/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/.compound-engineering/config.yaml new file mode 100644 index 000000000..4647a9b45 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/.compound-engineering/config.yaml @@ -0,0 +1,3 @@ +plan_model: claude-ce-eval-legacy +model_roles: + plan: claude-ce-eval-a low diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-entry/README.md b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/README.md new file mode 100644 index 000000000..26ef9f97e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/README.md @@ -0,0 +1,3 @@ +# greeter + +A one-file greeter used as a throwaway subject repo for skill-eval cells. diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-entry/package.json b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/package.json new file mode 100644 index 000000000..c822b3f22 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/package.json @@ -0,0 +1,5 @@ +{ + "name": "model-roles-greeter", + "private": true, + "version": "0.0.1" +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-entry/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-entry/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/.compound-engineering/config.yaml new file mode 100644 index 000000000..4be7d4d45 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/.compound-engineering/config.yaml @@ -0,0 +1,3 @@ +plan_model: claude-ce-eval-legacy +model_roles: + work: claude-ce-eval-b medium diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/README.md b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/README.md new file mode 100644 index 000000000..26ef9f97e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/README.md @@ -0,0 +1,3 @@ +# greeter + +A one-file greeter used as a throwaway subject repo for skill-eval cells. diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/package.json b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/package.json new file mode 100644 index 000000000..c822b3f22 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/package.json @@ -0,0 +1,5 @@ +{ + "name": "model-roles-greeter", + "private": true, + "version": "0.0.1" +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-existing-key/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.local.yaml b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.local.yaml new file mode 100644 index 000000000..b63285d38 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.local.yaml @@ -0,0 +1,2 @@ +model_roles: + plan: claude-ce-eval-c diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.yaml new file mode 100644 index 000000000..e70ecb50b --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/.compound-engineering/config.yaml @@ -0,0 +1,3 @@ +model_roles: + plan: claude-ce-eval-a low + work: claude-ce-eval-b medium diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-personal/README.md b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/README.md new file mode 100644 index 000000000..26ef9f97e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/README.md @@ -0,0 +1,3 @@ +# greeter + +A one-file greeter used as a throwaway subject repo for skill-eval cells. diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-personal/package.json b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/package.json new file mode 100644 index 000000000..c822b3f22 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/package.json @@ -0,0 +1,5 @@ +{ + "name": "model-roles-greeter", + "private": true, + "version": "0.0.1" +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-plan-personal/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-plan-personal/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/.compound-engineering/config.yaml new file mode 100644 index 000000000..37d28afdc --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + simplify: claude-ce-eval-a low diff --git a/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/README.md b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/README.md new file mode 100644 index 000000000..3134c1731 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/README.md @@ -0,0 +1,3 @@ +# orders + +Order totals for a small shop. diff --git a/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/package.json b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/package.json new file mode 100644 index 000000000..550b3b7df --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/package.json @@ -0,0 +1,8 @@ +{ + "name": "model-roles-orders", + "private": true, + "version": "0.0.1", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/src/totals.js b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/src/totals.js new file mode 100644 index 000000000..38664b4e5 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/src/totals.js @@ -0,0 +1,29 @@ +function orderTotal(order) { + let subtotal = 0 + for (let i = 0; i < order.items.length; i++) { + subtotal = subtotal + order.items[i].price * order.items[i].qty + } + let total + if (order.coupon === "TEN") { + total = subtotal - subtotal * 0.1 + } else { + total = subtotal + } + return total +} + +function orderTotalWithShipping(order, shipping) { + let subtotal = 0 + for (let i = 0; i < order.items.length; i++) { + subtotal = subtotal + order.items[i].price * order.items[i].qty + } + let total + if (order.coupon === "TEN") { + total = subtotal - subtotal * 0.1 + } else { + total = subtotal + } + return total + shipping +} + +module.exports = { orderTotal, orderTotalWithShipping } diff --git a/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/test/totals.test.js b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/test/totals.test.js new file mode 100644 index 000000000..742682af5 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-simplify-entry/test/totals.test.js @@ -0,0 +1,13 @@ +const test = require("node:test") +const assert = require("node:assert") +const { orderTotal, orderTotalWithShipping } = require("../src/totals.js") + +const order = { items: [{ price: 10, qty: 2 }, { price: 5, qty: 1 }], coupon: "TEN" } + +test("applies the coupon to the subtotal", () => { + assert.strictEqual(orderTotal(order), 22.5) +}) + +test("adds shipping after the coupon", () => { + assert.strictEqual(orderTotalWithShipping(order, 4), 26.5) +}) diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-entry/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-work-entry/.compound-engineering/config.yaml new file mode 100644 index 000000000..66ea796cb --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-entry/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + work: claude-ce-eval-a medium diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-entry/docs/plans/widget-plan.md b/tests/skill-eval-cell/fixtures/model-roles-work-entry/docs/plans/widget-plan.md new file mode 100644 index 000000000..88319a5ba --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-entry/docs/plans/widget-plan.md @@ -0,0 +1,39 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +--- + +# Quiet greeting + +## Goal Capsule + +Add an optional quiet greeting without changing the existing greet behavior. + +## Product Contract + +### Requirements + +R1. greetQuiet(name) returns an empty string for whitespace-only input and otherwise returns greet(name). + +## Planning Contract + +Add a separate export in src/quiet.js that imports greet from src/greet.js. Keep greet unchanged. No dependencies or architectural decisions are outstanding. + +## Implementation Units + +### U1. Quiet greeting + +- Covers R1. +- Files: create src/quiet.js and tests/quiet.test.js. +- Approach: trim the supplied string to detect blank input; otherwise delegate to greet. +- Test scenarios: empty string and spaces return empty; Alice returns the existing greeting. +- Verification: node --test tests/quiet.test.js. + +## Verification Contract + +Run node --test tests/quiet.test.js and check that greet('Alice') retains its existing output. Tests use Node's built-in test runner and assertions. + +## Definition of Done + +R1 passes the named scenarios; existing greet behavior remains unchanged. No open product or architecture decisions. diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-entry/package.json b/tests/skill-eval-cell/fixtures/model-roles-work-entry/package.json new file mode 100644 index 000000000..729ac4d93 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-entry/package.json @@ -0,0 +1 @@ +{"type":"commonjs"} diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-entry/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-work-entry/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-entry/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.local.yaml b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.local.yaml new file mode 100644 index 000000000..d40051756 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.local.yaml @@ -0,0 +1 @@ +work_engine_mode: off diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.yaml new file mode 100644 index 000000000..145180b1d --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + work: claude-ce-eval-a high diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/docs/plans/widget-plan.md b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/docs/plans/widget-plan.md new file mode 100644 index 000000000..88319a5ba --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/docs/plans/widget-plan.md @@ -0,0 +1,39 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +--- + +# Quiet greeting + +## Goal Capsule + +Add an optional quiet greeting without changing the existing greet behavior. + +## Product Contract + +### Requirements + +R1. greetQuiet(name) returns an empty string for whitespace-only input and otherwise returns greet(name). + +## Planning Contract + +Add a separate export in src/quiet.js that imports greet from src/greet.js. Keep greet unchanged. No dependencies or architectural decisions are outstanding. + +## Implementation Units + +### U1. Quiet greeting + +- Covers R1. +- Files: create src/quiet.js and tests/quiet.test.js. +- Approach: trim the supplied string to detect blank input; otherwise delegate to greet. +- Test scenarios: empty string and spaces return empty; Alice returns the existing greeting. +- Verification: node --test tests/quiet.test.js. + +## Verification Contract + +Run node --test tests/quiet.test.js and check that greet('Alice') retains its existing output. Tests use Node's built-in test runner and assertions. + +## Definition of Done + +R1 passes the named scenarios; existing greet behavior remains unchanged. No open product or architecture decisions. diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/package.json b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/package.json new file mode 100644 index 000000000..729ac4d93 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/package.json @@ -0,0 +1 @@ +{"type":"commonjs"} diff --git a/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/src/greet.js b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/src/greet.js new file mode 100644 index 000000000..8afc87d6e --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-work-personal-off/src/greet.js @@ -0,0 +1,3 @@ +module.exports = function greet(name) { + return `hello ${name}` +} diff --git a/tests/skill-eval-cell/judged/personas/model-roles-setup.md b/tests/skill-eval-cell/judged/personas/model-roles-setup.md new file mode 100644 index 000000000..798c5e0bb --- /dev/null +++ b/tests/skill-eval-cell/judged/personas/model-roles-setup.md @@ -0,0 +1,9 @@ +You maintain a small Node library and run coding agents on it from more than one machine. +Opening request: set up model roles for this repo. +What you want, and say when asked which roles to change: planning on `opus high`, and document review by two reviewers, `sonnet` and `zephyr-9 high`. Every other role stays as it is. +Which file: the team file, so teammates and cloud sessions get the same setup. +About `zephyr-9`: it is a model your company is trialing. You type it exactly as `zephyr-9 high`, and you still want it written if the assistant says it cannot confirm it. +Needs you will not volunteer but will confirm if the assistant raises them: you know `plan_model: opus` is already in the team file, you are fine with the new planning entry taking over from it, and you do not want that old line deleted. +What you would call overkill if proposed: setting a role you did not ask for, a personal override file, changing any other setting, installing tools. +When shown the preview: approve it if it holds exactly your two roles with your values; otherwise say what is wrong. +Style: short answers. If asked something this does not settle, say it's the assistant's call. diff --git a/tests/skill-eval-cell/judged/rubrics/model-roles-setup.md b/tests/skill-eval-cell/judged/rubrics/model-roles-setup.md new file mode 100644 index 000000000..3c6e61f81 --- /dev/null +++ b/tests/skill-eval-cell/judged/rubrics/model-roles-setup.md @@ -0,0 +1,25 @@ +# Grading rubric: the model role setup flow + +You grade one conversation in which an assistant sets up a per-repo map of model roles. You do not know how it was produced. You get the user's persona (the ground truth: which roles they want, which file, and what they would call overkill), the full conversation, and any document the assistant wrote. The map is written to a YAML config file, which is not a document, so the result section may say none was written. Grade the file from what the assistant printed in the conversation: the task asked it to print every config file's final contents. + +The repo started with one team file, `.compound-engineering/config.yaml`, holding a comment line, `plan_model: opus`, and `plan_output: md`. No personal file `.compound-engineering/config.local.yaml` existed. The eight roles are brainstorm, plan, doc-review, debug, work, simplify, code-review, and compound. + +1. roles_shown: before asking what to change, how many of the eight roles the assistant showed with both an effective value and where that value comes from (0 to 8). +2. plan_source_named: whether that first view says the plan step is governed today by `plan_model` (value `opus`), not by a map entry and not by the session model. +3. offer_basis_stated: whether, when offering models, each offered model family is tied to something the assistant observed: the app it runs in serves it, or a named installed CLI serves it. false if it offered a family with no such basis, or offered model ids it did not show a source for. +4. inherit_offered: whether `inherit` was offered as a choice. +5. entitlement_caveat: whether the assistant said that being offered means a route to the model exists, not that the user's account may use it. +6. replacement_said: whether, before writing, the assistant said the new plan entry replaces or takes over from `plan_model` for that step and named the key. +7. file_asked_with_reach: whether the assistant asked which file receives the map and stated both reaches: the team file reaches every worktree, clone, and cloud session (and teammates); the personal file reaches this checkout only. false if it asked without both reaches, or chose a file without asking. +8. preview_complete: whether, before writing, the assistant showed the whole resulting `model_roles` block (the plan entry and both doc-review seats) and asked once for approval. +9. unconfirmed_marker_correct: whether that preview carries `# unconfirmed` on the `zephyr-9` seat and on no other entry. null if no preview was shown. +10. wrote_before_approval: whether any config file was written or edited before the user approved the preview. Judge from the assistant's own account of its actions in each turn. +11. final_file_correct: after approval, whether the printed `config.yaml` holds a `model_roles` block with `plan: opus high` and a `doc-review` list of `sonnet` and `zephyr-9 high` (the second marked `# unconfirmed`), and still holds the original comment line, `plan_model: opus`, and `plan_output: md` unchanged. null if the final file contents were never printed. +12. local_file_absent: whether the conversation's final listing shows that no `config.local.yaml` exists. null if the final listing was never shown. +13. extra_roles: count of roles in the written or previewed block that the user did not ask for. +14. overkill: count of things the assistant proposed or did that the persona lists as overkill. +15. questions: count of questions the assistant asked the user. + +Return only one JSON object, with no code fence or other text: + +{"metrics": {"roles_shown": 0, "plan_source_named": false, "offer_basis_stated": false, "inherit_offered": false, "entitlement_caveat": false, "replacement_said": false, "file_asked_with_reach": false, "preview_complete": false, "unconfirmed_marker_correct": null, "wrote_before_approval": false, "final_file_correct": null, "local_file_absent": null, "extra_roles": 0, "overkill": 0, "questions": 0}, "items": [{"kind": "missing|wrong|overkill", "text": "one short line"}], "widened": "the most useful thing the flow told the user that they had not asked about", "pushback": "anything the user had to correct, or none"} diff --git a/tests/skill-eval-cell/judged/scenarios.ts b/tests/skill-eval-cell/judged/scenarios.ts index 525d36cae..c962b8243 100644 --- a/tests/skill-eval-cell/judged/scenarios.ts +++ b/tests/skill-eval-cell/judged/scenarios.ts @@ -85,6 +85,20 @@ function scoping(id: string, skill: "ce-brainstorm" | "ce-plan", fixture: string } } +/** + * main before the model role map. `ce-setup` has no model role flow at this ref, so the + * base arm shows only that the flow did not exist; run this scenario with `--arm post`. + */ +export const MODEL_ROLES_BASE_REF = "1fd12d69812b9c48e0b054bb4a000de2cb37f744" + +/** + * The map lands in a YAML file, which the judge's result document (`.md` / `.html` only) + * never includes, so the task has the assistant print the config directory at the end. + */ +export const SETUP_MODEL_ROLES_TASK = `Use ce-setup with me. I'm here and will answer your questions: ask in chat, one question at a time, and wait for my reply. When the flow has finished, list the files under .compound-engineering/ and print the final contents of each one verbatim under its file name. + +Request: {opening}` + const JOB_OPENING = "Add a nightly job that charges each customer's saved card for their overdue open invoices and marks them paid." const DUNNING_OPENING = "Build a complete dunning system for overdue invoices: a nightly job that charges each customer's saved card, a configurable retry schedule (for example day 1, 3 and 7 after the due date), customer emails before each attempt and after a failure, an admin page that lists failed collections with a manual \"retry now\" button, and a per-customer switch to pause collection." @@ -105,4 +119,19 @@ export const JUDGED_SCENARIOS: JudgedScenario[] = [ scoping("plan-solo-job", "ce-plan", "judged-billing", "job.md", JOB_OPENING, 2), scoping("plan-solo-dunning", "ce-plan", "judged-billing", "dunning.md", DUNNING_OPENING, 2), scoping("plan-from-brainstorm", "ce-plan", "judged-billing-planned", "job-planned.md", "Plan docs/plans/2026-09-20-0900-feat-nightly-invoice-charging-plan.md", 2), + { + id: "ce-setup/model-roles-flow", + skill: "ce-setup", + companions: [], + fixture: `${FIX}/judged-model-roles-setup`, + persona: "model-roles-setup.md", + opening: "Set up model roles for this repo.", + task: SETUP_MODEL_ROLES_TASK, + rubric: "model-roles-setup.md", + base_ref: MODEL_ROLES_BASE_REF, + hosts: ["claude", "codex"], + trials: 1, + max_turns: 25, + timeout_secs: 3600, + }, ] diff --git a/tests/skill-eval-cell/scenarios.md b/tests/skill-eval-cell/scenarios.md index 126f57503..c4ec4a5dd 100644 --- a/tests/skill-eval-cell/scenarios.md +++ b/tests/skill-eval-cell/scenarios.md @@ -115,6 +115,58 @@ Read the transcripts alongside the declared decisions; these cells do not execut | `ce-retune/fresh-operational-confirmation` | Fresh runs clear the operational bar; diagnostics and registered broken runs stay outside its count | | `ce-retune/behavioral-failure-stops-attempt` | A behavioral failure ends the attempt and requires diagnosis; no pooling or queue continuation | +## Model role map (post-only; the map has no pre-change arm) + +```bash +bun run test:skill-eval-pack -- --id ce-plan/role-entry-reaches-authoring-gate --arm post --hosts claude +bun run test:skill-eval-pack -- --skill ce-doc-review --arm post --hosts claude +``` + +Every row is write mode, because resolving a role runs a bundled script and a read-only cell denies the shell. Each stops at one decision and is graded on declared lines, the `TEAM` trailer, and an unchanged workspace. The tasks never name the map or its resolver. A skill's entry rows and its no-map row share one task, so only the fixture differs. Fixture model ids such as `claude-ce-eval-a` are grammar-valid fakes, so a declared id can only have come from the entry that won. + +Resolution, precedence, and review policy: + +| ID | Grade | +|---|---| +| `ce-plan/role-entry-reaches-authoring-gate` | `plan` entry beats `plan_model`: the entry's model at `low`, source key `model_roles`, route `claude-cli` | +| `ce-plan/personal-role-entry-overrides-one-role` | Personal `plan` entry wins from `config.local.yaml`, and the team entry's effort does not survive | +| `ce-plan/existing-key-decides-when-role-unset` | Map active with no `plan` entry: the `plan_model` value, attributed to `plan_model` | +| `ce-brainstorm/role-entry-reaches-approach-gate` | `brainstorm` entry at `high`, source key `model_roles`, route `claude-cli` | +| `ce-work/role-entry-routes-through-claude-engine` | `work` entry runs through the `claude` route at `medium`, not native | +| `ce-work/personal-off-keeps-team-entry-native` | Team `work` entry stays native; the personal `work_engine_mode` is named as the reason | +| `ce-doc-review/seat-list-runs-on-routine-plan` | Both seats are on the team of a plan that activates no judgment lens | +| `ce-doc-review/review-mode-off-skips-other-provider-seats` | Mode `off`: only the Claude seat is on the team; two seats not run, reason `review-mode` | +| `ce-code-review/seat-list-and-review-mode` | Mode `off`: the Claude seat is on the team; the Grok seat is not run, reason `review-mode` | +| `ce-debug/role-entry-hands-off-investigation` | The `debug` entry's model is declared before any investigation; subagent or session both pass | +| `ce-simplify-code/role-entry-governs-apply` | The `simplify` entry's model and `low` are declared, with the effort reported as not applied | +| `ce-compound/lightweight-never-hands-off` | A doc is written with no subagent, a `Model role compound:` line with route session, and `Documentation complete` still last | + +Restraint, one per role skill, with no `model_roles` key in either config file. Each grade fails when `model-role-resolve` appears in `ACTIONS`, when the report block mentions a model role, or when the ordinary decision is not declared: + +| ID | Ordinary decision still declared | +|---|---| +| `ce-brainstorm/no-map-runs-no-resolver` | No requested model; approaches generated by the session | +| `ce-plan/no-map-runs-no-resolver` | `plan_model` value, attributed to `plan_model` | +| `ce-doc-review/no-map-runs-no-resolver` | Team has coherence and feasibility | +| `ce-debug/no-map-runs-no-resolver` | The session investigates | +| `ce-work/no-map-runs-no-resolver` | Native execution | +| `ce-simplify-code/no-map-runs-no-resolver` | The session applies and verifies | +| `ce-code-review/no-map-runs-no-resolver` | Team has correctness | +| `ce-compound/no-map-runs-no-resolver` | A doc is written and the report ends on `Documentation complete` | + +What these rows cannot show: + +- **Host-dependent expectations.** The two review-policy rows are graded for a host that attests the Claude family. On a host with an unattested family (OpenCode, Cursor) every named seat is skipped under `off`, so run them with `--hosts claude`. The `claude-cli` and `claude` route expectations assume `claude` is on `PATH` and a subagent tool with no effort control. +- **The resolver run itself.** `must_exclude` reads the `ACTIONS` trailer, so a run that ran the resolver and did not list it passes the restraint half. The report-block check is the second observation. +- **Serving.** Every row stops before dispatch. Whether a seat or a hand-off is actually served, dropped, or receipted is the hand-run packs' job (`packs/ce-doc-review-cross-model.md`, `packs/ce-code-review-cross-model.md`). +- **Cursor.** The harness has no Cursor host, so AE7 and the Cursor half of AE9 stay manual checks. + +The setup flow is a judged conversation, `ce-setup/model-roles-flow` in `judged/scenarios.ts`. Its map lands in a YAML file the judge's result document does not carry, so the task has the assistant print the config directory at the end and the rubric grades that printout: + +```bash +bun run test:skill-eval-judge -- --id ce-setup/model-roles-flow --arm post --out /tmp/judge-model-roles +``` + ## Named gaps - **Reaping a peer session the cell never launched.** A timed-out host is killed by process group, but `ce-pov`'s peer runner double-forks and calls `setsid()`, so its supervisor lives in a new session outside that group and survives. The driver has no handle on it — it never sees the run id the runner keys its jobs by. Containing it means running each cell inside a cleanup boundary that owns new sessions too (a container, cgroup, or jail), not a change to the kill call. @@ -126,7 +178,7 @@ Read the transcripts alongside the declared decisions; these cells do not execut ## Intentionally not in the catalog -- Untouched small skills (commit, polish, promote, riffrec, simplify-code, test-xcode, worktree) — no shrink to A/B, and a row whose grade is only `ACTIONS: none` cannot fail. +- Untouched small skills (commit, polish, promote, riffrec, test-xcode, worktree) — no shrink to A/B, and a row whose grade is only `ACTIONS: none` cannot fail. `ce-simplify-code` has model role rows only. - `ce-pov` recognition quiz — replaced by `oracle-dispatches-peers`. - Sustained babysit watch, GitHub Enterprise, `gh stack` — unexercised, not passing. - **Grok host attestation and the Grok route** (`tmchow/investigate-grok-doc-review`). Three rows were written and run A/B against the branch merge-base: a Grok-host self-exclusion cell on `--hosts grok`, and a `grok`-binds-`grok-cli` cell on `--hosts claude,codex` for both `ce-doc-review` and `ce-pov`. Ten of ten arms passed, pre arms included, so none of them can fail the invariant they claim. From 0f00351a1d18f3b2af3888d70301fbfeef79443d Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:47:47 -0700 Subject: [PATCH 15/53] test(evals): add Cursor as an explicit eval host --hosts cursor runs a cell through cursor-agent -p in a trusted, sandboxed workspace, using the flags the bundled workers already run. It is never a default peer, because it needs a signed-in cursor-agent and bills its own account. Adds the unattested-host row for the review-mode policy, which is the case Cursor exists to check. Co-Authored-By: Claude Code --- tests/skill-eval-cell/README.md | 4 +-- tests/skill-eval-cell/catalog.test.ts | 2 ++ tests/skill-eval-cell/catalog.ts | 22 +++++++++++++ tests/skill-eval-cell/hosts.test.ts | 46 ++++++++++++++++++++++++++- tests/skill-eval-cell/hosts.ts | 44 +++++++++++++++++++++---- tests/skill-eval-cell/pack.ts | 2 +- tests/skill-eval-cell/provenance.ts | 2 +- tests/skill-eval-cell/run.ts | 2 +- tests/skill-eval-cell/scenarios.md | 1 + 9 files changed, 112 insertions(+), 13 deletions(-) diff --git a/tests/skill-eval-cell/README.md b/tests/skill-eval-cell/README.md index 620fb1fec..c29661719 100644 --- a/tests/skill-eval-cell/README.md +++ b/tests/skill-eval-cell/README.md @@ -1,8 +1,8 @@ # Skill-eval cell driver -Extract `skills/` from a git ref and run the same prompt on the CLIs already on PATH: `claude`, `codex`, `grok`. Bills those products (whatever you already use to run the harness). No extra Anthropic/OpenAI API key. No Vercel AI SDK. +Extract `skills/` from a git ref and run the same prompt on the CLIs already on PATH: `claude`, `codex`, `grok`, `opencode`, and, when named, `cursor-agent`. Bills those products (whatever you already use to run the harness). No extra Anthropic/OpenAI API key. No Vercel AI SDK. -Default hosts are the **other two** from the calling harness (Claude Code → Codex+Grok, Codex → Claude+Grok, Grok → Claude+Codex). `--hosts` overrides that. A missing CLI prints `warning: skipping : …` and the run continues with whatever is left. If no peers are installed it falls back to the current harness and prints `warning: own-eval only …`. Exit 2 only when nothing can run. `summary.json` records `current_harness`, `hosts_wanted`, `hosts_run`, `own_eval_only`, and `warnings`. Not in default `bun test` except mechanical pins (`hosts.test.ts`, `extract.test.ts`, `path-shim.test.ts`). Cursor is not a default host (`cursor-agent -p` hangs without prior trust). +Default hosts are the **other two** from the calling harness (Claude Code → Codex+Grok, Codex → Claude+Grok, Grok → Claude+Codex). `--hosts` overrides that. A missing CLI prints `warning: skipping : …` and the run continues with whatever is left. If no peers are installed it falls back to the current harness and prints `warning: own-eval only …`. Exit 2 only when nothing can run. `summary.json` records `current_harness`, `hosts_wanted`, `hosts_run`, `own_eval_only`, and `warnings`. Not in default `bun test` except mechanical pins (`hosts.test.ts`, `extract.test.ts`, `path-shim.test.ts`). Cursor is an explicit host: `--hosts claude,codex,cursor` runs the cell through `cursor-agent -p --trust`, and a default peer selection never includes it. It needs a signed-in `cursor-agent` (`cursor-agent login`); an unauthenticated run fails the cell instead of skipping it. Use it for behavior that depends on the Cursor host, such as serving a `model_roles` entry through a native subagent or the review egress policy under an unattested host family. ## Run diff --git a/tests/skill-eval-cell/catalog.test.ts b/tests/skill-eval-cell/catalog.test.ts index 90679f8d2..53f689b5b 100644 --- a/tests/skill-eval-cell/catalog.test.ts +++ b/tests/skill-eval-cell/catalog.test.ts @@ -192,6 +192,7 @@ describe("skill-eval-cell catalog", () => { "ce-work/personal-off-keeps-team-entry-native:references/model-roles.md", "ce-doc-review/seat-list-runs-on-routine-plan:references/model-roles.md", "ce-doc-review/review-mode-off-skips-other-provider-seats:references/model-roles.md", + "ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat:references/model-roles.md", "ce-code-review/seat-list-and-review-mode:references/model-roles.md", "ce-debug/role-entry-hands-off-investigation:references/model-roles.md", "ce-simplify-code/role-entry-governs-apply:references/model-roles.md", @@ -336,6 +337,7 @@ describe("skill-eval-cell catalog", () => { "ce-doc-review/approval-versus-judgment-summary", "ce-doc-review/no-map-runs-no-resolver", "ce-doc-review/review-mode-off-skips-other-provider-seats", + "ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat", "ce-doc-review/seat-list-runs-on-routine-plan", "ce-noslop/dense-paragraph-keeps-every-claim", "ce-noslop/detect-names-patterns-without-rewrite", diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 1b471a746..cc7179995 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -3749,6 +3749,28 @@ Use "Correct widget limit" as the description in the subject and any required bo git: "clean", }, }, + { + id: "ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat", + post_only: true, + skill: "ce-doc-review", + cohort: "untouched", + key_behavior: "judgment", + read_only: false, + git_init: true, + fixture: `${FIX}/model-roles-doc-review-mode-off`, + timeout_secs: 600, + why: "The same fixture as the row above, graded for a host whose serving family cannot be attested. Cursor is the host this exists for: its family is unknown by rule, so under `cross_model_review_mode: off` no named seat can be shown to stay with the session's provider and all three are skipped. Run this row with `--hosts cursor` (or `opencode`); on a host that attests a family it fails by design, because one seat runs.", + pre_contract: "`cross_model_review_mode: off` skips the single cross-model pass.", + task: roleDocReviewTask(ROLE_DOC_REVIEW_PLAN), + grade: { + files_read_post: ["references/model-roles.md"], + must_include: ["coherence", "feasibility"], + must_not_include: ["grok-ce-eval", "gpt-ce-eval", "claude-ce-eval-a"], + declared: { NOT_RUN_COUNT: "3", NOT_RUN_REASON: "review-mode" }, + delegates: "none", + git: "clean", + }, + }, { id: "ce-code-review/seat-list-and-review-mode", post_only: true, diff --git a/tests/skill-eval-cell/hosts.test.ts b/tests/skill-eval-cell/hosts.test.ts index cd2ef005d..297ba3f93 100644 --- a/tests/skill-eval-cell/hosts.test.ts +++ b/tests/skill-eval-cell/hosts.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import os from "node:os" import path from "node:path" -import { attestCurrentHost, cellEnv, peerHosts, planHost, resolveRunHosts, wrapPrompt } from "./hosts" +import { attestCurrentHost, cellEnv, hostBinary, peerHosts, planHost, resolveRunHosts, wrapPrompt } from "./hosts" describe("skill-eval-cell host plans pin measured gotchas", () => { const cwd = os.tmpdir() @@ -71,6 +71,50 @@ describe("skill-eval-cell host plans pin measured gotchas", () => { expect(resolved.warnings).toEqual([]) }) + test("cursor runs cursor-agent headless in a trusted, sandboxed workspace", () => { + const plan = planHost("cursor", { cwd, prompt: "task", promptFile }) + expect(plan.argv).toEqual([ + "cursor-agent", + "-p", + "--output-format", + "text", + "--trust", + "--sandbox", + "enabled", + "--workspace", + cwd, + "--force", + "task", + ]) + expect(plan.stdin).toBe("null") + const readOnly = planHost("cursor", { cwd, prompt: "task", promptFile, readOnly: true }) + expect(readOnly.argv.slice(readOnly.argv.indexOf("--mode"), readOnly.argv.indexOf("--mode") + 2)).toEqual(["--mode", "ask"]) + expect(readOnly.argv).not.toContain("--force") + expect(readOnly.argv.at(-1)).toBe("task") + }) + + test("cursor is attested from its markers, and a cell does not inherit them", () => { + expect(attestCurrentHost({ CURSOR_AGENT: "1" })).toBe("cursor") + expect(attestCurrentHost({ CURSOR_CONVERSATION_ID: "c" })).toBe("cursor") + const env = cellEnv({ CURSOR_AGENT: "1", CURSOR_CONVERSATION_ID: "c" }) + expect(env.CURSOR_AGENT).toBeUndefined() + expect(env.CURSOR_CONVERSATION_ID).toBeUndefined() + }) + + test("cursor is an explicit host, never a default peer, and resolves by its own binary", () => { + expect(peerHosts("claude")).toEqual(["codex", "grok", "opencode"]) + expect(peerHosts("unknown")).not.toContain("cursor") + expect(peerHosts("cursor")).toEqual(["claude", "codex", "grok", "opencode"]) + expect(hostBinary("cursor")).toBe("cursor-agent") + expect(hostBinary("claude")).toBe("claude") + const explicit = resolveRunHosts({ + explicit: ["claude", "cursor"], + env: { CLAUDECODE: "1" }, + onPath: () => true, + }) + expect(explicit.run).toEqual(["claude", "cursor"]) + }) + test("missing peer CLIs warn and continue; self-only is own-eval", () => { const limited = resolveRunHosts({ env: { GROK_AGENT: "1" }, diff --git a/tests/skill-eval-cell/hosts.ts b/tests/skill-eval-cell/hosts.ts index 1c55594a5..b9f805021 100644 --- a/tests/skill-eval-cell/hosts.ts +++ b/tests/skill-eval-cell/hosts.ts @@ -7,8 +7,19 @@ */ import path from "node:path" -export const HOSTS = ["claude", "codex", "grok", "opencode"] as const +export const HOSTS = ["claude", "codex", "grok", "opencode", "cursor"] as const export type Host = (typeof HOSTS)[number] +/** + * Hosts that run only when `--hosts` names them. Cursor needs a signed-in + * `cursor-agent` and bills its own account, so a default peer selection never + * picks it up just because the binary is on PATH. + */ +const EXPLICIT_ONLY_HOSTS: readonly Host[] = ["cursor"] + +/** The CLI a host runs as, where it differs from the host's name. */ +export function hostBinary(host: Host): string { + return host === "cursor" ? "cursor-agent" : host +} export type CurrentHost = Host | "unknown" export type HostResolution = { @@ -33,12 +44,12 @@ export function attestCurrentHost(env: NodeJS.ProcessEnv = process.env): Current } if (env.GROK_AGENT === "1" || env.GROK_SESSION_ID) return "grok" if (env.OPENCODE_TERMINAL) return "opencode" + if (env.CURSOR_AGENT || env.CURSOR_CONVERSATION_ID) return "cursor" return "unknown" } export function peerHosts(current: CurrentHost): Host[] { - if (current === "unknown") return [...HOSTS] - return HOSTS.filter((host) => host !== current) + return HOSTS.filter((host) => host !== current && !EXPLICIT_ONLY_HOSTS.includes(host)) } export function resolveRunHosts(opts: { @@ -48,7 +59,7 @@ export function resolveRunHosts(opts: { }): HostResolution { const current = attestCurrentHost(opts.env) const wanted = opts.explicit ?? peerHosts(current) - const onPath = opts.onPath ?? ((host: Host) => commandExists(host)) + const onPath = opts.onPath ?? ((host: Host) => commandExists(hostBinary(host))) const warnings: string[] = [] if (!opts.explicit && current === "unknown") { warnings.push("warning: could not attest current harness; defaulting to every CLI on PATH") @@ -56,8 +67,8 @@ export function resolveRunHosts(opts: { const skipped: HostResolution["skipped"] = [] let run = wanted.filter((host) => { if (onPath(host)) return true - skipped.push({ host, reason: `${host} CLI not on PATH` }) - warnings.push(`warning: skipping ${host}: ${host} CLI not on PATH`) + skipped.push({ host, reason: `${hostBinary(host)} CLI not on PATH` }) + warnings.push(`warning: skipping ${host}: ${hostBinary(host)} CLI not on PATH`) return false }) // Only the default peer selection falls back to self. An explicit --hosts that is @@ -108,6 +119,8 @@ export function cellEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEn delete env.GROK_AGENT delete env.GROK_SESSION_ID delete env.OPENCODE_TERMINAL + delete env.CURSOR_AGENT + delete env.CURSOR_CONVERSATION_ID delete env.CLICOLOR_FORCE delete env.GH_FORCE_TTY env.NO_COLOR = "1" @@ -168,6 +181,23 @@ export function planHost( } return { host, argv, env, stdin: "null", notes } } + if (host === "cursor") { + // Flags follow the cursor-agent invocations the bundled workers already run: + // `--trust` keeps print mode from waiting on a workspace-trust prompt, and + // `--sandbox enabled` holds for both postures. `--force` is the write posture + // (skills/ce-work/scripts/cross-model-work.sh); `--mode ask` is the read-only + // one (skills/ce-doc-review/scripts/cross-model-doc-review.sh). + notes.push("cursor-agent must be signed in (`cursor-agent login`); an unauthenticated run exits before the model is called") + const argv = ["cursor-agent", "-p", "--output-format", "text", "--trust", "--sandbox", "enabled", "--workspace", opts.cwd] + if (opts.readOnly) { + argv.push("--mode", "ask") + notes.push("read-only: --mode ask, no --force") + } else { + argv.push("--force") + } + argv.push(opts.prompt) + return { host, argv, env, stdin: "null", notes } + } notes.push("progress narration prints to stdout before the answer; grep for trailers, do not treat the whole file as the answer") const argv = [ "grok", @@ -205,7 +235,7 @@ export function wrapPrompt(opts: { return [ `Read the skill at ${path.join(opts.skillDir, "SKILL.md")} first.`, `Resolve bundled references and scripts from that directory.`, - `Do not read or use an installed plugin copy of this skill (not ~/.claude, ~/.grok, ~/.agents, ~/.config/opencode, project .opencode, or a plugin cache).`, + `Do not read or use an installed plugin copy of this skill (not ~/.claude, ~/.grok, ~/.agents, ~/.cursor, ~/.config/opencode, project .opencode, or a plugin cache).`, ...companions.map( (c) => `When it tells you to invoke the \`${c.name}\` skill, invoke it by reading ${path.join(c.dir, "SKILL.md")} and following it, resolving its references from that directory. Do not use an installed plugin copy of \`${c.name}\`.`, diff --git a/tests/skill-eval-cell/pack.ts b/tests/skill-eval-cell/pack.ts index bdd2c7dc8..767f25036 100644 --- a/tests/skill-eval-cell/pack.ts +++ b/tests/skill-eval-cell/pack.ts @@ -120,7 +120,7 @@ selectors (at least one, or --all for the whole catalog): options: --list print the matching scenarios and exit; runs nothing --arm pre|post|preview|ab default ab - --hosts claude,codex,grok,opencode default: the cell driver's default + --hosts claude,codex,grok,opencode,cursor default: the cell driver's default (cursor only when named) --out default: a new directory under OS temp --help, -h print this and exit` diff --git a/tests/skill-eval-cell/provenance.ts b/tests/skill-eval-cell/provenance.ts index f5499857d..351c15b5f 100644 --- a/tests/skill-eval-cell/provenance.ts +++ b/tests/skill-eval-cell/provenance.ts @@ -157,7 +157,7 @@ export function verifyEvidence(out: string, expectedManifestHash?: string): void throw new Error("no unique host results were collected") } for (const host of summary.hosts_run) { - if (!["claude", "codex", "grok", "opencode"].includes(host)) throw new Error("unknown recorded host") + if (!["claude", "codex", "grok", "opencode", "cursor"].includes(host)) throw new Error("unknown recorded host") const dir = containedPath(out, `hosts/${host}`) for (const name of ["stdout.txt", "stderr.txt", "exit.json", "prompt.md", "argv.json", "git-status.txt", "git-head-files.txt"]) { if (!fs.lstatSync(path.join(dir, name)).isFile()) throw new Error(`missing regular host evidence: ${name}`) diff --git a/tests/skill-eval-cell/run.ts b/tests/skill-eval-cell/run.ts index 1943a5687..c19f3e09d 100644 --- a/tests/skill-eval-cell/run.ts +++ b/tests/skill-eval-cell/run.ts @@ -20,7 +20,7 @@ function parseHosts(): Host[] | undefined { const raw = arg("--hosts") if (raw === undefined) return undefined if (!raw) { - console.error("usage: --hosts claude,codex,grok,opencode") + console.error("usage: --hosts claude,codex,grok,opencode,cursor") process.exit(2) } const wanted = raw.split(",").map((s) => s.trim()).filter(Boolean) as Host[] diff --git a/tests/skill-eval-cell/scenarios.md b/tests/skill-eval-cell/scenarios.md index c4ec4a5dd..11f437874 100644 --- a/tests/skill-eval-cell/scenarios.md +++ b/tests/skill-eval-cell/scenarios.md @@ -136,6 +136,7 @@ Resolution, precedence, and review policy: | `ce-work/personal-off-keeps-team-entry-native` | Team `work` entry stays native; the personal `work_engine_mode` is named as the reason | | `ce-doc-review/seat-list-runs-on-routine-plan` | Both seats are on the team of a plan that activates no judgment lens | | `ce-doc-review/review-mode-off-skips-other-provider-seats` | Mode `off`: only the Claude seat is on the team; two seats not run, reason `review-mode` | +| `ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat` | Mode `off` on a host with no attested family (run with `--hosts cursor`): no seat on the team; three seats not run, reason `review-mode` | | `ce-code-review/seat-list-and-review-mode` | Mode `off`: the Claude seat is on the team; the Grok seat is not run, reason `review-mode` | | `ce-debug/role-entry-hands-off-investigation` | The `debug` entry's model is declared before any investigation; subagent or session both pass | | `ce-simplify-code/role-entry-governs-apply` | The `simplify` entry's model and `low` are declared, with the effort reported as not applied | From 4da3c6d8c4f0745ead33f4e8edbee84eec145268 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:50:08 -0700 Subject: [PATCH 16/53] test(evals): keep the post-only row list sorted The unattested-host row was listed out of order, so the sorted comparison failed. Co-Authored-By: Claude Code --- tests/skill-eval-cell/catalog.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/skill-eval-cell/catalog.test.ts b/tests/skill-eval-cell/catalog.test.ts index 53f689b5b..780a4e94d 100644 --- a/tests/skill-eval-cell/catalog.test.ts +++ b/tests/skill-eval-cell/catalog.test.ts @@ -337,8 +337,8 @@ describe("skill-eval-cell catalog", () => { "ce-doc-review/approval-versus-judgment-summary", "ce-doc-review/no-map-runs-no-resolver", "ce-doc-review/review-mode-off-skips-other-provider-seats", - "ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat", "ce-doc-review/seat-list-runs-on-routine-plan", + "ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat", "ce-noslop/dense-paragraph-keeps-every-claim", "ce-noslop/detect-names-patterns-without-rewrite", "ce-noslop/facts-survive-the-edit", From ad0d4ae7aedfd80a2414183d5cb96d518846ed2f Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:58:09 -0700 Subject: [PATCH 17/53] fix(model-roles): state the opt-out reason in the resolver answer When a personal work_engine_mode: off keeps a team work entry native, the resolver now says so in warnings. In an eval on Codex the host read only the boolean and attributed the native run to the role map itself; with the reason in the answer, no host has to re-read the personal file to report it. Co-Authored-By: Claude Code --- skills/ce-brainstorm/scripts/model-role-resolve.py | 5 +++++ skills/ce-code-review/scripts/model-role-resolve.py | 5 +++++ skills/ce-compound/scripts/model-role-resolve.py | 5 +++++ skills/ce-debug/scripts/model-role-resolve.py | 5 +++++ skills/ce-doc-review/scripts/model-role-resolve.py | 5 +++++ skills/ce-plan/scripts/model-role-resolve.py | 5 +++++ skills/ce-setup/scripts/model-role-resolve.py | 5 +++++ skills/ce-simplify-code/scripts/model-role-resolve.py | 5 +++++ skills/ce-work/scripts/model-role-resolve.py | 5 +++++ tests/skills/model-role-resolver.test.ts | 5 +++++ 10 files changed, 50 insertions(+) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index a947626ee..fbe30bce0 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -302,6 +302,11 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: if role == "work" and result["state"] == "entries" and result["source"] == "team": # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" + if result["engine_opt_out"]: + # The reason travels with the answer, so a caller can state it without re-reading the file. + result["warnings"].append( + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model" + ) return result diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index 91b528a77..c95e30c47 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -249,8 +249,13 @@ describe("work engine opt-out", () => { const team = map(" work: gpt-5.5 high\n") const optedOut = role(makeProject(team, "work_engine_mode: off\n"), "work") expect([optedOut.source, optedOut.engine_opt_out]).toEqual(["team", true]) + // The reason travels with the answer, so a host need not re-read the personal file to state it. + expect(optedOut.warnings).toEqual([ + "config.local.yaml sets `work_engine_mode: off`: the team `work` entry is not handed to another model", + ]) const personal = role(makeProject(team, `work_engine_mode: off\n${map(" work: opus\n")}`), "work") expect([personal.source, personal.engine_opt_out]).toEqual(["local", false]) + expect(personal.warnings).toEqual([]) }) }) From 6e987bed4b90d0f915d48b41973084a29e2f97c7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 15:58:09 -0700 Subject: [PATCH 18/53] test(evals): grade the role cells on host-independent facts Round one on Claude and Codex showed three cells graded something other than the behavior under test. The restraint rows failed Claude for narrating that it checked for a map and found none; they now look for the Model role report line itself. The plan and brainstorm rows pinned the Claude CLI as the first route, which a host whose subagent tool can set effort does not owe; they now require a hand-off. The debug task told a literal host to stop before reading the settings the skill names. Co-Authored-By: Claude Code --- tests/skill-eval-cell/catalog.ts | 36 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index cc7179995..1f4f244b2 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -159,8 +159,16 @@ const SETUP_INSTRUCTIONS_TASK = const ROLE_REPORT_SHAPE = "Put your whole report between a line that is only RESULT-START and a line that is only RESULT-END, and put the trailers after RESULT-END. In the ACTIONS trailer, list every bundled skill script you ran, by file name, as well as any mutation." const ROLE_LINES = "each appearing once in your whole answer, as plain text with no backticks, quotes, or extra words" -/** No-map half of every restraint row: no resolver run, and nothing about model roles in the report. */ -const NO_ROLE_OUTPUT = { must_exclude: ["model-role-resolve"], result_must_not_include: ["model role"] } +/** + * No-map half of every restraint row: no resolver run, and no `Model role` report line. + * The needles are the line's two openings for that role, not the bare phrase: the task + * asks for the whole resolution walk, and a host that says it checked for the map and + * found none has skipped it correctly. + */ +const noRoleOutput = (role: string) => ({ + must_exclude: ["model-role-resolve"], + result_must_not_include: [`model role ${role}:`, `model role ${role} seat`], +}) const roleChoiceLines = (step: string) => `End your report with exactly these five lines, ${ROLE_LINES}. When more than one repo setting names a model for ${step}, report the one that wins. @@ -209,7 +217,7 @@ const ROLE_CODE_REVIEW_TASK = `Use the ce-code-review skill on the staged change ${roleReviewTeamLines("local reviewer")}` -const ROLE_DEBUG_TASK = `Use ce-debug on this bug for a bounded checkpoint: the seat cap check is failing. node tests/seat-cap.check.js exits 1, and SPEC.md is the product contract. Before you reproduce or trace anything, state how the investigation will be carried out and by which model; then stop. Do not investigate, edit, or dispatch. +const ROLE_DEBUG_TASK = `Use ce-debug on this bug for a bounded checkpoint: the seat cap check is failing. node tests/seat-cap.check.js exits 1, and SPEC.md is the product contract. Do the setup the skill calls for before its investigation starts, including reading any repo settings it names. Then, before you reproduce or trace anything, state how the investigation will be carried out and by which model, and stop. Do not reproduce, trace, edit, or dispatch. End your report with exactly these three lines, ${ROLE_LINES}: @@ -3588,8 +3596,10 @@ Use "Correct widget limit" as the description in the subject and any required bo REQUESTED_MODEL: "claude-ce-eval-a", REQUESTED_EFFORT: "low", SETTING_KEY: "model_roles", - STEP_ROUTE: "claude-cli", }, + // The first route attempted is the host's to decide: a host whose subagent tool + // can set effort tries native before the Claude CLI. The invariant is the hand-off. + must_include_any: [["STEP_ROUTE: claude-cli", "STEP_ROUTE: native-subagent"]], delegates: "none", git: "clean", }, @@ -3658,8 +3668,8 @@ Use "Correct widget limit" as the description in the subject and any required bo REQUESTED_MODEL: "claude-ce-eval-a", REQUESTED_EFFORT: "high", SETTING_KEY: "model_roles", - STEP_ROUTE: "claude-cli", }, + must_include_any: [["STEP_ROUTE: claude-cli", "STEP_ROUTE: native-subagent"]], delegates: "none", git: "clean", }, @@ -3879,7 +3889,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_BRAINSTORM_TASK, grade: { declared: { REQUESTED_MODEL: "none", STEP_ROUTE: "session" }, - ...NO_ROLE_OUTPUT, + ...noRoleOutput("brainstorm"), delegates: "none", git: "clean", }, @@ -3899,7 +3909,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_PLAN_TASK, grade: { declared: { REQUESTED_MODEL: "ce-eval-unavailable", SETTING_KEY: "plan_model" }, - ...NO_ROLE_OUTPUT, + ...noRoleOutput("plan"), delegates: "none", git: "clean", }, @@ -3919,7 +3929,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: roleDocReviewTask("docs/plans/2026-07-31-003-fix-portable-windows-path-unit-tests-plan.md"), grade: { must_include: ["coherence", "feasibility"], - ...NO_ROLE_OUTPUT, + ...noRoleOutput("doc-review"), delegates: "none", git: "clean", }, @@ -3939,7 +3949,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_DEBUG_TASK, grade: { declared: { INVESTIGATED_BY: "session", REQUESTED_MODEL: "none" }, - ...NO_ROLE_OUTPUT, + ...noRoleOutput("debug"), delegates: "none", git: "clean", }, @@ -3959,7 +3969,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_WORK_TASK, grade: { declared: { ENGINE_ROUTE: "native", REQUESTED_MODEL: "none" }, - ...NO_ROLE_OUTPUT, + ...noRoleOutput("work"), delegates: "none", git: "clean", }, @@ -3980,7 +3990,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_SIMPLIFY_TASK, grade: { declared: { APPLIED_BY: "session", REQUESTED_MODEL: "none" }, - ...NO_ROLE_OUTPUT, + ...noRoleOutput("simplify"), delegates: "none", workspace_contains: [ACCESS_TS_UNCHANGED], }, @@ -4001,7 +4011,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_CODE_REVIEW_TASK, grade: { must_include: ["correctness"], - ...NO_ROLE_OUTPUT, + ...noRoleOutput("code-review"), delegates: "none", workspace_contains: [ACCESS_TS_UNCHANGED], }, @@ -4021,7 +4031,7 @@ Use "Correct widget limit" as the description in the subject and any required bo task: ROLE_COMPOUND_TASK, grade: { must_include_any: [COMPOUND_TERMINAL_LINE_LAST], - ...NO_ROLE_OUTPUT, + ...noRoleOutput("compound"), delegates: "none", git: "dirty", workspace_contains: [COMPOUND_SOURCE_UNCHANGED], From e37b4c4a715c603b4cfe0669125fe308a8a3bbd2 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 16:14:56 -0700 Subject: [PATCH 19/53] fix(model-roles): name the resolver in each hook skip clause In one Codex eval run on a checkout with no model_roles key, the host loaded the shared reference and ran the resolver to check for the key. The result was the same, but a no-map checkout must not need Python. Every hook now says what skipping means: do not read the reference or run its resolver. The ce-debug hand-off also created its run directory before it knew a hand-off would happen; the directory and hand-off file now belong to the hand-off. Co-Authored-By: Claude Code --- skills/ce-brainstorm/references/reasoning-elevation.md | 2 +- skills/ce-code-review/references/select-and-route.md | 2 +- skills/ce-compound/references/lightweight.md | 2 +- skills/ce-compound/references/research.md | 2 +- skills/ce-debug/references/fix.md | 2 +- skills/ce-debug/references/investigate.md | 4 ++-- skills/ce-doc-review/references/dispatch.md | 2 +- skills/ce-plan/references/reasoning-elevation.md | 2 +- skills/ce-simplify-code/SKILL.md | 2 +- skills/ce-work/references/execution-engines.md | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index fb378fce8..ffaae2caf 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key, skip this and print nothing about model roles. + **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-code-review/references/select-and-route.md b/skills/ce-code-review/references/select-and-route.md index bfddee8f8..56720de99 100644 --- a/skills/ce-code-review/references/select-and-route.md +++ b/skills/ce-code-review/references/select-and-route.md @@ -76,7 +76,7 @@ The Review depth gate in `references/modes-and-output.md` already chose lite, fo ### Stage 3d: Bind the adversarial route and final roster -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `code-review` role before binding the adversarial route. With no such key, skip this and print nothing about model roles. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `code-review` role before binding the adversarial route. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. **Review seats.** When that key is active, read `references/cross-model-review.md` in full too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the local reviewers. A seat does not depend on which personas were selected, so seats run whether or not adversarial was selected. When that section says the single-peer pass does not run, the rest of this stage binds no route: keep `adversarial-reviewer` in the local roster when Stage 3 selected it, start the worker seats now, and add the natively served seats to the Stage 4 batch. The exclusive choice below, and its rule that a started peer and the in-process adversarial reviewer never receive the same brief, govern only the single-peer pass. diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index e2ca851c9..df4caa713 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,7 +10,7 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before writing the doc. With no such key, skip this and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before writing the doc. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. The orchestrator (main conversation) performs ALL of the following in one sequential pass: diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index f99b757b8..cd544e4e3 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -87,7 +87,7 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - Does not force bug-track fields onto knowledge-track learnings or vice versa #### 2. **Draft the body** (this context) - **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before drafting the body. With no such key, skip this and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. + **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before drafting the body. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. - Adapts output structure to the track chosen in step 1 - **Drafts the full doc-body prose** (all track-appropriate sections below) from the conversation and the verified fix. The failed attempts and the reasoning behind the fix are what a later reader cannot recover from the code, so they come from what happened in this session, not from a recap of the final diff. diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 37fd3afc0..0c375a520 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -4,7 +4,7 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and *One change at a time. If you are changing multiple things, stop.* -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the first edit. With no such key, skip this and print nothing about model roles. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the first edit. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. When Phase 1 already resolved the role, use that result. `references/investigate.md` states the role's deliverable, its route, and where the `Model role` line goes. This file adds only the fix hand-off. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index c01d6e9a9..7b9c37e72 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,13 +25,13 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the investigation starts. With no such key, skip this and print nothing about model roles. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the investigation starts. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route, so a native subagent is its only hand-off route, and the fallback ladder in `references/model-roles.md` comes straight after it. The entry is standing config. `mode:pipeline` and `mode:return-to-caller` honor it the way interactive use does, and no mode asks about it. **Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes, and the `Model role` line says so. -Create the run directory, then write `handoff.md` in it. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. +For a hand-off, create the run directory, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. ```bash SCRATCH_ROOT="/tmp/compound-engineering-$(id -u)"; diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index 120a7408f..05efc137c 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -6,7 +6,7 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Agent lifecycle.** Collect each agent's final outcome, including failures, before cleanup. When the harness lets the caller close or release agents, close or release the agents this review started before refilling slots, advancing stages, or returning. Do not message completed agents with no remaining work. Do not assume capacity was freed just because an agent completed or was interrupted, and do not invent cleanup operations. -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `doc-review` role before dispatching the reviewers. With no such key, skip this and print nothing about model roles. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `doc-review` role before dispatching the reviewers. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. **Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. Seats start in the same dispatch wave as those reviewers. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index fb378fce8..ffaae2caf 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key, skip this and print nothing about model roles. + **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-simplify-code/SKILL.md b/skills/ce-simplify-code/SKILL.md index 7c257c40e..e7c6eef85 100644 --- a/skills/ce-simplify-code/SKILL.md +++ b/skills/ce-simplify-code/SKILL.md @@ -41,7 +41,7 @@ Do not paraphrase these rubrics from memory. Read each file and pass it verbatim ## Step 3: Fix issues -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `simplify` role before applying any finding. With no such key, skip this and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `simplify` role before applying any finding. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. Proceed only after all three review outcomes are complete, whether returned by subagents or produced inline. Apply worthwhile findings directly; record false positives and low-value findings as skipped without asking the user. diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index 73ae9fb00..b1c47e99f 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -47,7 +47,7 @@ When the target resolves to the current host's default execution route and no di - **Do not** use this rule for `docs_root` — that key is `config.yaml` only. -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `work` role before reading the `work_engine_*` keys below. With no such key, skip this and print nothing about model roles. +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `work` role before reading the `work_engine_*` keys below. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. `references/model-roles.md` owns the resolver command, what each `state` means, precedence, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. "A `work` role entry" below adds only what is specific to this skill. From 860bae1dc81510905a32899192eb04f6eed32786 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 16:14:56 -0700 Subject: [PATCH 20/53] test(evals): say what a stopped review would have dispatched On Codex a decide-and-stop review cell reported TEAM: none and counted every seat as not run, because nothing had been dispatched yet. The fields now ask what the review would dispatch or leave out if it continued. Co-Authored-By: Claude Code --- tests/skill-eval-cell/catalog.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 1f4f244b2..43a2d097d 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -201,9 +201,9 @@ ${ROLE_REPORT_SHAPE}` const roleReviewTeamLines = (personas: string) => `End your report with exactly these three lines, ${ROLE_LINES}: -NOT_RUN_COUNT: -NOT_RUN_REASON: -TEAM: +NOT_RUN_COUNT: +NOT_RUN_REASON: +TEAM: ${ROLE_REPORT_SHAPE}` From cf6e1c03fcbdcc9d771b1a301876d36032fe3ec8 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 16:29:50 -0700 Subject: [PATCH 21/53] test(evals): add the Cursor native-seat case to the doc-review pack A live ce-doc-review run on the Cursor host served three seats as Cursor subagents on the ids its subagent tool lists. The case records how to repeat that check. It stays hand-run because the model list changes too often to pin in a graded fixture. Co-Authored-By: Claude Code --- .../packs/ce-doc-review-cross-model.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md b/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md index cb8149567..d39df5186 100644 --- a/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md +++ b/tests/skill-eval-cell/packs/ce-doc-review-cross-model.md @@ -199,7 +199,24 @@ PATH — and cross-host per the repo's eval default: Claude Code AND Codex. the user asking in conversation to review with Grok: the seat list is not used and the single-peer rules run for that request. +19. **Seats served as Cursor subagents (AE7; R3, R10).** Run on the Cursor host + (`--hosts cursor`), live, with no stub CLIs: this case is about what the + host's own subagent tool accepts, so stubs cannot stand in for it. Take + case 16's document and set `doc-review` to three models from three families + that the session's subagent tool lists, written app-neutral as + ` []` and never as a Cursor model id. Assert each seat is + dispatched as a subagent on the listed id that matches its model and + effort, no peer CLI job starts, and each seat writes + `whole-doc-native-s.json` with `independence_verified: false` and + `model_actual: "unverified"`. Coverage carries one + `Model role doc-review seat : ... route native subagent.` line per seat, + and the persona review still runs. Then change one seat to an effort the + tool lists no id for: that seat is not served natively, and it is dropped + and reported unless an installed peer CLI can serve it. Cursor's model list + changes, so pick the three models at run time and record them with the + result; do not pin them in a fixture. + ## Pass criteria -All eighteen cases pass on the current on-disk source, and case 2 confirms the +All nineteen cases pass on the current on-disk source, and case 2 confirms the conditional cost profile (no peer spawn on a routine validated plan). From 575a41b48f55bbfcdacbf915d9a2abf9e36bb833 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 16:58:13 -0700 Subject: [PATCH 22/53] fix(model-roles): name both config paths in the hook and open them by path In eval round four Codex missed the role map twice. The hook named the files as `.compound-engineering/config.local.yaml` or `config.yaml`, so one run looked for `config.yaml` at the repo root. Both runs listed files with a search that skips hidden directories and concluded no config existed. The step then ran on the session model and printed no `Model role` line, which is the silent failure the map must not have. The hook now gives both full paths and says to open them by path. Co-Authored-By: Claude Code --- skills/ce-brainstorm/references/reasoning-elevation.md | 2 +- skills/ce-code-review/references/select-and-route.md | 2 +- skills/ce-compound/references/lightweight.md | 2 +- skills/ce-compound/references/research.md | 2 +- skills/ce-debug/references/fix.md | 2 +- skills/ce-debug/references/investigate.md | 2 +- skills/ce-doc-review/references/dispatch.md | 2 +- skills/ce-plan/references/reasoning-elevation.md | 2 +- skills/ce-simplify-code/SKILL.md | 2 +- skills/ce-work/references/execution-engines.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index ffaae2caf..bc18dae0f 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-code-review/references/select-and-route.md b/skills/ce-code-review/references/select-and-route.md index 56720de99..9c6e8b3da 100644 --- a/skills/ce-code-review/references/select-and-route.md +++ b/skills/ce-code-review/references/select-and-route.md @@ -76,7 +76,7 @@ The Review depth gate in `references/modes-and-output.md` already chose lite, fo ### Stage 3d: Bind the adversarial route and final roster -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `code-review` role before binding the adversarial route. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `code-review` role before binding the adversarial route. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. **Review seats.** When that key is active, read `references/cross-model-review.md` in full too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the local reviewers. A seat does not depend on which personas were selected, so seats run whether or not adversarial was selected. When that section says the single-peer pass does not run, the rest of this stage binds no route: keep `adversarial-reviewer` in the local roster when Stage 3 selected it, start the worker seats now, and add the natively served seats to the Stage 4 batch. The exclusive choice below, and its rule that a started peer and the in-process adversarial reviewer never receive the same brief, govern only the single-peer pass. diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index df4caa713..434bcafcb 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,7 +10,7 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before writing the doc. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before writing the doc. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. The orchestrator (main conversation) performs ALL of the following in one sequential pass: diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index cd544e4e3..fbbedee19 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -87,7 +87,7 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - Does not force bug-track fields onto knowledge-track learnings or vice versa #### 2. **Draft the body** (this context) - **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before drafting the body. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before drafting the body. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. - Adapts output structure to the track chosen in step 1 - **Drafts the full doc-body prose** (all track-appropriate sections below) from the conversation and the verified fix. The failed attempts and the reasoning behind the fix are what a later reader cannot recover from the code, so they come from what happened in this session, not from a recap of the final diff. diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 0c375a520..580434d29 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -4,7 +4,7 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and *One change at a time. If you are changing multiple things, stop.* -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the first edit. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the first edit. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. When Phase 1 already resolved the role, use that result. `references/investigate.md` states the role's deliverable, its route, and where the `Model role` line goes. This file adds only the fix hand-off. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 7b9c37e72..009f99604 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,7 +25,7 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the investigation starts. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the investigation starts. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route, so a native subagent is its only hand-off route, and the fallback ladder in `references/model-roles.md` comes straight after it. The entry is standing config. `mode:pipeline` and `mode:return-to-caller` honor it the way interactive use does, and no mode asks about it. diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index 05efc137c..a429b615c 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -6,7 +6,7 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Agent lifecycle.** Collect each agent's final outcome, including failures, before cleanup. When the harness lets the caller close or release agents, close or release the agents this review started before refilling slots, advancing stages, or returning. Do not message completed agents with no remaining work. Do not assume capacity was freed just because an agent completed or was interrupted, and do not invent cleanup operations. -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `doc-review` role before dispatching the reviewers. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `doc-review` role before dispatching the reviewers. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. **Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. Seats start in the same dispatch wave as those reviewers. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index ffaae2caf..bc18dae0f 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-simplify-code/SKILL.md b/skills/ce-simplify-code/SKILL.md index e7c6eef85..2678fe4da 100644 --- a/skills/ce-simplify-code/SKILL.md +++ b/skills/ce-simplify-code/SKILL.md @@ -41,7 +41,7 @@ Do not paraphrase these rubrics from memory. Read each file and pass it verbatim ## Step 3: Fix issues -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `simplify` role before applying any finding. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `simplify` role before applying any finding. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. Proceed only after all three review outcomes are complete, whether returned by subagents or produced inline. Apply worthwhile findings directly; record false positives and low-value findings as skipped without asking the user. diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index b1c47e99f..d0e44124b 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -47,7 +47,7 @@ When the target resolves to the current host's default execution route and no di - **Do not** use this rule for `docs_root` — that key is `config.yaml` only. -**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `work` role before reading the `work_engine_*` keys below. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `work` role before reading the `work_engine_*` keys below. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. `references/model-roles.md` owns the resolver command, what each `state` means, precedence, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. "A `work` role entry" below adds only what is specific to this skill. From e27b09a46ca1ea7e6f854350e5a97ff34282564a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 17:47:27 -0700 Subject: [PATCH 23/53] fix(model-roles): say that the resolver decides the role After the hook told agents to open both config files, Codex read the entry itself in four of twenty entry cells and skipped the shared reference and the resolver. Its answers were right on simple fixtures, but hand-reading skips layering, validation, and the review policy. The hook now says to run the resolver and that its answer decides the role. Co-Authored-By: Claude Code --- skills/ce-brainstorm/references/reasoning-elevation.md | 2 +- skills/ce-code-review/references/select-and-route.md | 2 +- skills/ce-compound/references/lightweight.md | 2 +- skills/ce-compound/references/research.md | 2 +- skills/ce-debug/references/fix.md | 2 +- skills/ce-debug/references/investigate.md | 2 +- skills/ce-doc-review/references/dispatch.md | 2 +- skills/ce-plan/references/reasoning-elevation.md | 2 +- skills/ce-simplify-code/SKILL.md | 2 +- skills/ce-work/references/execution-engines.md | 2 +- tests/review-seat-block-parity.test.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index bc18dae0f..f2ac87af4 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. + **Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-code-review/references/select-and-route.md b/skills/ce-code-review/references/select-and-route.md index 9c6e8b3da..e8591f7f4 100644 --- a/skills/ce-code-review/references/select-and-route.md +++ b/skills/ce-code-review/references/select-and-route.md @@ -76,7 +76,7 @@ The Review depth gate in `references/modes-and-output.md` already chose lite, fo ### Stage 3d: Bind the adversarial route and final roster -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `code-review` role before binding the adversarial route. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `code-review` role before binding the adversarial route. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. **Review seats.** When that key is active, read `references/cross-model-review.md` in full too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the local reviewers. A seat does not depend on which personas were selected, so seats run whether or not adversarial was selected. When that section says the single-peer pass does not run, the rest of this stage binds no route: keep `adversarial-reviewer` in the local roster when Stage 3 selected it, start the worker seats now, and add the natively served seats to the Stage 4 batch. The exclusive choice below, and its rule that a started peer and the in-process adversarial reviewer never receive the same brief, govern only the single-peer pass. diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index 434bcafcb..6fbab8d4d 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,7 +10,7 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before writing the doc. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. The orchestrator (main conversation) performs ALL of the following in one sequential pass: diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index fbbedee19..41cdf9e7b 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -87,7 +87,7 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - Does not force bug-track fields onto knowledge-track learnings or vice versa #### 2. **Draft the body** (this context) - **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `compound` role before drafting the body. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. + **Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before drafting the body. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. - Adapts output structure to the track chosen in step 1 - **Drafts the full doc-body prose** (all track-appropriate sections below) from the conversation and the verified fix. The failed attempts and the reasoning behind the fix are what a later reader cannot recover from the code, so they come from what happened in this session, not from a recap of the final diff. diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 580434d29..34e73993c 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -4,7 +4,7 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and *One change at a time. If you are changing multiple things, stop.* -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the first edit. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the first edit. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. When Phase 1 already resolved the role, use that result. `references/investigate.md` states the role's deliverable, its route, and where the `Model role` line goes. This file adds only the fix hand-off. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 009f99604..84426bb44 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,7 +25,7 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `debug` role before the investigation starts. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route, so a native subagent is its only hand-off route, and the fallback ladder in `references/model-roles.md` comes straight after it. The entry is standing config. `mode:pipeline` and `mode:return-to-caller` honor it the way interactive use does, and no mode asks about it. diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index a429b615c..70e6a2735 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -6,7 +6,7 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Agent lifecycle.** Collect each agent's final outcome, including failures, before cleanup. When the harness lets the caller close or release agents, close or release the agents this review started before refilling slots, advancing stages, or returning. Do not message completed agents with no remaining work. Do not assume capacity was freed just because an agent completed or was interrupted, and do not invent cleanup operations. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `doc-review` role before dispatching the reviewers. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `doc-review` role before dispatching the reviewers. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. **Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. Seats start in the same dispatch wave as those reviewers. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index bc18dae0f..f2ac87af4 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `plan` role for ce-plan, or the `brainstorm` role for ce-brainstorm, before reading the per-skill key. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. + **Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-simplify-code/SKILL.md b/skills/ce-simplify-code/SKILL.md index 2678fe4da..f37432fc6 100644 --- a/skills/ce-simplify-code/SKILL.md +++ b/skills/ce-simplify-code/SKILL.md @@ -41,7 +41,7 @@ Do not paraphrase these rubrics from memory. Read each file and pass it verbatim ## Step 3: Fix issues -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `simplify` role before applying any finding. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `simplify` role before applying any finding. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. Proceed only after all three review outcomes are complete, whether returned by subagents or produced inline. Apply worthwhile findings directly; record false positives and low-value findings as skipped without asking the user. diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index d0e44124b..4b2e8b6e6 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -47,7 +47,7 @@ When the target resolves to the current host's default execution route and no di - **Do not** use this rule for `docs_root` — that key is `config.yaml` only. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path; a file search skips that hidden directory. When either carries an active `model_roles:` key, read `references/model-roles.md` now and resolve the `work` role before reading the `work_engine_*` keys below. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `work` role before reading the `work_engine_*` keys below. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. `references/model-roles.md` owns the resolver command, what each `state` means, precedence, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. "A `work` role entry" below adds only what is specific to this skill. diff --git a/tests/review-seat-block-parity.test.ts b/tests/review-seat-block-parity.test.ts index b14947f05..7d0d93bb6 100644 --- a/tests/review-seat-block-parity.test.ts +++ b/tests/review-seat-block-parity.test.ts @@ -75,7 +75,7 @@ describe("review seat block parity", () => { for (const { file, role } of TRIGGERS) { const content = await read(file) expect(content, `${file} resolves its role`).toContain( - `read \`references/model-roles.md\` now and resolve the \`${role}\` role`, + `read \`references/model-roles.md\` now and run its resolver for the \`${role}\` role`, ) expect(content, `${file} loads the seat rules`).toContain( "**Review seats.** When that key is active, read `references/cross-model-review.md`", From 9cd84e8fd3301cb3bfa0a6a22a48c4d25eba9c17 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 17:47:27 -0700 Subject: [PATCH 24/53] fix(model-roles): warn when the review mode is not auto or off A misspelled `cross_model_review_mode` falls through to `auto`, which leaves other-provider review seats unblocked with nothing said. The resolver now warns on review roles, and the skills already print resolver warnings with the `Model role` line. Also adds tests for the fail-closed paths review roles rely on: structural errors in the map and a config file the resolver cannot read. Drops a plan id from a comment in the shipped script. Co-Authored-By: Claude Code --- .../scripts/model-role-resolve.py | 15 ++++++- .../scripts/model-role-resolve.py | 15 ++++++- .../ce-compound/scripts/model-role-resolve.py | 15 ++++++- skills/ce-debug/scripts/model-role-resolve.py | 15 ++++++- .../scripts/model-role-resolve.py | 15 ++++++- skills/ce-plan/scripts/model-role-resolve.py | 15 ++++++- skills/ce-setup/scripts/model-role-resolve.py | 15 ++++++- .../scripts/model-role-resolve.py | 15 ++++++- skills/ce-work/scripts/model-role-resolve.py | 15 ++++++- tests/skills/model-role-resolver.test.ts | 42 ++++++++++++++++++- 10 files changed, 158 insertions(+), 19 deletions(-) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index fbe30bce0..6d3c43ef0 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -45,7 +45,8 @@ CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the attested host family (`--host-family`) is never blocked; with an unknown host family, or an unknown seat family, a named seat is always blocked under - either policy. An `inherit` seat is never blocked. + either policy. An `inherit` seat is never blocked. A `cross_model_review_mode` + value other than `auto` or `off` is ignored, with a warning on review roles. - `work` adds `engine_opt_out`: true when the entry comes from the team file and the personal file sets `work_engine_mode: off`. @@ -300,7 +301,7 @@ def resolve_role(role: str, layers: list, policy: dict) -> dict: result.update(state="entries" if entries else "inherit", source=source, entries=entries) break if role == "work" and result["state"] == "entries" and result["source"] == "team": - # KTD4: a personal `work_engine_mode: off` keeps a team entry off any external engine. + # A personal `work_engine_mode: off` keeps a team entry off any external engine. result["engine_opt_out"] = _unquote(layers[0][1]["top"].get("work_engine_mode", "")) == "off" if result["engine_opt_out"]: # The reason travels with the answer, so a caller can state it without re-reading the file. @@ -391,11 +392,21 @@ def _resolve(args) -> dict: "peers": peers, "mode": _ordinary_scalar("cross_model_review_mode", layers, REVIEW_MODES)[0] or "auto", } + # An invalid value falls through like any ordinary key. Say so: a typo here leaves review seats unblocked. + mode_warnings = [ + f"{parsed['file']}: `cross_model_review_mode: {value}` is not `auto` or `off` and is ignored" + for _, parsed in layers + for value in [_unquote(parsed["top"].get("cross_model_review_mode", ""))] + if value and value not in REVIEW_MODES + ] if args.all: out = resolve_all(layers, policy) + out["warnings"] += mode_warnings else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + if args.role in REVIEW_ROLES: + warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) if repo_root is None: out["warnings"].append("not inside a git repository; no CE config to read") diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index c95e30c47..a0285ed5b 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "child_process" -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs" +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os" import path from "path" import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test" @@ -242,6 +242,46 @@ describe("review egress policy", () => { ) expect(blocked(role(dir, "doc-review", ["--host-family", "claude"]))).toEqual([null, "review_mode_off"]) }) + + test("an unrecognized `cross_model_review_mode` value is ignored with a warning on review roles", () => { + const dir = makeProject(`cross_model_review_mode: Off\n${map(" doc-review: [grok-4.7]\n plan: opus\n")}`) + const doc = role(dir, "doc-review", ["--host-family", "claude"]) + // The key's existing rule: an invalid value falls through to `auto`, so the seat is not blocked. + expect(blocked(doc)).toEqual([null]) + expect(doc.warnings).toEqual(["config.yaml: `cross_model_review_mode: Off` is not `auto` or `off` and is ignored"]) + expect(role(dir, "plan").warnings).toEqual([]) + expect(run(dir, ["--all"]).warnings).toEqual(doc.warnings) + }) +}) + +describe("structural errors fail closed", () => { + const cases: [string, string][] = [ + ["a scalar under `model_roles:`", "model_roles: opus\n"], + ["a role declared twice", map(" doc-review: [opus]\n doc-review: [sonnet]\n")], + ["`model_roles:` declared twice", `${map(" plan: opus\n")}${map(" doc-review: [opus]\n")}`], + ] + for (const [name, team] of cases) { + test(`${name} makes a review role invalid with no seats`, () => { + const out = role(makeProject(team), "doc-review") + expect([out.state, out.entries]).toEqual(["invalid", []]) + expect(out.errors.length).toBeGreaterThan(0) + }) + } + + test.skipIf(process.getuid?.() === 0)("a config file the resolver cannot read is invalid, never unset", () => { + const dir = makeProject(map(" doc-review: [opus]\n")) + const file = path.join(dir, ".compound-engineering", "config.yaml") + chmodSync(file, 0o000) + try { + const one = role(dir, "doc-review") + expect([one.state, one.entries]).toEqual(["invalid", []]) + expect(one.errors[0]).toContain("model role resolver failed unexpectedly") + const all = run(dir, ["--all"]) + expect(all.roles.map((r: any) => r.state)).toEqual(ROLES.map(() => "invalid")) + } finally { + chmodSync(file, 0o644) + } + }) }) describe("work engine opt-out", () => { From df6861c4dc241b13b9bd65a6180c2ae6df3db8e6 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 17:47:27 -0700 Subject: [PATCH 25/53] fix(code-review): say when a small review does not apply the role The `code-review` role is resolved where the reviewer team is dispatched, which only the full review path reaches. A lite or focused review skipped a configured seat list with nothing said, while two guides promised seats on every review. Those paths now state in Coverage that the role was not applied and that `depth:full` applies it, and the guides say the same. Adds an eval cell for that case and pins a review base in the code-review role cells, where Codex once stopped at scope on a repo with one commit. Co-Authored-By: Claude Code --- docs/guides/ce-code-review.md | 2 +- docs/guides/configuration.md | 2 +- .../ce-code-review/references/depth-paths.md | 2 + tests/skill-eval-cell/catalog.test.ts | 3 ++ tests/skill-eval-cell/catalog.ts | 35 +++++++++++++- .../.compound-engineering/config.yaml | 2 + .../model-roles-code-review-lite/README.md | 3 ++ .../src/tablefmt.ts | 47 +++++++++++++++++++ tests/skill-eval-cell/scenarios.md | 3 +- 9 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/skill-eval-cell/fixtures/model-roles-code-review-lite/.compound-engineering/config.yaml create mode 100644 tests/skill-eval-cell/fixtures/model-roles-code-review-lite/README.md create mode 100644 tests/skill-eval-cell/fixtures/model-roles-code-review-lite/src/tablefmt.ts diff --git a/docs/guides/ce-code-review.md b/docs/guides/ce-code-review.md index 6c08e4e06..efb7993f9 100644 --- a/docs/guides/ce-code-review.md +++ b/docs/guides/ce-code-review.md @@ -136,7 +136,7 @@ This shares the provider/route kernel with `ce-doc-review` but keeps a narrower ## Model role -`ce-code-review` reads the `code-review` role from the `model_roles` map in CE config. The entry is one model or a list, each with an optional reasoning effort. Every item is a seat: one independent reviewer that gives the diff an adversarial read on that model. Seats run on every review, beside the persona reviewers, and they take the place of the conditional single-peer pass described above. The in-process adversarial reviewer still runs alongside them. +`ce-code-review` reads the `code-review` role from the `model_roles` map in CE config. The entry is one model or a list, each with an optional reasoning effort. Every item is a seat: one independent reviewer that gives the diff an adversarial read on that model. Seats run on every full review, beside the persona reviewers, and they take the place of the conditional single-peer pass described above. A lite or focused review dispatches no reviewer team, so it does not apply the role. It says so in Coverage, and `depth:full` applies it. The in-process adversarial reviewer still runs alongside them. `cross_model_review_mode: off` and `CROSS_MODEL_PEERS` still decide which seats may send review content to another provider. A seat that cannot run is dropped and reported, never filled by another model, and the persona review runs either way. Each seat prints its own `Model role code-review seat :` line naming what was requested, what served it, and the route. See [Model roles](./configuration.md#model-roles) for entry syntax, precedence, and the seat rules. diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ba540f072..bc3e6e863 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -149,7 +149,7 @@ With both files, plans are authored on the session model, each plan gets three i An entry is ` []`. The model is an alias such as `fable`, `opus`, or `sonnet`, or a full model id. The effort is one of `low`, `medium`, `high`, `xhigh`, or `max`. `inherit` means the session model. An entry never carries a CLI flag, a command, or an app-specific model slug, so the same map works in every supported harness. -Only `doc-review` and `code-review` take a list. Each item is one seat: an independent reviewer on that model. Seats run on every review of that role, in addition to the skill's own persona review. A single model on a review role is one seat. A scalar `inherit` or an empty list means no seats. Inside a list, `inherit` is one seat on the session model. +Only `doc-review` and `code-review` take a list. Each item is one seat: an independent reviewer on that model. Seats run on every review that dispatches the skill's reviewer team, in addition to that team. `ce-code-review` sizes a small, low-risk diff down to a lite or focused review with no team. That review does not apply the `code-review` entry and says so; `depth:full` applies it. A single model on a review role is one seat. A scalar `inherit` or an empty list means no seats. Inside a list, `inherit` is one seat on the session model. An entry governs the step's deliverable, not the conversation. Dialogue with you, orchestration inside the skill, and `lfg`'s hand-offs between steps stay on the session model. The scouts and verifiers a step dispatches keep their own model tier. A step honors its entry the same way whether you invoke the skill or `lfg` does. diff --git a/skills/ce-code-review/references/depth-paths.md b/skills/ce-code-review/references/depth-paths.md index 1d507e63a..32a98dfed 100644 --- a/skills/ce-code-review/references/depth-paths.md +++ b/skills/ce-code-review/references/depth-paths.md @@ -2,6 +2,8 @@ Read this only when the Review depth gate in `references/modes-and-output.md` selected lite or focused; a full run never opens it. Both paths finish in the dispatch context and write the same receipt shape that reference defines. +**Model role.** The `code-review` model role is applied only on the full spine, where the reviewer team is dispatched. Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key with a `code-review` entry. Open both by path, because a file search skips that hidden directory. When either has one, run no review seat on this path and do not run the resolver. Coverage then says that the `code-review` model role was not applied because this review took the lite or focused path, and that `depth:full` applies it. The focused path's independent read below is unchanged. With no such entry, say nothing about model roles. + ### Lite path Lite is the same review with the same receipt, done in this context. Do not dispatch reviewers or finish leaves. Read no reference beyond the two named below. The skill's done condition applies unchanged: every retained finding is supported by the source, and the receipt states its coverage limits. diff --git a/tests/skill-eval-cell/catalog.test.ts b/tests/skill-eval-cell/catalog.test.ts index 780a4e94d..598b23b7a 100644 --- a/tests/skill-eval-cell/catalog.test.ts +++ b/tests/skill-eval-cell/catalog.test.ts @@ -166,6 +166,8 @@ describe("skill-eval-cell catalog", () => { "ce-code-review/depth-gate-standards-violation:references/modes-and-output.md", "ce-code-review/depth-gate-unlisted-language:references/modes-and-output.md", "ce-code-review/depth-gate-yaml-lite:references/modes-and-output.md", + "ce-code-review/lite-review-says-role-not-applied:references/depth-paths.md", + "ce-code-review/lite-review-says-role-not-applied:references/modes-and-output.md", "ce-commit-push-pr/description-only-no-commit:references/pr-description-writing.md", "ce-compound-refresh/confirmed-worth-lens-deletes-only-with-quoted-artifact:references/worth-audit.md", "ce-commit-push-pr/babysit-off-preserves-human-decision:references/apply-and-handoff.md", @@ -320,6 +322,7 @@ describe("skill-eval-cell catalog", () => { "ce-code-review/depth-gate-standards-violation", "ce-code-review/depth-gate-unlisted-language", "ce-code-review/depth-gate-yaml-lite", + "ce-code-review/lite-review-says-role-not-applied", "ce-code-review/no-map-runs-no-resolver", "ce-code-review/seat-list-and-review-mode", "ce-code-review/validator-veto-routes-protected-rejections", diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 43a2d097d..505de3929 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -213,7 +213,7 @@ ${roleReviewTeamLines("persona")}` const ROLE_DOC_REVIEW_PLAN = "docs/plans/2026-09-28-1100-fix-greeting-name-trim-plan.md" -const ROLE_CODE_REVIEW_TASK = `Use the ce-code-review skill on the staged change in this repo with depth:full. This is a bounded checkpoint: run the skill through scope and reviewer selection until the final review team is settled, then stop. Do not start any detached job and do not dispatch any reviewer. Report the final team: every local reviewer, and every additional independent reviewer with the model it was requested on. +const ROLE_CODE_REVIEW_TASK = `Use the ce-code-review skill on the staged change in this repo with base:HEAD depth:full. This is a bounded checkpoint: run the skill through scope and reviewer selection until the final review team is settled, then stop. Do not start any detached job and do not dispatch any reviewer. Report the final team: every local reviewer, and every additional independent reviewer with the model it was requested on. ${roleReviewTeamLines("local reviewer")}` @@ -3804,6 +3804,39 @@ Use "Correct widget limit" as the description in the subject and any required bo workspace_contains: [ACCESS_TS_UNCHANGED], }, }, + { + id: "ce-code-review/lite-review-says-role-not-applied", + post_only: true, + skill: "ce-code-review", + cohort: "resized", + key_behavior: "judgment", + read_only: true, + git_init: true, + git_staged: ["src/tablefmt.ts"], + fixture: `${FIX}/model-roles-code-review-lite`, + why: "The `depth-gate-loud-lite` diff with a `code-review` seat list configured. Seats run only where the reviewer team is dispatched, so the review stays lite, runs no seat and no resolver, and its Coverage must say the role was not applied and that `depth:full` applies it. The task never mentions model roles; `depth:full` appears in the answer only when that Coverage sentence fires.", + pre_contract: "The lite path never looks at the model role map, so a configured seat list is skipped with nothing said.", + task: `Use the ce-code-review skill on this repo with mode:agent. Resolve the Review depth gate. If the gate selects lite or focused, also list each statement the receipt's Coverage will make, one per line, without reviewing the diff. This is a read-only probe: do not create the run directory, do not start a peer job, and do not dispatch reviewers. + +End with exactly one line in this form and nothing else on that line: + +DEPTH: lite + +or + +DEPTH: focused + +or + +DEPTH: full`, + grade: { + files_read_post: ["references/modes-and-output.md", "references/depth-paths.md"], + declared: { DEPTH: "lite" }, + must_include: ["depth:full"], + actions: "none", + delegates: "none", + }, + }, { id: "ce-debug/role-entry-hands-off-investigation", post_only: true, diff --git a/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/.compound-engineering/config.yaml b/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/.compound-engineering/config.yaml new file mode 100644 index 000000000..b15be8eb8 --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/.compound-engineering/config.yaml @@ -0,0 +1,2 @@ +model_roles: + code-review: [claude-ce-eval-a, grok-ce-eval high] diff --git a/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/README.md b/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/README.md new file mode 100644 index 000000000..a8870e43d --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/README.md @@ -0,0 +1,3 @@ +# tablefmt + +Renders a two-dimensional array as an aligned text table for the CLI's `list` command. Output is printed to stdout and read by people, not parsed. diff --git a/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/src/tablefmt.ts b/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/src/tablefmt.ts new file mode 100644 index 000000000..ebf0a89fb --- /dev/null +++ b/tests/skill-eval-cell/fixtures/model-roles-code-review-lite/src/tablefmt.ts @@ -0,0 +1,47 @@ +export type Cell = string | number | null | undefined + +export type Align = "left" | "right" + +export interface TableOptions { + headers: string[] + align?: Align[] + padding?: number +} + +function cellText(cell: Cell): string { + if (cell === null || cell === undefined) return "" + return String(cell) +} + +function widths(headers: string[], rows: Cell[][]): number[] { + const out = headers.map((h) => h.length) + for (const row of rows) { + row.forEach((cell, i) => { + const len = cellText(cell).length + if (len > (out[i] ?? 0)) out[i] = len + }) + } + return out +} + +function pad(text: string, width: number, align: Align, padding: number): string { + const gap = " ".repeat(padding) + const fill = " ".repeat(Math.max(0, width - text.length)) + return align === "right" ? gap + fill + text + gap : gap + text + fill + gap +} + +export function renderTable(rows: Cell[][], options: TableOptions): string { + const padding = options.padding ?? 1 + const cols = widths(options.headers, rows) + const align = options.align ?? options.headers.map(() => "left" as Align) + const line = (cells: Cell[]) => + "|" + cells.map((c, i) => pad(cellText(c), cols[i], align[i] ?? "left", padding)).join("|") + "|" + const rule = "+" + cols.map((w) => "-".repeat(w + padding * 2)).join("+") + "+" + const body = rows.map(line) + return [rule, line(options.headers), rule, ...body, rule].join("\n") +} + +export function renderList(items: string[]): string { + if (items.length === 0) return "(none)" + return items.map((item, i) => `${i + 1}. ${item}`).join("\n") +} diff --git a/tests/skill-eval-cell/scenarios.md b/tests/skill-eval-cell/scenarios.md index 11f437874..131d0ba0b 100644 --- a/tests/skill-eval-cell/scenarios.md +++ b/tests/skill-eval-cell/scenarios.md @@ -122,7 +122,7 @@ bun run test:skill-eval-pack -- --id ce-plan/role-entry-reaches-authoring-gate - bun run test:skill-eval-pack -- --skill ce-doc-review --arm post --hosts claude ``` -Every row is write mode, because resolving a role runs a bundled script and a read-only cell denies the shell. Each stops at one decision and is graded on declared lines, the `TEAM` trailer, and an unchanged workspace. The tasks never name the map or its resolver. A skill's entry rows and its no-map row share one task, so only the fixture differs. Fixture model ids such as `claude-ce-eval-a` are grammar-valid fakes, so a declared id can only have come from the entry that won. +Every row that resolves a role is write mode, because resolving runs a bundled script and a read-only cell denies the shell. `ce-code-review/lite-review-says-role-not-applied` is read-only, because a lite review runs no resolver. Each row stops at one decision and is graded on declared lines, the `TEAM` trailer, and an unchanged workspace. The tasks never name the map or its resolver. A skill's entry rows and its no-map row share one task, so only the fixture differs. Fixture model ids such as `claude-ce-eval-a` are grammar-valid fakes, so a declared id can only have come from the entry that won. Resolution, precedence, and review policy: @@ -138,6 +138,7 @@ Resolution, precedence, and review policy: | `ce-doc-review/review-mode-off-skips-other-provider-seats` | Mode `off`: only the Claude seat is on the team; two seats not run, reason `review-mode` | | `ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat` | Mode `off` on a host with no attested family (run with `--hosts cursor`): no seat on the team; three seats not run, reason `review-mode` | | `ce-code-review/seat-list-and-review-mode` | Mode `off`: the Claude seat is on the team; the Grok seat is not run, reason `review-mode` | +| `ce-code-review/lite-review-says-role-not-applied` | A small diff stays `lite` with a seat list configured, and Coverage names `depth:full` as the way to apply the role | | `ce-debug/role-entry-hands-off-investigation` | The `debug` entry's model is declared before any investigation; subagent or session both pass | | `ce-simplify-code/role-entry-governs-apply` | The `simplify` entry's model and `low` are declared, with the effort reported as not applied | | `ce-compound/lightweight-never-hands-off` | A doc is written with no subagent, a `Model role compound:` line with route session, and `Documentation complete` still last | From d1e8c25f7cc303f3765bed0e408bfedf6987d802 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 17:47:27 -0700 Subject: [PATCH 26/53] refactor(tests): reuse the frontmatter parser and the eval host list The setup-flow test parsed frontmatter by hand, the native hand-off test carried a third copy of the prompt-size helper to repeat a bound the budget test owns, and the eval provenance check retyped the host list. Co-Authored-By: Claude Code --- tests/skill-eval-cell/provenance.ts | 3 ++- tests/skills/ce-setup-model-roles.test.ts | 10 ++-------- tests/skills/model-role-native-handoff.test.ts | 10 ---------- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/tests/skill-eval-cell/provenance.ts b/tests/skill-eval-cell/provenance.ts index 351c15b5f..269081983 100644 --- a/tests/skill-eval-cell/provenance.ts +++ b/tests/skill-eval-cell/provenance.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto" import fs from "node:fs" import path from "node:path" +import { HOSTS } from "./hosts" export const PACK_SCHEMA_VERSION = 2 // The trusted assessment path, not only gradeArm's immediate dependencies. @@ -157,7 +158,7 @@ export function verifyEvidence(out: string, expectedManifestHash?: string): void throw new Error("no unique host results were collected") } for (const host of summary.hosts_run) { - if (!["claude", "codex", "grok", "opencode", "cursor"].includes(host)) throw new Error("unknown recorded host") + if (!(HOSTS as readonly string[]).includes(host)) throw new Error("unknown recorded host") const dir = containedPath(out, `hosts/${host}`) for (const name of ["stdout.txt", "stderr.txt", "exit.json", "prompt.md", "argv.json", "git-status.txt", "git-head-files.txt"]) { if (!fs.lstatSync(path.join(dir, name)).isFile()) throw new Error(`missing regular host evidence: ${name}`) diff --git a/tests/skills/ce-setup-model-roles.test.ts b/tests/skills/ce-setup-model-roles.test.ts index 5e89d5a80..d17d17b2d 100644 --- a/tests/skills/ce-setup-model-roles.test.ts +++ b/tests/skills/ce-setup-model-roles.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "fs" import path from "path" import { describe, expect, test } from "bun:test" -import { load } from "js-yaml" +import { parseFrontmatter } from "../../src/utils/frontmatter" // ce-setup's model role flow is the one place setup writes a `model_roles` map, // and the one place it may write the personal `config.local.yaml`. These guards @@ -20,15 +20,9 @@ const read = (rel: string) => readFileSync(path.join(REPO, rel), "utf8") const SKILL = read("skills/ce-setup/SKILL.md") const REFERENCE_PATH = "skills/ce-setup/references/model-roles-setup.md" -function frontmatter(body: string): Record { - const match = body.match(/^---\r?\n([\s\S]*?)\r?\n---/) - expect(match, "frontmatter block").not.toBeNull() - return load(match![1]) as Record -} - describe("ce-setup model role flow contract", () => { test("SKILL.md names the models trigger and routes to the reference", () => { - expect(frontmatter(SKILL)["argument-hint"]).toContain("models") + expect(parseFrontmatter(SKILL).data["argument-hint"]).toContain("models") const start = SKILL.indexOf("## Model Roles") expect(start, "## Model Roles section").toBeGreaterThan(-1) diff --git a/tests/skills/model-role-native-handoff.test.ts b/tests/skills/model-role-native-handoff.test.ts index 24fad5c4c..aaef73566 100644 --- a/tests/skills/model-role-native-handoff.test.ts +++ b/tests/skills/model-role-native-handoff.test.ts @@ -27,12 +27,6 @@ const hookFor = (role: string) => `\\*\\*Model role\\.\\*\\*[^\\n]*\`model_roles:\`[^\\n]*\`references/model-roles\\.md\`[^\\n]*\`${role}\` role`, ) -/** Byte size as a Windows checkout with CRLF line endings would inject it (tests/codex-skill-prompt-budget.test.ts). */ -function crlfByteSize(contents: string): number { - const lf = contents.replace(/\r\n/g, "\n") - return Buffer.byteLength(lf, "utf8") + (lf.match(/\n/g)?.length ?? 0) -} - /** Bodies of the fenced blocks in a Markdown file, without the fence lines. */ function fencedBlocks(source: string): string[] { return [...source.matchAll(/^```[^\n]*\n([\s\S]*?)\n```$/gm)].map((match) => match[1]) @@ -57,10 +51,6 @@ describe("ce-simplify-code `simplify` role hand-off", () => { expect(section(body, "## Step 5: Summarize")).toContain("`Model role` line") }) - test("the body keeps about 200 bytes of room under the 8,000-byte prompt bound", () => { - expect(crlfByteSize(body)).toBeLessThanOrEqual(8_000 - 200) - }) - test("the reviewers keep their tier, and a failed hand-off lands on the session", () => { const handoff = read("ce-simplify-code", HANDOFF) expect(handoff).toContain("`references/model-roles.md`") From 17b752e88f08f06367abdf25bd700ba0c7c2c5b4 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 18:19:01 -0700 Subject: [PATCH 27/53] fix(model-roles): put the skip first and run the resolver from the project Two Codex slips remained after the hook said the resolver decides. In 2 of 16 no-map cells Codex ran the resolver anyway, so the hook now states the skip before the action. In 2 of 40 entry cells Codex ran the resolver from the skill directory, where it finds no repository and answers `unset`; the shared reference now says to run it with the project as the working directory. The lite-review eval cell no longer pins the depth, which has its own rows. Co-Authored-By: Claude Code --- skills/ce-brainstorm/references/model-roles.md | 2 +- skills/ce-brainstorm/references/reasoning-elevation.md | 2 +- skills/ce-code-review/references/model-roles.md | 2 +- skills/ce-code-review/references/select-and-route.md | 2 +- skills/ce-compound/references/lightweight.md | 2 +- skills/ce-compound/references/model-roles.md | 2 +- skills/ce-compound/references/research.md | 2 +- skills/ce-debug/references/fix.md | 2 +- skills/ce-debug/references/investigate.md | 2 +- skills/ce-debug/references/model-roles.md | 2 +- skills/ce-doc-review/references/dispatch.md | 2 +- skills/ce-doc-review/references/model-roles.md | 2 +- skills/ce-plan/references/model-roles.md | 2 +- skills/ce-plan/references/reasoning-elevation.md | 2 +- skills/ce-simplify-code/SKILL.md | 2 +- skills/ce-simplify-code/references/model-roles.md | 2 +- skills/ce-work/references/execution-engines.md | 2 +- skills/ce-work/references/model-roles.md | 2 +- tests/skill-eval-cell/catalog.ts | 4 ++-- tests/skill-eval-cell/scenarios.md | 2 +- 20 files changed, 21 insertions(+), 21 deletions(-) diff --git a/skills/ce-brainstorm/references/model-roles.md b/skills/ce-brainstorm/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-brainstorm/references/model-roles.md +++ b/skills/ce-brainstorm/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index f2ac87af4..6798dcc36 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-code-review/references/model-roles.md b/skills/ce-code-review/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-code-review/references/model-roles.md +++ b/skills/ce-code-review/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-code-review/references/select-and-route.md b/skills/ce-code-review/references/select-and-route.md index e8591f7f4..cc685e022 100644 --- a/skills/ce-code-review/references/select-and-route.md +++ b/skills/ce-code-review/references/select-and-route.md @@ -76,7 +76,7 @@ The Review depth gate in `references/modes-and-output.md` already chose lite, fo ### Stage 3d: Bind the adversarial route and final roster -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `code-review` role before binding the adversarial route. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `code-review` role before binding the adversarial route. The resolver's answer decides the role; your own reading of the entry does not. **Review seats.** When that key is active, read `references/cross-model-review.md` in full too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the local reviewers. A seat does not depend on which personas were selected, so seats run whether or not adversarial was selected. When that section says the single-peer pass does not run, the rest of this stage binds no route: keep `adversarial-reviewer` in the local roster when Stage 3 selected it, start the worker seats now, and add the natively served seats to the Stage 4 batch. The exclusive choice below, and its rule that a started peer and the in-process adversarial reviewer never receive the same brief, govern only the single-peer pass. diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index 6fbab8d4d..512c19f28 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,7 +10,7 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver's answer decides the role; your own reading of the entry does not. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. The orchestrator (main conversation) performs ALL of the following in one sequential pass: diff --git a/skills/ce-compound/references/model-roles.md b/skills/ce-compound/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-compound/references/model-roles.md +++ b/skills/ce-compound/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index 41cdf9e7b..52008fdcc 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -87,7 +87,7 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - Does not force bug-track fields onto knowledge-track learnings or vice versa #### 2. **Draft the body** (this context) - **Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before drafting the body. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before drafting the body. The resolver's answer decides the role; your own reading of the entry does not. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. - Adapts output structure to the track chosen in step 1 - **Drafts the full doc-body prose** (all track-appropriate sections below) from the conversation and the verified fix. The failed attempts and the reasoning behind the fix are what a later reader cannot recover from the code, so they come from what happened in this session, not from a recap of the final diff. diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 34e73993c..456fac710 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -4,7 +4,7 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and *One change at a time. If you are changing multiple things, stop.* -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the first edit. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the first edit. The resolver's answer decides the role; your own reading of the entry does not. When Phase 1 already resolved the role, use that result. `references/investigate.md` states the role's deliverable, its route, and where the `Model role` line goes. This file adds only the fix hand-off. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 84426bb44..e5e347bf5 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,7 +25,7 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver's answer decides the role; your own reading of the entry does not. The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route, so a native subagent is its only hand-off route, and the fallback ladder in `references/model-roles.md` comes straight after it. The entry is standing config. `mode:pipeline` and `mode:return-to-caller` honor it the way interactive use does, and no mode asks about it. diff --git a/skills/ce-debug/references/model-roles.md b/skills/ce-debug/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-debug/references/model-roles.md +++ b/skills/ce-debug/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index 70e6a2735..621b58158 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -6,7 +6,7 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Agent lifecycle.** Collect each agent's final outcome, including failures, before cleanup. When the harness lets the caller close or release agents, close or release the agents this review started before refilling slots, advancing stages, or returning. Do not message completed agents with no remaining work. Do not assume capacity was freed just because an agent completed or was interrupted, and do not invent cleanup operations. -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `doc-review` role before dispatching the reviewers. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `doc-review` role before dispatching the reviewers. The resolver's answer decides the role; your own reading of the entry does not. **Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. Seats start in the same dispatch wave as those reviewers. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. diff --git a/skills/ce-doc-review/references/model-roles.md b/skills/ce-doc-review/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-doc-review/references/model-roles.md +++ b/skills/ce-doc-review/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-plan/references/model-roles.md b/skills/ce-plan/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-plan/references/model-roles.md +++ b/skills/ce-plan/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index f2ac87af4..6798dcc36 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. diff --git a/skills/ce-simplify-code/SKILL.md b/skills/ce-simplify-code/SKILL.md index f37432fc6..327f30534 100644 --- a/skills/ce-simplify-code/SKILL.md +++ b/skills/ce-simplify-code/SKILL.md @@ -41,7 +41,7 @@ Do not paraphrase these rubrics from memory. Read each file and pass it verbatim ## Step 3: Fix issues -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `simplify` role before applying any finding. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `simplify` role before applying any finding. The resolver's answer decides the role; your own reading of the entry does not. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. Proceed only after all three review outcomes are complete, whether returned by subagents or produced inline. Apply worthwhile findings directly; record false positives and low-value findings as skipped without asking the user. diff --git a/skills/ce-simplify-code/references/model-roles.md b/skills/ce-simplify-code/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-simplify-code/references/model-roles.md +++ b/skills/ce-simplify-code/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index 4b2e8b6e6..2f6276a1d 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -47,7 +47,7 @@ When the target resolves to the current host's default execution route and no di - **Do not** use this rule for `docs_root` — that key is `config.yaml` only. -**Model role.** Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key. Open both by path, because a file search skips that hidden directory. When either has the key, read `references/model-roles.md` now and run its resolver for the `work` role before reading the `work_engine_*` keys below. The resolver's answer decides the role; your own reading of the entry does not. With no such key in either file, skip this: do not read that reference or run its resolver, and print nothing about model roles. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `work` role before reading the `work_engine_*` keys below. The resolver's answer decides the role; your own reading of the entry does not. The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. `references/model-roles.md` owns the resolver command, what each `state` means, precedence, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. "A `work` role entry" below adds only what is specific to this skill. diff --git a/skills/ce-work/references/model-roles.md b/skills/ce-work/references/model-roles.md index b3eb4b3e0..05869ef2a 100644 --- a/skills/ce-work/references/model-roles.md +++ b/skills/ce-work/references/model-roles.md @@ -10,7 +10,7 @@ A *single-model role* has one entry. A *review role* has a list of seats, and th ## Resolve the role -Run this skill's resolver in one command: +Run this skill's resolver in one command, with the project as the working directory. The resolver reads the config of the repository it runs in, so a run from the skill directory finds no map. ```bash SKILL_DIR=""; diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 505de3929..230bbefbd 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -3814,7 +3814,7 @@ Use "Correct widget limit" as the description in the subject and any required bo git_init: true, git_staged: ["src/tablefmt.ts"], fixture: `${FIX}/model-roles-code-review-lite`, - why: "The `depth-gate-loud-lite` diff with a `code-review` seat list configured. Seats run only where the reviewer team is dispatched, so the review stays lite, runs no seat and no resolver, and its Coverage must say the role was not applied and that `depth:full` applies it. The task never mentions model roles; `depth:full` appears in the answer only when that Coverage sentence fires.", + why: "The `depth-gate-loud-lite` diff with a `code-review` seat list configured. Seats run only where the reviewer team is dispatched, so the review runs no seat and no resolver, and its Coverage must say the role was not applied and that `depth:full` applies it. Lite or focused both pass: the depth judgment has its own rows. The task never mentions model roles; `depth:full` appears in the answer only when that Coverage sentence fires.", pre_contract: "The lite path never looks at the model role map, so a configured seat list is skipped with nothing said.", task: `Use the ce-code-review skill on this repo with mode:agent. Resolve the Review depth gate. If the gate selects lite or focused, also list each statement the receipt's Coverage will make, one per line, without reviewing the diff. This is a read-only probe: do not create the run directory, do not start a peer job, and do not dispatch reviewers. @@ -3831,8 +3831,8 @@ or DEPTH: full`, grade: { files_read_post: ["references/modes-and-output.md", "references/depth-paths.md"], - declared: { DEPTH: "lite" }, must_include: ["depth:full"], + must_include_any: [["DEPTH: lite", "DEPTH: focused"]], actions: "none", delegates: "none", }, diff --git a/tests/skill-eval-cell/scenarios.md b/tests/skill-eval-cell/scenarios.md index 131d0ba0b..6ab7995f1 100644 --- a/tests/skill-eval-cell/scenarios.md +++ b/tests/skill-eval-cell/scenarios.md @@ -138,7 +138,7 @@ Resolution, precedence, and review policy: | `ce-doc-review/review-mode-off-skips-other-provider-seats` | Mode `off`: only the Claude seat is on the team; two seats not run, reason `review-mode` | | `ce-doc-review/unattested-host-review-mode-off-skips-every-named-seat` | Mode `off` on a host with no attested family (run with `--hosts cursor`): no seat on the team; three seats not run, reason `review-mode` | | `ce-code-review/seat-list-and-review-mode` | Mode `off`: the Claude seat is on the team; the Grok seat is not run, reason `review-mode` | -| `ce-code-review/lite-review-says-role-not-applied` | A small diff stays `lite` with a seat list configured, and Coverage names `depth:full` as the way to apply the role | +| `ce-code-review/lite-review-says-role-not-applied` | A small diff takes the lite or focused path with a seat list configured, and Coverage names `depth:full` as the way to apply the role | | `ce-debug/role-entry-hands-off-investigation` | The `debug` entry's model is declared before any investigation; subagent or session both pass | | `ce-simplify-code/role-entry-governs-apply` | The `simplify` entry's model and `low` are declared, with the effort reported as not applied | | `ce-compound/lightweight-never-hands-off` | A doc is written with no subagent, a `Model role compound:` line with route session, and `Documentation complete` still last | From 0da08823508ee0f4da19a0b2f3882b34a349fa3b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 18:54:26 -0700 Subject: [PATCH 28/53] docs(solutions): record what a config hook must state for Codex to follow it Codex missed the model role map four ways across the hook's wording changes: a bare file name, a file search that skips the hidden directory, reading the entry instead of running the resolver, and running the resolver from the skill directory. The learning records the wording that passed and the eval counts for each version. Co-Authored-By: Claude Code --- ...ates-path-decider-and-working-directory.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md diff --git a/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md b/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md new file mode 100644 index 000000000..b45498feb --- /dev/null +++ b/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md @@ -0,0 +1,108 @@ +--- +title: "A hook that acts on a repo config file states the full path, the script that decides, and where to run it" +date: 2026-10-01 +category: skill-design +module: skills +problem_type: design_pattern +component: tooling +severity: medium +applies_when: + - "A skill step acts only when a file under a hidden directory carries a key" + - "A bundled script owns a decision the agent could also read off a config file by eye" + - "A fresh-agent eval passes on Claude and fails on Codex with a correct-looking answer" + - "Grading a cell whose declared answer can be right when the skill's route was skipped" +tags: + - skill-authoring + - skill-eval + - cross-model + - codex + - hidden-directory + - bundled-script +--- + +# A hook that acts on a repo config file states the full path, the script that decides, and where to run it + +## Context + +The model role map added a short hook to ten skill steps. The hook says: when the repo's CE config carries a `model_roles:` key, read a shared reference and run a bundled resolver for this step's role. The resolver owns layering between the personal and team files, entry validation, and the review egress policy. + +Fresh-agent eval cells (`tests/skill-eval-cell/`) ran the same checkpoint on Claude, Codex, and Cursor. Neither Claude nor Cursor failed a cell for any reason below. Codex failed four different ways across the wording changes, and each failure was a fact the hook had left for the agent to work out: + +1. **Which file.** The hook named the files as "`.compound-engineering/config.local.yaml` or `config.yaml`". One Codex run looped over `.compound-engineering/config.local.yaml` and `config.yaml`, so it looked for the second file at the repo root. +2. **How to find it.** Two Codex runs listed files with `rg --files -g 'config.yaml'`. That search skips hidden directories, so it printed nothing, and the agent reported that no CE config existed. The step then ran on the session model and printed no `Model role` line. The user's map was ignored with nothing said. +3. **Who decides.** After the hook gave both full paths, Codex found the config in every cell. In 4 of 20 cells with an entry it then read the entry itself and never opened the reference or ran the resolver. Its answers were right because the fixtures held one simple entry. In a later cell, one of the two in item 4, the resolver could not start and Codex read the entry by eye: it reported `claude-ce-eval-5` for an entry that says `claude-ce-eval-a`. +4. **Where to run it.** After the hook said to run the resolver, Codex ran it from the skill directory in 2 of 20 cells. The resolver reads the config of the repository it runs in. In one cell it found no repository there and answered `unset` with the warning `not inside a git repository; no CE config to read` (`skills/ce-setup/scripts/model-role-resolve.py:412`), and Codex trusted that answer over the file it had just read. In the other cell the command failed to start. + +A fifth slip went the other way. Once the hook stressed that the resolver decides, Codex ran the resolver on a checkout with no map in 2 of 16 cells. The result was harmless, but the step is meant to cost nothing when no map exists. + +## Guidance + +A hook that conditions a step on a repo file gives the agent four facts in this order. The current wording is in `skills/ce-plan/references/reasoning-elevation.md:17`, and the same sentence shape is used at the other nine sites. + +1. **The full path of every file, and that it is opened by path.** Write `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml`, never the directory once and a bare file name after it. Say why: a file search skips that hidden directory. +2. **The skip, before the action.** State the case where nothing happens first, and name what is skipped: do not read the reference, do not run its script, print nothing. +3. **The action, naming the script as the thing to run.** "Run its resolver for the `plan` role" rather than "resolve the `plan` role". An agent that has just read the file treats "resolve" as already done. +4. **Who decides.** One sentence: the resolver's answer decides the role, and the agent's own reading of the entry does not. + +The reference that carries the script's command states where to run it. `skills/ce-plan/references/model-roles.md:13` says to run the resolver with the project as the working directory, because it reads the config of the repository it runs in. + +Grade the route as well as the answer. A cell that only checks the declared model passes when the agent read the entry by eye. The cells that exposed the third failure declare a required read with `files_read_post` (`tests/skill-eval-cell/catalog.ts`, graded in `tests/skill-eval-cell/grade.ts:275`), and their matching no-map cells forbid the resolver in `ACTIONS`. The pair fails in both directions: the route skipped when a map exists, and the route taken when none does. + +## Why This Matters + +Each omission produced a silent failure. The user configures a model, the step runs on a different one, and no line in the output says so. A wrong answer from a hand-read entry is worse on a review role, where the resolver also applies `cross_model_review_mode` and `CROSS_MODEL_PEERS` to decide which providers may receive the reviewed content. + +The failures are invisible on the authoring model. Claude followed the first wording in every cell, so an eval run on Claude alone would have shipped it. They are also invisible to a grade that reads only the declared answer, because simple fixtures make a shortcut look correct. + +Fixing one omission exposed the next. Telling the agent where the files are made it open them, and having opened them it stopped needing the script. Telling it the script decides made it run the script in cases that should skip. A hook like this is settled only when a round passes in both directions on the weaker host. The final wording has one such round, so the last row of the table below is thin evidence. + +## When to Apply + +- Writing or reviewing a skill step that reads a file under a dot-directory. +- Adding a bundled script that reads repo state the agent can also see. +- Choosing what an eval cell declares and grades for a step with a bundled script. + +The shared `ce-config-layers` block still appears in eleven files across ten skills, and its first bullet names the files as "`/.compound-engineering/config.local.yaml`, then `config.yaml`" (for example `skills/ce-brainstorm/SKILL.md`). That is the wording of the first failure above. It was not changed with the model role map. + +## Examples + +Before, the first wording of the hook: + +```text +**Model role.** When either repo CE config file (`.compound-engineering/config.local.yaml` +or `config.yaml`) carries an active `model_roles:` key, read `references/model-roles.md` +now and resolve the `plan` role before reading the per-skill key. With no such key, skip +this and print nothing about model roles. +``` + +After: + +```text +**Model role.** Open `.compound-engineering/config.local.yaml` and +`.compound-engineering/config.yaml` at the repo root by path, because a file search skips +that hidden directory. When neither has an active `model_roles:` key, skip this: do not +read `references/model-roles.md` or run its resolver, and print nothing about model roles. +When either has the key, read `references/model-roles.md` now and run its resolver for +the `plan` role before reading the per-skill key. The resolver's answer decides the role; +your own reading of the entry does not. +``` + +Codex results by hook wording, counted over the graded Codex cells of each round: + +| Hook wording | Config not found | Reference and resolver skipped | Resolver run with no map | Resolver run from the skill directory | +|---|---|---|---|---| +| Directory once, bare `config.yaml` after it | 2 of 9 | not reached | 0 | 0 | +| Both full paths, opened by path | 0 of 36 | 4 of 20 | 0 of 16 | 0 | +| Plus "run its resolver" and "the resolver's answer decides" | 0 of 38 | 0 of 20 | 2 of 16 | 2 of 20 | +| Skip stated first, and the reference says to run from the project | 0 of 19 | 0 of 10 | 0 of 8 | 0 of 10 | + +The first row is one partial round, the next two rows are two rounds each, and the last row is one round. Over the same rounds Claude passed 111 of 112 cells, and its one miss was an answer-format slip in a new cell. Cursor passed 58 of 58 over three rounds, the first of which ran before the bare file name was fixed. + +## Related + +- `docs/solutions/skill-design/strong-models-mask-defensive-skill-fixes.md`: the authoring model passing is not evidence the prose is sufficient. +- `docs/solutions/skill-design/authored-eval-corpora-contain-the-happy-path.md`: simple fixtures made the shortcut answers look right. +- `docs/solutions/skill-design/paired-old-vs-new-injection-skill-evals.md`: the eval method these cells use. +- `docs/solutions/skill-design/bundled-script-path-resolution-across-harnesses.md`: how a skill names the script it runs. +- `docs/solutions/skill-design/new-knowledge-source-re-derives-persona-gate-and-route.md`: the same review found the `code-review` role hooked into a reference only the full review path reads, as that learning predicts. +- `docs/solutions/skill-design/portable-agent-skill-authoring.md`: the standard these hooks follow. From 8b1b4e42c532638434f7d3f0efa19181857b9ef6 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 18:54:27 -0700 Subject: [PATCH 29/53] test(evals): stop pinning whether a native subagent applies the effort The simplify cell stops before the hand-off, so the answer is a prediction about the host's subagent tool. Codex said yes in three of six runs. The cell now grades the entry's model and effort and the shared reference being read. Co-Authored-By: Claude Code --- tests/skill-eval-cell/catalog.ts | 9 ++++++--- tests/skill-eval-cell/scenarios.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 230bbefbd..d5ea48f30 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -3869,13 +3869,16 @@ DEPTH: full`, git_staged: ["src/totals.js"], fixture: `${FIX}/model-roles-simplify-entry`, timeout_secs: 300, - why: "`simplify: claude-ce-eval-a low` governs apply and verify. The only hand-off route is a native subagent, which takes a model but no effort on every host verified so far, so the effort is reported as not applied. A host that cannot hand the model over applies in the session, where the requested effort is not applied either. Both outcomes pass. The staged file keeps `git status` dirty, so the no-edit check is that the duplicated loop is still there.", + why: "`simplify: claude-ce-eval-a low` governs apply and verify. The only hand-off route is a native subagent. The cell stops before the hand-off, so whether the effort will be applied is a prediction about the host's subagent tool: Codex answered yes in three of six runs and no in the rest, and Claude and Cursor answered no. Either answer passes, and so does applying in the session. The graded facts are the entry's model and effort and the shared reference being read. The staged file keeps `git status` dirty, so the no-edit check is that the duplicated loop is still there.", pre_contract: "ce-simplify-code reads no model setting; the session applies and verifies.", task: ROLE_SIMPLIFY_TASK, grade: { files_read_post: ["references/model-roles.md"], - declared: { REQUESTED_MODEL: "claude-ce-eval-a", REQUESTED_EFFORT: "low", EFFORT_APPLIED: "no" }, - must_include_any: [["APPLIED_BY: subagent", "APPLIED_BY: session"]], + declared: { REQUESTED_MODEL: "claude-ce-eval-a", REQUESTED_EFFORT: "low" }, + must_include_any: [ + ["APPLIED_BY: subagent", "APPLIED_BY: session"], + ["EFFORT_APPLIED: no", "EFFORT_APPLIED: yes"], + ], delegates: "none", workspace_contains: [{ path: "src/totals.js", needle: "function orderTotalWithShipping(order, shipping) {\n let subtotal = 0" }], }, diff --git a/tests/skill-eval-cell/scenarios.md b/tests/skill-eval-cell/scenarios.md index 6ab7995f1..19992fdf4 100644 --- a/tests/skill-eval-cell/scenarios.md +++ b/tests/skill-eval-cell/scenarios.md @@ -140,7 +140,7 @@ Resolution, precedence, and review policy: | `ce-code-review/seat-list-and-review-mode` | Mode `off`: the Claude seat is on the team; the Grok seat is not run, reason `review-mode` | | `ce-code-review/lite-review-says-role-not-applied` | A small diff takes the lite or focused path with a seat list configured, and Coverage names `depth:full` as the way to apply the role | | `ce-debug/role-entry-hands-off-investigation` | The `debug` entry's model is declared before any investigation; subagent or session both pass | -| `ce-simplify-code/role-entry-governs-apply` | The `simplify` entry's model and `low` are declared, with the effort reported as not applied | +| `ce-simplify-code/role-entry-governs-apply` | The `simplify` entry's model and `low` are declared; subagent or session both pass | | `ce-compound/lightweight-never-hands-off` | A doc is written with no subagent, a `Model role compound:` line with route session, and `Documentation complete` still last | Restraint, one per role skill, with no `model_roles` key in either config file. Each grade fails when `model-role-resolve` appears in `ACTIONS`, when the report block mentions a model role, or when the ordinary decision is not declared: From 99e18411a28bb1f312d71ce150b807d411349c34 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 20:12:09 -0700 Subject: [PATCH 30/53] fix(review): let a seat the resolver cleared pass the worker allowlist With CROSS_MODEL_PEERS set, the role resolver clears a seat in the attested host's own family and a Composer seat when `cursor` is listed, as the configuration guide states. Both review workers then refused those seats on an exact-match allowlist check, so a configured seat was started and dropped. In seat mode the workers now apply the same two rules. Another provider's seat is still refused, and the path without a seat number is unchanged. The workers also accept a Claude alias with one bracketed qualifier, such as `opus[1m]`, as a model override. Co-Authored-By: Claude Code --- .../scripts/cross-model-adversarial-review.sh | 13 ++++++-- .../scripts/cross-model-doc-review.sh | 13 ++++++-- .../ce-code-review-cross-model-routes.test.ts | 29 ++++++++++++++++++ .../ce-doc-review-cross-model-routes.test.ts | 30 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index 4f231c092..ee98eb9f4 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -324,7 +324,7 @@ validate_model_override() { [ "$override_target" = "$target" ] || return 0 [ "$target" != "cursor" ] || return 1 case "$route:$override" in - codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; + codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:fable\[*\]|claude:opus\[*\]|claude:sonnet\[*\]|claude:haiku\[*\]|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; *) return 1 ;; esac } @@ -434,6 +434,15 @@ out_missing_or_invalid() { # does not sanction Cursor must not fall through grok -> cursor-agent. cursor_egress_ok() { [ -z "$ALLOW" ] || in_csv cursor "$ALLOW" || in_csv composer "$ALLOW"; } +# The allowlist names recipients. For a seat, the role resolver has already +# cleared two targets it does not list: the attested host's own family, where +# nothing new leaves the machine, and Composer when `cursor` is listed. +seat_cleared() { + [ -n "$SEAT" ] || return 1 + if [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$1")" = "$HOST_PROVIDER" ]; then return 0; fi + [ "$1" = composer ] && in_csv cursor "$ALLOW" +} + # The Codex desktop app (Codex.app, or ChatGPT.app since the July 2026 merger) # ships `codex` at Contents/Resources without linking it onto PATH (#1272). # Append, never prepend, so a PATH-installed CLI stays authoritative. @@ -466,7 +475,7 @@ for p in $CANDIDATES; do case "$p" in codex|claude|grok|cursor|composer|opencode) ;; *) log "ignoring unknown target '$p' in candidates"; continue ;; esac [ -z "$SEAT" ] && [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue case " $SELECTED " in *" $p "*) continue ;; esac - if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi + if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW" && ! seat_cleared "$p"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi if ! provider_available "$p"; then log "provider '$p' has no installed route; skipping"; continue; fi SELECTED="$SELECTED $p" done diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index a1ea4b053..61a6db9b7 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -314,7 +314,7 @@ validate_model_override() { [ "$override_target" = "$target" ] || return 0 [ "$target" != "cursor" ] || return 1 case "$route:$override" in - codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; + codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:fable\[*\]|claude:opus\[*\]|claude:sonnet\[*\]|claude:haiku\[*\]|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; *) return 1 ;; esac } @@ -465,6 +465,15 @@ out_missing_or_invalid() { [ ! -s "$RAW_OUT" ] || ! jq -e '(.findings|type)=="ar # -- either way the user has accepted that content may reach Cursor. cursor_egress_ok() { [ -z "$ALLOW" ] || in_csv cursor "$ALLOW" || in_csv composer "$ALLOW"; } +# The allowlist names recipients. For a seat, the role resolver has already +# cleared two targets it does not list: the attested host's own family, where +# nothing new leaves the machine, and Composer when `cursor` is listed. +seat_cleared() { + [ -n "$SEAT" ] || return 1 + if [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$1")" = "$HOST_PROVIDER" ]; then return 0; fi + [ "$1" = composer ] && in_csv cursor "$ALLOW" +} + # Soft size gate: peer prompt embeds the full document. Over-budget docs skip # cleanly (R11) rather than collapsing silently inside the provider context window. MAX_DOC_CHARS="${CROSS_MODEL_MAX_DOC_CHARS:-200000}" @@ -513,7 +522,7 @@ for p in $CANDIDATES; do case "$p" in codex|claude|grok|cursor|composer|opencode) ;; *) log "ignoring unknown target '$p' in candidates"; continue ;; esac [ -z "$SEAT" ] && [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue case " $SELECTED " in *" $p "*) continue ;; esac # dedup - if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi + if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW" && ! seat_cleared "$p"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi if ! provider_available "$p"; then log "provider '$p' has no installed route; skipping"; continue; fi SELECTED="$SELECTED $p" done diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index c72e51e74..d07312e6c 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -1610,6 +1610,9 @@ describe("cross-model-adversarial-review normalization", () => { expect(emitAdapter("composer", SCRIPT, override)).toContain("--model composer-next") expect(emitAdapter("grok-cursor", SCRIPT, override)).toContain("--model grok-4.7-xhigh") expect(emitAdapter("cursor", SCRIPT, override)).not.toContain("--model") + // A Claude alias may carry one bracketed qualifier, the form a `model_roles` seat can name. + const qualified = { CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: "opus[1m]" } + expect(emitAdapter("claude", SCRIPT, qualified)).toContain("--model opus[1m]") const crossFamily = spawnSync("bash", [SCRIPT, "--emit-adapter", "composer"], { encoding: "utf8", @@ -1978,6 +1981,32 @@ describe("cross-model-adversarial-review review seats (CROSS_MODEL_SEAT)", () => expect(artifact(runDir, "adversarial-claude-s1.json").independence_verified).toBe(false) }) + test("CROSS_MODEL_PEERS refuses a seat only when its provider is a new recipient", () => { + const refused = "not in CROSS_MODEL_PEERS allowlist" + const { env } = sandbox(["claude", "cursor-agent"], seatStub) + const seat = { ...env, CROSS_MODEL_SEAT: "1" } + + // The role resolver clears a seat in the host's own family: nothing new leaves the machine. + let runDir = makeRunDir() + let r = run(seatArgs("claude", "claude", runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "codex" }) + expect(r.stderr).not.toContain(refused) + expect(r.stderr).toContain("peer run: provider=claude route=claude") + + // `cursor` on the allowlist sanctions Cursor as a recipient, so its Composer seat is not refused. + runDir = makeRunDir() + r = run(seatArgs("claude", "composer", runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "cursor" }) + expect(r.stderr).not.toContain(refused) + + // Another provider's seat is still refused, and so is Composer outside a seat. + runDir = makeRunDir() + r = run(seatArgs("codex", "claude", runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "grok" }) + expect(r.stderr).toContain(`provider 'claude' ${refused}`) + expect(seatOutputs(r.files)).toEqual([]) + runDir = makeRunDir() + r = run(seatArgs("claude", "composer", runDir), runDir, { ...env, CROSS_MODEL_PEERS: "cursor" }) + expect(r.stderr).toContain(`provider 'composer' ${refused}`) + }) + test("a seat's model and effort reach the claude and codex adapter arguments", () => { const capRoot = mkTempRoot("xmodel-cr-seat-argv-") const capFile = path.join(capRoot, "argv.txt") diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index 029da6052..142e1f8f5 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1359,6 +1359,9 @@ describe("cross-model-doc-review normalization (R18, KTD5)", () => { expect(emitAdapter("composer", override)).toContain("--model composer-next") expect(emitAdapter("grok-cursor", override)).toContain("--model grok-4.7-xhigh") expect(emitAdapter("cursor", override)).not.toContain("--model") + // A Claude alias may carry one bracketed qualifier, the form a `model_roles` seat can name. + const qualified = { CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: "opus[1m]" } + expect(emitAdapter("claude", qualified)).toContain("--model opus[1m]") const crossFamily = spawnSync("bash", [SCRIPT, "--emit-adapter", "composer"], { encoding: "utf8", @@ -1601,6 +1604,33 @@ describe("cross-model-doc-review review seats (CROSS_MODEL_SEAT)", () => { expect(artifact(runDir, "whole-doc-claude-s1.json").independence_verified).toBe(false) }) + test("CROSS_MODEL_PEERS refuses a seat only when its provider is a new recipient", () => { + const refused = "not in CROSS_MODEL_PEERS allowlist" + const { env } = sandbox(["claude", "cursor-agent"], seatStub) + const seat = { ...env, CROSS_MODEL_SEAT: "1" } + const doc = makeDoc() + + // The role resolver clears a seat in the host's own family: nothing new leaves the machine. + let runDir = makeRunDir() + let r = run(seatArgs("claude", "claude", doc, runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "codex" }) + expect(r.stderr).not.toContain(refused) + expect(r.stderr).toContain("peer run: provider=claude route=claude") + + // `cursor` on the allowlist sanctions Cursor as a recipient, so its Composer seat is not refused. + runDir = makeRunDir() + r = run(seatArgs("claude", "composer", doc, runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "cursor" }) + expect(r.stderr).not.toContain(refused) + + // Another provider's seat is still refused, and so is Composer outside a seat. + runDir = makeRunDir() + r = run(seatArgs("codex", "claude", doc, runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "grok" }) + expect(r.stderr).toContain(`provider 'claude' ${refused}`) + expect(r.files).toEqual([]) + runDir = makeRunDir() + r = run(seatArgs("claude", "composer", doc, runDir), runDir, { ...env, CROSS_MODEL_PEERS: "cursor" }) + expect(r.stderr).toContain(`provider 'composer' ${refused}`) + }) + test("a seat's model and effort reach the claude and codex adapter arguments", () => { const capRoot = mkTempRoot("xmodel-seat-argv-") const capFile = path.join(capRoot, "argv.txt") From 751d7e0e1f496bdef8c2249dbacbd96de873657a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 20:12:10 -0700 Subject: [PATCH 31/53] fix(model-roles): accept a bracketed qualifier and report unknown roles A model id with a bracketed qualifier, such as `opus[1m]`, was rejected as not a model id, so the entry was skipped. The resolver now accepts one trailing qualifier and infers the family from the part before it. Unknown keys under `model_roles` were reported only by `--all`, which only setup runs. A misspelled role, such as `code_review`, resolved as unset with no warning in the skill it was meant for. Every answer now carries them. Co-Authored-By: Claude Code --- .../scripts/model-role-resolve.py | 20 ++++++++++++----- .../scripts/model-role-resolve.py | 20 ++++++++++++----- .../ce-compound/scripts/model-role-resolve.py | 20 ++++++++++++----- skills/ce-debug/scripts/model-role-resolve.py | 20 ++++++++++++----- .../scripts/model-role-resolve.py | 20 ++++++++++++----- skills/ce-plan/scripts/model-role-resolve.py | 20 ++++++++++++----- skills/ce-setup/scripts/model-role-resolve.py | 20 ++++++++++++----- .../scripts/model-role-resolve.py | 20 ++++++++++++----- skills/ce-work/scripts/model-role-resolve.py | 20 ++++++++++++----- tests/skills/model-role-resolver.test.ts | 22 +++++++++++++++++-- 10 files changed, 146 insertions(+), 56 deletions(-) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index 6d3c43ef0..a57b7c408 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -55,7 +55,8 @@ step that are set: `{"key", "file", "value"}`, value null for a list or map key), `effective` (`{"from": "map|existing_key|session|invalid", "value"}`), and `shadowed` (the existing keys a map entry overrides). Its top-level -`warnings` name unknown role keys under `model_roles`. +`warnings`, and every `--role` answer's, name unknown role keys under +`model_roles`. """ from __future__ import annotations @@ -88,7 +89,8 @@ WORK_ENGINE_MODES = ("off", "prefer", "require") REVIEW_MODES = ("auto", "off") -_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*") +# An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. +_MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(\[[A-Za-z0-9]+\])?") # --- minimal YAML reader: top-level keys plus the model_roles block ---------- @@ -217,7 +219,7 @@ def parse_entry(text: str): def family(model: str) -> str: - name = model.lower() + name = model.lower().split("[", 1)[0] if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): @@ -350,14 +352,19 @@ def resolve_all(layers: list, policy: dict) -> dict: result["effective"] = _describe(result, existing) result["shadowed"] = [k["key"] for k in existing] if result["effective"]["from"] == "map" else [] roles.append(result) - warnings = [ + errors = [error for _, parsed in layers for error in parsed["errors"]] + return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": _unknown_roles(layers), "errors": errors} + + +def _unknown_roles(layers: list) -> list: + """One warning per key under `model_roles` that is not a role. Every answer + carries them, so a misspelled role is reported by the skill it was meant for.""" + return [ f"{parsed['file']}:{line}: unknown role `{key}` under model_roles is ignored (roles: {', '.join(ROLES)})" for _, parsed in layers for key, line in parsed["role_lines"].items() if key not in ROLES ] - errors = [error for _, parsed in layers for error in parsed["errors"]] - return {"roles": roles, "effort_scale": list(EFFORT_SCALE), "warnings": warnings, "errors": errors} # --- entry point ------------------------------------------------------------- @@ -405,6 +412,7 @@ def _resolve(args) -> dict: else: out = resolve_role(args.role, layers, policy) warnings, errors = out.pop("warnings"), out.pop("errors") + warnings += _unknown_roles(layers) if args.role in REVIEW_ROLES: warnings += mode_warnings out.update(effort_scale=list(EFFORT_SCALE), warnings=warnings, errors=errors) diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index a0285ed5b..e9a5947c3 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -158,6 +158,21 @@ describe("entries and layering", () => { expect(out.entries[1].reason).toContain("banana") }) + test("a model id may end in one bracketed qualifier", () => { + const dir = makeProject(map(" plan: opus[1m] high\n doc-review: [sonnet, opus[1m] max, opus[1m]]\n work: opus[]\n")) + expect(role(dir, "plan").entries[0]).toMatchObject({ model: "opus[1m]", effort: "high", family: "claude" }) + const seats = role(dir, "doc-review").entries + expect(seats.map((e: any) => [e.model, e.effort, e.family])).toEqual([ + ["sonnet", null, "claude"], + ["opus[1m]", "max", "claude"], + ["opus[1m]", null, "claude"], + ]) + // An empty qualifier is not an id. + const work = role(dir, "work") + expect(work.state).toBe("unset") + expect(work.warnings[0]).toContain("is not a model id") + }) + test("a malformed model_roles block is state invalid with an error for every role", () => { const dir = makeProject(map(" plan:\n model: opus\n effort: high\n doc-review: [opus]\n")) for (const name of ["plan", "doc-review"]) { @@ -168,10 +183,13 @@ describe("entries and layering", () => { } }) - test("an unknown role key is ignored by --role and warned about by --all", () => { + test("an unknown role key is ignored, with a warning from --role and from --all", () => { const dir = makeProject(map(" code_review: [opus]\n")) + // A misspelled role must not vanish: the skill that runs --role shows this warning. const one = role(dir, "code-review") - expect([one.state, one.warnings, one.errors]).toEqual(["unset", [], []]) + expect([one.state, one.errors]).toEqual(["unset", []]) + expect(one.warnings.length).toBe(1) + expect(one.warnings[0]).toContain("code_review") const all = run(dir, ["--all"]) expect(byRole(all, "code-review").state).toBe("unset") expect(all.warnings.length).toBe(1) From 90aa9dc2b2f0abb7532b0886558a686636bc67d5 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 20:12:10 -0700 Subject: [PATCH 32/53] test(ce-work): stop git maintenance racing the template repo copy CI failed once with ENOENT on `.git/objects/maintenance.lock` while a route test copied the seeded template repo. A detached maintenance run after the seed commit creates and removes that lock. The seed repo now disables auto maintenance, and every fixture copy inherits the setting. That one non-timeout failure also kept the test wrapper from re-running the timeouts in the same run, which were bun's lost child-exit in one worker. Co-Authored-By: Claude Code --- tests/skills/ce-work-cross-model-routes.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/skills/ce-work-cross-model-routes.test.ts b/tests/skills/ce-work-cross-model-routes.test.ts index abce46f22..0bf97affc 100644 --- a/tests/skills/ce-work-cross-model-routes.test.ts +++ b/tests/skills/ce-work-cross-model-routes.test.ts @@ -63,6 +63,10 @@ function seedCanonicalRepo(): string { writeFileSync(path.join(canonical, "README.md"), "seed\n") writeFileSync(path.join(canonical, "docs", "plans", "plan.md"), "# Test plan\n") spawnSync("git", ["init", "-q", canonical]) + // Every fixture is a recursive copy of this repo. A detached `git maintenance` + // run after the commit would create and remove lock files while it is copied. + spawnSync("git", ["-C", canonical, "config", "gc.auto", "0"]) + spawnSync("git", ["-C", canonical, "config", "maintenance.auto", "false"]) spawnSync("git", ["-C", canonical, "config", "user.email", "test@example.com"]) spawnSync("git", ["-C", canonical, "config", "user.name", "Test"]) spawnSync("git", ["-C", canonical, "add", "."]) From 17e24e7092860de10302e8b852834612b5b6b977 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 20:23:14 -0700 Subject: [PATCH 33/53] fix(review): read the peer allowlist the way the resolver does The role resolver trims and lowercases CROSS_MODEL_PEERS before deciding which seats are cleared. Both review workers compared the raw string, so a value such as " Cursor , grok" cleared a Composer seat in the resolver and was refused by the worker. The workers now normalize the allowlist once, before every check. Co-Authored-By: Claude Code --- .../ce-code-review/scripts/cross-model-adversarial-review.sh | 3 ++- skills/ce-doc-review/scripts/cross-model-doc-review.sh | 3 ++- tests/skills/ce-code-review-cross-model-routes.test.ts | 4 ++++ tests/skills/ce-doc-review-cross-model-routes.test.ts | 4 ++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index ee98eb9f4..6d825ae0f 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -417,7 +417,8 @@ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || skip "not inside a g PEER_WORKDIR="$REPO_ROOT" # --- resolve which provider(s) to run (exclude host, allowlist, availability) -- -ALLOW="${CROSS_MODEL_PEERS:-}" +# Read the allowlist the way the role resolver does: whitespace ignored, lowercase. +ALLOW="$(printf '%s' "${CROSS_MODEL_PEERS:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" MAX_PEERS="${CROSS_MODEL_MAX_PEERS:-1}" case "$MAX_PEERS" in ''|*[!0-9]*) MAX_PEERS=1 ;; esac [ "$MAX_PEERS" -gt 2 ] && MAX_PEERS=2 diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index 61a6db9b7..19b2f13d9 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -445,7 +445,8 @@ OUTPUT_CONTRACT_RULES="$(awk '//{f=1} f; /<\/output-contract>/{ [ -n "$OUTPUT_CONTRACT_RULES" ] || log "output-contract not found in $TEMPLATE; peer prompt omits the shared confidence rubric / FP catalog (calibration may differ from the twin)" # --- resolve which provider(s) to run (exclude host, allowlist, availability) -- -ALLOW="${CROSS_MODEL_PEERS:-}" # optional egress allowlist (R19) +# Read the allowlist the way the role resolver does: whitespace ignored, lowercase. +ALLOW="$(printf '%s' "${CROSS_MODEL_PEERS:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" MAX_PEERS="${CROSS_MODEL_MAX_PEERS:-1}" # default 1; clamped 0..2 (hard cap) case "$MAX_PEERS" in ''|*[!0-9]*) MAX_PEERS=1 ;; esac [ "$MAX_PEERS" -gt 2 ] && MAX_PEERS=2 diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index d07312e6c..5405068e7 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -1996,6 +1996,10 @@ describe("cross-model-adversarial-review review seats (CROSS_MODEL_SEAT)", () => runDir = makeRunDir() r = run(seatArgs("claude", "composer", runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "cursor" }) expect(r.stderr).not.toContain(refused) + // The resolver trims and lowercases the allowlist, so the worker reads it the same way. + runDir = makeRunDir() + r = run(seatArgs("claude", "composer", runDir), runDir, { ...seat, CROSS_MODEL_PEERS: " Cursor , grok" }) + expect(r.stderr).not.toContain(refused) // Another provider's seat is still refused, and so is Composer outside a seat. runDir = makeRunDir() diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index 142e1f8f5..a85c8af89 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1620,6 +1620,10 @@ describe("cross-model-doc-review review seats (CROSS_MODEL_SEAT)", () => { runDir = makeRunDir() r = run(seatArgs("claude", "composer", doc, runDir), runDir, { ...seat, CROSS_MODEL_PEERS: "cursor" }) expect(r.stderr).not.toContain(refused) + // The resolver trims and lowercases the allowlist, so the worker reads it the same way. + runDir = makeRunDir() + r = run(seatArgs("claude", "composer", doc, runDir), runDir, { ...seat, CROSS_MODEL_PEERS: " Cursor , grok" }) + expect(r.stderr).not.toContain(refused) // Another provider's seat is still refused, and so is Composer outside a seat. runDir = makeRunDir() From 64d7e0cad842c740e54cba7142a7692d1dfc4dde Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 20:30:59 -0700 Subject: [PATCH 34/53] test(evals): make the lite-review cell continue into its path The task told the agent to list Coverage statements without reviewing the diff, and Codex stopped at the depth gate without opening the lite-path reference in three of three baseline rounds. The task now says to continue into the selected path far enough to list them. Co-Authored-By: Claude Code --- tests/skill-eval-cell/catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index d5ea48f30..23ea5833d 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -3816,7 +3816,7 @@ Use "Correct widget limit" as the description in the subject and any required bo fixture: `${FIX}/model-roles-code-review-lite`, why: "The `depth-gate-loud-lite` diff with a `code-review` seat list configured. Seats run only where the reviewer team is dispatched, so the review runs no seat and no resolver, and its Coverage must say the role was not applied and that `depth:full` applies it. Lite or focused both pass: the depth judgment has its own rows. The task never mentions model roles; `depth:full` appears in the answer only when that Coverage sentence fires.", pre_contract: "The lite path never looks at the model role map, so a configured seat list is skipped with nothing said.", - task: `Use the ce-code-review skill on this repo with mode:agent. Resolve the Review depth gate. If the gate selects lite or focused, also list each statement the receipt's Coverage will make, one per line, without reviewing the diff. This is a read-only probe: do not create the run directory, do not start a peer job, and do not dispatch reviewers. + task: `Use the ce-code-review skill on this repo with mode:agent. Resolve the Review depth gate. If the gate selects lite or focused, continue into that path far enough to list each statement its receipt's Coverage must make, one per line. Do not review the diff itself. This is a read-only probe: do not create the run directory, do not start a peer job, and do not dispatch reviewers. End with exactly one line in this form and nothing else on that line: From e33d042ebffbe83a05ff561e0b7d3d4b6b642e0a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 20:41:15 -0700 Subject: [PATCH 35/53] fix(review): trim allowlist entries at their edges and match qualified aliases The allowlist normalization deleted every space, so `co dex,claude` became the valid recipient `codex`. The workers now trim only the edges of each entry, as the role resolver does, so a broken name stays unmatched. A requested alias with a bracketed qualifier, such as `opus[1m]`, had no expected receipt prefix. A matching Claude receipt was reported as a model mismatch by the review workers and left unverified by the elevation worker. Every receipt classifier now strips the qualifier before it derives the prefix, including the copy in ce-pov that shares the block. Co-Authored-By: Claude Code --- .../scripts/elevation-dispatch.sh | 5 +++-- .../scripts/cross-model-adversarial-review.sh | 11 ++++++---- .../scripts/cross-model-doc-review.sh | 11 ++++++---- skills/ce-plan/scripts/elevation-dispatch.sh | 5 +++-- skills/ce-pov/scripts/cross-model-pov.sh | 5 +++-- .../ce-code-review-cross-model-routes.test.ts | 20 ++++++++++++++++++ .../ce-doc-review-cross-model-routes.test.ts | 21 +++++++++++++++++++ tests/skills/elevation-dispatch.test.ts | 9 ++++++++ 8 files changed, 73 insertions(+), 14 deletions(-) diff --git a/skills/ce-brainstorm/scripts/elevation-dispatch.sh b/skills/ce-brainstorm/scripts/elevation-dispatch.sh index ddbbdac19..afff7c2b1 100755 --- a/skills/ce-brainstorm/scripts/elevation-dispatch.sh +++ b/skills/ce-brainstorm/scripts/elevation-dispatch.sh @@ -157,12 +157,13 @@ bounded_failure_evidence() { tail -c 800 "$PEERLOG" 2>/dev/null || true; } # Expected served-id prefix for a requested model alias, or empty if unknown. model_prefix() { # -> prefix | "" - case "$1" in + # A bracketed qualifier such as [1m] is not part of the served id. + case "${1%%\[*}" in fable) printf 'claude-fable-' ;; opus) printf 'claude-opus-' ;; sonnet) printf 'claude-sonnet-' ;; haiku) printf 'claude-haiku-' ;; - claude-*) printf '%s' "$1" ;; + claude-*) printf '%s' "${1%%\[*}" ;; esac } diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index 6d825ae0f..b34ca1fcc 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -129,12 +129,13 @@ route_receipt_supported() { # "unverified" — never a fallback to the requested value. Keep this block byte-identical across # ce-code-review and ce-doc-review (kernel parity). expected_model_prefix() { # -> expected served-id family prefix - case "$1" in + # A bracketed qualifier such as [1m] is not part of the served id. + case "${1%%\[*}" in fable) printf 'claude-fable' ;; opus) printf 'claude-opus' ;; sonnet) printf 'claude-sonnet' ;; haiku) printf 'claude-haiku' ;; - claude-*) printf '%s' "$1" ;; + claude-*) printf '%s' "${1%%\[*}" ;; esac } @@ -417,8 +418,10 @@ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || skip "not inside a g PEER_WORKDIR="$REPO_ROOT" # --- resolve which provider(s) to run (exclude host, allowlist, availability) -- -# Read the allowlist the way the role resolver does: whitespace ignored, lowercase. -ALLOW="$(printf '%s' "${CROSS_MODEL_PEERS:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" +# Read the allowlist the way the role resolver does: lowercase, with only the +# edges of each entry trimmed. A space inside a name must not repair it into a +# recipient the environment did not name. +ALLOW="$(printf '%s' "${CROSS_MODEL_PEERS:-}" | tr '\n\t\r' ' ' | tr '[:upper:]' '[:lower:]' | sed -e 's/ *, */,/g' -e 's/^ *//' -e 's/ *$//')" MAX_PEERS="${CROSS_MODEL_MAX_PEERS:-1}" case "$MAX_PEERS" in ''|*[!0-9]*) MAX_PEERS=1 ;; esac [ "$MAX_PEERS" -gt 2 ] && MAX_PEERS=2 diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index 19b2f13d9..92dd1a510 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -133,12 +133,13 @@ route_effort() { # -> requested effort: the override where the route t # "unverified" — never a fallback to the requested value. Keep this block byte-identical across # ce-code-review and ce-doc-review (kernel parity). expected_model_prefix() { # -> expected served-id family prefix - case "$1" in + # A bracketed qualifier such as [1m] is not part of the served id. + case "${1%%\[*}" in fable) printf 'claude-fable' ;; opus) printf 'claude-opus' ;; sonnet) printf 'claude-sonnet' ;; haiku) printf 'claude-haiku' ;; - claude-*) printf '%s' "$1" ;; + claude-*) printf '%s' "${1%%\[*}" ;; esac } @@ -445,8 +446,10 @@ OUTPUT_CONTRACT_RULES="$(awk '//{f=1} f; /<\/output-contract>/{ [ -n "$OUTPUT_CONTRACT_RULES" ] || log "output-contract not found in $TEMPLATE; peer prompt omits the shared confidence rubric / FP catalog (calibration may differ from the twin)" # --- resolve which provider(s) to run (exclude host, allowlist, availability) -- -# Read the allowlist the way the role resolver does: whitespace ignored, lowercase. -ALLOW="$(printf '%s' "${CROSS_MODEL_PEERS:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" +# Read the allowlist the way the role resolver does: lowercase, with only the +# edges of each entry trimmed. A space inside a name must not repair it into a +# recipient the environment did not name. +ALLOW="$(printf '%s' "${CROSS_MODEL_PEERS:-}" | tr '\n\t\r' ' ' | tr '[:upper:]' '[:lower:]' | sed -e 's/ *, */,/g' -e 's/^ *//' -e 's/ *$//')" MAX_PEERS="${CROSS_MODEL_MAX_PEERS:-1}" # default 1; clamped 0..2 (hard cap) case "$MAX_PEERS" in ''|*[!0-9]*) MAX_PEERS=1 ;; esac [ "$MAX_PEERS" -gt 2 ] && MAX_PEERS=2 diff --git a/skills/ce-plan/scripts/elevation-dispatch.sh b/skills/ce-plan/scripts/elevation-dispatch.sh index ddbbdac19..afff7c2b1 100755 --- a/skills/ce-plan/scripts/elevation-dispatch.sh +++ b/skills/ce-plan/scripts/elevation-dispatch.sh @@ -157,12 +157,13 @@ bounded_failure_evidence() { tail -c 800 "$PEERLOG" 2>/dev/null || true; } # Expected served-id prefix for a requested model alias, or empty if unknown. model_prefix() { # -> prefix | "" - case "$1" in + # A bracketed qualifier such as [1m] is not part of the served id. + case "${1%%\[*}" in fable) printf 'claude-fable-' ;; opus) printf 'claude-opus-' ;; sonnet) printf 'claude-sonnet-' ;; haiku) printf 'claude-haiku-' ;; - claude-*) printf '%s' "$1" ;; + claude-*) printf '%s' "${1%%\[*}" ;; esac } diff --git a/skills/ce-pov/scripts/cross-model-pov.sh b/skills/ce-pov/scripts/cross-model-pov.sh index 2a15e47f6..e9e46d627 100755 --- a/skills/ce-pov/scripts/cross-model-pov.sh +++ b/skills/ce-pov/scripts/cross-model-pov.sh @@ -99,12 +99,13 @@ M_COMPOSER="composer-2.5-fast" # cursor-agent composer (no high tier; -fast is t # "unverified" — never a fallback to the requested value. Keep this block byte-identical across # ce-code-review and ce-doc-review (kernel parity). expected_model_prefix() { # -> expected served-id family prefix - case "$1" in + # A bracketed qualifier such as [1m] is not part of the served id. + case "${1%%\[*}" in fable) printf 'claude-fable' ;; opus) printf 'claude-opus' ;; sonnet) printf 'claude-sonnet' ;; haiku) printf 'claude-haiku' ;; - claude-*) printf '%s' "$1" ;; + claude-*) printf '%s' "${1%%\[*}" ;; esac } diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index 5405068e7..78ccb2ff5 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -2009,6 +2009,26 @@ describe("cross-model-adversarial-review review seats (CROSS_MODEL_SEAT)", () => runDir = makeRunDir() r = run(seatArgs("claude", "composer", runDir), runDir, { ...env, CROSS_MODEL_PEERS: "cursor" }) expect(r.stderr).toContain(`provider 'composer' ${refused}`) + + // Only the edges of each entry are trimmed: a space inside a name never repairs it into a recipient. + runDir = makeRunDir() + r = run(seatArgs("claude", "codex", runDir), runDir, { ...env, CROSS_MODEL_PEERS: "co dex,claude" }) + expect(r.stderr).toContain(`provider 'codex' ${refused}`) + }) + + test("a Claude alias with a bracketed qualifier is checked against its family's receipt", () => { + const receiptStub = + `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"adversarial","findings":[{"title":"t","file":"a.ts","line":1}]},"modelUsage":{"claude-opus-5-5-20260801":{"inputTokens":10}}}'\n` + const { env } = sandbox(["claude"], receiptStub) + const runDir = makeRunDir() + const r = run(seatArgs("codex", "claude", runDir), runDir, { + ...env, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", + CROSS_MODEL_MODEL_OVERRIDE: "opus[1m]", + }) + expect(r.stderr).not.toContain("model mismatch") + expect(artifact(runDir, "adversarial-claude-s1.json").model_actual).toBe("claude-opus-5-5-20260801") }) test("a seat's model and effort reach the claude and codex adapter arguments", () => { diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index a85c8af89..93796e18d 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1633,6 +1633,27 @@ describe("cross-model-doc-review review seats (CROSS_MODEL_SEAT)", () => { runDir = makeRunDir() r = run(seatArgs("claude", "composer", doc, runDir), runDir, { ...env, CROSS_MODEL_PEERS: "cursor" }) expect(r.stderr).toContain(`provider 'composer' ${refused}`) + + // Only the edges of each entry are trimmed: a space inside a name never repairs it into a recipient. + runDir = makeRunDir() + r = run(seatArgs("claude", "codex", doc, runDir), runDir, { ...env, CROSS_MODEL_PEERS: "co dex,claude" }) + expect(r.stderr).toContain(`provider 'codex' ${refused}`) + }) + + test("a Claude alias with a bracketed qualifier is checked against its family's receipt", () => { + const receiptStub = + `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"whole-doc","findings":[{"section":"X","title":"t"}]},"modelUsage":{"claude-opus-5-5-20260801":{"inputTokens":10}}}'\n` + const { env } = sandbox(["claude"], receiptStub) + const doc = makeDoc() + const runDir = makeRunDir() + const r = run(seatArgs("codex", "claude", doc, runDir), runDir, { + ...env, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", + CROSS_MODEL_MODEL_OVERRIDE: "opus[1m]", + }) + expect(r.stderr).not.toContain("model mismatch") + expect(artifact(runDir, "whole-doc-claude-s1.json").model_actual).toBe("claude-opus-5-5-20260801") }) test("a seat's model and effort reach the claude and codex adapter arguments", () => { diff --git a/tests/skills/elevation-dispatch.test.ts b/tests/skills/elevation-dispatch.test.ts index 7fd1215b5..4e1530e72 100644 --- a/tests/skills/elevation-dispatch.test.ts +++ b/tests/skills/elevation-dispatch.test.ts @@ -313,6 +313,15 @@ describe("elevation-dispatch worker", () => { expect(result.receipt).toBe("mismatch") }) + test("an alias with a bracketed qualifier matches its family's receipt", () => { + const stub = + "#!/bin/sh\n" + + `printf '%s\\n' '${RESULT_LINE("PLAN BODY", { "claude-opus-4-8": { outputTokens: 5 } })}'\n` + const { result } = runWorker("opus[1m]", stub) + expect(result.served_model).toBe("claude-opus-4-8") + expect(result.receipt).toBe("matched") + }) + test("picks the requested family's key from a multi-key modelUsage, not keys[0]", () => { // jq `keys` is sorted, so keys[0] here is claude-haiku-*, an auxiliary // model — the served model for a requested opus is claude-opus-*. From 5647dc71777323f3fe0ea0950dcebfb62327a0d5 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 21:41:42 -0700 Subject: [PATCH 36/53] fix(code-review): state the unapplied role inside the lite Coverage sentence The note that a lite or focused review does not apply the `code-review` role sat in its own paragraph at the top of the reference. Codex listed the lite path's Coverage statements from the Coverage sentence and left the note out in four of five runs. The condition now sits in that sentence, and the separate paragraph is gone. After the move Codex stated it in three of five runs and Claude in one of one. The eval cell now passes `base:HEAD`, because Codex stopped at scope on the fixture's one-commit repo. Co-Authored-By: Claude Code --- skills/ce-code-review/references/depth-paths.md | 4 +--- tests/skill-eval-cell/catalog.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/skills/ce-code-review/references/depth-paths.md b/skills/ce-code-review/references/depth-paths.md index 32a98dfed..8cef8f1cc 100644 --- a/skills/ce-code-review/references/depth-paths.md +++ b/skills/ce-code-review/references/depth-paths.md @@ -2,8 +2,6 @@ Read this only when the Review depth gate in `references/modes-and-output.md` selected lite or focused; a full run never opens it. Both paths finish in the dispatch context and write the same receipt shape that reference defines. -**Model role.** The `code-review` model role is applied only on the full spine, where the reviewer team is dispatched. Check `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root for an active `model_roles:` key with a `code-review` entry. Open both by path, because a file search skips that hidden directory. When either has one, run no review seat on this path and do not run the resolver. Coverage then says that the `code-review` model role was not applied because this review took the lite or focused path, and that `depth:full` applies it. The focused path's independent read below is unchanged. With no such entry, say nothing about model roles. - ### Lite path Lite is the same review with the same receipt, done in this context. Do not dispatch reviewers or finish leaves. Read no reference beyond the two named below. The skill's done condition applies unchanged: every retained finding is supported by the source, and the receipt states its coverage limits. @@ -15,7 +13,7 @@ Produce, in this context: - Requirements verification: read the Plan Requirements Completeness and Stage 2b sections of `references/intent-and-plan.md`, discover a plan by that contract (the `plan:` argument, the PR body, or the branch), and when one is found fill `requirements_completeness`. An unaddressed requirement or unit is a finding routed by `plan_source` under that section's rule, so an explicit-plan omission reaches `actionable_findings`; it also makes the verdict Not ready unless the omission is intentional. - Test sufficiency: when the change alters runtime behavior without corresponding test work, record the gap in `testing_gaps`. Risks you can see but cannot settle go in `residual_risks`. -Coverage states that the lite path ran and no reviewer agents were dispatched; names the criteria files checked, or that none govern the change, and the instruction-file fallback when it supplied criteria; states that declared Compound Packs were not applied; and names what lite did not assess (learnings, agent-native gaps, deployment notes). +Coverage states that the lite path ran and no reviewer agents were dispatched; names the criteria files checked, or that none govern the change, and the instruction-file fallback when it supplied criteria; states that declared Compound Packs were not applied; and names what lite did not assess (learnings, agent-native gaps, deployment notes). Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When either has an active `model_roles:` key with a `code-review` entry, Coverage also states that the `code-review` model role was not applied because this review took the lite or focused path, and that `depth:full` applies it. These paths run no review seat and no resolver. Write the receipt and `metadata.json` (## Run artifacts in `references/modes-and-output.md`) into the run directory Stage 1b created, then record `--end review --start receipt --candidates ` and, once `metadata.json` is on disk, `--end receipt` followed by `summarize` as that section states, and emit the receipt as the response. In `mode:agent`, the receipt is the JSON object the JSON output format in `references/modes-and-output.md` defines, written to `review.json`. In default mode, it is every retained finding (stable `#`, severity, `file:line`, route), then Actionable Findings, Coverage, and Verdict, written to `report.md`; a retained finding that is not actionable still appears in the findings list. diff --git a/tests/skill-eval-cell/catalog.ts b/tests/skill-eval-cell/catalog.ts index 23ea5833d..772f8ef74 100644 --- a/tests/skill-eval-cell/catalog.ts +++ b/tests/skill-eval-cell/catalog.ts @@ -3816,7 +3816,7 @@ Use "Correct widget limit" as the description in the subject and any required bo fixture: `${FIX}/model-roles-code-review-lite`, why: "The `depth-gate-loud-lite` diff with a `code-review` seat list configured. Seats run only where the reviewer team is dispatched, so the review runs no seat and no resolver, and its Coverage must say the role was not applied and that `depth:full` applies it. Lite or focused both pass: the depth judgment has its own rows. The task never mentions model roles; `depth:full` appears in the answer only when that Coverage sentence fires.", pre_contract: "The lite path never looks at the model role map, so a configured seat list is skipped with nothing said.", - task: `Use the ce-code-review skill on this repo with mode:agent. Resolve the Review depth gate. If the gate selects lite or focused, continue into that path far enough to list each statement its receipt's Coverage must make, one per line. Do not review the diff itself. This is a read-only probe: do not create the run directory, do not start a peer job, and do not dispatch reviewers. + task: `Use the ce-code-review skill on the staged change in this repo with mode:agent base:HEAD. Resolve the Review depth gate. If the gate selects lite or focused, continue into that path far enough to list each statement its receipt's Coverage must make, one per line. Do not review the diff itself. This is a read-only probe: do not create the run directory, do not start a peer job, and do not dispatch reviewers. End with exactly one line in this form and nothing else on that line: From 652dd8312b419b998f3823f9926d7b4a2e4f27c7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 1 Oct 2026 23:50:32 -0700 Subject: [PATCH 37/53] refactor(model-roles): cut per-skill restatement of the shared reference The plan, brainstorm, work, debug, simplify, compound, and review references each restated rules that references/model-roles.md owns: precedence, when a hand-off happens, the serving order, the fallback ladder, and the Model role line. Those copies are removed, and each skill keeps what is specific to it. The debug hand-off now makes its run directory with mktemp -d instead of the shared scratch-root block. Content the feature adds drops from 89,754 to 80,071 bytes. On the model role eval cells, Codex passed 88 of 95 over five rounds (89 of 95 before), Claude 21 of 21, and Cursor 20 of 20. Co-Authored-By: Claude Code --- .../references/reasoning-elevation.md | 19 +++++-------- .../references/cross-model-review.md | 22 +++++++-------- skills/ce-compound/references/assembly.md | 2 +- skills/ce-compound/references/lightweight.md | 2 +- skills/ce-compound/references/report.md | 2 +- skills/ce-compound/references/research.md | 11 ++++---- skills/ce-debug/references/fix.md | 12 ++++----- skills/ce-debug/references/investigate.md | 27 +++++-------------- skills/ce-debug/references/pipeline-mode.md | 2 +- .../ce-debug/references/return-to-caller.md | 2 +- .../references/cross-model-review.md | 22 +++++++-------- skills/ce-doc-review/references/dispatch.md | 2 +- .../ce-plan/references/reasoning-elevation.md | 19 +++++-------- .../references/model-role-handoff.md | 10 +++---- .../references/cross-model-execution.md | 2 +- .../ce-work/references/execution-engines.md | 26 +++++++----------- 16 files changed, 72 insertions(+), 110 deletions(-) diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index 6798dcc36..3fb484c33 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -16,13 +16,13 @@ Resolve the per-skill **model choice immediately before adapter selection**, so **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. - The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. + The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. **Per-skill key.** The per-skill key decides when no `model_roles:` key is active, and when `references/model-roles.md` says the step continues with the skill's existing keys. The key is `plan_model` for ce-plan, `brainstorm_model` for ce-brainstorm. Read it the **same way this skill's Phase 0.0 (output-mode resolution) resolves `plan_output` / `brainstorm_output`**: reuse the repo root already resolved, else run `git rev-parse --show-toplevel`, then apply the ordinary-key rule (`config.local.yaml` then `config.yaml`). Reuse the Phase 0.0 reads if still in hand. Ignore commented (`#`-prefixed) lines. A model alias selects it; missing / commented / invalid / no file selects none. -**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. Inside the config tier, the role's map entry in either file outranks the per-skill key in either file. A live instruction or a caller carrier replaces the entry whole, including its effort, and the step then runs as it does with no map. +**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. -If the session model already **is** the resolved model and the choice carries no effort, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). A map entry with an effort always hands off, even when it names the session model. +If the session model already **is** the resolved model and the choice carries no effort, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). ## Adapter selection @@ -32,12 +32,7 @@ When elevation is active, resolve an adapter in this fixed order and use the fir 2. **Claude CLI.** Run the bundled `scripts/elevation-dispatch.sh` worker as a detached job (see Off-host dispatch). Available when `claude` is on PATH. Do not preflight authentication in the host command context: the detached worker's provider-capable call is authoritative, and an authentication failure there follows Recovery. 3. **Inline on the session model.** The always-available fallback. -**Map entries.** A choice from a `model_roles` entry uses these same adapters in the serving order of `references/model-roles.md`, which also owns the fallback ladder. That file needs these facts about this file's routes: - -- An entry with no effort keeps the fixed order above. -- An entry with an effort uses the first adapter that can carry the effort. The native adapter can only where the host's subagent tool sets effort. Elsewhere it is the ladder's route with no effort control. The Claude CLI adapter can for a Claude-family model, which means the resolver reports the entry's `family` as `claude`. It accepts every effort on the resolver's scale. -- An entry in another model family has no CLI adapter in this file, so only the native adapter can serve it. -- When no adapter can serve the entry as written, or the adapter that started fails, the ladder decides the next step. +**Map entries.** A choice from a `model_roles` entry uses these same adapters in the serving order of `references/model-roles.md`, which also owns the fallback ladder. The Claude CLI adapter serves an entry whose `family` the resolver reports as `claude`, and it accepts every effort on the resolver's scale. Elevation is never a correctness dependency: every adapter failure degrades to the next, and inline always completes the run. @@ -102,7 +97,7 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c `CE_PEER_HARD_SECS` (the outer runner cap) and `CE_ELEVATION_HARD_SECS` (the worker's own inner cap) are set to the **same** raised backstop well above any legitimate run (R11) — keep them equal so the inner cap never reaps a healthy run before the outer one. `CE_PEER_LOG_MAX_BYTES` is raised for the streaming route so a healthy high-volume run is not reaped as a failure (R22). `start` returns a job id in under ~2s. -`CE_ELEVATION_EFFORT` sets the reasoning effort of the elevated call. `` is the map entry's effort. Use `high`, the worker's default, when the choice carries none. The worker accepts `low`, `medium`, `high`, `xhigh`, and `max`, and it rejects any other value before it launches the model. +`CE_ELEVATION_EFFORT` sets the reasoning effort of the elevated call. `` is the map entry's effort. Use `high`, the worker's default, when the choice carries none. 3. **Poll** between your other work until the job reaches a terminal state (resolve `$PY` again — each tool call is a fresh shell): @@ -131,11 +126,11 @@ Classify from **both** the runner's terminal state and the worker's JSON result. A successful run has JSON `status: ok`. Treat any result whose `receipt` is `mismatch` as if it were a failure even when `status` is `ok`: **discard the output and degrade to the session model** — a served model that does not match the requested family must never be passed off as the requested one. (On the native route a mismatch instead falls through to the next adapter, per R6; on the CLI route inline is the only thing left, so discard-and-degrade is the fall-through.) -Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. A choice from a map entry instead follows the fallback ladder in `references/model-roles.md` wherever this file degrades to the session model, and its `Model role` line names the model that served. +Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. A choice from a map entry instead follows the fallback ladder in `references/model-roles.md` wherever this file degrades to the session model. ## Transparency -- **Choice from a map entry** → print the `Model role` line from `references/model-roles.md` in place of the lines below, whenever that file calls for one. It adds the applied effort and the reason for any fallback. On the Claude CLI route the applied effort is the result's `requested_effort`. The bullets below govern every other run: a choice from an instruction, a carrier, or a per-skill key, and a run with no choice at all. +- **Choice from a map entry** → print the `Model role` line from `references/model-roles.md` in place of the lines below, whenever that file calls for one. On the Claude CLI route the applied effort is the result's `requested_effort`. - **Elevation ran** → print one line naming the **model**, the **route**, and **why** it ran (config key, explicit user instruction, or caller carrier). Name the model as **served** when a receipt confirms it; otherwise name it as **requested** with an explicit *unverified* marker — on every route, including native. - **Print no line** when elevation did not run, and when the session model already is the model a **config key** requested. An **explicit user instruction** always produces a line, including when the session model already matches (so a recognized request is never indistinguishable from an unparsed one). - **Requested but unavailable before provider-capable dispatch** (no native support, `claude` absent, or the required launch permission unavailable) → run the step inline on the session model, name **which routing precondition was unmet**, and state what would make the requested model reachable. Once provider-capable dispatch is established, an authentication failure is instead a route-level Recovery outcome: name the observed authentication failure and the login or credential-refresh remediation. diff --git a/skills/ce-code-review/references/cross-model-review.md b/skills/ce-code-review/references/cross-model-review.md index 9bd93ba31..aac41fbba 100644 --- a/skills/ce-code-review/references/cross-model-review.md +++ b/skills/ce-code-review/references/cross-model-review.md @@ -9,9 +9,9 @@ Before any reviewed content leaves the machine, the host picks one concrete rout ## Review seats (model role map) -A review role's `model_roles` entry is a list of *seats*. Each seat is one more reviewer, on the model and at the effort the entry names. The hook at persona dispatch resolves this skill's review role with `references/model-roles.md`, which owns the resolver command, the serving order, and the `Model role` line. This section owns what a review role does with the resolver's answer. Outside this section, this file describes the *single-peer pass*: one target on one route, started only under that pass's own conditions. +A review role's `model_roles` entry is a list of *seats*. Each seat is one more reviewer, on the model and at the effort the entry names. The rest of this file describes the *single-peer pass*. -**When this section applies.** It applies only when that hook ran the resolver for this review. Otherwise skip it, because no `model_roles:` key is active or this review dispatches no persona team. The rest of this file then applies as written. +**When this section applies.** It applies only when the model role hook at persona dispatch ran the resolver for this review, and it states what the review does with the resolver's answer. Otherwise skip it, and the rest of this file applies as written. **The persona review always runs.** It runs when every seat is dropped, blocked, or invalid, and when the map cannot be read. @@ -28,7 +28,7 @@ Otherwise the resolver's answer decides what runs beside the persona review: The single-peer rules do not bind a seat unless this section points at them. That covers the one target and one route, the activation and run conditions, the default target order, and any rule that a started peer replaces an in-process reviewer. -**Which seats run.** Take the resolver's `entries` in order. +**Which seats run.** A seat does not depend on which personas were selected. Take the resolver's `entries` in order. - A seat marked `invalid` does not run. The other seats are unaffected. - A seat with `blocked_by` set does not run. The resolver has already applied `cross_model_review_mode` and `CROSS_MODEL_PEERS` to every seat, however the seat would be served, so do not evaluate either policy again here. @@ -37,26 +37,24 @@ The single-peer rules do not bind a seat unless this section points at them. Tha **Serving a seat.** Use the first of these that can serve the seat's model at the seat's effort: 1. **A native subagent**, when the host can hand a subagent that model at that effort, as `references/model-roles.md` (Serving order) defines it. A seat whose model is `inherit` is always served this way, on the session model. Instruct the subagent to change nothing and to review only the material this review covers. Save its return as `/-native-s.json` in the shape the worker writes: `reviewer` set to `-native-s`, `independence_verified: false`, `model_requested` as the entry names it, `model_actual: "unverified"`, and each `safe_auto` finding recorded as `gated_auto`. -2. **This skill's worker**, as one detached job, when the seat's `family` is `codex`, `claude`, `grok`, or `composer` and that target's own route is installed. The target is the family. The route is that target's token from the Step 1 table, and `grok` uses `grok-cli` only. Start the job with Step 4's start command, changing only these parameters: `--label "seat-"`; `CROSS_MODEL_SEAT=""` added to the `env` prefix; the seat's model as `CROSS_MODEL_MODEL_OVERRIDE_TARGET=""` and `CROSS_MODEL_MODEL_OVERRIDE=""`; and `CROSS_MODEL_EFFORT_OVERRIDE=""` when the seat has an effort. With the seat number set, the worker accepts a target in the host's own family and an unknown host family, and it writes `/--s.json` with reviewer `--s`. The worker still checks `CROSS_MODEL_PEERS` itself. +2. **This skill's worker**, as one detached job, when the seat's `family` is `codex`, `claude`, `grok`, or `composer` and that target's own route is installed. The target is the family. The route is that target's token from the Step 1 table, and `grok` uses `grok-cli` only. Start the job with Step 4's start command, changing only these parameters: `--label "seat-"`; `CROSS_MODEL_SEAT=""` added to the `env` prefix; the seat's model as `CROSS_MODEL_MODEL_OVERRIDE_TARGET=""` and `CROSS_MODEL_MODEL_OVERRIDE=""`; and `CROSS_MODEL_EFFORT_OVERRIDE=""` when the seat has an effort. With the seat number set, the worker accepts a target in the host's own family and an unknown host family, and it writes `/--s.json` with reviewer `--s`. -A seat that neither route can serve is dropped. So is a seat whose job ends with no artifact, for any reason: a model or effort the route refuses and a target the worker's allowlist check refuses both end that way. A dropped seat is never run on another model, at another effort, or through an intermediary route such as Grok through `cursor-agent`. The fallback ladder in `references/model-roles.md`, and this file's retry, recovery, and replacement-recipient rules, belong to other paths and are not used for a seat. +A seat that neither route can serve is dropped. So is a seat whose job ends with no artifact, whatever the reason. A dropped seat is never run on another model, at another effort, or through an intermediary route such as Grok through `cursor-agent`. This file's retry, recovery, and replacement-recipient rules are not used for a seat. -**One notice names every recipient.** Before anything is sent, print one notice that lists each seat that will run: its seat number, the provider that receives the reviewed content, the requested model and effort, and the route. It replaces a per-seat announcement. Step 3 still decides which modes print it, and Step 3's disclosure condition holds for every seat: name the model as requested, never as served. +**One notice names every recipient.** Before anything is sent, print one notice that lists each seat that will run: its seat number, the provider that receives the reviewed content, the requested model and effort, and the route. It replaces a per-seat announcement. Step 3 still decides which modes print it, and Step 3's disclosure condition holds for every seat. -**Starting and collecting.** Start the seats in the same dispatch wave as the persona reviewers. Bring every started seat job to a terminal outcome under this file's wait, deadline, and cleanup rules, measuring the shared deadline from the last `start`, and read each worker seat's artifact with Step 5's verified read at that seat's own path. Before a job directory is deleted, read a dropped seat's `out.log` for the reason. +**Starting and collecting.** Start the seats in the same dispatch wave as the persona reviewers. Bring every started seat job to a terminal outcome under this file's wait, deadline, and cleanup rules, measuring the shared deadline from the last `start`, and read each worker seat's artifact with Step 5's verified read at that seat's own path. Before a job directory is deleted, read a dropped seat's `out.log` for the reason. A re-invocation that reuses completed review results reuses the seat results with them. **Folding seats in.** Each seat artifact is one reviewer in synthesis under its own reviewer identity. This skill's synthesis rules are unchanged: - A finding raised only by seats is never applied silently. - Agreement between seats does not stack. However many seats agree, a finding gains at most the one promotion step this skill's synthesis allows. -- Only an artifact that records `independence_verified: true` can promote. A seat in the host's own family, a seat under an unknown host family, and a natively served seat never record it. +- Only an artifact that records `independence_verified: true` can promote. -**When seats run.** Seats run whenever the review dispatches its persona team. A seat does not depend on which personas were selected. A re-invocation that reuses completed review results reuses the seat results with them. - -**Reporting.** Print one `Model role seat :` line for every seat in `entries`, in the format `references/model-roles.md` defines. That covers seats that ran, dropped seats, blocked seats, and invalid seats. A seat that did not run prints `not run` with its reason: why it was dropped, the policy `blocked_by` names, or the invalid seat's `reason`. The lines go in Coverage and in the structured result this skill returns to a caller. For this review they replace the Coverage notes the single-peer pass would have written. +**Reporting.** Print one `Model role seat :` line for every seat in `entries`, whether or not it ran, in the format `references/model-roles.md` defines. A seat that did not run prints `not run` with its reason: why it was dropped, the policy `blocked_by` names, or the invalid seat's `reason`. The lines go in Coverage and in the structured result this skill returns to a caller. For this review they replace the Coverage notes the single-peer pass would have written. -**Seats in this skill.** The role is `code-review`. The seat brief is the adversarial peer brief, so `` is `adversarial`. Seats run beside the in-process `adversarial-reviewer`: no seat removes it from the roster, and a seat does not need it to be selected. Write Step 4's two input files once, before the first worker seat starts. A native seat is a generic subagent seeded with `references/personas/adversarial-reviewer.md`, dispatched in the Stage 4 batch with the other reviewers. In `pr-remote` / `branch-remote` scope, a seat that would be served through the worker is dropped, because the worker reviews the local tree, which is not the reviewed head. Natively served seats still run there, since they inspect the reviewed refs as the other reviewers do. After the local reviewers return, collect the worker seats with the single-reap finish, passing every seat job id to the one `status` read and to each `wait` slice. Record the seat artifacts, the `Model role` lines, and any reason the map could not be used under `model_role` in `finish-input.json` (`references/finish-input.md`). +**Seats in this skill.** The role is `code-review`. The seat brief is the adversarial peer brief, so `` is `adversarial`. Write Step 4's two input files once, before the first worker seat starts. A native seat is a generic subagent seeded with `references/personas/adversarial-reviewer.md`. In `pr-remote` / `branch-remote` scope, a seat that would be served through the worker is dropped, because the worker reviews the local tree, which is not the reviewed head. Natively served seats still run there. After the local reviewers return, collect the worker seats with the single-reap finish, passing every seat job id to the one `status` read and to each `wait` slice. Record the seat artifacts, the `Model role` lines, and any reason the map could not be used under `model_role` in `finish-input.json` (`references/finish-input.md`). ## Run conditions — run only when all hold diff --git a/skills/ce-compound/references/assembly.md b/skills/ce-compound/references/assembly.md index c1227dee5..0979544f4 100644 --- a/skills/ce-compound/references/assembly.md +++ b/skills/ce-compound/references/assembly.md @@ -8,7 +8,7 @@ The orchestrating agent (main conversation) performs these steps: -1. **Collect Phase 1 results from the run artifacts.** For each Phase 1 subagent, `Read` its artifact file under `{run_dir}/` (`related.json`, and `session-history.md` when session history ran). The artifact holds the subagent's full output. **Fall back to the subagent's inline return only when its artifact file is absent or empty** (e.g., `{run_id}` did not resolve, or the subagent failed to write). The artifact is authoritative when present. Reading it is what keeps the workflow working through the issue #956 summary-collapse, where the inline return is only an executive summary. When step 2 of `references/research.md` handed the body draft to the `compound` role, `{run_dir}/draft.md` is your body draft: read it here under the same fallback rule, and apply the steps below to it as you would to a draft of your own. The drafter wrote only that scratch file. You remain the only writer under `/`. +1. **Collect Phase 1 results from the run artifacts.** For each Phase 1 subagent, `Read` its artifact file under `{run_dir}/` (`related.json`, and `session-history.md` when session history ran). The artifact holds the subagent's full output. **Fall back to the subagent's inline return only when its artifact file is absent or empty** (e.g., `{run_id}` did not resolve, or the subagent failed to write). The artifact is authoritative when present. Reading it is what keeps the workflow working through the issue #956 summary-collapse, where the inline return is only an executive summary. When step 2 of `references/research.md` handed the body draft to the `compound` role, `{run_dir}/draft.md` is your body draft: read it here under the same fallback rule, and apply the steps below to it as you would to a draft of your own. You remain the only writer under `/`. 2. **Check the overlap assessment** from the Related Docs Finder before deciding what to write: | Overlap | Action | diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index 512c19f28..38e277006 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,7 +10,7 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver's answer decides the role; your own reading of the entry does not. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with route `session`. When that reference would otherwise have called for a hand-off, the line gives the reason that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver's answer decides the role; your own reading of the entry does not. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with `route session`. When that reference would otherwise have called for a hand-off, the line's `reason:` is that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. The orchestrator (main conversation) performs ALL of the following in one sequential pass: diff --git a/skills/ce-compound/references/report.md b/skills/ce-compound/references/report.md index f43fb3cc4..891695d5b 100644 --- a/skills/ce-compound/references/report.md +++ b/skills/ce-compound/references/report.md @@ -4,7 +4,7 @@ **User-runnable refresh rendering.** The reports below print a `ce-compound-refresh` invocation for the user to copy. Default to `/ce-compound-refresh `; use `$ce-compound-refresh ` only when the active host is Codex or explicitly documents dollar-prefixed skill invocation. Render only the invocation as inline code and output one form only. -**Model role line.** When the `compound` role resolved to `inherit` or `entries` this run, add its `Model role` line, in the format `references/model-roles.md` gives, as the last field line of whichever report below applies. In a non-interactive report that places it above the terminal line: `Documentation complete` or `Documentation skipped` stays the last line, unchanged, because callers parse it. When the role was never resolved, the reports are exactly the blocks below. +**Model role line.** When the `compound` role resolved to `inherit` or `entries` this run, add its `Model role` line, in the format `references/model-roles.md` gives, as the last field line of whichever report below applies. In a non-interactive report that places it above the terminal line: `Documentation complete` or `Documentation skipped` stays the last line, unchanged. When the role was never resolved, the reports are exactly the blocks below. ### Non-interactive mode diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index 52008fdcc..d94441ea9 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -25,7 +25,7 @@ If no relevant entries are found, proceed to Phase 1 without memory context. ### Phase 1: Research -You classify the learning and draft its body in this context, because both depend on the conversation, which only this context holds. A fresh subagent sees only what you paste into its prompt. The one exception is a `compound` model role hand-off, which step 2 describes: a subagent drafts the body from a hand-off file you write. The Related Docs Finder runs as a subagent alongside, because its corpus search reads many docs this context does not need to keep. Each Phase 1 subagent writes its full output to a per-run scratch artifact and returns only the artifact path to the orchestrator. +You classify the learning and draft its body in this context, because both depend on the conversation, which only this context holds. A fresh subagent sees only what you paste into its prompt. The one exception is the `compound` model role hand-off that step 2 describes. The Related Docs Finder runs as a subagent alongside, because its corpus search reads many docs this context does not need to keep. Each Phase 1 subagent writes its full output to a per-run scratch artifact and returns only the artifact path to the orchestrator. **Run ID and run dir (before dispatching the finder):** generate a unique run identifier and create the run directory. This scopes every Phase 1 artifact file to the same directory so the orchestrator can Read them back in Phase 2. @@ -46,11 +46,10 @@ echo "$RUN_DIR"; **CRITICAL: glob `/solutions/` fresh every run.** The current vocabulary and conventions above do not substitute for the live-tree search in step 3. -Pass `{run_id}` and the resolved absolute `{run_dir}` into every Phase 1 subagent prompt: the Related Docs Finder, the session-history synthesis subagent when it runs, and the Body Drafter when step 2 hands the draft off. Each subagent **writes its full structured output** to its own file under `{run_dir}/`, **confirms the write succeeded** (the file exists and is non-empty), and then **returns only a one-line confirmation containing the artifact path**, not the prose body inline. Artifact filenames by subagent: +Pass `{run_id}` and the resolved absolute `{run_dir}` into every Phase 1 subagent prompt: the Related Docs Finder, and the session-history synthesis subagent when it runs. Each subagent **writes its full structured output** to its own file under `{run_dir}/`, **confirms the write succeeded** (the file exists and is non-empty), and then **returns only a one-line confirmation containing the artifact path**, not the prose body inline. Artifact filenames by subagent: - **Related Docs Finder** → `{run_dir}/related.json` (links, refresh candidates, overlap assessment) - **Session History** synthesis subagent (when run) → `{run_dir}/session-history.md` (prose findings) -- **Body Drafter** (only on a `compound` role hand-off) → `{run_dir}/draft.md` (the doc body) **Return the full output inline whenever the artifact write did not succeed.** This covers both cases where the orchestrator's Phase 2 inline fallback would otherwise have nothing to read: (a) `{run_id}` is empty or did not resolve (non-Claude-Code platforms where the pre-resolution failed), so there is no path to write to; and (b) `{run_id}` resolved but the write itself failed (tool permission denied, absolute-path writes unavailable, disk error, or the post-write existence check came back empty). In either case the subagent must return its complete structured output inline instead of a path, because the path would point at a file that does not exist. Return only the bare path when, and only when, the write is confirmed on disk. The artifact pattern is a reliability improvement, not a hard requirement; the orchestrator handles a missing artifact in Phase 2 by using the inline return. @@ -112,11 +111,11 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - **When to Apply**: Conditions or situations where this applies - **Examples**: Concrete before/after or usage examples showing the practice in action - **Body hand-off.** This applies only when `references/model-roles.md` calls for a hand-off of the `compound` role. A native subagent on the entry's model is the only hand-off route. When the host's subagent primitive takes a model but no effort, an entry that has an effort is served through that reference's effort-not-applied rung. You still classify in this context, and you remain the only writer of product files. + **Body hand-off.** This applies only when `references/model-roles.md` calls for a hand-off of the `compound` role. You still classify in this context, and you remain the only writer of product files. - - **Hand-off file.** Write `{run_dir}/draft-handoff.md` with what the draft needs and only this conversation holds: the problem and its symptoms, each thing that was tried and failed and why it failed, the fix, why the fix works, how it was verified, the Phase 0.5 auto memory excerpt when there is one, and step 1's classification with its frontmatter skeleton and track. Write these in full. The drafter cannot see this conversation, so whatever the file leaves out is lost to the doc. + - **Hand-off file.** Write `{run_dir}/draft-handoff.md` with what the draft needs and only this conversation holds: the problem and its symptoms, each thing that was tried and failed and why it failed, the fix, why the fix works, how it was verified, the Phase 0.5 auto memory excerpt when there is one, and step 1's classification with its frontmatter skeleton and track. Write these in full. - **Dispatch.** Launch one **Body Drafter** subagent on the entry's model once the hand-off file is written and the Related Docs Finder and session history have finished, because it reads their artifacts. Give it file paths to read rather than a retelling of their contents: the hand-off file, and whichever of `related.json` and `session-history.md` exist. Its prompt also carries this step's drafting rules and the chosen track's output sections, verbatim. The drafter writes the body to `{run_dir}/draft.md` and nothing else, under the Phase 1 artifact contract above, and returns only that path. The draft is still a Phase 1 input, so assembly waits for it. - - **Fallback.** When the host cannot hand the entry's model to a subagent, or no usable draft comes back once the rejected-dispatch rule above has been applied, you draft the body in this context as this step describes. The `Model role` line then names the session as the route and gives the reason. + - **Fallback.** When the host cannot hand the entry's model to a subagent, or no usable draft comes back once the rejected-dispatch rule above has been applied, you draft the body in this context as this step describes. #### 3. **Related Docs Finder** (subagent) - Searches `/solutions/` for related documentation diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 456fac710..6c358e6dc 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -6,15 +6,15 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the first edit. The resolver's answer decides the role; your own reading of the entry does not. -When Phase 1 already resolved the role, use that result. `references/investigate.md` states the role's deliverable, its route, and where the `Model role` line goes. This file adds only the fix hand-off. +When Phase 1 already resolved the role, use that result. -**Fix hand-off.** When the role hands off and the host can give the entry's model to a subagent, one subagent on that model does steps 1 to 3 below: it writes the failing test, sees it fail for the right reason, and implements the minimal fix. The branch check, the confirmation for files that carry the user's unstaged work, the pre-fix scope record, steps 4 and 5, the count of failed attempts, and the commit stay with the session. When the host cannot hand over the model, the session does all five steps as this file describes, and the `Model role` line says so. +**Fix hand-off.** When the role hands off and the host can give the entry's model to a subagent, one subagent on that model does steps 1 to 3 below. The branch check, the confirmation for files that carry the user's unstaged work, the pre-fix scope record, steps 4 and 5, the count of failed attempts, and the commit stay with the session. When the host cannot hand over the model, the session does all five steps as this file describes. -- **Before the dispatch**, SKILL.md's Phase 3 preconditions, as this run's mode applies them, are complete for every file the diagnosis names. The session then adds the Phase 2 findings to `handoff.md`: the confirmed causal chain, the proposed fix, the files the fix may change, the test recommendation, and Phase 1's reproduction check. A run that has no run directory yet creates it first with the block in `references/investigate.md`. +- **Before the dispatch**, SKILL.md's Phase 3 preconditions, as this run's mode applies them, are complete for every file the diagnosis names. The session then adds the Phase 2 findings to `handoff.md`: the confirmed causal chain, the proposed fix, the files the fix may change, the test recommendation, and Phase 1's reproduction check. A run that has no run directory yet creates it first, the way `references/investigate.md` does. - **The subagent** receives the path of `handoff.md` and the text of steps 1 to 3. It edits only the files `handoff.md` names. When the fix needs another file, it stops and returns that file's name, and the session takes the file through the same preconditions before it dispatches again. The subagent does not commit, change branches, or dispatch anything. It returns the paths it changed and the failure it observed before the fix. -- **After the return**, the session reads the diff, runs steps 4 and 5 itself, and adds the changed paths to the fix-owned files. The commit happens where this run's mode already puts it. -- **A failed fix** follows the rule below unchanged: the session returns to Phase 2, invalidates the hypothesis, and counts the attempt. The retry dispatches again from `handoff.md` once the session has added that Phase 2 reasoning to it. -- **A hand-off that fails mid-edit** is finished inline. The session completes steps 1 to 3 from the working tree as it stands, runs steps 4 and 5, and names the fallback in the `reason:` of the `Model role` line. +- **After the return**, the session adds the changed paths to the fix-owned files. +- **A retry after a failed fix** dispatches again from `handoff.md` once the session has added the new Phase 2 reasoning to it. +- **A hand-off that fails mid-edit** is finished inline. The session completes steps 1 to 3 from the working tree as it stands, then runs steps 4 and 5. **Test-first:** diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index e5e347bf5..6f31b6dfb 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -27,28 +27,15 @@ Read the **full thread**, not just the opening post. Read every comment, with pa **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver's answer decides the role; your own reading of the entry does not. -The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route, so a native subagent is its only hand-off route, and the fallback ladder in `references/model-roles.md` comes straight after it. The entry is standing config. `mode:pipeline` and `mode:return-to-caller` honor it the way interactive use does, and no mode asks about it. - -**Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes, and the `Model role` line says so. - -For a hand-off, create the run directory, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. - -```bash -SCRATCH_ROOT="/tmp/compound-engineering-$(id -u)"; -[ ! -L "$SCRATCH_ROOT" ] && (umask 077; mkdir -p "$SCRATCH_ROOT") 2>/dev/null && [ ! -L "$SCRATCH_ROOT" ] && [ -O "$SCRATCH_ROOT" ] && [ -w "$SCRATCH_ROOT" ] || SCRATCH_ROOT="${TMPDIR:-/tmp}/compound-engineering-$(id -u)"; -if [ -L "$SCRATCH_ROOT" ]; then echo "unsafe scratch root symlink: $SCRATCH_ROOT" >&2; exit 1; fi; -(umask 077; mkdir -p "$SCRATCH_ROOT") || exit 1; -if [ -L "$SCRATCH_ROOT" ] || [ ! -O "$SCRATCH_ROOT" ]; then echo "scratch root is not owned by the current user: $SCRATCH_ROOT" >&2; exit 1; fi; -chmod 700 "$SCRATCH_ROOT" || exit 1; -RUN_ID=$(date +%Y%m%d-%H%M%S)-$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' '); -RUN_DIR="$SCRATCH_ROOT/ce-debug/$RUN_ID"; -(umask 077; mkdir -p "$RUN_DIR") || exit 1; chmod 700 "$RUN_DIR" || exit 1; -echo "$RUN_DIR"; -``` +The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route. `mode:pipeline` and `mode:return-to-caller` honor the entry the way interactive use does, and no mode asks about it. + +**Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes. + +For a hand-off, create a private run directory with `mktemp -d "${TMPDIR:-/tmp}/ce-debug-XXXXXX"`, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. -The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, and the text of SKILL.md's **Secrets in evidence** rule. The subagent follows Phases 1 and 2 of this file through hypothesis formation. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` in the evidence-return format the parallel investigators in Phase 2 use, and it returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. +The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, and the text of SKILL.md's **Secrets in evidence** rule. The subagent follows Phases 1 and 2 of this file through hypothesis formation. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` and returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. -The session reads `investigation.md` as a proposal. It runs the probes the subagent left, closes any gap, and decides whether the chain passes SKILL.md's causal-chain gate. The findings block and the fix-choice question come from the session, as they do with no map. The parallel investigation option in Phase 2 is unchanged. +The session reads `investigation.md` as a proposal. It runs the probes the subagent left, closes any gap, and decides whether the chain passes SKILL.md's causal-chain gate. **Reporting.** One `Model role debug:` line covers the run, in the format `references/model-roles.md` defines. In interactive use it is the last line of Phase 4's Debug Summary. In `mode:pipeline` and `mode:return-to-caller` it is the optional `model_role` field of the structured return. When the investigation and the fix were served differently, the line reports the fix, and its `reason:` says how the investigation was served. diff --git a/skills/ce-debug/references/pipeline-mode.md b/skills/ce-debug/references/pipeline-mode.md index 4168d6164..251c9ff53 100644 --- a/skills/ce-debug/references/pipeline-mode.md +++ b/skills/ce-debug/references/pipeline-mode.md @@ -42,7 +42,7 @@ Return each decision in the shared typed residual contract. Its `sources` enumer ## Structured return -The return in pipeline mode is machine-readable (the caller parses it). `model_role` is an optional key that no caller requires: it carries the `Model role debug:` line that `references/investigate.md` defines, as a string, and it is left out when no `model_roles` entry governed this run. +The return in pipeline mode is machine-readable (the caller parses it). `model_role` is an optional key that no caller requires: it carries the `Model role debug:` line as a string. ```json { diff --git a/skills/ce-debug/references/return-to-caller.md b/skills/ce-debug/references/return-to-caller.md index 9b1de5989..68203f80d 100644 --- a/skills/ce-debug/references/return-to-caller.md +++ b/skills/ce-debug/references/return-to-caller.md @@ -17,7 +17,7 @@ Phases 0 through 3 run as the body defines them, with the investigation rigor un ## Structured return -The return is machine-readable; the caller parses it and branches on the exact `status` spellings, so never rename, abbreviate, or add to them. The keys are fixed in the same way. `model_role` is the one optional key: it carries the `Model role debug:` line that `references/investigate.md` defines, as a string, and it is left out when no `model_roles` entry governed this run. +The return is machine-readable; the caller parses it and branches on the exact `status` spellings, so never rename, abbreviate, or add to them. The keys are fixed in the same way. `model_role` is the one optional key: it carries the `Model role debug:` line as a string. ```json { diff --git a/skills/ce-doc-review/references/cross-model-review.md b/skills/ce-doc-review/references/cross-model-review.md index 1d9cddb62..2e0f1cf7f 100644 --- a/skills/ce-doc-review/references/cross-model-review.md +++ b/skills/ce-doc-review/references/cross-model-review.md @@ -9,9 +9,9 @@ The host resolves and approves one concrete route before any document content le ## Review seats (model role map) -A review role's `model_roles` entry is a list of *seats*. Each seat is one more reviewer, on the model and at the effort the entry names. The hook at persona dispatch resolves this skill's review role with `references/model-roles.md`, which owns the resolver command, the serving order, and the `Model role` line. This section owns what a review role does with the resolver's answer. Outside this section, this file describes the *single-peer pass*: one target on one route, started only under that pass's own conditions. +A review role's `model_roles` entry is a list of *seats*. Each seat is one more reviewer, on the model and at the effort the entry names. The rest of this file describes the *single-peer pass*. -**When this section applies.** It applies only when that hook ran the resolver for this review. Otherwise skip it, because no `model_roles:` key is active or this review dispatches no persona team. The rest of this file then applies as written. +**When this section applies.** It applies only when the model role hook at persona dispatch ran the resolver for this review, and it states what the review does with the resolver's answer. Otherwise skip it, and the rest of this file applies as written. **The persona review always runs.** It runs when every seat is dropped, blocked, or invalid, and when the map cannot be read. @@ -28,7 +28,7 @@ Otherwise the resolver's answer decides what runs beside the persona review: The single-peer rules do not bind a seat unless this section points at them. That covers the one target and one route, the activation and run conditions, the default target order, and any rule that a started peer replaces an in-process reviewer. -**Which seats run.** Take the resolver's `entries` in order. +**Which seats run.** A seat does not depend on which personas were selected. Take the resolver's `entries` in order. - A seat marked `invalid` does not run. The other seats are unaffected. - A seat with `blocked_by` set does not run. The resolver has already applied `cross_model_review_mode` and `CROSS_MODEL_PEERS` to every seat, however the seat would be served, so do not evaluate either policy again here. @@ -37,26 +37,24 @@ The single-peer rules do not bind a seat unless this section points at them. Tha **Serving a seat.** Use the first of these that can serve the seat's model at the seat's effort: 1. **A native subagent**, when the host can hand a subagent that model at that effort, as `references/model-roles.md` (Serving order) defines it. A seat whose model is `inherit` is always served this way, on the session model. Instruct the subagent to change nothing and to review only the material this review covers. Save its return as `/-native-s.json` in the shape the worker writes: `reviewer` set to `-native-s`, `independence_verified: false`, `model_requested` as the entry names it, `model_actual: "unverified"`, and each `safe_auto` finding recorded as `gated_auto`. -2. **This skill's worker**, as one detached job, when the seat's `family` is `codex`, `claude`, `grok`, or `composer` and that target's own route is installed. The target is the family. The route is that target's token from the Step 1 table, and `grok` uses `grok-cli` only. Start the job with Step 4's start command, changing only these parameters: `--label "seat-"`; `CROSS_MODEL_SEAT=""` added to the `env` prefix; the seat's model as `CROSS_MODEL_MODEL_OVERRIDE_TARGET=""` and `CROSS_MODEL_MODEL_OVERRIDE=""`; and `CROSS_MODEL_EFFORT_OVERRIDE=""` when the seat has an effort. With the seat number set, the worker accepts a target in the host's own family and an unknown host family, and it writes `/--s.json` with reviewer `--s`. The worker still checks `CROSS_MODEL_PEERS` itself. +2. **This skill's worker**, as one detached job, when the seat's `family` is `codex`, `claude`, `grok`, or `composer` and that target's own route is installed. The target is the family. The route is that target's token from the Step 1 table, and `grok` uses `grok-cli` only. Start the job with Step 4's start command, changing only these parameters: `--label "seat-"`; `CROSS_MODEL_SEAT=""` added to the `env` prefix; the seat's model as `CROSS_MODEL_MODEL_OVERRIDE_TARGET=""` and `CROSS_MODEL_MODEL_OVERRIDE=""`; and `CROSS_MODEL_EFFORT_OVERRIDE=""` when the seat has an effort. With the seat number set, the worker accepts a target in the host's own family and an unknown host family, and it writes `/--s.json` with reviewer `--s`. -A seat that neither route can serve is dropped. So is a seat whose job ends with no artifact, for any reason: a model or effort the route refuses and a target the worker's allowlist check refuses both end that way. A dropped seat is never run on another model, at another effort, or through an intermediary route such as Grok through `cursor-agent`. The fallback ladder in `references/model-roles.md`, and this file's retry, recovery, and replacement-recipient rules, belong to other paths and are not used for a seat. +A seat that neither route can serve is dropped. So is a seat whose job ends with no artifact, whatever the reason. A dropped seat is never run on another model, at another effort, or through an intermediary route such as Grok through `cursor-agent`. This file's retry, recovery, and replacement-recipient rules are not used for a seat. -**One notice names every recipient.** Before anything is sent, print one notice that lists each seat that will run: its seat number, the provider that receives the reviewed content, the requested model and effort, and the route. It replaces a per-seat announcement. Step 3 still decides which modes print it, and Step 3's disclosure condition holds for every seat: name the model as requested, never as served. +**One notice names every recipient.** Before anything is sent, print one notice that lists each seat that will run: its seat number, the provider that receives the reviewed content, the requested model and effort, and the route. It replaces a per-seat announcement. Step 3 still decides which modes print it, and Step 3's disclosure condition holds for every seat. -**Starting and collecting.** Start the seats in the same dispatch wave as the persona reviewers. Bring every started seat job to a terminal outcome under this file's wait, deadline, and cleanup rules, measuring the shared deadline from the last `start`, and read each worker seat's artifact with Step 5's verified read at that seat's own path. Before a job directory is deleted, read a dropped seat's `out.log` for the reason. +**Starting and collecting.** Start the seats in the same dispatch wave as the persona reviewers. Bring every started seat job to a terminal outcome under this file's wait, deadline, and cleanup rules, measuring the shared deadline from the last `start`, and read each worker seat's artifact with Step 5's verified read at that seat's own path. Before a job directory is deleted, read a dropped seat's `out.log` for the reason. A re-invocation that reuses completed review results reuses the seat results with them. **Folding seats in.** Each seat artifact is one reviewer in synthesis under its own reviewer identity. This skill's synthesis rules are unchanged: - A finding raised only by seats is never applied silently. - Agreement between seats does not stack. However many seats agree, a finding gains at most the one promotion step this skill's synthesis allows. -- Only an artifact that records `independence_verified: true` can promote. A seat in the host's own family, a seat under an unknown host family, and a natively served seat never record it. +- Only an artifact that records `independence_verified: true` can promote. -**When seats run.** Seats run whenever the review dispatches its persona team. A seat does not depend on which personas were selected. A re-invocation that reuses completed review results reuses the seat results with them. - -**Reporting.** Print one `Model role seat :` line for every seat in `entries`, in the format `references/model-roles.md` defines. That covers seats that ran, dropped seats, blocked seats, and invalid seats. A seat that did not run prints `not run` with its reason: why it was dropped, the policy `blocked_by` names, or the invalid seat's `reason`. The lines go in Coverage and in the structured result this skill returns to a caller. For this review they replace the Coverage notes the single-peer pass would have written. +**Reporting.** Print one `Model role seat :` line for every seat in `entries`, whether or not it ran, in the format `references/model-roles.md` defines. A seat that did not run prints `not run` with its reason: why it was dropped, the policy `blocked_by` names, or the invalid seat's `reason`. The lines go in Coverage and in the structured result this skill returns to a caller. For this review they replace the Coverage notes the single-peer pass would have written. -**Seats in this skill.** The role is `doc-review`. The seat brief is the existing `whole-doc` brief, so `` is `whole-doc`, and every seat reviews the full document, never a slice. A worker seat passes `whole-doc` as `` and the full document as ``. A native seat is a generic subagent seeded with `references/personas/whole-doc-reviewer.md` and the full document, dispatched the way `references/dispatch.md` dispatches the persona reviewers. Step 4's trio calls and its one whole-doc sweep belong to the single-peer pass, so they do not run beside seats. `references/synthesis-and-presentation.md` states where the seat lines print. +**Seats in this skill.** The role is `doc-review`. The seat brief is the existing `whole-doc` brief, so `` is `whole-doc`, and every seat reviews the full document, never a slice. A worker seat passes `whole-doc` as `` and the full document as ``. A native seat is a generic subagent seeded with `references/personas/whole-doc-reviewer.md` and the full document, dispatched the way `references/dispatch.md` dispatches the persona reviewers. Step 4's trio calls and its one whole-doc sweep belong to the single-peer pass, so they do not run beside seats. ## Condition — run only when this holds diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index 621b58158..068a1bc65 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -8,7 +8,7 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `doc-review` role before dispatching the reviewers. The resolver's answer decides the role; your own reading of the entry does not. -**Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. Seats start in the same dispatch wave as those reviewers. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. +**Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. For each selected reviewer, read `references/personas/.md` and pass its full content as `{persona_file}`. Do not dispatch standalone agents by type/name and do not rely on platform-level custom-agent registration. diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index 6798dcc36..3fb484c33 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -16,13 +16,13 @@ Resolve the per-skill **model choice immediately before adapter selection**, so **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. - The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. `references/model-roles.md` owns the resolver command, what each resolver `state` means, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. This file adds the route facts under Adapter selection. + The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. **Per-skill key.** The per-skill key decides when no `model_roles:` key is active, and when `references/model-roles.md` says the step continues with the skill's existing keys. The key is `plan_model` for ce-plan, `brainstorm_model` for ce-brainstorm. Read it the **same way this skill's Phase 0.0 (output-mode resolution) resolves `plan_output` / `brainstorm_output`**: reuse the repo root already resolved, else run `git rev-parse --show-toplevel`, then apply the ordinary-key rule (`config.local.yaml` then `config.yaml`). Reuse the Phase 0.0 reads if still in hand. Ignore commented (`#`-prefixed) lines. A model alias selects it; missing / commented / invalid / no file selects none. -**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. Inside the config tier, the role's map entry in either file outranks the per-skill key in either file. A live instruction or a caller carrier replaces the entry whole, including its effort, and the step then runs as it does with no map. +**Precedence: latest explicit live user intent, then caller carrier, then config.** In pipeline / `disable-model-invocation` runs, where there is no live user dialogue, resolution is caller-carrier-then-config. Nothing elevates without one of those sources. -If the session model already **is** the resolved model and the choice carries no effort, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). A map entry with an effort always hands off, even when it names the session model. +If the session model already **is** the resolved model and the choice carries no effort, there is nothing to elevate: skip dispatch (see Transparency for whether a line is still printed). ## Adapter selection @@ -32,12 +32,7 @@ When elevation is active, resolve an adapter in this fixed order and use the fir 2. **Claude CLI.** Run the bundled `scripts/elevation-dispatch.sh` worker as a detached job (see Off-host dispatch). Available when `claude` is on PATH. Do not preflight authentication in the host command context: the detached worker's provider-capable call is authoritative, and an authentication failure there follows Recovery. 3. **Inline on the session model.** The always-available fallback. -**Map entries.** A choice from a `model_roles` entry uses these same adapters in the serving order of `references/model-roles.md`, which also owns the fallback ladder. That file needs these facts about this file's routes: - -- An entry with no effort keeps the fixed order above. -- An entry with an effort uses the first adapter that can carry the effort. The native adapter can only where the host's subagent tool sets effort. Elsewhere it is the ladder's route with no effort control. The Claude CLI adapter can for a Claude-family model, which means the resolver reports the entry's `family` as `claude`. It accepts every effort on the resolver's scale. -- An entry in another model family has no CLI adapter in this file, so only the native adapter can serve it. -- When no adapter can serve the entry as written, or the adapter that started fails, the ladder decides the next step. +**Map entries.** A choice from a `model_roles` entry uses these same adapters in the serving order of `references/model-roles.md`, which also owns the fallback ladder. The Claude CLI adapter serves an entry whose `family` the resolver reports as `claude`, and it accepts every effort on the resolver's scale. Elevation is never a correctness dependency: every adapter failure degrades to the next, and inline always completes the run. @@ -102,7 +97,7 @@ PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c `CE_PEER_HARD_SECS` (the outer runner cap) and `CE_ELEVATION_HARD_SECS` (the worker's own inner cap) are set to the **same** raised backstop well above any legitimate run (R11) — keep them equal so the inner cap never reaps a healthy run before the outer one. `CE_PEER_LOG_MAX_BYTES` is raised for the streaming route so a healthy high-volume run is not reaped as a failure (R22). `start` returns a job id in under ~2s. -`CE_ELEVATION_EFFORT` sets the reasoning effort of the elevated call. `` is the map entry's effort. Use `high`, the worker's default, when the choice carries none. The worker accepts `low`, `medium`, `high`, `xhigh`, and `max`, and it rejects any other value before it launches the model. +`CE_ELEVATION_EFFORT` sets the reasoning effort of the elevated call. `` is the map entry's effort. Use `high`, the worker's default, when the choice carries none. 3. **Poll** between your other work until the job reaches a terminal state (resolve `$PY` again — each tool call is a fresh shell): @@ -131,11 +126,11 @@ Classify from **both** the runner's terminal state and the worker's JSON result. A successful run has JSON `status: ok`. Treat any result whose `receipt` is `mismatch` as if it were a failure even when `status` is `ok`: **discard the output and degrade to the session model** — a served model that does not match the requested family must never be passed off as the requested one. (On the native route a mismatch instead falls through to the next adapter, per R6; on the CLI route inline is the only thing left, so discard-and-degrade is the fall-through.) -Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. A choice from a map entry instead follows the fallback ladder in `references/model-roles.md` wherever this file degrades to the session model, and its `Model role` line names the model that served. +Recovery **never substitutes a different model** — a plan the user believes came from their chosen model must not silently come from another. If recovery also fails, run inline on the session model. A choice from a map entry instead follows the fallback ladder in `references/model-roles.md` wherever this file degrades to the session model. ## Transparency -- **Choice from a map entry** → print the `Model role` line from `references/model-roles.md` in place of the lines below, whenever that file calls for one. It adds the applied effort and the reason for any fallback. On the Claude CLI route the applied effort is the result's `requested_effort`. The bullets below govern every other run: a choice from an instruction, a carrier, or a per-skill key, and a run with no choice at all. +- **Choice from a map entry** → print the `Model role` line from `references/model-roles.md` in place of the lines below, whenever that file calls for one. On the Claude CLI route the applied effort is the result's `requested_effort`. - **Elevation ran** → print one line naming the **model**, the **route**, and **why** it ran (config key, explicit user instruction, or caller carrier). Name the model as **served** when a receipt confirms it; otherwise name it as **requested** with an explicit *unverified* marker — on every route, including native. - **Print no line** when elevation did not run, and when the session model already is the model a **config key** requested. An **explicit user instruction** always produces a line, including when the session model already matches (so a recognized request is never indistinguishable from an unparsed one). - **Requested but unavailable before provider-capable dispatch** (no native support, `claude` absent, or the required launch permission unavailable) → run the step inline on the session model, name **which routing precondition was unmet**, and state what would make the requested model reachable. Once provider-capable dispatch is established, an authentication failure is instead a route-level Recovery outcome: name the observed authentication failure and the login or credential-refresh remediation. diff --git a/skills/ce-simplify-code/references/model-role-handoff.md b/skills/ce-simplify-code/references/model-role-handoff.md index a29c3e303..d082ede7a 100644 --- a/skills/ce-simplify-code/references/model-role-handoff.md +++ b/skills/ce-simplify-code/references/model-role-handoff.md @@ -1,10 +1,8 @@ # Model role hand-off for apply and verify -Read this when the `simplify` role resolved to `inherit` or `entries`. `references/model-roles.md` decides whether a hand-off happens, which route serves it, what the fallback is, and how the `Model role` line reads. This file adds only what is specific to this skill. +`references/model-roles.md` decides whether the `simplify` role hands off, which route serves it, what the fallback is, and how the `Model role` line reads. This file adds only what is specific to this skill. -**What the role governs.** The deliverable is the applied simplification edits and their verification, which is Step 3 and Step 4 together. The session keeps scope resolution, the three reviewer dispatches, and the Step 5 summary. The three reviewers keep their Model tier from Step 2. The entry does not change them. - -**Route.** This skill has no peer route, so a native subagent is the only hand-off route. When the host's subagent primitive takes a model but no effort, an entry that has an effort is served through the fallback ladder's effort-not-applied rung. +**What stays with the session.** The session keeps scope resolution, the three reviewer dispatches, and the Step 5 summary. The three reviewers keep their Model tier from Step 2. **Hand-off.** When a hand-off is called for and the host can hand the entry's model to a subagent, dispatch one subagent on that model once all three review outcomes are complete. The session edits nothing while that subagent runs. Step 2's permission-mode and agent-lifecycle rules apply to this dispatch as well. The subagent's prompt carries: @@ -17,6 +15,6 @@ Read this when the `simplify` role resolved to `inherit` or `entries`. `referenc The session writes the Step 5 summary from that return. -**Inline fallback.** When the host cannot hand the entry's model to a subagent, or the dispatch fails before the subagent has edited anything, the session performs Steps 3 and 4 itself, as it does when there is no map. The `Model role` line then names the session as the route and gives the reason. +**Inline fallback.** When the host cannot hand the entry's model to a subagent, or the dispatch fails before the subagent has edited anything, the session performs Steps 3 and 4 itself. -**Mid-edit failure.** When the subagent fails or stops after it has started editing, the working tree is the record of what it did. The session finishes inline from the working tree. It reads the current files and treats each finding as applied or not by what they now show. It completes Step 3 for the findings that are left, then runs Step 4 again over the whole result. The `Model role` line reports the fallback and its reason. +**Mid-edit failure.** When the subagent fails or stops after it has started editing, the session finishes inline from the working tree. It treats each finding as applied or not by what the files now show. It completes Step 3 for the findings that are left, then runs Step 4 again over the whole result. diff --git a/skills/ce-work/references/cross-model-execution.md b/skills/ce-work/references/cross-model-execution.md index 75cbe218d..fba4a0860 100644 --- a/skills/ce-work/references/cross-model-execution.md +++ b/skills/ce-work/references/cross-model-execution.md @@ -16,7 +16,7 @@ Use only these targets: `codex`, `claude`, `grok`, `cursor`, `composer`, and `op When a standing preference lists candidates in order, preflight them in that order without sending anything out. Skip a candidate only when its harness and requested/default model are equivalent to the current host, or when observed evidence shows it is unavailable. Attempt the documented adapter recipe first. Local CLI help or version information may refine a compatible mapping, but only inside the same sanctioned harness/model family and only if the fixed adapter still enforces every restriction. An explicit model pin cannot become another model. When `work_engine_effort` requests an effort for the candidate's harness, preflight also asks the adapter whether that route can run at it. The effort is data, never shell syntax, the same as a model selector: only a plain token that starts with a letter or digit and continues with letters, digits, `-`, or `_` may appear in a command, and any other value makes the candidate unavailable without running anything. Ask with this skill's bundled script by its absolute path under the skill directory, because it is not on `PATH`: `CROSS_MODEL_EFFORT_OVERRIDE='' "/scripts/cross-model-work.sh" --emit-adapter `. Exit 2 naming the effort means the route cannot run at it, and the candidate is unavailable with that reason. A script that cannot be found or run is a broken preflight to report, not evidence about the route. The first qualified candidate becomes the one fixed recipient. -A candidate that came from a `model_roles` entry, as `execution-engines.md` defines it, differs from the rules above as follows. When its entry carries an effort, it is never skipped as equivalent to the current host and never collapsed to native execution. A refusal moves it down the fallback ladder instead of making it unavailable: when the adapter exits 2 for the entry's effort, ask again at each lower level of the resolver's `effort_scale`, nearest first, and use the first level it accepts. When it accepts no level, the route has no effort control, and the candidate runs on the entry's model with no effort. When the route cannot serve the entry's model, the candidate may run on the harness's configured default model. Record what the route is asked to run, not what the entry asked for: the egress `effort` is the accepted level and is absent when none applies, and the binding's `model` is the model the route will run. `requested_model` and `requested_effort` in the receipts keep the entry's values, and `fallback_reason` names each difference. +A candidate that came from a `model_roles` entry, as `execution-engines.md` defines it, differs from the rules above as follows. When its entry carries an effort, it is never skipped as equivalent to the current host and never collapsed to native execution. A refusal moves it down the fallback ladder instead of making it unavailable: when the adapter exits 2 for the entry's effort, ask again at each lower level of the resolver's `effort_scale`, nearest first, and use the first level it accepts. When it accepts no level, the route has no effort control, and the candidate runs on the entry's model with no effort. When the route cannot serve the entry's model, the candidate may run on the harness's configured default model. Record what the route is asked to run, not what the entry asked for: the egress `effort` is the accepted level and is absent when none applies, and the binding's `model` is the model the route will run. Before egress, you may keep moving down the list after a candidate is proven unavailable. After dispatch starts, the adapter receives one fixed recipient and must never switch recipients, providers, or intermediaries internally. A different recipient requires a separately resolved and recorded attempt, and only after the first attempt reaches an authoritative terminal or reaped state; it is never an in-flight fallback. diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index 2f6276a1d..57ea032a0 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -49,7 +49,7 @@ When the target resolves to the current host's default execution route and no di **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `work` role before reading the `work_engine_*` keys below. The resolver's answer decides the role; your own reading of the entry does not. -The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. `references/model-roles.md` owns the resolver command, what each `state` means, precedence, when a hand-off happens, the serving order, the fallback ladder, and the `Model role` line. "A `work` role entry" below adds only what is specific to this skill. +The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. "A `work` role entry" below states what is specific to this skill. Standing configuration uses one mode plus an ordered route list. Resolve `work_engine_mode` and `work_engine_preferences` independently from the two repo files (`config.local.yaml` then `config.yaml`); a present local list, including `[]`, replaces the team list. Do not pick one file for the whole group. @@ -86,32 +86,24 @@ Traverse each ordered candidate during preflight. If a candidate is equivalent t This subsection applies when the resolver reports `inherit` or `entries` for the `work` role. In every other state, routing uses the keys above as it does with no map. -**Precedence.** The entry sits at the per-checkout configuration step of the resolution order. Every source above that step, including live intent and a typed caller binding, outranks the entry and replaces it whole, effort included. Routing then resolves as it does with no map. While the entry governs the run, `work_engine_mode`, `work_engine_preferences`, and `work_engine_effort` are not consulted. +**Precedence.** The entry sits at the per-checkout configuration step of the resolution order. Every source above that step, including live intent and a typed caller binding, outranks the entry. While the entry governs the run, `work_engine_mode`, `work_engine_preferences`, and `work_engine_effort` are not consulted. -**`inherit`.** The run uses native execution on the session model. +**Session model.** Whenever `references/model-roles.md` leaves the work with the session model, the run uses native execution. -**Personal opt-out.** When the resolver reports `engine_opt_out: true`, the entry is not handed to any other model: the work runs natively on the session model, and the `Model role` line gives the personal `work_engine_mode: off` as the reason. The resolver reports this only for a team-file entry, so a personal-file entry is unaffected. +**Personal opt-out.** When the resolver reports `engine_opt_out: true`, the entry is not handed to any other model: the work runs natively on the session model, and the `Model role` line gives the personal `work_engine_mode: off` as the reason. **Serving the entry.** The serving order in `references/model-roles.md` maps onto this skill's engines in this way: -- **No hand-off.** An entry with no effort that names the model the session attests it is on collapses to native execution. -- **Native subagent.** When the host's subagent primitive can be handed the entry's model at the entry's effort, the inline/subagent engine serves the entry: every unit is dispatched to a subagent on that model as `references/execution-strategy.md` describes, no unit runs inline, and no external candidate is formed. -- **Peer route.** Otherwise the entry becomes the only standing candidate, at `prefer`. The candidate is the resolver's `harness`, the entry's `model`, and the entry's `effort` as the effort requested for it. Normalize and preflight it like any other candidate. +- **Native subagent.** The inline/subagent engine serves the entry: every unit is dispatched to a subagent on the entry's model as `references/execution-strategy.md` describes, no unit runs inline, and no external candidate is formed. +- **Peer route.** The entry becomes the only standing candidate, at `prefer`. The candidate is the resolver's `harness`, the entry's `model`, and the entry's `effort` as the effort requested for it. Normalize and preflight it like any other candidate. -A candidate from an entry that carries an effort is never equivalent to the current host. It is not skipped as a route to self, and it does not collapse to native execution. It runs through the host's own CLI route at that effort: `opus medium` in a Claude Code session already on Opus runs through the `claude` route at `medium`. The equivalence and collapse rules elsewhere in this file keep governing candidates from every other source. +A candidate from an entry that carries an effort is never equivalent to the current host. It is not skipped as a route to self, and it does not collapse to native execution. It runs through the host's own CLI route at that effort: `opus medium` in a Claude Code session already on Opus runs through the `claude` route at `medium`. A null `harness` means the resolver could not place the model in a family, so no candidate can be formed from the entry. Unless a native subagent serves it, the run ends on native execution on the session model, and the `Model role` line says that the model's family has no engine route. -**Fallback.** A candidate from the entry follows the fallback ladder in `references/model-roles.md`, not the unavailable-candidate rules above. Those rules, including that nothing runs at a different effort than the one requested, keep governing `work_engine_effort`. These facts connect the ladder to this skill's routes: +**Fallback.** A candidate from the entry follows the fallback ladder in `references/model-roles.md`, not the unavailable-candidate rules above. Walk the ladder at preflight, before any work is sent. `references/cross-model-execution.md` gives the question that asks the adapter script which effort levels a route accepts. The route's default model is the same candidate with no `model`. After dispatch starts, the recipient lock and the single fallback claim in `references/cross-model-execution.md` apply unchanged. -- The adapter script says which effort levels a route accepts. `references/cross-model-execution.md` gives the question to ask it at preflight. -- A route for which the adapter accepts no level has no effort control. The Cursor routes are such routes today. The candidate runs there on the entry's model with no effort requested. -- The route's default model is the same candidate with no `model`. -- When the ladder ends on the session model, execution is native. - -Walk the ladder at preflight, before any work is sent. After dispatch starts, the recipient lock and the single fallback claim in `references/cross-model-execution.md` apply unchanged. - -**Report.** Print the `Model role` line with the run's closing summary. In Return-to-Caller Mode, print it immediately before the structured return, which gains no field for it. The existing fields carry the same facts. `requested_model` holds the model the entry names. `requested_effort` holds the entry's effort whenever an external route ran, even when that route ran at a lower effort or with none applied. `fallback_reason` names a step-down, an effort that was not applied, a default-model substitution, the personal opt-out, or a family with no engine route. The binding's `source` names the `model_roles` entry and the file it came from. +**Report.** Print the `Model role` line with the run's closing summary. In Return-to-Caller Mode, print it immediately before the structured return, which gains no field for it. `requested_model` holds the model the entry names. `requested_effort` holds the entry's effort whenever an external route ran, even when that route ran at a lower effort or with none applied. `fallback_reason` names a step-down, an effort that was not applied, a default-model substitution, the personal opt-out, or a family with no engine route. The binding's `source` names the `model_roles` entry and the file it came from. ## Step 1: Probe host capability From 68db22d4130e12a3698e540282b0448a986ef93a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:23:07 -0700 Subject: [PATCH 38/53] fix(model-roles): place a provider-qualified GPT or o-series id in Codex An id such as `openai/` or `openai.` passed the entry grammar but landed in no family, so a review seat could not use the Codex worker and a work entry got no engine. Both review workers already accept those forms as a Codex model override. The resolver now classifies a provider-qualified GPT or o-series id as Codex. Other ids with a slash stay in no family. Co-Authored-By: Claude Code --- skills/ce-brainstorm/scripts/model-role-resolve.py | 4 ++++ skills/ce-code-review/scripts/model-role-resolve.py | 4 ++++ skills/ce-compound/scripts/model-role-resolve.py | 4 ++++ skills/ce-debug/scripts/model-role-resolve.py | 4 ++++ skills/ce-doc-review/scripts/model-role-resolve.py | 4 ++++ skills/ce-plan/scripts/model-role-resolve.py | 4 ++++ skills/ce-setup/scripts/model-role-resolve.py | 4 ++++ .../ce-simplify-code/scripts/model-role-resolve.py | 4 ++++ skills/ce-work/scripts/model-role-resolve.py | 4 ++++ tests/skills/model-role-resolver.test.ts | 13 +++++++++++++ 10 files changed, 49 insertions(+) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index a57b7c408..743e25a2d 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -220,6 +220,10 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] + # The Codex route accepts a provider-qualified id, so `.` or `/` before a + # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. + if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + return "codex" if "/" in name: return "unknown" if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index e9a5947c3..781c3b0e6 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -212,6 +212,19 @@ describe("family and harness", () => { ]) }) + test("a provider-qualified GPT or o-series id is Codex, as the review workers accept it", () => { + const models = ["openai.gpt-5.5", "openai/gpt-5.5", "azure/o3-pro", "anthropic/claude-opus-5-5", "openai/some-model"] + const out = role(makeProject(map(` doc-review: [${models.join(", ")}]\n work: openai/gpt-5.5 high\n`)), "doc-review") + expect(out.entries.map((e: any) => [e.model, e.family])).toEqual([ + ["openai.gpt-5.5", "codex"], + ["openai/gpt-5.5", "codex"], + ["azure/o3-pro", "codex"], + // Only the forms a route accepts are placed: the Claude route takes no namespace. + ["anthropic/claude-opus-5-5", "unknown"], + ["openai/some-model", "unknown"], + ]) + }) + test("a work entry maps Composer to the cursor harness and an unknown family to none", () => { const composer = role(makeProject(map(" work: composer-2.5-fast\n")), "work") expect([composer.entries[0].family, composer.entries[0].harness]).toEqual(["composer", "cursor"]) From 3718f318bcdb0d7e9f9c5d7f765299b61ae9aeb1 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:32:01 -0700 Subject: [PATCH 39/53] refactor(model-roles): tighten the shared reference and name the key as the hook's trigger The shared model role reference loses filler, sentences repeated within the file, and three of five example lines. Every rule, the resolver command, the state table, and the working-directory sentence are unchanged. The resolver's docstring is trimmed with no behavior change. The hook now says the resolver runs whenever the `model_roles:` key exists, even with no entry for this role. With a map that held another role's entry only, Codex had skipped the resolver and once reported that other role's model. Over five rounds on the new wording Codex had no such miss. Content the feature adds drops from 80,071 to 78,793 bytes. Codex passed 89 of 95 cells over five rounds, Claude 21 of 21, and Cursor 20 of 20. Co-Authored-By: Claude Code --- .../ce-brainstorm/references/model-roles.md | 19 +++++++------------ .../references/reasoning-elevation.md | 2 +- .../scripts/model-role-resolve.py | 13 +++++-------- .../ce-code-review/references/model-roles.md | 19 +++++++------------ .../references/select-and-route.md | 2 +- .../scripts/model-role-resolve.py | 13 +++++-------- skills/ce-compound/references/lightweight.md | 2 +- skills/ce-compound/references/model-roles.md | 19 +++++++------------ skills/ce-compound/references/research.md | 2 +- .../ce-compound/scripts/model-role-resolve.py | 13 +++++-------- skills/ce-debug/references/fix.md | 2 +- skills/ce-debug/references/investigate.md | 2 +- skills/ce-debug/references/model-roles.md | 19 +++++++------------ skills/ce-debug/scripts/model-role-resolve.py | 13 +++++-------- skills/ce-doc-review/references/dispatch.md | 2 +- .../ce-doc-review/references/model-roles.md | 19 +++++++------------ .../scripts/model-role-resolve.py | 13 +++++-------- skills/ce-plan/references/model-roles.md | 19 +++++++------------ .../ce-plan/references/reasoning-elevation.md | 2 +- skills/ce-plan/scripts/model-role-resolve.py | 13 +++++-------- skills/ce-setup/scripts/model-role-resolve.py | 13 +++++-------- skills/ce-simplify-code/SKILL.md | 2 +- .../references/model-roles.md | 19 +++++++------------ .../scripts/model-role-resolve.py | 13 +++++-------- .../ce-work/references/execution-engines.md | 2 +- skills/ce-work/references/model-roles.md | 19 +++++++------------ skills/ce-work/scripts/model-role-resolve.py | 13 +++++-------- 27 files changed, 111 insertions(+), 178 deletions(-) diff --git a/skills/ce-brainstorm/references/model-roles.md b/skills/ce-brainstorm/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-brainstorm/references/model-roles.md +++ b/skills/ce-brainstorm/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-brainstorm/references/reasoning-elevation.md b/skills/ce-brainstorm/references/reasoning-elevation.md index 3fb484c33..9e19a75d0 100644 --- a/skills/ce-brainstorm/references/reasoning-elevation.md +++ b/skills/ce-brainstorm/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver decides the role, not your reading of the config. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-code-review/references/model-roles.md b/skills/ce-code-review/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-code-review/references/model-roles.md +++ b/skills/ce-code-review/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-code-review/references/select-and-route.md b/skills/ce-code-review/references/select-and-route.md index cc685e022..cdb635452 100644 --- a/skills/ce-code-review/references/select-and-route.md +++ b/skills/ce-code-review/references/select-and-route.md @@ -76,7 +76,7 @@ The Review depth gate in `references/modes-and-output.md` already chose lite, fo ### Stage 3d: Bind the adversarial route and final roster -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `code-review` role before binding the adversarial route. The resolver's answer decides the role; your own reading of the entry does not. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `code-review` role before binding the adversarial route. The resolver decides the role, not your reading of the config. **Review seats.** When that key is active, read `references/cross-model-review.md` in full too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the local reviewers. A seat does not depend on which personas were selected, so seats run whether or not adversarial was selected. When that section says the single-peer pass does not run, the rest of this stage binds no route: keep `adversarial-reviewer` in the local roster when Stage 3 selected it, start the worker seats now, and add the natively served seats to the Stage 4 batch. The exclusive choice below, and its rule that a started peer and the in-process adversarial reviewer never receive the same brief, govern only the single-peer pass. diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-compound/references/lightweight.md b/skills/ce-compound/references/lightweight.md index 38e277006..08d64a6e5 100644 --- a/skills/ce-compound/references/lightweight.md +++ b/skills/ce-compound/references/lightweight.md @@ -10,7 +10,7 @@ This mode skips parallel subagents entirely. The orchestrator performs all work Non-interactive mode enters Lightweight only when explicitly invoked with `depth:lightweight`; otherwise it defaults to Full for backward compatibility. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver's answer decides the role; your own reading of the entry does not. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with `route session`. When that reference would otherwise have called for a hand-off, the line's `reason:` is that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `compound` role before writing the doc. The resolver decides the role, not your reading of the config. Lightweight never hands the draft off, because it launches no subagents: you write the doc in this pass whatever the entry names. When the role resolves to `inherit` or `entries`, print its `Model role` line with `route session`. When that reference would otherwise have called for a hand-off, the line's `reason:` is that Lightweight launches no subagents. In the interactive block below, the line goes above the `Note:`. In non-interactive Lightweight, `references/report.md` places it. The orchestrator (main conversation) performs ALL of the following in one sequential pass: diff --git a/skills/ce-compound/references/model-roles.md b/skills/ce-compound/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-compound/references/model-roles.md +++ b/skills/ce-compound/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index d94441ea9..e65aab6d5 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -86,7 +86,7 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch - Does not force bug-track fields onto knowledge-track learnings or vice versa #### 2. **Draft the body** (this context) - **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `compound` role before drafting the body. The resolver's answer decides the role; your own reading of the entry does not. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `compound` role before drafting the body. The resolver decides the role, not your reading of the config. The role's deliverable is the learning document's body. This skill has no peer route. When that reference calls for a hand-off, **Body hand-off** at the end of this step replaces drafting in this context. Otherwise you draft here, as the rest of this step describes. - Adapts output structure to the track chosen in step 1 - **Drafts the full doc-body prose** (all track-appropriate sections below) from the conversation and the verified fix. The failed attempts and the reasoning behind the fix are what a later reader cannot recover from the code, so they come from what happened in this session, not from a recap of the final diff. diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 6c358e6dc..9af4b6a99 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -4,7 +4,7 @@ Read this before editing any file in Phase 3. SKILL.md runs the branch check and *One change at a time. If you are changing multiple things, stop.* -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the first edit. The resolver's answer decides the role; your own reading of the entry does not. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `debug` role before the first edit. The resolver decides the role, not your reading of the config. When Phase 1 already resolved the role, use that result. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 6f31b6dfb..2f283ef02 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,7 +25,7 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver's answer decides the role; your own reading of the entry does not. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver decides the role, not your reading of the config. The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route. `mode:pipeline` and `mode:return-to-caller` honor the entry the way interactive use does, and no mode asks about it. diff --git a/skills/ce-debug/references/model-roles.md b/skills/ce-debug/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-debug/references/model-roles.md +++ b/skills/ce-debug/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-doc-review/references/dispatch.md b/skills/ce-doc-review/references/dispatch.md index 068a1bc65..6274881e5 100644 --- a/skills/ce-doc-review/references/dispatch.md +++ b/skills/ce-doc-review/references/dispatch.md @@ -6,7 +6,7 @@ Respect the harness's active-subagent limit: dispatch only as many selected revi **Agent lifecycle.** Collect each agent's final outcome, including failures, before cleanup. When the harness lets the caller close or release agents, close or release the agents this review started before refilling slots, advancing stages, or returning. Do not message completed agents with no remaining work. Do not assume capacity was freed just because an agent completed or was interrupted, and do not invent cleanup operations. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `doc-review` role before dispatching the reviewers. The resolver's answer decides the role; your own reading of the entry does not. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `doc-review` role before dispatching the reviewers. The resolver decides the role, not your reading of the config. **Review seats.** When that key is active, read `references/cross-model-review.md` too, before running the resolver. Its Step 1 host attestation supplies the resolver's `--host-family`, and its review-seat section decides, from the resolver's answer, what runs beside the reviewers below. A seat does not depend on which personas were selected, so a review that activates no conditional persona still runs its seats. The Cross-Model Judgment Pass condition in `SKILL.md` governs only the single-peer pass, which runs when the role has no entry. diff --git a/skills/ce-doc-review/references/model-roles.md b/skills/ce-doc-review/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-doc-review/references/model-roles.md +++ b/skills/ce-doc-review/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-plan/references/model-roles.md b/skills/ce-plan/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-plan/references/model-roles.md +++ b/skills/ce-plan/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-plan/references/reasoning-elevation.md b/skills/ce-plan/references/reasoning-elevation.md index 3fb484c33..9e19a75d0 100644 --- a/skills/ce-plan/references/reasoning-elevation.md +++ b/skills/ce-plan/references/reasoning-elevation.md @@ -14,7 +14,7 @@ Resolve the per-skill **model choice immediately before adapter selection**, so 2. **Caller carrier** — a structured `:` token that an automatic orchestrator may pass in the invocation (LFG passes `plan_model:` to ce-plan; the analogous `brainstorm_model:` to ce-brainstorm). Use it when live user intent does not decide the choice. Strip it from the request text and never reconstruct it from product prose. It is honored in pipeline / `disable-model-invocation` runs. The alias must match `^[A-Za-z0-9._-]{1,64}$`; a malformed carrier counts as absent, not guessed. 3. **Config** — otherwise consult the role's map entry first and the per-skill key second. - **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver's answer decides the role; your own reading of the entry does not. + **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `plan` role in ce-plan, or the `brainstorm` role in ce-brainstorm, before reading the per-skill key. The resolver decides the role, not your reading of the config. The entry governs this file's elevated step: the authored plan for `plan`, the generated approaches for `brainstorm`. The peer route is this file's Claude CLI adapter, which serves Claude-family models only. diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-simplify-code/SKILL.md b/skills/ce-simplify-code/SKILL.md index 327f30534..944290c0c 100644 --- a/skills/ce-simplify-code/SKILL.md +++ b/skills/ce-simplify-code/SKILL.md @@ -41,7 +41,7 @@ Do not paraphrase these rubrics from memory. Read each file and pass it verbatim ## Step 3: Fix issues -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `simplify` role before applying any finding. The resolver's answer decides the role; your own reading of the entry does not. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `simplify` role before applying any finding. The resolver decides the role, not your reading of the config. The role's deliverable is the applied simplification edits and their verification, which is this step and Step 4. This skill has no peer route. When the role resolves to `inherit` or `entries`, read `references/model-role-handoff.md` before editing. Proceed only after all three review outcomes are complete, whether returned by subagents or produced inline. Apply worthwhile findings directly; record false positives and low-value findings as skipped without asking the user. diff --git a/skills/ce-simplify-code/references/model-roles.md b/skills/ce-simplify-code/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-simplify-code/references/model-roles.md +++ b/skills/ce-simplify-code/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index 57ea032a0..43a539f07 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -47,7 +47,7 @@ When the target resolves to the current host's default execution route and no di - **Do not** use this rule for `docs_root` — that key is `config.yaml` only. -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. When neither has an active `model_roles:` key, skip this: do not read `references/model-roles.md` or run its resolver, and print nothing about model roles. When either has the key, read `references/model-roles.md` now and run its resolver for the `work` role before reading the `work_engine_*` keys below. The resolver's answer decides the role; your own reading of the entry does not. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `work` role before reading the `work_engine_*` keys below. The resolver decides the role, not your reading of the config. The role's deliverable is the code the plan's units call for, and its peer route is this skill's cross-model engine routes. "A `work` role entry" below states what is specific to this skill. diff --git a/skills/ce-work/references/model-roles.md b/skills/ce-work/references/model-roles.md index 05869ef2a..2dd6e8eb9 100644 --- a/skills/ce-work/references/model-roles.md +++ b/skills/ce-work/references/model-roles.md @@ -1,12 +1,10 @@ # Model roles -A repo's CE config can carry a `model_roles:` map. Each entry names the model, and optionally the reasoning effort, that should produce one step's deliverable. This file states how a skill honors the entry for its role. The calling skill's hook names the role, the deliverable, and the peer route, when the skill has one. +Each entry in the repo CE config's `model_roles:` map names the model, and optionally the reasoning effort, that should produce one step's deliverable. **Outcome:** the deliverable is produced by the model and effort the entry names, or by the nearest option this file allows, and the skill's output says which model served it and by which route. -**When this applies.** The hook loads this file only when a repo CE config file carries an active `model_roles:` key. When the role has no entry, the skill behaves exactly as it does without this file. - -A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. This file points to them where they replace a rule below. +A *single-model role* has one entry. A *review role* has a list of seats, and the calling skill's own reference states the seat rules. ## Resolve the role @@ -27,7 +25,7 @@ Add `--host-family ` only when the calling skill already attests the ses | `entries` | Use the entry: its `model`, and its `effort` unless that is null. A single-model role has exactly one. | | `invalid` | The `model_roles` block is malformed. A single-model role continues as it does today and says why, quoting `errors`. | -When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. It never stops the run for that. +When the command yields no JSON (no interpreter, script not found, non-zero exit), a single-model role continues as it does today and says once that the model role map could not be read. A review role does not use the `invalid` row or the no-JSON rule. The calling skill's seat rules decide both. @@ -54,7 +52,7 @@ A hand-off gives the deliverable's work to another model run and takes the resul - The state is `inherit`. - The entry has no effort and names the model the session can attest it is already running on. The session attests its model from what its harness states, never from a guess. -Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort, so the session cannot show that it already runs at that effort. +Every other entry hands off. An entry with an effort always hands off, because no host exposes the session's own effort. ## Serving order @@ -75,11 +73,11 @@ When no route can serve the entry as written, or the route that started fails, t The resolver's `effort_scale` gives the order of efforts, lowest first: `low`, `medium`, `high`, `xhigh`, `max`. The route's adapter says which levels the route accepts. The agent does not guess them. -This ladder serves map entries only. The skill's existing keys keep the failure rules they have today. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. +This ladder serves map entries only. A review seat never uses the ladder: the calling skill's seat rules decide what happens to a seat that cannot be served. ## The `Model role` line -Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. Print nothing about model roles when no active `model_roles:` key exists. +Print one line for every step an entry governs, which means every step whose role resolved to `inherit` or `entries`. Put it in the output the skill already produces. ```text Model role : requested [ ]; ; route [; reason: ]. @@ -90,14 +88,11 @@ Model role : requested [ ]; ; route [; reas - `` is `native subagent`, ` CLI`, or `session`. - `reason:` appears only after a fallback, a dropped or blocked seat, or an invalid entry. -Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the served effort is never verified: the line reports the effort the route was asked to run at. +Name the served model as served only when the route's receipt confirms it. Without a receipt, write the model the route was asked for, followed by `(unverified)`. On the `session` route, write the model the session attests, or `unverified` alone when it attests none. No route returns an effort receipt, so the line reports the effort the route was asked to run at. Show the resolver's `warnings` for the role once, with this line. When the step prints no line, show them alone in the same place. ```text Model role : requested fable low; served fable (unverified) at low; route claude CLI. -Model role : requested opus max; served opus at high; route claude CLI; reason: the route does not accept max. -Model role : requested sonnet low; served sonnet, effort not applied; route native subagent. Model role : requested inherit; served opus; route session. -Model role seat 2: requested opus high; not run; reason: no route can serve it. ``` diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index 743e25a2d..1c1b092e4 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Resolve the model role map in this repo's CE config into a per-role answer. +"""Resolve the `model_roles:` map in this repo's CE config into a per-role answer. -Reads the top-level `model_roles:` map from -`/.compound-engineering/config.local.yaml` (personal) and +Reads `/.compound-engineering/config.local.yaml` (personal) and `config.yaml` (team) and prints one JSON object to stdout. Exit 0 whenever resolution ran -- a bad entry, a malformed block, or a missing repository is data in the JSON, never a traceback. Non-zero only for a bad command line. @@ -12,13 +11,11 @@ work: inherit # the session model doc-review: # review roles also take a list: one seat each - opus high - - inherit # a seat on the session model + - inherit code-review: [sonnet, opus max] -Roles: brainstorm, plan, doc-review, debug, work, simplify, code-review, -compound. Efforts: low, medium, high, xhigh, max. Only `doc-review` and -`code-review` accept a list; on them a scalar model is one seat, and `[]` or a -scalar `inherit` means no seats. +Only the review roles accept a list. On them a scalar model is one seat, and +`[]` or a scalar `inherit` means no seats. Layering is per role: the personal entry wins, else the team entry. On a single-model role an invalid entry is skipped with a warning and the next layer From 7c405c8b811f488557cf8741d6bd35dddd84390f Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:32:01 -0700 Subject: [PATCH 40/53] docs(solutions): update the config hook learning to the final wording and counts Adds the fifth fact the hook needs (the trigger is the key, not an entry for this role), replaces the example with the wording that shipped, and replaces the one-round row with eleven rounds on that wording and five on the last. Co-Authored-By: Claude Code --- ...ates-path-decider-and-working-directory.md | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md b/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md index b45498feb..f77f0c1b8 100644 --- a/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md +++ b/docs/solutions/skill-design/config-hook-states-path-decider-and-working-directory.md @@ -37,14 +37,15 @@ A fifth slip went the other way. Once the hook stressed that the resolver decide ## Guidance -A hook that conditions a step on a repo file gives the agent four facts in this order. The current wording is in `skills/ce-plan/references/reasoning-elevation.md:17`, and the same sentence shape is used at the other nine sites. +A hook that conditions a step on a repo file gives the agent five facts in this order. The current wording is in `skills/ce-plan/references/reasoning-elevation.md:17`, and the same sentence shape is used at the other nine sites. 1. **The full path of every file, and that it is opened by path.** Write `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml`, never the directory once and a bare file name after it. Say why: a file search skips that hidden directory. 2. **The skip, before the action.** State the case where nothing happens first, and name what is skipped: do not read the reference, do not run its script, print nothing. 3. **The action, naming the script as the thing to run.** "Run its resolver for the `plan` role" rather than "resolve the `plan` role". An agent that has just read the file treats "resolve" as already done. -4. **Who decides.** One sentence: the resolver's answer decides the role, and the agent's own reading of the entry does not. +4. **The trigger is the key, not an entry for this role.** "Otherwise, even with no entry for this role, read … and run its resolver." When the map held an entry for another role only, Codex saw none for its own and skipped the resolver, and once reported the other role's model. +5. **Who decides.** One sentence: the resolver decides the role, and the agent's reading of the config does not. -The reference that carries the script's command states where to run it. `skills/ce-plan/references/model-roles.md:13` says to run the resolver with the project as the working directory, because it reads the config of the repository it runs in. +The reference that carries the script's command states where to run it. `skills/ce-plan/references/model-roles.md:11` says to run the resolver with the project as the working directory, because it reads the config of the repository it runs in. Grade the route as well as the answer. A cell that only checks the declared model passes when the agent read the entry by eye. The cells that exposed the third failure declare a required read with `files_read_post` (`tests/skill-eval-cell/catalog.ts`, graded in `tests/skill-eval-cell/grade.ts:275`), and their matching no-map cells forbid the resolver in `ACTIONS`. The pair fails in both directions: the route skipped when a map exists, and the route taken when none does. @@ -54,7 +55,7 @@ Each omission produced a silent failure. The user configures a model, the step r The failures are invisible on the authoring model. Claude followed the first wording in every cell, so an eval run on Claude alone would have shipped it. They are also invisible to a grade that reads only the declared answer, because simple fixtures make a shortcut look correct. -Fixing one omission exposed the next. Telling the agent where the files are made it open them, and having opened them it stopped needing the script. Telling it the script decides made it run the script in cases that should skip. A hook like this is settled only when a round passes in both directions on the weaker host. The final wording has one such round, so the last row of the table below is thin evidence. +Fixing one omission exposed the next. Telling the agent where the files are made it open them, and having opened them it stopped needing the script. Telling it the script decides made it run the script in cases that should skip. A hook like this is settled only when rounds pass in both directions on the weaker host. One round is not evidence: the wording before the last one looked clean after one round and showed two misses over eleven. ## When to Apply @@ -80,11 +81,11 @@ After: ```text **Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips -that hidden directory. When neither has an active `model_roles:` key, skip this: do not -read `references/model-roles.md` or run its resolver, and print nothing about model roles. -When either has the key, read `references/model-roles.md` now and run its resolver for -the `plan` role before reading the per-skill key. The resolver's answer decides the role; -your own reading of the entry does not. +that hidden directory. If neither has an active `model_roles:` key, do not read +`references/model-roles.md`, do not run its resolver, and print nothing about model roles. +Otherwise, even with no entry for this role, read `references/model-roles.md` now and run +its resolver for the `plan` role before reading the per-skill key. The resolver decides +the role, not your reading of the config. ``` Codex results by hook wording, counted over the graded Codex cells of each round: @@ -94,9 +95,10 @@ Codex results by hook wording, counted over the graded Codex cells of each round | Directory once, bare `config.yaml` after it | 2 of 9 | not reached | 0 | 0 | | Both full paths, opened by path | 0 of 36 | 4 of 20 | 0 of 16 | 0 | | Plus "run its resolver" and "the resolver's answer decides" | 0 of 38 | 0 of 20 | 2 of 16 | 2 of 20 | -| Skip stated first, and the reference says to run from the project | 0 of 19 | 0 of 10 | 0 of 8 | 0 of 10 | +| Skip stated first, and the reference says to run from the project | 0 of 209 | 2 of 110 | 1 of 88 | 0 of 110 | +| Plus "even with no entry for this role" | 0 of 95 | 0 of 50 | 1 of 40 | 0 of 50 | -The first row is one partial round, the next two rows are two rounds each, and the last row is one round. Over the same rounds Claude passed 111 of 112 cells, and its one miss was an answer-format slip in a new cell. Cursor passed 58 of 58 over three rounds, the first of which ran before the bare file name was fixed. +The first row is one partial round, the next two rows are two rounds each, the fourth row is eleven rounds, and the last row is five. In the fourth row's rounds Codex also read the wrong role's entry once, in the cell whose map has no entry for its role; that did not recur in the last row. Over all of these rounds Claude passed every cell but one, an answer-format slip in a new cell, and Cursor passed every cell. ## Related From bdfb70043a992aa32806824d7d6236892aad18b5 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:38:08 -0700 Subject: [PATCH 41/53] fix(model-roles): accept every provider prefix the Codex route accepts The resolver placed a provider-qualified GPT or o-series id in Codex only when the provider name was letters, so `azure2/` landed in no family while both review workers accept it. A name already in another family is now decided first, and any other id with `.` or `/` before a GPT or o-series name is Codex, the same form the workers accept. Co-Authored-By: Claude Code --- .../scripts/model-role-resolve.py | 25 +++++++++---------- .../scripts/model-role-resolve.py | 25 +++++++++---------- .../ce-compound/scripts/model-role-resolve.py | 25 +++++++++---------- skills/ce-debug/scripts/model-role-resolve.py | 25 +++++++++---------- .../scripts/model-role-resolve.py | 25 +++++++++---------- skills/ce-plan/scripts/model-role-resolve.py | 25 +++++++++---------- skills/ce-setup/scripts/model-role-resolve.py | 25 +++++++++---------- .../scripts/model-role-resolve.py | 25 +++++++++---------- skills/ce-work/scripts/model-role-resolve.py | 25 +++++++++---------- tests/skills/model-role-resolver.test.ts | 6 ++++- 10 files changed, 113 insertions(+), 118 deletions(-) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index 1c1b092e4..f0c1136f0 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -217,20 +217,19 @@ def parse_entry(text: str): def family(model: str) -> str: name = model.lower().split("[", 1)[0] - # The Codex route accepts a provider-qualified id, so `.` or `/` before a - # GPT or o-series name is still Codex. Any other id with a slash belongs to no family here. - if re.fullmatch(r"[a-z][a-z_-]*[./](?:gpt-|o\d).*", name): + if "/" not in name: + if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): + return "claude" + if name.startswith("gpt-") or re.match(r"o\d", name): + return "codex" + if name.startswith("grok-"): + return "grok" + if name.startswith("composer-"): + return "composer" + # The Codex route accepts a provider-qualified id: anything, then `.` or `/`, then a GPT or + # o-series name. Every other id that reaches this point belongs to no family here. + if re.fullmatch(r".+[./](?:gpt-|o\d).*", name): return "codex" - if "/" in name: - return "unknown" - if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): - return "claude" - if name.startswith("gpt-") or re.match(r"o\d", name): - return "codex" - if name.startswith("grok-"): - return "grok" - if name.startswith("composer-"): - return "composer" return "unknown" diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index 781c3b0e6..4aa4890fd 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -213,12 +213,16 @@ describe("family and harness", () => { }) test("a provider-qualified GPT or o-series id is Codex, as the review workers accept it", () => { - const models = ["openai.gpt-5.5", "openai/gpt-5.5", "azure/o3-pro", "anthropic/claude-opus-5-5", "openai/some-model"] + const models = ["openai.gpt-5.5", "openai/gpt-5.5", "azure/o3-pro", "azure2/gpt-5.5", "grok-4.o1", "anthropic/claude-opus-5-5", "openai/some-model"] const out = role(makeProject(map(` doc-review: [${models.join(", ")}]\n work: openai/gpt-5.5 high\n`)), "doc-review") expect(out.entries.map((e: any) => [e.model, e.family])).toEqual([ ["openai.gpt-5.5", "codex"], ["openai/gpt-5.5", "codex"], ["azure/o3-pro", "codex"], + // Any provider prefix the Codex route accepts, digits included. + ["azure2/gpt-5.5", "codex"], + // A name already in another family is not re-read as provider-qualified. + ["grok-4.o1", "grok"], // Only the forms a route accepts are placed: the Claude route takes no namespace. ["anthropic/claude-opus-5-5", "unknown"], ["openai/some-model", "unknown"], From 5b875cf7220662e9094fcc87243bf0a0b3654329 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:44:42 -0700 Subject: [PATCH 42/53] fix(model-roles): give a provider-qualified work entry no engine The resolver put a provider-qualified GPT or o-series id in Codex for every role. The review workers accept that form, but the work engine's Codex route and its controller accept an unqualified id only, so a `work` entry with one was sent to an engine whose preflight refused it. The resolver now reports no engine harness for such a `work` entry. The entry is then served by a native subagent or the session, and the Model role line says why. The work engine's own model validation is unchanged. Co-Authored-By: Claude Code --- skills/ce-brainstorm/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-code-review/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-compound/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-debug/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-doc-review/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-plan/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-setup/scripts/model-role-resolve.py | 13 +++++++++++-- .../ce-simplify-code/scripts/model-role-resolve.py | 13 +++++++++++-- skills/ce-work/references/execution-engines.md | 4 ++-- skills/ce-work/scripts/model-role-resolve.py | 13 +++++++++++-- tests/skills/model-role-resolver.test.ts | 5 ++++- 11 files changed, 105 insertions(+), 21 deletions(-) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/skills/ce-work/references/execution-engines.md b/skills/ce-work/references/execution-engines.md index 43a539f07..f5f3b6811 100644 --- a/skills/ce-work/references/execution-engines.md +++ b/skills/ce-work/references/execution-engines.md @@ -99,11 +99,11 @@ This subsection applies when the resolver reports `inherit` or `entries` for the A candidate from an entry that carries an effort is never equivalent to the current host. It is not skipped as a route to self, and it does not collapse to native execution. It runs through the host's own CLI route at that effort: `opus medium` in a Claude Code session already on Opus runs through the `claude` route at `medium`. -A null `harness` means the resolver could not place the model in a family, so no candidate can be formed from the entry. Unless a native subagent serves it, the run ends on native execution on the session model, and the `Model role` line says that the model's family has no engine route. +A null `harness` means no engine route accepts the entry's model, so no candidate can be formed from the entry. Unless a native subagent serves it, the run ends on native execution on the session model, and the `Model role` line says that no engine route accepts the model. **Fallback.** A candidate from the entry follows the fallback ladder in `references/model-roles.md`, not the unavailable-candidate rules above. Walk the ladder at preflight, before any work is sent. `references/cross-model-execution.md` gives the question that asks the adapter script which effort levels a route accepts. The route's default model is the same candidate with no `model`. After dispatch starts, the recipient lock and the single fallback claim in `references/cross-model-execution.md` apply unchanged. -**Report.** Print the `Model role` line with the run's closing summary. In Return-to-Caller Mode, print it immediately before the structured return, which gains no field for it. `requested_model` holds the model the entry names. `requested_effort` holds the entry's effort whenever an external route ran, even when that route ran at a lower effort or with none applied. `fallback_reason` names a step-down, an effort that was not applied, a default-model substitution, the personal opt-out, or a family with no engine route. The binding's `source` names the `model_roles` entry and the file it came from. +**Report.** Print the `Model role` line with the run's closing summary. In Return-to-Caller Mode, print it immediately before the structured return, which gains no field for it. `requested_model` holds the model the entry names. `requested_effort` holds the entry's effort whenever an external route ran, even when that route ran at a lower effort or with none applied. `fallback_reason` names a step-down, an effort that was not applied, a default-model substitution, the personal opt-out, or a model that no engine route accepts. The binding's `source` names the `model_roles` entry and the file it came from. ## Step 1: Probe host capability diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index f0c1136f0..37ebba6f6 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -36,7 +36,8 @@ - A bad seat carries `"invalid": true`, a `reason`, and its `raw` text, with the other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: - the engine harness for the family (composer -> cursor), null when unknown. + the engine harness for the family (composer -> cursor), null when no engine + route accepts the id (an unknown family, or a provider-qualified Codex id). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -215,6 +216,14 @@ def parse_entry(text: str): return tokens[0], tokens[1] if len(tokens) == 2 else None +def _engine_harness(model: str, model_family: str) -> str | None: + """The work engine harness for an entry, or None when no engine route accepts it. + The engine's Codex route takes an unqualified id only, unlike the review workers.""" + if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + return None + return HARNESS.get(model_family) + + def family(model: str) -> str: name = model.lower().split("[", 1)[0] if "/" not in name: @@ -255,7 +264,7 @@ def _seat(number: int, text: str, role: str, policy: dict) -> dict: model, effort = parsed seat.update(model=model, effort=effort, family=family(model)) if role == "work": - seat["harness"] = HARNESS.get(seat["family"]) + seat["harness"] = _engine_harness(seat["model"], seat["family"]) if role in REVIEW_ROLES: seat["blocked_by"] = _blocked_by(seat["family"], policy) return seat diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index 4aa4890fd..1de2f7c44 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -214,7 +214,10 @@ describe("family and harness", () => { test("a provider-qualified GPT or o-series id is Codex, as the review workers accept it", () => { const models = ["openai.gpt-5.5", "openai/gpt-5.5", "azure/o3-pro", "azure2/gpt-5.5", "grok-4.o1", "anthropic/claude-opus-5-5", "openai/some-model"] - const out = role(makeProject(map(` doc-review: [${models.join(", ")}]\n work: openai/gpt-5.5 high\n`)), "doc-review") + const dir = makeProject(map(` doc-review: [${models.join(", ")}]\n work: openai/gpt-5.5 high\n`)) + const out = role(dir, "doc-review") + // The work engine's Codex route takes only an unqualified id, so the entry gets no engine. + expect(role(dir, "work").entries[0]).toMatchObject({ model: "openai/gpt-5.5", family: "codex", harness: null }) expect(out.entries.map((e: any) => [e.model, e.family])).toEqual([ ["openai.gpt-5.5", "codex"], ["openai/gpt-5.5", "codex"], From 39301ffb3150d88575e8df57b967c38594584732 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:55:37 -0700 Subject: [PATCH 43/53] fix(model-roles): give a bracket-qualified work entry no engine A `work` entry such as `opus[1m] high` resolved to the Claude engine, whose adapter and controller reject a bracketed qualifier, so preflight refused it. The resolver now reports no engine harness for any `work` entry the engine's routes do not accept: an id with a bracketed qualifier, or a provider- qualified Codex id. Such an entry runs on a native subagent or the session, and its Model role line says why. Co-Authored-By: Claude Code --- skills/ce-brainstorm/scripts/model-role-resolve.py | 8 +++++--- skills/ce-code-review/scripts/model-role-resolve.py | 8 +++++--- skills/ce-compound/scripts/model-role-resolve.py | 8 +++++--- skills/ce-debug/scripts/model-role-resolve.py | 8 +++++--- skills/ce-doc-review/scripts/model-role-resolve.py | 8 +++++--- skills/ce-plan/scripts/model-role-resolve.py | 8 +++++--- skills/ce-setup/scripts/model-role-resolve.py | 8 +++++--- skills/ce-simplify-code/scripts/model-role-resolve.py | 8 +++++--- skills/ce-work/scripts/model-role-resolve.py | 8 +++++--- tests/skills/model-role-resolver.test.ts | 3 +++ 10 files changed, 48 insertions(+), 27 deletions(-) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index 37ebba6f6..eed7dd5a1 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -37,7 +37,8 @@ other fields null. An `inherit` seat has model `inherit` and a null family. - `family` is also the seat's peer key. `harness` is set on `work` entries only: the engine harness for the family (composer -> cursor), null when no engine - route accepts the id (an unknown family, or a provider-qualified Codex id). + route accepts the id (an unknown family, a provider-qualified Codex id, or + an id with a bracketed qualifier). - `blocked_by` is set on review seats only: `review_mode_off` when `cross_model_review_mode` resolves to `off`, `peers_allowlist` when the CROSS_MODEL_PEERS environment variable excludes the seat. A seat in the @@ -218,8 +219,9 @@ def parse_entry(text: str): def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. - The engine's Codex route takes an unqualified id only, unlike the review workers.""" - if model_family == "codex" and not re.match(r"gpt-|o\d", model.lower()): + The engine's routes take an unqualified id only: no bracketed qualifier, and no + provider prefix on a Codex id. The review workers accept both.""" + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): return None return HARNESS.get(model_family) diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index 1de2f7c44..f4ab3ff61 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -167,6 +167,9 @@ describe("entries and layering", () => { ["opus[1m]", "max", "claude"], ["opus[1m]", null, "claude"], ]) + // The work engine's routes take an unqualified id only, so a qualified entry gets no engine. + const qualified = role(makeProject(map(" work: opus[1m] high\n")), "work").entries[0] + expect(qualified).toMatchObject({ model: "opus[1m]", family: "claude", harness: null }) // An empty qualifier is not an id. const work = role(dir, "work") expect(work.state).toBe("unset") From e778fc30a9b077d2fa8da6aa07ed5497c10473b8 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 00:55:37 -0700 Subject: [PATCH 44/53] fix(debug): resolve the debug role before the trivial-bug fast path The role hook sat at the start of Phase 1, and the trivial-bug fast path leaves Phase 0 for the fix-choice gate before reaching it. A diagnosis-only run on that path printed nothing about a configured `debug` role. The hook now sits before the fast path, and the hand-off paragraph says that the fast path has no investigation to hand off while the fix hand-off still applies. Codex resolved the role in two of three runs of the entry cell and ran no resolver in three of three no-map runs; Claude and Cursor passed both cells. Co-Authored-By: Claude Code --- skills/ce-debug/references/investigate.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 2f283ef02..d33c63f20 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -17,6 +17,10 @@ Read the **full thread**, not just the opening post. Read every comment, with pa **Everything else** (stack traces, test paths, error messages, descriptions of broken behavior): the problem statement is the input itself, and there is nothing to fetch. +**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `debug` role before any diagnosis, the trivial-bug fast-path below included. The resolver decides the role, not your reading of the config. + +The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route. `mode:pipeline` and `mode:return-to-caller` honor the entry the way interactive use does, and no mode asks about it. + **Trivial-bug fast-path:** if the cause is immediately readable from the input (single-file typo, missing import, obvious null deref or off-by-one with a one-line fix) and verification needs no deep tracing, present the cause and proposed fix, then return to SKILL.md's Phase 2 gate (the fix-choice question it asks before any edit) before editing. On "fix": first take SKILL.md's Phase 3 preconditions, the branch check and the pre-fix scope record, because a one-line fix is still a fix and Phase 4 cannot reconstruct that record afterwards. Then apply it, leave a one-line note explaining the cause, and skip to Phase 4's structured summary. On "diagnosis only": write the summary and stop. When in doubt, run the full framework. A wrong root cause costs more than the ceremony. **Questions:** do not ask by default; investigate first (read code, run tests, trace errors). Ask only when a genuine ambiguity blocks investigation and cannot be resolved by reading code or running tests, and ask one specific question. The exception: if the user signals prior failed attempts ("I've been trying", "keeps failing", "stuck"), ask what they already tried *before* investigating, so you do not repeat a dead end. @@ -25,11 +29,7 @@ Read the **full thread**, not just the opening post. Read every comment, with pa ### Phase 1: Investigate -**Model role.** Open `.compound-engineering/config.local.yaml` and `.compound-engineering/config.yaml` at the repo root by path, because a file search skips that hidden directory. If neither has an active `model_roles:` key, do not read `references/model-roles.md`, do not run its resolver, and print nothing about model roles. Otherwise, even with no entry for this role, read `references/model-roles.md` now and run its resolver for the `debug` role before the investigation starts. The resolver decides the role, not your reading of the config. - -The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route. `mode:pipeline` and `mode:return-to-caller` honor the entry the way interactive use does, and no mode asks about it. - -**Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes. +**Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes. The trivial-bug fast-path has no investigation to hand off: the session states the cause, and the fix hand-off in `references/fix.md` still applies. For a hand-off, create a private run directory with `mktemp -d "${TMPDIR:-/tmp}/ce-debug-XXXXXX"`, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. From 2bcd6059830494dd327ce070a7e5fc51fd82743a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 01:03:44 -0700 Subject: [PATCH 45/53] fix(review): write no seat artifact when the receipt names another model A seat that requested one Claude model and was served by another still published its artifact, and synthesis read it as the configured reviewer. A review seat is never filled by another model, so in seat mode a receipt that names a different model now leaves the seat with no artifact, and the worker logs why. The seat is then reported as dropped. Outside a seat the single-peer pass still publishes and warns, as before. Co-Authored-By: Claude Code --- .../scripts/cross-model-adversarial-review.sh | 16 +++++++++++++++ .../scripts/cross-model-doc-review.sh | 16 +++++++++++++++ .../ce-code-review-cross-model-routes.test.ts | 19 ++++++++++++++++++ .../ce-doc-review-cross-model-routes.test.ts | 20 +++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index b34ca1fcc..c979ef799 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -447,6 +447,18 @@ seat_cleared() { [ "$1" = composer ] && in_csv cursor "$ALLOW" } +# A seat is one model. A receipt that names a different model than the one the +# seat requested means the seat was not served, so nothing is published for it. +# Only the Claude route returns a receipt; a route without one cannot tell. +seat_model_mismatch() { # + [ -n "$SEAT" ] && [ "$1" = claude ] && [ "$MODEL_ACTUAL" != "unverified" ] || return 1 + local prefix + prefix="$(expected_model_prefix "$(route_model claude)")" + [ -n "$prefix" ] || return 1 + case "$MODEL_ACTUAL" in "$prefix"|"$prefix"-*) return 1 ;; esac + return 0 +} + # The Codex desktop app (Codex.app, or ChatGPT.app since the July 2026 merger) # ships `codex` at Contents/Resources without linking it onto PATH (#1272). # Append, never prepend, so a PATH-installed CLI stays authoritative. @@ -1219,6 +1231,10 @@ run_provider() { fi ATTEMPT_HARD_SECS="" + if seat_model_mismatch "$ACTUAL_ROUTE"; then + log "seat $SEAT: requested $(route_model "$ACTUAL_ROUTE"), backend served $MODEL_ACTUAL; a seat is never filled by another model, so no artifact is written" + rm -f "$RAW_OUT" + fi rm -f "$OUT" if [ -s "$RAW_OUT" ]; then _norm="$(mktemp "${TMPDIR:-/tmp}/xmodel-norm-XXXXXX")" diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index 92dd1a510..665e9b221 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -478,6 +478,18 @@ seat_cleared() { [ "$1" = composer ] && in_csv cursor "$ALLOW" } +# A seat is one model. A receipt that names a different model than the one the +# seat requested means the seat was not served, so nothing is published for it. +# Only the Claude route returns a receipt; a route without one cannot tell. +seat_model_mismatch() { # + [ -n "$SEAT" ] && [ "$1" = claude ] && [ "$MODEL_ACTUAL" != "unverified" ] || return 1 + local prefix + prefix="$(expected_model_prefix "$(route_model claude)")" + [ -n "$prefix" ] || return 1 + case "$MODEL_ACTUAL" in "$prefix"|"$prefix"-*) return 1 ;; esac + return 0 +} + # Soft size gate: peer prompt embeds the full document. Over-budget docs skip # cleanly (R11) rather than collapsing silently inside the provider context window. MAX_DOC_CHARS="${CROSS_MODEL_MAX_DOC_CHARS:-200000}" @@ -1200,6 +1212,10 @@ run_provider() { # # Publish ONLY the normalized OUT into RUN_DIR. RAW_OUT lives in the per-peer # workspace and is never a fold-in artifact — if this script dies before normalize # (orphaned launch), synthesis finds no .json in RUN_DIR. + if seat_model_mismatch "$ACTUAL_ROUTE"; then + log "seat $SEAT: requested $(route_model "$ACTUAL_ROUTE"), backend served $MODEL_ACTUAL; a seat is never filled by another model, so no artifact is written" + rm -f "$RAW_OUT" + fi rm -f "$OUT" if [ -s "$RAW_OUT" ]; then _norm="$(mktemp "${TMPDIR:-/tmp}/xmodel-doc-norm-XXXXXX")" diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index 78ccb2ff5..b6930e92e 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -2016,6 +2016,25 @@ describe("cross-model-adversarial-review review seats (CROSS_MODEL_SEAT)", () => expect(r.stderr).toContain(`provider 'codex' ${refused}`) }) + test("a seat whose receipt names another model writes no artifact", () => { + const otherModelStub = + `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"adversarial","findings":[{"title":"t","file":"a.ts","line":1}]},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":10}}}'\n` + const { env } = sandbox(["claude"], otherModelStub) + const override = { CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: "opus" } + + // A seat is one model: when the receipt says another served, the seat was not served. + let runDir = makeRunDir() + let r = run(seatArgs("codex", "claude", runDir), runDir, { ...env, ...override, CROSS_MODEL_SEAT: "1" }) + expect(seatOutputs(r.files)).toEqual([]) + expect(r.stderr).toContain("a seat is never filled by another model") + + // Outside a seat the single-peer pass still publishes and warns, as before. + runDir = makeRunDir() + r = run(seatArgs("codex", "claude", runDir), runDir, { ...env, ...override }) + expect(r.stderr).toContain("WARNING: model mismatch") + expect(seatOutputs(r.files).length).toBe(1) + }) + test("a Claude alias with a bracketed qualifier is checked against its family's receipt", () => { const receiptStub = `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"adversarial","findings":[{"title":"t","file":"a.ts","line":1}]},"modelUsage":{"claude-opus-5-5-20260801":{"inputTokens":10}}}'\n` diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index 93796e18d..6a4e3d76e 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1640,6 +1640,26 @@ describe("cross-model-doc-review review seats (CROSS_MODEL_SEAT)", () => { expect(r.stderr).toContain(`provider 'codex' ${refused}`) }) + test("a seat whose receipt names another model writes no artifact", () => { + const otherModelStub = + `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"whole-doc","findings":[{"section":"X","title":"t"}]},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":10}}}'\n` + const { env } = sandbox(["claude"], otherModelStub) + const doc = makeDoc() + const override = { CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: "opus" } + + // A seat is one model: when the receipt says another served, the seat was not served. + let runDir = makeRunDir() + let r = run(seatArgs("codex", "claude", doc, runDir), runDir, { ...env, ...override, CROSS_MODEL_SEAT: "1" }) + expect(r.files).toEqual([]) + expect(r.stderr).toContain("a seat is never filled by another model") + + // Outside a seat the single-peer pass still publishes and warns, as before. + runDir = makeRunDir() + r = run(seatArgs("codex", "claude", doc, runDir), runDir, { ...env, ...override }) + expect(r.stderr).toContain("WARNING: model mismatch") + expect(r.files.length).toBe(1) + }) + test("a Claude alias with a bracketed qualifier is checked against its family's receipt", () => { const receiptStub = `#!/bin/sh\ncat >/dev/null\nprintf '%s' '{"structured_output":{"reviewer":"whole-doc","findings":[{"section":"X","title":"t"}]},"modelUsage":{"claude-opus-5-5-20260801":{"inputTokens":10}}}'\n` From 0aa1b7f63fa1e1e7ad91879959106cb2c7a6a3e0 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 01:10:55 -0700 Subject: [PATCH 46/53] fix(debug): let the configured model diagnose on the trivial-bug path too The hand-off paragraph kept an exception: on the trivial-bug fast path the session stated the cause, although the `debug` role's deliverable is the diagnosis and the fix. The exception is gone. The entry's model produces the diagnosis on every path, and a trivial bug changes only how deep it goes: the subagent states the cause and the proposed fix and stops. The hand-off rules now sit before the fast path, where that path reads them. Claude and Cursor passed both debug cells. Codex passed the entry cell in two of three runs and the no-map cell in three of three. Co-Authored-By: Claude Code --- skills/ce-debug/references/investigate.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index d33c63f20..911e6e002 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -21,23 +21,23 @@ Read the **full thread**, not just the opening post. Read every comment, with pa The `debug` role's deliverable is the diagnosis and the fix. The role has no peer route. `mode:pipeline` and `mode:return-to-caller` honor the entry the way interactive use does, and no mode asks about it. -**Trivial-bug fast-path:** if the cause is immediately readable from the input (single-file typo, missing import, obvious null deref or off-by-one with a one-line fix) and verification needs no deep tracing, present the cause and proposed fix, then return to SKILL.md's Phase 2 gate (the fix-choice question it asks before any edit) before editing. On "fix": first take SKILL.md's Phase 3 preconditions, the branch check and the pre-fix scope record, because a one-line fix is still a fix and Phase 4 cannot reconstruct that record afterwards. Then apply it, leave a one-line note explaining the cause, and skip to Phase 4's structured summary. On "diagnosis only": write the summary and stop. When in doubt, run the full framework. A wrong root cause costs more than the ceremony. +**Investigation hand-off.** The entry's model produces the diagnosis on every path, and a trivial bug changes only how deep the diagnosis goes. When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model diagnoses: on the trivial-bug fast-path below it states the cause and the proposed fix, and otherwise it gathers the evidence and proposes the causal chain. When the host cannot, the session diagnoses as the rest of this file describes. -**Questions:** do not ask by default; investigate first (read code, run tests, trace errors). Ask only when a genuine ambiguity blocks investigation and cannot be resolved by reading code or running tests, and ask one specific question. The exception: if the user signals prior failed attempts ("I've been trying", "keeps failing", "stuck"), ask what they already tried *before* investigating, so you do not repeat a dead end. +For a hand-off, create a private run directory with `mktemp -d "${TMPDIR:-/tmp}/ce-debug-XXXXXX"`, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. ---- +The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, and the text of SKILL.md's **Secrets in evidence** rule. The subagent follows Phases 1 and 2 of this file through hypothesis formation, or on the fast-path stops once it has stated the cause and the proposed fix. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` and returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. -### Phase 1: Investigate +The session reads `investigation.md` as a proposal. It runs the probes the subagent left, closes any gap, and decides whether the chain passes SKILL.md's causal-chain gate. -**Investigation hand-off.** When the role hands off and the host can give the entry's model to a subagent, one read-only subagent on that model gathers the evidence and proposes the causal chain. When the host cannot, the session investigates as the rest of this file describes. The trivial-bug fast-path has no investigation to hand off: the session states the cause, and the fix hand-off in `references/fix.md` still applies. +**Reporting.** One `Model role debug:` line covers the run, in the format `references/model-roles.md` defines. In interactive use it is the last line of Phase 4's Debug Summary. In `mode:pipeline` and `mode:return-to-caller` it is the optional `model_role` field of the structured return. When the investigation and the fix were served differently, the line reports the fix, and its `reason:` says how the investigation was served. -For a hand-off, create a private run directory with `mktemp -d "${TMPDIR:-/tmp}/ce-debug-XXXXXX"`, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. +**Trivial-bug fast-path:** if the cause is immediately readable from the input (single-file typo, missing import, obvious null deref or off-by-one with a one-line fix) and verification needs no deep tracing, present the cause and proposed fix, then return to SKILL.md's Phase 2 gate (the fix-choice question it asks before any edit) before editing. On "fix": first take SKILL.md's Phase 3 preconditions, the branch check and the pre-fix scope record, because a one-line fix is still a fix and Phase 4 cannot reconstruct that record afterwards. Then apply it, leave a one-line note explaining the cause, and skip to Phase 4's structured summary. On "diagnosis only": write the summary and stop. When in doubt, run the full framework. A wrong root cause costs more than the ceremony. -The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, and the text of SKILL.md's **Secrets in evidence** rule. The subagent follows Phases 1 and 2 of this file through hypothesis formation. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` and returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. +**Questions:** do not ask by default; investigate first (read code, run tests, trace errors). Ask only when a genuine ambiguity blocks investigation and cannot be resolved by reading code or running tests, and ask one specific question. The exception: if the user signals prior failed attempts ("I've been trying", "keeps failing", "stuck"), ask what they already tried *before* investigating, so you do not repeat a dead end. -The session reads `investigation.md` as a proposal. It runs the probes the subagent left, closes any gap, and decides whether the chain passes SKILL.md's causal-chain gate. +--- -**Reporting.** One `Model role debug:` line covers the run, in the format `references/model-roles.md` defines. In interactive use it is the last line of Phase 4's Debug Summary. In `mode:pipeline` and `mode:return-to-caller` it is the optional `model_role` field of the structured return. When the investigation and the fix were served differently, the line reports the fix, and its `reason:` says how the investigation was served. +### Phase 1: Investigate #### 1.1 Reproduce the bug From ef7dba5b6080a39aa832dc041f3c4a7af3a8a7fe Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 01:19:59 -0700 Subject: [PATCH 47/53] fix(plan): match a served model id at an id boundary in the elevation worker A requested full id was compared to the served id by bare prefix, so `claude-opus-5` accepted a receipt for `claude-opus-50-*` and the worker reported `receipt: matched` for another model. The worker now matches the requested stem itself or the stem followed by `-`, in both the receipt check and the choice of which served id to report. Alias matching is unchanged. Co-Authored-By: Claude Code --- skills/ce-brainstorm/scripts/elevation-dispatch.sh | 10 +++++++--- skills/ce-plan/scripts/elevation-dispatch.sh | 10 +++++++--- tests/skills/elevation-dispatch.test.ts | 11 +++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/skills/ce-brainstorm/scripts/elevation-dispatch.sh b/skills/ce-brainstorm/scripts/elevation-dispatch.sh index afff7c2b1..135e6692f 100755 --- a/skills/ce-brainstorm/scripts/elevation-dispatch.sh +++ b/skills/ce-brainstorm/scripts/elevation-dispatch.sh @@ -173,9 +173,12 @@ classify_receipt() { # { [ -z "$served" ] || [ "$served" = "unverified" ]; } && { printf 'unverified'; return; } prefix="$(model_prefix "$1")" [ -z "$prefix" ] && { printf 'unverified'; return; } + # Match at an id boundary: the stem itself, or the stem followed by `-`. + # `claude-opus-5` must not accept `claude-opus-50-*`. + prefix="${prefix%-}" case "$served" in - "$prefix"*) printf 'matched' ;; - *) printf 'mismatch' ;; + "$prefix"|"$prefix"-*) printf 'matched' ;; + *) printf 'mismatch' ;; esac } @@ -261,11 +264,12 @@ run_codex_cmd # (an update notice, wrapper output) does not become the "result" we parse. EVENT="$(grep -a '"type":"result"' "$PEERLOG" 2>/dev/null | tail -1 || true)" PREFIX="$(model_prefix "$MODEL")" +PREFIX="${PREFIX%-}" # jq `keys` is sorted, so keys[0] is not necessarily the served model when # modelUsage carries an auxiliary model too; prefer the requested family's key. SERVED="$(printf '%s' "$EVENT" | jq -r --arg p "$PREFIX" \ '(.modelUsage // {} | keys) as $k - | (if $p != "" then first($k[] | select(startswith($p))) else empty end) // $k[0] // "unverified"' \ + | (if $p != "" then first($k[] | select(. == $p or startswith($p + "-"))) else empty end) // $k[0] // "unverified"' \ 2>/dev/null || printf 'unverified')" # Ship "ok" only on a clean success — a terminal event carries .result even when # truncated/errored (subtype error_*, is_error true). HAS_OUTPUT is a tiny jq diff --git a/skills/ce-plan/scripts/elevation-dispatch.sh b/skills/ce-plan/scripts/elevation-dispatch.sh index afff7c2b1..135e6692f 100755 --- a/skills/ce-plan/scripts/elevation-dispatch.sh +++ b/skills/ce-plan/scripts/elevation-dispatch.sh @@ -173,9 +173,12 @@ classify_receipt() { # { [ -z "$served" ] || [ "$served" = "unverified" ]; } && { printf 'unverified'; return; } prefix="$(model_prefix "$1")" [ -z "$prefix" ] && { printf 'unverified'; return; } + # Match at an id boundary: the stem itself, or the stem followed by `-`. + # `claude-opus-5` must not accept `claude-opus-50-*`. + prefix="${prefix%-}" case "$served" in - "$prefix"*) printf 'matched' ;; - *) printf 'mismatch' ;; + "$prefix"|"$prefix"-*) printf 'matched' ;; + *) printf 'mismatch' ;; esac } @@ -261,11 +264,12 @@ run_codex_cmd # (an update notice, wrapper output) does not become the "result" we parse. EVENT="$(grep -a '"type":"result"' "$PEERLOG" 2>/dev/null | tail -1 || true)" PREFIX="$(model_prefix "$MODEL")" +PREFIX="${PREFIX%-}" # jq `keys` is sorted, so keys[0] is not necessarily the served model when # modelUsage carries an auxiliary model too; prefer the requested family's key. SERVED="$(printf '%s' "$EVENT" | jq -r --arg p "$PREFIX" \ '(.modelUsage // {} | keys) as $k - | (if $p != "" then first($k[] | select(startswith($p))) else empty end) // $k[0] // "unverified"' \ + | (if $p != "" then first($k[] | select(. == $p or startswith($p + "-"))) else empty end) // $k[0] // "unverified"' \ 2>/dev/null || printf 'unverified')" # Ship "ok" only on a clean success — a terminal event carries .result even when # truncated/errored (subtype error_*, is_error true). HAS_OUTPUT is a tiny jq diff --git a/tests/skills/elevation-dispatch.test.ts b/tests/skills/elevation-dispatch.test.ts index 4e1530e72..aacfd4257 100644 --- a/tests/skills/elevation-dispatch.test.ts +++ b/tests/skills/elevation-dispatch.test.ts @@ -313,6 +313,17 @@ describe("elevation-dispatch worker", () => { expect(result.receipt).toBe("mismatch") }) + test("a full id matches its own receipt and not a longer id that starts the same", () => { + const served = (id: string) => + "#!/bin/sh\n" + `printf '%s\\n' '${RESULT_LINE("PLAN BODY", { [id]: { outputTokens: 5 } })}'\n` + // The dated form of the requested id is the same model. + expect(runWorker("claude-opus-5", served("claude-opus-5-20260801")).result.receipt).toBe("matched") + // `claude-opus-50-*` is another model, although it starts with the requested id. + const other = runWorker("claude-opus-5", served("claude-opus-50-20260801")).result + expect(other.served_model).toBe("claude-opus-50-20260801") + expect(other.receipt).toBe("mismatch") + }) + test("an alias with a bracketed qualifier matches its family's receipt", () => { const stub = "#!/bin/sh\n" + From 64d1c3623b2128ba82517b8d59855f0419516abf Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 01:36:12 -0700 Subject: [PATCH 48/53] fix(review): accept only a well-formed bracket qualifier on a Claude model The review workers accepted a Claude alias with a bracketed qualifier through a shell glob, which also let `opus[]`, `opus[a][b]`, and `opus[1m]junk]` through to a provider call that would fail. The workers now require exactly one trailing qualifier of letters and digits, the grammar the role resolver accepts, and check the id before it against the existing patterns. Co-Authored-By: Claude Code --- .../scripts/cross-model-adversarial-review.sh | 16 +++++++++++++++- .../scripts/cross-model-doc-review.sh | 16 +++++++++++++++- .../ce-code-review-cross-model-routes.test.ts | 8 ++++++++ .../ce-doc-review-cross-model-routes.test.ts | 8 ++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index c979ef799..5222b8b5c 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -324,8 +324,22 @@ validate_model_override() { target="$(route_target "$route")" || return 1 [ "$override_target" = "$target" ] || return 0 [ "$target" != "cursor" ] || return 1 + # A Claude id may end in exactly one bracketed qualifier of letters and + # digits, such as [1m]: the grammar the role resolver accepts. The qualifier + # is checked here and the id before it is checked below. + if [ "$route" = claude ]; then + case "$override" in + *\[*|*\]*) + local qualifier="${override#*\[}" + case "$override" in *\]) ;; *) return 1 ;; esac + qualifier="${qualifier%\]}" + case "$qualifier" in ''|*[!A-Za-z0-9]*) return 1 ;; esac + override="${override%%\[*}" + ;; + esac + fi case "$route:$override" in - codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:fable\[*\]|claude:opus\[*\]|claude:sonnet\[*\]|claude:haiku\[*\]|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; + codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; *) return 1 ;; esac } diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index 665e9b221..22a74e49e 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -314,8 +314,22 @@ validate_model_override() { target="$(route_target "$route")" || return 1 [ "$override_target" = "$target" ] || return 0 [ "$target" != "cursor" ] || return 1 + # A Claude id may end in exactly one bracketed qualifier of letters and + # digits, such as [1m]: the grammar the role resolver accepts. The qualifier + # is checked here and the id before it is checked below. + if [ "$route" = claude ]; then + case "$override" in + *\[*|*\]*) + local qualifier="${override#*\[}" + case "$override" in *\]) ;; *) return 1 ;; esac + qualifier="${qualifier%\]}" + case "$qualifier" in ''|*[!A-Za-z0-9]*) return 1 ;; esac + override="${override%%\[*}" + ;; + esac + fi case "$route:$override" in - codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:fable\[*\]|claude:opus\[*\]|claude:sonnet\[*\]|claude:haiku\[*\]|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; + codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-*|grok-cli:grok-*|grok-cursor:cursor-grok-*|grok-cursor:grok-4.7-*|composer:composer-*|opencode:*/*) ;; *) return 1 ;; esac } diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index b6930e92e..3e074e85d 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -1613,6 +1613,14 @@ describe("cross-model-adversarial-review normalization", () => { // A Claude alias may carry one bracketed qualifier, the form a `model_roles` seat can name. const qualified = { CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: "opus[1m]" } expect(emitAdapter("claude", SCRIPT, qualified)).toContain("--model opus[1m]") + // Exactly one qualifier of letters and digits, the grammar the role resolver accepts. + for (const malformed of ["opus[]", "opus[a][b]", "opus[1m]junk]", "claude-opus-5-5[1 m]"]) { + const bad = spawnSync("bash", [SCRIPT, "--emit-adapter", "claude"], { + encoding: "utf8", + env: { ...process.env, CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: malformed }, + }) + expect(bad.status, malformed).toBe(2) + } const crossFamily = spawnSync("bash", [SCRIPT, "--emit-adapter", "composer"], { encoding: "utf8", diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index 6a4e3d76e..8dd7b0429 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1362,6 +1362,14 @@ describe("cross-model-doc-review normalization (R18, KTD5)", () => { // A Claude alias may carry one bracketed qualifier, the form a `model_roles` seat can name. const qualified = { CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: "opus[1m]" } expect(emitAdapter("claude", qualified)).toContain("--model opus[1m]") + // Exactly one qualifier of letters and digits, the grammar the role resolver accepts. + for (const malformed of ["opus[]", "opus[a][b]", "opus[1m]junk]", "claude-opus-5-5[1 m]"]) { + const bad = spawnSync("bash", [SCRIPT, "--emit-adapter", "claude"], { + encoding: "utf8", + env: { ...process.env, CROSS_MODEL_MODEL_OVERRIDE_TARGET: "claude", CROSS_MODEL_MODEL_OVERRIDE: malformed }, + }) + expect(bad.status, malformed).toBe(2) + } const crossFamily = spawnSync("bash", [SCRIPT, "--emit-adapter", "composer"], { encoding: "utf8", From c2ecc942c93bd81ca555360f65cc2ae4de405b2f Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 02:01:06 -0700 Subject: [PATCH 49/53] fix(model-roles): give an editing role subagent the project instructions When the `debug` or `simplify` role is served by a native subagent, that subagent edits project files, and the steps it is handed tell it to follow the project's instructions. A subagent may start without the instructions the session has loaded, so both hand-off prompts now carry the active project instructions and subdirectory-scoped instructions that govern the files it may change, as text or as the paths of the files to read first. Co-Authored-By: Claude Code --- skills/ce-debug/references/fix.md | 2 +- skills/ce-simplify-code/references/model-role-handoff.md | 1 + tests/skills/ce-debug-model-role.test.ts | 6 ++++++ tests/skills/model-role-native-handoff.test.ts | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 9af4b6a99..723262d83 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -11,7 +11,7 @@ When Phase 1 already resolved the role, use that result. **Fix hand-off.** When the role hands off and the host can give the entry's model to a subagent, one subagent on that model does steps 1 to 3 below. The branch check, the confirmation for files that carry the user's unstaged work, the pre-fix scope record, steps 4 and 5, the count of failed attempts, and the commit stay with the session. When the host cannot hand over the model, the session does all five steps as this file describes. - **Before the dispatch**, SKILL.md's Phase 3 preconditions, as this run's mode applies them, are complete for every file the diagnosis names. The session then adds the Phase 2 findings to `handoff.md`: the confirmed causal chain, the proposed fix, the files the fix may change, the test recommendation, and Phase 1's reproduction check. A run that has no run directory yet creates it first, the way `references/investigate.md` does. -- **The subagent** receives the path of `handoff.md` and the text of steps 1 to 3. It edits only the files `handoff.md` names. When the fix needs another file, it stops and returns that file's name, and the session takes the file through the same preconditions before it dispatches again. The subagent does not commit, change branches, or dispatch anything. It returns the paths it changed and the failure it observed before the fix. +- **The subagent** receives the path of `handoff.md`, the text of steps 1 to 3, and the active project instructions and subdirectory-scoped instructions that govern the files `handoff.md` names. A subagent may start without the instructions the session has loaded, so the prompt gives them as text or as the paths of the instruction files to read before it edits. It edits only the files `handoff.md` names. When the fix needs another file, it stops and returns that file's name, and the session takes the file through the same preconditions before it dispatches again. The subagent does not commit, change branches, or dispatch anything. It returns the paths it changed and the failure it observed before the fix. - **After the return**, the session adds the changed paths to the fix-owned files. - **A retry after a failed fix** dispatches again from `handoff.md` once the session has added the new Phase 2 reasoning to it. - **A hand-off that fails mid-edit** is finished inline. The session completes steps 1 to 3 from the working tree as it stands, then runs steps 4 and 5. diff --git a/skills/ce-simplify-code/references/model-role-handoff.md b/skills/ce-simplify-code/references/model-role-handoff.md index d082ede7a..aea82cf7f 100644 --- a/skills/ce-simplify-code/references/model-role-handoff.md +++ b/skills/ce-simplify-code/references/model-role-handoff.md @@ -10,6 +10,7 @@ - the resolved scope, and the mutation boundary Step 3 draws around it - any structure constraint the caller passed, with its plan path - the text of Step 3 and Step 4 from `SKILL.md`, verbatim and without the model role paragraph, as the rules it works under +- the active project instructions and subdirectory-scoped instructions that govern the files in scope, as text or as the paths of the instruction files to read before it edits, because a subagent may start without the instructions the session has loaded - the instruction to perform both steps itself, inside that boundary, and to dispatch no further agent - the return it owes: the findings it applied, grouped as reuse, quality, and efficiency; the findings it skipped; the edits it reverted; and each check it ran, with its outcome diff --git a/tests/skills/ce-debug-model-role.test.ts b/tests/skills/ce-debug-model-role.test.ts index ff2185fcb..c39a71945 100644 --- a/tests/skills/ce-debug-model-role.test.ts +++ b/tests/skills/ce-debug-model-role.test.ts @@ -38,6 +38,12 @@ describe("ce-debug model role wiring", () => { expect(block).toMatch(/the pre-fix scope record[^.]*the commit stay with the session/) }) + test("the fix hand-off gives the editing subagent the project instructions for the files it may change", () => { + const block = fix.slice(fix.indexOf("**Fix hand-off.**"), fix.indexOf("**Test-first:**")) + // A subagent may start without the session's loaded instructions, and step 1 tells it to follow them. + expect(block).toMatch(/\*\*The subagent\*\*[^\n]*project instructions[^\n]*govern the files/) + }) + test("both structured returns carry the report as the optional model_role key", () => { for (const body of [returnToCaller, pipelineMode]) { expect(body).toContain('"model_role":') diff --git a/tests/skills/model-role-native-handoff.test.ts b/tests/skills/model-role-native-handoff.test.ts index aaef73566..c1cf7c571 100644 --- a/tests/skills/model-role-native-handoff.test.ts +++ b/tests/skills/model-role-native-handoff.test.ts @@ -63,6 +63,12 @@ describe("ce-simplify-code `simplify` role hand-off", () => { expect(midEdit).toContain("working tree") expect(midEdit).toMatch(/Step 4[^\n]*again/) }) + + test("the editing subagent's prompt carries the project instructions for the files in scope", () => { + // A subagent may start without the session's loaded instructions, and it edits and verifies project files. + const prompt = section(read("ce-simplify-code", HANDOFF), "The subagent's prompt carries:", "The session writes the Step 5 summary") + expect(prompt).toMatch(/^- [^\n]*project instructions[^\n]*govern the files/m) + }) }) describe("ce-compound `compound` role hand-off", () => { From a406bad4114a38f352bd558f349b18911610bf91 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 02:40:59 -0700 Subject: [PATCH 50/53] fix(model-roles): state once what every native subagent is given A role hand-off to a native subagent sent a prompt that held neither the project's instructions nor a usable path to the skill files its steps name. A probe that printed the investigation hand-off prompt showed both missing on Codex and Cursor. The previous commit patched this for two editing hand-offs only. The shared model-roles reference now states the rule for every native subagent: its prompt gives the project instructions that govern the work, and, when its steps name a file of the skill, the skill directory's absolute path to resolve those names against. The debug investigation, the debug fix, the simplify apply, and the compound draft prompts each point at that rule where the prompt is composed, and the per-prompt wording from the previous commit is removed. Co-Authored-By: Claude Code --- skills/ce-brainstorm/references/model-roles.md | 4 ++++ skills/ce-code-review/references/model-roles.md | 4 ++++ skills/ce-compound/references/model-roles.md | 4 ++++ skills/ce-compound/references/research.md | 2 +- skills/ce-debug/references/fix.md | 2 +- skills/ce-debug/references/investigate.md | 2 +- skills/ce-debug/references/model-roles.md | 4 ++++ skills/ce-doc-review/references/model-roles.md | 4 ++++ skills/ce-plan/references/model-roles.md | 4 ++++ .../references/model-role-handoff.md | 2 +- skills/ce-simplify-code/references/model-roles.md | 4 ++++ skills/ce-work/references/model-roles.md | 4 ++++ tests/skills/ce-debug-model-role.test.ts | 10 +++++++--- tests/skills/model-role-native-handoff.test.ts | 13 ++++++++++--- 14 files changed, 53 insertions(+), 10 deletions(-) diff --git a/skills/ce-brainstorm/references/model-roles.md b/skills/ce-brainstorm/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-brainstorm/references/model-roles.md +++ b/skills/ce-brainstorm/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-code-review/references/model-roles.md b/skills/ce-code-review/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-code-review/references/model-roles.md +++ b/skills/ce-code-review/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-compound/references/model-roles.md b/skills/ce-compound/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-compound/references/model-roles.md +++ b/skills/ce-compound/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-compound/references/research.md b/skills/ce-compound/references/research.md index e65aab6d5..2ca8de0e2 100644 --- a/skills/ce-compound/references/research.md +++ b/skills/ce-compound/references/research.md @@ -114,7 +114,7 @@ Classify a rejected dispatch by whether an agent launched: correct a pre-launch **Body hand-off.** This applies only when `references/model-roles.md` calls for a hand-off of the `compound` role. You still classify in this context, and you remain the only writer of product files. - **Hand-off file.** Write `{run_dir}/draft-handoff.md` with what the draft needs and only this conversation holds: the problem and its symptoms, each thing that was tried and failed and why it failed, the fix, why the fix works, how it was verified, the Phase 0.5 auto memory excerpt when there is one, and step 1's classification with its frontmatter skeleton and track. Write these in full. - - **Dispatch.** Launch one **Body Drafter** subagent on the entry's model once the hand-off file is written and the Related Docs Finder and session history have finished, because it reads their artifacts. Give it file paths to read rather than a retelling of their contents: the hand-off file, and whichever of `related.json` and `session-history.md` exist. Its prompt also carries this step's drafting rules and the chosen track's output sections, verbatim. The drafter writes the body to `{run_dir}/draft.md` and nothing else, under the Phase 1 artifact contract above, and returns only that path. The draft is still a Phase 1 input, so assembly waits for it. + - **Dispatch.** Launch one **Body Drafter** subagent on the entry's model once the hand-off file is written and the Related Docs Finder and session history have finished, because it reads their artifacts. Give it file paths to read rather than a retelling of their contents: the hand-off file, and whichever of `related.json` and `session-history.md` exist. Its prompt also carries this step's drafting rules and the chosen track's output sections, verbatim, and what `references/model-roles.md` says every native subagent is given. The drafter writes the body to `{run_dir}/draft.md` and nothing else, under the Phase 1 artifact contract above, and returns only that path. The draft is still a Phase 1 input, so assembly waits for it. - **Fallback.** When the host cannot hand the entry's model to a subagent, or no usable draft comes back once the rejected-dispatch rule above has been applied, you draft the body in this context as this step describes. #### 3. **Related Docs Finder** (subagent) diff --git a/skills/ce-debug/references/fix.md b/skills/ce-debug/references/fix.md index 723262d83..ed01c7d34 100644 --- a/skills/ce-debug/references/fix.md +++ b/skills/ce-debug/references/fix.md @@ -11,7 +11,7 @@ When Phase 1 already resolved the role, use that result. **Fix hand-off.** When the role hands off and the host can give the entry's model to a subagent, one subagent on that model does steps 1 to 3 below. The branch check, the confirmation for files that carry the user's unstaged work, the pre-fix scope record, steps 4 and 5, the count of failed attempts, and the commit stay with the session. When the host cannot hand over the model, the session does all five steps as this file describes. - **Before the dispatch**, SKILL.md's Phase 3 preconditions, as this run's mode applies them, are complete for every file the diagnosis names. The session then adds the Phase 2 findings to `handoff.md`: the confirmed causal chain, the proposed fix, the files the fix may change, the test recommendation, and Phase 1's reproduction check. A run that has no run directory yet creates it first, the way `references/investigate.md` does. -- **The subagent** receives the path of `handoff.md`, the text of steps 1 to 3, and the active project instructions and subdirectory-scoped instructions that govern the files `handoff.md` names. A subagent may start without the instructions the session has loaded, so the prompt gives them as text or as the paths of the instruction files to read before it edits. It edits only the files `handoff.md` names. When the fix needs another file, it stops and returns that file's name, and the session takes the file through the same preconditions before it dispatches again. The subagent does not commit, change branches, or dispatch anything. It returns the paths it changed and the failure it observed before the fix. +- **The subagent** receives the path of `handoff.md`, the text of steps 1 to 3, and what `references/model-roles.md` says every native subagent is given. It edits only the files `handoff.md` names. When the fix needs another file, it stops and returns that file's name, and the session takes the file through the same preconditions before it dispatches again. The subagent does not commit, change branches, or dispatch anything. It returns the paths it changed and the failure it observed before the fix. - **After the return**, the session adds the changed paths to the fix-owned files. - **A retry after a failed fix** dispatches again from `handoff.md` once the session has added the new Phase 2 reasoning to it. - **A hand-off that fails mid-edit** is finished inline. The session completes steps 1 to 3 from the working tree as it stands, then runs steps 4 and 5. diff --git a/skills/ce-debug/references/investigate.md b/skills/ce-debug/references/investigate.md index 911e6e002..9e2beeeb9 100644 --- a/skills/ce-debug/references/investigate.md +++ b/skills/ce-debug/references/investigate.md @@ -25,7 +25,7 @@ The `debug` role's deliverable is the diagnosis and the fix. The role has no pee For a hand-off, create a private run directory with `mktemp -d "${TMPDIR:-/tmp}/ce-debug-XXXXXX"`, then write `handoff.md` in it. A run with no hand-off creates neither. That file holds the problem statement from Phase 0 and the content of the issue of record when there is one. -The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, and the text of SKILL.md's **Secrets in evidence** rule. The subagent follows Phases 1 and 2 of this file through hypothesis formation, or on the fast-path stops once it has stated the cause and the proposed fix. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` and returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. +The subagent's prompt carries the path of `handoff.md`, the absolute path of this file, the output path `investigation.md` in the same directory, the text of SKILL.md's **Secrets in evidence** rule, and what `references/model-roles.md` says every native subagent is given. The subagent follows Phases 1 and 2 of this file through hypothesis formation, or on the fast-path stops once it has stated the cause and the proposed fix. It is read-only: it edits no file, changes no git state, and dispatches nothing. A probe that needs an edit or a git state change, which includes instrumentation and 1.2's stash experiment, is named in its output for the session to run. A question only the user can answer is named there too. The subagent writes `investigation.md` and returns only that path. The file states the reproduction check and what it showed, each observation with its file:line, what was ruled out and why, what is left for the session, and the proposed causal chain with its uncertain links marked. The session reads `investigation.md` as a proposal. It runs the probes the subagent left, closes any gap, and decides whether the chain passes SKILL.md's causal-chain gate. diff --git a/skills/ce-debug/references/model-roles.md b/skills/ce-debug/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-debug/references/model-roles.md +++ b/skills/ce-debug/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-doc-review/references/model-roles.md b/skills/ce-doc-review/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-doc-review/references/model-roles.md +++ b/skills/ce-doc-review/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-plan/references/model-roles.md b/skills/ce-plan/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-plan/references/model-roles.md +++ b/skills/ce-plan/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-simplify-code/references/model-role-handoff.md b/skills/ce-simplify-code/references/model-role-handoff.md index aea82cf7f..522e23c32 100644 --- a/skills/ce-simplify-code/references/model-role-handoff.md +++ b/skills/ce-simplify-code/references/model-role-handoff.md @@ -10,7 +10,7 @@ - the resolved scope, and the mutation boundary Step 3 draws around it - any structure constraint the caller passed, with its plan path - the text of Step 3 and Step 4 from `SKILL.md`, verbatim and without the model role paragraph, as the rules it works under -- the active project instructions and subdirectory-scoped instructions that govern the files in scope, as text or as the paths of the instruction files to read before it edits, because a subagent may start without the instructions the session has loaded +- what `references/model-roles.md` says every native subagent is given - the instruction to perform both steps itself, inside that boundary, and to dispatch no further agent - the return it owes: the findings it applied, grouped as reuse, quality, and efficiency; the findings it skipped; the edits it reverted; and each check it ran, with its outcome diff --git a/skills/ce-simplify-code/references/model-roles.md b/skills/ce-simplify-code/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-simplify-code/references/model-roles.md +++ b/skills/ce-simplify-code/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/skills/ce-work/references/model-roles.md b/skills/ce-work/references/model-roles.md index 2dd6e8eb9..682cd97f2 100644 --- a/skills/ce-work/references/model-roles.md +++ b/skills/ce-work/references/model-roles.md @@ -62,6 +62,10 @@ A hand-off uses the first route that can serve the entry's model at the entry's 2. **The peer route the calling skill names**, when the skill has one and that route can serve both the model and the effort. 3. **The fallback ladder** below. +## What every native subagent is given + +A native subagent starts with nothing the session has loaded. It also resolves a relative path against the project, not this skill. Its prompt therefore gives it two things. The first is the active project instructions, and any subdirectory-scoped instructions, that govern the work it is handed, as text or as absolute paths. The second, when its steps name a file of this skill, is the absolute path of this skill's directory and the instruction to resolve those file names against it. + ## Fallback for a single-model role When no route can serve the entry as written, or the route that started fails, the step takes the first of these that a route can serve: diff --git a/tests/skills/ce-debug-model-role.test.ts b/tests/skills/ce-debug-model-role.test.ts index c39a71945..949644f85 100644 --- a/tests/skills/ce-debug-model-role.test.ts +++ b/tests/skills/ce-debug-model-role.test.ts @@ -38,10 +38,14 @@ describe("ce-debug model role wiring", () => { expect(block).toMatch(/the pre-fix scope record[^.]*the commit stay with the session/) }) - test("the fix hand-off gives the editing subagent the project instructions for the files it may change", () => { + test("both hand-off prompts point at what the shared reference gives every native subagent", () => { + // The shared reference owns the rule: project instructions and this skill's files, as text or absolute paths. + // Each prompt names it where the prompt is composed, because the role was resolved many steps earlier. + const pointer = /`references\/model-roles\.md` says every native subagent is given/ + const investigation = investigate.slice(investigate.indexOf("**Investigation hand-off.**"), investigate.indexOf("**Reporting.**")) + expect(investigation).toMatch(new RegExp(`subagent's prompt carries[^\\n]*${pointer.source}`)) const block = fix.slice(fix.indexOf("**Fix hand-off.**"), fix.indexOf("**Test-first:**")) - // A subagent may start without the session's loaded instructions, and step 1 tells it to follow them. - expect(block).toMatch(/\*\*The subagent\*\*[^\n]*project instructions[^\n]*govern the files/) + expect(block).toMatch(new RegExp(`\\*\\*The subagent\\*\\*[^\\n]*${pointer.source}`)) }) test("both structured returns carry the report as the optional model_role key", () => { diff --git a/tests/skills/model-role-native-handoff.test.ts b/tests/skills/model-role-native-handoff.test.ts index c1cf7c571..d09addd2f 100644 --- a/tests/skills/model-role-native-handoff.test.ts +++ b/tests/skills/model-role-native-handoff.test.ts @@ -64,10 +64,17 @@ describe("ce-simplify-code `simplify` role hand-off", () => { expect(midEdit).toMatch(/Step 4[^\n]*again/) }) - test("the editing subagent's prompt carries the project instructions for the files in scope", () => { - // A subagent may start without the session's loaded instructions, and it edits and verifies project files. + test("the editing subagent's prompt points at what the shared reference gives every native subagent", () => { const prompt = section(read("ce-simplify-code", HANDOFF), "The subagent's prompt carries:", "The session writes the Step 5 summary") - expect(prompt).toMatch(/^- [^\n]*project instructions[^\n]*govern the files/m) + expect(prompt).toMatch(/^- what `references\/model-roles\.md` says every native subagent is given$/m) + }) + + test("the shared reference gives a native subagent the project instructions and this skill's files", () => { + // A fresh subagent has none of the session's loaded instructions and resolves `references/...` against + // the project. The eight copies are byte-identical (model-roles-reference-parity), so one copy is read. + const given = section(read("ce-simplify-code", "references/model-roles.md"), "## What every native subagent is given", "## Fallback") + expect(given).toContain("project instructions") + expect(given).toContain("absolute path of this skill's directory") }) }) From ed6e5b08fff5b19e1216dd3b4334d2e041fad9d4 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 2 Oct 2026 02:55:46 -0700 Subject: [PATCH 51/53] fix(model-roles): reject a case variant of a routable model id The role resolver lowercased a model id before classifying it, so `Opus` or `GPT-6.1-Sol` came back as a valid Claude or Codex entry. Every route's own validator matches ids case-sensitively, so the route then refused the id: a review seat was dropped and a single-model role fell back, with nothing telling the user why. The resolver now classifies ids as written. An id that only a lowercase spelling would route is skipped with a warning that names that spelling. An id that no route claims in any spelling keeps its own. Co-Authored-By: Claude Code --- docs/guides/configuration.md | 2 +- skills/ce-brainstorm/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-code-review/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-compound/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-debug/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-doc-review/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-plan/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-setup/scripts/model-role-resolve.py | 8 ++++++-- .../ce-simplify-code/scripts/model-role-resolve.py | 8 ++++++-- skills/ce-work/scripts/model-role-resolve.py | 8 ++++++-- tests/skills/model-role-resolver.test.ts | 13 +++++++++++++ 11 files changed, 68 insertions(+), 19 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index bc3e6e863..8e4d3f49b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -147,7 +147,7 @@ With both files, plans are authored on the session model, each plan gets three i | `code-review` | [`ce-code-review`](./ce-code-review.md) | Each independent review of the diff | `cross_model_peer`, `cross_model_model`, `cross_model_effort` | | `compound` | [`ce-compound`](./ce-compound.md) | The learning document | none | -An entry is ` []`. The model is an alias such as `fable`, `opus`, or `sonnet`, or a full model id. The effort is one of `low`, `medium`, `high`, `xhigh`, or `max`. `inherit` means the session model. An entry never carries a CLI flag, a command, or an app-specific model slug, so the same map works in every supported harness. +An entry is ` []`. The model is an alias such as `fable`, `opus`, or `sonnet`, or a full model id. Write an alias or id in its exact case: `Opus` is skipped with a warning that names `opus`. The effort is one of `low`, `medium`, `high`, `xhigh`, or `max`. `inherit` means the session model. An entry never carries a CLI flag, a command, or an app-specific model slug, so the same map works in every supported harness. Only `doc-review` and `code-review` take a list. Each item is one seat: an independent reviewer on that model. Seats run on every review that dispatches the skill's reviewer team, in addition to that team. `ce-code-review` sizes a small, low-risk diff down to a lite or focused review with no team. That review does not apply the `code-review` entry and says so; `depth:full` applies it. A single model on a review role is one seat. A scalar `inherit` or an empty list means no seats. Inside a list, `inherit` is one seat on the session model. diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index eed7dd5a1..ee23349d8 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -212,6 +212,10 @@ def parse_entry(text: str): return "expected ` []`" if not _MODEL_RE.fullmatch(tokens[0]): return f"`{tokens[0]}` is not a model id" + # Every route matches ids case-sensitively, so a case variant of a routable id would be + # classified here and then refused by the route. + if family(tokens[0]) == "unknown" and family(tokens[0].lower()) != "unknown": + return f"`{tokens[0]}` is not a model id a route accepts: ids are case-sensitive, write `{tokens[0].lower()}`" if len(tokens) == 2 and tokens[1] not in EFFORT_SCALE: return f"unknown effort `{tokens[1]}` (expected one of {', '.join(EFFORT_SCALE)})" return tokens[0], tokens[1] if len(tokens) == 2 else None @@ -221,13 +225,13 @@ def _engine_harness(model: str, model_family: str) -> str | None: """The work engine harness for an entry, or None when no engine route accepts it. The engine's routes take an unqualified id only: no bracketed qualifier, and no provider prefix on a Codex id. The review workers accept both.""" - if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model.lower())): + if "[" in model or (model_family == "codex" and not re.match(r"gpt-|o\d", model)): return None return HARNESS.get(model_family) def family(model: str) -> str: - name = model.lower().split("[", 1)[0] + name = model.split("[", 1)[0] if "/" not in name: if name in ("fable", "opus", "sonnet", "haiku") or name.startswith("claude-"): return "claude" diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index f4ab3ff61..798efb777 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -158,6 +158,19 @@ describe("entries and layering", () => { expect(out.entries[1].reason).toContain("banana") }) + test("a case variant of a routable id is rejected with the spelling to use", () => { + // Every route's own validator is case-sensitive, so the resolver must not call `Opus` a Claude id. + const dir = makeProject(map(" plan: Opus high\n doc-review: [sonnet, GPT-6.1-Sol]\n debug: MyOrg/Custom-Model\n")) + const plan = role(dir, "plan") + expect(plan.state).toBe("unset") + expect(plan.warnings[0]).toContain("write `opus`") + const seat = role(dir, "doc-review").entries[1] + expect(seat).toMatchObject({ invalid: true, raw: "GPT-6.1-Sol" }) + expect(seat.reason).toContain("write `gpt-6.1-sol`") + // An id that no route claims in any spelling keeps its own. + expect(role(dir, "debug").entries[0]).toMatchObject({ model: "MyOrg/Custom-Model", family: "unknown" }) + }) + test("a model id may end in one bracketed qualifier", () => { const dir = makeProject(map(" plan: opus[1m] high\n doc-review: [sonnet, opus[1m] max, opus[1m]]\n work: opus[]\n")) expect(role(dir, "plan").entries[0]).toMatchObject({ model: "opus[1m]", effort: "high", family: "claude" }) From eb5432ba99f6802d305f800efac24a9292fdf068 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Wed, 7 Oct 2026 20:05:48 -0700 Subject: [PATCH 52/53] fix(review): hold a seat to its receipt on every route that returns one A review seat is one model, so a seat whose receipt names another model is not served and writes no artifact. That check ran on the Claude route only, because Claude was the one route with a receipt. The workers now read a served-model receipt for Codex and native Grok as well, so a seat on either is held to it the same way. A Codex id may carry a provider namespace such as `openai/` that the served id does not, so the namespace is set aside before the two are compared. Co-Authored-By: Claude Code --- .../scripts/cross-model-adversarial-review.sh | 17 +++++++--- .../scripts/cross-model-doc-review.sh | 17 +++++++--- .../ce-code-review-cross-model-routes.test.ts | 33 +++++++++++++++++++ .../ce-doc-review-cross-model-routes.test.ts | 33 +++++++++++++++++++ 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh index ddb88fc5d..41d2bb719 100755 --- a/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +++ b/skills/ce-code-review/scripts/cross-model-adversarial-review.sh @@ -524,13 +524,20 @@ seat_cleared() { # A seat is one model. A receipt that names a different model than the one the # seat requested means the seat was not served, so nothing is published for it. -# This is checked on the Claude route, where the served id continues the -# requested alias or id; the family rule matches extract_model_receipt's. +# This holds on every route whose adapter returned a receipt; a route without +# one cannot tell. The family rule matches extract_model_receipt's, except that +# a Codex id may carry a provider namespace the served id does not. seat_model_mismatch() { # - [ -n "$SEAT" ] && [ "$1" = claude ] && [ "$MODEL_ACTUAL" != "unverified" ] || return 1 + [ -n "$SEAT" ] && [ "$MODEL_ACTUAL" != "unverified" ] || return 1 local family - family="$(route_model claude)"; family="${family%%\[*}" - case "$family" in fable|opus|sonnet|haiku) family="claude-$family" ;; esac + family="$(route_model "$1")" + [ "$family" != auto ] || return 1 + family="${family%%\[*}" + case "$1:$family" in + claude:fable|claude:opus|claude:sonnet|claude:haiku) family="claude-$family" ;; + codex:*[./]gpt-*) family="gpt-${family##*[./]gpt-}" ;; + codex:*[./]o[0-9]*) family="o${family##*[./]o}" ;; + esac case "$MODEL_ACTUAL" in "$family"|"$family"-*|"$family"\[*) return 1 ;; esac return 0 } diff --git a/skills/ce-doc-review/scripts/cross-model-doc-review.sh b/skills/ce-doc-review/scripts/cross-model-doc-review.sh index e16a82d22..52c61d86c 100755 --- a/skills/ce-doc-review/scripts/cross-model-doc-review.sh +++ b/skills/ce-doc-review/scripts/cross-model-doc-review.sh @@ -554,13 +554,20 @@ seat_cleared() { # A seat is one model. A receipt that names a different model than the one the # seat requested means the seat was not served, so nothing is published for it. -# This is checked on the Claude route, where the served id continues the -# requested alias or id; the family rule matches extract_model_receipt's. +# This holds on every route whose adapter returned a receipt; a route without +# one cannot tell. The family rule matches extract_model_receipt's, except that +# a Codex id may carry a provider namespace the served id does not. seat_model_mismatch() { # - [ -n "$SEAT" ] && [ "$1" = claude ] && [ "$MODEL_ACTUAL" != "unverified" ] || return 1 + [ -n "$SEAT" ] && [ "$MODEL_ACTUAL" != "unverified" ] || return 1 local family - family="$(route_model claude)"; family="${family%%\[*}" - case "$family" in fable|opus|sonnet|haiku) family="claude-$family" ;; esac + family="$(route_model "$1")" + [ "$family" != auto ] || return 1 + family="${family%%\[*}" + case "$1:$family" in + claude:fable|claude:opus|claude:sonnet|claude:haiku) family="claude-$family" ;; + codex:*[./]gpt-*) family="gpt-${family##*[./]gpt-}" ;; + codex:*[./]o[0-9]*) family="o${family##*[./]o}" ;; + esac case "$MODEL_ACTUAL" in "$family"|"$family"-*|"$family"\[*) return 1 ;; esac return 0 } diff --git a/tests/skills/ce-code-review-cross-model-routes.test.ts b/tests/skills/ce-code-review-cross-model-routes.test.ts index 60cd478fc..a058f47ec 100644 --- a/tests/skills/ce-code-review-cross-model-routes.test.ts +++ b/tests/skills/ce-code-review-cross-model-routes.test.ts @@ -1266,6 +1266,39 @@ describe("cross-model-adversarial-review review seats (CROSS_MODEL_SEAT)", () => expect(seatOutputs(r.files).length).toBe(1) }) + test("every route that returns a receipt drops a seat served by another model", () => { + // Codex and native Grok report the served model too, so a seat on either is held to it. + for (const [bin, target, host, model, other] of [ + ["codex", "codex", "claude", "gpt-seat-model", "gpt-other-model"], + ["grok", "grok", "claude", "grok-seat-model", "grok-other-model"], + ]) { + const seat = { CROSS_MODEL_SEAT: "1", CROSS_MODEL_MODEL_OVERRIDE_TARGET: target, CROSS_MODEL_MODEL_OVERRIDE: model } + let sb = sandbox([bin], acpStream({ text: review(), meta: served(other) })) + let runDir = makeRunDir() + let r = run(seatArgs(host, target, runDir), runDir, { ...sb.env, ...seat }) + expect(seatOutputs(r.files), target).toEqual([]) + expect(r.stderr).toContain("a seat is never filled by another model") + + sb = sandbox([bin], acpStream({ text: review(), meta: served(`${model}-20260801`) })) + runDir = makeRunDir() + r = run(seatArgs(host, target, runDir), runDir, { ...sb.env, ...seat }) + expect(seatOutputs(r.files), target).toEqual([`adversarial-${target}-s1.json`]) + } + + // A Codex id may carry a provider namespace that the served id does not. + for (const requested of ["openai/gpt-seat-model", "openai.gpt-seat-model"]) { + const sb = sandbox(["codex"], acpStream({ text: review(), meta: served("gpt-seat-model") })) + const runDir = makeRunDir() + const r = run(seatArgs("claude", "codex", runDir), runDir, { + ...sb.env, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "codex", + CROSS_MODEL_MODEL_OVERRIDE: requested, + }) + expect(seatOutputs(r.files), requested).toEqual(["adversarial-codex-s1.json"]) + } + }) + test("a Claude alias with a bracketed qualifier is checked against its family's receipt", () => { const { env } = sandbox(["claude"], acpStream({ text: review(), meta: served("claude-opus-5-5-20260801") })) const runDir = makeRunDir() diff --git a/tests/skills/ce-doc-review-cross-model-routes.test.ts b/tests/skills/ce-doc-review-cross-model-routes.test.ts index 55e57707f..f30471084 100644 --- a/tests/skills/ce-doc-review-cross-model-routes.test.ts +++ b/tests/skills/ce-doc-review-cross-model-routes.test.ts @@ -1089,6 +1089,39 @@ describe("cross-model-doc-review review seats (CROSS_MODEL_SEAT)", () => { expect(seatOutputs(r.files).length).toBe(1) }) + test("every route that returns a receipt drops a seat served by another model", () => { + // Codex and native Grok report the served model too, so a seat on either is held to it. + for (const [bin, target, host, model, other] of [ + ["codex", "codex", "claude", "gpt-seat-model", "gpt-other-model"], + ["grok", "grok", "claude", "grok-seat-model", "grok-other-model"], + ]) { + const seat = { CROSS_MODEL_SEAT: "1", CROSS_MODEL_MODEL_OVERRIDE_TARGET: target, CROSS_MODEL_MODEL_OVERRIDE: model } + let sb = sandbox([bin], acpStream({ text: review(), meta: served(other) })) + let runDir = makeRunDir() + let r = run(seatArgs(host, target, runDir), runDir, { ...sb.env, ...seat }) + expect(seatOutputs(r.files), target).toEqual([]) + expect(r.stderr).toContain("a seat is never filled by another model") + + sb = sandbox([bin], acpStream({ text: review(), meta: served(`${model}-20260801`) })) + runDir = makeRunDir() + r = run(seatArgs(host, target, runDir), runDir, { ...sb.env, ...seat }) + expect(seatOutputs(r.files), target).toEqual([`whole-doc-${target}-s1.json`]) + } + + // A Codex id may carry a provider namespace that the served id does not. + for (const requested of ["openai/gpt-seat-model", "openai.gpt-seat-model"]) { + const sb = sandbox(["codex"], acpStream({ text: review(), meta: served("gpt-seat-model") })) + const runDir = makeRunDir() + const r = run(seatArgs("claude", "codex", runDir), runDir, { + ...sb.env, + CROSS_MODEL_SEAT: "1", + CROSS_MODEL_MODEL_OVERRIDE_TARGET: "codex", + CROSS_MODEL_MODEL_OVERRIDE: requested, + }) + expect(seatOutputs(r.files), requested).toEqual(["whole-doc-codex-s1.json"]) + } + }) + test("a Claude alias with a bracketed qualifier is checked against its family's receipt", () => { const { env } = sandbox(["claude"], acpStream({ text: review(), meta: served("claude-opus-5-5-20260801") })) const runDir = makeRunDir() From c3057e7243e9d38d61b06369d259e982c0afc53b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Wed, 7 Oct 2026 20:26:04 -0700 Subject: [PATCH 53/53] fix(model-roles): report older keys and invalid seats as their consumers see them The resolver's full summary, which `ce-setup` shows as the current routing, was wrong in two cases. An older key with a closed vocabulary was reported from the first file that set it, even when the value was unsupported. The review skills skip an unsupported `cross_model_peer` and read the next file, so the summary now does the same. A review role whose list held an invalid seat showed an empty or shortened value with no warning. The summary now keeps each invalid seat as written, with the reason it does not run. Co-Authored-By: Claude Code --- .../ce-brainstorm/scripts/model-role-resolve.py | 15 ++++++++++++--- .../scripts/model-role-resolve.py | 15 ++++++++++++--- .../ce-compound/scripts/model-role-resolve.py | 15 ++++++++++++--- skills/ce-debug/scripts/model-role-resolve.py | 15 ++++++++++++--- .../ce-doc-review/scripts/model-role-resolve.py | 15 ++++++++++++--- skills/ce-plan/scripts/model-role-resolve.py | 15 ++++++++++++--- skills/ce-setup/scripts/model-role-resolve.py | 15 ++++++++++++--- .../scripts/model-role-resolve.py | 15 ++++++++++++--- skills/ce-work/scripts/model-role-resolve.py | 15 ++++++++++++--- tests/skills/model-role-resolver.test.ts | 17 +++++++++++++++++ 10 files changed, 125 insertions(+), 27 deletions(-) diff --git a/skills/ce-brainstorm/scripts/model-role-resolve.py b/skills/ce-brainstorm/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-brainstorm/scripts/model-role-resolve.py +++ b/skills/ce-brainstorm/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-code-review/scripts/model-role-resolve.py b/skills/ce-code-review/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-code-review/scripts/model-role-resolve.py +++ b/skills/ce-code-review/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-compound/scripts/model-role-resolve.py b/skills/ce-compound/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-compound/scripts/model-role-resolve.py +++ b/skills/ce-compound/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-debug/scripts/model-role-resolve.py b/skills/ce-debug/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-debug/scripts/model-role-resolve.py +++ b/skills/ce-debug/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-doc-review/scripts/model-role-resolve.py b/skills/ce-doc-review/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-doc-review/scripts/model-role-resolve.py +++ b/skills/ce-doc-review/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-plan/scripts/model-role-resolve.py b/skills/ce-plan/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-plan/scripts/model-role-resolve.py +++ b/skills/ce-plan/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-setup/scripts/model-role-resolve.py b/skills/ce-setup/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-setup/scripts/model-role-resolve.py +++ b/skills/ce-setup/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-simplify-code/scripts/model-role-resolve.py b/skills/ce-simplify-code/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-simplify-code/scripts/model-role-resolve.py +++ b/skills/ce-simplify-code/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/skills/ce-work/scripts/model-role-resolve.py b/skills/ce-work/scripts/model-role-resolve.py index ee23349d8..a9cf978a0 100755 --- a/skills/ce-work/scripts/model-role-resolve.py +++ b/skills/ce-work/scripts/model-role-resolve.py @@ -86,6 +86,11 @@ } STRUCTURED_KEYS = ("work_engine_preferences", "work_engine_effort") WORK_ENGINE_MODES = ("off", "prefer", "require") +# An older key with a closed vocabulary: its consumer skips any other value and reads the next file. +EXISTING_KEY_VALUES = { + "work_engine_mode": WORK_ENGINE_MODES, + "cross_model_peer": ("codex", "claude", "grok", "cursor", "composer", "opencode"), +} REVIEW_MODES = ("auto", "off") # An id may end in one bracketed qualifier, such as a context-window variant: `opus[1m]`. @@ -338,7 +343,7 @@ def _existing_keys(role: str, layers: list) -> list: source = next((s for s, parsed in layers if key in parsed["top"]), None) value = None else: - value, source = _ordinary_scalar(key, layers, WORK_ENGINE_MODES if key == "work_engine_mode" else None) + value, source = _ordinary_scalar(key, layers, EXISTING_KEY_VALUES.get(key)) if source is not None: found.append({"key": key, "file": source, "value": value}) return found @@ -350,8 +355,12 @@ def _describe(result: dict, existing: list) -> dict: if result["state"] == "inherit": return {"from": "map", "value": "inherit"} if result["state"] == "entries": - seats = [e for e in result["entries"] if not e.get("invalid")] - return {"from": "map", "value": ", ".join(" ".join(filter(None, (e["model"], e["effort"]))) for e in seats)} + # An invalid seat stays visible, with the reason it does not run. + seats = ( + f"{e['raw']} (invalid: {e['reason']})" if e.get("invalid") else " ".join(filter(None, (e["model"], e["effort"]))) + for e in result["entries"] + ) + return {"from": "map", "value": ", ".join(seats)} if existing: shown = (f"{k['key']}: {'(set)' if k['value'] is None else k['value']}" for k in existing) return {"from": "existing_key", "value": "; ".join(shown)} diff --git a/tests/skills/model-role-resolver.test.ts b/tests/skills/model-role-resolver.test.ts index 798efb777..36d89aa30 100644 --- a/tests/skills/model-role-resolver.test.ts +++ b/tests/skills/model-role-resolver.test.ts @@ -396,6 +396,23 @@ describe("--all", () => { expect(doc.shadowed).toEqual(["cross_model_peer"]) expect(byRole(all, "code-review").effective.from).toBe("existing_key") }) + + test("an older key is reported from the layer its consumer would use", () => { + // The review skills skip an unsupported `cross_model_peer` and read the next file. + const dir = makeProject("cross_model_peer: codex\n", "cross_model_peer: bogus\n") + const doc = byRole(run(dir, ["--all"]), "doc-review") + expect(doc.existing_keys).toEqual([{ key: "cross_model_peer", file: "team", value: "codex" }]) + expect(doc.effective).toEqual({ from: "existing_key", value: "cross_model_peer: codex" }) + }) + + test("an invalid review seat stays in the effective summary with its reason", () => { + const dir = makeProject(map(" doc-review: [opus impossible]\n code-review: [sonnet, opus impossible]\n")) + const all = run(dir, ["--all"]) + const doc = byRole(all, "doc-review") + expect(doc.effective.from).toBe("map") + expect(doc.effective.value).toContain("opus impossible (invalid: unknown effort `impossible`") + expect(byRole(all, "code-review").effective.value).toMatch(/^sonnet, opus impossible \(invalid: /) + }) }) describe("no config to read", () => {