# Lessons Learned

Recurring mistakes and the rule we adopted so the same issue doesn't bite twice. Append-only. Each entry has: date, context, lesson.

---

## Sprint 6 (closed 2026-06-08)

### Process — SPEC.md commit-first finally tamed the 4-sprint recurrence (team-lead)
**Context** : Sprints 3, 4, AND 5 had each shipped initial PRs with `M SPEC.md` uncommitted. Sprint 5 lessons.md proposed concrete fix candidates ; sprint 6 adopted candidate #1 (stage SPEC.md as part of the FIRST sprint commit, before any code). Result: commit `7aaa5cc` was the SPEC update, all subsequent commits were code/tests/docs, and `git status` at sprint close was 100% clean — no orphan SPEC change to carry over.
**Lesson** : process recurrences die when the fix is positional ("FIRST commit") rather than a checklist item at sprint close. The discipline is structural now: as soon as the team-lead writes the SPEC section in §0 of the plan, the matching SPEC.md edit commit follows immediately, before the plan commit itself. Memory updated. Keep doing this.

### Engineering — Persistent team `mimic` + idle members is worth the token cost (team-lead + user)
**Context** : Sprint 5 left `~/.claude/teams/mimic/config.json` as an empty husk because past sprints had called `TeamDelete` at wrap-up. Sprint 6 attempted to spawn agents one-by-one with their phase tasks; hit "team roster is flat — Teammates cannot spawn other teammates" when respawning backend-builder for a security fix mid-sprint. User flipped policy: spawn ALL 7 project-defined agents (`.claude/agents/*.md`) into team `mimic` at sprint start with idle prompts, wake them via `SendMessage` per phase, no shutdown until sprint close.
**Lesson** : the "never idle" rule from earlier memory was a token-economy heuristic that became a coordination problem. The team-roster gotcha (couldn't re-add a name after termination, couldn't add a fresh role after the first two spawns settled) confirms: pre-spawn the full roster idle, address by name throughout, accept the idle token cost. `feedback-team-spawn` memory now reflects this. DO NOT call TeamDelete unless we explicitly want to nuke the team for sprint 7+ work. The team persists, the members are re-spawned each sprint into the same team.

### Engineering — Security plugin caught CSV formula injection mid-sprint (team-lead + backend-builder)
**Context** : `security-guidance@claude-code-plugins` automated review flagged the CSV writer in `render_engagement_csv()` as vulnerable to spreadsheet formula injection (MEDIUM). The team-lead's first instinct was "this is internal, RBAC limits the attacker to authenticated red-team users — likely not exploitable" ; on second reading, recognized that the **explicit consumption path** for the CSV is the SOC analyst opening it in Excel/LibreOffice, AND that the red-team and SOC are different users on different machines (handoff is the whole point of the sprint). Fix applied (`_csv_safe()` helper, apostrophe prefix on formula-trigger chars), 3 tests added (`57dbd14`).
**Lesson** : when a security finding cites "internal service, may not be exploitable", ask one more layer of questions about the consumption path. If the cell content ever reaches a different user's spreadsheet, defuse it. Cost: 10-line helper + 3 tests, 60 seconds of work. Don't dismiss SEC findings on RBAC alone.

### Engineering — Stdlib first before custom helpers (code-reviewer + backend-builder)
**Context** : The export.py service shipped with a local `_html_escape` helper that reimplemented `html.escape(text, quote=True)` from the stdlib. Code-reviewer's NIT flagged it. Replaced by `from html import escape as _html_escape` (-7 lines, identical behavior).
**Lesson** : before writing an escape/format/parse helper, grep for an stdlib equivalent. The Python stdlib has `html`, `csv`, `unicodedata`, `re`, `email.utils`, `urllib.parse` — most "escape this safely" needs are already solved. Custom helpers age into security maintenance debt; stdlib doesn't.

### Engineering — Tests that mock the API client at module level can't exercise its fallback paths (frontend-builder + code-reviewer)
**Context** : `ExportEngagementButton.test.tsx` uses `vi.mock('@/api/exports', ...)` at module level — replaces the whole `downloadEngagementExport` function with a stub. Code-reviewer flagged that the Content-Disposition fallback inside `exports.ts` (`engagement-<id>.<ext>` when the header is malformed) was uncovered. Fix: new dedicated test file `exports.test.ts` that mocks the underlying `apiClient` (axios) instead, so the real `downloadEngagementExport` runs (`123d981`).
**Lesson** : a test file that mocks its target at module level can validate the CALLERS but not the TARGET'S internal logic. To cover a function's internal branches (fallbacks, error parsing, header parsing), you need a separate test file that mocks one layer DEEPER (axios, fetch, transport) and lets the function under test execute. Pattern : one test file per layer.

### Engineering — `engagement` param as `_engagement` for signature symmetry (backend-builder + code-reviewer)
**Context** : `render_engagement_csv()` takes `engagement` but never reads it — by design, since the CSV is machine-readable strict (no engagement header inside the file, only in the filename). Pyright flagged "not accessed". Two options: rename to `_engagement` (intentional unused marker) or drop the param. We renamed — keeps the trio of render functions (`md`/`csv`/`pdf`) callable with the same arguments from the endpoint switch.
**Lesson** : when one function in a sibling-trio doesn't need a parameter that the others use, keep the signature symmetric and prefix the unused param with `_`. The endpoint stays callable in a uniform way (`render_engagement_<fmt>(engagement, simulations)`), and the underscore signals "intentional" to mypy/pyright/ruff. Don't sacrifice symmetry for purity.

---

## Sprint 5 (closed 2026-05-28)

### Process — The "git status pre-sprint-close" discipline is still broken, 3 sprints in a row (team-lead)
**Context** : Sprint 3, sprint 4, AND sprint 5 all shipped initial PRs without the corresponding SPEC.md update (the new section drafted in §0 of the plan sat as `M SPEC.md` in the worktree but was never committed before push). Each time I caught it at the START of the NEXT sprint via `git fetch + git status` revealing the orphan change. Sprint 5 I caught it mid-PR (slightly earlier) but the underlying habit is still broken.
**Lesson** : the team-lead wrap-up checklist needs a hard step BEFORE the `git push` command: run `git status` AND eyeball every line. If anything shows `M` or `??` that's not the wrap-up commit's own files, decide explicitly. The mental model "I committed everything" is wrong because the §0 SPEC edit is done EARLY in the sprint and forgotten by the time wrap-up commits land. Fix candidates :
- Stage SPEC.md as part of the FIRST sprint commit (the plan commit), not as a separate later commit.
- OR add a `make sprint-close-check` target that runs `git status --short | grep -v "^[?][?] tasks/pr-body"` and fails if non-empty.
- OR add a pre-push hook in the project that warns on `M SPEC.md`.

### Process — Spec-reviewer 2-pass BEFORE backend dispatch eliminated mid-implementation addenda (team-lead)
**Context** : Sprint 3 and 4 both required urgent addenda to the backend-builder mid-implementation because the spec-reviewer's 2nd pass arrived after the backend-builder had already started. Sprint 5 explicitly waited for spec-reviewer Pass 2 APPROVED before dispatching backend — and the backend ran straight through with 0 addenda churn. The 2-pass model finally clicked.
**Lesson** : ALWAYS wait for the spec-reviewer's verdict on the post-edit pass before dispatching the first builder. If Pass 1 returns NEEDS-CHANGES, apply the fixes, request Pass 2, wait for APPROVED, THEN dispatch. The "let's send to builders in parallel to save time" instinct costs more than it saves.

### Process — Endpoint path drift caught by visual inspection, not by spec-reviewer (team-lead)
**Context** : Backend implemented `/api/templates`. Plan §1 § 2 said `/api/simulation-templates`. Spec-reviewer 2-pass didn't catch the path drift. Frontend used the plan path → mismatch → first frontend commit (`90fc5ba`) was effectively non-functional against the real backend. Caught immediately by team-lead grep + diagnostic.
**Lesson** : when backend "improves" a URL without flagging it as a deviation, it's still a deviation. Add to team-lead PR-merge mental checklist: `git diff main...HEAD | grep "@.*\.route\|@.*_bp\.(get|post|put|patch|delete)"` → does this match the plan §1/§2 path strings exactly? If no, dispatch a 1-line frontend fix BEFORE the post-design-review cycle.

### Process — Frontend-builder's screenshot script reuses stale mocks after path changes (frontend-builder)
**Context** : Sprint 5 path fix `2b70011` corrected the API client to `/api/templates`. But the Playwright screenshot script still mocked `/api/simulation-templates/<id>` — for the GET single endpoint specifically. Result: edit-form screenshot showed a 500 ErrorState instead of the actual form. Design-reviewer caught this as a critical coverage gap.
**Lesson** : when a path / contract changes mid-sprint, the screenshot script's route handlers must be updated in lockstep with the API client. A grep on the script for the old path is mandatory after every path-rename commit. Add to frontend-builder pre-screenshot checklist: `grep -E "<old-path>" $screenshot_script` must return empty.

### Engineering — Backend-builder's silent URL "improvement" (backend-builder)
**Context** : The team-lead's plan §1/§2 explicitly named the endpoints `/api/simulation-templates`. Backend-builder chose `/api/templates` (shorter, cleaner) but did NOT flag this as a deviation in the summary's "Open questions / deviations" section. The frontend-builder followed the plan and broke. The path change was defensible but the lack of escalation was not.
**Lesson** : when a builder chooses a different identifier than the plan (URL path, table name, column name, function name, etc.), even if "better", they MUST flag it in their summary under "Deviations from plan". The team-lead can then propagate the change to dependent briefs. Silent deviations break cross-team contracts.

### Engineering — Avoid calling `apply_patch()` on creation paths (backend-builder + spec-reviewer)
**Context** : Sprint 5 template instantiation copies fields from a template to a new Simulation. Spec-reviewer Pass 1 flagged that a builder unaware of the auto-transition trigger might "reuse the validation" by calling `apply_patch()` — which would trigger `pending → in_progress` on a non-empty technique_ids payload, violating AC-27.4. The plan was explicitly updated to forbid this call. Backend-builder set ORM fields directly, which sidesteps both the bundle lookup AND the auto-transition logic.
**Lesson** : `apply_patch()` is the wrong primitive for creation paths that copy already-validated data. Reach for direct ORM assignment (`sim.field = value`) when the source data is pre-validated (template → instance, replica → primary, etc.). Reserve `apply_patch()` for user-input paths that need full validation + workflow side effects.

### Engineering — Use `IntegrityError` catch for UNIQUE conflict 409, not pre-check SELECT (backend-builder + spec-reviewer)
**Context** : Sprint 5's first plan draft listed BOTH a pre-insert SELECT to check name uniqueness AND an `IntegrityError` catch as fallback. Spec-reviewer Pass 1 flagged this as dual strategy — the SELECT still races under concurrent inserts and adds dead code. Plan was simplified to "catch IntegrityError only, rollback, return 409". Backend implementation matches.
**Lesson** : for UNIQUE constraint violations, the DB is the authoritative source of truth. Catch the `IntegrityError`, roll back, return 409. Don't pre-check with SELECT — it races, and the IntegrityError catch is still required as a safety net (so the pre-check is just dead code). Same applies to other DB-enforced constraints (FK, CHECK).

### Process — Designer-reviewer accidental duplicate (`-2`) reminded me to use SendMessage (team-lead)
**Context** : Sprint 4 introduced the design-reviewer agent. Sprint 5 first design-review I called `Agent({name: "design-reviewer"})` — the system spawned `design-reviewer-2`. Same mistake as sprint 2/3 with backend/frontend builders. Cleaned up via shutdown_request, but the second design-review pass I correctly used SendMessage on the original `design-reviewer` — and got the verdict cleanly without duplicate noise.
**Lesson** : the "SendMessage to existing idle agent, not Agent" rule covers ALL agents in the team, not just builders. Includes design-reviewer, code-reviewer, spec-reviewer, test-verifier. Save: same `feedback-agent-reuse` memory note now applies broadly.

---

## Sprint 4 (closed 2026-05-27)

### Process — git status before declaring sprint complete (team-lead)
**Context** : Sprint 3 §0 of the plan updated `SPEC.md`'s § Simulation line to plural multi-techniques. That edit sat in the sprint 3 worktree but was never committed; PR #6 merged the sprint 3 code WITHOUT the corresponding spec text. I rediscovered it at sprint 4 start with a stray `M SPEC.md` in the worktree, then carried over via `ba313a3`. Sprint 3 had shipped with SPEC and code briefly out of sync.
**Lesson** : at sprint wrap-up, the team-lead's final pre-PR step MUST be `git status` followed by a 5-second scan for unstaged hunks. If anything is M/?? in the listing, decide explicitly (commit, stash, or abandon). Don't trust commit lists alone.

### Process — Endpoint round-trip mismatch caught by e2e, missed by 3 spec-reviewer passes (team-lead + spec-reviewer)
**Context** : Sprint 3's `GET /api/mitre/matrix` returned `tactic_id` as a slug (`"discovery"`). Sprint 4 added `tactic_ids` PATCH validation against TA-format (`"TA0007"`). Both endpoints worked in isolation but the round-trip (matrix → frontend → PATCH) failed silently — frontend sent the slug from the matrix as a `tactic_ids` value, and the backend rejected with 400. Spec-reviewer ran 3 passes against the plan WITHOUT catching this contract mismatch because both formats were documented in different sections. The test-verifier's e2e caught it (AC-21.6 defect).
**Lesson** : when a sprint introduces a NEW data path that re-uses an EXISTING endpoint's output, the spec-reviewer should explicitly trace the round-trip in code (matrix output → frontend store → PATCH body → backend validator). Pure spec reading misses contract mismatches that only manifest when a feature wires two existing endpoints together. Add to the spec-reviewer checklist: "for any new feature reusing a prior endpoint, trace the actual data flow A → B → C in code, not just specs in isolation."

### Engineering — Token system: split themed text vs fixed surface (frontend-builder + design-reviewer)
**Context** : First dark mode pass made `--color-ink` themed (light = `#1a1a1a`, dark = `#f9fafb`). That correctly inverts for body text, but inverts WRONG for "dark slabs" (utility strip, footer, modal backdrop) that used `bg-ink` as a fixed-dark surface — they became near-white bars in dark mode. Three separate symptoms, one root cause. Design-reviewer (its first run) caught it.
**Lesson** : if a single token (`ink`) plays multiple roles (text + dark surface), split into a themed token (`ink` = text) and a fixed token (`slab` = dark surface that does NOT invert). Same for overlays — `bg-ink/60` for a backdrop inverts; `bg-overlay` or a dedicated `.modal-backdrop` class with `rgba(0,0,0,0.6)` stays correct. Audit token roles BEFORE wiring dark mode, not after.

### Engineering — Form alignment: structural row layout > class tweaks (frontend-builder × 3 attempts)
**Context** : `UsersAdminPage` "Create account" form had labels misaligned by 24px when one cell carried a hint (`≥ 8 characters`) and others didn't, because `items-end` aligned cells to row-bottom. Sprint 2 post-QA "fixed" it with class tweaks. Sprint 4 post-QA reported the bug back. Sprint 4 first attempt added another class tweak (still broken per design-reviewer). Third attempt finally refactored to an explicit 3-row grid (labels / inputs+button / hints) where the browser CAN'T misalign rows of different cells.
**Lesson** : after one failed alignment fix via class tweaks, the next attempt MUST be structural (explicit grid rows, or `display: subgrid`, or DOM restructure). Don't try a third class-level tweak on an alignment issue that survived a prior class-level fix — the problem is in the structure, not the styling.

### Engineering — Hardcoded absolute paths in migration tests (backend-builder × 2 sprints)
**Context** : Sprint 3 backend-builder hardcoded `/home/user/.../.claude/worktrees/sprint-3-mitre-matrix/` in the migration round-trip test to load the migration module via `importlib.util.spec_from_file_location`. Sprint 4 the worktree path was renamed and the test broke — backend-builder "fixed" it by hardcoding the NEW worktree path. Code-reviewer caught it with the same finding twice.
**Lesson** : any test that loads a sibling file by absolute path is wrong by default. Always derive paths from `__file__` (`Path(__file__).resolve().parent.parent / "migrations" / "versions" / "0004_*.py"`). Backend-builder mental checklist before completing a migration test: grep `/home/user/` and grep `worktrees/` in the diff — anything matching is a smell.

### Process — Screenshots-mandatory rule has a fragile transport (frontend-builder)
**Context** : Sprint 4 first round of post-design-review screenshots, frontend-builder couldn't authenticate Playwright cleanly and delivered 5 screenshots of the login page only — the 4 critical fixes (UsersAdmin alignment, slab in matrix, badge contrast on form, done state dark) were NOT visible on the login page and could not be validated. The DoD says "if dev server can't start, escalate explicitly", but here the dev server DID start — auth just didn't resolve before the screenshot. Builder DID flag it; team-lead bounced back with the exact round-1 auth flow path. Second round worked.
**Lesson** : the DoD's screenshots clause needs to cover not just "can the server start" but "can the screenshot capture authenticated UI". For sprint 5+, the brief should remind the builder to use the exact auth pattern that worked in round 1 (`page.goto('/login') → fill → submit → wait for navigation`) OR to seed localStorage with a valid token before `goto`. Don't accept login-page-only screenshots as "screenshots" when the feature being validated lives behind auth.

### Process — Builders received cross-context inline summaries as re-dispatches (sprint 3 + 4 — pattern)
**Context** : When dispatching to one builder I include the other builder's API summary inline (e.g., backend summary embedded in the frontend dispatch as context). Twice now the OTHER builder has interpreted that embedded summary as a re-dispatch to themselves, re-verified their work, and confirmed completion (sprint 3 503-on-unloaded + sprint 4 AC-21.6 round-trip both occurred this way). Net positive — they caught real gaps — but coordination cost.
**Lesson** : when embedding another builder's API contract as context for builder B, prefix it with `# REFERENCE — BUILDER A's SUMMARY, INLINE CONTEXT ONLY, NO ACTION NEEDED FROM YOU` (or equivalent). Builders inherit the whole message and don't always parse which sections target them.

### Engineering — Backend-builder's self-correction via re-dispatch surfaced two real defects (sprint 3 + 4)
**Context** : Twice now the backend-builder, after interpreting a frontend dispatch as a "re-dispatch", noticed an unspecified or wrong behavior I'd left in the brief and shipped a fix:
- Sprint 3: I left "Bundle non chargé → comportement à décider, je propose 503" ambiguous. Backend-builder picked 503 and added a regression test (`673b25e`).
- Sprint 4: backend-builder re-read the AC-21.6 inline brief and reasoned through but their code was correct; the actual fix came after the test-verifier's defect report.
**Lesson** : when something in a builder brief reads "à décider" / "TBD" / "we'll see", that's a SPEC HOLE — close it before dispatch, not after. Use AskUserQuestion or take a defensible default and document explicitly which it is.

---

## Sprint 3 (closed 2026-05-27)

### Process — Spec-review 2-pass after team-lead edits (team-lead)
**Context** : Sprint 3 spec-reviewer ran a first pass on my drafted plan, flagged 5 items, I edited the plan to address 4 of them. Spec-reviewer ran a 2nd pass and caught 2 critical gaps I'd missed in my edits (REDTEAM_FIELDS update + SQLite batch_alter_table for nullable=False / drop_column). Backend-builder was already mid-implementation when the 2nd pass arrived — I dispatched an urgent addendum SendMessage.
**Lesson** : after editing the plan in response to spec-reviewer's notes, always run a 2nd spec-review pass before dispatching builders. The fixes themselves can introduce new gaps. Cheaper than urgent addenda mid-sprint. Cost: one extra read-only pass; benefit: no addenda churn.

### Process — Avoid embedding builder summaries that look like new dispatches (team-lead)
**Context** : The frontend dispatch brief contained a "BACKEND-BUILDER SUMMARY" inline section to give the frontend the API contract. The backend-builder also received this message (team routing) and interpreted the embedded summary as a fresh dispatch — they re-read it, found one ambiguity I'd resolved differently in the frontend brief than in the original backend brief (503 vs 400 on bundle unloaded), and pushed a fix commit `673b25e` independently. Net positive but a coordination cost.
**Lesson** : when embedding another builder's summary as inline context, prefix the section with "DO NOT ACT ON THIS — INLINE CONTEXT ONLY" or use a clear visual separator (`---`) plus a header that makes scope obvious. Builders inherit the entire message — they don't know which parts are addressed to them.

### Process — Explicit "Ajouter un test" in brief means a real test, not just code (team-lead)
**Context** : Sprint 3 post-review dispatch to backend-builder explicitly said "Test : ajouter un assert que… NOT NULL après upgrade" and "Test : un assert dans test_mitre.py qui vérifie… 'Command and Control'". Backend-builder fixed the code in commit `4596f09` but added zero new tests (162 → 162). I bounced back with a SendMessage; backend-builder added the tests in `393b6ed` (164/164).
**Lesson** : the discipline of "if the brief says 'add a test', the test is non-negotiable" must be enforced. Don't accept a fix-commit that doesn't include the regression tests requested in the brief — bounce back via SendMessage. Builders may otherwise treat tests as "if I have time" while only delivering the production change.

### Engineering — SQLite Alembic migrations require batch_alter_table for ALTER + DROP COLUMN (backend-builder)
**Context** : Spec-reviewer flagged that the migration brief mentioned `alter_column nullable=False` and `drop_column` without specifying `op.batch_alter_table(...)`. SQLite doesn't support either operation natively — without batch mode, the migration crashes at runtime. Backend-builder initially skipped the `nullable=False` step entirely with a comment "model + app logic enforces it"; code-reviewer pushed back ("batch mode rebuilds the table and does support the change — that's its purpose"). Final fix wraps the step in `batch_alter_table`.
**Lesson** : on SQLite, ANY operation that mutates a column type, nullability, or schema beyond ADD COLUMN must go through `with op.batch_alter_table(table) as batch_op: batch_op.alter_column(...)`. Don't accept "model enforces it" as a substitute for DDL-level constraint — a fresh DB initialised from migrations alone won't have the constraint.

### Engineering — Real migration round-trip > pure unit test (backend-builder)
**Context** : Backend-builder's initial migration backfill test was tautological — it inlined a `_backfill` Python helper and tested the helper against itself, never invoking the real Alembic `upgrade()`. Code-reviewer flagged it. Fix: load the migration module via `importlib.util.spec_from_file_location`, patch `alembic.op._proxy` with a live `Operations` context, run `upgrade()` against in-memory SQLite, then `sqlalchemy.inspect` the resulting schema.
**Lesson** : a migration test that doesn't invoke `command.upgrade()` (or the equivalent `Operations` runner against the real migration module) tests nothing about the actual migration path. Use `alembic.runtime.migration.MigrationContext` + `alembic.operations.Operations` to instantiate a real runner against an in-memory engine.

### UX — Modal Apply 0 disambiguation (frontend-builder)
**Context** : MitreMatrixModal initially labelled the Apply button as "Apply " (trailing space, no count) when 0 techniques were selected, while the button stayed enabled. A click with 0 selected and a non-empty current list would silently clear all techniques. Code-reviewer flagged. Final design: `disabled` when both counts are zero (nothing to do); label switches to "Clear all" when the user wants to wipe a non-empty list (count=0 but initial selection non-empty); standard "Apply N technique(s)" otherwise.
**Lesson** : for any "Apply"/"Confirm"/"Save" button whose effect depends on the diff between local and remote state, enumerate the three cases — no-op (disable), destructive intent (relabel to confirm), normal (count + verb) — before shipping. The trailing-space label is a code smell that exposes missing edge-case handling.

---

## Sprint 2 (closed 2026-05-26)

### Testing — Vitest module hoisting (frontend-builder)
**Context** : `vi.mock(path, factory)` is hoisted to module scope before any other statement runs. A `mockAuth(role)` helper that captures `role` in a closure crashes at runtime with `"role is not defined"` because the factory executes before the closure is set up.
**Lesson** : when a Vitest mock needs runtime-mutable state, declare the mutable in module scope (`let mockRole = 'redteam'`) and mutate it inside `beforeEach`. Closures over test-local variables don't survive the hoist.

### Testing — `useParams()` in MemoryRouter (frontend-builder)
**Context** : a page component that reads `useParams()` returns an empty object when rendered directly inside `<MemoryRouter>` — no params are extracted unless the component is mounted under a matching `<Route path="...">`.
**Lesson** : page tests that depend on params must wrap the component in `<MemoryRouter><Routes><Route path="/foo/:id" element={<Page />} /></Routes></MemoryRouter>` and set `initialEntries={['/foo/42']}`.

### Testing — jsdom missing browser APIs (frontend-builder)
**Context** : jsdom doesn't implement `Element.scrollIntoView`. Calling it in a component (e.g., scrolling the active autocomplete option into view) throws inside Vitest unless guarded.
**Lesson** : in components meant to run in both browser and jsdom, guard browser-only DOM APIs with optional chaining (`el?.scrollIntoView?.({ block: 'nearest' })`) or feature-detect before calling.

### Process — Reuse idle team agents via SendMessage, not Agent (team-lead)
**Context** : during post-review fixes, I re-spawned `backend-builder` and `frontend-builder` via `Agent({name: "..."})` even though the original instances were still alive (just idle). The system auto-suffixed `-2` and BOTH instances received the same brief, producing duplicate parallel commits on the branch. Frontend got two fix commits (`c9032a9` + `cf0e8a8`) where one would have sufficed; the second commit happened to layer cleanly on top, but only by luck.
**Lesson** : to redispatch work to an existing team agent, use `SendMessage({to: "backend-builder", ...})`. `Agent({name: ...})` creates a new instance when the name is taken. The team config at `~/.claude/teams/<team>/config.json` is the source of truth for who's already present.

### Workflow — Update e2e assertions when later sprints supersede placeholders (team-lead)
**Context** : Sprint 1 AC-4.9 asserted the literal text "Simulations à venir au Sprint 2" on `EngagementDetailPage`. Sprint 2 correctly replaced that placeholder with `<SimulationList>`, breaking the assertion. The test-verifier initially classified this as "pre-existing failure".
**Lesson** : whenever a later sprint replaces a placeholder asserted by an earlier sprint's e2e test, the earlier test must be refreshed in the same sprint (not deferred). A failing test that's "expected" is still a failing test — and it muddies the signal of the PR.
