diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md
index bd9e36dd1..8dfd3e477 100644
--- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md
+++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md
@@ -75,11 +75,12 @@ that — check always-rendered elements (containers, summary rows) rather than c
Example: BudgetSourcesPage bar chart `barLegendLabel` only renders for non-zero segments;
use `summaryItem` spans (Total/Available/Planned) for unconditional assertions.
-## Dashboard Card Count: 9 not 10 (2026-03-14)
+## Dashboard Card Count: 10 (UAT fix #844 added Recent Diary, 2026-03-15)
-DashboardPage has 9 CARD_DEFINITIONS. Both desktop grid AND mobile sections container render
-ALL cards simultaneously (CSS media queries control visibility, not conditional rendering).
-So dismiss button count in DOM = up to 18 (9 × 2 containers). Use `>= 9` not `>= 10`.
+DashboardPage has 10 CARD_DEFINITIONS (added 'recent-diary' in UAT fix #844). Both desktop
+grid AND mobile sections container render ALL cards simultaneously (CSS media queries control
+visibility, not conditional rendering). Dismiss button count in DOM = up to 20 (10 × 2 containers).
+Use `>= 10`. DashboardPage POM CARD_TITLES and DashboardCardId type updated to include 'recent-diary'.
## Dashboard Card Persistence After Reload (2026-03-14)
@@ -102,3 +103,97 @@ POM helper `getSuccessBannerText()` wraps `waitFor` in try/catch, returns null o
This masks failures: `expect(null).toContain(X)` throws confusing error. Use:
`await expect(sourcesPage.successBanner).toBeVisible()` (uses expect.timeout with retry).
Also add `waitForResponse` BEFORE save click — confirms API 200 before checking UI.
+
+## Diary Forms E2E (Story #805, 2026-03-14)
+
+Files: `e2e/pages/DiaryEntryCreatePage.ts`, `e2e/pages/DiaryEntryEditPage.ts`,
+`e2e/tests/diary/diary-forms.spec.ts`. DiaryEntryDetailPage.ts extended with edit/delete locators.
+
+Key selectors:
+
+- Create page type cards: `getByTestId('type-card-{type}')` — clicking immediately transitions to form
+- Create form: `#entry-date`, `#title`, `#body` (common); `#weather`, `#temperature`, `#workers`
+ (daily_log); `#inspector-name`, `#inspection-outcome` (site_visit); `#severity`,
+ `#resolution-status` (issue); `[name="material-input"]` (delivery)
+- Create submit: `getByRole('button', { name: /Create Entry|Creating\.\.\./i })`
+- Edit page: `getByRole('heading', { level: 1, name: 'Edit Diary Entry' })`
+- Edit back: `getByRole('button', { name: /← Back to Entry/i })`
+- Edit save: `getByRole('button', { name: /Save Changes|Saving\.\.\./i })`
+- Edit delete opens modal: `getByRole('button', { name: 'Delete Entry', exact: true })`
+- Detail Edit button: `getByRole('link', { name: 'Edit', exact: true })` (anchor, not button)
+- Detail Delete button: `getByRole('button', { name: 'Delete', exact: true })` (NOT "Delete Entry")
+- Modal: `getByRole('dialog')` — conditionally rendered; confirmDelete inside modal scope
+- Confirm delete: `modal.getByRole('button', { name: /Delete Entry|Deleting\.\.\./i })`
+- Edit/Delete buttons NOT rendered for automatic entries (`isAutomatic: true`)
+- DiaryEntryEditPage.save() registers waitForResponse (PATCH) BEFORE click — returns after API
+ NOTE: PR #830 changed updateDiaryEntry from PUT to PATCH — save() was broken; fixed in PR #832
+
+## Diary E2E (Story #804, 2026-03-14)
+
+Files: `e2e/pages/DiaryPage.ts`, `e2e/pages/DiaryEntryDetailPage.ts`,
+`e2e/tests/diary/diary-list.spec.ts`, `e2e/tests/diary/diary-detail.spec.ts`.
+
+Key selectors:
+
+- DiaryPage heading: `getByRole('heading', { level: 1, name: 'Construction Diary' })`
+- Filter bar: `getByTestId('diary-filter-bar')`, search: `getByTestId('diary-search-input')`
+- Type switcher: REMOVED from DiaryPage (UAT fix #840 removed DiaryEntryTypeSwitcher)
+- Entry cards: `getByTestId('diary-card-{id}')`, date groups: `getByTestId('date-group-{date}')`
+- Type chips: `getByTestId('type-filter-{entryType}')`, clear: `getByTestId('clear-filters-button')`
+- Pagination: `getByTestId('prev-page-button')` / `getByTestId('next-page-button')`
+- Detail back button: `getByLabel('Go back to diary')` (aria-label="Go back to diary"), back link: `getByRole('link', { name: 'Back to Diary' })`
+- Metadata wrappers: `getByTestId('daily-log-metadata|site-visit-metadata|delivery-metadata|issue-metadata')`
+- Outcome badge: `getByTestId('outcome-{pass|fail|conditional}')`, severity: `getByTestId('severity-{level}')`
+- Automatic badge: `locator('[class*="badge"]').filter({ hasText: 'Automatic' })`
+
+API: `POST /api/diary-entries` returns `DiaryEntrySummary` with `id` at top level (not nested).
+Empty state uses shared.emptyState CSS module class (conditional render — use `.not.toBeVisible()` not `.toBeHidden()`).
+DiaryPage.waitForLoaded() races: timeline visible OR emptyState visible OR errorBanner visible.
+
+## Photos API Mock Must Return { photos: [] } Not [] (2026-03-15)
+
+`GET /api/photos?entityType=...&entityId=...` returns `{ photos: [] }` (wrapped object).
+`getPhotosForEntity()` in `photoApi.ts` does `.then(r => r.photos)` — if mock returns `[]`,
+`r.photos` is `undefined` → `setPhotos(undefined)` → `PhotoGrid` crashes on `photos.length`.
+ALWAYS mock photos as: `body: JSON.stringify({ photos: [] })` not `body: '[]'`.
+
+## waitForURL on WebKit Tablet: pass `{ timeout: 15_000 }` for navigation after browser-back
+
+Applied to: diary-detail.spec.ts Scenarios 2 and 3.
+
+## Diary E2E Extended (Stories #806-#809, 2026-03-15)
+
+Files: `diary-photos-signatures.spec.ts`, `diary-automatic-events.spec.ts`
+POMs extended: DiaryEntryDetailPage (photoHeading, photoEmptyState, signatureSection, photoCountBadge),
+DiaryPage (photoCountBadge).
+NOTE: diary-export.spec.ts DELETED (UAT fix #845 removed export/print feature).
+DiaryEntryDetailPage.printButton locator REMOVED. DiaryPage.exportButton/exportDialog REMOVED.
+
+Key selectors:
+
+- Photo count badge on entry card: `data-testid="photo-count-{entryId}"` (only rendered when photoCount > 0)
+- Photo section heading: `[class*="photoHeading"]` — text "Photos (N)"
+- Photo empty state: `[class*="photoEmptyState"]` — text "No photos attached yet."
+- Signature section: `[class*="signatureSection"]` — conditional render (isSigned entries)
+- `isSigned=true` entries (UAT fix #837): Edit hidden, Delete VISIBLE, "Add photos" VISIBLE
+- `isAutomatic=true` entries: Edit hidden, Delete hidden, "Add photos" hidden
+- Auto events: must mock photos endpoint (`**/api/photos*`) when mocking diary detail entries
+- "Add photos" guard is `!isAutomatic` (not `!isAutomatic && !isSigned` as it was before #837)
+
+## Diary UAT Fixes E2E (2026-03-15)
+
+File: `e2e/tests/diary/diary-uat-fixes.spec.ts`
+
+Key behavioral changes validated:
+
+- Post-create navigation: `/diary/:id` (detail, NOT `/diary/:id/edit`) — UAT R2 fix #867 reverted #843
+- Detail back button: `getByLabel('Go back to diary')` navigates to `/diary` (NOT browser-back) — #842
+- Source link text: `data-testid="source-link-{sourceEntityId}"` shows `sourceEntityTitle` — #842
+- Automatic events: flat `
` with "Automated Events" heading — UAT R2 #868
+ (was collapsible `/` in UAT R1 #838 — CHANGED in UAT R2)
+- Dashboard "Recent Diary" card: title='Recent Diary', `recentDiaryCard()` helper in DashboardPage POM — #844
+- RecentDiaryCard "View All" link only rendered when `entries.length > 0` — mock with ≥1 entry
+- New Entry button: `getByRole('link', { name: 'New Entry', exact: true })` (no "+" prefix) — UAT R2 #866-C
+- Signed badge on cards: `data-testid="signed-badge-{entryId}"` text "✓ Signed" — UAT R2 #869
+- Mode filter chips: `data-testid="mode-filter-all/manual/automatic"` — UAT R2 #866-A
+- Photo input on create: `data-testid="create-photo-input"` (file, multiple, accept image/\*) — UAT R2 #867
diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md
index 6eb3001a6..717d9b645 100644
--- a/.claude/agent-memory/qa-integration-tester/MEMORY.md
+++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md
@@ -3,6 +3,16 @@
> Detailed notes live in topic files. This index links to them.
> See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md`
+## Modal Component Testing Patterns (2026-03-15, PR #856)
+
+**Test file**: `client/src/components/Modal/Modal.test.tsx` (16 tests)
+
+**Critical: createPortal changes query scope**. `Modal` uses `createPortal(…, document.body)`. The `container` from `render()` is the React root div — it does NOT contain portal content. Use `document.querySelector()` or `baseElement.querySelector()` for portal content. `screen` queries work fine because they query `document.body`.
+
+**Critical: `contentRef` includes the header close button**. The `ref` is on the whole content panel div, which wraps header (close button) + body + footer. `querySelectorAll('button, ...')` on `contentRef.current` finds the close button FIRST (DOM order). So `firstFocusable` is always the close button, not any inputs/buttons in the body children. Do NOT write tests expecting a body input to receive focus on mount.
+
+**Backdrop selector**: `document.querySelector('[class*="modalBackdrop"]')` — identity-obj-proxy returns class names as-is (`modalBackdrop` not a hashed string).
+
## UAT Fixes #729/#730/#731 Dashboard (2026-03-10)
**Files changed**: deleted `AtRiskItemsCard.test.tsx`; updated `InvoicePipelineCard.test.tsx`, `UpcomingMilestonesCard.test.tsx`, `CriticalPathCard.test.tsx`, `MiniGanttCard.test.tsx`, `DashboardPage.test.tsx`.
diff --git a/.claude/agent-memory/qa-integration-tester/story-diary-uat-fixes.md b/.claude/agent-memory/qa-integration-tester/story-diary-uat-fixes.md
new file mode 100644
index 000000000..346af2135
--- /dev/null
+++ b/.claude/agent-memory/qa-integration-tester/story-diary-uat-fixes.md
@@ -0,0 +1,38 @@
+---
+name: diary-uat-fixes
+description: Patterns and notes from writing tests for diary UAT fixes (sourceEntityTitle, export removal, RecentDiaryCard, detail page changes)
+type: project
+---
+
+## Diary UAT Fixes Test Notes (2026-03-15)
+
+**Branch**: `fix/836-845-diary-uat-fixes`
+**Commit**: `e29f37c`
+
+### Key changes tested
+
+1. `sourceEntityTitle: string | null` added to `DiaryEntrySummary` — every fixture using this type MUST include the field (TypeScript strict mode rejects missing required fields). Affected: `DiaryEntryCard.test.tsx`, `DiaryEntryDetailPage.test.tsx`, `DiaryPage.test.tsx`.
+
+2. **`/api/diary-entries/export` removed** — Fastify interprets "export" as the `:id` param for `GET /:id`, so the test asserts 404 (service throws `NotFoundError` for unknown id "export"). No route-level 404 — it hits the existing `GET /:id` handler.
+
+3. **`RecentDiaryCard`** — new component at `client/src/components/RecentDiaryCard/`. Props: `{ entries, isLoading, error }`. Loading uses `shared.loading`, error uses `shared.bannerError`. Empty state returns early with `/diary/new` link. Footer links: `+ New Entry` → `/diary/new`, `View All` → `/diary`. Entry items: `data-testid="recent-diary-{entry.id}"`.
+
+4. **Back button navigate(-1) → navigate('/diary')** — test clicks the back button and asserts the `/diary` route renders. Requires MemoryRouter with both routes registered.
+
+5. **Print button removed** — assert `queryByRole('button', { name: /print/i })` is not in document.
+
+### Milestone insert needs `.returning()` for auto-increment id
+
+Milestones table has `id: integer().primaryKey({ autoIncrement: true })`. To get the auto-generated ID:
+
+```ts
+const milestone = db.insert(milestones).values({...}).returning({ id: milestones.id }).get();
+const milestoneId = String(milestone!.id);
+```
+
+### Invoice insert requires vendor FK
+
+`invoices.vendorId` is `NOT NULL`. Always insert a vendor row first before inserting an invoice in service tests.
+
+**Why:** Learned during sourceEntityTitle resolution tests for invoice type.
+**How to apply:** When testing invoice-related sourceEntityTitle lookups, insert vendor → invoice → diary entry in that order.
diff --git a/.claude/agent-memory/ux-designer/story-15-4-invoice-budget-lines.md b/.claude/agent-memory/ux-designer/story-15-4-invoice-budget-lines.md
new file mode 100644
index 000000000..8334bf4cd
--- /dev/null
+++ b/.claude/agent-memory/ux-designer/story-15-4-invoice-budget-lines.md
@@ -0,0 +1,21 @@
+---
+name: Story 15.4 — Invoice Budget Lines Section spec decisions
+description: Key design decisions for the Invoice Detail Page budget lines section (Issue #606)
+type: project
+---
+
+Spec posted at https://github.com/steilerDev/cornerstone/issues/606#issuecomment-4020101918
+
+**Why:** EPIC-15 invoice rework needed a dedicated budget lines panel on InvoiceDetailPage.
+
+**How to apply:** Reference when implementing or reviewing InvoiceDetailPage changes.
+
+Key decisions:
+
+- Section placement: between Invoice Details card and LinkedDocumentsSection, full-width `.card`
+- Edit modal: old WI/HI pickers removed entirely; modal may shrink from `modalContentWide` (48rem) back to default 28rem
+- Remaining row: `var(--color-bg-secondary)` bg, `border-top: 2px solid var(--color-border-strong)` (double weight), italic label
+- Remaining color: `var(--color-text-primary)` (>0), `var(--color-success-text-on-light)` (=0), `var(--color-danger-text-on-light)` (<0)
+- HI vs WI entity type pill: use `var(--color-role-member-bg)` / `var(--color-role-member-text)` for "HI" discriminator chip
+- Picker modal: `min(640px, calc(100vw - 2rem))` — between default and wide
+- InvoiceDetailPage.module.css uses `box-shadow: var(--shadow-sm)` for cards (NOT `border: 1px solid`) — diverges from WorkItemDetailPage pattern
diff --git a/.claude/agents/backend-developer.md b/.claude/agents/backend-developer.md
index fcd43a19f..0bd7b1725 100644
--- a/.claude/agents/backend-developer.md
+++ b/.claude/agents/backend-developer.md
@@ -133,7 +133,7 @@ For each piece of work, follow this order:
Before considering any task complete, verify:
- [ ] Pre-commit hook passes (triggers on commit: selective tests, typecheck, build, audit)
-- [ ] CI checks pass after push (wait with `gh pr checks --watch`)
+- [ ] CI checks pass after push (use the **CI Gate Polling** pattern from `CLAUDE.md`)
- [ ] New code is structured for testability (clear interfaces, injectable dependencies)
- [ ] API responses match the contract shapes exactly
- [ ] Error responses use correct HTTP status codes and error shapes from the contract
@@ -169,7 +169,7 @@ Before considering any task complete, verify:
3. Commit with conventional commit message and your Co-Authored-By trailer (the pre-commit hook runs all quality gates automatically — selective lint/format/tests on staged files + full typecheck/build/audit)
4. Push: `git push -u origin `
5. Create a PR targeting `beta`: `gh pr create --base beta --title "..." --body "..."`
-6. Wait for CI: `gh pr checks --watch`
+6. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
7. **Request review**: After CI passes, the orchestrator launches `product-architect` and `security-engineer` to review the PR. Both must approve before merge.
8. **Address feedback**: If a reviewer requests changes, fix the issues on the same branch and push. The orchestrator will re-request review from the reviewer(s) that requested changes.
9. After merge, clean up: `git checkout beta && git pull && git branch -d `
diff --git a/.claude/agents/dev-team-lead.md b/.claude/agents/dev-team-lead.md
index 24e929a74..c87e3da2c 100644
--- a/.claude/agents/dev-team-lead.md
+++ b/.claude/agents/dev-team-lead.md
@@ -167,6 +167,8 @@ The spec document you return must follow this structure exactly:
- Each spec must be self-contained — the implementing agent should not need to read the wiki
- Include exact file paths, type signatures, and code patterns
- Reference existing files for patterns rather than describing patterns abstractly
+- Frontend specs must reference the shared component library (Badge, SearchPicker, Modal, Skeleton, EmptyState, FormError) where applicable — include which shared components to use in the step-by-step instructions
+- If the spec introduces a new UI pattern that resembles an existing shared component, use the shared component instead
## Work Decomposition Rules
@@ -203,6 +205,8 @@ After the orchestrator routes work to implementation agents, you review all modi
- Check for TypeScript strict mode compliance
- Verify ESM import conventions (`.js` extensions, `type` imports)
- Look for security issues (unsanitized input, missing auth checks, SQL injection)
+- Verify shared component usage — if the PR introduces new badge, picker, modal, skeleton, or empty state components instead of using the shared library, flag as CHANGES_REQUIRED
+- Verify CSS token compliance — no hardcoded color, spacing, radius, or font-size values (must use `var(--token-name)` from `tokens.css`)
**Return format:**
@@ -232,6 +236,57 @@ VERDICT: CHANGES_REQUIRED
Each issue in a `CHANGES_REQUIRED` verdict must include enough detail for the orchestrator to route a targeted fix spec to the appropriate agent.
+## Test Failure Diagnostic Protocol (Mode: review)
+
+This protocol activates **only** when test failure reports are included in the review input. When all tests pass, skip this section entirely (zero overhead on the happy path).
+
+### Source-of-Truth Hierarchy
+
+**Spec/Contract > Production code > Test code.** A correct test must never be weakened to accommodate buggy production code. Correct production code must never be broken to satisfy a wrong test.
+
+### Decision Tree
+
+When test failures are present in the review input, walk through these steps for each failure:
+
+1. **Read the spec** — Find the relevant acceptance criterion, API contract endpoint, or schema definition that governs the behavior under test. Record the spec reference.
+2. **Read the test assertion** — Identify exactly what the test expects (expected value, HTTP status, UI state, etc.).
+3. **Read the production code** — Trace the code path that produces the actual result.
+4. **Classify the root cause** — Use the table below:
+
+| Test matches spec? | Code matches spec? | Root cause | Fix target |
+| ------------------ | ------------------ | ------------------ | --------------------- |
+| Yes | No | `CODE_BUG` | Production code |
+| No | Yes | `TEST_BUG` | Test code |
+| No | No | `BOTH_WRONG` | Both (code first) |
+| Yes | Yes | `TEST_ENVIRONMENT` | Test setup/config |
+| Ambiguous | — | `SPEC_AMBIGUOUS` | Escalate to architect |
+
+5. **Produce diagnosis** — For each failure, emit a structured diagnosis block (see format below).
+
+### Diagnostic Output Format
+
+Extend the standard `CHANGES_REQUIRED` verdict with diagnosis fields for each test-failure issue:
+
+```
+VERDICT: CHANGES_REQUIRED
+
+## Issue 1:
+- **File**:
+- **Line(s)**:
+- **Problem**:
+- **Fix**:
+- **Agent**: backend-developer | frontend-developer | qa-integration-tester | e2e-test-engineer
+- **Diagnosis**: CODE_BUG | TEST_BUG | BOTH_WRONG | TEST_ENVIRONMENT
+- **Reasoning**: <1-2 sentences explaining why this classification was chosen>
+- **Spec reference**:
+```
+
+### Escalation Rules
+
+- **`SPEC_AMBIGUOUS`** — The spec does not clearly define the expected behavior. Return `VERDICT: ESCALATE_TO_ARCHITECT` instead of `CHANGES_REQUIRED`. Do not produce a fix spec — the product-architect must clarify the spec first, then the review is re-run.
+- **`BOTH_WRONG`** — Produce two fix specs: one for production code (routed to backend-developer or frontend-developer) and one for tests (routed to qa-integration-tester or e2e-test-engineer). The orchestrator applies production code fixes first, then test fixes.
+- **`TEST_ENVIRONMENT`** — The fix spec targets test setup, fixtures, or configuration — not the test assertions or production code.
+
## Commit & Push Details (Mode: commit)
1. Stage all changes: `git add ` (prefer specific files over `git add -A`)
@@ -284,11 +339,7 @@ For multi-item batches, include per-item summary bullets and one `Fixes #N` line
### CI Monitoring
-Watch CI checks after pushing:
-
-```bash
-gh pr checks --watch
-```
+Watch CI checks after pushing using the **CI Gate Polling** pattern from `CLAUDE.md` (use the beta or main variant based on the PR's target branch).
If CI fails:
@@ -352,7 +403,7 @@ In `[MODE: commit]`:
2. Stage specific files and commit with conventional message + all contributing agent trailers
3. Push: `git push -u origin `
4. Create PR targeting `beta` (if not already created)
-5. Watch CI: `gh pr checks --watch`
+5. Watch CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
6. If CI fails, return a fix spec (do NOT fix directly)
7. Return PR URL with CI status to orchestrator
diff --git a/.claude/agents/e2e-test-engineer.md b/.claude/agents/e2e-test-engineer.md
index dd58c4c00..2abf8c292 100644
--- a/.claude/agents/e2e-test-engineer.md
+++ b/.claude/agents/e2e-test-engineer.md
@@ -235,6 +235,28 @@ When you find a defect, report it as a **GitHub Issue** with the `bug` label. Us
---
+## Test Failure Reporting Format
+
+When E2E tests fail, report failures using this structured format. **Do NOT diagnose whether the fault lies in the production code or the test** — that determination belongs to the dev-team-lead's diagnostic protocol. Just report what you observe.
+
+```markdown
+### E2E Failure Report
+
+- **Test file**:
+- **Test name**:
+- **Line**:
+- **Viewport**: desktop | tablet | mobile
+- **Assertion**: expected `` but received ``
+- **Selector(s) used**:
+- **Error output**:
+- **Tested behavior**: <1 sentence describing what this test validates>
+- **Spec reference**:
+```
+
+Provide one block per failing test. If multiple assertions fail in the same test, report each assertion separately.
+
+---
+
## Strict Boundaries
- Do **NOT** write unit or integration tests — those belong to the `qa-integration-tester`
@@ -251,7 +273,7 @@ If you discover something that requires a fix, write a bug report. If you need c
## E2E Smoke Tests
-E2E smoke tests run automatically in CI (see `e2e-smoke` job in `.github/workflows/ci.yml`) — **do not run them locally**. After pushing your branch and creating a PR, wait for CI to report results: `gh pr checks --watch`. If CI E2E smoke tests fail, investigate and fix before proceeding.
+E2E smoke tests run automatically in CI (see `e2e-smoke` job in `.github/workflows/ci.yml`) — **do not run them locally**. After pushing your branch and creating a PR, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant). If CI E2E smoke tests fail, investigate and fix before proceeding.
## Quality Assurance Self-Checks
@@ -269,7 +291,7 @@ Before considering your work complete, verify:
- [ ] Dependent systems are tested via real containers (not only mocked)
- [ ] Smoke tests expanded if new major capabilities were added
- [ ] Bug reports have complete reproduction steps
-- [ ] CI checks pass after push (wait with `gh pr checks --watch`) — includes E2E smoke tests
+- [ ] CI checks pass after push (use the **CI Gate Polling** pattern from `CLAUDE.md`) — includes E2E smoke tests
---
diff --git a/.claude/agents/frontend-developer.md b/.claude/agents/frontend-developer.md
index d0e52ef92..3b1107b96 100644
--- a/.claude/agents/frontend-developer.md
+++ b/.claude/agents/frontend-developer.md
@@ -116,6 +116,26 @@ Follow this workflow for every task:
- **Use CSS custom properties from `tokens.css`** — never hardcode hex colors, font sizes, or spacing values. All visual values must reference semantic tokens (e.g., `var(--color-bg-primary)`, `var(--spacing-4)`)
- **Follow existing design patterns** for component states (hover, focus, disabled, error, empty), responsive behavior, and animations. Reference `tokens.css` and the Style Guide wiki page for established conventions
+## Shared Component Library
+
+Before building any UI element, check if a shared component exists. Using shared components is **mandatory** — do not create parallel implementations.
+
+| Component | Location | Use For |
+| -------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `Badge` | `client/src/components/Badge/` | Status indicators, severity badges, outcome badges — pass a variant map and current value |
+| `SearchPicker` | `client/src/components/SearchPicker/` | Search-as-you-type entity selection (work items, household items, etc.) — pass search function and display renderer |
+| `Modal` | `client/src/components/Modal/` | Dialog overlays — provides backdrop, escape key, focus management, header/content/actions slots |
+| `Skeleton` | `client/src/components/Skeleton/` | Loading placeholders — configurable line count and widths |
+| `EmptyState` | `client/src/components/EmptyState/` | Empty data states — icon, message, optional action button |
+| `FormError` | `client/src/components/FormError/` | Error banners and field-level error display in forms |
+
+**Rules:**
+
+1. If a shared component fits your need, use it — do not create a new component with similar functionality
+2. If you need a variation, extend the shared component with new props
+3. **Every new component must be built as a reusable shared component** in `client/src/components/` — never embed reusable UI patterns inside page-specific code. Design for reuse from the start: generic props, no hardcoded domain assumptions, documented usage
+4. All CSS values must use design tokens — never hardcode hex colors, spacing values, border-radius, or font sizes
+
## Boundaries (What NOT to Do)
- Do NOT implement server-side logic, API endpoints, or database operations
@@ -124,17 +144,19 @@ Follow this workflow for every task:
- Do NOT change the API contract without flagging the need to coordinate with the Architect
- Do NOT make architectural decisions (state management library changes, build tool changes) without Architect input — flag these as recommendations instead
- Do NOT install new major dependencies without checking if the Architect has guidelines on this
+- Do NOT create new badge, picker, modal, skeleton, or empty state components when shared versions exist — use and extend the shared components instead
## Quality Assurance
Before considering any task complete:
1. **Commit** your changes — the pre-commit hook runs all quality gates (lint, format, tests, typecheck, build, audit)
-2. **Wait for CI** after pushing (`gh pr checks --watch`) — do not proceed until green
+2. **Wait for CI** after pushing (use the **CI Gate Polling** pattern from `CLAUDE.md`) — do not proceed until green
3. **Verify** that all new components handle loading, error, and empty states
4. **Check** that TypeScript types are properly defined (no `any` types without justification)
5. **Ensure** new API client functions match the contract on the GitHub Wiki API Contract page
6. **Review** your own code for consistency with existing patterns in the codebase
+7. **Verify** shared component usage — confirm you're using Badge, SearchPicker, Modal, Skeleton, EmptyState, FormError where applicable instead of creating custom implementations
## Error Handling Patterns
@@ -170,7 +192,7 @@ Before considering any task complete:
3. Commit with conventional commit message and your Co-Authored-By trailer (the pre-commit hook runs all quality gates automatically — selective lint/format/tests on staged files + full typecheck/build/audit)
4. Push: `git push -u origin `
5. Create a PR targeting `beta`: `gh pr create --base beta --title "..." --body "..."`
-6. Wait for CI: `gh pr checks --watch`
+6. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
7. **Request review**: After CI passes, the orchestrator launches `product-architect` and `security-engineer` to review the PR. Both must approve before merge.
8. **Address feedback**: If a reviewer requests changes, fix the issues on the same branch and push. The orchestrator will re-request review from the reviewer(s) that requested changes.
9. After merge, clean up: `git checkout beta && git pull && git branch -d `
diff --git a/.claude/agents/product-architect.md b/.claude/agents/product-architect.md
index b7c3c4357..819d77f3b 100644
--- a/.claude/agents/product-architect.md
+++ b/.claude/agents/product-architect.md
@@ -225,7 +225,7 @@ When launched to review a pull request, follow this process:
3. Commit with conventional commit message and your Co-Authored-By trailer (the pre-commit hook runs all quality gates automatically — selective lint/format/tests on staged files + full typecheck/build/audit)
4. Push: `git push -u origin `
5. Create a PR targeting `beta`: `gh pr create --base beta --title "..." --body "..."`
-6. Wait for CI: `gh pr checks --watch`
+6. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
7. **Request review**: After CI passes, the orchestrator launches `product-owner`, `product-architect`, and `security-engineer` to review the PR. All must approve before merge.
8. **Address feedback**: If a reviewer requests changes, fix the issues on the same branch and push. The orchestrator will re-request review from the reviewer(s) that requested changes.
9. After merge, clean up: `git checkout beta && git pull && git branch -d `
diff --git a/.claude/agents/qa-integration-tester.md b/.claude/agents/qa-integration-tester.md
index 2672f6b20..6b982d418 100644
--- a/.claude/agents/qa-integration-tester.md
+++ b/.claude/agents/qa-integration-tester.md
@@ -175,6 +175,26 @@ When you find a defect, report it as a **GitHub Issue** with the `bug` label. Us
---
+## Test Failure Reporting Format
+
+When tests fail, report failures using this structured format. **Do NOT diagnose whether the fault lies in the production code or the test** — that determination belongs to the dev-team-lead's diagnostic protocol. Just report what you observe.
+
+```markdown
+### Failure Report
+
+- **Test file**:
+- **Test name**:
+- **Line**:
+- **Assertion**: expected `` but received ``
+- **Error output**:
+- **Tested behavior**: <1 sentence describing what this test validates>
+- **Spec reference**:
+```
+
+Provide one block per failing test. If multiple assertions fail in the same test, report each assertion separately.
+
+---
+
## Strict Boundaries
- Do **NOT** implement features or write application code
@@ -202,7 +222,7 @@ Before considering your work complete, verify:
- [ ] Bug reports have complete reproduction steps
- [ ] Performance metrics validated against baselines (bundle size, load time, API response time)
- [ ] Docker deployment tested if applicable
-- [ ] CI checks pass after push (wait with `gh pr checks --watch`)
+- [ ] CI checks pass after push (use the **CI Gate Polling** pattern from `CLAUDE.md`)
---
diff --git a/.claude/agents/ux-designer.md b/.claude/agents/ux-designer.md
index 5185f47c4..b87f7bf61 100644
--- a/.claude/agents/ux-designer.md
+++ b/.claude/agents/ux-designer.md
@@ -87,6 +87,24 @@ When a UI-touching story needs a visual spec, post a structured specification as
- Note which shared CSS classes from `shared.module.css` should be reused
- Identify opportunities to extend existing patterns rather than creating new ones
+#### Component Reuse Audit
+
+Before specifying any new UI element, audit the existing shared component library:
+
+1. **Check shared components**: Read `client/src/components/Badge/`, `SearchPicker/`, `Modal/`, `Skeleton/`, `EmptyState/`, and the shared styles in `shared.module.css`
+2. **Map to existing patterns**: For each UI element in the spec, identify which existing shared component should be used
+3. **Flag new patterns**: If a UI element genuinely needs a new component, explicitly justify why no existing component works and **specify it as a new reusable shared component** — every new component must be designed for reuse, never as a one-off
+4. **Reject duplication**: If the spec would create a component that overlaps with an existing shared component, redesign to use the existing one
+5. **Reject one-offs**: If a new component is proposed as page-specific but could be reused elsewhere, require it to be built as a shared component with generic props
+
+Include a "Component Mapping" table in every visual spec:
+
+| UI Element | Shared Component | Props/Variant | Notes |
+| ------------------- | ---------------- | -------------------------- | -------- |
+| Status indicator | `Badge` | variant="workItemStatus" | Existing |
+| Item selector | `SearchPicker` | searchFn={searchWorkItems} | Existing |
+| Confirmation dialog | `Modal` | — | Existing |
+
### 2. PR Design Review (Develop Step 8)
When reviewing PRs that touch `client/src/`, check the diff against the design system:
@@ -106,6 +124,8 @@ When reviewing PRs that touch `client/src/`, check the diff against the design s
- **Responsive implementation** — are breakpoints handled? Do layouts adapt for mobile/tablet/desktop? Touch targets adequate?
- **Accessibility** — proper ARIA attributes, keyboard navigation, focus management, sufficient color contrast?
- **Shared pattern usage** — are shared CSS classes from `shared.module.css` being used where applicable? Any duplication of existing patterns?
+- **Component reuse** — does the PR create new UI components that duplicate existing shared components (Badge, SearchPicker, Modal, Skeleton, EmptyState, FormError)? If so, request changes to use the shared component instead. Check `client/src/components/` for the shared library.
+- **Token compliance (stylelint)** — are there any hardcoded color, spacing, radius, or font-size values? All must use `var(--token-name)` from `tokens.css`. Stylelint should catch these, but verify in the diff.
- **Animation/transition** — do transitions use token durations? Is `prefers-reduced-motion` respected?
- **CSS Module conventions** — are class names descriptive? No global CSS leakage?
diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl
index 9e17da5a8..caa60a768 100644
--- a/.claude/metrics/review-metrics.jsonl
+++ b/.claude/metrics/review-metrics.jsonl
@@ -134,3 +134,15 @@
{"pr":783,"issues":[747],"epic":null,"type":"feat","mergedAt":"2026-03-13T14:45:00Z","filesChanged":5,"linesChanged":873,"fixLoopCount":1,"reviews":[{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":1,"medium":1,"low":1,"informational":2},"round":1},{"agent":"product-architect","verdict":"comment","findings":{"critical":0,"high":0,"medium":2,"low":1,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":1,"medium":3,"low":2,"informational":2}}
{"pr":790,"issues":[789],"epic":null,"type":"fix","mergedAt":"2026-03-13T00:00:00Z","filesChanged":3,"linesChanged":52,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
{"pr":792,"issues":[791],"epic":null,"type":"feat","mergedAt":"2026-03-13T00:00:00Z","filesChanged":5,"linesChanged":581,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":1,"medium":1,"low":1,"informational":0},"round":1},{"agent":"ux-designer","verdict":"request-changes","findings":{"critical":0,"high":0,"medium":1,"low":2,"informational":3},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":2},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":2}],"totalFindings":{"critical":0,"high":1,"medium":2,"low":3,"informational":6}}
+{"pr":812,"issues":[803],"epic":446,"type":"feat","mergedAt":"2026-03-14T17:30:00Z","filesChanged":8,"linesChanged":1894,"fixLoopCount":2,"reviews":[{"agent":"product-architect","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":3,"informational":0},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":2,"medium":1,"low":0,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":2,"medium":1,"low":3,"informational":2}}
+{"pr":821,"issues":[],"epic":null,"type":"external-review","reviewedAt":"2026-03-15T00:00:00Z","filesChanged":2,"linesChanged":29,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"request-changes","findings":{"critical":1,"high":1,"medium":1,"low":1,"informational":0},"round":1},{"agent":"security-engineer","verdict":"request-changes","findings":{"critical":1,"high":0,"medium":0,"low":1,"informational":1},"round":1},{"agent":"dev-team-lead","verdict":"request-changes","findings":{"critical":0,"high":1,"medium":2,"low":2,"informational":1},"round":1}],"totalFindings":{"critical":2,"high":2,"medium":3,"low":4,"informational":2}}
+{"pr":846,"issues":[836,837,838,839,840,841,842,843,844,845],"epic":446,"type":"fix","mergedAt":"2026-03-15T15:00:00Z","filesChanged":48,"linesChanged":3200,"fixLoopCount":2,"reviews":[],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":854,"issues":[853],"epic":null,"type":"chore","mergedAt":"2026-03-16T00:00:00Z","filesChanged":9,"linesChanged":2720,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"comment","findings":{"critical":0,"high":0,"medium":1,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":1,"low":0,"informational":0}}
+{"pr":857,"issues":[856],"epic":null,"type":"chore","mergedAt":"2026-03-16T00:00:00Z","filesChanged":7,"linesChanged":660,"fixLoopCount":2,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"request-changes","findings":{"critical":0,"high":0,"medium":3,"low":2,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":3,"low":2,"informational":4}}
+{"pr":859,"issues":[858],"epic":null,"type":"chore","mergedAt":"2026-03-16T00:00:00Z","filesChanged":2,"linesChanged":140,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0}}
+{"pr":861,"issues":[860],"epic":null,"type":"chore","mergedAt":"2026-03-16T00:00:00Z","filesChanged":10,"linesChanged":580,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":3},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"comment","findings":{"critical":0,"high":0,"medium":1,"low":2,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":1,"low":2,"informational":4}}
+{"pr":863,"issues":[862],"epic":null,"type":"chore","mergedAt":"2026-03-16T00:00:00Z","filesChanged":15,"linesChanged":620,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":4},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"comment","findings":{"critical":0,"high":0,"medium":1,"low":0,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":1,"low":0,"informational":5}}
+{"pr":865,"issues":[864],"epic":null,"type":"chore","mergedAt":"2026-03-16T00:00:00Z","filesChanged":4,"linesChanged":350,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":2,"informational":0}}
+{"pr":870,"issues":[866,867,868,869],"epic":446,"type":"fix","mergedAt":"2026-03-16T08:00:00Z","filesChanged":15,"linesChanged":800,"fixLoopCount":1,"reviews":[],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":877,"issues":[875,876],"epic":446,"type":"fix","mergedAt":"2026-03-16T12:00:00Z","filesChanged":5,"linesChanged":200,"fixLoopCount":1,"reviews":[],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+
diff --git a/.claude/skills/develop/SKILL.md b/.claude/skills/develop/SKILL.md
index 970540b31..5bc1769d8 100644
--- a/.claude/skills/develop/SKILL.md
+++ b/.claude/skills/develop/SKILL.md
@@ -185,6 +185,8 @@ After implementation agents complete, launch both test agents in parallel:
- List of files created/modified by the backend and frontend agents
- Reminder to triage prior E2E failures from recent beta PRs before writing new tests (the agent does this automatically per its "Before Starting Any Work" checklist)
+**If test agents report failures**: Collect structured failure reports (see the agents' "Test Failure Reporting Format" sections) and include them verbatim in the review input for step 6e. This triggers the dev-team-lead's diagnostic protocol.
+
#### 6e. Code Review
Launch the **dev-team-lead** in `[MODE: review]` with:
@@ -197,16 +199,23 @@ Launch the **dev-team-lead** in `[MODE: review]` with:
**If `VERDICT: CHANGES_REQUIRED`** → proceed to step 6f
+**If `VERDICT: ESCALATE_TO_ARCHITECT`** → The spec is ambiguous. Launch the **product-architect** agent to clarify the spec (provide the ambiguous spec reference and the dev-team-lead's reasoning). After the architect clarifies, re-launch the **dev-team-lead** in `[MODE: review]` with the clarified spec. Then proceed based on the new verdict.
+
#### 6f. Fix Loop (max 3 iterations)
Track `internalFixCount` (starts at 0). For each iteration:
-1. Parse the fix specs from the review verdict — each fix specifies which agent should handle it
-2. Re-launch the appropriate agent(s) with targeted fix specs:
- - Backend fixes → **backend-developer** (Haiku)
- - Frontend fixes → **frontend-developer** (Haiku)
- - Unit/integration test fixes → **qa-integration-tester**
- - E2E test fixes → **e2e-test-engineer**
+1. Parse the fix specs from the review verdict — each fix specifies which agent should handle it and includes a `Diagnosis` classification when test failures are involved
+2. Route fixes based on diagnosis:
+ - `CODE_BUG` → production code fix to **backend-developer** or **frontend-developer** (Haiku)
+ - `TEST_BUG` → test fix to **qa-integration-tester** or **e2e-test-engineer**
+ - `BOTH_WRONG` → apply production code fixes **first**, then test fixes (two sequential rounds)
+ - `TEST_ENVIRONMENT` → test setup fix to **qa-integration-tester** or **e2e-test-engineer**
+ - Non-test issues (no diagnosis) → route as before:
+ - Backend fixes → **backend-developer** (Haiku)
+ - Frontend fixes → **frontend-developer** (Haiku)
+ - Unit/integration test fixes → **qa-integration-tester**
+ - E2E test fixes → **e2e-test-engineer**
3. After fixes complete, re-launch **dev-team-lead** in `[MODE: review]` with updated file list
4. Increment `internalFixCount`
5. If `VERDICT: APPROVED` → proceed to step 6g
@@ -338,9 +347,7 @@ If any reviewer identifies blocking issues:
Once all reviews are clean, wait for CI to go green:
-```
-gh pr checks --watch
-```
+Use the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant — wait for `Quality Gates` only).
After CI is green, present the user with:
diff --git a/.claude/skills/epic-close/SKILL.md b/.claude/skills/epic-close/SKILL.md
index 504d215cf..587f19d73 100644
--- a/.claude/skills/epic-close/SKILL.md
+++ b/.claude/skills/epic-close/SKILL.md
@@ -49,7 +49,7 @@ Include:
- Total PRs, average fix loops per PR, % of PRs requiring fix loops
- Total findings breakdown by severity
-Post this report as a comment on the epic GitHub Issue. Include it in the promotion PR body (Step 9).
+Post this report as a comment on the epic GitHub Issue. Include it in the promotion PR body (Step 8).
### 2b. Lint Health Check
@@ -93,7 +93,7 @@ If there are refinement items to address:
```
gh pr create --base beta --title "chore: address refinement items for epic #" --body "..."
```
-8. Wait for CI: `gh pr checks --watch`
+8. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant — wait for `Quality Gates` only)
9. Squash merge: `gh pr merge --squash `
If no refinement items exist, skip to step 5.
@@ -111,31 +111,17 @@ Launch the **e2e-test-engineer** agent to:
- Open a PR targeting `beta` to trigger the full sharded E2E suite in CI (if it does not yet exist)
- Wait for the full E2E suite to pass (not just smoke tests)
+**If the e2e-test-engineer reports failures**: Collect the structured E2E failure reports and launch the **dev-team-lead** in `[MODE: review]` with the failure reports and the relevant spec/acceptance criteria. The dev-team-lead's diagnostic protocol will classify each failure and produce targeted fix specs. Route fixes based on the diagnosis (same routing as `/develop` step 6f).
+
This approval is **required** before proceeding to UAT validation.
### 6. UAT Validation
Launch the **product-owner** agent to produce UAT scenarios. The e2e-test-engineer must have already covered these scenarios in step 5. E2E pass + e2e-test-engineer report = sufficient validation. Post the UAT report as a comment on the epic issue and proceed to step 7.
-The UAT scenarios are included in the promotion PR (step 10) as a manual validation checklist so the user can spot-check during the promotion gate.
-
-### 7. Documentation
-
-Launch the **docs-writer** agent to:
-
-- Update the documentation site (`docs/`) with new feature guides
-- Update `README.md` with newly shipped capabilities
-- Write `RELEASE_SUMMARY.md` for the GitHub Release changelog enrichment
+The UAT scenarios are included in the promotion PR (step 8) as a manual validation checklist so the user can spot-check during the promotion gate.
-Commit documentation updates to `beta` via a PR:
-
-```bash
-gh pr create --base beta --title "docs: update documentation for epic #" --body "..."
-```
-
-Wait for CI, then squash merge.
-
-### 8. Branch Sync
+### 7. Branch Sync
Check if `main` has commits that `beta` doesn't (e.g., hotfixes cherry-picked to main):
@@ -145,9 +131,9 @@ git log origin/beta..origin/main --oneline
If so, create a sync PR (`main` → `beta`), wait for CI, merge before proceeding. This ensures the promotion PR merges cleanly.
-If no divergence, skip to step 9.
+If no divergence, skip to step 8.
-### 9. Epic Promotion
+### 8. Epic Promotion
Create a PR from `beta` to `main` using a **merge commit** (not squash). The promotion PR is the single human checkpoint — include a comprehensive summary:
@@ -197,7 +183,7 @@ EOF
)"
```
-### 10. Post Detailed UAT Criteria
+### 9. Post Detailed UAT Criteria
Post detailed UAT validation criteria as a comment on the promotion PR — step-by-step instructions the user can follow to validate each story:
@@ -216,25 +202,106 @@ EOF
)"
```
-### 11. CI Gate
+### 10. CI Gate
-Wait for all CI checks to pass on the promotion PR, including the full sharded E2E suite (runs on main-targeting PRs):
+Wait for all required CI gates to pass on the promotion PR using the **CI Gate Polling** pattern from `CLAUDE.md` (main variant — wait for both `Quality Gates` + `E2E Gates`).
-```
-gh pr checks --watch
-```
+If any gate fails, investigate and resolve before proceeding.
+
+### 11. Promotion Approval Loop
-If any check fails, investigate and resolve before proceeding.
+Initialize `uatFeedbackRound = 0`. This step loops until the user explicitly approves.
-### 12. User Approval
+#### 11a. Present for Approval
-**Wait for explicit user approval** before merging. Present the user with:
+Present the user with:
1. **Promotion PR link** — with the comprehensive summary, change inventory, and validation checklist
2. **DockerHub beta image** — `docker pull steilerdev/cornerstone:beta` for manual testing
3. **E2E + review summary** — confirmation that all automated validation passed
-The user reviews the PR, optionally tests with the beta image, and approves. Do NOT merge without user confirmation.
+If `uatFeedbackRound > 0`, also include a summary of changes made in the previous feedback round (issues created, PRs merged, what was fixed).
+
+Tell the user:
+
+- **To approve**: say "approved" (or similar confirmation) → proceed to step 12
+- **To provide feedback**: write feedback to `/tmp/notes.md` and say "feedback in notes" → fixes will be applied autonomously
+
+Do NOT merge without explicit user confirmation.
+
+#### 11b. Await Response
+
+Wait for the user's response. Branch:
+
+- If the user **approves** → proceed to step 12 (Documentation & Env Drift Check)
+- If the user says **"feedback in notes"** (or similar) → continue to 11c
+
+#### 11c. Read Feedback
+
+Read `/tmp/notes.md` and parse non-empty, non-comment lines. Print a numbered summary of the feedback items for the user to confirm.
+
+#### 11d. PO Grouping
+
+Launch the **product-owner** agent to:
+
+- Analyze the feedback items
+- Group related items that should be fixed together
+- Create a GitHub Issue for each group, labeled `bug`, linked as a sub-issue of the epic, and added to the Projects board in **Todo** status
+- Return the list of created issue numbers and their groupings
+
+#### 11e. Execute Fixes
+
+For each group of issues from 11d:
+
+1. Create a fresh branch from `origin/beta`: `git checkout -B fix/- origin/beta`
+2. Execute `/develop` steps 2–11 (skipping step 1 Rebase and step 4 Branch — branch is already created)
+3. Track success/failure for each group
+
+If any group fails after retry budget exhaustion, report the failure to the user and ask whether to continue with remaining groups or pause.
+
+#### 11f. Update Promotion PR
+
+After all fix groups are merged to `beta`:
+
+1. Close the current promotion PR: `gh pr close `
+2. Re-run Branch Sync (step 7) to ensure `main` and `beta` are aligned
+3. Create a new promotion PR with:
+ - Updated change inventory reflecting all fixes
+ - A **Feedback Rounds** section listing each round's issues and PRs
+ - Reference to the superseded PR: `Supersedes #`
+4. Post updated detailed UAT criteria (step 9) on the new PR
+
+#### 11g. CI Gate
+
+Wait for all required CI gates to pass on the new promotion PR using the **CI Gate Polling** pattern from `CLAUDE.md` (main variant — wait for both `Quality Gates` + `E2E Gates`).
+
+If any gate fails, investigate and resolve before proceeding.
+
+#### 11h. Loop
+
+Increment `uatFeedbackRound`. Go to **11a** with the new promotion PR.
+
+### 12. Documentation & Env Drift Check
+
+Launch the **docs-writer** agent to:
+
+- Update the documentation site (`docs/`) with new feature guides
+- Update `README.md` with newly shipped capabilities
+- Write `RELEASE_SUMMARY.md` for the GitHub Release changelog enrichment
+- **Verify `.env.example` freshness**: Scan server source code for all `process.env.*` references (primarily `server/src/plugins/config.ts`), compare against `.env.example` entries, and fix any drift. Rules:
+ - Optional features (OIDC, Paperless, etc.) must remain **commented out** with example placeholder values
+ - Preserve inline `# Optional: ...` documentation comments
+ - Update the Environment Variables table in `CLAUDE.md` if new vars were added
+
+Commit documentation updates to `beta` via a PR:
+
+```bash
+gh pr create --base beta --title "docs: update documentation for epic #" --body "..."
+```
+
+Wait for CI, then squash merge.
+
+**Note:** Documentation runs after user approval (step 11) to ensure docs reflect the final state, including any changes from UAT feedback rounds.
### 13. Merge & Post-Merge
diff --git a/.claude/skills/epic-run/SKILL.md b/.claude/skills/epic-run/SKILL.md
index 08becc987..2b268efc4 100644
--- a/.claude/skills/epic-run/SKILL.md
+++ b/.claude/skills/epic-run/SKILL.md
@@ -174,12 +174,12 @@ Execute `/epic-close` steps 2 through 13 in order. Step 1 (Rebase) is skipped
- **Step 4** (Refinement PR)
- **Step 5** (E2E Validation)
- **Step 6** (UAT Validation)
-- **Step 7** (Documentation)
-- **Step 8** (Branch Sync)
-- **Step 9** (Epic Promotion)
-- **Step 10** (Post Detailed UAT Criteria)
-- **Step 11** (CI Gate)
-- **Step 12** (User Approval) — **mandatory human gate**
+- **Step 7** (Branch Sync)
+- **Step 8** (Epic Promotion)
+- **Step 9** (Post Detailed UAT Criteria)
+- **Step 10** (CI Gate)
+- **Step 11** (Promotion Approval Loop) — **mandatory human gate** with autonomous feedback fix loop
+- **Step 12** (Documentation & Env Drift Check) — runs after user approval
- **Step 13** (Merge & Post-Merge)
If any step in `/epic-close` references "failed stories" or "excluded stories", use the `failedStories` list from Phase 2.
diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md
index 30b886777..14edb49f8 100644
--- a/.claude/skills/review-pr/SKILL.md
+++ b/.claude/skills/review-pr/SKILL.md
@@ -129,11 +129,7 @@ Present the blocking findings to the user. **Do NOT wait for CI.**
Post a consolidated `gh pr review --approve` comment on the PR summarizing the review outcome.
-Wait for CI to complete:
-
-```bash
-gh pr checks --watch
-```
+Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (use the beta or main variant based on the PR's target branch).
If CI fails, report the specific failures to the user. **Do NOT merge.**
diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml
index 75a2a8e4c..f1c21eb19 100644
--- a/.github/workflows/auto-fix.yml
+++ b/.github/workflows/auto-fix.yml
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate app token
id: app-token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c35bd12ad..eeda73c47 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -175,8 +175,8 @@ jobs:
load: true
tags: cornerstone:e2e
build-args: APP_VERSION=pr-${{ github.event.pull_request.number }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ cache-from: type=gha,scope=linux/amd64
+ cache-to: type=gha,mode=max,scope=linux/amd64
- name: Save image
run: docker save cornerstone:e2e -o cornerstone-e2e.tar
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 32cd46a30..b6eabd092 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -114,27 +114,38 @@ jobs:
fi
# ---------------------------------------------------------------------------
- # Job 2: Docker Build & Push
+ # Job 2: Docker Build (per-platform, native runners — no QEMU)
# ---------------------------------------------------------------------------
- docker:
- name: Docker
- runs-on: ubuntu-latest
+ docker-build:
+ name: Docker Build (${{ matrix.platform }})
needs: [release]
if: needs.release.outputs.new-release-published == 'true'
+ runs-on: ${{ matrix.runner }}
+
+ strategy:
+ fail-fast: true
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
permissions:
contents: read
+ deployments: write
id-token: write
+ environment:
+ name: ${{ needs.release.outputs.is-prerelease == 'true' && 'dockerhub-beta' || 'dockerhub-production' }}
+ url: https://hub.docker.com/r/steilerdev/cornerstone/tags?name=${{ needs.release.outputs.new-release-version }}
+
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: v${{ needs.release.outputs.new-release-version }}
- - name: Setup QEMU
- uses: docker/setup-qemu-action@v4
-
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -151,6 +162,62 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ platforms: ${{ matrix.platform }}
+ provenance: mode=max
+ sbom: true
+ cache-from: type=gha,scope=${{ matrix.platform }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+ build-args: APP_VERSION=${{ needs.release.outputs.new-release-version }}
+ outputs: type=image,name=steilerdev/cornerstone,push-by-digest=true,name-canonical=true,push=true
+
+ - name: Export digest
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ uses: actions/upload-artifact@v7
+ with:
+ name: docker-digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ # ---------------------------------------------------------------------------
+ # Job 3: Docker Merge — combine per-platform digests into multi-arch manifest
+ # ---------------------------------------------------------------------------
+ docker:
+ name: Docker
+ runs-on: ubuntu-latest
+ needs: [release, docker-build]
+
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Download digests
+ uses: actions/download-artifact@v8
+ with:
+ path: /tmp/digests
+ pattern: docker-digests-*
+ merge-multiple: true
+
+ - name: Setup Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
- name: Docker metadata (beta)
id: meta-beta
if: needs.release.outputs.is-prerelease == 'true'
@@ -172,19 +239,19 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.new-release-version }}
type=raw,value=latest
- - name: Build and push
- uses: docker/build-push-action@v7
- with:
- context: .
- push: true
- platforms: linux/amd64,linux/arm64
- provenance: mode=max
- sbom: true
- cache-from: type=gha
- cache-to: type=gha,mode=max
- build-args: APP_VERSION=${{ needs.release.outputs.new-release-version }}
- tags: ${{ steps.meta-beta.outputs.tags || steps.meta-stable.outputs.tags || 'cornerstone:ci-test' }}
- labels: ${{ steps.meta-beta.outputs.labels || steps.meta-stable.outputs.labels }}
+ - name: Create multi-arch manifest and push
+ working-directory: /tmp/digests
+ run: |
+ TAGS="${{ steps.meta-beta.outputs.tags || steps.meta-stable.outputs.tags }}"
+ TAG_ARGS=$(echo "$TAGS" | xargs printf -- '--tag %s\n')
+ docker buildx imagetools create $TAG_ARGS \
+ $(printf 'steilerdev/cornerstone@sha256:%s ' *)
+
+ - name: Inspect manifest
+ run: |
+ TAGS="${{ steps.meta-beta.outputs.tags || steps.meta-stable.outputs.tags }}"
+ FIRST_TAG=$(echo "$TAGS" | head -n1 | xargs)
+ docker buildx imagetools inspect "$FIRST_TAG"
- name: Job summary
run: |
@@ -210,7 +277,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
- # Job 3: Docker Scout Security Scan
+ # Job 4: Docker Scout Security Scan
# ---------------------------------------------------------------------------
scout:
name: Docker Scout
@@ -256,7 +323,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
- # Job 4: Merge main back into beta after stable release
+ # Job 5: Merge main back into beta after stable release
# ---------------------------------------------------------------------------
merge-back:
name: Merge Back to Beta
@@ -269,7 +336,7 @@ jobs:
steps:
- name: Generate app token
id: app-token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
@@ -297,7 +364,7 @@ jobs:
git push origin beta
# ---------------------------------------------------------------------------
- # Job 5: Push README.md to DockerHub
+ # Job 6: Push README.md to DockerHub
# ---------------------------------------------------------------------------
dockerhub-readme:
name: DockerHub README
@@ -323,7 +390,7 @@ jobs:
readme-filepath: ./README.md
# ---------------------------------------------------------------------------
- # Job 6: Capture documentation screenshots from the released Docker image
+ # Job 7: Capture documentation screenshots from the released Docker image
# ---------------------------------------------------------------------------
docs-screenshots:
name: Docs Screenshots
@@ -377,7 +444,7 @@ jobs:
retention-days: 1
# ---------------------------------------------------------------------------
- # Job 7: Build and deploy documentation site to GitHub Pages
+ # Job 8: Build and deploy documentation site to GitHub Pages
# ---------------------------------------------------------------------------
docs-deploy:
name: Docs Deploy
diff --git a/.stylelintrc.json b/.stylelintrc.json
new file mode 100644
index 000000000..508bdab00
--- /dev/null
+++ b/.stylelintrc.json
@@ -0,0 +1,53 @@
+{
+ "extends": "stylelint-config-standard",
+ "rules": {
+ "declaration-property-value-no-unknown": null,
+ "custom-property-pattern": null,
+ "property-no-unknown": null,
+ "selector-class-pattern": null,
+ "value-keyword-case": null,
+ "declaration-no-important": null,
+ "import-notation": null,
+ "property-no-vendor-prefix": null,
+ "at-rule-descriptor-no-unknown": null,
+ "shorthand-property-no-redundant-values": null,
+ "no-descending-specificity": null,
+ "color-hex-length": null,
+ "keyframes-name-pattern": null,
+ "rule-empty-line-before": null,
+ "comment-empty-line-before": null,
+ "media-feature-range-notation": null,
+ "color-function-notation": null,
+ "declaration-block-no-redundant-longhand-properties": null,
+ "custom-property-empty-line-before": null,
+ "alpha-value-notation": null,
+ "declaration-property-value-keyword-no-deprecated": null,
+ "declaration-block-no-shorthand-property-overrides": null,
+ "color-no-hex": true,
+ "function-disallowed-list": ["rgb", "rgba", "hsl", "hsla"],
+ "declaration-property-value-disallowed-list": {
+ "font-weight": ["/^\\d+$/"],
+ "z-index": ["/^\\d+$/"]
+ }
+ },
+ "overrides": [
+ {
+ "files": ["**/tokens.css"],
+ "rules": {
+ "color-no-hex": null,
+ "function-disallowed-list": null,
+ "declaration-property-value-disallowed-list": null
+ }
+ },
+ {
+ "files": ["docs/**/*.css", "docs/**/*.module.css"],
+ "rules": {
+ "color-no-hex": null,
+ "function-disallowed-list": null,
+ "declaration-property-value-disallowed-list": null,
+ "declaration-no-important": null
+ }
+ }
+ ],
+ "ignoreFiles": ["node_modules/**", "**/dist/**", "**/build/**", "docs/**"]
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 26fe225c0..418477c02 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -97,13 +97,13 @@ The orchestrator uses four skills to drive work. Each skill contains the full op
## Acceptance & Validation
-Every epic follows a two-phase validation lifecycle. **Development phase** (`/develop`): PO defines acceptance criteria, QA + E2E + security review each story/bug PR — PRs auto-merge after CI green + all reviewers approved. **Epic validation phase** (`/epic-close`): refinement, E2E coverage confirmation, UAT scenarios fed to e2e-test-engineer, docs update, promotion. Use `/epic-run` to execute the entire lifecycle in a single session. The only human gate is promotion from `beta` → `main`, where the user reviews a comprehensive summary with change inventory, validation report, and manual validation checklist.
+Every epic follows a two-phase validation lifecycle. **Development phase** (`/develop`): PO defines acceptance criteria, QA + E2E + security review each story/bug PR — PRs auto-merge after CI green + all reviewers approved. **Epic validation phase** (`/epic-close`): refinement, E2E coverage confirmation, UAT scenarios fed to e2e-test-engineer, promotion, then docs update. Use `/epic-run` to execute the entire lifecycle in a single session. The only human gate is promotion from `beta` → `main`, where the user reviews a comprehensive summary with change inventory, validation report, and manual validation checklist. If the user provides feedback via `/tmp/notes.md`, fixes are applied autonomously (PO groups items into issues, `/develop` fixes each group) and the promotion PR is re-created — looping until the user approves. Documentation runs after approval to reflect the final state.
### Key Rules
- **User approval required for promotion** — the user is the final authority on `beta` → `main` promotion
- **Automated before manual** — all automated tests must be green before the user validates
-- **Iterate until right** — failed validation triggers a fix-and-revalidate loop
+- **Iterate until right** — failed validation triggers a fix-and-revalidate loop (user writes feedback to `/tmp/notes.md`, system fixes autonomously and re-presents)
- **Acceptance criteria live on GitHub Issues** — stored on story issues, summarized on promotion PRs
- **Security review required** — the `security-engineer` must review every story PR
- **Test agents own all tests** — `qa-integration-tester` owns unit and integration tests; `e2e-test-engineer` owns Playwright E2E browser tests. Developer agents do not write tests.
@@ -127,7 +127,35 @@ All commits follow [Conventional Commits](https://www.conventionalcommits.org/):
- CI auto-fix bot: `npm run lint:fix` + `npm run format` + `npm audit fix` (runs on `beta` push, creates PR if changes needed)
- CI Quality Gates: typecheck + test + build (runs on every PR)
-To validate your work: **commit and push**. After pushing, **always wait for CI to go green** (`gh pr checks --watch`) before proceeding to the next step.
+To validate your work: **commit and push**. After pushing, **always wait for the required CI gates to pass** before proceeding to the next step.
+
+#### CI Gate Polling (canonical pattern)
+
+`gh pr checks --watch` does not support GitHub Rulesets (only legacy branch protection). Use the polling loops below to watch the required gate checks by name.
+
+**Step 1 — Check for merge conflicts.** CI may not run (or silently hang) if the PR has conflicts. Always verify mergeability first:
+
+```bash
+state=$(gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'); if [ "$state" != "MERGEABLE" ]; then echo "PR is not mergeable (state: $state) — resolve conflicts before waiting for CI"; exit 1; fi
+```
+
+If the state is `CONFLICTING`, rebase onto the target branch, force-push, and re-check. If the state is `UNKNOWN`, wait a few seconds and retry — GitHub may still be computing mergeability.
+
+**Step 2 — Poll for required gate checks.**
+
+**Beta PRs** (require `Quality Gates` only):
+
+```bash
+echo "Waiting for Quality Gates..."; while true; do bucket=$(gh pr checks --repo steilerDev/cornerstone --json name,bucket -q '.[] | select(.name == "Quality Gates") | .bucket' 2>/dev/null); case "$bucket" in pass) echo "Quality Gates passed"; break ;; fail) echo "Quality Gates FAILED"; exit 1 ;; *) sleep 30 ;; esac; done
+```
+
+**Main PRs** (require `Quality Gates` + `E2E Gates`):
+
+```bash
+echo "Waiting for Quality Gates + E2E Gates..."; while true; do qg=$(gh pr checks --repo steilerDev/cornerstone --json name,bucket -q '.[] | select(.name == "Quality Gates") | .bucket' 2>/dev/null); e2e=$(gh pr checks --repo steilerDev/cornerstone --json name,bucket -q '.[] | select(.name == "E2E Gates") | .bucket' 2>/dev/null); if [ "$qg" = "fail" ] || [ "$e2e" = "fail" ]; then echo "CI FAILED (QG=$qg, E2E=$e2e)"; exit 1; fi; if [ "$qg" = "pass" ] && [ "$e2e" = "pass" ]; then echo "All gates passed"; break; fi; sleep 30; done
+```
+
+Replace `` with the PR number. The polling loop handles the "checks not yet reported" edge case — an empty bucket means we retry after 30s.
The only exception is the QA agent running a specific test file it just wrote (e.g., `npx jest path/to/new.test.ts`) to verify correctness before committing — but never `npm test` (the full suite).
@@ -317,6 +345,28 @@ The `docs` workspace is NOT part of the application build (`npm run build`). Bui
```
- HTTP status codes: 200 (OK), 201 (Created), 204 (Deleted), 400 (Validation), 401 (Unauthed), 403 (Forbidden), 404 (Not Found), 409 (Conflict), 500 (Server Error)
+### Component Reuse Policy
+
+Before creating a new UI component, check if an existing shared component can be used or extended. The shared component library lives in `client/src/components/` and shared styles in `client/src/styles/shared.module.css`.
+
+**Shared components** (must be used instead of creating alternatives):
+
+- `Badge` — status indicators, severity badges, outcome badges (parameterized by variant map)
+- `SearchPicker` — search-as-you-type dropdowns for entity selection (work items, household items, etc.)
+- `Modal` — dialog overlays with backdrop, escape key, focus management
+- `Skeleton` — loading placeholder with configurable line count
+- `EmptyState` — empty data display with icon, message, and optional action
+- `FormError` — consistent error banner and field-level error display
+
+**Rules:**
+
+1. New UI that resembles an existing shared component MUST use or extend that component
+2. If a shared component doesn't quite fit, extend it with new props — don't create a parallel implementation
+3. **Every new component must be built as a reusable shared component** — no one-off implementations. If a UI pattern doesn't fit an existing shared component, create a new shared component in `client/src/components/` that can be reused by future features
+4. New shared components require UX designer visual spec approval
+5. All CSS values must use design tokens from `tokens.css` — no hardcoded colors, spacing, radii, or font sizes
+6. Stylelint enforces token usage automatically
+
## Testing Approach
- **Unit & integration tests**: Jest with ts-jest (co-located with source: `foo.test.ts` next to `foo.ts`)
@@ -402,6 +452,15 @@ Any agent making a decision that affects other agents (e.g., a new naming conven
When a code change invalidates information in agent memory (e.g., fixing a bug documented in memory, changing a public API, updating routes), the implementing agent must update the relevant agent memory files.
+### Test Failure Debugging Protocol
+
+When tests fail during development, a structured diagnostic protocol determines whether the failure is in the test, the production code, or the spec — preventing wasted fix loops (e.g., weakening a correct test to make broken code pass).
+
+- **Source-of-truth hierarchy**: Spec/Contract > Production code > Test code
+- **Rule**: Correct tests must not be weakened to accommodate buggy code; correct code must not be broken to satisfy a wrong test
+- **Protocol owner**: The `dev-team-lead` runs the diagnostic decision tree during `[MODE: review]` when test failures are present in the review input. See the dev-team-lead agent definition for the full classification table and escalation rules.
+- **Test agents report, not diagnose**: `qa-integration-tester` and `e2e-test-engineer` submit structured failure reports but do not determine whether the fault lies in code or tests — that judgment belongs to the dev-team-lead.
+
### Review Metrics
All reviewing agents (product-architect, security-engineer, product-owner, ux-designer) must append a structured metrics block as an HTML comment at the end of every PR review body. This is invisible to GitHub readers but parsed by the orchestrator for performance tracking.
diff --git a/Dockerfile b/Dockerfile
index c04fcb33c..5ec563901 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,26 +1,27 @@
# =============================================================================
# Cornerstone - Multi-stage Docker build
# =============================================================================
-# Stage 1 (client-builder): Runs on the BUILD HOST's native arch to avoid
-# QEMU emulation. Installs pure-JS deps and builds shared types + client
-# (webpack). No native addons needed — better-sqlite3 is skipped via
+# Stage 1 (app-builder): Runs on the BUILD HOST's native arch to avoid
+# QEMU emulation. Installs pure-JS deps and builds everything that produces
+# platform-independent output: shared types (tsc), client (webpack), and
+# server (tsc). No native addons needed — better-sqlite3 is skipped via
# --ignore-scripts.
-# Stage 2 (builder): Runs on the TARGET arch (may use QEMU for ARM64).
-# Installs deps with native addons (better-sqlite3), copies pre-built
-# shared/client from stage 1, builds server (tsc only — lightweight).
+# Stage 2 (deps): Runs on the TARGET arch to install production deps with
+# native addons. better-sqlite3 v12+ ships prebuilt binaries for
+# linuxmusl-arm64, so no compilation tools are needed — prebuild-install
+# downloads the correct binary during postinstall.
# Stage 3 (production): Minimal runtime image, no npm/build tools/shell.
# =============================================================================
# Standard build: docker build -t cornerstone .
# =============================================================================
# ---------------------------------------------------------------------------
-# Stage 1: Client builder (native arch — no QEMU)
+# Stage 1: App builder (native arch — no QEMU)
# ---------------------------------------------------------------------------
# $BUILDPLATFORM resolves to the Docker host's native architecture (e.g.
-# linux/amd64 on GitHub Actions), so webpack runs without QEMU emulation.
-# This avoids intermittent "Illegal instruction" crashes (exit code 132)
-# caused by V8 JIT generating code that QEMU's ARM64 emulation can't handle.
-FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-dev AS client-builder
+# linux/amd64 on GitHub Actions), so webpack and tsc run without QEMU
+# emulation. All build output is platform-independent JS/CSS/HTML.
+FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-dev AS app-builder
WORKDIR /app
@@ -32,7 +33,7 @@ COPY client/package.json client/
# Install all dependencies, skipping postinstall scripts. This avoids
# compiling better-sqlite3 (the only native addon) — it's not needed for
-# shared (tsc) or client (webpack) builds.
+# any build step (tsc/webpack produce platform-independent output).
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
# Stamp the release version into package.json (webpack's DefinePlugin reads
@@ -40,24 +41,26 @@ RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
ARG APP_VERSION=0.0.0-dev
RUN npm pkg set "version=${APP_VERSION}"
-# Copy source for shared and client only (server not needed here)
+# Copy all source (shared, client, server)
COPY tsconfig.base.json ./
COPY shared/ shared/
COPY client/ client/
+COPY server/ server/
-# Build shared types (tsc), then client (webpack)
-RUN npm run build -w shared && npm run build -w client
+# Build everything: shared types (tsc) → client (webpack) → server (tsc)
+# All output is platform-independent JS — safe to run on build host's arch.
+RUN npm run build -w shared && npm run build -w client && npm run build -w server
# ---------------------------------------------------------------------------
-# Stage 2: Server builder (target arch — may use QEMU for ARM64)
+# Stage 2: Production dependencies (target arch)
# ---------------------------------------------------------------------------
-FROM dhi.io/node:24-alpine3.23-dev AS builder
+# Runs on target platform so prebuild-install downloads the correct
+# architecture's prebuilt binary for better-sqlite3. No build tools needed —
+# the prebuild is fetched from GitHub Releases, not compiled.
+FROM dhi.io/node:24-alpine3.23-dev AS deps
WORKDIR /app
-# Install build tools for better-sqlite3 native addon compilation
-RUN apk update && apk add --no-cache build-base python3
-
# Copy package files for dependency installation
COPY package.json package-lock.json ./
COPY shared/package.json shared/
@@ -65,30 +68,10 @@ COPY server/package.json server/
COPY client/package.json client/
COPY docs/package.json docs/
-# Install all dependencies (including devDependencies for build).
-# Native addons (better-sqlite3) auto-detect musl libc and compile from
-# source when no matching prebuild is available — no --build-from-source needed.
-RUN --mount=type=cache,target=/root/.npm npm ci
-
-# Stamp the release version into package.json
-ARG APP_VERSION=0.0.0-dev
-RUN npm pkg set "version=${APP_VERSION}"
-
-# Copy pre-built shared types and client bundle from stage 1.
-# shared/tsconfig.json is needed for the server's project reference resolution.
-COPY --from=client-builder /app/shared/dist/ shared/dist/
-COPY --from=client-builder /app/client/dist/ client/dist/
-COPY shared/tsconfig.json shared/
-
-# Copy server source and base tsconfig (needed for tsc)
-COPY tsconfig.base.json ./
-COPY server/ server/
-
-# Build server only (tsc — lightweight, QEMU-safe)
-RUN npm run build -w server
-
-# Remove devDependencies, preserve built artifacts and compiled native addons
-RUN npm prune --omit=dev
+# Install production dependencies only. better-sqlite3's postinstall
+# (prebuild-install) downloads the matching prebuilt .node binary for the
+# target platform — no compilation, no build-base/python3 needed.
+RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# ---------------------------------------------------------------------------
# Stage 3: Production (no shell — exec form only)
@@ -100,8 +83,8 @@ WORKDIR /app/data
WORKDIR /app
# Copy runtime libraries needed by native addons (better-sqlite3 requires libgcc/libstdc++)
-COPY --from=builder /usr/lib/libgcc_s.so.1 /usr/lib/
-COPY --from=builder /usr/lib/libstdc++.so.6* /usr/lib/
+COPY --from=deps /usr/lib/libgcc_s.so.1 /usr/lib/
+COPY --from=deps /usr/lib/libstdc++.so.6* /usr/lib/
# Copy package files (needed for workspace resolution)
COPY package.json ./
@@ -110,18 +93,18 @@ COPY server/package.json server/
COPY client/package.json client/
COPY docs/package.json docs/
-# Copy production node_modules from builder (npm hoists most deps to root,
+# Copy production node_modules from deps (npm hoists most deps to root,
# but some may remain in workspace-specific node_modules due to version constraints)
-COPY --from=builder /app/node_modules/ node_modules/
-COPY --from=builder /app/server/node_modules/ server/node_modules/
+COPY --from=deps /app/node_modules/ node_modules/
+COPY --from=deps /app/server/node_modules/ server/node_modules/
-# Copy built artifacts from builder
-COPY --from=builder /app/shared/dist/ shared/dist/
-COPY --from=builder /app/server/dist/ server/dist/
-COPY --from=builder /app/client/dist/ client/dist/
+# Copy built artifacts from app-builder
+COPY --from=app-builder /app/shared/dist/ shared/dist/
+COPY --from=app-builder /app/server/dist/ server/dist/
+COPY --from=app-builder /app/client/dist/ client/dist/
# Copy SQL migration files (tsc does not copy non-TS assets)
-COPY --from=builder /app/server/src/db/migrations/ server/dist/db/migrations/
+COPY --from=app-builder /app/server/src/db/migrations/ server/dist/db/migrations/
# Expose server port
EXPOSE 3000
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 44008ac55..1b7968377 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -65,6 +65,14 @@ const ProfilePage = lazy(() => import('./pages/ProfilePage/ProfilePage'));
const UserManagementPage = lazy(() => import('./pages/UserManagementPage/UserManagementPage'));
const InvoicesPage = lazy(() => import('./pages/InvoicesPage/InvoicesPage'));
const InvoiceDetailPage = lazy(() => import('./pages/InvoiceDetailPage/InvoiceDetailPage'));
+const DiaryPage = lazy(() => import('./pages/DiaryPage/DiaryPage'));
+const DiaryEntryDetailPage = lazy(
+ () => import('./pages/DiaryEntryDetailPage/DiaryEntryDetailPage'),
+);
+const DiaryEntryCreatePage = lazy(
+ () => import('./pages/DiaryEntryCreatePage/DiaryEntryCreatePage'),
+);
+const DiaryEntryEditPage = lazy(() => import('./pages/DiaryEntryEditPage/DiaryEntryEditPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage'));
export function App() {
@@ -138,6 +146,42 @@ export function App() {
} />
+ {/* Diary section */}
+
+ Loading...
+ ,
+ );
+
+ expect(screen.getByTestId('modal-child')).toBeInTheDocument();
+ expect(screen.getByTestId('modal-child')).toHaveTextContent('Hello from inside');
+ });
+
+ it('renders footer content when footer prop is provided', () => {
+ render(
+
+
+
+ >
+ }
+ />,
+ );
+
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ });
+
+ it('does not render footer section when footer prop is omitted', () => {
+ render();
+
+ // Only the close button should be present — no footer action buttons
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(1);
+ expect(buttons[0]).toHaveAttribute('aria-label', 'Close dialog');
+ });
+
+ // ── Close interactions ────────────────────────────────────────────────────
+
+ it('close button calls onClose when clicked', () => {
+ const onClose = jest.fn<() => void>();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('backdrop click calls onClose', () => {
+ const onClose = jest.fn<() => void>();
+ // Modal uses createPortal into document.body, so content lives in baseElement (body)
+ // not in the render container. Use document.querySelector to find the backdrop.
+ render();
+
+ // The backdrop div uses the shared CSS module class "modalBackdrop"
+ // identity-obj-proxy returns class names as-is, so the class is "modalBackdrop"
+ const backdrop = document.querySelector('[class*="modalBackdrop"]');
+ expect(backdrop).toBeTruthy();
+
+ fireEvent.click(backdrop!);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Escape key calls onClose', () => {
+ const onClose = jest.fn<() => void>();
+ render();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Escape key does NOT call onClose after unmount (handler cleaned up)', () => {
+ const onClose = jest.fn<() => void>();
+ const { unmount } = render();
+
+ unmount();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('non-Escape key does not call onClose', () => {
+ const onClose = jest.fn<() => void>();
+ render();
+
+ fireEvent.keyDown(document, { key: 'Enter' });
+ fireEvent.keyDown(document, { key: 'Tab' });
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ // ── ARIA attributes ───────────────────────────────────────────────────────
+
+ it('dialog container has role="dialog"', () => {
+ render();
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('dialog has aria-modal="true"', () => {
+ render();
+
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
+ });
+
+ it('dialog has aria-labelledby referencing the title element id', () => {
+ render();
+
+ const dialog = screen.getByRole('dialog');
+ const labelledBy = dialog.getAttribute('aria-labelledby');
+ expect(labelledBy).toBeTruthy();
+
+ // The heading with that id should contain the title text
+ const titleEl = document.getElementById(labelledBy!);
+ expect(titleEl).toBeTruthy();
+ expect(titleEl).toHaveTextContent('Test Modal Title');
+ });
+
+ // ── className forwarding ──────────────────────────────────────────────────
+
+ it('forwards className to the content panel', () => {
+ // Modal uses createPortal; content lives in document.body, not the render container
+ render();
+
+ // The content div is the one that gets the extra className alongside the
+ // shared modalContent and local content class names
+ const contentPanel = document.querySelector('[class*="myCustomClass"]');
+ expect(contentPanel).toBeTruthy();
+ });
+
+ // ── Focus management ──────────────────────────────────────────────────────
+
+ it('focuses the close button on mount (first focusable in content panel)', () => {
+ render(
+
+
Non-interactive body
+ ,
+ );
+
+ // contentRef wraps the entire content panel. The close button sits in the
+ // header div — it is always the first focusable element in the panel.
+ // On mount, focus is moved to this button.
+ expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
+ });
+
+ it('focuses the close button even when children contain inputs (close button comes first in DOM)', () => {
+ render(
+
+
+
+ ,
+ );
+
+ // The close button is in the header, which precedes the body in DOM order.
+ // querySelectorAll returns elements in document order, so the close button
+ // is always [0] and receives focus.
+ expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
+ });
+
+ it('does not throw when children have no focusable elements', () => {
+ expect(() => {
+ render(
+
+
+ );
+ }
+
+ // Show initialTitle when value is pre-populated and not yet changed by the user
+ if (initialTitle && value && !selectedItem && !initialTitleCleared) {
+ return (
+
- );
- }
-
- // Show initialTitle when value is pre-populated and not yet changed by the user
- if (initialTitle && value && !selectedItem && !initialTitleCleared) {
- return (
-