verbalcoding 0.2.11 → 0.2.13
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/.env.example +98 -2
- package/README.es.md +134 -0
- package/README.fr.md +134 -0
- package/README.ja.md +134 -0
- package/README.ko.md +134 -0
- package/README.md +118 -74
- package/README.ru.md +134 -0
- package/README.zh.md +133 -0
- package/app-node/agent_adapters.mjs +37 -5
- package/app-node/agent_adapters.test.mjs +27 -1
- package/app-node/agent_detect.mjs +73 -0
- package/app-node/agent_detect.test.mjs +77 -0
- package/app-node/agent_routing.mjs +148 -0
- package/app-node/agent_routing.test.mjs +138 -0
- package/app-node/agent_turn.mjs +86 -0
- package/app-node/agent_turn.test.mjs +109 -0
- package/app-node/bridge_context.mjs +73 -0
- package/app-node/bridge_context.test.mjs +54 -0
- package/app-node/bridge_state.mjs +4 -0
- package/app-node/bridge_wireup.test.mjs +462 -0
- package/app-node/cli_install.test.mjs +31 -0
- package/app-node/cross_agent_routing.test.mjs +78 -0
- package/app-node/discord_command_router.mjs +204 -0
- package/app-node/discord_command_router.test.mjs +311 -0
- package/app-node/discord_voice_setup.mjs +251 -0
- package/app-node/discord_voice_setup.test.mjs +86 -0
- package/app-node/hermes_profiles.test.mjs +12 -1
- package/app-node/install_config.mjs +113 -3
- package/app-node/install_config.test.mjs +8 -0
- package/app-node/instance_doctor.test.mjs +9 -0
- package/app-node/instances.test.mjs +8 -1
- package/app-node/main.mjs +513 -1058
- package/app-node/mcp_tools.test.mjs +7 -0
- package/app-node/notification_handler.mjs +89 -0
- package/app-node/notification_handler.test.mjs +187 -0
- package/app-node/notify.mjs +73 -0
- package/app-node/notify.test.mjs +68 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +203 -0
- package/app-node/plan_mode.test.mjs +231 -0
- package/app-node/progress_handler.mjs +220 -0
- package/app-node/progress_handler.test.mjs +193 -0
- package/app-node/progress_speech.mjs +54 -32
- package/app-node/progress_speech.test.mjs +12 -3
- package/app-node/project_sessions.mjs +5 -2
- package/app-node/project_sessions.test.mjs +7 -0
- package/app-node/research_mode.mjs +282 -0
- package/app-node/research_mode.test.mjs +264 -0
- package/app-node/restart_notice.mjs +3 -0
- package/app-node/restart_notice.test.mjs +11 -0
- package/app-node/session_ontology.mjs +271 -0
- package/app-node/session_ontology.test.mjs +130 -0
- package/app-node/smart_progress.mjs +94 -0
- package/app-node/smart_progress.test.mjs +66 -0
- package/app-node/stream_sentencer.mjs +91 -0
- package/app-node/stream_sentencer.test.mjs +129 -0
- package/app-node/streaming_tts_queue.mjs +52 -0
- package/app-node/streaming_tts_queue.test.mjs +64 -0
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +22 -0
- package/app-node/text_routing.test.mjs +23 -1
- package/app-node/tts_backends.mjs +537 -3
- package/app-node/tts_backends.test.mjs +454 -0
- package/app-node/tts_player.mjs +164 -0
- package/app-node/tts_player.test.mjs +202 -0
- package/app-node/tts_runtime.mjs +134 -0
- package/app-node/tts_runtime.test.mjs +89 -0
- package/app-node/tts_settings.mjs +150 -3
- package/app-node/tts_settings.test.mjs +204 -0
- package/app-node/tts_voice_config.mjs +136 -2
- package/app-node/tts_voice_config.test.mjs +94 -0
- package/app-node/utterance_router.mjs +216 -0
- package/app-node/utterance_router.test.mjs +236 -0
- package/app-node/voice_autojoin.mjs +37 -0
- package/app-node/voice_autojoin.test.mjs +59 -0
- package/app-node/voice_io.mjs +272 -0
- package/app-node/voice_io.test.mjs +102 -0
- package/app-node/voice_turn_runner.mjs +449 -0
- package/app-node/voice_turn_runner.test.mjs +289 -0
- package/docs/CONFIGURATION.md +79 -96
- package/docs/FRESH_INSTALL.md +105 -63
- package/docs/HARNESSES.md +58 -0
- package/docs/HARNESS_AIDER.md +50 -0
- package/docs/HARNESS_CLAUDE.md +56 -0
- package/docs/HARNESS_CODEX.md +56 -0
- package/docs/HARNESS_CURSOR.md +45 -0
- package/docs/HARNESS_GEMINI.md +45 -0
- package/docs/HARNESS_HERMES.md +57 -0
- package/docs/HARNESS_OPENCLAW.md +44 -0
- package/docs/HARNESS_OPENCODE.md +44 -0
- package/docs/HERMES_VOICE.md +65 -0
- package/docs/MULTI_INSTANCE.md +16 -0
- package/docs/README.md +50 -0
- package/docs/RELEASE.md +42 -19
- package/docs/ROADMAP.md +53 -0
- package/docs/TROUBLESHOOTING.md +126 -0
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +94 -40
- package/docs/assets/figures/verbalcoding-flow.svg +1 -1
- package/docs/i18n/AGENTS.es.md +34 -0
- package/docs/i18n/AGENTS.fr.md +34 -0
- package/docs/i18n/AGENTS.ja.md +34 -0
- package/docs/i18n/AGENTS.ko.md +34 -0
- package/docs/i18n/AGENTS.ru.md +34 -0
- package/docs/i18n/AGENTS.zh.md +34 -0
- package/docs/i18n/CONFIGURATION.es.md +25 -0
- package/docs/i18n/CONFIGURATION.fr.md +25 -0
- package/docs/i18n/CONFIGURATION.ja.md +25 -0
- package/docs/i18n/CONFIGURATION.ko.md +25 -0
- package/docs/i18n/CONFIGURATION.ru.md +25 -0
- package/docs/i18n/CONFIGURATION.zh.md +25 -0
- package/docs/i18n/FRESH_INSTALL.es.md +27 -2
- package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
- package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
- package/docs/i18n/HARNESSES.es.md +58 -0
- package/docs/i18n/HARNESSES.fr.md +58 -0
- package/docs/i18n/HARNESSES.ja.md +58 -0
- package/docs/i18n/HARNESSES.ko.md +58 -0
- package/docs/i18n/HARNESSES.ru.md +58 -0
- package/docs/i18n/HARNESSES.zh.md +58 -0
- package/docs/i18n/HARNESS_AIDER.es.md +48 -0
- package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
- package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
- package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
- package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
- package/docs/i18n/HARNESS_CODEX.es.md +55 -0
- package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
- package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
- package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
- package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
- package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
- package/docs/i18n/HARNESS_HERMES.es.md +54 -0
- package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
- package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
- package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
- package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
- package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
- package/docs/i18n/HERMES_VOICE.es.md +46 -0
- package/docs/i18n/HERMES_VOICE.fr.md +46 -0
- package/docs/i18n/HERMES_VOICE.ja.md +46 -0
- package/docs/i18n/HERMES_VOICE.ko.md +65 -0
- package/docs/i18n/HERMES_VOICE.ru.md +46 -0
- package/docs/i18n/HERMES_VOICE.zh.md +46 -0
- package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
- package/docs/i18n/README.es.md +20 -134
- package/docs/i18n/README.fr.md +20 -134
- package/docs/i18n/README.ja.md +20 -134
- package/docs/i18n/README.ko.md +20 -133
- package/docs/i18n/README.ru.md +20 -134
- package/docs/i18n/README.zh.md +20 -133
- package/docs/i18n/RELEASE.es.md +26 -1
- package/docs/i18n/RELEASE.fr.md +26 -1
- package/docs/i18n/RELEASE.ja.md +26 -1
- package/docs/i18n/RELEASE.ko.md +26 -1
- package/docs/i18n/RELEASE.ru.md +26 -1
- package/docs/i18n/RELEASE.zh.md +26 -1
- package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
- package/docs/i18n/USAGE.es.md +25 -0
- package/docs/i18n/USAGE.fr.md +25 -0
- package/docs/i18n/USAGE.ja.md +25 -0
- package/docs/i18n/USAGE.ko.md +25 -0
- package/docs/i18n/USAGE.ru.md +25 -0
- package/docs/i18n/USAGE.zh.md +25 -0
- package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
- package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
- package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
- package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
- package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -0
- package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
- package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
- package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
- package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
- package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
- package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
- package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
- package/integrations/fireredtts2/mlx_llm.py +183 -0
- package/integrations/fireredtts2/synth.py +156 -0
- package/integrations/fireredtts2/synth_mlx.py +196 -0
- package/integrations/mlxaudio/synth.py +74 -0
- package/integrations/neuttsair/synth.py +104 -0
- package/integrations/omnivoice/synth.py +110 -0
- package/package.json +7 -1
- package/scripts/cli.mjs +88 -3
- package/scripts/doctor.mjs +115 -4
- package/scripts/install.mjs +20 -2
- package/scripts/install_fireredtts2.sh +109 -0
- package/scripts/install_mlxaudio.sh +34 -0
- package/scripts/install_mossttsnano.sh +46 -0
- package/scripts/postinstall.mjs +34 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
Cross-Agent Voice Routing — Implementation Plan
|
|
2
|
+
|
|
3
|
+
**Status: shipped** (commits `ab5bd93` → `60d50bc`, hardened through `a674d87`). User-facing docs: [docs/USAGE.md § Cross-agent voice routing](../../USAGE.md#cross-agent-voice-routing). This file is preserved for historical/design reference.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Goal: Route a VerbalCoding voice turn to any installed CLI agent (Codex, Aider, Claude Code, Gemini, OpenCode, OpenClaw, Cursor, Hermes) by voice — either explicitly ("ask Codex", "switch to Aider") or via a which_agent slot in the agent's DECISIONS_BEGIN/END block — with graceful fallback when the routed agent isn't installed.
|
|
8
|
+
|
|
9
|
+
Architecture: New pure module agent_routing.mjs. plan_mode.mjs preamble update. main.mjs gets a per-backend adapter cache + routing state. Single-turn default; "switch to X" makes it sticky. TTS prefixes the agent's name only on backend change.
|
|
10
|
+
|
|
11
|
+
Files created: app-node/agent_routing.mjs, app-node/agent_routing.test.mjs, app-node/cross_agent_routing.test.mjs.
|
|
12
|
+
Files modified: app-node/plan_mode.mjs, app-node/plan_mode.test.mjs, app-node/main.mjs, app-node/agent_adapters.test.mjs.
|
|
13
|
+
|
|
14
|
+
Design decisions resolved:
|
|
15
|
+
|
|
16
|
+
Single-turn for "ask X"; sticky for "switch to X"; "back to default" restores.
|
|
17
|
+
Context-passing = prefix block prepended to the prompt string each adapter already accepts.
|
|
18
|
+
Cross-agent session log = in-memory ring buffer (last 4 utterances) + last resolved-decisions set. Persistence deferred to feedback-loop research.
|
|
19
|
+
TTS prefix only when backend changes. EN: "Codex says: ". KO: "코덱스: ".
|
|
20
|
+
Backward compat: existing plans/sessions/decisions unchanged. parseAgentRoutingCommand returns {type: 'none'} for unrelated input.
|
|
21
|
+
Task 1 — Create agent_routing.mjs with parseAgentRoutingCommand + resolveBackendAlias
|
|
22
|
+
Test file (app-node/agent_routing.test.mjs):
|
|
23
|
+
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { parseAgentRoutingCommand, resolveBackendAlias } from './agent_routing.mjs';
|
|
27
|
+
|
|
28
|
+
test('parseAgentRoutingCommand recognizes "ask X" as single-turn', () => {
|
|
29
|
+
assert.deepEqual(parseAgentRoutingCommand('ask Codex what it thinks', 'en'),
|
|
30
|
+
{ type: 'route', backend: 'codex', sticky: false });
|
|
31
|
+
assert.deepEqual(parseAgentRoutingCommand('ask aider to write the test', 'en'),
|
|
32
|
+
{ type: 'route', backend: 'aider', sticky: false });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parseAgentRoutingCommand recognizes "switch to X" as sticky', () => {
|
|
36
|
+
assert.deepEqual(parseAgentRoutingCommand('switch to Aider', 'en'),
|
|
37
|
+
{ type: 'route', backend: 'aider', sticky: true });
|
|
38
|
+
assert.deepEqual(parseAgentRoutingCommand('switch to claude code', 'en'),
|
|
39
|
+
{ type: 'route', backend: 'claude', sticky: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('parseAgentRoutingCommand recognizes Korean routing phrases', () => {
|
|
43
|
+
assert.deepEqual(parseAgentRoutingCommand('코덱스한테 물어봐', 'ko'),
|
|
44
|
+
{ type: 'route', backend: 'codex', sticky: false });
|
|
45
|
+
assert.deepEqual(parseAgentRoutingCommand('aider로 전환해', 'ko'),
|
|
46
|
+
{ type: 'route', backend: 'aider', sticky: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('parseAgentRoutingCommand recognizes restore-default phrases', () => {
|
|
50
|
+
assert.deepEqual(parseAgentRoutingCommand('back to default', 'en'),
|
|
51
|
+
{ type: 'restore' });
|
|
52
|
+
assert.deepEqual(parseAgentRoutingCommand('use the default agent', 'en'),
|
|
53
|
+
{ type: 'restore' });
|
|
54
|
+
assert.deepEqual(parseAgentRoutingCommand('기본으로 돌아가', 'ko'),
|
|
55
|
+
{ type: 'restore' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('parseAgentRoutingCommand returns none on unrelated input', () => {
|
|
59
|
+
assert.deepEqual(parseAgentRoutingCommand('just write the function', 'en'),
|
|
60
|
+
{ type: 'none' });
|
|
61
|
+
assert.deepEqual(parseAgentRoutingCommand('plan it first', 'en'),
|
|
62
|
+
{ type: 'none' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('resolveBackendAlias maps user-facing names to canonical backends', () => {
|
|
66
|
+
assert.equal(resolveBackendAlias('Claude Code'), 'claude');
|
|
67
|
+
assert.equal(resolveBackendAlias('claude'), 'claude');
|
|
68
|
+
assert.equal(resolveBackendAlias('cursor cli'), 'cursor');
|
|
69
|
+
assert.equal(resolveBackendAlias('gemini cli'), 'gemini');
|
|
70
|
+
assert.equal(resolveBackendAlias('코덱스'), 'codex');
|
|
71
|
+
assert.equal(resolveBackendAlias('unknown'), null);
|
|
72
|
+
});
|
|
73
|
+
Implementation (app-node/agent_routing.mjs):
|
|
74
|
+
|
|
75
|
+
const BACKEND_ALIASES = {
|
|
76
|
+
hermes: ['hermes'],
|
|
77
|
+
claude: ['claude code', 'claude-code', 'claude'],
|
|
78
|
+
codex: ['codex', '코덱스'],
|
|
79
|
+
gemini: ['gemini cli', 'gemini-cli', 'gemini', '제미나이'],
|
|
80
|
+
opencode: ['opencode', 'open code'],
|
|
81
|
+
openclaw: ['openclaw', 'open claw'],
|
|
82
|
+
aider: ['aider', '에이더'],
|
|
83
|
+
cursor: ['cursor cli', 'cursor-cli', 'cursor agent', 'cursor-agent', 'cursor'],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const BACKEND_LOOKUP = (() => {
|
|
87
|
+
const pairs = [];
|
|
88
|
+
for (const [backend, aliases] of Object.entries(BACKEND_ALIASES)) {
|
|
89
|
+
for (const alias of aliases) pairs.push([alias.toLowerCase(), backend]);
|
|
90
|
+
}
|
|
91
|
+
pairs.sort((a, b) => b[0].length - a[0].length);
|
|
92
|
+
return pairs;
|
|
93
|
+
})();
|
|
94
|
+
|
|
95
|
+
const ASK_EN = /\bask\s+([a-z][a-z0-9 \-]{1,30}?)(?:\s+(?:to|what|if|whether)\b|[?,.]|$)/i;
|
|
96
|
+
const SWITCH_EN = /\bswitch\s+to\s+([a-z][a-z0-9 \-]{1,30}?)(?:[?,.]|$)/i;
|
|
97
|
+
const LET_FINISH_EN = /\blet\s+([a-z][a-z0-9 \-]{1,30}?)\s+(?:finish|handle|do)\b/i;
|
|
98
|
+
const RESTORE_EN = /\b(back\s+to\s+default|use\s+the\s+default\s+agent|default\s+agent)\b/i;
|
|
99
|
+
|
|
100
|
+
const ASK_KO = /([가-힣A-Za-z][가-힣A-Za-z0-9\-]{1,30})(?:한테|에게|에)\s*(물어|질문)/;
|
|
101
|
+
const SWITCH_KO = /([가-힣A-Za-z][가-힣A-Za-z0-9\-]{1,30})(?:로|으로)\s*(전환|바꿔|바꿔줘)/;
|
|
102
|
+
const RESTORE_KO = /(기본(?:으로)?\s*(?:돌아|복귀)|기본\s*에이전트)/;
|
|
103
|
+
|
|
104
|
+
export function resolveBackendAlias(rawName) {
|
|
105
|
+
const needle = String(rawName || '').toLowerCase().trim();
|
|
106
|
+
if (!needle) return null;
|
|
107
|
+
for (const [alias, backend] of BACKEND_LOOKUP) {
|
|
108
|
+
if (needle === alias) return backend;
|
|
109
|
+
}
|
|
110
|
+
for (const [alias, backend] of BACKEND_LOOKUP) {
|
|
111
|
+
if (needle.includes(alias)) return backend;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseAgentRoutingCommand(text, language = 'en') {
|
|
117
|
+
const t = String(text || '').trim();
|
|
118
|
+
if (!t) return { type: 'none' };
|
|
119
|
+
if (RESTORE_EN.test(t) || RESTORE_KO.test(t)) return { type: 'restore' };
|
|
120
|
+
const switchMatch = t.match(SWITCH_EN) || t.match(LET_FINISH_EN);
|
|
121
|
+
if (switchMatch) {
|
|
122
|
+
const backend = resolveBackendAlias(switchMatch[1]);
|
|
123
|
+
if (backend) return { type: 'route', backend, sticky: true };
|
|
124
|
+
}
|
|
125
|
+
const switchKo = t.match(SWITCH_KO);
|
|
126
|
+
if (switchKo) {
|
|
127
|
+
const backend = resolveBackendAlias(switchKo[1]);
|
|
128
|
+
if (backend) return { type: 'route', backend, sticky: true };
|
|
129
|
+
}
|
|
130
|
+
const askMatch = t.match(ASK_EN);
|
|
131
|
+
if (askMatch) {
|
|
132
|
+
const backend = resolveBackendAlias(askMatch[1]);
|
|
133
|
+
if (backend) return { type: 'route', backend, sticky: false };
|
|
134
|
+
}
|
|
135
|
+
const askKo = t.match(ASK_KO);
|
|
136
|
+
if (askKo) {
|
|
137
|
+
const backend = resolveBackendAlias(askKo[1]);
|
|
138
|
+
if (backend) return { type: 'route', backend, sticky: false };
|
|
139
|
+
}
|
|
140
|
+
return { type: 'none' };
|
|
141
|
+
}
|
|
142
|
+
Verify: node --test app-node/agent_routing.test.mjs → 6 tests PASS.
|
|
143
|
+
Commit: feat(agent-routing): parseAgentRoutingCommand + resolveBackendAlias
|
|
144
|
+
|
|
145
|
+
Task 2 — Append isAgentRoutingDecision and renderAgentPrefix
|
|
146
|
+
Tests (append to agent_routing.test.mjs):
|
|
147
|
+
|
|
148
|
+
import {
|
|
149
|
+
isAgentRoutingDecision,
|
|
150
|
+
renderAgentPrefix,
|
|
151
|
+
} from './agent_routing.mjs';
|
|
152
|
+
|
|
153
|
+
test('isAgentRoutingDecision detects which_agent slot', () => {
|
|
154
|
+
assert.equal(isAgentRoutingDecision({ slot: 'which_agent', options: ['codex', 'aider'] }), true);
|
|
155
|
+
assert.equal(isAgentRoutingDecision({ slot: 'oauth_provider', options: ['google', 'github'] }), false);
|
|
156
|
+
assert.equal(isAgentRoutingDecision({ slot: 'agent', options: ['codex', 'aider'] }), true);
|
|
157
|
+
assert.equal(isAgentRoutingDecision(null), false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('renderAgentPrefix uses English label for en', () => {
|
|
161
|
+
assert.equal(renderAgentPrefix('codex', 'en'), 'Codex says: ');
|
|
162
|
+
assert.equal(renderAgentPrefix('claude', 'en'), 'Claude Code says: ');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('renderAgentPrefix uses Korean label for ko', () => {
|
|
166
|
+
assert.equal(renderAgentPrefix('codex', 'ko'), '코덱스: ');
|
|
167
|
+
assert.equal(renderAgentPrefix('claude', 'ko'), 'Claude Code: ');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('renderAgentPrefix returns empty when backend unknown', () => {
|
|
171
|
+
assert.equal(renderAgentPrefix('', 'en'), '');
|
|
172
|
+
assert.equal(renderAgentPrefix(null, 'en'), '');
|
|
173
|
+
assert.equal(renderAgentPrefix('unknownbackend', 'en'), '');
|
|
174
|
+
});
|
|
175
|
+
Implementation (append to agent_routing.mjs):
|
|
176
|
+
|
|
177
|
+
const ROUTING_SLOT_NAMES = new Set(['which_agent', 'agent', 'who_answers', 'router_agent']);
|
|
178
|
+
|
|
179
|
+
const BACKEND_LABELS = {
|
|
180
|
+
hermes: { en: 'Hermes', ko: '헤르메스' },
|
|
181
|
+
claude: { en: 'Claude Code', ko: 'Claude Code' },
|
|
182
|
+
codex: { en: 'Codex', ko: '코덱스' },
|
|
183
|
+
gemini: { en: 'Gemini', ko: 'Gemini' },
|
|
184
|
+
opencode: { en: 'OpenCode', ko: 'OpenCode' },
|
|
185
|
+
openclaw: { en: 'OpenClaw', ko: 'OpenClaw' },
|
|
186
|
+
aider: { en: 'Aider', ko: 'Aider' },
|
|
187
|
+
cursor: { en: 'Cursor CLI', ko: 'Cursor CLI' },
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export function isAgentRoutingDecision(decision) {
|
|
191
|
+
if (!decision || typeof decision !== 'object') return false;
|
|
192
|
+
const slot = String(decision.slot || '').toLowerCase();
|
|
193
|
+
return ROUTING_SLOT_NAMES.has(slot);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function renderAgentPrefix(backend, language = 'en') {
|
|
197
|
+
const key = String(backend || '').toLowerCase();
|
|
198
|
+
if (!BACKEND_LABELS[key]) return '';
|
|
199
|
+
const en = /^en/i.test(String(language || ''));
|
|
200
|
+
const label = BACKEND_LABELS[key][en ? 'en' : 'ko'];
|
|
201
|
+
return en ? `${label} says: ` : `${label}: `;
|
|
202
|
+
}
|
|
203
|
+
Commit: feat(agent-routing): which_agent detection + localized TTS prefix
|
|
204
|
+
|
|
205
|
+
Task 3 — Append buildCrossAgentPrompt
|
|
206
|
+
Tests (append):
|
|
207
|
+
|
|
208
|
+
import { buildCrossAgentPrompt } from './agent_routing.mjs';
|
|
209
|
+
|
|
210
|
+
test('buildCrossAgentPrompt prepends handoff block in English', () => {
|
|
211
|
+
const out = buildCrossAgentPrompt({
|
|
212
|
+
prompt: 'Refactor the login route to use OAuth.',
|
|
213
|
+
fromBackend: 'claude', toBackend: 'codex',
|
|
214
|
+
resolvedDecisions: { oauth_provider: 'github' },
|
|
215
|
+
priorUtterances: ['plan it first', 'skip step 2'],
|
|
216
|
+
language: 'en',
|
|
217
|
+
});
|
|
218
|
+
assert.match(out, /Cross-agent handoff from Claude Code to Codex/);
|
|
219
|
+
assert.match(out, /Prior decisions: oauth_provider=github/);
|
|
220
|
+
assert.match(out, /Recent user voice: plan it first \| skip step 2/);
|
|
221
|
+
assert.match(out, /User request: Refactor the login route to use OAuth\./);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('buildCrossAgentPrompt omits empty sections', () => {
|
|
225
|
+
const out = buildCrossAgentPrompt({
|
|
226
|
+
prompt: 'do it', fromBackend: 'claude', toBackend: 'codex',
|
|
227
|
+
resolvedDecisions: {}, priorUtterances: [], language: 'en',
|
|
228
|
+
});
|
|
229
|
+
assert.doesNotMatch(out, /Prior decisions:/);
|
|
230
|
+
assert.doesNotMatch(out, /Recent user voice:/);
|
|
231
|
+
assert.match(out, /User request: do it/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('buildCrossAgentPrompt renders Korean header for ko', () => {
|
|
235
|
+
const out = buildCrossAgentPrompt({
|
|
236
|
+
prompt: '로그인 라우트 리팩토링해줘',
|
|
237
|
+
fromBackend: 'claude', toBackend: 'codex',
|
|
238
|
+
resolvedDecisions: {}, priorUtterances: [], language: 'ko',
|
|
239
|
+
});
|
|
240
|
+
assert.match(out, /에이전트 핸드오프: Claude Code → 코덱스/);
|
|
241
|
+
assert.match(out, /사용자 요청: 로그인 라우트 리팩토링해줘/);
|
|
242
|
+
});
|
|
243
|
+
Implementation (append):
|
|
244
|
+
|
|
245
|
+
function labelFor(backend, language) {
|
|
246
|
+
const key = String(backend || '').toLowerCase();
|
|
247
|
+
if (!BACKEND_LABELS[key]) return key || 'agent';
|
|
248
|
+
return BACKEND_LABELS[key][/^en/i.test(String(language || '')) ? 'en' : 'ko'];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function buildCrossAgentPrompt({
|
|
252
|
+
prompt, fromBackend, toBackend,
|
|
253
|
+
resolvedDecisions = {}, priorUtterances = [], language = 'en',
|
|
254
|
+
}) {
|
|
255
|
+
const en = /^en/i.test(String(language || ''));
|
|
256
|
+
const fromLabel = labelFor(fromBackend, language);
|
|
257
|
+
const toLabel = labelFor(toBackend, language);
|
|
258
|
+
const lines = [];
|
|
259
|
+
lines.push(en
|
|
260
|
+
? `[Cross-agent handoff from ${fromLabel} to ${toLabel}]`
|
|
261
|
+
: `[에이전트 핸드오프: ${fromLabel} → ${toLabel}]`);
|
|
262
|
+
const decKeys = Object.keys(resolvedDecisions || {});
|
|
263
|
+
if (decKeys.length) {
|
|
264
|
+
const parts = decKeys.map(k => `${k}=${resolvedDecisions[k] === null ? '(agent picks)' : resolvedDecisions[k]}`);
|
|
265
|
+
lines.push(en ? `Prior decisions: ${parts.join(', ')}` : `이전 결정: ${parts.join(', ')}`);
|
|
266
|
+
}
|
|
267
|
+
const utterances = (priorUtterances || []).filter(Boolean).slice(-4);
|
|
268
|
+
if (utterances.length) {
|
|
269
|
+
lines.push(en
|
|
270
|
+
? `Recent user voice: ${utterances.join(' | ')}`
|
|
271
|
+
: `최근 사용자 음성: ${utterances.join(' | ')}`);
|
|
272
|
+
}
|
|
273
|
+
lines.push(en ? `User request: ${prompt}` : `사용자 요청: ${prompt}`);
|
|
274
|
+
return lines.join('\n');
|
|
275
|
+
}
|
|
276
|
+
Commit: feat(agent-routing): buildCrossAgentPrompt for handoff context
|
|
277
|
+
|
|
278
|
+
Task 4 — Teach plan_mode.mjs preamble about which_agent
|
|
279
|
+
Tests (append to plan_mode.test.mjs):
|
|
280
|
+
|
|
281
|
+
import { isAgentRoutingDecision } from './agent_routing.mjs';
|
|
282
|
+
|
|
283
|
+
test('parsePlanOutput tags which_agent decision via isAgentRoutingDecision', () => {
|
|
284
|
+
const text = [
|
|
285
|
+
'PLAN_BEGIN',
|
|
286
|
+
'1. Survey the codebase',
|
|
287
|
+
'PLAN_END',
|
|
288
|
+
'DECISIONS_BEGIN',
|
|
289
|
+
'- which_agent | Who should answer? | codex | aider',
|
|
290
|
+
'DECISIONS_END',
|
|
291
|
+
].join('\n');
|
|
292
|
+
const out = parsePlanOutput(text);
|
|
293
|
+
assert.equal(out.decisions[0].slot, 'which_agent');
|
|
294
|
+
assert.equal(isAgentRoutingDecision(out.decisions[0]), true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('planModePreamble in English mentions which_agent', () => {
|
|
298
|
+
assert.match(planModePreamble('en'), /which_agent/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('planModePreamble in Korean mentions which_agent', () => {
|
|
302
|
+
assert.match(planModePreamble('ko'), /which_agent/);
|
|
303
|
+
});
|
|
304
|
+
Edit app-node/plan_mode.mjs — insert one line into each preamble:
|
|
305
|
+
|
|
306
|
+
English branch (replace existing array):
|
|
307
|
+
|
|
308
|
+
return [
|
|
309
|
+
'You are in PLAN MODE. Do NOT modify any files.',
|
|
310
|
+
'Reply with a short plan AND list any forks/decisions you would normally pick yourself.',
|
|
311
|
+
'PLAN_BEGIN', '1. ...', '2. ...', 'PLAN_END',
|
|
312
|
+
'DECISIONS_BEGIN',
|
|
313
|
+
'- <slot> | <one-sentence question> | <option1> | <option2> | ...',
|
|
314
|
+
'DECISIONS_END',
|
|
315
|
+
'Each step under 12 words. slot is a short snake_case key (e.g. oauth_provider).',
|
|
316
|
+
'Use slot "which_agent" when the choice is which CLI agent should answer next (options: codex, aider, claude, gemini, opencode, openclaw, cursor, hermes).',
|
|
317
|
+
'Omit the DECISIONS block entirely if there is nothing to ask.',
|
|
318
|
+
].join('\n');
|
|
319
|
+
Korean branch — insert this line before the final '결정이 필요 없으면...':
|
|
320
|
+
|
|
321
|
+
'slot이 "which_agent"이면 다음에 답할 CLI 에이전트를 묻는 분기다 (options: codex, aider, claude, gemini, opencode, openclaw, cursor, hermes).',
|
|
322
|
+
Commit: feat(plan-mode): preamble teaches the which_agent slot
|
|
323
|
+
|
|
324
|
+
Task 5 — Per-backend adapter cache + routing state in main.mjs
|
|
325
|
+
Add buildAgentSettings to the existing from './agent_adapters.mjs' import.
|
|
326
|
+
|
|
327
|
+
Insert immediately after const agentAdaptersBySession = new Map(); (~line 505):
|
|
328
|
+
|
|
329
|
+
const agentAdaptersByBackend = new Map();
|
|
330
|
+
let activeRouting = { backend: settings.agent.backend, sticky: false };
|
|
331
|
+
let lastUsedBackend = settings.agent.backend;
|
|
332
|
+
let lastResolvedDecisions = {};
|
|
333
|
+
let pendingFallbackPrompt = null;
|
|
334
|
+
const recentUtterances = [];
|
|
335
|
+
function recordUtterance(text) {
|
|
336
|
+
if (!text) return;
|
|
337
|
+
recentUtterances.push(text);
|
|
338
|
+
while (recentUtterances.length > 4) recentUtterances.shift();
|
|
339
|
+
}
|
|
340
|
+
function adapterForBackend(backend, session = null) {
|
|
341
|
+
const key = `${backend}::${session ? (session.slug || session.name) : '_default'}`;
|
|
342
|
+
if (agentAdaptersByBackend.has(key)) return agentAdaptersByBackend.get(key);
|
|
343
|
+
const baseEnv = { ...process.env, AGENT_BACKEND: backend };
|
|
344
|
+
let routedSettings;
|
|
345
|
+
try {
|
|
346
|
+
routedSettings = buildAgentSettings({ ROOT: settings.agent.cwd || process.cwd(), env: baseEnv });
|
|
347
|
+
} catch (e) {
|
|
348
|
+
warn(`adapterForBackend: cannot build settings for ${backend}: ${e?.message || e}`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (session) {
|
|
352
|
+
routedSettings = {
|
|
353
|
+
...routedSettings,
|
|
354
|
+
label: `${routedSettings.label} · ${session.name}`,
|
|
355
|
+
sessionFile: session.sessionFile,
|
|
356
|
+
cwd: session.workdir || routedSettings.cwd,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const adapter = createBridgeAgentAdapter(routedSettings);
|
|
360
|
+
agentAdaptersByBackend.set(key, adapter);
|
|
361
|
+
return adapter;
|
|
362
|
+
}
|
|
363
|
+
Commit: feat(main): per-backend adapter cache + routing state
|
|
364
|
+
|
|
365
|
+
Task 6 — Wire explicit voice routing into the main dispatch
|
|
366
|
+
Add import near the existing from './plan_mode.mjs':
|
|
367
|
+
|
|
368
|
+
import {
|
|
369
|
+
parseAgentRoutingCommand,
|
|
370
|
+
renderAgentPrefix,
|
|
371
|
+
buildCrossAgentPrompt,
|
|
372
|
+
isAgentRoutingDecision,
|
|
373
|
+
} from './agent_routing.mjs';
|
|
374
|
+
Insert just above const planOutcome = await dispatchPlanModeUtterance(prompt, signal); (~line 1510):
|
|
375
|
+
|
|
376
|
+
const routing = parseAgentRoutingCommand(prompt, settings.voiceLanguage);
|
|
377
|
+
if (routing.type === 'restore') {
|
|
378
|
+
activeRouting = { backend: settings.agent.backend, sticky: false };
|
|
379
|
+
const msg = /^en/i.test(String(settings.voiceLanguage || ''))
|
|
380
|
+
? `Back to the default agent (${settings.agent.label}).`
|
|
381
|
+
: `기본 에이전트로 돌아갈게 (${settings.agent.label}).`;
|
|
382
|
+
await sendText(`↩ ${msg}`);
|
|
383
|
+
await speakText(msg, signal, null);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (routing.type === 'route') {
|
|
387
|
+
const session = resolveProjectSessionForChannel(planChannelKey());
|
|
388
|
+
const candidate = adapterForBackend(routing.backend, session);
|
|
389
|
+
if (!candidate) {
|
|
390
|
+
const msg = /^en/i.test(String(settings.voiceLanguage || ''))
|
|
391
|
+
? `${routing.backend} is not installed. Want me to use ${settings.agent.label} instead?`
|
|
392
|
+
: `${routing.backend}이(가) 설치되어 있지 않아. ${settings.agent.label}로 대신 진행할까?`;
|
|
393
|
+
await sendText(`⚠️ ${msg}`);
|
|
394
|
+
await speakText(msg, signal, null);
|
|
395
|
+
pendingFallbackPrompt = { requestedBackend: routing.backend, originalPrompt: prompt };
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
activeRouting = { backend: routing.backend, sticky: routing.sticky };
|
|
399
|
+
}
|
|
400
|
+
recordUtterance(prompt);
|
|
401
|
+
Replace adapter resolution where the prompt finally runs against agentAdapter:
|
|
402
|
+
|
|
403
|
+
const session = resolveProjectSessionForChannel(planChannelKey());
|
|
404
|
+
const routedBackend = activeRouting.backend;
|
|
405
|
+
const routedAdapter = adapterForBackend(routedBackend, session) || adapterForProjectSession(session);
|
|
406
|
+
const isHandoff = lastUsedBackend !== routedBackend;
|
|
407
|
+
const finalPrompt = isHandoff
|
|
408
|
+
? buildCrossAgentPrompt({
|
|
409
|
+
prompt: promptForAgent,
|
|
410
|
+
fromBackend: lastUsedBackend,
|
|
411
|
+
toBackend: routedBackend,
|
|
412
|
+
resolvedDecisions: lastResolvedDecisions || {},
|
|
413
|
+
priorUtterances: recentUtterances.slice(0, -1),
|
|
414
|
+
language: settings.voiceLanguage,
|
|
415
|
+
})
|
|
416
|
+
: promptForAgent;
|
|
417
|
+
const ttsPrefix = isHandoff ? renderAgentPrefix(routedBackend, settings.voiceLanguage) : '';
|
|
418
|
+
lastUsedBackend = routedBackend;
|
|
419
|
+
if (!activeRouting.sticky) activeRouting = { backend: settings.agent.backend, sticky: false };
|
|
420
|
+
const result = await routedAdapter.run(finalPrompt, signal, plan);
|
|
421
|
+
Where TTS is spoken, prepend ttsPrefix. (If narration comes from the streaming sentencer, leave // TODO(bet-1.1): wire ttsPrefix into streaming path for now.)
|
|
422
|
+
|
|
423
|
+
Create app-node/cross_agent_routing.test.mjs:
|
|
424
|
+
|
|
425
|
+
import { test } from 'node:test';
|
|
426
|
+
import assert from 'node:assert/strict';
|
|
427
|
+
import {
|
|
428
|
+
parseAgentRoutingCommand, resolveBackendAlias,
|
|
429
|
+
renderAgentPrefix, buildCrossAgentPrompt,
|
|
430
|
+
} from './agent_routing.mjs';
|
|
431
|
+
|
|
432
|
+
test('routing pipeline: ask Codex resolves to single-turn route', () => {
|
|
433
|
+
const cmd = parseAgentRoutingCommand('ask Codex what it thinks', 'en');
|
|
434
|
+
assert.equal(cmd.type, 'route'); assert.equal(cmd.backend, 'codex'); assert.equal(cmd.sticky, false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('routing pipeline: switch to Aider resolves to sticky route', () => {
|
|
438
|
+
const cmd = parseAgentRoutingCommand('switch to Aider', 'en');
|
|
439
|
+
assert.equal(cmd.type, 'route'); assert.equal(cmd.backend, 'aider'); assert.equal(cmd.sticky, true);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('routing pipeline: alias resolves Claude Code to claude', () => {
|
|
443
|
+
assert.equal(resolveBackendAlias('Claude Code'), 'claude');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('routing pipeline: prefix changes only when backend changes', () => {
|
|
447
|
+
let last = 'claude', next = 'codex';
|
|
448
|
+
let prefix = last === next ? '' : renderAgentPrefix(next, 'en');
|
|
449
|
+
assert.equal(prefix, 'Codex says: ');
|
|
450
|
+
last = next; next = 'codex';
|
|
451
|
+
prefix = last === next ? '' : renderAgentPrefix(next, 'en');
|
|
452
|
+
assert.equal(prefix, '');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('routing pipeline: cross-agent prompt carries prior decisions', () => {
|
|
456
|
+
const out = buildCrossAgentPrompt({
|
|
457
|
+
prompt: 'finish the OAuth wire-up',
|
|
458
|
+
fromBackend: 'claude', toBackend: 'codex',
|
|
459
|
+
resolvedDecisions: { oauth_provider: 'github' },
|
|
460
|
+
priorUtterances: ['plan it first'], language: 'en',
|
|
461
|
+
});
|
|
462
|
+
assert.match(out, /from Claude Code to Codex/);
|
|
463
|
+
assert.match(out, /oauth_provider=github/);
|
|
464
|
+
});
|
|
465
|
+
Commit: feat(main): voice-driven cross-agent routing in dispatch
|
|
466
|
+
|
|
467
|
+
Task 7 — which_agent decision routes the executing turn
|
|
468
|
+
Append tests to cross_agent_routing.test.mjs:
|
|
469
|
+
|
|
470
|
+
import { parseDecisionAnswer } from './plan_mode.mjs';
|
|
471
|
+
|
|
472
|
+
test('which_agent decision: voice answer maps to backend name', () => {
|
|
473
|
+
const decision = { slot: 'which_agent', question: 'Who?', options: ['codex', 'aider'] };
|
|
474
|
+
assert.equal(parseDecisionAnswer('codex', decision, 'en').choice, 'codex');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('which_agent decision: ordinal answer maps to backend', () => {
|
|
478
|
+
const decision = { slot: 'which_agent', question: 'Who?', options: ['codex', 'aider', 'claude'] };
|
|
479
|
+
assert.equal(parseDecisionAnswer('the third one', decision, 'en').choice, 'claude');
|
|
480
|
+
});
|
|
481
|
+
In dispatchPlanModeUtterance (in main.mjs, ~line 380), after next = { ...existing, resolvedDecisions: ..., pendingDecisionIndex: ... }; add:
|
|
482
|
+
|
|
483
|
+
if (isAgentRoutingDecision(decision) && answer.choice) {
|
|
484
|
+
const candidate = adapterForBackend(answer.choice, resolveProjectSessionForChannel(key));
|
|
485
|
+
if (candidate) {
|
|
486
|
+
activeRouting = { backend: answer.choice, sticky: true };
|
|
487
|
+
} else {
|
|
488
|
+
const msg = /^en/i.test(String(language || ''))
|
|
489
|
+
? `${answer.choice} is not installed; staying with ${settings.agent.label}.`
|
|
490
|
+
: `${answer.choice}이(가) 설치되어 있지 않아. ${settings.agent.label}로 진행할게.`;
|
|
491
|
+
await sendText(`⚠️ ${msg}`);
|
|
492
|
+
await speakText(msg, signal, null);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
In the cmd.type === 'approve' branch (~line 409), add one line right before const promptToRun = [...]:
|
|
496
|
+
|
|
497
|
+
lastResolvedDecisions = existing.resolvedDecisions || {};
|
|
498
|
+
Commit: feat(plan-mode): which_agent decision routes the executing turn
|
|
499
|
+
|
|
500
|
+
Task 8 — Missing-agent fallback with yes/no grammar
|
|
501
|
+
Append tests to agent_routing.test.mjs:
|
|
502
|
+
|
|
503
|
+
import { buildFallbackDecision } from './agent_routing.mjs';
|
|
504
|
+
|
|
505
|
+
test('buildFallbackDecision yields a yes/no shape', () => {
|
|
506
|
+
const d = buildFallbackDecision('codex', 'Claude Code', 'en');
|
|
507
|
+
assert.equal(d.slot, 'fallback');
|
|
508
|
+
assert.deepEqual(d.options, ['yes', 'no']);
|
|
509
|
+
assert.match(d.question, /codex/);
|
|
510
|
+
assert.match(d.question, /Claude Code/);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('buildFallbackDecision yields a Korean prompt for ko', () => {
|
|
514
|
+
const d = buildFallbackDecision('codex', 'Claude Code', 'ko');
|
|
515
|
+
assert.match(d.question, /codex/);
|
|
516
|
+
assert.match(d.question, /Claude Code/);
|
|
517
|
+
});
|
|
518
|
+
Append to agent_routing.mjs:
|
|
519
|
+
|
|
520
|
+
export function buildFallbackDecision(missingBackend, fallbackLabel, language = 'en') {
|
|
521
|
+
const en = /^en/i.test(String(language || ''));
|
|
522
|
+
const question = en
|
|
523
|
+
? `${missingBackend} is not installed. Use ${fallbackLabel} instead?`
|
|
524
|
+
: `${missingBackend}이(가) 설치되어 있지 않아. ${fallbackLabel}로 대신 진행할까?`;
|
|
525
|
+
return { slot: 'fallback', question, options: ['yes', 'no'] };
|
|
526
|
+
}
|
|
527
|
+
Add buildFallbackDecision to the routing import in main.mjs. Ensure parseDecisionAnswer is imported from ./plan_mode.mjs in scope.
|
|
528
|
+
|
|
529
|
+
At the very top of the user-voice handler (above the Task 6 routing block):
|
|
530
|
+
|
|
531
|
+
if (pendingFallbackPrompt) {
|
|
532
|
+
const decision = buildFallbackDecision(
|
|
533
|
+
pendingFallbackPrompt.requestedBackend || 'agent',
|
|
534
|
+
settings.agent.label,
|
|
535
|
+
settings.voiceLanguage,
|
|
536
|
+
);
|
|
537
|
+
const answer = parseDecisionAnswer(prompt, decision, settings.voiceLanguage);
|
|
538
|
+
if (answer.type === 'unknown') {
|
|
539
|
+
const msg = /^en/i.test(String(settings.voiceLanguage || ''))
|
|
540
|
+
? 'Please answer yes or no.'
|
|
541
|
+
: '예 또는 아니오로 대답해줘.';
|
|
542
|
+
await sendText(`⚠️ ${msg}`);
|
|
543
|
+
await speakText(msg, signal, null);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const accepted = answer.type === 'auto' || answer.choice === 'yes';
|
|
547
|
+
const previous = pendingFallbackPrompt;
|
|
548
|
+
pendingFallbackPrompt = null;
|
|
549
|
+
if (!accepted) {
|
|
550
|
+
const msg = /^en/i.test(String(settings.voiceLanguage || '')) ? 'Cancelled.' : '취소했어.';
|
|
551
|
+
await sendText(`❎ ${msg}`);
|
|
552
|
+
await speakText(msg, signal, null);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
activeRouting = { backend: settings.agent.backend, sticky: false };
|
|
556
|
+
prompt = previous.originalPrompt;
|
|
557
|
+
}
|
|
558
|
+
Commit: feat(agent-routing): yes/no fallback when routed agent is missing
|
|
559
|
+
|
|
560
|
+
Task 9 — Lock minimum adapter contract for every backend
|
|
561
|
+
Append to app-node/agent_adapters.test.mjs (add import { test } from 'node:test'; and import assert from 'node:assert/strict'; at the top if not already imported):
|
|
562
|
+
|
|
563
|
+
import { assertAgentAdapterContract } from './agent_contract.mjs';
|
|
564
|
+
import { buildAgentSettings, createAgentAdapter } from './agent_adapters.mjs';
|
|
565
|
+
|
|
566
|
+
test('createAgentAdapter satisfies the agent adapter contract for every known backend', () => {
|
|
567
|
+
const backends = ['hermes', 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'aider', 'cursor'];
|
|
568
|
+
for (const backend of backends) {
|
|
569
|
+
const s = buildAgentSettings({
|
|
570
|
+
ROOT: '/tmp/vc-test',
|
|
571
|
+
env: { AGENT_BACKEND: backend, AGENT_COMMAND: 'echo test' },
|
|
572
|
+
});
|
|
573
|
+
const adapter = createAgentAdapter(s, { execFileAsync: async () => ({ stdout: '', stderr: '' }) });
|
|
574
|
+
assert.doesNotThrow(() => assertAgentAdapterContract(adapter), `${backend} should satisfy contract`);
|
|
575
|
+
assert.equal(adapter.backend, backend);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
Commit: test(agent-adapters): lock minimum contract for every backend
|
|
579
|
+
|
|
580
|
+
Task 10 — E2E composition test
|
|
581
|
+
Append to cross_agent_routing.test.mjs:
|
|
582
|
+
|
|
583
|
+
test('e2e composition: ask Codex single-turn, then back to default', () => {
|
|
584
|
+
const turn1 = parseAgentRoutingCommand('ask Codex what it thinks', 'en');
|
|
585
|
+
assert.equal(turn1.type, 'route'); assert.equal(turn1.sticky, false);
|
|
586
|
+
const prompt = buildCrossAgentPrompt({
|
|
587
|
+
prompt: 'ask Codex what it thinks', fromBackend: 'claude', toBackend: turn1.backend,
|
|
588
|
+
resolvedDecisions: {}, priorUtterances: [], language: 'en',
|
|
589
|
+
});
|
|
590
|
+
assert.match(prompt, /from Claude Code to Codex/);
|
|
591
|
+
const turn2 = parseAgentRoutingCommand('also add a test', 'en');
|
|
592
|
+
assert.equal(turn2.type, 'none');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('e2e composition: switch sticky until restore', () => {
|
|
596
|
+
assert.equal(parseAgentRoutingCommand('switch to Aider', 'en').sticky, true);
|
|
597
|
+
assert.equal(parseAgentRoutingCommand('write the test', 'en').type, 'none');
|
|
598
|
+
assert.equal(parseAgentRoutingCommand('back to default', 'en').type, 'restore');
|
|
599
|
+
});
|
|
600
|
+
Commit: test(routing): e2e composition for single-turn, sticky, restore
|
|
601
|
+
|
|
602
|
+
Task 11 — Surface routing in vc status (optional)
|
|
603
|
+
Grep app-node/main.mjs for the status renderer. Inside it:
|
|
604
|
+
|
|
605
|
+
const routingLine = `Routing: ${activeRouting.backend}${activeRouting.sticky ? ' (sticky)' : ''}`;
|
|
606
|
+
// push into existing status array
|
|
607
|
+
If no clear status builder, leave // TODO(bet-1.2): expose activeRouting in status output.
|
|
608
|
+
|
|
609
|
+
Commit: feat(main): surface active routing in vc status
|
|
610
|
+
|
|
611
|
+
Task 12 — Final verification
|
|
612
|
+
npm test # all green
|
|
613
|
+
npm run lint 2>/dev/null || echo "no lint"
|
|
614
|
+
Backward-compat audit:
|
|
615
|
+
|
|
616
|
+
Plan without DECISIONS_BEGIN/END still parses.
|
|
617
|
+
Plan with non-which_agent slot still resolves normally.
|
|
618
|
+
Session that never uses routing utterances still uses settings.agent.
|
|
619
|
+
All existing tests pass with only the appended ones added.
|
|
620
|
+
Self-Review Notes
|
|
621
|
+
Spec coverage: all 4 features (agent in fork, explicit routing, cross-agent context, missing-agent fallback) have at least one task.
|
|
622
|
+
Type consistency: activeRouting = {backend, sticky}, pendingFallbackPrompt = {requestedBackend, originalPrompt}, parseAgentRoutingCommand returns {type: 'route'|'restore'|'none', backend?, sticky?} — all consistent across tasks.
|
|
623
|
+
Placeholder scan: only two explicit deferred markers (bet-1.1 streaming-prefix anchor, bet-1.2 status output). Both bounded.
|
|
624
|
+
Non-goals preserved: bets 2 (diff review), 3 (phone-as-device), feedback-loop research thread, smart-progress demotion — all out of scope here.
|
|
625
|
+
|