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
@@ -16,11 +16,12 @@
16
16
  // Install path (default): ~/.config/opencode/plugins/makdoong2-team/
17
17
  // Place this file at ~/.config/opencode/plugins/makdoong2-team/src/opencode-plugin.ts
18
18
  // (or load the npm package via opencode.json "plugin": ["./plugins/makdoong2-team/src/opencode-plugin.ts"]).
19
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { basename as pathBasename, dirname as pathDirname, join, resolve as pathResolve, sep as pathSep } from "node:path";
22
22
  import { tool } from "@opencode-ai/plugin";
23
23
  import { appendSessionIndex, findWorktreeRoot, lookupSessionFromIndex } from "./session-index.js";
24
+ import { activeToolCount, clearToolCalls, forgetSession, isToolExecuting as registryIsToolExecuting, normalizePermissionEvent, notifySessionDeleted, pendingPermissionsFor, permissionAsked, permissionReplied, settleToolCalls, sharedSubSessionRegistry, toolFinished, toolStarted, } from "./sub-session-registry.js";
24
25
  import { computeVerdictHash } from "./verdict-hash.js";
25
26
  import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakExceeded, VERIFIER_ERROR_STREAK_LIMIT, } from "./verifier-verdict.js";
26
27
  import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
@@ -28,14 +29,14 @@ import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
28
29
  import { shouldEscalateStall } from "./stall-escalation.js";
29
30
  import { classifyStageCompletion, INCOMPLETE_HANG_REASON } from "./stage-completion.js";
30
31
  import { buildStateWriteBlockMessage, classifyStateJsonAccess, looksLikeRedirection, splitUnquotedSegments, WRITE_INDICATORS_UNQUOTED, STATE_SH_CALL_RE, stripQuotedSpans, } from "./state-access-guard.js";
31
- import { RESEARCH_SOURCES, DEFAULT_RESEARCH_TIMEOUT_MINUTES, buildResearchPrompt, classifyFanoutOutcome, mergeResearchFindings, normalizeQueries, parseResearchOutput, resolveParallelism, summarizeOutcomes, } from "./research-fanout.js";
32
32
  import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
33
- import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
33
+ import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, pluginOwnAllowPatterns, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
34
34
  import { scanSkillMcpRegistry, extractMcpName, looksLikeMcpNotFound, looksLikeMcpConnectionFailed, } from "./skill-mcp-registry.js";
35
35
  import { injectAllSecrets } from "./mcp-secret-injector.js";
36
36
  import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
37
37
  import { logger } from "./logger.js";
38
38
  import { redactAndTruncate } from "./redact-secrets.js";
39
+ import { extractApplyPatchPaths, isApplyPatchTool } from "./apply-patch-paths.js";
39
40
  import { issueReporterSkillLoadViolation, issueReporterTaskSpawnViolation, ISSUE_REPORTER_AGENT, classifyGithubApiCall, payloadDisplayPaths, displayMismatch, sha256Hex, } from "./issue-reporter-guard.js";
40
41
  // All runtime paths come from makdoong2-team.json (paths.* overrides) or are
41
42
  // derived from the opencode config dir. No MAKDOONG2 environment variables.
@@ -114,7 +115,6 @@ export function shouldOverrideSessionGoneOutcome(outcomeKind, currentSuccess, do
114
115
  const STAGE_ORDER = [
115
116
  "1_planning.jira",
116
117
  "1_planning.requirements",
117
- "1_planning.scope",
118
118
  "2_implementation.analysis",
119
119
  "2_implementation.dev",
120
120
  "2_implementation.test",
@@ -328,6 +328,15 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
328
328
  const cwd = worktree || directory || ".";
329
329
  const config = loadConfig();
330
330
  applyConfigOverrides(config.agents, config.model_policy);
331
+ // opencode 는 디렉토리(Instance)마다 이 factory 를 따로 부른다 — 같은 pid 안에
332
+ // 사본이 여럿이다. 어느 사본이 어떤 로그를 찍는지 구분하려고 진입 시점에 남긴다.
333
+ // 로그에 worktree 경로의 `[init]` 이 없으면 서브세션 훅이 어디서도 발화하지
334
+ // 않는 것이고, 그것이 issue #10 의 `tool_call_stall` 처방 1번이다.
335
+ logger.debug(`[init] plugin instance directory=${directory} worktree=${worktree} pid=${process.pid}`);
336
+ // 사본을 가로지르는 세션 신호(툴 실행 · 권한 요청 · 이슈키 · worktree · deleted
337
+ // 대기자)는 프로세스 전역 레지스트리에 둔다. 사본마다 Map 을 들면 worktree 사본의
338
+ // 훅이 올린 신호를 main 사본의 폴러가 영영 보지 못한다 (issue #10).
339
+ const registry = sharedSubSessionRegistry();
331
340
  // Sub-session monitor — splits a tmux pane next to 부장님 for each spawned
332
341
  // 막둥이 when invoked inside tmux with tmux.enabled=true in makdoong2-team.json.
333
342
  // No-op otherwise, so non-tmux runs are unaffected.
@@ -423,8 +432,8 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
423
432
  const guard = orphanCleanupGuard(p, {
424
433
  nowMs,
425
434
  ownerPid: tmuxMonitor.ownerProcessId,
426
- lastToolExecuteAtMs: sessionLastToolExecuteAt.get(sid),
427
- activeToolCount: sessionActiveToolCount.get(sid),
435
+ lastToolExecuteAtMs: registry.lastToolExecuteAt.get(sid),
436
+ activeToolCount: activeToolCount(registry, sid),
428
437
  toolAliveWindowMs: TOOL_EXECUTE_ALIVE_WINDOW_MS,
429
438
  });
430
439
  if (guard) {
@@ -449,11 +458,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
449
458
  if (sessionGone) {
450
459
  logger.debug(`[orphan-scan] pane-kill-only (session gone) sid=${sid} reason=${reason}`);
451
460
  await tmuxMonitor.forceKillBySessionId(sid).catch(() => undefined);
452
- sessionIssue.delete(sid);
461
+ forgetSession(registry, sid);
453
462
  pendingDispatch.delete(sid);
454
463
  subSessionIds.delete(sid);
455
- sessionLastToolExecuteAt.delete(sid);
456
- sessionActiveToolCount.delete(sid);
457
464
  }
458
465
  else {
459
466
  logger.debug(`[orphan-scan] cleaning sid=${sid} reason=${reason}`);
@@ -599,29 +606,38 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
599
606
  // threshold caps the accumulated hang_history across calls — the stall-path
600
607
  // counterpart of same_reason_streak on the REJECTED-verdict path.
601
608
  const stallEscalateThreshold = Math.max(1, Math.round(config.timeout?.stall_escalate_threshold ?? DEFAULT_STALL_ESCALATE_THRESHOLD));
602
- // 사용자가 opencode.json 에 명시적으로 allow 한 외부 디렉터리 패턴.
603
- // 종전에는 makdoong2-team.json(=`config`)에서 읽어 **항상 빈 배열**이었다 —
604
- // 그 스키마에는 permission 키가 없다. 자세한 내용은 config.ts 의 함수 주석 참조.
605
- const configuredAllowPatterns = loadOpencodeExternalDirAllows();
609
+ // 자동 승인 대상 외부 디렉터리 패턴 = 플러그인 자기 경로(항상) + 사용자 시드.
610
+ //
611
+ // 앞쪽이 2차 방어다: 설치가 opencode.json 패치를 남기지 못해도 서브에이전트가
612
+ // state.sh / 게이트 / stage spec 에 접근할 수 있어야 한다 (GitHub #8 의 부분 설치가
613
+ // PERMISSION_STALL 로 나타나던 경로). 뒤쪽은 사용자가 opencode.json 에 명시적으로
614
+ // allow 한 것 — 종전에는 makdoong2-team.json 에서 읽어 **항상 빈 배열**이었다.
615
+ const pluginOwnPatterns = pluginOwnAllowPatterns();
616
+ const configuredAllowPatterns = [
617
+ ...pluginOwnPatterns,
618
+ ...loadOpencodeExternalDirAllows((reason) => logger.warn(`[permission] opencode.json external_directory allows unavailable — ${reason} directory=${directory}`)),
619
+ ];
620
+ // 사본마다 한 번씩 찍힌다 — 같은 pid 에 개수가 다른 두 줄이 있으면 사본이
621
+ // 둘이라는 뜻이지 설정이 바뀐 것이 아니다 (issue #10 의 7개→5개). directory 를
622
+ // 같이 남겨 어느 사본이 몇 개를 읽었는지 바로 대응시킨다.
623
+ logger.debug(`[permission] plugin-own allows: ${pluginOwnPatterns.length}개 ` +
624
+ `(${pluginOwnPatterns.join(", ")}) directory=${directory}`);
606
625
  logger.debug(`[permission] configured external_directory allows: ${configuredAllowPatterns.length}개` +
607
- (configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : ""));
608
- const sessionLastToolExecuteAt = new Map();
609
- const sessionActiveToolCount = new Map();
610
- /** 활성 툴 카운터를 1 감소시킨다 (0 이면 항목 삭제). */
611
- function releaseActiveTool(sid) {
612
- const cur = sessionActiveToolCount.get(sid) ?? 0;
613
- if (cur <= 1)
614
- sessionActiveToolCount.delete(sid);
615
- else
616
- sessionActiveToolCount.set(sid, cur - 1);
617
- }
626
+ (configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : "") +
627
+ ` directory=${directory}`);
628
+ // 툴 실행 신호는 레지스트리(`registry.activeToolCalls` / `lastToolExecuteAt`)에
629
+ // 있다 — before 훅에서 toolStarted, after 훅·툴 part 종결·idle 에서 toolFinished /
630
+ // clearToolCalls. 종전의 사본-로컬 카운터는 issue #10 으로 제거됐다.
618
631
  const TOOL_EXECUTE_ALIVE_WINDOW_MS = 300_000;
619
632
  // MESSAGE_STALL 후 client.session.abort() 는 즉시 반환하지만 opencode 서버는 잠시 후
620
633
  // session.deleted 이벤트를 fire 한다. 그 사이(관측 최대 112s) sub-agent 가 tool call 을
621
634
  // 계속 발사할 수 있어 좀비 실행이 발생한다. abort 직후 session.deleted 를 대기하는
622
635
  // 헬퍼로 이 race window 를 닫는다. event 핸들러가 session.deleted 를 수신하면 이 map 의
623
636
  // pending waiter 를 resolve 한다.
624
- const sessionDeletedWaiters = new Map();
637
+ // 대기자 목록은 레지스트리에 있다 — session.deleted 이벤트는 세션의 디렉토리
638
+ // 사본에만 도착하므로(worktree 서브세션이면 worktree 사본), 여기서 기다리는
639
+ // main 사본과 이벤트를 받는 사본이 같은 목록을 봐야 한다.
640
+ const sessionDeletedWaiters = registry.sessionDeletedWaiters;
625
641
  const waitForSessionDeleted = (sessionId, maxWaitMs) => {
626
642
  return new Promise((resolve) => {
627
643
  let done = false;
@@ -651,13 +667,32 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
651
667
  const SESSION_DELETED_WAIT_MS = 30_000;
652
668
  const eventMaxChars = readLoggingConfig(config.logging).eventMaxChars;
653
669
  const WRITE_TOOLS = new Set(["write", "edit", "apply_patch"]);
654
- const sessionWorktree = new Map();
670
+ // dispatch 사본이 채우고 worktree 사본의 tool.execute.after 가 읽는다 — 레지스트리.
671
+ // 디스크의 session-index.ndjson 은 사본이 다른 프로세스에 있을 때의 2차 경로로 남는다.
672
+ const sessionWorktree = registry.sessionWorktree;
655
673
  const extractFilePathFromToolArgs = (args) => {
656
674
  if (!args || typeof args !== "object")
657
675
  return undefined;
658
676
  const fp = args.filePath;
659
677
  return typeof fp === "string" && fp.length > 0 ? fp : undefined;
660
678
  };
679
+ // 쓰기 툴이 건드리는 **모든** 대상 경로. write/edit 는 filePath 하나지만
680
+ // apply_patch 는 패치 본문 안에 여러 개가 들어 있다 — opencode 가 gpt-5 계열
681
+ // 세션에서 write/edit 대신 apply_patch 만 노출하므로(src/apply-patch-paths.ts
682
+ // 주석 참조) filePath 만 보는 코드는 그 세션에서 전부 장님이 된다.
683
+ //
684
+ // 반환: paths=대상 목록, resolved=대상을 확정했는가.
685
+ // resolved=false 는 "쓰기인데 대상을 모른다" 이고 정책상 차단이 기본값이다.
686
+ const extractWriteTargets = (toolName, args) => {
687
+ if (isApplyPatchTool(toolName)) {
688
+ const parsed = extractApplyPatchPaths(args);
689
+ return parsed.ok
690
+ ? { paths: parsed.paths, resolved: true }
691
+ : { paths: [], resolved: false, reason: parsed.reason };
692
+ }
693
+ const fp = extractFilePathFromToolArgs(args);
694
+ return fp ? { paths: [fp], resolved: true } : { paths: [], resolved: false, reason: "no_file_path" };
695
+ };
661
696
  const trackAndStageEngineerWrite = async (sessionID, filePath) => {
662
697
  let wt = sessionWorktree.get(sessionID) ?? pendingDispatch.get(sessionID)?.worktree;
663
698
  let issue = sessionIssue.get(sessionID);
@@ -724,7 +759,41 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
724
759
  }
725
760
  return { ok: true, relPath };
726
761
  };
727
- const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) => pollSubSessionCore(client, sessionId, {
762
+ // 폴러에 건네는 권한 요청 소스. v1 SDK 클라이언트에는 `permission` 네임스페이스가
763
+ // 없고, `GET /permission` 은 클라이언트가 묶인 디렉토리 스코프라 worktree
764
+ // 서브세션의 요청이 보이지 않는다 — 종전에는 이 소스가 항상 undefined 라
765
+ // 자동 승인·거부 루프가 한 번도 돌지 않았고 stall 메시지는 언제나 "unknown" 이었다
766
+ // (issue #10). 대신 그 세션의 사본이 `permission.asked` 이벤트로 레지스트리에
767
+ // 넣은 것을 읽고, 응답은 세션-라우팅 엔드포인트
768
+ // (`POST /session/{id}/permissions/{permissionID}`) 로 보낸다 — 세션 ID 로
769
+ // 라우팅되므로 어느 사본에서 불러도 맞는 Instance 에 닿는다.
770
+ const permissionSourceFor = (sessionId) => ({
771
+ list: async () => ({
772
+ data: pendingPermissionsFor(registry, sessionId).map((p) => ({
773
+ id: p.id,
774
+ sessionID: p.sessionID,
775
+ permission: p.permission,
776
+ patterns: p.patterns,
777
+ })),
778
+ }),
779
+ reply: async (req) => {
780
+ const c = client;
781
+ if (typeof c.postSessionIdPermissionsPermissionId !== "function") {
782
+ throw new Error("permission reply endpoint unavailable on this client");
783
+ }
784
+ const res = await c.postSessionIdPermissionsPermissionId({
785
+ path: { id: sessionId, permissionID: req.path.requestID },
786
+ body: { response: req.body.reply },
787
+ });
788
+ // permission.replied 이벤트는 세션의 사본에 도착하지만, 그 사본이 이벤트를
789
+ // 놓치더라도 다음 폴이 같은 요청을 다시 처리하지 않도록 즉시 뺀다.
790
+ permissionReplied(registry, sessionId, req.path.requestID);
791
+ return res;
792
+ },
793
+ });
794
+ const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) =>
795
+ // 클래스 인스턴스를 spread 하면 프로토타입 메서드가 빠지므로 필요한 것만 집는다.
796
+ pollSubSessionCore({ session: client.session, permission: permissionSourceFor(sessionId) }, sessionId, {
728
797
  timeoutMs,
729
798
  allowedWorktree,
730
799
  configuredAllowPatterns,
@@ -739,16 +808,26 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
739
808
  contentStableCompletionMs: 300_000,
740
809
  preambleOnlyTextThreshold: 200,
741
810
  isRecentlyActive: () => {
742
- if ((sessionActiveToolCount.get(sessionId) ?? 0) > 0)
811
+ if (registryIsToolExecuting(registry, sessionId))
743
812
  return true;
744
- const last = sessionLastToolExecuteAt.get(sessionId);
813
+ const last = registry.lastToolExecuteAt.get(sessionId);
745
814
  return typeof last === "number" && Date.now() - last < TOOL_EXECUTE_ALIVE_WINDOW_MS;
746
815
  },
747
- // "지금 이 순간 툴이 실행 중인가" — before 훅에서 올리고 after 훅에서 내리는
748
- // 활성 카운터 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
816
+ // "지금 이 순간 툴이 실행 중인가" — before 훅에서 넣고 after 훅에서 빼는
817
+ // callID 집합 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
749
818
  // 판정에 쓸 수 있다. 폴러가 읽는 메시지 스냅샷보다 항상 앞선다 (issue #7:
750
819
  // tool.execute.before 발화 110ms 뒤의 폴이 tool part 를 아직 못 봤다).
751
- isToolExecuting: () => (sessionActiveToolCount.get(sessionId) ?? 0) > 0,
820
+ //
821
+ // 읽기 전에 스냅샷과 대조한다: 툴이 throw 하면 after 훅이 오지 않아 항목이
822
+ // 남는데, 스냅샷에서 이미 completed/error 인 callID 는 확실히 끝난 것이다.
823
+ // 스냅샷에 아직 없는 callID 는 그대로 둔다 (110ms 창).
824
+ isToolExecuting: (snapshot) => {
825
+ const settled = settleToolCalls(registry, sessionId, snapshot.settledCallIDs);
826
+ if (settled > 0) {
827
+ logger.debug(`[pollSubSession] settled ${settled} stale tool call(s) from snapshot sid=${sessionId}`);
828
+ }
829
+ return registryIsToolExecuting(registry, sessionId);
830
+ },
752
831
  });
753
832
  // ── sessionID → agent 매핑. chat.params hook에서 채우고, tool.execute.before
754
833
  // hook에서 조회한다. 이 매핑을 통해 hook input에 없는 agent 식별을 우회한다.
@@ -771,7 +850,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
771
850
  // git branch 이름이나 worktree 경로를 추론하지 않고 신뢰할 수 있는 ISSUE를
772
851
  // 직접 전달받을 수 있다. 이 설계로 multi-worktree / 비-makdoong2-team 워크트리
773
852
  // 오탐 문제가 근본적으로 해결된다.
774
- const sessionIssue = new Map();
853
+ // 레지스트리에 둔다 — dispatch 사본이 넣고, 서브세션의 훅 사본(guard-bash.sh /
854
+ // sync-state.sh 인자)이 읽는다.
855
+ const sessionIssue = registry.sessionIssue;
775
856
  // ── skill_mcp lazy-load 방어 (mcp_name → skill_name 매핑).
776
857
  // SKILL.md frontmatter 를 스캔해 각 MCP 서버가 어떤 skill 에 embedded 인지
777
858
  // 캐시한다. `skill(name="...")` 로 skill 이 로드되기 전에는 opencode 가
@@ -848,11 +929,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
848
929
  }
849
930
  }
850
931
  finally {
851
- sessionIssue.delete(sid);
932
+ forgetSession(registry, sid);
852
933
  pendingDispatch.delete(sid);
853
934
  subSessionIds.delete(sid);
854
- sessionLastToolExecuteAt.delete(sid);
855
- sessionActiveToolCount.delete(sid);
856
935
  }
857
936
  };
858
937
  // team-leader가 직접 실행 시 물리적으로 차단할 툴 목록.
@@ -880,8 +959,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
880
959
  ["makdoong2-publisher", /(^|\/)\.makdoong2-team\/[^/]+\/(change-report\.md|review-comment-plan\.json)$/],
881
960
  // planner: 요구사항 초안 · 리서치 산출물
882
961
  ["makdoong2-planner", /(^|\/)\.makdoong2-team\/[^/]+\/[^/]+\.(md|json)$/],
883
- // researcher: 쓰기 없음
884
- ["makdoong2-researcher", null],
885
962
  ]);
886
963
  // ══════════════════════════════════════════════════════════════════════════
887
964
  // Sealed Workflow Enforcement — Outer-World Tool Blocking
@@ -913,10 +990,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
913
990
  "makdoong2-engineer",
914
991
  "makdoong2-publisher",
915
992
  "makdoong2-verifier",
916
- // Research fan-out worker. Sealed like every other sub-agent: its frontmatter
917
- // already omits Task (L1), but sealed workflow is defence in depth — a worker
918
- // missing here would be caught by nothing at runtime (L2).
919
- "makdoong2-researcher",
920
993
  // User-only issue reporter. 워크플로우에 참여하지 않지만 outer-world 위임은
921
994
  // 동일하게 금지 — 수집·마스킹·등록 전 과정을 자기 세션에서 완결해야 하며,
922
995
  // 위임하면 마스킹·승인 게이트가 위임처에서 우회될 수 있다.
@@ -929,7 +1002,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
929
1002
  const KNOWN_SAFE_TOOLS = new Set([
930
1003
  "dispatch_stage",
931
1004
  "dispatch_verifier",
932
- "dispatch_research",
933
1005
  "verify_stage",
934
1006
  "auto_advance_stage",
935
1007
  "get_fallback_model",
@@ -974,26 +1046,72 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
974
1046
  }
975
1047
  })();
976
1048
  return {
1049
+ // 이벤트는 **세션의 디렉토리 사본**에만 도착한다 (opencode 의 이벤트 브리지가
1050
+ // `location.directory` 로 필터링한다). worktree 서브세션의 permission.asked ·
1051
+ // message.part.updated · session.deleted 는 worktree 사본이 받고, 그 세션을
1052
+ // 폴링하는 것은 main 사본이다. 그래서 여기서 관측한 것은 전부 레지스트리에
1053
+ // 쓴다 — 사본-로컬 변수에 쓰면 폴러는 영영 보지 못한다 (issue #10).
977
1054
  event: async ({ event }) => {
978
- if (logger.isDebug() && event?.type?.startsWith("session.")) {
1055
+ if (logger.isDebug() && (event?.type?.startsWith("session.") || event?.type?.startsWith("permission."))) {
979
1056
  try {
980
- logger.debug(`[event] type=${event.type} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
1057
+ logger.debug(`[event] type=${event.type} directory=${directory} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
981
1058
  }
982
1059
  catch { /* ignore */ }
983
1060
  }
1061
+ if (event?.type === "permission.asked" || event?.type === "permission.updated") {
1062
+ const req = normalizePermissionEvent(event.type, event.properties);
1063
+ if (req) {
1064
+ permissionAsked(registry, req);
1065
+ logger.debug(`[event] permission pending sid=${req.sessionID} requestID=${req.id} ` +
1066
+ `permission=${req.permission} patterns=${JSON.stringify(req.patterns)}`);
1067
+ }
1068
+ return;
1069
+ }
1070
+ if (event?.type === "permission.replied") {
1071
+ const props = event.properties;
1072
+ const sid = typeof props?.sessionID === "string" ? props.sessionID : undefined;
1073
+ const requestID = typeof props?.requestID === "string" ? props.requestID
1074
+ : typeof props?.permissionID === "string" ? props.permissionID
1075
+ : undefined;
1076
+ if (sid && requestID)
1077
+ permissionReplied(registry, sid, requestID);
1078
+ return;
1079
+ }
1080
+ if (event?.type === "message.part.updated") {
1081
+ // 툴 part 가 completed/error 로 바뀌면 그 호출은 끝났다. 툴이 throw 한
1082
+ // 경우 tool.execute.after 가 오지 않으므로 이것이 유일한 종결 신호다.
1083
+ const props = event.properties;
1084
+ const part = props?.part;
1085
+ if (part && part.type === "tool") {
1086
+ const state = part.state;
1087
+ const status = typeof state?.status === "string" ? state.status : undefined;
1088
+ if (status === "completed" || status === "error") {
1089
+ const sid = typeof part.sessionID === "string" ? part.sessionID
1090
+ : typeof props?.sessionID === "string" ? props.sessionID
1091
+ : undefined;
1092
+ const callID = typeof part.callID === "string" ? part.callID : undefined;
1093
+ if (sid)
1094
+ toolFinished(registry, sid, callID);
1095
+ }
1096
+ }
1097
+ return;
1098
+ }
1099
+ if (event?.type === "session.idle") {
1100
+ // idle 로 전이한 세션에 실행 중인 툴은 정의상 없다 — 남은 항목은 전부 누수다.
1101
+ const props = event.properties;
1102
+ if (typeof props?.sessionID === "string")
1103
+ clearToolCalls(registry, props.sessionID);
1104
+ return;
1105
+ }
984
1106
  if (event?.type === "session.deleted") {
985
1107
  const props = event.properties;
986
1108
  const sid = props?.info?.id ?? props?.sessionID;
987
1109
  if (sid) {
988
- const waiters = sessionDeletedWaiters.get(sid);
989
- if (waiters && waiters.length > 0) {
990
- for (const w of [...waiters]) {
991
- try {
992
- w();
993
- }
994
- catch { /* ignore */ }
995
- }
996
- }
1110
+ notifySessionDeleted(registry, sid);
1111
+ // 세션이 사라졌으니 실행 신호·권한 요청도 의미가 없다. sessionIssue /
1112
+ // sessionWorktree 는 dispatch 사본의 cleanupSubSession 이 지운다.
1113
+ clearToolCalls(registry, sid);
1114
+ registry.pendingPermissions.delete(sid);
997
1115
  }
998
1116
  return;
999
1117
  }
@@ -1048,16 +1166,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1048
1166
  : undefined;
1049
1167
  const toolLower = (input.tool || "").toLowerCase();
1050
1168
  logger.debug(`[makdoong2-team hook] tool.execute.before fired: tool="${input.tool}" sessionID="${sessionID}" agent="${agent ?? "unknown"}" callID="${input.callID}"`);
1051
- // 활성 툴 카운터는 **증분과 감소가 반드시 짝을 이뤄야 한다**.
1169
+ // 활성 툴 집합은 **넣기와 빼기가 반드시 짝을 이뤄야 한다**.
1052
1170
  // tool.execute.after 는 툴이 실제로 실행된 뒤에만 돈다. 아래 가드들이 throw 하면
1053
1171
  // (차단된 bash · sealed 서브에이전트의 outer-world 툴 · leader 의 파일 쓰기 등)
1054
- // after 훅이 돌지 않아 카운터가 영구히 ≥1 로 남고, 그러면 isRecentlyActive() 가
1172
+ // after 훅이 돌지 않아 항목이 영구히 남고, 그러면 isRecentlyActive() 가
1055
1173
  // 항상 true 가 되어 그 세션의 gone 감지와 orphan 회수가 **영구 비활성**된다.
1056
1174
  // 차단은 정상 동작이라 반드시 일어나므로 확정적으로 누수됐다.
1057
- if (sessionID) {
1058
- sessionLastToolExecuteAt.set(sessionID, Date.now());
1059
- sessionActiveToolCount.set(sessionID, (sessionActiveToolCount.get(sessionID) ?? 0) + 1);
1060
- }
1175
+ // (툴 자체가 throw 하는 경우 — 권한 거부 · 파일 없음 — 는 여기서 잡을 수 없다.
1176
+ // 그쪽은 폴러가 스냅샷의 completed/error part 로 정리한다: settleToolCalls.)
1177
+ const activeToolKey = sessionID
1178
+ ? toolStarted(registry, sessionID, input.callID)
1179
+ : undefined;
1061
1180
  try {
1062
1181
  if (sessionID && (toolLower === "dispatch_stage" || toolLower === "dispatch_verifier")) {
1063
1182
  const callID = input.callID;
@@ -1158,31 +1277,62 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1158
1277
  }
1159
1278
  }
1160
1279
  }
1280
+ // ── Universal state.json hardrule (write 계열 툴) ──
1281
+ // bash 우회는 classifyStateJsonAccess 가 막지만 쓰기 **툴**은 종전에 무방비였다.
1282
+ // 산출물 허용 패턴(.makdoong2-team/<이슈키>/*.json)이 state.json 을 포함하므로
1283
+ // 여기서 명시적으로 도려낸다 — state.json 쓰기는 오직 state.sh 를 통해서만 한다.
1284
+ if (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower)) {
1285
+ const stateTargets = extractWriteTargets(toolLower, output.args);
1286
+ const hit = stateTargets.paths
1287
+ .map((p) => p.replace(/\\/g, "/"))
1288
+ .find((p) => /(^|\/)\.makdoong2-team\/[^/]+\/state\.json$/.test(p));
1289
+ if (hit) {
1290
+ logger.error(`[makdoong2-team hook] BLOCKED: state.json 을 '${input.tool}' 툴로 쓰려 했다 ` +
1291
+ `(agent="${agent ?? "unknown"}", path="${hit}")`);
1292
+ throw new Error(`[makdoong2-team state hardrule] state.json 은 '${input.tool}' 툴로 쓸 수 없다 ("${hit}").\n` +
1293
+ `마커 기록은 반드시 'bash ${SCRIPTS_DIR}/state.sh set <이슈키> <키경로> <값>' 으로 하라. ` +
1294
+ `읽기는 'bash ${SCRIPTS_DIR}/state.sh get' / 'status' 를 쓴다.`);
1295
+ }
1296
+ }
1161
1297
  // ── 산출물 제한 서브에이전트의 write 계열 툴 차단 ──
1162
1298
  //
1163
- // filePath 를 추출할 수 없으면(예: apply_patch — 경로가 패치 본문 안에
1164
- // 있다) 보수적으로 차단한다. 이 방향이 안전하다: 산출물이 제한된
1165
- // 에이전트가 대상 불명의 패치를 미는 것 자체가 정책 위반이고, 이들은
1166
- // 산출물을 Write 툴(filePath 인자 보유)로만 만든다. 실제로 네 에이전트
1167
- // 모두 frontmatter 에서 Patch 툴이 비활성이라 이 경로는 도달 불가에 가깝다.
1299
+ // 판정 대상은 **툴이 건드리는 모든 경로**다. write/edit 는 filePath 하나,
1300
+ // apply_patch 는 패치 본문에서 파싱한 목록이다 — 후자를 "대상 불명" 으로
1301
+ // 뭉뚱그려 차단하면, opencode 가 write/edit 를 노출하지 않는 gpt-5 계열
1302
+ // 세션에서는 산출물을 만들 합법적 수단이 하나도 남지 않아 워크플로가
1303
+ // 구조적으로 정지한다 (GitHub #8 재발).
1304
+ //
1305
+ // 대상을 **확정하지 못한** 경우(패치 파싱 실패 등)에는 종전대로 차단한다.
1306
+ // 미탐에는 복구 수단이 없고 오탐에는 안내가 있다.
1168
1307
  if (agent && ARTIFACT_RESTRICTED_AGENTS.has(agent)
1169
1308
  && (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower))) {
1170
1309
  const allowed = ARTIFACT_RESTRICTED_AGENTS.get(agent) ?? null;
1171
- const filePath = extractFilePathFromToolArgs(output.args);
1172
- const normalized = (filePath ?? "").replace(/\\/g, "/");
1173
- const permitted = allowed !== null && normalized !== "" && allowed.test(normalized);
1310
+ const targets = extractWriteTargets(toolLower, output.args);
1311
+ const normalized = targets.paths.map((p) => p.replace(/\\/g, "/"));
1312
+ const permitted = allowed !== null
1313
+ && targets.resolved
1314
+ && normalized.length > 0
1315
+ && normalized.every((p) => allowed.test(p));
1174
1316
  if (!permitted) {
1317
+ const shown = normalized.length > 0
1318
+ ? normalized.join(", ")
1319
+ : `unknown(${targets.reason ?? "no_target"})`;
1175
1320
  logger.error(`[makdoong2-team hook] BLOCKED: ${agent} 가 허용되지 않은 대상에 ` +
1176
- `${input.tool} 을 시도했다 (filePath=${filePath ?? "unknown"})`);
1321
+ `${input.tool} 을 시도했다 (targets=${shown})`);
1177
1322
  throw new Error(`[makdoong2-team artifact hardrule] "${agent}" 는 '${input.tool}' 로 ` +
1178
- `${filePath ? `"${filePath}" 에 ` : ""}쓸 수 없다.\n` +
1323
+ `${normalized.length > 0 ? `"${shown}" 에 ` : ""}쓸 수 없다.\n` +
1179
1324
  (allowed === null
1180
1325
  ? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
1181
1326
  : `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
1182
1327
  `(패턴: ${allowed.source}).\n` +
1183
- `그 경로에는 지금 즉시 'write' 툴(filePath 인자)로 쓸 수 있다 — ` +
1184
- `bash 리디렉션·apply_patch 우회가 차단된 것이지 산출물 쓰기 자체가 금지된 것이 아니다. ` +
1185
- `산출물이 필요하면 포기하지 말고 write 툴로 재시도하라.\n`) +
1328
+ (targets.resolved
1329
+ ? `대상 경로를 허용 패턴 안으로 바꿔 **지금 즉시 재시도**하라 — ` +
1330
+ `산출물 쓰기 자체가 금지된 것이 아니다.\n`
1331
+ : `이번 호출은 대상 경로를 확정하지 못해 차단됐다(${targets.reason ?? "unknown"}). ` +
1332
+ `'write' 툴이 있으면 filePath 로 직접 쓰고, 없으면 apply_patch 본문을 ` +
1333
+ `'*** Begin Patch' / '*** Add File: <허용 경로>' / '*** End Patch' 형식으로 ` +
1334
+ `정확히 작성해 재시도하라 — 형식이 맞으면 허용 경로에 대해 통과한다.\n`) +
1335
+ `bash 리디렉션 우회는 어느 경우에도 차단된다. 포기하지 말고 위 경로로 재시도하라.\n`) +
1186
1336
  `소스 코드 변경이 필요하면 그 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
1187
1337
  }
1188
1338
  }
@@ -1273,9 +1423,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1273
1423
  }
1274
1424
  }
1275
1425
  catch (err) {
1276
- // 차단으로 끝난 호출은 실행되지 않았다 → 증분을 되돌린다.
1426
+ // 차단으로 끝난 호출은 실행되지 않았다 → 넣은 항목을 되돌린다.
1277
1427
  if (sessionID)
1278
- releaseActiveTool(sessionID);
1428
+ toolFinished(registry, sessionID, activeToolKey);
1279
1429
  throw err;
1280
1430
  }
1281
1431
  },
@@ -1288,8 +1438,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1288
1438
  const toolLowerAfter = (input.tool || "").toLowerCase();
1289
1439
  const afterSessionID = input.sessionID;
1290
1440
  if (afterSessionID) {
1291
- sessionLastToolExecuteAt.set(afterSessionID, Date.now());
1292
- releaseActiveTool(afterSessionID);
1441
+ toolFinished(registry, afterSessionID, input.callID);
1293
1442
  }
1294
1443
  // ── Issue-reporter 표시 증명 기록·소멸 ──
1295
1444
  // 기록: 단독 `cat <payload>` 가 실행되면 그 시점의 파일 해시를 남긴다. 이
@@ -1327,23 +1476,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1327
1476
  if (afterSessionID && WRITE_TOOLS.has(toolLowerAfter)) {
1328
1477
  const agentAfter = sessionAgent.get(afterSessionID)
1329
1478
  ?? pendingDispatch.get(afterSessionID)?.agent;
1330
- const filePath = extractFilePathFromToolArgs(input.args);
1479
+ // apply_patch 는 filePath 인자가 없다 — 패치 본문에서 대상을 뽑는다.
1480
+ // gpt-5 계열 세션에는 write/edit 가 아예 없어 engineer 의 모든 편집이
1481
+ // 이 경로로 오므로, filePath 만 보면 auto git add 가 통째로 무력화되고
1482
+ // dev exit gate 가 unstaged 파일로 하드 차단된다.
1483
+ const targets = extractWriteTargets(toolLowerAfter, input.args);
1331
1484
  logger.debug(`[auto-git-add-hook] tool=${toolLowerAfter} session=${afterSessionID} ` +
1332
- `agent=${agentAfter ?? "unknown"} filePath=${filePath ?? "N/A"} ` +
1485
+ `agent=${agentAfter ?? "unknown"} targets=${targets.paths.length > 0 ? targets.paths.join(",") : `N/A(${targets.reason ?? "none"})`} ` +
1333
1486
  `wt=${sessionWorktree.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.worktree ?? "N/A"} ` +
1334
1487
  `issue=${sessionIssue.get(afterSessionID) ?? "N/A"}`);
1335
- if (agentAfter === "makdoong2-engineer" && filePath) {
1336
- const result = await trackAndStageEngineerWrite(afterSessionID, filePath)
1337
- .catch((err) => ({
1338
- ok: false,
1339
- relPath: filePath,
1340
- error: `hook exception: ${err?.message ?? String(err)}`,
1341
- }));
1342
- if (!result.ok && result.relPath) {
1343
- const warn = `\n\n[makdoong2-team] auto git add FAILED for "${result.relPath}": ${redactAndTruncate(result.error ?? "", 200)}\n` +
1344
- `→ 조치: bash 로 'git add -- ${result.relPath}' 를 직접 실행해 stage 하시오. dev exit gate 가 unstaged 파일을 차단한다.`;
1345
- const outAny = output;
1346
- outAny.output = (outAny.output ?? "") + warn;
1488
+ if (agentAfter === "makdoong2-engineer") {
1489
+ for (const filePath of targets.paths) {
1490
+ const result = await trackAndStageEngineerWrite(afterSessionID, filePath)
1491
+ .catch((err) => ({
1492
+ ok: false,
1493
+ relPath: filePath,
1494
+ error: `hook exception: ${err?.message ?? String(err)}`,
1495
+ }));
1496
+ if (!result.ok && result.relPath) {
1497
+ const warn = `\n\n[makdoong2-team] auto git add FAILED for "${result.relPath}": ${redactAndTruncate(result.error ?? "", 200)}\n` +
1498
+ `→ 조치: bash 로 'git add -- ${result.relPath}' 를 직접 실행해 stage 하시오. dev exit gate 가 unstaged 파일을 차단한다.`;
1499
+ const outAny = output;
1500
+ outAny.output = (outAny.output ?? "") + warn;
1501
+ }
1347
1502
  }
1348
1503
  }
1349
1504
  }
@@ -2489,267 +2644,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2489
2644
  * get_fallback_model — fallback advisor.
2490
2645
  * Returns the next model in the chain, or exhausted=true.
2491
2646
  */
2492
- /**
2493
- * dispatch_research — parallel multi-source research fan-out.
2494
- *
2495
- * Why the plugin does the fan-out instead of the agent: sealed sub-agents
2496
- * cannot delegate (ARCHITECTURE.md §4.2), and a prompt asking the model to
2497
- * "call the sources in parallel" cannot be enforced — the model may call
2498
- * them one at a time and nothing detects it. Doing it in code makes the
2499
- * parallelism deterministic, and gives each source its own session so one
2500
- * source's material never crowds out another's (DESIGN.md §3.7).
2501
- *
2502
- * Failure is isolated per source: one source failing yields a partial
2503
- * artifact plus an explicit failure row, never an aborted fan-out.
2504
- */
2505
- dispatch_research: tool({
2506
- description: "여러 리서치 소스(jira / confluence / bitbucket / github-oss)를 병렬 서브세션으로 동시 조사하고 " +
2507
- "결과를 research-findings.json 으로 병합한다. 소스별 컨텍스트가 격리되며 한 소스의 실패가 " +
2508
- "다른 소스를 막지 않는다. 1_planning.requirements 의 다출처 교차 조사에 사용.",
2509
- args: {
2510
- issue: tool.schema.string().describe("Jira issue key"),
2511
- worktree: tool.schema.string().describe("작업 디렉토리 절대경로 (state.sh 실행 컨텍스트)"),
2512
- queries: tool.schema
2513
- .array(tool.schema.object({
2514
- source: tool.schema
2515
- .string()
2516
- .describe("jira | confluence | bitbucket | github-oss"),
2517
- focus: tool.schema
2518
- .string()
2519
- .describe("이 소스에서 확인할 것. 구체적일수록 좋다."),
2520
- }))
2521
- .describe("소스별 조사 지시. 동시 실행되므로 서로 의존하면 안 된다."),
2522
- context: tool.schema
2523
- .string()
2524
- .optional()
2525
- .describe("모든 조사 세션에 공통 주입할 배경 (예: Jira 요약)"),
2526
- },
2527
- async execute(args, context) {
2528
- const startedAll = Date.now();
2529
- const parallelLimit = resolveParallelism(config.research?.max_parallel);
2530
- const { queries, rejected, deferred } = normalizeQueries(args.queries, parallelLimit);
2531
- if (queries.length === 0) {
2532
- return JSON.stringify({
2533
- ok: false,
2534
- reason: "실행 가능한 query 가 없다.",
2535
- rejected,
2536
- deferred,
2537
- allowed_sources: Object.keys(RESEARCH_SOURCES),
2538
- });
2539
- }
2540
- const parentSessionID = context?.sessionID ?? dispatchParentSessionID(undefined);
2541
- if (!parentSessionID) {
2542
- return JSON.stringify({
2543
- ok: false,
2544
- reason: "dispatch_research 호출자의 sessionID를 얻지 못했다.",
2545
- });
2546
- }
2547
- const researcherId = "makdoong2-researcher";
2548
- // Recursion guard: a research session must never start its own fan-out.
2549
- // The researcher's frontmatter omits this tool (L1), but a fan-out that
2550
- // could nest would multiply sessions without bound, so it is refused at
2551
- // runtime too — same defence-in-depth reasoning as SEALED_SUBAGENTS.
2552
- if (sessionAgent.get(parentSessionID) === researcherId) {
2553
- logger.error(`[dispatch_research] BLOCKED: researcher session ${parentSessionID} attempted a nested fan-out`);
2554
- return JSON.stringify({
2555
- ok: false,
2556
- reason: "리서치 세션은 dispatch_research 를 다시 호출할 수 없다 (중첩 fan-out 금지). " +
2557
- "배정받은 소스만 조사하고 JSON 을 반환하라.",
2558
- });
2559
- }
2560
- const policy = POLICIES[researcherId];
2561
- if (!policy) {
2562
- return JSON.stringify({
2563
- ok: false,
2564
- reason: `POLICIES 에 ${researcherId} 항목이 없다. 모델 정책 설정을 확인하라.`,
2565
- });
2566
- }
2567
- const [providerID, ...modelRest] = policy.primary.id.split("/");
2568
- const modelID = modelRest.join("/");
2569
- const timeoutMs = Math.max(60_000, Math.round((config.research?.timeout_minutes ?? DEFAULT_RESEARCH_TIMEOUT_MINUTES) * 60_000));
2570
- logger.debug(`[dispatch_research] fan-out start issue=${args.issue} sources=` +
2571
- `${queries.map((q) => q.spec.source).join(",")} parallel_limit=${parallelLimit} ` +
2572
- `timeout_ms=${timeoutMs} parentID=${parentSessionID}`);
2573
- const runOne = async (q) => {
2574
- const startedAt = Date.now();
2575
- const base = {
2576
- source: q.spec.source,
2577
- label: q.spec.label,
2578
- focus: q.focus,
2579
- session_id: null,
2580
- elapsed_ms: 0,
2581
- };
2582
- const fail = (error, sid) => ({
2583
- ...base,
2584
- session_id: sid,
2585
- elapsed_ms: Date.now() - startedAt,
2586
- status: "failed",
2587
- findings: [],
2588
- gaps: [],
2589
- error,
2590
- });
2591
- const createResult = await client.session
2592
- .create({
2593
- body: {
2594
- parentID: parentSessionID,
2595
- title: `research:${q.spec.source} (${args.issue})`,
2596
- },
2597
- query: { directory: args.worktree },
2598
- })
2599
- .catch((e) => ({ error: e, data: null }));
2600
- if (createResult.error || !createResult.data) {
2601
- return fail(`session create 실패: ${JSON.stringify(createResult.error)}`, null);
2602
- }
2603
- const sid = createResult.data.id;
2604
- sessionIssue.set(sid, args.issue);
2605
- subSessionIds.add(sid);
2606
- sessionWorktree.set(sid, args.worktree);
2607
- pendingDispatch.set(sid, {
2608
- stage: `research:${q.spec.source}`,
2609
- agent: researcherId,
2610
- worktree: args.worktree,
2611
- startedAt,
2612
- });
2613
- appendSessionIndex({
2614
- sessionID: sid,
2615
- agent: researcherId,
2616
- worktree: args.worktree,
2617
- issue: args.issue,
2618
- stage: `research:${q.spec.source}`,
2619
- createdAt: new Date().toISOString(),
2620
- });
2621
- let outcomeText = "";
2622
- try {
2623
- const promptText = buildResearchPrompt(q, {
2624
- issue: args.issue,
2625
- scriptsDir: SCRIPTS_DIR,
2626
- worktree: args.worktree,
2627
- context: args.context,
2628
- });
2629
- const promptPromise = client.session
2630
- .prompt({
2631
- path: { id: sid },
2632
- body: {
2633
- agent: researcherId,
2634
- tools: { question: false },
2635
- parts: [{ type: "text", text: promptText }],
2636
- model: { providerID, modelID },
2637
- },
2638
- query: { directory: args.worktree },
2639
- })
2640
- .catch((e) => ({ error: e }));
2641
- const early = await Promise.race([
2642
- promptPromise,
2643
- new Promise((r) => setTimeout(() => r("pending"), 2_000)),
2644
- ]);
2645
- if (early !== "pending" && early?.error) {
2646
- return fail(`prompt 실패: ${JSON.stringify(early.error)}`, sid);
2647
- }
2648
- await spawnPaneForSession(sid);
2649
- const outcome = await pollSubSession(sid, timeoutMs, args.worktree);
2650
- const legacy = pollOutcomeToLegacy(outcome);
2651
- outcomeText = legacy.text;
2652
- promptPromise.catch(() => { });
2653
- if (outcome.kind === "session_gone") {
2654
- await cleanupSubSession(sid, {
2655
- success: false,
2656
- reason: `research ${q.spec.source} session_gone`,
2657
- skipSessionOps: outcome.reason !== "message_stall",
2658
- });
2659
- return fail(`세션 종료 (${outcome.reason ?? "status_absent"})`, sid);
2660
- }
2661
- if (outcome.kind === "timeout") {
2662
- await cleanupSubSession(sid, {
2663
- success: false,
2664
- reason: `research ${q.spec.source} timeout`,
2665
- });
2666
- return fail(`시간 초과 (${Math.round(timeoutMs / 60_000)}분)`, sid);
2667
- }
2668
- const parsed = parseResearchOutput(outcomeText);
2669
- await cleanupSubSession(sid, { success: parsed.ok });
2670
- if (!parsed.ok) {
2671
- return fail(`출력 파싱 실패: ${parsed.reason}`, sid);
2672
- }
2673
- return {
2674
- ...base,
2675
- session_id: sid,
2676
- elapsed_ms: Date.now() - startedAt,
2677
- status: "ok",
2678
- findings: parsed.data.findings,
2679
- gaps: parsed.data.gaps,
2680
- error: null,
2681
- };
2682
- }
2683
- catch (e) {
2684
- await cleanupSubSession(sid, {
2685
- success: false,
2686
- reason: `research ${q.spec.source} threw`,
2687
- }).catch(() => undefined);
2688
- return fail(`예외: ${e instanceof Error ? e.message : String(e)}`, sid);
2689
- }
2690
- };
2691
- // Promise.all, not a loop: the whole point is that the sources run at
2692
- // the same time. runOne never rejects (every path returns an outcome),
2693
- // so one source cannot take the others down with it.
2694
- const outcomes = await Promise.all(queries.map(runOne));
2695
- const artifact = mergeResearchFindings(args.issue, new Date().toISOString(), outcomes, rejected, deferred);
2696
- // state.json 산출물 경로는 상대경로만 저장한다 (ARCHITECTURE.md §5.3).
2697
- const relPath = `.makdoong2-team/${args.issue}/research-findings.json`;
2698
- let artifactWritten = false;
2699
- let artifactError = null;
2700
- try {
2701
- const absDir = join(args.worktree, ".makdoong2-team", args.issue);
2702
- mkdirSync(absDir, { recursive: true });
2703
- writeFileSync(join(absDir, "research-findings.json"), JSON.stringify(artifact, null, 2));
2704
- artifactWritten = true;
2705
- }
2706
- catch (e) {
2707
- artifactError = e instanceof Error ? e.message : String(e);
2708
- logger.error(`[dispatch_research] artifact write 실패: ${artifactError}`);
2709
- }
2710
- if (artifactWritten) {
2711
- // cwd 는 반드시 args.worktree — state.sh root() 가 cwd 의 git toplevel 을
2712
- // 쓰므로 여기서 어긋나면 다른 state.json 에 기록된다 (ARCHITECTURE.md §10.2).
2713
- const setR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${'.stages."1_planning".substages."requirements".research_path'} ${JSON.stringify(relPath)}`
2714
- .cwd(args.worktree).quiet().nothrow();
2715
- if (setR.exitCode !== 0) {
2716
- logger.debug(`[dispatch_research] research_path 마커 기록 실패 exit=${setR.exitCode} ` +
2717
- `stderr=${redactAndTruncate(setR.stderr?.toString() ?? "", 160)}`);
2718
- }
2719
- }
2720
- const okCount = artifact.counts.ok;
2721
- const failedOutcomes = outcomes.filter((o) => o.status === "failed");
2722
- const fanout = classifyFanoutOutcome(artifact.counts, artifactWritten ? relPath : null, failedOutcomes.map((o) => o.label));
2723
- logger.debug(`[dispatch_research] fan-out done issue=${args.issue} ok=${okCount}/${outcomes.length} ` +
2724
- `findings=${artifact.counts.findings_total} status=${fanout.status} ` +
2725
- `elapsed_ms=${Date.now() - startedAll}`);
2726
- if (fanout.partial) {
2727
- // debug 가 아니라 warn — 기본 로깅 레벨에서도 보여야 하는 결손이다.
2728
- logger.warn(`[dispatch_research] PARTIAL issue=${args.issue} ok=${okCount}/${artifact.counts.requested} ` +
2729
- `failed=${failedOutcomes.map((o) => `${o.source}:${o.error ?? "unknown"}`).join(" | ")}`);
2730
- }
2731
- return JSON.stringify({
2732
- // 부분 성공도 ok=true. 한 소스가 죽었다고 나머지 조사 결과를 버리면
2733
- // fan-out 의 실패 격리가 의미를 잃는다. 결손은 status/partial 로 알린다.
2734
- ok: fanout.ok,
2735
- status: fanout.status,
2736
- partial: fanout.partial,
2737
- issue: args.issue,
2738
- artifact_path: artifactWritten ? relPath : null,
2739
- artifact_error: artifactError,
2740
- elapsed_ms: Date.now() - startedAll,
2741
- counts: artifact.counts,
2742
- summary: summarizeOutcomes(outcomes),
2743
- failed: failedOutcomes.map((o) => ({
2744
- source: o.source,
2745
- error: o.error,
2746
- })),
2747
- rejected,
2748
- deferred,
2749
- next_action: fanout.next_action,
2750
- });
2751
- },
2752
- }),
2753
2647
  get_fallback_model: tool({
2754
2648
  description: "Return the next model in this agent's fallback chain after a failure (rate_limit/5xx/context_exceeded).",
2755
2649
  args: {
@@ -2843,8 +2737,24 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2843
2737
  // current=null 로 남아 target_stage="1_planning.jira" 로 되돌아가는
2844
2738
  // silent regression 이 발생한다. `.issue` 필드를 probe 하여 이 상황을
2845
2739
  // 명시적 에러로 분리한다.
2846
- const probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
2740
+ let probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
2847
2741
  .cwd(effectiveCwd).quiet().nothrow();
2742
+ // ── 자가 복구 1회: forward sync 는 플러그인 몫이다 ──
2743
+ // worktree cwd 에서 state.json 이 안 보이는 가장 흔한 원인은 forward sync
2744
+ // 미수행이다. 종전에는 그 복구를 next_action 으로 **team-leader 에게 시켰는데**,
2745
+ // 그 안내 문구에 worktree 절대경로가 박혀 있었다. opencode 는 bash 명령이
2746
+ // 참조하는 디렉토리마다 external_directory 승인을 묻으므로(ARCHITECTURE §4.2a),
2747
+ // 리더가 그 명령을 실행하는 순간 primary 세션이 사용자에게 승인을 물었다 —
2748
+ // 정작 다른 모든 동기화는 플러그인이 조용히 처리하는데 이 경로만 예외였다.
2749
+ // 여기서 직접 한 번 시도하면 승인 요청도, 왕복도 사라진다.
2750
+ if (probe.exitCode !== 0 && args.worktree && args.worktree !== cwd) {
2751
+ const heal = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${args.worktree} ${args.issue}`
2752
+ .cwd(effectiveCwd).quiet().nothrow();
2753
+ probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
2754
+ .cwd(effectiveCwd).quiet().nothrow();
2755
+ logger.debug(`[wt-sync] SELF_HEAL issue=${args.issue} worktree=${args.worktree} ` +
2756
+ `sync_exit=${heal.exitCode} probe_after=${probe.exitCode}`);
2757
+ }
2848
2758
  if (probe.exitCode !== 0) {
2849
2759
  const rootR = await $ `bash ${SCRIPTS_DIR}/state.sh root`
2850
2760
  .cwd(effectiveCwd).quiet().nothrow();
@@ -2857,10 +2767,14 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2857
2767
  effective_cwd: effectiveCwd,
2858
2768
  reason: `state.json 을 판독할 수 없습니다. worktree cwd 기준 예상 경로: ${expectedPath}. ` +
2859
2769
  `주요 원인: (1) wt-sync-ignored.sh 로 worktree 동기화 미수행, (2) 다른 cwd(main repo)에서 state.json 을 조작하여 worktree 사본과 불일치, (3) state.json 손상.`,
2860
- next_action: `① 먼저 'bash ${SCRIPTS_DIR}/state.sh status ${args.issue}' 로 존재/유효성을 확인하세요 ` +
2770
+ // 안내에 **절대경로를 싣지 않는다.** 리더가 그 경로를 명령에 넣어 실행하면
2771
+ // primary 세션이 external_directory 승인을 사용자에게 묻는다. 아래 명령은
2772
+ // 전부 cwd 기준으로 동작하므로 경로 인자가 필요 없다.
2773
+ next_action: `동기화는 이미 1회 자동 시도했고 실패했습니다 — 같은 동기화를 직접 실행하지 마세요. ` +
2774
+ `① 먼저 'bash ${SCRIPTS_DIR}/state.sh status ${args.issue}' 로 존재/유효성을 확인하세요 ` +
2861
2775
  `(승인된 읽기 명령입니다 — 훅이 차단하지 않습니다). ` +
2862
- `② exists=false 면 'bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${effectiveCwd} ${args.issue}' 로 재동기화하거나, ` +
2863
- `'bash ${SCRIPTS_DIR}/state.sh init ${args.issue} ${effectiveCwd}' 로 초기화하세요. ` +
2776
+ `② exists=false 면 'bash ${SCRIPTS_DIR}/state.sh init ${args.issue}' 로 초기화하세요 ` +
2777
+ `(경로 인자 없이 — cwd 의 git toplevel 을 자동으로 씁니다). ` +
2864
2778
  `③ readable=false (JSON 손상) 면 사용자에게 에스컬레이션하세요. ` +
2865
2779
  `근본 원인이 해소되기 전에는 dispatch_stage 를 호출하지 마세요.`,
2866
2780
  });