@dmsdc-ai/aigentry-deliberation 0.0.24 → 0.0.26
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/index.js +475 -18
- package/package.json +1 -1
- package/selectors/decision-templates.json +86 -0
package/index.js
CHANGED
|
@@ -56,6 +56,12 @@ Usage:
|
|
|
56
56
|
* deliberation_browser_auto_turn 브라우저 LLM에 자동으로 턴을 전송하고 응답을 수집 (CDP 기반)
|
|
57
57
|
* deliberation_cli_auto_turn CLI speaker에 자동으로 턴을 전송하고 응답을 수집
|
|
58
58
|
* deliberation_request_review 코드 리뷰 요청 (CLI 리뷰어 자동 호출, sync/async 모드)
|
|
59
|
+
* decision_start 새 의사결정 세션 시작 (템플릿 지원)
|
|
60
|
+
* decision_status 의사결정 세션 상태 조회
|
|
61
|
+
* decision_respond user_probe 갈등 질문에 대한 사용자 응답 제출
|
|
62
|
+
* decision_resume 일시 중지된 세션 재개
|
|
63
|
+
* decision_history 과거 의사결정 기록 조회
|
|
64
|
+
* decision_templates Micro-Decision 템플릿 목록
|
|
59
65
|
*/
|
|
60
66
|
|
|
61
67
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -68,6 +74,13 @@ import { fileURLToPath } from "url";
|
|
|
68
74
|
import os from "os";
|
|
69
75
|
import { OrchestratedBrowserPort } from "./browser-control-port.js";
|
|
70
76
|
import { getModelSelectionForTurn } from "./model-router.js";
|
|
77
|
+
import {
|
|
78
|
+
DECISION_STAGES, STAGE_TRANSITIONS,
|
|
79
|
+
createDecisionSession, advanceStage, buildConflictMap,
|
|
80
|
+
parseOpinionFromResponse, buildOpinionPrompt,
|
|
81
|
+
generateConflictQuestions, buildSynthesis, buildActionPlan,
|
|
82
|
+
loadTemplates, matchTemplate,
|
|
83
|
+
} from "./decision-engine.js";
|
|
71
84
|
|
|
72
85
|
// ── Paths ──────────────────────────────────────────────────────
|
|
73
86
|
|
|
@@ -1220,17 +1233,20 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
|
|
|
1220
1233
|
|
|
1221
1234
|
// CDP auto-detection: probe endpoints for matching tabs
|
|
1222
1235
|
const cdpEndpoints = resolveCdpEndpoints();
|
|
1223
|
-
const
|
|
1236
|
+
const cdpTabsMap = new Map(); // dedupe by tab ID (multiple endpoints may return same tabs)
|
|
1224
1237
|
for (const endpoint of cdpEndpoints) {
|
|
1225
1238
|
try {
|
|
1226
1239
|
const tabs = await fetchJson(endpoint, 2000);
|
|
1227
1240
|
if (Array.isArray(tabs)) {
|
|
1228
1241
|
for (const t of tabs) {
|
|
1229
|
-
if (t.type === "page" && t.url
|
|
1242
|
+
if (t.type === "page" && t.url && t.id && !cdpTabsMap.has(t.id)) {
|
|
1243
|
+
cdpTabsMap.set(t.id, t);
|
|
1244
|
+
}
|
|
1230
1245
|
}
|
|
1231
1246
|
}
|
|
1232
1247
|
} catch { /* endpoint not reachable */ }
|
|
1233
1248
|
}
|
|
1249
|
+
const cdpTabs = [...cdpTabsMap.values()];
|
|
1234
1250
|
|
|
1235
1251
|
// Match CDP tabs with discovered browser candidates
|
|
1236
1252
|
for (const candidate of candidates) {
|
|
@@ -1244,7 +1260,7 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
|
|
|
1244
1260
|
String(t.url || "").startsWith("chrome-extension://") &&
|
|
1245
1261
|
String(t.title || "").toLowerCase().includes(candidateTitle)
|
|
1246
1262
|
);
|
|
1247
|
-
if (matches.length
|
|
1263
|
+
if (matches.length >= 1) {
|
|
1248
1264
|
candidate.cdp_available = true;
|
|
1249
1265
|
candidate.cdp_tab_id = matches[0].id;
|
|
1250
1266
|
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
@@ -1262,7 +1278,7 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
|
|
|
1262
1278
|
return new URL(t.url).hostname.toLowerCase() === candidateHost;
|
|
1263
1279
|
} catch { return false; }
|
|
1264
1280
|
});
|
|
1265
|
-
if (matches.length
|
|
1281
|
+
if (matches.length >= 1) {
|
|
1266
1282
|
candidate.cdp_available = true;
|
|
1267
1283
|
candidate.cdp_tab_id = matches[0].id;
|
|
1268
1284
|
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
@@ -1462,7 +1478,7 @@ function resolveTransportForSpeaker(state, speaker) {
|
|
|
1462
1478
|
// CLI-specific invocation flags for non-interactive execution
|
|
1463
1479
|
const CLI_INVOCATION_HINTS = {
|
|
1464
1480
|
claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "프롬프트"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
|
|
1465
|
-
codex: { cmd: "codex", flags: 'exec', example: 'codex exec "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
|
|
1481
|
+
codex: { cmd: "codex", flags: 'exec --model gpt-5.4-codex', example: 'codex exec --model gpt-5.4-codex "프롬프트"', modelFlag: '--model', defaultModel: 'gpt-5.4-codex', provider: 'chatgpt' },
|
|
1466
1482
|
gemini: { cmd: "gemini", flags: '', example: 'gemini "프롬프트"', modelFlag: '--model', provider: 'gemini' },
|
|
1467
1483
|
aider: { cmd: "aider", flags: '--message', example: 'aider --message "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
|
|
1468
1484
|
cursor: { cmd: "cursor", flags: '', example: 'cursor "프롬프트"', modelFlag: null, provider: 'chatgpt' },
|
|
@@ -1846,7 +1862,9 @@ function tmuxWindowCount(name) {
|
|
|
1846
1862
|
|
|
1847
1863
|
function buildTmuxAttachCommand(sessionId) {
|
|
1848
1864
|
const winName = tmuxWindowName(sessionId);
|
|
1849
|
-
|
|
1865
|
+
// Use grouped session (new-session -t) so each terminal has independent active window.
|
|
1866
|
+
// This prevents window-switching conflicts when multiple deliberations run concurrently.
|
|
1867
|
+
return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
|
|
1850
1868
|
}
|
|
1851
1869
|
|
|
1852
1870
|
function listPhysicalTerminalWindowIds() {
|
|
@@ -1887,16 +1905,31 @@ function listPhysicalTerminalWindowIds() {
|
|
|
1887
1905
|
|
|
1888
1906
|
function openPhysicalTerminal(sessionId) {
|
|
1889
1907
|
const winName = tmuxWindowName(sessionId);
|
|
1890
|
-
|
|
1908
|
+
// Use grouped session (new-session -t) for independent active window per client
|
|
1909
|
+
const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1891
1910
|
|
|
1892
|
-
// If a terminal is already attached
|
|
1911
|
+
// If a terminal is already attached, open a NEW grouped session instead of
|
|
1912
|
+
// select-window (which would hijack all attached clients' views).
|
|
1913
|
+
// Grouped sessions share windows but each has independent active window tracking.
|
|
1893
1914
|
if (tmuxHasAttachedClients(TMUX_SESSION)) {
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1915
|
+
if (process.platform === "darwin") {
|
|
1916
|
+
const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1917
|
+
try {
|
|
1918
|
+
execFileSync(
|
|
1919
|
+
"osascript",
|
|
1920
|
+
[
|
|
1921
|
+
"-e", 'tell application "Terminal"',
|
|
1922
|
+
"-e", "activate",
|
|
1923
|
+
"-e", `do script ${appleScriptQuote(groupAttachCmd)}`,
|
|
1924
|
+
"-e", "end tell",
|
|
1925
|
+
],
|
|
1926
|
+
{ encoding: "utf-8" }
|
|
1927
|
+
);
|
|
1928
|
+
return { opened: true, windowIds: [] };
|
|
1929
|
+
} catch { /* fall through to default behavior */ }
|
|
1930
|
+
}
|
|
1931
|
+
// Non-macOS or fallback: don't force select-window, just report success
|
|
1932
|
+
// The monitor window already exists in tmux; user can switch manually
|
|
1900
1933
|
return { opened: true, windowIds: [] };
|
|
1901
1934
|
}
|
|
1902
1935
|
|
|
@@ -2613,10 +2646,10 @@ server.tool(
|
|
|
2613
2646
|
: physicalOpened
|
|
2614
2647
|
? isWin
|
|
2615
2648
|
? `\n🖥️ 모니터 터미널 오픈됨 (Windows Terminal)`
|
|
2616
|
-
: `\n🖥️ 모니터 터미널 오픈됨: tmux
|
|
2649
|
+
: `\n🖥️ 모니터 터미널 오픈됨: tmux new-session -t ${TMUX_SESSION}`
|
|
2617
2650
|
: isWin
|
|
2618
2651
|
? `\n⚠️ 모니터 터미널 자동 오픈 실패`
|
|
2619
|
-
: `\n⚠️ tmux 윈도우는 생성됐지만 외부 터미널 자동 오픈 실패. 수동 실행: tmux
|
|
2652
|
+
: `\n⚠️ tmux 윈도우는 생성됐지만 외부 터미널 자동 오픈 실패. 수동 실행: tmux new-session -t ${TMUX_SESSION}`;
|
|
2620
2653
|
const manualNotDetected = hasManualSpeakers
|
|
2621
2654
|
? speakerOrder.filter(s => !candidateSnapshot.candidates.some(c => c.speaker === s))
|
|
2622
2655
|
: [];
|
|
@@ -3047,7 +3080,7 @@ server.tool(
|
|
|
3047
3080
|
child.stdin.end();
|
|
3048
3081
|
break;
|
|
3049
3082
|
case "codex":
|
|
3050
|
-
child = spawn("codex", ["exec", turnPrompt], { env, windowsHide: true });
|
|
3083
|
+
child = spawn("codex", ["exec", "--model", "gpt-5.4-codex", turnPrompt], { env, windowsHide: true });
|
|
3051
3084
|
break;
|
|
3052
3085
|
case "gemini":
|
|
3053
3086
|
child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
|
|
@@ -3756,6 +3789,430 @@ server.tool(
|
|
|
3756
3789
|
})
|
|
3757
3790
|
);
|
|
3758
3791
|
|
|
3792
|
+
// ── Decision Engine Tools ─────────────────────────────────────
|
|
3793
|
+
|
|
3794
|
+
server.tool(
|
|
3795
|
+
"decision_start",
|
|
3796
|
+
"새 의사결정 세션을 시작합니다. 여러 LLM이 독립적으로 의견을 제시하고 갈등을 가시화합니다.",
|
|
3797
|
+
{
|
|
3798
|
+
problem: z.string().describe("의사결정 문제 (예: 'JWT vs Session 인증 방식 선택')"),
|
|
3799
|
+
options: z.preprocess(
|
|
3800
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
3801
|
+
z.array(z.string()).optional()
|
|
3802
|
+
).describe("선택지 목록 (예: ['JWT', 'Session', 'OAuth2'])"),
|
|
3803
|
+
criteria: z.preprocess(
|
|
3804
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
3805
|
+
z.array(z.string()).optional()
|
|
3806
|
+
).describe("평가 기준 (미지정 시 템플릿에서 자동 로드)"),
|
|
3807
|
+
template: z.string().optional().describe("Micro-decision 템플릿 ID (lib-compare, arch-decision, pr-priority, naming-convention, tradeoff, risk-approval)"),
|
|
3808
|
+
speakers: z.preprocess(
|
|
3809
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
3810
|
+
z.array(z.string().trim().min(1).max(64)).min(2).optional()
|
|
3811
|
+
).describe("참여 LLM 목록 (최소 2명, 예: ['claude', 'codex', 'gemini'])"),
|
|
3812
|
+
},
|
|
3813
|
+
safeToolHandler("decision_start", async ({ problem, options, criteria, template, speakers }) => {
|
|
3814
|
+
// Auto-discover speakers if not provided
|
|
3815
|
+
if (!speakers || speakers.length === 0) {
|
|
3816
|
+
const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: false });
|
|
3817
|
+
speakers = candidateSnapshot.candidates
|
|
3818
|
+
.filter(c => c.type === "cli" && checkCliLiveness(c.speaker))
|
|
3819
|
+
.map(c => c.speaker)
|
|
3820
|
+
.slice(0, 4);
|
|
3821
|
+
if (speakers.length < 2) {
|
|
3822
|
+
return { content: [{ type: "text", text: "❌ 의사결정에 최소 2명의 speaker가 필요합니다. speakers를 직접 지정하세요." }] };
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
// Template matching
|
|
3827
|
+
const templates = loadTemplates();
|
|
3828
|
+
let matchedTemplate = null;
|
|
3829
|
+
if (template) {
|
|
3830
|
+
matchedTemplate = templates.find(t => t.id === template) || null;
|
|
3831
|
+
} else {
|
|
3832
|
+
matchedTemplate = matchTemplate(problem, templates);
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
// Use template criteria if not provided
|
|
3836
|
+
if ((!criteria || criteria.length === 0) && matchedTemplate) {
|
|
3837
|
+
criteria = matchedTemplate.criteria.map(c => c.name || c);
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
// Create session
|
|
3841
|
+
const session = createDecisionSession({
|
|
3842
|
+
problem,
|
|
3843
|
+
options: options || [],
|
|
3844
|
+
criteria: criteria || [],
|
|
3845
|
+
speakers,
|
|
3846
|
+
template: matchedTemplate?.id || null,
|
|
3847
|
+
participant_profiles: mapParticipantProfiles(speakers, [], {}),
|
|
3848
|
+
});
|
|
3849
|
+
|
|
3850
|
+
// Advance to parallel_opinions immediately (intake is just creation)
|
|
3851
|
+
advanceStage(session);
|
|
3852
|
+
|
|
3853
|
+
// Save session
|
|
3854
|
+
withSessionLock(session.id, () => {
|
|
3855
|
+
saveSession(session);
|
|
3856
|
+
});
|
|
3857
|
+
|
|
3858
|
+
appendRuntimeLog("INFO", `DECISION_START: ${session.id} | problem: ${problem.slice(0, 60)} | speakers: ${speakers.join(",")} | criteria: ${(criteria || []).length}`);
|
|
3859
|
+
|
|
3860
|
+
// Build opinion prompt for parallel execution
|
|
3861
|
+
const opinionPrompt = buildOpinionPrompt(problem, options || [], criteria || [], matchedTemplate?.id);
|
|
3862
|
+
|
|
3863
|
+
// Run parallel independent opinions using CLI auto-turn pattern
|
|
3864
|
+
const opinionResults = {};
|
|
3865
|
+
const opinionPromises = speakers.map(async (speaker) => {
|
|
3866
|
+
try {
|
|
3867
|
+
const hint = CLI_INVOCATION_HINTS[speaker] || CLI_INVOCATION_HINTS["_generic"];
|
|
3868
|
+
const cmd = hint?.cmd || speaker;
|
|
3869
|
+
|
|
3870
|
+
// Check liveness
|
|
3871
|
+
if (!checkCliLiveness(cmd)) {
|
|
3872
|
+
opinionResults[speaker] = { error: `CLI not available: ${cmd}` };
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
// Spawn CLI with opinion prompt
|
|
3877
|
+
const result = await new Promise((resolve, reject) => {
|
|
3878
|
+
let stdout = "";
|
|
3879
|
+
let stderr = "";
|
|
3880
|
+
|
|
3881
|
+
const args = [];
|
|
3882
|
+
if (speaker === "claude") {
|
|
3883
|
+
args.push("-p", "--output-format", "text", opinionPrompt);
|
|
3884
|
+
} else if (speaker === "codex") {
|
|
3885
|
+
args.push("exec", opinionPrompt);
|
|
3886
|
+
} else if (speaker === "gemini") {
|
|
3887
|
+
args.push("-p", opinionPrompt);
|
|
3888
|
+
} else {
|
|
3889
|
+
const flags = hint?.flags || [];
|
|
3890
|
+
args.push(...flags, opinionPrompt);
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
const proc = spawn(cmd, args, {
|
|
3894
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3895
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
3896
|
+
timeout: 180000,
|
|
3897
|
+
});
|
|
3898
|
+
|
|
3899
|
+
proc.stdout?.on("data", (d) => { stdout += d.toString(); });
|
|
3900
|
+
proc.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
3901
|
+
|
|
3902
|
+
const timer = setTimeout(() => {
|
|
3903
|
+
proc.kill("SIGTERM");
|
|
3904
|
+
reject(new Error("timeout"));
|
|
3905
|
+
}, 180000);
|
|
3906
|
+
|
|
3907
|
+
proc.on("close", (code) => {
|
|
3908
|
+
clearTimeout(timer);
|
|
3909
|
+
resolve(stdout.trim() || stderr.trim());
|
|
3910
|
+
});
|
|
3911
|
+
|
|
3912
|
+
proc.on("error", (err) => {
|
|
3913
|
+
clearTimeout(timer);
|
|
3914
|
+
reject(err);
|
|
3915
|
+
});
|
|
3916
|
+
});
|
|
3917
|
+
|
|
3918
|
+
opinionResults[speaker] = result;
|
|
3919
|
+
} catch (err) {
|
|
3920
|
+
opinionResults[speaker] = { error: err.message };
|
|
3921
|
+
}
|
|
3922
|
+
});
|
|
3923
|
+
|
|
3924
|
+
// Wait for all opinions in parallel
|
|
3925
|
+
await Promise.all(opinionPromises);
|
|
3926
|
+
|
|
3927
|
+
// Parse opinions and update session
|
|
3928
|
+
withSessionLock(session.id, () => {
|
|
3929
|
+
const latest = loadSession(session.id);
|
|
3930
|
+
if (!latest) return;
|
|
3931
|
+
|
|
3932
|
+
for (const [speaker, result] of Object.entries(opinionResults)) {
|
|
3933
|
+
if (typeof result === "string") {
|
|
3934
|
+
latest.opinions[speaker] = parseOpinionFromResponse(speaker, result, latest.criteria);
|
|
3935
|
+
latest.log.push({
|
|
3936
|
+
round: 1,
|
|
3937
|
+
speaker,
|
|
3938
|
+
content: result,
|
|
3939
|
+
timestamp: new Date().toISOString(),
|
|
3940
|
+
channel_used: "cli_auto",
|
|
3941
|
+
event: "opinion",
|
|
3942
|
+
});
|
|
3943
|
+
} else {
|
|
3944
|
+
appendRuntimeLog("WARN", `DECISION_OPINION_FAIL: ${session.id} | ${speaker}: ${result?.error}`);
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
// Advance to conflict_map
|
|
3949
|
+
latest.stage = "conflict_map";
|
|
3950
|
+
latest.metadata.updated = new Date().toISOString();
|
|
3951
|
+
|
|
3952
|
+
// Build conflict map
|
|
3953
|
+
latest.conflicts = buildConflictMap(latest.opinions, latest.criteria);
|
|
3954
|
+
|
|
3955
|
+
// Advance to user_probe
|
|
3956
|
+
latest.stage = "user_probe";
|
|
3957
|
+
latest.metadata.updated = new Date().toISOString();
|
|
3958
|
+
|
|
3959
|
+
saveSession(latest);
|
|
3960
|
+
});
|
|
3961
|
+
|
|
3962
|
+
// Load updated session for response
|
|
3963
|
+
const updatedSession = loadSession(session.id);
|
|
3964
|
+
const conflictText = generateConflictQuestions(updatedSession?.conflicts || []);
|
|
3965
|
+
const successCount = Object.keys(updatedSession?.opinions || {}).length;
|
|
3966
|
+
const templateInfo = matchedTemplate ? `\n**Template:** ${matchedTemplate.name}` : "";
|
|
3967
|
+
|
|
3968
|
+
appendRuntimeLog("INFO", `DECISION_OPINIONS_COMPLETE: ${session.id} | opinions: ${successCount}/${speakers.length} | conflicts: ${(updatedSession?.conflicts || []).length}`);
|
|
3969
|
+
|
|
3970
|
+
return {
|
|
3971
|
+
content: [{
|
|
3972
|
+
type: "text",
|
|
3973
|
+
text: `✅ **Decision Session 시작됨**\n\n**Session:** ${session.id}\n**Problem:** ${problem}\n**Speakers:** ${speakers.join(", ")}\n**Opinions collected:** ${successCount}/${speakers.length}${templateInfo}\n**Stage:** user_probe (사용자 입력 대기)\n**Conflicts:** ${(updatedSession?.conflicts || []).length}개\n\n---\n\n${conflictText}\n\n---\n\n사용자 응답을 \`decision_respond\`로 제출하세요.`,
|
|
3974
|
+
}],
|
|
3975
|
+
};
|
|
3976
|
+
})
|
|
3977
|
+
);
|
|
3978
|
+
|
|
3979
|
+
server.tool(
|
|
3980
|
+
"decision_status",
|
|
3981
|
+
"의사결정 세션의 현재 상태를 조회합니다.",
|
|
3982
|
+
{
|
|
3983
|
+
session_id: z.string().optional().describe("세션 ID (미지정 시 활성 decision 세션 자동 선택)"),
|
|
3984
|
+
},
|
|
3985
|
+
safeToolHandler("decision_status", async ({ session_id }) => {
|
|
3986
|
+
// Find decision sessions
|
|
3987
|
+
const active = listActiveSessions().filter(s => {
|
|
3988
|
+
const full = loadSession(s.id);
|
|
3989
|
+
return full?.type === "decision";
|
|
3990
|
+
});
|
|
3991
|
+
|
|
3992
|
+
let resolved = session_id;
|
|
3993
|
+
if (!resolved) {
|
|
3994
|
+
if (active.length === 0) return { content: [{ type: "text", text: "활성 decision 세션이 없습니다." }] };
|
|
3995
|
+
if (active.length === 1) resolved = active[0].id;
|
|
3996
|
+
else return { content: [{ type: "text", text: `여러 decision 세션이 진행 중입니다. session_id를 지정하세요:\n${active.map(s => `- ${s.id}`).join("\n")}` }] };
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
const state = loadSession(resolved);
|
|
4000
|
+
if (!state) return { content: [{ type: "text", text: `세션을 찾을 수 없습니다: ${resolved}` }] };
|
|
4001
|
+
|
|
4002
|
+
const opinionCount = Object.keys(state.opinions || {}).length;
|
|
4003
|
+
const conflictCount = (state.conflicts || []).length;
|
|
4004
|
+
const stageIdx = DECISION_STAGES.indexOf(state.stage);
|
|
4005
|
+
const progress = stageIdx >= 0 ? `${stageIdx + 1}/${DECISION_STAGES.length}` : state.stage;
|
|
4006
|
+
|
|
4007
|
+
return {
|
|
4008
|
+
content: [{
|
|
4009
|
+
type: "text",
|
|
4010
|
+
text: `📊 **Decision Session Status**\n\n**Session:** ${state.id}\n**Problem:** ${state.problem}\n**Stage:** ${state.stage} (${progress})\n**Status:** ${state.status}\n**Speakers:** ${(state.speakers || []).join(", ")}\n**Opinions:** ${opinionCount}/${(state.speakers || []).length}\n**Conflicts:** ${conflictCount}\n**Template:** ${state.template || "(none)"}\n**Created:** ${state.metadata?.created || ""}`,
|
|
4011
|
+
}],
|
|
4012
|
+
};
|
|
4013
|
+
})
|
|
4014
|
+
);
|
|
4015
|
+
|
|
4016
|
+
server.tool(
|
|
4017
|
+
"decision_respond",
|
|
4018
|
+
"user_probe 단계의 갈등 질문에 대한 사용자 응답을 제출합니다.",
|
|
4019
|
+
{
|
|
4020
|
+
session_id: z.string().optional().describe("세션 ID"),
|
|
4021
|
+
responses: z.preprocess(
|
|
4022
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
4023
|
+
z.array(z.string()).min(1)
|
|
4024
|
+
).describe("각 갈등 질문에 대한 응답 배열 (conflict 순서대로)"),
|
|
4025
|
+
},
|
|
4026
|
+
safeToolHandler("decision_respond", async ({ session_id, responses }) => {
|
|
4027
|
+
// Find decision session
|
|
4028
|
+
const active = listActiveSessions().filter(s => {
|
|
4029
|
+
const full = loadSession(s.id);
|
|
4030
|
+
return full?.type === "decision";
|
|
4031
|
+
});
|
|
4032
|
+
|
|
4033
|
+
let resolved = session_id;
|
|
4034
|
+
if (!resolved) {
|
|
4035
|
+
if (active.length === 1) resolved = active[0].id;
|
|
4036
|
+
else if (active.length === 0) return { content: [{ type: "text", text: "활성 decision 세션이 없습니다." }] };
|
|
4037
|
+
else return { content: [{ type: "text", text: `여러 decision 세션이 진행 중입니다. session_id를 지정하세요.` }] };
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
let synthesisText = "";
|
|
4041
|
+
let actionPlan = null;
|
|
4042
|
+
|
|
4043
|
+
withSessionLock(resolved, () => {
|
|
4044
|
+
const state = loadSession(resolved);
|
|
4045
|
+
if (!state) return;
|
|
4046
|
+
if (state.stage !== "user_probe") {
|
|
4047
|
+
synthesisText = `❌ 현재 단계(${state.stage})에서는 응답을 받을 수 없습니다. user_probe 단계에서만 가능합니다.`;
|
|
4048
|
+
return;
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
// Store user responses
|
|
4052
|
+
state.userProbeResponses = responses;
|
|
4053
|
+
state.log.push({
|
|
4054
|
+
event: "user_probe_response",
|
|
4055
|
+
responses,
|
|
4056
|
+
timestamp: new Date().toISOString(),
|
|
4057
|
+
});
|
|
4058
|
+
|
|
4059
|
+
// Advance to synthesis
|
|
4060
|
+
state.stage = "synthesis";
|
|
4061
|
+
state.metadata.updated = new Date().toISOString();
|
|
4062
|
+
|
|
4063
|
+
// Build synthesis
|
|
4064
|
+
state.synthesis = buildSynthesis(state);
|
|
4065
|
+
|
|
4066
|
+
// Advance to action_export
|
|
4067
|
+
state.stage = "action_export";
|
|
4068
|
+
state.metadata.updated = new Date().toISOString();
|
|
4069
|
+
|
|
4070
|
+
// Build action plan
|
|
4071
|
+
state.actionPlan = buildActionPlan(state);
|
|
4072
|
+
|
|
4073
|
+
// Advance to done
|
|
4074
|
+
state.stage = "done";
|
|
4075
|
+
state.status = "completed";
|
|
4076
|
+
state.metadata.updated = new Date().toISOString();
|
|
4077
|
+
|
|
4078
|
+
saveSession(state);
|
|
4079
|
+
|
|
4080
|
+
// Archive
|
|
4081
|
+
const archivePath = archiveState(state);
|
|
4082
|
+
cleanupSyncMarkdown(state);
|
|
4083
|
+
|
|
4084
|
+
synthesisText = state.synthesis;
|
|
4085
|
+
actionPlan = state.actionPlan;
|
|
4086
|
+
|
|
4087
|
+
appendRuntimeLog("INFO", `DECISION_COMPLETE: ${resolved} | conflicts_resolved: ${responses.length} | decision: ${(actionPlan?.decision || "").slice(0, 60)}`);
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
if (synthesisText.startsWith("❌")) {
|
|
4091
|
+
return { content: [{ type: "text", text: synthesisText }] };
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
const checklistText = actionPlan?.exportFormats?.checklist || "";
|
|
4095
|
+
return {
|
|
4096
|
+
content: [{
|
|
4097
|
+
type: "text",
|
|
4098
|
+
text: `✅ **Decision Complete**\n\n${synthesisText}\n\n---\n\n## Action Plan\n\n${checklistText}`,
|
|
4099
|
+
}],
|
|
4100
|
+
};
|
|
4101
|
+
})
|
|
4102
|
+
);
|
|
4103
|
+
|
|
4104
|
+
server.tool(
|
|
4105
|
+
"decision_resume",
|
|
4106
|
+
"일시 중지된 decision 세션을 재개합니다 (user_probe 단계에서 갈등 질문을 다시 표시).",
|
|
4107
|
+
{
|
|
4108
|
+
session_id: z.string().optional().describe("세션 ID"),
|
|
4109
|
+
},
|
|
4110
|
+
safeToolHandler("decision_resume", async ({ session_id }) => {
|
|
4111
|
+
const active = listActiveSessions().filter(s => {
|
|
4112
|
+
const full = loadSession(s.id);
|
|
4113
|
+
return full?.type === "decision";
|
|
4114
|
+
});
|
|
4115
|
+
|
|
4116
|
+
let resolved = session_id;
|
|
4117
|
+
if (!resolved) {
|
|
4118
|
+
if (active.length === 1) resolved = active[0].id;
|
|
4119
|
+
else if (active.length === 0) return { content: [{ type: "text", text: "재개할 decision 세션이 없습니다." }] };
|
|
4120
|
+
else return { content: [{ type: "text", text: `여러 세션 중 선택하세요:\n${active.map(s => `- ${s.id}`).join("\n")}` }] };
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
const state = loadSession(resolved);
|
|
4124
|
+
if (!state) return { content: [{ type: "text", text: `세션을 찾을 수 없습니다: ${resolved}` }] };
|
|
4125
|
+
if (state.stage !== "user_probe") {
|
|
4126
|
+
return { content: [{ type: "text", text: `세션이 user_probe 단계가 아닙니다 (현재: ${state.stage}). 재개할 수 없습니다.` }] };
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
const conflictText = generateConflictQuestions(state.conflicts || []);
|
|
4130
|
+
return {
|
|
4131
|
+
content: [{
|
|
4132
|
+
type: "text",
|
|
4133
|
+
text: `📋 **Decision Session 재개**\n\n**Session:** ${state.id}\n**Problem:** ${state.problem}\n**Stage:** user_probe\n\n---\n\n${conflictText}\n\n---\n\n사용자 응답을 \`decision_respond\`로 제출하세요.`,
|
|
4134
|
+
}],
|
|
4135
|
+
};
|
|
4136
|
+
})
|
|
4137
|
+
);
|
|
4138
|
+
|
|
4139
|
+
server.tool(
|
|
4140
|
+
"decision_history",
|
|
4141
|
+
"과거 의사결정 기록을 조회합니다.",
|
|
4142
|
+
{
|
|
4143
|
+
session_id: z.string().optional().describe("특정 세션 ID (미지정 시 전체 목록)"),
|
|
4144
|
+
},
|
|
4145
|
+
safeToolHandler("decision_history", async ({ session_id }) => {
|
|
4146
|
+
if (session_id) {
|
|
4147
|
+
const state = loadSession(session_id);
|
|
4148
|
+
if (!state) return { content: [{ type: "text", text: `세션을 찾을 수 없습니다: ${session_id}` }] };
|
|
4149
|
+
|
|
4150
|
+
const opinionSummary = Object.entries(state.opinions || {})
|
|
4151
|
+
.map(([speaker, op]) => `- **${speaker}**: ${op.summary || "(none)"} (confidence: ${Math.round((op.confidence || 0.5) * 100)}%)`)
|
|
4152
|
+
.join("\n");
|
|
4153
|
+
|
|
4154
|
+
return {
|
|
4155
|
+
content: [{
|
|
4156
|
+
type: "text",
|
|
4157
|
+
text: `📜 **Decision History: ${state.id}**\n\n**Problem:** ${state.problem}\n**Status:** ${state.status}\n**Stage:** ${state.stage}\n**Template:** ${state.template || "(none)"}\n\n## Opinions\n${opinionSummary || "(none)"}\n\n## Synthesis\n${state.synthesis || "(not yet synthesized)"}\n\n## Action Plan\n${state.actionPlan?.exportFormats?.checklist || "(not yet generated)"}`,
|
|
4158
|
+
}],
|
|
4159
|
+
};
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
// List all decision sessions from archives
|
|
4163
|
+
const projectSlug = getProjectSlug();
|
|
4164
|
+
const archiveDir = path.join(GLOBAL_STATE_DIR, projectSlug, "archive");
|
|
4165
|
+
let decisionArchives = [];
|
|
4166
|
+
try {
|
|
4167
|
+
const files = fs.readdirSync(archiveDir).filter(f => f.startsWith("decision-"));
|
|
4168
|
+
decisionArchives = files.map(f => {
|
|
4169
|
+
const match = f.match(/^decision-(.+)\.md$/);
|
|
4170
|
+
return match ? match[1] : f;
|
|
4171
|
+
});
|
|
4172
|
+
} catch { /* no archives */ }
|
|
4173
|
+
|
|
4174
|
+
// Also list active decision sessions
|
|
4175
|
+
const activeSessions = listActiveSessions().filter(s => {
|
|
4176
|
+
const full = loadSession(s.id);
|
|
4177
|
+
return full?.type === "decision";
|
|
4178
|
+
});
|
|
4179
|
+
|
|
4180
|
+
const activeList = activeSessions.map(s => `- 🟢 ${s.id} (${s.status})`).join("\n");
|
|
4181
|
+
const archiveList = decisionArchives.map(a => `- 📁 ${a}`).join("\n");
|
|
4182
|
+
|
|
4183
|
+
return {
|
|
4184
|
+
content: [{
|
|
4185
|
+
type: "text",
|
|
4186
|
+
text: `📜 **Decision History**\n\n## Active Sessions\n${activeList || "(none)"}\n\n## Archives\n${archiveList || "(none)"}`,
|
|
4187
|
+
}],
|
|
4188
|
+
};
|
|
4189
|
+
})
|
|
4190
|
+
);
|
|
4191
|
+
|
|
4192
|
+
server.tool(
|
|
4193
|
+
"decision_templates",
|
|
4194
|
+
"사용 가능한 Micro-Decision 템플릿 목록을 표시합니다.",
|
|
4195
|
+
{},
|
|
4196
|
+
safeToolHandler("decision_templates", async () => {
|
|
4197
|
+
const templates = loadTemplates();
|
|
4198
|
+
if (templates.length === 0) {
|
|
4199
|
+
return { content: [{ type: "text", text: "사용 가능한 템플릿이 없습니다." }] };
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
const list = templates.map(t => {
|
|
4203
|
+
const criteriaList = (t.criteria || []).map(c => c.name || c.label || c).join(", ");
|
|
4204
|
+
return `### ${t.name} (\`${t.id}\`)\n${t.description}\n- **Criteria:** ${criteriaList}\n- **Example:** ${t.example_problem || "(none)"}`;
|
|
4205
|
+
}).join("\n\n");
|
|
4206
|
+
|
|
4207
|
+
return {
|
|
4208
|
+
content: [{
|
|
4209
|
+
type: "text",
|
|
4210
|
+
text: `📋 **Decision Templates**\n\n${list}\n\n---\n\n\`decision_start(problem: "...", template: "lib-compare")\`로 사용하세요.`,
|
|
4211
|
+
}],
|
|
4212
|
+
};
|
|
4213
|
+
})
|
|
4214
|
+
);
|
|
4215
|
+
|
|
3759
4216
|
// ── Start ──────────────────────────────────────────────────────
|
|
3760
4217
|
|
|
3761
4218
|
// Only start server when run directly (not imported for testing)
|
|
@@ -3784,4 +4241,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
|
|
|
3784
4241
|
}
|
|
3785
4242
|
|
|
3786
4243
|
// ── Test exports (used by vitest) ──
|
|
3787
|
-
export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS };
|
|
4244
|
+
export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate };
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"templates": [
|
|
3
|
+
{
|
|
4
|
+
"id": "lib-compare",
|
|
5
|
+
"name": "Library/Package Selection",
|
|
6
|
+
"description": "Compare libraries or packages for a specific use case",
|
|
7
|
+
"keywords": ["library", "package", "npm", "dependency", "compare", "라이브러리", "패키지", "선택"],
|
|
8
|
+
"criteria": [
|
|
9
|
+
{ "name": "performance", "label": "Performance", "description": "Runtime speed and efficiency" },
|
|
10
|
+
{ "name": "bundle_size", "label": "Bundle Size", "description": "Package size impact on build" },
|
|
11
|
+
{ "name": "community", "label": "Community & Ecosystem", "description": "Active maintenance, documentation, community size" },
|
|
12
|
+
{ "name": "type_support", "label": "TypeScript Support", "description": "Type definitions and type safety" },
|
|
13
|
+
{ "name": "api_design", "label": "API Design", "description": "Developer experience and API ergonomics" }
|
|
14
|
+
],
|
|
15
|
+
"example_problem": "Should we use date-fns or dayjs for date formatting?"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "arch-decision",
|
|
19
|
+
"name": "Architecture Decision",
|
|
20
|
+
"description": "Choose between architectural approaches or patterns",
|
|
21
|
+
"keywords": ["architecture", "design", "pattern", "approach", "아키텍처", "설계", "구조"],
|
|
22
|
+
"criteria": [
|
|
23
|
+
{ "name": "scalability", "label": "Scalability", "description": "Ability to handle growth" },
|
|
24
|
+
{ "name": "complexity", "label": "Complexity", "description": "Implementation and maintenance complexity (lower is better)" },
|
|
25
|
+
{ "name": "maintainability", "label": "Maintainability", "description": "Long-term maintenance burden" },
|
|
26
|
+
{ "name": "performance", "label": "Performance", "description": "Runtime performance characteristics" },
|
|
27
|
+
{ "name": "cost", "label": "Cost", "description": "Infrastructure and development cost" }
|
|
28
|
+
],
|
|
29
|
+
"example_problem": "Should we use microservices or monolith for our backend?"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "pr-priority",
|
|
33
|
+
"name": "PR/Task Prioritization",
|
|
34
|
+
"description": "Prioritize pull requests or tasks by importance",
|
|
35
|
+
"keywords": ["priority", "PR", "task", "backlog", "prioritize", "우선순위", "백로그"],
|
|
36
|
+
"criteria": [
|
|
37
|
+
{ "name": "urgency", "label": "Urgency", "description": "Time sensitivity and deadline pressure" },
|
|
38
|
+
{ "name": "impact", "label": "Impact Scope", "description": "Number of users/systems affected" },
|
|
39
|
+
{ "name": "dependencies", "label": "Dependencies", "description": "How many other tasks depend on this" },
|
|
40
|
+
{ "name": "risk", "label": "Risk Level", "description": "Potential for introducing issues" },
|
|
41
|
+
{ "name": "effort", "label": "Effort", "description": "Implementation effort required (lower is better)" }
|
|
42
|
+
],
|
|
43
|
+
"example_problem": "Which of these 5 PRs should we review first?"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "naming-convention",
|
|
47
|
+
"name": "API/Code Naming Convention",
|
|
48
|
+
"description": "Decide on naming conventions for APIs, variables, or components",
|
|
49
|
+
"keywords": ["naming", "convention", "API", "name", "rename", "네이밍", "컨벤션", "이름"],
|
|
50
|
+
"criteria": [
|
|
51
|
+
{ "name": "consistency", "label": "Consistency", "description": "Alignment with existing codebase patterns" },
|
|
52
|
+
{ "name": "readability", "label": "Readability", "description": "How intuitive the name is" },
|
|
53
|
+
{ "name": "standards", "label": "Standards Compliance", "description": "Alignment with industry standards/RESTful conventions" },
|
|
54
|
+
{ "name": "brevity", "label": "Brevity", "description": "Conciseness without losing meaning" }
|
|
55
|
+
],
|
|
56
|
+
"example_problem": "Should our endpoint be /users/:id/posts or /posts?userId=:id?"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "tradeoff",
|
|
60
|
+
"name": "General Tradeoff Analysis",
|
|
61
|
+
"description": "Analyze tradeoffs between options with custom criteria",
|
|
62
|
+
"keywords": ["tradeoff", "trade-off", "compare", "vs", "versus", "트레이드오프", "비교"],
|
|
63
|
+
"criteria": [
|
|
64
|
+
{ "name": "benefit", "label": "Expected Benefit", "description": "Positive outcomes and advantages" },
|
|
65
|
+
{ "name": "cost", "label": "Cost/Effort", "description": "Resources required (lower is better)" },
|
|
66
|
+
{ "name": "risk", "label": "Risk", "description": "Potential downsides (lower is better)" },
|
|
67
|
+
{ "name": "reversibility", "label": "Reversibility", "description": "How easy to undo if wrong" }
|
|
68
|
+
],
|
|
69
|
+
"example_problem": "Should we rewrite the auth module or patch the existing one?"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "risk-approval",
|
|
73
|
+
"name": "Risk Approval",
|
|
74
|
+
"description": "Evaluate and approve/reject a risk or risky change",
|
|
75
|
+
"keywords": ["risk", "approve", "danger", "breaking", "리스크", "위험", "승인"],
|
|
76
|
+
"criteria": [
|
|
77
|
+
{ "name": "probability", "label": "Probability", "description": "Likelihood of the risk materializing" },
|
|
78
|
+
{ "name": "impact", "label": "Impact", "description": "Severity if the risk materializes" },
|
|
79
|
+
{ "name": "mitigation", "label": "Mitigation Quality", "description": "Effectiveness of proposed mitigations" },
|
|
80
|
+
{ "name": "detectability", "label": "Detectability", "description": "Ability to detect if risk occurs" },
|
|
81
|
+
{ "name": "alternatives", "label": "Alternative Availability", "description": "Availability of safer alternatives" }
|
|
82
|
+
],
|
|
83
|
+
"example_problem": "Should we deploy the database migration during peak hours?"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|