@mcpeak/cli 0.11.0 → 0.12.0

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.
@@ -1,12 +1,13 @@
1
1
  let node_fs_promises = require("node:fs/promises");
2
2
  let node_path = require("node:path");
3
3
  let node_readline_promises = require("node:readline/promises");
4
+ let _mcpeak_generate = require("@mcpeak/generate");
4
5
  let _mcpeak_runner = require("@mcpeak/runner");
5
6
  let node_child_process = require("node:child_process");
6
7
  let _mcpeak_core = require("@mcpeak/core");
7
8
  let node_fs = require("node:fs");
8
9
  //#region package.json
9
- var version = "0.11.0";
10
+ var version = "0.12.0";
10
11
  //#endregion
11
12
  //#region src/repair-render.ts
12
13
  /**
@@ -43,12 +44,21 @@ const APPROVAL_LABEL = {
43
44
  mismatched: "승인 지문 불일치",
44
45
  absent: "승인 지문 없음"
45
46
  };
47
+ const RUN_HISTORY_LABEL = {
48
+ present: "실행 기록 있음",
49
+ absent: "실행 기록 없음"
50
+ };
51
+ /**
52
+ * 명세를 오라클로 볼 수 있는가. `generate` 의 `specIsOracle` 과 같은 뜻이지만 여기서 다시
53
+ * 판정한다. 화면 렌더러는 진단 통로를 로드하지 않는다(설계서 §4.5).
54
+ */
55
+ const isOracle = (bundle) => bundle.spec.approval === "matched" && bundle.spec.runHistory === "present";
46
56
  /**
47
57
  * 지문이 승인 상태가 아닐 때 결과 맨 위에 붙는 블록. 결과가 `unsure` 여도 붙는다. 설계서 §6.5.
48
58
  * `matched` 면 빈 문자열이다.
49
59
  */
50
- function renderApprovalNotice(approval) {
51
- if (approval === "matched") return "";
60
+ function renderApprovalNotice(approval, runHistory) {
61
+ if (approval === "matched") return runHistory === "present" ? "" : "⚠ 이 명세는 승인 지문이 일치하지만 실제 서버 실행 기록이 없습니다.\n --baseline-only 나 --no-dry-run 으로 저장하면 케이스가 한 번도 실행되지 않은 채 승인됩니다.\n 입력값이 생성 시점의 자리값일 수 있어, 아래 제안은 명세 쪽 원인도 함께 받았습니다.\n\n";
52
62
  if (approval === "mismatched") return "⚠ 이 명세는 승인 상태가 아닙니다 (지문 불일치).\n 실패 원인이 서버가 아니라 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
53
63
  return "⚠ 이 명세는 승인 지문이 없습니다.\n 실제 서버로 검증된 적이 없는 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
54
64
  }
@@ -61,6 +71,7 @@ function kilobytes(bytes) {
61
71
  */
62
72
  function renderRepairConfirm(view) {
63
73
  const scope = view.omittedFailures === 0 ? `실패 ${view.totalFailures}건 중 ${view.sentFailures}건` : `실패 ${view.totalFailures}건 중 ${view.sentFailures}건 (--max-cases ${view.maxCases}, ${view.omittedFailures}건 제외)`;
74
+ const toolLine = view.omittedToolSchemas === 0 ? `${view.sentTools}개` : `${view.sentTools}개 (스키마 제외 ${view.omittedToolSchemas}개)`;
64
75
  let stderrLine;
65
76
  if (!view.includeStderr) stderrLine = "(전송하지 않음)";
66
77
  else if (view.stderr === void 0 || view.stderr === "") stderrLine = "없음";
@@ -74,7 +85,8 @@ function renderRepairConfirm(view) {
74
85
  "\n",
75
86
  ` provider ${view.providerId} (${escapeTerminalText$2(view.model)})\n`,
76
87
  ` 대상 ${scope}\n`,
77
- ` 명세 상태 ${APPROVAL_LABEL[view.approval]}\n`,
88
+ ` 명세 상태 ${APPROVAL_LABEL[view.approval]} · ${RUN_HISTORY_LABEL[view.runHistory]}\n`,
89
+ ` 도구 ${toolLine}\n`,
78
90
  ` stderr ${stderrLine}\n`,
79
91
  ` 전송 크기 ${kilobytes(view.requestBytes)}\n`,
80
92
  "\n",
@@ -130,7 +142,7 @@ function renderRepairResult(options) {
130
142
  const parts = [
131
143
  `── 서버 수정 방향 (${options.providerId} / ${escapeTerminalText$2(options.model)}) ──\n`,
132
144
  "\n",
133
- renderApprovalNotice(options.bundle.spec.approval)
145
+ renderApprovalNotice(options.bundle.spec.approval, options.bundle.spec.runHistory)
134
146
  ];
135
147
  if (options.result.status === "unsure") {
136
148
  /**
@@ -166,7 +178,7 @@ function renderRepairResult(options) {
166
178
  parts.push(` 원인 후보 ${escapeTerminalText$2(cause.summary)}\n`);
167
179
  if (cause.location !== "") parts.push(` 확인할 곳 ${escapeTerminalText$2(cause.location)}\n`);
168
180
  if (cause.evidence !== "") parts.push(` 근거 ${escapeTerminalText$2(cause.evidence)}\n`);
169
- if (cause.target === "spec" && options.bundle.spec.approval !== "matched") parts.push(" 분류 명세 쪽 원인으로 봄\n");
181
+ if (cause.target === "spec" && !isOracle(options.bundle)) parts.push(" 분류 명세 쪽 원인으로 봄\n");
170
182
  }
171
183
  }
172
184
  }
@@ -241,6 +253,11 @@ function echoValue(value) {
241
253
  */
242
254
  const SECRET_INPUT_HINT = "값은 셸 히스토리에 남지 않게 넣으세요. 예: `read -rs MCP_TOKEN; export MCP_TOKEN`";
243
255
  /**
256
+ * 같은 `read -rs` 예시를 명령줄 모양 그대로 둔 것. `--env` 안내가 올바른 호출 예시 바로 위에
257
+ * 적는다. `MCP_TOKEN` 자리는 `replaceAll` 로 실제 이름으로 바꿔 쓴다.
258
+ */
259
+ const SECRET_ENV_EXAMPLE = " read -rs MCP_TOKEN; export MCP_TOKEN";
260
+ /**
244
261
  * `--url` 값을 검증한다.
245
262
  *
246
263
  * 스킴을 http·https 로 제한한다. `file:` 이나 `ws:` 를 넘기면 `core` 의 SDK 안쪽에서
@@ -296,6 +313,34 @@ function createHeaderEnvCollector() {
296
313
  snapshot: () => Object.freeze({ ...values })
297
314
  };
298
315
  }
316
+ /**
317
+ * `--env <NAME>` 값 하나를 검사한다. **이름만** 받는다. 값을 여기 넣으면 `ps` 와 셸
318
+ * 히스토리와 녹화 출처에 그대로 남는다. 그것이 이 옵션이 이름만 받는 이유고, 거절 문장이
319
+ * 그 이유를 말해야 한다.
320
+ *
321
+ * `NODE_OPTIONS` 와 `MCPEAK_*` 는 External 배선이 자식 환경에 직접 쓰는 이름이다. 배선이
322
+ * 사용자 값 **뒤에** 얹히므로 덮이지는 않지만, 받아 놓고 조용히 덮어쓰면 "막은 척" 이 된다.
323
+ */
324
+ function parseEnvForwardOption(raw) {
325
+ const name = raw.trim();
326
+ if (name === "") return err("`--env` 옵션 값이 필요합니다.");
327
+ if (!ENV_NAME_PATTERN.test(name)) return err(`\`--env\` 는 환경변수 **이름**만 받습니다: '${echoValue(raw)}'\n→ 값을 명령줄에 쓰면 \`ps\` 목록과 셸 히스토리, 녹화 출처에 그대로 남기 때문입니다.
328
+ → 값은 환경변수에 넣고 이름만 넘기세요:
329
+ ${SECRET_ENV_EXAMPLE.replaceAll("MCP_TOKEN", "SUPABASE_ACCESS_TOKEN")}\n mcpeak test suite.json --command npx --arg -y --arg <서버> --env SUPABASE_ACCESS_TOKEN`);
330
+ if (name === "NODE_OPTIONS") return err("`--env NODE_OPTIONS` 는 받지 않습니다.\n→ 부모의 `NODE_OPTIONS` 는 녹화·재생 배선이 자식에게 이미 이어 붙입니다.");
331
+ if (name.startsWith("MCPEAK_")) return err(`\`--env ${name}\` 는 받지 않습니다.\n→ \`MCPEAK_\` 로 시작하는 이름은 녹화·재생 배선이 자식 환경에 직접 씁니다. 사용자 값이 그것을 덮으면 녹화가 조용히 꺼집니다.`);
332
+ return ok(name);
333
+ }
334
+ function createEnvForwardCollector() {
335
+ const names = [];
336
+ return {
337
+ add(name) {
338
+ if (names.includes(name)) return `\`--env ${name}\` 이 두 번 있습니다. 한 번만 쓰세요.`;
339
+ names.push(name);
340
+ },
341
+ snapshot: () => Object.freeze([...names])
342
+ };
343
+ }
299
344
  /** 대상을 사람이 읽는 한 조각으로 만든다. 오류 문장이 무엇에 붙으려 했는지 말할 때 쓴다. */
300
345
  function describeTarget(target) {
301
346
  return target.transport === "stdio" ? [target.command, ...target.args].join(" ") : target.url;
@@ -308,13 +353,23 @@ var ConnectTargetError = class extends Error {};
308
353
  *
309
354
  * `env` 는 stdio 전용이다. External 배선이 만든 자식 환경 변수라 띄울 프로세스가 있어야
310
355
  * 뜻이 있고, 파서가 `--url` 과 External 세션 옵션의 동시 사용을 이미 막는다.
356
+ *
357
+ * 자식 환경은 세 겹이고 순서가 계약이다(설계 §4.1):
358
+ * SDK 기본 허용 목록 < `--env` 로 넘긴 것 < External 배선(`options.env`).
359
+ * 배선이 마지막이다. 배선 변수가 사용자 값에 덮이면 녹화가 조용히 꺼진다.
311
360
  */
312
361
  async function openConnection(target, dependencies, options) {
313
- if (target.transport === "stdio") return await dependencies.connectStdio({
314
- command: target.command,
315
- args: target.args,
316
- ...options?.env === void 0 ? {} : { env: options.env }
317
- });
362
+ if (target.transport === "stdio") {
363
+ const env = {
364
+ ...resolveForwardedEnv(target.envNames, dependencies.readEnv),
365
+ ...options?.env
366
+ };
367
+ return await dependencies.connectStdio({
368
+ command: target.command,
369
+ args: target.args,
370
+ ...Object.keys(env).length === 0 ? {} : { env }
371
+ });
372
+ }
318
373
  const connectHttp = dependencies.connectHttp;
319
374
  if (connectHttp === void 0) throw new ConnectTargetError("이 진입점은 원격(Streamable HTTP) 서버를 지원하지 않습니다.\n→ `--url` 대신 `--command` 로 서버를 직접 띄우세요.");
320
375
  const headers = resolveHeaders(target.headerEnv, dependencies.readEnv);
@@ -355,6 +410,22 @@ function resolveHeaders(headerEnv, readEnv) {
355
410
  }
356
411
  return ok(headers);
357
412
  }
413
+ /**
414
+ * `--env` 이름을 값으로 바꾼다. **비어 있으면 이름만 말한다.** 값이 비었다는 사실을 알리려다
415
+ * 값을 화면에 찍으면 이 옵션이 존재하는 이유가 없어진다(ADR-0070 §3).
416
+ */
417
+ function resolveForwardedEnv(names, readEnv) {
418
+ if (names.length === 0) return {};
419
+ if (readEnv === void 0) throw new ConnectTargetError("이 진입점은 `--env` 를 지원하지 않습니다.\n→ 환경변수를 읽는 주입점(readEnv)이 배선되지 않았습니다.");
420
+ const env = {};
421
+ for (const name of names) {
422
+ const value = readEnv(name);
423
+ if (value === void 0 || value === "") throw new ConnectTargetError(`환경변수 \`${name}\` 가 비어 있습니다.\n→ 값을 넣고 다시 실행하세요:
424
+ ${SECRET_ENV_EXAMPLE.replaceAll("MCP_TOKEN", name)}`);
425
+ env[name] = value;
426
+ }
427
+ return env;
428
+ }
358
429
  //#endregion
359
430
  //#region src/dry-run.ts
360
431
  /** 보고서가 상한을 넘긴 경우의 안내. 케이스 수 말고는 사용자가 손댈 수 있는 것이 없다. */
@@ -389,6 +460,24 @@ const caseBlocks = (report) => {
389
460
  }
390
461
  return report.cases.map((_, index) => (blocks[index] ?? []).join("\n"));
391
462
  };
463
+ /** `renderReport` 가 건너뛴 단언 줄에 붙이는 표시. reporter.ts 와 같은 값이다. */
464
+ const SKIPPED_MARK$1 = "(건너뜀) ";
465
+ /** 케이스 본문 줄에서 들여쓰기를 벗긴다. 들여쓰기가 없는 줄은 본문이 아니다. */
466
+ const bodyLines$1 = (block) => block.split("\n").filter((line) => line.startsWith(INDENT$1)).map((line) => line.slice(4));
467
+ /**
468
+ * 실패 사유 첫 줄을 고른다. **문장을 새로 만들지 않고 렌더된 블록에서 고르기만 한다.**
469
+ *
470
+ * 판정은 구조화된 값으로 한다. `operation.status` 와 `assertions[].status` 가 이미 사실을
471
+ * 담고 있는데 렌더 문자열을 다시 파싱하면, 문안이 바뀌는 날 조용히 어긋난다. `(건너뜀) ` 줄을
472
+ * 실패한 단언으로 읽던 것이 정확히 그 방식의 결과다(설계 §3.3).
473
+ */
474
+ const failureLineOf = (result, block) => {
475
+ const body = bodyLines$1(block);
476
+ if (result.operation.status !== "completed") return body[0] ?? "";
477
+ const failed = result.assertions.find((assertion) => assertion.status === "failed");
478
+ if (failed === void 0) return "";
479
+ return body.find((line) => line.startsWith(failed.spec.type) && !line.includes(SKIPPED_MARK$1)) ?? "";
480
+ };
392
481
  /**
393
482
  * 연결 상실 사유별 문장. `runner` 의 `reporter.ts`·`junit.ts` 가 쓰는 어휘를 그대로 따른다.
394
483
  * 같은 죽음을 `test` 에서 볼 때와 `generate` 에서 볼 때 다른 낱말로 부르면 두 번 배우게 된다.
@@ -446,6 +535,8 @@ const toResult = (report) => {
446
535
  status: result.status,
447
536
  detail: blocks[index] ?? "",
448
537
  rejectionBasis: result.rejectionBasis,
538
+ operationFailed: result.operation.status !== "completed",
539
+ failureLine: failureLineOf(result, blocks[index] ?? ""),
449
540
  ...result.rejectionBody === void 0 ? {} : { rejectionBody: result.rejectionBody }
450
541
  }));
451
542
  const aborted = abortedFromStop(report);
@@ -644,7 +735,22 @@ const REMOTE_TARGET_OPTIONS = ` --url <URL> 이미 떠 있는 Streama
644
735
  남기 때문입니다. 값을 넣을 때도 히스토리에 남기지 마세요:
645
736
  read -rs MCP_TOKEN; export MCP_TOKEN
646
737
  mcpeak … --header-env Authorization=MCP_TOKEN`;
647
- const TEST_USAGE = "사용법: mcpeak test <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
738
+ /**
739
+ * stdio 대상의 환경변수 전달 옵션. `test` 와 `generate` 가 같은 문장을 쓴다.
740
+ *
741
+ * `--env` 가 이름만 받는 이유를 여기서 말한다. 이유를 모르면 사용자는 `--arg KEY=값` 이나
742
+ * `--command env` 래퍼로 우회하고, 그것이 이 옵션이 막으려던 노출이다. `--header-env` 와 같은
743
+ * 근거다.
744
+ */
745
+ const ENV_FORWARD_OPTION = ` --env <NAME> 부모 환경변수 NAME 을 서버 프로세스에 넘깁니다. 반복할 수 있습니다.
746
+ SDK 는 HOME·PATH 등 고정 목록만 자식에게 주므로, API 키처럼 서버가
747
+ 환경에서 읽는 값은 이 옵션으로 이름을 지정해야 갑니다. 값을 직접
748
+ 받지 않는 이유는 명령줄에 쓴 토큰이 \`ps\` 목록과 셸 히스토리, 녹화
749
+ 출처에 그대로 남기 때문입니다. \`NODE_OPTIONS\` 와 \`MCPEAK_\` 로
750
+ 시작하는 이름은 녹화·재생 배선이 쓰므로 받지 않습니다:
751
+ read -rs SUPABASE_ACCESS_TOKEN; export SUPABASE_ACCESS_TOKEN
752
+ mcpeak … --command npx --arg -y --arg <서버> --env SUPABASE_ACCESS_TOKEN`;
753
+ const TEST_USAGE = "사용법: mcpeak test <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] [--env <NAME> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
648
754
  /**
649
755
  * 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
650
756
  * 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
@@ -668,6 +774,7 @@ const TEST_OPTIONS = `옵션:
668
774
  출력을 CI 로그에 남기기 전에 확인하세요
669
775
  --reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
670
776
  않으므로 파이프나 && 는 쓸 수 없습니다
777
+ ${ENV_FORWARD_OPTION}
671
778
  ${REMOTE_TARGET_OPTIONS}
672
779
  --record-session <path>
673
780
  서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
@@ -692,7 +799,7 @@ feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오
692
799
  \`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
693
800
  붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
694
801
  SQLite 파일이라 영향받지 않습니다.**`;
695
- const GENERATE_USAGE = "사용법: mcpeak generate --out <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--suite-id <id>] [--name <name>] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--reset-cmd <command>] [--no-repair] [--force]";
802
+ const GENERATE_USAGE = "사용법: mcpeak generate --out <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] [--env <NAME> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--suite-id <id>] [--name <name>] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--reset-cmd <command>] [--fixtures <path>] [--no-repair] [--diagnose-rejections] [--force]";
696
803
  /**
697
804
  * 시험 실행 옵션 설명. 사용법 한 줄로는 `--reset-cmd` 가 셸을 거치지 않는다는 제약을 알 수
698
805
  * 없다. 모르고 쓰면 사고가 나는 값이다.
@@ -709,13 +816,25 @@ const GENERATE_DRY_RUN_OPTIONS = `옵션:
709
816
  달라져 재승인이 뜹니다. 고정하려면 --suite-id 와 --name 을
710
817
  직접 지정하세요
711
818
  --no-dry-run 승인 전 시험 실행을 건너뜁니다. 케이스가 실제 서버에서 확인되지
712
- 않은 채 저장됩니다
819
+ 않은 채 저장되고, 실행이 필요한 AI 사전보완도 건너뜁니다
713
820
  --reset-cmd <command> 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지 않으므로
714
821
  파이프나 && 는 쓸 수 없습니다
822
+ --fixtures <path> 실재하는 자원을 가리키는 값을 도구·필드별로 적은 JSON 파일입니다.
823
+ 생략하면 \`mcpeak.fixtures.json\` 을 찾고, 없으면 그냥 진행합니다.
824
+ 픽스처 파일에 토큰·비밀번호·API 키를 적지 마세요. 이 파일의 값은
825
+ 생성된 명세에 그대로 들어갑니다. 인증이 필요한 서버는 서버 기동
826
+ 환경변수로 자격을 넘기고, 픽스처에는 그 자격으로 접근 가능한
827
+ 자원 식별자만 적으세요
715
828
  --no-repair 시험 실행이 실패해도 입력값을 고쳐 다시 시도하지 않습니다.
716
829
  실패가 곧바로 분류 화면으로 갑니다
830
+ --diagnose-rejections
831
+ 통과한 거절 케이스의 응답을 나열하고, provider 가 있으면 그 거절이
832
+ 입력 검증 때문인지 서버 내부 오류인지 AI 에게 참고 의견을 묻습니다.
833
+ 기본은 끕니다. 손으로 쓴 거절 문장은 전부 이 목록에 오르므로,
834
+ 서버가 잘못된 입력에 그냥 터지는지 의심될 때 켜세요
717
835
  --force \`--out\` 경로에 파일이 있으면 지우고 새로 씁니다. 기본은 저장을
718
836
  멈추는 것입니다
837
+ ${ENV_FORWARD_OPTION}
719
838
  ${REMOTE_TARGET_OPTIONS}`;
720
839
  const REPAIR_USAGE = "사용법: mcpeak repair <bundle.json> --provider <codex|claude> --model <model> [--max-cases <N>] [--no-stderr] [--yes]";
721
840
  /**
@@ -854,15 +973,9 @@ function renderHttpDiagnostics(diagnostics) {
854
973
  //#region src/input-repair.ts
855
974
  /** 케이스 머리글 들여쓰기. 분류 화면(§8.3)과 같은 값이라 번호가 이어 읽힌다. */
856
975
  const HEAD = " ";
857
- /** 본문 들여쓰기. 설계 문서 §8.6 의 모든 본문 줄이 이 폭이다. */
976
+ /** 본문 들여쓰기. 교정 화면의 모든 본문 줄이 이 폭이다. */
858
977
  const BODY = " ";
859
978
  /**
860
- * 교정 대상의 실패 사유는 항상 이 한 줄이다. 대상 판별이 `isError` 단언의 `expected` 가
861
- * true 가 아닌 실패만 통과시키므로(§4.2), 진단 문장이 이것 말고 나올 수 없다.
862
- * `RepairTarget` 이 `detail` 을 나르지 않아 여기서 다시 적는다.
863
- */
864
- const FAILURE_LINE = `${BODY}isError 정상 응답을 기대했지만 오류 응답을 받았습니다.`;
865
- /**
866
979
  * 제안값의 출처를 말한다. `propose` 는 provider 가 있을 때만 배선되므로 여기 오는 값은
867
980
  * **항상 provider 가 만든 것**이다 — 그런데 전에는 서버에 귀속했다(#286).
868
981
  * 표기를 못 받은 경우에도 서버라고 말하지 않는다.
@@ -920,20 +1033,21 @@ const parseAnswer = (answer) => {
920
1033
  return answer;
921
1034
  }
922
1035
  };
923
- /** §8.6 의 기본값 표시. 문자열은 따옴표 없이 그대로 보여준다. */
924
- const renderDefault = (value) => typeof value === "string" ? value : JSON.stringify(value);
925
- /** §8.6.1 의 현재값 표시. 값의 경계가 보여야 해서 JSON 으로 적는다. */
926
- const renderCurrent = (value) => JSON.stringify(value) ?? "null";
927
1036
  /** `(tool, 필드명)` 캐시 키. 필드 이름에 점이 있어도 툴 이름과 섞이지 않게 앞을 길이로 가른다. */
928
1037
  const cacheKey = (tool, field) => `${tool.length}:${tool}.${field}`;
929
1038
  /**
930
1039
  * 한 필드를 사람에게 받는다. 선언 타입과 안 맞으면 같은 질문을 다시 한다(§4.5).
931
- * `proposed` 가 있으면 §8.6 형식, 없으면 §8.6.1 형식이다.
1040
+ *
1041
+ * 형식은 제안 유무와 **무관하게 하나다**(2026-09-13 설계 §4.5). 갈래가 둘이면 같은 화면이
1042
+ * 회차마다 다른 모양이 되고, 사용자가 무엇을 누르면 무엇이 들어가는지를 두 번 배운다.
932
1043
  */
933
1044
  const askField = async (io, options) => {
934
1045
  const fallback = options.proposed ?? options.current;
935
- const declared = options.declared === void 0 ? "" : `${options.declared}, `;
936
- const question = options.proposed === void 0 ? `${BODY}${options.field} (${declared}현재 ${renderCurrent(options.current)}): ` : `${BODY}${options.field}: [${renderDefault(options.proposed)}]`;
1046
+ const parts = [`필드 ${options.index}/${options.total}`];
1047
+ if (options.declared !== void 0) parts.push(options.declared);
1048
+ if (options.current !== void 0) parts.push(`현재 ${JSON.stringify(options.current)}`);
1049
+ parts.push(options.proposed === void 0 ? "엔터 = 현재 값 유지" : `엔터 = 제안 값 ${JSON.stringify(options.proposed)}`);
1050
+ const question = `${BODY}${options.tool}.${options.field} (${parts.join(", ")}): `;
937
1051
  for (;;) {
938
1052
  const answer = await io.input(question);
939
1053
  if (answer.trim() === "") return fallback;
@@ -949,10 +1063,16 @@ const askRound = async (io, target, options) => {
949
1063
  const next = {};
950
1064
  const asked = [];
951
1065
  let unchanged = true;
1066
+ const total = Object.keys(options.current).filter((field) => options.reuse?.get(field) === void 0).length;
1067
+ let index = 0;
952
1068
  for (const [field, value] of Object.entries(options.current)) {
953
1069
  const cached = options.reuse?.get(field);
1070
+ if (cached === void 0) index += 1;
954
1071
  const answered = cached === void 0 ? await askField(io, {
1072
+ tool: target.tool,
955
1073
  field,
1074
+ index,
1075
+ total,
956
1076
  current: value,
957
1077
  proposed: options.proposed?.[field],
958
1078
  declared: declaredTypeOf(options.tools, target.tool, field)
@@ -974,11 +1094,27 @@ async function repairInputs(options) {
974
1094
  * 이 호출이 끝나면 사라진다(§4.6).
975
1095
  */
976
1096
  const cache = /* @__PURE__ */ new Map();
1097
+ /**
1098
+ * 이 값을 누가 정했는가(#390 사양 0).
1099
+ *
1100
+ * 캐시에서 재사용한 필드는 **값을 준 케이스의 출처를 그대로 물려받는다.** 캐시가 값만
1101
+ * 담으면 사람이 AI 제안을 고쳐 넣은 값이 뒤 케이스에서 "AI 제안" 으로 찍혀 화면이
1102
+ * 거짓말을 한다.
1103
+ *
1104
+ * 비교는 `===` 가 아니라 `canonicalJson` 이다. 제안값이 객체나 배열일 수 있다.
1105
+ */
1106
+ const originOf = (tool, field, value, proposed) => {
1107
+ const cached = cache.get(cacheKey(tool, field));
1108
+ if (cached !== void 0 && (0, _mcpeak_runner.canonicalJson)(cached.value) === (0, _mcpeak_runner.canonicalJson)(value)) return cached.origin;
1109
+ if (proposed !== void 0 && Object.hasOwn(proposed, field) && (0, _mcpeak_runner.canonicalJson)(proposed[field]) === (0, _mcpeak_runner.canonicalJson)(value)) return "aiProposed";
1110
+ return "humanRepaired";
1111
+ };
977
1112
  /** §8.6.3 을 이미 찍은 키. 같은 안내를 케이스마다 되풀이하지 않는다. */
978
1113
  const announced = /* @__PURE__ */ new Set();
979
1114
  for (const [index, target] of options.targets.entries()) {
980
- options.io.write(`${HEAD}[${index + 1}] ${target.caseName}\n`);
981
- options.io.write(`${FAILURE_LINE}\n`);
1115
+ options.io.write(`${HEAD}[${index + 1}/${options.targets.length}] ${target.caseName}\n`);
1116
+ options.io.write(`${BODY}${target.failureLine}\n`);
1117
+ for (const line of target.serverMessage.split("\n").filter((line) => line !== "")) options.io.write(`${BODY}→ ${line}\n`);
982
1118
  options.io.write("\n");
983
1119
  const attempts = [];
984
1120
  const fields = Object.keys(target.input);
@@ -997,7 +1133,7 @@ async function repairInputs(options) {
997
1133
  const first = await askRound(options.io, target, {
998
1134
  current: target.input,
999
1135
  proposed,
1000
- reuse: new Map(reused.map((field) => [field, cache.get(cacheKey(target.tool, field))])),
1136
+ reuse: new Map(reused.map((field) => [field, cache.get(cacheKey(target.tool, field)).value])),
1001
1137
  tools: options.tools
1002
1138
  });
1003
1139
  if (first.unchanged) {
@@ -1013,10 +1149,17 @@ async function repairInputs(options) {
1013
1149
  for (const [field, value] of Object.entries(first.input)) attempts.push({
1014
1150
  field,
1015
1151
  value,
1016
- passed: firstVerdict.passed
1152
+ passed: firstVerdict.passed,
1153
+ origin: originOf(target.tool, field, value, proposed)
1017
1154
  });
1018
1155
  if (firstVerdict.passed) {
1019
- for (const field of first.asked) cache.set(cacheKey(target.tool, field), first.input[field]);
1156
+ for (const field of first.asked) {
1157
+ const value = first.input[field];
1158
+ cache.set(cacheKey(target.tool, field), {
1159
+ value,
1160
+ origin: originOf(target.tool, field, value, proposed)
1161
+ });
1162
+ }
1020
1163
  options.io.write(`${PASSED_LINE}\n`);
1021
1164
  outcomes.push({
1022
1165
  caseId: target.caseId,
@@ -1054,10 +1197,17 @@ async function repairInputs(options) {
1054
1197
  for (const [field, value] of Object.entries(second.input)) attempts.push({
1055
1198
  field,
1056
1199
  value,
1057
- passed: secondVerdict.passed
1200
+ passed: secondVerdict.passed,
1201
+ origin: originOf(target.tool, field, value, void 0)
1058
1202
  });
1059
1203
  if (secondVerdict.passed) {
1060
- for (const field of second.asked) cache.set(cacheKey(target.tool, field), second.input[field]);
1204
+ for (const field of second.asked) {
1205
+ const value = second.input[field];
1206
+ cache.set(cacheKey(target.tool, field), {
1207
+ value,
1208
+ origin: originOf(target.tool, field, value, void 0)
1209
+ });
1210
+ }
1061
1211
  options.io.write(`${PASSED_LINE}\n`);
1062
1212
  outcomes.push({
1063
1213
  caseId: target.caseId,
@@ -1077,7 +1227,208 @@ async function repairInputs(options) {
1077
1227
  return outcomes;
1078
1228
  }
1079
1229
  //#endregion
1230
+ //#region src/reset-hook.ts
1231
+ /**
1232
+ * 시험 실행 직전에 서버 상태를 되돌리는 초기화 명령을 실행한다. ADR-0023 의 결정을 구현한다.
1233
+ *
1234
+ * 셸을 거치지 않는다. `;`·`&&`·백틱은 해석되지 않고 그대로 인자가 된다. 실행 파일을 감싼
1235
+ * 큰따옴표만 경계로 인식하며 인자의 따옴표는 해석하지 않는다. 공백이 든 인자가 필요하면
1236
+ * 사용자가 스크립트 파일을 만들어야 한다. 이 제약은 도움말에 적힌 사양이지 구현 편의가 아니다.
1237
+ */
1238
+ /** 초기화 명령의 제한 시간. 설계 문서 §6. */
1239
+ const TIMEOUT_MS = 6e4;
1240
+ /** 보관하는 stderr 상한. 실패 안내에 쓰는 꼬리만 있으면 되고 그 이상은 화면을 덮는다. */
1241
+ const MAX_STDERR_BYTES = 8 * 1024;
1242
+ /** 제한 시간 초과일 때 `stderr` 에 넣는 문장. 종료 코드가 없으므로 사유가 여기에만 남는다. */
1243
+ const TIMEOUT_STDERR = `타임아웃(60초)`;
1244
+ /**
1245
+ * 초기화 명령이 정상 종료하지 못한 경우. 시험 실행을 시작하지 않고 저장도 하지 않는 사유가
1246
+ * 되므로, 호출 측이 화면에 쓸 수 있도록 명령·종료 코드·stderr 을 그대로 들고 다닌다.
1247
+ *
1248
+ * `exitCode` 가 `null` 인 경우는 셋이다. 실행 파일이 없거나(ENOENT), 시그널로 죽었거나,
1249
+ * 제한 시간을 넘겨 우리가 죽였을 때다.
1250
+ */
1251
+ var ResetCommandError = class extends Error {
1252
+ command;
1253
+ exitCode;
1254
+ stderr;
1255
+ constructor(command, exitCode, stderr) {
1256
+ super(`초기화 명령이 실패했습니다: ${command}`);
1257
+ this.command = command;
1258
+ this.exitCode = exitCode;
1259
+ this.stderr = stderr;
1260
+ this.name = "ResetCommandError";
1261
+ }
1262
+ };
1263
+ /**
1264
+ * stderr 의 **마지막** 상한만큼을 모은다. 앞부분을 버리는 것이 요점이다. 화면에 쓰는 것은
1265
+ * 마지막 3줄이고, 실패 원인은 출력의 끝에 있다. 앞에서 잘라 보관하면 긴 출력을 내는 명령에서
1266
+ * 정작 사유가 담긴 마지막 줄이 사라진다.
1267
+ */
1268
+ var StderrTail = class {
1269
+ chunks = [];
1270
+ size = 0;
1271
+ push(chunk) {
1272
+ this.chunks.push(chunk);
1273
+ this.size += chunk.byteLength;
1274
+ while (this.size > MAX_STDERR_BYTES) {
1275
+ const first = this.chunks[0];
1276
+ if (first === void 0) break;
1277
+ const excess = this.size - MAX_STDERR_BYTES;
1278
+ if (first.byteLength <= excess) {
1279
+ this.chunks.shift();
1280
+ this.size -= first.byteLength;
1281
+ } else {
1282
+ this.chunks[0] = first.subarray(excess);
1283
+ this.size -= excess;
1284
+ }
1285
+ }
1286
+ }
1287
+ text() {
1288
+ return Buffer.concat(this.chunks).toString("utf8");
1289
+ }
1290
+ };
1291
+ /**
1292
+ * 초기화 명령을 한 번 실행한다. 재시도하지 않는다.
1293
+ *
1294
+ * stdout 은 아예 받지 않는다. 성공 경로에서 남의 명령 출력을 우리 화면에 섞지 않기 위함이고,
1295
+ * 버퍼 상한에 걸려 멀쩡한 명령이 죽는 일도 없앤다.
1296
+ *
1297
+ * 빈 문자열이나 공백뿐인 명령은 `ResetCommandError` 가 아니라 `TypeError` 다. 옵션 파싱에서
1298
+ * 이미 걸러야 하는 값이고, 여기까지 왔다면 사용자 입력 문제가 아니라 호출 측 결함이다.
1299
+ */
1300
+ async function runResetCommand(command) {
1301
+ let tokens;
1302
+ try {
1303
+ tokens = (0, _mcpeak_core.tokenizeCommand)(command);
1304
+ } catch (error) {
1305
+ if (!(error instanceof _mcpeak_core.CommandParseError)) throw error;
1306
+ const reason = {
1307
+ EMPTY_EXECUTABLE: "실행 파일 경로가 비어 있습니다.",
1308
+ UNTERMINATED_EXECUTABLE_QUOTE: "실행 파일을 감싼 큰따옴표가 닫히지 않았습니다.",
1309
+ MISSING_EXECUTABLE_ARGUMENT_SEPARATOR: "실행 파일을 감싼 큰따옴표 뒤에는 공백이 필요합니다."
1310
+ }[error.code];
1311
+ throw new ResetCommandError(command, null, `잘못된 초기화 명령: ${reason}`);
1312
+ }
1313
+ const [file, ...args] = tokens;
1314
+ if (file === void 0) throw new TypeError("초기화 명령이 비어 있습니다.");
1315
+ return new Promise((resolve, reject) => {
1316
+ const stderr = new StderrTail();
1317
+ let timedOut = false;
1318
+ let settled = false;
1319
+ const child = (0, node_child_process.spawn)(file, args, {
1320
+ stdio: [
1321
+ "ignore",
1322
+ "ignore",
1323
+ "pipe"
1324
+ ],
1325
+ shell: false
1326
+ });
1327
+ const timer = setTimeout(() => {
1328
+ timedOut = true;
1329
+ child.kill("SIGKILL");
1330
+ }, TIMEOUT_MS);
1331
+ const finish = (settle) => {
1332
+ if (settled) return;
1333
+ settled = true;
1334
+ clearTimeout(timer);
1335
+ settle();
1336
+ };
1337
+ child.stderr?.on("data", (chunk) => {
1338
+ stderr.push(chunk);
1339
+ });
1340
+ child.on("error", (error) => {
1341
+ finish(() => {
1342
+ reject(new ResetCommandError(command, null, error.message));
1343
+ });
1344
+ });
1345
+ child.on("close", (code, signal) => {
1346
+ finish(() => {
1347
+ if (timedOut) {
1348
+ reject(new ResetCommandError(command, null, TIMEOUT_STDERR));
1349
+ return;
1350
+ }
1351
+ if (code === 0) {
1352
+ resolve();
1353
+ return;
1354
+ }
1355
+ reject(new ResetCommandError(command, code, stderr.text() || describeSignal(signal)));
1356
+ });
1357
+ });
1358
+ });
1359
+ }
1360
+ /** stderr 이 비어 있는데 시그널로 죽었으면 그 사실만이라도 남긴다. */
1361
+ const describeSignal = (signal) => signal === null ? "" : `시그널 ${signal} 로 종료되었습니다.`;
1362
+ /** 등급이 "같은 초기 상태" 를 보장하는가. 후보 비교의 전제다. */
1363
+ const resetIsComparable = (grade) => grade === "freshProcess" || grade === "commandOnly";
1364
+ /**
1365
+ * 등급별 화면 문안. **상수로 둔다.** 호출부에서 문자열을 조립하면 같은 등급이 화면마다
1366
+ * 다른 문장으로 나가고, 사용자는 두 화면이 같은 것을 말하는지 알 수 없다.
1367
+ */
1368
+ const RESET_GRADE_LINE = {
1369
+ freshProcess: "초기 상태: 서버를 다시 띄우고 초기화 명령을 실행했습니다.",
1370
+ commandOnly: "초기 상태: 초기화 명령만 실행했습니다. 서버 프로세스의 메모리 상태는 그대로입니다.",
1371
+ none: "초기 상태: 초기화 수단이 없습니다. 앞 실행이 바꾼 상태가 남아 있을 수 있습니다. --reset-cmd 로 지정하세요.",
1372
+ failed: "초기 상태: 초기화 명령이 실패했습니다. 이 실행의 결과를 검증 완료로 보지 않습니다."
1373
+ };
1374
+ /**
1375
+ * 초기화를 시도하고 등급을 돌려준다. 던지지 않는다. 호출부가 등급을 보고 판단한다.
1376
+ *
1377
+ * `runResetCommand` 를 그대로 두는 이유: 그 함수는 "명령이 실패하면 던진다" 는 계약이고
1378
+ * 시험 실행 직전 경로가 그 계약에 의존한다(실패하면 실행을 시작하지 않는다). 등급이
1379
+ * 필요한 자리는 판단이 다르다. 실패해도 진행하되 무엇을 못 했는지 적는다.
1380
+ *
1381
+ * `ResetCommandError` 가 아닌 오류는 **던진다.** 우리 결함을 등급으로 위장하면 초기화가
1382
+ * 안 된 것과 우리 코드가 깨진 것이 화면에서 같아 보인다.
1383
+ */
1384
+ async function attemptReset(resetCmd) {
1385
+ if (resetCmd === void 0) return { grade: "none" };
1386
+ try {
1387
+ await runResetCommand(resetCmd);
1388
+ return { grade: "commandOnly" };
1389
+ } catch (error) {
1390
+ if (!(error instanceof ResetCommandError)) throw error;
1391
+ return {
1392
+ grade: "failed",
1393
+ error
1394
+ };
1395
+ }
1396
+ }
1397
+ //#endregion
1080
1398
  //#region src/pre-fill-wiring.ts
1399
+ /** 두 후보를 같은 초기 상태에서 못 돌렸을 때의 첫 줄. 아래에 등급 문장이 이어 붙는다. */
1400
+ const COMPARE_SKIPPED_HEAD = "사전보완 비교를 건너뜁니다: 두 후보를 같은 초기 상태에서 실행할 수 없습니다.";
1401
+ /**
1402
+ * 비교 불가 사유. 앞·사이 두 등급 중 비교 불가인 것의 문장을 이어 붙인다.
1403
+ *
1404
+ * 둘이 같은 등급이면 한 번만 찍는다. 다르면 **둘 다 찍는다.** `failed` 가 `none` 보다
1405
+ * 약하다고 보지 않는다. 둘은 다음에 할 일이 다르다. `none` 은 `--reset-cmd` 를 주는 것이고
1406
+ * `failed` 는 그 명령을 고치는 것이다.
1407
+ */
1408
+ function compareSkippedReason(before, between) {
1409
+ const lines = [];
1410
+ for (const grade of [before, between]) if (!resetIsComparable(grade) && !lines.includes(RESET_GRADE_LINE[grade])) lines.push(RESET_GRADE_LINE[grade]);
1411
+ return [COMPARE_SKIPPED_HEAD, ...lines].join("\n");
1412
+ }
1413
+ /**
1414
+ * 시험 실행 상세의 본문 들여쓰기. `dry-run.ts` 의 `INDENT` 와 같은 값이다.
1415
+ * 그 파일을 다른 태스크가 고치고 있어 import 하지 않고 여기에 둔다.
1416
+ */
1417
+ const DETAIL_INDENT = " ";
1418
+ /** 서버 위반 줄의 접두사. 들여쓰기 뒤에 붙는다. */
1419
+ const VIOLATION_PREFIX = `${DETAIL_INDENT}→ `;
1420
+ /**
1421
+ * 시험 실행 상세에서 서버가 낸 위반 줄만 뽑아 개행으로 잇는다.
1422
+ * 위반 줄이 없으면 들여쓰기가 있는 첫 본문 줄을 그대로 쓴다. 그것도 없으면 빈 문자열이다.
1423
+ * 관측하지 못한 것을 관측했다고 적지 않는다(설계 §4.1).
1424
+ */
1425
+ function serverMessageOf$1(detail) {
1426
+ const lines = detail.split("\n");
1427
+ const violations = lines.filter((line) => line.startsWith(VIOLATION_PREFIX)).map((line) => line.slice(VIOLATION_PREFIX.length));
1428
+ if (violations.length > 0) return violations.join("\n");
1429
+ const first = lines.find((line) => line.startsWith(DETAIL_INDENT) && line.trim() !== "");
1430
+ return first === void 0 ? "" : first.slice(4);
1431
+ }
1081
1432
  const plainObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1082
1433
  /** 케이스 하나에 제안 값들을 얹은 입력. 원본을 바꾸지 않는다. */
1083
1434
  function withProposals(input, proposals) {
@@ -1135,8 +1486,39 @@ async function applyPreFill(options) {
1135
1486
  suite: baseline,
1136
1487
  cases: [],
1137
1488
  adopted: 0,
1138
- notAdopted: 0
1489
+ notAdopted: 0,
1490
+ held: 0,
1491
+ excluded: [],
1492
+ compared: true
1139
1493
  };
1494
+ const excluded = [];
1495
+ const specById = new Map(baseline.cases.map((item) => [item.id, item]));
1496
+ for (const [caseId, proposals] of byCase) {
1497
+ const item = specById.get(caseId);
1498
+ for (const proposal of proposals) {
1499
+ if (item === void 0) {
1500
+ excluded.push({
1501
+ caseId,
1502
+ field: proposal.field,
1503
+ reason: "명세에 없는 케이스"
1504
+ });
1505
+ continue;
1506
+ }
1507
+ if (item.operation.type !== "callTool") {
1508
+ excluded.push({
1509
+ caseId,
1510
+ field: proposal.field,
1511
+ reason: "callTool 이 아닌 케이스"
1512
+ });
1513
+ continue;
1514
+ }
1515
+ if (!plainObject$1(item.operation.input)) excluded.push({
1516
+ caseId,
1517
+ field: proposal.field,
1518
+ reason: "입력이 객체가 아닌 케이스"
1519
+ });
1520
+ }
1521
+ }
1140
1522
  const targetIds = /* @__PURE__ */ new Set();
1141
1523
  const aiInputs = /* @__PURE__ */ new Map();
1142
1524
  for (const item of baseline.cases) {
@@ -1152,31 +1534,44 @@ async function applyPreFill(options) {
1152
1534
  suite: baseline,
1153
1535
  cases: [],
1154
1536
  adopted: 0,
1155
- notAdopted: 0
1537
+ notAdopted: 0,
1538
+ held: 0,
1539
+ excluded,
1540
+ compared: true
1156
1541
  };
1542
+ const resetOnce = options.reset ?? (async () => "none");
1543
+ const gradeBefore = await resetOnce();
1157
1544
  const baselineRun = await run({
1158
1545
  client,
1159
1546
  suite: subsetSuite(baseline, targetIds)
1160
1547
  });
1548
+ const gradeBetween = await resetOnce();
1161
1549
  const aiRun = await run({
1162
1550
  client,
1163
1551
  suite: subsetSuite(baseline, targetIds, aiInputs)
1164
1552
  });
1553
+ const comparable = resetIsComparable(gradeBefore) && resetIsComparable(gradeBetween);
1165
1554
  const aborted = baselineRun.aborted !== void 0 || aiRun.aborted !== void 0;
1166
1555
  const baselinePassed = passedIds(baselineRun);
1167
1556
  const aiPassed = passedIds(aiRun);
1557
+ const aiDetails = new Map(aiRun.outcomes.map((outcome) => [outcome.caseId, outcome.detail]));
1168
1558
  const cases = [];
1169
1559
  const adoptedIds = /* @__PURE__ */ new Set();
1170
1560
  for (const item of baseline.cases) {
1171
1561
  if (!targetIds.has(item.id)) continue;
1172
1562
  const basePass = baselinePassed.has(item.id);
1173
1563
  const aiPass = aiPassed.has(item.id);
1174
- const useAi = !aborted && !basePass && aiPass;
1564
+ const useAi = comparable && !aborted && !basePass && aiPass;
1175
1565
  if (useAi) adoptedIds.add(item.id);
1566
+ const held = comparable && !aborted && !basePass && !aiPass;
1176
1567
  cases.push({
1177
1568
  caseId: item.id,
1178
1569
  source: useAi ? "ai" : "baseline",
1179
- needsClassification: !aborted && !basePass && !aiPass
1570
+ needsClassification: held,
1571
+ ...held ? {
1572
+ proposedFields: byCase.get(item.id) ?? [],
1573
+ serverMessage: serverMessageOf$1(aiDetails.get(item.id) ?? "")
1574
+ } : {}
1180
1575
  });
1181
1576
  }
1182
1577
  const adopted = adoptedIds.size;
@@ -1187,7 +1582,11 @@ async function applyPreFill(options) {
1187
1582
  },
1188
1583
  cases,
1189
1584
  adopted,
1190
- notAdopted: cases.length - adopted
1585
+ notAdopted: cases.length - adopted,
1586
+ held: cases.filter((item) => item.needsClassification).length,
1587
+ excluded,
1588
+ compared: comparable,
1589
+ ...comparable ? {} : { skippedReason: compareSkippedReason(gradeBefore, gradeBetween) }
1191
1590
  };
1192
1591
  }
1193
1592
  /** 점 표기 경로를 따라 필드 스키마를 찾는다. 못 찾으면 undefined 다. */
@@ -1469,196 +1868,88 @@ async function proposeRepair(options) {
1469
1868
  } catch {
1470
1869
  return UNAVAILABLE;
1471
1870
  }
1472
- }
1473
- //#endregion
1474
- //#region src/repair-target.ts
1475
- /** `renderReport` 가 케이스 본문 줄에 쓰는 들여쓰기. dry-run.ts 의 INDENT 와 같은 값이다. */
1476
- const INDENT = " ";
1477
- /** 단언 진단의 위반 줄 표시. 이 줄에만 서버가 돌려준 값이 실려 있다. */
1478
- const VIOLATION_MARK = "→ ";
1479
- /** 실패 사유 판정에 쓰는 단언 타입. 이 줄이 있어야 "정상 응답을 기대했는데 오류" 다. */
1480
- const IS_ERROR = "isError";
1481
- /** 케이스 본문 줄에서 들여쓰기를 벗긴다. 들여쓰기가 없는 줄은 본문이 아니다. */
1482
- const bodyLines = (detail) => detail.split("\n").filter((line) => line.startsWith(INDENT)).map((line) => line.slice(4));
1483
- /**
1484
- * 실패 사유가 `isError` 단언인가. 단언 줄은 타입 이름으로 시작하고, 진단 문장과 해결 줄은
1485
- * 그렇지 않다. 통과한 단언은 애초에 그려지지 않으므로 존재 자체가 실패의 근거다.
1486
- */
1487
- const failedByIsError = (detail) => bodyLines(detail).some((line) => line.startsWith(IS_ERROR));
1488
- /**
1489
- * 서버가 돌려준 오류 본문을 뽑는다. 문장을 새로 만들지 않고 위반 줄을 그대로 옮긴다.
1490
- * `isError` 진단에는 값이 실리지 않으므로 근거가 되는 것은 본문 단언의 위반 줄뿐이다.
1491
- * 뽑을 것이 없으면 빈 문자열이고, 그때 호출 측은 사람 입력으로 간다(§4.4).
1492
- */
1493
- const serverMessageOf = (detail) => bodyLines(detail).filter((line) => line.startsWith(VIOLATION_MARK)).map((line) => line.slice(2)).join("\n");
1494
- /**
1495
- * 위반 케이스인가. ADR-0022 가 만드는 케이스는 입력을 일부러 어긋나게 만든 것이라
1496
- * 고치면 케이스의 목적이 사라진다. 판별은 `isError` 단언의 `expected` 로 한다.
1497
- */
1498
- const expectsError = (spec) => spec.assertions.some((assertion) => assertion.type === "isError" && assertion.expected === true);
1499
- const toTarget = (spec, outcome, origins) => {
1500
- if (outcome.status === "passed") return void 0;
1501
- if (spec.operation.type !== "callTool") return void 0;
1502
- if (Object.keys(spec.operation.input).length === 0) return void 0;
1503
- if (origins.get(outcome.caseId) === "user") return void 0;
1504
- if (expectsError(spec)) return void 0;
1505
- if (!failedByIsError(outcome.detail)) return void 0;
1506
- return {
1507
- caseId: outcome.caseId,
1508
- caseName: outcome.caseName,
1509
- tool: spec.operation.tool,
1510
- input: spec.operation.input,
1511
- serverMessage: serverMessageOf(outcome.detail)
1512
- };
1513
- };
1514
- /**
1515
- * 교정 대상을 고른다. 반환 배열은 `outcomes` 순서다. 정렬하지 않는다.
1516
- * 순서를 바꾸면 화면 번호가 앞선 결과 화면과 어긋난다.
1517
- */
1518
- function selectRepairTargets(options) {
1519
- const specs = new Map(options.suite.cases.map((spec) => [spec.id, spec]));
1520
- const targets = [];
1521
- for (const outcome of options.outcomes) {
1522
- const spec = specs.get(outcome.caseId);
1523
- if (spec === void 0) continue;
1524
- const target = toTarget(spec, outcome, options.origins);
1525
- if (target !== void 0) targets.push(target);
1526
- }
1527
- return targets;
1528
- }
1529
- //#endregion
1530
- //#region src/reset-hook.ts
1531
- /**
1532
- * 시험 실행 직전에 서버 상태를 되돌리는 초기화 명령을 실행한다. ADR-0023 의 결정을 구현한다.
1533
- *
1534
- * 셸을 거치지 않는다. `;`·`&&`·백틱은 해석되지 않고 그대로 인자가 된다. 실행 파일을 감싼
1535
- * 큰따옴표만 경계로 인식하며 인자의 따옴표는 해석하지 않는다. 공백이 든 인자가 필요하면
1536
- * 사용자가 스크립트 파일을 만들어야 한다. 이 제약은 도움말에 적힌 사양이지 구현 편의가 아니다.
1537
- */
1538
- /** 초기화 명령의 제한 시간. 설계 문서 §6. */
1539
- const TIMEOUT_MS = 6e4;
1540
- /** 보관하는 stderr 상한. 실패 안내에 쓰는 꼬리만 있으면 되고 그 이상은 화면을 덮는다. */
1541
- const MAX_STDERR_BYTES = 8 * 1024;
1542
- /** 제한 시간 초과일 때 `stderr` 에 넣는 문장. 종료 코드가 없으므로 사유가 여기에만 남는다. */
1543
- const TIMEOUT_STDERR = `타임아웃(60초)`;
1544
- /**
1545
- * 초기화 명령이 정상 종료하지 못한 경우. 시험 실행을 시작하지 않고 저장도 하지 않는 사유가
1546
- * 되므로, 호출 측이 화면에 쓸 수 있도록 명령·종료 코드·stderr 을 그대로 들고 다닌다.
1547
- *
1548
- * `exitCode` 가 `null` 인 경우는 셋이다. 실행 파일이 없거나(ENOENT), 시그널로 죽었거나,
1549
- * 제한 시간을 넘겨 우리가 죽였을 때다.
1550
- */
1551
- var ResetCommandError = class extends Error {
1552
- command;
1553
- exitCode;
1554
- stderr;
1555
- constructor(command, exitCode, stderr) {
1556
- super(`초기화 명령이 실패했습니다: ${command}`);
1557
- this.command = command;
1558
- this.exitCode = exitCode;
1559
- this.stderr = stderr;
1560
- this.name = "ResetCommandError";
1561
- }
1562
- };
1563
- /**
1564
- * stderr 의 **마지막** 상한만큼을 모은다. 앞부분을 버리는 것이 요점이다. 화면에 쓰는 것은
1565
- * 마지막 3줄이고, 실패 원인은 출력의 끝에 있다. 앞에서 잘라 보관하면 긴 출력을 내는 명령에서
1566
- * 정작 사유가 담긴 마지막 줄이 사라진다.
1567
- */
1568
- var StderrTail = class {
1569
- chunks = [];
1570
- size = 0;
1571
- push(chunk) {
1572
- this.chunks.push(chunk);
1573
- this.size += chunk.byteLength;
1574
- while (this.size > MAX_STDERR_BYTES) {
1575
- const first = this.chunks[0];
1576
- if (first === void 0) break;
1577
- const excess = this.size - MAX_STDERR_BYTES;
1578
- if (first.byteLength <= excess) {
1579
- this.chunks.shift();
1580
- this.size -= first.byteLength;
1581
- } else {
1582
- this.chunks[0] = first.subarray(excess);
1583
- this.size -= excess;
1584
- }
1585
- }
1586
- }
1587
- text() {
1588
- return Buffer.concat(this.chunks).toString("utf8");
1589
- }
1590
- };
1591
- /**
1592
- * 초기화 명령을 한 번 실행한다. 재시도하지 않는다.
1593
- *
1594
- * stdout 은 아예 받지 않는다. 성공 경로에서 남의 명령 출력을 우리 화면에 섞지 않기 위함이고,
1595
- * 버퍼 상한에 걸려 멀쩡한 명령이 죽는 일도 없앤다.
1596
- *
1597
- * 빈 문자열이나 공백뿐인 명령은 `ResetCommandError` 가 아니라 `TypeError` 다. 옵션 파싱에서
1598
- * 이미 걸러야 하는 값이고, 여기까지 왔다면 사용자 입력 문제가 아니라 호출 측 결함이다.
1599
- */
1600
- async function runResetCommand(command) {
1601
- let tokens;
1602
- try {
1603
- tokens = (0, _mcpeak_core.tokenizeCommand)(command);
1604
- } catch (error) {
1605
- if (!(error instanceof _mcpeak_core.CommandParseError)) throw error;
1606
- const reason = {
1607
- EMPTY_EXECUTABLE: "실행 파일 경로가 비어 있습니다.",
1608
- UNTERMINATED_EXECUTABLE_QUOTE: "실행 파일을 감싼 큰따옴표가 닫히지 않았습니다.",
1609
- MISSING_EXECUTABLE_ARGUMENT_SEPARATOR: "실행 파일을 감싼 큰따옴표 뒤에는 공백이 필요합니다."
1610
- }[error.code];
1611
- throw new ResetCommandError(command, null, `잘못된 초기화 명령: ${reason}`);
1612
- }
1613
- const [file, ...args] = tokens;
1614
- if (file === void 0) throw new TypeError("초기화 명령이 비어 있습니다.");
1615
- return new Promise((resolve, reject) => {
1616
- const stderr = new StderrTail();
1617
- let timedOut = false;
1618
- let settled = false;
1619
- const child = (0, node_child_process.spawn)(file, args, {
1620
- stdio: [
1621
- "ignore",
1622
- "ignore",
1623
- "pipe"
1624
- ],
1625
- shell: false
1626
- });
1627
- const timer = setTimeout(() => {
1628
- timedOut = true;
1629
- child.kill("SIGKILL");
1630
- }, TIMEOUT_MS);
1631
- const finish = (settle) => {
1632
- if (settled) return;
1633
- settled = true;
1634
- clearTimeout(timer);
1635
- settle();
1636
- };
1637
- child.stderr?.on("data", (chunk) => {
1638
- stderr.push(chunk);
1639
- });
1640
- child.on("error", (error) => {
1641
- finish(() => {
1642
- reject(new ResetCommandError(command, null, error.message));
1643
- });
1644
- });
1645
- child.on("close", (code, signal) => {
1646
- finish(() => {
1647
- if (timedOut) {
1648
- reject(new ResetCommandError(command, null, TIMEOUT_STDERR));
1649
- return;
1650
- }
1651
- if (code === 0) {
1652
- resolve();
1653
- return;
1654
- }
1655
- reject(new ResetCommandError(command, code, stderr.text() || describeSignal(signal)));
1871
+ }
1872
+ //#endregion
1873
+ //#region src/repair-target.ts
1874
+ /** `renderReport` 가 케이스 본문 줄에 쓰는 들여쓰기. dry-run.ts 의 INDENT 와 같은 값이다. */
1875
+ const INDENT = " ";
1876
+ /** 단언 진단의 위반 줄 표시. 이 줄에만 서버가 돌려준 값이 실려 있다. */
1877
+ const VIOLATION_MARK = "→ ";
1878
+ /** 실패 사유 판정에 쓰는 단언 타입. 이 줄이 있어야 "정상 응답을 기대했는데 오류" 다. */
1879
+ const IS_ERROR = "isError";
1880
+ /** `renderReport` 가 건너뛴 단언 줄에 붙이는 표시. 건너뛴 단언은 실패가 아니다. */
1881
+ const SKIPPED_MARK = "(건너뜀) ";
1882
+ /** 케이스 본문 줄에서 들여쓰기를 벗긴다. 들여쓰기가 없는 줄은 본문이 아니다. */
1883
+ const bodyLines = (detail) => detail.split("\n").filter((line) => line.startsWith(INDENT)).map((line) => line.slice(4));
1884
+ /**
1885
+ * 실패 사유가 `isError` 단언인가. 단언 줄은 타입 이름으로 시작하고, 진단 문장과 해결 줄은
1886
+ * 그렇지 않다.
1887
+ *
1888
+ * **`(건너뜀) ` 이 붙은 줄은 실패가 아니다.** 호출이 끝나지 못하면 단언이 전부 건너뛰기로
1889
+ * 그려지는데, 그것을 실패로 읽어 못 고칠 케이스를 교정 대상에 올리던 것이 이번 결함이다.
1890
+ * 앞단에서 `operationFailed` 로 이미 가르지만 여기서도 방어적으로 뺀다.
1891
+ */
1892
+ const failedByIsError = (detail) => bodyLines(detail).some((line) => line.startsWith(IS_ERROR) && !line.includes(SKIPPED_MARK));
1893
+ /**
1894
+ * 서버가 돌려준 오류 본문을 뽑는다. 문장을 새로 만들지 않고 위반 줄을 그대로 옮긴다.
1895
+ * `isError` 진단에는 값이 실리지 않으므로 근거가 되는 것은 본문 단언의 위반 줄뿐이다.
1896
+ * 뽑을 것이 없으면 빈 문자열이고, 그때 호출 측은 사람 입력으로 간다(§4.4).
1897
+ */
1898
+ const serverMessageOf = (detail) => bodyLines(detail).filter((line) => line.startsWith(VIOLATION_MARK)).map((line) => line.slice(2)).join("\n");
1899
+ /**
1900
+ * 위반 케이스인가. ADR-0022 가 만드는 케이스는 입력을 일부러 어긋나게 만든 것이라
1901
+ * 고치면 케이스의 목적이 사라진다. 판별은 `isError` 단언의 `expected` 로 한다.
1902
+ */
1903
+ const expectsError = (spec) => spec.assertions.some((assertion) => assertion.type === "isError" && assertion.expected === true);
1904
+ /**
1905
+ * 교정 갈래와 고지 갈래가 **공유하는** 앞단 조건. 둘로 나눠 쓰면 조건이 갈리는 날 어느
1906
+ * 케이스가 양쪽에 다 들어가거나 어디에도 안 들어간다.
1907
+ */
1908
+ const eligible = (spec, outcome, origins) => {
1909
+ if (outcome.status === "passed") return false;
1910
+ if (spec.operation.type !== "callTool") return false;
1911
+ if (Object.keys(spec.operation.input).length === 0) return false;
1912
+ if (origins.get(outcome.caseId) === "user") return false;
1913
+ if (expectsError(spec)) return false;
1914
+ return true;
1915
+ };
1916
+ /**
1917
+ * 교정 대상과 못 고칠 실패를 한 번 순회해 가른다. 두 배열 모두 `outcomes` 순서다.
1918
+ * 정렬하지 않는다. 순서를 바꾸면 화면 번호가 앞선 결과 화면과 어긋난다.
1919
+ */
1920
+ function selectRepairTargets(options) {
1921
+ const specs = new Map(options.suite.cases.map((spec) => [spec.id, spec]));
1922
+ const targets = [];
1923
+ const thrown = [];
1924
+ for (const outcome of options.outcomes) {
1925
+ const spec = specs.get(outcome.caseId);
1926
+ if (spec === void 0) continue;
1927
+ if (!eligible(spec, outcome, options.origins)) continue;
1928
+ if (outcome.operationFailed) {
1929
+ thrown.push({
1930
+ caseId: outcome.caseId,
1931
+ caseName: outcome.caseName,
1932
+ failureLine: outcome.failureLine,
1933
+ serverMessage: serverMessageOf(outcome.detail)
1656
1934
  });
1935
+ continue;
1936
+ }
1937
+ if (!failedByIsError(outcome.detail)) continue;
1938
+ if (spec.operation.type !== "callTool") continue;
1939
+ targets.push({
1940
+ caseId: outcome.caseId,
1941
+ caseName: outcome.caseName,
1942
+ tool: spec.operation.tool,
1943
+ input: spec.operation.input,
1944
+ serverMessage: serverMessageOf(outcome.detail),
1945
+ failureLine: outcome.failureLine
1657
1946
  });
1658
- });
1947
+ }
1948
+ return {
1949
+ targets,
1950
+ thrown
1951
+ };
1659
1952
  }
1660
- /** stderr 이 비어 있는데 시그널로 죽었으면 그 사실만이라도 남긴다. */
1661
- const describeSignal = (signal) => signal === null ? "" : `시그널 ${signal} 로 종료되었습니다.`;
1662
1953
  //#endregion
1663
1954
  //#region src/generate-command.ts
1664
1955
  var UsageError$1 = class extends Error {};
@@ -1757,6 +2048,7 @@ const optionNames$1 = /* @__PURE__ */ new Set([
1757
2048
  "--out",
1758
2049
  "--command",
1759
2050
  "--arg",
2051
+ "--env",
1760
2052
  "--url",
1761
2053
  "--header-env",
1762
2054
  "--baseline-only",
@@ -1764,7 +2056,9 @@ const optionNames$1 = /* @__PURE__ */ new Set([
1764
2056
  "--model",
1765
2057
  "--no-dry-run",
1766
2058
  "--reset-cmd",
2059
+ "--fixtures",
1767
2060
  "--no-repair",
2061
+ "--diagnose-rejections",
1768
2062
  "--force"
1769
2063
  ]);
1770
2064
  /** 값을 받지 않는 옵션. `=` 를 붙여 쓸 수 없고 두 번 쓸 수 없다. */
@@ -1772,6 +2066,7 @@ const flagNames$1 = /* @__PURE__ */ new Set([
1772
2066
  "--baseline-only",
1773
2067
  "--no-dry-run",
1774
2068
  "--no-repair",
2069
+ "--diagnose-rejections",
1775
2070
  "--force"
1776
2071
  ]);
1777
2072
  const removedGenerateOptions = /* @__PURE__ */ new Set(["--cassette", "--record"]);
@@ -1803,6 +2098,7 @@ function parseGenerateCommand(argv) {
1803
2098
  const args = [];
1804
2099
  const flags = /* @__PURE__ */ new Set();
1805
2100
  const headerEnv = createHeaderEnvCollector();
2101
+ const envForward = createEnvForwardCollector();
1806
2102
  for (let index = 0; index < argv.length; index++) {
1807
2103
  const item = argv[index];
1808
2104
  if (item === void 0) continue;
@@ -1843,6 +2139,13 @@ function parseGenerateCommand(argv) {
1843
2139
  if (rejected !== void 0) throw new UsageError$1(rejected);
1844
2140
  continue;
1845
2141
  }
2142
+ if (option === "--env") {
2143
+ const parsed = parseEnvForwardOption(value);
2144
+ if (!parsed.ok) throw new UsageError$1(parsed.message);
2145
+ const rejected = envForward.add(parsed.value);
2146
+ if (rejected !== void 0) throw new UsageError$1(rejected);
2147
+ continue;
2148
+ }
1846
2149
  if (values.has(option)) throw new UsageError$1(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
1847
2150
  values.set(option, value);
1848
2151
  }
@@ -1859,6 +2162,7 @@ function parseGenerateCommand(argv) {
1859
2162
  if (command !== void 0 && rawUrl !== void 0) throw new UsageError$1("`--command` 와 `--url` 은 함께 쓸 수 없습니다.\n→ `--command` 는 서버를 프로세스로 띄우고, `--url` 은 이미 떠 있는 원격 서버에 붙습니다.\n→ 둘 중 무엇에서 명세를 뽑을지 하나만 고르세요.");
1860
2163
  if (command === void 0 && rawUrl === void 0) throw new UsageError$1("`--command` 또는 `--url` 옵션이 필요합니다.");
1861
2164
  if (rawUrl !== void 0 && args.length > 0) throw new UsageError$1("`--arg` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--arg` 는 우리가 띄우는 프로세스에 넘길 인자입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
2165
+ if (rawUrl !== void 0 && envForward.snapshot().length > 0) throw new UsageError$1("`--env` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--env` 는 우리가 띄우는 프로세스의 환경에 넣을 변수입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
1862
2166
  if (command !== void 0 && !headerEnv.isEmpty()) throw new UsageError$1("`--header-env` 는 `--url` 과 함께만 쓸 수 있습니다.\n→ 헤더는 HTTP 요청에 실립니다. `--command` 로 띄운 서버와는 stdio 로 이야기합니다.");
1863
2167
  const url = (() => {
1864
2168
  if (rawUrl === void 0) return void 0;
@@ -1874,9 +2178,13 @@ function parseGenerateCommand(argv) {
1874
2178
  const dryRun = !flags.has("--no-dry-run");
1875
2179
  const repair = !flags.has("--no-repair");
1876
2180
  if (!dryRun && !repair) throw new UsageError$1("`--no-dry-run`과 `--no-repair`는 함께 사용할 수 없습니다.");
2181
+ const diagnoseRejections = flags.has("--diagnose-rejections");
2182
+ if (diagnoseRejections && !dryRun) throw new UsageError$1("`--diagnose-rejections`는 `--no-dry-run`과 함께 사용할 수 없습니다. 시험 실행이 없으면 확인할 거절이 없습니다.");
1877
2183
  const resetCmd = values.get("--reset-cmd");
1878
2184
  if (!dryRun && resetCmd !== void 0) throw new UsageError$1("`--no-dry-run`과 `--reset-cmd`는 함께 사용할 수 없습니다.");
1879
2185
  if (resetCmd !== void 0 && resetCmd.trim() === "") throw new UsageError$1("`--reset-cmd` 옵션 값이 필요합니다.");
2186
+ const fixturesPath = values.get("--fixtures");
2187
+ if (fixturesPath !== void 0 && fixturesPath.trim() === "") throw new UsageError$1("`--fixtures` 옵션 값이 필요합니다.");
1880
2188
  return Object.freeze({
1881
2189
  suiteId: values.get("--suite-id"),
1882
2190
  name: values.get("--name"),
@@ -1884,7 +2192,8 @@ function parseGenerateCommand(argv) {
1884
2192
  target: url === void 0 ? Object.freeze({
1885
2193
  transport: "stdio",
1886
2194
  command,
1887
- args: Object.freeze(args)
2195
+ args: Object.freeze(args),
2196
+ envNames: envForward.snapshot()
1888
2197
  }) : Object.freeze({
1889
2198
  transport: "http",
1890
2199
  url,
@@ -1896,7 +2205,9 @@ function parseGenerateCommand(argv) {
1896
2205
  dryRun,
1897
2206
  force: flags.has("--force"),
1898
2207
  resetCmd,
1899
- repair
2208
+ repair,
2209
+ diagnoseRejections,
2210
+ fixturesPath
1900
2211
  });
1901
2212
  }
1902
2213
  /**
@@ -1963,6 +2274,8 @@ async function saveSuite(input, suite, fingerprint, deps, approvals = []) {
1963
2274
  }
1964
2275
  }
1965
2276
  const defaultModel = (provider) => provider === "codex" ? "gpt-5.6-luna" : "haiku";
2277
+ /** 대시보드의 검토 하위 입력에서 상위 메뉴로 돌아갈 때 쓰는 내부 제어값. */
2278
+ const REVIEW_BACK_VALUE = "__mcpeak_review_back__";
1966
2279
  /**
1967
2280
  * 전송 승인 화면. **통로마다 보내는 것이 다르므로 목록을 받는다**(#286) — 입력값 교정은
1968
2281
  * 실패한 케이스의 서버 응답 원문까지 싣는데, 기본 문구로 덮으면 화면이 사실보다 적게 말한다.
@@ -2194,8 +2507,221 @@ function writeDryRunNotice(io, notice) {
2194
2507
  }
2195
2508
  io.write("\n이 실행은 서버 상태를 바꿀 수 있습니다. 입력 검증이 없는 서버라면 외부 API 호출도\n그대로 나갑니다.\n");
2196
2509
  }
2510
+ /** `--fixtures` 를 안 주면 보는 경로. 없으면 조용히 지나간다(설계 §2.3). */
2511
+ const DEFAULT_FIXTURES_PATH = "mcpeak.fixtures.json";
2512
+ /**
2513
+ * 픽스처 파일을 읽는다. 읽을 수 없으면 사유를 stderr 에 적고 `null` 을 돌려준다(호출자가
2514
+ * 종료 코드 1 로 끝낸다).
2515
+ *
2516
+ * 파일 접근은 `deps.exists` · `deps.readFile` 주입점만 쓴다. `node:fs` 를 직접 부르면
2517
+ * 유닛테스트가 실제 파일 시스템을 타게 되고, 그것이 ADR-0013 이 막은 것이다.
2518
+ */
2519
+ async function loadFixtures(input, deps) {
2520
+ const explicit = input.fixturesPath !== void 0;
2521
+ const path = input.fixturesPath ?? DEFAULT_FIXTURES_PATH;
2522
+ let found;
2523
+ try {
2524
+ found = await deps.exists(path);
2525
+ } catch (error) {
2526
+ if (!explicit) return {};
2527
+ const code = error?.code;
2528
+ deps.writeStderr(`오류 [GENERATE_FIXTURE_UNREADABLE]: 픽스처 파일을 확인하지 못했습니다. 경로: ${path}${typeof code === "string" ? ` (${code})` : ""}\n해결: 그 경로와 상위 디렉터리의 권한을 확인하세요.\n`);
2529
+ return null;
2530
+ }
2531
+ if (!found) {
2532
+ if (!explicit) return {};
2533
+ deps.writeStderr(`오류 [GENERATE_FIXTURE_NOT_FOUND]: 픽스처 파일이 없습니다. 경로: ${path}\n해결: 경로를 확인하거나 \`--fixtures\` 를 빼고 실행하세요.\n`);
2534
+ return null;
2535
+ }
2536
+ let text;
2537
+ try {
2538
+ text = new TextDecoder("utf-8", { fatal: true }).decode(await deps.readFile(path));
2539
+ } catch (error) {
2540
+ const reason = error instanceof Error ? error.message : String(error);
2541
+ deps.writeStderr(`오류 [GENERATE_FIXTURE_UNREADABLE]: 픽스처 파일을 읽지 못했습니다. 경로: ${path} (${escapeTerminalText$2(reason)})\n해결: 파일이 UTF-8 텍스트인지, 읽기 권한이 있는지 확인하세요.\n`);
2542
+ return null;
2543
+ }
2544
+ const read = (0, _mcpeak_generate.readFixtureFile)(text);
2545
+ if (read.status === "invalid") {
2546
+ deps.writeStderr(`오류 [GENERATE_FIXTURE_INVALID]: ${escapeTerminalText$2(read.reason)} 경로: ${path}\n해결: 픽스처 파일을 고친 뒤 다시 실행하세요.\n`);
2547
+ return null;
2548
+ }
2549
+ const tools = Object.values(read.file.tools ?? {});
2550
+ const fields = tools.reduce((total, item) => total + Object.keys(item).length, 0);
2551
+ deps.writeStdout(`▸ 픽스처: ${escapeTerminalText$2(path)} (도구 ${tools.length}개, 필드 ${fields}개)\n`);
2552
+ return { file: read.file };
2553
+ }
2554
+ /**
2555
+ * 교정한 값을 픽스처 파일로 되돌린다(#390 T3).
2556
+ *
2557
+ * **기존 파일을 절대 고치지 않는다.** 병합 로직을 만들지 않는다. 파일이 있으면 붙여 넣을
2558
+ * 조각만 찍는다. 덮어쓸 수 있는 primitive 를 두지 않는다는 것이 이 저장소의 결론이고
2559
+ * (`docs/reports/task-r4.md`), `rename` 으로 떨어지고 싶어지는 자리에서 그 결정을
2560
+ * 우회하지 않는다. `openTemp` → `link` → `unlink` 만 쓴다.
2561
+ */
2562
+ async function writeBackFixtures(input, deps, io, targets, repairs) {
2563
+ const { entries, nestedFields } = collectFixtureWriteback(targets, repairs);
2564
+ if (nestedFields.length > 0) io.write(`▸ 중첩 경로 ${nestedFields.length}건은 픽스처에 적을 수 없습니다(최상위 필드만 받습니다): ${nestedFields.join(", ")}\n`);
2565
+ if (entries.length === 0) return;
2566
+ const path = input.fixturesPath ?? DEFAULT_FIXTURES_PATH;
2567
+ let exists;
2568
+ /** 확인 자체가 실패했을 때의 오류 코드. 파일이 **있는 것과 다르다.** */
2569
+ let checkFailed;
2570
+ try {
2571
+ exists = await deps.exists(path);
2572
+ } catch (error) {
2573
+ exists = true;
2574
+ const code = error?.code;
2575
+ checkFailed = typeof code === "string" ? code : "알 수 없는 오류";
2576
+ }
2577
+ if (checkFailed !== void 0) {
2578
+ io.write(`픽스처 파일이 있는지 확인하지 못해 쓰지 않았습니다: ${path} (${checkFailed})\n아래 조각을 직접 붙여 넣으세요.
2579
+
2580
+ ` + renderFixtureSnippet(entries));
2581
+ return;
2582
+ }
2583
+ if (exists) {
2584
+ io.write(`교정한 값 ${entries.length}건을 픽스처에 반영하려면 ${path} 의 "tools" 아래에 붙이세요.\n기존 파일이 있어 우리가 고치지 않습니다.
2585
+
2586
+ ` + renderFixtureSnippet(entries));
2587
+ return;
2588
+ }
2589
+ if (!io.interactive) {
2590
+ io.write(`교정한 값 ${entries.length}건을 픽스처로 저장할 수 있습니다. 대화형으로 다시 실행하면 ${path} 에 저장할지 묻습니다.\n\n` + renderFixtureSnippet(entries));
2591
+ return;
2592
+ }
2593
+ io.write(`교정한 값 ${entries.length}건을 ${path} 에 저장할까요?\n`);
2594
+ const width = Math.max(...entries.map((entry) => `${entry.tool}.${entry.field}`.length));
2595
+ for (const entry of entries) io.write(` ${`${entry.tool}.${entry.field}`.padEnd(width)} ${JSON.stringify(entry.value)} ${ORIGIN_LABEL[entry.origin]}\n`);
2596
+ if (!await io.confirm("저장할까요?")) return;
2597
+ const temporary = temporaryPath(path);
2598
+ let created = false;
2599
+ try {
2600
+ const handle = await deps.openTemp(temporary);
2601
+ created = true;
2602
+ try {
2603
+ await handle.writeFile(renderFixtureFile(entries), "utf8");
2604
+ await handle.sync();
2605
+ } finally {
2606
+ await handle.close();
2607
+ }
2608
+ await deps.link(temporary, path);
2609
+ io.write(`▸ 픽스처를 저장했습니다: ${path}\n`);
2610
+ } catch (error) {
2611
+ const code = error?.code;
2612
+ io.write(`▸ 픽스처를 저장하지 못했습니다: ${path}${typeof code === "string" ? ` (${code})` : ""}\n 아래 조각을 직접 붙여 넣으세요.
2613
+
2614
+ ` + renderFixtureSnippet(entries));
2615
+ } finally {
2616
+ if (created) await deps.unlink(temporary).catch(() => void 0);
2617
+ }
2618
+ }
2619
+ /** UTF-16 코드 단위 안정 비교. `localeCompare` 는 로캘·ICU 데이터에 따라 결과가 달라진다. */
2620
+ const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
2621
+ /** 출처 표기. `humanRepaired` 와 `aiProposed` 를 갈라 적는다. */
2622
+ const ORIGIN_LABEL = {
2623
+ humanRepaired: "(사람이 입력)",
2624
+ aiProposed: "(AI 제안, 사람이 확인)"
2625
+ };
2626
+ /**
2627
+ * 교정으로 통과한 값을 도구·필드로 모은다.
2628
+ *
2629
+ * 통과한 시도만 본다. 안 통한 값을 픽스처에 적으면 다음 실행이 그 값으로 시작해 같은 이유로
2630
+ * 죽는다. 같은 `(도구, 필드)` 가 여러 케이스에서 나오면 **뒤 케이스가 앞을 덮지 않는다.**
2631
+ * 앞 케이스의 값이 캐시로 뒤에 퍼진 것이라 값이 같고, 다르다면 먼저 통과한 쪽을 쓴다.
2632
+ */
2633
+ function collectFixtureWriteback(targets, repairs) {
2634
+ const toolOf = new Map(targets.map((item) => [item.caseId, item.tool]));
2635
+ const entries = /* @__PURE__ */ new Map();
2636
+ const nested = /* @__PURE__ */ new Set();
2637
+ for (const repair of repairs) {
2638
+ const tool = toolOf.get(repair.caseId);
2639
+ if (tool === void 0) continue;
2640
+ for (const attempt of repair.attempts) {
2641
+ if (!attempt.passed) continue;
2642
+ if (attempt.field.includes(".") || attempt.field.includes("[")) {
2643
+ nested.add(`${tool}.${attempt.field}`);
2644
+ continue;
2645
+ }
2646
+ const key = `${tool}\u0000${attempt.field}`;
2647
+ if (entries.has(key)) continue;
2648
+ entries.set(key, {
2649
+ tool,
2650
+ field: attempt.field,
2651
+ value: attempt.value,
2652
+ origin: attempt.origin
2653
+ });
2654
+ }
2655
+ }
2656
+ return {
2657
+ entries: [...entries.values()].sort((a, b) => a.tool === b.tool ? byCodeUnit(a.field, b.field) : byCodeUnit(a.tool, b.tool)),
2658
+ nestedFields: [...nested].sort(byCodeUnit)
2659
+ };
2660
+ }
2661
+ /**
2662
+ * 픽스처 파일 본문. **결정론적이어야 한다.** 도구·필드 이름 코드 단위 오름차순,
2663
+ * `JSON.stringify(x, null, 2)`, 끝 개행 하나다. 순서가 흔들리면 diff 가 매번 커진다.
2664
+ */
2665
+ function renderFixtureFile(entries) {
2666
+ const tools = {};
2667
+ for (const entry of entries) {
2668
+ const fields = tools[entry.tool] ?? {};
2669
+ fields[entry.field] = entry.value;
2670
+ tools[entry.tool] = fields;
2671
+ }
2672
+ return `${JSON.stringify({
2673
+ schemaVersion: 1,
2674
+ tools
2675
+ }, null, 2)}\n`;
2676
+ }
2677
+ /**
2678
+ * 기존 파일이 있을 때 찍는 조각. **붙여 넣을 수 있는 모양이어야 한다.**
2679
+ *
2680
+ * 사용자가 손으로 JSON 을 고치는 자리라 쉼표 하나가 틀리면 파일이 깨진다. 마지막 줄에
2681
+ * 쉼표를 붙이지 않는다.
2682
+ */
2683
+ function renderFixtureSnippet(entries) {
2684
+ const byTool = /* @__PURE__ */ new Map();
2685
+ for (const entry of entries) {
2686
+ const fields = byTool.get(entry.tool) ?? {};
2687
+ fields[entry.field] = entry.value;
2688
+ byTool.set(entry.tool, fields);
2689
+ }
2690
+ return `${[...byTool.entries()].map(([tool, fields]) => ` ${JSON.stringify(tool)}: ${JSON.stringify(fields)}`).join(",\n")}\n`;
2691
+ }
2692
+ /**
2693
+ * 실패한 케이스에 붙이는 자리값 안내(설계 §2.2).
2694
+ *
2695
+ * **필드 이름으로 추측하지 않는다.** 판정 근거는 "그 값을 우리가 지어냈는가" 하나이고,
2696
+ * 그것은 `fieldOrigins` 가 말해 주는 사실이다. 이름 표를 만들면 `q` 나 `target` 인 식별자를
2697
+ * 놓치고 이름이 `name` 인 순수 문자열에 잘못 붙는다.
2698
+ *
2699
+ * `userFixture` · `schemaDeclared` 에는 안 붙인다. 사용자가 실재를 보증했거나 서버가 준
2700
+ * 값이라 서버 쪽을 먼저 봐야 한다.
2701
+ */
2702
+ function placeholderGuidance(caseId, suite, fixtures) {
2703
+ if (fixtures === void 0) return "";
2704
+ const spec = suite.cases.find((item) => item.id === caseId);
2705
+ if (spec === void 0 || spec.operation.type !== "callTool") return "";
2706
+ if (spec.assertions.some((assertion) => assertion.type === "isError" && assertion.expected === true)) return "";
2707
+ const { tool, input } = spec.operation;
2708
+ let text = "";
2709
+ const seen = /* @__PURE__ */ new Set();
2710
+ for (const origin of fixtures.fieldOrigins) {
2711
+ if (origin.tool !== tool || origin.origin !== "schemaHint") continue;
2712
+ if (!Object.hasOwn(input, origin.field) || seen.has(origin.field)) continue;
2713
+ seen.add(origin.field);
2714
+ const field = escapeTerminalText$2(origin.field);
2715
+ const value = escapeTerminalText$2(JSON.stringify(input[origin.field]) ?? "undefined");
2716
+ text += ` ℹ 이 케이스의 '${field}' 값 ${value} 은 스키마의 type 만 보고 만든 자리값입니다.\n 실재하는 자원을 가리키지 않으므로 서버 결함이 아닐 수 있습니다.
2717
+ 해결: ${escapeTerminalText$2(fixtures.path)} 에 ${escapeTerminalText$2(tool)}.${field} 값을 지정하세요.\n`;
2718
+ const note = fixtures.notes?.[tool]?.[origin.field];
2719
+ if (note !== void 0) text += ` 메모: ${escapeTerminalText$2(note)}\n`;
2720
+ }
2721
+ return text;
2722
+ }
2197
2723
  /** 시험 실행 결과(§8.2). 0건인 종류는 찍지 않는다. */
2198
- function writeDryRunResult(io, result) {
2724
+ function writeDryRunResult(io, result, suite, fixtures) {
2199
2725
  const failures = result.outcomes.filter((outcome) => outcome.status !== "passed");
2200
2726
  const passed = result.outcomes.length - failures.length;
2201
2727
  io.write("\n");
@@ -2205,11 +2731,16 @@ function writeDryRunResult(io, result) {
2205
2731
  io.write("\n");
2206
2732
  io.write(failureHeading(index, outcome.caseName));
2207
2733
  io.write(detailBlock(outcome.detail));
2734
+ if (suite !== void 0) io.write(placeholderGuidance(outcome.caseId, suite, fixtures));
2208
2735
  }
2209
2736
  if (failures.length > 0) io.write("\n");
2210
2737
  }
2211
2738
  /**
2212
2739
  * 거절 근거 미확인 목록 (#89 · 설계 문서 §5.2). 시험 실행 결과 블록 바로 아래에 붙는다.
2740
+ * `--diagnose-rejections` 를 켰을 때만 부른다(ADR-0098).
2741
+ *
2742
+ * 무엇을 셀지는 부르는 쪽이 정해 배열로 넘긴다. 여기서 다시 거르면 같은 규칙이 두 곳에
2743
+ * 생기고, 한쪽만 고쳐지면 목록의 건수와 진단 건수가 소리 없이 갈린다.
2213
2744
  *
2214
2745
  * **이 케이스들은 통과했다.** 목록은 판정도 저장 여부도 바꾸지 않는다. `unverified` 는
2215
2746
  * "거절이 아니다" 가 아니라 "확인하지 못했다" 는 뜻이라, 문장이 실패나 결함이라고 말하지 않고
@@ -2219,8 +2750,7 @@ function writeDryRunResult(io, result) {
2219
2750
  * 여러 줄 응답이 한 줄이 되고 제어 문자도 함께 무해해진다. 자르기는 `runner` 가 이미 진단 값과
2220
2751
  * 같은 상한에서 했다(`clampObservedText`). 여기서 규칙을 새로 만들지 않는다.
2221
2752
  */
2222
- function writeRejectionUnverified(io, result) {
2223
- const unverified = result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
2753
+ function writeRejectionUnverified(io, unverified) {
2224
2754
  if (unverified.length === 0) return;
2225
2755
  const ids = unverified.map((outcome) => escapeTerminalText$2(outcome.caseId));
2226
2756
  const column = Math.max(...ids.map((id) => Array.from(id).length));
@@ -2272,11 +2802,10 @@ function writeRejectionDiagnosis(io, requested, results) {
2272
2802
  * 실패는 흐름을 끊지 않는다. 안내만 찍고 승인 화면이 이어진다. 진단은 참고이지 전제가 아니다.
2273
2803
  */
2274
2804
  async function askRejectionDiagnosis(options) {
2275
- const { io, deps, provider } = options;
2805
+ const { io, deps, provider, unverified } = options;
2276
2806
  const prepare = deps.prepareRejectionDiagnosisRequests;
2277
2807
  const dispatch = deps.dispatchRejectionDiagnosis;
2278
2808
  if (provider === void 0 || prepare === void 0 || dispatch === void 0) return;
2279
- const unverified = options.result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
2280
2809
  if (unverified.length === 0) return;
2281
2810
  const cases = unverified.flatMap((outcome) => {
2282
2811
  if (outcome.rejectionBody === void 0) return [];
@@ -2321,6 +2850,28 @@ async function askRejectionDiagnosis(options) {
2321
2850
  writeRejectionDiagnosis(io, cases.length, dispatched.results);
2322
2851
  }
2323
2852
  /**
2853
+ * 최종 전량 재검증을 못 했을 때의 문장. 상수로 둔다.
2854
+ *
2855
+ * 개별 케이스가 단독으로 통과한 것과 전량을 순서대로 돌려 통과한 것은 다르다. A 가 통과하고
2856
+ * B 가 단독으로 통과해도, A 다음에 B 를 돌리면 B 가 실패할 수 있다. 그 사실을 안 적으면
2857
+ * 사용자는 저장 뒤 첫 `mcpeak test` 에서 처음 안다(이슈 #399).
2858
+ */
2859
+ const FINAL_REVALIDATION_SKIPPED_LINES = "최종 전량 재검증을 하지 않았습니다. 개별 케이스 통과 기록만 있습니다.\n저장 후 `mcpeak test` 로 전체를 한 번 실행해 확인하세요.\n";
2860
+ /** 재검증 확인 화면. 실제 서버 호출이 는다는 사실을 묻기 전에 적는다. */
2861
+ function writeFinalRevalidationNotice(io, grade, caseCount) {
2862
+ io.write(`최종 명세를 처음부터 다시 실행해 검증합니다.
2863
+ ${RESET_GRADE_LINE[grade]}\n 실행할 케이스: ${caseCount}건 (실제 서버 호출입니다)\n`);
2864
+ }
2865
+ /** 저장 화면의 검증 범위 줄(§5). 검증 범위를 남기는 **유일한 자리**다. */
2866
+ function writeVerificationScope(io, verification, caseCount) {
2867
+ if (verification === "fullSuite") {
2868
+ io.write(` 검증 범위: ${caseCount}건을 최종 전량 실행에서 확인했습니다.\n`);
2869
+ return;
2870
+ }
2871
+ io.write(` 검증 범위: ${caseCount}건은 개별 실행에서만 확인했습니다. 전량 재검증을 하지 않았습니다.\n 저장 후 \`mcpeak test\` 로 전체를 한 번 실행해 확인하세요.
2872
+ `);
2873
+ }
2874
+ /**
2324
2875
  * 반영 요약(§8.8). 저장 확인 직전에 찍는다. 교정이 0건이면 아무것도 찍지 않는다.
2325
2876
  * 지문이 바뀐 이유가 화면에 남아야 사용자가 나중에 diff 를 보고 놀라지 않는다(§5.4).
2326
2877
  */
@@ -2417,7 +2968,7 @@ function writeResetFailure(io, error) {
2417
2968
  for (const line of error.stderr.split("\n").filter(Boolean).slice(-3)) io.write(` → ${line}\n`);
2418
2969
  io.write("저장하지 않았습니다. 초기화 명령을 고친 뒤 다시 save 를 고르세요.\n");
2419
2970
  }
2420
- async function runInteractiveReview(input, tools, session, deps, connection, skippedTools = []) {
2971
+ async function runInteractiveReview(input, tools, session, deps, connection, skippedTools = [], outputContractSkips = [], validBranchSkips = [], fixtures) {
2421
2972
  const io = deps.reviewIO;
2422
2973
  const prepare = deps.prepareAuthoringRequest;
2423
2974
  const dispatch = deps.dispatchAuthoringRequest;
@@ -2510,22 +3061,41 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2510
3061
  writeDryRunAborted(io, result, caseCount, diagnostics());
2511
3062
  continue;
2512
3063
  }
2513
- writeDryRunResult(io, result);
2514
- writeRejectionUnverified(io, result);
2515
- await askRejectionDiagnosis({
2516
- io,
2517
- deps,
2518
- result,
2519
- suite: dryRunSuite,
2520
- tools,
2521
- provider: preferred === void 0 ? void 0 : deps.rejectionProviders?.[preferred]?.(model ?? defaultModel(preferred)),
2522
- model: model ?? (preferred === void 0 ? "" : defaultModel(preferred))
2523
- });
2524
- const targets = input.repair ? selectRepairTargets({
3064
+ writeDryRunResult(io, result, dryRunSuite, fixtures);
3065
+ const unverified = result.outcomes.filter((outcome) => outcome.status === "passed" && outcome.rejectionBasis === "unverified");
3066
+ if (input.diagnoseRejections) {
3067
+ writeRejectionUnverified(io, unverified);
3068
+ await askRejectionDiagnosis({
3069
+ io,
3070
+ deps,
3071
+ unverified,
3072
+ suite: dryRunSuite,
3073
+ tools,
3074
+ provider: preferred === void 0 ? void 0 : deps.rejectionProviders?.[preferred]?.(model ?? defaultModel(preferred)),
3075
+ model: model ?? (preferred === void 0 ? "" : defaultModel(preferred))
3076
+ });
3077
+ } else if (unverified.length > 0) io.write(` 통과한 거절 케이스 ${unverified.length}건의 근거는 확인하지 않았습니다. --diagnose-rejections 로 목록과 AI 진단을 볼 수 있습니다.
3078
+
3079
+ `);
3080
+ const selection = input.repair ? selectRepairTargets({
2525
3081
  suite: dryRunSuite,
2526
3082
  outcomes: result.outcomes,
2527
3083
  origins: originsOf(session)
2528
- }) : [];
3084
+ }) : {
3085
+ targets: [],
3086
+ thrown: []
3087
+ };
3088
+ if (selection.thrown.length > 0) {
3089
+ io.write(` 교정 대상이 아닌 실패 ${selection.thrown.length}건 (호출 자체가 실패했습니다. 입력값 문제가 아닙니다)\n`);
3090
+ for (const item of selection.thrown) {
3091
+ io.write(` ${item.caseName}\n`);
3092
+ const body = item.serverMessage.split("\n").filter((line) => line !== "");
3093
+ if (body.length === 0) io.write(` ${item.failureLine}\n`);
3094
+ else for (const line of body) io.write(` → ${line}\n`);
3095
+ }
3096
+ io.write("\n");
3097
+ }
3098
+ const targets = selection.targets;
2529
3099
  let attempts;
2530
3100
  let effective = result;
2531
3101
  if (targets.length > 0) {
@@ -2579,6 +3149,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2579
3149
  });
2580
3150
  const repaired = new Map(repairs.filter((item) => item.repaired && item.input !== void 0).map((item) => [item.caseId, item.input]));
2581
3151
  attempts = new Map(repairs.filter((item) => !item.repaired && item.attempts.length > 0).map((item) => [item.caseId, item.attempts]));
3152
+ await writeBackFixtures(input, deps, io, targets, repairs);
2582
3153
  if (repaired.size > 0) {
2583
3154
  repairedCases = repaired.size;
2584
3155
  repairChanges = repairApplications(dryRunSuite, repaired);
@@ -2624,9 +3195,47 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2624
3195
  }
2625
3196
  approvals = review.approvals;
2626
3197
  }
3198
+ /**
3199
+ * 11.5. 최종 전량 재검증(§2, 이슈 #399).
3200
+ *
3201
+ * 입력값 교정은 케이스 하나만 담은 스위트로 재실행한다. 그 판단은 그 자리에서 옳다.
3202
+ * 문제는 그 뒤다. 저장 판정이 원래 실행 결과와 교정된 케이스의 **단독 통과**를 합친
3203
+ * 것이었다. **합집합은 실행이 아니다.** A 가 통과하고 B 가 단독으로 통과해도 A 다음에
3204
+ * B 를 돌리면 B 가 실패할 수 있고, 그 사실은 저장 뒤 첫 `mcpeak test` 에서야 드러난다.
3205
+ *
3206
+ * 여기서 초기 상태로 되돌리고 최종 스위트 전량을 한 번에 돌린다. **그 결과가 저장
3207
+ * 판정이다.**
3208
+ */
3209
+ let verification = "isolated";
3210
+ const revalidationSuite = session.approvedDraft.suite;
3211
+ if (input.dryRun) {
3212
+ const finalCaseCount = revalidationSuite.cases.length;
3213
+ const grade = (await (deps.attemptReset ?? attemptReset)(input.resetCmd)).grade;
3214
+ writeFinalRevalidationNotice(io, grade, finalCaseCount);
3215
+ if (await io.confirm(" 다시 실행할까요?")) {
3216
+ const again = await runDryRun({
3217
+ client: connection.client,
3218
+ suite: revalidationSuite
3219
+ });
3220
+ if (again.aborted !== void 0) {
3221
+ writeDryRunAborted(io, again, finalCaseCount, diagnostics());
3222
+ continue;
3223
+ }
3224
+ writeDryRunResult(io, again);
3225
+ const expectedToFail = new Set(approvals.filter((item) => item.status === "serverDefect").map((item) => item.id));
3226
+ const regressed = again.outcomes.filter((outcome) => outcome.status !== "passed" && !expectedToFail.has(outcome.caseId));
3227
+ if (regressed.length > 0) {
3228
+ io.write(`최종 전량 실행에서 ${regressed.length}건이 실패했습니다. 개별 실행에서는 통과했지만 순서대로 돌리면 실패합니다.\n 앞 케이스가 바꾼 상태를 뒤 케이스가 물려받습니다. 케이스 순서나 입력값을 확인하세요.
3229
+ `);
3230
+ continue;
3231
+ }
3232
+ verification = "fullSuite";
3233
+ } else io.write(FINAL_REVALIDATION_SKIPPED_LINES);
3234
+ }
2627
3235
  const fingerprint = session.approvedDraft.suiteFingerprint;
2628
3236
  io.write(`Final fingerprint: ${fingerprint}\n`);
2629
3237
  writeRepairSummary(io, repairedCases, repairChanges);
3238
+ writeVerificationScope(io, verification, revalidationSuite.cases.length);
2630
3239
  if (!await io.confirm("최종 JSON을 저장할까요?")) continue;
2631
3240
  const final = deps.finalizeAuthoringDraft({
2632
3241
  session,
@@ -2646,7 +3255,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2646
3255
  reportCoverageSafely(deps, () => deps.computeCoverage?.({
2647
3256
  suite: finalSuite,
2648
3257
  tools: tools.filter((_tool, index) => !skippedIndexes.has(index))
2649
- }), finalSuite, skippedTools);
3258
+ }), finalSuite, skippedTools, outputContractSkips, validBranchSkips);
2650
3259
  return 0;
2651
3260
  } catch (error) {
2652
3261
  if (error instanceof OutputExistsError) outputExistsFailure(deps, error.path);
@@ -2666,7 +3275,9 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2666
3275
  candidate
2667
3276
  });
2668
3277
  showDiff(io, diff);
2669
- const selected = action === "apply-all" ? diff.changes.map((change) => change.id) : (await io.input("적용할 change ID를 쉼표로 입력하세요: ")).split(",").map((id) => id.trim()).filter(Boolean);
3278
+ const selectedInput = action === "select" ? await io.input("적용할 change ID를 쉼표로 입력하세요: ") : null;
3279
+ if (selectedInput === "__mcpeak_review_back__") continue;
3280
+ const selected = selectedInput === null ? diff.changes.map((change) => change.id) : selectedInput.split(",").map((id) => id.trim()).filter(Boolean);
2670
3281
  if (!await confirmSpecFindings(io, candidate, diff, selected)) continue;
2671
3282
  if (!await io.confirm("선택한 변경을 적용할까요?")) continue;
2672
3283
  const result = apply({
@@ -2684,6 +3295,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2684
3295
  }
2685
3296
  if (action === "edit") {
2686
3297
  const path = await io.input("편집한 JSON 파일 경로: ");
3298
+ if (path === "__mcpeak_review_back__") continue;
2687
3299
  try {
2688
3300
  const result = reviewLocal({
2689
3301
  session,
@@ -2707,15 +3319,24 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
2707
3319
  io.write("지원하지 않는 메뉴입니다.\n");
2708
3320
  continue;
2709
3321
  }
3322
+ const previousPreferred = preferred;
3323
+ const previousModel = model;
2710
3324
  if (action !== "revise" && providerId !== preferred) model = input.provider === providerId ? input.model : void 0;
2711
3325
  preferred = providerId;
2712
- model = (model ?? await io.input(`${providerId} model (${defaultModel(providerId)}): `)) || defaultModel(providerId);
3326
+ const selectedModel = model ?? await io.input(`${providerId} model (${defaultModel(providerId)}): `);
3327
+ if (selectedModel === "__mcpeak_review_back__") {
3328
+ preferred = previousPreferred;
3329
+ model = previousModel;
3330
+ continue;
3331
+ }
3332
+ model = selectedModel || defaultModel(providerId);
2713
3333
  const provider = deps.providers?.[providerId]?.(model);
2714
3334
  if (!provider) {
2715
3335
  io.write(`${providerId} provider를 사용할 수 없습니다.\n`);
2716
3336
  continue;
2717
3337
  }
2718
3338
  const instruction = await io.input(action === "revise" ? "피드백: " : "AI 요청: ");
3339
+ if (instruction === "__mcpeak_review_back__") continue;
2719
3340
  const preview = prepare({
2720
3341
  mode: action === "revise" ? "revise" : "initial",
2721
3342
  instruction,
@@ -2770,7 +3391,8 @@ const AXIS_LABEL = {
2770
3391
  REQUIRED_OMITTED: "필수 필드 누락 거절",
2771
3392
  TYPE_VIOLATION: "타입 위반 거절",
2772
3393
  ENUM_VIOLATION: "선언되지 않은 값 거절",
2773
- RANGE_VIOLATION: "선언된 범위 밖 값 거절"
3394
+ RANGE_VIOLATION: "선언된 범위 밖 값 거절",
3395
+ UNDECLARED_FIELD: "선언되지 않은 필드 거절"
2774
3396
  };
2775
3397
  /**
2776
3398
  * runner 보고서 상한(1MB)에 닿기 전에 알리는 임계.
@@ -2797,9 +3419,44 @@ function padToWidth(text, width) {
2797
3419
  const padding = width - displayWidth(text);
2798
3420
  return padding > 0 ? text + " ".repeat(padding) : text;
2799
3421
  }
2800
- /** 축 하나를 사람이 읽는 문장으로. 필드가 없는 축(HAPPY_PATH)은 종류만 적는다. */
2801
- function axisLabel(kind, field) {
2802
- return field === null ? AXIS_LABEL[kind] : `${field} 의 ${AXIS_LABEL[kind]}`;
3422
+ /**
3423
+ * 범위 경계를 사람이 읽는 말로. 화면에만 쓰는 이름이라 축 정체성(`bound`)과는 별개다.
3424
+ * `Record<ContractRangeBound, string>` 이라서 `runner` 가 경계를 늘리면 여기서 타입 오류가
3425
+ * 난다. AXIS_LABEL 과 같은 이유로 같은 형태다.
3426
+ */
3427
+ const RANGE_BOUND_LABEL = {
3428
+ lower: "하한",
3429
+ upper: "상한"
3430
+ };
3431
+ /**
3432
+ * 축을 못 만든 사유를 사람 문장으로. `Record<UnanalyzedReason, string>` 이라서 `runner` 가
3433
+ * 사유를 늘리면 여기서 타입 오류가 난다. AXIS_LABEL 과 같은 이유로 같은 형태다.
3434
+ *
3435
+ * 이름만 나열하던 종전 화면은 사용자가 할 수 있는 일이 없었다. `filters` 하나만 보고는
3436
+ * 선언을 고쳐야 하는지, 우리 상한에 걸린 것인지, 애초에 읽을 근거가 없는 것인지 알 수 없다.
3437
+ * 이 프로젝트에서 실패 메시지는 곧 제품이다.
3438
+ */
3439
+ const UNANALYZED_REASON_LABEL = {
3440
+ blockingKeyword: "anyOf·oneOf·allOf·not·$ref 가 선언돼 있어 읽지 못했습니다.",
3441
+ noProperties: "object 인데 properties 선언이 없어 안을 읽지 못했습니다.",
3442
+ tupleItems: "items 가 배열(튜플)이라 원소 스키마를 하나로 묶지 못했습니다.",
3443
+ depthLimit: "중첩 깊이 상한(3)을 넘어 더 내려가지 않았습니다.",
3444
+ pathLimit: "한 도구의 경로 수 상한(64)을 넘어 더 만들지 않았습니다.",
3445
+ pathCollision: "경로 표기가 다른 필드와 겹쳐 양쪽 다 제외했습니다.",
3446
+ noGround: "type·enum·범위를 하나도 읽지 못해 요구할 근거가 없습니다.",
3447
+ unreadablePath: "이름에 '.' 이나 '[' 가 들어 경로로 읽으면 다른 자리를 가리켜 제외했습니다."
3448
+ };
3449
+ /**
3450
+ * 축 하나를 사람이 읽는 문장으로. 필드가 없는 축(HAPPY_PATH)은 종류만 적는다.
3451
+ *
3452
+ * 괄호는 범위 축에만 붙는다. 상한 축과 하한 축은 종류도 필드도 같아서, 경계를 안 적으면
3453
+ * 미검증 줄이 두 번 똑같이 나온다(이슈 #387). 같은 문장이 두 줄이면 사용자는 화면이 고장 난
3454
+ * 것으로 읽지, 한쪽 경계만 비었다는 뜻으로 읽지 않는다. 다른 축은 그럴 일이 없으므로 문장을
3455
+ * 그대로 둔다.
3456
+ */
3457
+ function axisLabel(kind, field, bound) {
3458
+ const base = field === null ? AXIS_LABEL[kind] : `${field} 의 ${AXIS_LABEL[kind]}`;
3459
+ return bound === null ? base : `${base} (${RANGE_BOUND_LABEL[bound]})`;
2803
3460
  }
2804
3461
  /** 툴 한 개의 줄과 그 아래 들여쓴 줄들. */
2805
3462
  function coverageToolLines(tool, nameWidth) {
@@ -2809,11 +3466,15 @@ function coverageToolLines(tool, nameWidth) {
2809
3466
  ` → 입력 스키마를 해석하지 못해 이 툴의 축을 세지 못했습니다 (${tool.unanalyzableReason ?? "schema"})`,
2810
3467
  " → 이 툴은 커버리지 숫자에 들어가지 않습니다"
2811
3468
  ];
2812
- const labels = tool.axes.filter((axis) => axis.caseId === null).map((axis) => axisLabel(axis.kind, axis.field));
3469
+ const labels = tool.axes.filter((axis) => axis.caseId === null).map((axis) => axisLabel(axis.kind, axis.field, axis.bound));
2813
3470
  const labelWidth = Math.max(0, ...labels.map(displayWidth)) + 5;
2814
3471
  const lines = [`${head}${tool.verified}/${tool.total}`];
2815
3472
  for (const label of labels) lines.push(` ? ${padToWidth(label, labelWidth)}미검증`);
2816
- if (tool.unanalyzedFields.length > 0) lines.push(` → 해석 못 한 필드 ${tool.unanalyzedFields.length}개: ${tool.unanalyzedFields.join(", ")}. 이 필드의 축은 세지 않았습니다`);
3473
+ if (tool.unanalyzedFields.length > 0) {
3474
+ lines.push(` → 해석 못 한 필드 ${tool.unanalyzedFields.length}개. 이 필드의 축은 세지 않았습니다`);
3475
+ const pathWidth = Math.max(0, ...tool.unanalyzedFields.map((f) => displayWidth(f.path))) + 3;
3476
+ for (const field of tool.unanalyzedFields) lines.push(` ${padToWidth(field.path, pathWidth)}${UNANALYZED_REASON_LABEL[field.reason]}`);
3477
+ }
2817
3478
  return lines;
2818
3479
  }
2819
3480
  /**
@@ -2856,13 +3517,17 @@ function renderSkippedTools(skipped) {
2856
3517
  * authoring 화면의 어법을 따르되 **전송 데이터 목록은 사실대로** 적는다. 여기서 나가는 것은
2857
3518
  * suite 전량이 아니라 툴 선언과 baseline 이 넣은 값뿐이다. authoring 문안을 그대로 태우면
2858
3519
  * 사용자가 승인하는 내용과 실제로 나가는 내용이 달라진다.
3520
+ *
3521
+ * 실제 서버 호출이 이 확인 뒤에 일어난다는 사실도 여기서 말한다. 저장 메뉴의 "계속할까요?" 만
3522
+ * 보고 온 사용자가 그 앞의 호출을 모른 채 승인하면 안 된다(#397).
2859
3523
  */
2860
3524
  function showPreFillRequest(io, preview) {
2861
3525
  const { request } = preview;
2862
3526
  const fields = request.cases.reduce((sum, item) => sum + item.assistFields.length, 0);
2863
3527
  io.write(`AI 사전보완 요청\nProvider: ${preview.providerId}\nModel: ${preview.model}\nPayload: ${preview.byteLength} bytes\nResult limit: ${preview.maxResultBytes} bytes\nTimeout: ${preview.providerTimeoutMs}ms\nFingerprint: ${preview.fingerprint}\n대상: 툴 ${request.tools.length}개, 케이스 ${request.cases.length}개, 채울 필드 ${fields}개\n전송 데이터: 툴 이름·설명·inputSchema, baseline 이 넣은 값, 그 값의 출처
2864
3528
  받는 것: 값 제안뿐입니다. 케이스를 더하거나 구조를 바꾸지 않습니다.
2865
- `);
3529
+ 채택 판정: 제안이 돌아온 케이스마다 baseline 값과 제안 값으로 실제 서버를 각각 한 번씩
3530
+ 부릅니다. 위 케이스 ${request.cases.length}개가 그 상한입니다. 상태를 바꾸는 툴이면 그 부작용이 남습니다.\n`);
2866
3531
  if (request.omitted.tools > 0) io.write(`⚠ 크기 상한 때문에 툴 ${request.omitted.tools}개를 요청에서 뺐습니다. 그 툴은 baseline 값 그대로 갑니다.\n`);
2867
3532
  }
2868
3533
  /**
@@ -2881,21 +3546,54 @@ function renderUnknownFormatSkips(skips) {
2881
3546
  }
2882
3547
  return `${lines.join("\n")}\n`;
2883
3548
  }
3549
+ /** 입력 케이스는 남겼지만 저장된 출력 계약을 만들지 못한 범위를 명시한다. */
3550
+ function renderOutputContractSkips(skips) {
3551
+ if (skips.length === 0) return "";
3552
+ return `${skips.map((skip) => `경고: 툴 '${skip.name}'의 출력 계약을 자동 검증하지 않습니다.\n 위치: ${skip.path}\n 이유: ${skip.message}\n 정상 호출의 isError 검사는 유지되지만 structuredContent 계약은 미검증입니다.`).join("\n")}\n`;
3553
+ }
3554
+ /**
3555
+ * 정상 입력 중 케이스를 만들지 못한 분기를 명시한다. 없으면 빈 문자열이다.
3556
+ *
3557
+ * 침묵하면 "정상 입력은 전부 밟았다" 로 읽힌다. 커버리지 표의 축은 위반 축이라 여기서 빠진
3558
+ * 분기를 세지 않으므로, 이 덩어리가 없으면 사용자가 빈틈을 볼 수 있는 자리가 없다.
3559
+ *
3560
+ * `reason` 은 `generate` 가 만들어 보낸 문장을 그대로 쓴다. 여기서 다시 쓰면 같은 상황이 두
3561
+ * 문안으로 갈린다. 순서도 `skips` 배열 그대로다. `generate` 가 이미 결정론적으로 정렬했고,
3562
+ * 여기서 다시 정렬하면 두 곳이 갈린다.
3563
+ */
3564
+ function renderValidBranchSkips(skips) {
3565
+ if (skips.length === 0) return "";
3566
+ const lines = [`정상 분기: 툴 ${new Set(skips.map((skip) => skip.tool)).size}개에서 ${skips.length}개 분기를 실행하지 않았습니다.`];
3567
+ for (const skip of skips) lines.push(` ${skip.tool}.${skip.field} ${skip.reason}`);
3568
+ return `${lines.join("\n")}\n`;
3569
+ }
2884
3570
  /**
2885
3571
  * AI 사전보완 결과 요약. 대상이 없으면 빈 문자열이다.
2886
3572
  *
3573
+ * 네 갈래를 나눠 적는다(설계 §4.3). `미채택` 과 `보류` 를 합쳐 "baseline 값이 이미 통과" 라고
3574
+ * 쓰면 baseline 이 실패한 케이스에 사실과 다른 말이 붙는다.
3575
+ *
2887
3576
  * **`버림` 은 사유와 대상을 반드시 적는다.** 개수만 적으면 사용자가 무엇을 잃었는지 모른다
2888
3577
  * (이슈 #120 이 `discarded` 가 개수뿐이라고 지적한 것과 같은 계열이다). 버림이 0건이면 그 줄을
2889
3578
  * 아예 찍지 않는다.
2890
3579
  */
2891
3580
  function renderPreFillSummary(options) {
2892
- const { toolCount, proposedToolCount, adopted, notAdopted, discarded } = options;
2893
- if (proposedToolCount === 0 && discarded.length === 0) return "";
3581
+ const { toolCount, proposedToolCount, adopted, notAdopted, held, heldCases, excluded, discarded } = options;
3582
+ if (proposedToolCount === 0 && discarded.length === 0 && excluded.length === 0) return "";
2894
3583
  const lines = [
2895
3584
  `AI 사전보완: 툴 ${toolCount}개 중 ${proposedToolCount}개에 값 제안을 받았습니다.`,
2896
3585
  ` 채택 ${adopted} (실제 서버에서 baseline 값이 실패하고 제안 값이 통과)`,
2897
- ` 미채택 ${notAdopted} (baseline 값이 이미 통과)`
3586
+ ` 미채택 ${notAdopted - held} (baseline 값이 이미 통과)`
2898
3587
  ];
3588
+ if (held > 0) {
3589
+ lines.push(` 보류 ${held} (baseline 값도 제안 값도 실패. 분류 화면에서 정합니다)`);
3590
+ for (const item of heldCases) {
3591
+ const proposals = item.proposedFields.map((p) => `${p.field}: ${JSON.stringify(p.value)}`).join(", ");
3592
+ lines.push(` ${item.caseId} ${proposals}`);
3593
+ for (const line of item.serverMessage.split("\n").filter((s) => s !== "")) lines.push(` → ${line}`);
3594
+ }
3595
+ }
3596
+ for (const item of excluded) lines.push(` 제외 1 (${item.reason}: ${item.caseId}.${item.field})`);
2899
3597
  for (const item of discarded) lines.push(` 버림 1 (${item.reason}: ${item.caseId}.${item.field})`);
2900
3598
  return `${lines.join("\n")}\n`;
2901
3599
  }
@@ -2916,13 +3614,17 @@ function renderCaseCountNotice(caseCount) {
2916
3614
  `;
2917
3615
  }
2918
3616
  /** 커버리지·건너뜀·케이스 수 고지를 stdout 에 찍는다. 전부 빈 문자열이면 아무것도 안 찍는다. */
2919
- function writeCoverageReport(deps, coverage, suite, skippedTools) {
3617
+ function writeCoverageReport(deps, coverage, suite, skippedTools, outputContractSkips, validBranchSkips) {
2920
3618
  if (coverage !== void 0) {
2921
3619
  const text = renderCoverage(coverage);
2922
3620
  if (text !== "") deps.writeStdout(text);
2923
3621
  }
2924
3622
  const skippedText = renderSkippedTools(skippedTools);
2925
3623
  if (skippedText !== "") deps.writeStdout(skippedText);
3624
+ const outputContractText = renderOutputContractSkips(outputContractSkips);
3625
+ if (outputContractText !== "") deps.writeStdout(outputContractText);
3626
+ const validBranchText = renderValidBranchSkips(validBranchSkips);
3627
+ if (validBranchText !== "") deps.writeStdout(validBranchText);
2926
3628
  const notice = renderCaseCountNotice(suite.cases.length);
2927
3629
  if (notice !== "") deps.writeStdout(notice);
2928
3630
  }
@@ -2936,9 +3638,9 @@ function writeCoverageReport(deps, coverage, suite, skippedTools) {
2936
3638
  * coverage 를 값이 아니라 thunk 로 받는 이유는 `computeCoverage` 자체가 던지는 경우까지 이 경계
2937
3639
  * 안에 넣기 위해서다.
2938
3640
  */
2939
- function reportCoverageSafely(deps, coverage, suite, skippedTools = []) {
3641
+ function reportCoverageSafely(deps, coverage, suite, skippedTools = [], outputContractSkips = [], validBranchSkips = []) {
2940
3642
  try {
2941
- writeCoverageReport(deps, coverage(), suite, skippedTools);
3643
+ writeCoverageReport(deps, coverage(), suite, skippedTools, outputContractSkips, validBranchSkips);
2942
3644
  } catch {
2943
3645
  deps.writeStderr("경고 [GENERATE_COVERAGE_UNAVAILABLE]: 명세는 저장했지만 커버리지를 계산하지 못했습니다.\n해결: 저장된 명세는 그대로 `mcpeak test` 로 쓸 수 있습니다. 커버리지만 다시 보려면 다른 --out 경로로 generate 를 실행하세요.\n");
2944
3646
  }
@@ -2952,6 +3654,9 @@ function reportCoverageSafely(deps, coverage, suite, skippedTools = []) {
2952
3654
  * **provider 가 죽어도 툴을 건너뛰지 않는다.** provider 실패는 사용자 서버의 문제가 아니라
2953
3655
  * 우리 쪽 사정이고, 그것 때문에 케이스를 잃는 손해가 더 크다. baseline 값으로 진행하고
2954
3656
  * 그 사실을 화면에 적는다.
3657
+ *
3658
+ * **`--no-dry-run` 이면 돌지 않는다.** 채택을 정하는 것이 실제 서버 실행인데 그 경로에는 실행이
3659
+ * 없다. 이 분기가 빠져 있어 시험 실행을 끄고도 툴이 두 번 호출됐다(#397).
2955
3660
  */
2956
3661
  async function runPreFill(input, tools, baseline, deps, client) {
2957
3662
  const skip = {
@@ -2973,6 +3678,10 @@ async function runPreFill(input, tools, baseline, deps, client) {
2973
3678
  baseline: baseline.suite
2974
3679
  });
2975
3680
  if (request === null) return skip;
3681
+ if (!input.dryRun) {
3682
+ io.write("▸ 시험 실행이 꺼져 있어(--no-dry-run) AI 사전보완을 건너뜁니다. 제안 값의 채택은 실제 서버\n 실행이 정하는데 그 실행이 없습니다. baseline 값으로 진행합니다.\n AI 사전보완이 필요하면 --no-dry-run 없이 실행하세요.\n");
3683
+ return skip;
3684
+ }
2976
3685
  let view;
2977
3686
  try {
2978
3687
  view = preview({
@@ -3001,13 +3710,22 @@ async function runPreFill(input, tools, baseline, deps, client) {
3001
3710
  const applied = await applyPreFill({
3002
3711
  client,
3003
3712
  baseline: baseline.suite,
3004
- preFill: dispatched.result
3713
+ preFill: dispatched.result,
3714
+ reset: () => (deps.attemptReset ?? attemptReset)(input.resetCmd).then((result) => result.grade)
3005
3715
  });
3716
+ if (applied.skippedReason !== void 0) io.write(`▸ ${applied.skippedReason}\n`);
3006
3717
  io.write(renderPreFillSummary({
3007
3718
  toolCount: tools.length,
3008
3719
  proposedToolCount: request.tools.length,
3009
3720
  adopted: applied.adopted,
3010
3721
  notAdopted: applied.notAdopted,
3722
+ held: applied.held,
3723
+ heldCases: applied.cases.filter((item) => item.needsClassification).map((item) => ({
3724
+ caseId: item.caseId,
3725
+ proposedFields: item.proposedFields ?? [],
3726
+ serverMessage: item.serverMessage ?? ""
3727
+ })),
3728
+ excluded: applied.excluded,
3011
3729
  discarded: dispatched.result.discarded
3012
3730
  }));
3013
3731
  return {
@@ -3039,6 +3757,10 @@ async function runGenerateCommand(argv, deps) {
3039
3757
  deps.writeStderr("오류 [GENERATE_INTERACTIVE_REQUIRED]: AI 검토에는 TTY가 필요합니다.\n해결: `--baseline-only`를 지정하거나 대화형 터미널에서 실행하세요.\n");
3040
3758
  return 1;
3041
3759
  }
3760
+ const fixturesRead = await loadFixtures(input, deps);
3761
+ if (fixturesRead === null) return 1;
3762
+ const fixtureFile = fixturesRead.file;
3763
+ const fixturePath = input.fixturesPath ?? DEFAULT_FIXTURES_PATH;
3042
3764
  let connection;
3043
3765
  /**
3044
3766
  * 대화형 검토는 아래 try 밖에서 돌린다. 검토가 던지는 오류를 여기 catch 가 삼키면
@@ -3059,7 +3781,8 @@ async function runGenerateCommand(argv, deps) {
3059
3781
  }
3060
3782
  const baseline = deps.createBaselineSuite(tools, {
3061
3783
  suiteId: input.suiteId,
3062
- suiteName: input.name
3784
+ suiteName: input.name,
3785
+ ...fixtureFile === void 0 ? {} : { fixtures: fixtureFile }
3063
3786
  });
3064
3787
  let sessionSuite = baseline.suite;
3065
3788
  let preFilledCaseIds = [];
@@ -3084,7 +3807,14 @@ async function runGenerateCommand(argv, deps) {
3084
3807
  active,
3085
3808
  session,
3086
3809
  tools,
3087
- skippedTools: baseline.skippedTools
3810
+ skippedTools: baseline.skippedTools,
3811
+ outputContractSkips: baseline.outputContractSkips,
3812
+ validBranchSkips: baseline.validBranchSkips,
3813
+ fixtures: {
3814
+ path: fixturePath,
3815
+ fieldOrigins: baseline.fieldOrigins,
3816
+ ...fixtureFile?.notes === void 0 ? {} : { notes: fixtureFile.notes }
3817
+ }
3088
3818
  };
3089
3819
  } else {
3090
3820
  const final = deps.finalizeAuthoringDraft({
@@ -3098,7 +3828,7 @@ async function runGenerateCommand(argv, deps) {
3098
3828
  const finalSuite = deps.getAuthoringExecutionSuite(final.snapshot);
3099
3829
  await saveSuite(input, finalSuite, final.snapshot.fingerprint, deps);
3100
3830
  deps.writeStdout(`baseline suite를 저장했습니다: ${input.outPath}\n`);
3101
- reportCoverageSafely(deps, () => baseline.coverage, finalSuite, baseline.skippedTools);
3831
+ reportCoverageSafely(deps, () => baseline.coverage, finalSuite, baseline.skippedTools, baseline.outputContractSkips, baseline.validBranchSkips);
3102
3832
  return 0;
3103
3833
  }
3104
3834
  } catch (error) {
@@ -3129,7 +3859,7 @@ async function runGenerateCommand(argv, deps) {
3129
3859
  return 1;
3130
3860
  }
3131
3861
  try {
3132
- return await runInteractiveReview(input, review.tools, review.session, deps, review.active, review.skippedTools);
3862
+ return await runInteractiveReview(input, review.tools, review.session, deps, review.active, review.skippedTools, review.outputContractSkips, review.validBranchSkips, review.fixtures);
3133
3863
  } finally {
3134
3864
  await review.active.close().catch(() => void 0);
3135
3865
  }
@@ -3228,6 +3958,18 @@ function caseApprovalStatuses(suite) {
3228
3958
  return new Map((suite.approval?.cases ?? []).map((entry) => [entry.id, entry.status]));
3229
3959
  }
3230
3960
  /**
3961
+ * 승인 시점에 실제 서버 실행 기록이 남았는가. `approval.cases` 의 존재가 기준이다.
3962
+ *
3963
+ * `generate` 는 시험 실행을 마치고 사람이 분류를 끝냈을 때만 이 키를 쓴다(`renderSuite` 의
3964
+ * 주석과 `reviewDryRun`). 그래서 `--baseline-only`·`--no-dry-run` 으로 저장한 명세는 지문이
3965
+ * 있어도 이 키가 없다. 지문 일치와 실행 기록은 별개다(#385).
3966
+ *
3967
+ * 빈 배열은 저장되지 않지만 손으로 쓴 파일에는 있을 수 있으므로 길이로 본다.
3968
+ */
3969
+ function specRunHistory(suite) {
3970
+ return (suite.approval?.cases?.length ?? 0) > 0 ? "present" : "absent";
3971
+ }
3972
+ /**
3231
3973
  * `serverDefect` 로 표시된 케이스가 다시 실패했을 때 붙이는 한 줄. 설계 문서 §9.
3232
3974
  * 들여쓰기는 보고서의 케이스 블록과 맞춘다. 개행으로 끝나고 호출자가 앞에 빈 줄을 붙인다.
3233
3975
  *
@@ -3252,6 +3994,18 @@ function renderSpecApproval(result, allPassed) {
3252
3994
  return `명세: 현재 명세와 저장된 approval.fingerprint가 불일치함
3253
3995
  → 승인 ${short(result.approvedFingerprint ?? "")} 현재 ${short(result.fingerprint)}\n` + outcome + " → 지문만으로는 변경 내용을 알 수 없습니다. 버전 관리에서 명세를 비교하세요.\n → 의도한 변경이면 같은 설정으로 mcpeak generate 를 다시 실행하고 --force 로 재승인하세요.\n";
3254
3996
  }
3997
+ /**
3998
+ * 도구 하나의 스키마 직렬화 상한. 넘으면 스키마를 빼고 `schemasOmitted` 로 표시한다.
3999
+ *
4000
+ * 관측된 실제 MCP 도구 스키마는 대부분 1 KiB 아래다. 8 KiB 를 넘는 것은 $defs 가 수십 개
4001
+ * 달린 생성 스키마이고, 그런 것은 통째로 실어도 AI 가 원인을 좁히는 데 못 쓰면서 요청
4002
+ * 예산만 먹는다. `MAX_REQUEST_BYTES`(256 KiB)에 걸려 요청 전체가 거절되는 것보다 그 도구만
4003
+ * 줄이는 편이 낫다(#393).
4004
+ *
4005
+ * `maxCases` 기본값이 12 이고 실패 케이스가 부른 도구만 싣는다. 도구 하나가 8 KiB 를 넘지
4006
+ * 않으면 12개라도 96 KiB 이고, 나머지(진단·입력·stderr)를 더해도 256 KiB 아래다.
4007
+ */
4008
+ const MAX_TOOL_SCHEMA_BYTES = 8192;
3255
4009
  /** 번들에 적는 CLI 식별자. `mcpeak --version` 이 찍는 것과 같은 출처를 쓴다. */
3256
4010
  const REPAIR_BUNDLE_GENERATED_BY = `mcpeak ${version}`;
3257
4011
  /**
@@ -3292,6 +4046,10 @@ function buildRepairBundle(options) {
3292
4046
  caseId: item.spec.id,
3293
4047
  caseName: item.spec.name,
3294
4048
  status: item.status,
4049
+ assertions: item.assertions.map((assertion) => ({
4050
+ type: assertion.spec.type,
4051
+ status: assertion.status
4052
+ })),
3295
4053
  diagnostics: diagnosticsOf(item)
3296
4054
  };
3297
4055
  if (item.spec.operation.type === "callTool") {
@@ -3306,20 +4064,43 @@ function buildRepairBundle(options) {
3306
4064
  suiteId: options.report.suite.id,
3307
4065
  suiteName: options.report.suite.name,
3308
4066
  approval: options.specApproval.state,
4067
+ runHistory: specRunHistory(options.suite),
3309
4068
  fingerprint: options.specApproval.fingerprint
3310
4069
  };
3311
4070
  if (options.specApproval.approvedFingerprint !== void 0) spec.approvedFingerprint = options.specApproval.approvedFingerprint;
4071
+ const called = new Set(failures.map((failure) => failure.tool).filter(Boolean));
4072
+ let toolSchemasOmitted = 0;
4073
+ const tools = (options.tools ?? []).filter((tool) => called.has(tool.name)).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0).map((tool) => {
4074
+ const schemas = { inputSchema: tool.inputSchema ?? {} };
4075
+ if (tool.outputSchema !== void 0) schemas.outputSchema = tool.outputSchema;
4076
+ const omitted = Buffer.byteLength(JSON.stringify(schemas), "utf8") > MAX_TOOL_SCHEMA_BYTES;
4077
+ if (omitted) toolSchemasOmitted++;
4078
+ const entry = {
4079
+ name: tool.name,
4080
+ inputSchema: omitted ? {} : schemas.inputSchema
4081
+ };
4082
+ if (!omitted && schemas.outputSchema !== void 0) entry.outputSchema = schemas.outputSchema;
4083
+ if (tool.description !== void 0) entry.description = tool.description;
4084
+ if (omitted) entry.schemasOmitted = true;
4085
+ return entry;
4086
+ });
3312
4087
  const bundle = {
3313
- bundleVersion: 1,
4088
+ bundleVersion: 3,
3314
4089
  generatedBy: options.cliVersion ?? REPAIR_BUNDLE_GENERATED_BY,
3315
4090
  spec,
3316
- failures
4091
+ failures,
4092
+ tools,
4093
+ target: { transport: options.target.transport }
3317
4094
  };
4095
+ if (toolSchemasOmitted > 0) bundle.truncated = { toolSchemas: toolSchemasOmitted };
3318
4096
  /**
3319
4097
  * 내용이 있을 때만 담는다. 판정은 화면이 쓰는 `hasDiagnosticContent` 와 **같은 함수**다.
3320
4098
  * 규칙이 갈라지면 화면에는 안 뜨는 것이 번들에는 들어간다. 설계서 §4.2.
3321
4099
  */
3322
- if (options.processDiagnostics !== void 0 && hasDiagnosticContent(options.processDiagnostics)) bundle.process = options.processDiagnostics;
4100
+ if (options.processDiagnostics !== void 0 && hasDiagnosticContent(options.processDiagnostics)) bundle.process = {
4101
+ ...options.processDiagnostics,
4102
+ scope: "suite"
4103
+ };
3323
4104
  return bundle;
3324
4105
  }
3325
4106
  /**
@@ -3340,8 +4121,8 @@ function describeRepairBundleInvalid(reason) {
3340
4121
  switch (reason) {
3341
4122
  case "notJson": return "번들 파일이 JSON 이 아닙니다. `--repair-bundle` 이 만든 파일이 맞는지, 편집 중에 깨지지 않았는지 확인하세요.";
3342
4123
  case "notObject": return "번들 최상위가 JSON 객체가 아닙니다. 배열이나 문자열이 담긴 다른 파일을 가리키고 있지 않은지 경로를 확인하세요.";
3343
- case "versionMismatch": return `번들 형식 버전이 이 CLI 가 아는 1 이 아닙니다. 최신 \`mcpeak test --repair-bundle\` 로 다시 만드세요.`;
3344
- case "missingField": return "번들에 필요한 항목이 없거나 값이 형식과 다릅니다. `spec` 의 `suiteId`·`suiteName`·`approval`, 각 실패의 `caseId`·`caseName`·`status`·`diagnostics`, 각 진단의 `code`·`message` 가 있어야 합니다. `mcpeak test --repair-bundle` 로 다시 만드세요.";
4124
+ case "versionMismatch": return `번들 형식 버전이 이 CLI 가 아는 3 이 아닙니다. 최신 \`mcpeak test --repair-bundle\` 로 다시 만드세요.`;
4125
+ case "missingField": return "번들에 필요한 항목이 없거나 값이 형식과 다릅니다. `spec` 의 `suiteId`·`suiteName`·`approval`·`runHistory`, 각 실패의 `caseId`·`caseName`·`status`·`assertions`·`diagnostics`, 각 진단의 `code`·`message`, 각 단언의 `type`·`status`, 도구 목록 `tools` 와 각 도구의 `name`·`inputSchema`, 대상 `target` 의 `transport` 가 있어야 합니다. `process` 가 있으면 그 안에 `scope` 도 있어야 합니다. `mcpeak test --repair-bundle` 로 다시 만드세요.";
3345
4126
  case "emptyFailures": return "번들에 실패한 케이스가 없습니다. 진단할 근거가 없으므로 provider 를 부르지 않습니다. 실패가 있는 실행에서 번들을 다시 만드세요.";
3346
4127
  }
3347
4128
  }
@@ -3358,6 +4139,7 @@ const APPROVAL_STATES = [
3358
4139
  "mismatched",
3359
4140
  "absent"
3360
4141
  ];
4142
+ const RUN_HISTORIES = ["present", "absent"];
3361
4143
  const FAILURE_STATUSES = [
3362
4144
  "failed",
3363
4145
  "timedOut",
@@ -3365,6 +4147,13 @@ const FAILURE_STATUSES = [
3365
4147
  "notRun"
3366
4148
  ];
3367
4149
  const APPROVED_AS = ["passed", "serverDefect"];
4150
+ /** runner 의 `AssertionResult.status` 와 같은 네 값이다. 케이스 상태와 다른 유니온이다. */
4151
+ const ASSERTION_STATUSES = [
4152
+ "passed",
4153
+ "failed",
4154
+ "skipped",
4155
+ "notRun"
4156
+ ];
3368
4157
  const isOneOf = (value, allowed) => typeof value === "string" && allowed.includes(value);
3369
4158
  /**
3370
4159
  * 소비하는 필드가 실제로 있고 형식이 맞는지 본다.
@@ -3381,6 +4170,12 @@ function failureShapeValid(failure) {
3381
4170
  if (failure.tool !== void 0 && typeof failure.tool !== "string") return false;
3382
4171
  if (failure.approvedAs !== void 0 && !isOneOf(failure.approvedAs, APPROVED_AS)) return false;
3383
4172
  if (failure.input !== void 0 && !plainObject(failure.input)) return false;
4173
+ if (!Array.isArray(failure.assertions)) return false;
4174
+ for (const assertion of failure.assertions) {
4175
+ if (!plainObject(assertion)) return false;
4176
+ if (typeof assertion.type !== "string") return false;
4177
+ if (!isOneOf(assertion.status, ASSERTION_STATUSES)) return false;
4178
+ }
3384
4179
  if (!Array.isArray(failure.diagnostics)) return false;
3385
4180
  for (const diagnostic of failure.diagnostics) {
3386
4181
  if (!plainObject(diagnostic)) return false;
@@ -3390,11 +4185,20 @@ function failureShapeValid(failure) {
3390
4185
  }
3391
4186
  return true;
3392
4187
  }
4188
+ function toolShapeValid(tool) {
4189
+ if (!plainObject(tool)) return false;
4190
+ if (typeof tool.name !== "string" || tool.name === "") return false;
4191
+ if (!plainObject(tool.inputSchema)) return false;
4192
+ if (tool.outputSchema !== void 0 && !plainObject(tool.outputSchema)) return false;
4193
+ if (tool.description !== void 0 && typeof tool.description !== "string") return false;
4194
+ return true;
4195
+ }
3393
4196
  function specShapeValid(spec) {
3394
4197
  if (!plainObject(spec)) return false;
3395
4198
  if (typeof spec.suiteId !== "string") return false;
3396
4199
  if (typeof spec.suiteName !== "string") return false;
3397
4200
  if (!isOneOf(spec.approval, APPROVAL_STATES)) return false;
4201
+ if (!isOneOf(spec.runHistory, RUN_HISTORIES)) return false;
3398
4202
  if (typeof spec.fingerprint !== "string") return false;
3399
4203
  return true;
3400
4204
  }
@@ -3412,7 +4216,7 @@ function readRepairBundle(text) {
3412
4216
  status: "invalid",
3413
4217
  reason: "notObject"
3414
4218
  };
3415
- if (parsed.bundleVersion !== 1) return {
4219
+ if (parsed.bundleVersion !== 3) return {
3416
4220
  status: "invalid",
3417
4221
  reason: "versionMismatch"
3418
4222
  };
@@ -3428,6 +4232,28 @@ function readRepairBundle(text) {
3428
4232
  status: "invalid",
3429
4233
  reason: "missingField"
3430
4234
  };
4235
+ if (!Array.isArray(parsed.tools)) return {
4236
+ status: "invalid",
4237
+ reason: "missingField"
4238
+ };
4239
+ for (const tool of parsed.tools) if (!toolShapeValid(tool)) return {
4240
+ status: "invalid",
4241
+ reason: "missingField"
4242
+ };
4243
+ if (!plainObject(parsed.target) || typeof parsed.target.transport !== "string") return {
4244
+ status: "invalid",
4245
+ reason: "missingField"
4246
+ };
4247
+ if (parsed.process !== void 0) {
4248
+ if (!plainObject(parsed.process)) return {
4249
+ status: "invalid",
4250
+ reason: "missingField"
4251
+ };
4252
+ if (parsed.process.scope !== "suite") return {
4253
+ status: "invalid",
4254
+ reason: "missingField"
4255
+ };
4256
+ }
3431
4257
  if (parsed.failures.length === 0) return {
3432
4258
  status: "invalid",
3433
4259
  reason: "emptyFailures"
@@ -3506,6 +4332,16 @@ function parseRepairCommand(argv) {
3506
4332
  * `repair` 명령. 번들을 읽고, 전송 내용을 확인받고, provider 에게 진단을 물어 화면에 찍는다.
3507
4333
  * 파일도 명세도 고치지 않는다. 종료 코드는 운영 실패에만 1 이다(설계서 §7.1).
3508
4334
  */
4335
+ /**
4336
+ * 진단 요청이 크기 상한을 넘었을 때의 문장.
4337
+ *
4338
+ * `repair-render.ts` 가 아니라 여기 두는 이유는 그 파일이 화면 조립을 맡고 이 문장은 이
4339
+ * 명령의 실패 경로에만 쓰이기 때문이다. 다른 실패 문장들처럼 `deps.writeStderr` 로 나간다.
4340
+ *
4341
+ * 무엇이 왜 큰지와 사용자가 할 수 있는 일을 함께 적는다. 상한 계산이 틀렸을 때 스택
4342
+ * 트레이스만 뜨면 사용자는 할 수 있는 일이 없다(#393).
4343
+ */
4344
+ const REPAIR_REQUEST_TOO_LARGE_LINE = "오류 [REPAIR_REQUEST_TOO_LARGE]: 진단 요청이 크기 상한을 넘었습니다.\n 번들의 도구 선언이 너무 큽니다. --max-cases 를 줄여 실패 케이스 수를 낮추면\n 함께 실리는 도구도 줄어듭니다.\n";
3509
4345
  async function runRepairCommand(argv, deps) {
3510
4346
  let input;
3511
4347
  try {
@@ -3533,27 +4369,49 @@ async function runRepairCommand(argv, deps) {
3533
4369
  return 1;
3534
4370
  }
3535
4371
  const provider = deps.diagnosis.providers[input.providerId](input.model);
3536
- const preview = deps.diagnosis.prepare({
3537
- specApproved: bundle.spec.approval === "matched",
3538
- suite: {
3539
- id: bundle.spec.suiteId,
3540
- name: bundle.spec.suiteName
3541
- },
3542
- failures: bundle.failures.map((failure) => ({
3543
- caseId: failure.caseId,
3544
- caseName: failure.caseName,
3545
- ...failure.tool === void 0 ? {} : { tool: failure.tool },
3546
- ...failure.input === void 0 ? {} : { input: failure.input },
3547
- ...failure.approvedAs === void 0 ? {} : { approvedAs: failure.approvedAs },
3548
- diagnostics: failure.diagnostics.map((diagnostic) => ({ ...diagnostic }))
3549
- })),
3550
- ...bundle.process === void 0 ? {} : { processDiagnostics: bundle.process },
3551
- tools: [],
3552
- providerId: input.providerId,
3553
- model: input.model,
3554
- maxCases: input.maxCases,
3555
- includeStderr: input.includeStderr
3556
- });
4372
+ /**
4373
+ * 요청 크기 상한을 넘으면 `prepareDiagnosisRequest` 가 `RangeError` 를 던진다.
4374
+ * 도구 선언을 싣기 시작하면서 실제로 닿을 수 있는 자리가 됐다(#393).
4375
+ *
4376
+ * 던진 것을 그대로 두면 스택 트레이스가 화면에 뜨고 사용자가 할 수 있는 일이 없다.
4377
+ * 번들을 다시 만들 수도 없다. 그 안에 이미 도구가 들어 있기 때문이다.
4378
+ */
4379
+ let preview;
4380
+ try {
4381
+ preview = deps.diagnosis.prepare({
4382
+ specTrust: {
4383
+ fingerprint: bundle.spec.approval,
4384
+ runHistory: bundle.spec.runHistory
4385
+ },
4386
+ suite: {
4387
+ id: bundle.spec.suiteId,
4388
+ name: bundle.spec.suiteName
4389
+ },
4390
+ failures: bundle.failures.map((failure) => ({
4391
+ caseId: failure.caseId,
4392
+ caseName: failure.caseName,
4393
+ ...failure.tool === void 0 ? {} : { tool: failure.tool },
4394
+ ...failure.input === void 0 ? {} : { input: failure.input },
4395
+ ...failure.approvedAs === void 0 ? {} : { approvedAs: failure.approvedAs },
4396
+ diagnostics: failure.diagnostics.map((diagnostic) => ({ ...diagnostic }))
4397
+ })),
4398
+ ...bundle.process === void 0 ? {} : { processDiagnostics: bundle.process },
4399
+ tools: bundle.tools.map((tool) => ({
4400
+ name: tool.name,
4401
+ inputSchema: tool.inputSchema,
4402
+ ...tool.description === void 0 ? {} : { description: tool.description },
4403
+ ...tool.outputSchema === void 0 ? {} : { outputSchema: tool.outputSchema }
4404
+ })),
4405
+ providerId: input.providerId,
4406
+ model: input.model,
4407
+ maxCases: input.maxCases,
4408
+ includeStderr: input.includeStderr
4409
+ });
4410
+ } catch (error) {
4411
+ if (!(error instanceof RangeError)) throw error;
4412
+ deps.writeStderr(REPAIR_REQUEST_TOO_LARGE_LINE);
4413
+ return 1;
4414
+ }
3557
4415
  const confirmView = {
3558
4416
  providerId: input.providerId,
3559
4417
  model: input.model,
@@ -3562,8 +4420,11 @@ async function runRepairCommand(argv, deps) {
3562
4420
  omittedFailures: preview.omitted.failures,
3563
4421
  maxCases: input.maxCases,
3564
4422
  approval: bundle.spec.approval,
4423
+ runHistory: bundle.spec.runHistory,
3565
4424
  includeStderr: input.includeStderr,
3566
4425
  ...preview.request.processDiagnostics === void 0 ? {} : { stderr: preview.request.processDiagnostics.stderr },
4426
+ sentTools: bundle.tools.length,
4427
+ omittedToolSchemas: bundle.tools.filter((tool) => tool.schemasOmitted === true).length,
3567
4428
  requestBytes: preview.byteLength
3568
4429
  };
3569
4430
  if (!input.yes) {
@@ -3737,6 +4598,7 @@ async function startExternalWiring(options) {
3737
4598
  mode: "record",
3738
4599
  sessionId: SESSION_ID,
3739
4600
  store,
4601
+ ...options.origin === void 0 ? {} : { origin: options.origin },
3740
4602
  ...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
3741
4603
  }) : await startExternalCoordinator({
3742
4604
  mode: "replay",
@@ -3873,6 +4735,7 @@ function parseTestCommand(argv) {
3873
4735
  let recordSessionPath;
3874
4736
  const args = [];
3875
4737
  const headerEnv = createHeaderEnvCollector();
4738
+ const envForward = createEnvForwardCollector();
3876
4739
  for (let index = 1; index < argv.length; index += 1) {
3877
4740
  const token = argv[index] ?? "";
3878
4741
  /**
@@ -3925,6 +4788,15 @@ function parseTestCommand(argv) {
3925
4788
  const rejected = headerEnv.add(parsed.value.header, parsed.value.envName);
3926
4789
  if (rejected !== void 0) fail(rejected);
3927
4790
  }
4791
+ } else if (token === "--env" || token.startsWith("--env=")) {
4792
+ const read = readOptionValue(argv, token, "--env", index);
4793
+ index = read.index;
4794
+ const parsed = parseEnvForwardOption(read.value);
4795
+ if (!parsed.ok) fail(parsed.message);
4796
+ else {
4797
+ const rejected = envForward.add(parsed.value);
4798
+ if (rejected !== void 0) fail(rejected);
4799
+ }
3928
4800
  } else if (token === "--arg" || token.startsWith("--arg=")) {
3929
4801
  let value;
3930
4802
  if (token === "--arg") {
@@ -4024,6 +4896,7 @@ function parseTestCommand(argv) {
4024
4896
  hint: TEST_USAGE_HINT
4025
4897
  });
4026
4898
  if (url !== void 0 && args.length > 0) fail("`--arg` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--arg` 는 우리가 띄우는 프로세스에 넘길 인자입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
4899
+ if (url !== void 0 && envForward.snapshot().length > 0) fail("`--env` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--env` 는 우리가 띄우는 프로세스의 환경에 넣을 변수입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
4027
4900
  if (command !== void 0 && !headerEnv.isEmpty()) fail("`--header-env` 는 `--url` 과 함께만 쓸 수 있습니다.\n→ 헤더는 HTTP 요청에 실립니다. `--command` 로 띄운 서버와는 stdio 로 이야기합니다.");
4028
4901
  if (url !== void 0 && stderrLines !== void 0) fail("`--stderr-lines` 는 `--url` 과 함께 쓸 수 없습니다.\n→ 이 옵션은 우리가 띄운 프로세스의 stderr 를 보여줍니다. 원격 서버에는 그 프로세스가 없습니다.\n→ 원격 서버 실패는 엔드포인트와 HTTP 상태로 진단합니다. 그 블록은 항상 나옵니다.");
4029
4902
  if (url !== void 0 && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("External 세션 옵션은 `--url` 과 함께 쓸 수 없습니다.\n→ External 배선은 우리가 띄운 자식 프로세스의 환경변수로 들어갑니다.\n→ 원격 서버는 우리가 띄우지 않으므로 그 주입 지점이 없습니다.");
@@ -4034,7 +4907,8 @@ function parseTestCommand(argv) {
4034
4907
  target: url === void 0 ? Object.freeze({
4035
4908
  transport: "stdio",
4036
4909
  command,
4037
- args: Object.freeze(args)
4910
+ args: Object.freeze(args),
4911
+ envNames: envForward.snapshot()
4038
4912
  }) : Object.freeze({
4039
4913
  transport: "http",
4040
4914
  url,
@@ -4881,7 +5755,9 @@ async function runCliCore(argv, dependencies, childEnv) {
4881
5755
  report: finalReport,
4882
5756
  suite: validated.value,
4883
5757
  specApproval,
4884
- processDiagnostics: settled.value
5758
+ processDiagnostics: settled.value,
5759
+ tools,
5760
+ target: { transport: input.target.transport }
4885
5761
  });
4886
5762
  if (bundle === void 0) dependencies.writeStdout(`\n${REPAIR_BUNDLE_EMPTY_LINE}`);
4887
5763
  else try {
@@ -4961,7 +5837,7 @@ function externalOpenFailure(mode, path, error) {
4961
5837
  if (code === "REPLAY_SOURCE_INVALID") return {
4962
5838
  code: "EXTERNAL_SESSION_FAILED",
4963
5839
  message: `녹화가 완료되지 않은 세션입니다: ${shownPath}`,
4964
- hint: "녹화 실행이 실패했을 수 있습니다. `--record-session` 으로 다시 녹화하세요."
5840
+ hint: "녹화가 도중에 끊겼거나, 지원하지 않는 응답(비-JSON·redirect)을 만나 호출을 끝내지 못한 세션입니다. `--record-session` 으로 다시 녹화하세요."
4965
5841
  };
4966
5842
  if (code === "SCHEMA_VERSION_UNSUPPORTED") return {
4967
5843
  code: "EXTERNAL_SESSION_FAILED",
@@ -4990,10 +5866,15 @@ function externalOpenFailure(mode, path, error) {
4990
5866
  function externalCloseFailure(error) {
4991
5867
  const code = error?.code;
4992
5868
  const detail = error instanceof Error ? error.message : String(error);
5869
+ if (code === "INCOMPLETE_SESSION") return {
5870
+ code: "EXTERNAL_SESSION_FAILED",
5871
+ message: "녹화를 완료하지 못했습니다. 끝나지 않은 외부 호출이 남았습니다.",
5872
+ hint: detail
5873
+ };
4993
5874
  return {
4994
5875
  code: "EXTERNAL_SESSION_FAILED",
4995
5876
  message: "External 세션을 닫지 못했습니다.",
4996
- hint: code === "INCOMPLETE_SESSION" ? detail : escapeTerminalText(detail)
5877
+ hint: escapeTerminalText(detail)
4997
5878
  };
4998
5879
  }
4999
5880
  /** 같은 단서의 재생 판. 남지 않는 것이 아니라 **실제 네트워크로 나가는** 것이 요점이다. */
@@ -5047,7 +5928,8 @@ function externalSessionOutcome(summary, sessionPath) {
5047
5928
  const shownPath = escapeTerminalText(sessionPath);
5048
5929
  if (summary.mode === "record") {
5049
5930
  if (summary.interactionCount === 0) return void 0;
5050
- if (summary.status !== "completed") return `\n→ 이 실행이 실패해 녹화를 완료하지 않았습니다: ${shownPath}\n 외부 호출 ${summary.interactionCount}건을 잡았지만 재생 원본으로 쓸 수 없습니다.\n→ 실패 원인을 고친 뒤 다시 녹화하세요.
5931
+ if (summary.status !== "completed") return `\n→ 녹화가 완료되지 않은 채 닫혔습니다: ${shownPath}\n 외부 호출 ${summary.interactionCount}건을 잡았지만 재생 원본으로 쓸 수 없습니다.\n→ 테스트 판정과는 무관합니다. 녹화 자체가 도중에 중단됐습니다.
5932
+ → 위에 나온 원인을 고친 뒤 다시 녹화하세요.
5051
5933
  `;
5052
5934
  return `\n→ 외부 호출 ${summary.interactionCount}건을 녹화했습니다: ${shownPath}\n 이 수는 어댑터가 잡은 호출만 셉니다. 범위 밖 호출은 세션에 남지 않습니다.
5053
5935
  `;
@@ -5094,9 +5976,16 @@ function formatUtc(recordedAt, date, time) {
5094
5976
  return `${date} ${time} UTC`;
5095
5977
  }
5096
5978
  function externalSessionNotice(summary) {
5979
+ const others = summary.otherProcessCalls ?? 0;
5097
5980
  if (summary.mode === "record") {
5098
- if (summary.interactionCount > 0) return void 0;
5099
- return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
5981
+ if (summary.interactionCount === 0 && others > 0) return `
5982
+ 알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.
5983
+ → 다른 프로세스가 먼저 이 세션의 기록을 시작해, 이 실행의 호출 ${others}건을 녹화하지 않았습니다.\n→ 서버가 외부 API를 자식 프로세스에서 부르면 먼저 시작한 쪽만 녹화됩니다.
5984
+ `;
5985
+ if (summary.interactionCount === 0) return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
5986
+ if (others > 0) return `\n알림: 이 실행의 외부 호출 ${others}건이 다른 프로세스에서 나와 녹화되지 않았습니다.\n→ 녹화본은 이 서버가 한 일의 일부만 담고 있습니다. 재생은 나머지를 막지 못합니다.
5987
+ `;
5988
+ return;
5100
5989
  }
5101
5990
  if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
5102
5991
  if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
@@ -5148,6 +6037,7 @@ async function runCli(argv, dependencies) {
5148
6037
  wiring = await startExternalWiring({
5149
6038
  mode: mode.mode,
5150
6039
  sessionPath: mode.path,
6040
+ ...mode.origin === void 0 ? {} : { origin: mode.origin },
5151
6041
  existingNodeOptions: process.env.NODE_OPTIONS
5152
6042
  });
5153
6043
  } catch (error) {
@@ -5161,7 +6051,7 @@ async function runCli(argv, dependencies) {
5161
6051
  throw error;
5162
6052
  }
5163
6053
  try {
5164
- const summary = await wiring.finish(exitCode === 0 ? "completed" : "failed");
6054
+ const summary = await wiring.finish("completed");
5165
6055
  const outcome = externalSessionOutcome(summary, mode.path);
5166
6056
  if (outcome !== void 0) dependencies.writeStderr(outcome);
5167
6057
  if (summary.mode === "replay") {
@@ -5202,10 +6092,18 @@ function externalModeOf(argv) {
5202
6092
  } catch {
5203
6093
  return;
5204
6094
  }
5205
- if (input.recordSessionPath !== void 0) return {
5206
- mode: "record",
5207
- path: input.recordSessionPath
5208
- };
6095
+ if (input.recordSessionPath !== void 0) {
6096
+ const origin = input.target.transport === "stdio" ? {
6097
+ command: input.target.command,
6098
+ args: input.target.args,
6099
+ suitePath: input.suitePath
6100
+ } : void 0;
6101
+ return {
6102
+ mode: "record",
6103
+ path: input.recordSessionPath,
6104
+ ...origin === void 0 ? {} : { origin }
6105
+ };
6106
+ }
5209
6107
  if (input.sessionPath !== void 0) return {
5210
6108
  mode: "replay",
5211
6109
  path: input.sessionPath
@@ -5218,6 +6116,12 @@ Object.defineProperty(exports, "GLOBAL_HELP", {
5218
6116
  return GLOBAL_HELP;
5219
6117
  }
5220
6118
  });
6119
+ Object.defineProperty(exports, "REVIEW_BACK_VALUE", {
6120
+ enumerable: true,
6121
+ get: function() {
6122
+ return REVIEW_BACK_VALUE;
6123
+ }
6124
+ });
5221
6125
  Object.defineProperty(exports, "commandDiscovery", {
5222
6126
  enumerable: true,
5223
6127
  get: function() {