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.
- package/README.md +4 -12
- package/agents/makdoong2-engineer.md +1 -0
- package/agents/makdoong2-planner.md +17 -61
- package/agents/makdoong2-team-leader.md +14 -4
- package/agents/makdoong2-verifier.md +6 -6
- package/assets/makdoong2-team.schema.json +0 -18
- package/bin/cli.js +27 -1
- package/bin/cli.ts +27 -1
- package/dist/agent-stage-config.d.ts +1 -1
- package/dist/agent-stage-config.js +0 -11
- package/dist/apply-patch-paths.d.ts +46 -0
- package/dist/apply-patch-paths.js +114 -0
- package/dist/config.d.ts +22 -9
- package/dist/config.js +61 -11
- package/dist/model-fallback-policy.js +0 -4
- package/dist/opencode-plugin.js +270 -356
- package/dist/poll-sub-session.d.ts +16 -1
- package/dist/poll-sub-session.js +76 -11
- package/dist/sub-session-registry.d.ts +74 -0
- package/dist/sub-session-registry.js +204 -0
- package/gates/stage-analysis-verify.sh +63 -5
- package/gates/stage4-dev-verify.sh +6 -6
- package/gates/verify.sh +0 -2
- package/opencode.json.example +0 -1
- package/package.json +1 -1
- package/scripts/gate-policy-test.sh +15 -14
- package/scripts/install-lib.mjs +1 -1
- package/scripts/install-lib.mts +1 -1
- package/scripts/model-policy.mjs +0 -4
- package/scripts/model-policy.mts +0 -4
- package/scripts/release.sh +13 -1
- package/scripts/run-tests.mts +4 -1
- package/scripts/state.sh +24 -2
- package/skills/_lib/load-secret.sh +15 -5
- package/src/hooks/session-start.sh +0 -1
- package/stages/01-planning.md +35 -54
- package/stages/02-requirements.md +53 -35
- package/stages/04-analysis.md +2 -2
- package/stages/05-worktree-dev.md +15 -0
- package/agents/makdoong2-researcher.md +0 -68
- package/dist/research-fanout.d.ts +0 -165
- package/dist/research-fanout.js +0 -341
- package/gates/stage3-scope-verify.sh +0 -69
- package/stages/03-scope.md +0 -81
package/dist/opencode-plugin.js
CHANGED
|
@@ -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
|
|
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:
|
|
427
|
-
activeToolCount:
|
|
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
|
-
|
|
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
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ((
|
|
811
|
+
if (registryIsToolExecuting(registry, sessionId))
|
|
743
812
|
return true;
|
|
744
|
-
const last =
|
|
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 훅에서
|
|
748
|
-
//
|
|
816
|
+
// "지금 이 순간 툴이 실행 중인가" — before 훅에서 넣고 after 훅에서 빼는
|
|
817
|
+
// callID 집합 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
|
|
749
818
|
// 판정에 쓸 수 있다. 폴러가 읽는 메시지 스냅샷보다 항상 앞선다 (issue #7:
|
|
750
819
|
// tool.execute.before 발화 110ms 뒤의 폴이 tool part 를 아직 못 봤다).
|
|
751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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 훅이 돌지 않아
|
|
1172
|
+
// after 훅이 돌지 않아 항목이 영구히 남고, 그러면 isRecentlyActive() 가
|
|
1055
1173
|
// 항상 true 가 되어 그 세션의 gone 감지와 orphan 회수가 **영구 비활성**된다.
|
|
1056
1174
|
// 차단은 정상 동작이라 반드시 일어나므로 확정적으로 누수됐다.
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
// 산출물을
|
|
1167
|
-
//
|
|
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
|
|
1172
|
-
const normalized = (
|
|
1173
|
-
const permitted = allowed !== null
|
|
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} 을 시도했다 (
|
|
1321
|
+
`${input.tool} 을 시도했다 (targets=${shown})`);
|
|
1177
1322
|
throw new Error(`[makdoong2-team artifact hardrule] "${agent}" 는 '${input.tool}' 로 ` +
|
|
1178
|
-
`${
|
|
1323
|
+
`${normalized.length > 0 ? `"${shown}" 에 ` : ""}쓸 수 없다.\n` +
|
|
1179
1324
|
(allowed === null
|
|
1180
1325
|
? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
|
|
1181
1326
|
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
|
|
1182
1327
|
`(패턴: ${allowed.source}).\n` +
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"}
|
|
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"
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}/
|
|
2863
|
-
`
|
|
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
|
});
|