@dmsdc-ai/aigentry-deliberation 0.0.23 → 0.0.25
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/README.md +6 -0
- package/index.js +476 -19
- package/package.json +2 -1
- package/selectors/decision-templates.json +86 -0
package/README.md
CHANGED
|
@@ -179,6 +179,12 @@ cp skills/deliberation-gate/SKILL.md ~/.claude/skills/deliberation-gate/SKILL.md
|
|
|
179
179
|
|
|
180
180
|
## What's New
|
|
181
181
|
|
|
182
|
+
### v0.0.24
|
|
183
|
+
- **Role inference**: Heading marker weight increased from +5 to +8, added critic(검증/평가/Review) and researcher(데이터/Data) patterns to reduce false positives
|
|
184
|
+
- **Logging payload**: TURN log includes `suggested_role`, `role_drift`; CLI_TURN log includes `prior_turns`, `effective_timeout`
|
|
185
|
+
- **Vote marker warning**: WARN-level `INVALID_TURN` logged when response lacks [AGREE]/[DISAGREE]/[CONDITIONAL] markers
|
|
186
|
+
- **Auto-deploy**: `postversion` hook auto-installs to MCP server path after `npm version`
|
|
187
|
+
|
|
182
188
|
### v0.0.23
|
|
183
189
|
- **Vote enforcement**: Turn prompts now require [AGREE]/[DISAGREE]/[CONDITIONAL] markers for reliable consensus measurement
|
|
184
190
|
- **Dynamic CLI timeout**: First CLI invocation gets 180s (cold-start buffer), subsequent turns use default 120s
|
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
|
|
|
@@ -219,10 +232,10 @@ const ROLE_KEYWORDS = {
|
|
|
219
232
|
};
|
|
220
233
|
|
|
221
234
|
const ROLE_HEADING_MARKERS = {
|
|
222
|
-
critic: /^##?\s*(Critic|비판|약점|심각도|위험\s
|
|
235
|
+
critic: /^##?\s*(Critic|비판|약점|심각도|위험\s*분석|검증|평가|Review)/m,
|
|
223
236
|
implementer: /^##?\s*(코드\s*스케치|구현|Implementation|제안\s*코드)/m,
|
|
224
237
|
mediator: /^##?\s*(합의|종합|중재|Consensus|Mediation)/m,
|
|
225
|
-
researcher: /^##?\s*(조사\s*결과|비교\s*분석|Research|사례\s
|
|
238
|
+
researcher: /^##?\s*(조사\s*결과|비교\s*분석|Research|사례\s*연구|근거|데이터|Data)/m,
|
|
226
239
|
};
|
|
227
240
|
|
|
228
241
|
function inferSuggestedRole(text) {
|
|
@@ -234,7 +247,7 @@ function inferSuggestedRole(text) {
|
|
|
234
247
|
// Structural heading markers get extra weight (equivalent to 5 keyword matches)
|
|
235
248
|
for (const [role, pattern] of Object.entries(ROLE_HEADING_MARKERS)) {
|
|
236
249
|
if (pattern.test(text)) {
|
|
237
|
-
scores[role] = (scores[role] || 0) +
|
|
250
|
+
scores[role] = (scores[role] || 0) + 8;
|
|
238
251
|
}
|
|
239
252
|
}
|
|
240
253
|
if (Object.keys(scores).length === 0) return "free";
|
|
@@ -1462,7 +1475,7 @@ function resolveTransportForSpeaker(state, speaker) {
|
|
|
1462
1475
|
// CLI-specific invocation flags for non-interactive execution
|
|
1463
1476
|
const CLI_INVOCATION_HINTS = {
|
|
1464
1477
|
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' },
|
|
1478
|
+
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
1479
|
gemini: { cmd: "gemini", flags: '', example: 'gemini "프롬프트"', modelFlag: '--model', provider: 'gemini' },
|
|
1467
1480
|
aider: { cmd: "aider", flags: '--message', example: 'aider --message "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
|
|
1468
1481
|
cursor: { cmd: "cursor", flags: '', example: 'cursor "프롬프트"', modelFlag: null, provider: 'chatgpt' },
|
|
@@ -1846,7 +1859,9 @@ function tmuxWindowCount(name) {
|
|
|
1846
1859
|
|
|
1847
1860
|
function buildTmuxAttachCommand(sessionId) {
|
|
1848
1861
|
const winName = tmuxWindowName(sessionId);
|
|
1849
|
-
|
|
1862
|
+
// Use grouped session (new-session -t) so each terminal has independent active window.
|
|
1863
|
+
// This prevents window-switching conflicts when multiple deliberations run concurrently.
|
|
1864
|
+
return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
|
|
1850
1865
|
}
|
|
1851
1866
|
|
|
1852
1867
|
function listPhysicalTerminalWindowIds() {
|
|
@@ -1887,16 +1902,31 @@ function listPhysicalTerminalWindowIds() {
|
|
|
1887
1902
|
|
|
1888
1903
|
function openPhysicalTerminal(sessionId) {
|
|
1889
1904
|
const winName = tmuxWindowName(sessionId);
|
|
1890
|
-
|
|
1905
|
+
// Use grouped session (new-session -t) for independent active window per client
|
|
1906
|
+
const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1891
1907
|
|
|
1892
|
-
// If a terminal is already attached
|
|
1908
|
+
// If a terminal is already attached, open a NEW grouped session instead of
|
|
1909
|
+
// select-window (which would hijack all attached clients' views).
|
|
1910
|
+
// Grouped sessions share windows but each has independent active window tracking.
|
|
1893
1911
|
if (tmuxHasAttachedClients(TMUX_SESSION)) {
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1912
|
+
if (process.platform === "darwin") {
|
|
1913
|
+
const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1914
|
+
try {
|
|
1915
|
+
execFileSync(
|
|
1916
|
+
"osascript",
|
|
1917
|
+
[
|
|
1918
|
+
"-e", 'tell application "Terminal"',
|
|
1919
|
+
"-e", "activate",
|
|
1920
|
+
"-e", `do script ${appleScriptQuote(groupAttachCmd)}`,
|
|
1921
|
+
"-e", "end tell",
|
|
1922
|
+
],
|
|
1923
|
+
{ encoding: "utf-8" }
|
|
1924
|
+
);
|
|
1925
|
+
return { opened: true, windowIds: [] };
|
|
1926
|
+
} catch { /* fall through to default behavior */ }
|
|
1927
|
+
}
|
|
1928
|
+
// Non-macOS or fallback: don't force select-window, just report success
|
|
1929
|
+
// The monitor window already exists in tmux; user can switch manually
|
|
1900
1930
|
return { opened: true, windowIds: [] };
|
|
1901
1931
|
}
|
|
1902
1932
|
|
|
@@ -2304,6 +2334,9 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
|
|
|
2304
2334
|
}
|
|
2305
2335
|
|
|
2306
2336
|
const votes = parseVotes(content);
|
|
2337
|
+
if (votes.length === 0) {
|
|
2338
|
+
appendRuntimeLog("WARN", `INVALID_TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | reason: no_vote_marker`);
|
|
2339
|
+
}
|
|
2307
2340
|
const suggestedRole = inferSuggestedRole(content);
|
|
2308
2341
|
const assignedRole = (state.speaker_roles || {})[normalizedSpeaker] || "free";
|
|
2309
2342
|
const roleDrift = assignedRole !== "free" && suggestedRole !== "free" && assignedRole !== suggestedRole;
|
|
@@ -2319,7 +2352,7 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
|
|
|
2319
2352
|
suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
|
|
2320
2353
|
role_drift: roleDrift || undefined,
|
|
2321
2354
|
});
|
|
2322
|
-
appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"}`);
|
|
2355
|
+
appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"} | suggested_role: ${suggestedRole} | role_drift: ${roleDrift || false}`);
|
|
2323
2356
|
|
|
2324
2357
|
state.current_speaker = selectNextSpeaker(state);
|
|
2325
2358
|
|
|
@@ -2610,10 +2643,10 @@ server.tool(
|
|
|
2610
2643
|
: physicalOpened
|
|
2611
2644
|
? isWin
|
|
2612
2645
|
? `\n🖥️ 모니터 터미널 오픈됨 (Windows Terminal)`
|
|
2613
|
-
: `\n🖥️ 모니터 터미널 오픈됨: tmux
|
|
2646
|
+
: `\n🖥️ 모니터 터미널 오픈됨: tmux new-session -t ${TMUX_SESSION}`
|
|
2614
2647
|
: isWin
|
|
2615
2648
|
? `\n⚠️ 모니터 터미널 자동 오픈 실패`
|
|
2616
|
-
: `\n⚠️ tmux 윈도우는 생성됐지만 외부 터미널 자동 오픈 실패. 수동 실행: tmux
|
|
2649
|
+
: `\n⚠️ tmux 윈도우는 생성됐지만 외부 터미널 자동 오픈 실패. 수동 실행: tmux new-session -t ${TMUX_SESSION}`;
|
|
2617
2650
|
const manualNotDetected = hasManualSpeakers
|
|
2618
2651
|
? speakerOrder.filter(s => !candidateSnapshot.candidates.some(c => c.speaker === s))
|
|
2619
2652
|
: [];
|
|
@@ -3044,7 +3077,7 @@ server.tool(
|
|
|
3044
3077
|
child.stdin.end();
|
|
3045
3078
|
break;
|
|
3046
3079
|
case "codex":
|
|
3047
|
-
child = spawn("codex", ["exec", turnPrompt], { env, windowsHide: true });
|
|
3080
|
+
child = spawn("codex", ["exec", "--model", "gpt-5.4-codex", turnPrompt], { env, windowsHide: true });
|
|
3048
3081
|
break;
|
|
3049
3082
|
case "gemini":
|
|
3050
3083
|
child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
|
|
@@ -3092,7 +3125,7 @@ server.tool(
|
|
|
3092
3125
|
});
|
|
3093
3126
|
|
|
3094
3127
|
const elapsedMs = Date.now() - startTime;
|
|
3095
|
-
appendRuntimeLog("INFO", `CLI_TURN: ${resolved} | speaker: ${speaker} | cli: ${hint.cmd} | elapsed: ${elapsedMs}ms | response_len: ${response.length}`);
|
|
3128
|
+
appendRuntimeLog("INFO", `CLI_TURN: ${resolved} | speaker: ${speaker} | cli: ${hint.cmd} | elapsed: ${elapsedMs}ms | response_len: ${response.length} | prior_turns: ${speakerPriorTurns} | effective_timeout: ${effectiveTimeout}s`);
|
|
3096
3129
|
|
|
3097
3130
|
if (!response) {
|
|
3098
3131
|
return { content: [{ type: "text", text: `⚠️ CLI "${speaker}"가 빈 응답을 반환했습니다.` }] };
|
|
@@ -3753,6 +3786,430 @@ server.tool(
|
|
|
3753
3786
|
})
|
|
3754
3787
|
);
|
|
3755
3788
|
|
|
3789
|
+
// ── Decision Engine Tools ─────────────────────────────────────
|
|
3790
|
+
|
|
3791
|
+
server.tool(
|
|
3792
|
+
"decision_start",
|
|
3793
|
+
"새 의사결정 세션을 시작합니다. 여러 LLM이 독립적으로 의견을 제시하고 갈등을 가시화합니다.",
|
|
3794
|
+
{
|
|
3795
|
+
problem: z.string().describe("의사결정 문제 (예: 'JWT vs Session 인증 방식 선택')"),
|
|
3796
|
+
options: z.preprocess(
|
|
3797
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
3798
|
+
z.array(z.string()).optional()
|
|
3799
|
+
).describe("선택지 목록 (예: ['JWT', 'Session', 'OAuth2'])"),
|
|
3800
|
+
criteria: z.preprocess(
|
|
3801
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
3802
|
+
z.array(z.string()).optional()
|
|
3803
|
+
).describe("평가 기준 (미지정 시 템플릿에서 자동 로드)"),
|
|
3804
|
+
template: z.string().optional().describe("Micro-decision 템플릿 ID (lib-compare, arch-decision, pr-priority, naming-convention, tradeoff, risk-approval)"),
|
|
3805
|
+
speakers: z.preprocess(
|
|
3806
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
3807
|
+
z.array(z.string().trim().min(1).max(64)).min(2).optional()
|
|
3808
|
+
).describe("참여 LLM 목록 (최소 2명, 예: ['claude', 'codex', 'gemini'])"),
|
|
3809
|
+
},
|
|
3810
|
+
safeToolHandler("decision_start", async ({ problem, options, criteria, template, speakers }) => {
|
|
3811
|
+
// Auto-discover speakers if not provided
|
|
3812
|
+
if (!speakers || speakers.length === 0) {
|
|
3813
|
+
const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: false });
|
|
3814
|
+
speakers = candidateSnapshot.candidates
|
|
3815
|
+
.filter(c => c.type === "cli" && checkCliLiveness(c.speaker))
|
|
3816
|
+
.map(c => c.speaker)
|
|
3817
|
+
.slice(0, 4);
|
|
3818
|
+
if (speakers.length < 2) {
|
|
3819
|
+
return { content: [{ type: "text", text: "❌ 의사결정에 최소 2명의 speaker가 필요합니다. speakers를 직접 지정하세요." }] };
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
// Template matching
|
|
3824
|
+
const templates = loadTemplates();
|
|
3825
|
+
let matchedTemplate = null;
|
|
3826
|
+
if (template) {
|
|
3827
|
+
matchedTemplate = templates.find(t => t.id === template) || null;
|
|
3828
|
+
} else {
|
|
3829
|
+
matchedTemplate = matchTemplate(problem, templates);
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
// Use template criteria if not provided
|
|
3833
|
+
if ((!criteria || criteria.length === 0) && matchedTemplate) {
|
|
3834
|
+
criteria = matchedTemplate.criteria.map(c => c.name || c);
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
// Create session
|
|
3838
|
+
const session = createDecisionSession({
|
|
3839
|
+
problem,
|
|
3840
|
+
options: options || [],
|
|
3841
|
+
criteria: criteria || [],
|
|
3842
|
+
speakers,
|
|
3843
|
+
template: matchedTemplate?.id || null,
|
|
3844
|
+
participant_profiles: mapParticipantProfiles(speakers, [], {}),
|
|
3845
|
+
});
|
|
3846
|
+
|
|
3847
|
+
// Advance to parallel_opinions immediately (intake is just creation)
|
|
3848
|
+
advanceStage(session);
|
|
3849
|
+
|
|
3850
|
+
// Save session
|
|
3851
|
+
withSessionLock(session.id, () => {
|
|
3852
|
+
saveSession(session);
|
|
3853
|
+
});
|
|
3854
|
+
|
|
3855
|
+
appendRuntimeLog("INFO", `DECISION_START: ${session.id} | problem: ${problem.slice(0, 60)} | speakers: ${speakers.join(",")} | criteria: ${(criteria || []).length}`);
|
|
3856
|
+
|
|
3857
|
+
// Build opinion prompt for parallel execution
|
|
3858
|
+
const opinionPrompt = buildOpinionPrompt(problem, options || [], criteria || [], matchedTemplate?.id);
|
|
3859
|
+
|
|
3860
|
+
// Run parallel independent opinions using CLI auto-turn pattern
|
|
3861
|
+
const opinionResults = {};
|
|
3862
|
+
const opinionPromises = speakers.map(async (speaker) => {
|
|
3863
|
+
try {
|
|
3864
|
+
const hint = CLI_INVOCATION_HINTS[speaker] || CLI_INVOCATION_HINTS["_generic"];
|
|
3865
|
+
const cmd = hint?.cmd || speaker;
|
|
3866
|
+
|
|
3867
|
+
// Check liveness
|
|
3868
|
+
if (!checkCliLiveness(cmd)) {
|
|
3869
|
+
opinionResults[speaker] = { error: `CLI not available: ${cmd}` };
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
// Spawn CLI with opinion prompt
|
|
3874
|
+
const result = await new Promise((resolve, reject) => {
|
|
3875
|
+
let stdout = "";
|
|
3876
|
+
let stderr = "";
|
|
3877
|
+
|
|
3878
|
+
const args = [];
|
|
3879
|
+
if (speaker === "claude") {
|
|
3880
|
+
args.push("-p", "--output-format", "text", opinionPrompt);
|
|
3881
|
+
} else if (speaker === "codex") {
|
|
3882
|
+
args.push("exec", opinionPrompt);
|
|
3883
|
+
} else if (speaker === "gemini") {
|
|
3884
|
+
args.push("-p", opinionPrompt);
|
|
3885
|
+
} else {
|
|
3886
|
+
const flags = hint?.flags || [];
|
|
3887
|
+
args.push(...flags, opinionPrompt);
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
const proc = spawn(cmd, args, {
|
|
3891
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3892
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
3893
|
+
timeout: 180000,
|
|
3894
|
+
});
|
|
3895
|
+
|
|
3896
|
+
proc.stdout?.on("data", (d) => { stdout += d.toString(); });
|
|
3897
|
+
proc.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
3898
|
+
|
|
3899
|
+
const timer = setTimeout(() => {
|
|
3900
|
+
proc.kill("SIGTERM");
|
|
3901
|
+
reject(new Error("timeout"));
|
|
3902
|
+
}, 180000);
|
|
3903
|
+
|
|
3904
|
+
proc.on("close", (code) => {
|
|
3905
|
+
clearTimeout(timer);
|
|
3906
|
+
resolve(stdout.trim() || stderr.trim());
|
|
3907
|
+
});
|
|
3908
|
+
|
|
3909
|
+
proc.on("error", (err) => {
|
|
3910
|
+
clearTimeout(timer);
|
|
3911
|
+
reject(err);
|
|
3912
|
+
});
|
|
3913
|
+
});
|
|
3914
|
+
|
|
3915
|
+
opinionResults[speaker] = result;
|
|
3916
|
+
} catch (err) {
|
|
3917
|
+
opinionResults[speaker] = { error: err.message };
|
|
3918
|
+
}
|
|
3919
|
+
});
|
|
3920
|
+
|
|
3921
|
+
// Wait for all opinions in parallel
|
|
3922
|
+
await Promise.all(opinionPromises);
|
|
3923
|
+
|
|
3924
|
+
// Parse opinions and update session
|
|
3925
|
+
withSessionLock(session.id, () => {
|
|
3926
|
+
const latest = loadSession(session.id);
|
|
3927
|
+
if (!latest) return;
|
|
3928
|
+
|
|
3929
|
+
for (const [speaker, result] of Object.entries(opinionResults)) {
|
|
3930
|
+
if (typeof result === "string") {
|
|
3931
|
+
latest.opinions[speaker] = parseOpinionFromResponse(speaker, result, latest.criteria);
|
|
3932
|
+
latest.log.push({
|
|
3933
|
+
round: 1,
|
|
3934
|
+
speaker,
|
|
3935
|
+
content: result,
|
|
3936
|
+
timestamp: new Date().toISOString(),
|
|
3937
|
+
channel_used: "cli_auto",
|
|
3938
|
+
event: "opinion",
|
|
3939
|
+
});
|
|
3940
|
+
} else {
|
|
3941
|
+
appendRuntimeLog("WARN", `DECISION_OPINION_FAIL: ${session.id} | ${speaker}: ${result?.error}`);
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
// Advance to conflict_map
|
|
3946
|
+
latest.stage = "conflict_map";
|
|
3947
|
+
latest.metadata.updated = new Date().toISOString();
|
|
3948
|
+
|
|
3949
|
+
// Build conflict map
|
|
3950
|
+
latest.conflicts = buildConflictMap(latest.opinions, latest.criteria);
|
|
3951
|
+
|
|
3952
|
+
// Advance to user_probe
|
|
3953
|
+
latest.stage = "user_probe";
|
|
3954
|
+
latest.metadata.updated = new Date().toISOString();
|
|
3955
|
+
|
|
3956
|
+
saveSession(latest);
|
|
3957
|
+
});
|
|
3958
|
+
|
|
3959
|
+
// Load updated session for response
|
|
3960
|
+
const updatedSession = loadSession(session.id);
|
|
3961
|
+
const conflictText = generateConflictQuestions(updatedSession?.conflicts || []);
|
|
3962
|
+
const successCount = Object.keys(updatedSession?.opinions || {}).length;
|
|
3963
|
+
const templateInfo = matchedTemplate ? `\n**Template:** ${matchedTemplate.name}` : "";
|
|
3964
|
+
|
|
3965
|
+
appendRuntimeLog("INFO", `DECISION_OPINIONS_COMPLETE: ${session.id} | opinions: ${successCount}/${speakers.length} | conflicts: ${(updatedSession?.conflicts || []).length}`);
|
|
3966
|
+
|
|
3967
|
+
return {
|
|
3968
|
+
content: [{
|
|
3969
|
+
type: "text",
|
|
3970
|
+
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\`로 제출하세요.`,
|
|
3971
|
+
}],
|
|
3972
|
+
};
|
|
3973
|
+
})
|
|
3974
|
+
);
|
|
3975
|
+
|
|
3976
|
+
server.tool(
|
|
3977
|
+
"decision_status",
|
|
3978
|
+
"의사결정 세션의 현재 상태를 조회합니다.",
|
|
3979
|
+
{
|
|
3980
|
+
session_id: z.string().optional().describe("세션 ID (미지정 시 활성 decision 세션 자동 선택)"),
|
|
3981
|
+
},
|
|
3982
|
+
safeToolHandler("decision_status", async ({ session_id }) => {
|
|
3983
|
+
// Find decision sessions
|
|
3984
|
+
const active = listActiveSessions().filter(s => {
|
|
3985
|
+
const full = loadSession(s.id);
|
|
3986
|
+
return full?.type === "decision";
|
|
3987
|
+
});
|
|
3988
|
+
|
|
3989
|
+
let resolved = session_id;
|
|
3990
|
+
if (!resolved) {
|
|
3991
|
+
if (active.length === 0) return { content: [{ type: "text", text: "활성 decision 세션이 없습니다." }] };
|
|
3992
|
+
if (active.length === 1) resolved = active[0].id;
|
|
3993
|
+
else return { content: [{ type: "text", text: `여러 decision 세션이 진행 중입니다. session_id를 지정하세요:\n${active.map(s => `- ${s.id}`).join("\n")}` }] };
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
const state = loadSession(resolved);
|
|
3997
|
+
if (!state) return { content: [{ type: "text", text: `세션을 찾을 수 없습니다: ${resolved}` }] };
|
|
3998
|
+
|
|
3999
|
+
const opinionCount = Object.keys(state.opinions || {}).length;
|
|
4000
|
+
const conflictCount = (state.conflicts || []).length;
|
|
4001
|
+
const stageIdx = DECISION_STAGES.indexOf(state.stage);
|
|
4002
|
+
const progress = stageIdx >= 0 ? `${stageIdx + 1}/${DECISION_STAGES.length}` : state.stage;
|
|
4003
|
+
|
|
4004
|
+
return {
|
|
4005
|
+
content: [{
|
|
4006
|
+
type: "text",
|
|
4007
|
+
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 || ""}`,
|
|
4008
|
+
}],
|
|
4009
|
+
};
|
|
4010
|
+
})
|
|
4011
|
+
);
|
|
4012
|
+
|
|
4013
|
+
server.tool(
|
|
4014
|
+
"decision_respond",
|
|
4015
|
+
"user_probe 단계의 갈등 질문에 대한 사용자 응답을 제출합니다.",
|
|
4016
|
+
{
|
|
4017
|
+
session_id: z.string().optional().describe("세션 ID"),
|
|
4018
|
+
responses: z.preprocess(
|
|
4019
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
4020
|
+
z.array(z.string()).min(1)
|
|
4021
|
+
).describe("각 갈등 질문에 대한 응답 배열 (conflict 순서대로)"),
|
|
4022
|
+
},
|
|
4023
|
+
safeToolHandler("decision_respond", async ({ session_id, responses }) => {
|
|
4024
|
+
// Find decision session
|
|
4025
|
+
const active = listActiveSessions().filter(s => {
|
|
4026
|
+
const full = loadSession(s.id);
|
|
4027
|
+
return full?.type === "decision";
|
|
4028
|
+
});
|
|
4029
|
+
|
|
4030
|
+
let resolved = session_id;
|
|
4031
|
+
if (!resolved) {
|
|
4032
|
+
if (active.length === 1) resolved = active[0].id;
|
|
4033
|
+
else if (active.length === 0) return { content: [{ type: "text", text: "활성 decision 세션이 없습니다." }] };
|
|
4034
|
+
else return { content: [{ type: "text", text: `여러 decision 세션이 진행 중입니다. session_id를 지정하세요.` }] };
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
let synthesisText = "";
|
|
4038
|
+
let actionPlan = null;
|
|
4039
|
+
|
|
4040
|
+
withSessionLock(resolved, () => {
|
|
4041
|
+
const state = loadSession(resolved);
|
|
4042
|
+
if (!state) return;
|
|
4043
|
+
if (state.stage !== "user_probe") {
|
|
4044
|
+
synthesisText = `❌ 현재 단계(${state.stage})에서는 응답을 받을 수 없습니다. user_probe 단계에서만 가능합니다.`;
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
// Store user responses
|
|
4049
|
+
state.userProbeResponses = responses;
|
|
4050
|
+
state.log.push({
|
|
4051
|
+
event: "user_probe_response",
|
|
4052
|
+
responses,
|
|
4053
|
+
timestamp: new Date().toISOString(),
|
|
4054
|
+
});
|
|
4055
|
+
|
|
4056
|
+
// Advance to synthesis
|
|
4057
|
+
state.stage = "synthesis";
|
|
4058
|
+
state.metadata.updated = new Date().toISOString();
|
|
4059
|
+
|
|
4060
|
+
// Build synthesis
|
|
4061
|
+
state.synthesis = buildSynthesis(state);
|
|
4062
|
+
|
|
4063
|
+
// Advance to action_export
|
|
4064
|
+
state.stage = "action_export";
|
|
4065
|
+
state.metadata.updated = new Date().toISOString();
|
|
4066
|
+
|
|
4067
|
+
// Build action plan
|
|
4068
|
+
state.actionPlan = buildActionPlan(state);
|
|
4069
|
+
|
|
4070
|
+
// Advance to done
|
|
4071
|
+
state.stage = "done";
|
|
4072
|
+
state.status = "completed";
|
|
4073
|
+
state.metadata.updated = new Date().toISOString();
|
|
4074
|
+
|
|
4075
|
+
saveSession(state);
|
|
4076
|
+
|
|
4077
|
+
// Archive
|
|
4078
|
+
const archivePath = archiveState(state);
|
|
4079
|
+
cleanupSyncMarkdown(state);
|
|
4080
|
+
|
|
4081
|
+
synthesisText = state.synthesis;
|
|
4082
|
+
actionPlan = state.actionPlan;
|
|
4083
|
+
|
|
4084
|
+
appendRuntimeLog("INFO", `DECISION_COMPLETE: ${resolved} | conflicts_resolved: ${responses.length} | decision: ${(actionPlan?.decision || "").slice(0, 60)}`);
|
|
4085
|
+
});
|
|
4086
|
+
|
|
4087
|
+
if (synthesisText.startsWith("❌")) {
|
|
4088
|
+
return { content: [{ type: "text", text: synthesisText }] };
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
const checklistText = actionPlan?.exportFormats?.checklist || "";
|
|
4092
|
+
return {
|
|
4093
|
+
content: [{
|
|
4094
|
+
type: "text",
|
|
4095
|
+
text: `✅ **Decision Complete**\n\n${synthesisText}\n\n---\n\n## Action Plan\n\n${checklistText}`,
|
|
4096
|
+
}],
|
|
4097
|
+
};
|
|
4098
|
+
})
|
|
4099
|
+
);
|
|
4100
|
+
|
|
4101
|
+
server.tool(
|
|
4102
|
+
"decision_resume",
|
|
4103
|
+
"일시 중지된 decision 세션을 재개합니다 (user_probe 단계에서 갈등 질문을 다시 표시).",
|
|
4104
|
+
{
|
|
4105
|
+
session_id: z.string().optional().describe("세션 ID"),
|
|
4106
|
+
},
|
|
4107
|
+
safeToolHandler("decision_resume", async ({ session_id }) => {
|
|
4108
|
+
const active = listActiveSessions().filter(s => {
|
|
4109
|
+
const full = loadSession(s.id);
|
|
4110
|
+
return full?.type === "decision";
|
|
4111
|
+
});
|
|
4112
|
+
|
|
4113
|
+
let resolved = session_id;
|
|
4114
|
+
if (!resolved) {
|
|
4115
|
+
if (active.length === 1) resolved = active[0].id;
|
|
4116
|
+
else if (active.length === 0) return { content: [{ type: "text", text: "재개할 decision 세션이 없습니다." }] };
|
|
4117
|
+
else return { content: [{ type: "text", text: `여러 세션 중 선택하세요:\n${active.map(s => `- ${s.id}`).join("\n")}` }] };
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
const state = loadSession(resolved);
|
|
4121
|
+
if (!state) return { content: [{ type: "text", text: `세션을 찾을 수 없습니다: ${resolved}` }] };
|
|
4122
|
+
if (state.stage !== "user_probe") {
|
|
4123
|
+
return { content: [{ type: "text", text: `세션이 user_probe 단계가 아닙니다 (현재: ${state.stage}). 재개할 수 없습니다.` }] };
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
const conflictText = generateConflictQuestions(state.conflicts || []);
|
|
4127
|
+
return {
|
|
4128
|
+
content: [{
|
|
4129
|
+
type: "text",
|
|
4130
|
+
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\`로 제출하세요.`,
|
|
4131
|
+
}],
|
|
4132
|
+
};
|
|
4133
|
+
})
|
|
4134
|
+
);
|
|
4135
|
+
|
|
4136
|
+
server.tool(
|
|
4137
|
+
"decision_history",
|
|
4138
|
+
"과거 의사결정 기록을 조회합니다.",
|
|
4139
|
+
{
|
|
4140
|
+
session_id: z.string().optional().describe("특정 세션 ID (미지정 시 전체 목록)"),
|
|
4141
|
+
},
|
|
4142
|
+
safeToolHandler("decision_history", async ({ session_id }) => {
|
|
4143
|
+
if (session_id) {
|
|
4144
|
+
const state = loadSession(session_id);
|
|
4145
|
+
if (!state) return { content: [{ type: "text", text: `세션을 찾을 수 없습니다: ${session_id}` }] };
|
|
4146
|
+
|
|
4147
|
+
const opinionSummary = Object.entries(state.opinions || {})
|
|
4148
|
+
.map(([speaker, op]) => `- **${speaker}**: ${op.summary || "(none)"} (confidence: ${Math.round((op.confidence || 0.5) * 100)}%)`)
|
|
4149
|
+
.join("\n");
|
|
4150
|
+
|
|
4151
|
+
return {
|
|
4152
|
+
content: [{
|
|
4153
|
+
type: "text",
|
|
4154
|
+
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)"}`,
|
|
4155
|
+
}],
|
|
4156
|
+
};
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
// List all decision sessions from archives
|
|
4160
|
+
const projectSlug = getProjectSlug();
|
|
4161
|
+
const archiveDir = path.join(GLOBAL_STATE_DIR, projectSlug, "archive");
|
|
4162
|
+
let decisionArchives = [];
|
|
4163
|
+
try {
|
|
4164
|
+
const files = fs.readdirSync(archiveDir).filter(f => f.startsWith("decision-"));
|
|
4165
|
+
decisionArchives = files.map(f => {
|
|
4166
|
+
const match = f.match(/^decision-(.+)\.md$/);
|
|
4167
|
+
return match ? match[1] : f;
|
|
4168
|
+
});
|
|
4169
|
+
} catch { /* no archives */ }
|
|
4170
|
+
|
|
4171
|
+
// Also list active decision sessions
|
|
4172
|
+
const activeSessions = listActiveSessions().filter(s => {
|
|
4173
|
+
const full = loadSession(s.id);
|
|
4174
|
+
return full?.type === "decision";
|
|
4175
|
+
});
|
|
4176
|
+
|
|
4177
|
+
const activeList = activeSessions.map(s => `- 🟢 ${s.id} (${s.status})`).join("\n");
|
|
4178
|
+
const archiveList = decisionArchives.map(a => `- 📁 ${a}`).join("\n");
|
|
4179
|
+
|
|
4180
|
+
return {
|
|
4181
|
+
content: [{
|
|
4182
|
+
type: "text",
|
|
4183
|
+
text: `📜 **Decision History**\n\n## Active Sessions\n${activeList || "(none)"}\n\n## Archives\n${archiveList || "(none)"}`,
|
|
4184
|
+
}],
|
|
4185
|
+
};
|
|
4186
|
+
})
|
|
4187
|
+
);
|
|
4188
|
+
|
|
4189
|
+
server.tool(
|
|
4190
|
+
"decision_templates",
|
|
4191
|
+
"사용 가능한 Micro-Decision 템플릿 목록을 표시합니다.",
|
|
4192
|
+
{},
|
|
4193
|
+
safeToolHandler("decision_templates", async () => {
|
|
4194
|
+
const templates = loadTemplates();
|
|
4195
|
+
if (templates.length === 0) {
|
|
4196
|
+
return { content: [{ type: "text", text: "사용 가능한 템플릿이 없습니다." }] };
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
const list = templates.map(t => {
|
|
4200
|
+
const criteriaList = (t.criteria || []).map(c => c.name || c.label || c).join(", ");
|
|
4201
|
+
return `### ${t.name} (\`${t.id}\`)\n${t.description}\n- **Criteria:** ${criteriaList}\n- **Example:** ${t.example_problem || "(none)"}`;
|
|
4202
|
+
}).join("\n\n");
|
|
4203
|
+
|
|
4204
|
+
return {
|
|
4205
|
+
content: [{
|
|
4206
|
+
type: "text",
|
|
4207
|
+
text: `📋 **Decision Templates**\n\n${list}\n\n---\n\n\`decision_start(problem: "...", template: "lib-compare")\`로 사용하세요.`,
|
|
4208
|
+
}],
|
|
4209
|
+
};
|
|
4210
|
+
})
|
|
4211
|
+
);
|
|
4212
|
+
|
|
3756
4213
|
// ── Start ──────────────────────────────────────────────────────
|
|
3757
4214
|
|
|
3758
4215
|
// Only start server when run directly (not imported for testing)
|
|
@@ -3781,4 +4238,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
|
|
|
3781
4238
|
}
|
|
3782
4239
|
|
|
3783
4240
|
// ── Test exports (used by vitest) ──
|
|
3784
|
-
export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS };
|
|
4241
|
+
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-deliberation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"description": "MCP Deliberation Server — Multi-session AI deliberation with smart speaker ordering and persona roles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"test": "vitest run",
|
|
49
49
|
"test:watch": "vitest",
|
|
50
50
|
"prepublishOnly": "vitest run",
|
|
51
|
+
"postversion": "node install.js",
|
|
51
52
|
"release:patch": "npm version patch && git push && git push --tags",
|
|
52
53
|
"release:minor": "npm version minor && git push && git push --tags",
|
|
53
54
|
"release:major": "npm version major && git push && git push --tags"
|
|
@@ -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
|
+
}
|