makdoong2-team 2.3.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +21 -8
- package/dist/config.js +34 -0
- package/dist/model-fallback-policy.js +0 -4
- package/dist/opencode-plugin.js +121 -307
- package/dist/poll-sub-session.d.ts +2 -0
- package/dist/poll-sub-session.js +37 -9
- 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 +3 -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,7 +16,7 @@
|
|
|
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";
|
|
@@ -28,14 +28,14 @@ import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
|
|
|
28
28
|
import { shouldEscalateStall } from "./stall-escalation.js";
|
|
29
29
|
import { classifyStageCompletion, INCOMPLETE_HANG_REASON } from "./stage-completion.js";
|
|
30
30
|
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
31
|
import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
|
|
33
|
-
import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
|
|
32
|
+
import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, pluginOwnAllowPatterns, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
|
|
34
33
|
import { scanSkillMcpRegistry, extractMcpName, looksLikeMcpNotFound, looksLikeMcpConnectionFailed, } from "./skill-mcp-registry.js";
|
|
35
34
|
import { injectAllSecrets } from "./mcp-secret-injector.js";
|
|
36
35
|
import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
|
|
37
36
|
import { logger } from "./logger.js";
|
|
38
37
|
import { redactAndTruncate } from "./redact-secrets.js";
|
|
38
|
+
import { extractApplyPatchPaths, isApplyPatchTool } from "./apply-patch-paths.js";
|
|
39
39
|
import { issueReporterSkillLoadViolation, issueReporterTaskSpawnViolation, ISSUE_REPORTER_AGENT, classifyGithubApiCall, payloadDisplayPaths, displayMismatch, sha256Hex, } from "./issue-reporter-guard.js";
|
|
40
40
|
// All runtime paths come from makdoong2-team.json (paths.* overrides) or are
|
|
41
41
|
// derived from the opencode config dir. No MAKDOONG2 environment variables.
|
|
@@ -114,7 +114,6 @@ export function shouldOverrideSessionGoneOutcome(outcomeKind, currentSuccess, do
|
|
|
114
114
|
const STAGE_ORDER = [
|
|
115
115
|
"1_planning.jira",
|
|
116
116
|
"1_planning.requirements",
|
|
117
|
-
"1_planning.scope",
|
|
118
117
|
"2_implementation.analysis",
|
|
119
118
|
"2_implementation.dev",
|
|
120
119
|
"2_implementation.test",
|
|
@@ -599,10 +598,19 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
599
598
|
// threshold caps the accumulated hang_history across calls — the stall-path
|
|
600
599
|
// counterpart of same_reason_streak on the REJECTED-verdict path.
|
|
601
600
|
const stallEscalateThreshold = Math.max(1, Math.round(config.timeout?.stall_escalate_threshold ?? DEFAULT_STALL_ESCALATE_THRESHOLD));
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
601
|
+
// 자동 승인 대상 외부 디렉터리 패턴 = 플러그인 자기 경로(항상) + 사용자 시드.
|
|
602
|
+
//
|
|
603
|
+
// 앞쪽이 2차 방어다: 설치가 opencode.json 패치를 남기지 못해도 서브에이전트가
|
|
604
|
+
// state.sh / 게이트 / stage spec 에 접근할 수 있어야 한다 (GitHub #8 의 부분 설치가
|
|
605
|
+
// PERMISSION_STALL 로 나타나던 경로). 뒤쪽은 사용자가 opencode.json 에 명시적으로
|
|
606
|
+
// allow 한 것 — 종전에는 makdoong2-team.json 에서 읽어 **항상 빈 배열**이었다.
|
|
607
|
+
const pluginOwnPatterns = pluginOwnAllowPatterns();
|
|
608
|
+
const configuredAllowPatterns = [
|
|
609
|
+
...pluginOwnPatterns,
|
|
610
|
+
...loadOpencodeExternalDirAllows(),
|
|
611
|
+
];
|
|
612
|
+
logger.debug(`[permission] plugin-own allows: ${pluginOwnPatterns.length}개 ` +
|
|
613
|
+
`(${pluginOwnPatterns.join(", ")})`);
|
|
606
614
|
logger.debug(`[permission] configured external_directory allows: ${configuredAllowPatterns.length}개` +
|
|
607
615
|
(configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : ""));
|
|
608
616
|
const sessionLastToolExecuteAt = new Map();
|
|
@@ -658,6 +666,23 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
658
666
|
const fp = args.filePath;
|
|
659
667
|
return typeof fp === "string" && fp.length > 0 ? fp : undefined;
|
|
660
668
|
};
|
|
669
|
+
// 쓰기 툴이 건드리는 **모든** 대상 경로. write/edit 는 filePath 하나지만
|
|
670
|
+
// apply_patch 는 패치 본문 안에 여러 개가 들어 있다 — opencode 가 gpt-5 계열
|
|
671
|
+
// 세션에서 write/edit 대신 apply_patch 만 노출하므로(src/apply-patch-paths.ts
|
|
672
|
+
// 주석 참조) filePath 만 보는 코드는 그 세션에서 전부 장님이 된다.
|
|
673
|
+
//
|
|
674
|
+
// 반환: paths=대상 목록, resolved=대상을 확정했는가.
|
|
675
|
+
// resolved=false 는 "쓰기인데 대상을 모른다" 이고 정책상 차단이 기본값이다.
|
|
676
|
+
const extractWriteTargets = (toolName, args) => {
|
|
677
|
+
if (isApplyPatchTool(toolName)) {
|
|
678
|
+
const parsed = extractApplyPatchPaths(args);
|
|
679
|
+
return parsed.ok
|
|
680
|
+
? { paths: parsed.paths, resolved: true }
|
|
681
|
+
: { paths: [], resolved: false, reason: parsed.reason };
|
|
682
|
+
}
|
|
683
|
+
const fp = extractFilePathFromToolArgs(args);
|
|
684
|
+
return fp ? { paths: [fp], resolved: true } : { paths: [], resolved: false, reason: "no_file_path" };
|
|
685
|
+
};
|
|
661
686
|
const trackAndStageEngineerWrite = async (sessionID, filePath) => {
|
|
662
687
|
let wt = sessionWorktree.get(sessionID) ?? pendingDispatch.get(sessionID)?.worktree;
|
|
663
688
|
let issue = sessionIssue.get(sessionID);
|
|
@@ -880,8 +905,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
880
905
|
["makdoong2-publisher", /(^|\/)\.makdoong2-team\/[^/]+\/(change-report\.md|review-comment-plan\.json)$/],
|
|
881
906
|
// planner: 요구사항 초안 · 리서치 산출물
|
|
882
907
|
["makdoong2-planner", /(^|\/)\.makdoong2-team\/[^/]+\/[^/]+\.(md|json)$/],
|
|
883
|
-
// researcher: 쓰기 없음
|
|
884
|
-
["makdoong2-researcher", null],
|
|
885
908
|
]);
|
|
886
909
|
// ══════════════════════════════════════════════════════════════════════════
|
|
887
910
|
// Sealed Workflow Enforcement — Outer-World Tool Blocking
|
|
@@ -913,10 +936,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
913
936
|
"makdoong2-engineer",
|
|
914
937
|
"makdoong2-publisher",
|
|
915
938
|
"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
939
|
// User-only issue reporter. 워크플로우에 참여하지 않지만 outer-world 위임은
|
|
921
940
|
// 동일하게 금지 — 수집·마스킹·등록 전 과정을 자기 세션에서 완결해야 하며,
|
|
922
941
|
// 위임하면 마스킹·승인 게이트가 위임처에서 우회될 수 있다.
|
|
@@ -929,7 +948,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
929
948
|
const KNOWN_SAFE_TOOLS = new Set([
|
|
930
949
|
"dispatch_stage",
|
|
931
950
|
"dispatch_verifier",
|
|
932
|
-
"dispatch_research",
|
|
933
951
|
"verify_stage",
|
|
934
952
|
"auto_advance_stage",
|
|
935
953
|
"get_fallback_model",
|
|
@@ -1158,31 +1176,62 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1158
1176
|
}
|
|
1159
1177
|
}
|
|
1160
1178
|
}
|
|
1179
|
+
// ── Universal state.json hardrule (write 계열 툴) ──
|
|
1180
|
+
// bash 우회는 classifyStateJsonAccess 가 막지만 쓰기 **툴**은 종전에 무방비였다.
|
|
1181
|
+
// 산출물 허용 패턴(.makdoong2-team/<이슈키>/*.json)이 state.json 을 포함하므로
|
|
1182
|
+
// 여기서 명시적으로 도려낸다 — state.json 쓰기는 오직 state.sh 를 통해서만 한다.
|
|
1183
|
+
if (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower)) {
|
|
1184
|
+
const stateTargets = extractWriteTargets(toolLower, output.args);
|
|
1185
|
+
const hit = stateTargets.paths
|
|
1186
|
+
.map((p) => p.replace(/\\/g, "/"))
|
|
1187
|
+
.find((p) => /(^|\/)\.makdoong2-team\/[^/]+\/state\.json$/.test(p));
|
|
1188
|
+
if (hit) {
|
|
1189
|
+
logger.error(`[makdoong2-team hook] BLOCKED: state.json 을 '${input.tool}' 툴로 쓰려 했다 ` +
|
|
1190
|
+
`(agent="${agent ?? "unknown"}", path="${hit}")`);
|
|
1191
|
+
throw new Error(`[makdoong2-team state hardrule] state.json 은 '${input.tool}' 툴로 쓸 수 없다 ("${hit}").\n` +
|
|
1192
|
+
`마커 기록은 반드시 'bash ${SCRIPTS_DIR}/state.sh set <이슈키> <키경로> <값>' 으로 하라. ` +
|
|
1193
|
+
`읽기는 'bash ${SCRIPTS_DIR}/state.sh get' / 'status' 를 쓴다.`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1161
1196
|
// ── 산출물 제한 서브에이전트의 write 계열 툴 차단 ──
|
|
1162
1197
|
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
// 산출물을
|
|
1167
|
-
//
|
|
1198
|
+
// 판정 대상은 **툴이 건드리는 모든 경로**다. write/edit 는 filePath 하나,
|
|
1199
|
+
// apply_patch 는 패치 본문에서 파싱한 목록이다 — 후자를 "대상 불명" 으로
|
|
1200
|
+
// 뭉뚱그려 차단하면, opencode 가 write/edit 를 노출하지 않는 gpt-5 계열
|
|
1201
|
+
// 세션에서는 산출물을 만들 합법적 수단이 하나도 남지 않아 워크플로가
|
|
1202
|
+
// 구조적으로 정지한다 (GitHub #8 재발).
|
|
1203
|
+
//
|
|
1204
|
+
// 대상을 **확정하지 못한** 경우(패치 파싱 실패 등)에는 종전대로 차단한다.
|
|
1205
|
+
// 미탐에는 복구 수단이 없고 오탐에는 안내가 있다.
|
|
1168
1206
|
if (agent && ARTIFACT_RESTRICTED_AGENTS.has(agent)
|
|
1169
1207
|
&& (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower))) {
|
|
1170
1208
|
const allowed = ARTIFACT_RESTRICTED_AGENTS.get(agent) ?? null;
|
|
1171
|
-
const
|
|
1172
|
-
const normalized = (
|
|
1173
|
-
const permitted = allowed !== null
|
|
1209
|
+
const targets = extractWriteTargets(toolLower, output.args);
|
|
1210
|
+
const normalized = targets.paths.map((p) => p.replace(/\\/g, "/"));
|
|
1211
|
+
const permitted = allowed !== null
|
|
1212
|
+
&& targets.resolved
|
|
1213
|
+
&& normalized.length > 0
|
|
1214
|
+
&& normalized.every((p) => allowed.test(p));
|
|
1174
1215
|
if (!permitted) {
|
|
1216
|
+
const shown = normalized.length > 0
|
|
1217
|
+
? normalized.join(", ")
|
|
1218
|
+
: `unknown(${targets.reason ?? "no_target"})`;
|
|
1175
1219
|
logger.error(`[makdoong2-team hook] BLOCKED: ${agent} 가 허용되지 않은 대상에 ` +
|
|
1176
|
-
`${input.tool} 을 시도했다 (
|
|
1220
|
+
`${input.tool} 을 시도했다 (targets=${shown})`);
|
|
1177
1221
|
throw new Error(`[makdoong2-team artifact hardrule] "${agent}" 는 '${input.tool}' 로 ` +
|
|
1178
|
-
`${
|
|
1222
|
+
`${normalized.length > 0 ? `"${shown}" 에 ` : ""}쓸 수 없다.\n` +
|
|
1179
1223
|
(allowed === null
|
|
1180
1224
|
? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
|
|
1181
1225
|
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
|
|
1182
1226
|
`(패턴: ${allowed.source}).\n` +
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1227
|
+
(targets.resolved
|
|
1228
|
+
? `대상 경로를 허용 패턴 안으로 바꿔 **지금 즉시 재시도**하라 — ` +
|
|
1229
|
+
`산출물 쓰기 자체가 금지된 것이 아니다.\n`
|
|
1230
|
+
: `이번 호출은 대상 경로를 확정하지 못해 차단됐다(${targets.reason ?? "unknown"}). ` +
|
|
1231
|
+
`'write' 툴이 있으면 filePath 로 직접 쓰고, 없으면 apply_patch 본문을 ` +
|
|
1232
|
+
`'*** Begin Patch' / '*** Add File: <허용 경로>' / '*** End Patch' 형식으로 ` +
|
|
1233
|
+
`정확히 작성해 재시도하라 — 형식이 맞으면 허용 경로에 대해 통과한다.\n`) +
|
|
1234
|
+
`bash 리디렉션 우회는 어느 경우에도 차단된다. 포기하지 말고 위 경로로 재시도하라.\n`) +
|
|
1186
1235
|
`소스 코드 변경이 필요하면 그 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
|
|
1187
1236
|
}
|
|
1188
1237
|
}
|
|
@@ -1327,23 +1376,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1327
1376
|
if (afterSessionID && WRITE_TOOLS.has(toolLowerAfter)) {
|
|
1328
1377
|
const agentAfter = sessionAgent.get(afterSessionID)
|
|
1329
1378
|
?? pendingDispatch.get(afterSessionID)?.agent;
|
|
1330
|
-
|
|
1379
|
+
// apply_patch 는 filePath 인자가 없다 — 패치 본문에서 대상을 뽑는다.
|
|
1380
|
+
// gpt-5 계열 세션에는 write/edit 가 아예 없어 engineer 의 모든 편집이
|
|
1381
|
+
// 이 경로로 오므로, filePath 만 보면 auto git add 가 통째로 무력화되고
|
|
1382
|
+
// dev exit gate 가 unstaged 파일로 하드 차단된다.
|
|
1383
|
+
const targets = extractWriteTargets(toolLowerAfter, input.args);
|
|
1331
1384
|
logger.debug(`[auto-git-add-hook] tool=${toolLowerAfter} session=${afterSessionID} ` +
|
|
1332
|
-
`agent=${agentAfter ?? "unknown"}
|
|
1385
|
+
`agent=${agentAfter ?? "unknown"} targets=${targets.paths.length > 0 ? targets.paths.join(",") : `N/A(${targets.reason ?? "none"})`} ` +
|
|
1333
1386
|
`wt=${sessionWorktree.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.worktree ?? "N/A"} ` +
|
|
1334
1387
|
`issue=${sessionIssue.get(afterSessionID) ?? "N/A"}`);
|
|
1335
|
-
if (agentAfter === "makdoong2-engineer"
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1388
|
+
if (agentAfter === "makdoong2-engineer") {
|
|
1389
|
+
for (const filePath of targets.paths) {
|
|
1390
|
+
const result = await trackAndStageEngineerWrite(afterSessionID, filePath)
|
|
1391
|
+
.catch((err) => ({
|
|
1392
|
+
ok: false,
|
|
1393
|
+
relPath: filePath,
|
|
1394
|
+
error: `hook exception: ${err?.message ?? String(err)}`,
|
|
1395
|
+
}));
|
|
1396
|
+
if (!result.ok && result.relPath) {
|
|
1397
|
+
const warn = `\n\n[makdoong2-team] auto git add FAILED for "${result.relPath}": ${redactAndTruncate(result.error ?? "", 200)}\n` +
|
|
1398
|
+
`→ 조치: bash 로 'git add -- ${result.relPath}' 를 직접 실행해 stage 하시오. dev exit gate 가 unstaged 파일을 차단한다.`;
|
|
1399
|
+
const outAny = output;
|
|
1400
|
+
outAny.output = (outAny.output ?? "") + warn;
|
|
1401
|
+
}
|
|
1347
1402
|
}
|
|
1348
1403
|
}
|
|
1349
1404
|
}
|
|
@@ -2489,267 +2544,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2489
2544
|
* get_fallback_model — fallback advisor.
|
|
2490
2545
|
* Returns the next model in the chain, or exhausted=true.
|
|
2491
2546
|
*/
|
|
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
2547
|
get_fallback_model: tool({
|
|
2754
2548
|
description: "Return the next model in this agent's fallback chain after a failure (rate_limit/5xx/context_exceeded).",
|
|
2755
2549
|
args: {
|
|
@@ -2843,8 +2637,24 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2843
2637
|
// current=null 로 남아 target_stage="1_planning.jira" 로 되돌아가는
|
|
2844
2638
|
// silent regression 이 발생한다. `.issue` 필드를 probe 하여 이 상황을
|
|
2845
2639
|
// 명시적 에러로 분리한다.
|
|
2846
|
-
|
|
2640
|
+
let probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
|
|
2847
2641
|
.cwd(effectiveCwd).quiet().nothrow();
|
|
2642
|
+
// ── 자가 복구 1회: forward sync 는 플러그인 몫이다 ──
|
|
2643
|
+
// worktree cwd 에서 state.json 이 안 보이는 가장 흔한 원인은 forward sync
|
|
2644
|
+
// 미수행이다. 종전에는 그 복구를 next_action 으로 **team-leader 에게 시켰는데**,
|
|
2645
|
+
// 그 안내 문구에 worktree 절대경로가 박혀 있었다. opencode 는 bash 명령이
|
|
2646
|
+
// 참조하는 디렉토리마다 external_directory 승인을 묻으므로(ARCHITECTURE §4.2a),
|
|
2647
|
+
// 리더가 그 명령을 실행하는 순간 primary 세션이 사용자에게 승인을 물었다 —
|
|
2648
|
+
// 정작 다른 모든 동기화는 플러그인이 조용히 처리하는데 이 경로만 예외였다.
|
|
2649
|
+
// 여기서 직접 한 번 시도하면 승인 요청도, 왕복도 사라진다.
|
|
2650
|
+
if (probe.exitCode !== 0 && args.worktree && args.worktree !== cwd) {
|
|
2651
|
+
const heal = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${args.worktree} ${args.issue}`
|
|
2652
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2653
|
+
probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
|
|
2654
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2655
|
+
logger.debug(`[wt-sync] SELF_HEAL issue=${args.issue} worktree=${args.worktree} ` +
|
|
2656
|
+
`sync_exit=${heal.exitCode} probe_after=${probe.exitCode}`);
|
|
2657
|
+
}
|
|
2848
2658
|
if (probe.exitCode !== 0) {
|
|
2849
2659
|
const rootR = await $ `bash ${SCRIPTS_DIR}/state.sh root`
|
|
2850
2660
|
.cwd(effectiveCwd).quiet().nothrow();
|
|
@@ -2857,10 +2667,14 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2857
2667
|
effective_cwd: effectiveCwd,
|
|
2858
2668
|
reason: `state.json 을 판독할 수 없습니다. worktree cwd 기준 예상 경로: ${expectedPath}. ` +
|
|
2859
2669
|
`주요 원인: (1) wt-sync-ignored.sh 로 worktree 동기화 미수행, (2) 다른 cwd(main repo)에서 state.json 을 조작하여 worktree 사본과 불일치, (3) state.json 손상.`,
|
|
2860
|
-
|
|
2670
|
+
// 안내에 **절대경로를 싣지 않는다.** 리더가 그 경로를 명령에 넣어 실행하면
|
|
2671
|
+
// primary 세션이 external_directory 승인을 사용자에게 묻는다. 아래 명령은
|
|
2672
|
+
// 전부 cwd 기준으로 동작하므로 경로 인자가 필요 없다.
|
|
2673
|
+
next_action: `동기화는 이미 1회 자동 시도했고 실패했습니다 — 같은 동기화를 직접 실행하지 마세요. ` +
|
|
2674
|
+
`① 먼저 'bash ${SCRIPTS_DIR}/state.sh status ${args.issue}' 로 존재/유효성을 확인하세요 ` +
|
|
2861
2675
|
`(승인된 읽기 명령입니다 — 훅이 차단하지 않습니다). ` +
|
|
2862
|
-
`② exists=false 면 'bash ${SCRIPTS_DIR}/
|
|
2863
|
-
`
|
|
2676
|
+
`② exists=false 면 'bash ${SCRIPTS_DIR}/state.sh init ${args.issue}' 로 초기화하세요 ` +
|
|
2677
|
+
`(경로 인자 없이 — cwd 의 git toplevel 을 자동으로 씁니다). ` +
|
|
2864
2678
|
`③ readable=false (JSON 손상) 면 사용자에게 에스컬레이션하세요. ` +
|
|
2865
2679
|
`근본 원인이 해소되기 전에는 dispatch_stage 를 호출하지 마세요.`,
|
|
2866
2680
|
});
|
|
@@ -56,6 +56,7 @@ export interface PollClientLike {
|
|
|
56
56
|
}) => Promise<unknown>;
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
export type PermissionStallReason = "outside_allowed_roots" | "non_external_permission" | "tool_call_stall";
|
|
59
60
|
export type PollOutcome = {
|
|
60
61
|
kind: "text";
|
|
61
62
|
text: string;
|
|
@@ -84,6 +85,7 @@ export type PollOutcome = {
|
|
|
84
85
|
permissionID?: string;
|
|
85
86
|
permissionType?: string;
|
|
86
87
|
permissionPatterns?: string[];
|
|
88
|
+
permissionReason?: PermissionStallReason;
|
|
87
89
|
} | {
|
|
88
90
|
kind: "session_gone";
|
|
89
91
|
polls: number;
|