diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index e7fb30e98..eb01bb594 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -10,6 +10,8 @@ if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi +payload="$(cat 2>/dev/null || true)" + NODE_VERSION="24.19.0" # Keep in step with the floor in package.json engines.node. A matching major is # not enough: dev dependencies (jsdom) carry a minor-level floor, so a 24.13 on @@ -102,12 +104,22 @@ if [ ! -d node_modules ]; then mkdir -p "$(dirname "$LOCK_STAMP")" echo "$lock_hash" > "$LOCK_STAMP" echo "[session-start] Dependencies installed" + status_msg="Dependencies installed" elif [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$lock_hash" ]; then echo "[session-start] node_modules is stale for the current lockfile, reinstalling" npm ci --no-audit --no-fund mkdir -p "$(dirname "$LOCK_STAMP")" echo "$lock_hash" > "$LOCK_STAMP" echo "[session-start] Dependencies reinstalled" + status_msg="Dependencies reinstalled" else echo "[session-start] node_modules matches the lockfile, skipping install" + status_msg="node_modules matches the lockfile, skipping install" +fi + +if [ -n "${CLAUDE_ENV_FILE:-}" ] || printf '%s' "$payload" | grep -Eq '"(hook_event_name|hookEventName)"[[:space:]]*:[[:space:]]*"SessionStart"'; then + node_v="$(node -v 2>/dev/null || echo 'unknown')" + npm_v="$(npm -v 2>/dev/null || echo 'unknown')" + context="[session-start] Node ${node_v} / npm ${npm_v} ready. ${status_msg}." + printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$context" fi diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 904952ea5..4c783dca9 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "e7c67a0d1984c1af856123ee041e27ec17e15022", - "committed_at": "2026-08-25T21:59:40+08:00" + "sha": "a5d918057c1fb8103f8aa3b57b8c4056aea533c6", + "committed_at": "2026-08-26T00:44:09+08:00" }, "routes": { "modes": [ @@ -2113,6 +2113,11 @@ "section": "root", "catalogued": true }, + { + "path": "docs/continuous-integration.md", + "section": "root", + "catalogued": true + }, { "path": "docs/current-clinical-work-brief.md", "section": "root", @@ -3576,8 +3581,8 @@ } ], "counts": { - "documents": 430, - "catalogued": 101, + "documents": 431, + "catalogued": 102, "uncatalogued": 329, "sections": 18 } diff --git a/docs/README.md b/docs/README.md index 21d605cec..001e4d816 100644 --- a/docs/README.md +++ b/docs/README.md @@ -82,6 +82,7 @@ npm run docs:check-links ## Process and review - [process-hardening.md](process-hardening.md) — verification gates, CI expectations, known debts +- [continuous-integration.md](continuous-integration.md) — workflow concurrency keys, push exemption, and Guard 2 in-flight CI push guard - [testing.md](testing.md) — test execution, focused/live commands, Playwright ownership, flake policy - [phone-chrome-physical-acceptance.md](phone-chrome-physical-acceptance.md) — labelled Safari and cold-launch PWA acceptance matrix - [productivity-workflows.md](productivity-workflows.md) — repo workflow planners (flightplan, triage, rag-lab, …) diff --git a/docs/ci-operations.md b/docs/ci-operations.md index 942e1be4c..11f66da57 100644 --- a/docs/ci-operations.md +++ b/docs/ci-operations.md @@ -1,5 +1,7 @@ # CI Operations and Runner Usage Assessment +See also [continuous-integration.md](continuous-integration.md) for pre-push safety controls and Guard 2 in-flight CI push guard details. + ## Overview and Concurrency Architecture In PR #2209 (merged `af2075a`), GitHub Actions workflow concurrency for base-branch (`main`, `release/**`) pushes was changed to key on `github.run_id`: diff --git a/docs/clinical-hazard-analysis.md b/docs/clinical-hazard-analysis.md index 47a285512..7611fd81b 100644 --- a/docs/clinical-hazard-analysis.md +++ b/docs/clinical-hazard-analysis.md @@ -132,6 +132,8 @@ _Pathway: coverage/confidence/trust gating. Note: the **coverage gate** (`evalua | **H5d** | Hardcoded clozapine / patient-property branches in the coverage gate use looser accept criteria than the generic path ([`rag.ts:3726`](../src/lib/rag/rag.ts)) — inconsistent strictness per drug. | Low–Medium | Medium | downstream gates apply uniformly | none asserts cross-drug parity | Per-drug literals in a safety-adjacent gate. | | **H5e** | Out-of-order response painting the wrong answer under a new question — **controlled**: a monotonic `searchRequestSeqRef` guards every state write incl. streamed progress ([`ClinicalDashboard.tsx`](../src/components/ClinicalDashboard.tsx), audit M10). | High (if it regressed) | Low | request-id guard | none (client React logic) | Minor: superseded stream has no `AbortController` (resource hygiene only). | +### H5: Provenance tags and synthetic summaries + **H5a update (2026-08-17, coordinator review):** the fast-path half of H5a is now partially mitigated — `deriveConfidence` (`src/lib/rag/rag-answer-support.ts`) computes `strongestNonSynthetic` by excluding rows tagged `similarity_origin: "synthetic_text"` and diff --git a/docs/continuous-integration.md b/docs/continuous-integration.md new file mode 100644 index 000000000..f9b5efa84 --- /dev/null +++ b/docs/continuous-integration.md @@ -0,0 +1,48 @@ +# Continuous Integration and Workflow Concurrency + +## Overview and Concurrency Architecture + +The repository enforces robust concurrency controls across GitHub Actions workflows to guarantee complete verification of merged commits and prevent cancellation storms on active pull requests. + +### 1. Base-Branch Push Concurrency (`ci.yml`) + +In `.github/workflows/ci.yml`, workflow concurrency is configured with a per-run group for pushes, schedules, and dispatches, while pull requests use ref-based deduplication: + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'push') && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'push' }} +``` + +- **Per-Run Concurrency Key for Base Branches:** Keying base-branch pushes on `github.run_id` ensures that every merged commit landing on `main` or `release/**` receives independent, isolated CI verification. +- **Queue Eviction Prevention:** GitHub Actions natively limits concurrency groups to at most one pending run. Without the per-run key, rapid merges evict waiting runs from the queue, destroying verification on intermediate commits. +- **Push Exemption from `cancel-in-progress`:** `cancel-in-progress: ${{ github.event_name != 'push' }}` explicitly guarantees that in-flight base-branch validation runs are never terminated by subsequent commits. + +### 2. Eval Canary Concurrency (`eval-canary.yml`) + +The weekly and on-demand evaluation canary runs with a dedicated single-flight group: + +```yaml +concurrency: + group: eval-canary + cancel-in-progress: false +``` + +This prevents multiple live evaluation workflows from overlapping or colliding against the shared live Supabase/OpenAI evaluation test harnesses. + +--- + +## Pre-Push Safety: Guard 2 (In-Flight CI Push Guard) + +To eliminate the anti-pattern where frequent branch syncs (e.g. repeated `git merge origin/main` loops) restart CI and cancel in-flight runs via `cancel-in-progress` (#TF6TPJ, #HSSHRG), `scripts/guard-push.mjs` enforces **Guard 2: in-flight CI push guard**. + +### Mechanism + +1. **Active Run Detection:** When pushing to an open PR branch, `findInFlightCiRuns()` inspects the branch's workflow runs for required CI (`ci.yml`) in active states: + - `pending`, `queued`, `in_progress`, `requested`, `waiting`. +2. **Push Interception:** If a required CI run is already in progress, `inFlightCiVerdict()` blocks the push before local refs are transmitted, logging the active run ID and PR number. +3. **Prevention of False-Red CI:** By preventing superfluous pushes while CI evaluates, Guard 2 ensures test suites complete and prevents `pr-required` from reporting false-red aggregate status caused by self-inflicted cancellations. +4. **Override:** In exceptional circumstances where an immediate force-update is required, set: + ```bash + SKIP_IN_FLIGHT_CI_GUARD=1 git push + ``` diff --git a/docs/launch-operator-runbook.md b/docs/launch-operator-runbook.md index ccb0c4f72..636ccdcfb 100644 --- a/docs/launch-operator-runbook.md +++ b/docs/launch-operator-runbook.md @@ -38,9 +38,12 @@ Legend: **⏸ PAUSE** = provider action, needs your approval · **✅ verify** = ## 0. Pre-flight (read-only) ```bash -npm run check:supabase-project # must report Clinical KB Database / sjrfecxgysukkwxsowpy +node -v # must report >= 24.15.0 < 25 (Node 24 engine floor) +npm -v # must report >= 11.0.0 < 12 (npm 11) +npm run check:runtime # validates Node 24 and npm 11 engines +npm run check:supabase-project # must report Clinical KB Database / sjrfecxgysukkwxsowpy npx supabase migration list --linked -npm run reindex:health # note jobs_pending / jobs_processing (needed for step 1 R17) +npm run reindex:health # note jobs_pending / jobs_processing (needed for step 1 R17) ``` ## 1. Confirm migration state; apply only unresolved controls 🧑 Supabase diff --git a/docs/site-map.md b/docs/site-map.md index cf9aa7825..d93e33df7 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -61,7 +61,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/tools` - Clinical tools and applications launcher directory. Source: `src/app/(search-app)/tools/page.tsx`. - `/ward-management` - Statewide psychiatry ward demand, bed capacity, and patient flow console. Source: `src/app/ward-management/page.tsx`. - `/ward-management/capacity` - Ward bed availability, unit occupancy, and staffing capacity. Source: `src/app/ward-management/capacity/page.tsx`. -- `/ward-management/constellation` - Compatibility redirect to `/ward-management/network`. Phase 2 retired the constellation command view. Source: `src/app/ward-management/constellation/page.tsx`. +- `/ward-management/constellation` - Intentional, unlinked backwards-compatibility redirect to `/ward-management/network` (pointing to the current Ward Management home). Phase 2 retired the constellation command view. Source: `src/app/ward-management/constellation/page.tsx`. - `/ward-management/ed/[edId]` - Synthetic emergency-department role screen for one origin department. Source: `src/app/ward-management/ed/[edId]/page.tsx`. - `/ward-management/exceptions` - Patient flow exceptions, delays, and escalation alerts. Source: `src/app/ward-management/exceptions/page.tsx`. - `/ward-management/governance` - Ward coordination governance, compliance, and audit log. Source: `src/app/ward-management/governance/page.tsx`. diff --git a/docs/testing.md b/docs/testing.md index ca4f3a2bc..8e572d75a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -278,6 +278,22 @@ downloadable expected/actual/diff artifact instead of a failed check. Missing ba runtime/assertion, and artifact-publication failures remain visible as job failures because those runs produced no trustworthy comparison evidence. +**Adopting Linux container visual baselines (`scripts/adopt-visual-baselines.mjs`).** +When document viewer layout changes (e.g. on-demand search, closed composer clearance changes), shell, or +therapy compass views update, visual baseline changes must be adopted directly from the Linux container CI +artifact (`visual-baseline-`) rather than generated locally on Windows/macOS. Run: + +```bash +node scripts/adopt-visual-baselines.mjs \ + --from \ + --run-id \ + --head <40-char-sha> \ + --reviewed-by "" \ + --write +``` + +This updates the authoritative Linux baselines under `tests/__screenshots__/linux/` and regenerates `tests/__screenshots__/linux/provenance.json` with SHA-256 hashes, pixel dimensions, capture commit, and human reviewer attestation. + ## Performance budget `npm run verify:lighthouse` builds and serves an isolated production app in demo mode, measures the diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md index 212b81f5b..d4fad6653 100644 --- a/docs/worker-deploy-runbook.md +++ b/docs/worker-deploy-runbook.md @@ -36,6 +36,9 @@ _"apply before worker redeploy"_) for the ordered apply plan. Confirm the gate before continuing: ```bash +node -v # must report >= 24.15.0 < 25 (Node 24 engine floor) +npm -v # must report >= 11.0.0 < 12 (npm 11) +npm run check:runtime # validates Node 24 and npm 11 engines npm run reindex:health # ok:true, and the RPC signatures accept p_worker_id ``` diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 221bb48f0..858a64242 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -157,7 +157,7 @@ const routeDescriptions: Record = { "/ward-management": "Statewide psychiatry ward demand, bed capacity, and patient flow console.", "/ward-management/capacity": "Ward bed availability, unit occupancy, and staffing capacity.", "/ward-management/constellation": - "Compatibility redirect to `/ward-management/network`. Phase 2 retired the constellation command view.", + "Intentional, unlinked backwards-compatibility redirect to `/ward-management/network` (pointing to the current Ward Management home). Phase 2 retired the constellation command view.", "/ward-management/ed/[edId]": "Synthetic emergency-department role screen for one origin department.", "/ward-management/exceptions": "Patient flow exceptions, delays, and escalation alerts.", "/ward-management/governance": "Ward coordination governance, compliance, and audit log.", diff --git a/tests/session-start-hook.test.ts b/tests/session-start-hook.test.ts index 15eb8e2a4..b4fd54370 100644 --- a/tests/session-start-hook.test.ts +++ b/tests/session-start-hook.test.ts @@ -186,6 +186,43 @@ describe("session-start hook", () => { expect(result.status).toBe(0); expect(result.stdout.trim()).toBe(""); }); + + it("emits valid JSON on stdout matching SessionStart schema when running in hook mode", () => { + const { home, project, hook } = stubEnvironment(); + const envFile = join(project, "claude-env"); + writeFileSync(envFile, ""); + + const payload = JSON.stringify({ hook_event_name: "SessionStart" }); + const result = spawnSync(bashCommand, [hook.replace(/\\/g, "/")], { + cwd: project, + env: { + ...process.env, + HOME: home.replace(/\\/g, "/"), + CLAUDE_CODE_REMOTE: "true", + CLAUDE_ENV_FILE: envFile.replace(/\\/g, "/"), + CLAUDE_PROJECT_DIR: project.replace(/\\/g, "/"), + } as NodeJS.ProcessEnv, + encoding: "utf8", + input: payload, + }); + + expect(result.status, `hook exited ${result.status}: ${result.stderr}`).toBe(0); + expect(result.stderr).toBe(""); + + const jsonLine = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith('{"hookSpecificOutput"')); + + expect(jsonLine, "expected hook to emit hookSpecificOutput JSON on stdout").toBeDefined(); + const parsed = JSON.parse(jsonLine!); + expect(parsed).toEqual({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: expect.stringContaining("[session-start]"), + }, + }); + }); }); describe("precompact observability hook", () => { diff --git a/tests/verify-phone-chrome.test.ts b/tests/verify-phone-chrome.test.ts index e73f7d16b..fe1b21bb8 100644 --- a/tests/verify-phone-chrome.test.ts +++ b/tests/verify-phone-chrome.test.ts @@ -9,6 +9,31 @@ const ids = (files: string[], fullMode: "auto" | "always" | "never" = "auto") => const stage = (files: string[], id: string) => phoneChromePlan(files).stages.find((candidate) => candidate.id === id); +const PHONE_CHROME_EXECUTED_CONTRACT_COUNT = 135; + +function countExecutedVitestCases(source: string): number { + const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + const pattern = /(?:^|\n)\s*(?:it|test)(?:\.each\s*\((?[\s\S]*?)\))?\s*\(/g; + let count = 0; + for (const match of code.matchAll(pattern)) { + const table = match.groups?.table?.trim(); + if (!table) { + count += 1; + continue; + } + if (!table.startsWith("[")) { + count += 1; + continue; + } + count += table + .slice(1, table.endsWith("]") ? -1 : undefined) + .split(",") + .map((part) => part.trim()) + .filter(Boolean).length; + } + return count; +} + describe("phoneChromePlan", () => { it("keeps documentation-only work out of browser suites", () => { expect(ids(["docs/phone-chrome-physical-acceptance.md"])).toEqual(["docs-index", "docs-links"]); @@ -119,6 +144,20 @@ describe("phoneChromePlan", () => { expect(plan.notes.join(" ")).not.toContain("No phone-chrome-affecting file was detected"); }, ); + + it("maintains the complete 9-suite phone-chrome contract baseline with 135 executed contracts", () => { + const plan = phoneChromePlan(["tests/header-scroll-hide-contract.test.ts"]); + const contractStage = plan.stages.find((candidate) => candidate.id === "contracts"); + expect(contractStage).toBeDefined(); + const contractFiles = (contractStage?.command.args as string[]).filter((arg) => arg.startsWith("tests/")); + expect(contractFiles).toHaveLength(9); + const executedContracts = contractFiles.reduce((total, file) => { + const cases = countExecutedVitestCases(readFileSync(resolve(process.cwd(), file), "utf8")); + expect(cases, `${file} must declare at least one executed contract`).toBeGreaterThan(0); + return total + cases; + }, 0); + expect(executedContracts).toBe(PHONE_CHROME_EXECUTED_CONTRACT_COUNT); + }); }); describe("runPhoneChromeStages", () => {