devez-vibe 1.6.44 → 1.6.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,11 @@
10
10
  npm install -g devez-vibe
11
11
  ```
12
12
 
13
- 설치하면 `dvz` 명령을 사용할 수 있습니다.
13
+ 설치하면 `dvz` 명령을 사용할 수 있습니다.
14
+
15
+ 설치 과정에서 `luna-loop` 스킬도 Codex와 Claude의 사용자 스킬 경로에 함께 설치됩니다.
16
+ 스킬은 자동 적용되지 않으며 `$luna-loop`로 직접 호출해야 합니다. 실제
17
+ `gpt-5.6-luna` 검증을 사용하려면 Codex provider와 Luna 위임 기능이 연결돼 있어야 합니다.
14
18
 
15
19
  | 요건 | 값 |
16
20
  | --- | --- |
package/bin/dvz.exe CHANGED
Binary file
@@ -1064,9 +1064,11 @@ async function createSession(params, resumeId) {
1064
1064
  turn: null,
1065
1065
  // Prompts that arrived while a turn was running, run in order afterwards.
1066
1066
  pendingPrompts: [],
1067
- // Steered prompts whose answer may still need a turn of its own.
1068
- steerPending: 0,
1069
- turnSequence: 1,
1067
+ // Steered prompts whose answer may still need a turn of its own.
1068
+ steerPending: 0,
1069
+ // SDK task notifications can prompt the main agent without a host request.
1070
+ automaticTurnsPending: 0,
1071
+ turnSequence: 1,
1070
1072
  itemSequence: 1,
1071
1073
  streamBlocks: new Map(),
1072
1074
  tools: new Map(),
@@ -1896,10 +1898,13 @@ function processSubagentSystemMessage(session, message) {
1896
1898
  running.lastSeenAt = Date.now();
1897
1899
  emitSubagents(session);
1898
1900
  return true;
1899
- }
1900
- if (message.subtype === "task_notification") {
1901
- if (message.ambient === true && message.task_id) {
1902
- session.ambientSubagentTasks ||= new Set();
1901
+ }
1902
+ if (message.subtype === "task_notification") {
1903
+ if (message.ambient !== true && message.skip_transcript !== true) {
1904
+ session.automaticTurnsPending = (session.automaticTurnsPending || 0) + 1;
1905
+ }
1906
+ if (message.ambient === true && message.task_id) {
1907
+ session.ambientSubagentTasks ||= new Set();
1903
1908
  session.ambientSubagentTasks.add(firstLine(message.task_id, 80));
1904
1909
  }
1905
1910
  const status = firstLine(message.status || "completed", 40);
@@ -2135,19 +2140,25 @@ function finishNotifiedSubagents(session, notifications) {
2135
2140
  }
2136
2141
  }
2137
2142
 
2138
- function processUser(session, message) {
2139
- // 자식 tool_result의 tool_use_id는 부모 세션의 것과 다른 공간이므로, 부모 흐름에
2143
+ function processUser(session, message) {
2144
+ // Resume replays are historical transcript frames, not fresh task results or
2145
+ // prompts. Re-processing their notification XML would invent a new turn.
2146
+ if (message.isReplay === true) return;
2147
+ // 자식 tool_result의 tool_use_id는 부모 세션의 것과 다른 공간이므로, 부모 흐름에
2140
2148
  // 섞이기 전에 서브에이전트 기록으로 보낸다.
2141
2149
  if (message.parent_tool_use_id) {
2142
2150
  recordSubagentResult(session, message);
2143
2151
  return;
2144
2152
  }
2145
- const notifications = taskNotifications(message);
2146
- if (notifications.length) {
2147
- if (!session.turn) beginTurn(session);
2148
- finishNotifiedSubagents(session, notifications);
2149
- return;
2150
- }
2153
+ const notifications = taskNotifications(message);
2154
+ if (notifications.length) {
2155
+ if (!session.turn) {
2156
+ if (session.automaticTurnsPending > 0) session.automaticTurnsPending -= 1;
2157
+ beginTurn(session);
2158
+ }
2159
+ finishNotifiedSubagents(session, notifications);
2160
+ return;
2161
+ }
2151
2162
  const content = Array.isArray(message.message?.content) ? message.message.content : [];
2152
2163
  for (const block of content) {
2153
2164
  if (block.type !== "tool_result") continue;
@@ -2303,7 +2314,7 @@ async function runPendingPrompt(session) {
2303
2314
  }
2304
2315
  }
2305
2316
 
2306
- function finishTurn(session, error, durationMs) {
2317
+ function finishTurn(session, error, durationMs) {
2307
2318
  if (!session.turn) return;
2308
2319
  flushPendingPlan(session);
2309
2320
  clearForegroundSubagents(session);
@@ -2312,48 +2323,57 @@ function finishTurn(session, error, durationMs) {
2312
2323
  if (durationMs != null) turn.durationMs = durationMs;
2313
2324
  notify("turn/completed", { threadId: session.id, turn });
2314
2325
  session.turn = null;
2315
- session.streamBlocks.clear();
2316
- }
2317
-
2318
- async function consume(session) {
2319
- for await (const message of session.query) {
2320
- adoptSessionId(session, message.session_id);
2321
- // A steered prompt answered after its turn already ended still deserves a
2322
- // turn of its own, or the host would drop every event that follows.
2323
- if (!session.turn
2324
- && session.steerPending > 0
2325
- && !message.parent_tool_use_id
2326
- && (message.type === "stream_event" || message.type === "assistant")) {
2327
- session.steerPending -= 1;
2328
- beginTurn(session);
2329
- }
2330
- if (message.type === "stream_event") {
2331
- if (message.event?.type === "content_block_delta" && (message.event?.delta?.text || message.event?.delta?.thinking)) {
2332
- if (session.turn) session.turn.sawStreamText = true;
2333
- }
2334
- await processStreamEvent(session, message);
2335
- } else if (message.type === "assistant") processAssistant(session, message);
2336
- else if (message.type === "user") processUser(session, message);
2337
- else if (message.type === "result") await processResult(session, message);
2338
- else if (message.type === "system" && processSubagentSystemMessage(session, message)) {
2339
- // Structured SDK task lifecycle handled above.
2340
- }
2341
- else if (message.type === "system" && message.subtype === "compact_boundary") {
2342
- noteCompactBoundary(session, message.compact_metadata);
2343
- notify("thread/compacted", { threadId: session.id });
2344
- } else if (message.type === "system" && message.subtype === "permission_denied") {
2345
- rememberPermissionDenial(session, {
2346
- tool: message.tool_name,
2347
- toolUseId: message.tool_use_id,
2348
- reason: message.decision_reason || message.decision_reason_type,
2349
- });
2350
- } else if (message.type === "rate_limit_event") {
2351
- notify("claude/account/updated", { threadId: session.id, rateLimitInfo: message.rate_limit_info });
2352
- } else if (message.type === "system" && message.subtype === "api_retry") {
2353
- notify("warning", { threadId: session.id, provider: "Claude", message: `Claude API 재시도 ${message.attempt}/${message.max_retries}` });
2354
- }
2355
- }
2356
- }
2326
+ session.streamBlocks.clear();
2327
+ }
2328
+
2329
+ // A task notification or steered prompt can start a main-agent response after
2330
+ // the host's previous turn already closed. Only a booked continuation may open
2331
+ // this fallback turn; stale top-level frames from initialization/resume stay out.
2332
+ function beginUntrackedTurn(session, message) {
2333
+ if (session.turn || message.parent_tool_use_id) return false;
2334
+ if (message.type !== "stream_event" && message.type !== "assistant") return false;
2335
+ if (session.automaticTurnsPending > 0) session.automaticTurnsPending -= 1;
2336
+ else if (session.steerPending > 0) session.steerPending -= 1;
2337
+ else return false;
2338
+ beginTurn(session);
2339
+ return true;
2340
+ }
2341
+
2342
+ async function consumeMessage(session, message) {
2343
+ adoptSessionId(session, message.session_id);
2344
+ beginUntrackedTurn(session, message);
2345
+ if (message.type === "stream_event") {
2346
+ if (message.event?.type === "content_block_delta" && (message.event?.delta?.text || message.event?.delta?.thinking)) {
2347
+ if (session.turn) session.turn.sawStreamText = true;
2348
+ }
2349
+ await processStreamEvent(session, message);
2350
+ } else if (message.type === "assistant") processAssistant(session, message);
2351
+ else if (message.type === "user") processUser(session, message);
2352
+ else if (message.type === "result") await processResult(session, message);
2353
+ else if (message.type === "system" && processSubagentSystemMessage(session, message)) {
2354
+ // Structured SDK task lifecycle handled above.
2355
+ }
2356
+ else if (message.type === "system" && message.subtype === "compact_boundary") {
2357
+ noteCompactBoundary(session, message.compact_metadata);
2358
+ notify("thread/compacted", { threadId: session.id });
2359
+ } else if (message.type === "system" && message.subtype === "permission_denied") {
2360
+ rememberPermissionDenial(session, {
2361
+ tool: message.tool_name,
2362
+ toolUseId: message.tool_use_id,
2363
+ reason: message.decision_reason || message.decision_reason_type,
2364
+ });
2365
+ } else if (message.type === "rate_limit_event") {
2366
+ notify("claude/account/updated", { threadId: session.id, rateLimitInfo: message.rate_limit_info });
2367
+ } else if (message.type === "system" && message.subtype === "api_retry") {
2368
+ notify("warning", { threadId: session.id, provider: "Claude", message: `Claude API 재시도 ${message.attempt}/${message.max_retries}` });
2369
+ }
2370
+ }
2371
+
2372
+ async function consume(session) {
2373
+ for await (const message of session.query) {
2374
+ await consumeMessage(session, message);
2375
+ }
2376
+ }
2357
2377
 
2358
2378
  const HANDOFF_HEADER = '<devez_provider_handoff chars="';
2359
2379
  const HANDOFF_SEPARATOR = '">\n';
@@ -3481,14 +3501,185 @@ async function runSelfTest() {
3481
3501
  };
3482
3502
  const captured = [];
3483
3503
  const stdoutWrite = process.stdout.write;
3484
- process.stdout.write = (chunk) => {
3485
- captured.push(String(chunk));
3486
- return true;
3487
- };
3488
- try {
3489
- processUser(lifecycleSession, {
3490
- message: { content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "launched" }] },
3491
- tool_use_result: { isAsync: true, status: "async_launched", agentId: "agent-1" },
3504
+ process.stdout.write = (chunk) => {
3505
+ captured.push(String(chunk));
3506
+ return true;
3507
+ };
3508
+ try {
3509
+ const automaticTurnSession = {
3510
+ id: "automatic-turn-self-test",
3511
+ turn: null,
3512
+ turnSequence: 1,
3513
+ itemSequence: 1,
3514
+ steerPending: 0,
3515
+ automaticTurnsPending: 0,
3516
+ streamBlocks: new Map(),
3517
+ tools: new Map(),
3518
+ tasks: new Map(),
3519
+ planCreatePending: false,
3520
+ subagents: new Map([
3521
+ ["toolu_automatic", {
3522
+ id: "toolu_automatic",
3523
+ toolUseId: "toolu_automatic",
3524
+ taskId: "automatic-agent",
3525
+ background: true,
3526
+ name: "Explore",
3527
+ description: "Inspect automatic response",
3528
+ tool: "",
3529
+ startedAt: Date.now(),
3530
+ lastSeenAt: Date.now(),
3531
+ }],
3532
+ ["toolu_automatic_2", {
3533
+ id: "toolu_automatic_2",
3534
+ toolUseId: "toolu_automatic_2",
3535
+ taskId: "automatic-agent-2",
3536
+ background: true,
3537
+ name: "Explore",
3538
+ description: "Inspect a second automatic response",
3539
+ tool: "",
3540
+ startedAt: Date.now(),
3541
+ lastSeenAt: Date.now(),
3542
+ }],
3543
+ ]),
3544
+ knownSubagents: new Map(),
3545
+ hiddenSubagentTasks: new Set(),
3546
+ ambientSubagentTasks: new Set(),
3547
+ subagentPulse: null,
3548
+ models: [],
3549
+ model: "claude:default",
3550
+ pendingPrompts: [],
3551
+ permissionDenials: [],
3552
+ lastContextUsage: null,
3553
+ lastContextWindow: 0,
3554
+ query: {
3555
+ async accountInfo() { return null; },
3556
+ async usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() { return null; },
3557
+ },
3558
+ };
3559
+ await consumeMessage(automaticTurnSession, {
3560
+ type: "assistant",
3561
+ parent_tool_use_id: null,
3562
+ message: { content: [{ type: "text", text: "stale replay" }] },
3563
+ });
3564
+ if (automaticTurnSession.turn !== null) {
3565
+ throw new Error("Claude stale top-level response incorrectly opened a host turn");
3566
+ }
3567
+ await consumeMessage(automaticTurnSession, {
3568
+ type: "user",
3569
+ isReplay: true,
3570
+ origin: { kind: "task-notification" },
3571
+ parent_tool_use_id: null,
3572
+ message: { content: `<task-notification>
3573
+ <task-id>automatic-agent</task-id>
3574
+ <tool-use-id>toolu_automatic</tool-use-id>
3575
+ <status>completed</status><summary>Historical completion</summary>
3576
+ </task-notification>` },
3577
+ });
3578
+ if (automaticTurnSession.turn !== null
3579
+ || automaticTurnSession.automaticTurnsPending !== 0
3580
+ || automaticTurnSession.subagents.size !== 2) {
3581
+ throw new Error("Claude replayed task notification changed live turn state");
3582
+ }
3583
+
3584
+ await consumeMessage(automaticTurnSession, {
3585
+ type: "system",
3586
+ subtype: "task_notification",
3587
+ task_id: "automatic-agent",
3588
+ tool_use_id: "toolu_automatic",
3589
+ status: "completed",
3590
+ summary: "Agent finished",
3591
+ });
3592
+ await consumeMessage(automaticTurnSession, {
3593
+ type: "system",
3594
+ subtype: "task_notification",
3595
+ task_id: "automatic-agent-2",
3596
+ tool_use_id: "toolu_automatic_2",
3597
+ status: "completed",
3598
+ summary: "Second agent finished",
3599
+ });
3600
+ if (automaticTurnSession.subagents.size !== 0
3601
+ || automaticTurnSession.automaticTurnsPending !== 2
3602
+ || automaticTurnSession.turn !== null) {
3603
+ throw new Error("Claude task notifications did not book both automatic turns");
3604
+ }
3605
+ await consumeMessage(automaticTurnSession, {
3606
+ type: "assistant",
3607
+ parent_tool_use_id: "toolu_child",
3608
+ message: { content: [{ type: "text", text: "child" }] },
3609
+ });
3610
+ if (automaticTurnSession.turn !== null
3611
+ || automaticTurnSession.automaticTurnsPending !== 2) {
3612
+ throw new Error("Claude child response consumed the pending main-agent turn");
3613
+ }
3614
+
3615
+ await consumeMessage(automaticTurnSession, {
3616
+ type: "stream_event",
3617
+ parent_tool_use_id: null,
3618
+ event: { type: "message_start" },
3619
+ });
3620
+ await consumeMessage(automaticTurnSession, {
3621
+ type: "stream_event",
3622
+ parent_tool_use_id: null,
3623
+ event: {
3624
+ type: "content_block_start",
3625
+ index: 0,
3626
+ content_block: { type: "text" },
3627
+ },
3628
+ });
3629
+ await consumeMessage(automaticTurnSession, {
3630
+ type: "stream_event",
3631
+ parent_tool_use_id: null,
3632
+ event: {
3633
+ type: "content_block_delta",
3634
+ index: 0,
3635
+ delta: { text: "automatic summary one" },
3636
+ },
3637
+ });
3638
+ await consumeMessage(automaticTurnSession, {
3639
+ type: "stream_event",
3640
+ parent_tool_use_id: null,
3641
+ event: { type: "content_block_stop", index: 0 },
3642
+ });
3643
+ await consumeMessage(automaticTurnSession, {
3644
+ type: "assistant",
3645
+ parent_tool_use_id: null,
3646
+ message: { content: [{ type: "text", text: "automatic summary one" }] },
3647
+ });
3648
+ if (automaticTurnSession.turn === null
3649
+ || automaticTurnSession.automaticTurnsPending !== 1) {
3650
+ throw new Error("Claude streamed automatic response did not open one host turn");
3651
+ }
3652
+ await consumeMessage(automaticTurnSession, {
3653
+ type: "result",
3654
+ is_error: false,
3655
+ modelUsage: {},
3656
+ permission_denials: [],
3657
+ duration_ms: 1,
3658
+ });
3659
+ if (automaticTurnSession.turn !== null) {
3660
+ throw new Error("Claude automatic response did not complete its host turn");
3661
+ }
3662
+
3663
+ await consumeMessage(automaticTurnSession, {
3664
+ type: "assistant",
3665
+ parent_tool_use_id: null,
3666
+ message: { content: [{ type: "text", text: "automatic summary two" }] },
3667
+ });
3668
+ await consumeMessage(automaticTurnSession, {
3669
+ type: "result",
3670
+ is_error: false,
3671
+ modelUsage: {},
3672
+ permission_denials: [],
3673
+ duration_ms: 1,
3674
+ });
3675
+ if (automaticTurnSession.turn !== null
3676
+ || automaticTurnSession.automaticTurnsPending !== 0) {
3677
+ throw new Error("Claude consecutive automatic responses left a pending turn");
3678
+ }
3679
+
3680
+ processUser(lifecycleSession, {
3681
+ message: { content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "launched" }] },
3682
+ tool_use_result: { isAsync: true, status: "async_launched", agentId: "agent-1" },
3492
3683
  });
3493
3684
  finishTurn(lifecycleSession, null, 1);
3494
3685
  if (!lifecycleSession.subagents.has("toolu_1") || lifecycleSession.turn !== null) {
@@ -3784,10 +3975,16 @@ async function runSelfTest() {
3784
3975
  .filter(Boolean)
3785
3976
  .map((line) => JSON.parse(line));
3786
3977
  const lifecycleMethods = lifecycleEvents.map((event) => event.method);
3787
- if (!lifecycleMethods.includes("turn/subagents/updated")
3788
- || lifecycleMethods.filter((method) => method === "turn/started").length < 3
3789
- || !lifecycleEvents.some((event) => event.method === "turn/subagent/line"
3790
- && event.params?.line?.kind === "error")) {
3978
+ if (!lifecycleMethods.includes("turn/subagents/updated")
3979
+ || lifecycleMethods.filter((method) => method === "turn/started").length < 3
3980
+ || !lifecycleEvents.some((event) => event.method === "item/completed"
3981
+ && event.params?.item?.type === "agentMessage"
3982
+ && event.params.item.text === "automatic summary one")
3983
+ || !lifecycleEvents.some((event) => event.method === "item/completed"
3984
+ && event.params?.item?.type === "agentMessage"
3985
+ && event.params.item.text === "automatic summary two")
3986
+ || !lifecycleEvents.some((event) => event.method === "turn/subagent/line"
3987
+ && event.params?.line?.kind === "error")) {
3791
3988
  throw new Error(`Claude subagent lifecycle events self-test failed: ${lifecycleMethods}`);
3792
3989
  }
3793
3990
  const openingSession = {
@@ -0,0 +1,51 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const packageRoot = dirname(fileURLToPath(import.meta.url));
12
+ const sourceRoot = join(packageRoot, "skills", "luna-loop");
13
+ const userHome = homedir();
14
+ const codexHome = process.env.CODEX_HOME?.trim() || join(userHome, ".codex");
15
+ const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(userHome, ".claude");
16
+
17
+ const targets = [
18
+ { name: "Codex", path: join(codexHome, "skills", "luna-loop") },
19
+ { name: "Claude", path: join(claudeHome, "skills", "luna-loop") },
20
+ ];
21
+
22
+ function copyTree(source, destination) {
23
+ mkdirSync(destination, { recursive: true });
24
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
25
+ const sourcePath = join(source, entry.name);
26
+ const destinationPath = join(destination, entry.name);
27
+ if (entry.isDirectory()) {
28
+ copyTree(sourcePath, destinationPath);
29
+ } else if (entry.isFile()) {
30
+ copyFileSync(sourcePath, destinationPath);
31
+ }
32
+ }
33
+ }
34
+
35
+ if (!existsSync(join(sourceRoot, "SKILL.md"))) {
36
+ console.error(`스킬 원본(luna-loop)을 찾지 못했습니다: ${sourceRoot}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ let failed = false;
41
+ for (const target of targets) {
42
+ try {
43
+ copyTree(sourceRoot, target.path);
44
+ console.log(`스킬 설치 완료 (${target.name}): ${target.path}`);
45
+ } catch (error) {
46
+ failed = true;
47
+ console.error(`스킬 설치 실패 (${target.name}): ${error instanceof Error ? error.message : error}`);
48
+ }
49
+ }
50
+
51
+ if (failed) process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devez-vibe",
3
- "version": "1.6.44",
3
+ "version": "1.6.45",
4
4
  "description": "Stable terminal UI for Codex and Claude Agent SDK",
5
5
  "keywords": [
6
6
  "codex",
@@ -23,6 +23,8 @@
23
23
  "files": [
24
24
  "bin/dvz.exe",
25
25
  "bridge/claude-agent-sdk-bridge.mjs",
26
+ "install-skills.mjs",
27
+ "skills/luna-loop/",
26
28
  "README.md",
27
29
  "LICENSE"
28
30
  ],
@@ -35,6 +37,9 @@
35
37
  "engines": {
36
38
  "node": ">=18"
37
39
  },
40
+ "scripts": {
41
+ "postinstall": "node install-skills.mjs"
42
+ },
38
43
  "dependencies": {
39
44
  "@anthropic-ai/claude-agent-sdk": "0.3.247"
40
45
  }
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: luna-loop
3
+ description: 직접 호출한 수정·구현 작업을 독립적인 Luna 검증과 재수정으로 반복 완료하고, 범위가 크면 검증 에이전트를 병렬 배치할 때 사용한다. 자동 선택은 하지 않는다.
4
+ ---
5
+
6
+ # 수정-검증 루프
7
+
8
+ 코드·설정·문서를 실제로 바꾸는 작업에서 완료 기준을 `수정함`으로 끝내지 않는다. 요청의 수용 조건을 정리하고, 변경 후 독립 검증을 거쳐 유효한 문제가 발견되면 원래 작업 맥락에서 재수정한 뒤 검증을 반복한다.
9
+
10
+ 이 스킬은 사용자가 `$luna-loop`로 직접 호출한 경우에만 사용한다. 직접 호출된 경우에는 새 수정 작업뿐 아니라 현재 변경의 검증만 요청된 경우도 수행하며, 호출되지 않은 단순 설명·조사에는 적용하지 않는다.
11
+
12
+ ## 적용 경계
13
+
14
+ - 구현·수정·버그 해결·리팩터링·UI 변경 또는 현재 변경 검증 요청에 적용한다.
15
+ - 검증만 요청된 경우에는 기준선 확인과 직접 검증·Luna 검증을 수행하고, 확정 문제가 있을 때만 수정을 진행한다.
16
+ - 커밋·푸시·배포·외부 시스템 변경은 사용자가 별도로 요청한 경우에만 수행한다.
17
+ - 기존 미커밋 변경은 기준선으로 보존하고, 이번 작업과 무관한 파일을 되돌리거나 정리하지 않는다.
18
+
19
+ ## 핵심 운영 원칙
20
+
21
+ 1. **주 편집자는 하나로 유지한다.** 검증 에이전트는 기본적으로 읽기 전용이며 파일을 수정하지 않는다. 여러 에이전트의 의견을 주 편집자가 원본 코드와 수용 조건으로 재확인한 뒤에만 수정한다.
22
+ 2. **검증 대상과 기준선을 분리한다.** 시작 전에 저장소 지침, 현재 변경, 실행 중인 프로세스, 기존 실패를 확인하고 이번 작업에서 생긴 변경과 이미 존재하던 상태를 구분한다.
23
+ 3. **추측을 결함으로 올리지 않는다.** 지적에는 기대 조건, 실제 코드 경로, 재현 방법 또는 사용자 영향이 모두 있어야 한다. 근거가 부족하면 `미확인`으로 남기고 무리하게 수정하지 않는다.
24
+ 4. **검증 결과를 꾸며내지 않는다.** Luna를 호출하지 못했거나 테스트·빌드가 실행되지 않았으면 성공으로 표시하지 말고 그 한계를 보고한다.
25
+
26
+ ## 실행 절차
27
+
28
+ ### 1. 기준선과 수용 조건 확정
29
+
30
+ - 저장소의 `AGENTS.md`, `CLAUDE.md`, 기여 문서와 관련 지침을 먼저 읽는다.
31
+ - 현재 작업 디렉터리, 변경 파일, 실행 중인 프로세스, 기존 테스트·빌드 실패를 확인한다.
32
+ - 사용자의 요구를 관찰 가능한 수용 조건으로 바꾼다. 표시·상태·입력·저장·오류·성능 중 영향을 받는 축을 빠뜨리지 않는다.
33
+ - 변경 파일과 직접 영향을 받는 호출자·피호출자·테스트를 짧은 목록으로 고정한다.
34
+ - 수용 조건마다 가장 값싼 직접 검증 명령이나 실행 경로를 정한다.
35
+
36
+ ### 1-1. Luna 실행 가능성 게이트
37
+
38
+ - 파일을 수정하기 전에 현재 호스트가 실제 `gpt-5.6-luna`를 위임 실행할 수 있는지 확인한다.
39
+ - Codex에서는 `multi_agent_v1__spawn_agent`가 노출되고 반환된 실행 모델을 `gpt-5.6-luna`로 확인할 수 있어야 한다.
40
+ - Claude에서는 Codex provider와 교차 제공자 Luna 위임 기능이 실제로 연결돼 있어야 한다. Claude 모델이나 직접 검증으로 대체하지 않는다.
41
+ - 게이트를 통과하지 못하면 새 수정은 시작하지 않고 `Luna 검증 불가` 사유와 필요한 연결 상태만 보고한다. 이미 존재하는 변경은 읽기 전용으로 확인할 수 있지만 완료로 판정하지 않는다.
42
+
43
+ ### 2. 최소 범위로 수정
44
+
45
+ - 기준선과 수용 조건을 벗어나지 않는 가장 작은 일관된 변경을 적용한다.
46
+ - 검증을 쉽게 하려고 무관한 리팩터링, 형식 정리, 테스트 삭제를 끼워 넣지 않는다.
47
+ - 수정 직후 변경 목록과 수용 조건의 대응을 다시 대조한다.
48
+
49
+ ### 3. 주 편집자의 직접 검증
50
+
51
+ - 가능한 경우 변경 지점에 가장 가까운 단위 테스트·정적 검사·빌드·실행 확인을 먼저 한다.
52
+ - 실패는 `이번 변경의 실패`, `기준선도 실패`, `환경상 실행 불가`로 나눠 기록한다.
53
+ - 직접 검증이 통과해도 독립 검증을 생략하지 않는다. 직접 검증은 실행 가능성, Luna 검증은 놓친 계약·회귀·경계 조건을 확인하는 역할이다.
54
+
55
+ ### 4. Luna 독립 검증
56
+
57
+ - 실행 가능성 게이트를 통과한 뒤에만 검증 에이전트를 생성한다. 모든 검증자는 실제 `gpt-5.6-luna`로 실행돼야 하며, Luna를 사용할 수 없으면 대체 검증 없이 중단한다.
58
+ - 검증자에게는 요청 요약, 수용 조건, 변경 파일·관련 코드 범위, 기준선 정보, 이미 실행한 검사만 전달한다. 주 편집자의 의심 결론이나 원하는 판정을 전달하지 않는다.
59
+ - 각 검증자는 읽기 전용으로 동작하게 하고, 근거 없는 광범위 탐색이나 파일 수정을 금지한다.
60
+ - 수정과 직접 검증이 끝난 직후, 완료 직전의 최종 검증 패널로 최소 2개의 Luna 검증자를 서로 다른 관점에서 동시에 생성한다.
61
+ - 변경 범위가 크면 독립 범위를 나눠 3~4개의 Luna 검증자를 동시에 생성한다. 다음 중 하나라도 해당하면 큰 변경으로 본다.
62
+ - 변경 파일이 5개 이상이다.
63
+ - 서로 다른 하위 시스템이 2개 이상이다.
64
+ - UI와 상태·저장·통신 로직이 함께 바뀐다.
65
+ - 수용 조건이 서로 다른 동작 축 3개 이상으로 나뉜다.
66
+ - 병렬 검증의 범위는 겹치지 않게 나눈다. 적합한 축은 `정상 사용 흐름·요구사항`, `회귀·상태·오류 경로`, `테스트·빌드·호환성`, `UI·입력·접근성`이다. UI가 없으면 마지막 축을 만들지 않는다.
67
+ - 모든 검증자의 결과를 받은 뒤 한 번에 종합한다. 같은 문제를 반복 보고한 수는 확신도의 근거가 아니라 한 건의 근거 묶음으로 취급한다.
68
+ - 생성한 각 검증자의 종료 상태를 확인한다. `완료`가 아닌 `오류`·`중단`·`시간 초과`·`상태 미확인`은 해당 범위의 검증 미완료로 기록한다.
69
+ - `오류`·`중단`·`시간 초과`·`상태 미확인`·`Luna 모델 미확인` 범위는 같은 범위의 새 Luna 검증자로 한 번만 재시도한다. 재시도도 완료되지 않으면 추가 재시도·직접 검증·다른 모델 대체 없이 즉시 중단하고 실행 불가 사유와 영향 범위를 보고한다.
70
+ - 검증자가 일부만 끝났다면 끝난 결과는 잠정 결과로만 사용한다. 누락 범위가 있으면 같은 범위의 Luna 검증자를 새로 실행하며, 직접 검증이나 다른 모델로 대체하지 않는다.
71
+ - 병렬 결과가 하나라도 미완료인 채로 남으면 전체를 통과로 판정하지 않는다. 새 Luna 검증자도 실패하면 해당 범위의 실행 불가 사유와 영향 범위를 보고하고 중단한다.
72
+
73
+ 검증자에게 전달할 기본 요청은 [검증자 계약](references/verifier-contract.md)을 따른다.
74
+
75
+ ### 5. 문제 판정과 재수정
76
+
77
+ - 각 지적을 원본 코드, 수용 조건, 실행 결과로 다시 확인한다. 검증자의 말만으로 수정하지 않는다.
78
+ - 유효한 지적만 `확정 문제`로 채택하고, 의도 확인이 필요한 내용은 `확인 요청`, 근거가 약한 내용은 `미확인`으로 분리한다.
79
+ - 요청·기획·기존 계약을 확인해야만 결정할 수 있는 `확인 요청`은 결함으로 단정하지 않는다. 사용자의 답이 없으면 해당 항목을 완료 차단 사유로 남긴다.
80
+ - 확정 문제를 주 편집자가 수정한 뒤, 영향을 받은 직접 검증을 다시 실행한다.
81
+ - 확정 문제가 하나라도 있으면 수정과 직접 검증을 끝낸 뒤 최종 Luna 검증 패널 전체를 같은 관점으로 다시 병렬 실행한다. 고립된 수정이어도 일부 검증자만 생략하지 않는다.
82
+ - 재검증에서 새로운 확정 문제가 나오면 기본 최대 3회의 수정-검증 회차 안에서 같은 루프를 반복한다. 3회 뒤에도 확정 문제가 남으면 아래 종료 규칙에 따라 중단하며 완료하지 않는다.
83
+ - 반복마다 회차, 채택·기각한 지적과 근거, 새로 바뀐 범위, 실행한 검사를 기록한다. 이전에 통과한 검사를 결과 없이 재사용하지 않는다.
84
+
85
+ ### 6. 종료 판정
86
+
87
+ 다음 조건을 모두 만족할 때만 완료로 판정한다.
88
+
89
+ - 수용 조건을 위반하는 확정 문제가 남아 있지 않다.
90
+ - 사용자 판단이 필요한 `확인 요청`이 남아 있지 않다. 남아 있다면 완료가 아니라 명시적 중단·질문 상태로 보고한다.
91
+ - 필수 직접 검증이 통과했거나, 실행 불가 사유와 영향 범위가 명확히 기록됐다.
92
+ - 마지막 변경 범위에 대해 실제 실행 모델이 `gpt-5.6-luna`로 확인된 Luna 검증이 끝났다.
93
+ - 기존 실패·미확인 런타임·사용자 확인 필요 항목이 성공으로 숨겨지지 않았다.
94
+
95
+ 기본 수정-검증 회차는 3회까지다. 3회 뒤에도 확정 문제가 남거나 같은 검증이 반복해서 모순되면 무한 재시도하지 말고, 남은 문제·시도한 수정·차단된 검증·사용자에게 필요한 결정을 보고한다. 사용자가 더 반복하라고 명시한 경우에만 회차를 늘린다.
96
+
97
+ ## 제공자 차이
98
+
99
+ 공통 본문은 Codex와 Claude에서 읽을 수 있게 작성한다. 위임 도구와 모델 이름은 [제공자 호환 안내](references/provider-compatibility.md)를 먼저 확인하고, 현재 호스트에 실제로 노출된 기능만 사용한다.
100
+
101
+ ## 결과 보고
102
+
103
+ 최종 보고에는 최소한 `확인된 원인`, `사용자 영향`, `실제 조치`, `직접 검증`, `Luna 검증`, `남은 한계`를 포함한다. 파일 경로·기술 식별자는 원인과 검증 근거를 판단하는 데 필요한 범위만 쓴다.
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "루프 검증"
3
+ short_description: "수정 후 Luna 반복 검증과 병렬 재검수를 자동화"
4
+ default_prompt: "직접 $luna-loop을 호출해 수정 사항을 검증하고 문제가 남으면 재수정한 뒤 다시 검증해 주세요."
5
+
6
+ policy:
7
+ allow_implicit_invocation: false
@@ -0,0 +1,23 @@
1
+ # 제공자 호환 안내
2
+
3
+ ## Codex
4
+
5
+ - `multi_agent_v1__spawn_agent`가 노출돼 있으면 검증자별로 분리된 읽기 전용 작업을 생성한다.
6
+ - 파일을 수정하기 전에 `gpt-5.6-luna` 위임 가능 여부를 확인한다. 사용자가 요청한 Luna 검증은 해당 모델을 명시해 생성하고, 실제 실행 모델을 반환 정보로 확인한다.
7
+ - 모델 지정이 거부되거나 위임 기능이 없거나 실제 모델 식별이 불가능하면 직접 검증·다른 모델·Claude 검증으로 폴백하지 않는다. 새 수정을 중단하고 `Luna 검증 불가` 또는 `Luna 모델 미확인`으로 기록한다.
8
+ - 수정과 직접 검증이 끝나면 서로 다른 관점의 Luna 검증자 2개 이상을 병렬로 생성한다. 큰 변경은 3~4개로 늘리고, 확정 문제를 수정한 뒤에는 전체 검증자 묶음을 다시 병렬 실행한다.
9
+ - 오류·시간 초과·상태 미확인은 같은 범위의 Luna 검증자로 한 번만 재시도한다. 재시도도 실패하면 대체 없이 중단한다.
10
+
11
+ ## Claude
12
+
13
+ - Claude provider에서도 동일한 `SKILL.md`를 읽을 수 있다. Claude Code의 `Task` 또는 동등한 위임 기능이 실제로 노출된 경우에만 검증자를 생성한다.
14
+ - Claude provider는 동일한 스킬을 읽을 수 있지만, Codex provider와 교차 제공자 `gpt-5.6-luna` 위임 기능이 실제로 연결된 경우에만 실행을 시작한다. Claude 계열 모델만 노출되면 대체하지 않고 중단한다.
15
+ - Claude 검증자는 쓰기 도구·쓰기 권한을 받지 않도록 호스트 기능으로 제한하고, 요청문에도 파일 수정 금지를 명시한다. 읽기 전용을 강제할 수 없으면 Luna 검증 불가로 중단한다.
16
+ - Claude의 계획·위임 도구 이름은 호스트 버전에 따라 달라질 수 있으므로 존재하지 않는 도구를 가정하지 않는다. 교차 제공자 Luna 위임과 실제 모델 식별이 확인되지 않으면 파일을 수정하지 않고 중단한다.
17
+ - Claude에서 Luna 검증을 시작한 뒤에도 실제 실행 모델이 `gpt-5.6-luna`로 확인되지 않으면 검증 실패로 처리하고, Claude 모델이나 직접 검증으로 대체하지 않는다.
18
+
19
+ ## 공통 제한
20
+
21
+ - 제공자가 무엇이든 검증자는 파일을 수정하지 않는다. 수정은 주 편집자 하나가 수행한다.
22
+ - 서로 다른 제공자의 결과가 충돌하면 양쪽 결과를 사실로 합치지 말고 원본 코드와 실행 결과로 재판정한다.
23
+ - 모델을 호출하지 못한 상태와 접근 불가한 실행 환경은 Luna 검증 불가로 기록하고 완료하지 않는다. 기준선 실패는 Luna 실행 결과와 별도 한계로 기록한다.
@@ -0,0 +1,45 @@
1
+ # 검증자 계약
2
+
3
+ 검증자는 변경을 고치거나 파일을 쓰지 않고, 독립적인 결함 탐색 결과만 반환한다. 변경의 옳고 그름을 미리 암시하는 문장이나 주 편집자의 의심 목록을 전달하지 않는다.
4
+
5
+ ## 전달할 정보
6
+
7
+ - 사용자의 요청을 한 문장으로 요약한 내용
8
+ - 관찰 가능한 수용 조건
9
+ - 기준선에서 이미 존재하던 실패와 이번 변경 파일
10
+ - 검토할 코드 범위와 필요한 테스트·빌드·실행 명령
11
+ - `읽기 전용`, `파일 수정 금지`, `근거 없는 추측 금지`라는 제약
12
+
13
+ ## 검증 요청문
14
+
15
+ ```text
16
+ 현재 변경을 읽기 전용으로 검토하라. 아래 수용 조건을 기준으로 요구사항 누락,
17
+ 기존 동작 회귀, 상태·입력·오류 경로, 경계값, 테스트·빌드·호환성 문제를 찾아라.
18
+ 검증자는 파일을 수정하지 말고, 문제가 없으면 없다고 명시하라.
19
+
20
+ 각 지적은 다음을 모두 포함해야 한다.
21
+ - 심각도: 치명적 / 주요 / 경미 / 미확인
22
+ - 위치: 파일과 줄 또는 식별 가능한 코드 범위
23
+ - 기대 조건: 어떤 수용 조건을 위반하는가
24
+ - 근거: 실제 코드 경로와 관찰된 동작
25
+ - 재현: 실행 명령, 입력 또는 재현 불가 사유
26
+ - 영향: 사용자·데이터·호환성에 미치는 결과
27
+ - 제안: 수정 방향만 제시하고 직접 수정하지 말 것
28
+
29
+ 수용 조건:
30
+ {acceptance_criteria}
31
+
32
+ 변경 범위:
33
+ {changed_scope}
34
+
35
+ 기준선 및 이미 실행한 검사:
36
+ {baseline_and_checks}
37
+ ```
38
+
39
+ ## 결과 종합 규칙
40
+
41
+ - 검증자가 찾은 내용은 데이터이며, 주 편집자가 원본과 수용 조건으로 채택 여부를 결정한다.
42
+ - 수용 조건·코드 경로·재현 또는 영향 중 하나라도 빠진 지적은 `미확인`으로 남긴다.
43
+ - 같은 지적을 여러 검증자가 보고해도 한 건으로 합치고, 서로 다른 근거만 병합한다.
44
+ - 검증자만 발견한 새 지적은 원본을 다시 추적해 실제 문제일 때만 채택한다.
45
+ - 주 편집자만 발견한 지적은 독립 검증 결과와 모순될 수 있으므로 직접 재현한 뒤 등급을 정한다.