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.
- package/.claude/hooks/stop-check.sh +10 -0
- package/.claude/rules/dw.md +2 -0
- package/.claude/skills/dw-decision/SKILL.md +2 -1
- package/.claude/skills/dw-goal/SKILL.md +206 -0
- package/.claude/skills/dw-goal-sync/SKILL.md +131 -0
- package/.claude/templates/agent-report.md +35 -35
- package/.dw/config/agents.yml +8 -0
- package/.dw/core/AGENTS.md +53 -53
- package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
- package/.dw/core/schemas/events/created.schema.json +33 -0
- package/.dw/core/schemas/events/debate_agent_failed.schema.json +42 -0
- package/.dw/core/schemas/events/debate_agent_replied.schema.json +44 -0
- package/.dw/core/schemas/events/debate_agent_started.schema.json +37 -0
- package/.dw/core/schemas/events/debate_completed.schema.json +36 -0
- package/.dw/core/schemas/events/debate_started.schema.json +47 -0
- package/.dw/core/schemas/events/goal_archived.schema.json +32 -0
- package/.dw/core/schemas/events/goal_created.schema.json +32 -0
- package/.dw/core/schemas/events/goal_field_updated.schema.json +35 -0
- package/.dw/core/schemas/events/goal_pivoted.schema.json +36 -0
- package/.dw/core/schemas/events/goal_status_changed.schema.json +40 -0
- package/.dw/core/schemas/events/goal_task_linked.schema.json +33 -0
- package/.dw/core/schemas/events/goal_task_unlinked.schema.json +33 -0
- package/.dw/core/schemas/events/index.json +185 -0
- package/.dw/core/schemas/events/orchestrator_cancelled.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_completed.schema.json +38 -0
- package/.dw/core/schemas/events/orchestrator_confirm.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_confirmed.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_error.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_pending_dropped.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_pending_expired.schema.json +32 -0
- package/.dw/core/schemas/events/orchestrator_recommend_rejected.schema.json +37 -0
- package/.dw/core/schemas/events/orchestrator_recommended.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_spawn_failed.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_started.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_timeout.schema.json +29 -0
- package/.dw/core/schemas/events/reconciled.schema.json +29 -0
- package/.dw/core/schemas/events/reconciled_stale.schema.json +29 -0
- package/.dw/core/schemas/events/session.created.schema.json +39 -0
- package/.dw/core/schemas/events/session.reconciled.schema.json +33 -0
- package/.dw/core/schemas/events/session.status_changed.schema.json +42 -0
- package/.dw/core/schemas/events/spawn_failed.schema.json +29 -0
- package/.dw/core/schemas/events/started.schema.json +59 -0
- package/.dw/core/schemas/events/stopped.schema.json +33 -0
- package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
- package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
- package/.dw/core/templates/v3/task.md +38 -9
- package/.dw/security/advisory-snapshot.json +157 -0
- package/LICENSE +201 -21
- package/NOTICE +26 -0
- package/README.md +5 -2
- package/SECURITY.md +87 -0
- package/TRADEMARK.md +65 -0
- package/bin/dw.mjs +1 -1
- package/package.json +13 -5
- package/src/cli.mjs +33 -0
- package/src/commands/decision-index.mjs +45 -0
- package/src/commands/goal-delete.mjs +3 -1
- package/src/commands/goal-link.mjs +3 -1
- package/src/commands/goal-status.mjs +95 -0
- package/src/commands/lint-task.mjs +20 -0
- package/src/commands/task-index.mjs +47 -0
- package/src/commands/task-migrate.mjs +16 -5
- package/src/commands/task-new.mjs +6 -0
- package/src/commands/task-summary.mjs +4 -3
- package/src/commands/voice.mjs +590 -4
- package/src/lib/board-data.mjs +220 -0
- package/src/lib/debate.mjs +325 -0
- package/src/lib/decision-store.mjs +146 -0
- package/src/lib/event-schema.mjs +342 -0
- package/src/lib/goal-store.mjs +40 -1
- package/src/lib/lint-rules.mjs +10 -1
- package/src/lib/orchestrator.mjs +31 -9
- package/src/lib/session-store.mjs +36 -4
- package/src/lib/task-store.mjs +164 -0
- package/src/lib/voice-action.mjs +165 -0
- package/src/lib/voice-parser.mjs +13 -0
- package/.dw/config/connectors.local.yml +0 -38
- package/.dw/core/PILLARS.md +0 -122
- package/CLAUDE.md +0 -44
package/src/commands/voice.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|
|
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 {}
|