@dmsdc-ai/aigentry-deliberation 0.0.37 → 0.0.39

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 CHANGED
@@ -111,8 +111,10 @@ Claude Code, Codex CLI, Gemini CLI의 MCP 설정을 자동 점검하고 문제
111
111
  | `deliberation_browser_llm_tabs` | List browser LLM tabs |
112
112
  | `deliberation_browser_auto_turn` | Auto-send turn to browser LLM |
113
113
  | `deliberation_route_turn` | Route turn to appropriate transport |
114
+ | `deliberation_run_until_blocked` | Auto-run mixed transports until completion or a manual block |
114
115
  | `deliberation_request_review` | Request code review |
115
116
  | `deliberation_cli_auto_turn` | Auto-send turn to CLI speaker |
117
+ | `deliberation_ingest_remote_reply` | Canonical semantic ingress for remote replies with explicit source metadata |
116
118
  | `deliberation_cli_config` | Configure CLI settings |
117
119
 
118
120
  ## Start Flow
@@ -133,10 +135,53 @@ Raw candidate tokens cannot start a deliberation.
133
135
  Telepty-managed sessions are now routed through the telepty bus instead of raw PTY inject guidance.
134
136
 
135
137
  - `deliberation_route_turn` publishes a typed `turn_request` envelope on `ws://localhost:3848/api/bus`
138
+ - `deliberation_run_until_blocked` can continue across `cli_respond`, `browser_auto`, and `telepty_bus` speakers until a manual block is reached
136
139
  - transport delivery is tracked with a 5-second `inject_written` ack window
137
140
  - semantic completion is tracked with a 60-second self-submit window
138
141
  - `session_health` bus events are cached for operator visibility
139
142
  - `deliberation_synthesize` validates and emits typed `deliberation_completed` envelopes for downstream automation
143
+ - telepty envelopes now carry top-level `version: 1` and optional `source_host`
144
+
145
+ ### Cross-Machine Event Catalog
146
+
147
+ Canonical boundary split with telepty:
148
+
149
+ - **Guaranteed (daemon-emitted):** `inject_written`, `session_health`, `session_register`, `session.replaced`, `session.idle`, `thread.opened`, `thread.closed`, `handoff.*`, `message_routed`
150
+ - **Best-effort (bus relay only):** `turn_request`, `turn_completed`, `deliberation_completed`
151
+ - `kind` is the canonical event discriminator
152
+ - `target` identifies the telepty session target
153
+ - `payload.prompt` is the canonical prompt field for `turn_request`
154
+ - `source_host` is optional transport metadata for cross-machine tracing
155
+
156
+ ### Remote Reply Ingress
157
+
158
+ If a remote participant cannot call local MCP tools directly, do **not** proxy-synthesize a reply. Use the deliberation-owned semantic ingress:
159
+
160
+ ```text
161
+ deliberation_ingest_remote_reply(
162
+ session_id: "...",
163
+ speaker: "...",
164
+ turn_id: "...",
165
+ content: "...",
166
+ source_machine_id: "peer-01",
167
+ source_session_id: "remote-gemini-001",
168
+ transport_scope: "remote_mcp",
169
+ artifact_refs: ["results.jsonl"]
170
+ )
171
+ ```
172
+
173
+ This preserves explicit provenance instead of inferring semantics from raw bus events.
174
+
175
+ ### Gemini Recovery
176
+
177
+ Canonical repair path today is still installer-based:
178
+
179
+ ```bash
180
+ npx --yes --package @dmsdc-ai/aigentry-deliberation deliberation-doctor
181
+ npx --yes --package @dmsdc-ai/aigentry-deliberation deliberation-install
182
+ ```
183
+
184
+ Use doctor first; if Gemini MCP registration/path/runtime drift is detected, rerun install and restart Gemini CLI.
140
185
 
141
186
  ## Experiment Retrospectives
142
187
 
package/index.js CHANGED
@@ -160,9 +160,11 @@ const StructuredSynthesisSchema = z.object({
160
160
  });
161
161
 
162
162
  const StructuredExecutionContractSchema = z.object({
163
- version: z.literal("v1"),
163
+ schema_version: z.number().int().positive(),
164
164
  source_session_id: z.string().min(1),
165
+ deliberation_id: z.string().min(1),
165
166
  summary: z.string(),
167
+ decisions: z.array(z.string()),
166
168
  tasks: z.array(StructuredActionableTaskSchema),
167
169
  experiment_outcome: StructuredExperimentOutcomeSchema.nullable().optional(),
168
170
  unresolved_questions: z.array(z.string()),
@@ -173,11 +175,13 @@ const StructuredExecutionContractSchema = z.object({
173
175
  });
174
176
 
175
177
  const TeleptyEnvelopeSchema = z.object({
178
+ version: z.number().int().positive().optional(),
176
179
  message_id: z.string().min(1),
177
180
  session_id: z.string().min(1),
178
181
  project: z.string().min(1),
179
182
  kind: z.string().min(1),
180
183
  source: z.string().min(1),
184
+ source_host: z.string().min(1).optional(),
181
185
  target: z.string().min(1),
182
186
  reply_to: z.string().nullable().optional(),
183
187
  trace: z.array(z.string()),
@@ -192,12 +196,27 @@ const TeleptyTurnRequestPayloadSchema = z.object({
192
196
  speaker: z.string().min(1),
193
197
  role: z.string().nullable().optional(),
194
198
  prompt: z.string().min(1),
199
+ content: z.string().min(1).describe("PTY-compatible alias for prompt field"),
195
200
  prompt_sha1: z.string().length(40),
196
201
  history_entries: z.number().int().nonnegative().optional(),
197
202
  transport_timeout_ms: z.number().int().positive(),
198
203
  semantic_timeout_ms: z.number().int().positive(),
199
204
  });
200
205
 
206
+ const TeleptyTurnCompletedPayloadSchema = z.object({
207
+ turn_id: z.string().nullable().optional(),
208
+ speaker: z.string().min(1),
209
+ round: z.number().int().positive(),
210
+ max_rounds: z.number().int().positive(),
211
+ next_speaker: z.string().min(1),
212
+ next_round: z.number().int().positive(),
213
+ status: z.string().min(1),
214
+ total_responses: z.number().int().nonnegative(),
215
+ channel_used: z.string().nullable().optional(),
216
+ fallback_reason: z.string().nullable().optional(),
217
+ orchestrator_session_id: z.string().nullable().optional(),
218
+ });
219
+
201
220
  const TeleptyDeliberationCompletedPayloadSchema = z.object({
202
221
  topic: z.string(),
203
222
  synthesis: z.string(),
@@ -207,6 +226,7 @@ const TeleptyDeliberationCompletedPayloadSchema = z.object({
207
226
 
208
227
  const TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS = {
209
228
  turn_request: TeleptyTurnRequestPayloadSchema,
229
+ turn_completed: TeleptyTurnCompletedPayloadSchema,
210
230
  deliberation_completed: TeleptyDeliberationCompletedPayloadSchema,
211
231
  };
212
232
 
@@ -543,12 +563,26 @@ function hashStructuredSynthesis(structured) {
543
563
  return hashPromptText(JSON.stringify(sortJsonValue(structured || null)));
544
564
  }
545
565
 
566
+ /**
567
+ * Build a deterministic execution contract from structured synthesis.
568
+ *
569
+ * Data model canonical roles:
570
+ * - structured_synthesis: human + reasoning canonical — rich context for
571
+ * human review (decisions rationale, experiment outcomes, full task descriptions).
572
+ * - execution_contract: automation canonical — minimal, deterministic task list
573
+ * derived from structured_synthesis via SHA-1 hash for provenance tracking.
574
+ * Consumers (inbox-watcher, devkit, registry, orchestrator) MUST prefer
575
+ * execution_contract when available; fall back to structured_synthesis only
576
+ * when execution_contract is absent.
577
+ */
546
578
  function buildExecutionContract({ state, structured }) {
547
579
  if (!structured) return null;
548
580
  return {
549
- version: "v1",
581
+ schema_version: 2,
550
582
  source_session_id: state.id,
583
+ deliberation_id: state.id,
551
584
  summary: structured.summary || "",
585
+ decisions: structured.decisions || [],
552
586
  tasks: structured.actionable_tasks || [],
553
587
  experiment_outcome: structured.experiment_outcome || null,
554
588
  unresolved_questions: [],
@@ -572,13 +606,24 @@ function validateTeleptyEnvelope(envelope) {
572
606
  return parsed;
573
607
  }
574
608
 
575
- function buildTeleptyEnvelope({ session_id, project, kind, source, target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
609
+ function resolveTeleptySourceHost() {
610
+ const explicit = process.env.TELEPTY_SOURCE_HOST;
611
+ if (typeof explicit === "string" && explicit.trim()) {
612
+ return explicit.trim();
613
+ }
614
+ const hostname = os.hostname();
615
+ return typeof hostname === "string" && hostname.trim() ? hostname.trim() : undefined;
616
+ }
617
+
618
+ function buildTeleptyEnvelope({ session_id, project, kind, source, source_host = resolveTeleptySourceHost(), target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
576
619
  return validateTeleptyEnvelope({
620
+ version: 1,
577
621
  message_id,
578
622
  session_id,
579
623
  project,
580
624
  kind,
581
625
  source,
626
+ source_host,
582
627
  target,
583
628
  reply_to,
584
629
  trace,
@@ -611,6 +656,7 @@ function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, i
611
656
  speaker,
612
657
  role,
613
658
  prompt: turnPrompt,
659
+ content: turnPrompt,
614
660
  prompt_sha1: hashPromptText(turnPrompt),
615
661
  history_entries: includeHistoryEntries,
616
662
  transport_timeout_ms: TELEPTY_TRANSPORT_TIMEOUT_MS,
@@ -619,6 +665,35 @@ function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, i
619
665
  });
620
666
  }
621
667
 
668
+ function buildTeleptyTurnCompletedEnvelope({ state, entry }) {
669
+ return buildTeleptyEnvelope({
670
+ session_id: state.id,
671
+ project: state.project || getProjectSlug(),
672
+ kind: "turn_completed",
673
+ source: `deliberation:${state.id}`,
674
+ target: "telepty-bus",
675
+ reply_to: state.orchestrator_session_id || state.id,
676
+ trace: [
677
+ `project:${state.project || getProjectSlug()}`,
678
+ `speaker:${entry.speaker}`,
679
+ `turn:${entry.turn_id || "none"}`,
680
+ ],
681
+ payload: {
682
+ turn_id: entry.turn_id || null,
683
+ speaker: entry.speaker,
684
+ round: entry.round,
685
+ max_rounds: state.max_rounds,
686
+ next_speaker: state.current_speaker || "none",
687
+ next_round: state.current_round,
688
+ status: state.status,
689
+ total_responses: Array.isArray(state.log) ? state.log.length : 0,
690
+ channel_used: entry.channel_used || null,
691
+ fallback_reason: entry.fallback_reason || null,
692
+ orchestrator_session_id: state.orchestrator_session_id || null,
693
+ },
694
+ });
695
+ }
696
+
622
697
  function buildTeleptySynthesisEnvelope({ state, synthesis, structured, executionContract }) {
623
698
  const derivedExecutionContract =
624
699
  executionContract !== undefined
@@ -864,6 +939,24 @@ async function ensureTeleptyBusSubscriber() {
864
939
  return result;
865
940
  }
866
941
 
942
+ async function callBrainIngest(executionContract) {
943
+ if (!executionContract) return { ok: false, reason: "no_contract" };
944
+ try {
945
+ const inboxDir = path.join(os.homedir(), ".aigentry", "inbox");
946
+ if (!fs.existsSync(inboxDir)) {
947
+ fs.mkdirSync(inboxDir, { recursive: true });
948
+ }
949
+ const fileName = `handoff-${executionContract.deliberation_id}.json`;
950
+ const filePath = path.join(inboxDir, fileName);
951
+ fs.writeFileSync(filePath, JSON.stringify(executionContract, null, 2), "utf8");
952
+ appendRuntimeLog("INFO", `BRAIN_INGEST: wrote handoff file ${filePath}`);
953
+ return { ok: true, path: filePath };
954
+ } catch (err) {
955
+ appendRuntimeLog("WARN", `BRAIN_INGEST: failed to write handoff file: ${err.message}`);
956
+ return { ok: false, error: err.message };
957
+ }
958
+ }
959
+
867
960
  async function notifyTeleptyBus(event) {
868
961
  const host = process.env.TELEPTY_HOST || "localhost";
869
962
  const port = process.env.TELEPTY_PORT || "3848";
@@ -889,6 +982,130 @@ async function notifyTeleptyBus(event) {
889
982
  }
890
983
  }
891
984
 
985
+ function getDefaultOrchestratorSessionId() {
986
+ // Check multiple env vars that may indicate an orchestrator context
987
+ const candidates = [
988
+ process.env.TELEPTY_SESSION_ID,
989
+ process.env.DELIBERATION_ORCHESTRATOR_ID,
990
+ process.env.ORCHESTRATOR_SESSION_ID,
991
+ ];
992
+ for (const value of candidates) {
993
+ if (typeof value === "string" && value.trim()) return value.trim();
994
+ }
995
+ return null;
996
+ }
997
+
998
+ function buildTurnCompletionNotificationText(state, entry) {
999
+ const nextSpeaker = state.current_speaker || "none";
1000
+ const turnId = entry.turn_id || "(none)";
1001
+ if (state.status === "awaiting_synthesis") {
1002
+ return [
1003
+ `[deliberation turn complete]`,
1004
+ `session_id: ${state.id}`,
1005
+ `speaker: ${entry.speaker}`,
1006
+ `turn_id: ${turnId}`,
1007
+ `round: ${entry.round}/${state.max_rounds}`,
1008
+ `status: awaiting_synthesis`,
1009
+ `responses: ${state.log.length}`,
1010
+ `all rounds complete; run deliberation_synthesize(session_id: "${state.id}")`,
1011
+ `no further reply needed.`,
1012
+ ].join("\n");
1013
+ }
1014
+
1015
+ return [
1016
+ `[deliberation turn complete]`,
1017
+ `session_id: ${state.id}`,
1018
+ `speaker: ${entry.speaker}`,
1019
+ `turn_id: ${turnId}`,
1020
+ `round: ${entry.round}/${state.max_rounds}`,
1021
+ `status: ${state.status}`,
1022
+ `next_speaker: ${nextSpeaker}`,
1023
+ `next_round: ${state.current_round}/${state.max_rounds}`,
1024
+ `responses: ${state.log.length}`,
1025
+ `informational notification only.`,
1026
+ `no further reply needed.`,
1027
+ ].join("\n");
1028
+ }
1029
+
1030
+ async function notifyTeleptySessionInject({ targetSessionId, prompt, fromSessionId, replyToSessionId = null, host = TELEPTY_DEFAULT_HOST }) {
1031
+ if (!targetSessionId || !prompt) return { ok: false, error: "missing target or prompt" };
1032
+ const token = loadTeleptyAuthToken();
1033
+ if (!token) return { ok: false, error: "telepty auth token unavailable" };
1034
+
1035
+ try {
1036
+ const response = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions/${encodeURIComponent(targetSessionId)}/inject`, {
1037
+ method: "POST",
1038
+ headers: {
1039
+ "Content-Type": "application/json",
1040
+ "x-telepty-token": token,
1041
+ },
1042
+ body: JSON.stringify({
1043
+ prompt,
1044
+ from: fromSessionId || null,
1045
+ reply_to: replyToSessionId || null,
1046
+ deliberation_session_id: null,
1047
+ thread_id: null,
1048
+ }),
1049
+ });
1050
+ const data = await response.json().catch(() => null);
1051
+ if (!response.ok) {
1052
+ return { ok: false, error: data?.error || `HTTP ${response.status}` };
1053
+ }
1054
+ return { ok: true, inject_id: data?.inject_id || null };
1055
+ } catch (err) {
1056
+ return { ok: false, error: String(err?.message || err) };
1057
+ }
1058
+ }
1059
+
1060
+ async function dispatchTeleptyTurnRequest({ state, speaker, prompt = null, includeHistoryEntries = 4, awaitSemantic = false }) {
1061
+ const { profile } = resolveTransportForSpeaker(state, speaker);
1062
+ const turnId = state.pending_turn_id || generateTurnId();
1063
+ const turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries);
1064
+ const busReady = await ensureTeleptyBusSubscriber();
1065
+ const envelope = buildTeleptyTurnRequestEnvelope({
1066
+ state,
1067
+ speaker,
1068
+ turnId,
1069
+ turnPrompt,
1070
+ includeHistoryEntries,
1071
+ profile,
1072
+ });
1073
+ const pending = registerPendingTeleptyTurnRequest({ envelope, profile, speaker });
1074
+ const publishResult = await notifyTeleptyBus(envelope);
1075
+ const health = profile?.telepty_session_id ? getTeleptySessionHealth(profile.telepty_session_id) : null;
1076
+
1077
+ if (!publishResult.ok) {
1078
+ cleanupPendingTeleptyTurn(envelope.message_id);
1079
+ return {
1080
+ ok: false,
1081
+ stage: "publish",
1082
+ envelope,
1083
+ turnPrompt,
1084
+ publishResult,
1085
+ busReady,
1086
+ health,
1087
+ };
1088
+ }
1089
+
1090
+ const transportResult = await pending.transportPromise;
1091
+ let semanticResult = null;
1092
+ if (awaitSemantic && transportResult.ok) {
1093
+ semanticResult = await pending.semanticPromise;
1094
+ }
1095
+
1096
+ return {
1097
+ ok: !awaitSemantic ? transportResult.ok : Boolean(semanticResult?.ok),
1098
+ stage: awaitSemantic ? (semanticResult?.ok ? "semantic" : (semanticResult?.code || "semantic_timeout")) : (transportResult.ok ? "transport" : (transportResult?.code || "transport_timeout")),
1099
+ envelope,
1100
+ turnPrompt,
1101
+ publishResult,
1102
+ transportResult,
1103
+ semanticResult,
1104
+ busReady,
1105
+ health,
1106
+ };
1107
+ }
1108
+
892
1109
  function getArchiveDir(projectSlug = getProjectSlug()) {
893
1110
  const slug = normalizeProjectSlug(projectSlug);
894
1111
  const obsidianDir = path.join(OBSIDIAN_PROJECTS, slug, "deliberations");
@@ -1435,19 +1652,27 @@ function detectCallerSpeaker() {
1435
1652
  const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
1436
1653
  if (hinted) return hinted;
1437
1654
 
1655
+ const envKeys = Object.keys(process.env).join(" ");
1438
1656
  const pathHint = process.env.PATH || "";
1439
- if (/\bCODEX_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
1440
- return "codex";
1441
- }
1442
- if (pathHint.includes("/.codex/")) {
1657
+
1658
+ // Codex detection
1659
+ if (/\bCODEX_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.codex/")) {
1443
1660
  return "codex";
1444
1661
  }
1445
1662
 
1446
- if (/\bCLAUDE_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
1663
+ // Claude detection
1664
+ if (/\bCLAUDE_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.claude/")) {
1447
1665
  return "claude";
1448
1666
  }
1449
- if (pathHint.includes("/.claude/")) {
1450
- return "claude";
1667
+
1668
+ // Gemini detection
1669
+ if (/\bGOOGLE_GENAI_[A-Z0-9_]+\b/.test(envKeys) || /\bGEMINI_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.gemini/")) {
1670
+ return "gemini";
1671
+ }
1672
+
1673
+ // Aider detection
1674
+ if (/\bAIDER_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/aider/")) {
1675
+ return "aider";
1451
1676
  }
1452
1677
 
1453
1678
  return null;
@@ -2582,6 +2807,20 @@ function cleanupSyncMarkdown(state) {
2582
2807
  try { fs.unlinkSync(cwdPath); } catch { /* ignore */ }
2583
2808
  }
2584
2809
 
2810
+ function formatSourceMetadataLine(meta) {
2811
+ if (!meta || typeof meta !== "object") return "";
2812
+ const parts = [];
2813
+ if (meta.source_machine_id) parts.push(`machine: ${meta.source_machine_id}`);
2814
+ if (meta.source_session_id) parts.push(`session: ${meta.source_session_id}`);
2815
+ if (meta.transport_scope) parts.push(`transport: ${meta.transport_scope}`);
2816
+ if (meta.reply_origin) parts.push(`origin: ${meta.reply_origin}`);
2817
+ if (meta.timestamp) parts.push(`timestamp: ${meta.timestamp}`);
2818
+ if (Array.isArray(meta.artifact_refs) && meta.artifact_refs.length > 0) {
2819
+ parts.push(`artifacts: ${meta.artifact_refs.join(", ")}`);
2820
+ }
2821
+ return parts.length > 0 ? `> _source: ${parts.join(" | ")}_\n\n` : "";
2822
+ }
2823
+
2585
2824
  function stateToMarkdown(s) {
2586
2825
  const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
2587
2826
  let md = `---
@@ -2628,6 +2867,7 @@ tags: [deliberation]
2628
2867
  if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
2629
2868
  md += `> _${parts.join(" | ")}_\n\n`;
2630
2869
  }
2870
+ md += formatSourceMetadataLine(entry.source_metadata);
2631
2871
  md += `${entry.content}\n\n`;
2632
2872
  if (entry.attachments && entry.attachments.length > 0) {
2633
2873
  for (const att of entry.attachments) {
@@ -2651,6 +2891,21 @@ function archiveState(state) {
2651
2891
  const filename = `deliberation-${ts}-${slug}.md`;
2652
2892
  const dest = path.join(getArchiveDir(state.project), filename);
2653
2893
  writeTextAtomic(dest, stateToMarkdown(state));
2894
+
2895
+ // Write machine-readable execution_contract sidecar for automation consumers
2896
+ if (state.execution_contract) {
2897
+ const contractDest = dest.replace(/\.md$/, ".contract.json");
2898
+ writeTextAtomic(contractDest, JSON.stringify({
2899
+ ...state.execution_contract,
2900
+ _meta: {
2901
+ archived_from: state.id,
2902
+ project: state.project,
2903
+ topic: state.topic,
2904
+ archived_at: new Date().toISOString(),
2905
+ },
2906
+ }, null, 2));
2907
+ }
2908
+
2654
2909
  return dest;
2655
2910
  }
2656
2911
 
@@ -3296,6 +3551,21 @@ function buildCliAutoTurnFailureText({ state, speaker, hint, err, effectiveTimeo
3296
3551
  );
3297
3552
  }
3298
3553
 
3554
+ function buildActiveReportingSection(state, speaker) {
3555
+ const orchestratorSessionId = state?.orchestrator_session_id;
3556
+ if (!orchestratorSessionId) return "";
3557
+
3558
+ return `\n[active_reporting_rule]
3559
+ - After you successfully submit this turn with \`deliberation_respond(...)\`, send a brief status update to the orchestrator session.
3560
+ - Primary method (telepty available):
3561
+ \`telepty inject --from \"$TELEPTY_SESSION_ID\" --reply-to ${orchestratorSessionId} ${orchestratorSessionId} \"[deliberation status] session_id: ${state.id} speaker: ${speaker} round: ${state.current_round}/${state.max_rounds} submitted. no further reply needed.\"\`
3562
+ - Fallback (telepty unavailable): Include the status in your next tool call or response context.
3563
+ - Keep it to one short line. Do not wait for a reply.
3564
+ - This rule applies regardless of transport type (CLI, browser, telepty_bus).
3565
+ [/active_reporting_rule]
3566
+ `;
3567
+ }
3568
+
3299
3569
  function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
3300
3570
  const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
3301
3571
  const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
@@ -3304,6 +3574,7 @@ function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries
3304
3574
  const noToolRule = speaker === "codex"
3305
3575
  ? `\n- Do not inspect files, run shell commands, browse, or call tools. Answer only from the provided discussion context.`
3306
3576
  : "";
3577
+ const activeReportingSection = buildActiveReportingSection(state, speaker);
3307
3578
 
3308
3579
  // Role prompt injection
3309
3580
  const speakerRole = (state.speaker_roles || {})[speaker] || "free";
@@ -3318,7 +3589,7 @@ project: ${state.project}
3318
3589
  topic: ${topic}
3319
3590
  round: ${state.current_round}/${state.max_rounds}
3320
3591
  target_speaker: ${speaker}
3321
- required_turn: ${state.current_speaker}${roleSection}
3592
+ required_turn: ${state.current_speaker}${roleSection}${activeReportingSection}
3322
3593
 
3323
3594
  [recent_log]
3324
3595
  ${recent}
@@ -3334,7 +3605,7 @@ ${recent}
3334
3605
  `;
3335
3606
  }
3336
3607
 
3337
- function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments }) {
3608
+ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments, source_metadata }) {
3338
3609
  const resolved = resolveSessionId(session_id);
3339
3610
  if (!resolved) {
3340
3611
  return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
@@ -3343,7 +3614,9 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
3343
3614
  return { content: [{ type: "text", text: multipleSessionsError() }] };
3344
3615
  }
3345
3616
 
3346
- return withSessionLock(resolved, () => {
3617
+ let completionState = null;
3618
+ let completionEntry = null;
3619
+ const result = withSessionLock(resolved, () => {
3347
3620
  const state = loadSession(resolved);
3348
3621
  if (!state || state.status !== "active") {
3349
3622
  return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
@@ -3388,7 +3661,7 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
3388
3661
  const suggestedRole = inferSuggestedRole(content);
3389
3662
  const assignedRole = (state.speaker_roles || {})[normalizedSpeaker] || "free";
3390
3663
  const roleDrift = assignedRole !== "free" && suggestedRole !== "free" && assignedRole !== suggestedRole;
3391
- state.log.push({
3664
+ const logEntry = {
3392
3665
  round: state.current_round,
3393
3666
  speaker: normalizedSpeaker,
3394
3667
  content,
@@ -3400,13 +3673,15 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
3400
3673
  suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
3401
3674
  role_drift: roleDrift || undefined,
3402
3675
  attachments: attachments || undefined,
3403
- });
3676
+ source_metadata: source_metadata || undefined,
3677
+ };
3678
+ state.log.push(logEntry);
3404
3679
  completePendingTeleptySemantic({
3405
3680
  sessionId: state.id,
3406
3681
  speaker: normalizedSpeaker,
3407
3682
  turnId: state.pending_turn_id || turn_id || null,
3408
3683
  });
3409
- 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"} | attachments: ${attachments ? attachments.length : 0}`);
3684
+ 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"} | attachments: ${attachments ? attachments.length : 0}${source_metadata?.source_machine_id ? ` | source_machine: ${source_metadata.source_machine_id}` : ""}`);
3410
3685
 
3411
3686
  state.current_speaker = selectNextSpeaker(state);
3412
3687
 
@@ -3434,6 +3709,17 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
3434
3709
  state.pending_turn_id = generateTurnId();
3435
3710
  }
3436
3711
 
3712
+ if (!state.orchestrator_session_id) {
3713
+ state.orchestrator_session_id = getDefaultOrchestratorSessionId() || null;
3714
+ }
3715
+ completionEntry = {
3716
+ ...logEntry,
3717
+ turn_id: logEntry.turn_id || turn_id || null,
3718
+ };
3719
+ completionState = {
3720
+ ...state,
3721
+ log: [...state.log],
3722
+ };
3437
3723
  saveSession(state);
3438
3724
  return {
3439
3725
  content: [{
@@ -3442,6 +3728,23 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
3442
3728
  }],
3443
3729
  };
3444
3730
  });
3731
+
3732
+ if (completionState && completionEntry) {
3733
+ const envelope = buildTeleptyTurnCompletedEnvelope({ state: completionState, entry: completionEntry });
3734
+ notifyTeleptyBus(envelope).catch(() => {});
3735
+
3736
+ const orchestratorSessionId = completionState.orchestrator_session_id || null;
3737
+ if (orchestratorSessionId) {
3738
+ const notificationText = buildTurnCompletionNotificationText(completionState, completionEntry);
3739
+ notifyTeleptySessionInject({
3740
+ targetSessionId: orchestratorSessionId,
3741
+ prompt: notificationText,
3742
+ fromSessionId: `deliberation:${completionState.id}`,
3743
+ }).catch(() => {});
3744
+ }
3745
+ }
3746
+
3747
+ return result;
3445
3748
  }
3446
3749
 
3447
3750
  // ── MCP Server ─────────────────────────────────────────────────
@@ -3525,8 +3828,11 @@ server.tool(
3525
3828
  (v) => (typeof v === "string" ? v === "true" : v),
3526
3829
  z.boolean().optional()
3527
3830
  ).describe("If true, automatically create a handoff task in the inbox when synthesis completes. Enables the Autonomous Deliberation Handoff pattern."),
3528
- },
3529
- safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute }) => {
3831
+ mode: z.enum(["standard", "lite"]).default("standard").describe("Deliberation mode. 'lite' caps speakers to 3 and rounds to 2 for quick decisions."),
3832
+ orchestrator_session_id: z.string().trim().min(1).max(128).optional()
3833
+ .describe("Optional telepty session ID to notify on turn completion. Defaults to TELEPTY_SESSION_ID when available."),
3834
+ },
3835
+ safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute, mode, orchestrator_session_id }) => {
3530
3836
  // ── First-time onboarding guard ──
3531
3837
  const config = loadDeliberationConfig();
3532
3838
  if (!config.setup_complete) {
@@ -3649,12 +3955,22 @@ server.tool(
3649
3955
  || normalizeSpeaker(hasManualSpeakers ? selectedSpeakers?.[0] : callerSpeaker)
3650
3956
  || normalizeSpeaker(selectedSpeakers?.[0])
3651
3957
  || DEFAULT_SPEAKERS[0];
3652
- const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
3958
+ let speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
3653
3959
 
3654
3960
  if (effectiveRequireManual) {
3655
3961
  clearSpeakerSelectionToken();
3656
3962
  }
3657
3963
 
3964
+ // Lite mode: cap speakers and rounds for quick decisions
3965
+ if (mode === "lite") {
3966
+ if (speakerOrder.length > 3) {
3967
+ speakerOrder.splice(3);
3968
+ }
3969
+ if (rounds > 2) {
3970
+ rounds = 2;
3971
+ }
3972
+ }
3973
+
3658
3974
  // Warn if only 1 speaker — deliberation requires 2+
3659
3975
  if (speakerOrder.length < 2) {
3660
3976
  const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
@@ -3706,6 +4022,8 @@ server.tool(
3706
4022
  speaker_roles: speaker_roles || (role_preset ? applyRolePreset(role_preset, speakerOrder) : {}),
3707
4023
  degradation: degradationLevels,
3708
4024
  auto_execute: auto_execute || false,
4025
+ mode: mode || "standard",
4026
+ orchestrator_session_id: orchestrator_session_id || getDefaultOrchestratorSessionId() || null,
3709
4027
  created: new Date().toISOString(),
3710
4028
  updated: new Date().toISOString(),
3711
4029
  };
@@ -3994,28 +4312,22 @@ server.tool(
3994
4312
  let manualFallbackPrompt = false;
3995
4313
 
3996
4314
  if (transport === "telepty_bus") {
3997
- turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, include_history_entries);
3998
- const busReady = await ensureTeleptyBusSubscriber();
3999
- const envelope = buildTeleptyTurnRequestEnvelope({
4315
+ const dispatchResult = await dispatchTeleptyTurnRequest({
4000
4316
  state,
4001
4317
  speaker,
4002
- turnId: turnId || generateTurnId(),
4003
- turnPrompt,
4318
+ prompt,
4004
4319
  includeHistoryEntries: include_history_entries,
4005
- profile,
4320
+ awaitSemantic: false,
4006
4321
  });
4007
- const pending = registerPendingTeleptyTurnRequest({ envelope, profile, speaker });
4008
- const publishResult = await notifyTeleptyBus(envelope);
4009
- const health = profile?.telepty_session_id ? getTeleptySessionHealth(profile.telepty_session_id) : null;
4322
+ turnPrompt = dispatchResult.turnPrompt;
4323
+ const { envelope, publishResult, transportResult, busReady, health } = dispatchResult;
4010
4324
 
4011
- if (!publishResult.ok) {
4012
- cleanupPendingTeleptyTurn(envelope.message_id);
4325
+ if (!dispatchResult.ok && dispatchResult.stage === "publish") {
4013
4326
  manualFallbackPrompt = true;
4014
4327
  extra += `\n\n❌ Telepty bus publish failed: ${publishResult.error || publishResult.status || "unknown error"}\n` +
4015
4328
  `Fallback: use manual telepty inject for this turn.`;
4016
4329
  guidance = formatTransportGuidance("manual", state, speaker);
4017
4330
  } else {
4018
- const transportResult = await pending.transportPromise;
4019
4331
  const healthLine = health
4020
4332
  ? `\n**Session health:** alive=${health.payload?.alive === true ? "yes" : "no"}, pid=${health.payload?.pid || "n/a"}, age=${Math.max(0, Math.round((health.age_ms || 0) / 1000))}s${health.stale ? " (stale)" : ""}`
4021
4333
  : "";
@@ -4360,6 +4672,226 @@ async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
4360
4672
  }
4361
4673
  }
4362
4674
 
4675
+ async function runBrowserAutoTurnCore(sessionId, speaker, timeoutSec = 45) {
4676
+ const state = loadSession(sessionId);
4677
+ if (!state || state.status !== "active") {
4678
+ return { ok: false, error: "Session not active" };
4679
+ }
4680
+
4681
+ const { transport, profile } = resolveTransportForSpeaker(state, speaker);
4682
+ if (transport !== "browser_auto") {
4683
+ return { ok: false, error: `Speaker "${speaker}" is not browser_auto type` };
4684
+ }
4685
+
4686
+ const turnId = state.pending_turn_id || generateTurnId();
4687
+ const port = getBrowserPort();
4688
+ const effectiveProvider = profile?.provider || "chatgpt";
4689
+ const modelSelection = getModelSelectionForTurn(state, speaker, effectiveProvider);
4690
+ const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
4691
+ const startTime = Date.now();
4692
+
4693
+ try {
4694
+ const attachResult = await port.attach(sessionId, {
4695
+ provider: effectiveProvider,
4696
+ url: profile?.url || undefined,
4697
+ });
4698
+ if (!attachResult.ok) {
4699
+ return { ok: false, error: `attach failed: ${attachResult.error?.message || "unknown error"}` };
4700
+ }
4701
+
4702
+ const loginCheck = await port.checkLogin(sessionId);
4703
+ if (loginCheck && !loginCheck.loggedIn) {
4704
+ await port.detach(sessionId);
4705
+ return { ok: false, error: `login required: ${loginCheck.reason || "not logged in"}` };
4706
+ }
4707
+
4708
+ if (modelSelection.model !== "default") {
4709
+ await port.switchModel(sessionId, modelSelection.model);
4710
+ }
4711
+
4712
+ const sendResult = await port.sendTurnWithDegradation(sessionId, turnId, turnPrompt);
4713
+ if (!sendResult.ok) {
4714
+ await port.detach(sessionId);
4715
+ return { ok: false, error: `send failed: ${sendResult.error?.message || "unknown error"}` };
4716
+ }
4717
+
4718
+ const waitResult = await port.waitTurnResult(sessionId, turnId, timeoutSec);
4719
+ await port.detach(sessionId);
4720
+ if (!waitResult.ok || !waitResult.data?.response) {
4721
+ return { ok: false, error: waitResult.error?.message || "no response received" };
4722
+ }
4723
+
4724
+ submitDeliberationTurn({
4725
+ session_id: sessionId,
4726
+ speaker,
4727
+ content: waitResult.data.response,
4728
+ turn_id: turnId,
4729
+ channel_used: "browser_auto",
4730
+ });
4731
+
4732
+ return {
4733
+ ok: true,
4734
+ response: waitResult.data.response,
4735
+ elapsedMs: Date.now() - startTime,
4736
+ model: modelSelection.model,
4737
+ provider: effectiveProvider,
4738
+ };
4739
+ } catch (err) {
4740
+ try { await port.detach(sessionId); } catch {}
4741
+ return { ok: false, error: err?.message || String(err) };
4742
+ }
4743
+ }
4744
+
4745
+ async function runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries = 4) {
4746
+ const state = loadSession(sessionId);
4747
+ if (!state || state.status !== "active") {
4748
+ return { ok: false, error: "Session not active" };
4749
+ }
4750
+
4751
+ const { transport } = resolveTransportForSpeaker(state, speaker);
4752
+ if (transport !== "telepty_bus") {
4753
+ return { ok: false, error: `Speaker "${speaker}" is not telepty_bus type` };
4754
+ }
4755
+
4756
+ const startTime = Date.now();
4757
+ const dispatchResult = await dispatchTeleptyTurnRequest({
4758
+ state,
4759
+ speaker,
4760
+ includeHistoryEntries,
4761
+ awaitSemantic: true,
4762
+ });
4763
+ if (!dispatchResult.publishResult?.ok) {
4764
+ return {
4765
+ ok: false,
4766
+ blocked: true,
4767
+ error: dispatchResult.publishResult?.error || dispatchResult.publishResult?.status || "telepty bus publish failed",
4768
+ envelope: dispatchResult.envelope,
4769
+ turnPrompt: dispatchResult.turnPrompt,
4770
+ };
4771
+ }
4772
+ if (!dispatchResult.transportResult?.ok) {
4773
+ return {
4774
+ ok: false,
4775
+ blocked: true,
4776
+ error: dispatchResult.transportResult?.code || "transport timeout",
4777
+ envelope: dispatchResult.envelope,
4778
+ turnPrompt: dispatchResult.turnPrompt,
4779
+ };
4780
+ }
4781
+ if (!dispatchResult.semanticResult?.ok) {
4782
+ return {
4783
+ ok: false,
4784
+ blocked: true,
4785
+ error: dispatchResult.semanticResult?.code || "semantic timeout",
4786
+ envelope: dispatchResult.envelope,
4787
+ turnPrompt: dispatchResult.turnPrompt,
4788
+ };
4789
+ }
4790
+
4791
+ return {
4792
+ ok: true,
4793
+ elapsedMs: Date.now() - startTime,
4794
+ envelope: dispatchResult.envelope,
4795
+ publishResult: dispatchResult.publishResult,
4796
+ transportResult: dispatchResult.transportResult,
4797
+ semanticResult: dispatchResult.semanticResult,
4798
+ };
4799
+ }
4800
+
4801
+ async function runUntilBlockedCore(sessionId, {
4802
+ maxTurns = 12,
4803
+ cliTimeoutSec = 120,
4804
+ browserTimeoutSec = 45,
4805
+ includeHistoryEntries = 4,
4806
+ } = {}) {
4807
+ const steps = [];
4808
+
4809
+ for (let iteration = 0; iteration < maxTurns; iteration += 1) {
4810
+ const state = loadSession(sessionId);
4811
+ if (!state) {
4812
+ return { ok: false, status: "missing", error: "Session not found", steps };
4813
+ }
4814
+ if (state.status !== "active" || state.current_speaker === "none") {
4815
+ return { ok: true, status: state.status, steps };
4816
+ }
4817
+
4818
+ const speaker = state.current_speaker;
4819
+ const { transport } = resolveTransportForSpeaker(state, speaker);
4820
+ const callerSpeaker = detectCallerSpeaker();
4821
+ if (transport === "cli_respond" && callerSpeaker && normalizeSpeaker(callerSpeaker) === normalizeSpeaker(speaker)) {
4822
+ return {
4823
+ ok: true,
4824
+ status: "blocked",
4825
+ block_reason: "self_turn",
4826
+ speaker,
4827
+ transport,
4828
+ turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
4829
+ steps,
4830
+ };
4831
+ }
4832
+
4833
+ if (transport === "manual" || transport === "clipboard") {
4834
+ return {
4835
+ ok: true,
4836
+ status: "blocked",
4837
+ block_reason: "manual_transport",
4838
+ speaker,
4839
+ transport,
4840
+ turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
4841
+ steps,
4842
+ };
4843
+ }
4844
+
4845
+ let result = null;
4846
+ if (transport === "cli_respond") {
4847
+ result = await runCliAutoTurnCore(sessionId, speaker, cliTimeoutSec);
4848
+ } else if (transport === "browser_auto") {
4849
+ result = await runBrowserAutoTurnCore(sessionId, speaker, browserTimeoutSec);
4850
+ } else if (transport === "telepty_bus") {
4851
+ result = await runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries);
4852
+ } else {
4853
+ return {
4854
+ ok: true,
4855
+ status: "blocked",
4856
+ block_reason: "unsupported_transport",
4857
+ speaker,
4858
+ transport,
4859
+ turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
4860
+ steps,
4861
+ };
4862
+ }
4863
+
4864
+ steps.push({
4865
+ speaker,
4866
+ transport,
4867
+ ok: Boolean(result?.ok),
4868
+ error: result?.error || null,
4869
+ elapsedMs: result?.elapsedMs || null,
4870
+ blocked: Boolean(result?.blocked),
4871
+ });
4872
+
4873
+ if (!result?.ok) {
4874
+ return {
4875
+ ok: Boolean(result?.blocked),
4876
+ status: result?.blocked ? "blocked" : "error",
4877
+ block_reason: result?.blocked ? (result.error || "transport_blocked") : null,
4878
+ speaker,
4879
+ transport,
4880
+ error: result?.error || null,
4881
+ turn_prompt: result?.turnPrompt || null,
4882
+ steps,
4883
+ };
4884
+ }
4885
+ }
4886
+
4887
+ const finalState = loadSession(sessionId);
4888
+ return {
4889
+ ok: true,
4890
+ status: finalState?.status === "active" ? "max_turns_reached" : (finalState?.status || "completed"),
4891
+ steps,
4892
+ };
4893
+ }
4894
+
4363
4895
  /**
4364
4896
  * Generate structured synthesis by calling a CLI speaker with a synthesis prompt.
4365
4897
  */
@@ -4481,27 +5013,14 @@ async function runAutoHandoff(sessionId) {
4481
5013
 
4482
5014
  appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN: ${sessionId} | speaker: ${speaker} | round: ${state.current_round}/${state.max_rounds}`);
4483
5015
 
4484
- const result = await runCliAutoTurnCore(sessionId, speaker);
4485
- if (!result.ok) {
4486
- appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_FAIL: ${sessionId} | speaker: ${speaker} | ${result.error}`);
4487
- // Skip this speaker, continue with next
4488
- const freshState = loadSession(sessionId);
4489
- if (freshState) {
4490
- // Advance to next speaker manually
4491
- const idx = freshState.speakers.indexOf(speaker);
4492
- const nextIdx = (idx + 1) % freshState.speakers.length;
4493
- freshState.current_speaker = freshState.speakers[nextIdx];
4494
- if (nextIdx === 0) freshState.current_round++;
4495
- if (freshState.current_round > freshState.max_rounds) {
4496
- freshState.status = "awaiting_synthesis";
4497
- freshState.current_speaker = "none";
4498
- }
4499
- saveSession(freshState);
4500
- }
4501
- continue;
5016
+ const runResult = await runUntilBlockedCore(sessionId, { maxTurns: 1, includeHistoryEntries: 3 });
5017
+ const step = runResult.steps.at(-1) || null;
5018
+ if (!runResult.ok || runResult.status === "blocked") {
5019
+ appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_BLOCKED: ${sessionId} | speaker: ${speaker} | ${runResult.block_reason || runResult.error || "unknown"}`);
5020
+ break;
4502
5021
  }
4503
5022
 
4504
- appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${result.elapsedMs}ms`);
5023
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${step?.elapsedMs || 0}ms`);
4505
5024
  }
4506
5025
 
4507
5026
  // Phase 2: Generate structured synthesis
@@ -4797,6 +5316,56 @@ server.tool(
4797
5316
  })
4798
5317
  );
4799
5318
 
5319
+ server.tool(
5320
+ "deliberation_run_until_blocked",
5321
+ "Auto-run a deliberation across mixed transports until it completes or reaches a manual/blocking turn.",
5322
+ {
5323
+ session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
5324
+ max_turns: z.number().int().min(1).max(50).default(12).describe("Maximum number of turns to auto-run before stopping"),
5325
+ cli_timeout_sec: z.number().int().min(30).max(900).default(120).describe("CLI auto-turn timeout (seconds)"),
5326
+ browser_timeout_sec: z.number().int().min(15).max(300).default(45).describe("Browser auto-turn timeout (seconds)"),
5327
+ include_history_entries: z.number().int().min(0).max(12).default(4).describe("Recent log entries to include for telepty turns"),
5328
+ },
5329
+ safeToolHandler("deliberation_run_until_blocked", async ({ session_id, max_turns, cli_timeout_sec, browser_timeout_sec, include_history_entries }) => {
5330
+ const resolved = resolveSessionId(session_id);
5331
+ if (!resolved) {
5332
+ return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
5333
+ }
5334
+ if (resolved === "MULTIPLE") {
5335
+ return { content: [{ type: "text", text: multipleSessionsError() }] };
5336
+ }
5337
+
5338
+ const initialState = loadSession(resolved);
5339
+ if (!initialState || initialState.status !== "active") {
5340
+ return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
5341
+ }
5342
+
5343
+ const result = await runUntilBlockedCore(resolved, {
5344
+ maxTurns: max_turns,
5345
+ cliTimeoutSec: cli_timeout_sec,
5346
+ browserTimeoutSec: browser_timeout_sec,
5347
+ includeHistoryEntries: include_history_entries,
5348
+ });
5349
+ const finalState = loadSession(resolved);
5350
+ const stepsText = (result.steps || []).length > 0
5351
+ ? result.steps.map((step, index) => `- ${index + 1}. ${step.speaker} [${step.transport}] → ${step.ok ? "ok" : (step.blocked ? `blocked (${step.error || "blocked"})` : `error (${step.error || "unknown"})`)}${step.elapsedMs ? ` (${step.elapsedMs}ms)` : ""}`).join("\n")
5352
+ : "- none";
5353
+
5354
+ let summary = `## Run Until Blocked — ${resolved}\n\n`;
5355
+ summary += `**Result:** ${result.status}\n`;
5356
+ summary += `**Current state:** ${finalState?.status || initialState.status}\n`;
5357
+ summary += `**Current speaker:** ${finalState?.current_speaker || initialState.current_speaker}\n`;
5358
+ if (result.block_reason) summary += `**Block reason:** ${result.block_reason}\n`;
5359
+ if (result.error) summary += `**Error:** ${result.error}\n`;
5360
+ if (result.turn_prompt) {
5361
+ summary += `\n### [turn_prompt]\n\`\`\`markdown\n${result.turn_prompt}\n\`\`\`\n`;
5362
+ }
5363
+ summary += `\n### Steps\n${stepsText}\n`;
5364
+
5365
+ return { content: [{ type: "text", text: summary }] };
5366
+ })
5367
+ );
5368
+
4800
5369
  server.tool(
4801
5370
  "deliberation_respond",
4802
5371
  "Submit a response for the current turn.",
@@ -4880,6 +5449,51 @@ server.tool(
4880
5449
  })
4881
5450
  );
4882
5451
 
5452
+ server.tool(
5453
+ "deliberation_ingest_remote_reply",
5454
+ "Canonical semantic ingress for replies produced on another machine/session. Use this instead of reconstructing deliberation state from transport events.",
5455
+ {
5456
+ session_id: z.string().describe("Deliberation session ID"),
5457
+ speaker: z.string().describe("Speaker name"),
5458
+ turn_id: z.string().min(1).describe("Turn ID associated with the issued turn_request"),
5459
+ content: z.string().min(1).describe("Remote reply content"),
5460
+ source_machine_id: z.string().min(1).describe("Source machine or peer identifier"),
5461
+ source_session_id: z.string().min(1).describe("Source remote session identifier"),
5462
+ transport_scope: z.string().min(1).describe("Transport scope used to carry the remote reply"),
5463
+ artifact_refs: z.array(z.string().min(1)).optional().describe("Optional artifact references the reply depends on"),
5464
+ reply_origin: z.string().optional().describe("Optional origin hint, e.g. remote_mcp, telepty_thread"),
5465
+ timestamp: z.string().optional().describe("Optional source timestamp"),
5466
+ },
5467
+ safeToolHandler("deliberation_ingest_remote_reply", async ({
5468
+ session_id,
5469
+ speaker,
5470
+ turn_id,
5471
+ content,
5472
+ source_machine_id,
5473
+ source_session_id,
5474
+ transport_scope,
5475
+ artifact_refs,
5476
+ reply_origin,
5477
+ timestamp,
5478
+ }) => {
5479
+ return submitDeliberationTurn({
5480
+ session_id,
5481
+ speaker,
5482
+ content,
5483
+ turn_id,
5484
+ channel_used: `remote_ingress:${transport_scope}`,
5485
+ source_metadata: {
5486
+ source_machine_id,
5487
+ source_session_id,
5488
+ transport_scope,
5489
+ artifact_refs: artifact_refs || [],
5490
+ reply_origin: reply_origin || null,
5491
+ timestamp: timestamp || new Date().toISOString(),
5492
+ },
5493
+ });
5494
+ })
5495
+ );
5496
+
4883
5497
  server.tool(
4884
5498
  "deliberation_list_remote_sessions",
4885
5499
  "List all active deliberation sessions on a remote machine (via Tailscale/IP) to find the correct session_id for context injection.",
@@ -5118,6 +5732,9 @@ server.tool(
5118
5732
  notifyTeleptyBus(synthesisEnvelope).catch(() => {}); // fire-and-forget
5119
5733
  }
5120
5734
 
5735
+ // Notify brain ingest if endpoint configured
5736
+ callBrainIngest(state.execution_contract).catch(() => {}); // fire-and-forget
5737
+
5121
5738
  return {
5122
5739
  content: [{
5123
5740
  type: "text",
@@ -6102,4 +6719,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
6102
6719
  }
6103
6720
 
6104
6721
  // ── Test exports (used by vitest) ──
6105
- 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, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
6722
+ 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, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptyTurnCompletedEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
package/install.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * What it does:
11
11
  * 1. Copies server files to ~/.local/lib/mcp-deliberation/
12
12
  * 2. Installs npm dependencies
13
- * 3. Registers MCP server in ~/.claude/.mcp.json (Claude Code)
13
+ * 3. Registers MCP server via `claude mcp add` (Claude Code)
14
14
  * 4. Registers MCP server in ~/.gemini/settings.json (Gemini CLI)
15
15
  * 5. Ready to use — next Claude Code or Gemini CLI session will auto-load
16
16
  * 6. Installs skill file (~/.claude/skills/deliberation-gate/SKILL.md)
@@ -61,6 +61,15 @@ function log(msg) {
61
61
  console.log(` ${msg}`);
62
62
  }
63
63
 
64
+ function commandExists(cmd) {
65
+ try {
66
+ execSync(IS_WIN ? `where ${cmd}` : `command -v ${cmd}`, { stdio: "pipe" });
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
64
73
  function copyFileIfExists(src, dest) {
65
74
  if (fs.existsSync(src)) {
66
75
  fs.copyFileSync(src, dest);
@@ -121,32 +130,55 @@ function install() {
121
130
  log(" Manual fix: cd ~/.local/lib/mcp-deliberation && npm install");
122
131
  }
123
132
 
124
- // Step 4: Register MCP server
133
+ // Step 4: Register MCP server (Claude Code)
125
134
  log("🔧 Registering Claude Code MCP server...");
126
- const claudeDir = path.join(HOME, ".claude");
127
- fs.mkdirSync(claudeDir, { recursive: true });
135
+ const serverEntryPoint = toForwardSlash(path.join(INSTALL_DIR, "index.js"));
136
+ let claudeRegistered = false;
128
137
 
129
- let mcpConfig = {};
130
- if (fs.existsSync(MCP_CONFIG)) {
138
+ // Prefer `claude mcp add` — this is the only method Claude Code reliably reads
139
+ if (commandExists("claude")) {
131
140
  try {
132
- mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
133
- } catch {
134
- mcpConfig = {};
141
+ // Remove existing registration first (ignore errors if not registered)
142
+ try { execSync("claude mcp remove deliberation -s user", { stdio: "pipe" }); } catch { /* ok */ }
143
+ execSync(`claude mcp add deliberation -s user -- node "${serverEntryPoint}"`, {
144
+ stdio: "pipe",
145
+ });
146
+ log(" → Registered via 'claude mcp add' (user scope)");
147
+ claudeRegistered = true;
148
+ } catch (err) {
149
+ log(` ⚠️ 'claude mcp add' failed: ${err.message}`);
135
150
  }
136
151
  }
137
152
 
138
- if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
153
+ // Fallback: write ~/.claude/.mcp.json (legacy, may not be read by Claude Code)
154
+ if (!claudeRegistered) {
155
+ const claudeDir = path.join(HOME, ".claude");
156
+ fs.mkdirSync(claudeDir, { recursive: true });
139
157
 
140
- const alreadyRegistered = !!mcpConfig.mcpServers.deliberation;
141
- mcpConfig.mcpServers.deliberation = {
142
- command: "node",
143
- args: [toForwardSlash(path.join(INSTALL_DIR, "index.js"))],
144
- };
158
+ let mcpConfig = {};
159
+ if (fs.existsSync(MCP_CONFIG)) {
160
+ try {
161
+ mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
162
+ } catch {
163
+ mcpConfig = {};
164
+ }
165
+ }
166
+
167
+ if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
168
+
169
+ const alreadyRegistered = !!mcpConfig.mcpServers.deliberation;
170
+ mcpConfig.mcpServers.deliberation = {
171
+ command: "node",
172
+ args: [serverEntryPoint],
173
+ };
145
174
 
146
- fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
147
- log(alreadyRegistered
148
- ? " → Existing registration updated"
149
- : " → Registered successfully");
175
+ fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
176
+ log(alreadyRegistered
177
+ ? " → Fallback: existing registration updated in .mcp.json"
178
+ : " → Fallback: registered in .mcp.json");
179
+ log(" ⚠️ Claude CLI not found. Run manually if server isn't detected:");
180
+ log(` claude mcp add deliberation -s user -- node "${serverEntryPoint}"`);
181
+ }
150
182
 
151
183
  // Step 5: Register Gemini CLI MCP server
152
184
  log("🔧 Registering Gemini CLI MCP server...");
@@ -283,14 +315,24 @@ Skill path: ${SKILL_DEST}
283
315
  } else if (args.includes("--uninstall") || args.includes("uninstall")) {
284
316
  console.log("\n🗑️ Deliberation MCP Server — Uninstalling\n");
285
317
 
286
- // Remove from Claude MCP config
318
+ // Remove from Claude Code MCP registration
319
+ let claudeUnregistered = false;
320
+ if (commandExists("claude")) {
321
+ try {
322
+ execSync("claude mcp remove deliberation -s user", { stdio: "pipe" });
323
+ log("Claude Code MCP server unregistered via 'claude mcp remove'");
324
+ claudeUnregistered = true;
325
+ } catch { /* may not exist */ }
326
+ }
327
+
328
+ // Also clean up legacy .mcp.json fallback
287
329
  if (fs.existsSync(MCP_CONFIG)) {
288
330
  try {
289
331
  const mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
290
332
  if (mcpConfig.mcpServers?.deliberation) {
291
333
  delete mcpConfig.mcpServers.deliberation;
292
334
  fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
293
- log("Claude Code MCP server unregistered");
335
+ if (!claudeUnregistered) log("Claude Code MCP server unregistered from .mcp.json");
294
336
  }
295
337
  } catch { /* ignore */ }
296
338
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-deliberation",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
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",
@@ -31,7 +31,9 @@ Claude/Codex를 포함해 MCP를 지원하는 임의 CLI들이 구조화된 토
31
31
  | `deliberation_context` | 프로젝트 컨텍스트 로드 | 불필요 |
32
32
  | `deliberation_browser_llm_tabs` | 브라우저 LLM 탭 목록 (웹 기반 LLM 참여용) | 불필요 |
33
33
  | `deliberation_route_turn` | 현재 차례 speaker의 transport(CLI/browser_auto/manual)를 자동 라우팅 | 선택적* |
34
+ | `deliberation_run_until_blocked` | CLI/browser_auto/telepty_bus를 자동 진행하다 막히는 지점에서 중단 | 선택적 |
34
35
  | `deliberation_respond` | 현재 차례의 응답 제출 | 선택적* |
36
+ | `deliberation_ingest_remote_reply` | 원격 머신 reply를 명시적 source metadata와 함께 semantic ingest | 선택적 |
35
37
  | `deliberation_history` | 전체 토론 기록 조회 | 선택적* |
36
38
  | `deliberation_synthesize` | 합성 보고서 생성 및 토론 완료 | 선택적* |
37
39
  | `deliberation_list` | 과거 토론 아카이브 목록 | 불필요 |
@@ -64,9 +66,11 @@ Claude/Codex를 포함해 MCP를 지원하는 임의 CLI들이 구조화된 토
64
66
  - CLI speaker → `deliberation_cli_auto_turn`으로 실제 CLI 실행
65
67
  - browser_auto → CDP로 자동 전송/수집
66
68
  - clipboard/manual → 클립보드 준비 + 사용자 안내
69
+ - 완전 자동으로 여러 턴을 밀고 싶으면 `deliberation_run_until_blocked`를 사용합니다. 이 도구는 `cli_respond`, `browser_auto`, `telepty_bus`를 연속 실행하고, 수동 transport 또는 self-turn에서 멈춥니다.
67
70
  3. **NEVER** — 오케스트레이터(자기 자신)가 다른 speaker를 대신하여 `deliberation_respond`에 응답을 작성하지 마세요. 이것은 "역할극"이며 실제 deliberation이 아닙니다. MCP 서버가 이를 감지하고 차단합니다.
68
71
  4. **NEVER** — `deliberation_respond`를 직접 호출하지 마세요 (자기 자신의 응답 제외). 다른 speaker의 턴은 반드시 `deliberation_route_turn` 또는 `deliberation_cli_auto_turn`/`deliberation_browser_auto_turn`을 통해 진행합니다.
69
72
  5. **MUST** — 자기 자신(오케스트레이터 역할의 claude)이 speaker인 경우에만 직접 `deliberation_respond`로 응답을 제출할 수 있습니다.
73
+ 6. **MUST** — 원격 머신/세션에서 들어온 응답을 semantic reply로 반영할 때는 raw bus event를 해석하지 말고 `deliberation_ingest_remote_reply`를 사용하세요. transport trace와 semantic ingest를 섞지 마세요.
70
74
 
71
75
  ## 워크플로우
72
76
 
@@ -95,7 +99,9 @@ Claude/Codex를 포함해 MCP를 지원하는 임의 CLI들이 구조화된 토
95
99
  5. **`deliberation_route_turn` 호출 (필수)** → 현재 차례 speaker transport 자동 결정 및 실행
96
100
  - CLI speaker → `deliberation_cli_auto_turn`이 실제 CLI를 실행하고 응답 수집
97
101
  - browser_auto → CDP로 자동 전송/수집
102
+ - telepty_bus → structured `turn_request` publish + remote self-submit 대기
98
103
  - 자기 자신(claude)이 speaker → 직접 `deliberation_respond`로 응답 제출
104
+ - 여러 턴을 한 번에 진행하려면 `deliberation_run_until_blocked(session_id)` 사용
99
105
  6. 반복 후 `deliberation_synthesize(session_id)` → 합성 완료
100
106
  7. 구현이 필요하면 `deliberation-executor` 스킬로 handoff
101
107
  예: "session_id {id} 합의안 구현해줘"
@@ -157,6 +163,20 @@ autoresearch 스타일 실험 루프를 검토할 때는 긴 컨텍스트를 `to
157
163
  })
158
164
  ```
159
165
 
166
+ 원격 reply를 semantic turn으로 넣어야 할 때:
167
+
168
+ ```text
169
+ deliberation_ingest_remote_reply(
170
+ session_id: "<session_id>",
171
+ speaker: "<speaker>",
172
+ turn_id: "<pending_turn_id>",
173
+ content: "<reply markdown>",
174
+ source_machine_id: "peer-01",
175
+ source_session_id: "remote-gemini-001",
176
+ transport_scope: "remote_mcp"
177
+ )
178
+ ```
179
+
160
180
  ### D. 자동 진행 (스크립트)
161
181
  ```bash
162
182
  # 새 토론
@@ -233,3 +253,36 @@ bash deliberation-monitor.sh --tmux
233
253
 
234
254
  - **deliberation-gate**: superpowers 워크플로우 통합 스킬. brainstorming/code-review/debugging 의사결정 지점에 멀티-AI 검증 게이트를 삽입합니다. `~/.claude/skills/deliberation-gate/SKILL.md`에 설치.
235
255
  - **deliberation-executor**: deliberation 합의안을 실제 코드 구현으로 전환하는 실행 전용 스킬.
256
+
257
+ ## Data Model: Canonical Roles
258
+
259
+ Deliberation produces two complementary data artifacts after synthesis:
260
+
261
+ | Artifact | Role | Consumers |
262
+ |----------|------|-----------|
263
+ | `structured_synthesis` | **Human + reasoning canonical** | Human reviewers, LLM context, decision history |
264
+ | `execution_contract` | **Automation canonical** | inbox-watcher, devkit, registry, orchestrator agents |
265
+
266
+ ### structured_synthesis (Human Canonical)
267
+ Rich context for human review. Contains:
268
+ - `summary`: natural language overview
269
+ - `decisions`: reasoning and rationale
270
+ - `actionable_tasks`: full task descriptions with context
271
+ - `experiment_outcome`: optional verdict (keep/discard/modify)
272
+
273
+ ### execution_contract (Automation Canonical)
274
+ Minimal, deterministic task list for machines. Contains:
275
+ - `version`: contract schema version (currently "v1")
276
+ - `source_session_id`: originating deliberation session
277
+ - `summary`: brief summary for log context
278
+ - `tasks`: flattened actionable task list (same shape as `actionable_tasks`)
279
+ - `generated_from.structured_synthesis_hash`: SHA-1 provenance hash
280
+
281
+ **Rule**: Automation consumers MUST prefer `execution_contract` when present.
282
+ Fall back to `structured_synthesis` only when `execution_contract` is `null`.
283
+
284
+ ### Archive Outputs
285
+ When a deliberation completes:
286
+ 1. **Markdown archive**: `~/.local/lib/mcp-deliberation/state/{project}/archive/deliberation-{ts}-{slug}.md`
287
+ 2. **Contract sidecar**: `~/.local/lib/mcp-deliberation/state/{project}/archive/deliberation-{ts}-{slug}.contract.json`
288
+ 3. **Telepty envelope**: `deliberation_completed` event with both artifacts in payload