@dmsdc-ai/aigentry-deliberation 0.0.45 → 0.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -69,6 +69,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
69
69
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
70
70
  import { z } from "zod";
71
71
  import { execFileSync, spawn } from "child_process";
72
+ import crypto from "crypto";
72
73
  import fs from "fs";
73
74
  import path from "path";
74
75
  import { fileURLToPath } from "url";
@@ -112,6 +113,7 @@ import {
112
113
  clearSpeakerSelectionToken,
113
114
  validateSpeakerSelectionSnapshot,
114
115
  confirmSpeakerSelectionToken,
116
+ markSelectionTokenConsumed,
115
117
  validateSpeakerSelectionRequest,
116
118
  // Browser participant helpers
117
119
  hasExplicitBrowserParticipantSelection,
@@ -234,6 +236,9 @@ import {
234
236
  } from "./decision-engine.js";
235
237
  import { detectLang, t } from "./i18n.js";
236
238
  import { checkToolEntitlement } from "./lib/entitlement.js";
239
+ // δ2 (#440) — telemetry emit wrapper. emit-skip-with-warning when role
240
+ // is unset; failures swallowed; never blocks the synthesis path.
241
+ import { emitSynthesisEvent, emitHandoffEvent } from "./logger-emit.js";
237
242
  import {
238
243
  initTeleptyDeps,
239
244
  // Schemas
@@ -406,6 +411,179 @@ function getSpeakerSelectionFile(projectSlug = getProjectSlug()) {
406
411
  return path.join(getProjectStateDir(projectSlug), SPEAKER_SELECTION_FILE);
407
412
  }
408
413
 
414
+ // ── ADR-264 helpers ─────────────────────────────────────────────
415
+
416
+ // §2.2 Proxy Response Submission: verify external_output via SHA-256 digest
417
+ // so an orchestrator that pre-spawned a CLI/browser can submit responses
418
+ // without the server re-running the CLI. `verify: "none"` + source
419
+ // "trusted_orchestrator" is an explicit opt-out used only at a trust boundary.
420
+ export function verifyExternalOutputProof({
421
+ external_output,
422
+ external_output_proof,
423
+ verify = "hash",
424
+ } = {}) {
425
+ if (typeof external_output !== "string" || external_output.length === 0) {
426
+ return { ok: false, code: "E_EXTERNAL_OUTPUT_MISSING" };
427
+ }
428
+
429
+ if (verify === "none") {
430
+ if (external_output_proof?.source !== "trusted_orchestrator") {
431
+ return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_SOURCE_REQUIRED" };
432
+ }
433
+ return {
434
+ ok: true,
435
+ audit: {
436
+ source: "trusted_orchestrator",
437
+ verify: "none",
438
+ length: Buffer.byteLength(external_output, "utf-8"),
439
+ },
440
+ };
441
+ }
442
+
443
+ if (!external_output_proof || typeof external_output_proof !== "object") {
444
+ return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_MISSING" };
445
+ }
446
+ if (external_output_proof.algo !== "sha256") {
447
+ return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_ALGO_UNSUPPORTED", algo: external_output_proof.algo };
448
+ }
449
+ const expected = String(external_output_proof.digest || "").toLowerCase();
450
+ const actual = crypto.createHash("sha256").update(external_output, "utf-8").digest("hex");
451
+ if (expected !== actual) {
452
+ return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_MISMATCH", expected, actual };
453
+ }
454
+ return {
455
+ ok: true,
456
+ audit: {
457
+ source: external_output_proof.source || "unspecified",
458
+ verify: "hash",
459
+ digest: actual,
460
+ },
461
+ };
462
+ }
463
+
464
+ // §2.3 step 5 — reuse the same safeId regex as withSessionLock (lib/speaker-discovery
465
+ // invariant: only a-z A-Z 0-9 가-힣 . _ - survive). Prevents `../`, `/`, and
466
+ // null-byte path traversal when a caller supplies session_id.
467
+ export function sanitizeSessionDirName(sessionId) {
468
+ return String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
469
+ }
470
+
471
+ // §2.3 steps 1-4, 7, 8 — resolve and audit an optional spawn_cwd against a
472
+ // prefix allowlist. Symlinks are allowed only if the realpath still satisfies
473
+ // the prefix check. Caller passes `allowedPrefixes` derived from
474
+ // ~/.config/mcp-deliberation/allowed-cwd-prefixes.json when present; otherwise
475
+ // defaults to $HOME + $TMPDIR so bench harnesses in /tmp work out of the box.
476
+ export function validateSpawnCwd(input, { allowedPrefixes } = {}) {
477
+ if (typeof input !== "string" || input.length === 0) {
478
+ return { ok: false, code: "E_CWD_NOT_ABSOLUTE", input };
479
+ }
480
+ if (!path.isAbsolute(input)) {
481
+ return { ok: false, code: "E_CWD_NOT_ABSOLUTE", input };
482
+ }
483
+
484
+ let resolved;
485
+ try {
486
+ resolved = fs.realpathSync(input);
487
+ } catch (err) {
488
+ const code = err && err.code === "ENOENT" ? "E_CWD_NOT_FOUND" : "E_CWD_NOT_FOUND";
489
+ return { ok: false, code, input, error: err?.message };
490
+ }
491
+
492
+ const prefixesUnresolved = Array.isArray(allowedPrefixes) && allowedPrefixes.length > 0
493
+ ? allowedPrefixes
494
+ : [os.homedir(), os.tmpdir()];
495
+
496
+ // Resolve the prefixes themselves so callers can pass "/tmp" and still match
497
+ // realpath'd children like "/private/tmp/foo" on macOS.
498
+ const prefixesResolved = prefixesUnresolved.map((p) => {
499
+ try { return fs.realpathSync(p); } catch { return p; }
500
+ });
501
+ const prefixesAll = Array.from(new Set([...prefixesUnresolved, ...prefixesResolved]));
502
+
503
+ const matches = (candidate, prefixList) =>
504
+ prefixList.some((prefix) =>
505
+ candidate === prefix || candidate.startsWith(prefix + path.sep),
506
+ );
507
+
508
+ const inputLooksAllowed = matches(input, prefixesAll);
509
+ const resolvedLooksAllowed = matches(resolved, prefixesResolved);
510
+ const symlinkCrossed = resolved !== input;
511
+
512
+ // §2.3 step 3 — input path itself must be under an allowed prefix, so
513
+ // passing "/etc/passwd" when tmpRoot is allowed immediately fails.
514
+ if (!inputLooksAllowed) {
515
+ return { ok: false, code: "E_CWD_NOT_ALLOWED", input, resolved, allowed_prefixes: prefixesResolved };
516
+ }
517
+
518
+ // §2.3 step 4 — input looked allowed, but realpath escapes → symlink attack.
519
+ if (!resolvedLooksAllowed) {
520
+ return { ok: false, code: "E_CWD_SYMLINK_ESCAPE", input, resolved, allowed_prefixes: prefixesResolved };
521
+ }
522
+
523
+ return { ok: true, input, resolved, allowed_prefixes: prefixesResolved, symlink_crossed: symlinkCrossed };
524
+ }
525
+
526
+ // §2.3 step 6 — create a per-session subdir under the resolved cwd so two
527
+ // concurrent cli_auto_turn calls do not share working state. Idempotent when
528
+ // the caller has already pointed at a matching session folder.
529
+ export function ensureSessionSubdir(resolvedCwd, sessionId) {
530
+ let safe = sanitizeSessionDirName(sessionId);
531
+ // `.` and `..` survive the ADR-specified regex but would climb out of
532
+ // resolvedCwd when joined. Rewrite to a literal segment so the subdir is
533
+ // always strictly nested.
534
+ if (safe === "." || safe === "..") {
535
+ safe = `_${safe.length === 2 ? "dotdot" : "dot"}`;
536
+ }
537
+ if (path.basename(resolvedCwd) === safe) {
538
+ return resolvedCwd;
539
+ }
540
+ const target = path.join(resolvedCwd, safe);
541
+ fs.mkdirSync(target, { recursive: true });
542
+ return target;
543
+ }
544
+
545
+ // §2.4 — normalize observability data into the {value, status} envelope that
546
+ // downstream aggregators skip (status !== "ok") before arithmetic. Fields not
547
+ // provided default to `adapter_missing`.
548
+ const OBS_FIELDS = [
549
+ "tokens_in",
550
+ "tokens_out",
551
+ "estimated_cost_usd",
552
+ "model_reported_by_cli",
553
+ "actual_model_id",
554
+ ];
555
+
556
+ export function buildObservabilityEnvelope(adapter) {
557
+ const src = adapter && typeof adapter === "object" ? adapter : {};
558
+ const out = {};
559
+ for (const field of OBS_FIELDS) {
560
+ const entry = src[field];
561
+ if (entry && typeof entry === "object" && "status" in entry) {
562
+ out[field] = { value: entry.value ?? null, status: String(entry.status) };
563
+ } else {
564
+ out[field] = { value: null, status: "adapter_missing" };
565
+ }
566
+ }
567
+ return out;
568
+ }
569
+
570
+ function loadAllowedCwdPrefixes() {
571
+ const overridePath = path.join(os.homedir(), ".config", "mcp-deliberation", "allowed-cwd-prefixes.json");
572
+ try {
573
+ const raw = fs.readFileSync(overridePath, "utf-8");
574
+ const parsed = JSON.parse(raw);
575
+ if (parsed && Array.isArray(parsed.prefixes) && parsed.prefixes.every((p) => typeof p === "string")) {
576
+ return parsed.prefixes;
577
+ }
578
+ appendRuntimeLog("WARN", `ALLOWED_CWD_PREFIXES_SCHEMA: ${overridePath} lacks { prefixes: string[] }, falling back to defaults`);
579
+ } catch (err) {
580
+ if (err && err.code !== "ENOENT") {
581
+ appendRuntimeLog("WARN", `ALLOWED_CWD_PREFIXES_LOAD: ${overridePath} parse failed (${err.message}), falling back to defaults`);
582
+ }
583
+ }
584
+ return null;
585
+ }
586
+
409
587
  function formatRuntimeError(error) {
410
588
  if (error instanceof Error) {
411
589
  return error.stack || error.message;
@@ -929,11 +1107,21 @@ server.tool(
929
1107
  const manualSpeakersProvided = Array.isArray(speakers) && speakers.length > 0;
930
1108
  let selectionValidation = { ok: true };
931
1109
  if (effectiveRequireManual && manualSpeakersProvided) {
932
- selectionValidation = validateSpeakerSelectionRequest({
933
- selectionState: loadSpeakerSelectionToken(),
934
- selection_token,
935
- speakers,
936
- includeBrowserSpeakers,
1110
+ // ADR-264 §2.1 — read-validate-consume wrapped in withProjectLock so two
1111
+ // MCP server processes racing on the same speaker-selection.json cannot
1112
+ // both observe the same token as unconsumed.
1113
+ selectionValidation = withProjectLock(getProjectSlug(), () => {
1114
+ const persistedState = loadSpeakerSelectionToken();
1115
+ const result = validateSpeakerSelectionRequest({
1116
+ selectionState: persistedState,
1117
+ selection_token,
1118
+ speakers,
1119
+ includeBrowserSpeakers,
1120
+ });
1121
+ if (result.ok) {
1122
+ markSelectionTokenConsumed({ selectionState: persistedState });
1123
+ }
1124
+ return result;
937
1125
  });
938
1126
  }
939
1127
  const hasManualSpeakers = manualSpeakersProvided && (!effectiveRequireManual || selectionValidation.ok);
@@ -1001,9 +1189,10 @@ server.tool(
1001
1189
  || DEFAULT_SPEAKERS[0];
1002
1190
  let speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
1003
1191
 
1004
- if (effectiveRequireManual) {
1005
- clearSpeakerSelectionToken();
1006
- }
1192
+ // ADR-264 §2.1 — selection token has already been stamped `consumed_at`
1193
+ // inside withProjectLock above. Leaving the file in place with the tombstone
1194
+ // lets any racing caller surface `token_already_consumed` instead of the
1195
+ // more ambiguous `missing_selection_state`. TTL garbage-collects it.
1007
1196
 
1008
1197
  // Lite mode: cap speakers and rounds for quick decisions
1009
1198
  if (mode === "lite") {
@@ -1166,9 +1355,21 @@ server.tool(
1166
1355
  },
1167
1356
  async ({ include_cli, include_browser }) => {
1168
1357
  const snapshot = await collectSpeakerCandidates({ include_cli, include_browser });
1169
- const selection = issueSpeakerSelectionToken({
1170
- candidates: snapshot.candidates,
1171
- include_browser,
1358
+ // ADR-264 §2.1 — issue inside withProjectLock and warn when overwriting a
1359
+ // confirmed-but-not-consumed state so racing consumers cannot silently
1360
+ // invalidate a token already promised to a start call in flight.
1361
+ const selection = withProjectLock(getProjectSlug(), () => {
1362
+ const existing = loadSpeakerSelectionToken();
1363
+ if (existing?.phase === "confirmed" && !existing.consumed_at) {
1364
+ appendRuntimeLog(
1365
+ "WARN",
1366
+ `SELECTION_OVERWRITE_CONFIRMED: project=${getProjectSlug()} | token=${existing.token} | selected=${(existing.selected_speakers || []).join(",")}`,
1367
+ );
1368
+ }
1369
+ return issueSpeakerSelectionToken({
1370
+ candidates: snapshot.candidates,
1371
+ include_browser,
1372
+ });
1172
1373
  });
1173
1374
  const text = formatSpeakerCandidatesReport(snapshot);
1174
1375
  return {
@@ -1639,8 +1840,14 @@ server.tool(
1639
1840
  {
1640
1841
  session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
1641
1842
  timeout_sec: z.number().optional().default(120).describe("CLI response wait timeout (seconds)"),
1843
+ // ADR-264 §2.3 — optional working directory for the spawned CLI. Validated
1844
+ // against an allowlist (default: $HOME + $TMPDIR; override via
1845
+ // ~/.config/mcp-deliberation/allowed-cwd-prefixes.json). The server creates
1846
+ // a session-unique subdirectory under the resolved cwd so concurrent
1847
+ // cli_auto_turn calls do not share working files.
1848
+ spawn_cwd: z.string().optional().describe("Absolute path under an allowed prefix to use as CLI working directory. Used to isolate project CLAUDE.md/GEMINI.md context from the server's cwd."),
1642
1849
  },
1643
- safeToolHandler("deliberation_cli_auto_turn", async ({ session_id, timeout_sec }) => {
1850
+ safeToolHandler("deliberation_cli_auto_turn", async ({ session_id, timeout_sec, spawn_cwd }) => {
1644
1851
  const resolved = resolveSessionId(session_id);
1645
1852
  if (!resolved) {
1646
1853
  return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
@@ -1701,6 +1908,34 @@ server.tool(
1701
1908
  priorTurns: speakerPriorTurns,
1702
1909
  });
1703
1910
 
1911
+ // ADR-264 §2.3 — validate optional spawn_cwd and allocate a session-unique
1912
+ // subdirectory before we spawn the child. Structured error codes (E_CWD_*)
1913
+ // surface via the return envelope so orchestrators can diagnose misuse.
1914
+ let resolvedSpawnCwd;
1915
+ if (typeof spawn_cwd === "string" && spawn_cwd.length > 0) {
1916
+ const override = loadAllowedCwdPrefixes();
1917
+ const cwdCheck = validateSpawnCwd(spawn_cwd, override ? { allowedPrefixes: override } : undefined);
1918
+ if (!cwdCheck.ok) {
1919
+ appendRuntimeLog(
1920
+ "WARN",
1921
+ `SPAWN_CWD_REJECTED: ${resolved} | speaker=${speaker} | code=${cwdCheck.code} | input=${cwdCheck.input} | resolved=${cwdCheck.resolved || "n/a"}`,
1922
+ );
1923
+ return {
1924
+ content: [{
1925
+ type: "text",
1926
+ text: `❌ **spawn_cwd rejected**: \`${cwdCheck.code}\`\n\nInput: \`${cwdCheck.input}\`\nResolved: \`${cwdCheck.resolved || "n/a"}\`\nAllowed prefixes: ${(cwdCheck.allowed_prefixes || []).map((p) => `\`${p}\``).join(", ") || "(none)"}\n\nProvide an absolute path under one of the allowed prefixes, or configure \`~/.config/mcp-deliberation/allowed-cwd-prefixes.json\`.`,
1927
+ }],
1928
+ };
1929
+ }
1930
+ if (cwdCheck.symlink_crossed) {
1931
+ appendRuntimeLog(
1932
+ "WARN",
1933
+ `SPAWN_CWD_SYMLINK: ${resolved} | speaker=${speaker} | input=${cwdCheck.input} | resolved=${cwdCheck.resolved}`,
1934
+ );
1935
+ }
1936
+ resolvedSpawnCwd = ensureSessionSubdir(cwdCheck.resolved, resolved);
1937
+ }
1938
+
1704
1939
  // Spawn CLI process
1705
1940
  const startTime = Date.now();
1706
1941
  try {
@@ -1710,6 +1945,8 @@ server.tool(
1710
1945
  if (hint.envPrefix?.includes("CLAUDECODE=")) {
1711
1946
  delete env.CLAUDECODE;
1712
1947
  }
1948
+ const spawnOpts = { env, windowsHide: true };
1949
+ if (resolvedSpawnCwd) spawnOpts.cwd = resolvedSpawnCwd;
1713
1950
 
1714
1951
  let child;
1715
1952
  let stdout = "";
@@ -1733,22 +1970,22 @@ server.tool(
1733
1970
  // Different invocation patterns per CLI
1734
1971
  switch (speaker) {
1735
1972
  case "claude":
1736
- child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
1973
+ child = spawn("claude", getCliExecArgs("claude"), spawnOpts);
1737
1974
  child.stdin.write(turnPrompt);
1738
1975
  child.stdin.end();
1739
1976
  break;
1740
1977
  case "codex":
1741
- child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
1978
+ child = spawn("codex", getCliExecArgs("codex"), spawnOpts);
1742
1979
  child.stdin.write(turnPrompt);
1743
1980
  child.stdin.end();
1744
1981
  break;
1745
1982
  case "gemini":
1746
- child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
1983
+ child = spawn("gemini", ["-p", turnPrompt], spawnOpts);
1747
1984
  break;
1748
1985
  default: {
1749
1986
  // Generic: try command with prompt as argument
1750
1987
  const flags = hint.flags ? hint.flags.split(/\s+/) : [];
1751
- child = spawn(hint.cmd, [...flags, turnPrompt], { env, windowsHide: true });
1988
+ child = spawn(hint.cmd, [...flags, turnPrompt], spawnOpts);
1752
1989
  break;
1753
1990
  }
1754
1991
  }
@@ -1828,11 +2065,26 @@ server.tool(
1828
2065
  fallback_reason: null,
1829
2066
  });
1830
2067
 
2068
+ // ADR-264 §2.4 — Phase 0 observability envelope. Per-CLI adapters
2069
+ // (claude/codex/gemini) that parse usage from stdout are a follow-up;
2070
+ // until then every field reports `adapter_missing` so downstream
2071
+ // aggregators can skip non-`ok` entries without NaN propagation.
2072
+ const observability = buildObservabilityEnvelope(null);
2073
+
1831
2074
  return {
1832
2075
  content: [{
1833
2076
  type: "text",
1834
- text: `✅ CLI auto-turn complete!\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n**Turn ID:** ${turnId}\n**Response length:** ${response.length} chars\n**Elapsed:** ${elapsedMs}ms\n\n${result.content[0].text}`,
2077
+ text: `✅ CLI auto-turn complete!\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n**Turn ID:** ${turnId}\n**Response length:** ${response.length} chars\n**Elapsed:** ${elapsedMs}ms${resolvedSpawnCwd ? `\n**spawn_cwd:** \`${resolvedSpawnCwd}\`` : ""}\n\n**Observability (ADR-264 §2.4, Phase 0):**\n- tokens_in: ${observability.tokens_in.status}\n- tokens_out: ${observability.tokens_out.status}\n- estimated_cost_usd: ${observability.estimated_cost_usd.status}\n- model_reported_by_cli: ${observability.model_reported_by_cli.status}\n- actual_model_id: ${observability.actual_model_id.status}\n\n${result.content[0].text}`,
1835
2078
  }],
2079
+ structuredContent: {
2080
+ speaker,
2081
+ cli: hint.cmd,
2082
+ turn_id: turnId,
2083
+ response_length: response.length,
2084
+ elapsed_ms: elapsedMs,
2085
+ spawn_cwd: resolvedSpawnCwd ?? null,
2086
+ ...observability,
2087
+ },
1836
2088
  };
1837
2089
 
1838
2090
  } catch (err) {
@@ -1915,10 +2167,22 @@ server.tool(
1915
2167
  use_clipboard: z.boolean().optional().describe("Read content from system clipboard (alternative to content/content_file)"),
1916
2168
  include_clipboard_image: z.boolean().optional().describe("Capture and include image from system clipboard"),
1917
2169
  turn_id: z.string().optional().describe("Turn verification ID (value received from deliberation_route_turn)"),
2170
+ // ADR-264 §2.2 — optional proxy submission. An orchestrator that pre-spawned
2171
+ // the CLI/browser itself can submit the collected response here instead of
2172
+ // asking the server to re-run the CLI. Default behavior (fields absent) is
2173
+ // unchanged: proxy is blocked.
2174
+ external_output: z.string().optional().describe("Pre-collected response body that the orchestrator obtained from a CLI/browser it spawned itself. Requires external_output_proof."),
2175
+ external_output_proof: z.object({
2176
+ algo: z.literal("sha256"),
2177
+ digest: z.string().describe("Lowercase hex sha256 digest of external_output bytes."),
2178
+ source: z.enum(["cli_stdout", "browser_dom", "trusted_orchestrator"]),
2179
+ }).optional().describe("Proof payload for external_output. sha256 of the raw UTF-8 bytes."),
2180
+ verify: z.enum(["hash", "none"]).optional().default("hash").describe("Proof verification mode. 'none' requires source=trusted_orchestrator and logs an explicit audit entry."),
1918
2181
  },
1919
- safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, use_clipboard, include_clipboard_image, turn_id }) => {
2182
+ safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, use_clipboard, include_clipboard_image, turn_id, external_output, external_output_proof, verify }) => {
1920
2183
  // Guard: prevent orchestrator from fabricating responses for CLI/browser speakers
1921
2184
  const resolved = resolveSessionId(session_id);
2185
+ let externalAcceptedContent = null;
1922
2186
  if (resolved && resolved !== "MULTIPLE") {
1923
2187
  const state = loadSession(resolved);
1924
2188
  if (state) {
@@ -1928,22 +2192,55 @@ server.tool(
1928
2192
  const callerSpeaker = detectCallerSpeaker();
1929
2193
  const callerIsSpeaker = callerSpeaker && (speaker === callerSpeaker);
1930
2194
  if (!callerIsSpeaker) {
1931
- return {
1932
- content: [{
1933
- type: "text",
1934
- text: t(
1935
- `⚠️ **Proxy response blocked**: Speaker "${speaker}" has ${transport} transport.\n\nThe orchestrator is not allowed to write responses on behalf of other speakers.\nUse the following tools instead:\n- CLI speaker → \`deliberation_route_turn\` or \`deliberation_cli_auto_turn\`\n- Browser speaker → \`deliberation_route_turn\` or \`deliberation_browser_auto_turn\`\n\nThese tools run the actual CLI/browser to collect genuine responses.`,
1936
- `⚠️ **대리 응답 차단**: speaker "${speaker}"는 ${transport} transport입니다.\n\n오케스트레이터가 다른 speaker를 대신하여 응답을 작성하는 것은 허용되지 않습니다.\n대신 다음 도구를 사용하세요:\n- CLI speaker → \`deliberation_route_turn\` 또는 \`deliberation_cli_auto_turn\`\n- 브라우저 speaker → \`deliberation_route_turn\` 또는 \`deliberation_browser_auto_turn\`\n\n이 도구들이 실제 CLI/브라우저를 실행하여 진짜 응답을 수집합니다.`,
1937
- state?.lang),
1938
- }],
1939
- };
2195
+ // ADR-264 §2.2 — accept pre-collected external_output when a proof
2196
+ // is supplied. Failed verification surfaces a structured error code
2197
+ // instead of falling back to the opaque "proxy blocked" message.
2198
+ if (typeof external_output === "string" && external_output.length > 0) {
2199
+ const proof = verifyExternalOutputProof({ external_output, external_output_proof, verify });
2200
+ if (proof.ok) {
2201
+ externalAcceptedContent = external_output;
2202
+ appendRuntimeLog(
2203
+ "INFO",
2204
+ `EXTERNAL_OUTPUT_ACCEPTED: ${resolved} | speaker=${speaker} | transport=${transport} | source=${proof.audit?.source} | verify=${proof.audit?.verify} | len=${external_output.length}`,
2205
+ );
2206
+ } else {
2207
+ const digestDetail = proof.expected
2208
+ ? `\n\nExpected digest: ${proof.expected}\nActual digest: ${proof.actual}`
2209
+ : "";
2210
+ appendRuntimeLog(
2211
+ "WARN",
2212
+ `EXTERNAL_OUTPUT_REJECTED: ${resolved} | speaker=${speaker} | code=${proof.code}${proof.expected ? ` | expected=${proof.expected} | actual=${proof.actual}` : ""}`,
2213
+ );
2214
+ return {
2215
+ content: [{
2216
+ type: "text",
2217
+ text: t(
2218
+ `⚠️ **Proxy response rejected**: \`${proof.code}\`\n\nThe supplied external_output did not pass verification (verify=\"${verify || "hash"}\").${digestDetail}\n\nSupply a correct sha256 proof, or call \`deliberation_cli_auto_turn\` / \`deliberation_browser_auto_turn\` to let the server run the transport.`,
2219
+ `⚠️ **대리 응답 거부**: \`${proof.code}\`\n\n제공된 external_output 검증 실패 (verify=\"${verify || "hash"}\").${digestDetail}\n\n올바른 sha256 proof를 제공하거나, \`deliberation_cli_auto_turn\` / \`deliberation_browser_auto_turn\` 으로 서버가 직접 transport를 실행하게 하세요.`,
2220
+ state?.lang),
2221
+ }],
2222
+ };
2223
+ }
2224
+ } else {
2225
+ return {
2226
+ content: [{
2227
+ type: "text",
2228
+ text: t(
2229
+ `⚠️ **Proxy response blocked**: Speaker "${speaker}" has ${transport} transport.\n\nThe orchestrator is not allowed to write responses on behalf of other speakers.\nUse the following tools instead:\n- CLI speaker → \`deliberation_route_turn\` or \`deliberation_cli_auto_turn\`\n- Browser speaker → \`deliberation_route_turn\` or \`deliberation_browser_auto_turn\`\n\nThese tools run the actual CLI/browser to collect genuine responses.\n\nAlternatively, the orchestrator can submit a response it spawned itself via \`external_output\` + \`external_output_proof\` (sha256 hex of the UTF-8 bytes).`,
2230
+ `⚠️ **대리 응답 차단**: speaker "${speaker}"는 ${transport} transport입니다.\n\n오케스트레이터가 다른 speaker를 대신하여 응답을 작성하는 것은 허용되지 않습니다.\n대신 다음 도구를 사용하세요:\n- CLI speaker → \`deliberation_route_turn\` 또는 \`deliberation_cli_auto_turn\`\n- 브라우저 speaker → \`deliberation_route_turn\` 또는 \`deliberation_browser_auto_turn\`\n\n이 도구들이 실제 CLI/브라우저를 실행하여 진짜 응답을 수집합니다.\n\n또는 오케스트레이터가 직접 spawn한 결과는 \`external_output\` + \`external_output_proof\` (sha256 hex) 로 제출할 수 있습니다.`,
2231
+ state?.lang),
2232
+ }],
2233
+ };
2234
+ }
1940
2235
  }
1941
2236
  }
1942
2237
  }
1943
2238
  }
1944
2239
 
1945
2240
  // Support reading content from file or clipboard to avoid JSON escaping issues
1946
- let finalContent = content;
2241
+ // ADR-264 §2.2 — external_output (when verified) takes precedence so proxy
2242
+ // submissions don't conflict with stale content/content_file values.
2243
+ let finalContent = externalAcceptedContent ?? content;
1947
2244
  if (use_clipboard && !content) {
1948
2245
  try {
1949
2246
  finalContent = readClipboardText();
@@ -2263,6 +2560,27 @@ server.tool(
2263
2560
  }
2264
2561
 
2265
2562
  appendRuntimeLog("INFO", `SYNTHESIZED: ${resolved} | turns: ${state.log.length} | rounds: ${state.max_rounds}`);
2563
+ emitSynthesisEvent(
2564
+ {
2565
+ session: resolved,
2566
+ turns: state.log.length,
2567
+ max_rounds: state.max_rounds,
2568
+ speakers: state.speakers || [],
2569
+ auto_execute: !!state.auto_execute,
2570
+ has_structured: !!structured,
2571
+ },
2572
+ resolved,
2573
+ );
2574
+ if (state.execution_contract) {
2575
+ emitHandoffEvent(
2576
+ {
2577
+ session: resolved,
2578
+ tasks_total: state.execution_contract?.tasks?.length ?? 0,
2579
+ auto_execute: !!state.auto_execute,
2580
+ },
2581
+ resolved,
2582
+ );
2583
+ }
2266
2584
  const synthesisEnvelope = buildTeleptySynthesisEnvelope({
2267
2585
  state,
2268
2586
  synthesis,
@@ -3255,4 +3573,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
3255
3573
  }
3256
3574
 
3257
3575
  // ── Test exports (used by vitest) ──
3258
- export { appendRuntimeLog, _flushDedupToFile, _isBrokenStdioError, checkToolEntitlement, 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 };
3576
+ export { appendRuntimeLog, _flushDedupToFile, _isBrokenStdioError, checkToolEntitlement, 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, markSelectionTokenConsumed, 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
@@ -41,6 +41,7 @@ function toForwardSlash(p) {
41
41
 
42
42
  const FILES_TO_COPY = [
43
43
  "index.js",
44
+ "logger-emit.js",
44
45
  "observer.js",
45
46
  "clipboard.js",
46
47
  "decision-engine.js",
package/lib/session.js CHANGED
@@ -30,6 +30,9 @@ import {
30
30
  normalizeSessionActors,
31
31
  } from "./speaker-discovery.js";
32
32
  import { t } from "../i18n.js";
33
+ // δ2 (#440) — telemetry emit wrapper. emit-skip-with-warning when role
34
+ // is unset; failures swallowed; never blocks the turn-submission path.
35
+ import { emitTurnEvent } from "../logger-emit.js";
33
36
 
34
37
  // ── Dependency injection ────────────────────────────────────────
35
38
  // Functions that live in index.js but are needed here. Injected once
@@ -633,6 +636,18 @@ export function submitDeliberationTurn({ session_id, speaker, content, turn_id,
633
636
  });
634
637
 
635
638
  if (completionState && completionEntry) {
639
+ emitTurnEvent(
640
+ "turn_complete",
641
+ {
642
+ session: completionState.id,
643
+ speaker: completionEntry.speaker,
644
+ round: completionEntry.round,
645
+ max_rounds: completionState.max_rounds,
646
+ next_speaker: completionState.current_speaker,
647
+ votes_count: Array.isArray(completionEntry.votes) ? completionEntry.votes.length : 0,
648
+ },
649
+ completionEntry.turn_id || undefined,
650
+ );
636
651
  const envelope = buildTeleptyTurnCompletedEnvelope({ state: completionState, entry: completionEntry });
637
652
  notifyTeleptyBus(envelope).catch(() => {});
638
653
 
@@ -106,6 +106,8 @@ export const CLI_INVOCATION_HINTS = {
106
106
  claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', defaultModel: null, provider: 'claude' },
107
107
  codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '-m', defaultModel: 'gpt-5.4', provider: 'chatgpt' },
108
108
  gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '-m', defaultModel: null, provider: 'gemini' },
109
+ grok: { cmd: "grok", flags: '-p', example: 'grok -p "prompt"', modelFlag: '-m', defaultModel: null, provider: 'grok' },
110
+ agy: { cmd: "agy", flags: '-p', example: 'agy -p "prompt"', modelFlag: '--model', defaultModel: null, provider: 'gemini' },
109
111
  aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
110
112
  cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
111
113
  };
@@ -379,6 +381,12 @@ export function validateSpeakerSelectionSnapshot({ selectionState, selection_tok
379
381
  return { ok: false, code: "token_mismatch" };
380
382
  }
381
383
 
384
+ // ADR-264 §2.1 — token_already_consumed precedes TTL/mode checks so the
385
+ // caller can distinguish "was already used" from "expired" or "wrong mode".
386
+ if (selectionState.consumed_at) {
387
+ return { ok: false, code: "token_already_consumed", consumed_at: selectionState.consumed_at };
388
+ }
389
+
382
390
  const createdAtMs = Date.parse(selectionState.created_at || "");
383
391
  if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
384
392
  return { ok: false, code: "expired_token" };
@@ -424,6 +432,21 @@ export function confirmSpeakerSelectionToken({ selectionState, selection_token,
424
432
  return { ok: true, selectionState: confirmedSelection };
425
433
  }
426
434
 
435
+ // ADR-264 §2.1 — atomic consume: stamp `consumed_at` onto the confirmed state
436
+ // file so cross-process races surface as `token_already_consumed` instead of
437
+ // allowing silent double-start. Callers MUST wrap the read+validate+mark
438
+ // sequence in `withProjectLock` (index.js) to be cross-process safe.
439
+ export function markSelectionTokenConsumed({ selectionState, nowMs = Date.now(), persist = true }) {
440
+ if (!selectionState || typeof selectionState !== "object") {
441
+ return selectionState;
442
+ }
443
+ const consumed = { ...selectionState, consumed_at: new Date(nowMs).toISOString() };
444
+ if (persist) {
445
+ _deps.writeJsonFileAtomic(_deps.getSpeakerSelectionFile(), consumed);
446
+ }
447
+ return consumed;
448
+ }
449
+
427
450
  export function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
428
451
  const snapshotValidation = validateSpeakerSelectionSnapshot({
429
452
  selectionState,
package/lib/transport.js CHANGED
@@ -89,6 +89,59 @@ export function tmuxWindowName(sessionId) {
89
89
  return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
90
90
  }
91
91
 
92
+ // #610 — Resolve the tmux window TARGET for select-window by INDEX rather than by
93
+ // name. Window names may contain Hangul (가-힣, see tmuxWindowName), and when the
94
+ // select-window target string is injected through the
95
+ // osascript -> Terminal.app -> zsh -> tmux path the non-ASCII bytes get corrupted,
96
+ // so `select-window -t "deliberation:<corrupted>"` never matches. The window INDEX
97
+ // is pure ASCII digits and survives that path intact. node reads window names back
98
+ // correctly (no osascript layer), so we map name -> index here.
99
+
100
+ // Pure: pick the index whose window name matches `windowName` from the raw output
101
+ // of `tmux list-windows -F '#{window_index}\t#{window_name}'`. Returns null when
102
+ // the window is not present.
103
+ export function parseTmuxWindowIndex(listWindowsOutput, windowName) {
104
+ for (const line of String(listWindowsOutput).split("\n")) {
105
+ const tabIdx = line.indexOf("\t");
106
+ if (tabIdx === -1) continue;
107
+ const index = line.slice(0, tabIdx).trim();
108
+ const name = line.slice(tabIdx + 1).trim();
109
+ if (name === windowName && /^\d+$/.test(index)) {
110
+ return Number.parseInt(index, 10);
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // Pure: build the select-window target ("deliberation:<index>") from a session id
117
+ // and the raw list-windows output. Falls back to the window name when the window
118
+ // is not yet listed (no regression vs. prior name-based behavior).
119
+ export function buildTmuxAttachTarget(sessionId, listWindowsOutput) {
120
+ const winName = tmuxWindowName(sessionId);
121
+ const index = parseTmuxWindowIndex(listWindowsOutput, winName);
122
+ const suffix = index !== null ? String(index) : winName;
123
+ return `${TMUX_SESSION}:${suffix}`;
124
+ }
125
+
126
+ // Impure wrapper: read the current tmux window listing. Empty string on failure
127
+ // so buildTmuxAttachTarget falls back to the window name.
128
+ export function listTmuxWindowsRaw(sessionName) {
129
+ try {
130
+ return execFileSync(
131
+ "tmux",
132
+ ["list-windows", "-t", sessionName, "-F", "#{window_index}\t#{window_name}"],
133
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
134
+ );
135
+ } catch {
136
+ return "";
137
+ }
138
+ }
139
+
140
+ // Resolve the live select-window target for a session's monitor window.
141
+ export function resolveTmuxWindowTarget(sessionId) {
142
+ return buildTmuxAttachTarget(sessionId, listTmuxWindowsRaw(TMUX_SESSION));
143
+ }
144
+
92
145
  export function appleScriptQuote(value) {
93
146
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
94
147
  }
@@ -187,10 +240,11 @@ export function tmuxWindowCount(name) {
187
240
  }
188
241
 
189
242
  export function buildTmuxAttachCommand(sessionId) {
190
- const winName = tmuxWindowName(sessionId);
243
+ // #610 target the window by INDEX (ASCII-safe), not by the possibly-Hangul name.
244
+ const winTarget = resolveTmuxWindowTarget(sessionId);
191
245
  // Use grouped session (new-session -t) so each terminal has independent active window.
192
246
  // This prevents window-switching conflicts when multiple deliberations run concurrently.
193
- return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
247
+ return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(winTarget)}`;
194
248
  }
195
249
 
196
250
  export function listPhysicalTerminalWindowIds() {
@@ -231,8 +285,12 @@ export function listPhysicalTerminalWindowIds() {
231
285
 
232
286
  export function openPhysicalTerminal(sessionId) {
233
287
  const winName = tmuxWindowName(sessionId);
288
+ // #610 — target the window by INDEX (ASCII-safe across the osascript path), not
289
+ // by the possibly-Hangul name. winName is still used below for node-side window
290
+ // matching (isTmuxWindowViewed / logging), which reads names correctly.
291
+ const winTarget = resolveTmuxWindowTarget(sessionId);
234
292
  // Use grouped session (new-session -t) for independent active window per client
235
- const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
293
+ const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${winTarget}"`;
236
294
 
237
295
  // Prevent duplicate windows for the SAME session:
238
296
  // If a client is already viewing this specific window, just activate Terminal.app
@@ -250,7 +308,7 @@ export function openPhysicalTerminal(sessionId) {
250
308
  // instead of select-window (which would hijack all attached clients' views).
251
309
  if (tmuxHasAttachedClients(TMUX_SESSION)) {
252
310
  if (process.platform === "darwin") {
253
- const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
311
+ const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${winTarget}"`;
254
312
  try {
255
313
  execFileSync(
256
314
  "osascript",
@@ -780,7 +838,23 @@ export async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
780
838
  channel_used: "cli_auto",
781
839
  });
782
840
 
783
- return { ok: true, response, elapsedMs: Date.now() - startTime };
841
+ // ADR-264 §2.4 Phase 0 observability: return envelope-ready fields so
842
+ // callers that aggregate results across transports see a uniform shape.
843
+ // CLI-specific adapters (claude/codex/gemini) are a follow-up; for now
844
+ // every field reports `adapter_missing` and downstream aggregators MUST
845
+ // skip entries where status !== "ok" before arithmetic.
846
+ return {
847
+ ok: true,
848
+ response,
849
+ elapsedMs: Date.now() - startTime,
850
+ observability: {
851
+ tokens_in: { value: null, status: "adapter_missing" },
852
+ tokens_out: { value: null, status: "adapter_missing" },
853
+ estimated_cost_usd: { value: null, status: "adapter_missing" },
854
+ model_reported_by_cli: { value: null, status: "adapter_missing" },
855
+ actual_model_id: { value: null, status: "adapter_missing" },
856
+ },
857
+ };
784
858
  } catch (err) {
785
859
  return { ok: false, error: err.message };
786
860
  }
package/logger-emit.js ADDED
@@ -0,0 +1,119 @@
1
+ // δ2 Phase 3 (#440) — deliberation emitter wrapper.
2
+ //
3
+ // Library-context emit. The deliberation MCP server is consumed by many
4
+ // session roles (orchestrator/coder/architect/...), so we cannot
5
+ // fabricate a Role when AIGENTRY_ROLE is unset: per the Phase 2 ACK
6
+ // decision (option B), we emit-skip-with-warning so telemetry stays
7
+ // clean of synthetic attribution. Warning fires once per process.
8
+ //
9
+ // Schema mapping per orchestrator A1 decision:
10
+ // spec event → ssot kind + payload.subtype
11
+ // turn_event → state-change + turn_start | turn_complete
12
+ // synthesis_event → report + synthesis
13
+ // handoff_event → report + handoff_v2
14
+ //
15
+ // ESM module (package.json "type": "module"). @dmsdc-ai/aigentry-logger is also
16
+ // ESM — direct `import` works.
17
+ //
18
+ // Failure mode (§9 독립): transport errors swallowed via try/catch.
19
+
20
+ import { emit as loggerEmit } from "@dmsdc-ai/aigentry-logger";
21
+
22
+ const VALID_ROLES = new Set([
23
+ "orchestrator",
24
+ "architect",
25
+ "coder",
26
+ "tester",
27
+ "builder",
28
+ "analyst",
29
+ "researcher",
30
+ "reviewer",
31
+ "logger",
32
+ ]);
33
+
34
+ let _warnedRoleUnset = false;
35
+ function warnOnceRoleUnset() {
36
+ if (_warnedRoleUnset) return;
37
+ _warnedRoleUnset = true;
38
+ try {
39
+ console.warn(
40
+ "[logger] emit skipped: AIGENTRY_ROLE unset (this is normal for library context)",
41
+ );
42
+ } catch (_e) {
43
+ /* noop */
44
+ }
45
+ }
46
+
47
+ export function __resetRoleWarningForTests() {
48
+ _warnedRoleUnset = false;
49
+ }
50
+
51
+ export function resolveContext(env = process.env) {
52
+ const session_id = env.AIGENTRY_SESSION_ID || `pid-${process.pid}`;
53
+ const rawRole = env.AIGENTRY_ROLE;
54
+ if (rawRole && VALID_ROLES.has(rawRole)) {
55
+ return { session_id, role: rawRole };
56
+ }
57
+ return { session_id, role: null };
58
+ }
59
+
60
+ export function emitTelemetry(input, opts = {}) {
61
+ const env = opts.env || process.env;
62
+ if (env.AIGENTRY_LOGGER_DISABLED === "1") return;
63
+ const ctx = resolveContext(env);
64
+ if (ctx.role === null) {
65
+ warnOnceRoleUnset();
66
+ return;
67
+ }
68
+ const now = opts.now || (() => new Date());
69
+ const event = {
70
+ schema_version: "1",
71
+ kind: input.kind,
72
+ session_id: ctx.session_id,
73
+ role: ctx.role,
74
+ emitted_at: now().toISOString(),
75
+ payload: input.payload || {},
76
+ };
77
+ if (input.correlation_id) event.correlation_id = input.correlation_id;
78
+ const sink = opts.__emit || loggerEmit;
79
+ try {
80
+ sink(event);
81
+ } catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ try {
84
+ console.error(`[deliberation:logger-emit] telemetry emit failed: ${msg}`);
85
+ } catch (_e) {
86
+ /* noop */
87
+ }
88
+ }
89
+ }
90
+
91
+ export function emitTurnEvent(subtype, payload, correlationId) {
92
+ emitTelemetry(
93
+ {
94
+ kind: "state-change",
95
+ payload: { subtype, ...(payload || {}) },
96
+ ...(correlationId ? { correlation_id: correlationId } : {}),
97
+ },
98
+ );
99
+ }
100
+
101
+ export function emitSynthesisEvent(payload, correlationId) {
102
+ emitTelemetry(
103
+ {
104
+ kind: "report",
105
+ payload: { subtype: "synthesis", ...(payload || {}) },
106
+ ...(correlationId ? { correlation_id: correlationId } : {}),
107
+ },
108
+ );
109
+ }
110
+
111
+ export function emitHandoffEvent(payload, correlationId) {
112
+ emitTelemetry(
113
+ {
114
+ kind: "report",
115
+ payload: { subtype: "handoff_v2", ...(payload || {}) },
116
+ ...(correlationId ? { correlation_id: correlationId } : {}),
117
+ },
118
+ );
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-deliberation",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "description": "MCP server for structured multi-AI discussions — deliberate across Claude, GPT, Gemini and more before committing to decisions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "observer.js",
39
39
  "browser-control-port.js",
40
40
  "degradation-state-machine.js",
41
+ "logger-emit.js",
41
42
  "session-monitor.sh",
42
43
  "session-monitor-win.js",
43
44
  "selectors/**",
@@ -59,6 +60,7 @@
59
60
  "release:major": "npm version major && git push && git push --tags"
60
61
  },
61
62
  "dependencies": {
63
+ "@dmsdc-ai/aigentry-logger": "^0.2.0",
62
64
  "@modelcontextprotocol/sdk": "^1.26.0",
63
65
  "ws": "^8.18.0"
64
66
  },