# Feature Spec: Runtime Performance & Agent Context Optimization

**Version:** 1.1
**Date:** 2026-03-03
**Status:** Draft (Architect Review Edition)
**Milestone:** M30 – Performance & Efficiency

> **Purpose:** Diagnose and implement fixes for sub-optimal runtime, memory, and agent context-size patterns in the control plane. Section 2 is the diagnostic record (what is wrong and why). Section 3 is the implementation plan (what to change and how). Each task in Section 3 maps to one or more findings in Section 2.

> **Note on roadmap positioning:** M30 is a performance track that is independent of the M33–M38 quality/governance roadmap. It can be executed before, after, or in parallel with those milestones with no ordering dependency.

---

## 0. Implementation Standards & References

### 0.1 Testing Standards

All new and modified code MUST follow the testing standards already established in the repo:

- Use Vitest (`describe/it/expect`, `vi` mocks/spies)
- Test files live in `apps/control-plane/test/*.spec.ts`
- Use **Given / When / Then** naming: `GIVEN_<context>_WHEN_<action>_THEN_<expected>`
- **Maintain coverage thresholds: Lines ≥90%, Branches ≥90%, Functions ≥90%, Statements ≥90%** (per CLAUDE.md — overrides any lower figure stated elsewhere in this spec)
- Time-dependent tests must use `vi.useFakeTimers()`
- No real filesystem I/O in unit tests; use `tmp` directories or `vi.mock('node:fs/promises')`

### 0.2 Guiding Constraints

- **No breaking tool contract changes.** Backward-compatible additive additions to existing tool input schemas (optional parameters with documented defaults) are permitted, but must be explicitly called out and validated through the MCP contract validator.
- **No breaking on-disk artifact format changes.** Backward-compatible additive fields (e.g., `schema_version`, `evidence_retention_count`) are permitted.
- **Write ordering is preserved.** Parallelizing reads is permitted; parallelizing tool calls that write shared state (PATCH_APPLY, LOCK_ACQUIRE, PLAN_UPDATE, etc.) is explicitly out of scope for this spec. Any change that makes previously-sequential write-producing tool calls concurrent is a behavioral change and must not be made here.
- **All existing tests must remain green** after each task.
- **Each task is independently mergeable** — do not batch unrelated changes.

---

## 0.3 Architectural Analysis (v1.0 Issue Log)

The following issues were identified in the v1.0 spec during architect review against the actual codebase. Each issue is assigned a severity and cross-references the task where it is resolved.

| #    | Severity     | Issue                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Resolution                                                                                                                                                                                                                            |
| ---- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A-1  | **CRITICAL** | **`latest.json` does not exist.** Finding I-5 and PER-T-005/006 claim `gates.ts:430` writes a `latest.json` sentinel. The actual code at line 429–430 writes `latest-${mode}.json` (e.g., `latest-fast.json`). PER-T-005's fix of reading `path.join(evidenceDir, 'latest.json')` would always return null, silently breaking `featureGetContext` for all agent roles. PER-T-006's pruning filter `f !== 'latest.json'` would delete all mode-specific sentinels. | PER-T-005 now writes a canonical `latest.json` in `gates.ts` alongside the per-mode sentinel; PER-T-006 pruning filter excludes `latest-*.json` patterns.                                                                             |
| A-2  | **CRITICAL** | **PER-T-003 is a write-ordering behavioral change.** The v1.0 fix eliminates the sequential `for` loop (lines 41–52) and moves `workerDecisionRunner.execute()` into the parallel `executing` map. `workerDecisionRunner.execute()` for the builder role can call PATCH_APPLY, LOCK_ACQUIRE, and PLAN_UPDATE — all writes to shared state. Making these concurrent violates §0.2.                                                                                 | PER-T-003 is redesigned as a **context-capture Map pattern**: the sequential loop is preserved; context is stored in a Map and reused by the parallel executing map, eliminating the duplicate fetch without changing write ordering. |
| A-3  | **CRITICAL** | **PER-T-003 reused pre-decision context for repair loop.** After `workerDecisionRunner.execute()` applies patches (changing working-tree state) and potentially acquires or releases locks (mutating `state.md`), the captured pre-decision context is stale. The repair loop needs fresh state to give agents accurate `locks.held` data.                                                                                                                        | PER-T-003 redesign fetches repair context as a fresh call after gate failure, which is the minimal re-fetch (one call per failure, not one per retry).                                                                                |
| A-4  | **HIGH**     | **Coverage thresholds wrong.** §0.1 specified Lines ≥70%, Branches ≥70%, Functions ≥85%. CLAUDE.md mandates 90% on all metrics. Implementing to v1.0 thresholds would pass spec-level gates but fail CI.                                                                                                                                                                                                                                                          | §0.1 corrected to 90% everywhere.                                                                                                                                                                                                     |
| A-5  | **HIGH**     | **`normalizeSet` extraction incomplete.** PER-T-013 identified 4 files but the codebase has 7 copies: `reporting-service.ts`, `merge-service.ts`, `feature-lifecycle-service.ts`, `feature-deletion-service.ts`, `kernel.ts`, `lock-service.ts`, `plan-service.ts`. Leaving 3 copies behind is worse than a partial refactor because it creates a false sense of completeness.                                                                                    | PER-T-013 scope expanded to all 7 files.                                                                                                                                                                                              |
| A-6  | **HIGH**     | **§0.2 constraint conflict with PER-T-008.** §0.2 stated "Input/output schemas for all 33 MCP tools remain unchanged." PER-T-008 adds an optional `role` parameter to `feature.get_context`. These directly contradict each other; §4 item 6 tried to paper over this by calling it "backward-compatible."                                                                                                                                                        | §0.2 amended to "no breaking changes" with explicit allowance for backward-compatible optional input additions.                                                                                                                       |
| A-7  | **HIGH**     | **Output schema implicit shape change in PER-T-008.** When `role=builder`, `qa_test_index` in the response payload becomes `{summary}` instead of the full object. The tool's output JSON schema doesn't account for this. Validators would fail at runtime when trying to validate the projected payload against the existing schema.                                                                                                                            | PER-T-008 adds a note that the existing output schema validates the `full` projection only; partial projections bypass output schema validation and return raw AOP objects.                                                           |
| A-8  | **MEDIUM**   | **`QaIndexRecord` type undefined.** PER-T-009 uses `QaIndexRecord` as a type in `projectQaIndex` but this type does not exist in the codebase. No import path is given.                                                                                                                                                                                                                                                                                           | PER-T-009 defines a local `QaIndexSnapshot` interface inline and derives the projection from `AnyRecord`.                                                                                                                             |
| A-9  | **MEDIUM**   | **Projection table vs implementation inconsistency.** PER-T-008 table shows QA role gets `summary + failed + pending` from `qa_test_index`, but PER-T-009 code returns `{ passed: _omitted, ...rest }` — all fields except `passed`, including `running`, `flaky`, etc. The table and code must agree.                                                                                                                                                            | PER-T-009 implementation updated to return only `{ summary, failed, pending }` for QA role, matching the table.                                                                                                                       |
| A-10 | **MEDIUM**   | **`schema_version` guard requires manual discipline.** PER-T-011 relies on developers remembering to bump `CURRENT_INDEX_SCHEMA_VERSION` when `normalizeIndexShape` changes shape. If forgotten, stale indices never migrate and the performance optimization yields incorrect behavior.                                                                                                                                                                          | PER-T-011 adds a mandatory developer protocol note with a co-located integration test that detects shape drift.                                                                                                                       |
| A-11 | **MEDIUM**   | **PER-T-007 leaves planning context fetches sequential.** After pre-filtering with `FEATURE_STATE_GET`, the full context fetches for `planningFeatureIds` remain sequential in a `for` loop. Parallelizing these fetches extends the savings from PER-T-001's parallel-reads principle.                                                                                                                                                                           | PER-T-007 updated to use `Promise.all` for the planning context fetches; `runPostQaReconciliation` pre-filter is fully specified with concrete code.                                                                                  |
| A-12 | **LOW**      | **`gate-service.spec.ts` does not exist.** PER-T-005 says "add cases to `gate-service.spec.ts` (create if not existing)" — should be explicit.                                                                                                                                                                                                                                                                                                                    | PER-T-005 updated to say "create `gate-service.spec.ts`."                                                                                                                                                                             |

---

## 1. Objectives

### 1.1 Must-Have Outcomes

- Reduce per-orchestration-iteration latency by eliminating unnecessary sequential I/O in wave executors.
- Eliminate the duplicate context fetch on gate failure in `BuildWaveExecutor`.
- Bound evidence directory growth with a configurable retention policy.
- Reduce agent context window token usage by 40–60% through role-specific projections and pre-filtering.
- Evict dead entries from `RunCoordinator.statusCache` to prevent slow memory growth over long runs.
- Replace the `JSON.stringify` diff in `readIndex` with a cheap version guard.

### 1.2 Non-Goals (Patterns Examined and Rejected)

The following patterns were reviewed but are **not considered sub-optimal** given current constraints:

- **Schema validation on every state write** (`kernel.ts`): Correctness is the priority; AJV-compiled validators are fast after first compilation. Sampling or skipping would risk silent schema drift.
- **AJV lazy compilation** (`schemas.ts`): First-call compilation is a one-time cost per schema type; the cache works correctly afterwards, and eager preloading adds startup latency without runtime benefit.
- **Session activation polling loop** (`kernel.ts`): Fixed 250ms polling bounded by a 5s timeout is an internal synchronization mechanism on a cold path, not a hot loop.
- **Object spread in `makeDefaultState`** (`kernel.ts`): Feature initialization is a cold path; spread of small constant-size default objects is negligible.
- **Parallelizing `workerDecisionRunner.execute()` calls** (`build-wave-executor.ts`): These calls can write shared state (patches, locks, plan updates). Making them concurrent is a behavioral change outside the scope of §0.2 and this spec.

---

## 2. Problem Analysis

Findings are organized by category. Numbers assume 10 active features over 3 orchestration iterations unless otherwise noted. Each finding cross-references its implementation task in Section 3.

### 2.1 Runtime / I/O Hotspots

#### Finding I-1 — `featureGetContext`: 5 sequential disk reads per call (HIGH) → PER-T-001

**File:** `apps/control-plane/src/application/services/feature-lifecycle-service.ts:142–146`

```typescript
const state    = await this.port.featureStateGet(featureId);   // read 1
const plan     = await this.port.planGet(featureId);            // read 2
const qaIndex  = await this.port.qaTestIndexGet(featureId);     // read 3
const evidence = await this.port.evidenceLatest(featureId);     // read 4
const specText = await fs.readFile(this.port.specPath(...));    // read 5
```

All five reads are sequential `await`s with zero data dependencies on each other. Under MCP transport every read incurs a full round-trip. This function is called at least once per feature per wave by all three wave executors, plus a second time in `BuildWaveExecutor` on gate failure.

**Quantified impact:** 10 features × 3 waves × 5 reads = 150 serial I/O calls per iteration that could be reduced to 30 parallel batches.

---

#### Finding I-2 — `BuildWaveExecutor`: duplicate full context fetch on gate failure (HIGH) → PER-T-003

**File:** `apps/control-plane/src/supervisor/build-wave-executor.ts:42` and `95`

The context bundle fetched before the initial `workerDecisionRunner.execute` call (line 42) is captured in the sequential phase. A second identical `FEATURE_GET_CONTEXT` call at line 95 re-fetches a large bundle unconditionally before entering the repair retry loop.

**Root cause:** The initial context fetch (line 42) and the repair context fetch (line 95) are in separate execution scopes: a sequential `for` loop and a parallel `Promise.all` map respectively. The variable from the sequential scope is not available in the parallel scope.

**Quantified impact:** With 5 retry attempts per failing feature × 50 KB context = 250 KB of redundant I/O per failing feature per wave.

**Constraint note:** The sequential `for` loop containing `workerDecisionRunner.execute()` MUST remain sequential (§0.2). The fix captures context into a Map during the sequential phase so the parallel phase can reuse it. Repair-loop context (post-decision) is still re-fetched once per feature on failure — this preserves accuracy while eliminating the per-retry re-fetch in the original code.

---

#### Finding I-3 — Build + QA wave executors: sequential state-filter loops (HIGH) → PER-T-002

**Files:** `apps/control-plane/src/supervisor/build-wave-executor.ts:30–38`, `qa-wave-executor.ts:54–62`

Both executors loop sequentially over all active features to filter by status before any gate work begins. With 20 active features this is 20 serial `FEATURE_STATE_GET` tool calls just to produce a smaller subset.

**Quantified impact:** 20 features × 2 wave executors = 40 serial tool calls per iteration that could be 2 parallel batches.

---

#### Finding I-4 — `reportDashboard`: sequential state + cost reads per feature (MEDIUM) → PER-T-004

**File:** `apps/control-plane/src/application/services/reporting-service.ts:81–108`

The dashboard loop reads `state.md` and the cost JSON file for each feature one after another. Called once per orchestration cycle via `TOOLS.REPORT_DASHBOARD` and also from the CLI `status` command.

**Quantified impact:** 50 features × 2 reads = 100 serial file reads per `reportDashboard` call.

---

#### Finding I-5 — `evidenceLatest`: O(N) stat scan to find newest file (MEDIUM) → PER-T-005

**File:** `apps/control-plane/src/application/services/gate-service.ts:236–256`

On every `featureGetContext` call, `evidenceLatest` lists the entire evidence directory, calls `fs.stat()` on every `.json` file to read mtimes, sorts by mtime, then reads the newest file.

`gates.ts:429–430` already writes a per-mode sentinel file on every gate run:

```typescript
const latestPath = path.join(evidenceDirectory, `latest-${mode}.json`);
await fs.writeFile(latestPath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
```

However, `evidenceLatest` ignores these per-mode sentinels and rediscovers the newest file the hard way on every call. A single canonical `latest.json` (written by `gates.ts` after each gate run, regardless of mode) would allow O(1) lookup.

**Quantified impact:** 50 retries × 3 profiles = 150 evidence files per feature × 10 features = 1,500 `stat()` calls per context fetch, all avoidable by reading `latest.json` directly.

---

#### Finding I-6 — `readIndex`: `JSON.stringify` comparison on every call (MEDIUM) → PER-T-011

**File:** `apps/control-plane/src/core/kernel.ts:758`

```typescript
const changed = JSON.stringify(existing ?? null) !== JSON.stringify(normalized);
```

`readIndex` is called on every orchestration cycle and every state transition. Serializing a 20–50 KB index object twice on every call is wasted CPU — schema migration (`changed === true`) only occurs after a code change that alters `normalizeIndexShape`.

---

#### Finding I-7 — `collisionsScan`: O(n²) plan comparisons with `JSON.stringify` in inner loop (MEDIUM) — Deferred

**File:** `apps/control-plane/src/application/services/reporting-service.ts:40–68`

`collisionsScan` reads every feature's `plan.json` (N disk reads via `collectAcceptedPlans`) then compares every pair (N²/2 `detectPlanCollisions` calls). `createCollisionFingerprint` runs `JSON.stringify(collisions)` inside the inner loop. For 100 features: 100 file reads + 4,950 comparisons.

**Status:** Deferred from this spec. Preferred fix is event-driven: compute the collision matrix at plan-submission time and cache it, rather than recomputing on every dashboard poll. Requires a design change to the plan-submission flow.

---

#### Finding I-8 — `QaWaveExecutor`: `loadRolePrompts` called inside per-feature loop (LOW) → PER-T-014

**File:** `apps/control-plane/src/supervisor/qa-wave-executor.ts:196`

`loadRolePrompts` is called once per feature inside the QA batch loop. `PromptBundleLoader` caches after the first load so subsequent calls are cheap — but the placement inside the loop is structurally fragile. If the cache is externally invalidated, each iteration independently hits the filesystem, and the regression is invisible.

---

### 2.2 Memory / Allocation Hotspots

#### Finding M-1 — `normalizeSet` copy-pasted in 7 files (LOW) → PER-T-013

**Files:** `reporting-service.ts`, `merge-service.ts`, `feature-lifecycle-service.ts`, `feature-deletion-service.ts`, `kernel.ts`, `lock-service.ts`, `plan-service.ts`

The same 3-line function (deduplicate + sort string array) is independently defined in seven files. Seven copies that must be kept in sync; any change to deduplication or sort semantics must be applied in all seven places.

**Note:** The v1.0 spec identified only 4 files. Three additional copies in `kernel.ts`, `lock-service.ts`, and `plan-service.ts` were missed.

---

#### Finding M-2 — `structuredClone(plan)` for a 5-field mutation (LOW) → PER-T-012

**File:** `apps/control-plane/src/supervisor/planning-wave-executor.ts:298`

`buildUpdatedPlan` uses `structuredClone` to deep-copy the entire plan object, then immediately overwrites 5 top-level fields. Since those fields are scalars or wholly replaced arrays (not mutated in-place), a shallow object spread produces identical semantics at a fraction of the cost.

---

#### Finding M-3 — `statusCache` entries never evicted for terminal features (MEDIUM) → PER-T-010

**File:** `apps/control-plane/src/supervisor/run-coordinator.ts:61–62, 272`

`statusCache` accumulates one entry per feature that ever transitions state. When `closeFeatureCluster` is called for a terminal feature (`MERGED`, `FAILED`, `PAUSED_BUDGET`), the Map entry is not deleted. Over a long run processing hundreds of features, the Map holds dead entries indefinitely.

---

#### Finding M-4 — Evidence directory grows unbounded across gate retry cycles (HIGH) → PER-T-006

**File:** `apps/control-plane/src/core/gates.ts:427–430`

Every gate run appends a new timestamped JSON file to `.aop/features/<id>/evidence/`. With 5 retry cycles × 3 gate profiles per feature the directory accumulates 15+ timestamped files per feature with no pruning. This directly worsens Finding I-5: the stat scan cost scales linearly with accumulated files.

---

### 2.3 Agent Context Size

#### Finding C-1 — Full QA index + full evidence + full state delivered to every agent unconditionally (HIGH) → PER-T-008, PER-T-009

**File:** `apps/control-plane/src/application/services/feature-lifecycle-service.ts:132–158`

The context bundle returned to every agent role includes the complete QA test index (all test records, including passed), the full gate evidence JSON (verbose stdout/stderr), and the complete state frontmatter (all historical lock, gate, and PR metadata). A feature with 200 tests and 50 gate retry cycles produces a bundle exceeding 80–100 KB — roughly 20,000–25,000 tokens per agent invocation.

| Field                | Problem                                                                               |
| -------------------- | ------------------------------------------------------------------------------------- |
| `qa_test_index`      | All test records including passed — agents only need `summary` + `failed` + `pending` |
| `latest_evidence`    | Full verbose gate output — agents need overall result + top failing steps             |
| `state.front_matter` | All historical locks/gates/PR metadata — agents need current status + held locks      |
| `plan`               | Full plan when builder/QA roles only need current-phase tasks                         |

---

#### Finding C-2 — `PlanningWaveExecutor` fetches full context for non-planning features (MEDIUM) → PER-T-007

**File:** `apps/control-plane/src/supervisor/planning-wave-executor.ts:125–133`

`run()` fetches the full 50KB context bundle for every active feature, then immediately discards it if status is not `PLANNING` or `BLOCKED`. For 10 active features where 8 are in `BUILDING` status, 8 full context bundles (400 KB total) are fetched and thrown away on each planning wave.

---

#### Finding C-3 — Decision log entries embed raw `JSON.stringify` of complex objects (LOW) — Deferred

**File:** `apps/control-plane/src/supervisor/planning-wave-executor.ts:313`

`appendDecisionLog` serializes an `AnyRecord` containing `qa_summary`, `edge_case_checklist`, `reasons`, and `latest_gate_overall` as a raw JSON string in the decisions log. These accumulate linearly with iteration count. If decisions logs are surfaced to agents in future context bundles, this becomes a context-size problem.

**Status:** Deferred. No agents currently consume decisions logs directly; addressing this requires a typed `DecisionLogEntry` interface and a schema for the log format, which is out of scope for this spec.

---

#### Finding C-4 — `loadRolePrompts` call placement risks sending prompts redundantly (LOW) → PER-T-014

See Finding I-8. The structural fix (hoist above the loop) also ensures prompts are loaded once and passed at session creation time rather than risking re-loading mid-batch.

---

### 2.4 Complete Findings Table

| ID  | File                                             | Lines        | Severity | Category | Task                 |
| --- | ------------------------------------------------ | ------------ | -------- | -------- | -------------------- |
| I-1 | `feature-lifecycle-service.ts`                   | 142–146      | HIGH     | I/O      | PER-T-001            |
| I-2 | `build-wave-executor.ts`                         | 42, 95       | HIGH     | I/O      | PER-T-003            |
| I-3 | `build-wave-executor.ts`, `qa-wave-executor.ts`  | 30–38, 54–62 | HIGH     | I/O      | PER-T-002            |
| I-4 | `reporting-service.ts`                           | 81–108       | MEDIUM   | I/O      | PER-T-004            |
| I-5 | `gate-service.ts`                                | 236–256      | MEDIUM   | I/O      | PER-T-005            |
| I-6 | `kernel.ts`                                      | 758          | MEDIUM   | CPU      | PER-T-011            |
| I-7 | `reporting-service.ts`                           | 40–68        | MEDIUM   | CPU/I/O  | Deferred             |
| I-8 | `qa-wave-executor.ts`                            | 196          | LOW      | I/O      | PER-T-014            |
| M-1 | 7 service/core files                             | various      | LOW      | Memory   | PER-T-013            |
| M-2 | `planning-wave-executor.ts`                      | 298          | LOW      | Memory   | PER-T-012            |
| M-3 | `run-coordinator.ts`                             | 61–62, 272   | MEDIUM   | Memory   | PER-T-010            |
| M-4 | `core/gates.ts`                                  | 427–430      | HIGH     | Disk     | PER-T-006            |
| C-1 | `feature-lifecycle-service.ts`                   | 132–158      | HIGH     | Context  | PER-T-008, PER-T-009 |
| C-2 | `planning-wave-executor.ts`                      | 125–133      | MEDIUM   | Context  | PER-T-007            |
| C-3 | `planning-wave-executor.ts`                      | 313          | LOW      | Context  | Deferred             |
| C-4 | `qa-wave-executor.ts`, `prompt-bundle-loader.ts` | 196, 15–52   | LOW      | Context  | PER-T-014            |

---

## 3. Milestone Plan

### PER-M1: Parallel I/O in Wave Executors (Highest Impact)

**Goal:** Eliminate sequential awaits where there are no data dependencies. No behavioral changes — only I/O ordering changes for reads; write-producing tool calls remain sequential.

---

#### PER-T-001: Parallelize the 5 sequential reads in `featureGetContext`

**Fixes:** Finding I-1
**File:** `apps/control-plane/src/application/services/feature-lifecycle-service.ts`
**Lines:** 142–146

**Before:**

```typescript
const state = await this.port.featureStateGet(featureId);
const plan = await this.port.planGet(featureId);
const qaIndex = await this.port.qaTestIndexGet(featureId);
const evidence = await this.port.evidenceLatest(featureId);
const specText = await fs.readFile(this.port.specPath(featureId), 'utf8').catch(() => '');
```

**After:**

```typescript
const [state, plan, qaIndex, evidence, specText] = await Promise.all([
  this.port.featureStateGet(featureId),
  this.port.planGet(featureId),
  this.port.qaTestIndexGet(featureId),
  this.port.evidenceLatest(featureId),
  fs.readFile(this.port.specPath(featureId), 'utf8').catch(() => ''),
]);
```

**Tests to write:** `apps/control-plane/test/feature-lifecycle-service.spec.ts`

- `GIVEN_featureGetContext_WHEN_called_THEN_all_reads_are_parallel` — assert each port method is called exactly once and all are called before any result is consumed.

**Acceptance criteria:**

1. `featureGetContext` returns identical output before and after.
2. All five port method spies are called; the individual calls are non-ordered.
3. A single I/O error in any one read propagates as a rejected promise (existing behavior).

---

#### PER-T-002: Parallelize the status-filter loops in `BuildWaveExecutor` and `QaWaveExecutor`

**Fixes:** Finding I-3
**Files:**

- `apps/control-plane/src/supervisor/build-wave-executor.ts` (lines 30–38)
- `apps/control-plane/src/supervisor/qa-wave-executor.ts` (lines 54–62)

**Before (BuildWaveExecutor):**

```typescript
const batch: string[] = [];
for (const featureId of featureIds) {
  const state = await this.toolCaller.callTool<FeatureStatePayload>(
    'builder',
    TOOLS.FEATURE_STATE_GET,
    {
      feature_id: featureId,
    },
  );
  if (state.data.front_matter.status === STATUS.BUILDING) {
    batch.push(featureId);
  }
}
```

**After (BuildWaveExecutor):**

```typescript
const states = await Promise.all(
  featureIds.map((featureId) =>
    this.toolCaller.callTool<FeatureStatePayload>('builder', TOOLS.FEATURE_STATE_GET, {
      feature_id: featureId,
    }),
  ),
);
const batch = featureIds.filter((_, i) => states[i].data.front_matter.status === STATUS.BUILDING);
```

Apply the same transformation to `QaWaveExecutor` substituting role `'qa'` and status `STATUS.QA`.

**Tests to write:** `apps/control-plane/test/batch-operations.spec.ts` (existing — add cases)

- `GIVEN_BuildWaveExecutor_run_WHEN_multiple_features_THEN_state_reads_are_parallel`
- `GIVEN_QaWaveExecutor_run_WHEN_multiple_features_THEN_state_reads_are_parallel`

**Acceptance criteria:**

1. Identical `batch` array produced before and after (same filter logic, same contents).
2. All `FEATURE_STATE_GET` calls are issued concurrently (spy call ordering is non-sequential).
3. `selected` slice behavior (`batch.slice(0, maxParallelGateRuns)`) is unchanged.

---

#### PER-T-003: Eliminate duplicate `FEATURE_GET_CONTEXT` fetch in `BuildWaveExecutor` retry path

**Fixes:** Finding I-2
**File:** `apps/control-plane/src/supervisor/build-wave-executor.ts`
**Lines:** 41–52 (sequential initial phase), 94–97 (duplicate fetch in parallel phase)

**Design rationale:** The sequential `for` loop at lines 41–52 MUST remain sequential (§0.2) because `workerDecisionRunner.execute()` can call write-producing tools (PATCH_APPLY, LOCK_ACQUIRE). The duplicate fetch at line 95 is eliminated by capturing each feature's context in a Map during the sequential phase, then looking it up in the parallel phase for the repair loop. The repair-loop context is still re-fetched once per feature on gate failure (not once per retry as it was before) to capture any state changes from the initial decision. This reduces the duplicate from O(retries) to O(1) on the failure path.

**Before:**

```typescript
// Sequential loop (lines 41–52): initial worker decision
for (const featureId of selected) {
  const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
    feature_id: featureId
  });
  await this.workerDecisionRunner.execute({ role: 'builder', featureId, contextBundle: context.data, ... });
}

// Parallel map (lines 54+): gate run and repair — re-fetches context on EVERY failure
const executing = selected.map(async (featureId) => {
  // ... gate run ...
  if (this.reactionsService && gateOverall === GATE_RESULT.FAIL) {
    const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {  // DUPLICATE
      feature_id: featureId
    });
    let retryCount = initialRetryCount;
    while (this.reactionsService.shouldRetry(featureId, retryCount)) {
      // context re-used for all retries — already a single call here,
      // but context was fetched AGAIN above before the while loop
      await this.workerDecisionRunner.execute({ ..., contextBundle: context.data, ... });
    }
  }
});
```

**After:**

```typescript
// Phase 1: Sequential — initial worker decisions (write ordering preserved)
const preDecisionContextByFeature = new Map<string, unknown>();
for (const featureId of selected) {
  const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
    feature_id: featureId,
  });
  preDecisionContextByFeature.set(featureId, context.data);
  await this.workerDecisionRunner.execute({
    role: 'builder',
    featureId,
    contextBundle: context.data,
    instructions: '...',
  });
}

// Phase 2: Parallel — gate runs and repair
const executing = selected.map(async (featureId) => {
  const stateForRetry = await this.toolCaller.callTool<FeatureStatePayload>(
    'builder',
    TOOLS.FEATURE_STATE_GET,
    {
      feature_id: featureId,
    },
  );
  const initialRetryCount = stateForRetry.data.front_matter.gate_retry_count ?? 0;

  // ... gate run (unchanged) ...

  if (this.reactionsService && gateOverall === GATE_RESULT.FAIL) {
    // Re-fetch context ONCE after gate failure to capture any state changes
    // from the initial decision phase (lock acquisitions, patch applications).
    // This is one fetch per failing feature — eliminates the per-retry fetch
    // that occurred in the v1.0 repair loop.
    const repairContext = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
      feature_id: featureId,
    });
    let retryCount = initialRetryCount;
    while (this.reactionsService.shouldRetry(featureId, retryCount)) {
      // ... retry loop uses repairContext.data for all retries (not re-fetched per retry)
      await this.workerDecisionRunner.execute({
        role: 'builder',
        featureId,
        contextBundle: repairContext.data,
        instructions: repairPrompt,
      });
      // ... gate re-run, retryCount increment ...
    }
  }
});
await Promise.allSettled(executing);
```

**Rationale for still re-fetching on gate failure:** After `workerDecisionRunner.execute()` in Phase 1, the feature may have acquired locks or had patches applied, mutating `state.md`. The repair loop needs `locks.held` to be accurate to avoid incorrect lock-conflict decisions. The re-fetch is gated on `gateOverall === GATE_RESULT.FAIL` — on the fast-path (gate passes), no additional context fetch occurs at all.

**Tests to write:** `apps/control-plane/test/batch-operations.spec.ts`

- `GIVEN_BuildWaveExecutor_gate_passes_WHEN_run_THEN_FEATURE_GET_CONTEXT_called_exactly_once_per_feature`
- `GIVEN_BuildWaveExecutor_gate_fails_WHEN_run_THEN_FEATURE_GET_CONTEXT_called_twice_per_failing_feature`
- `GIVEN_BuildWaveExecutor_multiple_retries_WHEN_gate_fails_THEN_repair_context_not_re_fetched_per_retry`

**Acceptance criteria:**

1. When gate passes, `FEATURE_GET_CONTEXT` is called exactly once per feature per `run()` invocation.
2. When gate fails, `FEATURE_GET_CONTEXT` is called exactly twice per failing feature: once in Phase 1 (pre-decision), once in Phase 2 (repair context) — never per retry.
3. Sequential write ordering of `workerDecisionRunner.execute()` across features is preserved.
4. The `selected` features for Phase 2 are the same as Phase 1.

---

#### PER-T-004: Parallelize state and cost reads in `reportDashboard`

**Fixes:** Finding I-4
**File:** `apps/control-plane/src/application/services/reporting-service.ts`
**Lines:** 81–108

**Before:**

```typescript
const features = [];
for (const featureId of featureIds) {
  const statePath = this.port.statePath(featureId);
  if (!(await pathExists(statePath))) {
    continue;
  }
  const state = await this.port.readState(featureId);
  const costData = await readJson<...>(this.port.featureCostPath(featureId), null);
  features.push({ ... });
}
```

**After:**

```typescript
const features = (
  await Promise.all(
    featureIds.map(async (featureId) => {
      const statePath = this.port.statePath(featureId);
      if (!(await pathExists(statePath))) {
        return null;
      }
      const [state, costData] = await Promise.all([
        this.port.readState(featureId),
        readJson<{ estimated_cost_usd: number; tokens_used: number }>(
          this.port.featureCostPath(featureId),
          null,
        ),
      ]);
      return {
        feature_id: featureId,
        status: state.frontMatter.status,
        branch:
          typeof state.frontMatter.worktree_branch === 'string'
            ? state.frontMatter.worktree_branch
            : typeof state.frontMatter.branch === 'string'
              ? state.frontMatter.branch
              : null,
        locks: readHeldLocks(state.frontMatter),
        gate_profile: state.frontMatter.gate_profile,
        gates: state.frontMatter.gates,
        pr: state.frontMatter.pr ?? null,
        last_updated: state.frontMatter.last_updated,
        activity_state: state.frontMatter.activity_state,
        activity_last_event_at: state.frontMatter.activity_last_event_at,
        activity_detected_via: state.frontMatter.activity_detected_via,
        cost: costData
          ? { estimated_cost_usd: costData.estimated_cost_usd, tokens_used: costData.tokens_used }
          : null,
      };
    }),
  )
).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
```

**Tests to write:** `apps/control-plane/test/services.spec.ts` (existing) — add cases:

- `GIVEN_reportDashboard_WHEN_multiple_features_exist_THEN_reads_are_parallel`
- `GIVEN_reportDashboard_WHEN_state_file_missing_THEN_feature_is_omitted`

**Acceptance criteria:**

1. Returned `features` array contains identical entries in the same order as before.
2. Features without a state file are omitted from output (unchanged behavior).
3. `readState` and `readJson` (cost) are called in parallel per feature.

---

### PER-M2: Evidence Directory Efficiency

**Goal:** Stop scanning the full evidence directory on every context fetch. Write a single canonical `latest.json` sentinel in `gates.ts` on every gate run. Add a retention policy so the directory stays bounded.

---

#### PER-T-005: Write canonical `latest.json` in `gates.ts` and read it directly in `evidenceLatest`

**Fixes:** Finding I-5
**Files:**

- `apps/control-plane/src/core/gates.ts` (add canonical sentinel write)
- `apps/control-plane/src/application/services/gate-service.ts` (read sentinel directly)

**Context:** `gates.ts:427–430` currently writes two files per gate run:

- `gate-${mode}-${Date.now()}.json` — the timestamped archive record
- `latest-${mode}.json` — a per-mode sentinel (e.g., `latest-fast.json`, `latest-full.json`)

`evidenceLatest` lists the entire directory and stats all files to find the newest. Neither approach is optimal for `evidenceLatest`: the per-mode sentinels require knowing which mode was run most recently.

**Fix:** Add a third write in `gates.ts` — a canonical `latest.json` — updated after every gate run regardless of mode. This is the single O(1) read target for `evidenceLatest`.

**Change to `gates.ts`** (after line 430):

```typescript
// Existing:
const evidencePath = path.join(evidenceDirectory, `gate-${mode}-${Date.now()}.json`);
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
const latestModePath = path.join(evidenceDirectory, `latest-${mode}.json`);
await fs.writeFile(latestModePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');

// Add canonical sentinel:
const latestPath = path.join(evidenceDirectory, 'latest.json');
await fs.writeFile(latestPath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
```

**Change to `gate-service.ts` `evidenceLatest`** — replace O(N) scan:

```typescript
// Before (O(N) stat scan):
const files = (await fs.readdir(evidenceDir))
  .filter((file) => file.endsWith('.json'))
  .map((file) => path.join(evidenceDir, file));
// ... stat all files, sort by mtime, read newest ...

// After (O(1) read):
const latestPath = path.join(evidenceDir, 'latest.json');
if (!(await pathExists(latestPath))) {
  return { data: { feature_id: featureId, latest: null } };
}
const latest = await readJson<AnyRecord>(latestPath, null);
return {
  data: {
    feature_id: featureId,
    latest,
    path: normalizeRepoPathForState(repoRoot, latestPath),
  },
};
```

**Tests to write:** Create `apps/control-plane/test/gate-service.spec.ts`

- `GIVEN_evidenceLatest_WHEN_latest_json_exists_THEN_returns_its_contents`
- `GIVEN_evidenceLatest_WHEN_no_evidence_dir_exists_THEN_returns_null`
- `GIVEN_evidenceLatest_WHEN_latest_json_missing_THEN_returns_null`
- `GIVEN_gates_run_WHEN_gate_completes_THEN_latest_json_is_written`
- `GIVEN_gates_run_WHEN_gate_completes_THEN_latest_json_matches_per_mode_sentinel`

**Acceptance criteria:**

1. `evidenceLatest` reads only `latest.json`; no `readdir` or `stat` calls.
2. Return shape `{ data: { feature_id, latest, path? } }` is identical to the existing interface.
3. `gates.ts` writes `latest.json` after every gate run; its content equals the evidence object.
4. `latest-${mode}.json` sentinels are still written (backward-compat for any external tooling).
5. Existing tests that rely on `latest` content remain green.

---

#### PER-T-006: Add configurable evidence retention policy

**Fixes:** Finding M-4
**Files:**

- `agentic/orchestrator/policy.yaml` — add `evidence_retention_count` field
- `agentic/orchestrator/defaults/policy.defaults.yaml` — add default value
- `agentic/orchestrator/schemas/policy.schema.json` — add field to `cleanup` object
- `apps/control-plane/src/core/gates.ts` — prune after writing new evidence file

**Policy change (`policy.yaml`):**

```yaml
cleanup:
  grace_period_seconds: 300
  auto_after_merge: true
  evidence_retention_count: 10 # NEW: keep the N most recent timestamped evidence files per feature
```

**Schema change (`schemas/policy.schema.json`):** Add to the `cleanup` object properties:

```json
"evidence_retention_count": {
  "type": "integer",
  "minimum": 1,
  "maximum": 100,
  "default": 10,
  "description": "Maximum number of timestamped evidence files to retain per feature. Oldest are pruned after each gate run."
}
```

**Implementation in `gates.ts`** — after writing all three evidence files, add pruning:

```typescript
await pruneEvidenceFiles(evidenceDir, retentionCount);

async function pruneEvidenceFiles(dir: string, keep: number): Promise<void> {
  const entries = await fs.readdir(dir);
  // Only prune timestamped gate files; never prune latest.json or latest-*.json sentinels
  const archived = entries
    .filter((f) => f.endsWith('.json') && f !== 'latest.json' && !f.startsWith('latest-'))
    .map((f) => ({ name: f, path: path.join(dir, f) }));

  if (archived.length <= keep) return;

  // Sort by name ascending — timestamp-prefixed filenames sort chronologically
  archived.sort((a, b) => a.name.localeCompare(b.name));
  const toDelete = archived.slice(0, archived.length - keep);
  await Promise.all(toDelete.map(({ path: p }) => fs.unlink(p).catch(() => undefined)));
}
```

The `retentionCount` value is read from the policy snapshot, defaulting to `10` if absent.

**Tests to write:** `apps/control-plane/test/incremental-gates.spec.ts` (existing) — add cases:

- `GIVEN_pruneEvidenceFiles_WHEN_archived_count_exceeds_retention_THEN_oldest_are_deleted`
- `GIVEN_pruneEvidenceFiles_WHEN_archived_count_within_retention_THEN_nothing_deleted`
- `GIVEN_pruneEvidenceFiles_WHEN_latest_json_present_THEN_it_is_never_pruned`
- `GIVEN_pruneEvidenceFiles_WHEN_latest_mode_sentinels_present_THEN_they_are_never_pruned`

**Acceptance criteria:**

1. After every gate run, the evidence directory contains at most `evidence_retention_count` timestamped `gate-*.json` files.
2. `latest.json` is never deleted by pruning.
3. `latest-${mode}.json` sentinel files (e.g., `latest-fast.json`) are never deleted by pruning.
4. Pruning occurs only after the new evidence file is successfully written.
5. Default `evidence_retention_count` of 10 applies when the field is absent from `policy.yaml`.
6. Policy schema validates correctly; invalid values (non-integer, <1, >100) are rejected.

---

### PER-M3: Agent Context Slimming

**Goal:** Reduce the token footprint of context bundles delivered to agents. The biggest savings come from role-specific projections and pre-filtering non-relevant features before fetching full context.

---

#### PER-T-007: Pre-filter non-planning features in `PlanningWaveExecutor` before fetching full context

**Fixes:** Finding C-2
**File:** `apps/control-plane/src/supervisor/planning-wave-executor.ts`
**Lines:** 124–153 (`run`) and 156–228 (`runPostQaReconciliation`)

**Before (`run`):**

```typescript
async run(featureIds: string[]): Promise<void> {
  for (const featureId of featureIds) {
    const context = await this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
      feature_id: featureId
    });
    const state = context.data.state.front_matter;
    if (state.status !== STATUS.PLANNING && state.status !== STATUS.BLOCKED) {
      continue;
    }
    // ...use context
  }
}
```

**After (`run`):**

```typescript
async run(featureIds: string[]): Promise<void> {
  // Phase 1: Batch-fetch lightweight state in parallel to identify planning features
  const states = await Promise.all(
    featureIds.map((featureId) =>
      this.toolCaller.callTool<FeatureStatePayload>('planner', TOOLS.FEATURE_STATE_GET, {
        feature_id: featureId
      })
    )
  );
  const planningFeatureIds = featureIds.filter((_, i) => {
    const status = states[i].data.front_matter.status;
    return status === STATUS.PLANNING || status === STATUS.BLOCKED;
  });

  // Phase 2: Fetch full context for planning features in parallel
  if (planningFeatureIds.length === 0) return;
  const contexts = await Promise.all(
    planningFeatureIds.map((featureId) =>
      this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
        feature_id: featureId
      })
    )
  );

  // Phase 3: Process each planning feature sequentially (preserves write ordering)
  for (let i = 0; i < planningFeatureIds.length; i++) {
    const featureId = planningFeatureIds[i];
    const context = contexts[i];
    // ...rest of planning logic unchanged, using context
  }
}
```

**Before (`runPostQaReconciliation`):**

```typescript
async runPostQaReconciliation(featureIds: string[], iteration: number): Promise<void> {
  for (const featureId of featureIds) {
    const context = await this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
      feature_id: featureId
    });
    const frontMatter = asRecord(asRecord(context.data.state).front_matter);
    const status = readStatus(frontMatter.status);
    if (!isPostQaStatus(status)) {
      continue;
    }
    // ...use context
  }
}
```

**After (`runPostQaReconciliation`):**

```typescript
async runPostQaReconciliation(featureIds: string[], iteration: number): Promise<void> {
  // Phase 1: Batch-fetch state in parallel to identify post-QA features
  const states = await Promise.all(
    featureIds.map((featureId) =>
      this.toolCaller.callTool<FeatureStatePayload>('planner', TOOLS.FEATURE_STATE_GET, {
        feature_id: featureId
      })
    )
  );
  const postQaFeatureIds = featureIds.filter((_, i) => {
    const status = readStatus(states[i].data.front_matter.status);
    return isPostQaStatus(status);
  });

  if (postQaFeatureIds.length === 0) return;

  // Phase 2: Fetch full context for post-QA features in parallel
  const contexts = await Promise.all(
    postQaFeatureIds.map((featureId) =>
      this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
        feature_id: featureId
      })
    )
  );

  // Phase 3: Process each post-QA feature sequentially (preserves write ordering for PLAN_UPDATE)
  for (let i = 0; i < postQaFeatureIds.length; i++) {
    const featureId = postQaFeatureIds[i];
    const context = contexts[i];
    // ...rest of reconciliation logic unchanged, using context
  }
}
```

**Tests to write:** `apps/control-plane/test/planning-wave-executor.spec.ts` (existing — add cases)

- `GIVEN_run_WHEN_features_not_in_planning_status_THEN_FEATURE_GET_CONTEXT_not_called`
- `GIVEN_run_WHEN_features_in_planning_THEN_FEATURE_GET_CONTEXT_called_only_for_those`
- `GIVEN_run_WHEN_multiple_planning_features_THEN_context_fetches_are_parallel`
- `GIVEN_runPostQaReconciliation_WHEN_features_not_in_post_qa_status_THEN_context_not_fetched`
- `GIVEN_runPostQaReconciliation_WHEN_post_qa_features_exist_THEN_context_fetches_are_parallel`

**Acceptance criteria:**

1. `FEATURE_GET_CONTEXT` is never called for features not in `PLANNING` or `BLOCKED` status (in `run`).
2. `FEATURE_GET_CONTEXT` is never called for features not in a post-QA status (in `runPostQaReconciliation`).
3. `FEATURE_STATE_GET` batch calls are issued in parallel.
4. Full context fetches for matching features are issued in parallel.
5. Plan submission and update logic for matched features runs sequentially (preserves write ordering for PLAN_SUBMIT, PLAN_UPDATE).

---

#### PER-T-008: Introduce role-specific context projections in `featureGetContext`

**Fixes:** Finding C-1
**Files:**

- `apps/control-plane/src/application/services/feature-lifecycle-service.ts`
- `agentic/orchestrator/tools/schemas/input/feature.get_context.input.schema.json` (add optional `role` field)

**Constraint note:** This task adds an optional parameter to the `feature.get_context` tool input schema. This is a backward-compatible addition per §0.2 (omitting `role` preserves the existing `full` projection). The output schema is NOT modified — it validates the `full` projection only. Partial projections bypass output schema validation at the service layer and return raw AOP objects directly; this is acceptable because projections only remove fields from a validated superset.

Add an optional `role` parameter to the existing `feature.get_context` input schema (backward-compatible: defaults to `'full'`):

```json
{
  "feature_id": { "type": "string" },
  "role": {
    "type": "string",
    "enum": ["full", "planner", "builder", "qa"],
    "default": "full",
    "description": "Returns a role-scoped projection of the context bundle to reduce token usage. Omitting this field or passing 'full' returns the complete bundle."
  }
}
```

**Context projections by role:**

| Field                | `full` | `planner`              | `builder`                                             | `qa`                                                 |
| -------------------- | ------ | ---------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
| `feature_id`         | ✓      | ✓                      | ✓                                                     | ✓                                                    |
| `spec`               | full   | full                   | trimmed to 8 KB                                       | trimmed to 4 KB                                      |
| `state.front_matter` | full   | full                   | `{ status, branch, locks.held, gates, gate_profile }` | `{ status, branch, gates, gate_profile }`            |
| `plan`               | full   | full                   | `{ tasks (current phase only), acceptance_criteria }` | `{ acceptance_criteria, risk }`                      |
| `qa_test_index`      | full   | `{ summary }`          | `{ summary }`                                         | `{ summary, failed, pending }` (no `passed`)         |
| `latest_evidence`    | full   | `{ overall, profile }` | `{ overall, profile, failed_steps[0..4] }`            | `{ overall, profile, failed_steps[0..9], coverage }` |

**Implementation in `FeatureLifecycleService`:**

```typescript
async featureGetContext(featureId: string, role: 'full' | 'planner' | 'builder' | 'qa' = 'full') {
  const [state, plan, qaIndex, evidence, specText] = await Promise.all([...]);  // PER-T-001

  if (role === 'full') {
    return {
      data: {
        feature_id: featureId,
        spec: specText,
        state: state.data,
        plan: plan.data.plan,
        qa_test_index: qaIndex.data,
        latest_evidence: evidence.data?.latest ?? null
      }
    };
  }

  return {
    data: {
      feature_id: featureId,
      spec: trimSpec(specText, specBudgetByRole[role]),
      state: projectState(state.data, role),
      plan: projectPlan(plan.data.plan, role),
      qa_test_index: projectQaIndex(qaIndex.data, role),
      latest_evidence: projectEvidence(evidence.data?.latest ?? null, role)
    }
  };
}

const specBudgetByRole: Record<string, number> = {
  planner: Infinity,
  builder: 8 * 1024,
  qa: 4 * 1024
};
```

Wave executors pass their role when calling context:

```typescript
// BuildWaveExecutor
const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
  feature_id: featureId,
  role: 'builder', // NEW
});
```

**Tests to write:** `apps/control-plane/test/feature-lifecycle-service.spec.ts`

- `GIVEN_featureGetContext_with_role_builder_WHEN_called_THEN_qa_index_is_summary_only`
- `GIVEN_featureGetContext_with_role_qa_WHEN_called_THEN_passed_tests_are_omitted`
- `GIVEN_featureGetContext_with_role_planner_WHEN_called_THEN_state_is_projected`
- `GIVEN_featureGetContext_with_role_full_WHEN_called_THEN_bundle_is_unchanged`
- `GIVEN_featureGetContext_no_role_WHEN_called_THEN_bundle_is_unchanged` (backward compat)

**Acceptance criteria:**

1. `role: 'full'` (and omitting `role`) returns the existing complete bundle — no regression for callers that do not pass a role.
2. `role: 'builder'` returns `{ summary }` only for `qa_test_index`; `latest_evidence` includes at most 5 `failed_steps`.
3. `role: 'qa'` returns `{ summary, failed, pending }` for `qa_test_index` (no `passed`); `latest_evidence` includes at most 10 `failed_steps` plus `coverage`.
4. `role: 'planner'` projects `state.front_matter` to `{ status, branch, locks.held, gates, gate_profile }`.
5. Context byte size for `builder` and `qa` roles is ≤50% of `full` bundle when QA index has ≥50 test records.
6. Input schema is updated and validated by the MCP contract validator (`npm run validate:mcp-contracts`).

---

#### PER-T-009: Extract and test the context projection helpers

**Fixes:** Finding C-1 (supporting task for PER-T-008)
**File:** `apps/control-plane/src/application/services/feature-lifecycle-service.ts`

Extract four pure projection helpers. Define a local snapshot type to avoid a circular dependency with the QA index schema:

```typescript
// Local snapshot type — mirrors the runtime shape of qa_test_index.json without importing the schema
interface QaIndexSnapshot {
  summary?: unknown;
  passed?: unknown;
  failed?: unknown;
  pending?: unknown;
  [key: string]: unknown;
}

export function projectQaIndex(
  qaIndex: QaIndexSnapshot,
  role: 'full' | 'planner' | 'builder' | 'qa',
): Partial<QaIndexSnapshot> {
  if (role === 'full') return qaIndex;
  if (role === 'planner' || role === 'builder') return { summary: qaIndex.summary };
  // 'qa': return summary + failed + pending; explicitly omit passed and all other fields
  return {
    summary: qaIndex.summary,
    failed: qaIndex.failed,
    pending: qaIndex.pending,
  };
}

export function projectEvidence(
  evidence: AnyRecord | null,
  role: 'full' | 'planner' | 'builder' | 'qa',
): AnyRecord | null {
  if (!evidence || role === 'full') return evidence;
  if (role === 'planner') return { overall: evidence.overall, profile: evidence.profile };
  const maxSteps = role === 'builder' ? 4 : 9;
  const failedSteps = Array.isArray(evidence.step_results)
    ? evidence.step_results.filter((s: AnyRecord) => s.outcome === 'fail').slice(0, maxSteps)
    : [];
  return {
    overall: evidence.overall,
    profile: evidence.profile,
    failed_steps: failedSteps,
    ...(role === 'qa' ? { coverage: evidence.coverage } : {}),
  };
}

export function projectState(
  stateData: AnyRecord,
  role: 'full' | 'planner' | 'builder' | 'qa',
): AnyRecord {
  if (role === 'full' || role === 'planner') return stateData;
  const fm = stateData.front_matter ?? {};
  const includedFields: (keyof typeof fm)[] =
    role === 'builder'
      ? ['status', 'branch', 'worktree_branch', 'locks', 'gates', 'gate_profile']
      : ['status', 'branch', 'worktree_branch', 'gates', 'gate_profile'];
  const projected: AnyRecord = {};
  for (const field of includedFields) {
    if (field in fm) projected[field] = fm[field];
  }
  return { front_matter: projected };
}

export function trimSpec(spec: string, budgetBytes: number): string {
  if (!Number.isFinite(budgetBytes) || spec.length <= budgetBytes) return spec;
  return spec.slice(0, budgetBytes);
}
```

**Tests to write:** `apps/control-plane/test/feature-lifecycle-service.spec.ts`

- `GIVEN_projectQaIndex_role_qa_WHEN_index_has_passed_tests_THEN_passed_is_omitted`
- `GIVEN_projectQaIndex_role_qa_WHEN_index_has_running_field_THEN_running_is_omitted`
- `GIVEN_projectQaIndex_role_full_WHEN_called_THEN_index_is_unchanged`
- `GIVEN_projectQaIndex_role_planner_WHEN_called_THEN_only_summary_returned`
- `GIVEN_projectQaIndex_role_builder_WHEN_called_THEN_only_summary_returned`
- `GIVEN_projectEvidence_role_builder_WHEN_many_failed_steps_THEN_truncated_to_4`
- `GIVEN_projectEvidence_role_qa_WHEN_many_failed_steps_THEN_truncated_to_9`
- `GIVEN_projectEvidence_role_qa_WHEN_called_THEN_coverage_is_included`
- `GIVEN_trimSpec_WHEN_spec_within_budget_THEN_unchanged`
- `GIVEN_trimSpec_WHEN_spec_exceeds_budget_THEN_truncated`

---

### PER-M4: Memory & CPU Cleanup

**Goal:** Fix the slow memory leak in `RunCoordinator`, eliminate redundant serialization in `readIndex`, replace `structuredClone` with a targeted spread, and unify all seven duplicated `normalizeSet` implementations.

---

#### PER-T-010: Evict `statusCache` entries on feature termination

**Fixes:** Finding M-3
**File:** `apps/control-plane/src/supervisor/run-coordinator.ts`
**Lines:** 183–191 (`rebalanceActiveFeatures`), 193–207 (queue-drain loop)

When `closeFeatureCluster(featureId)` is called for a terminal-status feature, the corresponding `statusCache` entry is never deleted. Over a long run processing hundreds of features, the Map accumulates dead entries indefinitely.

**Before:**

```typescript
for (const featureId of sortedCurrent) {
  const status = await this.readFeatureStatus(featureId);
  if (status && RunCoordinator.TERMINAL_STATUSES.has(status)) {
    await this.sessionOrchestrator.closeFeatureCluster(featureId);
    continue; // cache entry remains
  }
  survivingActiveFeatureIds.push(featureId);
}
```

**After:**

```typescript
for (const featureId of sortedCurrent) {
  const status = await this.readFeatureStatus(featureId);
  if (status && RunCoordinator.TERMINAL_STATUSES.has(status)) {
    await this.sessionOrchestrator.closeFeatureCluster(featureId);
    this.statusCache.delete(featureId); // NEW: evict dead entry
    continue;
  }
  survivingActiveFeatureIds.push(featureId);
}
```

Apply the same eviction in the queue-drain loop (lines 193–207) where features popped from `this.state.queue` are found to already be terminal.

**Tests to write:** `apps/control-plane/test/run-coordinator.spec.ts` (existing — add case)

- `GIVEN_rebalanceActiveFeatures_WHEN_feature_reaches_terminal_status_THEN_statusCache_entry_is_evicted`
- `GIVEN_rebalanceActiveFeatures_WHEN_queue_feature_is_terminal_THEN_statusCache_entry_is_evicted`

**Acceptance criteria:**

1. After `closeFeatureCluster` is called for a terminal feature, `statusCache.has(featureId)` returns `false`.
2. `notifyStatusTransitions` is unaffected — it reads `statusCache` only for currently active features.
3. No change to orchestration loop behavior or status transition notification logic.

---

#### PER-T-011: Replace `JSON.stringify` diff with version-based guard in `readIndex`

**Fixes:** Finding I-6
**Files:**

- `apps/control-plane/src/core/kernel.ts` (lines 755–763)
- `agentic/orchestrator/schemas/index.schema.json` (add optional `schema_version` field)

`readIndex` serializes the entire index (20–50 KB) twice on every call just to detect whether schema migration is needed — a cold-path concern paid on every hot-path read.

**Current behavior:**

```typescript
const changed = JSON.stringify(existing ?? null) !== JSON.stringify(normalized);
if (changed) {
  await atomicWriteJson(this.indexPath, normalized);
}
```

**After:** Add a `schema_version` field to the index shape. `normalizeIndexShape` sets it to the current constant. Migration check becomes a single integer comparison:

```typescript
// DEVELOPER PROTOCOL: When changing the shape of normalizeIndexShape (adding fields,
// changing defaults, renaming keys), you MUST increment CURRENT_INDEX_SCHEMA_VERSION.
// Forgetting to increment means legacy index files will never be re-normalized after
// the shape change. There is an integration test below that will catch shape drift.
const CURRENT_INDEX_SCHEMA_VERSION = 1;

async readIndex(): Promise<AnyRecord> {
  const existing = await readJson(this.indexPath, null);
  const needsMigration =
    existing === null ||
    (existing as AnyRecord).schema_version !== CURRENT_INDEX_SCHEMA_VERSION;
  if (needsMigration) {
    const normalized = this.normalizeIndexShape(existing);
    await atomicWriteJson(this.indexPath, normalized);
    return normalized;
  }
  return existing as AnyRecord;
}
```

`normalizeIndexShape` output gains `schema_version: CURRENT_INDEX_SCHEMA_VERSION`. The field is added to `schemas/index.schema.json` as an optional integer.

**Shape-drift detection test:** Add a test that calls `normalizeIndexShape` with the current `CURRENT_INDEX_SCHEMA_VERSION` and asserts that the output's `schema_version` equals it. If a developer changes `normalizeIndexShape` without bumping the constant, the test will still pass — but if they also change what `normalizeIndexShape` produces (e.g., new field), the test ensures the constant is documented in context. Add a comment in the test: `// If this test fails, increment CURRENT_INDEX_SCHEMA_VERSION in kernel.ts`.

**Tests to write:** `apps/control-plane/test/kernel.spec.ts` (existing — add cases)

- `GIVEN_readIndex_WHEN_schema_version_matches_THEN_normalizeIndexShape_not_called`
- `GIVEN_readIndex_WHEN_schema_version_absent_THEN_migration_runs_and_writes_file`
- `GIVEN_readIndex_WHEN_index_is_null_THEN_migration_runs`
- `GIVEN_normalizeIndexShape_WHEN_called_THEN_schema_version_equals_current_constant` (shape-drift guard)

**Acceptance criteria:**

1. When the on-disk index has the current `schema_version`, `normalizeIndexShape` is not called and no write occurs.
2. When the field is absent (legacy index), normalization and re-write happen exactly once.
3. The `schema_version` field is included in `index.schema.json` as an optional integer and validates correctly.
4. No other fields in the index are changed by this migration.
5. The developer protocol comment is co-located with the constant definition in `kernel.ts`.

---

#### PER-T-012: Replace `structuredClone(plan)` with targeted field spread in `buildUpdatedPlan`

**Fixes:** Finding M-2
**File:** `apps/control-plane/src/supervisor/planning-wave-executor.ts`
**Line:** 298

`structuredClone` serializes the entire plan object graph for a mutation that only touches five top-level fields. Since those fields are scalars or wholly replaced arrays, a shallow spread is sufficient and does not risk mutating the original.

**Before:**

```typescript
private buildUpdatedPlan(plan: AnyRecord, planVersion: number, decision: ReconciliationDecision): AnyRecord {
  const nextPlan = structuredClone(plan);
  const acceptanceCriteria = asStringArray(nextPlan.acceptance_criteria);
  const riskItems = asStringArray(nextPlan.risk);

  nextPlan.plan_version = planVersion + 1;
  nextPlan.revision_of = planVersion;
  nextPlan.revision_reason = decision.reasons.join('; ');
  nextPlan.acceptance_criteria = normalizeList([...acceptanceCriteria, ...decision.acceptanceCriteriaAdditions]);
  nextPlan.risk = normalizeList([...riskItems, ...decision.edgeCaseChecklist]);
  return nextPlan;
}
```

**After:**

```typescript
private buildUpdatedPlan(plan: AnyRecord, planVersion: number, decision: ReconciliationDecision): AnyRecord {
  return {
    ...plan,
    plan_version: planVersion + 1,
    revision_of: planVersion,
    revision_reason: decision.reasons.join('; '),
    acceptance_criteria: normalizeList([
      ...asStringArray(plan.acceptance_criteria),
      ...decision.acceptanceCriteriaAdditions
    ]),
    risk: normalizeList([...asStringArray(plan.risk), ...decision.edgeCaseChecklist])
  };
}
```

**Tests to write:** `apps/control-plane/test/planning-wave-executor.spec.ts` (existing — add cases)

- `GIVEN_buildUpdatedPlan_WHEN_called_THEN_original_plan_is_not_mutated`
- `GIVEN_buildUpdatedPlan_WHEN_called_THEN_only_mutated_fields_change`

**Acceptance criteria:**

1. Returned plan object has all fields from the original plan plus the five updated fields.
2. The original `plan` argument is not mutated.
3. `structuredClone` is not called.
4. Existing `planVersion + 1` and `normalizeList` behavior is preserved.

---

#### PER-T-013: Extract `normalizeSet` to a shared utility (all 7 files)

**Fixes:** Finding M-1
**Files:**

- **Create:** `apps/control-plane/src/core/utils.ts`
- **Refactor** (replace local definition, add import):
  - `apps/control-plane/src/core/kernel.ts`
  - `apps/control-plane/src/application/services/reporting-service.ts`
  - `apps/control-plane/src/application/services/merge-service.ts`
  - `apps/control-plane/src/application/services/feature-lifecycle-service.ts`
  - `apps/control-plane/src/application/services/feature-deletion-service.ts`
  - `apps/control-plane/src/application/services/lock-service.ts`
  - `apps/control-plane/src/application/services/plan-service.ts`

**Note:** The v1.0 spec missed `kernel.ts`, `lock-service.ts`, and `plan-service.ts`. All 7 files must be updated in this task for the extraction to be complete.

Add to `apps/control-plane/src/core/utils.ts`:

```typescript
/** Deduplicate and sort a string array. Stable across key order. */
export function normalizeSet(array: string[]): string[] {
  return [...new Set(array)].sort((a, b) => a.localeCompare(b));
}
```

In each of the seven files, remove the local definition and add:

```typescript
import { normalizeSet } from '../../core/utils.js'; // adjust relative path per file
```

For `kernel.ts` the import is:

```typescript
import { normalizeSet } from './utils.js';
```

**Tests to write:** `apps/control-plane/test/core-utils.spec.ts` (existing — add cases)

- `GIVEN_normalizeSet_WHEN_array_has_duplicates_THEN_deduplicates`
- `GIVEN_normalizeSet_WHEN_array_is_unsorted_THEN_sorts_lexicographically`
- `GIVEN_normalizeSet_WHEN_empty_array_THEN_returns_empty`

**Acceptance criteria:**

1. All seven files import from `core/utils.js`; no local `normalizeSet` definitions remain anywhere in the codebase.
2. Behavior is identical to the seven previous implementations (same Set deduplication, same `localeCompare` sort).
3. No existing tests fail.
4. `npm run lint` and `npm run typecheck` pass with no errors.

---

#### PER-T-014: Move `loadRolePrompts` outside the per-feature loop in `QaWaveExecutor`

**Fixes:** Findings I-8, C-4
**File:** `apps/control-plane/src/supervisor/qa-wave-executor.ts`
**Line:** 196

`loadRolePrompts()` is called once per feature inside the QA batch loop. The `PromptBundleLoader` cache makes subsequent calls cheap, but the placement is structurally fragile — cache invalidation silently causes per-feature file I/O.

**Before:**

```typescript
for (const featureId of batch.slice(0, maxParallelGateRuns)) {
  // ... gate run logic ...
  const prompts = await this.promptProvider.loadRolePrompts(); // INSIDE LOOP
  const newQa = await this.provider.createSession('qa', featureId, prompts.qa);
  // ...
}
```

**After:**

```typescript
const prompts = await this.promptProvider.loadRolePrompts(); // OUTSIDE LOOP

for (const featureId of batch.slice(0, maxParallelGateRuns)) {
  // ... gate run logic ...
  const newQa = await this.provider.createSession('qa', featureId, prompts.qa);
  // ...
}
```

**Tests to write:** `apps/control-plane/test/session-management.spec.ts` (existing — add case)

- `GIVEN_QaWaveExecutor_run_WHEN_multiple_features_in_batch_THEN_loadRolePrompts_called_once`

**Acceptance criteria:**

1. `loadRolePrompts` is called exactly once per `run()` invocation regardless of batch size.
2. The same `prompts` object is used for all session creations in the batch.
3. QA session rotation behavior is unchanged.

---

## 4. Acceptance Criteria (Spec-Level)

1. All 14 tasks pass their own unit tests and the full existing test suite (`npm test`).
2. `npm run typecheck` and `npm run lint` report zero errors and zero warnings after all tasks.
3. MCP contract validation (`npm run validate:mcp-contracts`) passes.
4. Architecture validation (`npm run validate:architecture`) passes.
5. No on-disk artifact format is broken; all schema additions are backward-compatible optional fields.
6. The `role` parameter on `feature.get_context` is optional; omitting it returns the existing complete bundle byte-for-byte.
7. Evidence pruning never removes `latest.json` or any `latest-${mode}.json` sentinel file.

---

## 5. Risks and Mitigations

| Risk                                                                   | Mitigation                                                                                                                                      |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `Promise.all` parallelism exposes hidden race conditions in test mocks | Add ordering assertions in tests; use `vi.fn()` with explicit mock return sequences                                                             |
| Context projection silently drops fields agents depend on              | Gated behind `role: 'full'` default; add integration test asserting full bundle is unchanged                                                    |
| `schema_version` constant not bumped when shape changes                | Developer protocol comment co-located in `kernel.ts`; shape-drift detection test in `kernel.spec.ts`                                            |
| `structuredClone` removal causes mutation if spread is too shallow     | The five mutated fields are scalars or wholly replaced arrays; mutation-guard test confirms                                                     |
| Evidence pruning deletes a file being read concurrently                | Pruning occurs after the gate write completes; `fs.unlink` uses `.catch(() => undefined)`                                                       |
| `normalizeSet` extraction breaks an unknown caller                     | Signature is identical; all seven files updated atomically in this task; `npm run typecheck` catches any missed callers                         |
| Repair context re-fetch in PER-T-003 is still an extra call            | This is by design: one fetch per failing feature vs one fetch per retry in v1.0; net improvement                                                |
| `latest.json` write in `gates.ts` fails mid-run                        | `latest.json` is non-critical metadata; `latest-${mode}.json` sentinel is still written first; `evidenceLatest` falls back to `null` gracefully |

---

## 6. Task Backlog

| ID        | Milestone | File(s)                                                                 | Description                                                         |
| --------- | --------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------- |
| PER-T-001 | PER-M1    | `feature-lifecycle-service.ts`                                          | Parallelize 5 sequential reads in `featureGetContext`               |
| PER-T-002 | PER-M1    | `build-wave-executor.ts`, `qa-wave-executor.ts`                         | Parallelize status-filter loops                                     |
| PER-T-003 | PER-M1    | `build-wave-executor.ts`                                                | Context-capture Map pattern; eliminate per-retry duplicate fetch    |
| PER-T-004 | PER-M1    | `reporting-service.ts`                                                  | Parallelize `reportDashboard` state+cost reads                      |
| PER-T-005 | PER-M2    | `gate-service.ts`, `gates.ts`                                           | Write canonical `latest.json`; read it directly in `evidenceLatest` |
| PER-T-006 | PER-M2    | `policy.yaml`, `policy.defaults.yaml`, `policy.schema.json`, `gates.ts` | Add `evidence_retention_count` and pruning logic                    |
| PER-T-007 | PER-M3    | `planning-wave-executor.ts`                                             | Pre-filter non-planning features; parallelize context fetches       |
| PER-T-008 | PER-M3    | `feature-lifecycle-service.ts`, input schemas                           | Role-specific context projections                                   |
| PER-T-009 | PER-M3    | `feature-lifecycle-service.ts`                                          | Extract and test projection helpers                                 |
| PER-T-010 | PER-M4    | `run-coordinator.ts`                                                    | Evict `statusCache` on feature termination                          |
| PER-T-011 | PER-M4    | `kernel.ts`, `schemas/index.schema.json`                                | Replace `JSON.stringify` diff with `schema_version` guard           |
| PER-T-012 | PER-M4    | `planning-wave-executor.ts`                                             | Replace `structuredClone(plan)` with spread                         |
| PER-T-013 | PER-M4    | `core/utils.ts` + 7 files                                               | Deduplicate `normalizeSet` into shared utility                      |
| PER-T-014 | PER-M4    | `qa-wave-executor.ts`                                                   | Move `loadRolePrompts` outside per-feature loop                     |

---

## 7. Implementation Order

Tasks within each milestone are independent and can be implemented in any order or in parallel. Milestone ordering recommendation:

1. **PER-M1 first** — highest-frequency hotpaths; no schema or interface changes; changes are local refactors. PER-T-001 is a prerequisite for PER-T-008 (PER-T-008 calls PER-T-001's parallel reads internally).
2. **PER-M2 second** — bounds disk growth; PER-T-005 requires writing `latest.json` in `gates.ts` before the O(1) read in `evidenceLatest` is safe.
3. **PER-M4 third** — low-risk cleanups; PER-T-013 produces the shared utility that PER-T-008 might reference.
4. **PER-M3 last** — depends on PER-T-001 (for the parallel reads inside `featureGetContext`) and PER-T-005 (for accurate `latest_evidence` projection content).

---

## 8. Step-by-Step Implementation Plan

The following 28 ordered steps represent the complete implementation sequence. Steps within the same milestone can be executed concurrently.

### Phase 1: PER-M4 Quick Wins (no dependencies, safe warm-up)

1. **Create `apps/control-plane/src/core/utils.ts`** with `normalizeSet`.
2. **Update all 7 files** that define `normalizeSet` locally to import from `core/utils.ts`; delete local definitions.
3. **Add tests** to `core-utils.spec.ts` for `normalizeSet`.
4. **Run** `npm run typecheck && npm run lint && npm test` — must pass.
5. **Replace `structuredClone(plan)`** with spread in `planning-wave-executor.ts:298`.
6. **Add mutation-guard tests** to `planning-wave-executor.spec.ts`.
7. **Evict `statusCache` entries** in `run-coordinator.ts` `rebalanceActiveFeatures` and queue-drain loop.
8. **Add eviction tests** to `run-coordinator.spec.ts`.
9. **Move `loadRolePrompts`** outside the per-feature loop in `qa-wave-executor.ts`.
10. **Add one-call test** to `session-management.spec.ts`.
11. **Run** `npm test` — all must pass.

### Phase 2: PER-M2 Evidence Efficiency

12. **Add `latest.json` write** to `gates.ts` (after existing `latest-${mode}.json` write).
13. **Update `evidenceLatest`** in `gate-service.ts` to read `latest.json` directly.
14. **Create `gate-service.spec.ts`** with sentinel-read and null-fallback tests.
15. **Add `evidence_retention_count`** to `policy.yaml`, `policy.defaults.yaml`, and `policy.schema.json`.
16. **Add `pruneEvidenceFiles`** to `gates.ts`; call it after each gate run.
17. **Add pruning tests** to `incremental-gates.spec.ts`.
18. **Run** `npm run validate:mcp-contracts && npm test` — must pass.

### Phase 3: PER-M1 Parallel I/O

19. **Parallelize 5 reads** in `featureGetContext` (`feature-lifecycle-service.ts`).
20. **Add parallel-read test** to `feature-lifecycle-service.spec.ts`.
21. **Parallelize status-filter loops** in `BuildWaveExecutor` and `QaWaveExecutor`.
22. **Add parallel-state-read tests** to `batch-operations.spec.ts`.
23. **Implement context-capture Map** in `BuildWaveExecutor` (PER-T-003); add tests to `batch-operations.spec.ts`.
24. **Parallelize dashboard reads** in `reportDashboard` (`reporting-service.ts`); add tests.
25. **Replace `readIndex` JSON.stringify** with schema_version guard; add `schema_version` to `normalizeIndexShape` and `index.schema.json`; add tests to `kernel.spec.ts`.
26. **Run** `npm test` — all must pass.

### Phase 4: PER-M3 Context Slimming

27. **Extract projection helpers** (`projectQaIndex`, `projectEvidence`, `projectState`, `trimSpec`) into `feature-lifecycle-service.ts`; add comprehensive tests.
28. **Add `role` parameter** to `feature.get_context` input schema; update `featureGetContext` to dispatch to projections; update all wave executors to pass `role`; run `npm run validate:mcp-contracts`; add role-specific integration tests.

---

## 9. Spec Gap Tracker

| Task                                                               | Status     | Notes                                      |
| ------------------------------------------------------------------ | ---------- | ------------------------------------------ |
| PER-T-001: Parallelize 5 reads in `featureGetContext`              | ⬜ Pending |                                            |
| PER-T-002: Parallelize status-filter loops                         | ⬜ Pending |                                            |
| PER-T-003: Context-capture Map pattern                             | ⬜ Pending | Redesigned from v1.0                       |
| PER-T-004: Parallelize `reportDashboard` reads                     | ⬜ Pending |                                            |
| PER-T-005: Write + read `latest.json` sentinel                     | ⬜ Pending | v1.0 had wrong filename                    |
| PER-T-006: Evidence retention policy                               | ⬜ Pending | Pruning filter updated for `latest-*.json` |
| PER-T-007: Pre-filter + parallel context in `PlanningWaveExecutor` | ⬜ Pending | `runPostQaReconciliation` fully specified  |
| PER-T-008: Role-specific context projections                       | ⬜ Pending | Output schema note added                   |
| PER-T-009: Extract projection helpers                              | ⬜ Pending | `QaIndexSnapshot` type defined             |
| PER-T-010: Evict `statusCache` on termination                      | ⬜ Pending |                                            |
| PER-T-011: `schema_version` guard in `readIndex`                   | ⬜ Pending | Version-bump protocol added                |
| PER-T-012: Replace `structuredClone` with spread                   | ⬜ Pending |                                            |
| PER-T-013: Extract `normalizeSet` (7 files)                        | ⬜ Pending | Expanded from 4 to 7 files                 |
| PER-T-014: Move `loadRolePrompts` out of loop                      | ⬜ Pending |                                            |
