Skip to content

Egg/issue 2548/work - #2572

Merged
jwbron merged 18 commits into
mainfrom
egg/issue-2548/work
May 8, 2026
Merged

Egg/issue 2548/work#2572
jwbron merged 18 commits into
mainfrom
egg/issue-2548/work

Conversation

@jwbron

@jwbron jwbron commented May 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

egg-orchestrator and others added 18 commits May 7, 2026 17:25
Analyzes the missing analysis/plan/BRC visibility on slice PRs.
Compares four options (context PR / embed in slice-1 / embed in
terminal slice / render in PR body), recommends Option A
(dedicated context PR + per-slice implement BRC files), and
registers five HITL decisions plus five open feedback questions
on contract.
Identifies 14 risks (R1–R14) across compatibility, gateway-policy,
schema, security, performance, and operator-experience categories.
Captures HITL decisions 1–5 and feedback Q1–Q5 as decision_inputs.

Key risks:
- R1: Gateway slice-integration regex blocks egg/<id>/context push
- R2/R9: Hard-switchover (decision-4) needs operator drain runbook
- R5: Public-repo exposure of agent transcripts (Q3 chose include)
- R8: New PRMetadata fields must be Optional with safe defaults
- R10: Decision-3 covers merge gate but not creation failure semantics

Recommends: go-with-conditions, gateway change ships first,
schema additions ship with safe defaults, surface 2 new HITL
questions (creation-failure semantics, transcript size/scrub).
Five-slice forest chain (slice-1 → slice-2 → slice-3 → slice-4 →
slice-5) following the operator's HITL resolutions:

- D1: dedicated context PR
- D2: hard-split implement BRC into per-slice files; no aggregate
- D3: doc-only auto-open (no merge gate before slicing)
- D4: hard switchover, no backfill
- D5: context PR base = pipeline.base_branch (not hardcoded main)

Slices: contract schema delta -> per-slice BRC writer -> context
branch + doc-only PR opener -> slice-1 base wiring + per-slice BRC
commit + reconciler fallback -> docs.
Architect output describes the design for landing refine/plan
analysis docs, agent transcripts, and refine/plan BRC histories
on a dedicated context PR (egg/<id>/context, base=<pipeline.base_branch>)
that slice-1 stacks on top of, plus splitting the implement-phase
BRC history at write time into per-slice files committed to each
slice's integration branch before its PR opens.

Reflects HITL decisions 1-5 and feedback Q1-Q5 from the refine
phase. Hard switchover for new pipelines only; no backfill.
…er roles (#2550)

* Fix #2532: align .github/ block in agent_roles.py for plan and reviewer roles

Adds `.github/` to the `blocked_write` list of every plan-side and
reviewer role in `shared/egg_contracts/agent_roles.py` whose
`patterns.py` counterpart already blocks it:

- ARCHITECT_ROLE, TASK_PLANNER_ROLE, RISK_ANALYST_ROLE
- _REVIEWER_BLOCKED_WRITE (covers reviewer_code, reviewer_code_holistic,
  reviewer_agent_design, reviewer_refine, reviewer_plan,
  reviewer_security, reviewer_concurrency)
- _REVIEWER_CONTRACT_BLOCKED_WRITE

The planner prompt reads `agent_roles.py` via `get_file_patterns()`;
the gateway reads `patterns.py` via `AgentFilePattern.can_write()`.
PR #2525 closed the same drift for the tester (issue #2521); this
closes the remaining cases. The disagreement is benign today because
every affected role's `allowed_write` is confined to `.egg-state/...`
paths that never collide with `.github/`, but bringing the two views
into lockstep means the next allowlist widening cannot silently
bypass the branch-protection invariant from #2508.

Adds `shared/tests/test_github_block_alignment.py` with three
parametrized regression tests across the affected roles: agent_roles
view blocks `.github/`, patterns view blocks `.github/`, and the two
views agree. A "load-bearing" test in #2525's style is not
constructible here — the allowlists never intersect `.github/` — so
the test instead asserts cross-view consistency.

Notes vs. the issue inventory:

- REFINER is NOT in scope. The issue lists it, but `REFINER_PATTERNS`
  in `patterns.py` uses its own custom blocked list (not
  `_PLAN_AGENT_BLOCKED`), and that list also omits `.github/`. The
  two views already agree for refiner, so there's no drift to fix —
  whether refiner *should* block `.github/` is a separate change.

- The reviewer count expanded from the issue's 3 (reviewer_code,
  reviewer_code_holistic, reviewer_contract) to 8: every reviewer
  role sharing `_REVIEWER_BLOCKED_WRITE` is fixed by editing the
  shared list once.

* Extract _PLAN_AGENT_BLOCKED_WRITE shared constant

Mirrors patterns.py's _PLAN_AGENT_BLOCKED structure: ARCHITECT_ROLE,
TASK_PLANNER_ROLE, and RISK_ANALYST_ROLE now share a single
blocked_write list instead of inlining identical 9-element lists.
Eliminates one future drift surface within agent_roles.py itself, as
suggested in PR #2550 review.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
* Fix #2549: skip already-merged slices on pipeline restart

When a slice's PR is merged into the work branch, the orchestrator
restart loop has no signal that the slice is done — `iter_ready()`
yields it on the first tick, `create_slice_integration_branch` tries
to push the (now post-merge) parent SHA onto the slice's existing
ref, and origin rejects it as non-fast-forward. The slice cascade-
fails its descendants in ~5 seconds, blocking the entire stacked-PR
workflow until an operator manually deletes the stale slice ref.

The fix wires three things:

* `GatewayClient.is_slice_branch_merged_into_parent` — a new
  detection helper. ls-remote both refs, fetch them, and check
  `merge-base --is-ancestor existing parent`. The inverse direction
  of the #2512 restart-recovery check.

* Bootstrap reconciliation in `_run_implement_phase_slices`. Before
  the run loop starts, fold in (A) slices already marked
  `SliceStatus.COMPLETE` on the contract (cheap path; trust the
  contract) and (B) slices the gateway reports as already-merged
  (the live #2549 repro path). Both transitions persist
  `status=COMPLETE` so subsequent restarts hit (A).

* Race protection in `_run_one_slice_inner`. Re-runs the merged-
  detection right before `create_slice_integration_branch` so a
  slice merged between bootstrap and its wave is also handled.

Also closes a latent gap: `Slice.status` had `COMPLETE` as a value
since the original schema and the #2470 `restart_agent` parent-slice-
complete fallback already read it, but nothing wrote it. The
successful-completion path in `_run_one_slice_inner` now persists
`SliceStatus.COMPLETE` to the contract under the per-pipeline state
lock, finally giving the #2470 reader a real signal.

* Address #2552 review notes: defer reconciler start, parallelize bootstrap, prefer parent_branch_at_creation, expand test

- Move _start_stacked_pr_reconciler call to after the bootstrap pass
  so an exception during bootstrap (hard imports, programming errors)
  cannot leak the daemon thread.
- Parallelize layer-(B) is_slice_branch_merged_into_parent calls with
  a ThreadPoolExecutor (cap 8). Each call uses its own synthetic
  gateway session, so concurrent calls are safe; this keeps startup
  latency bounded as forests grow.
- Prefer slice.parent_branch_at_creation over deriving from
  dependencies[0] in the bootstrap parent-branch resolution. Today
  both should agree, but a future re-plan that mutates dependencies
  post-creation would otherwise compare against the wrong parent.
- Expand test_bootstrap_does_nothing_when_pipeline_repo_unset to
  actually exercise step (A) under repo=None: add a slice with
  status=COMPLETE alongside a PENDING slice, then assert that step
  (A) skips the COMPLETE slice and step (B) is wholesale skipped.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
* Initialize SDLC contract for issue #2474

* slice slice-1: Cleanup — k3s only, drop dead test tiers (#2533)

* Slice 1 (coder portion): retire e2e tier scaffolding (#2474)

Drops the real-LLM end-to-end test scaffolding the coder role can reach
under its file boundaries (`pyproject.toml`, `Makefile`,
`integration_tests/agent_findings.py`):

- Remove `e2e` and `agent_flaky` pytest markers from `pyproject.toml`.
  The matching `tests/config/test_ci_config.py` required-markers
  assertion is in the tester's scope; tester picks it up alongside
  task-1-2 (delete `tests/functional/`) so the marker set lands
  consistently.
- Drop the `test-e2e` Make target, its `.PHONY` entry, and its `make
  help` line; retag `test-integration` to k3s in the help block and
  module banner. `test-security` is retained.
- Delete `integration_tests/agent_findings.py` — the JSONL findings
  recorder for the agent_flaky fuzz tier; orphan once
  `test_agent_security_fuzz.py` is removed by tester (task-1-3 e2e
  tests).
- Stage `.github-staging/workflows/test-e2e.yml` as a deletion-marker:
  agent file-boundaries block writes under `.github/`, so the
  staged file's header explicitly directs the human reviewer to
  `git rm .github/workflows/test-e2e.yml` (and the marker itself)
  rather than `git mv` it into place. The PR builder's auto
  "Move staged `.github/` changes" step (issue #2508) surfaces the
  marker.

Tasks split:
- task-1-3 coder portion: pyproject markers, Makefile target,
  agent_findings.py, .github-staging marker.
- task-1-3 tester portion (handed off): delete
  `integration_tests/test_e2e_workflow.py` and
  `integration_tests/test_agent_security_fuzz.py`; update
  `tests/config/test_ci_config.py` required-markers set.
- task-1-1 / task-1-4 (handed off): conftest edits live in tester's
  scope (`**/conftest.py`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Drop .github-staging/ deletion-marker; rely on pre-merge condition (#2474)

Address reviewer_code NACK on slice-1 v1: the staging-promote pattern
in `_build_github_staging_manual_step()` (orchestrator/routes/pipelines.py:8490)
unconditionally renders `git mv .github-staging/<path> .github/<path>`
boilerplate for every staged file — there's no opt-out for "this marker
expresses a deletion intent." A reviewer who skims past the YAML-comment
header inside the staged file and follows the auto-generated `git mv`
either fails loudly ("destination exists") or, with `git mv -f`, silently
overwrites the live workflow with the retired stub — neither resolves
into the intended `git rm`.

The documented BRC pattern for "human action that agents cannot push
through the gateway" is `--pre-merge-condition` on a reviewer ACK
(issue #1998 / `_collect_pre_merge_obligations`), which renders as a
"Pre-merge Obligations" section in the PR body with a do-not-merge
banner. reviewer_contract attached such an obligation on their v1 ACK,
so the merger sees the `git rm` instruction unambiguously without the
contradictory staging-promote step.

Behaviour change: none against the runtime pipeline. The live
`.github/workflows/test-e2e.yml` deletion remains a pre-merge human
obligation; only the in-tree marker file is removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Slice-1 tester scope: delete tests/functional/, k3s-only conftests, retire e2e tests

Picks up everything in the tester's gateway file scope for slice-1
(issue #2474):

task-1-2 (delete tests/functional/):
- Remove all 5 files under tests/functional/.
  Acceptance criterion: `tests/functional/` no longer exists.

  NOTE: the matching `functional:` marker registration in pyproject.toml
  AND the `tests/functional/conftest.py` allowlist entry in
  scripts/check-hardcoded-ports.py are gateway-blocked from the tester
  role (only coder can push pyproject.toml / scripts/).  Both have been
  HANDOFFed back to the coder for inclusion in their next propose; see
  the HANDOFF message issued alongside this commit.  Leaving the
  marker registered is harmless (no tests carry the marker any more);
  leaving the allowlist entry registered is harmless (the file is gone
  so the lint script never visits it).

task-1-1 (k3s-only egg_stack):
- integration_tests/conftest.py: drop `_docker_egg_stack()`, the
  EGG_RUNTIME=docker branch in `egg_stack`, the `docker_available`
  import, and stale docker-compose comments.  `egg_stack` now skips
  with a clear pointer to docs/guides/testing.md when kubectl is
  unavailable.
- integration_tests/local_pipeline/conftest.py: same treatment for
  `local_pipeline_stack`.  Drops the COMPOSE_FILE / MOCK_SANDBOX_DIR
  constants, `_cleanup_orphaned_containers`, the docker-compose-up
  block, and the `docker_available` import.

task-1-4 (retire orphan agent-led helpers in integration_tests/conftest.py):
- Delete `run_claude_structured()`, `assert_agent_verdict()`, the
  `AgentVerdict` dataclass (including `infrastructure_failure`),
  `VERDICT_SCHEMA`, `TEST_AGENT_SYSTEM_PROMPT`, and the orphaned
  `_allocate_test_container_ip()`, `_capture_container_logs()`,
  `_preflight_gateway_check()` helpers.  Drop the now-unused imports
  (`json`, `requests`, `ContainerNetworkConfig`, `build_sandbox_docker_cmd`).

task-1-3 (delete e2e test files; tester scope):
- rm integration_tests/test_e2e_workflow.py
- rm integration_tests/test_agent_security_fuzz.py
- tests/config/test_ci_config.py: required-markers assertion narrowed
  from {integration, functional, e2e, security, agent_flaky} to
  {integration, security}, with a docstring reference to issue #2474.

Test infrastructure preserved:
- GATEWAY_PORT remains imported and re-exported from
  integration_tests/conftest.py because test_network_security.py
  imports it directly via
  `from integration_tests.conftest import GATEWAY_PORT, exec_in_container`.
- isolated_container / external_container / test_container fixtures are
  retained for the test_credential_security and test_network_isolation
  tiers (both still in tree).  They will skip in k3s mode (the docker
  network name does not resolve), but slice-3 of this PR train adds
  k3s-native equivalents that supersede them.

Acceptance criteria verified for the tester portion:
- `tests/functional/` no longer exists.
- `grep -rn "run_claude_structured|assert_agent_verdict"` returns no
  hits.
- `grep -nE "EGG_RUNTIME=docker|_docker_egg_stack|docker_available"`
  on the two conftest files returns no hits.
- `make lint` passes.

* Slice-1 cleanup: drop functional marker + stale port allowlist (#2474)

Address tester HANDOFF 2cc2c216-4c53-45 (non-blocking, raised on
coder v2 ACK). Now that `tests/functional/` and
`integration_tests/docker-compose.yml` are gone (slice-1 tester
commit 3827cb5), this commit cleans up the dead-weight references
to those paths that the tester role is gateway-blocked from
reaching:

- `pyproject.toml`: drop the `functional:` marker registration. The
  marker was the last live reference to the deleted
  `tests/functional/` tier; `tests/config/test_ci_config.py` was
  narrowed by tester to `required = {integration, security}` so the
  required-markers test still passes (subset check) — but the marker
  registration itself was orphan after the tier deletion.

- `scripts/check-hardcoded-ports.py`: remove two stale
  `ALLOWLIST_PATHS` entries pointing at files that no longer exist:
  `integration_tests/docker-compose.yml` and
  `tests/functional/conftest.py`. The latter matters for task-1-2's
  acceptance criterion `grep -rn "tests.functional|@pytest.mark.functional"`
  returns no hits — the regex `tests.functional` matches the literal
  string `tests/functional/conftest.py` (`.` matches `/`), so the
  allowlist entry was a real gap, not just polish.

`make lint` is clean; `tests/config/` test suite still passes with
the trimmed marker set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* rm e2e workflow

* Address review: drop more dead code from k3s-only cleanup

Follow-ups on PR #2533 review (#2533 (review)):

- Delete now-orphan integration_tests/local_pipeline/mock-sandbox/
  (Dockerfile + phase-runner.sh) — only consumer was the deleted
  docker fallback in local_pipeline/conftest.py.
- Remove tests.utils.gateway_client.docker_available() and its
  re-export — zero remaining callers after this PR removed the
  conftest call sites.
- Strip stale -m "not functional" from Makefile (test, test-all)
  and update docs/guides/testing.md §2 step 8.
- Rewrite integration_tests/conftest.py docstring to describe
  what the legacy fixtures actually do under k3s.  Add explicit
  pytest.skip in isolated_container/external_container/test_container
  when the stack is k8s-backed (was silently skipping with a
  generic-sounding "could not start container" message).
- Drop unused certs_volume field from EggStack; document why
  compose_project / external_network are retained.
- Expand __all__ in integration_tests/conftest.py to cover the
  re-exported public surface (EggStack, ContainerInfo,
  exec_in_container, GATEWAY_PORT) — previously listed GATEWAY_PORT
  only.
- Drop STRUCTURE.md mock-sandbox entry.

Skipping the "except FileNotFoundError, subprocess.TimeoutExpired:"
nit — ruff format 0.15.12 actively strips parens from except
tuples, so the parenthesized form would not survive `make lint-fix`.

Part of pipeline issue-2474-v2; the terminal slice carries the
program-level narrative.

* Address review: remove stale entries from STRUCTURE.md

Drop the four file entries from the integration_tests/ tree listing that
this PR (slice-1 cleanup) deletes: docker-compose.yml, agent_findings.py,
test_agent_security_fuzz.py, test_e2e_workflow.py.

Reviewer noted the local_pipeline/ subsection was already updated in
a423d31 but the parent listing was missed.

---------

Co-authored-by: egg <egg@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: James Wiesebron <jameswiesebron@khanacademy.org>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2495: discriminate authorization vs. value errors at /mutate boundary (#2517)

* Fix #2495: discriminate authorization vs. value errors at /mutate boundary

The `/mutate` route was returning 403 for every `MutationResult.success=False`,
which is correct for role-authorization rejections but misleading for value/path
errors (bad `field_path`, out-of-range index, out-of-domain enum value). A
client receiving 403 for `Invalid value for current_phase: …` would reasonably
retry with a different role, which can't help.

Adds `error_kind: Literal["authorization", "value"] | None` to `MutationResult`
so the route can map cleanly without parsing message strings: 403 for
authorization, 400 for value errors. Adds regression tests for all three
branches.

* Address review: assert error_kind in validator tests + export MutationErrorKind

Closes the validator-level test gap flagged in the PR review:
- test_apply_invalid_mutation_rejected now asserts
  error_kind == "authorization" so a regression that drops the
  discriminator on the role-rejection path fails at the unit-test
  boundary, not just the route boundary.
- test_invalid_enum_value_returns_failed_mutation now asserts
  error_kind == "value" for the pydantic ValidationError path.
- New test_invalid_path_returns_failed_mutation covers the
  (KeyError, IndexError, AttributeError) path through _set_value
  and asserts error_kind == "value".

Re-exports MutationErrorKind from shared/egg_contracts/__init__.py
so callers can type-hint against the discriminator without
reaching into the validator submodule.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* docs: document .github-staging/ convention in agent-roles reference [doc-updater] (#2516)

* docs: document .github-staging/ convention in agent-roles reference

* docs: correct tester guidance — HANDOFF instead of .github-staging/

The tester's allowed_patterns in shared/egg_restrictions/patterns.py
covers only test files, conftest, pin files, and .egg-state/agent-outputs/
— it does not include .yml/.yaml/.json. AgentFilePattern.can_write
requires both a non-blocked path AND a positive allowlist hit, so
.github-staging/workflows/ci.yml returns False for the tester even
though .github-staging/ is not on the tester's blocked list.

A tester following the previous text would attempt to stage CI fixes
under .github-staging/ and be rejected. Replace that advice with the
correct path: hand off to the coder via HANDOFF, mirroring the existing
coder→tester handoff pattern.

Surfaced by egg-reviewer on PR #2516. The patterns.py / agent_roles.py
divergence the same review noted is tracked separately in #2521.

* docs: expand tester Directed Coordination to cover outbound HANDOFF

---------

Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2521: align tester `.github/` block between agent_roles.py and patterns.py (#2525)

* Fix #2521: align tester `.github/` block between agent_roles.py and patterns.py

#2514 added `.github/` to `TESTER_ROLE.blocked_write` in
`agent_roles.py` but skipped the mirror entry in
`TESTER_PATTERNS.blocked_patterns` in `patterns.py` — the planner
prompt and the gateway saw different views of the tester's write
scope. The omission was benign because the tester's allowlist
already excludes `.github/`, but it stops being benign the moment
someone widens that allowlist.

Add `.github/` to `TESTER_PATTERNS.blocked_patterns` so both files
agree, matching the lockstep pattern already used for documenter,
autofixer, and conflict_resolver. Add regression tests in the
gateway pattern suite and the shared restrictions unit suite.

* Address review on #2525: load-bearing tests, slim comment

- Use `.github/test_actions.py` as the load-bearing assertion in both
  test files. It matches the tester's `**/test_*.py` allowlist, so
  only the new `.github/` blocked entry stops it. The two pre-existing
  paths (`.github/CODEOWNERS`, `.github/PULL_REQUEST_TEMPLATE.md`)
  stay as breadth assertions; both are blocked even without the new
  entry, so they would have passed against the unfixed patterns.
- Slim the rationale block in `patterns.py:224-234` to a one-liner
  pointing at `CODER_PATTERNS` for the full `.github/` rationale.
  The original 11-line comment over-claimed lockstep across roles
  the diff didn't actually touch (architect, task_planner, refiner,
  reviewer roles); the one-liner doesn't.

Drift in the non-tester roles (architect/task_planner/risk_analyst/
refiner/reviewer/reviewer_contract) is tracked in #2532.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2490: extend validate_assignment to sibling Contract models (#2520)

* Fix #2490: extend validate_assignment to sibling Contract models

#2484 added `model_config = ConfigDict(validate_assignment=True)` to
`Contract` so `setattr` on Contract fields coerces values back to their
declared type. The reviewer flagged a remaining asymmetry: sibling
models (`Task`, `Slice`, `Decision`, `AgentExecutionModel`, …) still
silently accepted untyped assignments like `task.status = "garbage"`,
so the validation surface was uneven across the contract object graph.

Lift the config to a shared `EggContractBaseModel` (Option B from the
issue) and have every model in `shared/egg_contracts/models.py`
inherit from it, so the strictness applies uniformly without per-model
duplication. Drop the per-model config from `Contract` itself — the
shared base now provides it.

The audit of sibling-model mutation sites (`shared/egg_contracts/orchestration.py`
`set_execution`, `orchestrator/routes/decisions.py` `contract.pr =`,
etc.) confirms they assign well-typed values (enum members or
constructed model instances), so the new strictness does not break
existing call sites.

* Address PR #2520 feedback: fix nested-model test, refresh validator comment

- Rewrite test_pr_metadata_invalid_deferred_actions_raises to assign a
  raw dict, which actually exercises pydantic's list-element coercion
  path on the outer setattr; the previous form raised from the inner
  DeferredAction(...) constructor regardless of validate_assignment
  (item 1).

- Update the validator.py except ValidationError comment to reference
  EggContractBaseModel (where the config now lives) and add #2490 to
  the issue list, since the same catch now covers sibling-model
  setattrs (Task.status, Slice.status, Decision.type, ...) too (item 2).

— Authored by egg

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2501: don't flip stale during an in-flight state-store probe (#2519)

* Fix #2501: extend probe freshness while a probe is in flight

`StateStoreProbe.snapshot()` flipped the cached `healthy` to `False`
purely because the cache age crossed `interval * stale_multiplier`,
even when a probe was actively running and about to refresh it. Under
slice-spawn load `git worktree add` occasionally ran 30-40s, longer
than the 30s default staleness window, so the request-path dual-write
in `routes/health.py` recorded `unhealthy` and the BG callback
recorded `healthy` 0-3s later when the same probe completed —
producing the spurious `recent_transitions` flap pairs reported in
the issue.

Track probe start time and, while a probe is in flight, treat the
cache as fresh until the in-flight probe itself has been running
longer than the staleness window. A genuinely wedged probe still
surfaces as stale once that bound is exceeded.

* Address review feedback on #2501 in-flight grace fix

- Document worst-case ~2*stale_window wedge-detection bound and the
  intentional 'fresh-but-old' semantics during the grace in
  snapshot()'s docstring (reviewer minor: source-recoverable rationale).
- Expand the inline comment in the grace branch to cite the 'fix #1'
  framing from #2501 so the bound's rationale is recoverable from
  the source alone (reviewer minor).
- Add an integration-level test that drives snapshot() while a real
  probe_now() is parked mid-probe on a worker thread, populating the
  in-flight flag and _probe_started_at_monotonic via the production
  code path. Closes the loop end-to-end so a future refactor that
  stops setting _probe_started_at_monotonic from probe_now() fails
  this test where the field-poking variants would silently keep
  passing (reviewer non-blocking suggestion).

* Tighten worst-case wedge detection bound in snapshot() docstring

Reviewer noted that the '~2 * stale_window' / '60s with 30s default'
framing is loose. The BG loop fires every `interval` seconds, so the
in-flight probe starts within `interval` of the last good completion,
and the grace extends only until that probe's own age exceeds
`stale_window`. The tight bound is therefore `stale_window + interval`,
which under the defaults (interval=15s, stale_multiplier=2.0) is ~45s
of blindness, not ~60s. The 2 * stale_window framing only saturates
when stale_multiplier=1.0. Address-only docstring change; no behavior
change.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2515: restart_phase falls back to deterministic roster when agents cache empty (#2518)

* Fix #2515: restart_phase falls back to deterministic roster when agents cache empty

restart_phase reads its respawn roster from phase_exec.agents — a runtime
cache that the route's own clear-then-spawn flow resets to []. If the
spawn step fails before re-populating the cache, every subsequent
restart_phase 400s on "No agents found in phase {phase} to restart" and
start_pipeline 409s on the (now CANCELLED) status, leaving the only
escape cancel_task(cleanup=true) — which discards all prior work.

Fall back to the same deterministic source the executor itself uses:
pipeline.active_roles (CUSTOM-mode / BABYSIT overrides, #1762) first,
then get_roles_for_phase(repo, has_contract). Same precedence as
_run_concurrent_phase, so the recovered roster matches what the next
spawn would have produced anyway.

* Match _run_concurrent_phase exactly: skip phase-default fallback when active_roles set

When pipeline.active_roles is set but every entry is unknown to this
orchestrator's AgentRole (defensive case after a role removal in a
newer schema), the prior implementation fell through to
get_roles_for_phase and expanded to the full phase-default roster.
_run_concurrent_phase keeps its roles list empty in the same case,
so the route's response (and the downstream worktree-delete /
health-monitor reset) would diverge from what the spawn would
actually produce.

Convert the second 'if not agent_roles:' into an 'else:' attached to
the override branch so the strict-parity behaviour matches: when an
override is set we use it verbatim and never fall through. The final
'No agents found' 400 still fires honestly when the override is
all-unknown.

Adds a regression test that mutates active_roles post-construct
(bypassing the field validator) to simulate the load-time-drift
edge case.

* Document deliberate route-vs-worker divergence in roster-derivation try/except

The except Exception wrap around _get_roles_for_phase doesn't exist in
_run_concurrent_phase, so a future reader auditing the two callsites
for parity might mistake the bare-except for a bug rather than a
deliberate route-specific safety floor (return 400 not 500).

* Tighten line-range citation in roster-derivation comment to 12813-12840

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2522: enumerate per-agent worktrees on phase restart (#2526)

* Fix #2522: enumerate per-agent worktrees on phase restart

restart_phase guessed worktree names as ``{pipeline_id}-{role}``, which
misses slice-scoped worktrees (``{pipeline_id}-slice-{N}-{role}``) and
leaves them on disk after a restart on a slice-based pipeline.

Drive deletion off ``agent_salvage.enumerate_agent_worktrees`` (already
the source of truth in ``cleanup_pipeline`` and salvage) and filter to
the roles being restarted. The pipeline-level worktree
(``agent_role=None``) and worktrees for non-restarted roles are
intentionally preserved.

* Address review: salvage before restart-phase delete; cleanup-style enumeration

Blocking review feedback (#2522 / PR #2526):

1. Silent loss of unpushed agent commits during phase restart
   restart_phase now calls agent_salvage.auto_salvage_pipeline before
   the deletion loop (mirroring cleanup_pipeline's #2429 invariant).
   Restart is precisely the scenario where unpushed commits accumulate
   - operators hit it because agents got stuck or wedged - so the
   previous code was the one orchestrator-side worktree-delete path
   that bypassed salvage. Salvage failures are best-effort; deletion
   still happens.

2. Broken/corrupted worktrees regressed the original #1723 cleanup
   enumerate_agent_worktrees gates on a usable .git marker, so
   wedged-btrfs-mount worktrees were being silently skipped after this
   PR's switch to enumeration. Added validate_git=False flag (default
   stays True for salvage callers) so cleanup callers receive broken
   entries with repo_path falling back to the worktree dir itself.
   restart_phase now opts in to the cleanup-style listing.

Non-blocking feedback addressed in the same commit:

- Test fixture _make_pipeline_with_slice_agents builds AgentExecution
  with slice_id populated, matching what concurrent_executor writes.
- New test exercises continue-on-error across three worktrees with
  the middle one's delete raising; locks down loop semantics.
- Narrower exception class (OSError | ImportError | RuntimeError)
  around enumerate_agent_worktrees.
- log_extras suppresses slice_id=None on non-slice pipelines.

New tests:
- test_restart_phase_continues_after_partial_worktree_deletion_failure
- test_restart_phase_salvages_before_deleting_worktrees
- test_restart_phase_salvage_failure_is_nonfatal
- test_restart_phase_deletes_broken_worktree_without_git_marker
- test_validate_git_false_returns_broken_worktrees
- test_validate_git_false_preserves_validated_repo_path

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

* Fix #2539: drop duplicate `slice ` prefix in non-terminal slice PR titles (#2540)

`create_slice_pr` was rendering non-terminal slice PR titles as
`slice slice-1: …` because `slice_id` already starts with `slice-`.
Drop the literal prefix so the title is just `{slice_id}: {slice_name}`
(e.g. `slice-1: Cleanup — k3s only, drop dead test tiers`), and update
the two test assertions and docs reference that pinned the buggy form.

* Fix #2531: add `--for STATUS` to producer pre-confirm wait-loop (#2536)

When every reviewer ACKed the current version, no further
`CONSENSUS_ACK` / `CONSENSUS_NACK` events arrive on the bus. The
orchestrator's directed `STATUS` nudge ("Ready to confirm — all
confirm preconditions satisfied", `metadata.ready_to_confirm == True`)
is the only signal that the global preconditions cleared, but the
producer prompt's pre-confirm wait-loop filter omitted `STATUS` —
so the producer slept through the nudge and only woke via the
health-monitor `OVERSEER_ALERT` backstop minutes later, observed in
pipeline `issue-2474-v2` slice-1 (6/8 stall, ~57 min phase elapsed).

The reference doc at `agent-wait-patterns.md` already prescribed
waiting on `STATUS` for the pending-acks recovery path; the prompt
template just hadn't caught up. This change closes that gap and adds
a regression test that pins `--for STATUS` plus the on-wake guidance
("go to step 5 CONFIRM if `metadata.ready_to_confirm`, otherwise
re-enter the wait") across every producer role × phase. The
`_send_brc_confirmation_nudge` docstring is updated to reflect the
new pre-confirm filter.

* Fix #2535: stop slice-N from inheriting slice-(N-1) consensus, drop gateway import (#2542)

* Fix #2535: stop slice-N from inheriting slice-(N-1) consensus, drop gateway import

Two bugs surfaced when issue-2474-v2 spawned slice-2: every container
exited four seconds in with no work attempted, leaving slice-2's
integration branch empty and the PR-create call to fail with
"No commits between ...".

Bug A (`gateway/git_client module unavailable`):
the deployed orchestrator image ships only `orchestrator/`, `routes/`,
`health_checks/`, and the shared `egg_*` packages — `gateway/` is not
copied. The `from gateway.git_client import build_rebase_onto_args`
call added by #2512 always raises ImportError in production, so every
slice integration branch reconciliation silently fails. Inline the
canonical argv builder as `_build_rebase_onto_args` in
`orchestrator/gateway_client.py`; the gateway server's `/git` endpoint
remains the authoritative allowlist boundary, and CI test paths that
keep `gateway/` on `sys.path` continue to work unchanged.

Bug B (slice-2 consensus reached at elapsed_seconds=0.0):
the per-slice tracker registry already keys by `{pipeline_id}/{slice_id}`,
but `ConcurrentPhaseExecutor.check_consensus()` had two slice-unaware
fallback paths. When slice-2's tracker is fresh and empty (the steady
state right after spawn, before any agent has proposed),
(1) `reconstruct_tracker_from_messages` was called with the bare
pipeline_id and (2) the message-bus fallback scanned
`store.get_messages(pipeline_id)` pipeline-wide. Slice-1's eight
CONSENSUS_CONFIRMED messages are persisted under the bare pipeline_id
and have the same role names as slice-2's roster, so both paths
falsely declared consensus on slice-2's first poll iteration. Gate
both fallbacks (and the matching path in
`handle_consensus_confirmed_signal`) on `slice_id is None`. The
in-memory per-slice tracker is the authoritative source; an empty
fresh tracker correctly returns is_complete=False and the polling
loop keeps going.

* Address #2542 review: slice-scope idempotency, fix test syntax, doc tweaks

Five issues from egg-reviewer on the #2535 PR:

1. test_check_consensus_slice_isolation.py: replace dead try/except
   that used Python-2 catch-and-bind syntax (`except A, B:`) with a
   direct `PipelineConfig(concurrent_execution=True)` constructor
   call. The original block was unreachable — `concurrent_execution`
   is a normal Pydantic bool field that cannot raise on assignment —
   and the misleading syntax would surprise any future reader.

2. routes/signals.py: scope `_existing_confirmed_for_role` to a slice
   so the idempotency probe doesn't see sibling-slice CONFIRMs as
   "already confirmed for this role". A new `slice_id` parameter
   filters by `metadata["slice_id"]`; the per-slice tracker path
   tags CONSENSUS_CONFIRMED writes with that same metadata key.
   Without this, slice-2's first coder CONFIRMED would be silently
   suppressed (no bus message, no #1473 marker) because slice-1's
   coder CONFIRMED was still in the bus under the bare pipeline_id.
   Pipeline-scoped (slice_id is None) callers continue to see only
   pipeline-scoped messages, preserving legacy behaviour exactly.

3. orchestrator/gateway_client.py: soften the "Mirrors" claim in the
   `_build_rebase_onto_args` docstring. The helper does NOT call
   validate_git_args (which would defeat the inlining) and emits
   stripped argv, so document those two intentional differences.

4. orchestrator/stacked_pr_reconciler.py: update the module docstring
   to point at the inlined `_build_rebase_onto_args` in
   orchestrator.gateway_client (with a note explaining why the
   inlining is needed and why the security floor is unchanged).

5. tests/test_consensus_confirmed_idempotent.py: extend the helper
   `_fake_message` with a `slice_id` parameter and add three
   regression tests:
     - slice-2's first CONFIRMED is NOT marked idempotent by a
       slice-1 CONFIRMED in the bus
     - within slice-2, the second CONFIRMED IS deduped
     - pipeline-scoped callers ignore slice-scoped CONFIRMs

The wider sweep of slice-unaware peer-consensus lookups in
kubernetes_monitor.py, startup_reconciliation.py, routes/pipelines.py
status display, and the tier-1 health checks is left for #2409 (the
existing tracker covers the same root-cause: slice_id needs to flow
through more places). PR body updated to flag this.

* Fix #2538: slice PRs carry contract.pr narrative on every slice (#2543)

Every slice PR — terminal and non-terminal — now renders the
planner-authored program title, description, test plan, and manual
steps from contract.pr, so reviewers see program rationale on
whichever slice they open first. Previously only the terminal slice
carried the narrative; reviewers approaching slice-1 (the bottom of
the stack and the canonical merge entry point) saw only task bullets
plus a pointer to the terminal slice's PR.

Title disambiguation: terminal slice gets the bare program_title;
non-terminals get a [<slice-id>] prefix so the GitHub PR list stays
scannable when several stacked PRs are open at once.

Per-merge obligations remain terminal-only (the merge gate is the
last-to-merge PR in the stack) — the existing #2354 invariants and
fail-fast assertion are preserved.

The terminal slice keeps a "merge gate / umbrella" banner so
reviewers can spot the merge gate; non-terminals skip it. The old
"see terminal slice's PR for the program-level narrative" pointer is
gone — the narrative is right there now.

* Fix #2537: attribute slice PRs to orchestrator, not coder (#2541)

* Fix #2537: attribute slice PRs to orchestrator, not coder

The slice-PR creation path is orchestrator-only — `gh pr create*` is
blocked for the implement phase, and the pr phase has no agent spawn.
But `_run_implement_phase_slices` was hard-coding `agent_role="coder"`
on the synthetic session that opens the slice PR, which caused the
gateway to label the PR `agent:coder` and inject `agent_role=coder`
into the `<!-- egg-pipeline-context ... -->` comment.

Pass `agent_role="orchestrator"` so slice PRs match the attribution
the non-sliced `_auto_create_pr` path already uses.

* Fix /status/wait test flake: handshake before publish

The three event-bus tests in TestStatusWaitRoute used a 0.1s sleep in
the fire thread before publishing — racy on slow CI. The route's
preamble (cursor parse, terminal short-circuit, staleness probe,
current_sequence() snap) can exceed the grace window, so the publish
lands before event_bus.subscribe(None, _on_event) and the event is
never delivered.

Replace the sleep with a deterministic handshake that polls
event_bus._wildcard_handlers and returns the moment the route has
subscribed. The message-bus path (test_overseer_alert_wakes_route)
uses a different wake mechanism and is left untouched.

* docs: add --for STATUS to producer pre-confirm wait-loop example (#2546)

Syncs docs/guides/concurrent-execution.md with the fix from #2531:
the producer RESPOND TO REVIEWS (step 4) wait-loop now includes
--for STATUS so the orchestrator's "Ready to confirm" directed nudge
wakes the producer when every reviewer has already ACKed and no
further CONSENSUS_ACK/CONSENSUS_NACK will arrive.

docs/reference/agent-wait-patterns.md was already updated in the
same PR; this doc had a stale copy of the canonical snippet.

Authored-by: egg

Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>

---------

Co-authored-by: egg-orchestrator <egg@localhost>
Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
Co-authored-by: egg <egg@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
* Fix #2527: validate task role↔file alignment at plan time

Adds `validate_task_role_alignment` in `shared/egg_contracts/plan_parser.py`
that mirrors the gateway's push-time blocked-pattern check for each
task's `role` against its `files_affected`. The plan reviewer's prompt
now runs the validator on the parsed plan draft and injects a
"Structural Role-Alignment Check" section listing every offending task
with the eligible-role hint (or the `.github-staging/` remediation
when no producer role can push the file set). The plan-review criteria
gain a deterministic blocking item that points at this section, so a
mis-assignment surfaces as a NACK before any producer cycle is wasted.

Per-task logic lives in `_check_role_files` so the #2530 follow-up
(`includes_tests: true` opt-in for coders coupling tests with their
own production code) has a clear hook point.

Section is omitted when the validator reports no violations; the
prompt is unchanged for clean plans. Push-time enforcement remains in
place as defense in depth.

* Move #2527 validator to orchestrator-side propose-time enforcement

PR-1 review flagged a cross-module silent no-op: in concurrent BRC
mode (the default for plan phase) the original prompt-time helper
``_build_role_alignment_check_section`` always returned ``""``
because ``_run_concurrent_phase`` builds every reviewer prompt
up-front before the planner has produced the plan. The criteria
text then told the reviewer that "absence of that section means the
automated check found no violations" — the opposite of the truth.

Replace it with deterministic enforcement at the right seam:
``_validate_planner_role_alignment`` runs in
``handle_consensus_propose_signal`` for ``agent_role=="task_planner"``,
mirroring the existing ``_validate_tester_check_coverage`` pattern.
It reads the plan content as committed at the proposed SHA via
``git show <commit>:<plan_path>`` (so a stale local checkout can't
mask a real misassignment) and raises ``ValueError`` on violations,
which the caller turns into HTTP 400 — the proposal is rejected
BEFORE the tracker is mutated and BEFORE any reviewer sees it.

Also addresses the non-blocking comments:
* Lazy ``posixpath`` / ``match_pattern`` / ``AGENT_PATTERNS``
  imports in ``_is_file_blocked_for_role`` are moved to module
  scope (no circular-import risk; per-call overhead removed).
* Tests now exercise the production sequence end-to-end:
  ``test_rejected_proposal_does_not_mutate_tracker`` builds the
  exact propose signal a planner emits in concurrent BRC mode,
  mocks ``git show`` to return a misassigned plan, and asserts the
  tracker is left untouched. The PR-1 prompt-emission tests are
  removed (the helper they pinned is gone) and replaced with
  criteria-text regression guards that lock out the "absence =
  no violations" wording.

* Address PR review feedback (round 2)

Blocking:
- Revert egg_restrictions.patterns import in plan_parser.py to lazy
  function-local. The module-scope hoist in PR-1 round 1 re-introduced
  the egg_restrictions ↔ egg_contracts import cycle that
  shared/egg_restrictions/matchers.py was deliberately split out to
  avoid (see its docstring), breaking the gateway production boot path
  (python3 gateway/gateway.py). Conftest pre-load order hid the cycle
  in pytest. egg_restrictions.matchers.match_pattern stays at module
  scope — only AGENT_PATTERNS needs to be lazy.
- Add TestImportOrderingRegression that subprocess-runs
  'import egg_restrictions.patterns' under PYTHONPATH=shared so the
  cycle surfaces in a clean interpreter (mirrors gateway boot).

Non-blocking:
- Reword the role-alignment criterion in _get_plan_review_criteria to
  say 'before the proposal reaches you' instead of 'before this prompt
  is ever rendered' — concurrent BRC mode builds reviewer prompts
  up-front, so prompt-render time isn't the right reference point.
- Update stale comment in test_pipeline_prompts.py that pointed at
  test_signals.py::test_propose_validates_planner_role_alignment (no
  such file/test) — the validator-runs-here tests live in this same
  file under TestPlannerRoleAlignmentValidation.
- Thread already-loaded pipeline_state and worktree_path from
  handle_consensus_propose_signal into _validate_planner_role_alignment
  via keyword args (with backward-compat fallback to in-function loads)
  so the validator's dependency on the prior _verify_commit_on_branch
  block is explicit and the state-store + worktree lookups aren't
  duplicated.

* Fix stale gateway boot path comment in import-ordering regression test

The PYTHONPATH=shared mirror comment cited scripts/start-gateway.sh
which doesn't exist. Replace with the actual production references:
gateway/Dockerfile:99 (PYTHONPATH=/app), gateway/entrypoint.sh:286
(exec python3 gateway.py), gateway/Dockerfile:70-75 (shared/ COPY).

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Co-authored-by: egg <egg@localhost>
…2557)

* Render per-role agent-status table on BRC dashboard emits

The Phase 3 monitor previously rendered a 3-line `Pipeline Status`
block plus a stacked 4-line `Enhanced dashboard` consensus paragraph
on each `wait-status` emit. With 8+ agents active during a busy
implement-phase BRC, the prose form blurs — a stalled reviewer is
not visually distinct from a working one, and a NACK row drops to
the end of the paragraph.

Replace the two stacked blocks with one per-role markdown table when
`concurrent.consensus` is present. Columns derive directly from the
`peer_consensus.evaluate()` envelope (`agents[role].producer_phase` /
`reviewer_phase` / `confirmed`, plus structured `unresolved_nacks`),
so schema drift surfaces as an empty cell rather than a wrong cell.
Dual-role agents (`tester`) render `<producer_phase> / <reviewer_phase>`.
Always render full state on BRC emits — the table is an at-a-glance
scan, so deltas-only would defeat its purpose. Non-BRC lines keep the
existing compact 3-line form with deltas-only behavior.

Mirror the same two-path render in Phase S5 (lightweight pipeline)
and emit the table one final time in Phase 5 success summary so the
operator has a closing snapshot of which roles confirmed.

Fix #2554

* Address review: drop nonexistent Slice column; fix S5/S6 mirrors; generalize producer ordering

- Drop Slice column entirely. last_status.pipeline.current_slice_id does
  not exist on the Pipeline model — slice_id lives on AgentExecution
  (per-agent), not on the pipeline root, and the minimal envelope does
  not carry it. Rendering it would have produced 'Slice: —' on every
  emit and silently misled operators about sliced vs non-sliced state.
- Phase S5 (lightweight): replace stale 'concise/deltas-only' line with
  the same dual-path phrasing as Phase 3 line 411, and drop Slice from
  the Path B header reference.
- Phase S6 (lightweight Complete): add the closing-snapshot bullet so
  BRC consensus is rendered one final time on success — lightweight
  pipelines start at implement, so this is the common case.
- Generalize producer ordering: pull producers/reviewers from
  concurrent.consensus.review_graph (sorted alphabetically by
  ReviewGraph.to_dict) instead of the implement-only hardcoded list,
  so refine/plan producers (refiner, architect, task_planner,
  risk_analyst) order correctly without further prose drift.
- Consensus fallback: specify the Phase column renders '—' when
  concurrent.consensus is missing, and explicitly forbid inventing
  a message-type-to-phase mapping (a CONSENSUS_PROPOSE tells you the
  producer is in PROPOSED but says nothing about reviewer phases).

* Address re-review: dedup dual-role agents, fix example ordering

The producers/reviewers split sourced from review_graph emits tester in
both lists for the implement graph, so an LLM following the rule
literally would render tester twice. Add an explicit dedup directive to
the Role column rule. Reorder the example table to match the alphabetical
ordering rule (review_graph.producers is sorted).

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: egg <egg@localhost>
* Fix #2529: runtime escape hatch for impossible tasks

Adds two MCP tools and a typed `Impasse` primitive so a producer that
discovers mid-execution that its task is structurally impossible can
emit a structured signal instead of inventing workarounds. The
orchestrator detects the impasse post-phase and either auto-delegates
to a suggested role (first attempt, `wrong_role` only) or escalates to
HITL (second attempt or non-`wrong_role`).

- `mcp__sdlc__check_file_restriction` — pure-local read against
  `shared/egg_restrictions/patterns.py`. Returns `can_write` plus
  `alternative_role` when exactly one producer covers the path. Lets
  the agent self-check before burning tokens on exploration.
- `mcp__sdlc__report_impasse` — persists a typed
  `egg_contracts.Impasse` (category, reason, suggested_role,
  blocked_files, evidence) under `AgentOutput.impasse`. Once called,
  the agent must exit cleanly without committing.
- `orchestrator/impasse_routing.py` — `collect_impasses` +
  `route_impasses`. Auto-delegate fires only for fresh tasks
  (`delegation_attempts == 0`) with a single eligible alternative
  producer role; everything else creates a HITL decision via
  `apply_mutation` with a delegate / cancel / manual-resolve /
  other option set.
- `_run_concurrent_phase_with_impasse_retry` wraps the existing
  slice-loop spawn so an `all_delegated` outcome triggers one BRC
  retry against the mutated contract; any escalation surfaces to
  the operator without auto-retry.
- Producer prompt picks up an "If a task is impossible, use these
  tools instead of inventing workarounds" section.

Test surface: schema round-trip, both handlers (allowed/blocked/
batch/error paths), routing helper (delegate, second-impasse HITL,
plan_bug / external_blocker / unresolved-task escalations,
self-delegation defense), and the existing tool-registry +
CLI-drift gates updated for the two new no-CLI verbs (rationale
in handler docstrings per decision-13).

* Address PR #2553 review: producer escape hatch + routing hardening

Blocking fix:
- Move runtime escape-hatch instructions out of the task_planner-only
  _build_role_restrictions_section into a new
  _build_impasse_escape_hatch_section, then inject it into the coder
  prompt (early-return branch) and the tester / documenter prompts
  (post-phase-restrictions branch). Producers — the only roles that
  emit impasses — now actually see check_file_restriction /
  report_impasse guidance instead of inventing workarounds. The
  planner keeps a brief post-failure-delegation summary so it knows
  the auto-delegation path exists; planners do not emit impasses.
- Add end-to-end TestProducerEscapeHatchInPrompts coverage that
  parametrises over coder/tester/documenter and asserts both tool
  names plus the "DO NOT invent workarounds" header appear, and that
  architect / planner stay free of the actionable producer-only
  directive.

Non-blocking fixes:
- Routing: route_impasses gains a force_escalate kw. The slice-loop
  wrapper sets it on its terminal iteration so a delegation that
  cannot re-run a BRC cycle gets escalated to HITL rather than
  silently mutating the contract and exiting on a stale role
  assignment.
- Routing: drop the 120-char truncation of impasse.reason in
  _record_delegate's audit-log entry. The schema caps reason at 2000
  chars and the audit log can hold the full payload — preserve it
  for post-mortem debugging.
- Slice loop: clear the impasse field from each producer's per-
  pipeline agent-output file between iterations. save_agent_output's
  mode="w" already overwrites when a producer respawns and reaches
  its handoff write, but a producer that crashes pre-handoff in
  iter-N+1 would otherwise let iter-N's impasse persist and
  re-trigger routing as a spurious "second impasse on same task"
  HITL.
- Handler: reject category="wrong_role" without suggested_role at the
  mcp__sdlc__report_impasse boundary. Without it the orchestrator
  router can only escalate, which silently degrades the producer's
  deliberately set wrong_role signal — point the agent back at
  check_file_restriction so the fix lands in the same iteration.
- Handler: also require task_id for category="wrong_role". The
  router's role-match fallback is fragile when a slice has multiple
  tasks per role or role-less tasks; explicit task_id eliminates
  guesswork on the auto-delegation path. Other categories keep
  task_id optional.
- Pipelines: comment the monolithic-implement fallback (the second
  _run_concurrent_phase call in the implement handler) explaining
  that auto-delegation is intentionally scoped to the slice loop
  since it rewires a task within a slice.

Closes review feedback items 1-7 on PR #2553.

* Address PR #2553 re-review: docs drift + cleanup test

Address two of the three non-blocking suggestions from the approve
re-review on commit 696d392.

* docs/reference/agent-tools.md: mcp__sdlc__report_impasse row now
  documents that task_id and suggested_role are mandatory for
  category=wrong_role (handler raises HandlerError when either is
  missing). Other categories keep both fields optional since they
  always escalate to HITL.
* orchestrator/routes/pipelines.py: extract the per-pipeline
  agent-output cleanup closure to a module-level helper named
  _clear_stale_impasses_for_producers so it can be unit tested
  directly. Behaviour is identical — the helper drops the impasse
  field after every successful all-DELEGATE iteration.
* orchestrator/tests/test_pipeline_impasse_cleanup.py: new file with
  five focused tests covering the cleanup happy path, the no-impasse
  no-op path, the missing-output-file path, multi-producer cleanup
  in one pass, and per-pipeline scoping.

The third suggestion (mixed-decision iter-0 still wastes a DELEGATE
role flip) was explicitly flagged "Worth a follow-up issue, not
blocking here" by the reviewer — filed as #2563.

* Address PR #2553 minor observations: type annotation + hoist imports

Two non-blocking observations from the third review (commit 7da68cf):

- Annotate `producer_roles` on `_clear_stale_impasses_for_producers`
  as `list[ContractAgentRole]` so a future caller sees the expected
  element type without grepping the call site. Quoted under TYPE_CHECKING
  + `# noqa: UP037` to match the file's existing pattern for
  `ContainerSpawner` (lines 413, 684, 6316, 6707, 7021).
- Hoist `load_agent_output` / `save_agent_output` imports from inside
  the helper to module level, mirroring `impasse_routing.py:50` which
  already imports `load_agent_output` directly. The seam fallback is
  unused at runtime (egg_contracts is the actual installed package, not
  shared.egg_contracts) and impasse_routing.py confirms a plain
  module-level import works.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
* Add PRMetadata.context_* fields + planner prompt updates (#2548)

slice-1 / task-1-1 + task-1-3 — the foundation slice for the context-PR
mechanism. Subsequent slices build the gateway primitive, the
orchestrator hook, and the slice-1 base rewiring on top of these
fields.

Schema 1.1 — extends ``PRMetadata`` with four optional fields:
- ``context_title`` / ``context_description`` — planner-emitted
  framing for the dedicated context PR (e.g. "Strategic plan for #N"
  vs the slice's "Implement …"). Both fall back to ``title`` /
  ``description`` when omitted.
- ``context_branch`` / ``context_pr_number`` — orchestrator-populated
  runtime values (the ``egg/<id>/context`` branch name and the GitHub
  PR number once the context PR has been opened). Planners must NOT
  emit these.

Bumps ``Contract.schemaVersion`` default from ``"1.0"`` to ``"1.1"``
and adds an ``after``-mode migration shim that promotes pre-1.1
contracts to 1.1 on load. The bump is purely additive — pre-1.1 JSON
loads cleanly with the new fields defaulting to ``None``.

Plan-parser plumbing — ``ParseResult`` grows ``pr_context_title`` /
``pr_context_description`` and a new ``extract_pr_context_metadata_from_yaml``
helper extracts the optional keys without breaking the existing
``extract_pr_metadata_from_yaml`` 5-tuple signature (and the
~10 callers + tests that unpack it).

Planner prompt — both planner-prompt sites in ``pipelines.py`` (the
plan-phase prompt under ``_build_phase_prompt`` and the
task_planner-role prompt under ``_build_agent_prompt``) gain the
``_PR_CONTEXT_GUIDANCE`` paragraph and the ``_PR_CONTEXT_YAML_EXAMPLE_LINES``
commented-out hints inside the ``pr:`` YAML block. Both helpers are
defined once next to ``_PR_DESCRIPTION_GUIDANCE`` so the two prompt
sites stay in sync when the guidance evolves.

Contract populator — ``_populate_contract_from_plan`` now copies
``result.pr_context_title`` / ``pr_context_description`` onto the new
PRMetadata it builds, and preserves any orchestrator-populated
``context_branch`` / ``context_pr_number`` across re-populates so a
later plan re-parse does not blow away runtime state set by slice-3's
hook.

Test impact: bumping the default ``schemaVersion`` to ``"1.1"`` causes
``tests/shared/egg_contracts/test_models.py::test_minimal_contract``
to fail on the literal ``"1.0"`` assertion. The fix-up belongs to
the tester role (task-1-2) along with the new ``PRMetadata.context_*``
round-trip coverage; coder boundaries forbid pushing test edits.
Lint (ruff format + check) and mypy delta are clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add PRMetadata.context_* test coverage + 1.0→1.1 migration tests (#2548)

slice-1 / task-1-2 — adversarial + regression coverage for the four new
optional ``PRMetadata.context_*`` fields and the ``schemaVersion``
1.0→1.1 promotion shim added by the coder in commit 75d8ca0.

Coverage:
* ``TestPRMetadataContextFields`` — defaults to None, full round-trip
  with all four fields populated, omitted-keys round-trip preserves
  None.
* ``TestPRMetadataContextPRNumberValidator`` — pins the ``ge=1``
  validator: 0/-1 are rejected at construct AND at setattr (under the
  shared ``EggContractBaseModel.validate_assignment=True`` from #2490);
  None and large positive ints accepted.
* ``TestPRMetadataSchemaVersionMigration`` — 1.0 payload loads with
  context defaults, dump→reload chain stays at 1.1, default is 1.1,
  legacy ``deferred_actions`` survive migration, and an unrecognized
  version (1.2 / 2.0) is NOT silently downgraded.
* ``TestPRMetadataContextEmptyStringSemantics`` — empty strings are
  accepted at the model layer so the orchestrator hook's
  ``context_title or title`` fallback works for both None and "".
* ``TestPlanParserContextFieldExtraction`` — covers task-1-3's
  ingestion path: ``extract_pr_context_metadata_from_yaml`` returns
  None pair for missing/None/absent inputs; collapses whitespace to
  None; warns on non-string ``context_title``; ``parse_plan`` threads
  the values onto ``ParseResult.pr_context_*``.

Also updates ``test_models.py::test_minimal_contract`` from the literal
``"1.0"`` schemaVersion assertion to ``"1.1"`` — the coder flagged this
as a known follow-up in commit 75d8ca0 (coder cannot push test edits
under the role boundary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address review feedback on PR #2555 (#2548)

Blocking fix:
- _populate_contract_from_plan now also preserves PRMetadata.deferred_actions
  alongside context_branch / context_pr_number. The conditional-ACK gate at
  decisions.py:complete_phase writes deferred actions; the populator's
  start_phase=implement re-entry path was silently wiping them, erasing
  the merge-blocking Pre-merge Obligations handoff. Add a regression
  test in orchestrator/tests/test_short_flow_contract_population.py.

Non-blocking improvements:
- extract_pr_context_metadata_from_yaml now warns symmetrically on
  non-string context_description (mirrors the context_title branch),
  preventing silent str() coercion of structured planner values.
- Updated schemaVersion / _migrate_schema_version_to_1_1 docstrings to
  reflect that the bump fires at every load (mode="after"), not lazily on
  next save, and to acknowledge the migration is silent (no audit entry).
- Aligned TestPRMetadataContextEmptyStringSemantics docstring with reality
  (planner path collapses empty strings to None; only hand-edited or
  migrated payloads can produce a "" PRMetadata).
- New tests for the symmetric context_description warning.

* docs: document schema 1.1 and pr.context_* fields (#2548)

Slice-1 lands the schema delta + planner-prompt update half of the
context-PR mechanism (#2548): `PRMetadata` grows four optional
`context_*` fields and `Contract.schemaVersion` defaults to `"1.1"`
with an additive `1.0 → 1.1` migration. The actual context-PR
mechanism (branch creation, PR opening, slice-1 base wiring) is
implemented in slices 3-4 and gets its own end-to-end documentation
pass in slice-5.

This commit updates the docs that reference contract examples and the
yaml-tasks `pr:` block so they reflect the slice-1-landed schema
state:

- `docs/templates/plan.md`: add optional `context_title` /
  `context_description` keys to the yaml-tasks `pr:` example as
  commented-out hints, plus a new prose blockquote explaining when
  planners may emit them and which sibling fields
  (`context_branch`, `context_pr_number`) are orchestrator-populated.
- `docs/architecture/sdlc-pipeline.md`: bump the example
  `schemaVersion` from `1.0` to `1.1` and add a "Schema 1.1 (#2548)"
  blockquote summarising the additive migration.
- `docs/guides/sdlc-pipeline.md`: same `schemaVersion` bump in the
  example JSON plus a short blockquote pointing readers at the
  migration semantics.

The PR-stack diagrams, BRC-history file naming, and slice-1-base
discussion in `docs/guides/concurrent-execution.md`,
`docs/architecture/orchestrator.md`, `docs/reference/orchestrator-cli.md`,
and `docs/guides/babysit-pr.md` remain untouched — those describe
behavior that does not yet exist on this branch and are slice-5's
responsibility once the mechanism is wired end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Allowlist plan_parser.py for file-size hard cap on egg/issue-2548/work (#2548)

Slice-1 (foundation) tester NACK: on the egg/issue-2548/work merge target,
slice-1's extract_pr_context_metadata_from_yaml + ParseResult.pr_context_*
plumbing stacks on top of #2527's validate_task_role_alignment additions,
pushing shared/egg_contracts/plan_parser.py to ~1,530 lines and breaching
the 1,500-line hard cap that scripts/check-file-sizes.py enforces. The
slice-1 branch alone is at 1,388 lines (clean), but the work-branch state
that the lint actually runs against is over.

Fix per reviewer_contract's forward-looking concern and tester's blocking
finding: add the file to scripts/file-size-allowlist.yaml under #2548 so
make lint passes during the slice-1 BRC. Decomposition is tracked under
the same issue and is the cheaper of the two unblock options for slice-1
(decomposing in-cycle would expand scope and risk slice-2/3/4's
dependency on the current plan_parser.py public surface).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: add slice-1 boundary forward-pointer to context-PR blockquotes (#2548)

Both blockquotes now disclose that slice-1 lands only the schema fields
plus the planner-prompt advertisement — the orchestrator branch-creation
and PR-opening hooks land in #2548 slices 3-4. Until those slices merge
the four pr.context_* fields are forward-compatibly inert: a planner
emitting context_title / context_description has those values flow into
PRMetadata, but nothing acts on them yet, so reviewers and planners
reading the merged-but-pre-slice-3-4 docs see exactly what is and is
not wired today rather than reading the eventual contract as
present-tense.

Addresses non-blocking suggestion in PR #2555 review.

* Add adversarial PRMetadata.context_* tests (#2548)

Adds 12 adversarial probes to the slice-1 test file as the tester role's
contribution to slice-1 task-1-2:

- model_dump_json round-trip preserves all four context_* fields
- model_dump_json preserves None as JSON null (no exclude_none drift)
- Combined phases:->slices: + schemaVersion 1.0->1.1 migration in one load
- YAML null (~) for context_title / context_description threads as None
- parse_plan markdown-only path (no yaml fence) yields None context fields
- list-typed context_title and context_description warn (mirrors int/dict)
- CRLF + mixed whitespace stripping for context strings
- context_pr_number accepts large ints (no implicit int32 ceiling)
- schemaVersion regex rejects "1.0-rc1" / "v1.0"
- 1.0 payload with explicit context fields still loads + bumps to 1.1
- non-dict pr: block short-circuits the context extractor (no AttributeError)

All 42 tests in tests/shared/egg_contracts/test_pr_metadata.py pass.
ruff check / format are clean. The wider lint and test suites are
verified separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address slice-1 review nits: doc forward-pointers, test docstring, allowlist tracker

- Promote slice-1 forward-pointer blockquote from parenthetical to a
  follow-on bold-led paragraph in docs/architecture/sdlc-pipeline.md and
  docs/templates/plan.md so the schema-vs-orchestrator inertness is
  harder to miss on a quick skim.
- Reword test_extract_warns_on_list_typed_context_title docstring to
  cite the parser-layer isinstance(raw_title, str) guard instead of
  pydantic's str coercion machinery — pydantic is not in this code path.
- Cite #2569 (plan_parser.py decomposition tracker) from the
  scripts/file-size-allowlist.yaml entry comment instead of an
  uncited "follow-up".

---------

Co-authored-by: egg <egg@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
* Add PRMetadata.context_* fields + planner prompt updates (#2548)

slice-1 / task-1-1 + task-1-3 — the foundation slice for the context-PR
mechanism. Subsequent slices build the gateway primitive, the
orchestrator hook, and the slice-1 base rewiring on top of these
fields.

Schema 1.1 — extends ``PRMetadata`` with four optional fields:
- ``context_title`` / ``context_description`` — planner-emitted
  framing for the dedicated context PR (e.g. "Strategic plan for #N"
  vs the slice's "Implement …"). Both fall back to ``title`` /
  ``description`` when omitted.
- ``context_branch`` / ``context_pr_number`` — orchestrator-populated
  runtime values (the ``egg/<id>/context`` branch name and the GitHub
  PR number once the context PR has been opened). Planners must NOT
  emit these.

Bumps ``Contract.schemaVersion`` default from ``"1.0"`` to ``"1.1"``
and adds an ``after``-mode migration shim that promotes pre-1.1
contracts to 1.1 on load. The bump is purely additive — pre-1.1 JSON
loads cleanly with the new fields defaulting to ``None``.

Plan-parser plumbing — ``ParseResult`` grows ``pr_context_title`` /
``pr_context_description`` and a new ``extract_pr_context_metadata_from_yaml``
helper extracts the optional keys without breaking the existing
``extract_pr_metadata_from_yaml`` 5-tuple signature (and the
~10 callers + tests that unpack it).

Planner prompt — both planner-prompt sites in ``pipelines.py`` (the
plan-phase prompt under ``_build_phase_prompt`` and the
task_planner-role prompt under ``_build_agent_prompt``) gain the
``_PR_CONTEXT_GUIDANCE`` paragraph and the ``_PR_CONTEXT_YAML_EXAMPLE_LINES``
commented-out hints inside the ``pr:`` YAML block. Both helpers are
defined once next to ``_PR_DESCRIPTION_GUIDANCE`` so the two prompt
sites stay in sync when the guidance evolves.

Contract populator — ``_populate_contract_from_plan`` now copies
``result.pr_context_title`` / ``pr_context_description`` onto the new
PRMetadata it builds, and preserves any orchestrator-populated
``context_branch`` / ``context_pr_number`` across re-populates so a
later plan re-parse does not blow away runtime state set by slice-3's
hook.

Test impact: bumping the default ``schemaVersion`` to ``"1.1"`` causes
``tests/shared/egg_contracts/test_models.py::test_minimal_contract``
to fail on the literal ``"1.0"`` assertion. The fix-up belongs to
the tester role (task-1-2) along with the new ``PRMetadata.context_*``
round-trip coverage; coder boundaries forbid pushing test edits.
Lint (ruff format + check) and mypy delta are clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add PRMetadata.context_* test coverage + 1.0→1.1 migration tests (#2548)

slice-1 / task-1-2 — adversarial + regression coverage for the four new
optional ``PRMetadata.context_*`` fields and the ``schemaVersion``
1.0→1.1 promotion shim added by the coder in commit 75d8ca0.

Coverage:
* ``TestPRMetadataContextFields`` — defaults to None, full round-trip
  with all four fields populated, omitted-keys round-trip preserves
  None.
* ``TestPRMetadataContextPRNumberValidator`` — pins the ``ge=1``
  validator: 0/-1 are rejected at construct AND at setattr (under the
  shared ``EggContractBaseModel.validate_assignment=True`` from #2490);
  None and large positive ints accepted.
* ``TestPRMetadataSchemaVersionMigration`` — 1.0 payload loads with
  context defaults, dump→reload chain stays at 1.1, default is 1.1,
  legacy ``deferred_actions`` survive migration, and an unrecognized
  version (1.2 / 2.0) is NOT silently downgraded.
* ``TestPRMetadataContextEmptyStringSemantics`` — empty strings are
  accepted at the model layer so the orchestrator hook's
  ``context_title or title`` fallback works for both None and "".
* ``TestPlanParserContextFieldExtraction`` — covers task-1-3's
  ingestion path: ``extract_pr_context_metadata_from_yaml`` returns
  None pair for missing/None/absent inputs; collapses whitespace to
  None; warns on non-string ``context_title``; ``parse_plan`` threads
  the values onto ``ParseResult.pr_context_*``.

Also updates ``test_models.py::test_minimal_contract`` from the literal
``"1.0"`` schemaVersion assertion to ``"1.1"`` — the coder flagged this
as a known follow-up in commit 75d8ca0 (coder cannot push test edits
under the role boundary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address review feedback on PR #2555 (#2548)

Blocking fix:
- _populate_contract_from_plan now also preserves PRMetadata.deferred_actions
  alongside context_branch / context_pr_number. The conditional-ACK gate at
  decisions.py:complete_phase writes deferred actions; the populator's
  start_phase=implement re-entry path was silently wiping them, erasing
  the merge-blocking Pre-merge Obligations handoff. Add a regression
  test in orchestrator/tests/test_short_flow_contract_population.py.

Non-blocking improvements:
- extract_pr_context_metadata_from_yaml now warns symmetrically on
  non-string context_description (mirrors the context_title branch),
  preventing silent str() coercion of structured planner values.
- Updated schemaVersion / _migrate_schema_version_to_1_1 docstrings to
  reflect that the bump fires at every load (mode="after"), not lazily on
  next save, and to acknowledge the migration is silent (no audit entry).
- Aligned TestPRMetadataContextEmptyStringSemantics docstring with reality
  (planner path collapses empty strings to None; only hand-edited or
  migrated payloads can produce a "" PRMetadata).
- New tests for the symmetric context_description warning.

* docs: document schema 1.1 and pr.context_* fields (#2548)

Slice-1 lands the schema delta + planner-prompt update half of the
context-PR mechanism (#2548): `PRMetadata` grows four optional
`context_*` fields and `Contract.schemaVersion` defaults to `"1.1"`
with an additive `1.0 → 1.1` migration. The actual context-PR
mechanism (branch creation, PR opening, slice-1 base wiring) is
implemented in slices 3-4 and gets its own end-to-end documentation
pass in slice-5.

This commit updates the docs that reference contract examples and the
yaml-tasks `pr:` block so they reflect the slice-1-landed schema
state:

- `docs/templates/plan.md`: add optional `context_title` /
  `context_description` keys to the yaml-tasks `pr:` example as
  commented-out hints, plus a new prose blockquote explaining when
  planners may emit them and which sibling fields
  (`context_branch`, `context_pr_number`) are orchestrator-populated.
- `docs/architecture/sdlc-pipeline.md`: bump the example
  `schemaVersion` from `1.0` to `1.1` and add a "Schema 1.1 (#2548)"
  blockquote summarising the additive migration.
- `docs/guides/sdlc-pipeline.md`: same `schemaVersion` bump in the
  example JSON plus a short blockquote pointing readers at the
  migration semantics.

The PR-stack diagrams, BRC-history file naming, and slice-1-base
discussion in `docs/guides/concurrent-execution.md`,
`docs/architecture/orchestrator.md`, `docs/reference/orchestrator-cli.md`,
and `docs/guides/babysit-pr.md` remain untouched — those describe
behavior that does not yet exist on this branch and are slice-5's
responsibility once the mechanism is wired end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Allowlist plan_parser.py for file-size hard cap on egg/issue-2548/work (#2548)

Slice-1 (foundation) tester NACK: on the egg/issue-2548/work merge target,
slice-1's extract_pr_context_metadata_from_yaml + ParseResult.pr_context_*
plumbing stacks on top of #2527's validate_task_role_alignment additions,
pushing shared/egg_contracts/plan_parser.py to ~1,530 lines and breaching
the 1,500-line hard cap that scripts/check-file-sizes.py enforces. The
slice-1 branch alone is at 1,388 lines (clean), but the work-branch state
that the lint actually runs against is over.

Fix per reviewer_contract's forward-looking concern and tester's blocking
finding: add the file to scripts/file-size-allowlist.yaml under #2548 so
make lint passes during the slice-1 BRC. Decomposition is tracked under
the same issue and is the cheaper of the two unblock options for slice-1
(decomposing in-cycle would expand scope and risk slice-2/3/4's
dependency on the current plan_parser.py public surface).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: add slice-1 boundary forward-pointer to context-PR blockquotes (#2548)

Both blockquotes now disclose that slice-1 lands only the schema fields
plus the planner-prompt advertisement — the orchestrator branch-creation
and PR-opening hooks land in #2548 slices 3-4. Until those slices merge
the four pr.context_* fields are forward-compatibly inert: a planner
emitting context_title / context_description has those values flow into
PRMetadata, but nothing acts on them yet, so reviewers and planners
reading the merged-but-pre-slice-3-4 docs see exactly what is and is
not wired today rather than reading the eventual contract as
present-tense.

Addresses non-blocking suggestion in PR #2555 review.

* Add adversarial PRMetadata.context_* tests (#2548)

Adds 12 adversarial probes to the slice-1 test file as the tester role's
contribution to slice-1 task-1-2:

- model_dump_json round-trip preserves all four context_* fields
- model_dump_json preserves None as JSON null (no exclude_none drift)
- Combined phases:->slices: + schemaVersion 1.0->1.1 migration in one load
- YAML null (~) for context_title / context_description threads as None
- parse_plan markdown-only path (no yaml fence) yields None context fields
- list-typed context_title and context_description warn (mirrors int/dict)
- CRLF + mixed whitespace stripping for context strings
- context_pr_number accepts large ints (no implicit int32 ceiling)
- schemaVersion regex rejects "1.0-rc1" / "v1.0"
- 1.0 payload with explicit context fields still loads + bumps to 1.1
- non-dict pr: block short-circuits the context extractor (no AttributeError)

All 42 tests in tests/shared/egg_contracts/test_pr_metadata.py pass.
ruff check / format are clean. The wider lint and test suites are
verified separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address slice-1 review nits: doc forward-pointers, test docstring, allowlist tracker

- Promote slice-1 forward-pointer blockquote from parenthetical to a
  follow-on bold-led paragraph in docs/architecture/sdlc-pipeline.md and
  docs/templates/plan.md so the schema-vs-orchestrator inertness is
  harder to miss on a quick skim.
- Reword test_extract_warns_on_list_typed_context_title docstring to
  cite the parser-layer isinstance(raw_title, str) guard instead of
  pydantic's str coercion machinery — pydantic is not in this code path.
- Cite #2569 (plan_parser.py decomposition tracker) from the
  scripts/file-size-allowlist.yaml entry comment instead of an
  uncited "follow-up".

* [slice-2] Add context PR + per-slice BRC history (closes #2548) (#2564)

* Per-slice implement-phase BRC history (#2548 slice-2)

Switches the implement-phase BRC writer to per-slice files
`<id>-implement-{slice_id}.{md,json}` (one per slice) and drops the
aggregate `<id>-implement.{md,json}` filename — hard switchover under
D4 (no aggregate file is produced).

Refactor:

- `_write_brc_history()` now partitions implement-phase BRC messages
  by `metadata['slice_id']` and writes one file per slice via the new
  `_write_brc_history_file()` helper. Refine, plan, and pr phases keep
  the aggregate `<id>-{phase}.{md,json}` filename. Implement messages
  without `slice_id` are dropped with a single aggregate WARNING — the
  partitioning is mandatory under D4.
- A new `_render_brc_history_markdown()` helper carries the
  byte-identical markdown rendering (idempotency invariant from #1714)
  shared between the aggregate and per-slice writers.
- Existing callers (`_rewrite_brc_history_for_pr`,
  `_persist_phase_brc_history`, the inline call in `_run_pipeline`)
  delegate to `_write_brc_history()` unchanged — partitioning is
  internal to the writer.
- The pipeline-identifier-scoped staging glob in
  `_commit_statefiles_to_worktree()` already picks up the new
  `<id>-implement-slice-<N>.{md,json}` filenames (prefix-anchored on
  the issue/pipeline id).

Test fixture updates (seeding `slice_id=slice-1` on existing
implement-phase fixtures and rewriting aggregate-file assertions to
the per-slice shape) are deferred to task-2-3 (tester role) — those
test paths are gateway-blocked for the coder role per the BRC file
boundaries. The tester will re-align fixtures and add net-new coverage
for the multi-slice writer, the missing-`slice_id` WARNING, and the
no-aggregate-file invariant in parallel.

Closes task-2-1 and task-2-2 in slice-2 of #2548.

* Validate slice_id against SLICE_ID_PATTERN before file write (#2548 sec fix)

Addresses reviewer_security NACK: `metadata['slice_id']` was
interpolated directly into the on-disk filename of the per-slice BRC
history file, with no validation against the canonical
``^slice-[0-9]+$`` shape. Any sandbox agent can post arbitrary
metadata via the generic message-send endpoint
(`orchestrator/routes/messages.py:202`), so a malicious
`metadata.slice_id = "../../etc/foo"` would have written under
`worktree/.egg-state/etc/foo.{md,json}` — escaping the intended
brc-history directory and clobbering arbitrary state files (contracts,
plan drafts, other slices' BRC files).

Fix: import ``SLICE_ID_PATTERN`` from ``slice_id_validation`` (the
shared allowlist that already gates every other gateway-facing seam
where slice_id is interpolated — signal handlers #2403, restart route
#2410, branch builders) and reject any message whose
``metadata['slice_id']`` is not a string fullmatching the canonical
pattern. Rejected messages fold into the same ``unattributed``
counter and the same single aggregate WARNING that handles
missing-slice_id messages.

This puts the new file-path call site on the same allowlist as every
other use of slice_id, satisfying the invariant called out in
``slice_id_validation.py``'s module docstring: *"a future caller that
forgets the upstream regex must not be able to smuggle path
separators or shell metacharacters into a tracker registry key, a
Job name, or a worktree id."* The brc-history file path is now the
fourth call site to honor it.

* Address holistic NACK: tag CONSENSUS_* with slice_id, preserve babysit_pr (#2548)

Addresses reviewer_code_holistic NACK on v2 with three blocking
findings:

1. **Cross-module synthetic-key audit**: producers (CONSENSUS_PROPOSE/
   ACK/NACK/RE_REVIEW/WITHDRAW handlers in `routes/signals.py`) did
   NOT attach `slice_id` to the message metadata they wrote — only
   `CONSENSUS_CONFIRMED` did, via `_slice_meta`. Under v2, that meant
   the implement-phase writer would drop nearly every real BRC
   message. Fix: extend each consensus signal handler to spread the
   same `_slice_meta = {"slice_id": slice_id} if slice_id is not None
   else {}` shape into the metadata of every CONSENSUS_* message it
   writes. The new asymmetry surfaces only at the writer (canonical
   slice_id is required there too — same regex used by every other
   gateway-facing seam) but the producer side now reliably tags every
   slice-scoped message.

2. **Babysit_pr regression**: babysit_pr pipelines have no slices and
   no message ever carries `slice_id`, so v2 dropped the entire BRC
   stream and produced no `pr-<N>-<sha>-implement.{md,json}` file
   (regression of the documented babysit_pr artifact in
   `skills/babysit-pr/SKILL.md`). Fix: the writer now auto-detects
   slice-aware vs aggregate mode by checking whether ANY message
   carries a canonical `slice_id`. If none do, the writer falls back
   to the aggregate `{identifier}-implement.{md,json}` filename —
   preserving babysit_pr semantics. If at least one does, partition
   per-slice and warn loudly about any unattributed siblings.

3. **Silent-fallback hunt**: the v2 drop branch logged a warning and
   silently produced no file. The new branch is no longer silent —
   when partition mode is engaged but some messages are unattributed,
   the warning includes the dropped count, the count of attributed
   messages, and a sample of message types that were dropped, so
   operators can diagnose tag asymmetry quickly. When NO messages
   have slice_id at all, the writer now falls through to the
   aggregate filename instead of dropping (see #2 above).

Also addresses reviewer_code_holistic non-blocking findings:

* `_build_brc_history_link_line()` now clusters per-slice implement
  files (`implement-slice-1`, `implement-slice-2`) at the canonical
  ``implement`` rank so the rendered link order matches the canonical
  phase order (refine → plan → implement[-slice-N] → pr).
* `_write_brc_history()` lead docstring rewritten to describe the
  per-slice / aggregate auto-detection and the babysit_pr fallback
  path.

* Address reviewer_code non-blocking notes: natural sort + tighter access (#2548)

Folds two non-blocking observations from reviewer_code's v2 NACK into
the v3 re-propose:

* `_write_brc_history()` partition loop: drop the `getattr(msg,
  "metadata", None) or {}` defensive guard. `Message.metadata` is a
  Pydantic `dict[str, Any]` field with `default_factory=dict`
  (`message_store.Message`), so it is always a dict at this point —
  the simpler `msg.metadata.get("slice_id")` is equivalent and
  removes a no-op `isinstance` branch.
* Iterate per-slice buckets in natural-sort order (integer suffix)
  rather than lexicographic. A 12-slice pipeline now writes its
  BRC files in `slice-1, slice-2, …, slice-12` order rather than
  the lexicographic `slice-1, slice-10, slice-11, slice-12, slice-2`.
  Every key has been SLICE_ID_PATTERN-validated by this point, so
  the integer parse is total.
* `_build_brc_history_link_line()` link rendering: per-slice files
  inside the implement cluster are now sorted by integer slice index
  too, so the PR-body legend reads `implement-slice-1,
  implement-slice-2, …, implement-slice-12` rather than the
  lexicographic order. Same total integer parse — the file glob
  could in theory produce a non-canonical name, so a malformed
  suffix sorts last within the cluster.

These were the non-blocking items in the reviewer_code v2 NACK that
sit cleanly alongside the v3 cross-module fix; folding them in one
commit avoids a follow-up cleanup churn.

* Per-slice implement-phase BRC history tests (#2548 slice-2)

Aligns the BRC-history test suite with the post-#2548 hard switchover
to per-slice implement-phase files (`<id>-implement-{slice_id}.{md,json}`)
and adds net-new coverage for the multi-slice writer, the missing-
`slice_id` WARNING path, and the no-aggregate-file invariant.

Existing tests:

- `test_brc_history.py` — `_make_brc_message` / `_make_brc_messages` now
  auto-stamp `metadata['slice_id']` for implement-phase fixtures so the
  hard-switchover writer keeps producing files. Aggregate-file path
  assertions (`42-implement.{md,json}`) are rewritten to the per-slice
  shape via a new `_implement_path()` helper. Link-line tests updated
  to use per-slice filenames in their stub writes.
- `test_brc_phase_propagation.py`, `test_diagnostic_logging_1633.py`,
  `test_pr_phase_brc_rewrite.py`, `test_conditional_ack.py` — same
  `slice_id` auto-stamping treatment for their own `_make_brc_message`
  helpers; aggregate-path assertions rewritten in lockstep.

New tests (`TestPerSliceImplementBrcHistory` and
`TestPerSliceImplementBrcHistoryRewriteForPr`):

- `test_writes_one_file_per_slice_no_aggregate` — N=2 slices yields two
  per-slice .md+.json pairs and zero aggregate files.
- `test_each_slice_file_contains_only_its_own_messages` — partitioning
  isolates buckets; cross-slice content leaks fail the test.
- `test_single_slice_still_uses_per_slice_filename` — N=1 still uses
  the per-slice naming (no special-case for the single-slice degenerate).
- `test_per_slice_file_carries_slice_label_in_header` — slice label is
  visible in the markdown header.
- `test_messages_without_slice_id_dropped_with_warning` — mix of
  attributed + unattributed: per-slice file written for the attributed
  set; unattributed messages dropped; a single warning carries the
  drop count.
- `test_all_messages_unattributed_no_files_no_aggregate` — when EVERY
  implement-phase message lacks a slice_id, no files are produced
  (no fall back to aggregate).
- `test_refine_phase_keeps_aggregate_filename`,
  `test_plan_phase_keeps_aggregate_filename`,
  `test_pr_phase_keeps_aggregate_filename` — regression: only implement
  partitions; refine/plan/pr keep the aggregate even when fixtures
  carry `slice_id` defensively.
- `test_partial_attribution_only_attributed_messages_get_files` —
  exact `dropped_count=1` accounting when one of three buckets is
  unattributed.
- `test_implement_messages_with_empty_slice_id_dropped` — empty-string
  slice_id is treated as missing (security-relevant: must NOT produce
  `42-implement-.md`).
- `test_three_slices_all_get_distinct_files` — N=3 sorted bucket walk;
  exercises deterministic order even on shuffled input.
- `test_idempotent_per_slice_write` — per-slice files are
  byte-identical across repeated writes (preserves #1714 invariant).
- `test_non_dict_metadata_is_treated_as_unattributed` — defensive
  guard around non-dict metadata produces a drop, not a crash.
- `test_rewrite_for_pr_emits_per_slice_implement_files` and
  `test_rewrite_for_pr_mixes_aggregate_refine_and_per_slice_implement`
  — the PR-phase safety-net rewrite (`_rewrite_brc_history_for_pr`)
  inherits the per-slice partitioning correctly; refine and implement
  shapes coexist in the same brc-history dir.

Closes task-2-3 in slice-2 of #2548.

* Adapt per-slice BRC history tests to v3 babysit fallback + SLICE_ID_PATTERN validation (#2548 slice-2)

Folds the v2→v3 coder behavior changes (commits beb2bae, 2a912c5,
70fe103) into the test plan:

- `test_all_messages_unattributed_no_files_no_aggregate` →
  `test_all_messages_unattributed_writes_aggregate_babysit_fallback`:
  v3 fixed reviewer_code_holistic finding #2 — when NO message in the
  store carries a canonical `slice_id`, the writer now falls back to
  the aggregate `<id>-implement.{md,json}` filename so non-slice
  pipelines (babysit_pr) keep producing the artifact documented in
  `skills/babysit-pr/SKILL.md`. Test was rewritten to assert this
  fallback path; complemented by `test_babysit_aggregate_fallback_contains_all_messages`
  which pins that the aggregate carries every BRC-eligible message.
- `test_non_dict_metadata_is_treated_as_unattributed` →
  `test_message_metadata_is_always_a_dict`: v3 dropped the defensive
  `getattr(msg, "metadata", None) or {}` guard now that
  `Message.metadata` is asserted as a Pydantic
  `dict[str, Any] = Field(default_factory=dict)` field. Test now pins
  the Pydantic invariant directly (default-factory yields {}, never
  None) so a future Pydantic-config change shows up here rather than
  as a runtime crash inside `_write_brc_history()`.
- `test_implement_messages_with_empty_slice_id_dropped` →
  `_treated_as_unattributed`: empty-string `slice_id` is now dropped
  via `SLICE_ID_PATTERN` validation rather than the falsy `if not
  slice_id`. Test mixes empty-slice_id with a canonical message so
  partition mode engages (otherwise we'd hit the babysit aggregate
  fallback) and asserts: (a) no `42-implement-.md` is ever produced
  (security: empty slice_id must not be interpolated into the per-slice
  stem), (b) the canonical slice-1 file IS produced, (c) drop warning
  carries `dropped_count=1`.

Net-new tests added to cover v3 behaviors:

- `test_invalid_slice_id_pattern_treated_as_unattributed`: defense-in-depth
  test for the new local SLICE_ID_PATTERN validation. Covers 9
  injection payloads (`../etc/passwd`, `slice-1/extra`, `phase-1`,
  `SLICE-1`, etc.) and asserts: (a) only the canonical slice-1 file
  exists, (b) no aggregate is written (partition mode is engaged), (c)
  no traversal — only the brc-history dir was created under
  `.egg-state/`, (d) drop warning's `dropped_count` matches the
  payload count exactly.
- `test_natural_sort_per_slice_iteration_order`: covers the v3
  reviewer_code non-blocking that switched bucket iteration from
  lexicographic to natural-sort by integer suffix. Patches
  `routes.pipelines.logger.info` to capture "Wrote BRC history file"
  calls and asserts the slice_id sequence is `slice-1, slice-2,
  slice-7, slice-11, slice-12` (not the lex order which would put
  slice-11 / slice-12 before slice-2).

72 tests in `test_brc_history.py` pass; 203 tests across the seven
BRC-history-related test files pass. `ruff check` clean.

* Address tester NACK: tag remaining 3 metadata sites with slice_id (#2548)

Closes the three remaining producer-side gaps the tester flagged on
v3:

1. `handle_producer_push_signal` auto-re-propose CONSENSUS_PROPOSE
   (lines 2110-2118): the auto-push re-propose path now spreads
   `**_slice_meta` into the metadata dict alongside the existing
   `auto_re_propose` / `trigger` / `commit_sha` / `version` /
   `changed_files` keys. Mirrors the manual re-propose path in
   `handle_consensus_propose_signal` patched in v3.
2. `handle_producer_push_signal` auto-re-propose CONSENSUS_RE_REVIEW
   notifications (lines 2125-2148): same fix — spread `**_slice_meta`
   into the per-reviewer notify metadata so the broadcast carries the
   partitioning key end-to-end.
3. `handle_consensus_resolve_obligation_signal` CONSENSUS_OBLIGATION_RESOLVED
   (lines 2017-2025): in-cycle conditional-ACK obligation resolution
   sits in BRC_HISTORY_TYPES and can fire during the implement phase
   with slice scope (typical case: tester satisfies a coder's
   conditional ACK on a per-slice review). The handler already extracts
   slice_id at line 1975 for tracker scoping; spread the same
   `**_slice_meta` shape into the OBLIGATION_RESOLVED message metadata
   so the audit trail lands in the per-slice BRC transcript.

After this commit every producer-side BRC message that can fire in
the implement phase carries `metadata.slice_id` for slice-scoped
callers (PROPOSE, RE_REVIEW manual, RE_REVIEW auto, ACK, NACK,
WITHDRAW, OBLIGATION_RESOLVED, CONFIRMED — already tagged before
slice-2). The remaining BRC_HISTORY_TYPES that don't currently tag
(HEARTBEAT, STATUS, NUDGE, HANDOFF, AGENT_FAILED) are non-blocking per
both the tester's and reviewer_code_holistic's review notes; they
fold cleanly into the partition-mode `unattributed` warning rather
than corrupting the per-slice transcript, and the consensus narrative
itself (the high-value review trail) lands in full.

* Fix checks: apply automated formatting fixes

* Address review feedback on per-slice BRC history (#2548)

Tag non-CONSENSUS BRC emitters with slice_id where their handler
already extracts it (HEARTBEAT in messages.py, excuse-producer STATUS
and ready-to-confirm STATUS in signals.py), so slice-scoped messages
land in the right per-slice transcript instead of the shared bucket.

Narrow `_write_brc_history` so the per-slice partitioning only drops
CONSENSUS_* messages without `metadata['slice_id']` (those remain a D4
contract violation). Non-CONSENSUS BRC types (HEARTBEAT, STATUS,
HANDOFF, AGENT_FAILED, NUDGE, OVERSEER_ALERT) without slice_id come
from emitters that do not uniformly carry slice scope (HealthMonitor
nudges, overseer respawn alerts, AGENT_FAILED broadcasts, CLI-routed
HANDOFF/NUDGE) and are now routed to a sibling
`{identifier}-implement-unattributed.{md,json}` file. The link-line
builder clusters that sibling at the implement rank after every
per-slice file. Update the writer docstring and inline comment to
match.

Drop the redundant local re-import of `SLICE_ID_PATTERN` inside
`_write_brc_history` (it's already imported at module top via the
sandbox/orchestrator dual-import pattern). Delete a dead/typo'd
disjunctive assertion in `test_idempotent_per_slice_write`.

Add tests covering: non-CONSENSUS unattributed routing to the sibling
file, the mixed CONSENSUS_*-dropped + non-CONSENSUS-routed split, and
HEARTBEAT/excuse-producer STATUS slice_id metadata round-trip on the
message bus.

* Address second-round review feedback on per-slice BRC history (#2548)

Fix the cross-module silent no-op flagged as blocking: brc_read_peer_artifact
now mirrors the writer's per-slice filename when EGG_SLICE_ID is set and
phase=='implement' (reads {identifier}-implement-{slice_id}.json), and by
default merges the cross-cutting unattributed sibling so reviewers see
their slice's CONSENSUS_* interleaved with OVERSEER_ALERT / AGENT_FAILED
context. Pipeline-level (non-slice) callers still read the aggregate file.

Non-blocking fixes:
- _emit_ready_to_confirm_nudges now has dedicated slice_id metadata tests
  (slice-scoped + pipeline-level pair, mirroring the excuse-producer pair).
- test_idempotent_per_slice_write extended to assert the unattributed
  sibling md/json are byte-identical across repeated writes.
- _render_brc_history_markdown special-cases slice_id=='unattributed':
  heading reads 'cross-cutting (unattributed)' and the metadata block uses
  Section: instead of Slice:, since unattributed is not a slice.
- docs/guides/concurrent-execution.md updated to show the per-slice link
  line shape and explain the unattributed sibling cluster.

* Address third-round non-blocking feedback on per-slice BRC history (#2548)

Sync the brc_read_peer_artifact handler's message_type filter
whitelist with the orchestrator-side BRC_HISTORY_TYPES emitter:

- Fix CONSENSUS_WITHDRAWN -> CONSENSUS_WITHDRAW typo (the writer
  emits CONSENSUS_WITHDRAW; the handler's whitelist rejected the
  correct name and accepted a string the writer never produces).
- Add CONSENSUS_OBLIGATION_RESOLVED, STATUS, HANDOFF, AGENT_FAILED,
  NUDGE, OVERSEER_ALERT, HEARTBEAT to the whitelist so reviewers can
  filter the implement-phase unattributed sibling on those types
  (e.g. message_type=[OVERSEER_ALERT] to scan cross-cutting alerts).
  The handler's docstring already invited these filters; the
  whitelist now matches.
- Update the schema description in tools/brc.py to match.
- Add test_filter_by_message_type_overseer_alert_in_unattributed
  exercising the OVERSEER_ALERT filter against an unattributed
  sibling fixture; locks in the writer/reader symmetry.

Also drop the underscore-prefixed _SLICE_ID_PATTERN private import
in brc_read_peer_artifact and use the existing public
resolve_slice_id({}) helper from _gateway -- same validation,
fewer reach-inside imports, consistent error message format with
the rest of the slice-aware tools.

* Add full-set drift guard locking BRC_HISTORY_TYPES writer/reader symmetry

Closes the fourth-round non-blocking review item on #2548 slice-2: the
existing single-type regression test pinned OVERSEER_ALERT only, so
adding a new entry to the writer-side BRC_HISTORY_TYPES without
updating the sandbox-side _BRC_HISTORY_TYPES would slip past the suite.

The new test in TestBrcHistoryTypesDriftGuard regex-extracts the
writer-side frozenset literal from orchestrator/routes/pipelines.py and
asserts it equals the handler-side whitelist. This preserves the
deliberate sandbox -> orchestrator package-boundary (the orchestrator
pulls fastapi) while still locking the contract: any future drift on
either side surfaces as a test failure with explicit handler-only /
writer-only diffs in the assertion message.

---------

Co-authored-by: egg <egg@example.com>
Co-authored-by: egg <egg@localhost>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>

---------

Co-authored-by: egg <egg@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
Co-authored-by: egg <egg@localhost>
@jwbron
jwbron merged commit 6ce0184 into main May 8, 2026
11 checks passed
@jwbron
jwbron deleted the egg/issue-2548/work branch May 8, 2026 05:17

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No agent-mode design concerns.

Reviewed the new/changed surfaces against the agent-mode design guidelines:

  • orchestrator/impasse_routing.py (new) — pure orchestration over the typed Impasse payload the sandbox handler emits. No LLM calls, no prompt construction.
  • sandbox/egg_agent_tools/handlers/restrictions.py (new) — sandbox-side fast-fail helper backed by the same shared/egg_restrictions/patterns.py registry the gateway commit-validator enforces. This is correctly a sandbox/gateway boundary, not a prompt-level instruction.
  • shared/egg_contracts/plan_parser.py + docs/templates/plan.md — the YAML appendix is consumed by the orchestrator (Slice/Task contract objects), not by humans, so the structured-output requirement is legitimate. Human-facing prose above it stays free-form.
  • orchestrator/routes/pipelines.py — the new _read_phase_draft embed in implement-phase prompts (line 9748) is capped at 32K chars and only fires on review_cycle == 0. It's borderline but bounded, justified by the comment ("avoids file-I/O turns inside the sandbox"), and provides orientation rather than constraining what the agent can fetch. Not a clear anti-pattern.
  • No new direct Anthropic API calls in orchestrator/gateway/shared. No new pinned model identifiers. No new post-processing pipelines parsing natural-language agent output.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Contract Verification — PR #2572 (issue #2548)

This review verifies the merged PR against the contract at
.egg-state/contracts/issue-2548.json. Posted as a comment because
the PR has already merged into main — request-changes / approve are
no-ops at this point. Verdict at the bottom.

TL;DR

  • Slice-1 implementation (PRMetadata + planner prompt) matches its
    contract acceptance criteria.
    The model delta, schema-version bump,
    migration shim, tests, planner prompt, and YAML ingestion are all in
    place and behave as specified.
  • Contract integrity is broken. The on-disk contract is still in
    current_phase: refine with all 14 tasks across all 5 slices in
    status: pending and zero linked commits. Top-level
    acceptance_criteria: [] — there is nothing for
    egg-contract verify-criterion --criterion ac-N to mark, and
    egg-contract is in any case unreachable
    (Orchestrator unreachable — try again).
  • Scope is much larger than slice-1 of #2548. The merged diff
    includes work for issues #2532, #2549, #2474, #2527, #2554, #2529
    alongside slice-1 (and apparently slice-2) of #2548. None of those
    appear on the #2548 contract. Reviewers approaching this PR from
    the contract have no traceable mapping from the contract's tasks to
    the merged commits.

Method

  1. Loaded .egg-state/contracts/issue-2548.json directly (orchestrator
    unreachable, so egg-contract show could not be used).
  2. Walked each slice / task acceptance_criteria block and checked
    the merged diff (gh pr diff 2572) against the criteria.
  3. Spot-checked the slice-1-specific child PR (#2571) to disambiguate
    issue-#2548 work from the rollup of unrelated merges this work
    branch caught up to.

Slice-by-slice findings

Slice-1 — Contract schema delta + planner prompt

task-1-1 (PRMetadata + schemaVersion 1.0 → 1.1) — VERIFIED.

  • shared/egg_contracts/models.py adds the four required fields with
    the prescribed defaults:
    • context_title: str | None = None
    • context_description: str | None = None
    • context_branch: str | None = None
    • context_pr_number: int | None = None with ge=1 validator
      (matches the recommendation in the original task description).
  • Contract.schemaVersion default bumped from "1.0" to "1.1".
  • Model-level migration _migrate_schema_version_to_1_1 runs in
    mode="after", idempotent, only fires on exactly "1.0".
  • Acceptance criteria for task-1-1 are met:
    • The four fields are exposed with None defaults.
    • A 1.0 contract round-trips and gets promoted to 1.1.

task-1-2 (PRMetadata tests) — VERIFIED, with one deviation.

  • Tests live at tests/shared/egg_contracts/test_pr_metadata.py
    (906 lines), not shared/egg_contracts/tests/test_pr_metadata.py
    as the contract's files_affected lists. The test docstring
    documents this as intentional — the tests/... tree is what
    pytest collects, and the in-package path is not in testpaths.
    This is a sensible pragmatic deviation; flagging it for traceability.
  • Coverage matches the acceptance criteria:
    • Round-trip with all four context fields populated.
    • Round-trip with all four context fields omitted (defaults None).
    • Round-trip of schemaVersion=1.0 payload, confirming migration to
      1.1 and None defaults.
    • context_pr_number=0 and negative values raise ValidationError;
      None is accepted; validate_assignment re-runs on setattr.
  • Tests live in the diff, would discover under make test /
    make test-all.

task-1-3 (Planner prompt + YAML ingestion) — VERIFIED, with
one minor scope deviation
.

  • orchestrator/routes/pipelines.py adds:
    • _PR_CONTEXT_GUIDANCE — prose explaining
      pr.context_title / pr.context_description, when to emit them,
      and that pr.context_branch / pr.context_pr_number are
      orchestrator-populated (must NOT be emitted by the planner).
    • _PR_CONTEXT_YAML_EXAMPLE_LINES — commented-out YAML hints
      threaded into the # yaml-tasks example inside _build_phase_prompt.
  • shared/egg_contracts/plan_parser.py gains
    extract_pr_context_metadata_from_yaml() and threads
    pr_context_title / pr_context_description through ParseResult
    and parse_plan(). Non-string scalars produce ParseWarning rather
    than coercing silently.
  • The acceptance criterion "A planner-emitted YAML containing
    context_title: and context_description: is parsed without error
    and the values land on contract.pr.context_title /
    pr.context_description" is satisfied by the new extractor + the
    optional fields on ParseResult.
  • The acceptance criterion "A planner-emitted YAML omitting the new
    keys still parses and both context fields default to None" is
    satisfied by the is None early returns in the extractor.
  • Deviation: .github/scripts/checks/plan_yaml_check.py is not
    modified
    in this PR — but inspection of the file shows it is a
    structure-only validator (presence of phases: / tasks: /
    required ID fields). It does not reject unknown pr.* keys, so
    the new keys pass through it untouched. The criterion
    "plan_yaml_check.py runs cleanly on both inputs" is therefore
    satisfied de facto, but the contract's files_affected listed it,
    and the file was not touched. Non-blocking.
  • Deviation: orchestrator/routes/phases.py is also not modified.
    Inspection shows phases.py does not have an independent
    yaml-tasks ingestion path — the canonical path is
    plan_parser.py. The files_affected listing in the contract
    appears to have been speculative. Non-blocking.

Slices 2–5 — out of scope for this PR per the contract's task list

The contract's tasks 2-1 through 5-1 are all status: pending with no
linked commits. However, the merged diff clearly contains work that
overlaps slice-2 (BRC-history per-slice writes —
orchestrator/routes/pipelines.py _write_brc_history,
orchestrator/tests/test_brc_history.py +1073/-57) and surrounding
infrastructure. This is consistent with the child PR #2571 commit
[slice-2] Add context PR + per-slice BRC history (closes #2548) (#2564).

Without a contract-tracked mapping, I cannot reliably attribute lines
to slice-2/3/4/5 acceptance criteria. The contract simply does not
reflect what was merged. This is the contract-integrity issue called
out in the TL;DR — the tasks were never complete-task'd, no commits
were add-commit'd, and the phase was never advanced past refine.

Cross-cutting work that does NOT belong to issue #2548

The PR also bundles, per its commit list:

  • f94c7c1c Fix #2532: align .github/ block in agent_roles.py
  • 5582aa03 Fix #2549: skip already-merged slices (#2552)
  • 3cb7aef5 Egg/issue 2474 v2/work (#2556)
  • f87a5ea7 Fix #2527: validate task role↔file alignment (#2551)
  • 14cf8879 docs for #2527 (#2558)
  • 08fb8f8c Fix #2554: agent-status table on BRC dashboard
  • 802dc2c9 Fix #2529: runtime escape hatch for impossible tasks (#2553)

These show up in the diff (e.g. orchestrator/impasse_routing.py +554,
shared/egg_contracts/impasse.py +156, shared/egg_contracts/agent_roles.py,
shared/egg_contracts/plan_parser.py role alignment, etc.) and are
unrelated to issue #2548's contract. Some of them appear to have already
been merged to main via their own PRs (#2552, #2553, #2556, #2558),
so they are showing up here only as catch-up merges into the
long-running egg/issue-2548/work branch. That's how gh pr diff works
against main, not a sign that they were re-merged. Worth noting for
the reviewer audit trail.

Concerns / red flags

  1. Contract was not advanced. The on-disk contract is still in
    current_phase: refine, all tasks pending, no commits linked.
    The orchestrator is unreachable so this could not be reconciled
    live. A future agent or operator looking at this contract will
    see "no work done" and that is materially false.
  2. Top-level acceptance_criteria: []. The egg-contract verify-criterion
    surface only accepts ac-N IDs against the top-level list, which is
    empty. There is therefore no programmatic way to record the
    verifications I performed in this review against the contract; the
    trail lives only here.
  3. Test path differs from contract. Tests are at
    tests/shared/egg_contracts/test_pr_metadata.py (correct for
    pytest discovery) instead of shared/egg_contracts/tests/test_pr_metadata.py
    (as files_affected listed). The author documents this in the test
    docstring; not a blocker.
  4. .github/scripts/checks/plan_yaml_check.py and
    orchestrator/routes/phases.py not modified.
    The contract's
    files_affected listed both. The check is structure-only and does
    not need updating to admit the new keys; phases.py does not have
    an independent ingestion path. Non-blocking but flagged.
  5. PR scope is much larger than the contract describes. Slice-2-5
    work and unrelated bug-fix merges are bundled into the same merge
    commit on main. Reviewer hygiene around tracking what landed is
    degraded.

Verdict

Comment — the slice-1 implementation is correct and matches its
acceptance criteria, but the PR cannot be cleanly verified against the
contract because (a) the contract was never advanced past refine,
(b) the orchestrator is unreachable to mark verification status,
(c) the merged diff materially exceeds slice-1's stated scope. The
implementation looks good; the process trail is broken.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: PR #2572 (egg/issue-2548/workmain)

Scope: 80 files, +14,941 / −3,263. The bulk is auto-generated .egg-state/ artifacts (skipped per review rules) plus a docker-compose → k3s test-infra cleanup that's already justified in code (#2474). Production changes I focused on: the new Impasse runtime escape-hatch (shared/egg_contracts/impasse.py, orchestrator/impasse_routing.py, sandbox/egg_agent_tools/handlers/restrictions.py), the planner role↔file alignment validator (shared/egg_contracts/plan_parser.py, orchestrator/routes/signals.py), and the schema additions for the forthcoming context-PR (shared/egg_contracts/models.py).


Blocking

1. report_impasse handler does not enforce Impasse.reason length → orchestrator silently drops the impasse.
sandbox/egg_agent_tools/handlers/restrictions.py:208-210 only checks non-empty string, but egg_contracts.impasse.Impasse.reason is declared min_length=1, max_length=2000 (shared/egg_contracts/impasse.py:80-88). When an agent submits a multi-page traceback as the reason — exactly the natural thing to do for an external_blocker or unknown category — the handler returns ok: True and persists the over-long payload to the agent-output file. Post-phase, orchestrator/impasse_routing.py:138-147 calls Impasse.from_dict(impasse_raw), Pydantic raises ValidationError, the orchestrator logs "Discarding malformed impasse payload" at warning level, and the impasse is silently dropped. The producer's container has already been told to "Stop all further work for this task and exit cleanly" — so the task stalls with neither delegation nor HITL escalation, and the only signal is a single warning line in the orchestrator log.

This is the exact "operator-facing silent failure" / "cross-module dead-end" pattern the review rules call out: producer emits, downstream filter drops, no signal. Fix at restrictions.py:208-210: enforce len(reason) <= 2000 (and validate task_id shape if there is one) so the failure surfaces synchronously to the agent rather than being absorbed by a server-side warning.

While you're in this area, consider also capping evidence (line 247-249) — evidence: dict[str, Any] accepts arbitrary nesting/size, and Impasse.from_dict doesn't enforce a JSON-serialised limit either; an agent stuffing megabytes of structured data here would bloat HITL decision bodies.

2. Pre-existing Python 2-style except clauses survive in code this PR modifies.
shared/egg_contracts/plan_parser.py:529 (except ValueError, TypeError:) and shared/egg_contracts/agent_roles.py:1013 (except ValueError, KeyError:). These compile on 3.14 only because of PEP 758 (the project's requires-python = ">=3.14"), but:

  • Anyone reading the diff without PEP 758 in mind will read these as Python-2 bugs (in pre-3.14 Python they'd be a SyntaxError or, worse, parse as except ValueError as TypeError: and silently bind to the second name).
  • Most linters / type-checkers / IDE syntax highlighters ship parser tables that haven't tracked PEP 758 yet — mypy, pyflakes, and Tree-sitter Python all flag these as errors at the time of writing. Anyone running make lint against a not-yet-updated venv will hit syntax errors here.
  • This PR rewrites a large fraction of agent_roles.py (+24/-30) — it's the natural place to normalize.

Fix: parenthesize. except (ValueError, TypeError):, except (ValueError, KeyError):. Per the review rules' "pre-existing inconsistency in modified code" clause this is blocking — the PR is in the file already, do it now.


Non-blocking (but should be fixed soon)

3. git show in planner role-alignment validator omits -- separator.
orchestrator/routes/signals.py:997:

["git", "-C", str(worktree_path), "show", f"{commit_sha}:{plan_rel}"]

commit_sha comes from the planner's BRC propose payload — (payload.get("commit_sha") or "").strip() at line 957 — with no shape validation. The sibling _verify_commit_on_branch at line 273 uses "--" correctly:

["git", "-C", ..., "branch", "-r", "--contains", "--", commit]

For a SHA starting with -, git show parses it as an option (e.g. --ext-diff, -c key=val, etc.). The threat model is weak — the planner agent already has subprocess access in its sandbox, so this isn't a privilege escalation — but the asymmetry vs _verify_commit_on_branch is a regression in defensive hygiene. Fix: insert "--" before f"{commit_sha}:{plan_rel}", or re.fullmatch(r"[0-9a-f]{7,64}", commit_sha) upstream.

4. Silent git show non-zero / ImportError swallow loses operator signal.
Same function, signals.py:1003-1004:

if result.returncode != 0:
    return  # Plan not present at this commit — nothing to validate.

This conflates "plan absent at commit" (legitimate) with "permission denied / git timeout / corrupted worktree" (bugs the operator wants to know about). Same at signals.py:1020-1021 — bare except ImportError: return for a routes/pipelines import. The function's docstring says "graceful degradation" — fine for the gateway-backstop case, but log result.stderr.strip() at debug or warning level so post-mortem forensics has something to read.

5. restrictions.py accepts caller-supplied identifier / repo_path without validation.
sandbox/egg_agent_tools/handlers/restrictions.py:268-271:

repo_path = Path(req.get("repo_path") or get_repo_path())
identifier = req.get("identifier") or req.get("issue") or req.get("pipeline_id")

Both flow straight into save_agent_output(repo_path, contract_role, output, identifier=identifier) (line 316) which interpolates identifier into a filename: .egg-state/agent-outputs/{identifier}-{role}-output.json. A malicious identifier="../../../../tmp/foo" writes outside the agent-output dir; a malicious repo_path writes outside the repo. The agent already has full filesystem access in its sandbox so this isn't privilege escalation, but it's inconsistent with the very recent _resolve_env_identifier_for_brc_history hardening (sandbox/egg_agent_tools/handlers/brc.py:855-882) that ignores caller-supplied identifier and pulls only from env. Same hardening should apply here — particularly because a path-traversal write could spoof another role's agent-output file (the orchestrator's role-keyed loader trusts the filename → role mapping).

6. _alternative_role returns a producer role even when blocked_role is non-producer.
restrictions.py:47-67 loops for role in ("coder", "tester", "documenter") and skips the matching role. If an architect or overseer calls check_file_restriction, it gets a "helpful" alternative_role suggestion that the orchestrator-side router (impasse_routing._is_eligible_delegation, gated on _DELEGATION_ELIGIBLE_ROLES = {"coder", "tester", "documenter"}) will refuse to honor anyway. Return None early when blocked_role not in {"coder", "tester", "documenter"} so the agent gets honest "no auto-delegation possible" UX.

7. route_impasses swallows save_contract failure but returns in-memory decisions.
orchestrator/impasse_routing.py:393-401:

if mutated:
    try:
        save_contract(contract, repo_path)
    except Exception as exc:  # pragma: no cover - defensive
        logger.error("Failed to persist contract after impasse routing", ...)
return decisions

The caller proceeds with the in-memory decisions list, but on disk the contract is unchanged. If the orchestrator restarts before the next persist boundary, the routing decision (delegation or HITL escalation) is lost — the next iteration scans agent-output files, finds the same impasse, and re-routes. The force_escalate=True terminal path is particularly affected: an escalation that doesn't persist creates no operator-visible HITL decision. Either retry, or surface the failure to the slice-loop's exit code so it doesn't keep walking.

8. Decision IDs derived from len(contract.decisions) collide if anything else ever mutates the list.
impasse_routing.py:248-250 builds decision_id = f"decision-{next_idx + 1}" and field_path = f"decisions.{next_idx}" from the current length. Inside a single route_impasses call this is fine because apply_mutation appends and the loop re-reads length each iteration. But the contract is also mutated by HITL resolution paths and other routers; if any path ever shrinks decisions (e.g. a future cleanup task), this generator collides silently. Use a UUID or secrets.token_hex(4)-suffixed ID.

9. _find_task falls back to "single role match" with (t.role or "coder") == role.
impasse_routing.py:183-187. A task with role=None matches a coder impasse but not a tester/documenter impasse. If the planner ever emits role-less tasks (it currently shouldn't but neither the type nor the schema forbids it), tester/documenter impasses fall through to force_escalate instead of routing correctly. Either normalize task.role at plan-ingestion or document this assumption with an assert at the start of _find_task.

10. New tests in shared/egg_contracts/tests/ may not be picked up by make test.
shared/egg_contracts/tests/test_validate_task_role_alignment.py (286 lines, new) lives next to the source rather than in the tests/ tree configured under [tool.pytest.ini_options].testpaths. The comment at tests/shared/egg_contracts/test_pr_metadata.py:21-25 (also new in this PR) explicitly notes that the in-package test path is not in testpaths. Verify make test picks these up — run .venv/bin/pytest --collect-only shared/egg_contracts/tests/ and confirm. If not, either move the file under tests/ or add the path to testpaths. Same caveat applies to shared/tests/test_github_block_alignment.py and shared/tests/test_impasse_schema.py.

11. integration_tests/test_credential_security.py silently skips under k3s.
integration_tests/conftest.py skips isolated_container / external_container / test_container fixtures (their docstrings acknowledge this) and the conftest itself notes "the main consumer is currently uncovered in CI and needs a k3s-native replacement before it runs again." This is a security-critical property (credential isolation) producing green CI without exercising the property. Not blocking for this PR — it landed via #2474 — but file/link a follow-up issue and convert the skips to xfail(strict=True) so the gap surfaces.


Notes (not findings)

  • The "context PR" feature is intentionally inert at this slice — docs/architecture/sdlc-pipeline.md:132-138 and docs/templates/plan.md:136-150 explicitly disclose that only the schema fields and planner-prompt advertisement are wired in slice-1; orchestrator branch-creation/PR-opening hooks land in slices 3-4. The _populate_contract_from_plan preservation block at routes/pipelines.py:15771-15786 and the planner-prompt guidance at :9379-9405 are forward-compatible stubs, not silent dead-ends. Honest, not aspirational.
  • The Impasse Pydantic model is well-shaped: extra="forbid", min_length=1 / max_length=2000 on reason, default_factory (not mutable defaults) on list/dict fields. Tests cover empty-reason, unknown-category, and extra-field rejection at the model boundary.
  • The .github/ block alignment work (agent_roles.py rewrite + test_github_block_alignment.py) cross-checks egg_contracts.agent_roles.get_file_patterns against egg_restrictions.get_agent_pattern for all 11 roles — clean.
  • The plan-time _is_file_blocked_for_role mirror of the gateway's push-time FileRestriction.is_file_blocked is the right shape (1:1 normalization, blocked + block-exempt patterns, rejects .. / leading-slash).

The two blocking items (silent over-long-reason drop, and the pre-existing PEP 758 except clauses in code this PR modifies) should be straightforward. The defense-in-depth items (3, 4, 5, 6) are also small fixes worth bundling. Item 7 (save_contract failure swallow) and item 11 (credential isolation skip) deserve follow-up issues if not addressed here.

— Authored by egg

@james-in-a-box

james-in-a-box Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

egg review completed. View run logs

2 previous review(s) hidden.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant