diff --git a/.beads/.br_recovery/beads.db-shm.20260407_203021_124243331.bak b/.beads/.br_recovery/beads.db-shm.20260407_203021_124243331.bak new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/.beads/.br_recovery/beads.db-shm.20260407_203021_124243331.bak differ diff --git a/.beads/.br_recovery/beads.db-shm.20260407_203339_843371201.bak b/.beads/.br_recovery/beads.db-shm.20260407_203339_843371201.bak new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/.beads/.br_recovery/beads.db-shm.20260407_203339_843371201.bak differ diff --git a/.beads/.br_recovery/beads.db-shm.20260407_203640_299728987.bak b/.beads/.br_recovery/beads.db-shm.20260407_203640_299728987.bak new file mode 100644 index 0000000..5c6e15f Binary files /dev/null and b/.beads/.br_recovery/beads.db-shm.20260407_203640_299728987.bak differ diff --git a/.beads/.br_recovery/beads.db-wal.20260407_203021_124243331.bak b/.beads/.br_recovery/beads.db-wal.20260407_203021_124243331.bak new file mode 100644 index 0000000..e69de29 diff --git a/.beads/.br_recovery/beads.db-wal.20260407_203339_843371201.bak b/.beads/.br_recovery/beads.db-wal.20260407_203339_843371201.bak new file mode 100644 index 0000000..e69de29 diff --git a/.beads/.br_recovery/beads.db-wal.20260407_203640_299728987.bak b/.beads/.br_recovery/beads.db-wal.20260407_203640_299728987.bak new file mode 100644 index 0000000..80ba054 Binary files /dev/null and b/.beads/.br_recovery/beads.db-wal.20260407_203640_299728987.bak differ diff --git a/.beads/.br_recovery/beads.db.20260407_203021_124243331.bak b/.beads/.br_recovery/beads.db.20260407_203021_124243331.bak new file mode 100644 index 0000000..648484b Binary files /dev/null and b/.beads/.br_recovery/beads.db.20260407_203021_124243331.bak differ diff --git a/.beads/.br_recovery/beads.db.20260407_203339_843371201.bak b/.beads/.br_recovery/beads.db.20260407_203339_843371201.bak new file mode 100644 index 0000000..589fded Binary files /dev/null and b/.beads/.br_recovery/beads.db.20260407_203339_843371201.bak differ diff --git a/.beads/.br_recovery/beads.db.20260407_203640_299728987.bak b/.beads/.br_recovery/beads.db.20260407_203640_299728987.bak new file mode 100644 index 0000000..4cf5467 Binary files /dev/null and b/.beads/.br_recovery/beads.db.20260407_203640_299728987.bak differ diff --git a/.beads/.br_recovery/beads.db.20260408_193915_955995288.bak b/.beads/.br_recovery/beads.db.20260408_193915_955995288.bak new file mode 100644 index 0000000..3a85234 Binary files /dev/null and b/.beads/.br_recovery/beads.db.20260408_193915_955995288.bak differ diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4d618ac..26ba2e0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -33,7 +33,7 @@ {"id":"spaxel-7nk","title":"fix: sleep/handler.go cannot index interface{} map value","description":"## Problem\n`internal/sleep/handler.go` lines 229 and 232 fail with: `cannot index result[\"metrics\"] (map index expression of type interface{})`\n\nThe `result` map is of type `map[string]interface{}`, so `result[\"metrics\"]` returns `interface{}`, which cannot be directly indexed.\n\n## Fix\nAdd a type assertion before indexing. Around lines 229-232 in `internal/sleep/handler.go`:\n```go\n// Before the two if-blocks, get a typed reference:\nif metricsMap, ok := result[\"metrics\"].(map[string]interface{}); ok {\n if !metrics.SleepStartTime.IsZero() {\n metricsMap[\"sleep_start_time\"] = metrics.SleepStartTime.Format(\"15:04\")\n }\n if !metrics.SleepEndTime.IsZero() {\n metricsMap[\"sleep_end_time\"] = metrics.SleepEndTime.Format(\"15:04\")\n }\n}\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/sleep/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:05.128489582Z","created_by":"coding","updated_at":"2026-04-06T22:40:47.430043249Z","closed_at":"2026-04-06T22:40:47.429779459Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-7qo","title":"Dashboard: WebSocket reconnection with exponential backoff and state management","description":"## Overview\nImplement robust client-side WebSocket reconnection with exponential backoff, jitter, and visual state transitions during disconnects.\n\n## Reconnection logic (dashboard/js/websocket.js or app.js)\n- Backoff sequence: 1s, 2s, 4s, 8s, max 10s; ±500ms random jitter on each attempt\n- Track disconnect_duration_ms from first disconnect event\n\n## Visual state transitions:\nDisconnect < 5s: silent (no UI change); blob positions extrapolated from last velocity\nDisconnect 5-30s: 3D scene dims to 50% opacity; 'Reconnecting...' spinner in status bar; user interaction disabled\nDisconnect > 30s: non-blocking modal: 'Connection lost — [Reload Page]'; allow viewing stale scene\n\n## Blob position extrapolation (<5s):\n- On disconnect: record last_position and last_velocity per blob\n- Each animation frame: position = last_position + last_velocity × elapsed_s (capped at 2s extrapolation)\n\n## On successful reconnect:\n- Clear all blob trails (path history lines)\n- Apply snapshot from first WebSocket message (spaxel-fll)\n- Restore scene opacity to 100%\n- Dismiss spinner and modal\n- Log 'Reconnected after Xs' to console\n\n## Acceptance\n- Disconnect for 3s: no visual change; blobs continue moving smoothly\n- Disconnect for 10s: scene dims, spinner shown\n- Disconnect for 35s: modal appears; scene still visible\n- Reconnect: modal dismissed, trails cleared, scene snaps to current state within 200ms\n- Requires: spaxel-fll (snapshot protocol), spaxel-896 (panel framework)","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-06T16:44:33.446200584Z","created_by":"coding","updated_at":"2026-04-07T16:45:31.143915724Z","closed_at":"2026-04-07T16:45:31.143823228Z","close_reason":"WebSocket reconnection with exponential backoff and visual state management was already fully implemented in commit ff3428f. All acceptance criteria met: backoff 1s-10s with ±500ms jitter, <5s silent extrapolation, 5-30s dimming with spinner, >30s modal with reload button, reconnect clears trails and restores scene from snapshot.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-7x2","title":"Wire anomaly detection & security mode API endpoints","description":"## Backend\n\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## Acceptance\n- Endpoints return correct JSON structure\n- Anomaly events push to WS feed as 'alert' messages\n- Arm/disarm state persists across server restarts","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:09:35.812256758Z","created_by":"coding","updated_at":"2026-04-07T19:17:55.756354374Z","closed_at":"2026-04-07T19:17:55.756259632Z","close_reason":"All acceptance criteria verified and already committed in b1c2218. AnomalyDetector initialized in main() with 6h periodic updates. Anomaly events broadcast to dashboard WS as alert messages. GET /api/anomalies?since=24h, POST /api/security/arm, POST /api/security/disarm, GET /api/security/status all wired and tested. Arm/disarm state persisted to learning_state table and restored on restart. All 14 related tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} -{"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"hotel","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-04-09T11:27:48.353978798Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:10"]} +{"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"hotel","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-04-09T11:37:58.184558436Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:10"]} {"id":"spaxel-896","title":"Build dashboard panel/modal/sidebar UI framework","description":"## Problem\n\nThe dashboard is currently a single live 3D view with no panel system. All Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions) requires a panel/sidebar framework to hang on.\n\n## What to build\n\n### Panel System (dashboard/js/panels.js)\n- Slide-in sidebar (right, 360px) with close button and title\n- Modal overlay (centered, 600px wide) for forms and wizards\n- Toast notification stack (bottom-right)\n- Panel registry: panels can be opened by name from anywhere in the app\n\n### Route/Mode Navigation (dashboard/js/router.js)\n- Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay\n- Mode toggle bar in the header: Live | Timeline | Automations | Settings\n- Active mode preserved across page refresh (localStorage)\n\n### State Management (dashboard/js/state.js)\n- Central app state object (nodes, blobs, zones, links, alerts, events, ble_devices, triggers)\n- Subscribe/notify pattern for components to react to state changes\n- Separate from WebSocket message parsing\n\n### Settings Panel (dashboard/js/settings-panel.js)\n- Motion threshold slider\n- Sensing rate override\n- Notification channel config (Ntfy URL, Pushover token)\n- System info (version, uptime, node count)\n\n## Acceptance\n\n- Panel opens/closes smoothly with CSS transitions\n- Route changes update the active view without page reload\n- Settings panel reads from GET /api/settings and saves via POST /api/settings\n- All existing 3D live view functionality unaffected","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:55:28.903260636Z","created_by":"coding","updated_at":"2026-04-06T14:08:18.251230378Z","closed_at":"2026-04-06T14:08:18.250924137Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-8u3","title":"Fleet manager with SQLite persistence","description":"Node registry, role assignment engine, and self-healing.\n\n## Deliverables\n- New package: mothership/internal/fleet/\n- SQLite node registry (MAC, ID, role, last seen, health, position)\n- Role assignment engine (TX/RX/passive/TX-RX including passive radar virtual node)\n- Stagger scheduling for multi-node packet timing\n- Self-healing: auto role reassignment on node loss, graceful degradation warnings\n- REST API endpoints: GET /api/nodes, GET /api/nodes/:mac, POST /api/nodes/:mac/role\n\n## Acceptance Criteria\n- Node state persists across mothership restarts\n- Roles auto-reassign when a node goes offline\n- Stagger scheduling prevents packet collisions\n- Tests cover registration, role assignment, and failure recovery\n\n## References\n- Plan: docs/plan/plan.md items 14\n- SQLite: modernc.org/sqlite (pure Go, already in go.mod intent)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:38.835804826Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.132787526Z","closed_at":"2026-03-28T05:36:26.132727724Z","close_reason":"Implemented: fleet/manager.go + fleet/registry.go (fb69190) — SQLite node registry, role assignment engine, stagger scheduling, self-healing role reassignment on node loss","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-8u3","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.704767150Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-9eg","title":"Expand dashboard WebSocket feed: events, alerts, anomalies, triggers, BLE","description":"## Problem\n\nThe dashboard WebSocket (/ws/dashboard) currently only sends blob/node/zone/link/confidence/predictions/sleep/flow state. Events, alerts, anomalies, triggers, and BLE device data are never pushed to the dashboard, making Phase 6-9 UI impossible without a polling API.\n\n## What to add to the WS feed\n\nIn mothership/internal/dashboard/ (hub.go or server.go):\n\n### New message types to broadcast:\n\n**event** — presence transitions, zone entries/exits, portal crossings\n { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }\n\n**alert** — anomaly detections, security mode triggers\n { type: 'alert', alert: { id, ts, severity, description, acknowledged } }\n\n**ble_scan** — BLE device list updates (5s interval)\n { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }\n\n**trigger_state** — automation trigger state changes\n { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }\n\n**system_health** — periodic system stats (60s interval)\n { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }\n\n### In dashboard JS (app.js):\n- Handle each new message type in the WebSocket onmessage handler\n- Update app state for each type\n- Log unhandled types to console (for future debugging)\n\n## Acceptance\n\n- All 5 new message types appear in browser devtools WebSocket inspector\n- BLE device list updates every 5s when devices are present\n- Events appear within 1s of a zone transition\n- Existing blob/node/link messages unaffected","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T12:55:40.859267153Z","created_by":"coding","updated_at":"2026-04-07T15:20:38.722641396Z","closed_at":"2026-04-07T15:20:38.722538891Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-28k","type":"blocks","created_at":"2026-04-06T14:18:27.421346709Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-2ea","type":"blocks","created_at":"2026-04-06T14:18:27.498282335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-fyi","type":"blocks","created_at":"2026-04-06T14:18:27.643320410Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-hf8","type":"blocks","created_at":"2026-04-06T14:18:27.581630865Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-ncw","type":"blocks","created_at":"2026-04-06T14:18:27.696083142Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -63,7 +63,7 @@ {"id":"spaxel-g1o","title":"Anomaly detection: 7-day pattern learning algorithm","description":"## Overview\nImplement the statistical pattern learning engine for anomaly detection — per-zone, per-hour-of-day, per-day-of-week occupancy modeling using Welford's online algorithm.\n\n## Backend (mothership/analytics/ or signal/)\n- Pattern model: per (zone_id, hour_of_day, day_of_week): mean_count, variance, sample_count via Welford's algorithm\n- Hourly update goroutine: every hour, observe zone occupancy counts and update model\n- Cold start: suppress all anomaly alerts for 7 days; model slot 'ready' when sample_count >= 50\n- Anomaly scoring:\n - z_score = (observed_count - mean) / sqrt(variance + epsilon)\n - time_score = normalized z_score for this hour/day combo\n - zone_score = 1.0 if zone normally empty at this time, else 0.0\n - composite_score = max(time_score, zone_score) with fallback\n - threshold: alert if composite > 0.85; yellow warning at 0.60\n- Outlier protection: skip model update when anomaly_score >= 0.5 (don't learn from anomalies)\n- Security mode override: any detection = score 1.0 regardless of model\n- SQLite anomaly_patterns table: zone_id, hour_of_day (0-23), day_of_week (0-6), mean_count REAL, variance REAL, sample_count INT, updated_at INT\n\n## REST API\n- GET /api/anomalies?since=24h — list recent anomaly events with scores\n- GET /api/anomaly_patterns?zone= — inspect pattern model for debugging\n\n## Acceptance\n- Pattern model survives server restart (persisted to SQLite)\n- No alerts during 7-day cold start regardless of activity\n- Welford update is numerically stable: no NaN/Inf at any sample count\n- Outlier protection confirmed: injecting synthetic anomaly does not corrupt model after 3 occurrences\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:02:39.580201662Z","created_by":"coding","updated_at":"2026-04-07T01:28:23.140993262Z","closed_at":"2026-04-07T01:28:23.140700890Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-glq","title":"fix: apdetector imports wrong module prefix (jedarden vs spaxel)","description":"## Problem\n`internal/apdetector/detector.go:14` imports `github.com/jedarden/spaxel/mothership/internal/oui` but the module is `github.com/spaxel/mothership`.\n\n## Fix\nIn `mothership/internal/apdetector/detector.go` line 14, change:\n```go\n\"github.com/jedarden/spaxel/mothership/internal/oui\"\n```\nto:\n```go\n\"github.com/spaxel/mothership/internal/oui\"\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/apdetector/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:29:41.749357378Z","created_by":"coding","updated_at":"2026-04-06T22:32:46.587104774Z","closed_at":"2026-04-06T22:32:46.586900234Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-goc","title":"BLE device discovery & registration dashboard panel","description":"## Overview\nPeople & Devices panel for discovering, registering, and labeling BLE devices seen by the fleet.\n\n## Dashboard (dashboard/js/ble-panel.js)\n- 'People & Devices' panel (via spaxel-896 panel framework)\n- Discovered devices list: sorted by sighting frequency; shows MAC (truncated), name, RSSI, last seen, type icon\n- Registration UI: click device → assign label, type (person/pet/object), color\n- Auto-type hints from manufacturer data: iPhone, Apple Watch, Fitbit, Tile, AirTag\n- Manual pre-registration by address (for tracker tags not yet seen)\n- Unregistered count badge on panel toggle button\n\n## Backend\n- SQLite ble_devices table: addr, label, type, color, icon, first_seen, last_seen, last_rssi, sighting_count\n- GET /api/ble/devices?registered=true|false — filter registered vs discovered\n- PUT /api/ble/devices/{mac} — set label, type, color, assign to person\n- GET /api/ble/devices/{mac}/history — sighting timeline\n\n## Acceptance\n- Panel shows all devices seen in last 24h by default\n- Label assignment persists across server restart\n- Registered devices show up with name in 3D blob labels and timeline events\n- Requires: spaxel-896 (panel framework), spaxel-9eg (BLE WS feed)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:29.882665390Z","created_by":"coding","updated_at":"2026-04-06T19:21:57.494710305Z","closed_at":"2026-04-06T19:21:57.494608982Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} -{"id":"spaxel-h58","title":"Add dashboard identify buttons","description":"Add 'Identify' button to fleet status page that POSTs to /api/nodes/{mac}/identify. Add 'Identify (blink LED)' context menu option on right-click in 3D view.\n\n**Acceptance:**\n- Fleet status page has 'Identify' button per row\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T10:58:29.796981551Z","created_by":"coding","updated_at":"2026-04-09T11:32:38.640171467Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-lve"],"dependencies":[{"issue_id":"spaxel-h58","depends_on_id":"spaxel-783","type":"blocks","created_at":"2026-04-09T11:11:50.023008981Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-h58","depends_on_id":"spaxel-jkw","type":"blocks","created_at":"2026-04-09T11:11:50.070094371Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-h58","title":"Add dashboard identify buttons","description":"Add 'Identify' button to fleet status page that POSTs to /api/nodes/{mac}/identify. Add 'Identify (blink LED)' context menu option on right-click in 3D view.\n\n**Acceptance:**\n- Fleet status page has 'Identify' button per row\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T10:58:29.796981551Z","created_by":"coding","updated_at":"2026-04-09T11:41:41.896671808Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-lve"],"dependencies":[{"issue_id":"spaxel-h58","depends_on_id":"spaxel-783","type":"blocks","created_at":"2026-04-09T11:11:50.023008981Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-h58","depends_on_id":"spaxel-jkw","type":"blocks","created_at":"2026-04-09T11:11:50.070094371Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-hey","title":"CSI recording buffer","description":"## Background\n\nThe CSI recording buffer is the foundation for time-travel debugging (Phase 8, spaxel-pvz). Every CSI binary frame received from a node should be persisted to disk in real-time. The plan specifies 48-hour retention. This bead implements the recording infrastructure and wires it into the ingestion server. Starting data collection in Phase 2 means real CSI data accumulates from day one, invaluable for debugging Phase 3+ algorithms offline.\n\n## What to Implement\n\nNew package: mothership/internal/recorder/\n\n### Segment file design\nUse 1-hour segment files per link: data/{nodeMAC}-{peerMAC}/{YYYYMMDD-HH}.csi. Each file is append-only. Frame format: [4-byte big-endian length][raw CSI binary frame bytes]. Background goroutine deletes segment files older than 48h (configurable via RecorderConfig.RetentionHours).\n\n### API\n- recorder.Manager — manages per-link recorders, one goroutine per link\n- recorder.Manager.Write(linkID string, frame []byte) — called from ingestion server per frame\n- recorder.Manager.ReadFrom(linkID string, since time.Time) <-chan []byte — returns channel of frames in chronological order from 'since' timestamp; closes channel when caught up to current time\n- recorder.Manager.AvailableRange(linkID string) (start, end time.Time, err error) — oldest and newest frame timestamps\n- recorder.Manager.Close() — graceful shutdown\n\n### Storage estimate\nAt 2Hz idle: ~176 bytes/frame × 2/s × 3600s × 48h = ~60MB/link/48h. At 50Hz active for 1h/day: add ~30MB. Total for 4 links × 48h ≈ 360MB–720MB. Configure via RecorderConfig.MaxBytesPerLink (default 1GB) as a secondary guard.\n\n### Wire-up\nIn mothership/internal/ingestion/server.go, after parsing a valid binary CSI frame (in the existing frame parsing path), call recorder.Manager.Write(linkID, rawFrameBytes). The recorder must not block the ingestion goroutine — use a buffered channel (capacity 1000 frames) per link.\n\n## Key Files\n- mothership/internal/ingestion/server.go — add recorder.Write call after frame parse\n- mothership/internal/ingestion/frame.go — frame parsing reference\n- New: mothership/internal/recorder/manager.go, recorder/segment.go, recorder/segment_test.go, recorder/manager_test.go\n\n## Acceptance Criteria\n- CSI frames written to segment files in real-time (< 10ms write latency)\n- ReadFrom correctly replays frames in timestamp order\n- Segment files older than RetentionHours deleted automatically\n- Write does not block ingestion goroutine (buffered channel, drops with warning if full)\n- go test ./internal/recorder/... passes","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T03:29:50.502500283Z","created_by":"coding","updated_at":"2026-03-28T04:28:24.975258849Z","closed_at":"2026-03-28T04:28:24.974961809Z","close_reason":"CSI recording buffer already implemented in commit 0816a5c. All components complete: recorder/segment.go (append-only 1-hour segment files), recorder/manager.go (per-link buffered recording with Write/ReadFrom/AvailableRange/Close, 48h retention, 1GB/link limit), full test coverage (20 tests passing), wired into ingestion server.go and main.go.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-hf8","title":"Add BLE scan messages to WebSocket feed","description":"Add 'ble_scan' message type to /ws/dashboard for BLE device list updates every 5s. Broadcast: { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }. Handle in app.js onmessage. Updates every 5s when devices present.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.545878314Z","created_by":"coding","updated_at":"2026-04-07T12:24:17.785369902Z","closed_at":"2026-04-07T12:24:17.785105015Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-hgm","title":"Spatial automation builder","description":"## Background\n\nThe ultimate value of presence detection is actionability. Users want their smart home to respond to who is where: lights on when someone enters, alerts when the baby leaves the nursery, notifications when the house is empty. The spatial automation builder provides a no-code interface for creating these rules, with webhook and MQTT delivery. It is the bridge between spaxel's physical sensing and the broader smart home ecosystem.\n\n## Automation Structure\n\nAn automation has three parts: trigger, optional conditions, and one or more actions. All three are stored as JSON in SQLite, allowing flexible extension without schema migrations.\n\nTrigger types:\n- zone_enter: fires when a person (or anyone) enters a named zone or custom trigger volume\n- zone_leave: fires when a person (or anyone) leaves a named zone\n- zone_dwell: fires when a person has been in a zone continuously for > N minutes (configurable threshold)\n- zone_vacant: fires when a zone transitions from occupied to empty (last person leaves)\n- person_count_change: fires when zone occupant count crosses a threshold (e.g. count goes from 2 to 3)\n- fall_detected: fires when fall detection (spaxel-zvs fall bead) fires for a person in a zone\n- anomaly: fires when anomaly detector (Phase 7) fires an anomaly event\n- ble_device_present: fires when a specific BLE device (or any labelled device) is first seen in a scan cycle (useful for \"arrive home\" detection)\n- ble_device_absent: fires when a specific BLE device has not been seen for > N minutes (useful for \"left home\" detection)\n\nCondition filters:\n- person_filter: specific person_id (or \"anyone\")\n- time_window: ISO 8601 time range (e.g. \"22:00-07:00\" for night)\n- day_of_week: bitmask (0=Sun, 1=Mon, ... 6=Sat)\n- system_mode: home, away, sleep (modes set by user or auto-detected)\n- zone_occupancy: additional zone occupancy condition (e.g. \"only if Living Room is empty\")\n\nAction types:\n- webhook: POST to a user-configured URL with JSON payload\n- mqtt_publish: publish to a topic with a payload (uses the MQTT client from home automation integration bead)\n- ntfy: send a push notification via ntfy (self-hosted or ntfy.sh)\n- pushover: send a Pushover notification\n\nPayload templating for all action types supports these variables:\n{{person_name}}, {{zone_name}}, {{from_zone}}, {{to_zone}}, {{timestamp}}, {{occupant_count}}, {{event_type}}, {{person_color}}, {{confidence}}\n\nExample webhook payload template:\n{\"text\": \"{{person_name}} entered {{zone_name}} at {{timestamp}}\", \"color\": \"{{person_color}}\"}\n\n## 3D Trigger Volumes\n\nIn addition to named zones, automations can use arbitrary 3D cuboid volumes as their spatial target. These are drawn in the 3D editor like zone bounding boxes but are not associated with a zone name — they exist only for automation triggers. Rendered as dashed-outline cuboids (not filled) in the 3D scene with the automation name as label.\n\nSQLite schema: trigger_volumes (id, automation_id FK, name, bounds_min_xyz, bounds_max_xyz)\n\nThe crossing detection and occupancy logic from the portals bead (spaxel-qlh) is reused — trigger volumes use the same containment test.\n\n## Automations SQLite Schema\n\nCREATE TABLE automations (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n enabled BOOLEAN DEFAULT TRUE,\n trigger_type TEXT NOT NULL,\n trigger_config TEXT NOT NULL, -- JSON: {\"zone_id\":\"...\",\"person_id\":\"anyone\",\"dwell_minutes\":5}\n conditions TEXT, -- JSON array of condition objects\n actions TEXT NOT NULL, -- JSON array of action objects\n last_fired DATETIME,\n fire_count INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n## AutomationEngine\n\nNew struct: mothership/internal/automation/engine.go\n\nAutomationEngine subscribes to the internal event bus (Phase 8, spaxel-sl2 activity timeline — the event bus is implemented there). On each event, it:\n1. Finds all enabled automations whose trigger_type matches the event type\n2. For each matching automation, evaluates all conditions\n3. If all conditions pass: fires the actions\n\nCondition evaluation:\n- time_window: parse \"HH:MM-HH:MM\", check if current time (in server timezone) is within range. Handle overnight ranges (22:00-07:00 spans midnight).\n- day_of_week: check current day against bitmask\n- person_filter: check event.PersonID against filter (or \"anyone\" wildcard)\n- system_mode: check current SystemMode (stored in mothership state)\n\nAction execution:\n- webhook: goroutine with 5s timeout, HTTP POST with rendered payload. Retry once after 30s on 5xx response. Log all requests and responses.\n- mqtt_publish: synchronous publish if MQTT client is connected. If not connected: log \"MQTT not configured\" and skip.\n- ntfy/pushover: use the notification module (Phase 6 spatial context notifications bead).\n\nAction results (success/failure/timeout) are logged in SQLite: action_log (automation_id, fired_at, event_json, actions_results_json).\n\n## Dashboard UI\n\nAutomations management page (route /automations):\n- List view: table of all automations with name, trigger type, enabled toggle, last fired timestamp, fire count, and edit/delete actions\n- Create/edit modal: step-by-step builder:\n 1. Choose trigger: dropdown of trigger types with plain-English labels (\"When someone enters a room\", \"When a room becomes empty\", etc.)\n 2. Configure trigger: zone picker (or \"Any zone\"), person picker (or \"Anyone\"), threshold for dwell/count\n 3. Add conditions (optional): time window picker, day picker, system mode selector\n 4. Add action: action type dropdown, then action-specific fields (URL for webhook, topic for MQTT, ntfy server + topic)\n 5. Template editor for payload with variable hints\n 6. Summary: \"When Alice enters the Kitchen between 7am and 9am on weekdays, POST to https://...\"\n- \"Test fire\" button: simulates the trigger event and fires all actions with test_mode=true flag in payload. Useful for debugging webhooks.\n- 3D view integration: when an automation's trigger zone is hovered in the automation editor, the corresponding zone/trigger volume highlights in the 3D scene.\n- Visual feedback in 3D view when trigger fires: brief highlight (bright flash) of the trigger zone in the 3D scene.\n\n## System Mode\n\nSystemMode is a top-level state: HOME, AWAY, SLEEP.\n- HOME: normal operation\n- AWAY: all registered BLE devices absent, security-level alert on any detection (managed by anomaly detection, Phase 7)\n- SLEEP: quiet hours active, non-urgent notifications suppressed\n\nMode changes:\n- Manual toggle from dashboard settings\n- Auto-away: all registered BLE devices absent for > 15 minutes -> auto-set AWAY\n- Auto-home: first registered BLE device seen again -> auto-set HOME\nAuto-sleep: user can configure a schedule (e.g. 10pm-7am on weekdays)\n\nMode is exposed as GET /api/mode and POST /api/mode {\"mode\":\"home\"/\"away\"/\"sleep\"}.\n\n## Tests\n\n- Test trigger matching for each trigger type: inject matching event, verify automation fires; inject non-matching event, verify no fire\n- Test time_window condition: \"22:00-07:00\" blocks at 08:00, passes at 23:00, passes at 04:00\n- Test overnight time range correctly handles midnight boundary\n- Test person_filter condition: \"anyone\" matches all events; specific person_id only matches events with that person\n- Test webhook dispatch: mock HTTP server, verify POST arrives with correct rendered payload\n- Test webhook retry: mock server returns 503 first request, 200 second, verify retry fires after 30s\n- Test MQTT publish with mock broker: verify correct topic and payload\n- Test \"test fire\" mode sets test_mode=true in payload\n- Test fire_count increments in SQLite after each fire\n\n## Acceptance Criteria\n\n- Automation fires correctly within 200ms of its trigger event for each trigger type\n- Webhook delivers payload to mock server within 5s\n- MQTT message arrives with correct topic and payload\n- Time-window condition blocks automations outside their configured window\n- 3D trigger volume editor allows drawing custom volumes not tied to named zones\n- \"Test fire\" button correctly simulates trigger without requiring a real event\n- Fire count and last_fired timestamp update in database after each fire\n- Retry mechanism handles transient webhook failures\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:46:36.925844184Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.766389180Z","closed_at":"2026-03-29T18:07:39.766280132Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-hgm","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.294048305Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 956630a..78ca556 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -519b5cbe094415a05cf438fe6675577e851d5460 +47e8fa7999422a342f9d82ecef70deee3f7ccc03