From 908cba5639862ada49177cc4ad74943c7c60dc2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 22:07:04 +0800 Subject: [PATCH 01/28] =?UTF-8?q?docs:=20add=20agent=20team=20design=20spe?= =?UTF-8?q?c=20=E2=80=94=20Claude=20Code=20orchestrator=20+=20OpenCode=20K?= =?UTF-8?q?imi=20workers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifies the multi-agent development workflow for Bantayog Alert Phases 6–12: task-level DAG dependencies, lockfile serialization, machine-verifiable two-stage quality gates, collective merge strategy, circuit breaker, suspicion-scored human-in-the-loop gate, and TERMINAL_FAILURE escalation protocol. Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-24-agent-team-design.md | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-24-agent-team-design.md diff --git a/docs/superpowers/specs/2026-04-24-agent-team-design.md b/docs/superpowers/specs/2026-04-24-agent-team-design.md new file mode 100644 index 00000000..c71598aa --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-agent-team-design.md @@ -0,0 +1,425 @@ +# Agent Team Design — Claude Code Orchestrator + OpenCode Kimi Workers + +**Date:** 2026-04-24 +**Status:** Approved +**Scope:** Multi-agent development workflow for Bantayog Alert (Phases 6–12) + +--- + +## 1. Overview + +This document specifies the multi-agent development workflow for Bantayog Alert. Claude Code (Claude Sonnet 4.6) acts as the orchestrator. OpenCode Exxeed agents running Kimi models act as implementation workers. The PRD and architecture spec are the authoritative source of truth for all task decomposition. + +**What this system is:** + +- A structured way to parallelize implementation work across PRD phases +- A quality-gated pipeline with machine-verifiable checks +- A human-in-the-loop escalation path for failures that exceed agent capability + +**What this system is not:** + +- A replacement for human judgment on architecture decisions +- A fully autonomous deployment pipeline (no agent may deploy to any environment) +- A way to skip the two-stage review gate + +--- + +## 2. Roles + +### Claude Code (Orchestrator) + +Runs as the primary Claude Code session. Responsible for: + +- Reading the PRD and current `docs/progress.md` at the start of each phase +- Decomposing phases into tasks scoped to package boundaries +- Writing task briefs to `docs/agent-tasks/YYYY-MM-DD-[task-slug].md` +- Maintaining the DAG at `docs/agent-tasks/dag.json` +- Creating git worktrees for each task +- Spawning OpenCode Exxeed workers via `Bash run_in_background` +- Running Stage 1 and Stage 2 gates (automated scripts) +- Applying suspicion scoring before merging to `main` +- Logging all agent runs to `docs/agent-tasks/telemetry.jsonl` +- Writing escalation artifacts on terminal failure + +### OpenCode Exxeed Workers + +Invoked via `opencode run` with the `--agent exxeed` flag. Each worker: + +- Receives the full task brief as the run message +- Follows the Exxeed 4-phase workflow: Spec Ingestion → Implementation Plan → Implementation → Verification +- Writes a prose handoff to `.claude/plans/exxeed-[slug]-report.md` +- Writes a machine-readable result to `.claude/plans/exxeed-[slug]-result.json` +- Exits — does not merge, deploy, or open PRs + +### Git Worktrees + +One worktree per task, located at `../bantayog-wt-[task-slug]`, on branch `agent/[task-slug]`. Workers operate exclusively within their worktree. They cannot see or affect sibling worktrees. + +--- + +## 3. Models + +| Task type | Model | +| ----------------------------------------------------- | ---------------------------------- | +| Standard implementation (callables, UI, hooks) | `kimi-for-coding/k2p6` | +| Schema design, Firestore rules, algorithm-heavy tasks | `kimi-for-coding/kimi-k2-thinking` | + +The model is specified in the task brief and passed to `opencode run --model`. + +--- + +## 4. Task Brief Format + +Saved to `docs/agent-tasks/YYYY-MM-DD-[task-slug].md`: + +```markdown +# Agent Task: [task-slug] + +Date: YYYY-MM-DD +Phase: [PRD phase number] +Model: kimi-for-coding/k2p6 +Worktree: ../bantayog-wt-[task-slug] +Branch: agent/[task-slug] +Modifies lockfile: false + +## Objective + +[One sentence — what this task produces] + +## Spec references + +- docs/superpowers/specs/[relevant-design.md] +- prd/bantayog-alert-prd-v1.0.md §[section] + +## Requirements + +R01: [functional requirement] +R02: [functional requirement] +R03: [constraint — e.g., "do not touch firestore.rules directly, use scripts/build-rules.ts"] + +## Files to create + +- packages/shared-validators/src/[file].ts — [purpose] + +## Files to modify + +- packages/shared-validators/src/index.ts — re-export new schema + +## Files NOT to touch + +- [everything outside this task's package boundary] +- [list explicitly — this is enforced by Stage 1] + +## Verification command + +pnpm --filter @bantayog/shared-validators test && pnpm --filter @bantayog/shared-validators typecheck + +## Blocks (cannot start until this task merges) + +- [task-slug-B] + +## Blocked by (must merge before this task starts) + +- [task-slug-A] +``` + +--- + +## 5. Machine-Readable Result Format + +Workers write `.claude/plans/exxeed-[slug]-result.json` before exiting: + +```json +{ + "task": "[task-slug]", + "verification_exit_code": 0, + "verification_command": "pnpm --filter @bantayog/functions test", + "files_changed": ["functions/src/callables/telemetry.ts"], + "requirements_satisfied": ["R01", "R02", "R03"], + "open_items": [], + "baseline": "47 passing, 0 failing", + "final": "49 passing, 0 failing", + "discovered_required_files": [] +} +``` + +`discovered_required_files` is populated if the agent found a file outside the allowed list that is genuinely required (not a scope violation). Stage 1 fails on a non-empty list, but Claude Code may update the task brief and respawn rather than treating it as a hard failure. + +--- + +## 6. Dependency Graph + +Claude Code maintains `docs/agent-tasks/dag.json` for each phase: + +```json +{ + "T1": { "blocks": ["T2", "T3", "T4"], "modifies_lockfile": false }, + "T2": { "blocks": ["T6"], "modifies_lockfile": false }, + "T3": { "blocks": ["T5"], "modifies_lockfile": true }, + "T4": { "blocks": ["T5"], "modifies_lockfile": true }, + "T5": { "blocks": ["T6"], "modifies_lockfile": false }, + "T6": { "blocks": [], "modifies_lockfile": false } +} +``` + +**Spawn rules:** + +- `blocked_by` for any task is the inverse of `blocks` — computed at runtime by Claude Code, not stored in the file. T1 blocks T2 means T2 is blocked_by T1. +- A task spawns when all tasks in its `blocked_by` set have merged to the phase staging branch. +- Two tasks with `modifies_lockfile: true` may never run in parallel. The second waits for the first to merge. +- A `lockfile-reconcile` task (no code changes, runs `pnpm install`) is appended after any merge group that contained a `modifies_lockfile` task. + +**Layer ordering (general rule):** + +``` +L0 — shared-validators schemas and types (no deps) +L1 — Firestore rules, functions/callables (needs L0) +L2 — apps (admin-desktop, citizen-pwa, (needs L1 for the package it consumes) + responder-app) +L3 — E2E tests, acceptance harness (needs L2) +``` + +Task briefs override this with explicit edges when the actual dependency is finer-grained. + +--- + +## 7. Invocation + +Claude Code spawns a worker: + +```bash +opencode run "$(cat docs/agent-tasks/YYYY-MM-DD-[task-slug].md)" \ + --agent exxeed \ + --model kimi-for-coding/k2p6 \ + --dir ../bantayog-wt-[task-slug] \ + --dangerously-skip-permissions +``` + +Workers run via `Bash run_in_background: true` so Claude Code can spawn multiple in parallel without blocking. + +**Worktree setup (before spawning):** + +```bash +git worktree add ../bantayog-wt-[task-slug] -b agent/[task-slug] +``` + +--- + +## 8. Merge Strategy — Phase Staging Branch + +Parallel tasks never merge directly to `main`. All tasks in a phase merge to a phase staging branch first: + +``` +main + └── phase/[N]-[description] + ├── agent/T2 + ├── agent/T3 + └── agent/T4 +``` + +When **all** tasks in a merge group pass both gates, the staging branch merges to `main` as a single PR. If any task fails terminally, the staging branch is deleted — no partial state lands on `main`. + +--- + +## 9. Quality Gates + +### Stage 1 — Artifact Verification (per task) + +Runs immediately after a worker exits, before the branch is merged to the staging branch. + +**Script:** `scripts/agent-gate-stage1.sh ` + +Checks: + +1. `exxeed-[slug]-result.json` exists and is valid JSON +2. `verification_exit_code` is `0` +3. `git diff --name-only main` output in the worktree contains only files listed in the task brief's `files_to_create` and `files_to_modify` fields +4. `discovered_required_files` is empty (if non-empty, fail-open: Claude Code reviews and may update brief + respawn) +5. `open_items` contains no entries marked `❌` + +Exit 0 = pass. Exit 1 = fail. No prose parsing. + +### Stage 2 — Code Quality (per task, then combined) + +**Runs twice:** + +**Run A — per-task** on the worktree before merging to the staging branch: + +```bash +# scripts/agent-gate-stage2.sh +pnpm --filter $1 lint -- --max-warnings=$(cat .lint-baseline) && +pnpm --filter $1 typecheck && +pnpm --filter $1 test -- --coverage && +if [[ "$1" == *"@bantayog/functions"* ]]; then + firebase emulators:exec --only firestore,database,storage \ + "pnpm --filter $1 test:rules" +fi && +scripts/check-no-any.sh $1 && +scripts/check-no-empty-catch.sh $1 && +scripts/check-lockfile-integrity.sh +``` + +The `test:rules` step only runs when the filter includes `@bantayog/functions`. The `if` block exits non-zero on test failure — `|| true` is intentionally absent. + +**Run B — combined staging branch** after all parallel tasks have merged to the staging branch, before opening the PR to `main`: + +```bash +# scripts/agent-gate-stage2-combined.sh +git checkout $1 +npx turbo run lint typecheck test --affected && +firebase emulators:exec --only firestore,database,storage \ + "pnpm --filter @bantayog/functions exec vitest run src/__tests__/rules" && +scripts/check-lockfile-integrity.sh +``` + +This catches cross-task issues (duplicate imports, type errors that only appear when both T2 and T5 are present together). + +### Lint Baseline + +`.lint-baseline` is a checked-in file containing the current warning count from `main`: + +```bash +# Generate once, commit to main: +pnpm lint 2>&1 | grep -c "warning" > .lint-baseline +``` + +Stage 2 uses `--max-warnings=$(cat .lint-baseline)` so tasks fail only on _new_ warnings, not pre-existing ones. + +--- + +## 10. Circuit Breaker & Retry + +``` +Attempt 1 → Stage 1 or Stage 2 fails + └── Claude Code writes targeted correction brief + (specific violations only — not a full re-statement of the original brief) + └── Fresh worktree created: ../bantayog-wt-[slug]-retry-1 + (not --continue; clean context prevents compounding hallucinated state) + └── Agent respawned + +Attempt 2 → Stage 1 or Stage 2 fails again + └── Claude Code attempts direct fix on the worktree + +Claude Code fix → Stage 2 still fails + └── TERMINAL_FAILURE: escalate to human, no further auto-retry +``` + +--- + +## 11. Human-in-the-Loop Gate + +Computed before merging the staging branch to `main`: + +| Signal | Score | +| ---------------------------------------------------- | -------------------- | +| Total files changed > 5 | +2 | +| Total lines changed > 100 | +1 | +| Any file outside `allowed_files` detected at Stage 1 | +5 (immediate block) | +| Firestore rules or `firestore.indexes.json` touched | +3 | +| Any task passed on attempt 2+ | +2 | +| Any `discovered_required_files` entries accepted | +1 | + +**Score ≥ 3:** Claude Code posts a summary (files changed, gate results, suspicion score, diff stat) and waits for an explicit `proceed` from the user before merging. + +**Score < 3:** Claude Code merges automatically. + +Firestore rules changes always score ≥ 3 by definition and always require human approval. + +--- + +## 12. Observability + +Every agent run appends one line to `docs/agent-tasks/telemetry.jsonl`: + +```jsonl +{ + "ts": "2026-04-24T10:00:00Z", + "phase": 6, + "task": "T3", + "model": "kimi-for-coding/k2p6", + "agent": "exxeed", + "attempt": 1, + "stage1": "PASS", + "stage2": "FAIL", + "duration_sec": 420, + "files_changed": 3, + "lines_changed": 87 +} +``` + +Claude Code writes this entry after each gate run, not at agent exit. The telemetry file is committed to `main` at the end of each phase. + +--- + +## 13. Escalation — TERMINAL_FAILURE + +When the circuit breaker reaches terminal state, Claude Code: + +1. **Writes** `.claude/escalations/YYYY-MM-DD-[task-slug]-terminal.md` containing: + - The telemetry entries for all attempts (from `telemetry.jsonl`) + - The full `git diff` from the last failed worktree + - The original task brief + - All correction briefs written during retry + - A one-paragraph diagnosis of what the agent failed to do and why Claude Code's direct fix also failed + +2. **Opens a GitHub issue** with: + - Title: `[terminal-failure] Phase N — [task-slug]` + - Label: `terminal-failure` + - Body: link to the escalation file + one-paragraph summary + +3. **Stops** — does not retry, does not attempt a workaround, does not merge partial work. + +The human receives: escalation file path, GitHub issue link, and the exact `git worktree` path where the failed state lives (not deleted until human resolves). + +--- + +## 14. Phase Workflow Summary + +``` +1. Claude Code reads PRD phase + progress.md +2. Claude Code decomposes into tasks, writes task briefs, writes dag.json +3. Claude Code creates phase staging branch: git checkout -b phase/N-description +4. For each task whose blocked_by set is empty: + a. git worktree add ../bantayog-wt-[slug] -b agent/[slug] + b. opencode run (background) → worker runs Exxeed 4-phase workflow + c. Worker exits → Stage 1 gate runs + d. Stage 1 pass → Stage 2 Run A gate runs + e. Both pass → merge agent/[slug] into staging branch + f. Newly unblocked tasks → repeat from step 4 +5. All tasks merged to staging branch +6. lockfile-reconcile task runs (pnpm install) +7. Stage 2 Run B (combined) runs on staging branch +8. Suspicion score computed +9. Score < 3 → merge staging → main (single PR) + Score ≥ 3 → post summary, wait for human proceed +10. Worktrees deleted, telemetry.jsonl committed, progress.md updated +``` + +--- + +## 15. Example: Phase 6 — Responder App Telemetry + +**Tasks and DAG:** + +``` +T1 (schemas, kimi-k2-thinking) + → T2 (firestore rules, kimi-k2-thinking) ──────────────────────> T6 (e2e) + → T3 (functions/callables, kimi-k2p6, modifies_lockfile) ↑ + → T4 (capacitor native setup, kimi-k2p6, modifies_lockfile) ──> T5 (hooks + race-loss UI) ──┘ + (needs T1, T3, T4) +``` + +T3 and T4 both modify lockfiles → serialized. T2 has no lockfile changes → runs parallel to whichever of T3/T4 is active. + +**Execution sequence:** + +``` +Spawn: T1 (alone — no deps, sets baseline) +T1 merges → Spawn: T2, T3 in parallel + T3 completes → lockfile-reconcile → T4 spawns + T2 completes (no lockfile conflict with T4) +T4 merges → lockfile-reconcile → T5 spawns + (T3 must also be merged before T5 starts) +T5 merges → T6 spawns +T6 merges → Stage 2 Run B → suspicion score → PR to main +``` From 0d5508101b8ed60ad4dd53db7156b35487409783 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 22:23:27 +0800 Subject: [PATCH 02/28] =?UTF-8?q?docs(agent-team):=20v2.0=20spec=20?= =?UTF-8?q?=E2=80=94=20address=2017=20production=20failure=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive rewrite incorporating all gaps from adversarial review: - Companion JSON per task (machine-readable metadata, ends markdown parsing fragility) - Stage 1 reruns verification_command independently (agents can't self-report success) - Base commit SHA pinned at worktree creation (no floating main diff target) - files_to_delete field + DAG blocks/blocked_by symmetry check - Per-package lint baselines (.lint-baselines.json) - Timeout field + MAX_PARALLEL_AGENTS=3 semaphore - Explicit merge conflict strategy (lockfiles: -X theirs; code: abort + escalate) - Terminal failure scope: task-scoped, cancels phase, preserves all worktrees - Firestore rules score: +5 immediate block (was +3, ambiguous) - Secrets scan (check-secrets.sh) in both Stage 2 Run A and Run B - Single source of truth: companion JSON for modifies_lockfile, dag.json generated from it - Emulator Stage 2 steps serialized to prevent port collisions - Lockfile reconcile runs immediately after each modifies_lockfile task (not phase end) - Phase state persistence (phase-state.json) for crash recovery - Telemetry committed after every task (not phase end) - Claude Code self-telemetry (orchestrator_action events) - Prerequisites section Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-24-agent-team-design.md | 491 +++++++++++------- 1 file changed, 308 insertions(+), 183 deletions(-) diff --git a/docs/superpowers/specs/2026-04-24-agent-team-design.md b/docs/superpowers/specs/2026-04-24-agent-team-design.md index c71598aa..0714b7dd 100644 --- a/docs/superpowers/specs/2026-04-24-agent-team-design.md +++ b/docs/superpowers/specs/2026-04-24-agent-team-design.md @@ -1,19 +1,37 @@ # Agent Team Design — Claude Code Orchestrator + OpenCode Kimi Workers **Date:** 2026-04-24 +**Version:** 2.0 **Status:** Approved **Scope:** Multi-agent development workflow for Bantayog Alert (Phases 6–12) --- +## 0. Prerequisites + +The following must be true before the workflow runs: + +| Prerequisite | Check | +| ------------------------------------------------------------- | ---------------------------------------------------------------- | +| `opencode` CLI installed and authenticated | `opencode providers list` shows Kimi credentials | +| `gh` CLI installed and authenticated | `gh auth status` succeeds | +| `git worktree` available | `git --version` ≥ 2.5 | +| `.gitignore` covers `.env*`, `*.key`, `service-account*.json` | Verify before first phase | +| `turbo.json` `--affected` graph is accurate | Run `npx turbo run lint --dry-run` and confirm affected packages | +| All tests on `main` are currently deterministic | Run full suite twice; both runs must produce the same result | + +If any prerequisite fails, stop and fix it before spawning agents. + +--- + ## 1. Overview -This document specifies the multi-agent development workflow for Bantayog Alert. Claude Code (Claude Sonnet 4.6) acts as the orchestrator. OpenCode Exxeed agents running Kimi models act as implementation workers. The PRD and architecture spec are the authoritative source of truth for all task decomposition. +Claude Code (Claude Sonnet 4.6) acts as orchestrator. OpenCode Exxeed agents running Kimi models act as implementation workers. The PRD and architecture spec are the authoritative source of truth for all task decomposition. **What this system is:** - A structured way to parallelize implementation work across PRD phases -- A quality-gated pipeline with machine-verifiable checks +- A quality-gated pipeline with machine-verifiable checks at every stage - A human-in-the-loop escalation path for failures that exceed agent capability **What this system is not:** @@ -28,59 +46,45 @@ This document specifies the multi-agent development workflow for Bantayog Alert. ### Claude Code (Orchestrator) -Runs as the primary Claude Code session. Responsible for: +Responsible for: reading the PRD and `docs/progress.md`, decomposing phases into tasks, writing task artifacts, creating worktrees, managing the spawn semaphore, running all gate scripts, merging branches in dependency order, writing telemetry (including its own actions), and escalating on terminal failure. -- Reading the PRD and current `docs/progress.md` at the start of each phase -- Decomposing phases into tasks scoped to package boundaries -- Writing task briefs to `docs/agent-tasks/YYYY-MM-DD-[task-slug].md` -- Maintaining the DAG at `docs/agent-tasks/dag.json` -- Creating git worktrees for each task -- Spawning OpenCode Exxeed workers via `Bash run_in_background` -- Running Stage 1 and Stage 2 gates (automated scripts) -- Applying suspicion scoring before merging to `main` -- Logging all agent runs to `docs/agent-tasks/telemetry.jsonl` -- Writing escalation artifacts on terminal failure +Claude Code writes a telemetry entry for every action it takes, not just agent outcomes. See Section 12. ### OpenCode Exxeed Workers -Invoked via `opencode run` with the `--agent exxeed` flag. Each worker: +Invoked via `opencode run`. Each worker: -- Receives the full task brief as the run message +- Receives the full task brief markdown as the run message - Follows the Exxeed 4-phase workflow: Spec Ingestion → Implementation Plan → Implementation → Verification - Writes a prose handoff to `.claude/plans/exxeed-[slug]-report.md` - Writes a machine-readable result to `.claude/plans/exxeed-[slug]-result.json` -- Exits — does not merge, deploy, or open PRs +- Exits — does not merge, deploy, commit to the staging branch, or open PRs ### Git Worktrees -One worktree per task, located at `../bantayog-wt-[task-slug]`, on branch `agent/[task-slug]`. Workers operate exclusively within their worktree. They cannot see or affect sibling worktrees. +One worktree per task, at `../bantayog-wt-[slug]`, on branch `agent/[slug]`. Workers cannot see or affect sibling worktrees. --- ## 3. Models -| Task type | Model | -| ----------------------------------------------------- | ---------------------------------- | -| Standard implementation (callables, UI, hooks) | `kimi-for-coding/k2p6` | -| Schema design, Firestore rules, algorithm-heavy tasks | `kimi-for-coding/kimi-k2-thinking` | +| Task type | Model | +| ----------------------------------------------- | ---------------------------------- | +| Standard implementation (callables, UI, hooks) | `kimi-for-coding/k2p6` | +| Schema design, Firestore rules, algorithm-heavy | `kimi-for-coding/kimi-k2-thinking` | -The model is specified in the task brief and passed to `opencode run --model`. +Model is specified in the companion JSON and passed to `opencode run --model`. --- -## 4. Task Brief Format +## 4. Task Artifacts (Two Files Per Task) -Saved to `docs/agent-tasks/YYYY-MM-DD-[task-slug].md`: +Each task produces two files with the same basename: -```markdown -# Agent Task: [task-slug] +### 4a. Human-Readable Brief — `docs/agent-tasks/YYYY-MM-DD-[slug].md` -Date: YYYY-MM-DD -Phase: [PRD phase number] -Model: kimi-for-coding/k2p6 -Worktree: ../bantayog-wt-[task-slug] -Branch: agent/[task-slug] -Modifies lockfile: false +```markdown +# Agent Task: [slug] ## Objective @@ -97,44 +101,53 @@ R01: [functional requirement] R02: [functional requirement] R03: [constraint — e.g., "do not touch firestore.rules directly, use scripts/build-rules.ts"] -## Files to create - -- packages/shared-validators/src/[file].ts — [purpose] - -## Files to modify - -- packages/shared-validators/src/index.ts — re-export new schema - ## Files NOT to touch -- [everything outside this task's package boundary] -- [list explicitly — this is enforced by Stage 1] - -## Verification command +- [explicitly list adjacent files that are out of scope] +- [Stage 1 enforces this list against the companion JSON's allowed_files] +``` -pnpm --filter @bantayog/shared-validators test && pnpm --filter @bantayog/shared-validators typecheck +The brief is for the agent to read. All machine-parsed fields live in the companion JSON. -## Blocks (cannot start until this task merges) +### 4b. Companion JSON — `docs/agent-tasks/YYYY-MM-DD-[slug].json` -- [task-slug-B] +```json +{ + "slug": "p6-t3-functions-telemetry", + "phase": 6, + "model": "kimi-for-coding/k2p6", + "pnpm_filter": "@bantayog/functions", + "timeout_minutes": 30, + "modifies_lockfile": true, + "base_commit": "", + "allowed_files": { + "create": ["functions/src/callables/telemetry.ts"], + "modify": ["functions/src/index.ts"], + "delete": [] + }, + "verification_command": "pnpm --filter @bantayog/functions test && pnpm --filter @bantayog/functions typecheck", + "blocks": ["p6-t5"], + "blocked_by": ["p6-t1"] +} +``` -## Blocked by (must merge before this task starts) +`base_commit` is empty when written by Claude Code and filled in at worktree creation time. `blocked_by` is the explicit inverse of `blocks` — both fields are written together so there is no ambiguity. `dag.json` is generated from the companion JSONs, not maintained separately. -- [task-slug-A] -``` +**Single source of truth rule:** If `modifies_lockfile` in a companion JSON ever differs from the derived value in `dag.json`, Stage 1 fails immediately. --- ## 5. Machine-Readable Result Format -Workers write `.claude/plans/exxeed-[slug]-result.json` before exiting: +Workers write `.claude/plans/exxeed-[slug]-result.json`: ```json { - "task": "[task-slug]", + "task": "p6-t3-functions-telemetry", "verification_exit_code": 0, - "verification_command": "pnpm --filter @bantayog/functions test", + "verification_command": "pnpm --filter @bantayog/functions test && pnpm --filter @bantayog/functions typecheck", "files_changed": ["functions/src/callables/telemetry.ts"], + "files_deleted": [], "requirements_satisfied": ["R01", "R02", "R03"], "open_items": [], "baseline": "47 passing, 0 failing", @@ -143,81 +156,100 @@ Workers write `.claude/plans/exxeed-[slug]-result.json` before exiting: } ``` -`discovered_required_files` is populated if the agent found a file outside the allowed list that is genuinely required (not a scope violation). Stage 1 fails on a non-empty list, but Claude Code may update the task brief and respawn rather than treating it as a hard failure. +`discovered_required_files`: populated if the agent found a file outside `allowed_files` that is genuinely required (not a scope violation). Stage 1 fails-open on a non-empty list — Claude Code reviews and may update `allowed_files` in the companion JSON and respawn, rather than treating it as a hard failure. --- ## 6. Dependency Graph -Claude Code maintains `docs/agent-tasks/dag.json` for each phase: +Claude Code generates `docs/agent-tasks/dag.json` from companion JSONs at phase start: ```json { - "T1": { "blocks": ["T2", "T3", "T4"], "modifies_lockfile": false }, - "T2": { "blocks": ["T6"], "modifies_lockfile": false }, - "T3": { "blocks": ["T5"], "modifies_lockfile": true }, - "T4": { "blocks": ["T5"], "modifies_lockfile": true }, - "T5": { "blocks": ["T6"], "modifies_lockfile": false }, - "T6": { "blocks": [], "modifies_lockfile": false } + "p6-t1": { "blocks": ["p6-t2", "p6-t3", "p6-t4"], "blocked_by": [], "modifies_lockfile": false }, + "p6-t2": { "blocks": ["p6-t6"], "blocked_by": ["p6-t1"], "modifies_lockfile": false }, + "p6-t3": { "blocks": ["p6-t5"], "blocked_by": ["p6-t1"], "modifies_lockfile": true }, + "p6-t4": { "blocks": ["p6-t5"], "blocked_by": ["p6-t1"], "modifies_lockfile": true }, + "p6-t5": { "blocks": ["p6-t6"], "blocked_by": ["p6-t3", "p6-t4"], "modifies_lockfile": false }, + "p6-t6": { "blocks": [], "blocked_by": ["p6-t2", "p6-t5"], "modifies_lockfile": false } } ``` **Spawn rules:** -- `blocked_by` for any task is the inverse of `blocks` — computed at runtime by Claude Code, not stored in the file. T1 blocks T2 means T2 is blocked_by T1. - A task spawns when all tasks in its `blocked_by` set have merged to the phase staging branch. - Two tasks with `modifies_lockfile: true` may never run in parallel. The second waits for the first to merge. -- A `lockfile-reconcile` task (no code changes, runs `pnpm install`) is appended after any merge group that contained a `modifies_lockfile` task. +- Lockfile reconciliation (`pnpm install`) runs **immediately** after each `modifies_lockfile` task merges to staging — not at phase end. This ensures downstream tasks start from a valid lockfile state. -**Layer ordering (general rule):** +**Layer ordering (general guidance — task-level edges override):** ``` -L0 — shared-validators schemas and types (no deps) -L1 — Firestore rules, functions/callables (needs L0) -L2 — apps (admin-desktop, citizen-pwa, (needs L1 for the package it consumes) - responder-app) -L3 — E2E tests, acceptance harness (needs L2) +L0 — shared-validators schemas and types +L1 — Firestore rules, functions/callables +L2 — apps (admin-desktop, citizen-pwa, responder-app) +L3 — E2E tests, acceptance harness ``` -Task briefs override this with explicit edges when the actual dependency is finer-grained. - --- ## 7. Invocation -Claude Code spawns a worker: +### Pre-spawn checks + +Before spawning any agent, Claude Code: + +1. Verifies `opencode` is reachable: `opencode --version` +2. Confirms the worktree path is inside the project root — not above `$HOME` or in any path containing `.ssh`, `.gnupg`, `.config`, or system directories +3. Confirms no existing worktree at `../bantayog-wt-[slug]` (stale from a previous crash — see Section 14 on restart) +4. Records the base commit: `BASE_SHA=$(git rev-parse main)` and writes it to `base_commit` in the companion JSON + +### Spawn command ```bash -opencode run "$(cat docs/agent-tasks/YYYY-MM-DD-[task-slug].md)" \ +BASE_SHA=$(git rev-parse main) +# write BASE_SHA to companion JSON base_commit field +git worktree add ../bantayog-wt-[slug] -b agent/[slug] + +opencode run "$(cat docs/agent-tasks/YYYY-MM-DD-[slug].md)" \ --agent exxeed \ --model kimi-for-coding/k2p6 \ - --dir ../bantayog-wt-[task-slug] \ + --dir ../bantayog-wt-[slug] \ --dangerously-skip-permissions ``` -Workers run via `Bash run_in_background: true` so Claude Code can spawn multiple in parallel without blocking. +`--dangerously-skip-permissions` bypasses OpenCode's interactive permission prompts. It is acceptable here because: (1) each agent runs in an isolated git worktree outside `main`'s branch, (2) the pre-spawn check confirms the worktree is within the project tree, and (3) Claude Code reviews the full diff before any merge. The flag must not be used if the pre-spawn check fails. -**Worktree setup (before spawning):** +Workers run via `Bash run_in_background: true`. Claude Code does not block waiting for them. -```bash -git worktree add ../bantayog-wt-[task-slug] -b agent/[task-slug] -``` +### Concurrency limit + +`MAX_PARALLEL_AGENTS = 3` (constant). Claude Code maintains a semaphore queue. When a slot opens (agent exits or times out), the next ready task spawns. This prevents disk exhaustion (8 worktrees × node_modules) and API rate-limit saturation. + +### Timeout enforcement + +Each task brief specifies `timeout_minutes`. If an agent is still running after that duration, Claude Code kills the background process and treats the run as a Stage 1 failure (missing or incomplete result.json). The telemetry entry records `"exit_reason": "timeout"`. --- ## 8. Merge Strategy — Phase Staging Branch -Parallel tasks never merge directly to `main`. All tasks in a phase merge to a phase staging branch first: +All agent branches merge to a phase staging branch, never directly to `main`: ``` main - └── phase/[N]-[description] - ├── agent/T2 - ├── agent/T3 - └── agent/T4 + └── phase/6-responder-telemetry + ├── agent/p6-t2 (merged) + ├── agent/p6-t3 (merged) + └── agent/p6-t5 (pending) ``` -When **all** tasks in a merge group pass both gates, the staging branch merges to `main` as a single PR. If any task fails terminally, the staging branch is deleted — no partial state lands on `main`. +### Merge conflict strategy + +- **Lockfiles (`pnpm-lock.yaml`, `package-lock.json`):** Use `git merge -X theirs`. The lockfile-reconcile step that follows is the authoritative version. +- **Code conflicts:** Abort the merge (`git merge --abort`), log it as a Stage 2 Run B failure, and escalate to human resolution before proceeding. +- **Preventing conflicts:** Agents whose tasks are sequentially ordered (due to `modifies_lockfile: true`) cannot cause code conflicts with each other by design. The primary conflict risk is in shared import files — caught by Stage 2 Run B. + +When **all** phase tasks pass both gates, the staging branch merges to `main` as a single squash PR. If any task reaches terminal failure, the entire staging branch is deleted — no partial state lands on `main`. --- @@ -225,201 +257,294 @@ When **all** tasks in a merge group pass both gates, the staging branch merges t ### Stage 1 — Artifact Verification (per task) -Runs immediately after a worker exits, before the branch is merged to the staging branch. +Runs immediately after a worker exits or times out. -**Script:** `scripts/agent-gate-stage1.sh ` +**Script:** `scripts/agent-gate-stage1.sh ` -Checks: +Steps (all must pass; any failure exits 1): -1. `exxeed-[slug]-result.json` exists and is valid JSON -2. `verification_exit_code` is `0` -3. `git diff --name-only main` output in the worktree contains only files listed in the task brief's `files_to_create` and `files_to_modify` fields -4. `discovered_required_files` is empty (if non-empty, fail-open: Claude Code reviews and may update brief + respawn) -5. `open_items` contains no entries marked `❌` - -Exit 0 = pass. Exit 1 = fail. No prose parsing. +1. `exxeed-[slug]-result.json` exists and is valid JSON. +2. Stage 1 **re-runs** `verification_command` from the companion JSON inside the worktree. Compares actual exit code to `verification_exit_code` in result.json. Mismatch = immediate fail. Agents cannot claim success without running tests. +3. `git -C diff --name-only ` (pinned SHA from companion JSON, not floating `main`) contains only files listed in `allowed_files.create`, `allowed_files.modify`, and `allowed_files.delete`. Extra files = fail. +4. If `discovered_required_files` is non-empty: **fail-open**. Claude Code is notified and may update `allowed_files` in the companion JSON and respawn. Not an automatic hard fail. +5. `open_items` array in result.json contains no entries with status `❌`. +6. Companion JSON `modifies_lockfile` matches the derived value in `dag.json`. Mismatch = immediate fail. +7. For every task slug X listed in this task's `blocks`, X's companion JSON must list this slug in its `blocked_by`. Asymmetric edges = immediate fail — this prevents silent execution misordering. ### Stage 2 — Code Quality (per task, then combined) -**Runs twice:** - -**Run A — per-task** on the worktree before merging to the staging branch: +#### Run A — per-task (before merging to staging branch) ```bash # scripts/agent-gate-stage2.sh -pnpm --filter $1 lint -- --max-warnings=$(cat .lint-baseline) && -pnpm --filter $1 typecheck && -pnpm --filter $1 test -- --coverage && +BASELINE=$(jq -r --arg pkg "$1" '.[$pkg] // 0' .lint-baselines.json) + +pnpm --filter "$1" lint -- --max-warnings="$BASELINE" && +pnpm --filter "$1" typecheck && +pnpm --filter "$1" test -- --coverage && if [[ "$1" == *"@bantayog/functions"* ]]; then firebase emulators:exec --only firestore,database,storage \ "pnpm --filter $1 test:rules" fi && -scripts/check-no-any.sh $1 && -scripts/check-no-empty-catch.sh $1 && +scripts/check-no-any.sh "$1" && +scripts/check-no-empty-catch.sh "$1" && +scripts/check-secrets.sh "$1" && scripts/check-lockfile-integrity.sh ``` -The `test:rules` step only runs when the filter includes `@bantayog/functions`. The `if` block exits non-zero on test failure — `|| true` is intentionally absent. +**Emulator port collisions:** Stage 2 Run A steps that invoke `firebase emulators:exec` are serialized — only one emulator session runs at a time, even if multiple agents have completed Stage 1 simultaneously. Non-emulator steps run in parallel. -**Run B — combined staging branch** after all parallel tasks have merged to the staging branch, before opening the PR to `main`: +#### Run B — combined staging branch (before PR to `main`) ```bash # scripts/agent-gate-stage2-combined.sh -git checkout $1 +git checkout "$1" npx turbo run lint typecheck test --affected && firebase emulators:exec --only firestore,database,storage \ "pnpm --filter @bantayog/functions exec vitest run src/__tests__/rules" && +scripts/check-secrets.sh all && scripts/check-lockfile-integrity.sh ``` -This catches cross-task issues (duplicate imports, type errors that only appear when both T2 and T5 are present together). +Run B catches cross-task issues: duplicate imports, type errors that only appear when both T2 and T5 are combined, and shared-file conflicts that individual task gates cannot see. -### Lint Baseline +### Lint Baselines -`.lint-baseline` is a checked-in file containing the current warning count from `main`: +`.lint-baselines.json` (checked in, generated once from `main`): + +```json +{ + "@bantayog/functions": 5, + "@bantayog/shared-validators": 0, + "@bantayog/citizen-pwa": 12, + "@bantayog/admin-desktop": 8, + "@bantayog/responder-app": 3 +} +``` + +Stage 2 Run A looks up the relevant package count. A task fails only on _new_ warnings for its package, not pre-existing ones in other packages. Run B checks the combined count across all affected packages. + +Regenerate with: ```bash -# Generate once, commit to main: -pnpm lint 2>&1 | grep -c "warning" > .lint-baseline +scripts/generate-lint-baselines.sh > .lint-baselines.json +git add .lint-baselines.json && git commit -m "chore: update lint baselines" ``` -Stage 2 uses `--max-warnings=$(cat .lint-baseline)` so tasks fail only on _new_ warnings, not pre-existing ones. +### Secrets Scan + +`scripts/check-secrets.sh ` uses `git-secrets` (or equivalent) to scan staged changes for: + +- Hardcoded API keys and tokens +- `.env` values committed to tracked files +- Firebase project IDs or service account JSON content +- Private key headers (`-----BEGIN`) + +Runs in both Stage 2 Run A and Run B. --- ## 10. Circuit Breaker & Retry ``` -Attempt 1 → Stage 1 or Stage 2 fails +Attempt 1 → Stage 1 or Stage 2 Run A fails └── Claude Code writes targeted correction brief - (specific violations only — not a full re-statement of the original brief) - └── Fresh worktree created: ../bantayog-wt-[slug]-retry-1 - (not --continue; clean context prevents compounding hallucinated state) - └── Agent respawned + (specific violations only — not a re-statement of the original brief) + └── Fresh worktree: ../bantayog-wt-[slug]-retry-1 + (not --continue; clean context prevents compounding prior hallucinated state) + └── Agent respawned with correction brief as the run message -Attempt 2 → Stage 1 or Stage 2 fails again - └── Claude Code attempts direct fix on the worktree +Attempt 2 → Still fails + └── Claude Code attempts direct fix in ../bantayog-wt-[slug]-retry-1 -Claude Code fix → Stage 2 still fails - └── TERMINAL_FAILURE: escalate to human, no further auto-retry +Claude Code direct fix → Stage 2 still fails + └── TERMINAL_FAILURE (see Section 11) ``` +On direct fix, Claude Code operates in `../bantayog-wt-[slug]-retry-1` (the most recent worktree). The original `../bantayog-wt-[slug]` is preserved for forensic comparison alongside retry-1. + +--- + +## 11. Terminal Failure + +### Scope + +A terminal failure is **task-scoped**, not phase-scoped: + +- All pending tasks in the **current phase** are cancelled. +- Running background agents are killed. +- The phase staging branch is deleted — tasks already merged to staging are discarded. +- **Other phases are unaffected** (they have their own staging branches). +- All worktrees for this phase (original + retries) are preserved, not deleted, until the human resolves the escalation. +- The telemetry log records total tokens and duration spent on the failed phase. + +### Escalation artifacts + +Claude Code writes `.claude/escalations/YYYY-MM-DD-[slug]-terminal.md` containing: + +- All telemetry entries for every attempt (`telemetry.jsonl` entries for this slug) +- `git diff ` from the last failed worktree +- The original task brief +- All correction briefs written during retry +- A one-paragraph diagnosis: what the agent failed to do, and why Claude Code's direct fix also failed + +Claude Code opens a GitHub issue: + +- Title: `[terminal-failure] Phase N — [slug]` +- Label: `terminal-failure` +- Body: link to escalation file + one-paragraph summary + +Claude Code then **stops** — no retry, no workaround, no partial merge. + +The human receives: escalation file path, GitHub issue link, worktree paths for forensic inspection. + --- -## 11. Human-in-the-Loop Gate +## 12. Human-in-the-Loop Gate -Computed before merging the staging branch to `main`: +Computed by Claude Code before merging staging → `main`: | Signal | Score | | ---------------------------------------------------- | -------------------- | | Total files changed > 5 | +2 | | Total lines changed > 100 | +1 | | Any file outside `allowed_files` detected at Stage 1 | +5 (immediate block) | -| Firestore rules or `firestore.indexes.json` touched | +3 | +| Firestore rules or `firestore.indexes.json` touched | +5 (immediate block) | | Any task passed on attempt 2+ | +2 | | Any `discovered_required_files` entries accepted | +1 | -**Score ≥ 3:** Claude Code posts a summary (files changed, gate results, suspicion score, diff stat) and waits for an explicit `proceed` from the user before merging. +**Score ≥ 3:** Claude Code posts a summary (files changed, gate results, suspicion score, diff stat) and waits for explicit `proceed` before merging. **Score < 3:** Claude Code merges automatically. -Firestore rules changes always score ≥ 3 by definition and always require human approval. +Firestore rules changes always score ≥ 5, always block for human approval. --- -## 12. Observability +## 13. Observability -Every agent run appends one line to `docs/agent-tasks/telemetry.jsonl`: +All events append to `docs/agent-tasks/telemetry.jsonl`. Claude Code commits this file **after every task completion** (not at phase end) to prevent loss on crash. + +**Agent run events (written by Claude Code after each gate):** ```jsonl { "ts": "2026-04-24T10:00:00Z", + "actor": "agent", "phase": 6, - "task": "T3", + "task": "p6-t3", "model": "kimi-for-coding/k2p6", - "agent": "exxeed", "attempt": 1, "stage1": "PASS", - "stage2": "FAIL", + "stage2_run_a": "FAIL", "duration_sec": 420, "files_changed": 3, - "lines_changed": 87 + "lines_changed": 87, + "exit_reason": "completed" } ``` -Claude Code writes this entry after each gate run, not at agent exit. The telemetry file is committed to `main` at the end of each phase. +**Claude Code orchestrator events:** + +```jsonl +{"ts":"2026-04-24T10:00:00Z","actor":"claude-code","action":"spawn","phase":6,"task":"p6-t3","model":"kimi-for-coding/k2p6"} +{"ts":"2026-04-24T10:07:00Z","actor":"claude-code","action":"stage1_result","phase":6,"task":"p6-t3","result":"PASS"} +{"ts":"2026-04-24T10:07:05Z","actor":"claude-code","action":"merge","phase":6,"task":"p6-t3","target":"phase/6-responder-telemetry"} +``` + +`exit_reason` values: `completed`, `timeout`, `terminal_failure`. + +Telemetry file is committed after each task and at phase end. It is never deleted — it is the permanent record of all agent work on this project. --- -## 13. Escalation — TERMINAL_FAILURE +## 14. Phase State Persistence (Crash Recovery) -When the circuit breaker reaches terminal state, Claude Code: +Claude Code writes `docs/agent-tasks/phase-state.json` atomically after every state transition (spawn, gate result, merge, cancel): -1. **Writes** `.claude/escalations/YYYY-MM-DD-[task-slug]-terminal.md` containing: - - The telemetry entries for all attempts (from `telemetry.jsonl`) - - The full `git diff` from the last failed worktree - - The original task brief - - All correction briefs written during retry - - A one-paragraph diagnosis of what the agent failed to do and why Claude Code's direct fix also failed +```json +{ + "phase": 6, + "staging_branch": "phase/6-responder-telemetry", + "base_commit": "abc1234", + "tasks": { + "p6-t1": { "status": "merged", "worktree": null, "pid": null }, + "p6-t2": { "status": "in_progress", "worktree": "../bantayog-wt-p6-t2", "pid": 12345 }, + "p6-t3": { "status": "pending", "worktree": null, "pid": null } + } +} +``` -2. **Opens a GitHub issue** with: - - Title: `[terminal-failure] Phase N — [task-slug]` - - Label: `terminal-failure` - - Body: link to the escalation file + one-paragraph summary +`status` values: `pending`, `in_progress`, `stage1_pass`, `stage2_pass`, `merged`, `failed`, `cancelled`, `terminal`. -3. **Stops** — does not retry, does not attempt a workaround, does not merge partial work. +On Claude Code restart, it reads `phase-state.json` before taking any action: -The human receives: escalation file path, GitHub issue link, and the exact `git worktree` path where the failed state lives (not deleted until human resolves). +- Tasks with `in_progress` status: check if the PID is still running. If yes, continue monitoring. If no, treat as a Stage 1 fail and begin retry. +- Tasks with `merged` status: do not respawn. +- Tasks with `pending` status: evaluate whether their `blocked_by` set is satisfied before spawning. + +`phase-state.json` is committed alongside each `telemetry.jsonl` update. --- -## 14. Phase Workflow Summary +## 15. Phase Workflow Summary ``` -1. Claude Code reads PRD phase + progress.md -2. Claude Code decomposes into tasks, writes task briefs, writes dag.json -3. Claude Code creates phase staging branch: git checkout -b phase/N-description -4. For each task whose blocked_by set is empty: - a. git worktree add ../bantayog-wt-[slug] -b agent/[slug] - b. opencode run (background) → worker runs Exxeed 4-phase workflow - c. Worker exits → Stage 1 gate runs - d. Stage 1 pass → Stage 2 Run A gate runs - e. Both pass → merge agent/[slug] into staging branch - f. Newly unblocked tasks → repeat from step 4 -5. All tasks merged to staging branch -6. lockfile-reconcile task runs (pnpm install) -7. Stage 2 Run B (combined) runs on staging branch -8. Suspicion score computed -9. Score < 3 → merge staging → main (single PR) - Score ≥ 3 → post summary, wait for human proceed -10. Worktrees deleted, telemetry.jsonl committed, progress.md updated + 1. Claude Code reads PRD phase + progress.md + 2. Claude Code writes task briefs (.md) + companion JSONs (.json) for all tasks in the phase + 3. Claude Code generates dag.json from companion JSONs + 4. Claude Code creates staging branch: git checkout -b phase/N-description + 5. Claude Code initializes phase-state.json + 6. For each task whose blocked_by set is fully merged (respecting MAX_PARALLEL_AGENTS = 3): + a. Pre-spawn checks (path safety, no stale worktree, write base_commit to companion JSON) + b. git worktree add ../bantayog-wt-[slug] -b agent/[slug] + c. opencode run (background) → worker runs Exxeed 4-phase workflow + d. Write telemetry: {actor: "claude-code", action: "spawn", ...} + e. Update phase-state.json + f. On agent exit (or timeout kill): + - Stage 1 gate runs (reruns verification_command, checks file allowlist, etc.) + - Stage 1 pass → Stage 2 Run A (serialized if emulator required) + - Both pass → merge agent/[slug] into staging branch + - If task's `modifies_lockfile` is true: run lockfile-reconcile (pnpm install) immediately before unblocking downstream tasks + - Write telemetry, update phase-state.json + - Newly unblocked tasks → repeat from step 6 + g. Stage 1 or 2 fail → circuit breaker (Section 10) + h. Terminal failure → Section 11 escalation, cancel all pending tasks, delete staging branch + 7. All tasks merged to staging branch + 8. Stage 2 Run B (combined) runs on staging branch + 9. Suspicion score computed (Section 12) +10. Score < 3 → Claude Code merges staging → main (single squash PR) + Score ≥ 3 → post summary, wait for explicit "proceed" +11. Worktrees deleted (except any preserved for terminal failure forensics) +12. telemetry.jsonl committed, phase-state.json archived, progress.md updated ``` --- -## 15. Example: Phase 6 — Responder App Telemetry +## 16. Example: Phase 6 — Responder App Telemetry -**Tasks and DAG:** +**Companion JSON DAG:** ``` -T1 (schemas, kimi-k2-thinking) - → T2 (firestore rules, kimi-k2-thinking) ──────────────────────> T6 (e2e) - → T3 (functions/callables, kimi-k2p6, modifies_lockfile) ↑ - → T4 (capacitor native setup, kimi-k2p6, modifies_lockfile) ──> T5 (hooks + race-loss UI) ──┘ - (needs T1, T3, T4) +p6-t1 (schemas, k2-thinking, lockfile: false) + → p6-t2 (rules, k2-thinking, lockfile: false) ─────────────────────────> p6-t6 (e2e) + → p6-t3 (functions, k2p6, lockfile: true) ─┐ ↑ + → p6-t4 (capacitor, k2p6, lockfile: true) ─┤──> p6-t5 (hooks + race-loss UI) ─────┘ + (p6-t5 blocked_by: [p6-t3, p6-t4]) ``` -T3 and T4 both modify lockfiles → serialized. T2 has no lockfile changes → runs parallel to whichever of T3/T4 is active. - -**Execution sequence:** +**Execution with semaphore (MAX_PARALLEL_AGENTS = 3):** ``` -Spawn: T1 (alone — no deps, sets baseline) -T1 merges → Spawn: T2, T3 in parallel - T3 completes → lockfile-reconcile → T4 spawns - T2 completes (no lockfile conflict with T4) -T4 merges → lockfile-reconcile → T5 spawns - (T3 must also be merged before T5 starts) -T5 merges → T6 spawns -T6 merges → Stage 2 Run B → suspicion score → PR to main +Spawn: p6-t1 (slot 1 of 3) +p6-t1 merges → + Spawn: p6-t2 (slot 1), p6-t3 (slot 2) [parallel; both ready, neither modifies lockfile conflict yet] + p6-t3 completes → lockfile-reconcile immediately → p6-t4 queued (p6-t3 has lockfile: true) + p6-t2 completes (no lockfile conflict with p6-t4) + p6-t4 spawns (slot 2) → completes → lockfile-reconcile + Now p6-t5 unblocked (both p6-t3 and p6-t4 merged) + Spawn: p6-t5 (slot 1) + p6-t5 merges → p6-t6 unblocked + Spawn: p6-t6 (slot 1) + p6-t6 merges → Stage 2 Run B → suspicion score → PR to main ``` From 7cd3a71e907b7424563264c16e6b3e40c5fec1c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 22:31:18 +0800 Subject: [PATCH 03/28] =?UTF-8?q?docs(agent-team):=20v2.1=20=E2=80=94=20fl?= =?UTF-8?q?ake=20detection,=20forensic=20worktree=20naming,=20gh=20fallbac?= =?UTF-8?q?k,=20staging=5Fcomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Single model: kimi-for-coding/k2p6 for all tasks (drop two-model split) - Flake detection: detect-flakes.sh runs verification 3x; pass 2/3 = flaky, no +2 score - Terminal forensic worktrees renamed *-TERMINAL-{timestamp}; pre-spawn skips them - GitHub issue creation failure: telemetry entry + disk fallback + loud session message - Run B: uses pnpm run test:rules (package-owned path, not hardcoded vitest path) - phase-state.json: new staging_complete/run_b_pass/pr_opened/done phase-level statuses; restart logic re-runs Stage 2 Run B when status is staging_complete Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-24-agent-team-design.md | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-04-24-agent-team-design.md b/docs/superpowers/specs/2026-04-24-agent-team-design.md index 0714b7dd..52af598e 100644 --- a/docs/superpowers/specs/2026-04-24-agent-team-design.md +++ b/docs/superpowers/specs/2026-04-24-agent-team-design.md @@ -68,12 +68,7 @@ One worktree per task, at `../bantayog-wt-[slug]`, on branch `agent/[slug]`. Wor ## 3. Models -| Task type | Model | -| ----------------------------------------------- | ---------------------------------- | -| Standard implementation (callables, UI, hooks) | `kimi-for-coding/k2p6` | -| Schema design, Firestore rules, algorithm-heavy | `kimi-for-coding/kimi-k2-thinking` | - -Model is specified in the companion JSON and passed to `opencode run --model`. +All tasks use `kimi-for-coding/k2p6`. Model is specified in the companion JSON and passed to `opencode run --model`. --- @@ -200,7 +195,7 @@ Before spawning any agent, Claude Code: 1. Verifies `opencode` is reachable: `opencode --version` 2. Confirms the worktree path is inside the project root — not above `$HOME` or in any path containing `.ssh`, `.gnupg`, `.config`, or system directories -3. Confirms no existing worktree at `../bantayog-wt-[slug]` (stale from a previous crash — see Section 14 on restart) +3. Confirms no existing worktree at `../bantayog-wt-[slug]` matching the active name pattern. Worktrees matching `../bantayog-wt-[slug]-TERMINAL-*` are forensic archives from prior terminal failures and are not a blocking condition — they are skipped. 4. Records the base commit: `BASE_SHA=$(git rev-parse main)` and writes it to `base_commit` in the companion JSON ### Spawn command @@ -301,7 +296,7 @@ scripts/check-lockfile-integrity.sh git checkout "$1" npx turbo run lint typecheck test --affected && firebase emulators:exec --only firestore,database,storage \ - "pnpm --filter @bantayog/functions exec vitest run src/__tests__/rules" && + "pnpm --filter @bantayog/functions run test:rules" && scripts/check-secrets.sh all && scripts/check-lockfile-integrity.sh ``` @@ -375,7 +370,7 @@ A terminal failure is **task-scoped**, not phase-scoped: - Running background agents are killed. - The phase staging branch is deleted — tasks already merged to staging are discarded. - **Other phases are unaffected** (they have their own staging branches). -- All worktrees for this phase (original + retries) are preserved, not deleted, until the human resolves the escalation. +- All worktrees for this phase (original + retries) are **renamed** before preservation: `../bantayog-wt-[slug]-TERMINAL-$(date +%s)` and `../bantayog-wt-[slug]-retry-1-TERMINAL-$(date +%s)`. The `*-TERMINAL-*` suffix prevents future pre-spawn checks from blocking on them while keeping the state intact for forensic inspection. - The telemetry log records total tokens and duration spent on the failed phase. ### Escalation artifacts @@ -388,15 +383,21 @@ Claude Code writes `.claude/escalations/YYYY-MM-DD-[slug]-terminal.md` containin - All correction briefs written during retry - A one-paragraph diagnosis: what the agent failed to do, and why Claude Code's direct fix also failed -Claude Code opens a GitHub issue: +Claude Code attempts to open a GitHub issue: - Title: `[terminal-failure] Phase N — [slug]` - Label: `terminal-failure` - Body: link to escalation file + one-paragraph summary +If `gh issue create` exits non-zero (network failure, auth expired, rate limit): + +1. Appends `{"actor":"claude-code","action":"escalation_failed","task":"[slug]","reason":"gh_issue_create_failed"}` to `telemetry.jsonl` +2. Writes the full issue body to `.claude/escalations/YYYY-MM-DD-[slug]-issue-fallback.md` +3. Prints to the session: `TERMINAL FAILURE — gh issue create failed — manual intervention required: .claude/escalations/YYYY-MM-DD-[slug]-terminal.md` + Claude Code then **stops** — no retry, no workaround, no partial merge. -The human receives: escalation file path, GitHub issue link, worktree paths for forensic inspection. +The human receives: escalation file path, GitHub issue link (or fallback file path), worktree paths for forensic inspection. --- @@ -404,14 +405,14 @@ The human receives: escalation file path, GitHub issue link, worktree paths for Computed by Claude Code before merging staging → `main`: -| Signal | Score | -| ---------------------------------------------------- | -------------------- | -| Total files changed > 5 | +2 | -| Total lines changed > 100 | +1 | -| Any file outside `allowed_files` detected at Stage 1 | +5 (immediate block) | -| Firestore rules or `firestore.indexes.json` touched | +5 (immediate block) | -| Any task passed on attempt 2+ | +2 | -| Any `discovered_required_files` entries accepted | +1 | +| Signal | Score | +| ---------------------------------------------------------- | -------------------- | +| Total files changed > 5 | +2 | +| Total lines changed > 100 | +1 | +| Any file outside `allowed_files` detected at Stage 1 | +5 (immediate block) | +| Firestore rules or `firestore.indexes.json` touched | +5 (immediate block) | +| Any task passed on attempt 2+ (Stage 2 failure, not flaky) | +2 | +| Any `discovered_required_files` entries accepted | +1 | **Score ≥ 3:** Claude Code posts a summary (files changed, gate results, suspicion score, diff stat) and waits for explicit `proceed` before merging. @@ -419,6 +420,16 @@ Computed by Claude Code before merging staging → `main`: Firestore rules changes always score ≥ 5, always block for human approval. +### Flake Detection + +Before scoring a retry as +2, Claude Code runs `scripts/detect-flakes.sh `, which re-executes the verification command 3 times. If it passes ≥ 2 of 3 runs, the failure is classified as a flaky test, not a real quality regression: + +- Telemetry records `"flaky": true` on that task's entry +- The +2 suspicion score is **not** applied +- A `"actor": "claude-code", "action": "flake_detected"` event is written to telemetry + +If the failure is genuine (passes 0 or 1 of 3 runs), the +2 score is applied and the retry proceeds normally. + --- ## 13. Observability @@ -475,13 +486,21 @@ Claude Code writes `docs/agent-tasks/phase-state.json` atomically after every st } ``` -`status` values: `pending`, `in_progress`, `stage1_pass`, `stage2_pass`, `merged`, `failed`, `cancelled`, `terminal`. +`status` values (per-task): `pending`, `in_progress`, `stage1_pass`, `stage2_pass`, `merged`, `failed`, `cancelled`, `terminal`. + +Phase-level status (top-level field in `phase-state.json`): `active`, `staging_complete`, `run_b_pass`, `pr_opened`, `done`, `terminal`. + +- `staging_complete`: all tasks merged to the staging branch; Run B has not yet run. This status is written **before** Run B starts. On restart, if phase status is `staging_complete`, Claude Code re-runs Run B — it does not assume Run B passed. +- `run_b_pass`: Run B completed successfully; PR not yet opened. +- `pr_opened`: PR to `main` has been opened; awaiting merge or human `proceed`. On Claude Code restart, it reads `phase-state.json` before taking any action: -- Tasks with `in_progress` status: check if the PID is still running. If yes, continue monitoring. If no, treat as a Stage 1 fail and begin retry. -- Tasks with `merged` status: do not respawn. -- Tasks with `pending` status: evaluate whether their `blocked_by` set is satisfied before spawning. +- Phase status `staging_complete` → re-run Stage 2 Run B before proceeding. +- Phase status `run_b_pass` or `pr_opened` → resume from that point (do not re-run gates). +- Per-task `in_progress`: check if the PID is still running. If yes, continue monitoring. If no, treat as a Stage 1 fail and begin retry. +- Per-task `merged`: do not respawn. +- Per-task `pending`: evaluate whether `blocked_by` set is satisfied before spawning. `phase-state.json` is committed alongside each `telemetry.jsonl` update. @@ -511,12 +530,14 @@ On Claude Code restart, it reads `phase-state.json` before taking any action: g. Stage 1 or 2 fail → circuit breaker (Section 10) h. Terminal failure → Section 11 escalation, cancel all pending tasks, delete staging branch 7. All tasks merged to staging branch - 8. Stage 2 Run B (combined) runs on staging branch - 9. Suspicion score computed (Section 12) -10. Score < 3 → Claude Code merges staging → main (single squash PR) - Score ≥ 3 → post summary, wait for explicit "proceed" -11. Worktrees deleted (except any preserved for terminal failure forensics) -12. telemetry.jsonl committed, phase-state.json archived, progress.md updated + 8. Write phase status → `staging_complete` in phase-state.json (crash-safe checkpoint) + 9. Stage 2 Run B (combined) runs on staging branch +10. Write phase status → `run_b_pass` in phase-state.json +11. Suspicion score computed (Section 12) +12. Score < 3 → Claude Code merges staging → main (single squash PR); write phase status → `pr_opened` + Score ≥ 3 → post summary, wait for explicit "proceed"; then merge and write `pr_opened` +13. Worktrees deleted (except `*-TERMINAL-*` forensic archives) +14. telemetry.jsonl committed, phase-state.json archived, progress.md updated; write phase status → `done` ``` --- From 7906394eec460361453b295a3bb46449e9817bc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 10:20:00 +0800 Subject: [PATCH 04/28] feat(agent-tooling): scaffold agent team orchestration and quality gates --- .claude/escalations/.gitkeep | 0 .lint-baselines.json | 12 +++++ docs/agent-tasks/.gitkeep | 0 scripts/agent-gate-stage1.sh | 72 +++++++++++++++++++++++++++ scripts/agent-gate-stage2-combined.sh | 25 ++++++++++ scripts/agent-gate-stage2.sh | 40 +++++++++++++++ scripts/check-lockfile-integrity.sh | 11 ++++ scripts/check-no-any.sh | 30 +++++++++++ scripts/check-no-empty-catch.sh | 32 ++++++++++++ scripts/check-secrets.sh | 44 ++++++++++++++++ scripts/detect-flakes.sh | 26 ++++++++++ scripts/generate-lint-baselines.sh | 32 ++++++++++++ 12 files changed, 324 insertions(+) create mode 100644 .claude/escalations/.gitkeep create mode 100644 .lint-baselines.json create mode 100644 docs/agent-tasks/.gitkeep create mode 100755 scripts/agent-gate-stage1.sh create mode 100755 scripts/agent-gate-stage2-combined.sh create mode 100755 scripts/agent-gate-stage2.sh create mode 100755 scripts/check-lockfile-integrity.sh create mode 100755 scripts/check-no-any.sh create mode 100755 scripts/check-no-empty-catch.sh create mode 100755 scripts/check-secrets.sh create mode 100755 scripts/detect-flakes.sh create mode 100755 scripts/generate-lint-baselines.sh diff --git a/.claude/escalations/.gitkeep b/.claude/escalations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.lint-baselines.json b/.lint-baselines.json new file mode 100644 index 00000000..419b64b5 --- /dev/null +++ b/.lint-baselines.json @@ -0,0 +1,12 @@ +{ + "@bantayog/shared-types": 0, + "@bantayog/citizen-pwa": 0, + "@bantayog/shared-sms-parser": 0, + "@bantayog/responder-app": 0, + "@bantayog/shared-ui": 0, + "@bantayog/functions": 0, + "@bantayog/admin-desktop": 0, + "@bantayog/shared-validators": 0, + "@bantayog/shared-data": 0, + "@bantayog/e2e-tests": 0 +} diff --git a/docs/agent-tasks/.gitkeep b/docs/agent-tasks/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/agent-gate-stage1.sh b/scripts/agent-gate-stage1.sh new file mode 100755 index 00000000..4e75bd00 --- /dev/null +++ b/scripts/agent-gate-stage1.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Usage: scripts/agent-gate-stage1.sh +# Exit 0 = PASS +# Exit 1 = FAIL (hard fail — retry or escalate) +# Exit 2 = FAIL-OPEN (discovered_required_files populated — Claude Code decides) +set -uo pipefail +SLUG="${1:?Usage: agent-gate-stage1.sh }" +WORKTREE="${2:?Usage: agent-gate-stage1.sh }" +PLANS_DIR="$WORKTREE/.claude/plans" +TASKS_DIR="docs/agent-tasks" +fail() { echo "STAGE1 FAIL: $*" >&2; exit 1; } +fail_open() { echo "STAGE1 FAIL-OPEN: $*" >&2; exit 2; } +pass() { echo "STAGE1 PASS: $SLUG"; exit 0; } +# 1. Companion JSON +COMPANION_JSON=$(find "$TASKS_DIR" -name "*-${SLUG}.json" 2>/dev/null 2>&1 | head -1) +# Workaround: find command was slightly off in the plan. +COMPANION_JSON=$(ls $TASKS_DIR/*-$SLUG.json 2>/dev/null | head -1) +[[ -z "$COMPANION_JSON" ]] && fail "companion JSON not found for slug: $SLUG" +jq empty "$COMPANION_JSON" 2>/dev/null || fail "companion JSON is invalid" +# 2. Result JSON +RESULT_JSON="$PLANS_DIR/exxeed-${SLUG}-result.json" +[[ ! -f "$RESULT_JSON" ]] && fail "result JSON not found: $RESULT_JSON" +jq empty "$RESULT_JSON" 2>/dev/null || fail "result JSON is invalid" +# 3. Re-run verification command +VERIFICATION_CMD=$(jq -r '.verification_command' "$COMPANION_JSON") +BASE_COMMIT=$(jq -r '.base_commit' "$COMPANION_JSON") +[[ -z "$BASE_COMMIT" || "$BASE_COMMIT" == "null" ]] && \ + fail "base_commit not set in companion JSON" +echo "Stage 1: Re-running verification: $VERIFICATION_CMD" +if ! (cd "$WORKTREE" && eval "$VERIFICATION_CMD" 2>&1); then + CLAIMED=$(jq -r '.verification_exit_code' "$RESULT_JSON") + fail "verification command failed (agent claimed exit_code=$CLAIMED)" +fi +# 4. File allowlist check against pinned base_commit +ALLOWED=$(jq -r \ + '(.allowed_files.create // [])[], (.allowed_files.modify // [])[], (.allowed_files.delete // [])[]' \ + "$COMPANION_JSON" 2>/dev/null | sort -u) +ACTUAL=$(git -C "$WORKTREE" diff --name-only "$BASE_COMMIT" 2>/dev/null | sort) +DISALLOWED=$(comm -23 <(echo "$ACTUAL") <(echo "$ALLOWED") | grep -v '^$' || true) +if [[ -n "$DISALLOWED" ]]; then + fail "files changed outside allowed_files: +$DISALLOWED" +fi +# 5. discovered_required_files — fail-open +DISCOVERED=$(jq '.discovered_required_files | length' "$RESULT_JSON" 2>/dev/null || echo "0") +if [[ "$DISCOVERED" -gt 0 ]]; then + FILES=$(jq -r '.discovered_required_files[]' "$RESULT_JSON") + fail_open "agent found required files outside allowed list — update companion JSON and respawn: +$FILES" +fi +# 6. No unresolved open items +FAILED_ITEMS=$(jq -r '.open_items[] | select(contains("❌"))' "$RESULT_JSON" 2>/dev/null || true) +[[ -n "$FAILED_ITEMS" ]] && fail "open_items contains unresolved ❌ items: +$FAILED_ITEMS" +# 7. modifies_lockfile consistency with dag.json +DAG_JSON="$TASKS_DIR/$(ls $TASKS_DIR | grep 'dag.json' | head -1)" +if [[ -f "$DAG_JSON" ]]; then + COMPANION_LF=$(jq -r '.modifies_lockfile' "$COMPANION_JSON") + DAG_LF=$(jq -r --arg s "$SLUG" '.[$s].modifies_lockfile // "null"' "$DAG_JSON") + [[ "$COMPANION_LF" != "$DAG_LF" ]] && \ + fail "modifies_lockfile mismatch: companion=$COMPANION_LF dag=$DAG_LF" +fi +# 8. blocks/blocked_by symmetry +while IFS= read -r blocked_slug; do + [[ -z "$blocked_slug" ]] && continue + BLOCKED_JSON=$(ls $TASKS_DIR/*-$blocked_slug.json 2>/dev/null | head -1) + [[ -z "$BLOCKED_JSON" ]] && continue + if ! jq -e --arg s "$SLUG" '.blocked_by[] | select(. == $s)' "$BLOCKED_JSON" &>/dev/null; then + fail "asymmetric edge: $SLUG.blocks has $blocked_slug, but $blocked_slug.blocked_by missing $SLUG" + fi +done < <(jq -r '.blocks[]? // empty' "$COMPANION_JSON" 2>/dev/null) +pass diff --git a/scripts/agent-gate-stage2-combined.sh b/scripts/agent-gate-stage2-combined.sh new file mode 100755 index 00000000..6fa2a00d --- /dev/null +++ b/scripts/agent-gate-stage2-combined.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Stage 2 Run B — full combined staging branch quality gate. +# Checks out staging-branch, runs turbo affected, all rules tests, secrets, lockfile. +set -uo pipefail +STAGING_BRANCH="${1:?Usage: agent-gate-stage2-combined.sh [repo-path]}" +REPO="${2:-.}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fail() { echo "STAGE2 FAIL (Run B): $*" >&2; exit 1; } +cd "$REPO" +git checkout "$STAGING_BRANCH" || fail "cannot checkout $STAGING_BRANCH" +echo "Stage 2 Run B: $STAGING_BRANCH" +# Turbo affected — covers all changed packages +echo "→ turbo lint typecheck test --affected" +npx turbo run lint typecheck test --affected || fail "turbo affected" +# All Firestore/RTDB/Storage rules in emulator +echo "→ test:rules:firestore (combined)" +firebase emulators:exec --only firestore,database,storage \ + "pnpm --filter @bantayog/functions run test:rules:firestore" || fail "test:rules:firestore" +# Secrets scan across entire staging branch diff +echo "→ check-secrets (all)" +"$SCRIPT_DIR/check-secrets.sh" all "$REPO" || fail "check-secrets" +# Lockfile integrity +echo "→ check-lockfile-integrity" +"$SCRIPT_DIR/check-lockfile-integrity.sh" "$REPO" || fail "check-lockfile-integrity" +echo "STAGE2 PASS (Run B): $STAGING_BRANCH" diff --git a/scripts/agent-gate-stage2.sh b/scripts/agent-gate-stage2.sh new file mode 100755 index 00000000..fd5f141c --- /dev/null +++ b/scripts/agent-gate-stage2.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Usage: scripts/agent-gate-stage2.sh +# Stage 2 Run A — per-task quality gate. +# Emulator tests only run for @bantayog/functions tasks. +set -uo pipefail +FILTER="${1:?Usage: agent-gate-stage2.sh }" +WORKTREE="${2:?Usage: agent-gate-stage2.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BASELINES="$REPO_ROOT/.lint-baselines.json" +fail() { echo "STAGE2 FAIL (Run A): $*" >&2; exit 1; } +[[ ! -f "$BASELINES" ]] && fail ".lint-baselines.json not found — run scripts/generate-lint-baselines.sh" +MAX_WARNINGS=$(jq -r --arg f "$FILTER" '.[$f] // 0' "$BASELINES" 2>/dev/null || echo "0") +echo "Stage 2 Run A: filter=$FILTER worktree=$WORKTREE max_warnings=$MAX_WARNINGS" +cd "$WORKTREE" +# Lint +echo "→ lint" +pnpm --filter "$FILTER" lint -- --max-warnings="$MAX_WARNINGS" || fail "lint" +# Typecheck +echo "→ typecheck" +pnpm --filter "$FILTER" typecheck || fail "typecheck" +# Unit tests with coverage +echo "→ test" +pnpm --filter "$FILTER" test -- --coverage || fail "test" +# Rules tests — only for functions (serialized by caller to avoid emulator port collisions) +if [[ "$FILTER" == *"functions"* ]]; then + echo "→ test:rules:firestore (emulator)" + firebase emulators:exec --only firestore,database,storage \ + "pnpm --filter $FILTER run test:rules:firestore" || fail "test:rules:firestore" +fi +# Check scripts +echo "→ check-no-any" +"$SCRIPT_DIR/check-no-any.sh" "$FILTER" "$WORKTREE" || fail "check-no-any" +echo "→ check-no-empty-catch" +"$SCRIPT_DIR/check-no-empty-catch.sh" "$FILTER" "$WORKTREE" || fail "check-no-empty-catch" +echo "→ check-secrets" +"$SCRIPT_DIR/check-secrets.sh" "$FILTER" "$WORKTREE" || fail "check-secrets" +echo "→ check-lockfile-integrity" +"$SCRIPT_DIR/check-lockfile-integrity.sh" "$WORKTREE" || fail "check-lockfile-integrity" +echo "STAGE2 PASS (Run A): $FILTER" diff --git a/scripts/check-lockfile-integrity.sh b/scripts/check-lockfile-integrity.sh new file mode 100755 index 00000000..f7203702 --- /dev/null +++ b/scripts/check-lockfile-integrity.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Usage: check-lockfile-integrity.sh [worktree-path] +# Fails if pnpm-lock.yaml is not in sync with package.json files. +set -uo pipefail +WORKTREE="${1:-.}" +cd "$WORKTREE" +if ! pnpm install --frozen-lockfile --lockfile-only 2>/dev/null; then + echo "FAIL: pnpm-lock.yaml is out of sync with package.json" + exit 1 +fi +echo "PASS: pnpm-lock.yaml integrity check" diff --git a/scripts/check-no-any.sh b/scripts/check-no-any.sh new file mode 100755 index 00000000..e706de44 --- /dev/null +++ b/scripts/check-no-any.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Usage: check-no-any.sh +# Fails if TypeScript source contains `: any` or `as any` patterns. +set -uo pipefail +FILTER="${1:?Usage: check-no-any.sh }" +WORKTREE="${2:?Usage: check-no-any.sh }" +SRC_DIR="" +while IFS= read -r pkg_json; do + pkg_name=$(jq -r '.name' "$pkg_json" 2>/dev/null) + if [[ "$pkg_name" == "$FILTER" ]]; then + SRC_DIR="$(dirname "$pkg_json")/src" + break + fi +done < <(find "$WORKTREE" -name "package.json" -not -path "*/node_modules/*" -not -path "*/.git/*") +if [[ -z "$SRC_DIR" || ! -d "$SRC_DIR" ]]; then + if [[ "$FILTER" == "all" ]]; then + SRC_DIR="$WORKTREE" + else + echo "PASS: no src dir for $FILTER (skipping)" + exit 0 + fi +fi +MATCHES=$(grep -rn ": any\b\|as any\b" --include="*.ts" --include="*.tsx" "$SRC_DIR" \ + | grep -v "// eslint-disable\|catch.*: unknown\|catch {" || true) +if [[ -n "$MATCHES" ]]; then + echo "FAIL: 'any' types found in $FILTER:" + echo "$MATCHES" + exit 1 +fi +echo "PASS: no 'any' types in $FILTER" diff --git a/scripts/check-no-empty-catch.sh b/scripts/check-no-empty-catch.sh new file mode 100755 index 00000000..aca496dc --- /dev/null +++ b/scripts/check-no-empty-catch.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Usage: check-no-empty-catch.sh +# Fails if TypeScript source contains empty catch blocks: catch { } or catch (e) { } +set -uo pipefail +FILTER="${1:?Usage: check-no-empty-catch.sh }" +WORKTREE="${2:?Usage: check-no-empty-catch.sh }" +SRC_DIR="" +while IFS= read -r pkg_json; do + pkg_name=$(jq -r '.name' "$pkg_json" 2>/dev/null) + if [[ "$pkg_name" == "$FILTER" ]]; then + SRC_DIR="$(dirname "$pkg_json")/src" + break + fi +done < <(find "$WORKTREE" -name "package.json" -not -path "*/node_modules/*" -not -path "*/.git/*") +if [[ -z "$SRC_DIR" || ! -d "$SRC_DIR" ]]; then + if [[ "$FILTER" == "all" ]]; then + SRC_DIR="$WORKTREE" + else + echo "PASS: no src dir for $FILTER (skipping)" + exit 0 + fi +fi +# Match catch blocks with empty bodies (whitespace only between braces) +# Excludes lines with comments explaining the intentional empty catch +MATCHES=$(grep -rn "catch\s*[({][^)]*[)}]\s*{\s*}" --include="*.ts" --include="*.tsx" "$SRC_DIR" \ + | grep -v "\/\/ intentional\|\/\/ transaction contention\|\/\/ fire-and-forget" || true) +if [[ -n "$MATCHES" ]]; then + echo "FAIL: empty catch blocks found in $FILTER:" + echo "$MATCHES" + exit 1 +fi +echo "PASS: no empty catch blocks in $FILTER" diff --git a/scripts/check-secrets.sh b/scripts/check-secrets.sh new file mode 100755 index 00000000..cc3e7e5b --- /dev/null +++ b/scripts/check-secrets.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Usage: check-secrets.sh +# Scans for common secret patterns (API keys, private keys, etc.) +set -uo pipefail +FILTER="${1:?Usage: check-secrets.sh }" +WORKTREE="${2:?Usage: check-secrets.sh }" +PATTERNS=( + "AIza[0-9A-Za-z\\-_]{35}" # Google API Key + "-----BEGIN (RSA|EC|PRIVATE) KEY-----" + "\"[a-zA-Z0-9]{32,}\"" # Generic long hex/base64 strings + "xox[bp]-[0-9]{12}" # Slack tokens + "sqp_[a-f0-9]{40}" # SonarQube tokens +) +SCAN_DIR="" +if [[ "$FILTER" == "all" ]]; then + SCAN_DIR="$WORKTREE" +else + while IFS= read -r pkg_json; do + pkg_name=$(jq -r '.name' "$pkg_json" 2>/dev/null) + if [[ "$pkg_name" == "$FILTER" ]]; then + SCAN_DIR="$(dirname "$pkg_json")" + break + fi + done < <(find "$WORKTREE" -name "package.json" -not -path "*/node_modules/*" -not -path "*/.git/*") +fi +if [[ -z "$SCAN_DIR" ]]; then + echo "PASS: directory not found for $FILTER (skipping)" + exit 0 +fi +FOUND=0 +for pattern in "${PATTERNS[@]}"; do + MATCHES=$(grep -rnE "$pattern" --include="*.ts" --include="*.tsx" --include="*.js" \ + --include="*.json" --exclude-dir=node_modules --exclude-dir=.git "$SCAN_DIR" \ + | grep -v "\.env\.example\|test\|spec\|mock\|fixture\|// " || true) + if [[ -n "$MATCHES" ]]; then + echo "FAIL: potential secret found (pattern: $pattern):" + echo "$MATCHES" + FOUND=1 + fi +done +if [[ "$FOUND" -eq 1 ]]; then + exit 1 +fi +echo "PASS: no secrets patterns found in $FILTER" diff --git a/scripts/detect-flakes.sh b/scripts/detect-flakes.sh new file mode 100755 index 00000000..817d76ab --- /dev/null +++ b/scripts/detect-flakes.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Usage: detect-flakes.sh +# Runs the verification command 3 times. +# Exits 0 with "flaky" if it passes >= 2/3 runs. +# Exits 1 with "genuine_failure" if it passes <= 1/3 runs. +set -uo pipefail +CMD="${1:?Usage: detect-flakes.sh }" +WORKTREE="${2:?Usage: detect-flakes.sh }" +PASSES=0 +for i in 1 2 3; do + echo "Flake detection: run $i/3" + if (cd "$WORKTREE" && eval "$CMD" &>/dev/null); then + PASSES=$((PASSES + 1)) + echo " run $i: PASS" + else + echo " run $i: FAIL" + fi +done +echo "Result: $PASSES/3 passed" +if [[ "$PASSES" -ge 2 ]]; then + echo "flaky" + exit 0 +else + echo "genuine_failure" + exit 1 +fi diff --git a/scripts/generate-lint-baselines.sh b/scripts/generate-lint-baselines.sh new file mode 100755 index 00000000..2cf3fbf7 --- /dev/null +++ b/scripts/generate-lint-baselines.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +declare -A PACKAGES=( + ["@bantayog/functions"]="functions" + ["@bantayog/shared-validators"]="packages/shared-validators" + ["@bantayog/shared-ui"]="packages/shared-ui" + ["@bantayog/shared-types"]="packages/shared-types" + ["@bantayog/shared-sms-parser"]="packages/shared-sms-parser" + ["@bantayog/shared-data"]="packages/shared-data" + ["@bantayog/citizen-pwa"]="apps/citizen-pwa" + ["@bantayog/admin-desktop"]="apps/admin-desktop" + ["@bantayog/responder-app"]="apps/responder-app" + ["@bantayog/e2e-tests"]="e2e-tests" +) +echo "{" +first=true +for pkg in "${!PACKAGES[@]}"; do + dir="${PACKAGES[$pkg]}" + if [[ ! -f "$dir/package.json" ]]; then + continue + fi + # Run lint and count lines that look like warnings. + # Use (grep -c ... || true) to safely handle zero matches. + count=$(pnpm --filter "$pkg" lint -- --format unix 2>&1 | (grep -c ": warning" || true)) + count=$(echo "$count" | tr -d '[:space:]') + if [[ "$first" == "true" ]]; then + first=false + else + printf ",\n" + fi + printf ' "%s": %s' "$pkg" "$count" +done +printf "\n}\n" From 5e00328f00c940bc652fea8567f216ac40f95f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:26:57 +0800 Subject: [PATCH 05/28] feat(admin-desktop): add vitest + testing-library test infrastructure - Add @testing-library/user-event dependency - Add react plugin to vitest config for JSX transform - Add test script to package.json --- apps/admin-desktop/package.json | 4 +++- apps/admin-desktop/vitest.config.ts | 2 ++ pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/admin-desktop/package.json b/apps/admin-desktop/package.json index fdf2845d..6689d6a8 100644 --- a/apps/admin-desktop/package.json +++ b/apps/admin-desktop/package.json @@ -8,7 +8,8 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "lint": "eslint src", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@bantayog/shared-types": "workspace:*", @@ -21,6 +22,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/apps/admin-desktop/vitest.config.ts b/apps/admin-desktop/vitest.config.ts index 5eaa352f..268e592b 100644 --- a/apps/admin-desktop/vitest.config.ts +++ b/apps/admin-desktop/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' export default defineConfig({ + plugins: [react()], test: { globals: true, environment: 'happy-dom', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93b3e1fc..20fa05f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@testing-library/react': specifier: ^16.0.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.2.14 version: 19.2.14 From 661eb086b78a3d6385e1d33ac78df2f2c3599464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:34:12 +0800 Subject: [PATCH 06/28] =?UTF-8?q?feat(triage):=20pagination=20+=20severity?= =?UTF-8?q?=20field=20=E2=80=94=20remove=20severityDerived,=20add=20hasMor?= =?UTF-8?q?e/loadMore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/__tests__/triage-queue.test.tsx | 121 ++++++++++++++++++ .../admin-desktop/src/hooks/useMuniReports.ts | 70 +++++++--- .../src/pages/TriageQueuePage.tsx | 40 +++--- 3 files changed, 194 insertions(+), 37 deletions(-) create mode 100644 apps/admin-desktop/src/__tests__/triage-queue.test.tsx diff --git a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx new file mode 100644 index 00000000..9b0a3782 --- /dev/null +++ b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +const mockUseMuniReports = vi.fn() + +vi.mock('../hooks/useMuniReports', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + useMuniReports: (...args: unknown[]) => mockUseMuniReports(...args), +})) + +vi.mock('../app/firebase', () => ({ + db: {}, +})) + +vi.mock('@bantayog/shared-ui', () => ({ + useAuth: () => ({ + claims: { municipalityId: 'daet', role: 'municipal_admin' }, + signOut: vi.fn(), + }), +})) + +vi.mock('../services/callables', () => ({ + callables: { + verifyReport: vi.fn(), + rejectReport: vi.fn(), + }, +})) + +vi.mock('../pages/ReportDetailPanel', () => ({ + ReportDetailPanel: () =>
detail
, +})) +vi.mock('../pages/DispatchModal', () => ({ + DispatchModal: () =>
dispatch
, +})) +vi.mock('../pages/CloseReportModal', () => ({ + CloseReportModal: () =>
close
, +})) + +import { TriageQueuePage } from '../pages/TriageQueuePage' + +describe('TriageQueuePage', () => { + beforeEach(() => { + mockUseMuniReports.mockReturnValue({ + reports: [], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + }) + + it('renders Load More button when hasMore is true', () => { + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + ], + hasMore: true, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument() + }) + + it('does not render Load More button when hasMore is false', () => { + render() + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument() + }) + + it('calls loadMore when Load More is clicked', () => { + const loadMore = vi.fn() + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + ], + hasMore: true, + loadMore, + loading: false, + error: null, + }) + render() + fireEvent.click(screen.getByRole('button', { name: /load more/i })) + expect(loadMore).toHaveBeenCalledTimes(1) + }) + + it('shows Showing X count', () => { + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + { + reportId: 'r2', + status: 'new', + severity: 'medium', + createdAt: null, + municipalityLabel: '', + }, + ], + hasMore: true, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + expect(screen.getByText(/showing 2/i)).toBeInTheDocument() + }) + + it('renders severity from severity field, not severityDerived', () => { + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + ], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + expect(screen.getByText(/high/i)).toBeInTheDocument() + }) +}) diff --git a/apps/admin-desktop/src/hooks/useMuniReports.ts b/apps/admin-desktop/src/hooks/useMuniReports.ts index 2f95d6ed..00d06df9 100644 --- a/apps/admin-desktop/src/hooks/useMuniReports.ts +++ b/apps/admin-desktop/src/hooks/useMuniReports.ts @@ -1,24 +1,39 @@ import { useEffect, useState } from 'react' -import { collection, onSnapshot, query, where, orderBy, limit, Timestamp } from 'firebase/firestore' +import { + collection, + onSnapshot, + query, + where, + orderBy, + limit, + type Timestamp, +} from 'firebase/firestore' import { db } from '../app/firebase' export interface MuniReportRow { reportId: string status: string - severityDerived: string + severity: string + reportType?: string + duplicateClusterId?: string + barangayId?: string createdAt: Timestamp municipalityLabel: string } +const ACTIVE_STATUSES = ['new', 'awaiting_verify', 'verified', 'assigned'] as const + export function useMuniReports(municipalityId: string | undefined) { - const [rows, setRows] = useState([]) + const [limitCount, setLimitCount] = useState(100) + const [reports, setReports] = useState([]) + const [hasMore, setHasMore] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { if (!municipalityId) { queueMicrotask(() => { - setRows([]) + setReports([]) setLoading(false) }) return @@ -27,27 +42,32 @@ export function useMuniReports(municipalityId: string | undefined) { setLoading(true) }) const q = query( - collection(db, 'reports'), + collection(db, 'report_ops'), where('municipalityId', '==', municipalityId), - where('status', 'in', ['new', 'awaiting_verify', 'verified', 'assigned']), + where('status', 'in', ACTIVE_STATUSES), orderBy('createdAt', 'desc'), - limit(100), + limit(limitCount + 1), ) const unsub = onSnapshot( q, (snap) => { - setRows( - snap.docs.map((d) => { - const data = d.data() - return { - reportId: d.id, - status: String(data.status), - severityDerived: String(data.severityDerived ?? 'medium'), - createdAt: data.createdAt as Timestamp, - municipalityLabel: String(data.municipalityLabel ?? ''), - } - }), - ) + const all = snap.docs.map((d) => { + const data = d.data() + const row: MuniReportRow = { + reportId: d.id, + status: String(data.status), + severity: String(data.severity ?? 'medium'), + createdAt: data.createdAt as Timestamp, + municipalityLabel: String(data.municipalityLabel ?? ''), + } + if (data.reportType !== undefined) row.reportType = String(data.reportType) + if (data.duplicateClusterId !== undefined) + row.duplicateClusterId = String(data.duplicateClusterId) + if (data.barangayId !== undefined) row.barangayId = String(data.barangayId) + return row + }) + setHasMore(all.length > limitCount) + setReports(all.slice(0, limitCount)) setLoading(false) }, (err) => { @@ -56,7 +76,15 @@ export function useMuniReports(municipalityId: string | undefined) { }, ) return unsub - }, [municipalityId]) + }, [municipalityId, limitCount]) - return { rows, loading, error } + return { + reports, + hasMore, + loadMore: () => { + setLimitCount((n) => n + 100) + }, + loading, + error, + } } diff --git a/apps/admin-desktop/src/pages/TriageQueuePage.tsx b/apps/admin-desktop/src/pages/TriageQueuePage.tsx index 80f60a8c..3f17d3bd 100644 --- a/apps/admin-desktop/src/pages/TriageQueuePage.tsx +++ b/apps/admin-desktop/src/pages/TriageQueuePage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useAuth } from '@bantayog/shared-ui' -import { useMuniReports } from '../hooks/useMuniReports' +import { useMuniReports, type MuniReportRow } from '../hooks/useMuniReports' import { ReportDetailPanel } from './ReportDetailPanel' import { DispatchModal } from './DispatchModal' import { CloseReportModal } from './CloseReportModal' @@ -10,7 +10,7 @@ export function TriageQueuePage() { const { claims, signOut } = useAuth() const municipalityId = typeof claims?.municipalityId === 'string' ? claims.municipalityId : undefined - const { rows, loading, error } = useMuniReports(municipalityId) + const { reports, hasMore, loadMore, loading, error } = useMuniReports(municipalityId) const [selected, setSelected] = useState(null) const [dispatchForReportId, setDispatchForReportId] = useState(null) const [closeForReportId, setCloseForReportId] = useState(null) @@ -63,22 +63,30 @@ export function TriageQueuePage() {

Loading…

) : error ? (

Error: {error}

- ) : rows.length === 0 ? ( + ) : reports.length === 0 ? (

No active reports.

) : ( -
    - {rows.map((r) => ( -
  • - -
  • - ))} -
+ <> +

+ Showing {reports.length} + {hasMore ? '+' : ''} reports +

+
    + {reports.map((r: MuniReportRow) => ( +
  • + +
  • + ))} +
+ {hasMore && } + )} {selected && ( From 1ec884de1457e0cdd98846371fa9dc8251803b5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:37:47 +0800 Subject: [PATCH 07/28] feat(triage): j/k/Escape keyboard shortcuts for queue navigation --- .../src/__tests__/triage-queue.test.tsx | 63 +++++++++++++++++++ .../src/pages/TriageQueuePage.tsx | 31 ++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx index 9b0a3782..5a19a9b4 100644 --- a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx +++ b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' const mockUseMuniReports = vi.fn() @@ -118,4 +119,66 @@ describe('TriageQueuePage', () => { render() expect(screen.getByText(/high/i)).toBeInTheDocument() }) + + it('pressing j selects the next report in the list', async () => { + const user = userEvent.setup() + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + { + reportId: 'r2', + status: 'new', + severity: 'medium', + createdAt: null, + municipalityLabel: '', + }, + ], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + await user.keyboard('j') + expect(screen.getByText('detail')).toBeInTheDocument() + }) + + it('pressing k moves selection backward', async () => { + const user = userEvent.setup() + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + { + reportId: 'r2', + status: 'new', + severity: 'medium', + createdAt: null, + municipalityLabel: '', + }, + ], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + await user.keyboard('jj') + await user.keyboard('k') + expect(screen.getByText('detail')).toBeInTheDocument() + }) + + it('keyboard shortcuts do not fire when a modal is open', async () => { + const user = userEvent.setup() + mockUseMuniReports.mockReturnValue({ + reports: [], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + await user.keyboard('j') + await user.keyboard('k') + expect(screen.queryByText('detail')).not.toBeInTheDocument() + }) }) diff --git a/apps/admin-desktop/src/pages/TriageQueuePage.tsx b/apps/admin-desktop/src/pages/TriageQueuePage.tsx index 3f17d3bd..4dad408e 100644 --- a/apps/admin-desktop/src/pages/TriageQueuePage.tsx +++ b/apps/admin-desktop/src/pages/TriageQueuePage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAuth } from '@bantayog/shared-ui' import { useMuniReports, type MuniReportRow } from '../hooks/useMuniReports' import { ReportDetailPanel } from './ReportDetailPanel' @@ -49,6 +49,35 @@ export function TriageQueuePage() { })() } + const indexRef = useRef(-1) + const modalOpen = !!dispatchForReportId || !!closeForReportId + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (modalOpen) return + if (e.key === 'j') { + const next = Math.min(indexRef.current + 1, reports.length - 1) + if (next >= 0) { + indexRef.current = next + setSelected(reports[next]?.reportId ?? null) + } + } else if (e.key === 'k') { + const prev = Math.max(indexRef.current - 1, 0) + if (prev >= 0 && reports.length > 0) { + indexRef.current = prev + setSelected(reports[prev]?.reportId ?? null) + } + } else if (e.key === 'Escape') { + setDispatchForReportId(null) + setCloseForReportId(null) + } + } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + } + }, [modalOpen, reports]) + return (
From 055134e72c671401794b6556f9e7854f3940c8e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:49:21 +0800 Subject: [PATCH 08/28] =?UTF-8?q?feat(triggers):=20duplicateClusterTrigger?= =?UTF-8?q?=20=E2=80=94=20geohash=20+=20Turf.js=20200m=20proximity=20clust?= =?UTF-8?q?ering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../triggers/duplicate-cluster.test.ts | 245 ++++++++++++++++++ functions/src/index.ts | 1 + .../src/triggers/duplicate-cluster-trigger.ts | 102 ++++++++ infra/firebase/firestore.indexes.json | 10 + 4 files changed, 358 insertions(+) create mode 100644 functions/src/__tests__/triggers/duplicate-cluster.test.ts create mode 100644 functions/src/triggers/duplicate-cluster-trigger.ts diff --git a/functions/src/__tests__/triggers/duplicate-cluster.test.ts b/functions/src/__tests__/triggers/duplicate-cluster.test.ts new file mode 100644 index 00000000..10636817 --- /dev/null +++ b/functions/src/__tests__/triggers/duplicate-cluster.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { setDoc, doc } from 'firebase/firestore' +import { type Firestore } from 'firebase-admin/firestore' +import type { QueryDocumentSnapshot } from 'firebase-admin/firestore' + +vi.mock('firebase-admin/database', () => ({ getDatabase: vi.fn(() => ({})) })) +let adminDb: Firestore +vi.mock('../../admin-init.js', () => ({ + get adminDb() { + return adminDb + }, +})) + +import { duplicateClusterTriggerCore } from '../../triggers/duplicate-cluster-trigger.js' + +const ts = 1713350400000 +let testEnv: RulesTestEnvironment + +beforeAll(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'dup-cluster-test', + firestore: { + host: 'localhost', + port: 8081, + rules: + 'rules_version = "2"; service cloud.firestore { match /{d=**} { allow read, write: if true; } }', + }, + }) + adminDb = testEnv.unauthenticatedContext().firestore() as unknown as Firestore +}) + +beforeEach(async () => { + await testEnv.clearFirestore() +}) +afterAll(async () => { + await testEnv.cleanup() +}) + +const DAET_GEOHASH = 'w7hfm2mb' +const NEARBY_GEOHASH = 'w7hfm2mc' + +async function seedReportOps(id: string, overrides: Record) { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'report_ops', id), { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + ...overrides, + }) + }) +} + +function makeSnap(id: string, data: Record): QueryDocumentSnapshot { + return { + id, + ref: adminDb.collection('report_ops').doc(id), + data: () => data, + } as unknown as QueryDocumentSnapshot +} + +describe('duplicateClusterTrigger', () => { + it('does not set duplicateClusterId when no nearby reports exist', async () => { + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + const snap = makeSnap('r-new', newData) + await duplicateClusterTriggerCore(adminDb, snap) + const updated = await adminDb.collection('report_ops').doc('r-new').get() + expect(updated.data()?.duplicateClusterId).toBeUndefined() + }) + + it('sets duplicateClusterId on both reports when same type + muni + within geohash proximity + within 2h', async () => { + await seedReportOps('r-existing', { locationGeohash: NEARBY_GEOHASH, createdAt: ts - 3600000 }) + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + await seedReportOps('r-new', { locationGeohash: DAET_GEOHASH }) + const snap = makeSnap('r-new', newData) + await duplicateClusterTriggerCore(adminDb, snap) + const newSnap = await adminDb.collection('report_ops').doc('r-new').get() + const existingSnap = await adminDb.collection('report_ops').doc('r-existing').get() + expect(newSnap.data()?.duplicateClusterId).toBeDefined() + expect(newSnap.data()?.duplicateClusterId).toBe(existingSnap.data()?.duplicateClusterId) + }) + + it('does not cluster reports of different types', async () => { + await seedReportOps('r-fire', { + reportType: 'fire', + locationGeohash: NEARBY_GEOHASH, + createdAt: ts - 60000, + }) + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + const snap = makeSnap('r-new', newData) + await duplicateClusterTriggerCore(adminDb, snap) + const updated = await adminDb.collection('report_ops').doc('r-new').get() + expect(updated.data()?.duplicateClusterId).toBeUndefined() + }) + + it('does not cluster reports older than 2h', async () => { + const TWO_H_PLUS_ONE = 2 * 3600000 + 1 + await seedReportOps('r-old', { + locationGeohash: NEARBY_GEOHASH, + createdAt: ts - TWO_H_PLUS_ONE, + }) + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + const snap = makeSnap('r-new', newData) + await duplicateClusterTriggerCore(adminDb, snap) + const updated = await adminDb.collection('report_ops').doc('r-new').get() + expect(updated.data()?.duplicateClusterId).toBeUndefined() + }) + + it('assigns the same existing clusterId when a third report joins a cluster', async () => { + const existingClusterId = 'cluster-uuid-existing' + await seedReportOps('r-first', { + locationGeohash: NEARBY_GEOHASH, + createdAt: ts - 3600000, + duplicateClusterId: existingClusterId, + }) + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + await seedReportOps('r-third', { locationGeohash: DAET_GEOHASH }) + const snap = makeSnap('r-third', newData) + await duplicateClusterTriggerCore(adminDb, snap) + const updated = await adminDb.collection('report_ops').doc('r-third').get() + expect(updated.data()?.duplicateClusterId).toBe(existingClusterId) + }) + + it('skips reports with no locationGeohash', async () => { + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + const snap = makeSnap('r-noloc', newData) + await duplicateClusterTriggerCore(adminDb, snap) + const updated = await adminDb.collection('report_ops').doc('r-noloc').get() + expect(updated.data()?.duplicateClusterId).toBeUndefined() + }) + + it('is safe to run twice (idempotent cluster assignment)', async () => { + await seedReportOps('r-existing', { locationGeohash: NEARBY_GEOHASH, createdAt: ts - 3600000 }) + const newData = { + municipalityId: 'daet', + reportType: 'flood', + status: 'new', + severity: 'high', + createdAt: ts, + updatedAt: ts, + agencyIds: [], + activeResponderCount: 0, + requiresLocationFollowUp: false, + locationGeohash: DAET_GEOHASH, + visibility: { scope: 'municipality', sharedWith: [] }, + schemaVersion: 1, + } + const snap = makeSnap('r-new', newData) + await seedReportOps('r-new', { locationGeohash: DAET_GEOHASH }) + await duplicateClusterTriggerCore(adminDb, snap) + const firstRunSnap = await adminDb.collection('report_ops').doc('r-new').get() + const firstClusterId = firstRunSnap.data()?.duplicateClusterId + + const snap2 = makeSnap('r-new', { ...newData, duplicateClusterId: firstClusterId }) + await duplicateClusterTriggerCore(adminDb, snap2) + const secondRunSnap = await adminDb.collection('report_ops').doc('r-new').get() + expect(secondRunSnap.data()?.duplicateClusterId).toBe(firstClusterId) + }) +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index 46fcb5a1..7a65ee83 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -20,6 +20,7 @@ export { enterFieldMode, exitFieldMode } from './callables/enter-field-mode.js' export { shareReport } from './callables/share-report.js' export { addCommandChannelMessage } from './callables/add-command-channel-message.js' export { borderAutoShareTrigger } from './triggers/border-auto-share.js' +export { duplicateClusterTrigger } from './triggers/duplicate-cluster-trigger.js' // onMediaFinalize is lazily instantiated to avoid triggering Firebase Functions v2 // storage import-time env checks (FIREBASE_CONFIG) during unit testing. diff --git a/functions/src/triggers/duplicate-cluster-trigger.ts b/functions/src/triggers/duplicate-cluster-trigger.ts new file mode 100644 index 00000000..736c3248 --- /dev/null +++ b/functions/src/triggers/duplicate-cluster-trigger.ts @@ -0,0 +1,102 @@ +import { onDocumentCreated } from 'firebase-functions/v2/firestore' +import * as ngeohash from 'ngeohash' +import * as turf from '@turf/turf' +import type { QueryDocumentSnapshot } from 'firebase-admin/firestore' +import { adminDb } from '../admin-init.js' +import { logDimension } from '@bantayog/shared-validators' + +const log = logDimension('duplicateClusterTrigger') + +const NON_TERMINAL_STATUSES = [ + 'new', + 'awaiting_verify', + 'verified', + 'assigned', + 'acknowledged', + 'en_route', + 'on_scene', + 'reopened', +] +const TWO_H_MS = 2 * 60 * 60 * 1000 +const PROXIMITY_METERS = 200 +const BATCH_CAP = 250 + +export async function duplicateClusterTriggerCore( + db: FirebaseFirestore.Firestore, + snap: QueryDocumentSnapshot, +): Promise { + const data = snap.data() + const { + locationGeohash, + municipalityId, + reportType, + createdAt, + duplicateClusterId: existingCluster, + } = data + + if (!locationGeohash || typeof locationGeohash !== 'string') return + + const nowMs: number = typeof createdAt === 'number' ? createdAt : Date.now() + const cutoff = nowMs - TWO_H_MS + + const candidates = await db + .collection('report_ops') + .where('municipalityId', '==', municipalityId) + .where('reportType', '==', reportType) + .where('status', 'in', NON_TERMINAL_STATUSES) + .where('createdAt', '>', cutoff) + .limit(300) + .get() + + const prefix = locationGeohash.slice(0, 6) + const neighborPrefixes = new Set([prefix, ...ngeohash.neighbors(prefix)]) + const triggerPoint = ngeohash.decode(locationGeohash) + const triggerCoord = turf.point([triggerPoint.longitude, triggerPoint.latitude]) + + const nearby = candidates.docs.filter((d) => { + if (d.id === snap.id) return false + const gh = d.data().locationGeohash as string | undefined + if (!gh || !neighborPrefixes.has(gh.slice(0, 6))) return false + const pt = ngeohash.decode(gh) + const dist = turf.distance(turf.point([pt.longitude, pt.latitude]), triggerCoord, { + units: 'meters', + }) + return dist <= PROXIMITY_METERS + }) + + if (nearby.length === 0) return + + const existingClusterFromNearby = nearby.find((d) => d.data().duplicateClusterId)?.data() + .duplicateClusterId as string | undefined + const clusterId = existingCluster ?? existingClusterFromNearby ?? crypto.randomUUID() + + const toUpdate = nearby + .filter((d) => d.data().duplicateClusterId !== clusterId) + .slice(0, BATCH_CAP - 1) + + if (toUpdate.length === 0 && existingCluster === clusterId) return + + const batch = db.batch() + if (existingCluster !== clusterId) { + batch.update(snap.ref, { duplicateClusterId: clusterId }) + } + for (const d of toUpdate) { + batch.update(d.ref, { duplicateClusterId: clusterId }) + } + await batch.commit() + + log({ + severity: 'INFO', + code: 'dup.cluster.assigned', + message: `Assigned ${String(toUpdate.length + 1)} docs to cluster ${String(clusterId)}`, + }) +} + +export const duplicateClusterTrigger = onDocumentCreated( + { document: 'report_ops/{reportId}', region: 'asia-southeast1' }, + async (event) => { + const snap = event.data + if (!snap) return + await duplicateClusterTriggerCore(adminDb, snap) + }, +) diff --git a/infra/firebase/firestore.indexes.json b/infra/firebase/firestore.indexes.json index 11fafb86..8ad02aec 100644 --- a/infra/firebase/firestore.indexes.json +++ b/infra/firebase/firestore.indexes.json @@ -287,6 +287,16 @@ { "fieldPath": "zoneType", "order": "ASCENDING" }, { "fieldPath": "deletedAt", "order": "ASCENDING" } ] + }, + { + "collectionGroup": "report_ops", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "municipalityId", "order": "ASCENDING" }, + { "fieldPath": "reportType", "order": "ASCENDING" }, + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "ASCENDING" } + ] } ], "fieldOverrides": [] From eb8b5d6602071484519243f2606f3c40c0d55583 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 13:03:40 +0800 Subject: [PATCH 09/28] feat(sweep): extend adminOperationsSweep with shift handoff escalation path --- .../scheduled/admin-operations-sweep.test.ts | 47 +++++++++++++++++-- .../src/scheduled/admin-operations-sweep.ts | 23 +++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/functions/src/__tests__/scheduled/admin-operations-sweep.test.ts b/functions/src/__tests__/scheduled/admin-operations-sweep.test.ts index 453578fc..6883850f 100644 --- a/functions/src/__tests__/scheduled/admin-operations-sweep.test.ts +++ b/functions/src/__tests__/scheduled/admin-operations-sweep.test.ts @@ -52,12 +52,12 @@ describe('adminOperationsSweep — agency assistance escalation', () => { priority: 'normal', fulfilledByDispatchIds: [], expiresAt: ts + 3600000, - schemaVersion: 1, + escalatedAt: null, }) }) await adminOperationsSweepCore(adminDb, { now: Timestamp.fromMillis(ts) }) const snap = await adminDb.collection('agency_assistance_requests').doc('ar1').get() - expect(snap.data()?.escalatedAt).toBeUndefined() + expect(snap.data()?.escalatedAt).toBeNull() }) it('sets escalatedAt on requests pending over 30 minutes', async () => { @@ -74,7 +74,7 @@ describe('adminOperationsSweep — agency assistance escalation', () => { priority: 'normal', fulfilledByDispatchIds: [], expiresAt: ts + 3600000, - schemaVersion: 1, + escalatedAt: null, }) }) await adminOperationsSweepCore(adminDb, { now: Timestamp.fromMillis(ts) }) @@ -88,7 +88,6 @@ describe('adminOperationsSweep — agency assistance escalation', () => { await setDoc(doc(ctx.firestore(), 'agency_assistance_requests', 'ar1'), { status: 'pending', createdAt: ts - THIRTY_MIN_MS - 1, - escalatedAt: originalEscalatedAt, reportId: 'r1', requestedByMunicipalId: 'daet', requestedByMunicipality: 'Daet', @@ -98,7 +97,7 @@ describe('adminOperationsSweep — agency assistance escalation', () => { priority: 'normal', fulfilledByDispatchIds: [], expiresAt: ts + 3600000, - schemaVersion: 1, + escalatedAt: originalEscalatedAt, }) }) await adminOperationsSweepCore(adminDb, { now: Timestamp.fromMillis(ts) }) @@ -106,3 +105,41 @@ describe('adminOperationsSweep — agency assistance escalation', () => { expect(snap.data()?.escalatedAt).toBe(originalEscalatedAt) // unchanged }) }) + +describe('adminOperationsSweep — shift handoff escalation', () => { + it('ignores handoffs pending less than 30 minutes', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'shift_handoffs', 'h1'), { + fromUid: 'admin-1', + municipalityId: 'daet', + notes: '', + activeIncidentSnapshot: [], + status: 'pending', + createdAt: ts - THIRTY_MIN_MS + 60000, + expiresAt: ts + 1800000, + escalatedAt: null, + }) + }) + await adminOperationsSweepCore(adminDb, { now: Timestamp.fromMillis(ts) }) + const snap = await adminDb.collection('shift_handoffs').doc('h1').get() + expect(snap.data()?.escalatedAt).toBeNull() + }) + + it('sets escalatedAt on handoffs pending over 30 minutes', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'shift_handoffs', 'h1'), { + fromUid: 'admin-1', + municipalityId: 'daet', + notes: '', + activeIncidentSnapshot: [], + status: 'pending', + createdAt: ts - THIRTY_MIN_MS - 1, + expiresAt: ts + 1800000, + escalatedAt: null, + }) + }) + await adminOperationsSweepCore(adminDb, { now: Timestamp.fromMillis(ts) }) + const snap = await adminDb.collection('shift_handoffs').doc('h1').get() + expect(snap.data()?.escalatedAt).toBe(ts) + }) +}) diff --git a/functions/src/scheduled/admin-operations-sweep.ts b/functions/src/scheduled/admin-operations-sweep.ts index 9dbd87ea..64cbeab7 100644 --- a/functions/src/scheduled/admin-operations-sweep.ts +++ b/functions/src/scheduled/admin-operations-sweep.ts @@ -40,6 +40,29 @@ export async function adminOperationsSweepCore( }), ) } + + // Shift handoff escalation: pending > 30min with no escalatedAt + const pendingHandoffs = await db + .collection('shift_handoffs') + .where('status', '==', 'pending') + .where('createdAt', '<', cutoff) + .where('escalatedAt', '==', null) + .get() + + const toEscalateHandoffs = pendingHandoffs.docs + for (let i = 0; i < toEscalateHandoffs.length; i += BATCH_SIZE) { + const batch = toEscalateHandoffs.slice(i, i + BATCH_SIZE) + await Promise.all( + batch.map(async (d) => { + await d.ref.update({ escalatedAt: deps.now.toMillis() }) + log({ + severity: 'INFO', + code: 'sweep.handoff.escalated', + message: `Escalated handoff ${d.id}`, + }) + }), + ) + } } export const adminOperationsSweep = onSchedule( From 71cc33c3ef59b13c12213aa11804b8f457e0c7af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 13:08:31 +0800 Subject: [PATCH 10/28] feat(admin-desktop): ShiftHandoffModal + incoming handoff banner for A.3 --- .../__tests__/shift-handoff-modal.test.tsx | 80 +++++++++++++++++ .../src/__tests__/triage-queue.test.tsx | 4 + .../src/hooks/usePendingHandoffs.ts | 37 ++++++++ .../src/pages/TriageQueuePage.tsx | 88 ++++++++++++++++++- apps/admin-desktop/src/services/callables.ts | 14 +++ 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx create mode 100644 apps/admin-desktop/src/hooks/usePendingHandoffs.ts diff --git a/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx b/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx new file mode 100644 index 00000000..9d0898ec --- /dev/null +++ b/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +const mockInitiateHandoff = vi.hoisted(() => vi.fn()) + +vi.mock('../app/firebase', () => ({ db: {} })) + +vi.mock('@bantayog/shared-ui', () => ({ + useAuth: () => ({ + claims: { municipalityId: 'daet', role: 'municipal_admin' }, + signOut: vi.fn(), + }), +})) + +vi.mock('../services/callables', () => ({ + callables: { + verifyReport: vi.fn(), + rejectReport: vi.fn(), + initiateShiftHandoff: mockInitiateHandoff, + acceptShiftHandoff: vi.fn(), + }, +})) + +vi.mock('../hooks/useMuniReports', () => ({ + useMuniReports: () => ({ + reports: [], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }), +})) + +vi.mock('../hooks/usePendingHandoffs', () => ({ + usePendingHandoffs: () => [], +})) + +vi.mock('../pages/ReportDetailPanel', () => ({ ReportDetailPanel: () =>
detail
})) +vi.mock('../pages/DispatchModal', () => ({ DispatchModal: () =>
dispatch
})) +vi.mock('../pages/CloseReportModal', () => ({ CloseReportModal: () =>
close
})) + +import { TriageQueuePage } from '../pages/TriageQueuePage' + +describe('ShiftHandoffModal', () => { + beforeEach(() => { + mockInitiateHandoff.mockResolvedValue({ success: true, handoffId: 'h-new-1' }) + }) + + it('renders Start Handoff button in header', () => { + render() + expect(screen.getByRole('button', { name: /start handoff/i })).toBeInTheDocument() + }) + + it('opens ShiftHandoffModal on Start Handoff click', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /start handoff/i })) + expect(screen.getByRole('dialog', { name: /shift handoff/i })).toBeInTheDocument() + }) + + it('calls initiateShiftHandoff on Initiate click', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /start handoff/i })) + const notesField = screen.getByLabelText(/notes/i) + await user.type(notesField, 'End of day shift') + await user.click(screen.getByRole('button', { name: /initiate/i })) + expect(mockInitiateHandoff).toHaveBeenCalledWith( + expect.objectContaining({ notes: 'End of day shift' }), + ) + }) +}) + +describe('Incoming handoff banner', () => { + it('shows no banner when no pending handoffs', () => { + render() + expect(screen.queryByRole('button', { name: /accept handoff/i })).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx index 5a19a9b4..127ffc15 100644 --- a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx +++ b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx @@ -27,6 +27,10 @@ vi.mock('../services/callables', () => ({ }, })) +vi.mock('../hooks/usePendingHandoffs', () => ({ + usePendingHandoffs: () => [], +})) + vi.mock('../pages/ReportDetailPanel', () => ({ ReportDetailPanel: () =>
detail
, })) diff --git a/apps/admin-desktop/src/hooks/usePendingHandoffs.ts b/apps/admin-desktop/src/hooks/usePendingHandoffs.ts new file mode 100644 index 00000000..dd090cbb --- /dev/null +++ b/apps/admin-desktop/src/hooks/usePendingHandoffs.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import { collection, onSnapshot, query, where, type Timestamp } from 'firebase/firestore' +import { db } from '../app/firebase' + +export interface PendingHandoff { + id: string + fromUid: string + createdAt: Timestamp + notes: string + activeIncidentSnapshot: string[] +} + +export function usePendingHandoffs(municipalityId: string | undefined) { + const [handoffs, setHandoffs] = useState([]) + + useEffect(() => { + if (!municipalityId) return + const q = query( + collection(db, 'shift_handoffs'), + where('municipalityId', '==', municipalityId), + where('status', '==', 'pending'), + ) + return onSnapshot(q, (snap) => { + setHandoffs( + snap.docs.map((d) => ({ + id: d.id, + fromUid: String(d.data().fromUid), + createdAt: d.data().createdAt as Timestamp, + notes: String(d.data().notes ?? ''), + activeIncidentSnapshot: (d.data().activeIncidentSnapshot ?? []) as string[], + })), + ) + }) + }, [municipalityId]) + + return handoffs +} diff --git a/apps/admin-desktop/src/pages/TriageQueuePage.tsx b/apps/admin-desktop/src/pages/TriageQueuePage.tsx index 4dad408e..c5d194b6 100644 --- a/apps/admin-desktop/src/pages/TriageQueuePage.tsx +++ b/apps/admin-desktop/src/pages/TriageQueuePage.tsx @@ -5,6 +5,7 @@ import { ReportDetailPanel } from './ReportDetailPanel' import { DispatchModal } from './DispatchModal' import { CloseReportModal } from './CloseReportModal' import { callables } from '../services/callables' +import { usePendingHandoffs } from '../hooks/usePendingHandoffs' export function TriageQueuePage() { const { claims, signOut } = useAuth() @@ -15,6 +16,10 @@ export function TriageQueuePage() { const [dispatchForReportId, setDispatchForReportId] = useState(null) const [closeForReportId, setCloseForReportId] = useState(null) const [banner, setBanner] = useState(null) + const [handoffModalOpen, setHandoffModalOpen] = useState(false) + const [handoffNotes, setHandoffNotes] = useState('') + const [handoffLoading, setHandoffLoading] = useState(false) + const pendingHandoffs = usePendingHandoffs(municipalityId) const handleVerify = (reportId: string) => { void (async () => { @@ -82,9 +87,46 @@ export function TriageQueuePage() {

Triage · {municipalityId ?? 'N/A'}

- + +
{banner &&
{banner}
} + {pendingHandoffs.length > 0 && ( +
+ {pendingHandoffs.length} pending handoff(s) awaiting acceptance. + {pendingHandoffs.map((h) => ( + + ))} +
+ )}

Queue

@@ -150,6 +192,50 @@ export function TriageQueuePage() { }} /> )} + {handoffModalOpen && ( + +

Initiate Shift Handoff

+ +