From cb59109c9801a75609717f38d98b68f73c1b5d60 Mon Sep 17 00:00:00 2001 From: J3vb Date: Sun, 5 Apr 2026 08:50:01 +0200 Subject: [PATCH] updated readme --- .cache/project-map/git-data.json | 6020 +++++++++++++++++ .cache/project-map/session-data.json | 619 ++ Client/tauri-client/src/generated/.typecache | 7 + Client/tauri-client/src/generated/commands.ts | 115 + Client/tauri-client/src/generated/events.ts | 54 + Client/tauri-client/src/generated/index.ts | 12 + Client/tauri-client/src/generated/types.ts | 92 + README.md | 370 +- tools/project-map/.gitignore | 1 + tools/project-map/dashboard.html | 1960 ++++++ tools/project-map/index.mjs | 100 + tools/project-map/lib/agent-manager.mjs | 988 +++ tools/project-map/lib/backlog-parser.mjs | 124 + tools/project-map/lib/debt-scanner.mjs | 311 + tools/project-map/lib/file-watcher.mjs | 115 + tools/project-map/lib/git-scanner.mjs | 341 + tools/project-map/lib/go-coverage.mjs | 131 + tools/project-map/lib/import-graph.mjs | 472 ++ tools/project-map/lib/morning-briefing.mjs | 190 + tools/project-map/lib/priority-engine.mjs | 180 + tools/project-map/lib/report-generator.mjs | 162 + tools/project-map/lib/research-agent.mjs | 223 + tools/project-map/lib/scanner.mjs | 198 + tools/project-map/lib/session-manager.mjs | 607 ++ tools/project-map/lib/session-parser.mjs | 357 + tools/project-map/lib/suggestion-engine.mjs | 238 + tools/project-map/lib/terminal-summary.mjs | 103 + tools/project-map/lib/vitest-coverage.mjs | 216 + tools/project-map/lib/worktree-manager.mjs | 387 ++ tools/project-map/package.json | 13 + tools/project-map/server.mjs | 661 ++ tools/project-map/tests/agent-diff.test.mjs | 104 + tools/project-map/tests/agent-queue.test.mjs | 89 + .../project-map/tests/backlog-parser.test.mjs | 80 + .../tests/briefing-and-normalization.test.mjs | 254 + tools/project-map/tests/cache-mode.test.mjs | 58 + .../project-map/tests/module-naming.test.mjs | 129 + tools/project-map/tests/server-auth.test.mjs | 73 + 38 files changed, 15900 insertions(+), 254 deletions(-) create mode 100644 .cache/project-map/git-data.json create mode 100644 .cache/project-map/session-data.json create mode 100644 Client/tauri-client/src/generated/.typecache create mode 100644 Client/tauri-client/src/generated/commands.ts create mode 100644 Client/tauri-client/src/generated/events.ts create mode 100644 Client/tauri-client/src/generated/index.ts create mode 100644 Client/tauri-client/src/generated/types.ts create mode 100644 tools/project-map/.gitignore create mode 100644 tools/project-map/dashboard.html create mode 100644 tools/project-map/index.mjs create mode 100644 tools/project-map/lib/agent-manager.mjs create mode 100644 tools/project-map/lib/backlog-parser.mjs create mode 100644 tools/project-map/lib/debt-scanner.mjs create mode 100644 tools/project-map/lib/file-watcher.mjs create mode 100644 tools/project-map/lib/git-scanner.mjs create mode 100644 tools/project-map/lib/go-coverage.mjs create mode 100644 tools/project-map/lib/import-graph.mjs create mode 100644 tools/project-map/lib/morning-briefing.mjs create mode 100644 tools/project-map/lib/priority-engine.mjs create mode 100644 tools/project-map/lib/report-generator.mjs create mode 100644 tools/project-map/lib/research-agent.mjs create mode 100644 tools/project-map/lib/scanner.mjs create mode 100644 tools/project-map/lib/session-manager.mjs create mode 100644 tools/project-map/lib/session-parser.mjs create mode 100644 tools/project-map/lib/suggestion-engine.mjs create mode 100644 tools/project-map/lib/terminal-summary.mjs create mode 100644 tools/project-map/lib/vitest-coverage.mjs create mode 100644 tools/project-map/lib/worktree-manager.mjs create mode 100644 tools/project-map/package.json create mode 100644 tools/project-map/server.mjs create mode 100644 tools/project-map/tests/agent-diff.test.mjs create mode 100644 tools/project-map/tests/agent-queue.test.mjs create mode 100644 tools/project-map/tests/backlog-parser.test.mjs create mode 100644 tools/project-map/tests/briefing-and-normalization.test.mjs create mode 100644 tools/project-map/tests/cache-mode.test.mjs create mode 100644 tools/project-map/tests/module-naming.test.mjs create mode 100644 tools/project-map/tests/server-auth.test.mjs diff --git a/.cache/project-map/git-data.json b/.cache/project-map/git-data.json new file mode 100644 index 00000000..3301eab6 --- /dev/null +++ b/.cache/project-map/git-data.json @@ -0,0 +1,6020 @@ +{ + "timestamp": "2026-03-31T09:22:37.883Z", + "commitsByModule": { + "root": { + "count": 80, + "lastCommit": "2026-03-30 22:36:38 +0200", + "commits": [ + { + "hash": "f7372aec3219d36b76bc269bb7940ee914e660eb", + "date": "2026-03-30 22:36:38 +0200", + "message": "docs: add screenshots to README", + "type": "docs" + }, + { + "hash": "86541ac3aa4af84c4d787d73a1f0ffc1e4119d7f", + "date": "2026-03-30 22:32:31 +0200", + "message": "chore: gitignore internal docs subdirectories", + "type": "chore" + }, + { + "hash": "cec6ca6a97df645d41c781cc257dad24e5a407ca", + "date": "2026-03-30 22:31:06 +0200", + "message": "docs: add public documentation for contributors and users", + "type": "docs" + }, + { + "hash": "bfe3404e14ab7143410a77c69bf7c28b4a161af1", + "date": "2026-03-30 21:07:50 +0200", + "message": "updated gitignore", + "type": "other" + }, + { + "hash": "44390ad886be392b55d1a15995eba0fb9c4a80d1", + "date": "2026-03-30 21:05:28 +0200", + "message": "chore: clean up tracked files for v1.0.0 public release", + "type": "chore" + }, + { + "hash": "ad7ac75c74d0351d37e0eded5e4594528041866d", + "date": "2026-03-30 20:50:44 +0200", + "message": "docs: v1.0.0 release prep — version bump, license, README overhaul", + "type": "docs" + }, + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat" + }, + { + "hash": "8882cfa330e28f273cd1e53234e4e95b37a6c7eb", + "date": "2026-03-30 16:47:19 +0200", + "message": "docs: regenerate codemaps from current codebase", + "type": "docs" + }, + { + "hash": "ad76d4a4139460d24974d6c436ba9782ac5ddfb2", + "date": "2026-03-30 16:37:28 +0200", + "message": "docs: update session log with TS error fix details", + "type": "docs" + }, + { + "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", + "date": "2026-03-30 16:35:02 +0200", + "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", + "type": "test" + }, + { + "hash": "3f0d5309374562116f211eab06e170ca2f31818c", + "date": "2026-03-30 14:24:12 +0200", + "message": "yu", + "type": "other" + }, + { + "hash": "773c811f06c6523b599a880908ac670c654437fb", + "date": "2026-03-30 14:23:39 +0200", + "message": "docs: add repo copilot assets section to CLAUDE.md and README", + "type": "docs" + }, + { + "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", + "date": "2026-03-30 12:42:31 +0200", + "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "5eeff05e892ec554f0a3f6c4903301f48c14e7c8", + "date": "2026-03-29 19:42:07 +0200", + "message": "chore: remove accidental empty file", + "type": "chore" + }, + { + "hash": "7521fbb41fa474d2d0cd6dfd85f319897a12e67e", + "date": "2026-03-29 19:40:47 +0200", + "message": "docs: update task tracking and dashboard for v1.3.0", + "type": "docs" + }, + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "1294d9a2b104df51b48607bf8491b1028cf9c620", + "date": "2026-03-29 12:35:29 +0200", + "message": "chore: remove accidental empty files", + "type": "chore" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "829a8b0efddabb52e58b9e8bf8a7324be30d0cea", + "date": "2026-03-29 10:13:04 +0200", + "message": "fix: repair 14 failing E2E tests and expand voice lifecycle coverage", + "type": "fix" + }, + { + "hash": "e98ac2a36a76a912b122b898ad24f36bda38e71c", + "date": "2026-03-29 08:28:54 +0200", + "message": "docs: update CLAUDE.md with voice polish changes, mark TODOS complete", + "type": "docs" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "effa9a69e2cb157facc671965e1b2736943a6f08", + "date": "2026-03-28 21:09:00 +0100", + "message": "docs: update CLAUDE.md with observability features and new files", + "type": "docs" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "f15db645c7f79886196b61695f5a8827c85ce86e", + "date": "2026-03-28 12:23:29 +0100", + "message": "docs: update CLAUDE.md with settings panel redesign and accent color fix", + "type": "docs" + }, + { + "hash": "435f00964728e747e159640793e4ca7fb358fd79", + "date": "2026-03-28 11:30:02 +0100", + "message": "docs: update CLAUDE.md with auto-login, health endpoint, sidebar changes", + "type": "docs" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "534e993d965a8be91e0033d39529602ed2da2ee4", + "date": "2026-03-27 16:17:18 +0100", + "message": "docs: update CLAUDE.md with voice timer, accent restore, DM auth rule", + "type": "docs" + }, + { + "hash": "35ac3a268485d16598737d1004c800abd813fa5d", + "date": "2026-03-27 15:02:09 +0100", + "message": "docs: add DM system, unified sidebar, themes, and quick-switch features", + "type": "docs" + }, + { + "hash": "568237ef7fa1c941edc8209d34510d97478edac8", + "date": "2026-03-27 13:41:58 +0100", + "message": "docs: add DM system design spec", + "type": "docs" + }, + { + "hash": "21e92ec1ec9051b585c764b0adfd4cddbe0625e7", + "date": "2026-03-27 10:52:16 +0100", + "message": "docs: add connection quality indicator to CLAUDE.md features + lib listing", + "type": "docs" + }, + { + "hash": "c838e92d87a6d1a3248c211325c112580bb8fbed", + "date": "2026-03-27 10:47:06 +0100", + "message": "chore: add .superpowers/ to gitignore", + "type": "chore" + }, + { + "hash": "03c8acd2213343bab99c2d72d46a61e0f6359d43", + "date": "2026-03-26 22:13:47 +0100", + "message": "t", + "type": "other" + }, + { + "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", + "date": "2026-03-26 22:09:56 +0100", + "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", + "type": "chore" + }, + { + "hash": "21fe4b8991ed63b884e71385fa82bae324bac551", + "date": "2026-03-26 18:39:50 +0100", + "message": "docs: add specs for screenshare audio and video focus mode", + "type": "docs" + }, + { + "hash": "d1cce204f1f09c27c922cf6fff9d8d1b7ce1c59d", + "date": "2026-03-25 17:29:34 +0100", + "message": "chore: gitignore .claude-flow/ and .mcp.json", + "type": "chore" + }, + { + "hash": "019cafd90a1dc07a0b9ed011bebc27f1fb99a847", + "date": "2026-03-24 21:42:16 +0100", + "message": "docs: sync documentation with codebase — version alignment, config fixes, test scripts", + "type": "docs" + }, + { + "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", + "date": "2026-03-22 21:10:02 +0100", + "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", + "type": "fix" + }, + { + "hash": "25625cfd6ec3527247fb5c134d0509a067cbe51e", + "date": "2026-03-22 19:30:11 +0100", + "message": "docs: add voice metrics and DESIGN.md TODOs from review", + "type": "docs" + }, + { + "hash": "9949e9de9ea8e84a6b8448bc78e04df83cb392be", + "date": "2026-03-22 15:34:26 +0100", + "message": "fix: restore audio track attachment for remote playback", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "fc63aec2f9294c6898f9afe4b8b6b7c6c69ece2d", + "date": "2026-03-20 05:50:14 +0100", + "message": "docs: update specs for LiveKit migration (Phase 4)", + "type": "docs" + }, + { + "hash": "c39bd09c714f650650847899bf70ccb9cc53162c", + "date": "2026-03-19 19:47:32 +0100", + "message": "fix: pass signing key to Tauri build step in CI", + "type": "fix" + }, + { + "hash": "bbbe983230d0a807c9e115c9c2c0dfb8b4b2ee1f", + "date": "2026-03-19 18:19:51 +0100", + "message": "chore: restrict Dependabot to minor/patch bumps only", + "type": "chore" + }, + { + "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", + "date": "2026-03-19 17:54:13 +0100", + "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", + "type": "docs" + }, + { + "hash": "d9a33b6c4e727d8205bf6020978445f4df558eb9", + "date": "2026-03-19 05:40:22 +0100", + "message": "docs: update README and CLAUDE.md for v1.0.0 release", + "type": "docs" + }, + { + "hash": "f8c54a7ba5cafe29f28bec19b855b3582d3e1b6c", + "date": "2026-03-18 23:09:52 +0100", + "message": "docs: update README with voice NAT traversal, per-user volume, noise suppression", + "type": "docs" + }, + { + "hash": "def313beb1389cb117d330dc83b29e72ce9dcbdc", + "date": "2026-03-18 23:03:14 +0100", + "message": "chore: add chatserver.exe~ to gitignore", + "type": "chore" + }, + { + "hash": "8fd8d3e91ecebd6d03420133a0d5e6b739ac8d29", + "date": "2026-03-18 17:56:03 +0100", + "message": "docs: rewrite README with current features, architecture, and config", + "type": "docs" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", + "date": "2026-03-18 07:03:53 +0100", + "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "1914067941b8512b895e32075fe2ced0b3da3841", + "date": "2026-03-17 03:20:37 +0100", + "message": "fix: address PR review findings (issues #3-#8)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "264b4f0f65fccc8ec7ec8fa9e8c61a10284a3cb0", + "date": "2026-03-17 02:52:49 +0100", + "message": "chore: clean up remaining WPF artifacts and track missing files", + "type": "chore" + }, + { + "hash": "5c4a232bde25f75b72cbe338d530e15d2631f430", + "date": "2026-03-17 02:49:45 +0100", + "message": "chore: remove legacy WPF client code and references", + "type": "chore" + }, + { + "hash": "fb4b57743d63e0f783a839dc1f14e97cbcc28d4a", + "date": "2026-03-17 02:45:44 +0100", + "message": "ci: add GitHub best practices config (templates, dependabot, CI optimization)", + "type": "other" + }, + { + "hash": "e6247cde5b8c3ee2078938f4337edb3dc095256c", + "date": "2026-03-17 02:34:01 +0100", + "message": "test: complete E2E improvement phases 4-6", + "type": "test" + }, + { + "hash": "4dc61a506f18398c424d04eb6dbf31d44a5cbc30", + "date": "2026-03-17 02:25:49 +0100", + "message": "feat: add virtual scrolling to MessageList for large channels", + "type": "feat" + }, + { + "hash": "ceba8b9cf0401b8d07a3412d9e16dbff46f10236", + "date": "2026-03-17 02:21:41 +0100", + "message": "docs: mark TODOS #9 (split oversized files) as complete", + "type": "docs" + }, + { + "hash": "ff0f761b121d6b14e52b635ade04b328a029c41c", + "date": "2026-03-17 02:09:46 +0100", + "message": "\"Claude Code Review workflow\"", + "type": "other" + }, + { + "hash": "0b9f929d081d8d7d8422084d920633b6bff08168", + "date": "2026-03-17 02:09:45 +0100", + "message": "\"Claude PR Assistant workflow\"", + "type": "other" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "08049e7275f87f55321024cc2efbee37820bb493", + "date": "2026-03-16 17:11:17 +0100", + "message": "docs: align API.md paths to /api/v1/, fix CLAUDE.md contradiction, mark review resolved", + "type": "docs" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + }, + { + "hash": "743a2d974738f76db7764bdd43e78fbf25889d4f", + "date": "2026-03-15 16:54:55 +0100", + "message": "chore: update .gitignore to exclude local tooling, build artifacts, and internal docs", + "type": "chore" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "7e9a7bbf623a188879b21dc5a792577231a83c27", + "date": "2026-03-15 07:09:01 +0100", + "message": "chore: gitignore Claude Code local config and research notes", + "type": "chore" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "07636447ab328ced8a3561a1446df76561259010", + "date": "2026-03-14 22:38:08 +0100", + "message": "chore: gitignore server runtime artifacts (binary, config, data)", + "type": "chore" + }, + { + "hash": "03bea4690bf9d4cd16dbf385eb559f44e696ded6", + "date": "2026-03-14 22:11:42 +0100", + "message": "chore: add .claude/settings.local.json to gitignore", + "type": "chore" + }, + { + "hash": "fb6b56e87686550da1f07387e6052bdc1efe6e5e", + "date": "2026-03-14 22:09:11 +0100", + "message": "docs: add Phase 7 distribution and updates design spec", + "type": "docs" + }, + { + "hash": "9083599730b58b84e6b8daf9320a515b94e440de", + "date": "2026-03-14 22:03:12 +0100", + "message": "docs: add server_restart message type and update endpoints to specs", + "type": "docs" + }, + { + "hash": "065715abf5979f1bf43cbf928153ef7947b2bd73", + "date": "2026-03-14 21:58:53 +0100", + "message": "docs: add README, SECURITY, CONTRIBUTING, and setup guides", + "type": "docs" + }, + { + "hash": "95dc1bc1d1734f0664a4848867eeeaf6e8989f64", + "date": "2026-03-14 21:58:06 +0100", + "message": "ci: add GitHub Actions CI and release workflows", + "type": "other" + }, + { + "hash": "a0da1e82c72368dfef08cf54731387ed937c5ebb", + "date": "2026-03-14 19:57:39 +0100", + "message": "chore: initial commit with project specs and configuration", + "type": "chore" + } + ] + }, + "ts:config": { + "count": 110, + "lastCommit": "2026-03-30 21:54:24 +0200", + "commits": [ + { + "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", + "date": "2026-03-30 21:54:24 +0200", + "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", + "type": "fix" + }, + { + "hash": "ad7ac75c74d0351d37e0eded5e4594528041866d", + "date": "2026-03-30 20:50:44 +0200", + "message": "docs: v1.0.0 release prep — version bump, license, README overhaul", + "type": "docs" + }, + { + "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", + "date": "2026-03-30 20:13:34 +0200", + "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", + "type": "feat" + }, + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat" + }, + { + "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", + "date": "2026-03-30 16:35:02 +0200", + "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", + "type": "test" + }, + { + "hash": "4ffa0c5d4755545fb4146fe62d93b7c4bf8c5260", + "date": "2026-03-30 14:23:31 +0200", + "message": "fix: add missing getFloatTimeDomainData mock to silence VAD timer errors", + "type": "fix" + }, + { + "hash": "0b22f073945487c390cf71143061fbfd5812225a", + "date": "2026-03-30 14:20:05 +0200", + "message": "test: Phase 4 coverage — meaningful behavior tests push client to 95%", + "type": "test" + }, + { + "hash": "78d2e70ccea3cb7eff8de19c89618ba38b9205dc", + "date": "2026-03-30 13:59:47 +0200", + "message": "test: Phase 3 coverage — 22 files bumped from 70-92% to 95-100%", + "type": "test" + }, + { + "hash": "5b6d6d4ba24c008ee920b3b03bc0bc4d90784cbd", + "date": "2026-03-30 13:38:20 +0200", + "message": "test: Phase 2 coverage — below-70% files now at 95-100%", + "type": "test" + }, + { + "hash": "414efdbd28d9282acebfaabb652aea54f23e8525", + "date": "2026-03-30 13:21:29 +0200", + "message": "test: Phase 1 coverage — 10 zero-coverage files now at 95-100%", + "type": "test" + }, + { + "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", + "date": "2026-03-30 12:42:31 +0200", + "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", + "type": "fix" + }, + { + "hash": "23b62a964c798ca99996be4de65e210a05b1af02", + "date": "2026-03-29 21:34:54 +0200", + "message": "fix: TypeScript build errors in embeds.ts and totp-settings test", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", + "date": "2026-03-29 19:39:22 +0200", + "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", + "type": "fix" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "829a8b0efddabb52e58b9e8bf8a7324be30d0cea", + "date": "2026-03-29 10:13:04 +0200", + "message": "fix: repair 14 failing E2E tests and expand voice lifecycle coverage", + "type": "fix" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "57c35c2b6821a3f43297295b2e4edc5cb9943433", + "date": "2026-03-28 18:55:11 +0100", + "message": "fix: CI failures — correct chat delete test expectations and coverage threshold", + "type": "fix" + }, + { + "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", + "date": "2026-03-28 18:23:18 +0100", + "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", + "type": "fix" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "9636d0e9761b69b762ef869a5eaa1eb65cbb1320", + "date": "2026-03-27 14:25:54 +0100", + "message": "fix: DM header shows @ instead of #, DM welcome message, DM appears in sidebar list", + "type": "fix" + }, + { + "hash": "6858c67408f19f973ac02f8658522825db85646e", + "date": "2026-03-27 13:10:21 +0100", + "message": "chore: stage QuickSwitchOverlay files missed from earlier commit", + "type": "chore" + }, + { + "hash": "cfcc76760d0408115971b4224ebcd6e2644301c1", + "date": "2026-03-27 12:56:40 +0100", + "message": "feat: add member list header/resize, DM section in sidebar, remove member toggle from chat header", + "type": "feat" + }, + { + "hash": "2829220f06432cc6ae7e502059b898de79b12357", + "date": "2026-03-27 12:22:22 +0100", + "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", + "type": "fix" + }, + { + "hash": "00a92851d4e2e92ed9412825fb2a70292770b889", + "date": "2026-03-27 12:09:38 +0100", + "message": "refactor: remove ServerStrip component and CSS (replaced by unified sidebar)", + "type": "refactor" + }, + { + "hash": "e6b5d43e669b1aa1ec4bfccf3aa4a235dc8980f0", + "date": "2026-03-27 11:37:57 +0100", + "message": "feat: add theme manager with built-in + custom theme support", + "type": "feat" + }, + { + "hash": "fbbb87229e13e2247bbe6d2afd91c8ac753188c3", + "date": "2026-03-27 11:34:45 +0100", + "message": "fix: add sidebarMode/activeDmUserId to test UiState mocks", + "type": "fix" + }, + { + "hash": "7be56f95458b31c8435ce50ef52d60bd1820639b", + "date": "2026-03-27 11:29:23 +0100", + "message": "feat: add sidebarMode and activeDmUserId to UI store", + "type": "feat" + }, + { + "hash": "bdff04fd080b5e9a2bd9f4371db4a1e94d9ae146", + "date": "2026-03-27 10:25:40 +0100", + "message": "fix: regenerate ICO with proper multi-size PNG entries (7 sizes)", + "type": "fix" + }, + { + "hash": "9c38bacddb19c0354248b6403b99de15b08befb1", + "date": "2026-03-27 10:09:53 +0100", + "message": "feat: replace app icon with OC neon glow logo", + "type": "feat" + }, + { + "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", + "date": "2026-03-27 08:05:32 +0100", + "message": "fix: client code review — 27 fixes across 17 files", + "type": "fix" + }, + { + "hash": "2bc47efc6e4d3d10b091a98be60bdeb0e5eed6f6", + "date": "2026-03-26 22:10:49 +0100", + "message": "feat: add RNNoise worklet TypeScript source", + "type": "feat" + }, + { + "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", + "date": "2026-03-26 22:09:56 +0100", + "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", + "type": "chore" + }, + { + "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", + "date": "2026-03-26 22:07:52 +0100", + "message": "feat: stream quality presets, nuclear mute, sidebar width fix", + "type": "feat" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", + "date": "2026-03-26 18:07:20 +0100", + "message": "feat: full screenshare support — button state, video grid, auto-reconnect", + "type": "feat" + }, + { + "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", + "date": "2026-03-25 19:07:52 +0100", + "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", + "date": "2026-03-22 21:10:02 +0100", + "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", + "type": "fix" + }, + { + "hash": "73a757b47f425fca0c5c6da9748c4fc032d1ec04", + "date": "2026-03-22 20:14:27 +0100", + "message": "fix: resolve TS2556 spread argument error in livekit-session test", + "type": "fix" + }, + { + "hash": "076402ab010e0eb1659cb776bd8bee8417d31e48", + "date": "2026-03-22 20:06:59 +0100", + "message": "fix: resolve CI failures — lint errors and coverage threshold", + "type": "fix" + }, + { + "hash": "067597a507a417d4128c5f7dabcb705681292f6c", + "date": "2026-03-22 19:30:02 +0100", + "message": "test: fix 4 pre-existing test failures for updated settings UI", + "type": "test" + }, + { + "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", + "date": "2026-03-22 10:04:49 +0100", + "message": "feat: add user status selector to Account settings tab", + "type": "feat" + }, + { + "hash": "c3a13d09a142bcf4b233a278c9178d0f05fc3cfa", + "date": "2026-03-22 09:30:03 +0100", + "message": "fix: address review issues in settings redesign", + "type": "fix" + }, + { + "hash": "32faf87b3c5c67bf730f587921373dfb7e1f3d75", + "date": "2026-03-22 08:26:21 +0100", + "message": "test: update invite manager selectors for modal redesign", + "type": "test" + }, + { + "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", + "date": "2026-03-21 23:33:18 +0100", + "message": "feat: replace all emoji icons with Lucide SVG icons", + "type": "feat" + }, + { + "hash": "30a6c7692a1a6b7ca28a88bc135159574c24b7d5", + "date": "2026-03-21 22:00:40 +0100", + "message": "test: update pinned messages tests for Discord-style panel", + "type": "test" + }, + { + "hash": "3365567063ad8d400f4e781300d4cc44b4fd496d", + "date": "2026-03-21 21:24:29 +0100", + "message": "fix: add missing channelName to test createMessageList calls", + "type": "fix" + }, + { + "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", + "date": "2026-03-21 21:08:58 +0100", + "message": "feat: add Discord-style welcome state for empty channels", + "type": "feat" + }, + { + "hash": "6ee3698525b22e2471c05e9e0addb8b42e963470", + "date": "2026-03-21 20:53:47 +0100", + "message": "fix: prevent virtual scroll rebuild loops and improve image height caching", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", + "date": "2026-03-20 07:11:45 +0100", + "message": "fix: speaking ring, devtools, CSP, and connection fixes", + "type": "fix" + }, + { + "hash": "91ebce72727a54fb96c72dadeb257bd92124ef4f", + "date": "2026-03-20 06:33:57 +0100", + "message": "fix: wire LiveKit speaker detection to voice activation ring", + "type": "fix" + }, + { + "hash": "ff2b82280c6002a5363f25af434e1898b15322ca", + "date": "2026-03-20 06:27:43 +0100", + "message": "fix: resolve LiveKit connection issues", + "type": "fix" + }, + { + "hash": "24c6b54c49b2041e3dcef0cd8add2502931df32f", + "date": "2026-03-20 05:47:11 +0100", + "message": "fix: update settings overlay test for 4 audio toggles", + "type": "fix" + }, + { + "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", + "date": "2026-03-20 05:46:19 +0100", + "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", + "type": "feat" + }, + { + "hash": "bd307eb41c112d51926b6d1324a4464b32da1002", + "date": "2026-03-20 05:07:37 +0100", + "message": "chore: bump version to 1.2.0 for LiveKit migration", + "type": "chore" + }, + { + "hash": "17600081dfef4d5bccf6777279559599acfc1eff", + "date": "2026-03-20 02:00:29 +0100", + "message": "feat: wire search bar to server FTS5 search API (T-065)", + "type": "feat" + }, + { + "hash": "f11a53913ffb192b6d2978834e793d1053e45d27", + "date": "2026-03-20 01:33:43 +0100", + "message": "refactor: extract MainPage into focused controllers with tests", + "type": "refactor" + }, + { + "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", + "date": "2026-03-19 21:35:18 +0100", + "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", + "type": "fix" + }, + { + "hash": "b387f1880ae8997b745a8054cfd9a24319cebf3c", + "date": "2026-03-19 19:22:42 +0100", + "message": "update gitignore", + "type": "other" + }, + { + "hash": "4505c8d4648e5611907cef68cca9a7a4a13ce886", + "date": "2026-03-19 19:22:23 +0100", + "message": "chore: update Tauri updater public key for v1.1.0 signing keypair", + "type": "chore" + }, + { + "hash": "0f3c9e578279b62182a8d61727ae15dba8cfed71", + "date": "2026-03-19 18:44:29 +0100", + "message": "chore: bump version to 1.1.0", + "type": "chore" + }, + { + "hash": "ec3d7bee921ce202e26aa2cde7cc40a43ae4a8bd", + "date": "2026-03-19 18:25:49 +0100", + "message": "test: add coverage for GIF picker, Tenor API, PTT, and image rendering", + "type": "test" + }, + { + "hash": "445da87055685485522399a4bdcb47f477c8d1d6", + "date": "2026-03-19 18:06:07 +0100", + "message": "fix: add ResizeObserver stub to message list tests", + "type": "fix" + }, + { + "hash": "c6e864580946b4017e67182c777b19936f436458", + "date": "2026-03-19 17:43:32 +0100", + "message": "fix: stop camera preview and mic meter when settings overlay closes", + "type": "fix" + }, + { + "hash": "3010750b6f9dfe171bad7f13be0be2da7dd4ea3e", + "date": "2026-03-19 16:51:49 +0100", + "message": "test: add unit tests for voice session camera lifecycle", + "type": "test" + }, + { + "hash": "9ad91c6393d28dc8b75e9b229b9b4b46bb1386bb", + "date": "2026-03-19 16:47:18 +0100", + "message": "feat: add webcam device selector and preview to settings", + "type": "feat" + }, + { + "hash": "81b45d55e312c7f6a4085073ffbc8842d7c5bd6f", + "date": "2026-03-19 16:39:35 +0100", + "message": "feat: add VideoGrid component for rendering camera streams", + "type": "feat" + }, + { + "hash": "5b10bc28f323b8b2365036de84a72f5d2897e3bc", + "date": "2026-03-19 16:39:29 +0100", + "message": "feat: add video device manager for camera capture", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "4ac2167570ec73ebb0f8f4ed2b5462bba2e8961e", + "date": "2026-03-19 07:41:58 +0100", + "message": "fix: use v1Compatible for createUpdaterArtifacts (v2Compatible not valid)", + "type": "fix" + }, + { + "hash": "d4fd9e3db26e4ecf6a09600476079a1a2f207aae", + "date": "2026-03-19 07:33:59 +0100", + "message": "fix: enable Tauri updater artifacts and bump client to v1.0.0", + "type": "fix" + }, + { + "hash": "9f5be0819df487fff856a9237fa8ecc8131ce44a", + "date": "2026-03-19 06:28:08 +0100", + "message": "fix: adjust coverage thresholds and exclude Tauri-runtime files", + "type": "fix" + }, + { + "hash": "22706a795581a003059a9838ace4ac05d43fefb2", + "date": "2026-03-19 06:20:00 +0100", + "message": "fix: resolve CI failures in server lint and client tests", + "type": "fix" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", + "date": "2026-03-18 17:10:16 +0100", + "message": "feat: native file downloads, upload size fix, native E2E tests", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "a37abb47a266fca465ebaaa6866308f3868df373", + "date": "2026-03-18 11:33:59 +0100", + "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", + "type": "fix" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "498d34a3705a11293738c4ba07ef3c1d6eb35886", + "date": "2026-03-18 07:09:45 +0100", + "message": "fix: add missing localCamera/localScreenshare to VoiceState resets in tests", + "type": "fix" + }, + { + "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", + "date": "2026-03-18 07:03:53 +0100", + "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", + "type": "fix" + }, + { + "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", + "date": "2026-03-18 06:45:05 +0100", + "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", + "type": "fix" + }, + { + "hash": "d3d1e067db271a199c641771604f55e05acd809c", + "date": "2026-03-18 06:40:12 +0100", + "message": "fix: render actual images for attachments, remove orphaned components (BUG-026, BUG-030)", + "type": "fix" + }, + { + "hash": "c1e2733f11a746aef74897bed712f69693e72acb", + "date": "2026-03-18 06:28:45 +0100", + "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", + "type": "fix" + }, + { + "hash": "c49a8bc4d63beba7900459981cdb152f77b6624c", + "date": "2026-03-18 05:38:53 +0100", + "message": "fix: harden E2E tests with anti-flakiness config and voice widget selector fixes", + "type": "fix" + }, + { + "hash": "bc12273ab78155ba942b2d9296736cec8254326b", + "date": "2026-03-18 04:30:38 +0100", + "message": "fix: resolve CI failures — coverage exclusion and stale test removal", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", + "date": "2026-03-17 20:03:34 +0100", + "message": "fix: voice channel join/leave visibility and disconnect bugs", + "type": "fix" + }, + { + "hash": "17eb67811f7844324d6703bfddce0d7ae8996267", + "date": "2026-03-17 14:01:26 +0000", + "message": "fix: resolve CI failures — errcheck lint and TS noUncheckedIndexedAccess errors", + "type": "fix" + }, + { + "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", + "date": "2026-03-17 14:54:50 +0100", + "message": "fix: address PR #15 review issues (#16-#23)", + "type": "fix" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "264b4f0f65fccc8ec7ec8fa9e8c61a10284a3cb0", + "date": "2026-03-17 02:52:49 +0100", + "message": "chore: clean up remaining WPF artifacts and track missing files", + "type": "chore" + }, + { + "hash": "e6247cde5b8c3ee2078938f4337edb3dc095256c", + "date": "2026-03-17 02:34:01 +0100", + "message": "test: complete E2E improvement phases 4-6", + "type": "test" + }, + { + "hash": "4dc61a506f18398c424d04eb6dbf31d44a5cbc30", + "date": "2026-03-17 02:25:49 +0100", + "message": "feat: add virtual scrolling to MessageList for large channels", + "type": "feat" + }, + { + "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", + "date": "2026-03-17 02:17:26 +0100", + "message": "refactor: split oversized files + add store notification batching", + "type": "refactor" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "eef229a74fcf70acf92f385f6386d27af32436ac", + "date": "2026-03-15 20:46:38 +0100", + "message": "test: add test helpers and integration tests for dispatcher + stores", + "type": "test" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "go:admin": { + "count": 27, + "lastCommit": "2026-03-30 21:54:24 +0200", + "commits": [ + { + "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", + "date": "2026-03-30 21:54:24 +0200", + "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", + "type": "fix" + }, + { + "hash": "d9aaeb5f4c8c018f5ffbd9c64ae87126e5b5348a", + "date": "2026-03-30 21:48:14 +0200", + "message": "fix: admin panel CSP blocking inline event handlers and boolean toggle display", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "97f186041da8052d054cff73c93f23886bd374d5", + "date": "2026-03-29 19:39:46 +0200", + "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", + "type": "refactor" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "b15738c4d80f4983f31d1076e8aaff07171a0a7c", + "date": "2026-03-19 05:32:40 +0100", + "message": "feat: redesign admin panel, add live server logs and audit log filters", + "type": "feat" + }, + { + "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", + "date": "2026-03-19 04:19:03 +0100", + "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", + "type": "fix" + }, + { + "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", + "date": "2026-03-19 04:04:53 +0100", + "message": "fix: resolve 6 medium issues from full go-review", + "type": "fix" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "f8ac121cfd684c241fe2d659bb4a7ec1987c96c6", + "date": "2026-03-18 05:15:54 +0100", + "message": "fix: add nil hub tests for PatchUser ban and role change paths (BUG-001)", + "type": "fix" + }, + { + "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", + "date": "2026-03-17 14:54:50 +0100", + "message": "fix: address PR #15 review issues (#16-#23)", + "type": "fix" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "1914067941b8512b895e32075fe2ced0b3da3841", + "date": "2026-03-17 03:20:37 +0100", + "message": "fix: address PR review findings (issues #3-#8)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", + "date": "2026-03-17 02:17:26 +0100", + "message": "refactor: split oversized files + add store notification batching", + "type": "refactor" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "61dc275a1545f772753bee1585ac8b030d8cae1a", + "date": "2026-03-14 22:36:35 +0100", + "message": "feat: add setup wizard for initial owner account creation", + "type": "feat" + }, + { + "hash": "40c269bc7a60ccfa0ec52c8524834491c554ec67", + "date": "2026-03-14 22:06:54 +0100", + "message": "feat: add update notification banner to admin dashboard", + "type": "feat" + }, + { + "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", + "date": "2026-03-14 22:05:13 +0100", + "message": "feat: implement server auto-update API endpoints with download, verify, and restart", + "type": "feat" + }, + { + "hash": "dcae0b91b23c178e3b747cdf25176c8d09ffd103", + "date": "2026-03-14 21:37:47 +0100", + "message": "fix: correct embed path (static not admin/static) and simplify audit_log migration", + "type": "fix" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:db": { + "count": 44, + "lastCommit": "2026-03-30 21:54:24 +0200", + "commits": [ + { + "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", + "date": "2026-03-30 21:54:24 +0200", + "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "97f186041da8052d054cff73c93f23886bd374d5", + "date": "2026-03-29 19:39:46 +0200", + "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", + "type": "refactor" + }, + { + "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", + "date": "2026-03-29 19:39:22 +0200", + "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", + "type": "fix" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "3dff9fef64db54cdfe153d48f46d9cc7d7bac9d0", + "date": "2026-03-27 13:58:48 +0100", + "message": "feat(server): add DM REST endpoints, WebSocket routing, and ready payload", + "type": "feat" + }, + { + "hash": "c8d547ce15643085eaed6e65c33044eef098f427", + "date": "2026-03-27 13:46:06 +0100", + "message": "feat(server): add DM schema migration and database query layer", + "type": "feat" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "f52e4d7e49852cf3cbae2ba789a1b382dcc6f1c8", + "date": "2026-03-21 21:42:14 +0100", + "message": "refactor: deduplicate pin handler and DB scan logic", + "type": "refactor" + }, + { + "hash": "85ef4f0d1d8d9e6b9eb2550f4d0b9b74542b792a", + "date": "2026-03-21 21:36:10 +0100", + "message": "feat: add server-side pin/unpin endpoints", + "type": "feat" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", + "date": "2026-03-19 21:35:18 +0100", + "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", + "type": "fix" + }, + { + "hash": "aa713390e4669998f964cc11dec0ddc8c3f443cc", + "date": "2026-03-19 04:27:11 +0100", + "message": "fix: add rows.Err() check in GetAttachmentsByMessageIDs", + "type": "fix" + }, + { + "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", + "date": "2026-03-19 04:19:03 +0100", + "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", + "type": "fix" + }, + { + "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", + "date": "2026-03-19 04:04:53 +0100", + "message": "fix: resolve 6 medium issues from full go-review", + "type": "fix" + }, + { + "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", + "date": "2026-03-19 03:53:40 +0100", + "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", + "type": "fix" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", + "date": "2026-03-18 05:03:38 +0100", + "message": "test: boost server test coverage to 80%+ across all packages", + "type": "test" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", + "date": "2026-03-17 14:54:50 +0100", + "message": "fix: address PR #15 review issues (#16-#23)", + "type": "fix" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "1914067941b8512b895e32075fe2ced0b3da3841", + "date": "2026-03-17 03:20:37 +0100", + "message": "fix: address PR review findings (issues #3-#8)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "642cd541c781649698799245a6a3d0f4a8ae0a02", + "date": "2026-03-16 17:00:09 +0100", + "message": "feat: add attachment persistence and link on chat_send (High #3)", + "type": "feat" + }, + { + "hash": "0bd9165f61f361a5acb9fb85ffd0e3cf8aca6d29", + "date": "2026-03-16 16:57:01 +0100", + "message": "feat: align REST responses with API.md spec (High #1)", + "type": "feat" + }, + { + "hash": "0680a32496e69f60a21d76a80d2a2d40244fe805", + "date": "2026-03-16 16:54:56 +0100", + "message": "fix: resolve 4 Critical + 2 High + 1 Medium server protocol violations", + "type": "fix" + }, + { + "hash": "aa194f45b39c17f9bf74868941f3e31dd5bf73e4", + "date": "2026-03-15 12:21:10 +0100", + "message": "fix: prevent stale \"online\" status for users not connected via WebSocket", + "type": "fix" + }, + { + "hash": "c56244366097c8b44013e130994360d421a50c01", + "date": "2026-03-15 12:18:19 +0100", + "message": "fix: add JSON tags to Role/VoiceState, fix WebSocket error surfacing", + "type": "fix" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "61dc275a1545f772753bee1585ac8b030d8cae1a", + "date": "2026-03-14 22:36:35 +0100", + "message": "feat: add setup wizard for initial owner account creation", + "type": "feat" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", + "date": "2026-03-14 21:17:09 +0100", + "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", + "type": "feat" + }, + { + "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", + "date": "2026-03-14 20:52:11 +0100", + "message": "feat: implement Phase 2 auth & security with TDD", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:scripts": { + "count": 3, + "lastCommit": "2026-03-30 21:54:24 +0200", + "commits": [ + { + "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", + "date": "2026-03-30 21:54:24 +0200", + "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + } + ] + }, + "go:ws": { + "count": 72, + "lastCommit": "2026-03-30 21:54:24 +0200", + "commits": [ + { + "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", + "date": "2026-03-30 21:54:24 +0200", + "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", + "type": "fix" + }, + { + "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", + "date": "2026-03-30 16:35:02 +0200", + "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", + "type": "test" + }, + { + "hash": "97f186041da8052d054cff73c93f23886bd374d5", + "date": "2026-03-29 19:39:46 +0200", + "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", + "type": "refactor" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "57c35c2b6821a3f43297295b2e4edc5cb9943433", + "date": "2026-03-28 18:55:11 +0100", + "message": "fix: CI failures — correct chat delete test expectations and coverage threshold", + "type": "fix" + }, + { + "hash": "26b3f954bf0e165f601f5b562f0c76eb47afb828", + "date": "2026-03-28 18:43:23 +0100", + "message": "chore: remove unused dmChannelClosePayload and buildDMChannelClose", + "type": "chore" + }, + { + "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", + "date": "2026-03-28 18:23:18 +0100", + "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", + "type": "fix" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "3dff9fef64db54cdfe153d48f46d9cc7d7bac9d0", + "date": "2026-03-27 13:58:48 +0100", + "message": "feat(server): add DM REST endpoints, WebSocket routing, and ready payload", + "type": "feat" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "30b5c0203971be61594535a3df9e58044c2c0648", + "date": "2026-03-25 17:28:14 +0100", + "message": "fix: remove invalid active_loopback_prevention from LiveKit config", + "type": "fix" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", + "date": "2026-03-24 20:23:40 +0100", + "message": "fix: address code review — security hardening, leak fixes, credential safety", + "type": "fix" + }, + { + "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", + "date": "2026-03-22 21:10:02 +0100", + "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", + "type": "fix" + }, + { + "hash": "076402ab010e0eb1659cb776bd8bee8417d31e48", + "date": "2026-03-22 20:06:59 +0100", + "message": "fix: resolve CI failures — lint errors and coverage threshold", + "type": "fix" + }, + { + "hash": "19aa699f17ecb3cadf770b70b15009dd8e1572fb", + "date": "2026-03-22 19:29:42 +0100", + "message": "fix: address CEO review findings — health check, error logging, camera guard, metrics, tests", + "type": "fix" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "ff2b82280c6002a5363f25af434e1898b15322ca", + "date": "2026-03-20 06:27:43 +0100", + "message": "fix: resolve LiveKit connection issues", + "type": "fix" + }, + { + "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", + "date": "2026-03-20 06:11:09 +0100", + "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", + "type": "fix" + }, + { + "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", + "date": "2026-03-20 05:34:38 +0100", + "message": "feat: add LiveKit webhook handler (Phase 1.5)", + "type": "feat" + }, + { + "hash": "a7b53d42f30bbdf99d13a943d0e3b24aab261eb2", + "date": "2026-03-20 05:30:27 +0100", + "message": "feat: rewrite server voice handlers for LiveKit (Phase 1)", + "type": "feat" + }, + { + "hash": "923d071a71342c771f3314744e81f5f16e8ce845", + "date": "2026-03-20 05:14:52 +0100", + "message": "feat: add LiveKit infrastructure (Phase 0)", + "type": "feat" + }, + { + "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", + "date": "2026-03-19 21:35:18 +0100", + "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", + "type": "fix" + }, + { + "hash": "92be1372292e3dc1fb342752bf326d87af6f2a57", + "date": "2026-03-19 17:13:59 +0100", + "message": "fix: use unique stream IDs for audio and video tracks to prevent dedup collision", + "type": "fix" + }, + { + "hash": "e16651093770e7920ad6e84df93d458ab72887ab", + "date": "2026-03-19 16:52:15 +0100", + "message": "test: add video track coexistence integration test", + "type": "test" + }, + { + "hash": "0be1250c71d5726eb44d7f4281e0d8535bac81d7", + "date": "2026-03-19 16:33:47 +0100", + "message": "feat: extend SFU to forward video tracks and enforce MaxVideo limit", + "type": "feat" + }, + { + "hash": "2bab20ea4f45aa10867280c4e86f89b4a0e428fe", + "date": "2026-03-19 16:25:12 +0100", + "message": "refactor: use composite track keys in VoiceRoom for multi-track support", + "type": "refactor" + }, + { + "hash": "22706a795581a003059a9838ace4ac05d43fefb2", + "date": "2026-03-19 06:20:00 +0100", + "message": "fix: resolve CI failures in server lint and client tests", + "type": "fix" + }, + { + "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", + "date": "2026-03-19 04:04:53 +0100", + "message": "fix: resolve 6 medium issues from full go-review", + "type": "fix" + }, + { + "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", + "date": "2026-03-19 03:53:40 +0100", + "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", + "type": "fix" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "a9b816cf2646f7aa6689812feb770d83af95c0e7", + "date": "2026-03-18 07:24:19 +0100", + "message": "fix: prevent stale PeerConnection callbacks from killing new voice sessions", + "type": "fix" + }, + { + "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", + "date": "2026-03-18 05:03:38 +0100", + "message": "test: boost server test coverage to 80%+ across all packages", + "type": "test" + }, + { + "hash": "bc12273ab78155ba942b2d9296736cec8254326b", + "date": "2026-03-18 04:30:38 +0100", + "message": "fix: resolve CI failures — coverage exclusion and stale test removal", + "type": "fix" + }, + { + "hash": "a97c4e928ce471ee92c836ad964d7b284cd49be1", + "date": "2026-03-18 04:16:49 +0100", + "message": "fix: guard ICE close handler to prevent double voice_leave and SQLITE_BUSY", + "type": "fix" + }, + { + "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", + "date": "2026-03-18 04:13:12 +0100", + "message": "fix: voice session cleanup on ICE close and connection failure", + "type": "fix" + }, + { + "hash": "74736439302946749bce01f4d23f38221315bf7f", + "date": "2026-03-18 03:53:22 +0100", + "message": "feat: wire SFU track forwarding and renegotiation in voice handlers", + "type": "feat" + }, + { + "hash": "fe4b5494d1e6cf00500a8d79ea6acc70e9f70181", + "date": "2026-03-18 03:49:23 +0100", + "message": "feat: add renegotiateParticipant with Perfect Negotiation", + "type": "feat" + }, + { + "hash": "62efd74a4dc2e7649b6a976c31bb8a6b0cf4efac", + "date": "2026-03-18 03:47:30 +0100", + "message": "feat: add GetClient helper and ICE candidate callback", + "type": "feat" + }, + { + "hash": "e3ee8a460a1470284446ebb646c5c45488e3a8c7", + "date": "2026-03-18 03:44:38 +0100", + "message": "feat: add buildVoiceOffer and buildVoiceICE message builders", + "type": "feat" + }, + { + "hash": "63ca0b8e00469754e13c978fee9eef8dfc499b83", + "date": "2026-03-18 03:41:48 +0100", + "message": "feat: add VoiceTrack struct and track CRUD to VoiceRoom", + "type": "feat" + }, + { + "hash": "00bbb46b62c3aab6e5791795c3602ed2a1f05ed8", + "date": "2026-03-18 02:41:55 +0100", + "message": "fix: reject duplicate WebSocket logins to prevent reconnect ping-pong", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", + "date": "2026-03-17 20:03:34 +0100", + "message": "fix: voice channel join/leave visibility and disconnect bugs", + "type": "fix" + }, + { + "hash": "17eb67811f7844324d6703bfddce0d7ae8996267", + "date": "2026-03-17 14:01:26 +0000", + "message": "fix: resolve CI failures — errcheck lint and TS noUncheckedIndexedAccess errors", + "type": "fix" + }, + { + "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", + "date": "2026-03-17 14:54:50 +0100", + "message": "fix: address PR #15 review issues (#16-#23)", + "type": "fix" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "1914067941b8512b895e32075fe2ced0b3da3841", + "date": "2026-03-17 03:20:37 +0100", + "message": "fix: address PR review findings (issues #3-#8)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "46e811031e23a6631ab475926af034e35802903a", + "date": "2026-03-16 17:17:00 +0100", + "message": "test: add authorization and contract tests for channel access", + "type": "test" + }, + { + "hash": "642cd541c781649698799245a6a3d0f4a8ae0a02", + "date": "2026-03-16 17:00:09 +0100", + "message": "feat: add attachment persistence and link on chat_send (High #3)", + "type": "feat" + }, + { + "hash": "0680a32496e69f60a21d76a80d2a2d40244fe805", + "date": "2026-03-16 16:54:56 +0100", + "message": "fix: resolve 4 Critical + 2 High + 1 Medium server protocol violations", + "type": "fix" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "09efb58697a546435cd3258f89249dbc97662254", + "date": "2026-03-14 21:58:18 +0100", + "message": "feat: add server_restart WebSocket message type for update notifications", + "type": "feat" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", + "date": "2026-03-14 21:17:09 +0100", + "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "client:other": { + "count": 15, + "lastCommit": "2026-03-30 21:05:28 +0200", + "commits": [ + { + "hash": "44390ad886be392b55d1a15995eba0fb9c4a80d1", + "date": "2026-03-30 21:05:28 +0200", + "message": "chore: clean up tracked files for v1.0.0 public release", + "type": "chore" + }, + { + "hash": "264b4f0f65fccc8ec7ec8fa9e8c61a10284a3cb0", + "date": "2026-03-17 02:52:49 +0100", + "message": "chore: clean up remaining WPF artifacts and track missing files", + "type": "chore" + }, + { + "hash": "5c4a232bde25f75b72cbe338d530e15d2631f430", + "date": "2026-03-17 02:49:45 +0100", + "message": "chore: remove legacy WPF client code and references", + "type": "chore" + }, + { + "hash": "743a2d974738f76db7764bdd43e78fbf25889d4f", + "date": "2026-03-15 16:54:55 +0100", + "message": "chore: update .gitignore to exclude local tooling, build artifacts, and internal docs", + "type": "chore" + }, + { + "hash": "c56244366097c8b44013e130994360d421a50c01", + "date": "2026-03-15 12:18:19 +0100", + "message": "fix: add JSON tags to Role/VoiceState, fix WebSocket error surfacing", + "type": "fix" + }, + { + "hash": "eac665ac88f3ce301b6ab523d507d934dc432777", + "date": "2026-03-15 12:00:28 +0100", + "message": "perf: fix O(n) allocations, freeze brushes, improve disposal and nullability", + "type": "perf" + }, + { + "hash": "86f95173051af898db3e623c746d2b37ca01e3c2", + "date": "2026-03-15 11:53:52 +0100", + "message": "fix: resolve critical TLS race, invisible messages, and 5 other review issues", + "type": "fix" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "32f2833eb458e9472c865439d3b2832967eab496", + "date": "2026-03-15 00:33:07 +0100", + "message": "chore: gitignore client publish output directories", + "type": "chore" + }, + { + "hash": "6de2764cf72a5eee45682bb9ab3a9b825220d683", + "date": "2026-03-15 00:31:56 +0100", + "message": "fix: include SavePassword/LoadPassword/DeletePassword in ICredentialService interface", + "type": "fix" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "a7ef09c69b9da0d70b60722826fb4f178d02da10", + "date": "2026-03-14 22:04:24 +0100", + "message": "feat: implement client auto-update with GitHub Release checking and update dialog", + "type": "feat" + }, + { + "hash": "94d0539f7f8d9065381e92bf69572ae6467eece7", + "date": "2026-03-14 21:07:25 +0100", + "message": "chore: add Client .gitignore, remove tracked build artifacts", + "type": "chore" + }, + { + "hash": "213c346bfaa292aaaca1eb81ef6b7a020ddf6417", + "date": "2026-03-14 21:07:07 +0100", + "message": "feat: scaffold Phase 3 WPF client shell with MVVM and TDD structure", + "type": "feat" + } + ] + }, + "ts:components": { + "count": 102, + "lastCommit": "2026-03-30 20:13:34 +0200", + "commits": [ + { + "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", + "date": "2026-03-30 20:13:34 +0200", + "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", + "type": "feat" + }, + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat" + }, + { + "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", + "date": "2026-03-30 12:42:31 +0200", + "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", + "type": "fix" + }, + { + "hash": "23b62a964c798ca99996be4de65e210a05b1af02", + "date": "2026-03-29 21:34:54 +0200", + "message": "fix: TypeScript build errors in embeds.ts and totp-settings test", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", + "date": "2026-03-29 19:39:22 +0200", + "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", + "type": "fix" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "21a438dca52f7c20ac31a03b900900fc3c09973e", + "date": "2026-03-28 12:16:57 +0100", + "message": "fix: accent color persistence and Discord-style settings panel", + "type": "fix" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "9636d0e9761b69b762ef869a5eaa1eb65cbb1320", + "date": "2026-03-27 14:25:54 +0100", + "message": "fix: DM header shows @ instead of #, DM welcome message, DM appears in sidebar list", + "type": "fix" + }, + { + "hash": "29d16f5976cc2d92050c56e88cff7295bc62a0c4", + "date": "2026-03-27 13:19:07 +0100", + "message": "fix: make neon-glow default theme, accent color picker now overrides theme accent", + "type": "fix" + }, + { + "hash": "6858c67408f19f973ac02f8658522825db85646e", + "date": "2026-03-27 13:10:21 +0100", + "message": "chore: stage QuickSwitchOverlay files missed from earlier commit", + "type": "chore" + }, + { + "hash": "2829220f06432cc6ae7e502059b898de79b12357", + "date": "2026-03-27 12:22:22 +0100", + "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", + "type": "fix" + }, + { + "hash": "80cd05923678a8552faf970c3f1e101d44f48938", + "date": "2026-03-27 12:09:47 +0100", + "message": "feat: add neon-glow to theme selector and apply body class on theme switch", + "type": "feat" + }, + { + "hash": "00a92851d4e2e92ed9412825fb2a70292770b889", + "date": "2026-03-27 12:09:38 +0100", + "message": "refactor: remove ServerStrip component and CSS (replaced by unified sidebar)", + "type": "refactor" + }, + { + "hash": "de5952fd269275179111c100031947ce49fd1ddd", + "date": "2026-03-27 11:46:40 +0100", + "message": "feat: add back-to-server header to DmSidebar", + "type": "feat" + }, + { + "hash": "57985d0a7f40496bc1dd65527fcbe617176b9ca7", + "date": "2026-03-27 11:39:48 +0100", + "message": "feat: add disconnect/switch-server button to UserBar", + "type": "feat" + }, + { + "hash": "8731750f75ca66c8870ddca5a03bce5fa2d5cb03", + "date": "2026-03-27 08:35:37 +0100", + "message": "feat: voice connection quality indicator with transport stats", + "type": "feat" + }, + { + "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", + "date": "2026-03-27 08:05:32 +0100", + "message": "fix: client code review — 27 fixes across 17 files", + "type": "fix" + }, + { + "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", + "date": "2026-03-26 22:09:56 +0100", + "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", + "type": "chore" + }, + { + "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", + "date": "2026-03-26 22:07:52 +0100", + "message": "feat: stream quality presets, nuclear mute, sidebar width fix", + "type": "feat" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", + "date": "2026-03-26 18:07:20 +0100", + "message": "feat: full screenshare support — button state, video grid, auto-reconnect", + "type": "feat" + }, + { + "hash": "c1d3d34768be3213e6dff6728e9b558c208d1283", + "date": "2026-03-25 19:15:50 +0100", + "message": "fix: invert sensitivity slider direction to match Discord UX", + "type": "fix" + }, + { + "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", + "date": "2026-03-25 19:07:52 +0100", + "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", + "type": "fix" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", + "date": "2026-03-24 20:23:40 +0100", + "message": "fix: address code review — security hardening, leak fixes, credential safety", + "type": "fix" + }, + { + "hash": "10063e0487172f606cab3fe385e33edd57befd29", + "date": "2026-03-22 19:29:53 +0100", + "message": "fix: address design review findings — empty states, ARIA, token refresh", + "type": "fix" + }, + { + "hash": "46be81081a009c022710b0275a3994ed1cbb191a", + "date": "2026-03-22 12:07:24 +0100", + "message": "fix: address code review issues in settings and message rendering", + "type": "fix" + }, + { + "hash": "78c13c7ec43e0867cbe6d7b4cb9a705c3d99c1bb", + "date": "2026-03-22 11:16:21 +0100", + "message": "fix: use Rust invoke for Open DevTools button", + "type": "fix" + }, + { + "hash": "5df1cf71cb05ff82a2c9c67ff3d263fad4a0dee6", + "date": "2026-03-22 11:02:38 +0100", + "message": "feat: wire up settings preferences to their consuming features", + "type": "feat" + }, + { + "hash": "6734deb57e0f3247089c2dc5eeb5c8c84136d554", + "date": "2026-03-22 10:31:00 +0100", + "message": "fix: scope settings CSS classes and use correct separator class", + "type": "fix" + }, + { + "hash": "8215c500cbbf4a60edcef31e5dfccec75c10f743", + "date": "2026-03-22 10:29:46 +0100", + "message": "feat: add Navigation, Communication, and Messages sections to Keybinds tab", + "type": "feat" + }, + { + "hash": "7a2feb1a55eb85037e68b0a9fa385a749d680287", + "date": "2026-03-22 10:26:41 +0100", + "message": "feat: add Advanced settings tab with Developer Mode, Hardware Acceleration, and DevTools", + "type": "feat" + }, + { + "hash": "57137d1a416374dd4f4c46943ec6bc7cf5634779", + "date": "2026-03-22 10:18:18 +0100", + "message": "feat: add Accessibility settings tab with reduced motion, high contrast, and large font", + "type": "feat" + }, + { + "hash": "f9bdeca12e1cd70fb0885afb41ec4f0aab5fd63f", + "date": "2026-03-22 10:09:41 +0100", + "message": "feat: add Text & Images settings tab", + "type": "feat" + }, + { + "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", + "date": "2026-03-22 10:04:49 +0100", + "message": "feat: add user status selector to Account settings tab", + "type": "feat" + }, + { + "hash": "aed8734828503b21e5a908bcac8b592e9e184a1a", + "date": "2026-03-22 09:54:07 +0100", + "message": "feat: add input/output volume sliders to Voice & Audio settings tab", + "type": "feat" + }, + { + "hash": "20e30bee96237c1d8970c294a4c249fe1f864137", + "date": "2026-03-22 09:50:32 +0100", + "message": "feat: add accent color picker to Appearance settings tab", + "type": "feat" + }, + { + "hash": "ea2edcd6896d58a8f6d0b4b466aa92912136f1bd", + "date": "2026-03-22 09:44:46 +0100", + "message": "fix: remove duplicate h1 headers in settings tabs and guard camera auto-start", + "type": "fix" + }, + { + "hash": "c3a13d09a142bcf4b233a278c9178d0f05fc3cfa", + "date": "2026-03-22 09:30:03 +0100", + "message": "fix: address review issues in settings redesign", + "type": "fix" + }, + { + "hash": "5bf9f89ed3aa1546d0e16577425d6553210eb434", + "date": "2026-03-22 09:18:52 +0100", + "message": "feat: Discord-style account profile card redesign (Phase 2 + 3)", + "type": "feat" + }, + { + "hash": "39eb2ab2b8545fe875cf5a5c80451ac3d0d035cf", + "date": "2026-03-22 09:14:55 +0100", + "message": "feat: Discord-style settings sidebar with icons, profile section, and categories", + "type": "feat" + }, + { + "hash": "e0a811fc156c90f24cdb92232699cbf3d1bc4825", + "date": "2026-03-22 09:00:48 +0100", + "message": "feat: Discord-style timestamps, reply avatars, virtual scroll fixes", + "type": "feat" + }, + { + "hash": "1679d071783a26d514d1a603d0e9912c8f398da6", + "date": "2026-03-22 08:47:09 +0100", + "message": "feat: add Discord-style timestamp formatting and message layout tokens", + "type": "feat" + }, + { + "hash": "5048a077810934e800d1e2e26b15da257f511fcc", + "date": "2026-03-22 08:19:22 +0100", + "message": "style: redesign server invites modal with card layout and icons", + "type": "other" + }, + { + "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", + "date": "2026-03-21 23:33:18 +0100", + "message": "feat: replace all emoji icons with Lucide SVG icons", + "type": "feat" + }, + { + "hash": "fa690a9ad41ac70fc20c10282ed148382209d4fe", + "date": "2026-03-21 21:55:28 +0100", + "message": "polish: pinned panel — avatar colors, timestamp formatting, CSS tokens", + "type": "other" + }, + { + "hash": "8445fa6799341ef00b0a1fd668a726423ea5b0d2", + "date": "2026-03-21 21:51:05 +0100", + "message": "feat: Discord-style pinned messages panel with avatars and animations", + "type": "feat" + }, + { + "hash": "e9ddef659ea489550f0787cfad4c528de604ff3c", + "date": "2026-03-21 21:15:55 +0100", + "message": "feat: add copy button to code blocks", + "type": "feat" + }, + { + "hash": "a522ff4aa26b85413a91aa73ac4458cc10a0eb72", + "date": "2026-03-21 21:14:23 +0100", + "message": "fix: Escape cancels reply/edit + full date tooltips on timestamps", + "type": "fix" + }, + { + "hash": "6878138b33c3fb7b79bb898a1c6dfb025eaf5bdf", + "date": "2026-03-21 21:12:42 +0100", + "message": "feat: add scroll-to-bottom floating button in MessageList", + "type": "feat" + }, + { + "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", + "date": "2026-03-21 21:08:58 +0100", + "message": "feat: add Discord-style welcome state for empty channels", + "type": "feat" + }, + { + "hash": "b460bf925523db1fb28464dd143d686b05308287", + "date": "2026-03-21 21:00:54 +0100", + "message": "perf: only rebuild message list DOM on member role changes", + "type": "perf" + }, + { + "hash": "b1868a99fef8fdf8a60e0c2ba65ef29220a0908b", + "date": "2026-03-21 21:00:54 +0100", + "message": "fix: use null-safe check for file input selection in MessageInput", + "type": "fix" + }, + { + "hash": "f9bc7b1a6b33dd33f619178ddb7255455ce4015b", + "date": "2026-03-21 20:59:32 +0100", + "message": "fix: remove duplicate lightbox and fix event listener leaks in openImageLightbox", + "type": "fix" + }, + { + "hash": "a7fb5a95ddc599b39cde297f58f8f3c96699cce7", + "date": "2026-03-21 20:55:57 +0100", + "message": "fix: remove duplicate lightbox and fix event listener leaks in openImageLightbox", + "type": "fix" + }, + { + "hash": "6ee3698525b22e2471c05e9e0addb8b42e963470", + "date": "2026-03-21 20:53:47 +0100", + "message": "fix: prevent virtual scroll rebuild loops and improve image height caching", + "type": "fix" + }, + { + "hash": "4dd1580f43564f6faa0f500914e2ac1b62eb08ae", + "date": "2026-03-21 12:39:17 +0100", + "message": "fix: measure DOM before setting spacers + update spacers on unchanged range", + "type": "fix" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "dba5f75fd8c8f066af3520e9194e15ae0750ee4b", + "date": "2026-03-20 07:48:13 +0100", + "message": "feat: sensitivity slider controls mic gating + speaking ring", + "type": "feat" + }, + { + "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", + "date": "2026-03-20 05:46:19 +0100", + "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", + "type": "feat" + }, + { + "hash": "f8e41180f48632937e8184d48106dc05287c1721", + "date": "2026-03-20 02:21:53 +0100", + "message": "fix: settings bugs, accessibility, and camera stream leak", + "type": "fix" + }, + { + "hash": "17600081dfef4d5bccf6777279559599acfc1eff", + "date": "2026-03-20 02:00:29 +0100", + "message": "feat: wire search bar to server FTS5 search API (T-065)", + "type": "feat" + }, + { + "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", + "date": "2026-03-19 17:54:13 +0100", + "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", + "type": "docs" + }, + { + "hash": "c6e864580946b4017e67182c777b19936f436458", + "date": "2026-03-19 17:43:32 +0100", + "message": "fix: stop camera preview and mic meter when settings overlay closes", + "type": "fix" + }, + { + "hash": "ccc51015fc984f12438c9ceb6594449867e41018", + "date": "2026-03-19 16:47:26 +0100", + "message": "feat: add camera active indicator to voice widget", + "type": "feat" + }, + { + "hash": "9ad91c6393d28dc8b75e9b229b9b4b46bb1386bb", + "date": "2026-03-19 16:47:18 +0100", + "message": "feat: add webcam device selector and preview to settings", + "type": "feat" + }, + { + "hash": "81b45d55e312c7f6a4085073ffbc8842d7c5bd6f", + "date": "2026-03-19 16:39:35 +0100", + "message": "feat: add VideoGrid component for rendering camera streams", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "04ae86b0b2166cd8a74e18d36e74f29bfd25b4c5", + "date": "2026-03-19 09:51:16 +0100", + "message": "feat: show client version on settings Logs tab", + "type": "feat" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", + "date": "2026-03-18 17:10:16 +0100", + "message": "feat: native file downloads, upload size fix, native E2E tests", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "a37abb47a266fca465ebaaa6866308f3868df373", + "date": "2026-03-18 11:33:59 +0100", + "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", + "type": "fix" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", + "date": "2026-03-18 07:03:53 +0100", + "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", + "type": "fix" + }, + { + "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", + "date": "2026-03-18 06:45:05 +0100", + "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", + "type": "fix" + }, + { + "hash": "d3d1e067db271a199c641771604f55e05acd809c", + "date": "2026-03-18 06:40:12 +0100", + "message": "fix: render actual images for attachments, remove orphaned components (BUG-026, BUG-030)", + "type": "fix" + }, + { + "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", + "date": "2026-03-18 06:33:17 +0100", + "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", + "type": "fix" + }, + { + "hash": "c1e2733f11a746aef74897bed712f69693e72acb", + "date": "2026-03-18 06:28:45 +0100", + "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", + "date": "2026-03-17 20:03:34 +0100", + "message": "fix: voice channel join/leave visibility and disconnect bugs", + "type": "fix" + }, + { + "hash": "1914067941b8512b895e32075fe2ced0b3da3841", + "date": "2026-03-17 03:20:37 +0100", + "message": "fix: address PR review findings (issues #3-#8)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "4dc61a506f18398c424d04eb6dbf31d44a5cbc30", + "date": "2026-03-17 02:25:49 +0100", + "message": "feat: add virtual scrolling to MessageList for large channels", + "type": "feat" + }, + { + "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", + "date": "2026-03-17 02:17:26 +0100", + "message": "refactor: split oversized files + add store notification batching", + "type": "refactor" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "ts:styles": { + "count": 61, + "lastCommit": "2026-03-30 20:13:34 +0200", + "commits": [ + { + "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", + "date": "2026-03-30 20:13:34 +0200", + "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", + "type": "feat" + }, + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "21a438dca52f7c20ac31a03b900900fc3c09973e", + "date": "2026-03-28 12:16:57 +0100", + "message": "fix: accent color persistence and Discord-style settings panel", + "type": "fix" + }, + { + "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", + "date": "2026-03-28 11:24:31 +0100", + "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "81d97c027929a2c308e328f073ef4a094805bd2b", + "date": "2026-03-27 14:51:38 +0100", + "message": "feat: move invite button to unified header, hide duplicate channel-sidebar-header", + "type": "feat" + }, + { + "hash": "29d16f5976cc2d92050c56e88cff7295bc62a0c4", + "date": "2026-03-27 13:19:07 +0100", + "message": "fix: make neon-glow default theme, accent color picker now overrides theme accent", + "type": "fix" + }, + { + "hash": "b8be76041a879e6ed256c745d2fe10fbd945088a", + "date": "2026-03-27 13:04:35 +0100", + "message": "fix: compact member items in sidebar, increase max resize height to 65%", + "type": "fix" + }, + { + "hash": "cfcc76760d0408115971b4224ebcd6e2644301c1", + "date": "2026-03-27 12:56:40 +0100", + "message": "feat: add member list header/resize, DM section in sidebar, remove member toggle from chat header", + "type": "feat" + }, + { + "hash": "ca0016ecac8b96ba25977de1e5d04316020987b6", + "date": "2026-03-27 12:29:44 +0100", + "message": "feat: relocate MemberList into unified sidebar as collapsible section", + "type": "feat" + }, + { + "hash": "2829220f06432cc6ae7e502059b898de79b12357", + "date": "2026-03-27 12:22:22 +0100", + "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", + "type": "fix" + }, + { + "hash": "00a92851d4e2e92ed9412825fb2a70292770b889", + "date": "2026-03-27 12:09:38 +0100", + "message": "refactor: remove ServerStrip component and CSS (replaced by unified sidebar)", + "type": "refactor" + }, + { + "hash": "1f4875c781845a7408d89fa6a5934e06edf884a3", + "date": "2026-03-27 12:01:25 +0100", + "message": "feat: rewrite SidebarArea as unified sidebar (removes ServerStrip)", + "type": "feat" + }, + { + "hash": "de5952fd269275179111c100031947ce49fd1ddd", + "date": "2026-03-27 11:46:40 +0100", + "message": "feat: add back-to-server header to DmSidebar", + "type": "feat" + }, + { + "hash": "7715ac29553df52a6632fe34729823ca99a5f299", + "date": "2026-03-27 11:32:36 +0100", + "message": "feat: add OC Neon Glow theme CSS with theme contract variables", + "type": "feat" + }, + { + "hash": "e19b0aa05f828850da3f27dd5142bbda64251d8a", + "date": "2026-03-27 10:00:03 +0100", + "message": "feat: login screen redesign with OC branding and animations", + "type": "feat" + }, + { + "hash": "8731750f75ca66c8870ddca5a03bce5fa2d5cb03", + "date": "2026-03-27 08:35:37 +0100", + "message": "feat: voice connection quality indicator with transport stats", + "type": "feat" + }, + { + "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", + "date": "2026-03-26 22:07:52 +0100", + "message": "feat: stream quality presets, nuclear mute, sidebar width fix", + "type": "feat" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", + "date": "2026-03-25 19:07:52 +0100", + "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", + "type": "fix" + }, + { + "hash": "10063e0487172f606cab3fe385e33edd57befd29", + "date": "2026-03-22 19:29:53 +0100", + "message": "fix: address design review findings — empty states, ARIA, token refresh", + "type": "fix" + }, + { + "hash": "46be81081a009c022710b0275a3994ed1cbb191a", + "date": "2026-03-22 12:07:24 +0100", + "message": "fix: address code review issues in settings and message rendering", + "type": "fix" + }, + { + "hash": "57137d1a416374dd4f4c46943ec6bc7cf5634779", + "date": "2026-03-22 10:18:18 +0100", + "message": "feat: add Accessibility settings tab with reduced motion, high contrast, and large font", + "type": "feat" + }, + { + "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", + "date": "2026-03-22 10:04:49 +0100", + "message": "feat: add user status selector to Account settings tab", + "type": "feat" + }, + { + "hash": "20e30bee96237c1d8970c294a4c249fe1f864137", + "date": "2026-03-22 09:50:32 +0100", + "message": "feat: add accent color picker to Appearance settings tab", + "type": "feat" + }, + { + "hash": "c3a13d09a142bcf4b233a278c9178d0f05fc3cfa", + "date": "2026-03-22 09:30:03 +0100", + "message": "fix: address review issues in settings redesign", + "type": "fix" + }, + { + "hash": "5bf9f89ed3aa1546d0e16577425d6553210eb434", + "date": "2026-03-22 09:18:52 +0100", + "message": "feat: Discord-style account profile card redesign (Phase 2 + 3)", + "type": "feat" + }, + { + "hash": "39eb2ab2b8545fe875cf5a5c80451ac3d0d035cf", + "date": "2026-03-22 09:14:55 +0100", + "message": "feat: Discord-style settings sidebar with icons, profile section, and categories", + "type": "feat" + }, + { + "hash": "e0a811fc156c90f24cdb92232699cbf3d1bc4825", + "date": "2026-03-22 09:00:48 +0100", + "message": "feat: Discord-style timestamps, reply avatars, virtual scroll fixes", + "type": "feat" + }, + { + "hash": "94a4bdd0ef960df16c238a13cc563f892281b840", + "date": "2026-03-22 08:58:15 +0100", + "message": "style: update compact mode spacing for Discord layout", + "type": "other" + }, + { + "hash": "1679d071783a26d514d1a603d0e9912c8f398da6", + "date": "2026-03-22 08:47:09 +0100", + "message": "feat: add Discord-style timestamp formatting and message layout tokens", + "type": "feat" + }, + { + "hash": "5048a077810934e800d1e2e26b15da257f511fcc", + "date": "2026-03-22 08:19:22 +0100", + "message": "style: redesign server invites modal with card layout and icons", + "type": "other" + }, + { + "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", + "date": "2026-03-21 23:33:18 +0100", + "message": "feat: replace all emoji icons with Lucide SVG icons", + "type": "feat" + }, + { + "hash": "3cf8544cca8477671ffafc3b16565184457dcc1f", + "date": "2026-03-21 22:27:55 +0100", + "message": "fix: address code review — spin keyframe collision, mentioned border shift, use hover token", + "type": "fix" + }, + { + "hash": "359cb2802a3cfd771fe886d1547a3d55b28d243c", + "date": "2026-03-21 22:23:38 +0100", + "message": "style: mention highlights, skeleton loaders, tooltips, role color support", + "type": "other" + }, + { + "hash": "7557d4bb8361345af455e28241c1638774c0eaae", + "date": "2026-03-21 22:21:33 +0100", + "message": "style: input focus glow, picker/action animations, spring popup", + "type": "other" + }, + { + "hash": "6e3143c4a864fcac01465fee41b14c087f763b2f", + "date": "2026-03-21 22:19:23 +0100", + "message": "style: Discord polish — unread pill, offline desaturation, hover tints, animations", + "type": "other" + }, + { + "hash": "2cb0e99e6a8979ae5c5e905b938ddad1a93f0f9d", + "date": "2026-03-21 22:12:11 +0100", + "message": "style: Discord-style scrollbars, font rendering, and header elevation shadows", + "type": "other" + }, + { + "hash": "413f0c0022fa68bace6ba3844a072e9c4fdd5cfc", + "date": "2026-03-21 22:10:30 +0100", + "message": "style: add Discord-style design tokens for hover, interactive, elevation, typography", + "type": "other" + }, + { + "hash": "fa690a9ad41ac70fc20c10282ed148382209d4fe", + "date": "2026-03-21 21:55:28 +0100", + "message": "polish: pinned panel — avatar colors, timestamp formatting, CSS tokens", + "type": "other" + }, + { + "hash": "8445fa6799341ef00b0a1fd668a726423ea5b0d2", + "date": "2026-03-21 21:51:05 +0100", + "message": "feat: Discord-style pinned messages panel with avatars and animations", + "type": "feat" + }, + { + "hash": "f39f7c6d7e1ba2664731f30667bd16e9cb675482", + "date": "2026-03-21 21:19:48 +0100", + "message": "polish: CSS visual consistency pass — hover/focus/transitions", + "type": "other" + }, + { + "hash": "e9ddef659ea489550f0787cfad4c528de604ff3c", + "date": "2026-03-21 21:15:55 +0100", + "message": "feat: add copy button to code blocks", + "type": "feat" + }, + { + "hash": "6878138b33c3fb7b79bb898a1c6dfb025eaf5bdf", + "date": "2026-03-21 21:12:42 +0100", + "message": "feat: add scroll-to-bottom floating button in MessageList", + "type": "feat" + }, + { + "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", + "date": "2026-03-21 21:08:58 +0100", + "message": "feat: add Discord-style welcome state for empty channels", + "type": "feat" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "17600081dfef4d5bccf6777279559599acfc1eff", + "date": "2026-03-20 02:00:29 +0100", + "message": "feat: wire search bar to server FTS5 search API (T-065)", + "type": "feat" + }, + { + "hash": "a764d12bafb818dc0ffe1a93bf85f4d33cb00e8d", + "date": "2026-03-19 16:47:34 +0100", + "message": "feat: add video grid CSS styles", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", + "date": "2026-03-18 17:10:16 +0100", + "message": "feat: native file downloads, upload size fix, native E2E tests", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "ts:lib": { + "count": 85, + "lastCommit": "2026-03-30 19:12:36 +0200", + "commits": [ + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat" + }, + { + "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", + "date": "2026-03-30 12:42:31 +0200", + "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", + "date": "2026-03-28 18:23:18 +0100", + "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", + "type": "fix" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "21a438dca52f7c20ac31a03b900900fc3c09973e", + "date": "2026-03-28 12:16:57 +0100", + "message": "fix: accent color persistence and Discord-style settings panel", + "type": "fix" + }, + { + "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", + "date": "2026-03-28 11:24:31 +0100", + "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", + "type": "feat" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "e5afe7d20136f78e09ffd7fd951814a036c84fb3", + "date": "2026-03-27 14:08:25 +0100", + "message": "feat(client): add DM store, API methods, dispatcher events, and sidebar wiring", + "type": "feat" + }, + { + "hash": "29d16f5976cc2d92050c56e88cff7295bc62a0c4", + "date": "2026-03-27 13:19:07 +0100", + "message": "fix: make neon-glow default theme, accent color picker now overrides theme accent", + "type": "fix" + }, + { + "hash": "2829220f06432cc6ae7e502059b898de79b12357", + "date": "2026-03-27 12:22:22 +0100", + "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", + "type": "fix" + }, + { + "hash": "57985d0a7f40496bc1dd65527fcbe617176b9ca7", + "date": "2026-03-27 11:39:48 +0100", + "message": "feat: add disconnect/switch-server button to UserBar", + "type": "feat" + }, + { + "hash": "e6b5d43e669b1aa1ec4bfccf3aa4a235dc8980f0", + "date": "2026-03-27 11:37:57 +0100", + "message": "feat: add theme manager with built-in + custom theme support", + "type": "feat" + }, + { + "hash": "c7809347499263f3b4de078a19b6043eb4470f88", + "date": "2026-03-27 08:41:34 +0100", + "message": "fix: connection stats RTT detection and rate formatting", + "type": "fix" + }, + { + "hash": "8731750f75ca66c8870ddca5a03bce5fa2d5cb03", + "date": "2026-03-27 08:35:37 +0100", + "message": "feat: voice connection quality indicator with transport stats", + "type": "feat" + }, + { + "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", + "date": "2026-03-27 08:05:32 +0100", + "message": "fix: client code review — 27 fixes across 17 files", + "type": "fix" + }, + { + "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", + "date": "2026-03-26 22:07:52 +0100", + "message": "feat: stream quality presets, nuclear mute, sidebar width fix", + "type": "feat" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", + "date": "2026-03-26 18:07:20 +0100", + "message": "feat: full screenshare support — button state, video grid, auto-reconnect", + "type": "feat" + }, + { + "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", + "date": "2026-03-25 19:07:52 +0100", + "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", + "type": "fix" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", + "date": "2026-03-24 20:23:40 +0100", + "message": "fix: address code review — security hardening, leak fixes, credential safety", + "type": "fix" + }, + { + "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", + "date": "2026-03-22 21:10:02 +0100", + "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", + "type": "fix" + }, + { + "hash": "10063e0487172f606cab3fe385e33edd57befd29", + "date": "2026-03-22 19:29:53 +0100", + "message": "fix: address design review findings — empty states, ARIA, token refresh", + "type": "fix" + }, + { + "hash": "9949e9de9ea8e84a6b8448bc78e04df83cb392be", + "date": "2026-03-22 15:34:26 +0100", + "message": "fix: restore audio track attachment for remote playback", + "type": "fix" + }, + { + "hash": "46be81081a009c022710b0275a3994ed1cbb191a", + "date": "2026-03-22 12:07:24 +0100", + "message": "fix: address code review issues in settings and message rendering", + "type": "fix" + }, + { + "hash": "5df1cf71cb05ff82a2c9c67ff3d263fad4a0dee6", + "date": "2026-03-22 11:02:38 +0100", + "message": "feat: wire up settings preferences to their consuming features", + "type": "feat" + }, + { + "hash": "f9bdeca12e1cd70fb0885afb41ec4f0aab5fd63f", + "date": "2026-03-22 10:09:41 +0100", + "message": "feat: add Text & Images settings tab", + "type": "feat" + }, + { + "hash": "aed8734828503b21e5a908bcac8b592e9e184a1a", + "date": "2026-03-22 09:54:07 +0100", + "message": "feat: add input/output volume sliders to Voice & Audio settings tab", + "type": "feat" + }, + { + "hash": "39eb2ab2b8545fe875cf5a5c80451ac3d0d035cf", + "date": "2026-03-22 09:14:55 +0100", + "message": "feat: Discord-style settings sidebar with icons, profile section, and categories", + "type": "feat" + }, + { + "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", + "date": "2026-03-21 23:33:18 +0100", + "message": "feat: replace all emoji icons with Lucide SVG icons", + "type": "feat" + }, + { + "hash": "6ee3698525b22e2471c05e9e0addb8b42e963470", + "date": "2026-03-21 20:53:47 +0100", + "message": "fix: prevent virtual scroll rebuild loops and improve image height caching", + "type": "fix" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "a9998714d7b39ae40a30dd1b66829a83683d97fd", + "date": "2026-03-21 12:07:34 +0100", + "message": "fix: reset camera state on voice leave to prevent stale video grid", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "dba5f75fd8c8f066af3520e9194e15ae0750ee4b", + "date": "2026-03-20 07:48:13 +0100", + "message": "feat: sensitivity slider controls mic gating + speaking ring", + "type": "feat" + }, + { + "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", + "date": "2026-03-20 07:11:45 +0100", + "message": "fix: speaking ring, devtools, CSP, and connection fixes", + "type": "fix" + }, + { + "hash": "91ebce72727a54fb96c72dadeb257bd92124ef4f", + "date": "2026-03-20 06:33:57 +0100", + "message": "fix: wire LiveKit speaker detection to voice activation ring", + "type": "fix" + }, + { + "hash": "ff2b82280c6002a5363f25af434e1898b15322ca", + "date": "2026-03-20 06:27:43 +0100", + "message": "fix: resolve LiveKit connection issues", + "type": "fix" + }, + { + "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", + "date": "2026-03-20 06:11:09 +0100", + "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", + "type": "fix" + }, + { + "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", + "date": "2026-03-20 05:46:19 +0100", + "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", + "type": "feat" + }, + { + "hash": "f8e41180f48632937e8184d48106dc05287c1721", + "date": "2026-03-20 02:21:53 +0100", + "message": "fix: settings bugs, accessibility, and camera stream leak", + "type": "fix" + }, + { + "hash": "f11a53913ffb192b6d2978834e793d1053e45d27", + "date": "2026-03-20 01:33:43 +0100", + "message": "refactor: extract MainPage into focused controllers with tests", + "type": "refactor" + }, + { + "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", + "date": "2026-03-19 21:35:18 +0100", + "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", + "type": "fix" + }, + { + "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", + "date": "2026-03-19 17:54:13 +0100", + "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", + "type": "docs" + }, + { + "hash": "92be1372292e3dc1fb342752bf326d87af6f2a57", + "date": "2026-03-19 17:13:59 +0100", + "message": "fix: use unique stream IDs for audio and video tracks to prevent dedup collision", + "type": "fix" + }, + { + "hash": "594fe3ebb486e2a6cd1f8a06c06635cc993f6a45", + "date": "2026-03-19 17:07:30 +0100", + "message": "fix: add local camera self-view to video grid", + "type": "fix" + }, + { + "hash": "e41da3707094a7a38eb8a0884058e326d60f2607", + "date": "2026-03-19 16:42:24 +0100", + "message": "feat: add camera enable/disable and remote video handling to voice session", + "type": "feat" + }, + { + "hash": "5b10bc28f323b8b2365036de84a72f5d2897e3bc", + "date": "2026-03-19 16:39:29 +0100", + "message": "feat: add video device manager for camera capture", + "type": "feat" + }, + { + "hash": "9399dfe1254f16d72a6c6c8ae490c0fc48a7e7be", + "date": "2026-03-19 16:38:43 +0100", + "message": "feat: add video track add/remove to WebRTC service", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "a9b816cf2646f7aa6689812feb770d83af95c0e7", + "date": "2026-03-18 07:24:19 +0100", + "message": "fix: prevent stale PeerConnection callbacks from killing new voice sessions", + "type": "fix" + }, + { + "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", + "date": "2026-03-18 07:03:53 +0100", + "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", + "type": "fix" + }, + { + "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", + "date": "2026-03-18 06:45:05 +0100", + "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", + "type": "fix" + }, + { + "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", + "date": "2026-03-18 06:33:17 +0100", + "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", + "type": "fix" + }, + { + "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", + "date": "2026-03-18 04:13:12 +0100", + "message": "fix: voice session cleanup on ICE close and connection failure", + "type": "fix" + }, + { + "hash": "eb5f399075bc19e6d004adf1d09b0f2a87f71e78", + "date": "2026-03-18 03:59:41 +0100", + "message": "feat: add voice_offer/answer/ice dispatcher handlers", + "type": "feat" + }, + { + "hash": "be5efad53661ea32c385f911cc236be070893669", + "date": "2026-03-18 03:57:57 +0100", + "message": "feat: create voiceSession module for voice lifecycle orchestration", + "type": "feat" + }, + { + "hash": "a66b2d24062366867f60c2ad78e63b16e938ae9b", + "date": "2026-03-18 03:55:49 +0100", + "message": "feat: add handleServerOffer with SDP rollback and createOffer", + "type": "feat" + }, + { + "hash": "ffc9252810072c2dba70c0da12df510314fa6b85", + "date": "2026-03-18 03:38:04 +0100", + "message": "fix: correct VoiceIcePayload candidate type to RTCIceCandidateInit", + "type": "fix" + }, + { + "hash": "00bbb46b62c3aab6e5791795c3602ed2a1f05ed8", + "date": "2026-03-18 02:41:55 +0100", + "message": "fix: reject duplicate WebSocket logins to prevent reconnect ping-pong", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "17eb67811f7844324d6703bfddce0d7ae8996267", + "date": "2026-03-17 14:01:26 +0000", + "message": "fix: resolve CI failures — errcheck lint and TS noUncheckedIndexedAccess errors", + "type": "fix" + }, + { + "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", + "date": "2026-03-17 14:54:50 +0100", + "message": "fix: address PR #15 review issues (#16-#23)", + "type": "fix" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", + "date": "2026-03-17 02:17:26 +0100", + "message": "refactor: split oversized files + add store notification batching", + "type": "refactor" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "ts:pages": { + "count": 72, + "lastCommit": "2026-03-30 19:12:36 +0200", + "commits": [ + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat" + }, + { + "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", + "date": "2026-03-30 12:42:31 +0200", + "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", + "type": "fix" + }, + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", + "date": "2026-03-28 11:24:31 +0100", + "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", + "type": "feat" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "81d97c027929a2c308e328f073ef4a094805bd2b", + "date": "2026-03-27 14:51:38 +0100", + "message": "feat: move invite button to unified header, hide duplicate channel-sidebar-header", + "type": "feat" + }, + { + "hash": "3dba5dd42c923c80de180c38be4ec51acd77e711", + "date": "2026-03-27 14:46:09 +0100", + "message": "fix: closing DM switches to next DM or channels, resolve DM recipient name for header", + "type": "fix" + }, + { + "hash": "f96be172b40560287f482aaee18294ea95dc0bab", + "date": "2026-03-27 14:41:36 +0100", + "message": "fix: only save non-DM channels as \"channel before DM\" for back navigation", + "type": "fix" + }, + { + "hash": "1331fce0ae6509cbf9c12a233e3a15719a049349", + "date": "2026-03-27 14:39:47 +0100", + "message": "fix: DM close removes from sidebar, DM header shows real user status", + "type": "fix" + }, + { + "hash": "6207a0c936d3e3170ec2b5d816969b5b21f6a1b6", + "date": "2026-03-27 14:35:17 +0100", + "message": "fix: restore previous channel when leaving DM mode via back button", + "type": "fix" + }, + { + "hash": "9636d0e9761b69b762ef869a5eaa1eb65cbb1320", + "date": "2026-03-27 14:25:54 +0100", + "message": "fix: DM header shows @ instead of #, DM welcome message, DM appears in sidebar list", + "type": "fix" + }, + { + "hash": "59a752bd3b1e357509586a4dbf138640542f030e", + "date": "2026-03-27 14:13:52 +0100", + "message": "fix: add visible class to DM member picker modal overlay", + "type": "fix" + }, + { + "hash": "e5afe7d20136f78e09ffd7fd951814a036c84fb3", + "date": "2026-03-27 14:08:25 +0100", + "message": "feat(client): add DM store, API methods, dispatcher events, and sidebar wiring", + "type": "feat" + }, + { + "hash": "b8be76041a879e6ed256c745d2fe10fbd945088a", + "date": "2026-03-27 13:04:35 +0100", + "message": "fix: compact member items in sidebar, increase max resize height to 65%", + "type": "fix" + }, + { + "hash": "cfcc76760d0408115971b4224ebcd6e2644301c1", + "date": "2026-03-27 12:56:40 +0100", + "message": "feat: add member list header/resize, DM section in sidebar, remove member toggle from chat header", + "type": "feat" + }, + { + "hash": "5a2d943960ca816b0c853815adef5dace99b4983", + "date": "2026-03-27 12:39:34 +0100", + "message": "feat: wire DM conversations to member data in sidebar", + "type": "feat" + }, + { + "hash": "3c98e1b984e181df2f58387880b6e2332b7258dc", + "date": "2026-03-27 12:34:28 +0100", + "message": "feat: wire quick-switch overlay to disconnect and navigate to ConnectPage", + "type": "feat" + }, + { + "hash": "ca0016ecac8b96ba25977de1e5d04316020987b6", + "date": "2026-03-27 12:29:44 +0100", + "message": "feat: relocate MemberList into unified sidebar as collapsible section", + "type": "feat" + }, + { + "hash": "90230a2f90b0a86c33db4973b726968ea291b6da", + "date": "2026-03-27 12:04:48 +0100", + "message": "feat: update MainPage layout — remove server strip and standalone member list", + "type": "feat" + }, + { + "hash": "1f4875c781845a7408d89fa6a5934e06edf884a3", + "date": "2026-03-27 12:01:25 +0100", + "message": "feat: rewrite SidebarArea as unified sidebar (removes ServerStrip)", + "type": "feat" + }, + { + "hash": "39b036220679c77ca421a9a153f086d59f244dbb", + "date": "2026-03-27 11:46:49 +0100", + "message": "feat: add DM mode support to ChatHeader with recipient info", + "type": "feat" + }, + { + "hash": "e92772ce79ef6b7750c69e9e3ec00e71f1baed79", + "date": "2026-03-27 10:13:31 +0100", + "message": "fix: move status dot out of icon div to fix letter centering", + "type": "fix" + }, + { + "hash": "e954348d8e279c0213fa516e08ffc968746dd601", + "date": "2026-03-27 10:05:18 +0100", + "message": "fix: replace form logo with OC neon glow SVG to match branding", + "type": "fix" + }, + { + "hash": "aee0e5659a6166010b9ee3ac268e2ae922e44b4c", + "date": "2026-03-27 10:04:08 +0100", + "message": "fix: server icon shows single initial for compact 28px size", + "type": "fix" + }, + { + "hash": "e19b0aa05f828850da3f27dd5142bbda64251d8a", + "date": "2026-03-27 10:00:03 +0100", + "message": "feat: login screen redesign with OC branding and animations", + "type": "feat" + }, + { + "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", + "date": "2026-03-26 22:09:56 +0100", + "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", + "type": "chore" + }, + { + "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", + "date": "2026-03-26 18:07:20 +0100", + "message": "feat: full screenshare support — button state, video grid, auto-reconnect", + "type": "feat" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", + "date": "2026-03-22 10:04:49 +0100", + "message": "feat: add user status selector to Account settings tab", + "type": "feat" + }, + { + "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", + "date": "2026-03-21 23:33:18 +0100", + "message": "feat: replace all emoji icons with Lucide SVG icons", + "type": "feat" + }, + { + "hash": "fa690a9ad41ac70fc20c10282ed148382209d4fe", + "date": "2026-03-21 21:55:28 +0100", + "message": "polish: pinned panel — avatar colors, timestamp formatting, CSS tokens", + "type": "other" + }, + { + "hash": "8445fa6799341ef00b0a1fd668a726423ea5b0d2", + "date": "2026-03-21 21:51:05 +0100", + "message": "feat: Discord-style pinned messages panel with avatars and animations", + "type": "feat" + }, + { + "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", + "date": "2026-03-21 21:08:58 +0100", + "message": "feat: add Discord-style welcome state for empty channels", + "type": "feat" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", + "date": "2026-03-20 06:11:09 +0100", + "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", + "type": "fix" + }, + { + "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", + "date": "2026-03-20 05:46:19 +0100", + "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", + "type": "feat" + }, + { + "hash": "17600081dfef4d5bccf6777279559599acfc1eff", + "date": "2026-03-20 02:00:29 +0100", + "message": "feat: wire search bar to server FTS5 search API (T-065)", + "type": "feat" + }, + { + "hash": "f11a53913ffb192b6d2978834e793d1053e45d27", + "date": "2026-03-20 01:33:43 +0100", + "message": "refactor: extract MainPage into focused controllers with tests", + "type": "refactor" + }, + { + "hash": "21d1622893919cee4d22d5a87b8245a6d0066db8", + "date": "2026-03-19 17:29:20 +0100", + "message": "fix: remove frozen video tiles when remote user disables camera", + "type": "fix" + }, + { + "hash": "d28321fe48d385dc277d5b0bca84adc49bb0295d", + "date": "2026-03-19 17:19:56 +0100", + "message": "fix: add USE_VIDEO permission to Member role and fix single-user video mode", + "type": "fix" + }, + { + "hash": "594fe3ebb486e2a6cd1f8a06c06635cc993f6a45", + "date": "2026-03-19 17:07:30 +0100", + "message": "fix: add local camera self-view to video grid", + "type": "fix" + }, + { + "hash": "ed7af5ff2d383916a03ba903a74fd1f67e66101e", + "date": "2026-03-19 16:46:49 +0100", + "message": "feat: wire video grid into main page with chat/video toggle", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "a37abb47a266fca465ebaaa6866308f3868df373", + "date": "2026-03-18 11:33:59 +0100", + "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", + "type": "fix" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", + "date": "2026-03-18 07:03:53 +0100", + "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", + "type": "fix" + }, + { + "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", + "date": "2026-03-18 06:45:05 +0100", + "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", + "type": "fix" + }, + { + "hash": "48be1e43e8ec64036fcf7aa4e89afca70238760f", + "date": "2026-03-18 06:35:55 +0100", + "message": "fix: message operations — reaction toggle, delete confirm, edit validation, toasts (BUG-024, BUG-028, BUG-029, BUG-037, BUG-038)", + "type": "fix" + }, + { + "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", + "date": "2026-03-18 06:33:17 +0100", + "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", + "type": "fix" + }, + { + "hash": "c1e2733f11a746aef74897bed712f69693e72acb", + "date": "2026-03-18 06:28:45 +0100", + "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", + "type": "fix" + }, + { + "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", + "date": "2026-03-18 04:13:12 +0100", + "message": "fix: voice session cleanup on ICE close and connection failure", + "type": "fix" + }, + { + "hash": "1f8c517df8560f8715ee1f5d44e7ca5b84260ed7", + "date": "2026-03-18 04:02:05 +0100", + "message": "feat: wire voiceSession into MainPage and main.ts lifecycle", + "type": "feat" + }, + { + "hash": "00bbb46b62c3aab6e5791795c3602ed2a1f05ed8", + "date": "2026-03-18 02:41:55 +0100", + "message": "fix: reject duplicate WebSocket logins to prevent reconnect ping-pong", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", + "date": "2026-03-17 20:03:34 +0100", + "message": "fix: voice channel join/leave visibility and disconnect bugs", + "type": "fix" + }, + { + "hash": "1914067941b8512b895e32075fe2ced0b3da3841", + "date": "2026-03-17 03:20:37 +0100", + "message": "fix: address PR review findings (issues #3-#8)", + "type": "fix" + }, + { + "hash": "644bd05842886115c24d3bd1c1ceeaca79fd1880", + "date": "2026-03-17 02:21:03 +0100", + "message": "refactor: split MainPage.ts into ChatHeader and OverlayManagers modules", + "type": "refactor" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "tauri-rust": { + "count": 20, + "lastCommit": "2026-03-30 16:35:02 +0200", + "commits": [ + { + "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", + "date": "2026-03-30 16:35:02 +0200", + "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", + "type": "test" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", + "date": "2026-03-28 18:23:18 +0100", + "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", + "type": "fix" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "27f05a5a1ac1426b9758a9222aaec4f0ab9cb1b6", + "date": "2026-03-27 08:48:54 +0100", + "message": "fix: remember password now actually stores the password", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", + "date": "2026-03-24 20:23:40 +0100", + "message": "fix: address code review — security hardening, leak fixes, credential safety", + "type": "fix" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", + "date": "2026-03-20 07:11:45 +0100", + "message": "fix: speaking ring, devtools, CSP, and connection fixes", + "type": "fix" + }, + { + "hash": "f8e41180f48632937e8184d48106dc05287c1721", + "date": "2026-03-20 02:21:53 +0100", + "message": "fix: settings bugs, accessibility, and camera stream leak", + "type": "fix" + }, + { + "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", + "date": "2026-03-19 17:54:13 +0100", + "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", + "type": "docs" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", + "date": "2026-03-18 17:10:16 +0100", + "message": "feat: native file downloads, upload size fix, native E2E tests", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", + "date": "2026-03-17 14:54:50 +0100", + "message": "fix: address PR #15 review issues (#16-#23)", + "type": "fix" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "go:api": { + "count": 53, + "lastCommit": "2026-03-29 21:31:18 +0200", + "commits": [ + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", + "date": "2026-03-29 19:39:22 +0200", + "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", + "type": "fix" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "894b4ed829e6820d6199121f4c6709363df7280d", + "date": "2026-03-28 13:21:07 +0100", + "message": "fix: resolve all 11 open bugs, add account deletion, harden security", + "type": "fix" + }, + { + "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", + "date": "2026-03-28 11:24:31 +0100", + "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", + "type": "feat" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "3dff9fef64db54cdfe153d48f46d9cc7d7bac9d0", + "date": "2026-03-27 13:58:48 +0100", + "message": "feat(server): add DM REST endpoints, WebSocket routing, and ready payload", + "type": "feat" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", + "date": "2026-03-24 20:23:40 +0100", + "message": "fix: address code review — security hardening, leak fixes, credential safety", + "type": "fix" + }, + { + "hash": "076402ab010e0eb1659cb776bd8bee8417d31e48", + "date": "2026-03-22 20:06:59 +0100", + "message": "fix: resolve CI failures — lint errors and coverage threshold", + "type": "fix" + }, + { + "hash": "19aa699f17ecb3cadf770b70b15009dd8e1572fb", + "date": "2026-03-22 19:29:42 +0100", + "message": "fix: address CEO review findings — health check, error logging, camera guard, metrics, tests", + "type": "fix" + }, + { + "hash": "f52e4d7e49852cf3cbae2ba789a1b382dcc6f1c8", + "date": "2026-03-21 21:42:14 +0100", + "message": "refactor: deduplicate pin handler and DB scan logic", + "type": "refactor" + }, + { + "hash": "85ef4f0d1d8d9e6b9eb2550f4d0b9b74542b792a", + "date": "2026-03-21 21:36:10 +0100", + "message": "feat: add server-side pin/unpin endpoints", + "type": "feat" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", + "date": "2026-03-20 06:11:09 +0100", + "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", + "type": "fix" + }, + { + "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", + "date": "2026-03-20 05:34:38 +0100", + "message": "feat: add LiveKit webhook handler (Phase 1.5)", + "type": "feat" + }, + { + "hash": "a7b53d42f30bbdf99d13a943d0e3b24aab261eb2", + "date": "2026-03-20 05:30:27 +0100", + "message": "feat: rewrite server voice handlers for LiveKit (Phase 1)", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "22706a795581a003059a9838ace4ac05d43fefb2", + "date": "2026-03-19 06:20:00 +0100", + "message": "fix: resolve CI failures in server lint and client tests", + "type": "fix" + }, + { + "hash": "b15738c4d80f4983f31d1076e8aaff07171a0a7c", + "date": "2026-03-19 05:32:40 +0100", + "message": "feat: redesign admin panel, add live server logs and audit log filters", + "type": "feat" + }, + { + "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", + "date": "2026-03-19 04:19:03 +0100", + "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", + "type": "fix" + }, + { + "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", + "date": "2026-03-19 04:04:53 +0100", + "message": "fix: resolve 6 medium issues from full go-review", + "type": "fix" + }, + { + "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", + "date": "2026-03-19 03:53:40 +0100", + "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", + "type": "fix" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", + "date": "2026-03-18 17:10:16 +0100", + "message": "feat: native file downloads, upload size fix, native E2E tests", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "79f0040f376c989650af60b35a7449b45f40935b", + "date": "2026-03-17 02:56:19 +0100", + "message": "feat: server enhancements, client test selectors, and UI polish", + "type": "feat" + }, + { + "hash": "46e811031e23a6631ab475926af034e35802903a", + "date": "2026-03-16 17:17:00 +0100", + "message": "test: add authorization and contract tests for channel access", + "type": "test" + }, + { + "hash": "642cd541c781649698799245a6a3d0f4a8ae0a02", + "date": "2026-03-16 17:00:09 +0100", + "message": "feat: add attachment persistence and link on chat_send (High #3)", + "type": "feat" + }, + { + "hash": "0bd9165f61f361a5acb9fb85ffd0e3cf8aca6d29", + "date": "2026-03-16 16:57:01 +0100", + "message": "feat: align REST responses with API.md spec (High #1)", + "type": "feat" + }, + { + "hash": "0680a32496e69f60a21d76a80d2a2d40244fe805", + "date": "2026-03-16 16:54:56 +0100", + "message": "fix: resolve 4 Critical + 2 High + 1 Medium server protocol violations", + "type": "fix" + }, + { + "hash": "aa194f45b39c17f9bf74868941f3e31dd5bf73e4", + "date": "2026-03-15 12:21:10 +0100", + "message": "fix: prevent stale \"online\" status for users not connected via WebSocket", + "type": "fix" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", + "date": "2026-03-14 22:05:13 +0100", + "message": "feat: implement server auto-update API endpoints with download, verify, and restart", + "type": "feat" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", + "date": "2026-03-14 21:17:09 +0100", + "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", + "type": "feat" + }, + { + "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", + "date": "2026-03-14 20:52:11 +0100", + "message": "feat: implement Phase 2 auth & security with TDD", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:auth": { + "count": 9, + "lastCommit": "2026-03-29 21:31:18 +0200", + "commits": [ + { + "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", + "date": "2026-03-29 21:31:18 +0200", + "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", + "type": "feat" + }, + { + "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", + "date": "2026-03-29 19:39:22 +0200", + "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", + "type": "fix" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", + "date": "2026-03-14 20:52:11 +0100", + "message": "feat: implement Phase 2 auth & security with TDD", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "ts:main.ts": { + "count": 22, + "lastCommit": "2026-03-29 19:40:11 +0200", + "commits": [ + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", + "date": "2026-03-28 20:42:37 +0100", + "message": "feat: add observability, debugging, and diagnostics across all layers", + "type": "feat" + }, + { + "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", + "date": "2026-03-28 11:24:31 +0100", + "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", + "type": "feat" + }, + { + "hash": "3c98e1b984e181df2f58387880b6e2332b7258dc", + "date": "2026-03-27 12:34:28 +0100", + "message": "feat: wire quick-switch overlay to disconnect and navigate to ConnectPage", + "type": "feat" + }, + { + "hash": "72f34ea8f6d9a9ad82e60d82c62b14ce98c0e0de", + "date": "2026-03-27 12:09:56 +0100", + "message": "feat: restore saved theme on app startup", + "type": "feat" + }, + { + "hash": "7715ac29553df52a6632fe34729823ca99a5f299", + "date": "2026-03-27 11:32:36 +0100", + "message": "feat: add OC Neon Glow theme CSS with theme contract variables", + "type": "feat" + }, + { + "hash": "27f05a5a1ac1426b9758a9222aaec4f0ab9cb1b6", + "date": "2026-03-27 08:48:54 +0100", + "message": "fix: remember password now actually stores the password", + "type": "fix" + }, + { + "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", + "date": "2026-03-20 15:21:56 +0100", + "message": "refactor: UI architecture improvements + GIF auto-pause", + "type": "refactor" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", + "date": "2026-03-20 07:11:45 +0100", + "message": "fix: speaking ring, devtools, CSP, and connection fixes", + "type": "fix" + }, + { + "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", + "date": "2026-03-20 05:46:19 +0100", + "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "a37abb47a266fca465ebaaa6866308f3868df373", + "date": "2026-03-18 11:33:59 +0100", + "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", + "type": "fix" + }, + { + "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", + "date": "2026-03-18 04:13:12 +0100", + "message": "fix: voice session cleanup on ICE close and connection failure", + "type": "fix" + }, + { + "hash": "1f8c517df8560f8715ee1f5d44e7ca5b84260ed7", + "date": "2026-03-18 04:02:05 +0100", + "message": "feat: wire voiceSession into MainPage and main.ts lifecycle", + "type": "feat" + }, + { + "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", + "date": "2026-03-17 20:03:34 +0100", + "message": "fix: voice channel join/leave visibility and disconnect bugs", + "type": "fix" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "ts:stores": { + "count": 28, + "lastCommit": "2026-03-29 19:40:11 +0200", + "commits": [ + { + "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", + "date": "2026-03-29 19:40:11 +0200", + "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", + "type": "chore" + }, + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", + "date": "2026-03-29 00:51:54 +0100", + "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", + "type": "feat" + }, + { + "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", + "date": "2026-03-28 11:24:31 +0100", + "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", + "type": "feat" + }, + { + "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", + "date": "2026-03-28 10:39:23 +0100", + "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", + "type": "feat" + }, + { + "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", + "date": "2026-03-27 16:14:54 +0100", + "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", + "type": "fix" + }, + { + "hash": "cb07a57254d6c6e981971229003f2b1fb914909b", + "date": "2026-03-27 14:31:00 +0100", + "message": "fix: filter DM channels from channel sidebar list", + "type": "fix" + }, + { + "hash": "e5afe7d20136f78e09ffd7fd951814a036c84fb3", + "date": "2026-03-27 14:08:25 +0100", + "message": "feat(client): add DM store, API methods, dispatcher events, and sidebar wiring", + "type": "feat" + }, + { + "hash": "a34f4cb971b4439e84707dc8a939f66e07ec197c", + "date": "2026-03-27 12:39:46 +0100", + "message": "feat: persist collapsible section state per-server in localStorage", + "type": "feat" + }, + { + "hash": "80cd05923678a8552faf970c3f1e101d44f48938", + "date": "2026-03-27 12:09:47 +0100", + "message": "feat: add neon-glow to theme selector and apply body class on theme switch", + "type": "feat" + }, + { + "hash": "7be56f95458b31c8435ce50ef52d60bd1820639b", + "date": "2026-03-27 11:29:23 +0100", + "message": "feat: add sidebarMode and activeDmUserId to UI store", + "type": "feat" + }, + { + "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", + "date": "2026-03-27 08:05:32 +0100", + "message": "fix: client code review — 27 fixes across 17 files", + "type": "fix" + }, + { + "hash": "738f4970576467e285c051549a4d78241966958c", + "date": "2026-03-24 21:35:40 +0100", + "message": "fix: address code review — 8 issues across server and client", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "91ebce72727a54fb96c72dadeb257bd92124ef4f", + "date": "2026-03-20 06:33:57 +0100", + "message": "fix: wire LiveKit speaker detection to voice activation ring", + "type": "fix" + }, + { + "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", + "date": "2026-03-20 05:46:19 +0100", + "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", + "type": "feat" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "6dbb945213901429f50a77c4648a5eab67271418", + "date": "2026-03-18 14:13:52 +0100", + "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", + "type": "feat" + }, + { + "hash": "9c63dd6efed184908b192fe60162683019a89d5c", + "date": "2026-03-18 11:28:13 +0100", + "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", + "type": "feat" + }, + { + "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", + "date": "2026-03-18 06:33:17 +0100", + "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", + "type": "fix" + }, + { + "hash": "c1e2733f11a746aef74897bed712f69693e72acb", + "date": "2026-03-18 06:28:45 +0100", + "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", + "type": "fix" + }, + { + "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", + "date": "2026-03-17 20:25:15 +0100", + "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", + "type": "feat" + }, + { + "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", + "date": "2026-03-17 01:59:34 +0100", + "message": "fix: resolve 15 post-review issues across server and client", + "type": "fix" + }, + { + "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", + "date": "2026-03-16 16:43:46 +0100", + "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", + "type": "feat" + }, + { + "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", + "date": "2026-03-15 20:43:13 +0100", + "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", + "type": "feat" + }, + { + "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", + "date": "2026-03-15 19:44:02 +0100", + "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", + "type": "feat" + } + ] + }, + "go:go.mod": { + "count": 10, + "lastCommit": "2026-03-29 19:39:46 +0200", + "commits": [ + { + "hash": "97f186041da8052d054cff73c93f23886bd374d5", + "date": "2026-03-29 19:39:46 +0200", + "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", + "type": "refactor" + }, + { + "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", + "date": "2026-03-20 05:34:38 +0100", + "message": "feat: add LiveKit webhook handler (Phase 1.5)", + "type": "feat" + }, + { + "hash": "a7b53d42f30bbdf99d13a943d0e3b24aab261eb2", + "date": "2026-03-20 05:30:27 +0100", + "message": "feat: rewrite server voice handlers for LiveKit (Phase 1)", + "type": "feat" + }, + { + "hash": "923d071a71342c771f3314744e81f5f16e8ce845", + "date": "2026-03-20 05:14:52 +0100", + "message": "feat: add LiveKit infrastructure (Phase 0)", + "type": "feat" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", + "date": "2026-03-14 21:59:58 +0100", + "message": "feat: add updater package with GitHub Release checking and checksum verification", + "type": "feat" + }, + { + "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", + "date": "2026-03-14 21:17:09 +0100", + "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", + "type": "feat" + }, + { + "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", + "date": "2026-03-14 20:52:11 +0100", + "message": "feat: implement Phase 2 auth & security with TDD", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:go.sum": { + "count": 9, + "lastCommit": "2026-03-29 19:39:46 +0200", + "commits": [ + { + "hash": "97f186041da8052d054cff73c93f23886bd374d5", + "date": "2026-03-29 19:39:46 +0200", + "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", + "type": "refactor" + }, + { + "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", + "date": "2026-03-20 05:34:38 +0100", + "message": "feat: add LiveKit webhook handler (Phase 1.5)", + "type": "feat" + }, + { + "hash": "923d071a71342c771f3314744e81f5f16e8ce845", + "date": "2026-03-20 05:14:52 +0100", + "message": "feat: add LiveKit infrastructure (Phase 0)", + "type": "feat" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", + "date": "2026-03-14 21:59:58 +0100", + "message": "feat: add updater package with GitHub Release checking and checksum verification", + "type": "feat" + }, + { + "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", + "date": "2026-03-14 21:17:09 +0100", + "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", + "type": "feat" + }, + { + "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", + "date": "2026-03-14 20:52:11 +0100", + "message": "feat: implement Phase 2 auth & security with TDD", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:main.go": { + "count": 12, + "lastCommit": "2026-03-29 19:39:46 +0200", + "commits": [ + { + "hash": "97f186041da8052d054cff73c93f23886bd374d5", + "date": "2026-03-29 19:39:46 +0200", + "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", + "type": "refactor" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "b15738c4d80f4983f31d1076e8aaff07171a0a7c", + "date": "2026-03-19 05:32:40 +0100", + "message": "feat: redesign admin panel, add live server logs and audit log filters", + "type": "feat" + }, + { + "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", + "date": "2026-03-19 03:53:40 +0100", + "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", + "type": "fix" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "aa194f45b39c17f9bf74868941f3e31dd5bf73e4", + "date": "2026-03-15 12:21:10 +0100", + "message": "fix: prevent stale \"online\" status for users not connected via WebSocket", + "type": "fix" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", + "date": "2026-03-14 22:05:13 +0100", + "message": "feat: implement server auto-update API endpoints with download, verify, and restart", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:storage": { + "count": 6, + "lastCommit": "2026-03-29 12:35:04 +0200", + "commits": [ + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", + "date": "2026-03-21 10:08:44 +0100", + "message": "refactor: server hardening + client decomposition + protocol resilience", + "type": "refactor" + }, + { + "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", + "date": "2026-03-18 05:03:38 +0100", + "message": "test: boost server test coverage to 80%+ across all packages", + "type": "test" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:updater": { + "count": 10, + "lastCommit": "2026-03-29 12:35:04 +0200", + "commits": [ + { + "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", + "date": "2026-03-29 12:35:04 +0200", + "message": "fix: security hardening — 45 issues from full-project Copilot audit", + "type": "fix" + }, + { + "hash": "680f060dda8d1c82208c61f66f8d46f3e68e6228", + "date": "2026-03-19 09:49:21 +0100", + "message": "feat: add negative caching to updater to avoid API spam", + "type": "feat" + }, + { + "hash": "22706a795581a003059a9838ace4ac05d43fefb2", + "date": "2026-03-19 06:20:00 +0100", + "message": "fix: resolve CI failures in server lint and client tests", + "type": "fix" + }, + { + "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", + "date": "2026-03-19 04:19:03 +0100", + "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", + "type": "fix" + }, + { + "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", + "date": "2026-03-18 17:47:59 +0100", + "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", + "type": "feat" + }, + { + "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", + "date": "2026-03-18 05:03:38 +0100", + "message": "test: boost server test coverage to 80%+ across all packages", + "type": "test" + }, + { + "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", + "date": "2026-03-17 08:09:52 +0100", + "message": "fix: resolve all golangci-lint issues blocking CI server build", + "type": "fix" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", + "date": "2026-03-14 22:05:13 +0100", + "message": "feat: implement server auto-update API endpoints with download, verify, and restart", + "type": "feat" + }, + { + "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", + "date": "2026-03-14 21:59:58 +0100", + "message": "feat: add updater package with GitHub Release checking and checksum verification", + "type": "feat" + } + ] + }, + "go:.air.toml": { + "count": 1, + "lastCommit": "2026-03-29 12:19:08 +0200", + "commits": [ + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + } + ] + }, + "go:permissions": { + "count": 2, + "lastCommit": "2026-03-29 12:19:08 +0200", + "commits": [ + { + "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", + "date": "2026-03-29 12:19:08 +0200", + "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", + "type": "refactor" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + } + ] + }, + "go:config": { + "count": 16, + "lastCommit": "2026-03-28 18:23:18 +0100", + "commits": [ + { + "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", + "date": "2026-03-28 18:23:18 +0100", + "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", + "type": "fix" + }, + { + "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", + "date": "2026-03-26 20:53:09 +0100", + "message": "fix: voice mute pipeline, security hardening, and video tile controls", + "type": "fix" + }, + { + "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", + "date": "2026-03-24 21:30:23 +0100", + "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", + "type": "feat" + }, + { + "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", + "date": "2026-03-24 20:23:40 +0100", + "message": "fix: address code review — security hardening, leak fixes, credential safety", + "type": "fix" + }, + { + "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", + "date": "2026-03-20 12:30:12 +0100", + "message": "fix: camera button delay and video feed flickering + security hardening", + "type": "fix" + }, + { + "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", + "date": "2026-03-20 06:11:09 +0100", + "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", + "type": "fix" + }, + { + "hash": "923d071a71342c771f3314744e81f5f16e8ce845", + "date": "2026-03-20 05:14:52 +0100", + "message": "feat: add LiveKit infrastructure (Phase 0)", + "type": "feat" + }, + { + "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", + "date": "2026-03-19 12:23:24 +0100", + "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", + "type": "feat" + }, + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + }, + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", + "date": "2026-03-15 07:07:59 +0100", + "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", + "type": "feat" + }, + { + "hash": "d8dbb081cbe4f0981285f27e25a78819b1c27cab", + "date": "2026-03-14 22:17:47 +0100", + "message": "fix: set default TLS cert/key paths to data/cert.pem and data/key.pem", + "type": "fix" + }, + { + "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", + "date": "2026-03-14 21:59:58 +0100", + "message": "feat: add updater package with GitHub Release checking and checksum verification", + "type": "feat" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "go:migrations": { + "count": 10, + "lastCommit": "2026-03-27 13:46:06 +0100", + "commits": [ + { + "hash": "c8d547ce15643085eaed6e65c33044eef098f427", + "date": "2026-03-27 13:46:06 +0100", + "message": "feat(server): add DM schema migration and database query layer", + "type": "feat" + }, + { + "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", + "date": "2026-03-21 12:34:31 +0100", + "message": "fix: virtual scroll jumping with images/GIFs", + "type": "fix" + }, + { + "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", + "date": "2026-03-21 11:59:14 +0100", + "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", + "type": "fix" + }, + { + "hash": "d28321fe48d385dc277d5b0bca84adc49bb0295d", + "date": "2026-03-19 17:19:56 +0100", + "message": "fix: add USE_VIDEO permission to Member role and fix single-user video mode", + "type": "fix" + }, + { + "hash": "e92b8fe2df616937c26338fe56319977db194497", + "date": "2026-03-17 11:05:52 +0100", + "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", + "type": "feat" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + }, + { + "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", + "date": "2026-03-15 00:31:39 +0100", + "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", + "type": "feat" + }, + { + "hash": "dcae0b91b23c178e3b747cdf25176c8d09ffd103", + "date": "2026-03-14 21:37:47 +0100", + "message": "fix: correct embed path (static not admin/static) and simplify audit_log migration", + "type": "fix" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + }, + "ts:types": { + "count": 1, + "lastCommit": "2026-03-18 23:02:06 +0100", + "commits": [ + { + "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", + "date": "2026-03-18 23:02:06 +0100", + "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", + "type": "feat" + } + ] + }, + "go:.golangci.yml": { + "count": 1, + "lastCommit": "2026-03-17 04:11:04 +0100", + "commits": [ + { + "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", + "date": "2026-03-17 04:11:04 +0100", + "message": "fix: address PR review findings (issues #9-#14)", + "type": "fix" + } + ] + }, + "go:ws_cov.out": { + "count": 2, + "lastCommit": "2026-03-15 16:54:55 +0100", + "commits": [ + { + "hash": "743a2d974738f76db7764bdd43e78fbf25889d4f", + "date": "2026-03-15 16:54:55 +0100", + "message": "chore: update .gitignore to exclude local tooling, build artifacts, and internal docs", + "type": "chore" + }, + { + "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", + "date": "2026-03-15 11:42:25 +0100", + "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", + "type": "feat" + } + ] + }, + "go:chatserver.exe": { + "count": 6, + "lastCommit": "2026-03-14 22:38:08 +0100", + "commits": [ + { + "hash": "07636447ab328ced8a3561a1446df76561259010", + "date": "2026-03-14 22:38:08 +0100", + "message": "chore: gitignore server runtime artifacts (binary, config, data)", + "type": "chore" + }, + { + "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", + "date": "2026-03-14 22:05:13 +0100", + "message": "feat: implement server auto-update API endpoints with download, verify, and restart", + "type": "feat" + }, + { + "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", + "date": "2026-03-14 21:31:03 +0100", + "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", + "type": "feat" + }, + { + "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", + "date": "2026-03-14 21:17:09 +0100", + "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", + "type": "feat" + }, + { + "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", + "date": "2026-03-14 20:52:11 +0100", + "message": "feat: implement Phase 2 auth & security with TDD", + "type": "feat" + }, + { + "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", + "date": "2026-03-14 20:34:37 +0100", + "message": "feat: implement Phase 1 server skeleton with TDD", + "type": "feat" + } + ] + } + }, + "fileChurn": [ + { + "file": "Client/tauri-client/src/styles/app.css", + "commits": 54, + "module": "ts:styles" + }, + { + "file": "Client/tauri-client/src/pages/MainPage.ts", + "commits": 44, + "module": "ts:pages" + }, + { + "file": "Server/api/router.go", + "commits": 35, + "module": "go:api" + }, + { + "file": "Client/tauri-client/src/lib/livekitSession.ts", + "commits": 30, + "module": "ts:lib" + }, + { + "file": "CLAUDE.md", + "commits": 28, + "module": "root" + }, + { + "file": "Server/ws/voice_handlers.go", + "commits": 24, + "module": "go:ws" + }, + { + "file": "Client/tauri-client/src/components/SettingsOverlay.ts", + "commits": 23, + "module": "ts:components" + }, + { + "file": "Server/ws/handlers.go", + "commits": 23, + "module": "go:ws" + }, + { + "file": "Server/ws/hub.go", + "commits": 23, + "module": "go:ws" + }, + { + "file": "Client/tauri-client/src/main.ts", + "commits": 22, + "module": "ts:main.ts" + }, + { + "file": "Client/tauri-client/src/components/MessageList.ts", + "commits": 21, + "module": "ts:components" + }, + { + "file": "Client/tauri-client/src/pages/main-page/SidebarArea.ts", + "commits": 21, + "module": "ts:pages" + }, + { + "file": "Server/ws/messages.go", + "commits": 20, + "module": "go:ws" + }, + { + "file": "Client/tauri-client/tests/unit/settings-overlay.test.ts", + "commits": 19, + "module": "ts:config" + }, + { + "file": "Client/tauri-client/src/lib/dispatcher.ts", + "commits": 19, + "module": "ts:lib" + }, + { + "file": "Client/tauri-client/src-tauri/tauri.conf.json", + "commits": 17, + "module": "ts:config" + }, + { + "file": "Client/tauri-client/src/components/message-list/media.ts", + "commits": 17, + "module": "ts:components" + }, + { + "file": "Client/tauri-client/src/components/settings/VoiceAudioTab.ts", + "commits": 17, + "module": "ts:components" + }, + { + "file": "Server/ws/serve.go", + "commits": 17, + "module": "go:ws" + }, + { + "file": ".gitignore", + "commits": 16, + "module": "root" + } + ], + "staleness": { + "root": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 22:36:38 +0200" + }, + "ts:config": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 21:54:24 +0200" + }, + "go:admin": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 21:54:24 +0200" + }, + "go:db": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 21:54:24 +0200" + }, + "go:scripts": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 21:54:24 +0200" + }, + "go:ws": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 21:54:24 +0200" + }, + "client:other": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 21:05:28 +0200" + }, + "ts:components": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 20:13:34 +0200" + }, + "ts:styles": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 20:13:34 +0200" + }, + "ts:lib": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 19:12:36 +0200" + }, + "ts:pages": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 19:12:36 +0200" + }, + "tauri-rust": { + "daysSinceLastCommit": 0, + "lastCommitDate": "2026-03-30 16:35:02 +0200" + }, + "go:api": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 21:31:18 +0200" + }, + "go:auth": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 21:31:18 +0200" + }, + "ts:main.ts": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 19:40:11 +0200" + }, + "ts:stores": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 19:40:11 +0200" + }, + "go:go.mod": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 19:39:46 +0200" + }, + "go:go.sum": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 19:39:46 +0200" + }, + "go:main.go": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 19:39:46 +0200" + }, + "go:storage": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 12:35:04 +0200" + }, + "go:updater": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 12:35:04 +0200" + }, + "go:.air.toml": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 12:19:08 +0200" + }, + "go:permissions": { + "daysSinceLastCommit": 1, + "lastCommitDate": "2026-03-29 12:19:08 +0200" + }, + "go:config": { + "daysSinceLastCommit": 2, + "lastCommitDate": "2026-03-28 18:23:18 +0100" + }, + "go:migrations": { + "daysSinceLastCommit": 3, + "lastCommitDate": "2026-03-27 13:46:06 +0100" + }, + "ts:types": { + "daysSinceLastCommit": 12, + "lastCommitDate": "2026-03-18 23:02:06 +0100" + }, + "go:.golangci.yml": { + "daysSinceLastCommit": 14, + "lastCommitDate": "2026-03-17 04:11:04 +0100" + }, + "go:ws_cov.out": { + "daysSinceLastCommit": 15, + "lastCommitDate": "2026-03-15 16:54:55 +0100" + }, + "go:chatserver.exe": { + "daysSinceLastCommit": 16, + "lastCommitDate": "2026-03-14 22:38:08 +0100" + } + }, + "velocity": { + "daily": [ + { + "date": "2026-03-02", + "count": 0 + }, + { + "date": "2026-03-03", + "count": 0 + }, + { + "date": "2026-03-04", + "count": 0 + }, + { + "date": "2026-03-05", + "count": 0 + }, + { + "date": "2026-03-06", + "count": 0 + }, + { + "date": "2026-03-07", + "count": 0 + }, + { + "date": "2026-03-08", + "count": 0 + }, + { + "date": "2026-03-09", + "count": 0 + }, + { + "date": "2026-03-10", + "count": 0 + }, + { + "date": "2026-03-11", + "count": 0 + }, + { + "date": "2026-03-12", + "count": 0 + }, + { + "date": "2026-03-13", + "count": 0 + }, + { + "date": "2026-03-14", + "count": 21 + }, + { + "date": "2026-03-15", + "count": 14 + }, + { + "date": "2026-03-16", + "count": 6 + }, + { + "date": "2026-03-17", + "count": 23 + }, + { + "date": "2026-03-18", + "count": 35 + }, + { + "date": "2026-03-19", + "count": 46 + }, + { + "date": "2026-03-20", + "count": 17 + }, + { + "date": "2026-03-21", + "count": 28 + }, + { + "date": "2026-03-22", + "count": 31 + }, + { + "date": "2026-03-23", + "count": 0 + }, + { + "date": "2026-03-24", + "count": 4 + }, + { + "date": "2026-03-25", + "count": 4 + }, + { + "date": "2026-03-26", + "count": 7 + }, + { + "date": "2026-03-27", + "count": 48 + }, + { + "date": "2026-03-28", + "count": 13 + }, + { + "date": "2026-03-29", + "count": 13 + }, + { + "date": "2026-03-30", + "count": 22 + }, + { + "date": "2026-03-31", + "count": 0 + } + ], + "weeklyAvg": 15.29, + "trend": "accelerating" + }, + "commitTypes": { + "feat": 107, + "fix": 112, + "test": 15, + "refactor": 10, + "docs": 28, + "chore": 24, + "other": 36 + }, + "recentCommits": [ + { + "hash": "f7372aec3219d36b76bc269bb7940ee914e660eb", + "date": "2026-03-30 22:36:38 +0200", + "message": "docs: add screenshots to README", + "type": "docs", + "modules": [ + "root" + ] + }, + { + "hash": "86541ac3aa4af84c4d787d73a1f0ffc1e4119d7f", + "date": "2026-03-30 22:32:31 +0200", + "message": "chore: gitignore internal docs subdirectories", + "type": "chore", + "modules": [ + "root" + ] + }, + { + "hash": "cec6ca6a97df645d41c781cc257dad24e5a407ca", + "date": "2026-03-30 22:31:06 +0200", + "message": "docs: add public documentation for contributors and users", + "type": "docs", + "modules": [ + "root" + ] + }, + { + "hash": "22daadacfa5199c29a3061743350838ae9b65b0c", + "date": "2026-03-30 22:17:14 +0200", + "message": "Merge pull request #84 from J3vb/dev", + "type": "other", + "modules": [] + }, + { + "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", + "date": "2026-03-30 21:54:24 +0200", + "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", + "type": "fix", + "modules": [ + "ts:config", + "go:admin", + "go:db", + "go:scripts", + "go:ws" + ] + }, + { + "hash": "d9aaeb5f4c8c018f5ffbd9c64ae87126e5b5348a", + "date": "2026-03-30 21:48:14 +0200", + "message": "fix: admin panel CSP blocking inline event handlers and boolean toggle display", + "type": "fix", + "modules": [ + "go:admin" + ] + }, + { + "hash": "bfe3404e14ab7143410a77c69bf7c28b4a161af1", + "date": "2026-03-30 21:07:50 +0200", + "message": "updated gitignore", + "type": "other", + "modules": [ + "root" + ] + }, + { + "hash": "44390ad886be392b55d1a15995eba0fb9c4a80d1", + "date": "2026-03-30 21:05:28 +0200", + "message": "chore: clean up tracked files for v1.0.0 public release", + "type": "chore", + "modules": [ + "root", + "client:other" + ] + }, + { + "hash": "ad7ac75c74d0351d37e0eded5e4594528041866d", + "date": "2026-03-30 20:50:44 +0200", + "message": "docs: v1.0.0 release prep — version bump, license, README overhaul", + "type": "docs", + "modules": [ + "root", + "ts:config" + ] + }, + { + "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", + "date": "2026-03-30 20:13:34 +0200", + "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", + "type": "feat", + "modules": [ + "ts:components", + "ts:styles", + "ts:config" + ] + }, + { + "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", + "date": "2026-03-30 19:12:36 +0200", + "message": "feat: sidebar stream preview + screenshare focus fix", + "type": "feat", + "modules": [ + "ts:components", + "ts:lib", + "ts:pages", + "ts:styles", + "ts:config", + "root" + ] + }, + { + "hash": "8882cfa330e28f273cd1e53234e4e95b37a6c7eb", + "date": "2026-03-30 16:47:19 +0200", + "message": "docs: regenerate codemaps from current codebase", + "type": "docs", + "modules": [ + "root" + ] + }, + { + "hash": "ad76d4a4139460d24974d6c436ba9782ac5ddfb2", + "date": "2026-03-30 16:37:28 +0200", + "message": "docs: update session log with TS error fix details", + "type": "docs", + "modules": [ + "root" + ] + }, + { + "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", + "date": "2026-03-30 16:35:02 +0200", + "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", + "type": "test", + "modules": [ + "root", + "ts:config", + "tauri-rust", + "go:ws" + ] + }, + { + "hash": "3f0d5309374562116f211eab06e170ca2f31818c", + "date": "2026-03-30 14:24:12 +0200", + "message": "yu", + "type": "other", + "modules": [ + "root" + ] + } + ], + "totalCommits30d": 332 +} \ No newline at end of file diff --git a/.cache/project-map/session-data.json b/.cache/project-map/session-data.json new file mode 100644 index 00000000..eb0d4a11 --- /dev/null +++ b/.cache/project-map/session-data.json @@ -0,0 +1,619 @@ +{ + "timestamp": "2026-03-31T09:23:48.747Z", + "sessions": [ + { + "date": "2025-03-24", + "summary": "Comprehensive file reference documentation review — 28 bugs fixed, all docs updated to match code reality", + "tasksCompleted": 28, + "modulesTouched": [ + "api", + "auth", + "db", + "ws", + "voice", + "permissions", + "config", + "stores", + "components", + "tauri-rust", + "protocol", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2025-03-24-file-reference-review.md" + }, + { + "date": "2026-03-17", + "summary": "Completed all 13 CEO review fixes and raised server test coverage to 80%+", + "tasksCompleted": 14, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "config", + "stores", + "tauri-rust", + "tests", + "ci" + ], + "fileName": "2026-03-17-ceo-fixes-and-coverage.md" + }, + { + "date": "2026-03-17", + "summary": "CEO plan review sections 1-8 (HOLD SCOPE) for tauri-migration branch", + "tasksCompleted": 0, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "storage", + "config", + "stores", + "pages", + "tauri-rust", + "tests", + "security", + "ci" + ], + "fileName": "2026-03-17-ceo-review-sections-1-8.md" + }, + { + "date": "2026-03-17", + "summary": "CEO plan review of tauri-migration branch — HOLD SCOPE", + "tasksCompleted": 0, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "voice", + "permissions", + "storage", + "components", + "pages", + "tauri-rust", + "protocol", + "tests", + "security" + ], + "fileName": "2026-03-17-ceo-review.md" + }, + { + "date": "2026-03-17", + "summary": "Fixed all golangci-lint issues blocking CI, set up project brain vault", + "tasksCompleted": 2, + "modulesTouched": [ + "api", + "ws", + "voice", + "tauri-rust", + "tests", + "docs", + "ci" + ], + "fileName": "2026-03-17-lint-fixes-and-vault-setup.md" + }, + { + "date": "2026-03-17", + "summary": "Pre-landing review of tauri-migration, commit, push, and PR #15 to dev", + "tasksCompleted": 0, + "modulesTouched": [ + "auth", + "db", + "config", + "tauri-rust", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-17-merge-review-and-pr.md" + }, + { + "date": "2026-03-18", + "summary": "Channel management, file uploads, URL previews, voice fixes, UX polish", + "tasksCompleted": 15, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "voice", + "storage", + "components", + "tauri-rust", + "ci" + ], + "fileName": "2026-03-18-features.md" + }, + { + "date": "2026-03-18", + "summary": "Added native E2E testing via WebView2 CDP", + "tasksCompleted": 1, + "modulesTouched": [ + "api", + "auth", + "voice", + "storage", + "config", + "lib", + "tauri-rust", + "e2e", + "tests", + "docs", + "ci" + ], + "fileName": "2026-03-18-native-e2e.md" + }, + { + "date": "2026-03-18", + "summary": "Voice chat NAT fix, audio pipeline overhaul, debugging infrastructure", + "tasksCompleted": 12, + "modulesTouched": [ + "api", + "auth", + "voice", + "stores", + "components", + "tauri-rust", + "tests", + "ci" + ], + "fileName": "2026-03-18-voice-audio-fixes.md" + }, + { + "date": "2026-03-19", + "summary": "Go code review (4 passes, 19 fixes), admin panel redesign, live server logs, audit log filters, console output cleanup", + "tasksCompleted": 7, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "voice", + "permissions", + "storage", + "config", + "lib", + "components", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-19-admin-panel-and-server-review.md" + }, + { + "date": "2026-03-20", + "summary": "Camera button delay fix, video feed flicker fix, security hardening, documentation sync", + "tasksCompleted": 4, + "modulesTouched": [ + "auth", + "db", + "voice", + "permissions", + "config", + "stores", + "pages", + "tauri-rust", + "protocol", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-20-livekit-camera-fixes.md" + }, + { + "date": "2026-03-20", + "summary": "", + "tasksCompleted": 0, + "modulesTouched": [ + "admin", + "auth", + "db", + "ws", + "stores", + "protocol", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-20-research-report.md" + }, + { + "date": "2026-03-21", + "summary": "Implemented 30 actionable fixes from code review audit across server and client", + "tasksCompleted": 30, + "modulesTouched": [ + "api", + "auth", + "db", + "ws", + "voice", + "storage", + "stores", + "components", + "pages", + "tauri-rust", + "e2e", + "tests", + "security", + "ci" + ], + "fileName": "2026-03-21-code-review-fixes.md" + }, + { + "date": "2026-03-22", + "summary": "Competitive research, feature roadmap, GitHub issues, vault build-out", + "tasksCompleted": 10, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "voice", + "permissions", + "config", + "lib", + "stores", + "components", + "tauri-rust", + "protocol", + "e2e", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-22-competitive-research.md" + }, + { + "date": "2026-03-26", + "summary": "Implemented client-side fixes from Codex/code review findings and added targeted regression coverage.", + "tasksCompleted": 1, + "modulesTouched": [ + "api", + "auth", + "ws", + "voice", + "config", + "stores", + "components", + "tests", + "ci" + ], + "fileName": "2026-03-26-client-review-fixes.md" + }, + { + "date": "2026-03-27", + "summary": "Code review (27 fixes), connection quality indicator, remember-password fix, login redesign with OC branding, new app icon.", + "tasksCompleted": 38, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "voice", + "storage", + "config", + "lib", + "stores", + "components", + "tauri-rust", + "protocol", + "tests", + "security", + "ci" + ], + "fileName": "2026-03-27-dual-model-code-review.md" + }, + { + "date": "2026-03-28", + "summary": "Spec audit (18 files, 50 fixes), 143 unit tests, E2E overhaul, CSS injection fix", + "tasksCompleted": 8, + "modulesTouched": [ + "api", + "auth", + "db", + "ws", + "voice", + "storage", + "lib", + "stores", + "components", + "tauri-rust", + "e2e", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-28-specs-tests-e2e.md" + }, + { + "date": "2026-03-29", + "summary": "Client-side 2FA integration — TOTP enrollment/disable UI, api.ts fixes, documentation sync", + "tasksCompleted": 5, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "voice", + "permissions", + "config", + "stores", + "components", + "e2e", + "tests", + "docs", + "security", + "ci" + ], + "fileName": "2026-03-29-2fa-client-integration.md" + }, + { + "date": "2026-03-29", + "summary": "Full-project security + code quality audit, 30 issues fixed", + "tasksCompleted": 27, + "modulesTouched": [ + "admin", + "api", + "auth", + "db", + "ws", + "voice", + "permissions", + "config", + "lib", + "tests", + "security", + "ci" + ], + "fileName": "2026-03-29-security-audit-fixes.md" + }, + { + "date": "2026-03-30", + "summary": "Fixed 10 test quality bugs (BUG-058 through BUG-067)", + "tasksCompleted": 10, + "modulesTouched": [ + "auth", + "voice", + "permissions", + "config", + "tauri-rust", + "e2e", + "tests", + "docs", + "ci" + ], + "fileName": "2026-03-30-bug-remediation.md" + }, + { + "date": "2026-03-30", + "summary": "Full documentation update — README, Changelog, Dashboard, task tracking", + "tasksCompleted": 5, + "modulesTouched": [ + "api", + "auth", + "db", + "voice", + "permissions", + "config", + "components", + "e2e", + "tests", + "docs", + "ci" + ], + "fileName": "2026-03-30-documentation-update.md" + }, + { + "date": "2026-03-30", + "summary": "Sidebar stream preview + screenshare focus fix", + "tasksCompleted": 0, + "modulesTouched": [ + "api", + "auth", + "voice", + "components", + "ci" + ], + "fileName": "2026-03-30-stream-preview.md" + } + ], + "progressOverTime": [ + { + "date": "2025-03-24", + "cumulativeDone": 28, + "sessionDone": 28 + }, + { + "date": "2026-03-17", + "cumulativeDone": 42, + "sessionDone": 14 + }, + { + "date": "2026-03-17", + "cumulativeDone": 42, + "sessionDone": 0 + }, + { + "date": "2026-03-17", + "cumulativeDone": 42, + "sessionDone": 0 + }, + { + "date": "2026-03-17", + "cumulativeDone": 44, + "sessionDone": 2 + }, + { + "date": "2026-03-17", + "cumulativeDone": 44, + "sessionDone": 0 + }, + { + "date": "2026-03-18", + "cumulativeDone": 59, + "sessionDone": 15 + }, + { + "date": "2026-03-18", + "cumulativeDone": 60, + "sessionDone": 1 + }, + { + "date": "2026-03-18", + "cumulativeDone": 72, + "sessionDone": 12 + }, + { + "date": "2026-03-19", + "cumulativeDone": 79, + "sessionDone": 7 + }, + { + "date": "2026-03-20", + "cumulativeDone": 83, + "sessionDone": 4 + }, + { + "date": "2026-03-20", + "cumulativeDone": 83, + "sessionDone": 0 + }, + { + "date": "2026-03-21", + "cumulativeDone": 113, + "sessionDone": 30 + }, + { + "date": "2026-03-22", + "cumulativeDone": 123, + "sessionDone": 10 + }, + { + "date": "2026-03-26", + "cumulativeDone": 124, + "sessionDone": 1 + }, + { + "date": "2026-03-27", + "cumulativeDone": 162, + "sessionDone": 38 + }, + { + "date": "2026-03-28", + "cumulativeDone": 170, + "sessionDone": 8 + }, + { + "date": "2026-03-29", + "cumulativeDone": 175, + "sessionDone": 5 + }, + { + "date": "2026-03-29", + "cumulativeDone": 202, + "sessionDone": 27 + }, + { + "date": "2026-03-30", + "cumulativeDone": 212, + "sessionDone": 10 + }, + { + "date": "2026-03-30", + "cumulativeDone": 217, + "sessionDone": 5 + }, + { + "date": "2026-03-30", + "cumulativeDone": 217, + "sessionDone": 0 + } + ], + "streaks": { + "current": 5, + "longest": 6, + "totalSessions": 22 + }, + "lastSession": { + "date": "2026-03-30", + "summary": "Sidebar stream preview + screenshare focus fix", + "tasksCompleted": 0, + "modulesTouched": [ + "api", + "auth", + "voice", + "components", + "ci" + ] + }, + "inProgress": [], + "recentlyDone": [ + { + "id": "T-197", + "description": "Discord-style video grid with fixed 16:9 aspect ratio", + "date": "2026-03-30" + }, + { + "id": "T-198", + "description": "Sidebar stream preview + screenshare focus fix", + "date": "2026-03-30" + }, + { + "id": "T-199", + "description": "Fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", + "date": "2026-03-30" + }, + { + "id": "T-200", + "description": "Regenerate codemaps from current codebase", + "date": "2026-03-30" + }, + { + "id": "T-201", + "description": "Full documentation update (README, Changelog, Dashboard, all docs)", + "date": "2026-03-30" + }, + { + "id": "T-192", + "description": "Client 2FA enrollment/disable settings UI", + "date": "2026-03-29" + }, + { + "id": "T-193", + "description": "Client 2FA test coverage — 27 new tests", + "date": "2026-03-29" + }, + { + "id": "T-194", + "description": "Full regression validation pass — all green", + "date": "2026-03-29" + }, + { + "id": "T-023", + "description": "Add TOTP 2FA support — Login challenge: DONE; Server endpoints: DONE; Client enrollment UI: DONE; Client tests: DONE", + "date": "2026-03-29" + }, + { + "id": "T-190", + "description": "Propagate `context.Context` from WS upgrade through all handlers — added `ctx context.Context` field to Client struct (set from `r.Context()` on WS upgrade), updated `MessageHandler` type signature, threaded ctx through all 17 WS handlers across 9 files (chat, presence, reaction, voice, ping). Added `ExecContext`/`QueryRowContext`/`QueryContext`/`BeginTx` context-accepting methods to DB wrapper. Go build + all ws/api/auth/db tests pass", + "date": "2026-03-29" + } + ] +} \ No newline at end of file diff --git a/Client/tauri-client/src/generated/.typecache b/Client/tauri-client/src/generated/.typecache new file mode 100644 index 00000000..eaf1fd9e --- /dev/null +++ b/Client/tauri-client/src/generated/.typecache @@ -0,0 +1,7 @@ +{ + "version": 1, + "commands_hash": "ca3b770e3d69abf7", + "structs_hash": "2c0574a96a92e42f", + "config_hash": "c72a07caa5bc6ed4", + "combined_hash": "6a107ade235e2401" +} \ No newline at end of file diff --git a/Client/tauri-client/src/generated/commands.ts b/Client/tauri-client/src/generated/commands.ts new file mode 100644 index 00000000..3626da55 --- /dev/null +++ b/Client/tauri-client/src/generated/commands.ts @@ -0,0 +1,115 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-04-03T09:09:31.628896400+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +import { invoke } from "@tauri-apps/api/core"; +import * as types from "./types"; + +export async function startLivekitProxy( + params: types.StartLivekitProxyParams, +): Promise { + return invoke("start_livekit_proxy", params); +} + +export async function stopLivekitProxy(): Promise { + return invoke("stop_livekit_proxy"); +} + +export async function checkClientUpdate( + params: types.CheckClientUpdateParams, +): Promise { + return invoke("check_client_update", params); +} + +export async function downloadAndInstallUpdate( + params: types.DownloadAndInstallUpdateParams, +): Promise { + return invoke("download_and_install_update", params); +} + +export async function pttStart(): Promise { + return invoke("ptt_start"); +} + +export async function pttStop(): Promise { + return invoke("ptt_stop"); +} + +export async function pttSetKey(params: types.PttSetKeyParams): Promise { + return invoke("ptt_set_key", params); +} + +export async function pttGetKey(): Promise { + return invoke("ptt_get_key"); +} + +export async function pttListenForKey(): Promise { + return invoke("ptt_listen_for_key"); +} + +export async function saveCredential( + params: types.SaveCredentialParams, +): Promise { + return invoke("save_credential", params); +} + +export async function loadCredential( + params: types.LoadCredentialParams, +): Promise { + return invoke("load_credential", params); +} + +export async function deleteCredential( + params: types.DeleteCredentialParams, +): Promise { + return invoke("delete_credential", params); +} + +export async function wsConnect(params: types.WsConnectParams): Promise { + return invoke("ws_connect", params); +} + +export async function wsSend(params: types.WsSendParams): Promise { + return invoke("ws_send", params); +} + +export async function wsDisconnect(): Promise { + return invoke("ws_disconnect"); +} + +export async function acceptCertFingerprint( + params: types.AcceptCertFingerprintParams, +): Promise { + return invoke("accept_cert_fingerprint", params); +} + +export async function getSettings(): Promise { + return invoke("get_settings"); +} + +export async function saveSettings( + params: types.SaveSettingsParams, +): Promise { + return invoke("save_settings", params); +} + +export async function storeCertFingerprint( + params: types.StoreCertFingerprintParams, +): Promise { + return invoke("store_cert_fingerprint", params); +} + +export async function getCertFingerprint( + params: types.GetCertFingerprintParams, +): Promise { + return invoke("get_cert_fingerprint", params); +} + +export async function openDevtools(): Promise { + return invoke("open_devtools"); +} diff --git a/Client/tauri-client/src/generated/events.ts b/Client/tauri-client/src/generated/events.ts new file mode 100644 index 00000000..bce17bae --- /dev/null +++ b/Client/tauri-client/src/generated/events.ts @@ -0,0 +1,54 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-04-03T09:09:31.629251800+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +/** + * Event Listeners + * Type-safe event listener helpers for Tauri events + */ +import { listen, type UnlistenFn, type Event } from "@tauri-apps/api/event"; +import * as types from "./types"; + +/** + * Listen for 'status-change' events + * @param handler - Callback function to handle the event + * @returns Promise that resolves to an unlisten function + */ +export async function onStatusChange( + handler: (payload: string) => void, +): Promise { + return listen("status-change", (event) => { + handler(event.payload); + }); +} + +/** + * Listen for 'ws-state' events + * @param handler - Callback function to handle the event + * @returns Promise that resolves to an unlisten function + */ +export async function onWsState( + handler: (payload: string) => void, +): Promise { + return listen("ws-state", (event) => { + handler(event.payload); + }); +} + +/** + * Listen for 'cert-tofu' events + * @param handler - Callback function to handle the event + * @returns Promise that resolves to an unlisten function + */ +export async function onCertTofu( + handler: (payload: types.Value) => void, +): Promise { + return listen("cert-tofu", (event) => { + handler(event.payload); + }); +} diff --git a/Client/tauri-client/src/generated/index.ts b/Client/tauri-client/src/generated/index.ts new file mode 100644 index 00000000..01678db3 --- /dev/null +++ b/Client/tauri-client/src/generated/index.ts @@ -0,0 +1,12 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-04-03T09:09:31.629428700+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +export * from "./types"; +export * from "./commands"; +export * from "./events"; diff --git a/Client/tauri-client/src/generated/types.ts b/Client/tauri-client/src/generated/types.ts new file mode 100644 index 00000000..ffa7df95 --- /dev/null +++ b/Client/tauri-client/src/generated/types.ts @@ -0,0 +1,92 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-04-03T09:09:31.628377200+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +export interface UpdateCheckResult { + available: boolean; + version?: string | null; + body?: string | null; +} + +export type Value = unknown; + +export interface CredentialData { + username: string; + token: string; +} + +export interface StartLivekitProxyParams { + remoteHost: string; + [key: string]: unknown; +} + +export interface CheckClientUpdateParams { + serverUrl: string; + [key: string]: unknown; +} + +export interface DownloadAndInstallUpdateParams { + serverUrl: string; + [key: string]: unknown; +} + +export interface PttSetKeyParams { + vkCode: number; + [key: string]: unknown; +} + +export interface SaveCredentialParams { + host: string; + username: string; + token: string; + password?: string | null; + [key: string]: unknown; +} + +export interface LoadCredentialParams { + host: string; + [key: string]: unknown; +} + +export interface DeleteCredentialParams { + host: string; + [key: string]: unknown; +} + +export interface WsConnectParams { + url: string; + [key: string]: unknown; +} + +export interface WsSendParams { + message: string; + [key: string]: unknown; +} + +export interface AcceptCertFingerprintParams { + host: string; + fingerprint: string; + [key: string]: unknown; +} + +export interface SaveSettingsParams { + key: string; + value: Value; + [key: string]: unknown; +} + +export interface StoreCertFingerprintParams { + host: string; + fingerprint: string; + [key: string]: unknown; +} + +export interface GetCertFingerprintParams { + host: string; + [key: string]: unknown; +} diff --git a/README.md b/README.md index a8a1e9f0..999d3b72 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,15 @@ *The gaming chat platform you actually own.* -> **Early Alpha — Building in the Open** -> OwnCord is under active development and is not production-ready. Do not use it for sensitive communications. Security hardening is in progress. Contributions and [security reports](https://github.com/J3vb/OwnCord/issues) are welcome. +## Development Model +OwnCord follows an AI-assisted, iteration-focused development workflow. +A significant portion of the implementation is generated with AI tooling, with quality validated through automated checks (CI, tests, linting) and real-world usage during alpha. -A self-hosted Windows chat platform with real-time messaging, -voice/video, file sharing, and a web admin panel. Run your own -server and keep everything under your control — zero cloud -dependencies, works fully on LAN. +This approach enables rapid iteration and experimentation, but it also means behavior and internal structure may evolve quickly between releases. + + +OwnCord is a self-hosted chat stack with a Go server and a Tauri desktop client. +It includes real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel.

OwnCord Client @@ -25,155 +27,73 @@ dependencies, works fully on LAN. Admin Panel

+## Current Project Status + +| Area | Status | +| ---- | ------ | +| Core chat flow | Working in alpha | +| Voice/video | Working in alpha | +| Admin panel | Working in alpha | +| Security hardening | In progress | + +## Platform Support (Current Releases) + +| Component | Windows x64 | Linux x64 | Linux ARM64 | +| --------- | ----------- | --------- | ----------- | +| Server binary | Yes | Not published yet | N/A | +| Desktop client | Yes | Not published yet | Not published yet | +| Docker server | N/A | Not published yet | N/A | + +## Start Here + +- New user quick path: [docs/quick-start.md](docs/quick-start.md) +- Linux Docker deployment: [docs/deployment.md](docs/deployment.md) +- Remote access without router config: [docs/tailscale.md](docs/tailscale.md) +- Manual router/network setup: [docs/port-forwarding.md](docs/port-forwarding.md) + ## Quick Start -1. Download `chatserver.exe` and the OwnCord installer from - [GitHub Releases](https://github.com/J3vb/OwnCord/releases) -2. Run `chatserver.exe` — generates `config.yaml` and a `data/` - directory (database, TLS certs, uploads, backups) on first run -3. Open `https://localhost:8443/admin` to create the Owner account -4. Generate an invite code in the admin panel and share it -5. Friends install the client, enter your server address - (`ip:8443`), and register with the invite code -> Note: You should locate your active IPv4 via `ipconfig` for Win or `ip a` for Linux, since OwnCord server runs on `0.0.0.0`. -> -> Example: 192.168.1.2:8443 +### Option A: Prebuilt binaries -The client uses TOFU (Trust On First Use) for self-signed -certificates — it prompts to trust the server on first -connection, then pins it for future sessions. +1. Download assets from [GitHub Releases](https://github.com/J3vb/OwnCord/releases). +2. Run the server binary: + - Windows: `chatserver.exe` + - Linux: `./chatserver` +3. Open `https://localhost:8443/admin` and create your Owner account. +4. Generate invite codes in the admin panel and share them with friends. +### Option B: Docker (Linux server) -## Features +```bash +cd Server +cp .env.example .env +cp livekit.yaml.example livekit.yaml +# Edit both files before starting +docker compose up -d +``` -### Chat +See the full setup guide in [docs/deployment.md](docs/deployment.md). -- Real-time text messaging over WebSocket -- Message editing, deletion, and replies -- Emoji reactions with per-message counts -- Typing indicators -- Full-text message search (SQLite FTS5) -- Pinned messages per channel -- Rich link previews with Open Graph metadata -- YouTube embed support with cached titles -- GIF picker powered by Tenor with inline rendering -- Inline image previews with lightbox viewer +The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections. -### Voice & Video +## What OwnCord Already Has -- Voice channels powered by LiveKit SFU -- Webcam video chat with Discord-style grid layout (fixed 16:9 aspect ratio) -- Sidebar stream preview (hover to see live video thumbnail) -- Mute, deafen, camera, and screenshare controls -- Push-to-talk with global hotkey (non-consuming, works while unfocused) -- Per-user volume control (right-click user in voice channel) -- RNNoise ML noise suppression -- Voice activity detection with speaker indicators (pulsing green glow) -- Connection quality indicator with expandable transport stats -- Voice call duration timer (MM:SS / HH:MM:SS elapsed) -- LiveKit server runs as a companion process alongside `chatserver.exe` +- Real-time channels and direct messages over WebSocket +- Voice/video channels via LiveKit +- Invite-only registration and role-based permissions +- Web admin panel with logs, backups, and update tooling +- File uploads and inline media rendering +- TOTP 2FA support and API rate limiting +- Desktop client auto-update with signature verification -### Direct Messages - -- One-on-one DM conversations with any server member -- DM preview section in sidebar with unread bubble indicators -- Auto-reopen DM channels on incoming message -- DM header shows `@ username` with live online status - -### Channels & Organization - -- Text and voice channels organized by categories -- Create, edit, delete, and reorder channels -- Unread message indicators -- Quick channel switcher (Ctrl+K) - -### File Sharing - -- Drag-and-drop and clipboard paste uploads -- Inline image previews with persistent caching (IndexedDB) -- File download with native save dialog -- Configurable max upload size - -### Users & Permissions - -- Invite-only registration with invite codes -- Role-based permissions with custom roles -- Member list with online/offline presence -- User profiles with status (online, idle, dnd, offline) - -### Administration - -- Web-based admin panel at `/admin` (IP-restricted to private networks by default) -- Dashboard with server stats and recent activity -- User management (ban, kick, role assignment) with modals -- Channel management (create, edit, delete) -- Settings management (server name, MOTD, limits, security) -- Live server log streaming via SSE with level filters, - search, auto-scroll, pause/resume, copy, and clear -- Audit log with search, action type filter, copy, and CSV export -- Database backup and restore with pre-restore safety backups -- Server update checker and one-click apply (GitHub Releases) -- Metrics endpoint with uptime, goroutines, heap, connected users -- Diagnostics endpoint for connectivity checks - -### Security - -- TLS encryption (self-signed, Let's Encrypt, or custom cert) -- Trust-on-first-use certificate pinning in the client -- Two-factor authentication (TOTP) with QR enrollment and backup codes -- Ed25519-signed client auto-updates -- Rate limiting on all endpoints -- CSRF protection and security headers -- Account deletion with password confirmation and data anonymization - -### Desktop Client - -- Native Windows app built with Tauri v2 -- System tray integration -- Desktop notifications with taskbar flash and sound -- In-app auto-update with progress notification -- Credential storage via Windows Credential Manager -- Auto-login with saved credentials (one-click connect) -- Custom emoji picker -- Compact mode for information-dense layouts -- Discord-style settings panel with blurred backdrop -- OC Neon Glow theme with custom theming system (JSON import/export) -- Accent color picker -- Quick-switch server overlay for multi-server users -- Structured logging with JSONL persistence (5-day rotation) - -### Voice & Video Setup (Optional) - -Voice and video require [LiveKit Server](https://github.com/livekit/livekit/releases): - -1. Download `livekit-server` from the LiveKit releases page -2. Edit `config.yaml` and set: - ```yaml - voice: - livekit_api_key: "devkey" # any string - livekit_api_secret: "secret-min-32-characters-long!!" # min 32 chars - livekit_binary: "C:/path/to/livekit-server.exe" - ``` -3. Restart `chatserver.exe` — it auto-starts LiveKit as a - companion process - -### Networking - -For friends outside your LAN, you need to forward these ports: - -| Port | Protocol | Purpose | -| ---- | -------- | ------- | -| `8443` | TCP | HTTPS, WebSocket, REST API | -| `7881` | TCP | LiveKit signaling (voice/video) | -| `50000-60000` | UDP | LiveKit WebRTC media (voice/video) | - -Alternatively, use Tailscale for zero-config networking -with no port forwarding. +See deeper feature and architecture docs in [docs/client-architecture.md](docs/client-architecture.md) and [docs/protocol.md](docs/protocol.md). ## Architecture -Two components: a **Go server** and a **Tauri v2 client** -(Rust + TypeScript). +Two main components: + +- Go server (REST API, WebSocket hub, SQLite, admin panel) +- Tauri v2 desktop client (Rust backend + TypeScript frontend) ```text +---------------------+ +---------------------+ @@ -195,161 +115,103 @@ Two components: a **Go server** and a **Tauri v2 client** +---------------------+ ``` -- **WebSocket** — chat messages, typing, presence, voice signaling -- **REST API** — message history, file uploads, channel management, auth -- **LiveKit** — voice and video via LiveKit SFU (companion process) - -## Project Structure - -```text -OwnCord/ -├── Server/ # Go server -│ ├── api/ # REST handlers + middleware -│ ├── ws/ # WebSocket hub + handlers -│ ├── db/ # SQLite queries + migrations -│ ├── auth/ # Authentication + rate limiting -│ ├── config/ # YAML config loading -│ ├── updater/ # GitHub Releases update checker -│ ├── admin/ # Web admin panel (static SPA) -│ ├── storage/ # File upload storage -│ ├── permissions/ # Role-based permission system -│ └── migrations/ # Database migration files -├── Client/ -│ └── tauri-client/ # Tauri v2 desktop client -│ ├── src-tauri/ # Rust backend (plugins, commands) -│ ├── src/ # TypeScript frontend -│ │ ├── lib/ # Core services (API, WS, LiveKit, updater) -│ │ ├── stores/ # Reactive state (auth, channels, messages, voice) -│ │ ├── components/ # UI components (28 modules) -│ │ ├── pages/ # Page layouts -│ │ └── styles/ # CSS -│ └── tests/ # Unit, integration, and E2E tests -└── docs/ # Project documentation (Obsidian vault) -``` - -## Building from Source +## Build and Test ### Prerequisites - Go 1.25+ - Node.js 20+ -- Rust (stable) -- Windows 10/11 +- Rust stable (client builds) -### Server +### Build from source ```bash +# Server (Windows) cd Server go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . -``` -### Client +# Server (Linux) +cd Server +CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" . -```bash +# Client cd Client/tauri-client npm install npm run tauri build ``` -The installer is output to -`Client/tauri-client/src-tauri/target/release/bundle/nsis/`. - -### Running Tests +### Core verification commands ```bash # Server -cd Server && go test ./... -cd Server && go test ./... -cover # with coverage +cd Server +go test ./... # Client cd Client/tauri-client -npm test # all tests (vitest) -npm run test:unit # unit tests only -npm run test:integration # integration tests -npm run test:e2e # Playwright E2E (mocked Tauri) -npm run test:e2e:native # Playwright E2E (real Tauri exe + CDP) -npm run test:coverage # coverage report - -# Type checking & linting -npm run typecheck # full typecheck -npm run lint # ESLint check -npm run lint:fix # ESLint auto-fix +npm run typecheck +npm run lint +npm test ``` +For the full command set, use [docs/contributing.md](docs/contributing.md). + ## Configuration -The server generates a `config.yaml` on first run. All runtime data -is stored in a `data/` directory alongside the executable: +On first run, the server generates `config.yaml` and a local `data/` directory: ```text data/ -├── owncord.db # SQLite database -├── certs/ # TLS certificates (auto-generated if self_signed) -├── uploads/ # User-uploaded files -└── backups/ # Database backups +├── chatserver.db +├── certs/ +├── uploads/ +└── backups/ ``` -Key settings: +Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions. +See [docs/server-configuration.md](docs/server-configuration.md). -| Setting | Default | Description | -| ------- | ------- | ----------- | -| `server.port` | `8443` | HTTPS port | -| `server.name` | `OwnCord Server` | Display name | -| `tls.mode` | `self_signed` | TLS mode (self_signed, acme, manual, off) | -| `upload.max_size_mb` | `100` | Max upload size | -| `voice.livekit_url` | `ws://localhost:7880` | LiveKit server WebSocket URL | -| `voice.livekit_api_key` | — | LiveKit API key (required for voice) | -| `voice.livekit_api_secret` | — | LiveKit API secret (min 32 chars, required for voice) | -| `voice.livekit_binary` | — | Path to `livekit-server` binary (empty = don't auto-start) | -| `voice.quality` | `medium` | Voice quality (low, medium, high) | -| `server.admin_allowed_cidrs` | private nets | CIDRs allowed to access `/admin` | -| `github.token` | — | Token for update checks (optional, for higher rate limits) | +## Security and Vulnerability Reporting -## Auto-Updates +- For vulnerabilities, use GitHub Security Advisories (private disclosure flow). +- Do not open public issues for security bugs. +- Read full policy and hardening notes in [docs/security.md](docs/security.md). -The client checks for updates after connecting to the server. -Updates are Ed25519-signed and verified before install. +## Update Signing Notes (Maintainers) -To enable signed releases in CI, add these GitHub repository secrets: +Client and server update signing keys are intentionally separate. -- `TAURI_SIGNING_PRIVATE_KEY` — Ed25519 private key - (via `npx tauri signer generate`) -- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — key password +Required Actions secrets for release signing: -## Documentation +- `TAURI_SIGNING_PRIVATE_KEY` +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` -- [Quick Start Guide](docs/quick-start.md) -- [Server Configuration](docs/server-configuration.md) -- [LiveKit Setup (Voice/Video)](docs/livekit-setup.md) -- [Deployment Guide](docs/deployment.md) -- [Port Forwarding](docs/port-forwarding.md) -- [Tailscale Guide](docs/tailscale.md) -- [REST API Reference](docs/api.md) -- [WebSocket Protocol](docs/protocol.md) -- [Database Schema](docs/schema.md) -- [Client Architecture](docs/client-architecture.md) -- [Contributing](docs/contributing.md) -- [Security Policy](docs/security.md) +When rotating the server updater key, update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and use staged rollover for live fleets. + +## Docs Index + +- [docs/quick-start.md](docs/quick-start.md) +- [docs/deployment.md](docs/deployment.md) +- [docs/livekit-setup.md](docs/livekit-setup.md) +- [docs/port-forwarding.md](docs/port-forwarding.md) +- [docs/tailscale.md](docs/tailscale.md) +- [docs/api.md](docs/api.md) +- [docs/protocol.md](docs/protocol.md) +- [docs/schema.md](docs/schema.md) +- [docs/client-architecture.md](docs/client-architecture.md) +- [docs/contributing.md](docs/contributing.md) +- [docs/security.md](docs/security.md) ## Contributing -1. Fork the repo and create a feature branch from `dev` -2. Follow existing code style and conventions -3. Write tests for new functionality -4. Open a PR against `dev` with a clear description +1. Create a branch from `dev`. +2. Keep changes focused and tested. +3. Open a PR targeting `dev`. -See [Contributing Guide](docs/contributing.md) for details. - -## Tech Stack - -| Component | Technology | -| --------- | --------- | -| Server | Go, chi router, LiveKit server SDK | -| Database | SQLite (pure Go, embedded) | -| Client | Tauri v2 (Rust + TypeScript) | -| Voice/Video | LiveKit SFU (companion process) | -| Build | NSIS installer, GitHub Actions CI | +See [docs/contributing.md](docs/contributing.md) for the full process. ## License -AGPL-3.0 +AGPL-3.0 \ No newline at end of file diff --git a/tools/project-map/.gitignore b/tools/project-map/.gitignore new file mode 100644 index 00000000..ceddaa37 --- /dev/null +++ b/tools/project-map/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/tools/project-map/dashboard.html b/tools/project-map/dashboard.html new file mode 100644 index 00000000..3cc05a36 --- /dev/null +++ b/tools/project-map/dashboard.html @@ -0,0 +1,1960 @@ + + + + + +OwnCord Project Map + + + + + +
+

OwnCord Project Map

+
+ + + +
+
+ +
+
Loading project data...
+
+ + + + + + + + + + diff --git a/tools/project-map/index.mjs b/tools/project-map/index.mjs new file mode 100644 index 00000000..2fc75c0b --- /dev/null +++ b/tools/project-map/index.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * OwnCord Project Map Generator + * + * Scans the repository, collects test coverage, parses the backlog, + * scores priorities, and outputs a markdown report + terminal summary. + * + * Usage: + * node index.mjs # full run (runs tests for coverage) + * node index.mjs --quick # skip test runs, use cached coverage + * node index.mjs --research # interactive research launcher + * node index.mjs --serve # launch web dashboard + */ + +import { scanModules } from './lib/scanner.mjs'; +import { collectGoCoverage } from './lib/go-coverage.mjs'; +import { collectVitestCoverage } from './lib/vitest-coverage.mjs'; +import { parseBacklog } from './lib/backlog-parser.mjs'; +import { scorePriorities } from './lib/priority-engine.mjs'; +import { generateReport } from './lib/report-generator.mjs'; +import { printTerminalSummary } from './lib/terminal-summary.mjs'; +import { launchResearchAgent } from './lib/research-agent.mjs'; +import { scanGitHistory } from './lib/git-scanner.mjs'; +import { parseSessionHistory } from './lib/session-parser.mjs'; +import { scanTechnicalDebt } from './lib/debt-scanner.mjs'; +import { buildImportGraph } from './lib/import-graph.mjs'; +import { generateSuggestions } from './lib/suggestion-engine.mjs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, mkdirSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); +const CACHE_DIR = resolve(__dirname, '.cache'); +const REPORT_PATH = resolve(ROOT, 'docs/brain/00-Overview/Project-Map.md'); + +const args = process.argv.slice(2); +const quick = args.includes('--quick'); +const research = args.includes('--research'); +const serve = args.includes('--serve'); + +if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); + +async function main() { + if (research) { + await launchResearchAgent(ROOT); + return; + } + + if (serve) { + // Dynamic import to launch the server + await import('./server.mjs'); + return; + } + + console.log(quick + ? '\n Project Map (quick mode — using cached data)\n' + : '\n Project Map (full mode — running tests for coverage)\n'); + + // Core data + const modules = await scanModules(ROOT); + const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick); + const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick); + const backlog = await parseBacklog(ROOT); + const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog); + + // Enhanced data (graceful failures) + let gitData = null, sessionData = null, debtData = null, importGraph = null, suggestions = null; + try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Git] ${e.message}`); } + try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Session] ${e.message}`); } + try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Debt] ${e.message}`); } + try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Graph] ${e.message}`); } + try { + suggestions = await generateSuggestions(CACHE_DIR, { + priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData, + }); + } catch (e) { console.error(` [Suggestions] ${e.message}`); } + + // Generate markdown report + await generateReport(REPORT_PATH, modules, goCoverage, vitestCoverage, backlog, priorities); + + // Print terminal summary + printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities); + + // Print top suggestions + if (suggestions?.suggestions?.length) { + const CYAN = '\x1b[36m', BOLD = '\x1b[1m', DIM = '\x1b[2m', RESET = '\x1b[0m'; + console.log(`\n ${CYAN}${BOLD}SMART SUGGESTIONS (${suggestions.strategy})${RESET}`); + console.log(` ${DIM}${'─'.repeat(60)}${RESET}`); + for (const s of suggestions.suggestions.slice(0, 5)) { + console.log(` ${CYAN}${s.rank}.${RESET} ${BOLD}${s.module}${RESET} (score: ${s.score}) — ${DIM}${s.rationale}${RESET}`); + } + console.log(''); + } +} + +main().catch(err => { + console.error('Project map failed:', err.message); + process.exit(1); +}); diff --git a/tools/project-map/lib/agent-manager.mjs b/tools/project-map/lib/agent-manager.mjs new file mode 100644 index 00000000..53dc5bf3 --- /dev/null +++ b/tools/project-map/lib/agent-manager.mjs @@ -0,0 +1,988 @@ +/** + * Fleet Agent Manager — concurrent job queue with worktree isolation. + * + * Reworked from single-job queue to support parallel agent execution. + * Each agent runs in its own git worktree via worktree-manager. + * + * Key changes from v1: + * - activeAgents Map replaces queueLocked boolean + * - QueueStore provides atomic read-modify-write for queue file + * - Ring buffer caps live output at 500KB per agent + * - Provisioning state for worktree creation phase + * - MAX_CONCURRENT configurable parallel limit + * - Timeout escalation: 80% warning → SIGTERM → 5s → SIGKILL + */ +import { execFileSync, spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, + unlinkSync, +} from 'node:fs'; +import { resolve, join } from 'node:path'; + +import { + createWorktree, + destroyWorktree, + cleanupStaleWorktrees, +} from './worktree-manager.mjs'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const QUEUE_FILE = 'agent-queue.json'; +const RESULTS_DIR = 'agent-results'; +const PROMPTS_DIR = 'agent-prompts'; + +const ALLOWED_TYPES = new Set([ + 'research', + 'write-tests', + 'code-review', + 'security-audit', + 'fix-debt', + 'custom', +]); + +const TIMEOUTS = { + research: 600_000, + 'write-tests': 1_200_000, + 'code-review': 900_000, + 'security-audit': 1_200_000, + 'fix-debt': 900_000, + custom: 1_800_000, +}; + +/** Default max concurrent agents */ +let MAX_CONCURRENT = 4; + +/** Ring buffer cap per agent (bytes) */ +const RING_BUFFER_MAX = 512_000; // 500KB + +// --------------------------------------------------------------------------- +// QueueStore — centralized queue I/O with atomic updates +// --------------------------------------------------------------------------- + +class QueueStore { + #cacheDir; + #updateLock = false; + + constructor(cacheDir) { + this.#cacheDir = cacheDir; + } + + /** Read current queue state */ + get() { + const file = resolve(this.#cacheDir, QUEUE_FILE); + if (!existsSync(file)) return []; + try { + const raw = readFileSync(file, 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + /** Get a single job by ID */ + getJob(jobId) { + return this.get().find(j => j.id === jobId) ?? null; + } + + /** + * Atomic read-modify-write. The updater function receives the current + * jobs array and must return the new jobs array. + * Prevents concurrent reads from overwriting each other's changes. + */ + update(updaterFn) { + if (this.#updateLock) { + throw new Error('QueueStore: concurrent update detected'); + } + this.#updateLock = true; + try { + const jobs = this.get(); + const updated = updaterFn(jobs); + writeFileSync( + resolve(this.#cacheDir, QUEUE_FILE), + JSON.stringify(updated, null, 2), + ); + return updated; + } finally { + this.#updateLock = false; + } + } +} + +/** Module-level store instance — set via init() or createJob() */ +let store = null; + +function ensureStore(cacheDir) { + if (!store || store._dir !== cacheDir) { + store = new QueueStore(cacheDir); + store._dir = cacheDir; + } + return store; +} + +// --------------------------------------------------------------------------- +// Helpers — directory setup +// --------------------------------------------------------------------------- + +function ensureDirs(cacheDir) { + mkdirSync(resolve(cacheDir, RESULTS_DIR), { recursive: true }); + mkdirSync(resolve(cacheDir, PROMPTS_DIR), { recursive: true }); +} + +// --------------------------------------------------------------------------- +// Helpers — scope path resolution +// --------------------------------------------------------------------------- + +const GO_PACKAGES = new Set([ + 'api', 'ws', 'db', 'auth', 'config', 'admin', 'migrations', 'scripts', +]); + +const TS_AREAS = new Set([ + 'lib', 'stores', 'components', 'pages', +]); + +function resolveScopePath(target) { + if (GO_PACKAGES.has(target)) return `Server/${target}/`; + if (TS_AREAS.has(target)) return `Client/tauri-client/src/${target}/`; + if (target === 'tauri-rust') return 'Client/tauri-client/src-tauri/src/'; + return target; +} + +// --------------------------------------------------------------------------- +// Helpers — process checking +// --------------------------------------------------------------------------- + +function isProcessAlive(pid) { + const safePid = parseInt(pid, 10); + if (!Number.isInteger(safePid) || safePid <= 0) return false; + try { + process.kill(safePid, 0); + return true; + } catch { + return false; + } +} + +function isClaudeProcess(pid) { + const safePid = parseInt(pid, 10); + if (!Number.isInteger(safePid) || safePid <= 0) return false; + try { + if (process.platform === 'win32') { + const out = execFileSync( + 'tasklist', + ['/FI', `PID eq ${safePid}`, '/FO', 'CSV', '/NH'], + { stdio: 'pipe', timeout: 5_000 }, + ).toString(); + const lower = out.toLowerCase(); + return lower.includes('claude') || lower.includes('node'); + } + const out = execFileSync('ps', ['-p', String(safePid), '-o', 'comm='], { + stdio: 'pipe', + timeout: 5_000, + }).toString().trim().toLowerCase(); + return out.includes('claude') || out.includes('node'); + } catch { + return false; + } +} + +function killProcess(pid) { + const safePid = parseInt(pid, 10); + if (!Number.isInteger(safePid) || safePid <= 0) return; + try { + process.kill(safePid, 'SIGTERM'); + setTimeout(() => { + try { + process.kill(safePid, 0); + process.kill(safePid, 'SIGKILL'); + } catch { /* already dead */ } + }, 5_000); + } catch { /* already dead */ } +} + +// --------------------------------------------------------------------------- +// Active agents — tracks running processes +// --------------------------------------------------------------------------- + +/** Map */ +const activeAgents = new Map(); + +/** Processing flag — guards the auto-process interval from double-spawn */ +let isProcessing = false; + +// --------------------------------------------------------------------------- +// Ring buffer for live output +// --------------------------------------------------------------------------- + +const liveOutputBuffers = new Map(); + +function appendToRingBuffer(jobId, text) { + const current = liveOutputBuffers.get(jobId) || ''; + let updated = current + text; + + if (updated.length > RING_BUFFER_MAX) { + const truncateMarker = '\n[... output truncated — showing last 500KB ...]\n'; + updated = truncateMarker + updated.slice(updated.length - RING_BUFFER_MAX + truncateMarker.length); + } + + liveOutputBuffers.set(jobId, updated); +} + +export function getLiveOutput(jobId) { + return liveOutputBuffers.get(jobId) || ''; +} + +export function clearLiveOutput(jobId) { + liveOutputBuffers.delete(jobId); +} + +// --------------------------------------------------------------------------- +// Prompt templates +// --------------------------------------------------------------------------- + +function buildPrompt(job, _root) { + const { type, target, customPrompt } = job; + const scopePath = resolveScopePath(target); + + switch (type) { + case 'research': + return [ + `You are a research agent for OwnCord. Investigate the ${target} module.`, + 'Focus on: coverage gaps, potential bugs, edge cases, security concerns, missing functionality.', + `Scope: ${scopePath}`, + 'Read relevant source and test files. Prioritize findings as CRITICAL/HIGH/MEDIUM/LOW.', + 'Output a structured markdown report.', + ].join('\n'); + + case 'write-tests': + return [ + 'You are a test-writing agent for OwnCord.', + `Module: ${target}`, + `Read the source files in ${scopePath}.`, + 'Read docs/brain/06-Specs/TESTING-STRATEGY.md for test patterns.', + 'Write tests targeting untested functions and branches. Aim for 80%+ coverage.', + 'Save tests to the appropriate test directory.', + ].join('\n'); + + case 'code-review': + return [ + 'You are a code review agent for OwnCord.', + `Review recent changes in the ${target} module for bugs, security issues, code quality.`, + `Scope: ${scopePath}`, + 'Output findings with file:line references and severity ratings.', + ].join('\n'); + + case 'security-audit': + return [ + 'You are a security audit agent for OwnCord.', + `Perform an OWASP Top 10 review of the ${target} module.`, + `Scope: ${scopePath}`, + 'Check for: injection, auth bypass, XSS, CSRF, path traversal, hardcoded secrets.', + 'Output findings with severity, file:line, and remediation steps.', + ].join('\n'); + + case 'fix-debt': + return [ + 'You are a technical debt agent for OwnCord.', + `Address TODO/FIXME/HACK items in the ${target} module.`, + `Scope: ${scopePath}`, + 'For each debt marker, either fix it or explain why it should remain.', + ].join('\n'); + + case 'custom': + return customPrompt; + + default: + return `Investigate ${target} in OwnCord.`; + } +} + +// --------------------------------------------------------------------------- +// Activity hint parsing — extract file paths from agent stdout +// --------------------------------------------------------------------------- + +const FILE_PATH_PATTERNS = [ + /Server\/[\w/.-]+\.go/g, + /Client\/[\w/.-]+\.tsx?/g, + /(? '+' + l).join('\n'); + } else { + const args = baselineSha + ? ['diff', baselineSha, '--', f.path] + : ['diff', '--', f.path]; + const content = gitArgs(args, diffRoot); + diffs[f.path] = content; + } + } catch { + diffs[f.path] = ''; + } + } + + return { files, diffs, freshness }; +} + +// --------------------------------------------------------------------------- +// Public API — configuration +// --------------------------------------------------------------------------- + +export function setMaxConcurrent(n) { + const val = parseInt(n, 10); + if (Number.isInteger(val) && val >= 1 && val <= 8) { + MAX_CONCURRENT = val; + } +} + +export function getMaxConcurrent() { + return MAX_CONCURRENT; +} + +export function getActiveCount() { + return activeAgents.size; +} + +export function getActiveAgentIds() { + return [...activeAgents.keys()]; +} + +// --------------------------------------------------------------------------- +// Public API — health check +// --------------------------------------------------------------------------- + +export function healthCheck() { + try { + const version = execFileSync('claude', ['--version'], { stdio: 'pipe' }) + .toString() + .trim(); + return { available: true, version }; + } catch (err) { + return { available: false, error: err.message || 'Claude CLI not found' }; + } +} + +// --------------------------------------------------------------------------- +// Public API — job CRUD +// --------------------------------------------------------------------------- + +export function getJobs(cacheDir) { + const s = ensureStore(cacheDir); + const jobs = s.get(); + const sorted = [...jobs].sort((a, b) => { + const priDiff = (b.priority ?? 1) - (a.priority ?? 1); + if (priDiff !== 0) return priDiff; + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }); + return { jobs: sorted }; +} + +export function createJob(cacheDir, { type, target, priority, customPrompt }) { + if (!ALLOWED_TYPES.has(type)) { + throw new Error(`Invalid job type "${type}". Allowed: ${[...ALLOWED_TYPES].join(', ')}`); + } + if (!target || typeof target !== 'string' || target.trim().length === 0) { + throw new Error('Job target must be a non-empty string'); + } + if (type === 'custom' && (!customPrompt || typeof customPrompt !== 'string' || customPrompt.trim().length === 0)) { + throw new Error('Custom jobs require a non-empty customPrompt'); + } + + ensureDirs(cacheDir); + const s = ensureStore(cacheDir); + + const job = { + id: `job-${Date.now()}-${randomBytes(4).toString('hex')}`, + type, + target: target.trim(), + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + resultPath: null, + error: null, + priority: typeof priority === 'number' ? priority : 1, + retryCount: 0, + maxRetries: 2, + pid: null, + customPrompt: customPrompt ?? null, + // Fleet fields + worktreePath: null, + branchName: null, + configMethod: null, + }; + + s.update(jobs => [...jobs, job]); + return job; +} + +export function cancelJob(cacheDir, jobId) { + const s = ensureStore(cacheDir); + const job = s.getJob(jobId); + if (!job) throw new Error(`Job "${jobId}" not found`); + + if (job.status === 'queued') { + s.update(jobs => jobs.filter(j => j.id !== jobId)); + return { ok: true }; + } + + if (job.status === 'provisioning' || job.status === 'running') { + // Kill process if running + const agent = activeAgents.get(jobId); + if (agent?.pid) killProcess(agent.pid); + if (agent?.timer) clearTimeout(agent.timer); + if (agent?.warningTimer) clearTimeout(agent.warningTimer); + activeAgents.delete(jobId); + + // Destroy worktree + if (job.worktreePath) { + try { + const root = resolve(job.worktreePath, '..', '..'); + destroyWorktree(root, jobId); + } catch (err) { + console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`); + } + } + + s.update(jobs => jobs.map(j => + j.id === jobId + ? { ...j, status: 'cancelled', completedAt: new Date().toISOString(), pid: null } + : j, + )); + setTimeout(() => clearLiveOutput(jobId), 5000); + return { ok: true }; + } + + // Terminal statuses (review, done, failed, cancelled) — just remove from list + if (['review', 'dead', 'cancelled'].includes(job.status)) { + if (job.worktreePath) { + try { + const root = resolve(job.worktreePath, '..', '..'); + destroyWorktree(root, jobId); + } catch (err) { + console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`); + } + } + s.update(jobs => jobs.filter(j => j.id !== jobId)); + return { ok: true }; + } + + throw new Error(`Cannot cancel job "${jobId}" with status "${job.status}"`); +} + +export function getJobResult(cacheDir, jobId) { + const resultFile = resolve(cacheDir, RESULTS_DIR, `${jobId}.md`); + if (!existsSync(resultFile)) return { content: null }; + return { content: readFileSync(resultFile, 'utf8') }; +} + +// --------------------------------------------------------------------------- +// Public API — fleet queue processing +// --------------------------------------------------------------------------- + +/** Override CLI command for testing */ +let _spawnCommand = 'claude'; +export function setSpawnCommand(cmd) { + if (process.env.NODE_ENV !== 'test') { + throw new Error('setSpawnCommand is only available in test environments'); + } + if (typeof cmd !== 'string' || cmd.length === 0 || cmd.includes('/') || cmd.includes('\\')) { + throw new Error('setSpawnCommand: cmd must be a simple command name'); + } + _spawnCommand = cmd; +} + +/** Check processing state (for diagnostics) */ +export function getIsProcessing() { return isProcessing; } + +/** + * Process the queue — spawn agents up to MAX_CONCURRENT. + * Returns after spawning; agents run asynchronously. + * + * @param {string} root — project root + * @param {string} cacheDir — cache directory + * @param {function} [onOutput] — callback (jobId, chunk) for streaming + * @param {function} [onWarning] — callback (jobId, message) for timeout warnings + * @returns {Promise<{ launched: number, skipped: string[] }>} + */ +export async function processQueue(root, cacheDir, onOutput, onWarning) { + if (isProcessing) return { launched: 0, skipped: ['locked'] }; + isProcessing = true; + + try { + ensureDirs(cacheDir); + const s = ensureStore(cacheDir); + const jobs = s.get(); + + // How many slots available? + const slotsAvailable = MAX_CONCURRENT - activeAgents.size; + if (slotsAvailable <= 0) { + return { launched: 0, skipped: ['at_capacity'] }; + } + + // Pick highest-priority queued jobs + const queued = jobs + .filter(j => j.status === 'queued') + .sort((a, b) => { + const priDiff = (b.priority ?? 1) - (a.priority ?? 1); + if (priDiff !== 0) return priDiff; + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }) + .slice(0, slotsAvailable); + + if (queued.length === 0) { + return { launched: 0, skipped: [] }; + } + + let launched = 0; + const skipped = []; + + for (const job of queued) { + try { + // Mark provisioning + s.update(jobs => jobs.map(j => + j.id === job.id ? { ...j, status: 'provisioning' } : j, + )); + + // Create worktree + let worktreeInfo; + try { + worktreeInfo = createWorktree(root, job.id); + } catch (wtErr) { + console.error(` [Agent] Worktree creation failed for ${job.id}: ${wtErr.message}`); + // Re-queue or mark dead + const newRetry = (job.retryCount ?? 0) + 1; + const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead'; + s.update(jobs => jobs.map(j => + j.id === job.id + ? { ...j, status: nextStatus, error: `Worktree failed: ${wtErr.message}`, retryCount: newRetry, pid: null } + : j, + )); + skipped.push(job.id); + continue; + } + + // Build prompt and spawn agent + const prompt = buildPrompt(job, root); + const timeout = TIMEOUTS[job.type] ?? TIMEOUTS.custom; + const resultPath = resolve(cacheDir, RESULTS_DIR, `${job.id}.md`); + + // Save prompt for debugging + const promptFile = resolve(cacheDir, PROMPTS_DIR, `${job.id}.txt`); + try { writeFileSync(promptFile, prompt, 'utf8'); } catch { /* non-critical */ } + + // Spawn claude in the worktree + const child = spawn(_spawnCommand, [ + '-p', prompt, + '--dangerously-skip-permissions', + '--output-format', 'text', + ], { + cwd: worktreeInfo.worktreePath, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const pid = child.pid ?? null; + if (pid === null) { + console.error(` [Agent] Failed to get PID for ${job.id}`); + destroyWorktree(root, job.id); + const newRetry = (job.retryCount ?? 0) + 1; + const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead'; + s.update(jobs => jobs.map(j => + j.id === job.id + ? { ...j, status: nextStatus, error: 'Failed to start process', retryCount: newRetry, pid: null, worktreePath: null } + : j, + )); + skipped.push(job.id); + continue; + } + + // Capture baseline SHA for diff tracking + let baselineSha = null; + try { + baselineSha = gitArgs(['rev-parse', 'HEAD'], worktreeInfo.worktreePath); + } catch { /* non-critical */ } + + // Mark running + s.update(jobs => jobs.map(j => + j.id === job.id + ? { + ...j, + status: 'running', + startedAt: new Date().toISOString(), + pid, + worktreePath: worktreeInfo.worktreePath, + branchName: worktreeInfo.branchName, + configMethod: worktreeInfo.configMethod, + baselineSha, + preExistingDirtyFiles: [], + } + : j, + )); + + // Initialize live output + liveOutputBuffers.set(job.id, ''); + let stdout = ''; + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + stdout += text; + appendToRingBuffer(job.id, text); + if (typeof onOutput === 'function') { + try { onOutput(job.id, text); } catch { /* non-critical */ } + } + }); + + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + appendToRingBuffer(job.id, `[stderr] ${text}`); + }); + + // Timeout warning at 80% + const warningTimer = setTimeout(() => { + if (typeof onWarning === 'function') { + try { onWarning(job.id, `Agent approaching timeout (80% of ${timeout / 1000}s)`); } catch { /* */ } + } + }, timeout * 0.8); + + // Hard timeout — SIGTERM then SIGKILL + const timer = setTimeout(() => { + killProcess(pid); + }, timeout); + + // Track active agent + activeAgents.set(job.id, { + pid, + worktreePath: worktreeInfo.worktreePath, + branchName: worktreeInfo.branchName, + child, + timer, + warningTimer, + }); + + // Handle completion + child.on('close', (code) => { + clearTimeout(timer); + clearTimeout(warningTimer); + activeAgents.delete(job.id); + + const currentJob = ensureStore(cacheDir).getJob(job.id); + if (!currentJob) return; + + // If cancelled while running, don't overwrite + if (currentJob.status === 'cancelled') { + setTimeout(() => clearLiveOutput(job.id), 5000); + return; + } + + const timedOut = !isProcessAlive(pid) && code !== 0; + + if (code === 0) { + // Success — save result, mark as review (worktree kept for merge) + writeFileSync(resultPath, stdout, 'utf8'); + s.update(jobs => jobs.map(j => + j.id === job.id + ? { ...j, status: 'review', completedAt: new Date().toISOString(), resultPath, pid: null } + : j, + )); + } else { + // Failure + const newRetry = (currentJob.retryCount ?? 0) + 1; + const maxRetries = currentJob.maxRetries ?? 2; + const errorMsg = code === null + ? `Timed out after ${timeout / 1000}s` + : `Exited with code ${code}`; + const nextStatus = newRetry < maxRetries ? 'queued' : 'dead'; + + // Save partial output + if (stdout.length > 0) { + writeFileSync(resultPath, stdout, 'utf8'); + } + + // Destroy worktree on failure + try { destroyWorktree(root, job.id); } catch { /* best effort */ } + + s.update(jobs => jobs.map(j => + j.id === job.id + ? { + ...j, + status: nextStatus, + completedAt: nextStatus === 'dead' ? new Date().toISOString() : null, + startedAt: nextStatus === 'queued' ? null : currentJob.startedAt, + error: errorMsg, + retryCount: newRetry, + pid: null, + resultPath: stdout.length > 0 ? resultPath : null, + worktreePath: null, + branchName: nextStatus === 'queued' ? null : currentJob.branchName, + } + : j, + )); + } + + setTimeout(() => clearLiveOutput(job.id), 5000); + }); + + child.on('error', (err) => { + clearTimeout(timer); + clearTimeout(warningTimer); + activeAgents.delete(job.id); + + try { destroyWorktree(root, job.id); } catch { /* best effort */ } + + const newRetry = (job.retryCount ?? 0) + 1; + const maxRetries = job.maxRetries ?? 2; + const nextStatus = newRetry < maxRetries ? 'queued' : 'dead'; + + s.update(jobs => jobs.map(j => + j.id === job.id + ? { + ...j, + status: nextStatus, + completedAt: nextStatus === 'dead' ? new Date().toISOString() : null, + error: err.message, + retryCount: newRetry, + pid: null, + worktreePath: null, + } + : j, + )); + + setTimeout(() => clearLiveOutput(job.id), 5000); + }); + + launched += 1; + } catch (err) { + console.error(` [Agent] Unexpected error launching ${job.id}: ${err.message}`); + skipped.push(job.id); + } + } + + return { launched, skipped }; + } finally { + isProcessing = false; + } +} + +// --------------------------------------------------------------------------- +// Public API — orphan recovery +// --------------------------------------------------------------------------- + +export function recoverOrphans(cacheDir, root) { + const s = ensureStore(cacheDir); + const jobs = s.get(); + let recovered = 0; + + const updated = jobs.map((job) => { + if (job.status !== 'running' && job.status !== 'provisioning') return job; + + const pid = job.pid; + const alive = pid && isProcessAlive(pid); + const isClaude = alive && isClaudeProcess(pid); + + if (alive && isClaude) return job; + + // Orphan detected + recovered += 1; + + // Destroy worktree if it exists + if (job.worktreePath && root) { + try { destroyWorktree(root, job.id); } catch { /* best effort */ } + } + + const newRetry = (job.retryCount ?? 0) + 1; + const maxRetries = job.maxRetries ?? 2; + + if (newRetry < maxRetries) { + return { + ...job, + status: 'queued', + startedAt: null, + error: 'Orphaned process — re-queued', + retryCount: newRetry, + pid: null, + worktreePath: null, + branchName: null, + }; + } + + return { + ...job, + status: 'dead', + completedAt: new Date().toISOString(), + error: 'Orphaned process — max retries exceeded', + retryCount: newRetry, + pid: null, + worktreePath: null, + branchName: null, + }; + }); + + if (recovered > 0) { + s.update(() => updated); + } + + // Also clean stale worktree directories + if (root) { + try { + cleanupStaleWorktrees(root, (jobId) => { + const job = jobs.find(j => j.id === jobId); + return job?.status === 'running' && job?.pid && isProcessAlive(job.pid); + }); + } catch { /* best effort */ } + } + + return { recovered }; +} + +// --------------------------------------------------------------------------- +// Public API — result pruning +// --------------------------------------------------------------------------- + +export function pruneResults(cacheDir) { + const dir = resolve(cacheDir, RESULTS_DIR); + if (!existsSync(dir)) return { pruned: 0 }; + + let entries; + try { + entries = readdirSync(dir) + .map((name) => { + const full = join(dir, name); + try { + const stat = statSync(full); + return { name, path: full, mtime: stat.mtimeMs }; + } catch { + return null; + } + }) + .filter(Boolean); + } catch { + return { pruned: 0 }; + } + + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = Date.now() - thirtyDaysMs; + let pruned = 0; + + const remaining = []; + for (const entry of entries) { + if (entry.mtime < cutoff) { + try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ } + } else { + remaining.push(entry); + } + } + + if (remaining.length > 50) { + remaining.sort((a, b) => a.mtime - b.mtime); + const excess = remaining.slice(0, remaining.length - 50); + for (const entry of excess) { + try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ } + } + } + + return { pruned }; +} + +// --------------------------------------------------------------------------- +// Legacy exports for backward compatibility (used by existing tests) +// --------------------------------------------------------------------------- + +/** @deprecated Use getIsProcessing() instead */ +export function isQueueLocked() { return isProcessing; } + +/** @deprecated Use internal reset instead */ +export function resetQueueLock() { isProcessing = false; } diff --git a/tools/project-map/lib/backlog-parser.mjs b/tools/project-map/lib/backlog-parser.mjs new file mode 100644 index 00000000..4a1c9aa3 --- /dev/null +++ b/tools/project-map/lib/backlog-parser.mjs @@ -0,0 +1,124 @@ +/** + * Backlog parser — reads Backlog.md and extracts open tasks, + * mapping them to project modules by keyword matching. + */ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// Keywords that map tasks to modules +const MODULE_KEYWORDS = { + // Server Go packages + 'admin': ['admin', 'admin panel', 'admin api'], + 'api': ['api', 'REST', 'endpoint', 'handler', 'middleware'], + 'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'], + 'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'], + 'ws': ['websocket', 'WS', 'hub', 'voice', 'broadcast', 'ringbuffer', 'reconnect'], + 'permissions': ['permission', 'role', 'RBAC'], + 'storage': ['upload', 'file storage', 'attachment'], + 'config': ['config', 'settings'], + // Client areas + 'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'], + 'stores': ['store', 'state management'], + 'components': ['component', 'UI', 'sidebar', 'overlay', 'widget', 'picker', 'modal'], + 'pages': ['page', 'ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'], + // Rust + 'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater', 'ptt.rs'], + // Cross-cutting + 'protocol': ['protocol', 'message type', 'payload'], + 'e2e': ['E2E', 'Playwright', 'end-to-end'], + 'livekit': ['LiveKit', 'voice', 'video', 'spatial audio', 'whisper', 'screen sharing', 'streaming'], + // Feature areas + 'gaming': ['game detection', 'game time', 'LAN', 'tournament', 'leaderboard', 'seat map', 'Xfire'], + 'community': ['poll', 'gallery', 'scheduler', 'activity feed', 'pinned notes'], + 'platform': ['theme engine', 'webhook', 'bot', 'plugin', 'backup', 'monitoring'], + 'ai': ['AI', 'noise cancellation', 'translation', 'summarization', 'overlay'], +}; + +function matchModule(description) { + const lower = description.toLowerCase(); + const matches = []; + + for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) { + for (const kw of keywords) { + const kwLower = kw.toLowerCase(); + const re = new RegExp(`\\b${kwLower}\\b`); + if (re.test(lower)) { + matches.push(mod); + break; + } + } + } + + return matches.length > 0 ? matches : ['unclassified']; +} + +function extractPhase(sectionHeader) { + if (sectionHeader.includes('Bug')) return 'bug'; + if (sectionHeader.includes('Code Review')) return 'code-review'; + if (sectionHeader.includes('Medium Priority') || sectionHeader.includes('P2')) return 'medium'; + if (sectionHeader.includes('High Priority') || sectionHeader.includes('P0') || sectionHeader.includes('P1')) return 'high'; + if (sectionHeader.includes('Deferred')) return 'deferred'; + if (/\bR1\b/.test(sectionHeader)) return 'roadmap-r1'; + if (/\bR2\b/.test(sectionHeader)) return 'roadmap-r2'; + if (/\bR3\b/.test(sectionHeader)) return 'roadmap-r3'; + if (/\bR4\b/.test(sectionHeader)) return 'roadmap-r4'; + if (/\bR5\b/.test(sectionHeader)) return 'roadmap-r5'; + if (/\bR6\b/.test(sectionHeader)) return 'roadmap-r6'; + return 'other'; +} + +export async function parseBacklog(root) { + const backlogPath = resolve(root, 'docs/brain/02-Tasks/Backlog.md'); + if (!existsSync(backlogPath)) { + console.log(' [Backlog] File not found'); + return { tasks: [], openCount: 0, doneCount: 0, byModule: {}, byPhase: {} }; + } + + const content = readFileSync(backlogPath, 'utf8'); + const lines = content.split('\n'); + + const tasks = []; + let currentSection = ''; + + for (const line of lines) { + // Track section headers + if (line.startsWith('## ') || line.startsWith('### ')) { + currentSection = line.replace(/^#+\s*/, ''); + } + + // Match task lines in two formats: + // - [ ] **T-XXX:** description (colon inside bold) + // - [ ] **T-XXX**: description (colon after bold) + const taskMatch = line.match(/^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/); + if (taskMatch) { + const done = taskMatch[1] === 'x'; + const id = `T-${taskMatch[2]}`; + const description = taskMatch[3].replace(/\s*—\s*\d{4}-\d{2}-\d{2}$/, '').trim(); + const modules = matchModule(description); + const phase = extractPhase(currentSection); + + tasks.push({ id, description, done, modules, phase, section: currentSection }); + } + } + + // Aggregate + const openTasks = tasks.filter(t => !t.done); + const doneTasks = tasks.filter(t => t.done); + + const byModule = {}; + for (const task of openTasks) { + for (const mod of task.modules) { + if (!byModule[mod]) byModule[mod] = []; + byModule[mod].push(task); + } + } + + const byPhase = {}; + for (const task of openTasks) { + if (!byPhase[task.phase]) byPhase[task.phase] = []; + byPhase[task.phase].push(task); + } + + console.log(` [Backlog] ${openTasks.length} open tasks, ${doneTasks.length} done`); + return { tasks, openCount: openTasks.length, doneCount: doneTasks.length, byModule, byPhase }; +} diff --git a/tools/project-map/lib/debt-scanner.mjs b/tools/project-map/lib/debt-scanner.mjs new file mode 100644 index 00000000..cf73a16a --- /dev/null +++ b/tools/project-map/lib/debt-scanner.mjs @@ -0,0 +1,311 @@ +import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises'; +import { join, relative, sep, posix } from 'node:path'; + +const MARKER_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i; + +const SCAN_DIRS = [ + { dir: 'Server', ext: '.go', skipFile: '_test.go', skipDirs: ['vendor'] }, + { dir: 'Client/tauri-client/src', ext: '.ts', skipFile: null, skipDirs: ['node_modules', 'dist'] }, + { dir: 'Client/tauri-client/src-tauri/src', ext: '.rs', skipFile: null, skipDirs: ['target'] }, +]; + +const LARGE_FILE_WARNING = 400; +const LARGE_FILE_CRITICAL = 800; +const LONG_FUNCTION_LINES = 50; +const DEEP_NESTING_THRESHOLD = 4; + +/** + * Recursively collect files matching an extension, skipping specified directories. + */ +async function collectFiles(base, ext, skipFile, skipDirs) { + const results = []; + + async function walk(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (skipDirs.includes(entry.name)) continue; + await walk(join(dir, entry.name)); + } else if (entry.isFile() && entry.name.endsWith(ext)) { + if (skipFile && entry.name.endsWith(skipFile)) continue; + results.push(join(dir, entry.name)); + } + } + } + + await walk(base); + return results; +} + +/** + * Derive the module name from a file path relative to root. + */ +function getModule(relPath) { + const parts = relPath.split(/[/\\]/); + + // Server/{package}/file.go -> package name + if (parts[0] === 'Server' && parts.length >= 2) { + return parts.length >= 3 ? parts[1] : 'server-root'; + } + + // Client/tauri-client/src-tauri/src/file.rs -> "tauri-rust" + if ( + parts[0] === 'Client' && + parts[1] === 'tauri-client' && + parts[2] === 'src-tauri' + ) { + return 'tauri-rust'; + } + + // Client/tauri-client/src/{area}/file.ts -> area name + if ( + parts[0] === 'Client' && + parts[1] === 'tauri-client' && + parts[2] === 'src' + ) { + return parts.length >= 5 ? parts[3] : 'client-root'; + } + + return 'unknown'; +} + +/** + * Normalize path to forward slashes for consistent output. + */ +function normalizePath(p) { + return p.split(sep).join(posix.sep); +} + +/** + * Detect long functions via brace-depth tracking. + * Returns array of { line, name, lines }. + */ +function detectLongFunctions(content, ext) { + const lines = content.split('\n'); + const functions = []; + + // Stack: { name, startLine, depth } + let current = null; + let braceDepth = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + + // Detect function start + let funcName = null; + + if (ext === '.go') { + const match = trimmed.match(/^func\s+(?:\([^)]*\)\s*)?(\w+)/); + if (match) funcName = match[1]; + } else if (ext === '.ts') { + // Named function declaration + const fnMatch = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/); + if (fnMatch) { + funcName = fnMatch[1]; + } else { + // Arrow function: const name = (...) => { + const arrowMatch = trimmed.match(/^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])*=>\s*\{/); + if (arrowMatch) { + funcName = arrowMatch[1]; + } + } + } else if (ext === '.rs') { + const match = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/); + if (match) funcName = match[1]; + } + + if (funcName && current === null) { + current = { name: funcName, startLine: i + 1, depth: braceDepth }; + } + + // Track braces + for (const ch of line) { + if (ch === '{') braceDepth++; + if (ch === '}') braceDepth--; + } + if (braceDepth < 0) braceDepth = 0; + + // Check if current function has ended + if (current !== null && braceDepth <= current.depth) { + const funcLines = (i + 1) - current.startLine + 1; + if (funcLines > LONG_FUNCTION_LINES) { + functions.push({ + line: current.startLine, + name: current.name, + lines: funcLines, + }); + } + current = null; + } + } + + return functions; +} + +/** + * Scan a single file for all debt indicators. + */ +function scanFile(content, relPath, ext) { + const lines = content.split('\n'); + const mod = getModule(relPath); + const file = normalizePath(relPath); + + const markers = []; + const deepNesting = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Marker detection + const markerMatch = line.match(MARKER_RE); + if (markerMatch) { + markers.push({ + type: markerMatch[1].toUpperCase(), + file, + line: i + 1, + text: markerMatch[2].trim(), + module: mod, + }); + } + + // Deep nesting detection + const leadingSpaces = line.match(/^(\s*)/)[1]; + const tabCount = (leadingSpaces.match(/\t/g) || []).length; + const spaceCount = leadingSpaces.replace(/\t/g, '').length; + const depth = tabCount + Math.floor(spaceCount / 4); + + if (depth >= DEEP_NESTING_THRESHOLD && line.trim().length > 0) { + deepNesting.push({ + file, + line: i + 1, + depth, + module: mod, + }); + } + } + + // Large file detection + let largeFile = null; + if (lines.length > LARGE_FILE_WARNING) { + largeFile = { + file, + lines: lines.length, + module: mod, + severity: lines.length > LARGE_FILE_CRITICAL ? 'critical' : 'warning', + }; + } + + // Long function detection + const longFunctions = detectLongFunctions(content, ext).map((f) => ({ + file, + line: f.line, + name: f.name, + lines: f.lines, + module: mod, + })); + + return { markers, largeFile, longFunctions, deepNesting }; +} + +/** + * Scan the codebase for technical debt indicators. + * + * @param {string} root - Absolute path to the repository root + * @param {string} cacheDir - Directory to store cached results + * @param {boolean} quick - If true and cache exists, return cached data + * @returns {Promise} Technical debt report + */ +export async function scanTechnicalDebt(root, cacheDir, quick) { + const cachePath = join(cacheDir, 'debt-data.json'); + + if (quick) { + try { + const cached = await readFile(cachePath, 'utf-8'); + return JSON.parse(cached); + } catch { + // Cache miss, proceed with full scan + } + } + + const allMarkers = []; + const allLargeFiles = []; + const allLongFunctions = []; + let allDeepNesting = []; + + for (const { dir, ext, skipFile, skipDirs } of SCAN_DIRS) { + const baseDir = join(root, ...dir.split('/')); + const files = await collectFiles(baseDir, ext, skipFile, skipDirs); + + for (const filePath of files) { + let content; + try { + content = await readFile(filePath, 'utf-8'); + } catch { + continue; + } + + const relPath = relative(root, filePath); + const result = scanFile(content, relPath, ext); + + allMarkers.push(...result.markers); + if (result.largeFile) allLargeFiles.push(result.largeFile); + allLongFunctions.push(...result.longFunctions); + allDeepNesting.push(...result.deepNesting); + } + } + + // Keep only top 20 deepest nesting instances + allDeepNesting.sort((a, b) => b.depth - a.depth); + allDeepNesting = allDeepNesting.slice(0, 20); + + // Build per-module summary + const byModule = {}; + const ensureModule = (mod) => { + if (!byModule[mod]) { + byModule[mod] = { markers: 0, largeFiles: 0, longFunctions: 0 }; + } + }; + + for (const m of allMarkers) { + ensureModule(m.module); + byModule[m.module].markers++; + } + for (const f of allLargeFiles) { + ensureModule(f.module); + byModule[f.module].largeFiles++; + } + for (const f of allLongFunctions) { + ensureModule(f.module); + byModule[f.module].longFunctions++; + } + + const report = { + timestamp: new Date().toISOString(), + markers: allMarkers, + largeFiles: allLargeFiles, + longFunctions: allLongFunctions, + deepNesting: allDeepNesting, + summary: { + totalMarkers: allMarkers.length, + totalLargeFiles: allLargeFiles.length, + totalLongFunctions: allLongFunctions.length, + byModule, + }, + }; + + // Write cache + try { + await mkdir(cacheDir, { recursive: true }); + await writeFile(cachePath, JSON.stringify(report, null, 2), 'utf-8'); + } catch { + // Non-fatal: cache write failure is acceptable + } + + return report; +} diff --git a/tools/project-map/lib/file-watcher.mjs b/tools/project-map/lib/file-watcher.mjs new file mode 100644 index 00000000..a5832825 --- /dev/null +++ b/tools/project-map/lib/file-watcher.mjs @@ -0,0 +1,115 @@ +/** + * File watcher + SSE auto-refresh. + * Watches key directories and notifies connected SSE clients on changes. + */ +import { watch } from 'node:fs'; +import { resolve } from 'node:path'; + +export function createFileWatcher(root, onChangeCallback) { + const watchDirs = [ + resolve(root, 'Server'), + resolve(root, 'Client/tauri-client/src'), + resolve(root, 'Client/tauri-client/src-tauri/src'), + resolve(root, 'docs/brain'), + ]; + + // Debounce: only fire callback once per 2 seconds + let debounceTimer = null; + let destroyed = false; + const debounceMs = 2000; + + function handleChange(eventType, filename) { + if (destroyed) return; + + // Ignore non-source files + if (filename && ( + filename.includes('node_modules') || + filename.includes('.git') || + filename.includes('dist') || + filename.includes('target') || + filename.endsWith('.swp') || + filename.endsWith('~') + )) return; + + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (destroyed) return; + try { + onChangeCallback({ eventType, filename, timestamp: new Date().toISOString() }); + } catch (err) { + console.error('[Watch] callback error:', err.message); + } + }, debounceMs); + } + + const watchers = []; + for (const dir of watchDirs) { + try { + const w = watch(dir, { recursive: true }, handleChange); + watchers.push(w); + } catch { + // Directory might not exist — skip silently + } + } + + return { + close() { + destroyed = true; + if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } + for (const w of watchers) { + try { w.close(); } catch { /* ignore */ } + } + }, + }; +} + +/** + * SSE (Server-Sent Events) manager. + * Maintains a list of connected clients and broadcasts events to all. + */ +export function createSSEManager() { + const clients = new Set(); + + const heartbeatInterval = setInterval(() => { + for (const client of clients) { + try { client.write(': ping\n\n'); } catch { clients.delete(client); } + } + }, 30000); + + function handleConnection(req, res) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + + // Send initial connected event + res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() })}\n\n`); + + clients.add(res); + + req.on('close', () => { + clients.delete(res); + }); + } + + function broadcast(event) { + const data = `data: ${JSON.stringify(event)}\n\n`; + for (const client of clients) { + try { client.write(data); } catch { clients.delete(client); } + } + } + + function close() { + clearInterval(heartbeatInterval); + for (const client of clients) { + try { client.end(); } catch { /* ignore */ } + } + clients.clear(); + } + + // Safety net: clean up on process exit to prevent leaked intervals + process.on('exit', () => { clearInterval(heartbeatInterval); }); + + return { handleConnection, broadcast, close, get clientCount() { return clients.size; } }; +} diff --git a/tools/project-map/lib/git-scanner.mjs b/tools/project-map/lib/git-scanner.mjs new file mode 100644 index 00000000..89b999b9 --- /dev/null +++ b/tools/project-map/lib/git-scanner.mjs @@ -0,0 +1,341 @@ +/** + * Git history scanner — analyzes recent commits to provide per-module + * activity, file churn, staleness, velocity, and commit-type breakdown. + * + * Zero external dependencies — Node built-ins only. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const WINDOW_DAYS = 30; +const TOP_CHURN = 20; +const RECENT_LIMIT = 15; + +const COMMIT_TYPES = ['feat', 'fix', 'test', 'refactor', 'docs', 'chore', 'perf']; + +/** + * Map a file path to its logical module name. + * Server/{package}/ → package name (e.g. "ws", "db", "auth") + * Client/tauri-client/src/{area}/ → area name (e.g. "components", "lib") + * Client/tauri-client/src-tauri/src/ → "tauri-rust" + * + * NOTE: names are unprefixed to match scanner.mjs, backlog-parser.mjs, + * debt-scanner.mjs, and suggestion-engine.mjs canonical keys. + */ +function resolveModule(filePath) { + const normalized = filePath.replace(/\\/g, '/'); + + if (normalized.startsWith('Server/')) { + const parts = normalized.split('/'); + // parts[1] is a subdirectory only when there are 3+ segments + // e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root' + return parts.length >= 3 ? parts[1] : 'server-root'; + } + + if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) { + return 'tauri-rust'; + } + + if (normalized.startsWith('Client/tauri-client/src/')) { + const parts = normalized.replace('Client/tauri-client/src/', '').split('/'); + return parts.length >= 1 && parts[0] !== '' ? parts[0] : 'client-root'; + } + + if (normalized.startsWith('Client/tauri-client/')) { + return 'client-config'; + } + + if (normalized.startsWith('Client/')) { + return 'client-other'; + } + + // Root-level files (README, .gitignore, etc.) + return 'root'; +} + +/** + * Parse a conventional-commit prefix from a message. + * Returns one of the known types or "other". + */ +function parseCommitType(message) { + const match = message.match(/^(\w+)(?:\(.+?\))?[!]?:/); + if (!match) return 'other'; + const prefix = match[1].toLowerCase(); + return COMMIT_TYPES.includes(prefix) ? prefix : 'other'; +} + +/** + * Run a git command and return stdout as a string. + * Uses execFileSync with argument arrays to avoid shell injection. + */ +function git(args, root) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +/** + * Parse the raw git log output into structured commit objects. + * + * git log --format="%H|%ai|%s" --name-only produces: + * HASH|DATE|SUBJECT + * (blank line) + * file1 + * file2 + * HASH|DATE|SUBJECT + * (blank line) + * file1 + * ... + * + * We detect header lines by the HASH|DATE|SUBJECT pattern and + * accumulate file lines until the next header. + */ +function parseGitLog(raw) { + const commits = []; + const lines = raw.trim().split('\n'); + + let current = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '') continue; + + // Detect header: 40-char hex hash, then pipe, then date, then pipe, then subject + const pipeIdx1 = trimmed.indexOf('|'); + const pipeIdx2 = pipeIdx1 !== -1 ? trimmed.indexOf('|', pipeIdx1 + 1) : -1; + const isHeader = pipeIdx1 === 40 && pipeIdx2 !== -1; + + if (isHeader) { + // Finalize previous commit + if (current) { + current.modules = [...new Set(current.files.map(resolveModule))]; + commits.push(current); + } + + const hash = trimmed.slice(0, pipeIdx1); + const date = trimmed.slice(pipeIdx1 + 1, pipeIdx2); + const message = trimmed.slice(pipeIdx2 + 1); + const type = parseCommitType(message); + + current = { hash, date, message, type, files: [], modules: [] }; + } else if (current) { + current.files.push(trimmed); + } + } + + // Finalize last commit + if (current) { + current.modules = [...new Set(current.files.map(resolveModule))]; + commits.push(current); + } + + return commits; +} + +/** + * Build per-module commit data from the parsed commits. + */ +function buildCommitsByModule(commits) { + const byModule = {}; + + for (const commit of commits) { + for (const mod of commit.modules) { + if (!byModule[mod]) { + byModule[mod] = { count: 0, lastCommit: commit.date, commits: [] }; + } + byModule[mod].count += 1; + byModule[mod].commits.push({ + hash: commit.hash, + date: commit.date, + message: commit.message, + type: commit.type, + }); + // Keep the most recent date + if (commit.date > byModule[mod].lastCommit) { + byModule[mod].lastCommit = commit.date; + } + } + } + + return byModule; +} + +/** + * Compute file churn — number of commits touching each file. + * Returns top N most churned files. + */ +function buildFileChurn(commits) { + const churnMap = {}; + + for (const commit of commits) { + for (const file of commit.files) { + if (!churnMap[file]) { + churnMap[file] = { file, commits: 0, module: resolveModule(file) }; + } + churnMap[file].commits += 1; + } + } + + return Object.values(churnMap) + .sort((a, b) => b.commits - a.commits) + .slice(0, TOP_CHURN); +} + +/** + * Calculate staleness — days since last commit per module. + */ +function buildStaleness(commitsByModule) { + const now = Date.now(); + const staleness = {}; + + for (const [mod, data] of Object.entries(commitsByModule)) { + const lastDate = new Date(data.lastCommit); + const daysSince = Math.floor((now - lastDate.getTime()) / (1000 * 60 * 60 * 24)); + staleness[mod] = { + daysSinceLastCommit: daysSince, + lastCommitDate: data.lastCommit, + }; + } + + return staleness; +} + +/** + * Build daily velocity and rolling 7-day average. + */ +function buildVelocity(commits) { + // Build a map of date → commit count + const dailyMap = {}; + const now = new Date(); + + for (let i = 0; i < WINDOW_DAYS; i++) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + dailyMap[key] = 0; + } + + for (const commit of commits) { + const key = commit.date.slice(0, 10); + if (key in dailyMap) { + dailyMap[key] += 1; + } + } + + const daily = Object.entries(dailyMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); + + // Rolling 7-day average (use last 7 days) + const last7 = daily.slice(-7); + const weeklyAvg = last7.length > 0 + ? Math.round((last7.reduce((s, d) => s + d.count, 0) / last7.length) * 100) / 100 + : 0; + + // Trend: compare first half vs second half of the window + const mid = Math.floor(daily.length / 2); + const firstHalf = daily.slice(0, mid); + const secondHalf = daily.slice(mid); + + const avgFirst = firstHalf.length > 0 + ? firstHalf.reduce((s, d) => s + d.count, 0) / firstHalf.length + : 0; + const avgSecond = secondHalf.length > 0 + ? secondHalf.reduce((s, d) => s + d.count, 0) / secondHalf.length + : 0; + + let trend = 'stable'; + const delta = avgSecond - avgFirst; + if (delta > 0.3) trend = 'accelerating'; + else if (delta < -0.3) trend = 'decelerating'; + + return { daily, weeklyAvg, trend }; +} + +/** + * Build commit type breakdown. + */ +function buildCommitTypes(commits) { + const types = { feat: 0, fix: 0, test: 0, refactor: 0, docs: 0, chore: 0, other: 0 }; + + for (const commit of commits) { + const bucket = commit.type in types ? commit.type : 'other'; + types[bucket] += 1; + } + + return types; +} + +/** + * Main entry point. Scans git history for the last 30 days and returns + * structured data about module activity, churn, staleness, and velocity. + * + * @param {string} root - Repository root directory + * @param {string} cacheDir - Directory to store cached results + * @param {boolean} quick - If true and cache exists, return cached data + * @returns {Promise} Structured git history data + */ +// Exported for testing +export { resolveModule }; + +export async function scanGitHistory(root, cacheDir, quick = false) { + const cacheFile = join(cacheDir, 'git-data.json'); + + // Quick mode: return cache if available + if (quick && existsSync(cacheFile)) { + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + return cached; + } catch { + // Cache corrupt — fall through to fresh scan + } + } + + // Fetch git log with file names + const raw = git( + ['log', `--since=${WINDOW_DAYS} days ago`, '--format=%H|%ai|%s', '--name-only'], + root, + ); + + const commits = parseGitLog(raw); + const commitsByModule = buildCommitsByModule(commits); + const fileChurn = buildFileChurn(commits); + const staleness = buildStaleness(commitsByModule); + const velocity = buildVelocity(commits); + const commitTypes = buildCommitTypes(commits); + + const recentCommits = commits.slice(0, RECENT_LIMIT).map(c => ({ + hash: c.hash, + date: c.date, + message: c.message, + type: c.type, + modules: c.modules, + })); + + const result = { + timestamp: new Date().toISOString(), + commitsByModule, + fileChurn, + staleness, + velocity, + commitTypes, + recentCommits, + totalCommits30d: commits.length, + }; + + // Persist cache + try { + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8'); + } catch { + // Non-fatal — scanning still succeeds without cache write + } + + return result; +} diff --git a/tools/project-map/lib/go-coverage.mjs b/tools/project-map/lib/go-coverage.mjs new file mode 100644 index 00000000..c890cabe --- /dev/null +++ b/tools/project-map/lib/go-coverage.mjs @@ -0,0 +1,131 @@ +/** + * Go test coverage collector. + * Runs `go test ./... -cover -short -json` and parses per-package coverage. + * Caches results to .cache/go-coverage.json. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const CACHE_FILE = 'go-coverage.json'; + +// Extract short package name from full Go module path +// e.g. "github.com/owncord/server/admin" -> "admin" +// e.g. "github.com/owncord/server" -> "root" +function extractPkgName(fullPath) { + const parts = fullPath.split('/'); + const last = parts[parts.length - 1]; + // If the package ends with "server" it's the root package + if (last === 'server') return 'server-root'; + return last; +} + +function parseGoCoverage(jsonLines) { + const coverage = {}; + + for (const line of jsonLines.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + // Look for coverage output lines + if (entry.Action === 'output' && entry.Output) { + // Match: "coverage: 67.3% of statements" + const coverMatch = entry.Output.match(/coverage:\s+([\d.]+)%\s+of\s+statements/); + if (coverMatch && entry.Package) { + const pkg = extractPkgName(entry.Package); + coverage[pkg] = { + percentage: parseFloat(coverMatch[1]), + package: pkg, + }; + } + // Match: "[no test files]" + if (entry.Output.includes('[no test files]') && entry.Package) { + const pkg = extractPkgName(entry.Package); + coverage[pkg] = { + percentage: null, + package: pkg, + noTests: true, + }; + } + } + // Also check for pass/fail + if (entry.Action === 'pass' && entry.Package) { + const pkg = extractPkgName(entry.Package); + if (!coverage[pkg]) { + coverage[pkg] = { percentage: 0, package: pkg }; + } + coverage[pkg].passed = true; + } + if (entry.Action === 'fail' && entry.Package) { + const pkg = extractPkgName(entry.Package); + if (!coverage[pkg]) { + coverage[pkg] = { percentage: 0, package: pkg }; + } + coverage[pkg].failed = true; + } + } catch { /* skip non-JSON lines */ } + } + + return coverage; +} + +export async function collectGoCoverage(root, cacheDir, quick) { + const cacheFile = resolve(cacheDir, CACHE_FILE); + + if (quick && existsSync(cacheFile)) { + console.log(' [Go] Using cached coverage data'); + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + if (cached.hasFailures) { + console.warn(' [Go Coverage] Cached data has test failures — re-running tests'); + // Fall through to full collection instead of returning cached + } else { + return cached; + } + } catch { + console.warn(' [Go] Corrupt cache — re-running tests'); + } + } + + const serverDir = resolve(root, 'Server'); + if (!existsSync(serverDir)) { + console.log(' [Go] Server directory not found, skipping'); + return {}; + } + + console.log(' [Go] Running tests with coverage (this may take a minute)...'); + try { + const output = execFileSync('go', ['test', './...', '-cover', '-short', '-json', '-count=1'], { + cwd: serverDir, + encoding: 'utf8', + timeout: 300_000, // 5 min + maxBuffer: 10 * 1024 * 1024, + }); + + const coverage = parseGoCoverage(output); + + // Cache results + const result = { + timestamp: new Date().toISOString(), + packages: coverage, + }; + writeFileSync(cacheFile, JSON.stringify(result, null, 2)); + console.log(` [Go] Coverage collected for ${Object.keys(coverage).length} packages`); + return result; + } catch (err) { + // go test returns non-zero on test failure but still produces output + if (err.stdout) { + const coverage = parseGoCoverage(err.stdout); + const result = { + timestamp: new Date().toISOString(), + packages: coverage, + hasFailures: true, + }; + writeFileSync(cacheFile, JSON.stringify(result, null, 2)); + console.log(` [Go] Coverage collected (some tests failed)`); + return result; + } + console.error(` [Go] Failed to collect coverage: ${err.message}`); + return { timestamp: new Date().toISOString(), packages: {}, error: err.message }; + } +} diff --git a/tools/project-map/lib/import-graph.mjs b/tools/project-map/lib/import-graph.mjs new file mode 100644 index 00000000..e6d810a5 --- /dev/null +++ b/tools/project-map/lib/import-graph.mjs @@ -0,0 +1,472 @@ +/** + * Import-graph builder — parses Go, TypeScript, and Rust source files to + * construct a dependency graph with fan-in/fan-out metrics and cycle detection. + * Zero external dependencies — Node built-ins only. + */ +import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve, basename, relative } from 'node:path'; + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +/** + * Recursively collect files matching `extensions` under `dir`, skipping + * common non-source directories. + */ +function collectFiles(dir, extensions, skipSuffix = []) { + const results = []; + if (!existsSync(dir)) return results; + + function walk(d) { + let entries; + try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || ['node_modules', 'vendor', 'dist', 'target'].includes(entry.name)) continue; + const full = join(d, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() + && extensions.some(ext => entry.name.endsWith(ext)) + && !skipSuffix.some(suf => entry.name.endsWith(suf))) { + results.push(full); + } + } + } + + walk(dir); + return results; +} + +// --------------------------------------------------------------------------- +// Go import parsing +// --------------------------------------------------------------------------- + +const GO_MODULE_PREFIX = 'github.com/owncord/server/'; + +/** + * Parse a single Go file and return the set of internal package short-names + * it imports (e.g. "db", "auth", "ws"). + */ +function parseGoImports(content) { + const deps = new Set(); + // Match import blocks: import ( ... ) + const blockRe = /import\s*\(([^)]*)\)/gs; + let blockMatch; + while ((blockMatch = blockRe.exec(content)) !== null) { + const block = blockMatch[1]; + const lineRe = /["']([^"']+)["']/g; + let lineMatch; + while ((lineMatch = lineRe.exec(block)) !== null) { + const path = lineMatch[1]; + if (path.startsWith(GO_MODULE_PREFIX)) { + deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]); + } + } + } + // Match single-line imports: import "..." + const singleRe = /import\s+["']([^"']+)["']/g; + let singleMatch; + while ((singleMatch = singleRe.exec(content)) !== null) { + const path = singleMatch[1]; + if (path.startsWith(GO_MODULE_PREFIX)) { + deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]); + } + } + return deps; +} + +/** + * Scan Go source files under `serverDir` and return a map of + * package-name -> Set. + */ +function scanGoImports(serverDir) { + const graph = new Map(); + const files = collectFiles(serverDir, ['.go'], ['_test.go']); + + for (const file of files) { + // Determine package from directory name relative to server root + const rel = relative(serverDir, file); + const pkg = rel.includes('/') || rel.includes('\\') + ? rel.split(/[/\\]/)[0] + : '.'; // root-level files (main.go etc.) + + if (pkg === '.' || pkg === 'scripts' || pkg === 'migrations') continue; + + if (!graph.has(pkg)) graph.set(pkg, new Set()); + + let content; + try { content = readFileSync(file, 'utf8'); } catch { continue; } + + const deps = parseGoImports(content); + const existing = graph.get(pkg); + for (const dep of deps) { + if (dep !== pkg) existing.add(dep); + } + } + + return graph; +} + +// --------------------------------------------------------------------------- +// TypeScript import parsing +// --------------------------------------------------------------------------- + +/** Known TS path-alias prefixes that map to source directories. */ +const TS_ALIAS_MAP = { + '@lib': 'lib', + '@stores': 'stores', + '@components': 'components', + '@pages': 'pages', + '@styles': 'styles', + '@types': 'types', +}; + +/** + * Parse a TypeScript file and return the set of internal module names it + * imports (e.g. "lib", "stores", "components"). + */ +function parseTsImports(content) { + const deps = new Set(); + + // Match: import ... from '...' / import '...' (including type imports) + const re = /import\s+(?:type\s+)?(?:[^'"]*from\s+)?['"]([^'"]+)['"]/g; + let m; + while ((m = re.exec(content)) !== null) { + const specifier = m[1]; + + // Check @alias paths: @lib/foo -> "lib" + for (const [alias, dir] of Object.entries(TS_ALIAS_MAP)) { + if (specifier.startsWith(alias + '/') || specifier === alias) { + deps.add(dir); + break; + } + } + + // Check relative paths: ../lib/foo -> "lib", ./sibling stays in same dir + if (specifier.startsWith('../')) { + const segment = specifier.slice(3).split('/')[0]; + if (segment && Object.values(TS_ALIAS_MAP).includes(segment)) { + deps.add(segment); + } + } + } + + return deps; +} + +/** + * Determine which TS "module" a file belongs to based on its directory. + * Files directly in src/ are grouped as "main". + */ +function tsModuleName(file, srcDir) { + const rel = relative(srcDir, file).replace(/\\/g, '/'); + const parts = rel.split('/'); + if (parts.length === 1) return basename(parts[0], '.ts'); // top-level file -> its name + return parts[0]; // first directory segment +} + +/** + * Scan TypeScript files under `srcDir` and return a dependency map. + */ +function scanTsImports(srcDir) { + const graph = new Map(); + const files = collectFiles(srcDir, ['.ts']); + + for (const file of files) { + const mod = tsModuleName(file, srcDir); + if (!graph.has(mod)) graph.set(mod, new Set()); + + let content; + try { content = readFileSync(file, 'utf8'); } catch { continue; } + + const deps = parseTsImports(content); + const existing = graph.get(mod); + for (const dep of deps) { + if (dep !== mod) existing.add(dep); + } + } + + return graph; +} + +// --------------------------------------------------------------------------- +// Rust import parsing +// --------------------------------------------------------------------------- + +/** + * Parse a Rust file and return module names referenced via `mod` declarations + * and `use crate::` paths. + */ +function parseRustImports(content) { + const deps = new Set(); + + // mod declarations: mod foo; + const modRe = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm; + let m; + while ((m = modRe.exec(content)) !== null) { + deps.add(m[1]); + } + + // use crate::foo (possibly ::bar::baz) + const useRe = /use\s+crate::(\w+)/g; + while ((m = useRe.exec(content)) !== null) { + deps.add(m[1]); + } + + return deps; +} + +/** + * Determine Rust module name from filename. + * lib.rs and main.rs -> "lib" / "main"; others -> stem. + */ +function rustModuleName(file) { + const name = basename(file, '.rs'); + return name; +} + +/** + * Scan Rust files and return a dependency map. + */ +function scanRustImports(rustDir) { + const graph = new Map(); + const files = collectFiles(rustDir, ['.rs']); + + for (const file of files) { + const mod = rustModuleName(file); + if (!graph.has(mod)) graph.set(mod, new Set()); + + let content; + try { content = readFileSync(file, 'utf8'); } catch { continue; } + + const deps = parseRustImports(content); + const existing = graph.get(mod); + for (const dep of deps) { + if (dep !== mod) existing.add(dep); + } + } + + return graph; +} + +// --------------------------------------------------------------------------- +// Graph analysis +// --------------------------------------------------------------------------- + +/** + * Merge multiple per-language graphs into a single adjacency list (object). + * Each value is a Map>; we also track which language + * each module belongs to. + */ +function mergeGraphs(goGraph, tsGraph, rustGraph) { + const merged = {}; // module -> string[] + const types = {}; // module -> 'go' | 'typescript' | 'rust' + + for (const [mod, deps] of goGraph) { + const key = `go/${mod}`; + merged[key] = [...deps].map(d => `go/${d}`); + types[key] = 'go'; + } + for (const [mod, deps] of tsGraph) { + const key = `ts/${mod}`; + merged[key] = [...deps].map(d => `ts/${d}`); + types[key] = 'typescript'; + } + for (const [mod, deps] of rustGraph) { + const key = `rs/${mod}`; + merged[key] = [...deps].map(d => `rs/${d}`); + types[key] = 'rust'; + } + + return { merged, types }; +} + +/** + * Compute fan-in (how many modules depend on this one) and fan-out (how many + * modules this one depends on). + */ +function computeMetrics(graph) { + const fanIn = {}; + const fanOut = {}; + + for (const mod of Object.keys(graph)) { + fanOut[mod] = graph[mod].length; + if (!(mod in fanIn)) fanIn[mod] = 0; + for (const dep of graph[mod]) { + fanIn[dep] = (fanIn[dep] || 0) + 1; + } + } + + return { fanIn, fanOut }; +} + +/** + * Detect all cycles in the directed graph using iterative DFS with an + * explicit stack to avoid call-stack overflow on large graphs. + * Returns an array of cycles, each cycle being an array of module names. + */ +function detectCycles(graph) { + const WHITE = 0; // unvisited + const GRAY = 1; // in current path + const BLACK = 2; // fully explored + + const color = {}; + for (const node of Object.keys(graph)) { + color[node] = WHITE; + } + + const cycles = []; + + for (const start of Object.keys(graph)) { + if (color[start] !== WHITE) continue; + + // Stack entries: [node, neighborIndex, pathSoFar] + const stack = [[start, 0, [start]]]; + color[start] = GRAY; + + while (stack.length > 0) { + const top = stack[stack.length - 1]; + const node = top[0]; + const neighbors = graph[node] || []; + + if (top[1] >= neighbors.length) { + // All neighbors explored — backtrack + color[node] = BLACK; + stack.pop(); + continue; + } + + const neighbor = neighbors[top[1]]; + top[1]++; + + if (color[neighbor] === GRAY) { + // Found a cycle — extract the cycle portion from the current path + const fullPath = [...top[2], neighbor]; + const cycleStart = fullPath.indexOf(neighbor); + if (cycleStart !== -1 && cycleStart < fullPath.length - 1) { + cycles.push(fullPath.slice(cycleStart)); + } + } else if (color[neighbor] === WHITE) { + color[neighbor] = GRAY; + stack.push([neighbor, 0, [...top[2], neighbor]]); + } + } + } + + return cycles; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Build the full import graph for the OwnCord project. + * + * @param {string} root — project root directory + * @param {string} cacheDir — directory for caching results + * @param {boolean} quick — if true, return cached results when available + * @returns {Promise} — import graph data + */ +export async function buildImportGraph(root, cacheDir, quick) { + const cacheFile = join(cacheDir, 'import-graph.json'); + + // Quick mode: return cache if it exists + if (quick && existsSync(cacheFile)) { + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + return cached; + } catch { + // Cache corrupt — rebuild + } + } + + // Scan each language + const serverDir = resolve(root, 'Server'); + const tsSrcDir = resolve(root, 'Client', 'tauri-client', 'src'); + const rustDir = resolve(root, 'Client', 'tauri-client', 'src-tauri', 'src'); + + const goGraph = scanGoImports(serverDir); + const tsGraph = scanTsImports(tsSrcDir); + const rustGraph = scanRustImports(rustDir); + + // Merge into unified adjacency list + const { merged, types } = mergeGraphs(goGraph, tsGraph, rustGraph); + + // Compute metrics + const { fanIn, fanOut } = computeMetrics(merged); + + // Build nodes array + const nodes = Object.keys(merged).map(mod => { + const fi = fanIn[mod] || 0; + const fo = fanOut[mod] || 0; + return { + name: mod, + type: types[mod] || 'go', + fanIn: fi, + fanOut: fo, + coupling: fi * fo, + }; + }); + + // Also include nodes that appear only as dependencies (no outgoing edges) + for (const mod of Object.keys(fanIn)) { + if (!(mod in merged)) { + const fi = fanIn[mod] || 0; + nodes.push({ + name: mod, + type: types[mod] || inferType(mod), + fanIn: fi, + fanOut: 0, + coupling: 0, + }); + } + } + + // Build edges array + const edges = []; + for (const [from, deps] of Object.entries(merged)) { + for (const to of deps) { + edges.push({ from, to }); + } + } + + // Detect cycles + const cycles = detectCycles(merged); + + // Top 5 most coupled + const mostCoupled = [...nodes] + .sort((a, b) => b.coupling - a.coupling) + .slice(0, 5) + .map(({ name, coupling, fanIn, fanOut }) => ({ name, coupling, fanIn, fanOut })); + + const result = { + timestamp: new Date().toISOString(), + graph: merged, + nodes, + edges, + cycles, + mostCoupled, + }; + + // Write cache + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8'); + } catch { + // Non-fatal — proceed without caching + } + + return result; +} + +/** + * Infer language type from a prefixed module name. + */ +function inferType(mod) { + if (mod.startsWith('go/')) return 'go'; + if (mod.startsWith('ts/')) return 'typescript'; + if (mod.startsWith('rs/')) return 'rust'; + return 'go'; +} diff --git a/tools/project-map/lib/morning-briefing.mjs b/tools/project-map/lib/morning-briefing.mjs new file mode 100644 index 00000000..0ab209eb --- /dev/null +++ b/tools/project-map/lib/morning-briefing.mjs @@ -0,0 +1,190 @@ +/** + * Morning Briefing — digest of overnight agent results + session suggestions. + * Composes data from session-parser, agent-manager, suggestion-engine, and backlog-parser. + */ +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { resolve, join } from 'node:path'; + +/** + * Generate a morning briefing digest. + * Shows what happened since the last session (agent results, coverage changes, etc.) + * and suggests what to work on next. + */ +export async function generateBriefing(root, cacheDir, { + sessionData = null, + suggestions = null, + backlog = null, + agentJobs = null, +} = {}) { + const briefing = { + timestamp: new Date().toISOString(), + greeting: '', + agentResults: [], + deadJobs: [], + coverageChanges: [], + suggestedTasks: [], + autoQueueSuggestions: [], + stats: { completedJobs: 0, failedJobs: 0, deadJobs: 0 }, + }; + + // Determine last session date for "since last session" filtering + const lastSessionDate = sessionData?.lastSession?.date + ? new Date(sessionData.lastSession.date) + : null; + + // Greeting + if (lastSessionDate) { + const daysSince = Math.floor((Date.now() - lastSessionDate.getTime()) / 86400000); + if (daysSince === 0) { + briefing.greeting = 'Welcome back! You had a session earlier today.'; + } else if (daysSince === 1) { + briefing.greeting = 'Good morning! Last session was yesterday.'; + } else { + briefing.greeting = `Welcome back! It's been ${daysSince} days since your last session.`; + } + } else { + briefing.greeting = 'Welcome! This is your first time opening the briefing.'; + } + + // Agent results since last session + if (agentJobs?.jobs) { + for (const job of agentJobs.jobs) { + if (job.status === 'completed') { + const completedAt = job.completedAt ? new Date(job.completedAt) : null; + if (!lastSessionDate || (completedAt && completedAt > lastSessionDate)) { + // Read result summary (first 500 chars) + let summary = ''; + const resultPath = resolve(cacheDir, `agent-results/${job.id}.md`); + if (existsSync(resultPath)) { + try { + const content = readFileSync(resultPath, 'utf8'); + summary = content.slice(0, 500).split('\n').slice(0, 10).join('\n'); + if (content.length > 500) summary += '\n...'; + } catch { /* skip */ } + } + briefing.agentResults.push({ + id: job.id, + type: job.type, + target: job.target, + completedAt: job.completedAt, + summary, + }); + briefing.stats.completedJobs++; + } + } + + if (job.status === 'failed') briefing.stats.failedJobs++; + + if (job.status === 'dead') { + briefing.deadJobs.push({ + id: job.id, + type: job.type, + target: job.target, + error: job.error, + retryCount: job.retryCount, + }); + briefing.stats.deadJobs++; + } + } + } + + // Top suggested tasks from suggestion engine + if (suggestions?.suggestions) { + briefing.suggestedTasks = suggestions.suggestions.slice(0, 5).map(s => ({ + module: s.module, + score: s.score, + rationale: s.rationale, + breakdown: s.breakdown, + })); + } + + // Auto-queue suggestions — only when agent jobs context is available + briefing.autoQueueSuggestions = agentJobs + ? generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) + : []; + + return briefing; +} + +/** + * Generate smart auto-queue suggestions. + * Recommends agent jobs based on current project state. + */ +function generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) { + const existing = new Set( + (agentJobs?.jobs || []) + .filter(j => ['queued', 'running', 'completed'].includes(j.status)) + .map(j => `${j.type}:${j.target}`) + ); + + const suggestions_list = []; + + if (!suggestions?.suggestions) return suggestions_list; + + for (const s of suggestions.suggestions.slice(0, 10)) { + // Coverage gap → suggest write-tests + if (s.breakdown?.coverage > 20) { + const key = `write-tests:${s.module}`; + if (!existing.has(key)) { + suggestions_list.push({ + type: 'write-tests', + target: s.module, + rationale: `Coverage gap on ${s.module} (score: ${s.breakdown.coverage})`, + priority: 2, + }); + } + } + + // High debt → suggest fix-debt + if (s.breakdown?.debt > 15) { + const key = `fix-debt:${s.module}`; + if (!existing.has(key)) { + suggestions_list.push({ + type: 'fix-debt', + target: s.module, + rationale: `Tech debt markers in ${s.module} (score: ${s.breakdown.debt})`, + priority: 3, + }); + } + } + + // Open bugs → suggest code-review + if (s.breakdown?.bugs > 10) { + const key = `code-review:${s.module}`; + if (!existing.has(key)) { + suggestions_list.push({ + type: 'code-review', + target: s.module, + rationale: `Open bugs/review items in ${s.module} (score: ${s.breakdown.bugs})`, + priority: 1, + }); + } + } + } + + // Stale modules → suggest research (limit to top 2) + const staleModules = suggestions.suggestions + .filter(s => s.signals?.some(sig => sig.signal === 'stale')) + .slice(0, 2); + + for (const s of staleModules) { + const key = `research:${s.module}`; + if (!existing.has(key)) { + suggestions_list.push({ + type: 'research', + target: s.module, + rationale: `Module ${s.module} has been stale — investigate status`, + priority: 4, + }); + } + } + + // Deduplicate and limit to 5 + const seen = new Set(); + return suggestions_list.filter(s => { + const key = `${s.type}:${s.target}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }).slice(0, 5); +} diff --git a/tools/project-map/lib/priority-engine.mjs b/tools/project-map/lib/priority-engine.mjs new file mode 100644 index 00000000..96c85b0b --- /dev/null +++ b/tools/project-map/lib/priority-engine.mjs @@ -0,0 +1,180 @@ +/** + * Priority scoring engine. + * Ranks modules by where work is most needed based on: + * - Coverage gap (distance from 80% target) + * - Module size (larger modules = higher impact) + * - Open bug/task count + * - Roadmap position (earlier phases = higher priority) + */ + +const COVERAGE_TARGET = 80; + +const PHASE_WEIGHTS = { + 'bug': 10, + 'high': 8, + 'code-review': 7, + 'medium': 5, + 'deferred': 3, + 'roadmap-r1': 4, + 'roadmap-r2': 3, + 'roadmap-r3': 2, + 'roadmap-r4': 1.5, + 'roadmap-r5': 1, + 'roadmap-r6': 0.5, + 'other': 2, +}; + +function coverageGapScore(coveragePct) { + if (coveragePct === null || coveragePct === undefined) return 50; // no tests = high gap + const gap = Math.max(0, COVERAGE_TARGET - coveragePct); + return gap * 1.5; // 1.5 points per percent below target +} + +function sizeScore(sourceFiles, sourceLines) { + // Larger modules are higher impact + return Math.min(30, sourceFiles * 2 + (sourceLines / 200)); +} + +function taskScore(openTasks) { + if (!openTasks || openTasks.length === 0) return 0; + let score = 0; + for (const task of openTasks) { + score += PHASE_WEIGHTS[task.phase] || 2; + } + return score; +} + +export function scorePriorities(modules, goCoverage, vitestCoverage, backlog) { + const scores = []; + + // Score Go packages + for (const pkg of modules.go) { + const covData = goCoverage.packages?.[pkg.name]; + const coverage = covData?.percentage ?? null; + const openTasks = backlog.byModule[pkg.name] || []; + + const cScore = coverageGapScore(coverage); + const sScore = sizeScore(pkg.sourceFiles, pkg.sourceLines); + const tScore = taskScore(openTasks); + const total = cScore + sScore + tScore; + + scores.push({ + name: pkg.name, + type: 'go', + path: pkg.path, + coverage, + coverageGap: cScore, + sizeImpact: sScore, + taskWeight: tScore, + totalScore: total, + openTaskCount: openTasks.length, + openTasks: openTasks.map(t => t.id), + recommendation: getRecommendation(coverage, openTasks, pkg), + }); + } + + // Score TypeScript areas + const tsAreas = vitestCoverage.areas || {}; + for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { + const areaCov = tsAreas[dir.name]; + const coverage = areaCov?.statements ?? (tsAreas._total?.statements ?? null); + const openTasks = backlog.byModule[dir.name] || []; + + const cScore = coverageGapScore(coverage); + const sScore = sizeScore(dir.sourceFiles, dir.sourceLines); + const tScore = taskScore(openTasks); + const total = cScore + sScore + tScore; + + scores.push({ + name: dir.name, + type: 'typescript', + path: dir.path, + coverage, + coverageGap: cScore, + sizeImpact: sScore, + taskWeight: tScore, + totalScore: total, + openTaskCount: openTasks.length, + openTasks: openTasks.map(t => t.id), + recommendation: getRecommendation(coverage, openTasks, dir), + }); + } + + // Score Rust + for (const rust of modules.rust) { + const openTasks = backlog.byModule['tauri-rust'] || []; + const cScore = coverageGapScore(null); // no Rust tests + const sScore = sizeScore(rust.sourceFiles, rust.sourceLines); + const tScore = taskScore(openTasks); + const total = cScore + sScore + tScore; + + scores.push({ + name: rust.name, + type: 'rust', + path: rust.path, + coverage: null, + coverageGap: cScore, + sizeImpact: sScore, + taskWeight: tScore, + totalScore: total, + openTaskCount: openTasks.length, + openTasks: openTasks.map(t => t.id), + recommendation: 'Add Rust unit tests — currently zero test coverage', + }); + } + + // Score cross-cutting areas from backlog + const crossCutting = ['livekit', 'gaming', 'community', 'platform', 'ai', 'protocol']; + for (const area of crossCutting) { + const openTasks = backlog.byModule[area] || []; + if (openTasks.length === 0) continue; + + const tScore = taskScore(openTasks); + scores.push({ + name: area, + type: 'feature-area', + path: '', + coverage: null, + coverageGap: 0, + sizeImpact: 0, + taskWeight: tScore, + totalScore: tScore, + openTaskCount: openTasks.length, + openTasks: openTasks.map(t => t.id), + recommendation: `${openTasks.length} open task(s) in backlog`, + }); + } + + // Sort by total score descending + scores.sort((a, b) => b.totalScore - a.totalScore); + + return scores; +} + +function getRecommendation(coverage, openTasks, module) { + const parts = []; + + if (coverage === null) { + parts.push('No test coverage'); + } else if (coverage < 60) { + parts.push(`Coverage critically low (${coverage.toFixed(1)}%)`); + } else if (coverage < COVERAGE_TARGET) { + parts.push(`Coverage below target (${coverage.toFixed(1)}% < ${COVERAGE_TARGET}%)`); + } + + if (openTasks.length > 0) { + const bugs = openTasks.filter(t => t.phase === 'bug'); + const reviews = openTasks.filter(t => t.phase === 'code-review'); + if (bugs.length > 0) parts.push(`${bugs.length} open bug(s)`); + if (reviews.length > 0) parts.push(`${reviews.length} code review fix(es)`); + if (parts.length === 0 || (bugs.length === 0 && reviews.length === 0)) { + parts.push(`${openTasks.length} open task(s)`); + } + } + + if (module.sourceFiles > 15) { + parts.push('Large module — high impact'); + } + + return parts.length > 0 ? parts.join('; ') : 'Good shape'; +} diff --git a/tools/project-map/lib/report-generator.mjs b/tools/project-map/lib/report-generator.mjs new file mode 100644 index 00000000..4331dd5c --- /dev/null +++ b/tools/project-map/lib/report-generator.mjs @@ -0,0 +1,162 @@ +/** + * Markdown report generator. + * Writes the full project map to docs/brain/00-Overview/Project-Map.md. + */ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +function badge(coverage) { + if (coverage === null || coverage === undefined) return '---'; + return `${coverage.toFixed(1)}%`; +} + +function statusIcon(coverage) { + if (coverage === null || coverage === undefined) return 'No tests'; + if (coverage >= 80) return 'Above target'; + if (coverage >= 70) return 'Near target'; + if (coverage >= 50) return 'Below target'; + return 'Critical'; +} + +export async function generateReport(reportPath, modules, goCoverage, vitestCoverage, backlog, priorities) { + mkdirSync(dirname(reportPath), { recursive: true }); + + const now = new Date().toISOString().replace('T', ' ').slice(0, 19); + const lines = []; + + lines.push('# OwnCord Project Map'); + lines.push(''); + lines.push(`> Auto-generated on ${now} by \`tools/project-map\``); + lines.push(`> Run \`node tools/project-map/index.mjs\` to regenerate`); + lines.push(''); + + // --- Summary --- + lines.push('## Summary'); + lines.push(''); + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Go packages | ${modules.summary.goPackages} |`); + lines.push(`| Go source files | ${modules.summary.goSourceFiles} |`); + lines.push(`| Go test files | ${modules.summary.goTestFiles} |`); + lines.push(`| TypeScript source files | ${modules.summary.tsSourceFiles} |`); + lines.push(`| TypeScript test files | ${modules.summary.tsTestFiles} |`); + lines.push(`| Rust source files | ${modules.summary.rustSourceFiles} |`); + lines.push(`| Rust test files | ${modules.summary.rustTestFiles} |`); + lines.push(`| Open backlog tasks | ${backlog.openCount} |`); + lines.push(`| Completed tasks | ${backlog.doneCount} |`); + lines.push(`| Completion rate | ${backlog.doneCount > 0 ? ((backlog.doneCount / (backlog.openCount + backlog.doneCount)) * 100).toFixed(1) : 0}% |`); + lines.push(''); + + // --- Server Coverage --- + lines.push('## Server (Go) — Test Coverage'); + lines.push(''); + lines.push('| Package | Source Files | Test Files | Coverage | Status |'); + lines.push('|---------|-------------|------------|----------|--------|'); + + for (const pkg of modules.go) { + const covData = goCoverage.packages?.[pkg.name]; + const cov = covData?.percentage ?? null; + const status = covData?.noTests ? 'No tests' : statusIcon(cov); + const failed = covData?.failed ? ' (FAILING)' : ''; + lines.push(`| \`${pkg.name}/\` | ${pkg.sourceFiles} | ${pkg.testFiles} | ${badge(cov)} | ${status}${failed} |`); + } + lines.push(''); + + // --- Client Coverage --- + lines.push('## Client (TypeScript) — Test Coverage'); + lines.push(''); + const tsAreas = vitestCoverage.areas || {}; + const totalCov = tsAreas._total; + if (totalCov) { + lines.push(`**Overall:** ${totalCov.statements.toFixed(1)}% statements, ${totalCov.branches.toFixed(1)}% branches, ${totalCov.functions.toFixed(1)}% functions`); + lines.push(''); + } + if (vitestCoverage.testCount) { + lines.push(`**Total tests:** ${vitestCoverage.testCount}`); + lines.push(''); + } + + lines.push('| Area | Source Files | Coverage (stmts) | Status |'); + lines.push('|------|-------------|------------------|--------|'); + for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { + const areaCov = tsAreas[dir.name]; + const cov = areaCov?.statements ?? null; + lines.push(`| \`${dir.name}/\` | ${dir.sourceFiles} | ${badge(cov)} | ${statusIcon(cov)} |`); + } + lines.push(''); + + // Test file counts + lines.push('| Test Suite | Files |'); + lines.push('|-----------|-------|'); + for (const dir of modules.typescript.filter(d => d.type !== 'typescript')) { + lines.push(`| \`${dir.name}\` | ${dir.testFiles} |`); + } + lines.push(''); + + // --- Rust --- + lines.push('## Client (Rust/Tauri) — Status'); + lines.push(''); + for (const rust of modules.rust) { + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Source files | ${rust.sourceFiles} |`); + lines.push(`| Lines of code | ${rust.sourceLines} |`); + lines.push(`| Test files | ${rust.testFiles} |`); + lines.push(`| Coverage | No test infrastructure |`); + } + lines.push(''); + + // --- Backlog by Phase --- + lines.push('## Open Work — By Phase'); + lines.push(''); + const phaseOrder = ['bug', 'high', 'code-review', 'medium', 'deferred', 'roadmap-r1', 'roadmap-r2', 'roadmap-r3', 'roadmap-r4', 'roadmap-r5', 'roadmap-r6', 'other']; + const phaseLabels = { + 'bug': 'Bugs', 'high': 'High Priority', 'code-review': 'Code Review', + 'medium': 'Medium Priority', 'deferred': 'Deferred Features', + 'roadmap-r1': 'R1: Community Essentials', 'roadmap-r2': 'R2: Gaming DNA', + 'roadmap-r3': 'R3: Voice Power', 'roadmap-r4': 'R4: LAN Party Toolkit', + 'roadmap-r5': 'R5: Platform & Extensibility', 'roadmap-r6': 'R6: Future Vision', + 'other': 'Other', + }; + + for (const phase of phaseOrder) { + const tasks = backlog.byPhase[phase]; + if (!tasks || tasks.length === 0) continue; + lines.push(`### ${phaseLabels[phase] || phase} (${tasks.length})`); + lines.push(''); + for (const task of tasks) { + lines.push(`- **${task.id}:** ${task.description}`); + } + lines.push(''); + } + + // --- Where to Work Next --- + lines.push('## Where to Work Next'); + lines.push(''); + lines.push('Ranked by priority score (coverage gap + module size + open tasks):'); + lines.push(''); + lines.push('| # | Module | Type | Score | Coverage | Open Tasks | Recommendation |'); + lines.push('|---|--------|------|-------|----------|------------|----------------|'); + + const top = priorities.slice(0, 15); + top.forEach((p, i) => { + const cov = p.coverage !== null ? `${p.coverage.toFixed(1)}%` : '---'; + lines.push(`| ${i + 1} | \`${p.name}\` | ${p.type} | ${p.totalScore.toFixed(0)} | ${cov} | ${p.openTaskCount} | ${p.recommendation} |`); + }); + lines.push(''); + + // --- Research --- + lines.push('## Research'); + lines.push(''); + lines.push('Use `node tools/project-map/index.mjs --research` to launch a Claude Code agent'); + lines.push('that investigates a specific area and saves findings to `docs/brain/00-Overview/Research/`.'); + lines.push(''); + + // --- Staleness --- + lines.push('---'); + lines.push(`*Last generated: ${now}*`); + lines.push(''); + + writeFileSync(reportPath, lines.join('\n'), 'utf8'); + console.log(`\n Report written to: ${reportPath}`); +} diff --git a/tools/project-map/lib/research-agent.mjs b/tools/project-map/lib/research-agent.mjs new file mode 100644 index 00000000..9c254237 --- /dev/null +++ b/tools/project-map/lib/research-agent.mjs @@ -0,0 +1,223 @@ +/** + * Research agent launcher. + * Spawns a Claude Code subprocess to investigate a specific area + * and saves findings to docs/brain/00-Overview/Research/. + */ +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createInterface } from 'node:readline'; + +const RESEARCH_DIR = 'docs/brain/00-Overview/Research'; + +const RESEARCH_AREAS = { + 'server-api': { + label: 'Server API package', + description: 'Analyze Server/api/ for coverage gaps, missing error handling, untested endpoints', + scope: 'Server/api/', + }, + 'server-ws': { + label: 'Server WebSocket package', + description: 'Analyze Server/ws/ for coverage gaps, race conditions, edge cases in voice/chat handlers', + scope: 'Server/ws/', + }, + 'server-auth': { + label: 'Server auth package', + description: 'Analyze Server/auth/ for security gaps, missing test cases, TOTP edge cases', + scope: 'Server/auth/', + }, + 'server-db': { + label: 'Server database package', + description: 'Analyze Server/db/ for missing indexes, query performance, untested queries', + scope: 'Server/db/', + }, + 'server-admin': { + label: 'Server admin package', + description: 'Analyze Server/admin/ for test coverage issues, build-tag gating, missing functionality', + scope: 'Server/admin/', + }, + 'client-livekit': { + label: 'Client LiveKit session', + description: 'Analyze livekitSession.ts and related audio/video code for coverage gaps and edge cases', + scope: 'Client/tauri-client/src/lib/livekitSession.ts', + }, + 'client-stores': { + label: 'Client stores', + description: 'Analyze reactive stores for state management edge cases, race conditions, memory leaks', + scope: 'Client/tauri-client/src/stores/', + }, + 'rust-backend': { + label: 'Tauri Rust backend', + description: 'Analyze Rust backend for missing tests, security gaps in proxy/credential code', + scope: 'Client/tauri-client/src-tauri/src/', + }, + 'e2e-coverage': { + label: 'E2E test coverage', + description: 'Analyze E2E test suite for missing critical user flows, flaky tests, gaps', + scope: 'Client/tauri-client/tests/e2e/', + }, + 'security': { + label: 'Security audit', + description: 'Review codebase for OWASP Top 10 vulnerabilities, auth bypass, injection, XSS', + scope: 'Server/ Client/tauri-client/src/', + }, + 'protocol': { + label: 'Protocol compliance', + description: 'Check server and client code against docs/brain/06-Specs/PROTOCOL.md for drift', + scope: 'Server/ws/ Client/tauri-client/src/lib/dispatcher.ts', + }, +}; + +function buildPrompt(areaKey, area, root) { + const date = new Date().toISOString().slice(0, 10); + const outputFile = `${RESEARCH_DIR}/${areaKey}-${date}.md`; + + return `You are a research agent investigating the OwnCord project. + +## Task +${area.description} + +## Scope +Focus on: ${area.scope} + +## Instructions +1. Read the relevant source files and test files +2. Identify: + - Coverage gaps (functions/branches not tested) + - Potential bugs or edge cases + - Security concerns + - Code quality issues + - Missing functionality vs specs +3. For each finding, note the file path and line number +4. Prioritize findings as CRITICAL, HIGH, MEDIUM, or LOW + +## Output +Write your findings to: ${outputFile} + +Use this format: +--- +# Research: ${area.label} +Date: ${date} +Scope: ${area.scope} + +## Summary +[2-3 sentence overview] + +## Findings + +### CRITICAL +- [finding with file:line reference] + +### HIGH +- [finding with file:line reference] + +### MEDIUM +- [finding with file:line reference] + +### LOW +- [finding with file:line reference] + +## Recommendations +[Prioritized list of what to fix/improve next] +--- + +After writing the file, print a brief summary of what you found.`; +} + +async function promptUser(question) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(question, answer => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export async function launchResearchAgent(root) { + console.log('\n Research Agent Launcher\n'); + console.log(' Available research areas:\n'); + + const keys = Object.keys(RESEARCH_AREAS); + keys.forEach((key, i) => { + const area = RESEARCH_AREAS[key]; + console.log(` ${i + 1}. ${area.label} — ${area.description.slice(0, 70)}...`); + }); + + console.log(`\n ${keys.length + 1}. Custom (enter your own research prompt)`); + + const choice = await promptUser('\n Enter number (or "q" to quit): '); + + if (choice === 'q' || choice === '') { + console.log(' Cancelled.'); + return; + } + + const num = parseInt(choice, 10); + if (isNaN(num) || num < 1 || num > keys.length + 1) { + console.log(' Invalid choice.'); + return; + } + + // Ensure research directory exists + const researchDir = resolve(root, RESEARCH_DIR); + mkdirSync(researchDir, { recursive: true }); + + let prompt; + let areaKey; + + if (num <= keys.length) { + areaKey = keys[num - 1]; + const area = RESEARCH_AREAS[areaKey]; + prompt = buildPrompt(areaKey, area, root); + console.log(`\n Launching research on: ${area.label}`); + } else { + const customPrompt = await promptUser(' Enter your research prompt: '); + if (!customPrompt) { + console.log(' Cancelled.'); + return; + } + areaKey = 'custom'; + prompt = customPrompt; + console.log(`\n Launching custom research...`); + } + + // Save the prompt for reference + const date = new Date().toISOString().slice(0, 10); + const promptFile = resolve(researchDir, `${areaKey}-${date}-prompt.txt`); + // Boundary check: ensure promptFile stays within researchDir + if (!promptFile.startsWith(resolve(researchDir))) { + console.error(' Error: prompt file path escapes research directory'); + return; + } + writeFileSync(promptFile, prompt, 'utf8'); + + console.log(` Prompt saved to: ${promptFile}`); + console.log(`\n To run the research agent, execute:\n`); + console.log(` claude --print "${promptFile.replace(/\\/g, '/')}"`); + console.log(`\n Or copy the prompt and paste it into a Claude Code session.`); + console.log(` The agent will save findings to: ${RESEARCH_DIR}/${areaKey}-${date}.md\n`); + + // Try to launch claude directly if available + try { + execFileSync('claude', ['--version'], { stdio: 'pipe' }); + const launch = await promptUser(' Claude CLI detected. Launch now? (y/n): '); + if (launch.toLowerCase() === 'y') { + console.log('\n Spawning Claude Code agent...\n'); + // Use prompt file instead of inline prompt to avoid shell injection + const child = spawn('claude', ['--print', promptFile], { + cwd: root, + stdio: 'inherit', + shell: false, + }); + child.on('close', (code) => { + console.log(`\n Research agent exited with code ${code}`); + console.log(` Check ${RESEARCH_DIR}/${areaKey}-${date}.md for findings.\n`); + }); + // Wait for completion + await new Promise(resolve => child.on('close', resolve)); + } + } catch { + // Claude CLI not available — just show instructions + } +} diff --git a/tools/project-map/lib/scanner.mjs b/tools/project-map/lib/scanner.mjs new file mode 100644 index 00000000..a5618253 --- /dev/null +++ b/tools/project-map/lib/scanner.mjs @@ -0,0 +1,198 @@ +/** + * Module scanner — discovers Go packages, TypeScript directories, and Rust files. + * Returns a structured inventory of the project. + */ +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; + +function scanDir(dir, extensions, recursive = true) { + let fileCount = 0; + let lineCount = 0; + + function walk(d) { + if (!existsSync(d)) return; + let entries; + try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'vendor' || entry.name === 'dist' || entry.name === 'target') continue; + const full = join(d, entry.name); + if (entry.isDirectory() && recursive) { + walk(full); + } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) { + fileCount++; + try { + lineCount += readFileSync(full, 'utf8').split('\n').length; + } catch { /* skip */ } + } + } + } + walk(dir); + return { fileCount, lineCount }; +} + +function scanGoDir(dir, name, pathPrefix) { + const source = scanDir(dir, ['.go'], false); + const testSource = { fileCount: 0, lineCount: 0 }; + + try { + for (const f of readdirSync(dir)) { + if (f.endsWith('_test.go')) { + testSource.fileCount++; + try { + testSource.lineCount += readFileSync(join(dir, f), 'utf8').split('\n').length; + } catch { /* skip */ } + } + } + } catch { /* skip */ } + + const srcFiles = source.fileCount - testSource.fileCount; + const srcLines = source.lineCount - testSource.lineCount; + + if (srcFiles > 0 || testSource.fileCount > 0) { + return { + name, + type: 'go', + path: pathPrefix, + sourceFiles: srcFiles, + sourceLines: srcLines, + testFiles: testSource.fileCount, + testLines: testSource.lineCount, + }; + } + return null; +} + +function scanGoPackages(serverDir) { + const packages = []; + if (!existsSync(serverDir)) return packages; + + // Scan root-level Go files (main.go, etc.) + const rootPkg = scanGoDir(serverDir, 'server-root', 'Server'); + if (rootPkg) packages.push(rootPkg); + + // Scan subdirectory packages + for (const entry of readdirSync(serverDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'vendor') continue; + const pkg = scanGoDir(join(serverDir, entry.name), entry.name, `Server/${entry.name}`); + if (pkg) packages.push(pkg); + } + return packages; +} + +function scanTSDirectories(clientSrcDir) { + const dirs = []; + if (!existsSync(clientSrcDir)) return dirs; + + const scanAreas = ['lib', 'stores', 'components', 'pages', 'styles']; + for (const area of scanAreas) { + const areaDir = join(clientSrcDir, area); + if (!existsSync(areaDir)) continue; + const source = scanDir(areaDir, ['.ts', '.tsx', '.js', '.jsx'], true); + dirs.push({ + name: area, + type: 'typescript', + path: `Client/tauri-client/src/${area}`, + sourceFiles: source.fileCount, + sourceLines: source.lineCount, + testFiles: 0, // tests are in a separate dir + testLines: 0, + }); + } + + // Count test files + const testDir = resolve(clientSrcDir, '../tests'); + if (existsSync(testDir)) { + for (const subdir of ['unit', 'integration']) { + const td = join(testDir, subdir); + if (!existsSync(td)) continue; + const tests = scanDir(td, ['.ts', '.tsx', '.test.ts', '.test.tsx', '.spec.ts'], true); + dirs.push({ + name: `tests/${subdir}`, + type: 'typescript-tests', + path: `Client/tauri-client/tests/${subdir}`, + sourceFiles: 0, + sourceLines: 0, + testFiles: tests.fileCount, + testLines: tests.lineCount, + }); + } + + // E2E tests + const e2eDir = join(testDir, 'e2e'); + if (existsSync(e2eDir)) { + const e2e = scanDir(e2eDir, ['.ts', '.spec.ts'], true); + dirs.push({ + name: 'tests/e2e', + type: 'e2e-tests', + path: `Client/tauri-client/tests/e2e`, + sourceFiles: 0, + sourceLines: 0, + testFiles: e2e.fileCount, + testLines: e2e.lineCount, + }); + } + } + + return dirs; +} + +function scanRustFiles(rustDir) { + if (!existsSync(rustDir)) return []; + const source = scanDir(rustDir, ['.rs'], true); + const testCount = (() => { + let count = 0; + function walk(d) { + try { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = join(d, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('_test.rs') || entry.name === 'tests.rs') count++; + else if (entry.name.endsWith('.rs')) { + // Check for #[cfg(test)] inside the file + try { + const content = readFileSync(full, 'utf8'); + if (content.includes('#[cfg(test)]')) count++; + } catch { /* skip */ } + } + } + } catch { /* skip */ } + } + walk(rustDir); + return count; + })(); + + return [{ + name: 'tauri-rust', + type: 'rust', + path: 'Client/tauri-client/src-tauri/src', + sourceFiles: source.fileCount, + sourceLines: source.lineCount, + testFiles: testCount, + testLines: 0, + }]; +} + +export async function scanModules(root) { + const serverDir = resolve(root, 'Server'); + const clientSrcDir = resolve(root, 'Client/tauri-client/src'); + const rustDir = resolve(root, 'Client/tauri-client/src-tauri/src'); + + const goPackages = scanGoPackages(serverDir); + const tsDirectories = scanTSDirectories(clientSrcDir); + const rustFiles = scanRustFiles(rustDir); + + return { + go: goPackages, + typescript: tsDirectories, + rust: rustFiles, + summary: { + goPackages: goPackages.length, + goSourceFiles: goPackages.reduce((s, p) => s + p.sourceFiles, 0), + goTestFiles: goPackages.reduce((s, p) => s + p.testFiles, 0), + tsSourceFiles: tsDirectories.filter(d => d.type === 'typescript').reduce((s, d) => s + d.sourceFiles, 0), + tsTestFiles: tsDirectories.filter(d => d.type !== 'typescript').reduce((s, d) => s + d.testFiles, 0), + rustSourceFiles: rustFiles.reduce((s, r) => s + r.sourceFiles, 0), + rustTestFiles: rustFiles.reduce((s, r) => s + r.testFiles, 0), + }, + }; +} diff --git a/tools/project-map/lib/session-manager.mjs b/tools/project-map/lib/session-manager.mjs new file mode 100644 index 00000000..d3b40b92 --- /dev/null +++ b/tools/project-map/lib/session-manager.mjs @@ -0,0 +1,607 @@ +/** + * Session manager — consolidates session planning, tracking, and vault writing. + * + * Manages the full session lifecycle: plan → start → poll → end, with + * automatic vault integration (session logs, task file updates). + * + * Zero external dependencies — Node built-ins only. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync } from 'node:fs'; +import { resolve, join } from 'node:path'; + +import { generateSuggestions } from './suggestion-engine.mjs'; +import { parseSessionHistory } from './session-parser.mjs'; +import { parseBacklog } from './backlog-parser.mjs'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SESSION_STATE_FILE = 'session-state.json'; +const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes +const TASK_ID_REGEX = /\b(?:fix|close|resolve|implement|complete)\s+T-(\d+)/gi; +const SHA_RE = /^[0-9a-f]{40}$/i; + +function validateSha(sha) { + if (!SHA_RE.test(sha)) throw new Error(`Invalid git SHA: "${sha}"`); + return sha; +} + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +function git(args, root) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); +} + +function getHeadSha(root) { + return git(['rev-parse', 'HEAD'], root); +} + +// --------------------------------------------------------------------------- +// File path to module mapping (mirrors git-scanner.mjs logic) +// --------------------------------------------------------------------------- + +function fileToModule(filePath) { + const normalized = filePath.replace(/\\/g, '/'); + + if (normalized.startsWith('Server/')) { + const parts = normalized.split('/'); + // parts[1] is a subdirectory only when there are 3+ segments + // e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root' + return parts.length >= 3 ? parts[1] : 'server-root'; + } + + if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) { + return 'tauri-rust'; + } + + if (normalized.startsWith('Client/tauri-client/src/')) { + const area = normalized.replace('Client/tauri-client/src/', '').split('/')[0]; + return area || 'client-root'; + } + + if (normalized.startsWith('Client/tauri-client/')) { + return 'client-config'; + } + + return 'root'; +} + +// --------------------------------------------------------------------------- +// Session state I/O +// --------------------------------------------------------------------------- + +function stateFilePath(cacheDir) { + return resolve(cacheDir, SESSION_STATE_FILE); +} + +function readSessionState(cacheDir) { + const fp = stateFilePath(cacheDir); + if (!existsSync(fp)) return null; + + try { + return JSON.parse(readFileSync(fp, 'utf8')); + } catch { + console.warn(' [Session] Corrupt session-state.json — renaming and treating as inactive'); + try { + const corruptPath = `${fp}.corrupt.${Date.now()}`; + renameSync(fp, corruptPath); + } catch { /* best effort */ } + return null; + } +} + +function writeSessionState(cacheDir, state) { + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + writeFileSync(stateFilePath(cacheDir), JSON.stringify(state, null, 2)); +} + +// --------------------------------------------------------------------------- +// Safe vault write — temp file → validate → atomic rename → rollback copy +// --------------------------------------------------------------------------- + +function safeWriteFile(targetPath, content) { + const dir = resolve(targetPath, '..'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const tmpPath = targetPath + '.tmp'; + const backupPath = targetPath + '.bak'; + + // Write to temp file + writeFileSync(tmpPath, content, 'utf8'); + + // Validate: temp file content must match what was written + const written = readFileSync(tmpPath, 'utf8'); + if (written !== content) { + unlinkSync(tmpPath); + throw new Error(`Safe write validation failed: content mismatch at ${tmpPath}`); + } + + // Keep one rollback copy of existing file + if (existsSync(targetPath)) { + try { + if (existsSync(backupPath)) unlinkSync(backupPath); + renameSync(targetPath, backupPath); + } catch { + // Non-fatal — proceed without backup + } + } + + // Atomic rename + renameSync(tmpPath, targetPath); +} + +// --------------------------------------------------------------------------- +// Vault helpers +// --------------------------------------------------------------------------- + +function readVaultFile(filePath) { + try { + if (!existsSync(filePath)) return null; + return readFileSync(filePath, 'utf8'); + } catch (err) { + console.warn(` [Session] Failed to read vault file ${filePath}: ${err.message}`); + return null; + } +} + +function formatDuration(startIso) { + const ms = Date.now() - new Date(startIso).getTime(); + const minutes = Math.floor(ms / 60000); + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function generateSessionLogPath(root, summary) { + const sessionsDir = resolve(root, 'docs/brain/03-Sessions'); + const today = new Date().toISOString().slice(0, 10); + const slug = summary + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40); + + let baseName = `${today}-${slug || 'session'}`; + let filePath = join(sessionsDir, `${baseName}.md`); + let counter = 1; + + while (existsSync(filePath)) { + filePath = join(sessionsDir, `${baseName}-${counter}.md`); + counter++; + } + + return filePath; +} + +function buildSessionLogContent(state, autoMarker = '') { + const today = new Date().toISOString().slice(0, 10); + const tasksCompleted = state.commits + .flatMap(c => extractTaskIds(c)) + .filter((id, i, arr) => arr.indexOf(id) === i); + + const modulesStr = state.modulesTouched.length > 0 + ? state.modulesTouched.join(', ') + : 'none'; + + const commitsList = state.commits.length > 0 + ? state.commits.map(c => `- ${c}`).join('\n') + : '- (no commits)'; + + const filesList = state.filesChanged.length > 0 + ? state.filesChanged.slice(0, 20).map(f => `- ${f}`).join('\n') + : '- (no files changed)'; + + const tasksTable = tasksCompleted.length > 0 + ? tasksCompleted.map(id => `| ${id} | completed | Done |`).join('\n') + : '| — | — | — |'; + + const marker = autoMarker ? ` ${autoMarker}` : ''; + + return `--- +date: ${today} +summary: "Session${marker} — ${state.modulesTouched.slice(0, 3).join(', ') || 'general'}" +tasks-completed: ${tasksCompleted.length} +--- + +# Session — ${today}${marker} + +## Goal + +*Auto-generated session log from session manager* + +## What Was Done + +### Commits +${commitsList} + +### Files Changed (${state.filesChanged.length} total) +${filesList}${state.filesChanged.length > 20 ? `\n- ...and ${state.filesChanged.length - 20} more` : ''} + +### Modules Touched +${modulesStr} + +## Decisions Made + +- *See commit messages for details* + +## Blockers / Issues + +- + +## Next Steps + +- + +## Tasks Touched + +| Task | Action | Status | +| ---- | ------ | ------ | +${tasksTable} +`; +} + +// --------------------------------------------------------------------------- +// Task ID extraction from commit messages +// --------------------------------------------------------------------------- + +function extractTaskIds(commitMessage) { + const ids = []; + let match; + const regex = new RegExp(TASK_ID_REGEX.source, TASK_ID_REGEX.flags); + while ((match = regex.exec(commitMessage)) !== null) { + ids.push(`T-${match[1]}`); + } + return ids; +} + +// --------------------------------------------------------------------------- +// Vault task file updates +// --------------------------------------------------------------------------- + +function updateVaultTasks(root, completedTaskIds, preloadedInProgressContent) { + const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md'); + const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md'); + const today = new Date().toISOString().slice(0, 10); + const updated = []; + + const inProgressContent = preloadedInProgressContent ?? readVaultFile(inProgressPath); + const doneContent = readVaultFile(donePath); + + if (!inProgressContent || !doneContent) { + console.warn(' [Session] Cannot update vault tasks — file read failed'); + return updated; + } + + const idSet = new Set(completedTaskIds); + const movedTasks = []; + const remainingLines = []; + + // Parse In Progress.md, extract completed tasks + for (const line of inProgressContent.split('\n')) { + const taskMatch = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/); + if (taskMatch && idSet.has(`T-${taskMatch[1]}`)) { + movedTasks.push({ + id: `T-${taskMatch[1]}`, + description: taskMatch[2].trim(), + }); + } else { + remainingLines.push(line); + } + } + + if (movedTasks.length === 0) return updated; + + // Write updated In Progress.md + safeWriteFile(inProgressPath, remainingLines.join('\n')); + + // Append to Done.md + const doneEntries = movedTasks + .map(t => `- [x] **${t.id}:** ${t.description} — completed ${today}`) + .join('\n'); + + const sectionHeader = `\n## Session (${today})\n\n`; + const insertionPoint = doneContent.indexOf('\n## '); + let newDoneContent; + + if (insertionPoint !== -1) { + // Insert after the main heading, before the first section + newDoneContent = doneContent.slice(0, insertionPoint) + + sectionHeader + doneEntries + '\n' + + doneContent.slice(insertionPoint); + } else { + newDoneContent = doneContent + sectionHeader + doneEntries + '\n'; + } + + safeWriteFile(donePath, newDoneContent); + + for (const t of movedTasks) { + updated.push(t.id); + } + + return updated; +} + +// Exported for testing +export { fileToModule }; + +// --------------------------------------------------------------------------- +// Exported functions +// --------------------------------------------------------------------------- + +/** + * Generate a session plan combining suggestions, last session, and backlog. + */ +export async function generatePlan(root, cacheDir) { + // Gather data from existing engines + const [sessionData, backlog] = await Promise.all([ + parseSessionHistory(root, cacheDir, true), + parseBacklog(root), + ]); + + const suggestions = await generateSuggestions(cacheDir, { + backlog, + sessionData, + }); + + // Build greeting + const last = sessionData.lastSession; + const greeting = last.date + ? `Welcome back! Last session: ${last.date} — ${last.summary || 'no summary'}` + : 'Welcome! This appears to be the first session.'; + + // Map suggestions to plan tasks + const tasks = suggestions.suggestions.map((s, i) => { + const topSignals = s.signals || []; + const relatedFiles = topSignals + .filter(sig => sig.signal === 'open-bug' || sig.signal === 'open-task') + .map(sig => sig.value) + .slice(0, 5); + + const score = s.score || 0; + let estimatedFocus = 'small'; + if (score > 100) estimatedFocus = 'large'; + else if (score > 40) estimatedFocus = 'medium'; + + return { + priority: Math.min(i + 1, 5), + title: `Focus on ${s.module}`, + rationale: s.rationale, + estimatedFocus, + relatedFiles, + module: s.module, + }; + }); + + // In-progress tasks + const inProgress = sessionData.inProgress.map(t => ({ + id: t.id, + description: t.description, + })); + + return { + greeting, + tasks, + lastSession: { + date: last.date, + summary: last.summary, + tasksCompleted: last.tasksCompleted, + }, + inProgress, + }; +} + +/** + * Start a new session. Records HEAD SHA and creates session state. + * Rejects if a session is already active. + */ +export function startSession(root, cacheDir) { + const existing = readSessionState(cacheDir); + if (existing && existing.active) { + throw new Error( + 'Session already active (started at ' + existing.startedAt + '). ' + + 'End or recover it before starting a new one.' + ); + } + + const baselineSha = getHeadSha(root); + const state = { + active: true, + startedAt: new Date().toISOString(), + baselineSha, + filesChanged: [], + commits: [], + modulesTouched: [], + }; + + writeSessionState(cacheDir, state); + return { ...state }; +} + +/** + * Get the current session status with live diff stats. + */ +export function getSessionStatus(cacheDir) { + const state = readSessionState(cacheDir); + if (!state || !state.active) { + return { active: false }; + } + + return { ...state }; +} + +/** + * Poll for changes since session baseline. Updates session state with + * current file changes, commits, and modules touched. + */ +export function pollChanges(root, cacheDir) { + const state = readSessionState(cacheDir); + if (!state || !state.active) { + return { active: false }; + } + + let filesChanged = []; + let newCommits = []; + + try { + const diffOutput = git(['diff', '--name-only', validateSha(state.baselineSha)], root); + filesChanged = diffOutput ? diffOutput.split('\n').filter(Boolean) : []; + } catch (err) { + throw new Error(`Failed to get git diff: ${err.message}`); + } + + try { + const logOutput = git(['log', '--oneline', `${validateSha(state.baselineSha)}..HEAD`], root); + newCommits = logOutput ? logOutput.split('\n').filter(Boolean) : []; + } catch (err) { + throw new Error(`Failed to get git log: ${err.message}`); + } + + // Derive modules from changed files + const moduleSet = new Set(); + for (const file of filesChanged) { + moduleSet.add(fileToModule(file)); + } + + const updatedState = { + ...state, + filesChanged, + commits: newCommits, + modulesTouched: [...moduleSet], + lastHeartbeat: new Date().toISOString(), + }; + + writeSessionState(cacheDir, updatedState); + return { ...updatedState }; +} + +/** + * End the current session. Generates a vault session log, updates task + * files, and clears session state. + */ +export function endSession(root, cacheDir) { + const state = readSessionState(cacheDir); + if (!state || !state.active) { + throw new Error('No active session to end.'); + } + + // Final poll to capture latest changes + const finalState = pollChanges(root, cacheDir); + + // Generate session log + const logContent = buildSessionLogContent(finalState); + const logPath = generateSessionLogPath( + root, + finalState.modulesTouched.slice(0, 3).join('-') || 'session' + ); + + safeWriteFile(logPath, logContent); + + // Extract task IDs from commit messages and update vault + const allTaskIds = finalState.commits + .flatMap(c => extractTaskIds(c)) + .filter((id, i, arr) => arr.indexOf(id) === i); + + let tasksUpdated = []; + if (allTaskIds.length > 0) { + // Verify task IDs exist in vault before updating + const inProgressContent = readVaultFile( + resolve(root, 'docs/brain/02-Tasks/In Progress.md') + ); + if (inProgressContent) { + const knownIds = allTaskIds.filter(id => inProgressContent.includes(id)); + const unknownIds = allTaskIds.filter(id => !inProgressContent.includes(id)); + + for (const uid of unknownIds) { + console.warn(` [Session] Task ${uid} not found in In Progress — skipping`); + } + + if (knownIds.length > 0) { + tasksUpdated = updateVaultTasks(root, knownIds, inProgressContent); + } + } + } + + // Clear session state + const closedState = { + active: false, + closedAt: new Date().toISOString(), + startedAt: finalState.startedAt, + baselineSha: finalState.baselineSha, + }; + writeSessionState(cacheDir, closedState); + + return { + sessionLog: logPath, + tasksUpdated, + filesChanged: finalState.filesChanged.length, + duration: formatDuration(finalState.startedAt), + }; +} + +/** + * Recover a stale session on dashboard startup. + * If session-state.json shows active=true but last git activity was + * >30 minutes ago, auto-close it with a partial log. + */ +export function recoverStaleSession(root, cacheDir) { + const state = readSessionState(cacheDir); + if (!state || !state.active) { + return { recovered: false }; + } + + // Use session-specific timestamps: lastHeartbeat > startedAt + // Do NOT use repo-wide git log — that reflects any commit, not this session's activity + const lastActivityTime = new Date(state.lastHeartbeat || state.startedAt).getTime(); + + const elapsed = Date.now() - lastActivityTime; + if (elapsed < STALE_THRESHOLD_MS) { + return { recovered: false }; + } + + // Auto-close the stale session + let finalState; + try { + finalState = pollChanges(root, cacheDir); + } catch { + // If poll fails, use whatever state we have + finalState = { ...state }; + } + + const logContent = buildSessionLogContent(finalState, '[auto-closed]'); + const logPath = generateSessionLogPath(root, 'auto-closed'); + + try { + safeWriteFile(logPath, logContent); + } catch (err) { + console.warn(` [Session] Failed to write auto-close log: ${err.message}`); + } + + // Clear session state + const closedState = { + active: false, + closedAt: new Date().toISOString(), + startedAt: state.startedAt, + baselineSha: state.baselineSha, + autoRecovered: true, + }; + writeSessionState(cacheDir, closedState); + + return { + recovered: true, + log: logPath, + }; +} diff --git a/tools/project-map/lib/session-parser.mjs b/tools/project-map/lib/session-parser.mjs new file mode 100644 index 00000000..417db167 --- /dev/null +++ b/tools/project-map/lib/session-parser.mjs @@ -0,0 +1,357 @@ +/** + * Session parser — reads session log files from docs/brain/03-Sessions/, + * builds a timeline, tracks progress over time, calculates streaks, + * and extracts last-session and task-status info. + */ +import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { resolve, join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Module keyword map — maps session content to touched modules +// --------------------------------------------------------------------------- + +const MODULE_KEYWORDS = { + 'admin': ['admin panel', 'admin api', 'admin'], + 'api': ['REST', 'endpoint', 'handler', 'middleware', 'api'], + 'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'], + 'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'], + 'ws': ['websocket', 'hub', 'broadcast', 'ringbuffer', 'reconnect'], + 'voice': ['voice', 'audio', 'LiveKit', 'livekit', 'WebRTC', 'SFU', 'RTP', 'VAD'], + 'permissions': ['permission', 'role', 'RBAC'], + 'storage': ['upload', 'file storage', 'attachment'], + 'config': ['config', 'settings'], + 'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'], + 'stores': ['store', 'state management'], + 'components': ['component', 'sidebar', 'overlay', 'widget', 'picker', 'modal'], + 'pages': ['ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'], + 'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater'], + 'protocol': ['protocol', 'message type', 'payload'], + 'e2e': ['E2E', 'Playwright', 'end-to-end'], + 'tests': ['test', 'coverage', 'vitest', 'unit test', 'integration test'], + 'docs': ['documentation', 'README', 'CLAUDE.md', 'spec', 'docs'], + 'security': ['security', 'XSS', 'CSRF', 'injection', 'audit', 'vulnerability'], + 'ci': ['CI', 'GitHub Actions', 'lint', 'golangci'], +}; + +// --------------------------------------------------------------------------- +// Frontmatter parser +// --------------------------------------------------------------------------- + +/** + * Parse YAML-like frontmatter delimited by --- lines. + * Returns { meta: { key: value }, body: string }. + */ +function parseFrontmatter(content) { + const lines = content.split('\n').map(l => l.replace(/\r$/, '')); + if (lines[0].trim() !== '---') { + return { meta: {}, body: content }; + } + + const meta = {}; + let endIdx = -1; + + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + endIdx = i; + break; + } + const colonIdx = lines[i].indexOf(':'); + if (colonIdx > 0) { + const key = lines[i].slice(0, colonIdx).trim(); + let val = lines[i].slice(colonIdx + 1).trim(); + // Strip surrounding quotes + if ((val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + meta[key] = val; + } + } + + if (endIdx === -1) { + return { meta: {}, body: content }; + } + + const body = lines.slice(endIdx + 1).join('\n'); + return { meta, body }; +} + +// --------------------------------------------------------------------------- +// Module detection from session body text +// --------------------------------------------------------------------------- + +function detectModules(body) { + const lower = body.toLowerCase(); + const found = []; + + for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) { + for (const kw of keywords) { + if (lower.includes(kw.toLowerCase())) { + found.push(mod); + break; + } + } + } + + return found.length > 0 ? found : ['general']; +} + +// --------------------------------------------------------------------------- +// Task file parsers (Done.md, In Progress.md) +// --------------------------------------------------------------------------- + +function parseDoneFile(filePath) { + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, 'utf8'); + const lines = content.split('\n').map(l => l.replace(/\r$/, '')); + const tasks = []; + let currentSection = ''; + + for (const line of lines) { + if (line.startsWith('## ')) { + currentSection = line.replace(/^##\s*/, ''); + } + + // Match both formats: + // - [x] **T-XXX:** description — completed YYYY-MM-DD + // - [x] **T-XXX**: description — YYYY-MM-DD + const match = line.match( + /^- \[x\] \*\*T-(\d+)[:\*]*\*?\*?\s*(.+?)(?:\s*—\s*(?:completed\s+)?(\d{4}-\d{2}-\d{2}))?$/ + ); + if (match) { + const id = `T-${match[1]}`; + const description = match[2] + .replace(/\s*—\s*(?:completed\s+)?\d{4}-\d{2}-\d{2}$/, '') + .trim(); + // Date from the line itself, or try to extract from section header + const date = match[3] || extractDateFromSection(currentSection) || ''; + tasks.push({ id, description, date }); + } + } + + return tasks; +} + +function extractDateFromSection(section) { + const match = section.match(/\((\d{4}-\d{2}-\d{2})\)/); + return match ? match[1] : null; +} + +function parseInProgressFile(filePath) { + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, 'utf8'); + const lines = content.split('\n').map(l => l.replace(/\r$/, '')); + const tasks = []; + + for (const line of lines) { + // Match: - [ ] **T-XXX:** description + const match = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/); + if (match) { + const id = `T-${match[1]}`; + const description = match[2].trim(); + tasks.push({ id, description }); + } + + // Also match lines without checkbox but with task ID + const altMatch = line.match(/^\*\*T-(\d+):\*\*\s*(.+)/); + if (!match && altMatch) { + const id = `T-${altMatch[1]}`; + const description = altMatch[2].trim(); + tasks.push({ id, description }); + } + } + + return tasks; +} + +// --------------------------------------------------------------------------- +// Streak calculation +// --------------------------------------------------------------------------- + +function calculateStreaks(sortedDates) { + if (sortedDates.length === 0) { + return { current: 0, longest: 0, totalSessions: 0 }; + } + + // Deduplicate dates (multiple sessions on same day count as 1) + const uniqueDates = [...new Set(sortedDates)].sort(); + const totalSessions = sortedDates.length; + + let longest = 1; + let currentStreak = 1; + let streakAtEnd = 1; + + for (let i = 1; i < uniqueDates.length; i++) { + const prev = new Date(uniqueDates[i - 1] + 'T00:00:00Z'); + const curr = new Date(uniqueDates[i] + 'T00:00:00Z'); + const diffMs = curr.getTime() - prev.getTime(); + const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + currentStreak++; + } else { + currentStreak = 1; + } + + if (currentStreak > longest) { + longest = currentStreak; + } + + streakAtEnd = currentStreak; + } + + // Check if the most recent session date is today or yesterday + // to determine if the current streak is still "active" + const lastDate = new Date(uniqueDates[uniqueDates.length - 1] + 'T00:00:00Z'); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const daysSinceLast = Math.round((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); + + const current = daysSinceLast <= 1 ? streakAtEnd : 0; + + return { current, longest, totalSessions }; +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +/** + * Parse all session logs and task files, returning a structured timeline. + * + * @param {string} root - Repository root directory + * @param {string} cacheDir - Directory to store cached results + * @param {boolean} quick - If true and cache exists, return cached data + * @returns {Promise} - Parsed session history + */ +export async function parseSessionHistory(root, cacheDir, quick) { + const cachePath = join(cacheDir, 'session-data.json'); + + // Quick mode: return cached data if available + if (quick && existsSync(cachePath)) { + try { + const cached = JSON.parse(readFileSync(cachePath, 'utf8')); + console.log(' [Sessions] Returning cached session data'); + return cached; + } catch { + // Cache corrupt — fall through to fresh parse + } + } + + const sessionsDir = resolve(root, 'docs/brain/03-Sessions'); + const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md'); + const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md'); + + // ----------------------------------------------------------------------- + // 1. Parse all session files + // ----------------------------------------------------------------------- + + const sessions = []; + + if (existsSync(sessionsDir)) { + const files = readdirSync(sessionsDir) + .filter(f => f.endsWith('.md') && f !== 'index.md') + .sort(); + + for (const fileName of files) { + const filePath = join(sessionsDir, fileName); + const content = readFileSync(filePath, 'utf8'); + const { meta, body } = parseFrontmatter(content); + + const date = meta.date || fileName.slice(0, 10); + const summary = meta.summary || ''; + const tasksCompleted = parseInt(meta['tasks-completed'] || '0', 10); + const modulesTouched = detectModules(body); + + sessions.push({ + date, + summary, + tasksCompleted, + modulesTouched, + fileName, + }); + } + } + + // Sort by date ascending + sessions.sort((a, b) => a.date.localeCompare(b.date)); + + console.log(` [Sessions] Parsed ${sessions.length} session files`); + + // ----------------------------------------------------------------------- + // 2. Build progress over time (cumulative tasks completed) + // ----------------------------------------------------------------------- + + const progressOverTime = []; + let cumulative = 0; + + for (const session of sessions) { + cumulative += session.tasksCompleted; + progressOverTime.push({ + date: session.date, + cumulativeDone: cumulative, + sessionDone: session.tasksCompleted, + }); + } + + // ----------------------------------------------------------------------- + // 3. Calculate streaks + // ----------------------------------------------------------------------- + + const sessionDates = sessions.map(s => s.date); + const streaks = calculateStreaks(sessionDates); + + // ----------------------------------------------------------------------- + // 4. Extract last session info + // ----------------------------------------------------------------------- + + const lastSessionRaw = sessions.length > 0 ? sessions[sessions.length - 1] : null; + const lastSession = lastSessionRaw + ? { + date: lastSessionRaw.date, + summary: lastSessionRaw.summary, + tasksCompleted: lastSessionRaw.tasksCompleted, + modulesTouched: lastSessionRaw.modulesTouched, + } + : { date: '', summary: '', tasksCompleted: 0, modulesTouched: [] }; + + // ----------------------------------------------------------------------- + // 5. Parse task files + // ----------------------------------------------------------------------- + + const allDone = parseDoneFile(donePath); + // Last 10 completed tasks (file is ordered newest-first by section) + const recentlyDone = allDone.slice(0, 10); + + const inProgress = parseInProgressFile(inProgressPath); + + console.log(` [Sessions] ${allDone.length} done tasks, ${inProgress.length} in-progress`); + + // ----------------------------------------------------------------------- + // 6. Assemble result and cache + // ----------------------------------------------------------------------- + + const result = { + timestamp: new Date().toISOString(), + sessions, + progressOverTime, + streaks, + lastSession, + inProgress, + recentlyDone, + }; + + // Write cache + try { + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + writeFileSync(cachePath, JSON.stringify(result, null, 2)); + } catch (err) { + console.warn(` [Sessions] Failed to write cache: ${err.message}`); + } + + return result; +} diff --git a/tools/project-map/lib/suggestion-engine.mjs b/tools/project-map/lib/suggestion-engine.mjs new file mode 100644 index 00000000..3a81b65e --- /dev/null +++ b/tools/project-map/lib/suggestion-engine.mjs @@ -0,0 +1,238 @@ +/** + * Smart suggestion engine. + * Combines all signals (coverage, bugs, churn, staleness, coupling, debt, recent work) + * into ranked "Focus Next" recommendations. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const CACHE_FILE = 'suggestions.json'; +const PREFS_FILE = 'user-prefs.json'; + +// Signal weights (tunable) +const WEIGHTS = { + coverageGap: 2.0, // per % below 80 + openBugs: 15, // per bug + codeReviewFixes: 10, // per fix + openTasks: 3, // per task + highChurn: 1.5, // per commit in last 30d + staleness: 0.5, // per day since last commit + coupling: 2.0, // per coupling score point + debtMarkers: 1.0, // per TODO/FIXME/HACK + largeFiles: 5, // per large file + longFunctions: 3, // per long function + recentWorkCooldown: -20, // penalty if worked on in last 2 sessions +}; + +function loadPrefs(cacheDir) { + const prefsFile = resolve(cacheDir, PREFS_FILE); + if (existsSync(prefsFile)) { + try { return JSON.parse(readFileSync(prefsFile, 'utf8')); } catch { /* skip */ } + } + return { recentlyWorked: [], strategy: 'balanced' }; +} + +function savePrefs(cacheDir, prefs) { + try { + writeFileSync(resolve(cacheDir, PREFS_FILE), JSON.stringify(prefs, null, 2)); + } catch { /* non-fatal — prefs write failure should not crash the server */ } +} + +export function markWorkedOn(cacheDir, moduleName) { + const prefs = loadPrefs(cacheDir); + prefs.recentlyWorked = [moduleName, ...prefs.recentlyWorked.filter(m => m !== moduleName)].slice(0, 5); + savePrefs(cacheDir, prefs); +} + +export function setStrategy(cacheDir, strategy) { + const prefs = loadPrefs(cacheDir); + prefs.strategy = strategy; + savePrefs(cacheDir, prefs); +} + +export async function generateSuggestions(cacheDir, { + priorities = [], + goCoverage = {}, + vitestCoverage = {}, + backlog = {}, + gitData = null, + debtData = null, + importGraph = null, + sessionData = null, +} = {}) { + const prefs = loadPrefs(cacheDir); + const strategy = prefs.strategy || 'balanced'; + const recentlyWorked = new Set(prefs.recentlyWorked || []); + + // Strategy multipliers + const strategyMult = { + balanced: { coverage: 1, bugs: 1, momentum: 1, debt: 1 }, + 'bugs-first': { coverage: 0.5, bugs: 2.5, momentum: 0.5, debt: 0.5 }, + 'coverage-first': { coverage: 2.5, bugs: 0.5, momentum: 0.5, debt: 0.5 }, + 'momentum-first': { coverage: 0.5, bugs: 0.5, momentum: 2.5, debt: 0.5 }, + 'debt-first': { coverage: 0.5, bugs: 0.5, momentum: 0.5, debt: 2.5 }, + }; + const mult = strategyMult[strategy] || strategyMult.balanced; + + // Build per-module signal aggregation + const moduleSignals = {}; + + function ensureModule(name) { + if (!moduleSignals[name]) { + moduleSignals[name] = { + name, + signals: [], + score: 0, + coverageScore: 0, + bugScore: 0, + momentumScore: 0, + debtScore: 0, + }; + } + return moduleSignals[name]; + } + + // 1. Coverage gaps (from priorities which already have this data) + for (const p of priorities) { + const m = ensureModule(p.name); + if (p.coverage !== null && p.coverage < 80) { + const gap = 80 - p.coverage; + const pts = gap * WEIGHTS.coverageGap * mult.coverage; + m.coverageScore += pts; + m.signals.push({ signal: 'coverage-gap', value: `${p.coverage.toFixed(1)}% (${gap.toFixed(0)}% below target)`, points: pts }); + } else if (p.coverage === null && p.type !== 'feature-area') { + const pts = 80 * WEIGHTS.coverageGap * 0.5 * mult.coverage; // unknown coverage = assume 50% gap + m.coverageScore += pts; + m.signals.push({ signal: 'no-coverage-data', value: 'No test coverage', points: pts }); + } + } + + // 2. Open bugs and tasks + for (const [phase, tasks] of Object.entries(backlog.byPhase || {})) { + for (const task of tasks) { + for (const mod of task.modules) { + const m = ensureModule(mod); + if (phase === 'bug') { + const pts = WEIGHTS.openBugs * mult.bugs; + m.bugScore += pts; + m.signals.push({ signal: 'open-bug', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); + } else if (phase === 'code-review') { + const pts = WEIGHTS.codeReviewFixes * mult.bugs; + m.bugScore += pts; + m.signals.push({ signal: 'code-review-fix', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); + } else { + const pts = WEIGHTS.openTasks * mult.momentum; + m.momentumScore += pts; + m.signals.push({ signal: 'open-task', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); + } + } + } + } + + // 3. Git signals (churn, staleness) + if (gitData) { + for (const [mod, data] of Object.entries(gitData.commitsByModule || {})) { + const m = ensureModule(mod); + // High churn = needs attention + if (data.count > 10) { + const pts = (data.count - 10) * WEIGHTS.highChurn * mult.momentum; + m.momentumScore += pts; + m.signals.push({ signal: 'high-churn', value: `${data.count} commits in 30d`, points: pts }); + } + } + + for (const [mod, data] of Object.entries(gitData.staleness || {})) { + const m = ensureModule(mod); + if (data.daysSinceLastCommit > 14) { + const pts = data.daysSinceLastCommit * WEIGHTS.staleness * mult.momentum; + m.momentumScore += pts; + m.signals.push({ signal: 'stale', value: `${data.daysSinceLastCommit} days since last commit`, points: pts }); + } + } + } + + // 4. Debt signals + if (debtData) { + for (const [mod, data] of Object.entries(debtData.summary?.byModule || {})) { + const m = ensureModule(mod); + if (data.markers > 0) { + const pts = data.markers * WEIGHTS.debtMarkers * mult.debt; + m.debtScore += pts; + m.signals.push({ signal: 'debt-markers', value: `${data.markers} TODO/FIXME/HACK`, points: pts }); + } + if (data.largeFiles > 0) { + const pts = data.largeFiles * WEIGHTS.largeFiles * mult.debt; + m.debtScore += pts; + m.signals.push({ signal: 'large-files', value: `${data.largeFiles} oversized file(s)`, points: pts }); + } + if (data.longFunctions > 0) { + const pts = data.longFunctions * WEIGHTS.longFunctions * mult.debt; + m.debtScore += pts; + m.signals.push({ signal: 'long-functions', value: `${data.longFunctions} long function(s)`, points: pts }); + } + } + } + + // 5. Coupling signals + if (importGraph) { + for (const node of importGraph.nodes || []) { + const m = ensureModule(node.name); + if (node.coupling > 10) { + const pts = node.coupling * WEIGHTS.coupling * mult.debt; + m.debtScore += pts; + m.signals.push({ signal: 'high-coupling', value: `Coupling score ${node.coupling} (fan-in ${node.fanIn}, fan-out ${node.fanOut})`, points: pts }); + } + } + } + + // 6. Recent work cooldown + for (const [name, m] of Object.entries(moduleSignals)) { + if (recentlyWorked.has(name)) { + const pts = WEIGHTS.recentWorkCooldown; + m.momentumScore += pts; + m.signals.push({ signal: 'recently-worked', value: 'Worked on recently — cooling down', points: pts }); + } + } + + // Compute total scores + for (const m of Object.values(moduleSignals)) { + m.score = m.coverageScore + m.bugScore + m.momentumScore + m.debtScore; + } + + // Sort and take top suggestions + const sorted = Object.values(moduleSignals) + .filter(m => m.score > 0) + .sort((a, b) => b.score - a.score); + + const suggestions = sorted.slice(0, 10).map((m, i) => { + // Build a human-readable rationale from top 3 signals + const topSignals = [...m.signals].sort((a, b) => b.points - a.points).slice(0, 3); + const rationale = topSignals.map(s => s.value).join('; '); + + return { + rank: i + 1, + module: m.name, + score: Math.round(m.score), + rationale, + breakdown: { + coverage: Math.round(m.coverageScore), + bugs: Math.round(m.bugScore), + momentum: Math.round(m.momentumScore), + debt: Math.round(m.debtScore), + }, + signals: m.signals, + }; + }); + + const result = { + timestamp: new Date().toISOString(), + strategy, + suggestions, + recentlyWorked: [...recentlyWorked], + }; + + try { + writeFileSync(resolve(cacheDir, CACHE_FILE), JSON.stringify(result, null, 2)); + } catch { /* non-fatal */ } + return result; +} diff --git a/tools/project-map/lib/terminal-summary.mjs b/tools/project-map/lib/terminal-summary.mjs new file mode 100644 index 00000000..f17944ad --- /dev/null +++ b/tools/project-map/lib/terminal-summary.mjs @@ -0,0 +1,103 @@ +/** + * Terminal summary — prints a color-coded overview to stdout. + */ + +const RESET = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const CYAN = '\x1b[36m'; +const WHITE = '\x1b[37m'; + +function covColor(pct) { + if (pct === null || pct === undefined) return RED; + if (pct >= 80) return GREEN; + if (pct >= 60) return YELLOW; + return RED; +} + +function bar(pct, width = 20) { + if (pct === null || pct === undefined) return `${RED}${'░'.repeat(width)}${RESET} ---`; + const filled = Math.round((pct / 100) * width); + const empty = width - filled; + const color = covColor(pct); + return `${color}${'█'.repeat(filled)}${'░'.repeat(empty)}${RESET} ${pct.toFixed(1)}%`; +} + +function divider(title) { + const line = '─'.repeat(60); + return `\n ${CYAN}${BOLD}${title}${RESET}\n ${DIM}${line}${RESET}`; +} + +export function printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities) { + console.log(divider('PROJECT OVERVIEW')); + + const total = backlog.openCount + backlog.doneCount; + const pct = total > 0 ? ((backlog.doneCount / total) * 100).toFixed(1) : '0.0'; + console.log(` ${WHITE}Tasks: ${GREEN}${backlog.doneCount} done${RESET} / ${YELLOW}${backlog.openCount} open${RESET} (${pct}% complete)`); + console.log(` ${WHITE}Files: ${modules.summary.goSourceFiles} Go + ${modules.summary.tsSourceFiles} TS + ${modules.summary.rustSourceFiles} Rust${RESET}`); + console.log(` ${WHITE}Tests: ${modules.summary.goTestFiles} Go + ${modules.summary.tsTestFiles} TS + ${modules.summary.rustTestFiles} Rust${RESET}`); + + // Go coverage + console.log(divider('SERVER (GO) COVERAGE')); + for (const pkg of modules.go) { + const covData = goCoverage.packages?.[pkg.name]; + const cov = covData?.percentage ?? null; + const name = `${pkg.name}/`.padEnd(16); + const failed = covData?.failed ? ` ${RED}FAIL${RESET}` : ''; + console.log(` ${WHITE}${name}${RESET} ${bar(cov)}${failed}`); + } + + // TS coverage + console.log(divider('CLIENT (TYPESCRIPT) COVERAGE')); + const tsAreas = vitestCoverage.areas || {}; + const totalCov = tsAreas._total; + if (totalCov) { + console.log(` ${WHITE}${'Overall'.padEnd(16)}${RESET} ${bar(totalCov.statements)}`); + } + for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { + const areaCov = tsAreas[dir.name]; + const cov = areaCov?.statements ?? null; + const name = `${dir.name}/`.padEnd(16); + console.log(` ${WHITE}${name}${RESET} ${bar(cov)}`); + } + + // Rust + console.log(divider('CLIENT (RUST) STATUS')); + for (const rust of modules.rust) { + console.log(` ${WHITE}${rust.sourceFiles} files, ${rust.sourceLines} lines${RESET} — ${RED}No test infrastructure${RESET}`); + } + + // Where to work next + console.log(divider('WHERE TO WORK NEXT (TOP 5)')); + const top5 = priorities.slice(0, 5); + top5.forEach((p, i) => { + const num = `${i + 1}.`.padEnd(3); + const name = p.name.padEnd(16); + const cov = p.coverage !== null ? `${p.coverage.toFixed(0)}%`.padEnd(5) : '--- '; + const tasks = p.openTaskCount > 0 ? `${YELLOW}${p.openTaskCount} task(s)${RESET}` : `${GREEN}0 tasks${RESET}`; + console.log(` ${CYAN}${num}${RESET} ${BOLD}${name}${RESET} ${covColor(p.coverage)}${cov}${RESET} ${tasks} — ${DIM}${p.recommendation}${RESET}`); + }); + + // Open bugs + const bugs = backlog.byPhase['bug'] || []; + if (bugs.length > 0) { + console.log(divider('OPEN BUGS')); + for (const bug of bugs) { + console.log(` ${RED}${bug.id}${RESET}: ${bug.description}`); + } + } + + // Code review items + const reviews = backlog.byPhase['code-review'] || []; + if (reviews.length > 0) { + console.log(divider('CODE REVIEW FIXES')); + for (const r of reviews) { + console.log(` ${YELLOW}${r.id}${RESET}: ${r.description}`); + } + } + + console.log(`\n ${DIM}Full report: docs/brain/00-Overview/Project-Map.md${RESET}\n`); +} diff --git a/tools/project-map/lib/vitest-coverage.mjs b/tools/project-map/lib/vitest-coverage.mjs new file mode 100644 index 00000000..8912277c --- /dev/null +++ b/tools/project-map/lib/vitest-coverage.mjs @@ -0,0 +1,216 @@ +/** + * Vitest coverage collector. + * Runs vitest with coverage and parses the Istanbul coverage-final.json. + * Caches results to .cache/vitest-coverage.json. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// Coverage data older than this is considered stale and gets a warning flag +const FRESHNESS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + +const CACHE_FILE = 'vitest-coverage.json'; + +function computeFileCoverage(fileData) { + const s = fileData.s || {}; + const b = fileData.b || {}; + const f = fileData.f || {}; + + const stmtTotal = Object.keys(s).length; + const stmtCovered = Object.values(s).filter(v => v > 0).length; + + let branchTotal = 0; + let branchCovered = 0; + for (const branches of Object.values(b)) { + for (const count of branches) { + branchTotal++; + if (count > 0) branchCovered++; + } + } + + const fnTotal = Object.keys(f).length; + const fnCovered = Object.values(f).filter(v => v > 0).length; + + return { + statements: stmtTotal > 0 ? (stmtCovered / stmtTotal) * 100 : 100, + branches: branchTotal > 0 ? (branchCovered / branchTotal) * 100 : 100, + functions: fnTotal > 0 ? (fnCovered / fnTotal) * 100 : 100, + stmtTotal, + stmtCovered, + branchTotal, + branchCovered, + fnTotal, + fnCovered, + }; +} + +function parseCoverageFinal(coverageJsonPath) { + if (!existsSync(coverageJsonPath)) return {}; + + const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8')); + const byArea = {}; + let totalStmt = 0, totalStmtCov = 0; + let totalBranch = 0, totalBranchCov = 0; + let totalFn = 0, totalFnCov = 0; + + for (const [filePath, fileData] of Object.entries(raw)) { + const normalized = filePath.replace(/\\/g, '/'); + const srcMatch = normalized.match(/src\/(\w+)\//); + const area = srcMatch ? srcMatch[1] : 'other'; + + const cov = computeFileCoverage(fileData); + + if (!byArea[area]) { + byArea[area] = { + files: 0, + stmtTotal: 0, stmtCovered: 0, + branchTotal: 0, branchCovered: 0, + fnTotal: 0, fnCovered: 0, + }; + } + + byArea[area].files++; + byArea[area].stmtTotal += cov.stmtTotal; + byArea[area].stmtCovered += cov.stmtCovered; + byArea[area].branchTotal += cov.branchTotal; + byArea[area].branchCovered += cov.branchCovered; + byArea[area].fnTotal += cov.fnTotal; + byArea[area].fnCovered += cov.fnCovered; + + totalStmt += cov.stmtTotal; + totalStmtCov += cov.stmtCovered; + totalBranch += cov.branchTotal; + totalBranchCov += cov.branchCovered; + totalFn += cov.fnTotal; + totalFnCov += cov.fnCovered; + } + + // Compute percentages + const result = {}; + for (const [area, data] of Object.entries(byArea)) { + result[area] = { + files: data.files, + statements: data.stmtTotal > 0 ? (data.stmtCovered / data.stmtTotal) * 100 : 100, + branches: data.branchTotal > 0 ? (data.branchCovered / data.branchTotal) * 100 : 100, + functions: data.fnTotal > 0 ? (data.fnCovered / data.fnTotal) * 100 : 100, + }; + } + + result._total = { + statements: totalStmt > 0 ? (totalStmtCov / totalStmt) * 100 : 0, + branches: totalBranch > 0 ? (totalBranchCov / totalBranch) * 100 : 0, + functions: totalFn > 0 ? (totalFnCov / totalFn) * 100 : 0, + }; + + return result; +} + +export async function collectVitestCoverage(root, cacheDir, quick) { + const cacheFile = resolve(cacheDir, CACHE_FILE); + + const clientDir = resolve(root, 'Client/tauri-client'); + + if (quick && existsSync(cacheFile)) { + console.log(' [TS] Using cached coverage data'); + let cached; + try { + cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + } catch { + console.warn(' [TS] Corrupt cache — falling through to fresh collection'); + cached = null; + } + if (cached) { + // Still evaluate freshness of the underlying coverage-final.json + const coveragePath = resolve(clientDir, 'coverage/coverage-final.json'); + if (existsSync(coveragePath)) { + try { + const covStat = statSync(coveragePath); + const ageMs = Date.now() - covStat.mtimeMs; + if (ageMs > FRESHNESS_MAX_AGE_MS) { + cached.stale = true; + console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`); + } + } catch { /* stat failed, leave stale flag as-is */ } + } + return cached; + } + } + if (!existsSync(clientDir)) { + console.log(' [TS] Client directory not found, skipping'); + return {}; + } + + // Check if coverage-final.json already exists (from a previous test run) + const coveragePath = resolve(clientDir, 'coverage/coverage-final.json'); + let needsRun = !existsSync(coveragePath); + let stale = false; + + // Check freshness — don't blindly trust old coverage data + if (!needsRun) { + try { + const covStat = statSync(coveragePath); + const ageMs = Date.now() - covStat.mtimeMs; + if (ageMs > FRESHNESS_MAX_AGE_MS) { + stale = true; + console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`); + if (!quick) { + needsRun = true; // Full mode re-runs stale coverage + } + } + } catch { /* stat failed, treat as needing a run */ needsRun = true; } + } + + if (needsRun && !quick) { + console.log(' [TS] Running vitest with coverage (this may take a moment)...'); + try { + execFileSync('npx', ['vitest', 'run', '--coverage'], { + cwd: clientDir, + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 10 * 1024 * 1024, + }); + } catch { + // vitest may exit non-zero but still produce coverage + } + } else if (!needsRun) { + console.log(' [TS] Using existing coverage-final.json'); + } + + let coverageData = {}; + if (existsSync(coveragePath)) { + coverageData = parseCoverageFinal(coveragePath); + } + + // Get test count from a quick vitest --reporter=json run if available + let testCount = 0; + const reportPath = resolve(clientDir, 'coverage-report.json'); + if (existsSync(reportPath)) { + try { + const report = JSON.parse(readFileSync(reportPath, 'utf8')); + testCount = report.numTotalTests ?? 0; + } catch { /* skip */ } + } + + // Instead of running vitest again, count files from coverage data + if (testCount === 0 && Object.keys(coverageData).length > 0) { + try { + const coverageJsonPath = resolve(clientDir, 'coverage', 'coverage-final.json'); + if (existsSync(coverageJsonPath)) { + const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8')); + testCount = Object.keys(raw).length; + } + } catch { /* keep testCount as 0 */ } + } + + const result = { + timestamp: new Date().toISOString(), + areas: coverageData, + testCount, + stale, + }; + + writeFileSync(cacheFile, JSON.stringify(result, null, 2)); + console.log(` [TS] Coverage collected for ${Object.keys(coverageData).length} areas, ${testCount} tests`); + return result; +} diff --git a/tools/project-map/lib/worktree-manager.mjs b/tools/project-map/lib/worktree-manager.mjs new file mode 100644 index 00000000..83d905fd --- /dev/null +++ b/tools/project-map/lib/worktree-manager.mjs @@ -0,0 +1,387 @@ +/** + * Worktree Manager — git worktree lifecycle for isolated agent execution. + * + * Each agent job gets its own worktree under .worktrees/. + * Changes stay isolated until explicitly merged back. + * + * Lifecycle: + * createWorktree() → agent runs → mergeWorktree() → destroyWorktree() + * + * State machine: + * queued → provisioning → running → review → merged → archived + * → failed → (retry → queued) + * → cancelled + */ +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + symlinkSync, + cpSync, + rmSync, + readdirSync, + lstatSync, +} from 'node:fs'; +import { resolve, join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const WORKTREES_DIR = '.worktrees'; +const BRANCH_PREFIX = 'agent/'; +const GIT_TIMEOUT = 30_000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function git(args, cwd, opts = {}) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + timeout: opts.timeout ?? GIT_TIMEOUT, + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }).trim(); +} + +/** + * Link or copy .claude/ and CLAUDE.md into a worktree so agents + * inherit project config, skills, and MCP server settings. + * + * Strategy: try symlink first (works if Developer Mode enabled on Windows). + * If symlink fails (EPERM), fall back to a full copy. + * + * @returns {'symlink'|'copy'|'none'} — method used + */ +function inheritConfig(rootDir, worktreePath) { + let method = 'none'; + + const claudeDir = resolve(rootDir, '.claude'); + const claudeMd = resolve(rootDir, 'CLAUDE.md'); + const targetDir = resolve(worktreePath, '.claude'); + const targetMd = resolve(worktreePath, 'CLAUDE.md'); + + if (existsSync(claudeDir) && !existsSync(targetDir)) { + // Try symlink + try { + symlinkSync(claudeDir, targetDir, 'junction'); // junction works without admin on Windows + method = 'symlink'; + } catch { + // Fallback to copy + try { + cpSync(claudeDir, targetDir, { recursive: true }); + method = 'copy'; + } catch (copyErr) { + console.error(` [Worktree] Failed to copy .claude/: ${copyErr.message}`); + } + } + } + + if (existsSync(claudeMd) && !existsSync(targetMd)) { + try { + symlinkSync(claudeMd, targetMd, 'file'); + } catch { + try { + cpSync(claudeMd, targetMd); + } catch { /* non-critical */ } + } + } + + return method; +} + +/** + * Remove inherited config from a worktree before destruction. + * Must handle both symlinks and copies. + */ +function removeInheritedConfig(worktreePath) { + const targets = [ + resolve(worktreePath, '.claude'), + resolve(worktreePath, 'CLAUDE.md'), + ]; + + for (const target of targets) { + if (!existsSync(target)) continue; + try { + rmSync(target, { recursive: true, force: true }); + } catch (err) { + console.error(` [Worktree] Failed to remove ${target}: ${err.message}`); + } + } +} + +// --------------------------------------------------------------------------- +// Merge mutex — only one merge at a time +// --------------------------------------------------------------------------- + +let mergeInProgress = false; + +export function isMergeInProgress() { + return mergeInProgress; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Create an isolated git worktree for an agent job. + * + * @param {string} root — project root (must be a git repo) + * @param {string} jobId — unique job identifier (used for directory + branch name) + * @returns {{ worktreePath: string, branchName: string, configMethod: string }} + */ +export function createWorktree(root, jobId) { + const worktreesBase = resolve(root, WORKTREES_DIR); + if (!existsSync(worktreesBase)) { + mkdirSync(worktreesBase, { recursive: true }); + } + + const worktreePath = resolve(worktreesBase, jobId); + const branchName = `${BRANCH_PREFIX}${jobId}`; + + if (existsSync(worktreePath)) { + throw new Error(`Worktree already exists for job ${jobId}`); + } + + // Create worktree with a new branch based on current HEAD + git(['worktree', 'add', worktreePath, '-b', branchName], root); + + // Inherit project config + const configMethod = inheritConfig(root, worktreePath); + + return { worktreePath, branchName, configMethod }; +} + +/** + * Destroy a worktree and its branch. + * Handles the Windows-specific cleanup order: remove config → remove dir → prune → delete branch. + * + * @param {string} root — project root + * @param {string} jobId — job identifier + */ +export function destroyWorktree(root, jobId) { + const worktreePath = resolve(root, WORKTREES_DIR, jobId); + const branchName = `${BRANCH_PREFIX}${jobId}`; + + if (existsSync(worktreePath)) { + // Step 1: Remove inherited config (symlinks/copies) first + removeInheritedConfig(worktreePath); + + // Step 2: Try git worktree remove + try { + git(['worktree', 'remove', worktreePath, '--force'], root); + } catch { + // Fallback: force-remove directory + prune + try { + rmSync(worktreePath, { recursive: true, force: true }); + } catch (rmErr) { + console.error(` [Worktree] rm failed for ${jobId}: ${rmErr.message}`); + } + try { + git(['worktree', 'prune'], root); + } catch { /* best effort */ } + } + } else { + // Directory already gone — just prune + try { + git(['worktree', 'prune'], root); + } catch { /* best effort */ } + } + + // Step 3: Delete the branch + try { + git(['branch', '-D', branchName], root); + } catch { + // Branch may already be deleted or never created + } +} + +/** + * List all active worktrees. + * + * @param {string} root — project root + * @returns {Array<{ path: string, branch: string, head: string, jobId: string|null }>} + */ +export function listWorktrees(root) { + let raw; + try { + raw = git(['worktree', 'list', '--porcelain'], root); + } catch { + return []; + } + + const worktrees = []; + let current = {}; + + for (const line of raw.split('\n')) { + if (line.startsWith('worktree ')) { + if (current.path) worktrees.push(current); + current = { path: line.slice(9) }; + } else if (line.startsWith('HEAD ')) { + current.head = line.slice(5); + } else if (line.startsWith('branch ')) { + current.branch = line.slice(7); + } else if (line === '') { + if (current.path) worktrees.push(current); + current = {}; + } + } + if (current.path) worktrees.push(current); + + // Filter to agent worktrees only and extract jobId + return worktrees + .filter(w => w.branch && w.branch.includes(BRANCH_PREFIX)) + .map(w => ({ + ...w, + jobId: w.branch.replace(`refs/heads/${BRANCH_PREFIX}`, ''), + })); +} + +/** + * Merge a worktree's branch back into the current branch. + * + * Guards: + * - Only one merge at a time (mutex) + * - Working tree must be clean + * + * @param {string} root — project root + * @param {string} jobId — job identifier + * @param {string} [message] — optional merge commit message + * @returns {{ success: boolean, filesChanged: number, commitSha: string|null, conflicts: string[] }} + */ +export function mergeWorktree(root, jobId, message) { + if (mergeInProgress) { + return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'merge_in_progress' }; + } + + mergeInProgress = true; + + try { + // Guard: working tree must be clean + try { + git(['diff', '--quiet'], root); + git(['diff', '--quiet', '--cached'], root); + } catch { + return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'working_tree_dirty' }; + } + + const branchName = `${BRANCH_PREFIX}${jobId}`; + const commitMsg = message || `agent: merge results from ${jobId}`; + + // Guard: branch must exist + try { + git(['rev-parse', '--verify', branchName], root); + } catch { + return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'branch_not_found' }; + } + + // Attempt merge + try { + git(['merge', branchName, '--no-ff', '-m', commitMsg], root, { timeout: 60_000 }); + } catch (mergeErr) { + // Check if it's a conflict + try { + const conflictRaw = git(['diff', '--name-only', '--diff-filter=U'], root); + const conflicts = conflictRaw.split('\n').filter(Boolean); + + if (conflicts.length > 0) { + // Abort the merge + try { git(['merge', '--abort'], root); } catch { /* may already be clean */ } + return { success: false, filesChanged: 0, commitSha: null, conflicts }; + } + } catch { /* fall through */ } + + // Not a conflict — some other error + try { git(['merge', '--abort'], root); } catch { /* best effort */ } + throw mergeErr; + } + + // Success — get stats + const commitSha = git(['rev-parse', '--short', 'HEAD'], root); + let filesChanged = 0; + try { + const stat = git(['diff', '--stat', 'HEAD~1..HEAD', '--numstat'], root); + filesChanged = stat.split('\n').filter(Boolean).length; + } catch { /* non-critical */ } + + return { success: true, filesChanged, commitSha, conflicts: [] }; + } finally { + mergeInProgress = false; + } +} + +/** + * Clean up stale worktrees — those whose agent process is dead. + * Call on server startup. + * + * @param {string} root — project root + * @param {function} isJobAlive — callback (jobId) => boolean, checks if the agent process is still running + * @returns {{ cleaned: number, errors: string[] }} + */ +export function cleanupStaleWorktrees(root, isJobAlive) { + const worktreesBase = resolve(root, WORKTREES_DIR); + if (!existsSync(worktreesBase)) return { cleaned: 0, errors: [] }; + + let entries; + try { + entries = readdirSync(worktreesBase); + } catch { + return { cleaned: 0, errors: [] }; + } + + let cleaned = 0; + const errors = []; + + for (const entry of entries) { + const entryPath = resolve(worktreesBase, entry); + try { + const stat = lstatSync(entryPath); + if (!stat.isDirectory()) continue; + } catch { + continue; + } + + // Check if the agent for this worktree is still alive + const alive = typeof isJobAlive === 'function' ? isJobAlive(entry) : false; + if (alive) continue; + + // Dead worktree — clean up + try { + destroyWorktree(root, entry); + cleaned += 1; + } catch (err) { + errors.push(`${entry}: ${err.message}`); + } + } + + return { cleaned, errors }; +} + +/** + * Get disk usage estimate for a worktree directory (in bytes). + * Best-effort — returns 0 on failure. + */ +export function getWorktreeDiskUsage(root, jobId) { + const worktreePath = resolve(root, WORKTREES_DIR, jobId); + if (!existsSync(worktreePath)) return 0; + + try { + // Use git to count the worktree overhead (not the full repo — worktrees share objects) + const countOutput = git(['count-objects', '-vH'], worktreePath); + // Parse "size-pack: 42.00 MiB" or similar + const match = countOutput.match(/size-pack:\s*([\d.]+)\s*(\w+)/); + if (match) { + const size = parseFloat(match[1]); + const unit = match[2].toLowerCase(); + if (unit.startsWith('kib') || unit.startsWith('k')) return Math.round(size * 1024); + if (unit.startsWith('mib') || unit.startsWith('m')) return Math.round(size * 1024 * 1024); + if (unit.startsWith('gib') || unit.startsWith('g')) return Math.round(size * 1024 * 1024 * 1024); + return Math.round(size); + } + } catch { /* best effort */ } + + return 0; +} diff --git a/tools/project-map/package.json b/tools/project-map/package.json new file mode 100644 index 00000000..c849d424 --- /dev/null +++ b/tools/project-map/package.json @@ -0,0 +1,13 @@ +{ + "name": "owncord-project-map", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Project map generator for OwnCord — completeness, testing, priorities", + "scripts": { + "map": "node index.mjs", + "map:quick": "node index.mjs --quick", + "map:research": "node index.mjs --research", + "test": "node --test --test-concurrency=1 tests/*.test.mjs" + } +} diff --git a/tools/project-map/server.mjs b/tools/project-map/server.mjs new file mode 100644 index 00000000..e71fc167 --- /dev/null +++ b/tools/project-map/server.mjs @@ -0,0 +1,661 @@ +#!/usr/bin/env node +/** + * Project Map Web Dashboard + * Run: node tools/project-map/server.mjs + * Open: http://localhost:3333 + */ +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import { readFileSync, writeFileSync as _writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { scanModules } from './lib/scanner.mjs'; +import { collectGoCoverage } from './lib/go-coverage.mjs'; +import { collectVitestCoverage } from './lib/vitest-coverage.mjs'; +import { parseBacklog } from './lib/backlog-parser.mjs'; +import { scorePriorities } from './lib/priority-engine.mjs'; +import { scanGitHistory } from './lib/git-scanner.mjs'; +import { parseSessionHistory } from './lib/session-parser.mjs'; +import { scanTechnicalDebt } from './lib/debt-scanner.mjs'; +import { buildImportGraph } from './lib/import-graph.mjs'; +import { generateSuggestions, markWorkedOn, setStrategy } from './lib/suggestion-engine.mjs'; +import { createFileWatcher, createSSEManager } from './lib/file-watcher.mjs'; +import { generatePlan, startSession, getSessionStatus, pollChanges, endSession, recoverStaleSession } from './lib/session-manager.mjs'; +import { healthCheck, getJobs, createJob, cancelJob, getJobResult, processQueue, recoverOrphans, pruneResults, getLiveOutput, getJobDiff, parseActivityHints, getActiveCount, getMaxConcurrent, setMaxConcurrent, getActiveAgentIds } from './lib/agent-manager.mjs'; +import { mergeWorktree, destroyWorktree, listWorktrees, isMergeInProgress, getWorktreeDiskUsage } from './lib/worktree-manager.mjs'; +import { generateBriefing } from './lib/morning-briefing.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); +const CACHE_DIR = resolve(__dirname, '.cache'); +let PORT = parseInt(process.env.PORT || '3333', 10); +if (isNaN(PORT) || PORT < 1 || PORT > 65535) { console.error(`Invalid PORT "${process.env.PORT}", using 3333`); PORT = 3333; } + +if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); + +// Auth token — generated per process, required for privileged endpoints +const AUTH_TOKEN = process.env.PROJECT_MAP_TOKEN || randomBytes(24).toString('hex'); + +// In-memory cache (mode-aware) +let cachedData = null; +let cachedDataMode = null; // 'quick' or 'full' +let collectInflight = null; + +// Valid job types +const VALID_JOB_TYPES = new Set(['research', 'write-tests', 'code-review', 'security-audit', 'fix-debt', 'custom']); +const JOB_ID_RE = /^job-\d+(-[a-f0-9]+)?$/; + +async function collectData(quick = true) { + console.log(` Collecting data (${quick ? 'quick' : 'full'} mode)...`); + + const modules = await scanModules(ROOT); + const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick); + const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick); + const backlog = await parseBacklog(ROOT); + const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog); + + let gitData = null, sessionData = null, debtData = null, importGraph = null; + try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Git] Error:', e.message); } + try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Session] Error:', e.message); } + try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Debt] Error:', e.message); } + try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Graph] Error:', e.message); } + + let suggestions = null; + try { + suggestions = await generateSuggestions(CACHE_DIR, { + priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData, + }); + } catch (e) { console.error(' [Suggestions] Error:', e.message); } + + // Agent jobs + let agentJobs = null; + try { agentJobs = getJobs(CACHE_DIR); } catch (e) { console.error(' [Jobs] Error:', e.message); } + + // Morning briefing + let briefing = null; + try { briefing = await generateBriefing(ROOT, CACHE_DIR, { sessionData, suggestions, backlog, agentJobs }); } catch (e) { console.error(' [Briefing] Error:', e.message); } + + return { + modules, goCoverage, vitestCoverage, backlog, priorities, + gitData, sessionData, debtData, importGraph, suggestions, + agentJobs, briefing, + agentHealth: null, // populated on demand + timestamp: new Date().toISOString(), + }; +} + +// SSE manager for live updates +const sse = createSSEManager(); + +// File watcher for auto-refresh +const watcher = createFileWatcher(ROOT, (change) => { + console.log(` [Watch] Change detected: ${change.filename}`); + cachedData = null; + sse.broadcast({ type: 'file-change', ...change }); +}); + +// Valid strategy values +const VALID_STRATEGIES = new Set(['balanced', 'bugs-first', 'coverage-first', 'momentum-first', 'debt-first']); + +// Parse JSON body from POST requests (with size limit and error handling) +async function parseBody(req) { + return new Promise((resolve) => { + let body = ''; + let destroyed = false; + req.on('data', chunk => { + if (destroyed) return; + body += chunk; + if (body.length > 4096) { destroyed = true; req.destroy(); resolve(null); } + }); + req.on('end', () => { + if (destroyed) return; + try { resolve(JSON.parse(body)); } catch { resolve(null); } + }); + req.on('error', () => { if (!destroyed) resolve(null); }); + }); +} + +function json(res, status, data) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +// Auto-process agent queue on an interval +let processInterval = null; + +// Diff poller — polls git diff every 2s while a job is running +let diffPollerInterval = null; + +function startDiffPoller() { + if (diffPollerInterval) return; + console.log(' [Diff] Starting diff poller (2s interval)'); + diffPollerInterval = setInterval(() => { + try { + const { jobs } = getJobs(CACHE_DIR); + const running = jobs.filter(j => j.status === 'running' && j.baselineSha); + if (running.length === 0) { + stopDiffPoller(); + return; + } + for (const job of running) { + try { + const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []); + sse.broadcast({ + type: 'job-diff', + jobId: job.id, + files: diffData.files, + diffs: diffData.diffs, + freshness: diffData.freshness, + }); + } catch (err) { + console.error(` [Diff] Error polling job ${job.id}:`, err.message); + } + } + } catch { /* silent */ } + }, 2000); +} + +function stopDiffPoller() { + if (!diffPollerInterval) return; + console.log(' [Diff] Stopping diff poller'); + clearInterval(diffPollerInterval); + diffPollerInterval = null; +} + +// Check bearer token for privileged endpoints +function isAuthorized(req) { + const auth = req.headers['authorization'] || ''; + return auth === `Bearer ${AUTH_TOKEN}`; +} + +function requireAuth(req, res) { + if (isAuthorized(req)) return true; + json(res, 401, { error: 'Unauthorized — pass Authorization: Bearer ' }); + return false; +} + +const BIND_HOST = process.env.PROJECT_MAP_HOST || '127.0.0.1'; + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + res.setHeader('Access-Control-Allow-Origin', `http://localhost:${PORT}`); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + try { + // === EXISTING ROUTES === + + if (url.pathname === '/api/data') { + const quick = url.searchParams.get('full') !== '1'; + const requestedMode = quick ? 'quick' : 'full'; + // Don't reuse quick cache for full requests; always refresh if mode changed or forced + const cacheValid = cachedData && cachedDataMode === requestedMode && !url.searchParams.has('refresh'); + // Also allow full cache to serve quick requests (full is a superset) + const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !url.searchParams.has('refresh'); + if (!cacheValid && !fullCacheForQuick) { + if (!collectInflight) { + collectInflight = collectData(quick).then(data => { + cachedData = data; + cachedDataMode = requestedMode; + collectInflight = null; + return data; + }).catch(err => { + collectInflight = null; + throw err; + }); + } + await collectInflight; + } + // Strip agentJobs and job-derived briefing fields from unauthenticated responses + if (!isAuthorized(req)) { + const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData; + if (fullBriefing) { + const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing; + publicData.briefing = publicBriefing; + } + json(res, 200, publicData); + } else { + json(res, 200, cachedData); + } + return; + } + + if (url.pathname === '/api/events') { + sse.handleConnection(req, res); + return; + } + + if (url.pathname === '/api/worked-on' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const body = await parseBody(req); + if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } + if (typeof body.module !== 'string' || !body.module || body.module.length > 100 || !/^[\w:/-]+$/.test(body.module)) { + json(res, 400, { error: 'Invalid module name' }); return; + } + markWorkedOn(CACHE_DIR, body.module); + cachedData = null; cachedDataMode = null; + json(res, 200, { ok: true }); + return; + } + + if (url.pathname === '/api/strategy' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const body = await parseBody(req); + if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } + if (!VALID_STRATEGIES.has(body.strategy)) { + json(res, 400, { error: `Invalid strategy. Valid: ${[...VALID_STRATEGIES].join(', ')}` }); return; + } + setStrategy(CACHE_DIR, body.strategy); + cachedData = null; cachedDataMode = null; + json(res, 200, { ok: true }); + return; + } + + // === SESSION ROUTES === + + if (url.pathname === '/api/session/plan' && req.method === 'GET') { + // Unauthenticated — returns non-sensitive planning suggestions + const plan = await generatePlan(ROOT, CACHE_DIR); + json(res, 200, plan); + return; + } + + if (url.pathname === '/api/session/start' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const state = startSession(ROOT, CACHE_DIR); + sse.broadcast({ type: 'session-started' }); + json(res, 200, state); + return; + } + + if (url.pathname === '/api/session/status' && req.method === 'GET') { + const status = getSessionStatus(CACHE_DIR); + // If active, poll for latest changes + if (status.active) { + try { pollChanges(ROOT, CACHE_DIR); } catch { /* best effort */ } + const updated = getSessionStatus(CACHE_DIR); + json(res, 200, updated); + } else { + json(res, 200, status); + } + return; + } + + if (url.pathname === '/api/session/end' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const result = endSession(ROOT, CACHE_DIR); + cachedData = null; // Vault changed + sse.broadcast({ type: 'session-ended', ...result }); + json(res, 200, result); + return; + } + + // === AGENT JOB ROUTES === + + if (url.pathname === '/api/agent/health' && req.method === 'GET') { + const health = healthCheck(); + json(res, 200, health); + return; + } + + if (url.pathname === '/api/jobs' && req.method === 'GET') { + if (!requireAuth(req, res)) return; + const jobs = getJobs(CACHE_DIR); + json(res, 200, jobs); + return; + } + + if (url.pathname === '/api/jobs' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const body = await parseBody(req); + if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } + if (!VALID_JOB_TYPES.has(body.type)) { + json(res, 400, { error: `Invalid job type. Valid: ${[...VALID_JOB_TYPES].join(', ')}` }); return; + } + if (!body.target || typeof body.target !== 'string' || !/^[\w:/-]+$/.test(body.target) || body.target.length > 100) { + json(res, 400, { error: 'Invalid target: must be alphanumeric with :/-_ only, max 100 chars' }); return; + } + if (body.type === 'custom') { + if (typeof body.customPrompt !== 'string' || body.customPrompt.trim().length === 0) { + json(res, 400, { error: 'customPrompt required for custom jobs' }); return; + } + if (body.customPrompt.length > 8000) { + json(res, 400, { error: 'customPrompt exceeds 8000 char limit' }); return; + } + } + // Health check before allowing job creation + const health = healthCheck(); + if (!health.available) { + json(res, 503, { error: 'Claude CLI not available', details: health.error }); return; + } + const job = createJob(CACHE_DIR, { + type: body.type, + target: body.target, + priority: body.priority, + customPrompt: body.customPrompt, + }); + json(res, 201, job); + return; + } + + // DELETE /api/jobs/:id + const deleteMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/); + if (deleteMatch && req.method === 'DELETE') { + if (!requireAuth(req, res)) return; + const jobId = deleteMatch[1]; + if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } + try { + cancelJob(CACHE_DIR, jobId); + json(res, 200, { ok: true }); + } catch (err) { + json(res, 404, { error: err.message }); + } + return; + } + + // GET /api/jobs/:id/result + const resultMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/result$/); + if (resultMatch && req.method === 'GET') { + if (!requireAuth(req, res)) return; + const jobId = resultMatch[1]; + if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } + const result = getJobResult(CACHE_DIR, jobId); + json(res, 200, result); + return; + } + + // GET /api/jobs/:id/output — live output for running jobs + const outputMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/output$/); + if (outputMatch && req.method === 'GET') { + if (!requireAuth(req, res)) return; + const jobId = outputMatch[1]; + if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } + const output = getLiveOutput(jobId); + json(res, 200, { output }); + return; + } + + // GET /api/jobs/:id/diff — current git diff for a job + const diffMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/diff$/); + if (diffMatch && req.method === 'GET') { + if (!requireAuth(req, res)) return; + const jobId = diffMatch[1]; + if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } + const { jobs } = getJobs(CACHE_DIR); + const job = jobs.find(j => j.id === jobId); + if (!job) { json(res, 404, { error: 'Job not found' }); return; } + if (!job.baselineSha) { json(res, 200, { files: [], diffs: {}, freshness: new Date().toISOString() }); return; } + try { + const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []); + json(res, 200, diffData); + } catch (err) { + json(res, 500, { error: 'Failed to compute diff: ' + err.message }); + } + return; + } + + if (url.pathname === '/api/jobs/process' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + startDiffPoller(); + const result = await processQueue(ROOT, CACHE_DIR, + (jobId, chunk) => { + sse.broadcast({ type: 'job-output', jobId, chunk }); + const hints = parseActivityHints(chunk); + for (const hint of hints) { + sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file }); + } + }, + (jobId, message) => { + sse.broadcast({ type: 'job-warning', jobId, message }); + }, + ); + if (result.launched > 0) { + sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() }); + const { jobs } = getJobs(CACHE_DIR); + if (!jobs.some(j => j.status === 'running')) stopDiffPoller(); + } + json(res, 200, result); + return; + } + + // POST /api/jobs/:id/merge — merge worktree back to current branch + const mergeMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/merge$/); + if (mergeMatch && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const jobId = mergeMatch[1]; + if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } + const { jobs } = getJobs(CACHE_DIR); + const job = jobs.find(j => j.id === jobId); + if (!job) { json(res, 404, { error: 'Job not found' }); return; } + if (job.status !== 'review') { json(res, 400, { error: `Cannot merge job with status "${job.status}" — must be in review` }); return; } + + // Broadcast merge lock + sse.broadcast({ type: 'merge-lock', locked: true, jobId }); + const body = await parseBody(req); + const message = body?.message || undefined; + + const result = mergeWorktree(ROOT, jobId, message); + + if (result.error === 'merge_in_progress') { + sse.broadcast({ type: 'merge-lock', locked: false, jobId }); + json(res, 423, { error: 'Another merge is in progress' }); + return; + } + if (result.error === 'working_tree_dirty') { + sse.broadcast({ type: 'merge-lock', locked: false, jobId }); + json(res, 400, { error: 'Working tree has uncommitted changes — commit or stash first' }); + return; + } + if (result.error === 'branch_not_found') { + sse.broadcast({ type: 'merge-lock', locked: false, jobId }); + json(res, 410, { error: 'Branch no longer exists — worktree was already cleaned up. Use Dismiss to clear this job.' }); + return; + } + if (!result.success && result.conflicts.length > 0) { + sse.broadcast({ type: 'merge-lock', locked: false, jobId }); + json(res, 409, { error: 'merge_conflict', conflicts: result.conflicts }); + return; + } + + // Success — destroy worktree and update job status + try { destroyWorktree(ROOT, jobId); } catch { /* best effort */ } + // Update job status to merged + const s = getJobs(CACHE_DIR); // refresh + // Direct queue file update for status change + const allJobs = s.jobs.map(j => + j.id === jobId ? { ...j, status: 'merged', worktreePath: null, branchName: null } : j, + ); + const { writeFileSync: wfs } = await import('node:fs'); + wfs(resolve(CACHE_DIR, 'agent-queue.json'), JSON.stringify(allJobs, null, 2)); + + cachedData = null; + sse.broadcast({ type: 'merge-lock', locked: false, jobId }); + sse.broadcast({ type: 'job-update', jobId, status: 'merged' }); + json(res, 200, { success: true, filesChanged: result.filesChanged, commitSha: result.commitSha }); + return; + } + + // === FLEET ROUTES === + + if (url.pathname === '/api/fleet/status' && req.method === 'GET') { + const { jobs } = getJobs(CACHE_DIR); + const active = jobs.filter(j => j.status === 'running' || j.status === 'provisioning').length; + const queued = jobs.filter(j => j.status === 'queued').length; + const review = jobs.filter(j => j.status === 'review').length; + const worktrees = listWorktrees(ROOT).map(w => ({ + jobId: w.jobId, + path: w.path, + branch: w.branch, + diskMB: Math.round(getWorktreeDiskUsage(ROOT, w.jobId) / (1024 * 1024)), + })); + json(res, 200, { active, queued, review, maxConcurrent: getMaxConcurrent(), worktrees }); + return; + } + + if (url.pathname === '/api/fleet/config' && req.method === 'POST') { + if (!requireAuth(req, res)) return; + const body = await parseBody(req); + if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } + if (typeof body.maxConcurrent === 'number') { + if (body.maxConcurrent < 1 || body.maxConcurrent > 8) { + json(res, 400, { error: 'maxConcurrent must be between 1 and 8' }); return; + } + setMaxConcurrent(body.maxConcurrent); + } + // Save to user prefs for persistence + const prefsPath = resolve(CACHE_DIR, 'user-prefs.json'); + let prefs = {}; + try { prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); } catch { /* new file */ } + if (body.maxConcurrent) prefs.maxConcurrent = body.maxConcurrent; + if (body.autopilot) prefs.autopilot = { ...prefs.autopilot, ...body.autopilot }; + _writeFileSync(prefsPath, JSON.stringify(prefs, null, 2)); + json(res, 200, { ok: true }); + return; + } + + if (url.pathname === '/api/worktrees' && req.method === 'GET') { + if (!requireAuth(req, res)) return; + const worktrees = listWorktrees(ROOT); + json(res, 200, { worktrees }); + return; + } + + // === BRIEFING ROUTE === + + if (url.pathname === '/api/briefing' && req.method === 'GET') { + // Use cached data if available, otherwise collect fresh + if (!cachedData) cachedData = await collectData(true); + const authenticated = isAuthorized(req); + const briefing = await generateBriefing(ROOT, CACHE_DIR, { + sessionData: cachedData.sessionData, + suggestions: cachedData.suggestions, + backlog: cachedData.backlog, + // Only pass agent jobs for authenticated callers + agentJobs: authenticated ? getJobs(CACHE_DIR) : null, + }); + if (!authenticated) { + // Strip all agent-job-derived fields from unauthenticated responses + const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = briefing; + json(res, 200, publicBriefing); + } else { + json(res, 200, briefing); + } + return; + } + + // === DASHBOARD HTML === + + if (url.pathname === '/' || url.pathname === '/index.html') { + const html = readFileSync(resolve(__dirname, 'dashboard.html'), 'utf8'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + return; + } + + res.writeHead(404); + res.end('Not found'); + } catch (err) { + console.error(` [Server] ${req.method} ${url.pathname} error:`, err); + json(res, 500, { error: 'Internal server error' }); + } +}); + +// Startup recovery +async function startup() { + console.log('\n Project Map Dashboard'); + console.log(` http://${BIND_HOST}:${PORT}?token=${AUTH_TOKEN}`); + // Also write token to a file for programmatic access + try { + _writeFileSync(resolve(CACHE_DIR, 'token.txt'), AUTH_TOKEN, { mode: 0o600 }); + } catch { /* non-critical */ } + + // Recover stale sessions + try { + const recovery = recoverStaleSession(ROOT, CACHE_DIR); + if (recovery.recovered) console.log(' [Session] Recovered stale session'); + } catch (e) { console.error(' [Session] Recovery error:', e.message); } + + // Recover orphaned agent jobs (fleet-aware — also cleans stale worktrees) + try { + const orphans = recoverOrphans(CACHE_DIR, ROOT); + if (orphans.recovered > 0) console.log(` [Fleet] Recovered ${orphans.recovered} orphaned jobs`); + } catch (e) { console.error(' [Fleet] Orphan recovery error:', e.message); } + + // Load saved fleet config + try { + const prefsPath = resolve(CACHE_DIR, 'user-prefs.json'); + if (existsSync(prefsPath)) { + const prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); + if (prefs.maxConcurrent) setMaxConcurrent(prefs.maxConcurrent); + } + } catch { /* use defaults */ } + + // Prune old results + try { + const pruned = pruneResults(CACHE_DIR); + if (pruned.pruned > 0) console.log(` [Agent] Pruned ${pruned.pruned} old results`); + } catch (e) { /* silent */ } + + // Agent health check + try { + const health = healthCheck(); + if (health.available) { + console.log(` [Agent] Claude CLI available (${health.version})`); + } else { + console.log(' [Agent] Claude CLI not available — agent jobs disabled'); + } + } catch { /* silent */ } + + // Auto-process agent queue every 30 seconds (fleet-aware) + processInterval = setInterval(async () => { + try { + const { jobs } = getJobs(CACHE_DIR); + const hasQueued = jobs.some(j => j.status === 'queued'); + const activeCount = getActiveCount(); + if (hasQueued && activeCount < getMaxConcurrent()) { + console.log(` [Fleet] Auto-processing queue (${activeCount}/${getMaxConcurrent()} active)...`); + startDiffPoller(); + const result = await processQueue(ROOT, CACHE_DIR, + (jobId, chunk) => { + sse.broadcast({ type: 'job-output', jobId, chunk }); + const hints = parseActivityHints(chunk); + for (const hint of hints) { + sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file }); + } + }, + (jobId, message) => { + sse.broadcast({ type: 'job-warning', jobId, message }); + }, + ); + if (result.launched > 0) { + sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() }); + } + const { jobs: currentJobs } = getJobs(CACHE_DIR); + if (!currentJobs.some(j => j.status === 'running')) stopDiffPoller(); + } + } catch { /* silent */ } + }, 30000); + + console.log(' File watcher active — dashboard auto-refreshes on changes'); + console.log(` Fleet: max ${getMaxConcurrent()} concurrent agents, auto-processes every 30s\n`); +} + +server.listen(PORT, BIND_HOST, startup); + +process.on('SIGINT', () => { + if (processInterval) clearInterval(processInterval); + stopDiffPoller(); + watcher.close(); + server.close(); + process.exit(0); +}); + +// Exports for testing — token only available in test environment +export function getAuthToken() { + if (process.env.NODE_ENV !== 'test') throw new Error('Token access restricted to test environment'); + return AUTH_TOKEN; +} +export { isAuthorized, server, BIND_HOST }; diff --git a/tools/project-map/tests/agent-diff.test.mjs b/tools/project-map/tests/agent-diff.test.mjs new file mode 100644 index 00000000..a0bfd813 --- /dev/null +++ b/tools/project-map/tests/agent-diff.test.mjs @@ -0,0 +1,104 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; +import { parseActivityHints, getJobDiff } from '../lib/agent-manager.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const GIT_TEST_DIR = resolve(__dirname, '.test-git-diff'); + +describe('parseActivityHints', () => { + it('extracts Go file paths', () => { + const chunk = 'Reading Server/ws/handler.go for analysis'; + const hints = parseActivityHints(chunk); + assert.equal(hints.length, 1); + assert.equal(hints[0].file, 'Server/ws/handler.go'); + }); + + it('extracts TypeScript file paths', () => { + const chunk = 'Checking Client/tauri-client/src/stores/voice.ts'; + const hints = parseActivityHints(chunk); + assert.equal(hints.length, 1); + assert.equal(hints[0].file, 'Client/tauri-client/src/stores/voice.ts'); + }); + + it('extracts Rust file paths', () => { + const chunk = 'Found issue in src-tauri/src/commands.rs'; + const hints = parseActivityHints(chunk); + assert.equal(hints.length, 1); + assert.equal(hints[0].file, 'src-tauri/src/commands.rs'); + }); + + it('extracts multiple paths from one chunk', () => { + const chunk = 'Comparing Server/ws/handler.go with Server/ws/conn.go'; + const hints = parseActivityHints(chunk); + assert.equal(hints.length, 2); + }); + + it('returns empty array when no paths found', () => { + const hints = parseActivityHints('Just some regular text here'); + assert.deepEqual(hints, []); + }); + + it('handles null/undefined input', () => { + assert.deepEqual(parseActivityHints(null), []); + assert.deepEqual(parseActivityHints(undefined), []); + assert.deepEqual(parseActivityHints(''), []); + }); +}); + +describe('getJobDiff', () => { + beforeEach(() => { + rmSync(GIT_TEST_DIR, { recursive: true, force: true }); + mkdirSync(GIT_TEST_DIR, { recursive: true }); + execSync('git init', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); + writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'original\n'); + execSync('git add . && git commit -m "init"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); + }); + + afterEach(() => { + rmSync(GIT_TEST_DIR, { recursive: true, force: true }); + }); + + it('returns empty files when nothing changed', () => { + const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); + const result = getJobDiff(GIT_TEST_DIR, headSha, []); + assert.deepEqual(result.files, []); + assert.deepEqual(result.diffs, {}); + }); + + it('detects modified files with +/- counts', () => { + const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); + writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\nline2\n'); + const result = getJobDiff(GIT_TEST_DIR, headSha, []); + assert.equal(result.files.length, 1); + assert.equal(result.files[0].path, 'file.txt'); + assert.equal(result.files[0].status, 'M'); + assert.ok(result.files[0].additions >= 1); + assert.ok(typeof result.diffs['file.txt'] === 'string'); + assert.ok(result.diffs['file.txt'].includes('modified')); + }); + + it('detects new files', () => { + const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); + writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'brand new\n'); + const result = getJobDiff(GIT_TEST_DIR, headSha, []); + const newFile = result.files.find(f => f.path === 'newfile.txt'); + assert.ok(newFile); + assert.equal(newFile.status, 'A'); + }); + + it('excludes pre-existing dirty files', () => { + const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); + writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\n'); + writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'new\n'); + const result = getJobDiff(GIT_TEST_DIR, headSha, ['file.txt']); + assert.equal(result.files.length, 1); + assert.equal(result.files[0].path, 'newfile.txt'); + assert.ok(!result.diffs['file.txt']); + }); +}); diff --git a/tools/project-map/tests/agent-queue.test.mjs b/tools/project-map/tests/agent-queue.test.mjs new file mode 100644 index 00000000..bf1b1845 --- /dev/null +++ b/tools/project-map/tests/agent-queue.test.mjs @@ -0,0 +1,89 @@ +/** + * Tests for agent-manager.mjs — verifies that a spawn failure + * does not wedge the queue (lock must be released). + * + * Uses setSpawnCommand() to inject a guaranteed-nonexistent binary, + * making the test deterministic regardless of whether Claude CLI is installed. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, rmSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createJob, + processQueue, + getJobs, + isQueueLocked, + resetQueueLock, + setSpawnCommand, +} from '../lib/agent-manager.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_CACHE = resolve(__dirname, '.test-cache-agent'); +const FAKE_ROOT = resolve(__dirname, '.test-root-agent'); + +// A binary name that will never exist on any platform +const NONEXISTENT_CMD = '__owncord_test_no_such_binary_12345__'; + +describe('agent queue lock recovery', () => { + beforeEach(() => { + mkdirSync(TEST_CACHE, { recursive: true }); + mkdirSync(FAKE_ROOT, { recursive: true }); + resetQueueLock(); + setSpawnCommand(NONEXISTENT_CMD); + }); + + afterEach(() => { + resetQueueLock(); + setSpawnCommand('claude'); + rmSync(TEST_CACHE, { recursive: true, force: true }); + rmSync(FAKE_ROOT, { recursive: true, force: true }); + }); + + it('releases lock after spawn error (command not found)', async () => { + createJob(TEST_CACHE, { + type: 'research', + target: 'api', + priority: 1, + }); + + assert.equal(isQueueLocked(), false, 'lock should be free before processing'); + + // processQueue spawns the nonexistent command which will ENOENT + const result = await processQueue(FAKE_ROOT, TEST_CACHE); + + assert.equal(result.processed, true, 'should have attempted processing'); + assert.equal(isQueueLocked(), false, 'lock MUST be released after spawn failure'); + }); + + it('allows subsequent jobs after a failed spawn', async () => { + createJob(TEST_CACHE, { type: 'research', target: 'ws', priority: 1 }); + + // First job will fail due to nonexistent command + await processQueue(FAKE_ROOT, TEST_CACHE); + assert.equal(isQueueLocked(), false, 'lock should be free after first failure'); + + // Create and process another job — should not be blocked + createJob(TEST_CACHE, { type: 'code-review', target: 'auth', priority: 1 }); + const result2 = await processQueue(FAKE_ROOT, TEST_CACHE); + + // It should attempt processing (not be stuck on 'locked') + assert.notEqual(result2.reason, 'locked', 'queue should not be wedged'); + }); + + it('marks job as dead after max retries', async () => { + createJob(TEST_CACHE, { type: 'research', target: 'db', priority: 1 }); + + // Exhaust retries (maxRetries defaults to 2) + await processQueue(FAKE_ROOT, TEST_CACHE); + await processQueue(FAKE_ROOT, TEST_CACHE); + + const { jobs } = getJobs(TEST_CACHE); + const job = jobs.find(j => j.target === 'db'); + assert.ok(job, 'job should still exist in queue'); + assert.equal(job.status, 'dead', 'job should be dead after max retries'); + assert.ok(job.error, 'job should have an error message'); + }); +}); diff --git a/tools/project-map/tests/backlog-parser.test.mjs b/tools/project-map/tests/backlog-parser.test.mjs new file mode 100644 index 00000000..471e4c06 --- /dev/null +++ b/tools/project-map/tests/backlog-parser.test.mjs @@ -0,0 +1,80 @@ +/** + * Tests for backlog-parser.mjs — verifies both task syntaxes are parsed + * and open/done counts match. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../../..'); + +// Direct regex test (same regex the parser uses) +const TASK_RE = /^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/; + +describe('backlog parser regex', () => { + it('matches colon-inside-bold format: **T-165:**', () => { + const line = '- [x] **T-165:** Fix BUG-046 — wrap voice switchActiveDevice — 2026-03-28'; + const m = line.match(TASK_RE); + assert.ok(m, 'should match'); + assert.equal(m[1], 'x'); + assert.equal(m[2], '165'); + assert.ok(m[3].startsWith('Fix BUG-046')); + }); + + it('matches colon-after-bold format: **T-033**:', () => { + const line = '- [x] **T-033**: Fix voice state broadcast silent DB failures — 2026-03-21'; + const m = line.match(TASK_RE); + assert.ok(m, 'should match'); + assert.equal(m[1], 'x'); + assert.equal(m[2], '033'); + assert.ok(m[3].startsWith('Fix voice state')); + }); + + it('matches open tasks (unchecked)', () => { + const line = '- [ ] **T-195:** User profile/password/session management endpoints'; + const m = line.match(TASK_RE); + assert.ok(m, 'should match'); + assert.equal(m[1], ' '); + assert.equal(m[2], '195'); + }); + + it('does not match non-task lines', () => { + assert.equal('## Some Section'.match(TASK_RE), null); + assert.equal('- Regular bullet point'.match(TASK_RE), null); + assert.equal('- [ ] No bold task id here'.match(TASK_RE), null); + }); +}); + +describe('backlog parser integration', () => { + it('parses actual Backlog.md and counts match', async () => { + const { parseBacklog } = await import('../lib/backlog-parser.mjs'); + const result = await parseBacklog(ROOT); + + // Read the file directly and count with both regexes + const backlogPath = resolve(ROOT, 'docs/brain/02-Tasks/Backlog.md'); + if (!existsSync(backlogPath)) { + // Skip if file doesn't exist in CI + return; + } + const content = readFileSync(backlogPath, 'utf8'); + const lines = content.split('\n'); + + let expectedOpen = 0; + let expectedDone = 0; + for (const line of lines) { + const m = line.match(TASK_RE); + if (m) { + if (m[1] === 'x') expectedDone++; + else expectedOpen++; + } + } + + assert.equal(result.openCount, expectedOpen, `open count mismatch: got ${result.openCount}, expected ${expectedOpen}`); + assert.equal(result.doneCount, expectedDone, `done count mismatch: got ${result.doneCount}, expected ${expectedDone}`); + assert.ok(result.tasks.length > 0, 'should find tasks'); + assert.equal(result.tasks.length, expectedOpen + expectedDone, 'total tasks should match'); + }); +}); diff --git a/tools/project-map/tests/briefing-and-normalization.test.mjs b/tools/project-map/tests/briefing-and-normalization.test.mjs new file mode 100644 index 00000000..97268b1b --- /dev/null +++ b/tools/project-map/tests/briefing-and-normalization.test.mjs @@ -0,0 +1,254 @@ +/** + * Behavioral tests for: + * 1. Root-level Server/*.go files normalize to 'server-root' + * 2. Unauthenticated /api/briefing does not leak agent job data + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; + +// --------------------------------------------------------------------------- +// 1. Root package normalization — behavioral tests calling the real functions +// --------------------------------------------------------------------------- + +describe('root package normalization', () => { + let resolveModule, fileToModule; + + before(async () => { + ({ resolveModule } = await import('../lib/git-scanner.mjs')); + ({ fileToModule } = await import('../lib/session-manager.mjs')); + }); + + const rootFiles = ['Server/main.go', 'Server/go.mod', 'Server/go.sum', 'Server/Makefile']; + const subDirFiles = [ + { path: 'Server/ws/handler.go', expected: 'ws' }, + { path: 'Server/db/models.go', expected: 'db' }, + { path: 'Server/auth/middleware.go', expected: 'auth' }, + ]; + + for (const filePath of rootFiles) { + it(`git-scanner: ${filePath} → server-root`, () => { + assert.equal(resolveModule(filePath), 'server-root'); + }); + + it(`session-manager: ${filePath} → server-root`, () => { + assert.equal(fileToModule(filePath), 'server-root'); + }); + } + + for (const { path, expected } of subDirFiles) { + it(`git-scanner: ${path} → ${expected}`, () => { + assert.equal(resolveModule(path), expected); + }); + + it(`session-manager: ${path} → ${expected}`, () => { + assert.equal(fileToModule(path), expected); + }); + } + + it('git-scanner and session-manager agree on all test paths', () => { + const all = [...rootFiles, ...subDirFiles.map(s => s.path)]; + for (const p of all) { + assert.equal(resolveModule(p), fileToModule(p), + `mismatch on ${p}: git-scanner=${resolveModule(p)}, session-manager=${fileToModule(p)}`); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2. Briefing leak — real HTTP test against the server +// --------------------------------------------------------------------------- + +describe('/api/briefing unauthenticated leak prevention', () => { + let generateBriefing; + + before(async () => { + ({ generateBriefing } = await import('../lib/morning-briefing.mjs')); + }); + + it('generateBriefing with agentJobs=null produces no job data', async () => { + const briefing = await generateBriefing('.', '.', { + sessionData: null, + suggestions: null, + backlog: null, + agentJobs: null, + }); + assert.deepEqual(briefing.agentResults, [], 'agentResults should be empty'); + assert.deepEqual(briefing.deadJobs, [], 'deadJobs should be empty'); + assert.deepEqual(briefing.autoQueueSuggestions, [], 'autoQueueSuggestions should be empty when agentJobs is null'); + assert.equal(briefing.stats.completedJobs, 0); + assert.equal(briefing.stats.deadJobs, 0); + }); + + it('generateBriefing with agentJobs includes job data', async () => { + const fakeJobs = { + jobs: [ + { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, + { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, + ], + }; + const briefing = await generateBriefing('.', '.', { + sessionData: null, + suggestions: null, + backlog: null, + agentJobs: fakeJobs, + }); + assert.ok(briefing.agentResults.length > 0, 'should have agent results'); + assert.ok(briefing.deadJobs.length > 0, 'should have dead jobs'); + assert.ok(briefing.agentResults[0].id === 'j-1', 'should contain job id'); + }); + + it('server strips job fields from unauthenticated briefing response', async () => { + // Read server source to verify the stripping logic + const { readFileSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); + + // The /api/briefing route must check auth and strip fields + const briefingRoute = source.slice( + source.indexOf("url.pathname === '/api/briefing'"), + source.indexOf("url.pathname === '/api/briefing'") + 800 + ); + assert.ok(briefingRoute.includes('isAuthorized(req)'), 'briefing route should check auth'); + assert.ok(briefingRoute.includes('agentJobs: authenticated'), 'should only pass agentJobs when authenticated'); + assert.ok(briefingRoute.includes('agentResults'), 'should strip agentResults'); + assert.ok(briefingRoute.includes('deadJobs'), 'should strip deadJobs'); + assert.ok(briefingRoute.includes('autoQueueSuggestions'), 'should strip autoQueueSuggestions'); + assert.ok(briefingRoute.includes('stats'), 'should strip stats'); + }); + + it('HTTP: unauthenticated briefing has no job fields', async () => { + // Spin up a minimal test server that mimics the briefing route logic + const { generateBriefing } = await import('../lib/morning-briefing.mjs'); + + const fakeJobs = { + jobs: [ + { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, + { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, + ], + }; + + const TOKEN = 'test-secret-token'; + + const srv = createServer(async (req, res) => { + const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`; + const briefing = await generateBriefing('.', '.', { + sessionData: null, + suggestions: null, + backlog: null, + agentJobs: authenticated ? fakeJobs : null, + }); + let payload; + if (!authenticated) { + const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...pub } = briefing; + payload = pub; + } else { + payload = briefing; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }); + + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + + try { + // Unauthenticated request + const unauthRes = await fetch(`http://127.0.0.1:${port}/api/briefing`); + const unauthData = await unauthRes.json(); + + assert.equal(unauthData.agentResults, undefined, 'agentResults must not be present'); + assert.equal(unauthData.deadJobs, undefined, 'deadJobs must not be present'); + assert.equal(unauthData.stats, undefined, 'stats must not be present'); + assert.equal(unauthData.autoQueueSuggestions, undefined, 'autoQueueSuggestions must not be present'); + assert.ok(unauthData.greeting, 'greeting should still be present'); + assert.ok(unauthData.suggestedTasks !== undefined, 'suggestedTasks should still be present'); + + // Authenticated request — should have all fields + const authRes = await fetch(`http://127.0.0.1:${port}/api/briefing`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + const authData = await authRes.json(); + + assert.ok(Array.isArray(authData.agentResults), 'authenticated should have agentResults'); + assert.ok(Array.isArray(authData.deadJobs), 'authenticated should have deadJobs'); + assert.ok(authData.stats, 'authenticated should have stats'); + assert.ok(authData.agentResults.length > 0, 'authenticated should have job data'); + } finally { + srv.close(); + } + }); + + it('HTTP: unauthenticated /api/data strips job fields from embedded briefing', async () => { + const { generateBriefing } = await import('../lib/morning-briefing.mjs'); + + const fakeJobs = { + jobs: [ + { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, + { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, + ], + }; + + const TOKEN = 'test-data-token'; + + // Simulate the /api/data route logic with an embedded briefing + const srv = createServer(async (req, res) => { + const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`; + + // Build a cachedData-like object with briefing containing job data + const briefing = await generateBriefing('.', '.', { + sessionData: null, + suggestions: null, + backlog: null, + agentJobs: fakeJobs, + }); + const cachedData = { modules: [], agentJobs: fakeJobs, briefing, timestamp: new Date().toISOString() }; + + let payload; + if (!authenticated) { + const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData; + if (fullBriefing) { + const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing; + publicData.briefing = publicBriefing; + } + payload = publicData; + } else { + payload = cachedData; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }); + + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + + try { + // Unauthenticated — briefing should be sanitized + const unauthRes = await fetch(`http://127.0.0.1:${port}/api/data`); + const unauthData = await unauthRes.json(); + + assert.equal(unauthData.agentJobs, undefined, 'agentJobs must not be present'); + assert.ok(unauthData.briefing, 'briefing should still exist'); + assert.equal(unauthData.briefing.agentResults, undefined, 'briefing.agentResults must not be present'); + assert.equal(unauthData.briefing.deadJobs, undefined, 'briefing.deadJobs must not be present'); + assert.equal(unauthData.briefing.stats, undefined, 'briefing.stats must not be present'); + assert.equal(unauthData.briefing.autoQueueSuggestions, undefined, 'briefing.autoQueueSuggestions must not be present'); + assert.ok(unauthData.briefing.greeting, 'briefing.greeting should remain'); + + // Authenticated — full data + const authRes = await fetch(`http://127.0.0.1:${port}/api/data`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + const authData = await authRes.json(); + + assert.ok(authData.agentJobs, 'authenticated should have agentJobs'); + assert.ok(authData.briefing.agentResults, 'authenticated briefing should have agentResults'); + assert.ok(authData.briefing.deadJobs, 'authenticated briefing should have deadJobs'); + assert.ok(authData.briefing.stats, 'authenticated briefing should have stats'); + } finally { + srv.close(); + } + }); +}); diff --git a/tools/project-map/tests/cache-mode.test.mjs b/tools/project-map/tests/cache-mode.test.mjs new file mode 100644 index 00000000..3cd644c1 --- /dev/null +++ b/tools/project-map/tests/cache-mode.test.mjs @@ -0,0 +1,58 @@ +/** + * Tests for server cache behavior — verifies that quick-mode cached data + * is not incorrectly reused for full-mode requests. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('server cache mode awareness', () => { + it('source code has mode-aware cache logic', async () => { + const { readFileSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); + + // Should track cache mode separately + assert.ok(source.includes('cachedDataMode'), 'should have cachedDataMode variable'); + + // The /api/data handler should compare requested mode with cached mode + assert.ok(source.includes('requestedMode'), 'should compute requestedMode'); + + // Quick cache should not be valid for full requests + assert.ok( + source.includes('cachedDataMode === requestedMode'), + 'should compare cached mode with requested mode' + ); + }); + + it('cache logic prevents quick data from serving full requests', () => { + // Simulate the cache validation logic extracted from server.mjs + function isCacheValid(cachedData, cachedDataMode, requestedMode, hasRefresh) { + const cacheValid = cachedData && cachedDataMode === requestedMode && !hasRefresh; + const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !hasRefresh; + return cacheValid || fullCacheForQuick; + } + + const data = { some: 'data' }; + + // Quick cache should serve quick requests + assert.ok(isCacheValid(data, 'quick', 'quick', false), 'quick cache serves quick request'); + + // Quick cache should NOT serve full requests + assert.ok(!isCacheValid(data, 'quick', 'full', false), 'quick cache must NOT serve full request'); + + // Full cache should serve full requests + assert.ok(isCacheValid(data, 'full', 'full', false), 'full cache serves full request'); + + // Full cache should serve quick requests (superset) + assert.ok(isCacheValid(data, 'full', 'quick', false), 'full cache serves quick request'); + + // No cached data + assert.ok(!isCacheValid(null, null, 'quick', false), 'no cache is not valid'); + + // Refresh flag forces re-fetch + assert.ok(!isCacheValid(data, 'quick', 'quick', true), 'refresh flag invalidates cache'); + }); +}); diff --git a/tools/project-map/tests/module-naming.test.mjs b/tools/project-map/tests/module-naming.test.mjs new file mode 100644 index 00000000..887151a6 --- /dev/null +++ b/tools/project-map/tests/module-naming.test.mjs @@ -0,0 +1,129 @@ +/** + * Tests for module name normalization — verifies that git-scanner, + * scanner, backlog-parser, and debt-scanner all produce the same + * canonical module keys so signals merge correctly. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Import the resolveModule function from git-scanner by reading the source +// and extracting the function (git-scanner doesn't export it). +// Instead, we test the expected behavior by checking the patterns. +describe('module naming consistency', () => { + // These are the canonical module names used by scanner.mjs: + // Go packages: 'api', 'ws', 'db', 'auth', 'config', 'admin', etc. + // TS areas: 'lib', 'stores', 'components', 'pages', 'styles' + // Rust: 'tauri-rust' + + // git-scanner resolveModule should produce the SAME names (no prefixes). + // We test this by importing the git-scanner module and checking its output. + + it('git-scanner resolveModule produces unprefixed Go module names', async () => { + // Read the git-scanner source and check that resolveModule for Server paths + // no longer returns "go:" prefixed names + const { readFileSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8'); + + // Ensure "go:" prefix is NOT used in resolveModule + const resolveModuleFn = source.match(/function resolveModule\([\s\S]*?\n\}/); + assert.ok(resolveModuleFn, 'should find resolveModule function'); + + const fnBody = resolveModuleFn[0]; + assert.ok(!fnBody.includes('`go:'), 'resolveModule should not produce go: prefixed names'); + assert.ok(!fnBody.includes('`ts:'), 'resolveModule should not produce ts: prefixed names'); + assert.ok(!fnBody.includes("'go:"), 'resolveModule should not produce go: prefixed names (single quotes)'); + assert.ok(!fnBody.includes("'ts:"), 'resolveModule should not produce ts: prefixed names (single quotes)'); + }); + + it('session-manager fileToModule matches git-scanner convention', async () => { + const { readFileSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const sessionSource = readFileSync(resolve(__dirname, '../lib/session-manager.mjs'), 'utf8'); + const gitSource = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8'); + + // Both should use 'server-root' for Server/ root, not 'go:root' + assert.ok(sessionSource.includes("'server-root'"), 'session-manager should use server-root'); + assert.ok(gitSource.includes("'server-root'"), 'git-scanner should use server-root'); + + // Both should use 'client-config' not 'ts:config' + assert.ok(sessionSource.includes("'client-config'"), 'session-manager should use client-config'); + assert.ok(gitSource.includes("'client-config'"), 'git-scanner should use client-config'); + }); + + it('backlog and scanner module names have no type prefixes', async () => { + const { readFileSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const backlogSource = readFileSync(resolve(__dirname, '../lib/backlog-parser.mjs'), 'utf8'); + + // Backlog MODULE_KEYWORDS keys should be plain names + const keyMatch = backlogSource.match(/const MODULE_KEYWORDS = \{([\s\S]*?)\n\};/); + assert.ok(keyMatch, 'should find MODULE_KEYWORDS'); + + // Ensure no prefixed keys + assert.ok(!keyMatch[1].includes("'go:"), 'backlog keywords should not have go: prefix'); + assert.ok(!keyMatch[1].includes("'ts:"), 'backlog keywords should not have ts: prefix'); + }); + + it('suggestion engine merges modules from different sources under same key', async () => { + const { generateSuggestions } = await import('../lib/suggestion-engine.mjs'); + const { mkdirSync, rmSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const tmpCache = resolve(__dirname, '.test-cache-naming'); + mkdirSync(tmpCache, { recursive: true }); + + try { + // Simulate data from different sources all using 'components' (not 'ts:components') + const result = await generateSuggestions(tmpCache, { + priorities: [{ name: 'components', coverage: 40, type: 'typescript' }], + backlog: { + byPhase: { + bug: [{ id: 'T-001', description: 'UI button bug', modules: ['components'] }], + }, + }, + gitData: { + commitsByModule: { + components: { count: 15, lastCommit: new Date().toISOString() }, + }, + staleness: {}, + }, + debtData: { + summary: { + byModule: { + components: { markers: 3, largeFiles: 1, longFunctions: 0 }, + }, + }, + }, + }); + + // All signals should merge into a single 'components' entry + const compSuggestion = result.suggestions.find(s => s.module === 'components'); + assert.ok(compSuggestion, 'should have a components suggestion'); + + // It should have signals from coverage, bugs, git, AND debt + const signalTypes = new Set(compSuggestion.signals.map(s => s.signal)); + assert.ok(signalTypes.has('coverage-gap'), 'should have coverage signal'); + assert.ok(signalTypes.has('open-bug'), 'should have bug signal'); + assert.ok(signalTypes.has('high-churn'), 'should have churn signal'); + assert.ok(signalTypes.has('debt-markers'), 'should have debt signal'); + + // Verify NO 'ts:components' entry exists + const prefixed = result.suggestions.find(s => s.module === 'ts:components'); + assert.equal(prefixed, undefined, 'should NOT have ts:components — should be merged into components'); + } finally { + rmSync(tmpCache, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/project-map/tests/server-auth.test.mjs b/tools/project-map/tests/server-auth.test.mjs new file mode 100644 index 00000000..036ddc20 --- /dev/null +++ b/tools/project-map/tests/server-auth.test.mjs @@ -0,0 +1,73 @@ +/** + * Tests for server.mjs — verifies that all mutating / job / session + * endpoints require auth and that the server binds to localhost. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('server security configuration', () => { + // Helper — read server source once per test (stateless, no port conflict) + async function readServerSource() { + const { readFileSync } = await import('node:fs'); + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); + return readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); + } + + it('binds to localhost by default', async () => { + const source = await readServerSource(); + assert.ok(source.includes("'127.0.0.1'"), 'default bind host should be 127.0.0.1'); + assert.ok(source.includes('BIND_HOST'), 'should use BIND_HOST variable'); + assert.ok(source.includes('server.listen(PORT, BIND_HOST'), 'server.listen should include host parameter'); + }); + + it('does not use wildcard CORS', async () => { + const source = await readServerSource(); + const corsLines = source.split('\n').filter(l => l.includes('Access-Control-Allow-Origin')); + for (const line of corsLines) { + assert.ok(!line.includes("'*'"), `CORS should not be wildcard: ${line.trim()}`); + } + }); + + it('generates an auth token on startup', async () => { + const source = await readServerSource(); + assert.ok(source.includes('randomBytes'), 'should use crypto.randomBytes for token generation'); + assert.ok(source.includes('AUTH_TOKEN'), 'should define AUTH_TOKEN'); + assert.ok(source.includes('Bearer'), 'should use Bearer token scheme'); + }); + + // ---- Protected route checks ---- + + const protectedRoutes = [ + { label: 'POST /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'POST'" }, + { label: 'DELETE /api/jobs/:id', pattern: "deleteMatch && req.method === 'DELETE'" }, + { label: 'GET /api/jobs/:id/result', pattern: "resultMatch && req.method === 'GET'" }, + { label: 'POST /api/jobs/process', pattern: "url.pathname === '/api/jobs/process'" }, + { label: 'GET /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'GET'" }, + { label: 'POST /api/worked-on', pattern: "url.pathname === '/api/worked-on'" }, + { label: 'POST /api/strategy', pattern: "url.pathname === '/api/strategy'" }, + { label: 'POST /api/session/start', pattern: "url.pathname === '/api/session/start'" }, + { label: 'POST /api/session/end', pattern: "url.pathname === '/api/session/end'" }, + ]; + + for (const { label, pattern } of protectedRoutes) { + it(`protects ${label} with requireAuth`, async () => { + const source = await readServerSource(); + const idx = source.indexOf(pattern); + assert.ok(idx !== -1, `should have handler for ${label}`); + const routeBlock = source.slice(idx, idx + 300); + assert.ok(routeBlock.includes('requireAuth'), `${label} should call requireAuth`); + }); + } + + it('strips agentJobs and briefing job fields from unauthenticated /api/data responses', async () => { + const source = await readServerSource(); + // The /api/data handler should check isAuthorized and strip agentJobs + assert.ok(source.includes('isAuthorized(req)'), '/api/data should check isAuthorized'); + assert.ok(source.includes('agentJobs: _stripped'), 'should destructure out agentJobs for public response'); + // Should also strip job-derived fields from the embedded briefing + assert.ok(source.includes('briefing: fullBriefing'), 'should destructure out briefing for sanitization'); + assert.ok(source.includes('publicBriefing'), 'should rebuild a public briefing without job fields'); + }); +});