dw-kit 1.8.0-rc.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude/hooks/stop-check.sh +10 -0
  2. package/.claude/rules/dw.md +2 -0
  3. package/.claude/skills/dw-decision/SKILL.md +2 -1
  4. package/.claude/skills/dw-goal/SKILL.md +206 -0
  5. package/.claude/skills/dw-goal-sync/SKILL.md +131 -0
  6. package/.claude/templates/agent-report.md +35 -35
  7. package/.dw/config/agents.yml +8 -0
  8. package/.dw/core/AGENTS.md +53 -53
  9. package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
  10. package/.dw/core/schemas/events/created.schema.json +33 -0
  11. package/.dw/core/schemas/events/debate_agent_failed.schema.json +42 -0
  12. package/.dw/core/schemas/events/debate_agent_replied.schema.json +44 -0
  13. package/.dw/core/schemas/events/debate_agent_started.schema.json +37 -0
  14. package/.dw/core/schemas/events/debate_completed.schema.json +36 -0
  15. package/.dw/core/schemas/events/debate_started.schema.json +47 -0
  16. package/.dw/core/schemas/events/goal_archived.schema.json +32 -0
  17. package/.dw/core/schemas/events/goal_created.schema.json +32 -0
  18. package/.dw/core/schemas/events/goal_field_updated.schema.json +35 -0
  19. package/.dw/core/schemas/events/goal_pivoted.schema.json +36 -0
  20. package/.dw/core/schemas/events/goal_status_changed.schema.json +40 -0
  21. package/.dw/core/schemas/events/goal_task_linked.schema.json +33 -0
  22. package/.dw/core/schemas/events/goal_task_unlinked.schema.json +33 -0
  23. package/.dw/core/schemas/events/index.json +185 -0
  24. package/.dw/core/schemas/events/orchestrator_cancelled.schema.json +29 -0
  25. package/.dw/core/schemas/events/orchestrator_completed.schema.json +38 -0
  26. package/.dw/core/schemas/events/orchestrator_confirm.schema.json +33 -0
  27. package/.dw/core/schemas/events/orchestrator_confirmed.schema.json +33 -0
  28. package/.dw/core/schemas/events/orchestrator_error.schema.json +29 -0
  29. package/.dw/core/schemas/events/orchestrator_pending_dropped.schema.json +29 -0
  30. package/.dw/core/schemas/events/orchestrator_pending_expired.schema.json +32 -0
  31. package/.dw/core/schemas/events/orchestrator_recommend_rejected.schema.json +37 -0
  32. package/.dw/core/schemas/events/orchestrator_recommended.schema.json +33 -0
  33. package/.dw/core/schemas/events/orchestrator_spawn_failed.schema.json +29 -0
  34. package/.dw/core/schemas/events/orchestrator_started.schema.json +33 -0
  35. package/.dw/core/schemas/events/orchestrator_timeout.schema.json +29 -0
  36. package/.dw/core/schemas/events/reconciled.schema.json +29 -0
  37. package/.dw/core/schemas/events/reconciled_stale.schema.json +29 -0
  38. package/.dw/core/schemas/events/session.created.schema.json +39 -0
  39. package/.dw/core/schemas/events/session.reconciled.schema.json +33 -0
  40. package/.dw/core/schemas/events/session.status_changed.schema.json +42 -0
  41. package/.dw/core/schemas/events/spawn_failed.schema.json +29 -0
  42. package/.dw/core/schemas/events/started.schema.json +59 -0
  43. package/.dw/core/schemas/events/stopped.schema.json +33 -0
  44. package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
  45. package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
  46. package/.dw/core/templates/v3/task.md +38 -9
  47. package/.dw/security/advisory-snapshot.json +157 -0
  48. package/LICENSE +201 -21
  49. package/NOTICE +26 -0
  50. package/README.md +5 -2
  51. package/SECURITY.md +87 -0
  52. package/TRADEMARK.md +65 -0
  53. package/bin/dw.mjs +1 -1
  54. package/package.json +13 -5
  55. package/src/cli.mjs +33 -0
  56. package/src/commands/decision-index.mjs +45 -0
  57. package/src/commands/goal-delete.mjs +3 -1
  58. package/src/commands/goal-link.mjs +3 -1
  59. package/src/commands/goal-status.mjs +95 -0
  60. package/src/commands/lint-task.mjs +20 -0
  61. package/src/commands/task-index.mjs +47 -0
  62. package/src/commands/task-migrate.mjs +16 -5
  63. package/src/commands/task-new.mjs +6 -0
  64. package/src/commands/task-summary.mjs +4 -3
  65. package/src/commands/voice.mjs +590 -4
  66. package/src/lib/board-data.mjs +220 -0
  67. package/src/lib/debate.mjs +325 -0
  68. package/src/lib/decision-store.mjs +146 -0
  69. package/src/lib/event-schema.mjs +342 -0
  70. package/src/lib/goal-store.mjs +40 -1
  71. package/src/lib/lint-rules.mjs +10 -1
  72. package/src/lib/orchestrator.mjs +31 -9
  73. package/src/lib/session-store.mjs +36 -4
  74. package/src/lib/task-store.mjs +164 -0
  75. package/src/lib/voice-action.mjs +165 -0
  76. package/src/lib/voice-parser.mjs +13 -0
  77. package/.dw/config/connectors.local.yml +0 -38
  78. package/.dw/core/PILLARS.md +0 -122
  79. package/CLAUDE.md +0 -44
@@ -27,7 +27,7 @@ import { spawn } from 'node:child_process';
27
27
  import yaml from 'js-yaml';
28
28
  import chalk from 'chalk';
29
29
  import { parseVoiceCommand, speakSummaryFor, detectGreeting, suggestCommands, langKey } from '../lib/voice-parser.mjs';
30
- import { runOrchestrator, shortenForTTS } from '../lib/orchestrator.mjs';
30
+ import { runOrchestrator, shortenForTTS, detectDeepMode } from '../lib/orchestrator.mjs';
31
31
  import { loadConnectorsConfig } from './connector.mjs';
32
32
  import { fetchGoogleTts } from '../lib/tts-fallback.mjs';
33
33
  import { loadOrGenerateCert, readCertPair, commonLanHostnames, detectTlsProvider } from '../lib/tls-helpers.mjs';
@@ -38,7 +38,10 @@ import { resolveCommandPath, spawnAgent } from '../lib/spawn-helpers.mjs';
38
38
  // Per-token pending action state (ADR-0014). In-memory; lost on server
39
39
  // restart by design — voice latency budget makes that acceptable.
40
40
  const pendingActions = new Map();
41
- const PENDING_TTL_MS = 30_000;
41
+ // F-45: 30s was too short for voice ASR + thinking + speaking. Owner-reported
42
+ // turn at 2026-05-28 where 'yes' arrived after the window expired. 60s is a
43
+ // kinder default; user can still say 'no'/'huỷ' to cancel explicitly.
44
+ const PENDING_TTL_MS = 60_000;
42
45
 
43
46
  // Returns the array of pending entries that JUST expired this sweep — caller
44
47
  // may surface a one-time toast to the user so they know the pending dropped
@@ -77,6 +80,9 @@ import {
77
80
  openOutputFd, closeFd, isValidSessionId, isAlive, GOAL_MAX_LEN,
78
81
  } from '../lib/session-store.mjs';
79
82
  import { buildSessionTreeData } from '../lib/session-tree.mjs';
83
+ import { startDebate, resolveDebateRoster } from '../lib/debate.mjs';
84
+ import { createSseBroker } from '../lib/sse-broker.mjs';
85
+ import { buildBoardData } from '../lib/board-data.mjs';
80
86
 
81
87
  const CACHE_DIR = '.dw/cache';
82
88
  const VOICE_TOKEN_PATH = '.dw/cache/voice.token';
@@ -234,6 +240,15 @@ async function dispatch({ cmd, rootDir, defaultAgent }) {
234
240
  }
235
241
  }
236
242
 
243
+ // ADR-0015 KR-D: multi-agent live debate. Fire-and-forget — agent replies
244
+ // arrive on the SSE stream /voice/debate-stream as they land.
245
+ if (name === 'debate') {
246
+ if (!args.topic) return { ok: false, error: 'No debate topic recognized' };
247
+ const lang = cmd.lang || 'en-US';
248
+ const result = startDebate({ topic: args.topic, rootDir, lang });
249
+ return result;
250
+ }
251
+
237
252
  return { ok: false, error: `Unknown command: ${name}` };
238
253
  }
239
254
 
@@ -288,6 +303,17 @@ function renderResult({ cmd, result, lang = 'en-US' }) {
288
303
  if (cmd.name === 'stop') {
289
304
  return { display: `${L === 'vi' ? 'Đã dừng' : 'Stopped'}: ${result.data.stopped}`, spoken };
290
305
  }
306
+ if (cmd.name === 'debate') {
307
+ const agents = (result.data?.agents || []).map((a) => a.agent).join(' vs ');
308
+ const head = L === 'vi'
309
+ ? `Bắt đầu tranh luận: ${agents}. Đợi từng agent trả lời…`
310
+ : `Debate kicked off: ${agents}. Streaming replies…`;
311
+ return {
312
+ display: `${head}\n\n_debate_id: ${result.data.debate_id}_`,
313
+ spoken: L === 'vi' ? `Bắt đầu tranh luận giữa ${agents}.` : `Debate kicked off between ${agents}.`,
314
+ debate_id: result.data.debate_id,
315
+ };
316
+ }
291
317
  return { display: L === 'vi' ? 'Xong.' : 'Done.', spoken };
292
318
  }
293
319
 
@@ -372,6 +398,15 @@ function html(token, langOptions = ['en-US'], fallbackMode = 'auto') {
372
398
  <div class="tree-body" id="session-tree-body">Loading…</div>
373
399
  </aside>
374
400
  <div class="main-col">
401
+ <!-- ADR-0015 KR-D: multi-agent debate panel — hidden until a debate fires -->
402
+ <section id="debate-panel" style="display:none; margin-bottom:16px; background:#0b1018; border:1px solid #21262d; border-radius:6px; padding:10px 12px;">
403
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; padding-bottom:6px; border-bottom:1px solid #21262d;">
404
+ <h2 style="font-size:13px; font-weight:600; margin:0">Live debate</h2>
405
+ <span id="debate-meta" style="color:var(--dim); font-size:11px"></span>
406
+ <button id="debate-close" title="Close debate panel" type="button" style="background:transparent; color:var(--dim); border:1px solid #30363d; padding:2px 8px; font-size:12px; border-radius:4px; cursor:pointer">✕</button>
407
+ </div>
408
+ <div id="debate-cols" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;"></div>
409
+ </section>
375
410
  <div class="controls">
376
411
  <button id="mic" aria-pressed="false">Tap to talk</button>
377
412
  <select id="langSelect" title="Recognition + TTS language">${langSelectOptions}</select>
@@ -643,12 +678,118 @@ async function send(text) {
643
678
  replacePlaceholder(placeholderId, { display: data.display || '(no response)', error: !data.ok });
644
679
  speak(data.spoken || '');
645
680
  setStatus('Idle.' + (data.command === 'orchestrator' ? ' (orchestrator)' : ''));
681
+ // ADR-0015 KR-D: if the dispatch returned a debate_id, open the SSE stream
682
+ // and render agent replies as they arrive.
683
+ if (data.command === 'debate' && data.debate_id) {
684
+ openDebateStream(data.debate_id, data.roster || []);
685
+ }
646
686
  } catch (e) {
647
687
  replacePlaceholder(placeholderId, { display: 'Network error: ' + e.message, error: true });
648
688
  setStatus('Network error.');
649
689
  }
650
690
  }
651
691
 
692
+ // ─ ADR-0015 KR-D debate UI ─
693
+
694
+ let debateEventSource = null;
695
+ let debateState = { debate_id: null, columns: {}, queue: [] };
696
+
697
+ function openDebateStream(debate_id, roster) {
698
+ const panel = document.getElementById('debate-panel');
699
+ const cols = document.getElementById('debate-cols');
700
+ const meta = document.getElementById('debate-meta');
701
+ const closeBtn = document.getElementById('debate-close');
702
+ cols.innerHTML = '';
703
+ debateState = { debate_id, columns: {}, queue: [] };
704
+
705
+ // Best-effort: fetch roster from server if dispatch didn't send it.
706
+ if (!roster || !roster.length) {
707
+ fetch('/voice/debate-roster?t=' + encodeURIComponent(TOKEN))
708
+ .then((r) => r.json())
709
+ .then((j) => { if (j.ok && j.data.roster?.length) initColumns(j.data.roster); })
710
+ .catch(() => initColumns(['claude', 'codex']));
711
+ } else {
712
+ initColumns(roster);
713
+ }
714
+
715
+ function initColumns(names) {
716
+ cols.innerHTML = '';
717
+ for (const name of names) {
718
+ const col = document.createElement('div');
719
+ col.style.cssText = 'border:1px solid #21262d; border-radius:4px; padding:8px;';
720
+ const h = document.createElement('div');
721
+ h.style.cssText = 'font-weight:600; color:var(--accent); margin-bottom:6px;';
722
+ h.textContent = name;
723
+ const status = document.createElement('span');
724
+ status.style.cssText = 'color:var(--dim); font-size:11px; margin-left:8px; font-weight:400';
725
+ status.textContent = '(thinking…)';
726
+ h.appendChild(status);
727
+ const body = document.createElement('div');
728
+ body.style.cssText = 'font-size:13px; line-height:1.5; white-space:pre-wrap; color:var(--fg);';
729
+ body.textContent = '';
730
+ col.appendChild(h);
731
+ col.appendChild(body);
732
+ cols.appendChild(col);
733
+ debateState.columns[name] = { col, status, body };
734
+ }
735
+ meta.textContent = 'debate_id ' + debate_id.slice(-6);
736
+ panel.style.display = 'block';
737
+ }
738
+
739
+ if (debateEventSource) { try { debateEventSource.close(); } catch {} debateEventSource = null; }
740
+ debateEventSource = new EventSource('/voice/debate-stream?t=' + encodeURIComponent(TOKEN));
741
+ debateEventSource.addEventListener('message', (ev) => {
742
+ let payload;
743
+ try { payload = JSON.parse(ev.data); } catch { return; }
744
+ if (!payload || payload.debate_id !== debate_id) return;
745
+ if (payload.event === 'debate_agent_replied') {
746
+ const c = debateState.columns[payload.agent];
747
+ if (c) {
748
+ c.status.textContent = '(' + (payload.ms || 0) + 'ms)';
749
+ c.status.style.color = 'var(--ok)';
750
+ c.body.textContent = payload.reply || '';
751
+ // Queue TTS per agent — Web Speech API serializes per-tab.
752
+ if (payload.reply) speakDebate(payload.agent, payload.reply);
753
+ }
754
+ } else if (payload.event === 'debate_agent_failed') {
755
+ const c = debateState.columns[payload.agent];
756
+ if (c) {
757
+ c.status.textContent = '(failed)';
758
+ c.status.style.color = 'var(--err)';
759
+ c.body.textContent = '⚠ ' + (payload.error || 'unknown error');
760
+ }
761
+ } else if (payload.event === 'debate_completed') {
762
+ meta.textContent = 'completed · ' + (payload.agents || []).join(' / ');
763
+ try { debateEventSource.close(); } catch {}
764
+ debateEventSource = null;
765
+ }
766
+ });
767
+ debateEventSource.addEventListener('error', () => { /* keep panel open; SSE retry handled by browser */ });
768
+
769
+ closeBtn.onclick = () => {
770
+ panel.style.display = 'none';
771
+ if (debateEventSource) { try { debateEventSource.close(); } catch {} debateEventSource = null; }
772
+ };
773
+ }
774
+
775
+ // Per-agent TTS voice pick — falls back to the global current voice when an
776
+ // agent has no dedicated voice. Queued (Web Speech API serializes per-tab).
777
+ function speakDebate(agent, text) {
778
+ if (!('speechSynthesis' in window)) return;
779
+ const u = new SpeechSynthesisUtterance(text);
780
+ u.lang = currentLang;
781
+ const voices = window.speechSynthesis.getVoices();
782
+ // Hash agent name to pick a stable voice from the list (lo-fi differentiation).
783
+ if (voices.length) {
784
+ let h = 0;
785
+ for (const ch of agent) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
786
+ const sameLang = voices.filter((v) => v.lang && v.lang.startsWith(currentLang.split('-')[0]));
787
+ const pool = sameLang.length ? sameLang : voices;
788
+ u.voice = pool[h % pool.length];
789
+ }
790
+ window.speechSynthesis.speak(u);
791
+ }
792
+
652
793
  function replacePlaceholder(id, { display, error }) {
653
794
  const el = document.getElementById(id);
654
795
  if (!el) return;
@@ -786,6 +927,9 @@ if (document.readyState === 'interactive' || document.readyState === 'complete')
786
927
  // Refresh tree after any command that may have started/stopped a session.
787
928
  // Wrap \`send\` so the post-command refresh fires for both mic and text input.
788
929
  // 300ms delay lets the index write settle before we re-read.
930
+ // F-44: this is now a fallback — SSE live stream (below) handles realtime
931
+ // refresh for events that touch the index. The wrap stays as belt-and-
932
+ // suspenders in case the SSE socket is closed.
789
933
  const _origSend = send;
790
934
  send = async function(text) {
791
935
  await _origSend(text);
@@ -794,6 +938,338 @@ send = async function(text) {
794
938
  setTimeout(loadSessionTree, 300);
795
939
  }
796
940
  };
941
+
942
+ // ─ F-44: realtime session-tree refresh via /voice/events-stream SSE ─
943
+ //
944
+ // One EventSource lives for the page lifetime. We listen for session.* +
945
+ // goal.* + debate_completed signals and re-fetch the session tree (and any
946
+ // other portfolio surface). Debate-specific events still drive the debate
947
+ // panel through its own openDebateStream() — both share the same broker
948
+ // (same backend file tail), they just filter different event names.
949
+ let _treeSseSource = null;
950
+ let _treeRefreshPending = false;
951
+ function scheduleTreeRefresh() {
952
+ // Debounce 250ms — many events can land in the same tick (e.g. created +
953
+ // status_changed:running fire within a few ms).
954
+ if (_treeRefreshPending) return;
955
+ _treeRefreshPending = true;
956
+ setTimeout(() => { _treeRefreshPending = false; loadSessionTree(); }, 250);
957
+ }
958
+ function openEventsStream() {
959
+ if (_treeSseSource) { try { _treeSseSource.close(); } catch {} _treeSseSource = null; }
960
+ _treeSseSource = new EventSource('/voice/events-stream?t=' + encodeURIComponent(TOKEN));
961
+ _treeSseSource.addEventListener('message', (ev) => {
962
+ let payload;
963
+ try { payload = JSON.parse(ev.data); } catch { return; }
964
+ if (!payload || !payload.event) return;
965
+ const name = payload.event;
966
+ if (name === 'session.created' || name === 'session.status_changed' || name === 'session.reconciled') {
967
+ scheduleTreeRefresh();
968
+ } else if (name === 'debate_completed' || name === 'debate_started') {
969
+ // A debate creates a parent audit session — also refresh tree.
970
+ scheduleTreeRefresh();
971
+ } else if (name.startsWith('goal.')) {
972
+ // Goal field/status/version updates may affect any portfolio view.
973
+ scheduleTreeRefresh();
974
+ }
975
+ // Unknown events: ignore — adapter protocol §4 forward-compat.
976
+ });
977
+ _treeSseSource.addEventListener('error', () => {
978
+ // Browser auto-reconnects via the broker's \`retry: 5000\`. Nothing to do.
979
+ });
980
+ }
981
+ // Open the stream once the page is interactive. SSE survives across many
982
+ // voice turns; restart only if the page reloads.
983
+ if (document.readyState === 'loading') {
984
+ window.addEventListener('DOMContentLoaded', openEventsStream);
985
+ } else {
986
+ openEventsStream();
987
+ }
988
+ </script>
989
+ </body></html>`;
990
+ }
991
+
992
+ // ─ F-49 Đợt 2: Kanban board page ─
993
+ //
994
+ // Self-contained HTML at /board?t=<token>. Polls /voice/board for JSON, then
995
+ // renders three columns: Goals (with task cards), Subtasks (To Do/In Progress/
996
+ // Done counts), Sessions (live). Subscribes to /voice/events-stream for
997
+ // realtime refresh (same SSE broker as the voice page F-44 wiring).
998
+ //
999
+ // Click a subtask card → POST /voice/command with text "start agent for task X"
1000
+ // → the orchestrator + F-43/F-47 expansion handles spawning. Confirmation gate
1001
+ // still applies (ADR-0014).
1002
+ function boardHtml(token) {
1003
+ return `<!DOCTYPE html>
1004
+ <html lang="en"><head>
1005
+ <meta charset="utf-8">
1006
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1007
+ <title>dw board — Kanban</title>
1008
+ <style>
1009
+ :root { color-scheme: light dark; --bg: #0d1117; --fg: #c9d1d9; --accent: #58a6ff; --dim: #6b7280; --err: #f85149; --ok: #3fb950; --warn: #d29922; --card: #161b22; --border: #21262d; }
1010
+ * { box-sizing: border-box; }
1011
+ body { background: var(--bg); color: var(--fg); font: 14px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; margin: 0; padding: 16px; }
1012
+ h1 { font-size: 18px; margin: 0 0 4px 0; }
1013
+ .subtitle { color: var(--dim); font-size: 13px; margin-bottom: 16px; }
1014
+ .topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
1015
+ .topbar a { color: var(--accent); text-decoration: none; font-size: 13px; }
1016
+ .topbar a:hover { text-decoration: underline; }
1017
+ .totals { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--dim); }
1018
+ .totals .pill { background: var(--card); border: 1px solid var(--border); padding: 3px 8px; border-radius: 999px; }
1019
+ .totals .pill .num { color: var(--fg); font-weight: 600; }
1020
+ .columns { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 16px; align-items: start; }
1021
+ @media (max-width: 980px) { .columns { grid-template-columns: 1fr; } }
1022
+ .col { background: #0b1018; border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }
1023
+ .col h2 { font-size: 13px; font-weight: 600; margin: 0 0 8px 0; padding-bottom: 6px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; }
1024
+ .col h2 .count { color: var(--dim); font-weight: 400; font-size: 11px; }
1025
+ .goal-card { background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 4px; padding: 8px 10px; margin-bottom: 10px; }
1026
+ .goal-card.cycle-north-star { border-left-color: var(--warn); }
1027
+ .goal-card .gid { color: var(--accent); font-weight: 600; font-size: 12px; }
1028
+ .goal-card .gtitle { color: var(--fg); font-size: 13px; margin-top: 2px; }
1029
+ .goal-card .gmeta { color: var(--dim); font-size: 11px; margin-top: 4px; }
1030
+ .progress-bar { height: 4px; background: var(--border); border-radius: 2px; margin-top: 6px; overflow: hidden; }
1031
+ .progress-bar .fill { height: 100%; background: var(--ok); transition: width 0.3s; }
1032
+ .task-card { background: #0d1117; border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; margin-top: 6px; margin-left: 12px; cursor: pointer; transition: border-color 0.15s; }
1033
+ .task-card:hover { border-color: var(--accent); }
1034
+ .task-card .ttitle { color: var(--fg); font-size: 12px; font-weight: 500; }
1035
+ .task-card .tmeta { color: var(--dim); font-size: 11px; }
1036
+ .subtask-row { font-size: 11px; padding: 2px 0; color: var(--fg); cursor: pointer; }
1037
+ .subtask-row:hover { color: var(--accent); }
1038
+ .subtask-row.bucket-done { color: var(--dim); text-decoration: line-through; }
1039
+ .subtask-row.bucket-blocked { color: var(--err); }
1040
+ .subtask-row.bucket-in_progress { color: var(--warn); }
1041
+ .session-card { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; margin-bottom: 6px; font-size: 12px; }
1042
+ .session-card .sagent { color: var(--accent); font-weight: 600; }
1043
+ .session-card .sstatus { display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 10px; font-weight: 600; text-transform: uppercase; margin-right: 4px; }
1044
+ .pill-running { background: rgba(63, 185, 80, 0.18); color: var(--ok); }
1045
+ .pill-idle { background: rgba(107, 114, 128, 0.22); color: var(--dim); }
1046
+ .pill-terminal { background: rgba(248, 81, 73, 0.15); color: var(--err); }
1047
+ .empty { color: var(--dim); font-style: italic; font-size: 12px; padding: 8px 0; }
1048
+ .toast { position: fixed; bottom: 16px; right: 16px; background: var(--card); border: 1px solid var(--accent); padding: 10px 14px; border-radius: 6px; font-size: 13px; max-width: 360px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
1049
+ .toast.err { border-color: var(--err); }
1050
+ </style>
1051
+ </head><body>
1052
+ <div class="topbar">
1053
+ <div>
1054
+ <h1>📋 dw board</h1>
1055
+ <div class="subtitle">Goals · Tasks · Subtasks · Sessions — click a subtask to spawn an agent</div>
1056
+ </div>
1057
+ <div style="display:flex; gap:12px; align-items:center;">
1058
+ <a href="/?t=${token}">← voice page</a>
1059
+ <button id="refresh" type="button" style="background:var(--card); color:var(--fg); border:1px solid var(--border); padding:4px 10px; border-radius:4px; cursor:pointer;">↻ refresh</button>
1060
+ </div>
1061
+ </div>
1062
+
1063
+ <div class="totals" id="totals"></div>
1064
+
1065
+ <div class="columns" style="margin-top:14px;">
1066
+ <div class="col" id="col-goals">
1067
+ <h2>Goals + Tasks <span class="count" id="ct-goals"></span></h2>
1068
+ <div id="goals-body">Loading…</div>
1069
+ </div>
1070
+ <div class="col" id="col-subtasks">
1071
+ <h2>Subtasks (open) <span class="count" id="ct-subtasks"></span></h2>
1072
+ <div id="subtasks-body">Loading…</div>
1073
+ </div>
1074
+ <div class="col" id="col-sessions">
1075
+ <h2>Sessions <span class="count" id="ct-sessions"></span></h2>
1076
+ <div id="sessions-body">Loading…</div>
1077
+ </div>
1078
+ </div>
1079
+
1080
+ <div id="toast" class="toast" style="display:none"></div>
1081
+
1082
+ <script>
1083
+ const TOKEN = ${JSON.stringify(token)};
1084
+ let lastData = null;
1085
+
1086
+ function showToast(msg, isErr) {
1087
+ const t = document.getElementById('toast');
1088
+ t.textContent = msg;
1089
+ t.classList.toggle('err', !!isErr);
1090
+ t.style.display = 'block';
1091
+ clearTimeout(t._timer);
1092
+ t._timer = setTimeout(() => { t.style.display = 'none'; }, 4500);
1093
+ }
1094
+
1095
+ async function loadBoard() {
1096
+ try {
1097
+ const res = await fetch('/voice/board?t=' + encodeURIComponent(TOKEN));
1098
+ const j = await res.json();
1099
+ if (!j.ok) throw new Error(j.error || 'fetch failed');
1100
+ lastData = j.data;
1101
+ render(j.data);
1102
+ } catch (e) {
1103
+ showToast('Load failed: ' + e.message, true);
1104
+ }
1105
+ }
1106
+
1107
+ function render(d) {
1108
+ // Totals bar
1109
+ const totalsEl = document.getElementById('totals');
1110
+ totalsEl.innerHTML = '';
1111
+ const pills = [
1112
+ ['goals', d.totals.goals_active + '/' + d.totals.goals_total],
1113
+ ['tasks', d.totals.tasks_active + '/' + d.totals.tasks_total],
1114
+ ['pending', d.totals.subtasks_pending],
1115
+ ['in progress', d.totals.subtasks_in_progress],
1116
+ ['done', d.totals.subtasks_done],
1117
+ ['sessions running', d.totals.sessions_running],
1118
+ ];
1119
+ for (const [k, v] of pills) {
1120
+ const p = document.createElement('span');
1121
+ p.className = 'pill';
1122
+ p.innerHTML = '<span class="num">' + v + '</span> ' + k;
1123
+ totalsEl.appendChild(p);
1124
+ }
1125
+
1126
+ // Column 1 — Goals + their tasks.
1127
+ const gbody = document.getElementById('goals-body');
1128
+ gbody.innerHTML = '';
1129
+ if (!d.goals.length) {
1130
+ gbody.innerHTML = '<div class="empty">No active goals.</div>';
1131
+ }
1132
+ for (const g of d.goals) {
1133
+ const gc = document.createElement('div');
1134
+ gc.className = 'goal-card cycle-' + (g.cycle || '');
1135
+ const gid = document.createElement('div'); gid.className = 'gid';
1136
+ gid.textContent = (g.icon ? g.icon + ' ' : '') + g.goal_id;
1137
+ const gt = document.createElement('div'); gt.className = 'gtitle';
1138
+ gt.textContent = g.title;
1139
+ const gm = document.createElement('div'); gm.className = 'gmeta';
1140
+ gm.textContent = g.status + (g.target_date ? ' · ' + g.target_date : '') + (g.cycle ? ' · ' + g.cycle : '');
1141
+ gc.appendChild(gid); gc.appendChild(gt); gc.appendChild(gm);
1142
+ if (g.progress && Number.isFinite(g.progress.percent)) {
1143
+ const bar = document.createElement('div'); bar.className = 'progress-bar';
1144
+ const fill = document.createElement('div'); fill.className = 'fill';
1145
+ fill.style.width = g.progress.percent + '%';
1146
+ bar.appendChild(fill); gc.appendChild(bar);
1147
+ }
1148
+ for (const t of g.tasks) {
1149
+ const tc = document.createElement('div'); tc.className = 'task-card';
1150
+ const tt = document.createElement('div'); tt.className = 'ttitle';
1151
+ tt.textContent = t.task_id;
1152
+ const tm = document.createElement('div'); tm.className = 'tmeta';
1153
+ tm.textContent = t.status + (t.phase ? ' · ' + t.phase.slice(0, 40) : '');
1154
+ tc.appendChild(tt); tc.appendChild(tm);
1155
+ // Click task → spawn agent (uses orchestrator + F-43/F-47 expansion).
1156
+ tc.onclick = () => triggerSpawnForTask(t, g);
1157
+ gc.appendChild(tc);
1158
+ }
1159
+ gbody.appendChild(gc);
1160
+ }
1161
+ document.getElementById('ct-goals').textContent = '(' + d.goals.length + ')';
1162
+
1163
+ // Column 2 — Open subtasks (pending + in_progress).
1164
+ const sbody = document.getElementById('subtasks-body');
1165
+ sbody.innerHTML = '';
1166
+ const open = [];
1167
+ for (const g of d.goals) {
1168
+ for (const t of g.tasks) {
1169
+ for (const st of t.subtasks) {
1170
+ if (st.status_bucket === 'pending' || st.status_bucket === 'in_progress' || st.status_bucket === 'blocked') {
1171
+ open.push({ goal: g, task: t, st });
1172
+ }
1173
+ }
1174
+ }
1175
+ }
1176
+ if (!open.length) {
1177
+ sbody.innerHTML = '<div class="empty">No open subtasks.</div>';
1178
+ }
1179
+ for (const entry of open.slice(0, 60)) {
1180
+ const row = document.createElement('div');
1181
+ row.className = 'subtask-row bucket-' + entry.st.status_bucket;
1182
+ row.textContent = entry.st.status_icon + ' ' + entry.st.st_id + ' · ' + entry.task.task_id + ' · ' + entry.st.title.slice(0, 80);
1183
+ row.title = entry.st.notes;
1184
+ row.onclick = () => triggerSpawnForSubtask(entry.st, entry.task, entry.goal);
1185
+ sbody.appendChild(row);
1186
+ }
1187
+ document.getElementById('ct-subtasks').textContent = '(' + open.length + ')';
1188
+
1189
+ // Column 3 — Sessions.
1190
+ const sessBody = document.getElementById('sessions-body');
1191
+ sessBody.innerHTML = '';
1192
+ if (!d.sessions.length) {
1193
+ sessBody.innerHTML = '<div class="empty">No sessions.</div>';
1194
+ }
1195
+ for (const s of d.sessions) {
1196
+ const sc = document.createElement('div'); sc.className = 'session-card';
1197
+ const head = document.createElement('div');
1198
+ const pill = document.createElement('span'); pill.className = 'sstatus pill-' + bucketForStatus(s.status); pill.textContent = s.status;
1199
+ const ag = document.createElement('span'); ag.className = 'sagent'; ag.textContent = s.agent;
1200
+ head.appendChild(pill); head.appendChild(ag);
1201
+ const goal = document.createElement('div'); goal.style.color = 'var(--dim)'; goal.style.fontSize = '11px'; goal.textContent = (s.goal_preview || '').slice(0, 80);
1202
+ const id = document.createElement('div'); id.style.color = 'var(--dim)'; id.style.fontSize = '10px'; id.textContent = s.session_id.slice(-12);
1203
+ sc.appendChild(head); sc.appendChild(goal); sc.appendChild(id);
1204
+ sessBody.appendChild(sc);
1205
+ }
1206
+ document.getElementById('ct-sessions').textContent = '(' + d.sessions.length + ')';
1207
+ }
1208
+
1209
+ function bucketForStatus(s) {
1210
+ if (s === 'running' || s === 'starting') return 'running';
1211
+ if (s === 'completed' || s === 'exited') return 'idle';
1212
+ return 'terminal';
1213
+ }
1214
+
1215
+ function triggerSpawnForTask(task, goal) {
1216
+ const text = 'start agent for task ' + task.task_id + ' under goal ' + goal.goal_id;
1217
+ showToast('Asking orchestrator: ' + text);
1218
+ fetch('/voice/command', {
1219
+ method: 'POST',
1220
+ headers: { 'Content-Type': 'application/json', 'X-Voice-Token': TOKEN },
1221
+ body: JSON.stringify({ text, lang: 'en-US' }),
1222
+ }).then((r) => r.json()).then((j) => {
1223
+ if (j.command === 'orchestrator_recommend') {
1224
+ showToast('Orchestrator suggested an action — go to voice page to confirm.');
1225
+ } else if (j.ok) {
1226
+ showToast('Done: ' + (j.spoken || 'OK'));
1227
+ setTimeout(loadBoard, 400);
1228
+ } else {
1229
+ showToast('Failed: ' + (j.error || j.display || 'unknown'), true);
1230
+ }
1231
+ }).catch((e) => showToast('Network error: ' + e.message, true));
1232
+ }
1233
+
1234
+ function triggerSpawnForSubtask(st, task, goal) {
1235
+ const text = 'start agent for subtask ' + st.st_id + ' of task ' + task.task_id + ' under goal ' + goal.goal_id;
1236
+ showToast('Asking orchestrator: ' + text);
1237
+ fetch('/voice/command', {
1238
+ method: 'POST',
1239
+ headers: { 'Content-Type': 'application/json', 'X-Voice-Token': TOKEN },
1240
+ body: JSON.stringify({ text, lang: 'en-US' }),
1241
+ }).then((r) => r.json()).then((j) => {
1242
+ if (j.command === 'orchestrator_recommend') {
1243
+ showToast('Orchestrator suggested an action — go to voice page to confirm.');
1244
+ } else if (j.ok) {
1245
+ showToast('Done: ' + (j.spoken || 'OK'));
1246
+ setTimeout(loadBoard, 400);
1247
+ } else {
1248
+ showToast('Failed: ' + (j.error || j.display || 'unknown'), true);
1249
+ }
1250
+ }).catch((e) => showToast('Network error: ' + e.message, true));
1251
+ }
1252
+
1253
+ document.getElementById('refresh').onclick = loadBoard;
1254
+ window.addEventListener('DOMContentLoaded', loadBoard);
1255
+ if (document.readyState !== 'loading') loadBoard();
1256
+
1257
+ // Realtime refresh via the shared SSE broker (F-44).
1258
+ let _refreshPending = false;
1259
+ function scheduleRefresh() {
1260
+ if (_refreshPending) return;
1261
+ _refreshPending = true;
1262
+ setTimeout(() => { _refreshPending = false; loadBoard(); }, 300);
1263
+ }
1264
+ const es = new EventSource('/voice/events-stream?t=' + encodeURIComponent(TOKEN));
1265
+ es.addEventListener('message', (ev) => {
1266
+ let p; try { p = JSON.parse(ev.data); } catch { return; }
1267
+ if (!p || !p.event) return;
1268
+ const n = p.event;
1269
+ if (n.startsWith('session.') || n.startsWith('goal.') || n === 'debate_completed' || n === 'debate_started') {
1270
+ scheduleRefresh();
1271
+ }
1272
+ });
797
1273
  </script>
798
1274
  </body></html>`;
799
1275
  }
@@ -860,6 +1336,9 @@ export async function voiceCommand(opts = {}) {
860
1336
  const langOptions = [defaultLang, ...extras.filter((l) => l !== defaultLang)];
861
1337
  const fallbackMode = voiceCfg.fallback_tts || 'auto';
862
1338
 
1339
+ // ADR-0015: SSE broker for debate replies — lazy-init on first connection.
1340
+ let debateBroker = null;
1341
+
863
1342
  server.on('request', async (req, res) => {
864
1343
  try {
865
1344
  if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
@@ -960,6 +1439,39 @@ export async function voiceCommand(opts = {}) {
960
1439
  }
961
1440
  }
962
1441
  const withExpired = (display) => (display || '') + (expiredNote || '');
1442
+
1443
+ // F-46: if user says yes/ok and there's no pending BUT one expired
1444
+ // in THIS sweep, treat it as a confirmation of the just-expired
1445
+ // action (the user clearly intended to confirm; the 60s window
1446
+ // closed a beat before they spoke). This rescues the very common
1447
+ // race where voice ASR + thinking + speaking exceeds the TTL.
1448
+ // Only applies to the SAME token's expired entry and only when
1449
+ // pendingActions is otherwise empty for this token.
1450
+ if (!pendingActions.get(token) && isConfirmPhrase(text)) {
1451
+ const rescue = justExpired.find((e) => e.token === token);
1452
+ if (rescue) {
1453
+ log.step('pending_rescued', { action: rescue.pending.action, by: 'F-46 just_expired_rescue' });
1454
+ const result = await executeAction({
1455
+ name: rescue.pending.action, args: rescue.pending.args, rootDir, lang: requestLang,
1456
+ });
1457
+ if (rescue.pending.sessionId) appendActionEvent(rootDir, rescue.pending, 'orchestrator_confirmed_after_expiry', result);
1458
+ log.step('action_executed', { action: rescue.pending.action, ok: result.ok });
1459
+ log.done(result.ok ? 'action_executed' : 'action_failed');
1460
+ const rescueNote = requestLang.startsWith('vi')
1461
+ ? '\n\n(Hết hạn rồi nhưng tôi vẫn chạy theo "yes" của bạn — F-46 rescue.)'
1462
+ : '\n\n(Past the window, but ran on your "yes" anyway — F-46 rescue.)';
1463
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1464
+ res.end(JSON.stringify({
1465
+ ok: result.ok,
1466
+ display: (result.display || '') + rescueNote,
1467
+ spoken: result.spoken,
1468
+ command: 'action_executed',
1469
+ action: rescue.pending.action,
1470
+ }));
1471
+ return;
1472
+ }
1473
+ }
1474
+
963
1475
  const pending = pendingActions.get(token);
964
1476
  if (pending) {
965
1477
  log.step('pending_check', { action: pending.action, expires_in_s: Math.round((pending.expires_at - Date.now()) / 1000) });
@@ -1005,6 +1517,8 @@ export async function voiceCommand(opts = {}) {
1005
1517
  log.step('greeting', { matched: false });
1006
1518
 
1007
1519
  const cmd = parseVoiceCommand(text);
1520
+ // Pass language through so dispatch (e.g. debate) can localize replies.
1521
+ if (cmd) cmd.lang = requestLang;
1008
1522
  log.step('parser', { matched: cmd ? cmd.name : 'miss' });
1009
1523
  if (!cmd) {
1010
1524
  // Hybrid fallback (Phase 4 hardening, owner directive 2026-05-24):
@@ -1017,7 +1531,13 @@ export async function voiceCommand(opts = {}) {
1017
1531
  const agentName = cfg.orchestrator.agent || 'claude';
1018
1532
  const timeoutMs = Number(cfg.orchestrator.timeout_ms) || 30000;
1019
1533
  log.step('orchestrator_spawn', { agent: agentName, timeout_ms: timeoutMs });
1020
- const orch = await runOrchestrator({ text, rootDir, agentName, timeoutMs, lang: requestLang });
1534
+ // F-48: detect deep-mode trigger phrases and loosen the prompt.
1535
+ const deepMode = detectDeepMode(text, requestLang);
1536
+ if (deepMode) log.step('deep_mode', { triggered: true });
1537
+ const orch = await runOrchestrator({
1538
+ text, rootDir, agentName, timeoutMs, lang: requestLang,
1539
+ mode: deepMode ? 'deep' : 'quick',
1540
+ });
1021
1541
  log.step('orchestrator_result', { ok: orch.ok, reply_chars: orch.reply?.length || 0, error: orch.error });
1022
1542
  if (orch.ok) {
1023
1543
  // ADR-0014: parse [ACTION: ...] tag from the orchestrator's
@@ -1104,7 +1624,17 @@ export async function voiceCommand(opts = {}) {
1104
1624
  const rendered = renderResult({ cmd, result, lang: requestLang });
1105
1625
  log.done('fast_path', { command: cmd.name, ok: result.ok, error: result.ok ? undefined : (result.error || '').slice(0, 200) });
1106
1626
  res.writeHead(200, { 'Content-Type': 'application/json' });
1107
- res.end(JSON.stringify({ ok: result.ok, display: withExpired(rendered.display), spoken: rendered.spoken, command: cmd.name, error: result.error }));
1627
+ res.end(JSON.stringify({
1628
+ ok: result.ok,
1629
+ display: withExpired(rendered.display),
1630
+ spoken: rendered.spoken,
1631
+ command: cmd.name,
1632
+ error: result.error,
1633
+ // ADR-0015: expose debate_id + roster to the client so the page
1634
+ // can open the SSE stream + render the columns.
1635
+ debate_id: cmd.name === 'debate' ? result.debate_id : undefined,
1636
+ roster: cmd.name === 'debate' ? (result.agents || []).map((a) => a.agent) : undefined,
1637
+ }));
1108
1638
  });
1109
1639
  return;
1110
1640
  }
@@ -1125,6 +1655,62 @@ export async function voiceCommand(opts = {}) {
1125
1655
  res.end(JSON.stringify(body, null, 2));
1126
1656
  return;
1127
1657
  }
1658
+ // ADR-0015 SSE stream — agents' replies tail events-global.jsonl via
1659
+ // sse-broker. Client filters frames by debate_id client-side.
1660
+ // F-44: /voice/events-stream is the unified endpoint covering ALL
1661
+ // global events (debate.*, goal.*, session.*). /voice/debate-stream
1662
+ // is kept as a backward-compat alias of the SAME broker — the file
1663
+ // it tails contains everything; client filters by event name.
1664
+ if (req.method === 'GET' && (req.url.startsWith('/voice/events-stream') || req.url.startsWith('/voice/debate-stream'))) {
1665
+ const urlObj = new URL(req.url, `http://localhost:${port}`);
1666
+ if (urlObj.searchParams.get('t') !== token) {
1667
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1668
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
1669
+ return;
1670
+ }
1671
+ if (!debateBroker) debateBroker = createSseBroker(rootDir, { maxClients: 10 });
1672
+ debateBroker.addClient(req, res);
1673
+ return;
1674
+ }
1675
+ // ADR-0015 roster info — browser uses this to render the column labels
1676
+ // and pick per-agent TTS voices.
1677
+ if (req.method === 'GET' && req.url.startsWith('/voice/debate-roster')) {
1678
+ const urlObj = new URL(req.url, `http://localhost:${port}`);
1679
+ if (urlObj.searchParams.get('t') !== token) {
1680
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1681
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
1682
+ return;
1683
+ }
1684
+ const { roster, timeout_ms } = resolveDebateRoster(rootDir);
1685
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1686
+ res.end(JSON.stringify({ ok: true, data: { roster, timeout_ms } }));
1687
+ return;
1688
+ }
1689
+ // F-49 Đợt 2: Kanban board JSON + HTML page.
1690
+ if (req.method === 'GET' && req.url.startsWith('/voice/board')) {
1691
+ const urlObj = new URL(req.url, `http://localhost:${port}`);
1692
+ if (urlObj.searchParams.get('t') !== token) {
1693
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1694
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
1695
+ return;
1696
+ }
1697
+ try {
1698
+ const data = buildBoardData(rootDir);
1699
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1700
+ res.end(JSON.stringify({ ok: true, data }, null, 2));
1701
+ } catch (e) {
1702
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1703
+ res.end(JSON.stringify({ ok: false, error: e.message }));
1704
+ }
1705
+ return;
1706
+ }
1707
+ if (req.method === 'GET' && (req.url === '/board' || req.url.startsWith('/board?'))) {
1708
+ // Token authorization happens in JSON fetch — the HTML itself is
1709
+ // public scaffolding (matches the / behavior).
1710
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
1711
+ res.end(boardHtml(token));
1712
+ return;
1713
+ }
1128
1714
  res.writeHead(404); res.end();
1129
1715
  } catch (e) {
1130
1716
  try { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } catch {}