makdoong2-team 2.3.2 → 3.0.1

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.
Files changed (44) hide show
  1. package/README.md +4 -12
  2. package/agents/makdoong2-engineer.md +1 -0
  3. package/agents/makdoong2-planner.md +17 -61
  4. package/agents/makdoong2-team-leader.md +14 -4
  5. package/agents/makdoong2-verifier.md +6 -6
  6. package/assets/makdoong2-team.schema.json +0 -18
  7. package/bin/cli.js +27 -1
  8. package/bin/cli.ts +27 -1
  9. package/dist/agent-stage-config.d.ts +1 -1
  10. package/dist/agent-stage-config.js +0 -11
  11. package/dist/apply-patch-paths.d.ts +46 -0
  12. package/dist/apply-patch-paths.js +114 -0
  13. package/dist/config.d.ts +22 -9
  14. package/dist/config.js +61 -11
  15. package/dist/model-fallback-policy.js +0 -4
  16. package/dist/opencode-plugin.js +270 -356
  17. package/dist/poll-sub-session.d.ts +16 -1
  18. package/dist/poll-sub-session.js +76 -11
  19. package/dist/sub-session-registry.d.ts +74 -0
  20. package/dist/sub-session-registry.js +204 -0
  21. package/gates/stage-analysis-verify.sh +63 -5
  22. package/gates/stage4-dev-verify.sh +6 -6
  23. package/gates/verify.sh +0 -2
  24. package/opencode.json.example +0 -1
  25. package/package.json +1 -1
  26. package/scripts/gate-policy-test.sh +15 -14
  27. package/scripts/install-lib.mjs +1 -1
  28. package/scripts/install-lib.mts +1 -1
  29. package/scripts/model-policy.mjs +0 -4
  30. package/scripts/model-policy.mts +0 -4
  31. package/scripts/release.sh +13 -1
  32. package/scripts/run-tests.mts +4 -1
  33. package/scripts/state.sh +24 -2
  34. package/skills/_lib/load-secret.sh +15 -5
  35. package/src/hooks/session-start.sh +0 -1
  36. package/stages/01-planning.md +35 -54
  37. package/stages/02-requirements.md +53 -35
  38. package/stages/04-analysis.md +2 -2
  39. package/stages/05-worktree-dev.md +15 -0
  40. package/agents/makdoong2-researcher.md +0 -68
  41. package/dist/research-fanout.d.ts +0 -165
  42. package/dist/research-fanout.js +0 -341
  43. package/gates/stage3-scope-verify.sh +0 -69
  44. package/stages/03-scope.md +0 -81
@@ -5,6 +5,19 @@ export type MessagePartLike = {
5
5
  state?: {
6
6
  status?: string;
7
7
  };
8
+ /** ToolPart 의 호출 ID — `tool.execute.before/after` 훅의 callID 와 같은 값. */
9
+ callID?: string;
10
+ };
11
+ /**
12
+ * 폴 시점 스냅샷에서 읽은 툴 호출 상태. `isToolExecuting` 에 넘겨 훅 레지스트리와
13
+ * 대조하게 한다 — throw 로 끝난 툴은 after 훅이 돌지 않아 레지스트리에 남는데,
14
+ * 스냅샷의 completed/error part 가 그 호출이 끝났음을 확정해 준다.
15
+ */
16
+ export type ToolSnapshot = {
17
+ /** completed / error 로 끝난 호출 ID (스냅샷 전체 메시지 기준). */
18
+ settledCallIDs: ReadonlySet<string>;
19
+ /** pending / running 인 호출 ID (마지막 assistant 메시지 기준). */
20
+ inFlightCallIDs: ReadonlySet<string>;
8
21
  };
9
22
  export type MessageInfoLike = {
10
23
  id?: string;
@@ -56,6 +69,7 @@ export interface PollClientLike {
56
69
  }) => Promise<unknown>;
57
70
  };
58
71
  }
72
+ export type PermissionStallReason = "outside_allowed_roots" | "non_external_permission" | "tool_call_stall";
59
73
  export type PollOutcome = {
60
74
  kind: "text";
61
75
  text: string;
@@ -84,6 +98,7 @@ export type PollOutcome = {
84
98
  permissionID?: string;
85
99
  permissionType?: string;
86
100
  permissionPatterns?: string[];
101
+ permissionReason?: PermissionStallReason;
87
102
  } | {
88
103
  kind: "session_gone";
89
104
  polls: number;
@@ -107,7 +122,7 @@ export interface PollOptions {
107
122
  messageStallThresholdMs?: number;
108
123
  statusAbsentGraceMs?: number;
109
124
  isRecentlyActive?: () => boolean;
110
- isToolExecuting?: () => boolean;
125
+ isToolExecuting?: (snapshot: ToolSnapshot) => boolean;
111
126
  contentStableCompletionMs?: number;
112
127
  preambleOnlyTextThreshold?: number;
113
128
  nudgeAtFraction?: number;
@@ -29,6 +29,37 @@
29
29
  const TOOL_CALL_PART_TYPES = new Set(["tool", "tool_call", "tool-call", "tool_use"]);
30
30
  /** 아직 끝나지 않은 툴 상태 (ToolStatePending / ToolStateRunning). */
31
31
  const IN_FLIGHT_TOOL_STATUSES = new Set(["pending", "running"]);
32
+ /** 확실히 끝난 툴 상태 (ToolStateCompleted / ToolStateError). */
33
+ const SETTLED_TOOL_STATUSES = new Set(["completed", "error"]);
34
+ /**
35
+ * 스냅샷에서 툴 호출 ID 를 상태별로 모은다. settled 는 전체 메시지를 훑는다 —
36
+ * 레지스트리에 남은 항목은 이전 turn 의 호출일 수도 있다. inFlight 는 마지막
37
+ * assistant 메시지만 본다 (`hasPendingToolCall` 과 같은 기준).
38
+ */
39
+ function collectToolSnapshot(messages, lastAssistant) {
40
+ const settledCallIDs = new Set();
41
+ const inFlightCallIDs = new Set();
42
+ for (const m of messages) {
43
+ if (m.info?.role !== "assistant")
44
+ continue;
45
+ for (const part of m.parts ?? []) {
46
+ if (!TOOL_CALL_PART_TYPES.has(part.type))
47
+ continue;
48
+ if (typeof part.callID !== "string" || part.callID.length === 0)
49
+ continue;
50
+ const status = part.state?.status;
51
+ if (typeof status === "string" && SETTLED_TOOL_STATUSES.has(status))
52
+ settledCallIDs.add(part.callID);
53
+ }
54
+ }
55
+ for (const part of lastAssistant?.parts ?? []) {
56
+ if (!isInFlightToolPart(part))
57
+ continue;
58
+ if (typeof part.callID === "string" && part.callID.length > 0)
59
+ inFlightCallIDs.add(part.callID);
60
+ }
61
+ return { settledCallIDs, inFlightCallIDs };
62
+ }
32
63
  /**
33
64
  * "지금 실행 중인 툴 호출이 있는가".
34
65
  *
@@ -236,7 +267,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
236
267
  // 둘 중 하나라도 "툴이 떠 있다" 고 하면 완료로 판정하지 않는다 — 스냅샷은
237
268
  // 서버 반영이 늦고(issue #7), 훅 신호는 훅이 안 붙은 환경에서 아예 없다.
238
269
  // OR 로 묶어야 각자의 사각지대를 서로 덮는다.
239
- const toolExecuting = isToolExecuting?.() === true;
270
+ const toolExecuting = isToolExecuting
271
+ ? isToolExecuting(collectToolSnapshot(messages, lastAssistant)) === true
272
+ : false;
240
273
  const toolInFlight = hasPendingToolCall || toolExecuting;
241
274
  const stalledMs = now() - lastProgressAt;
242
275
  // session_gone (status_absent) has two admission paths with different
@@ -314,9 +347,15 @@ export async function pollSubSession(client, sessionId, options = {}) {
314
347
  .catch(() => undefined);
315
348
  }
316
349
  else {
350
+ // 두 실패를 뭉뚱그리지 않는다. "허용 경로 밖" 은 대안 경로를 알려주면
351
+ // 회복 가능하고, "external_directory 가 아닌 권한" 은 경로와 무관한
352
+ // 에이전트 설정 문제라 조치가 정반대다. 종전에는 한 문장이었다.
353
+ const reason = p.permission === "external_directory"
354
+ ? "outside_allowed_roots"
355
+ : "non_external_permission";
317
356
  err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount}` +
318
357
  ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
319
- ` — auto-rejecting (outside worktree scope or non-external_directory)`);
358
+ ` reason=${reason} — auto-rejecting`);
320
359
  await client.permission
321
360
  .reply({ path: { requestID: p.id }, body: { reply: "reject" } })
322
361
  .catch(() => undefined);
@@ -329,6 +368,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
329
368
  permissionID: p.id,
330
369
  permissionType: p.permission,
331
370
  permissionPatterns: p.patterns,
371
+ permissionReason: reason,
332
372
  };
333
373
  }
334
374
  }
@@ -369,7 +409,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
369
409
  err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}` +
370
410
  (stalledPerm
371
411
  ? ` pending permissionID=${stalledPerm.id} type=${stalledPerm.permission} patterns=${JSON.stringify(stalledPerm.patterns)}`
372
- : ` pending permission unknown (permission.list unavailable or empty)`));
412
+ : client.permission
413
+ ? ` no pending permission observed for this session (tool part in flight, no tool.execute signal)`
414
+ : ` pending permission unknown (permission source unavailable)`));
373
415
  await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
374
416
  return {
375
417
  kind: "permission_stall",
@@ -379,6 +421,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
379
421
  permissionID: stalledPerm?.id,
380
422
  permissionType: stalledPerm?.permission,
381
423
  permissionPatterns: stalledPerm?.patterns,
424
+ permissionReason: "tool_call_stall",
382
425
  };
383
426
  }
384
427
  // Message stall: sub-agent registered (sessionEverAppeared) and appears
@@ -601,17 +644,39 @@ export function pollOutcomeToLegacy(outcome) {
601
644
  };
602
645
  case "aborted":
603
646
  return { text: `(aborted: ${outcome.reason})`, success: false };
604
- case "permission_stall":
647
+ case "permission_stall": {
648
+ // 사유별로 **처방이 다르다.** 종전에는 셋을 한 문장으로 보고해서, 워크스페이스
649
+ // 밖 접근(대안 경로를 알려주면 끝날 일)과 부분 설치(재설치가 필요한 일)와
650
+ // 에이전트 오설정이 전부 같은 메시지로 나왔다.
651
+ const detail = outcome.permissionType
652
+ ? `${outcome.permissionType} permission (id=${outcome.permissionID}` +
653
+ `${outcome.permissionPatterns ? `, patterns=${JSON.stringify(outcome.permissionPatterns)}` : ""})`
654
+ : "an unidentified permission request";
655
+ const remedy = (() => {
656
+ switch (outcome.permissionReason) {
657
+ case "outside_allowed_roots":
658
+ return "워크스페이스 밖 경로 접근이라 차단됐다. " +
659
+ "임시 파일이 필요하면 `/tmp` 이 아니라 worktree 안의 " +
660
+ "`.makdoong2-team/<이슈키>/tmp/` 에 만들어라 — 그 경로는 cwd 안이라 승인이 필요 없고, " +
661
+ "git exclude 와 worktree 동기화 대상이다. `/tmp` 에 쓴 것은 동기화도 커밋도 되지 않아 조용히 사라진다.";
662
+ case "non_external_permission":
663
+ return "external_directory 가 아닌 권한 요청이 서브에이전트에 도달했다 — 경로 문제가 아니라 " +
664
+ "에이전트 permission 설정 문제다. 해당 에이전트의 frontmatter `permission:` 블록을 점검하라 " +
665
+ "(정식 키는 `edit`; `write` 키는 존재하지 않아 조용히 무시된다).";
666
+ default:
667
+ return "툴 호출 part 가 떠 있는데 실행 신호(tool.execute.before)도 권한 요청도 관측되지 않았다. 점검: " +
668
+ "(1) 세션의 디렉토리에서도 플러그인이 로드되는가 — 로그의 `[init] plugin instance directory=…` 가 " +
669
+ "worktree 경로로도 찍혀야 한다 (opencode 는 디렉토리마다 플러그인을 따로 초기화한다) " +
670
+ "(2) opencode.json 의 permission.external_directory 시드 (`npx makdoong2-team doctor`).";
671
+ }
672
+ })();
605
673
  return {
606
- text: outcome.permissionType
607
- ? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission ` +
608
- `(id=${outcome.permissionID}${outcome.permissionPatterns ? `, patterns=${JSON.stringify(outcome.permissionPatterns)}` : ""}) ` +
609
- `— cannot be answered in subagent context; aborted after ${outcome.stalledMs}ms)`
610
- : `(permission_stall: sub-agent tool call stalled for ${outcome.stalledMs}ms — likely waiting for a permission approval ` +
611
- `that cannot be answered in subagent context; pending permission could not be identified (permission.list unavailable or empty). ` +
612
- `점검: opencode.json 의 permission.external_directory 시드 존재 여부 (npx makdoong2-team doctor))`,
674
+ text: `(permission_stall: sub-agent blocked on ${detail} — cannot be answered in subagent context; ` +
675
+ `aborted after ${outcome.stalledMs}ms. reason=${outcome.permissionReason ?? "unknown"})\n` +
676
+ `→ ${remedy}`,
613
677
  success: false,
614
678
  };
679
+ }
615
680
  case "session_gone":
616
681
  return {
617
682
  text: outcome.reason === "message_stall"
@@ -0,0 +1,74 @@
1
+ export type PendingPermission = {
2
+ id: string;
3
+ sessionID: string;
4
+ permission: string;
5
+ patterns: string[];
6
+ askedAt: number;
7
+ };
8
+ export interface SubSessionRegistry {
9
+ readonly version: 1;
10
+ /** sessionID → (callID → startedAt). before 에서 넣고 after / 툴 part 종결에서 뺀다. */
11
+ readonly activeToolCalls: Map<string, Map<string, number>>;
12
+ /** sessionID → 마지막 tool.execute.* 발화 시각. */
13
+ readonly lastToolExecuteAt: Map<string, number>;
14
+ /** sessionID → (requestID → 요청). permission.asked 로 넣고 replied 로 뺀다. */
15
+ readonly pendingPermissions: Map<string, Map<string, PendingPermission>>;
16
+ /** sessionID → 이슈키. dispatch 사본이 쓰고 훅 사본(guard-bash.sh 인자)이 읽는다. */
17
+ readonly sessionIssue: Map<string, string>;
18
+ /** sessionID → worktree 절대경로. auto-git-add 가 읽는다. */
19
+ readonly sessionWorktree: Map<string, string>;
20
+ /** session.deleted 대기자. abort 후 재디스패치 전 race window 를 닫는다. */
21
+ readonly sessionDeletedWaiters: Map<string, Array<() => void>>;
22
+ /** callID 가 없는 before 훅에 붙일 대체 키 일련번호. */
23
+ anonSeq: number;
24
+ }
25
+ export declare function createSubSessionRegistry(): SubSessionRegistry;
26
+ /**
27
+ * 프로세스 전역 레지스트리. 첫 호출이 만들고 이후 호출은 같은 객체를 돌려준다.
28
+ * 키에 버전을 박아 두어, 형태가 다른 미래 버전이 같은 프로세스에 섞여도 서로의
29
+ * 객체를 깨뜨리지 않는다.
30
+ */
31
+ export declare function sharedSubSessionRegistry(): SubSessionRegistry;
32
+ /** 테스트 전용 — 전역 객체를 비운다. */
33
+ export declare function resetSharedSubSessionRegistryForTests(): void;
34
+ /** `tool.execute.before` — 이 세션에서 callID 툴이 실행을 시작했다. 사용한 키를 돌려준다. */
35
+ export declare function toolStarted(reg: SubSessionRegistry, sessionID: string, callID: string | undefined, now?: number): string;
36
+ /**
37
+ * `tool.execute.after` 또는 툴 part 의 completed/error 관측 — 실행이 끝났다.
38
+ * 멱등이다: 같은 callID 를 두 번 끝내도, 모르는 callID 를 끝내도 아무 일도 없다.
39
+ * callID 가 없으면 가장 최근에 시작한 항목을 뺀다 (종전 카운터 의미 유지).
40
+ */
41
+ export declare function toolFinished(reg: SubSessionRegistry, sessionID: string, callID: string | undefined, now?: number): boolean;
42
+ /**
43
+ * 메시지 스냅샷과 대조해 이미 끝난 호출을 정리한다.
44
+ *
45
+ * 툴이 throw 하면(권한 거부 · 파일 없음 · MCP 오류) opencode 는 `tool.execute.after`
46
+ * 를 부르지 않는다 — 항목이 영영 남아 `isToolExecuting()` 이 참으로 굳고, 그러면
47
+ * 완료 판정이 유보돼 substage 가 절대 타임아웃까지 기다린다. 스냅샷의 tool part
48
+ * 가 completed/error 면 그 호출은 확실히 끝난 것이므로 여기서 뺀다. 스냅샷에
49
+ * **아직 없는** 호출은 건드리지 않는다 (issue #7 의 110ms 창 — 훅은 발화했는데
50
+ * 서버가 part 를 아직 반영하지 않은 상태). 정리한 개수를 돌려준다.
51
+ */
52
+ export declare function settleToolCalls(reg: SubSessionRegistry, sessionID: string, settledCallIDs: Iterable<string>): number;
53
+ /** 지금 이 순간 이 세션에서 실행 중인 툴이 있는가 (순간값). */
54
+ export declare function isToolExecuting(reg: SubSessionRegistry, sessionID: string): boolean;
55
+ export declare function activeToolCount(reg: SubSessionRegistry, sessionID: string): number;
56
+ /** 세션이 idle 로 전이했다 — 실행 중인 툴은 정의상 없다. */
57
+ export declare function clearToolCalls(reg: SubSessionRegistry, sessionID: string): void;
58
+ /**
59
+ * 플러그인 `event` 훅이 받는 권한 요청 이벤트를 공통 형태로 바꾼다.
60
+ *
61
+ * 1.18 v1 브리지는 `permission.asked` 를 `PermissionRequest` 그대로 싣는다
62
+ * (`id / sessionID / permission / patterns`). 더 오래된 SDK 형태 `permission.updated`
63
+ * 는 `type` · `pattern`(문자열 또는 배열) 을 쓴다. 둘 다 받아 둔다 — 어느 쪽이 오든
64
+ * 폴러가 보는 것은 같은 shape 이어야 한다. 요청으로 볼 수 없는 페이로드는 null.
65
+ */
66
+ export declare function normalizePermissionEvent(type: string, properties: unknown, now?: number): PendingPermission | null;
67
+ export declare function permissionAsked(reg: SubSessionRegistry, req: PendingPermission): void;
68
+ export declare function permissionReplied(reg: SubSessionRegistry, sessionID: string, requestID: string): void;
69
+ /** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
70
+ export declare function pendingPermissionsFor(reg: SubSessionRegistry, sessionID: string): PendingPermission[];
71
+ /** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
72
+ export declare function forgetSession(reg: SubSessionRegistry, sessionID: string): void;
73
+ /** session.deleted — 대기자를 전부 깨운다. 깨운 수를 돌려준다. */
74
+ export declare function notifySessionDeleted(reg: SubSessionRegistry, sessionID: string): number;
@@ -0,0 +1,204 @@
1
+ // 프로세스 전역 서브세션 레지스트리 — 플러그인 사본 사이의 공유 신호.
2
+ //
3
+ // opencode 1.18 은 **디렉토리(Instance)마다 플러그인을 따로 초기화한다**.
4
+ // `dispatch_stage` 는 서브세션을 `directory=<worktree>` 로 만들므로 그 세션의
5
+ // `tool.execute.before/after` · `event` 훅은 worktree Instance 의 플러그인 사본에서
6
+ // 발화하고, 그 세션을 감시하는 `pollSubSession` 은 main Instance 의 사본에서 돈다.
7
+ // 사본마다 자기 Map 을 들고 있으면 폴러는 언제나 빈 Map 을 본다 —
8
+ // `isToolExecuting()` 이 영영 false 라 60초를 넘는 모든 툴(sbt test 등)이
9
+ // `permission_stall`(reason=tool_call_stall) 로 abort 됐다 (GitHub issue #10).
10
+ //
11
+ // 그래서 사본을 가로지르는 신호는 전부 여기, `globalThis` 에 둔다. 같은 프로세스
12
+ // 안의 모든 사본이 — 모듈 경로가 달라 module cache 가 갈리더라도 — 한 객체를 본다.
13
+ // 여기 두는 것은 "훅 사본이 쓰고 폴러 사본이 읽는" 세션 단위 신호뿐이다.
14
+ // `pendingDispatch` 같은 디스패치 소유 상태는 공유하지 않는다 — 공유하면 worktree
15
+ // 사본의 `session.created` 핸들러가 pane 을 한 번 더 띄운다.
16
+ //
17
+ // 이 파일은 src/opencode-plugin.ts 에서 import 만 한다 (re-export 금지 —
18
+ // 로더가 모든 named export 를 plugin factory 로 호출한다).
19
+ const SHARED_KEY = Symbol.for("makdoong2-team.sub-session-registry.v1");
20
+ export function createSubSessionRegistry() {
21
+ return {
22
+ version: 1,
23
+ activeToolCalls: new Map(),
24
+ lastToolExecuteAt: new Map(),
25
+ pendingPermissions: new Map(),
26
+ sessionIssue: new Map(),
27
+ sessionWorktree: new Map(),
28
+ sessionDeletedWaiters: new Map(),
29
+ anonSeq: 0,
30
+ };
31
+ }
32
+ /**
33
+ * 프로세스 전역 레지스트리. 첫 호출이 만들고 이후 호출은 같은 객체를 돌려준다.
34
+ * 키에 버전을 박아 두어, 형태가 다른 미래 버전이 같은 프로세스에 섞여도 서로의
35
+ * 객체를 깨뜨리지 않는다.
36
+ */
37
+ export function sharedSubSessionRegistry() {
38
+ const g = globalThis;
39
+ const existing = g[SHARED_KEY];
40
+ if (existing && existing.version === 1)
41
+ return existing;
42
+ const created = createSubSessionRegistry();
43
+ g[SHARED_KEY] = created;
44
+ return created;
45
+ }
46
+ /** 테스트 전용 — 전역 객체를 비운다. */
47
+ export function resetSharedSubSessionRegistryForTests() {
48
+ delete globalThis[SHARED_KEY];
49
+ }
50
+ // ── 툴 실행 신호 ─────────────────────────────────────────────────────────────
51
+ const ANON_PREFIX = "anon#";
52
+ /** `tool.execute.before` — 이 세션에서 callID 툴이 실행을 시작했다. 사용한 키를 돌려준다. */
53
+ export function toolStarted(reg, sessionID, callID, now = Date.now()) {
54
+ const key = typeof callID === "string" && callID.length > 0 ? callID : `${ANON_PREFIX}${++reg.anonSeq}`;
55
+ let calls = reg.activeToolCalls.get(sessionID);
56
+ if (!calls) {
57
+ calls = new Map();
58
+ reg.activeToolCalls.set(sessionID, calls);
59
+ }
60
+ calls.set(key, now);
61
+ reg.lastToolExecuteAt.set(sessionID, now);
62
+ return key;
63
+ }
64
+ /**
65
+ * `tool.execute.after` 또는 툴 part 의 completed/error 관측 — 실행이 끝났다.
66
+ * 멱등이다: 같은 callID 를 두 번 끝내도, 모르는 callID 를 끝내도 아무 일도 없다.
67
+ * callID 가 없으면 가장 최근에 시작한 항목을 뺀다 (종전 카운터 의미 유지).
68
+ */
69
+ export function toolFinished(reg, sessionID, callID, now = Date.now()) {
70
+ reg.lastToolExecuteAt.set(sessionID, now);
71
+ const calls = reg.activeToolCalls.get(sessionID);
72
+ if (!calls || calls.size === 0)
73
+ return false;
74
+ let key;
75
+ if (typeof callID === "string" && callID.length > 0) {
76
+ if (!calls.has(callID))
77
+ return false;
78
+ key = callID;
79
+ }
80
+ else {
81
+ let latest = -Infinity;
82
+ for (const [k, startedAt] of calls) {
83
+ if (startedAt >= latest) {
84
+ latest = startedAt;
85
+ key = k;
86
+ }
87
+ }
88
+ }
89
+ if (key === undefined)
90
+ return false;
91
+ calls.delete(key);
92
+ if (calls.size === 0)
93
+ reg.activeToolCalls.delete(sessionID);
94
+ return true;
95
+ }
96
+ /**
97
+ * 메시지 스냅샷과 대조해 이미 끝난 호출을 정리한다.
98
+ *
99
+ * 툴이 throw 하면(권한 거부 · 파일 없음 · MCP 오류) opencode 는 `tool.execute.after`
100
+ * 를 부르지 않는다 — 항목이 영영 남아 `isToolExecuting()` 이 참으로 굳고, 그러면
101
+ * 완료 판정이 유보돼 substage 가 절대 타임아웃까지 기다린다. 스냅샷의 tool part
102
+ * 가 completed/error 면 그 호출은 확실히 끝난 것이므로 여기서 뺀다. 스냅샷에
103
+ * **아직 없는** 호출은 건드리지 않는다 (issue #7 의 110ms 창 — 훅은 발화했는데
104
+ * 서버가 part 를 아직 반영하지 않은 상태). 정리한 개수를 돌려준다.
105
+ */
106
+ export function settleToolCalls(reg, sessionID, settledCallIDs) {
107
+ const calls = reg.activeToolCalls.get(sessionID);
108
+ if (!calls || calls.size === 0)
109
+ return 0;
110
+ let n = 0;
111
+ for (const id of settledCallIDs) {
112
+ if (calls.delete(id))
113
+ n++;
114
+ }
115
+ if (calls.size === 0)
116
+ reg.activeToolCalls.delete(sessionID);
117
+ return n;
118
+ }
119
+ /** 지금 이 순간 이 세션에서 실행 중인 툴이 있는가 (순간값). */
120
+ export function isToolExecuting(reg, sessionID) {
121
+ return (reg.activeToolCalls.get(sessionID)?.size ?? 0) > 0;
122
+ }
123
+ export function activeToolCount(reg, sessionID) {
124
+ return reg.activeToolCalls.get(sessionID)?.size ?? 0;
125
+ }
126
+ /** 세션이 idle 로 전이했다 — 실행 중인 툴은 정의상 없다. */
127
+ export function clearToolCalls(reg, sessionID) {
128
+ reg.activeToolCalls.delete(sessionID);
129
+ }
130
+ // ── 권한 요청 관측 ───────────────────────────────────────────────────────────
131
+ /**
132
+ * 플러그인 `event` 훅이 받는 권한 요청 이벤트를 공통 형태로 바꾼다.
133
+ *
134
+ * 1.18 v1 브리지는 `permission.asked` 를 `PermissionRequest` 그대로 싣는다
135
+ * (`id / sessionID / permission / patterns`). 더 오래된 SDK 형태 `permission.updated`
136
+ * 는 `type` · `pattern`(문자열 또는 배열) 을 쓴다. 둘 다 받아 둔다 — 어느 쪽이 오든
137
+ * 폴러가 보는 것은 같은 shape 이어야 한다. 요청으로 볼 수 없는 페이로드는 null.
138
+ */
139
+ export function normalizePermissionEvent(type, properties, now = Date.now()) {
140
+ if (type !== "permission.asked" && type !== "permission.updated")
141
+ return null;
142
+ if (!properties || typeof properties !== "object")
143
+ return null;
144
+ const p = properties;
145
+ const id = typeof p.id === "string" ? p.id : undefined;
146
+ const sessionID = typeof p.sessionID === "string" ? p.sessionID : undefined;
147
+ if (!id || !sessionID)
148
+ return null;
149
+ const permission = typeof p.permission === "string" ? p.permission
150
+ : typeof p.type === "string" ? p.type
151
+ : "unknown";
152
+ const rawPatterns = p.patterns ?? p.pattern;
153
+ const patterns = Array.isArray(rawPatterns)
154
+ ? rawPatterns.filter((x) => typeof x === "string")
155
+ : typeof rawPatterns === "string" ? [rawPatterns]
156
+ : [];
157
+ return { id, sessionID, permission, patterns, askedAt: now };
158
+ }
159
+ export function permissionAsked(reg, req) {
160
+ let bySession = reg.pendingPermissions.get(req.sessionID);
161
+ if (!bySession) {
162
+ bySession = new Map();
163
+ reg.pendingPermissions.set(req.sessionID, bySession);
164
+ }
165
+ bySession.set(req.id, req);
166
+ }
167
+ export function permissionReplied(reg, sessionID, requestID) {
168
+ const bySession = reg.pendingPermissions.get(sessionID);
169
+ if (!bySession)
170
+ return;
171
+ bySession.delete(requestID);
172
+ if (bySession.size === 0)
173
+ reg.pendingPermissions.delete(sessionID);
174
+ }
175
+ /** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
176
+ export function pendingPermissionsFor(reg, sessionID) {
177
+ const bySession = reg.pendingPermissions.get(sessionID);
178
+ if (!bySession)
179
+ return [];
180
+ return [...bySession.values()].sort((a, b) => a.askedAt - b.askedAt);
181
+ }
182
+ // ── 수명 ─────────────────────────────────────────────────────────────────────
183
+ /** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
184
+ export function forgetSession(reg, sessionID) {
185
+ reg.activeToolCalls.delete(sessionID);
186
+ reg.lastToolExecuteAt.delete(sessionID);
187
+ reg.pendingPermissions.delete(sessionID);
188
+ reg.sessionIssue.delete(sessionID);
189
+ reg.sessionWorktree.delete(sessionID);
190
+ }
191
+ /** session.deleted — 대기자를 전부 깨운다. 깨운 수를 돌려준다. */
192
+ export function notifySessionDeleted(reg, sessionID) {
193
+ const waiters = reg.sessionDeletedWaiters.get(sessionID);
194
+ if (!waiters || waiters.length === 0)
195
+ return 0;
196
+ const copy = [...waiters];
197
+ for (const w of copy) {
198
+ try {
199
+ w();
200
+ }
201
+ catch { /* waiter 오류는 이벤트 처리를 막지 않는다 */ }
202
+ }
203
+ return copy.length;
204
+ }
@@ -5,7 +5,8 @@
5
5
  #
6
6
  # 진입 조건:
7
7
  # 1. state.json 존재
8
- # 2. 1_planning.scope substage done=true
8
+ # 2. 1_planning.requirements substage done=true + 승인 + 품질 게이트
9
+ # (scope 흡수 전 stage3-scope-verify.sh 가 하던 검사를 그대로 이어받는다)
9
10
  #
10
11
  # SKIP 조건:
11
12
  # 프로젝트 루트에 build tool 마커 파일이 하나도 없음
@@ -41,11 +42,68 @@ q() {
41
42
  ANALYSIS_DONE="$(q '.stages."2_implementation".substages."analysis".done')"
42
43
  [ "$ANALYSIS_DONE" != "true" ] || fail "이미 done=true 완료됨 — auto_advance_stage 로 다음 단계 진행"
43
44
 
44
- # 3. scope substage 완료 확인
45
- SCOPE_DONE="$(q '.stages."1_planning".substages."scope".done')"
46
- [ "$SCOPE_DONE" = "true" ] || fail "1_planning.scope 미완료 (done=$SCOPE_DONE)"
45
+ # 3. requirements substage 완료 + 승인 + 품질 확인
46
+ #
47
+ # 종전에는 `1_planning.scope` substage 의 done 만 봤다. scope 가 requirements 로
48
+ # 흡수되면서(범위 확정은 요구사항 확정과 같은 판단의 연속이다) 그 자리를 지키던
49
+ # stage3-scope-verify.sh 의 검사가 통째로 이 게이트로 넘어왔다. 검사를 옮기지 않고
50
+ # 게이트만 지우면 승인·모호성·spec drift 검증이 파이프라인에서 사라진다.
51
+ REQ_DONE="$(q '.stages."1_planning".substages."requirements".done')"
52
+ [ "$REQ_DONE" = "true" ] || fail "1_planning.requirements 미완료 (done=$REQ_DONE)"
53
+
54
+ if [ "$(q '.policy.auto_approve."1_planning.requirements"')" != "true" ]; then
55
+ [ "$(q '.stages."1_planning".substages."requirements".approved_by_user')" = "true" ] \
56
+ || fail "requirements substage 사용자 승인 없음 (또는 .policy.auto_approve.\"1_planning.requirements\" 미설정)"
57
+ if [ "$(q '.stages."1_planning".substages."requirements".verification_pending')" = "true" ]; then
58
+ fail "requirements substage 검증 대기 중 (verification_pending) — 사용자 승인 후 approved_by_user를 기록하라"
59
+ fi
60
+ fi
61
+
62
+ if [ "$(q '.stages."1_planning".substages."requirements".interview_required')" = "true" ]; then
63
+ [ "$(q '.stages."1_planning".substages."requirements".interview_completed')" = "true" ] \
64
+ || fail "requirements substage 인터뷰 미완료 — 모든 미결 항목 해소 후 interview_completed=true 기록 필요"
65
+ fi
66
+
67
+ # --- 요구사항 품질 게이트 (조건부 — 마커 존재 시에만 검사, 구형 state 호환) ---
68
+ # 1) Ambiguity Score 수렴: 기록된 값이 0.2 초과면 요구사항 미수렴 (stages/02-requirements.md)
69
+ AMB="$(q '.stages."1_planning".substages."requirements".ambiguity_score')"
70
+ if [ "$AMB" != "__MISSING__" ] && [ "$AMB" != "null" ] && [ -n "$AMB" ]; then
71
+ awk -v a="$AMB" 'BEGIN { exit (a + 0 <= 0.2) ? 0 : 1 }' \
72
+ || fail "ambiguity_score=$AMB > 0.2 — 요구사항 미수렴. 인터뷰로 미결 항목 해소 후 재산정하라"
73
+ fi
74
+
75
+ # 2) 명세 동결(spec drift) 검증: spec_hash 기록 시 draft 파일 해시 재계산 일치 필요
76
+ SPEC_HASH="$(q '.stages."1_planning".substages."requirements".spec_hash')"
77
+ if [ "$SPEC_HASH" != "__MISSING__" ] && [ "$SPEC_HASH" != "null" ] && [ -n "$SPEC_HASH" ]; then
78
+ DRAFT="$(q '.stages."1_planning".substages."requirements".draft_path')"
79
+ ROOT="$("$SCRIPTS/state.sh" root)"
80
+ # 마커 누락과 파일 부재를 구분해서 알린다. 둘 다 "확정 명세 파일 없음" 으로
81
+ # 뭉뚱그리면 파일은 멀쩡하고 마커만 빠진 흔한 경우(issue #6-①)에 무엇을 해야
82
+ # 하는지 알 수 없다.
83
+ DEFAULT_DRAFT=".makdoong2-team/${ISSUE}/requirements-draft.md"
84
+ if [ "$DRAFT" = "__MISSING__" ] || [ "$DRAFT" = "null" ] || [ -z "$DRAFT" ]; then
85
+ if [ -f "$ROOT/$DEFAULT_DRAFT" ]; then
86
+ fail "spec_hash 는 기록됐는데 draft_path 마커가 없다 — 파일은 ${DEFAULT_DRAFT} 에 있다.
87
+ 복구: (1) sha256sum \"${ROOT}/${DEFAULT_DRAFT}\" 가 spec_hash(${SPEC_HASH}) 와 같은지 대조하고,
88
+ (2) 같으면 state.sh set 으로 requirements.draft_path 에 \"${DEFAULT_DRAFT}\" 를 기록한다,
89
+ (3) 다르면 명세가 동결 후 변경된 것이므로 requirements substage 를 재작업한다."
90
+ fi
91
+ fail "spec_hash 는 기록됐는데 draft_path 마커도 확정 명세 파일도 없다 — requirements substage 를 재작업하라 (stages/02-requirements.md)"
92
+ fi
93
+ # "생성된 적 없음" 을 안내에서 빼면 복구 방향을 잘못 잡는다 — 실제로 가장 흔한
94
+ # 경우인데 종전 메시지는 동기화 누락·삭제만 언급해 리더가 동기화 문제부터
95
+ # 의심했다 (issue #8).
96
+ [ -f "$ROOT/$DRAFT" ] \
97
+ || fail "draft_path=${DRAFT} 마커는 있으나 파일이 없다 (기준 경로 ${ROOT}). 가능한 원인 순서대로:
98
+ (1) 애초에 생성된 적 없음 — planner 가 마커만 기록하고 파일 생성에 실패한 경우. requirements substage 를 재작업해 쓰기 툴로 초안부터 생성하라 (stages/02-requirements.md),
99
+ (2) worktree 동기화 누락 — 다른 cwd(main repo/worktree)의 같은 상대경로에 파일이 있는지 확인,
100
+ (3) 파일이 삭제됨 — 삭제 경위 확인 후 requirements 재작업"
101
+ ACTUAL="$(sha256sum "$ROOT/$DRAFT" | cut -d' ' -f1)"
102
+ [ "$ACTUAL" = "$SPEC_HASH" ] \
103
+ || fail "확정 명세 무단 변경 감지 (spec drift) — 동결 후 변경은 사용자 재승인 + spec_hash 재기록 절차만 허용 (stages/02-requirements.md)"
104
+ fi
47
105
 
48
- # 3. SKIP 판정: worktree 루트에서 build tool 마커 파일 존재 여부
106
+ # 4. SKIP 판정: worktree 루트에서 build tool 마커 파일 존재 여부
49
107
  WT="$(q '.worktree' | tr -d '"')"
50
108
  [ -d "$WT" ] || WT="$(pwd)"
51
109
 
@@ -23,7 +23,7 @@ assert_worktree_sibling(){
23
23
  [ "$pwt" = "$pmain" ] || fail "worktree가 메인 repo의 형제 디렉토리가 아님: $wt (메인: $main)"
24
24
  }
25
25
  [ "$(q '.stages."2_implementation".substages."dev".done')" != "true" ] || fail "이미 done=true 완료됨 — auto_advance_stage 로 다음 단계 진행"
26
- [ "$(q '.stages."1_planning".substages."scope".done')" = "true" ] || fail "scope substage 미완료"
26
+ [ "$(q '.stages."1_planning".substages."requirements".done')" = "true" ] || fail "requirements substage 미완료"
27
27
  # analysis substage 는 게이트가 SKIP 시 done=true 로 마킹하므로 skipped 여부와 무관하게 done=true 검사만 하면 된다.
28
28
  [ "$(q '.stages."2_implementation".substages."analysis".done')" = "true" ] || fail "analysis substage 미완료 (skipped 도 done=true 로 처리됨)"
29
29
 
@@ -31,11 +31,11 @@ WT="$(q '.worktree')"
31
31
  [ -n "$WT" ] && [ "$WT" != "__MISSING__" ] || fail "worktree 경로 미설정 — state.json의 .worktree 필드 확인"
32
32
  assert_worktree_sibling "$WT"
33
33
 
34
- if [ "$(q '.policy.auto_approve."1_planning.scope"')" != "true" ]; then
35
- [ "$(q '.stages."1_planning".substages."scope".approved_by_user')" = "true" ] \
36
- || fail "scope substage 사용자 승인 없음 (또는 .policy.auto_approve.\"1_planning.scope\" 미설정)"
37
- if [ "$(q '.stages."1_planning".substages."scope".verification_pending')" = "true" ]; then
38
- fail "scope substage 검증 대기 중 (verification_pending) — 사용자 승인 후 approved_by_user를 기록하라"
34
+ if [ "$(q '.policy.auto_approve."1_planning.requirements"')" != "true" ]; then
35
+ [ "$(q '.stages."1_planning".substages."requirements".approved_by_user')" = "true" ] \
36
+ || fail "requirements substage 사용자 승인 없음 (또는 .policy.auto_approve.\"1_planning.requirements\" 미설정)"
37
+ if [ "$(q '.stages."1_planning".substages."requirements".verification_pending')" = "true" ]; then
38
+ fail "requirements substage 검증 대기 중 (verification_pending) — 사용자 승인 후 approved_by_user를 기록하라"
39
39
  fi
40
40
  fi
41
41
  echo "MAKDOONG2-GATE OK: 2_implementation.dev"
package/gates/verify.sh CHANGED
@@ -23,8 +23,6 @@ case "$TARGET" in
23
23
  echo "MAKDOONG2-GATE OK: $TARGET (jira validation handled by extension gate)" ;;
24
24
  1_planning.requirements)
25
25
  "$HERE/stage2-requirements-verify.sh" "$ISSUE" ;;
26
- 1_planning.scope)
27
- "$HERE/stage3-scope-verify.sh" "$ISSUE" ;;
28
26
  2_implementation.analysis)
29
27
  "$HERE/stage-analysis-verify.sh" "$ISSUE" ;;
30
28
  2_implementation.dev)
@@ -34,7 +34,6 @@
34
34
  "verify_stage": true,
35
35
  "dispatch_stage": true,
36
36
  "dispatch_verifier": true,
37
- "dispatch_research": true,
38
37
  "auto_advance_stage": true,
39
38
  "get_fallback_model": true
40
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "2.3.2",
3
+ "version": "3.0.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },