makdoong2-team 2.3.1 → 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 +33 -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 +218 -332
- package/dist/poll-sub-session.d.ts +2 -0
- package/dist/poll-sub-session.js +37 -9
- package/dist/stage-completion.d.ts +55 -0
- package/dist/stage-completion.js +91 -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 -51
- package/stages/02-requirements.md +53 -33
- 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 -133
- package/dist/research-fanout.js +0 -291
- 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";
|
|
@@ -26,15 +26,16 @@ import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakEx
|
|
|
26
26
|
import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
|
|
27
27
|
import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
|
|
28
28
|
import { shouldEscalateStall } from "./stall-escalation.js";
|
|
29
|
+
import { classifyStageCompletion, INCOMPLETE_HANG_REASON } from "./stage-completion.js";
|
|
29
30
|
import { buildStateWriteBlockMessage, classifyStateJsonAccess, looksLikeRedirection, splitUnquotedSegments, WRITE_INDICATORS_UNQUOTED, STATE_SH_CALL_RE, stripQuotedSpans, } from "./state-access-guard.js";
|
|
30
|
-
import { RESEARCH_SOURCES, DEFAULT_RESEARCH_TIMEOUT_MINUTES, buildResearchPrompt, mergeResearchFindings, normalizeQueries, parseResearchOutput, resolveParallelism, summarizeOutcomes, } from "./research-fanout.js";
|
|
31
31
|
import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
|
|
32
|
-
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";
|
|
33
33
|
import { scanSkillMcpRegistry, extractMcpName, looksLikeMcpNotFound, looksLikeMcpConnectionFailed, } from "./skill-mcp-registry.js";
|
|
34
34
|
import { injectAllSecrets } from "./mcp-secret-injector.js";
|
|
35
35
|
import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
|
|
36
36
|
import { logger } from "./logger.js";
|
|
37
37
|
import { redactAndTruncate } from "./redact-secrets.js";
|
|
38
|
+
import { extractApplyPatchPaths, isApplyPatchTool } from "./apply-patch-paths.js";
|
|
38
39
|
import { issueReporterSkillLoadViolation, issueReporterTaskSpawnViolation, ISSUE_REPORTER_AGENT, classifyGithubApiCall, payloadDisplayPaths, displayMismatch, sha256Hex, } from "./issue-reporter-guard.js";
|
|
39
40
|
// All runtime paths come from makdoong2-team.json (paths.* overrides) or are
|
|
40
41
|
// derived from the opencode config dir. No MAKDOONG2 environment variables.
|
|
@@ -113,7 +114,6 @@ export function shouldOverrideSessionGoneOutcome(outcomeKind, currentSuccess, do
|
|
|
113
114
|
const STAGE_ORDER = [
|
|
114
115
|
"1_planning.jira",
|
|
115
116
|
"1_planning.requirements",
|
|
116
|
-
"1_planning.scope",
|
|
117
117
|
"2_implementation.analysis",
|
|
118
118
|
"2_implementation.dev",
|
|
119
119
|
"2_implementation.test",
|
|
@@ -598,10 +598,19 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
598
598
|
// threshold caps the accumulated hang_history across calls — the stall-path
|
|
599
599
|
// counterpart of same_reason_streak on the REJECTED-verdict path.
|
|
600
600
|
const stallEscalateThreshold = Math.max(1, Math.round(config.timeout?.stall_escalate_threshold ?? DEFAULT_STALL_ESCALATE_THRESHOLD));
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
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(", ")})`);
|
|
605
614
|
logger.debug(`[permission] configured external_directory allows: ${configuredAllowPatterns.length}개` +
|
|
606
615
|
(configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : ""));
|
|
607
616
|
const sessionLastToolExecuteAt = new Map();
|
|
@@ -657,6 +666,23 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
657
666
|
const fp = args.filePath;
|
|
658
667
|
return typeof fp === "string" && fp.length > 0 ? fp : undefined;
|
|
659
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
|
+
};
|
|
660
686
|
const trackAndStageEngineerWrite = async (sessionID, filePath) => {
|
|
661
687
|
let wt = sessionWorktree.get(sessionID) ?? pendingDispatch.get(sessionID)?.worktree;
|
|
662
688
|
let issue = sessionIssue.get(sessionID);
|
|
@@ -879,8 +905,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
879
905
|
["makdoong2-publisher", /(^|\/)\.makdoong2-team\/[^/]+\/(change-report\.md|review-comment-plan\.json)$/],
|
|
880
906
|
// planner: 요구사항 초안 · 리서치 산출물
|
|
881
907
|
["makdoong2-planner", /(^|\/)\.makdoong2-team\/[^/]+\/[^/]+\.(md|json)$/],
|
|
882
|
-
// researcher: 쓰기 없음
|
|
883
|
-
["makdoong2-researcher", null],
|
|
884
908
|
]);
|
|
885
909
|
// ══════════════════════════════════════════════════════════════════════════
|
|
886
910
|
// Sealed Workflow Enforcement — Outer-World Tool Blocking
|
|
@@ -912,10 +936,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
912
936
|
"makdoong2-engineer",
|
|
913
937
|
"makdoong2-publisher",
|
|
914
938
|
"makdoong2-verifier",
|
|
915
|
-
// Research fan-out worker. Sealed like every other sub-agent: its frontmatter
|
|
916
|
-
// already omits Task (L1), but sealed workflow is defence in depth — a worker
|
|
917
|
-
// missing here would be caught by nothing at runtime (L2).
|
|
918
|
-
"makdoong2-researcher",
|
|
919
939
|
// User-only issue reporter. 워크플로우에 참여하지 않지만 outer-world 위임은
|
|
920
940
|
// 동일하게 금지 — 수집·마스킹·등록 전 과정을 자기 세션에서 완결해야 하며,
|
|
921
941
|
// 위임하면 마스킹·승인 게이트가 위임처에서 우회될 수 있다.
|
|
@@ -928,7 +948,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
928
948
|
const KNOWN_SAFE_TOOLS = new Set([
|
|
929
949
|
"dispatch_stage",
|
|
930
950
|
"dispatch_verifier",
|
|
931
|
-
"dispatch_research",
|
|
932
951
|
"verify_stage",
|
|
933
952
|
"auto_advance_stage",
|
|
934
953
|
"get_fallback_model",
|
|
@@ -1008,9 +1027,23 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1008
1027
|
await spawnPaneForSession(sid);
|
|
1009
1028
|
},
|
|
1010
1029
|
"chat.params": async (input) => {
|
|
1011
|
-
if (input.sessionID
|
|
1012
|
-
|
|
1030
|
+
if (!input.sessionID || !input.agent)
|
|
1031
|
+
return;
|
|
1032
|
+
// 정체성은 downgrade 하지 않는다 (2차 방어).
|
|
1033
|
+
// sessionAgent 는 sealed 서브에이전트 판정의 입력이므로, 한 번 sealed 로
|
|
1034
|
+
// 확정된 세션이 makdoong2 소속이 아닌 이름으로 덮어써지면 그 세션의
|
|
1035
|
+
// outer-world 차단·산출물 경로 제한이 조용히 풀린다. agent 를 빠뜨린
|
|
1036
|
+
// 프롬프트 하나로 그렇게 되어선 안 된다 — 실제로 NUDGE 가 그랬다 (issue #9).
|
|
1037
|
+
// 호출부(1차 방어)는 전부 agent 를 싣지만, 새 호출부가 또 빠뜨려도
|
|
1038
|
+
// 보안 속성은 유지되어야 한다.
|
|
1039
|
+
const known = sessionAgent.get(input.sessionID);
|
|
1040
|
+
if (known && SEALED_SUBAGENTS.has(known) && !SEALED_SUBAGENTS.has(input.agent)) {
|
|
1041
|
+
logger.warn(`[makdoong2-team hook] chat.params agent downgrade ignored: session=${input.sessionID} ` +
|
|
1042
|
+
`known="${known}" incoming="${input.agent}" — sealed 정체성을 유지한다. ` +
|
|
1043
|
+
`이 프롬프트 호출부가 agent 를 싣지 않았을 가능성이 높다.`);
|
|
1044
|
+
return;
|
|
1013
1045
|
}
|
|
1046
|
+
sessionAgent.set(input.sessionID, input.agent);
|
|
1014
1047
|
},
|
|
1015
1048
|
// ─────────────────────────────────────────────────────────────
|
|
1016
1049
|
// PreToolUse — block destructive bash, gate `git push`,
|
|
@@ -1143,31 +1176,62 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1143
1176
|
}
|
|
1144
1177
|
}
|
|
1145
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
|
+
}
|
|
1146
1196
|
// ── 산출물 제한 서브에이전트의 write 계열 툴 차단 ──
|
|
1147
1197
|
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
// 산출물을
|
|
1152
|
-
//
|
|
1198
|
+
// 판정 대상은 **툴이 건드리는 모든 경로**다. write/edit 는 filePath 하나,
|
|
1199
|
+
// apply_patch 는 패치 본문에서 파싱한 목록이다 — 후자를 "대상 불명" 으로
|
|
1200
|
+
// 뭉뚱그려 차단하면, opencode 가 write/edit 를 노출하지 않는 gpt-5 계열
|
|
1201
|
+
// 세션에서는 산출물을 만들 합법적 수단이 하나도 남지 않아 워크플로가
|
|
1202
|
+
// 구조적으로 정지한다 (GitHub #8 재발).
|
|
1203
|
+
//
|
|
1204
|
+
// 대상을 **확정하지 못한** 경우(패치 파싱 실패 등)에는 종전대로 차단한다.
|
|
1205
|
+
// 미탐에는 복구 수단이 없고 오탐에는 안내가 있다.
|
|
1153
1206
|
if (agent && ARTIFACT_RESTRICTED_AGENTS.has(agent)
|
|
1154
1207
|
&& (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower))) {
|
|
1155
1208
|
const allowed = ARTIFACT_RESTRICTED_AGENTS.get(agent) ?? null;
|
|
1156
|
-
const
|
|
1157
|
-
const normalized = (
|
|
1158
|
-
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));
|
|
1159
1215
|
if (!permitted) {
|
|
1216
|
+
const shown = normalized.length > 0
|
|
1217
|
+
? normalized.join(", ")
|
|
1218
|
+
: `unknown(${targets.reason ?? "no_target"})`;
|
|
1160
1219
|
logger.error(`[makdoong2-team hook] BLOCKED: ${agent} 가 허용되지 않은 대상에 ` +
|
|
1161
|
-
`${input.tool} 을 시도했다 (
|
|
1220
|
+
`${input.tool} 을 시도했다 (targets=${shown})`);
|
|
1162
1221
|
throw new Error(`[makdoong2-team artifact hardrule] "${agent}" 는 '${input.tool}' 로 ` +
|
|
1163
|
-
`${
|
|
1222
|
+
`${normalized.length > 0 ? `"${shown}" 에 ` : ""}쓸 수 없다.\n` +
|
|
1164
1223
|
(allowed === null
|
|
1165
1224
|
? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
|
|
1166
1225
|
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
|
|
1167
1226
|
`(패턴: ${allowed.source}).\n` +
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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`) +
|
|
1171
1235
|
`소스 코드 변경이 필요하면 그 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
|
|
1172
1236
|
}
|
|
1173
1237
|
}
|
|
@@ -1312,23 +1376,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1312
1376
|
if (afterSessionID && WRITE_TOOLS.has(toolLowerAfter)) {
|
|
1313
1377
|
const agentAfter = sessionAgent.get(afterSessionID)
|
|
1314
1378
|
?? pendingDispatch.get(afterSessionID)?.agent;
|
|
1315
|
-
|
|
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);
|
|
1316
1384
|
logger.debug(`[auto-git-add-hook] tool=${toolLowerAfter} session=${afterSessionID} ` +
|
|
1317
|
-
`agent=${agentAfter ?? "unknown"}
|
|
1385
|
+
`agent=${agentAfter ?? "unknown"} targets=${targets.paths.length > 0 ? targets.paths.join(",") : `N/A(${targets.reason ?? "none"})`} ` +
|
|
1318
1386
|
`wt=${sessionWorktree.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.worktree ?? "N/A"} ` +
|
|
1319
1387
|
`issue=${sessionIssue.get(afterSessionID) ?? "N/A"}`);
|
|
1320
|
-
if (agentAfter === "makdoong2-engineer"
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
+
}
|
|
1332
1402
|
}
|
|
1333
1403
|
}
|
|
1334
1404
|
}
|
|
@@ -1808,18 +1878,26 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1808
1878
|
logger.debug(`[dispatch_stage] engineer session ready — ` +
|
|
1809
1879
|
`session_id=${subSessionID} stage=${args.target_stage} attempt=${attempt}\n` +
|
|
1810
1880
|
` monitor: opencode attach http://127.0.0.1:44707 --session ${subSessionID}`);
|
|
1881
|
+
// 종전 문구는 2번에서 state.sh 마커 기록을 요구하면서 마지막 줄에서
|
|
1882
|
+
// "새 tool 호출 추가 금지" 라고 못박아 서로 모순됐다. 실제로 planner 가
|
|
1883
|
+
// Jira 검증 6/6 을 끝내고도 "마커 기록 전 시한 도달" 이라며 마커를
|
|
1884
|
+
// 하나도 남기지 않고 종료해 27분이 통째로 버려졌다 (GitHub issue #9).
|
|
1885
|
+
// 마커 기록은 금지의 예외임을 문구 안에서 명시한다.
|
|
1811
1886
|
const nudgeText = [
|
|
1812
|
-
"⚠ 작업 시한 80% 도달 —
|
|
1887
|
+
"⚠ 작업 시한 80% 도달 — 지금부터는 마커 기록과 요약만 하고 즉시 세션을 종료하시오.",
|
|
1813
1888
|
"",
|
|
1814
|
-
"
|
|
1815
|
-
"1. 진행 중인 단일 tool call
|
|
1816
|
-
`2. bash ${SCRIPTS_DIR}/state.sh
|
|
1889
|
+
"순서대로 수행:",
|
|
1890
|
+
"1. 진행 중인 단일 tool call 만 마무리한다. 새 조사·탐색·구현은 시작하지 않는다.",
|
|
1891
|
+
`2. **이미 끝낸 작업의 state.json 마커를 지금 전부 기록한다.** bash ${SCRIPTS_DIR}/state.sh set 호출은`,
|
|
1892
|
+
" 아래 금지 규칙의 예외이며 필요한 횟수만큼 호출한다. 완료한 substage 는 .done=true 까지 기록한다.",
|
|
1893
|
+
" 마커 없이 종료하면 그 작업은 수행되지 않은 것으로 판정되어 substage 전체가 처음부터 재실행된다",
|
|
1894
|
+
" — 지금까지의 결과가 통째로 버려진다. 기록할 시간이 없다는 판단은 하지 말 것.",
|
|
1817
1895
|
"3. 3줄 이상 한국어 요약 텍스트 출력:",
|
|
1818
1896
|
" - 처리한 substage 결과 (완료/차단/조기종료)",
|
|
1819
1897
|
" - 변경한 state.json 마커 목록",
|
|
1820
1898
|
" - 다음 단계 안내",
|
|
1821
1899
|
"",
|
|
1822
|
-
"금지:
|
|
1900
|
+
"금지: 새로운 조사·구현 tool 호출. 허용: state.sh 마커 기록. 요약 출력 직후 즉시 종료.",
|
|
1823
1901
|
].join("\n");
|
|
1824
1902
|
const engineerNudge = async (sid, elapsedMs) => {
|
|
1825
1903
|
logger.debug(`[dispatch_stage] NUDGE sid=${sid} elapsed=${Math.round(elapsedMs / 1000)}s`);
|
|
@@ -1827,6 +1905,13 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1827
1905
|
.promptAsync({
|
|
1828
1906
|
path: { id: sid },
|
|
1829
1907
|
body: {
|
|
1908
|
+
// agent 를 빼면 opencode 가 기본 에이전트(`build`)로 이 turn 을
|
|
1909
|
+
// 돌리고, `chat.params` 가 sessionAgent[sid] 를 그 값으로 덮어쓴다.
|
|
1910
|
+
// 그 순간부터 이 세션은 sealed sub-agent 로 인식되지 않아
|
|
1911
|
+
// outer-world 위임 차단과 산출물 경로 제한이 전부 풀린다.
|
|
1912
|
+
// 실측 로그에서 NUDGE 직후 bash 호출이 agent="build" 로 기록됐다
|
|
1913
|
+
// (GitHub issue #9 부수 관찰). 여기서만 누락돼 있었다.
|
|
1914
|
+
agent: spec.id,
|
|
1830
1915
|
parts: [{ type: "text", text: nudgeText }],
|
|
1831
1916
|
model: { providerID: activeProviderID, modelID: activeModelID },
|
|
1832
1917
|
},
|
|
@@ -2028,30 +2113,60 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2028
2113
|
}
|
|
2029
2114
|
}
|
|
2030
2115
|
promptPromise.catch(() => { });
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
const
|
|
2039
|
-
const resetDoneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${resetDonePath}`
|
|
2116
|
+
// 완료 판정은 sub-agent 의 문장이 아니라 substage 마커로 한다 (issue #9).
|
|
2117
|
+
// pollSubSession 의 kind="text" 는 "최종 turn 이 나왔다" 일 뿐이고,
|
|
2118
|
+
// 예산을 다 쓰고 "조기종료 — 마커 기록 없음" 이라고 말한 세션도 같은 kind 를
|
|
2119
|
+
// 낸다. 그 둘을 구분하는 유일한 값이 게이트·verifier 가 읽는 그 .done 이다.
|
|
2120
|
+
// cwd 는 effectiveWorktree — 이 substage 의 다른 state.json 접근과
|
|
2121
|
+
// 같은 파일을 봐야 한다 (args.worktree 를 쓰면 교정 발동 시 갈린다).
|
|
2122
|
+
const readMarker = async (field) => {
|
|
2123
|
+
const r = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${stageJqPath(args.target_stage)}.${field}`}`
|
|
2040
2124
|
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
}
|
|
2125
|
+
return r.exitCode === 0 ? (r.stdout?.toString().trim() ?? null) : null;
|
|
2126
|
+
};
|
|
2127
|
+
const completion = classifyStageCompletion({
|
|
2128
|
+
outcomeKind: finalOutcome.kind,
|
|
2129
|
+
success,
|
|
2130
|
+
doneValue: success ? await readMarker("done") : null,
|
|
2131
|
+
interviewRequiredValue: success ? await readMarker("interview_required") : null,
|
|
2132
|
+
});
|
|
2133
|
+
if (completion.resetHangHistory) {
|
|
2134
|
+
// 리셋 조건은 "dispatch 정상 반환" 이 아니라 "substage 실제 완료(done=true)"
|
|
2135
|
+
// 다. 종전에는 세션이 텍스트만 뱉고 done=false 로 끝나도 리셋됐고,
|
|
2136
|
+
// 재-dispatch 를 반복하는 동안 이력이 매번 비워져
|
|
2137
|
+
// stall_escalate_threshold 가 사실상 도달 불가였다 (issue #8).
|
|
2138
|
+
const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
|
|
2139
|
+
const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
|
|
2140
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2141
|
+
logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
|
|
2142
|
+
`exit=${resetR.exitCode} — substage done=true`);
|
|
2143
|
+
}
|
|
2144
|
+
else if (success) {
|
|
2145
|
+
logger.debug(`[hang_history] reset skipped issue=${args.issue} stage=${args.target_stage} ` +
|
|
2146
|
+
`completion=${completion.completion} — dispatch 는 정상 반환했지만 substage 미완료`);
|
|
2147
|
+
}
|
|
2148
|
+
if (completion.recordHang) {
|
|
2149
|
+
// hang_history 는 dispatch_stage 호출 사이를 넘어 살아남는 유일한
|
|
2150
|
+
// 카운터다. 여기에 남기지 않으면 이 실패 모드는 cross-call 상한
|
|
2151
|
+
// (stall_escalate_threshold) 에 영영 도달하지 못하고, 매 호출이
|
|
2152
|
+
// 타임아웃 전체를 태우며 무한히 재실행된다 (issue #9).
|
|
2153
|
+
const incompleteEntry = JSON.stringify({
|
|
2154
|
+
attempt,
|
|
2155
|
+
at: new Date().toISOString(),
|
|
2156
|
+
reason: INCOMPLETE_HANG_REASON,
|
|
2157
|
+
elapsed_ms: finalOutcome.elapsedMs,
|
|
2158
|
+
polls: finalOutcome.polls,
|
|
2159
|
+
session_id: subSessionID,
|
|
2160
|
+
model: activeModelFull,
|
|
2161
|
+
fallback_depth: activeFallbackDepth,
|
|
2162
|
+
final: true,
|
|
2163
|
+
});
|
|
2164
|
+
const incompleteJqPath = stageJqPath(args.target_stage) + ".hang_history";
|
|
2165
|
+
const incompleteR = await $ `bash ${SCRIPTS_DIR}/state.sh append ${args.issue} ${incompleteJqPath} ${incompleteEntry}`
|
|
2166
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2167
|
+
logger.warn(`[dispatch_stage] STAGE_INCOMPLETE issue=${args.issue} stage=${args.target_stage} ` +
|
|
2168
|
+
`session=${subSessionID} outcome_kind=${finalOutcome.kind} elapsed_ms=${finalOutcome.elapsedMs} ` +
|
|
2169
|
+
`— 최종 텍스트는 나왔으나 .done=false. hang_history append exit=${incompleteR.exitCode}`);
|
|
2055
2170
|
}
|
|
2056
2171
|
const retryDisallowed = finalOutcome.kind === "timeout" &&
|
|
2057
2172
|
finalOutcome.transientFailures === 0;
|
|
@@ -2061,7 +2176,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2061
2176
|
`동일 dispatch_stage 를 재호출하지 말고 사용자에게 상황을 보고한 뒤 지시를 기다리거나 get_fallback_model 로 다른 모델을 요청하세요.`
|
|
2062
2177
|
: undefined;
|
|
2063
2178
|
finalResultJson = JSON.stringify({
|
|
2064
|
-
ok:
|
|
2179
|
+
ok: completion.ok,
|
|
2065
2180
|
stage: args.target_stage,
|
|
2066
2181
|
agent: spec.id,
|
|
2067
2182
|
model: activeModelFull,
|
|
@@ -2071,6 +2186,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2071
2186
|
fallback_depth: activeFallbackDepth,
|
|
2072
2187
|
output: finalLegacy.text.slice(0, 8000),
|
|
2073
2188
|
outcome_kind: finalOutcome.kind,
|
|
2189
|
+
// 완료 여부는 이 두 필드로 읽는다. output 문구를 해석하지 말 것 (issue #9).
|
|
2190
|
+
stage_done: completion.stageDone,
|
|
2191
|
+
completion: completion.completion,
|
|
2074
2192
|
polls: finalOutcome.polls,
|
|
2075
2193
|
elapsed_ms: finalOutcome.elapsedMs,
|
|
2076
2194
|
transient_failures: finalOutcome.kind === "timeout"
|
|
@@ -2078,9 +2196,10 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2078
2196
|
: undefined,
|
|
2079
2197
|
retry_disallowed: retryDisallowed || undefined,
|
|
2080
2198
|
retry_disallowed_reason: retryDisallowedReason,
|
|
2081
|
-
|
|
2199
|
+
next_action: completion.nextAction,
|
|
2200
|
+
reason: completion.ok
|
|
2082
2201
|
? overriddenReason
|
|
2083
|
-
: finalLegacy.text,
|
|
2202
|
+
: (completion.incompleteReason ?? finalLegacy.text),
|
|
2084
2203
|
});
|
|
2085
2204
|
}
|
|
2086
2205
|
finally {
|
|
@@ -2425,259 +2544,6 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2425
2544
|
* get_fallback_model — fallback advisor.
|
|
2426
2545
|
* Returns the next model in the chain, or exhausted=true.
|
|
2427
2546
|
*/
|
|
2428
|
-
/**
|
|
2429
|
-
* dispatch_research — parallel multi-source research fan-out.
|
|
2430
|
-
*
|
|
2431
|
-
* Why the plugin does the fan-out instead of the agent: sealed sub-agents
|
|
2432
|
-
* cannot delegate (ARCHITECTURE.md §4.2), and a prompt asking the model to
|
|
2433
|
-
* "call the sources in parallel" cannot be enforced — the model may call
|
|
2434
|
-
* them one at a time and nothing detects it. Doing it in code makes the
|
|
2435
|
-
* parallelism deterministic, and gives each source its own session so one
|
|
2436
|
-
* source's material never crowds out another's (DESIGN.md §3.7).
|
|
2437
|
-
*
|
|
2438
|
-
* Failure is isolated per source: one source failing yields a partial
|
|
2439
|
-
* artifact plus an explicit failure row, never an aborted fan-out.
|
|
2440
|
-
*/
|
|
2441
|
-
dispatch_research: tool({
|
|
2442
|
-
description: "여러 리서치 소스(jira / confluence / bitbucket / github-oss)를 병렬 서브세션으로 동시 조사하고 " +
|
|
2443
|
-
"결과를 research-findings.json 으로 병합한다. 소스별 컨텍스트가 격리되며 한 소스의 실패가 " +
|
|
2444
|
-
"다른 소스를 막지 않는다. 1_planning.requirements 의 다출처 교차 조사에 사용.",
|
|
2445
|
-
args: {
|
|
2446
|
-
issue: tool.schema.string().describe("Jira issue key"),
|
|
2447
|
-
worktree: tool.schema.string().describe("작업 디렉토리 절대경로 (state.sh 실행 컨텍스트)"),
|
|
2448
|
-
queries: tool.schema
|
|
2449
|
-
.array(tool.schema.object({
|
|
2450
|
-
source: tool.schema
|
|
2451
|
-
.string()
|
|
2452
|
-
.describe("jira | confluence | bitbucket | github-oss"),
|
|
2453
|
-
focus: tool.schema
|
|
2454
|
-
.string()
|
|
2455
|
-
.describe("이 소스에서 확인할 것. 구체적일수록 좋다."),
|
|
2456
|
-
}))
|
|
2457
|
-
.describe("소스별 조사 지시. 동시 실행되므로 서로 의존하면 안 된다."),
|
|
2458
|
-
context: tool.schema
|
|
2459
|
-
.string()
|
|
2460
|
-
.optional()
|
|
2461
|
-
.describe("모든 조사 세션에 공통 주입할 배경 (예: Jira 요약)"),
|
|
2462
|
-
},
|
|
2463
|
-
async execute(args, context) {
|
|
2464
|
-
const startedAll = Date.now();
|
|
2465
|
-
const parallelLimit = resolveParallelism(config.research?.max_parallel);
|
|
2466
|
-
const { queries, rejected, deferred } = normalizeQueries(args.queries, parallelLimit);
|
|
2467
|
-
if (queries.length === 0) {
|
|
2468
|
-
return JSON.stringify({
|
|
2469
|
-
ok: false,
|
|
2470
|
-
reason: "실행 가능한 query 가 없다.",
|
|
2471
|
-
rejected,
|
|
2472
|
-
deferred,
|
|
2473
|
-
allowed_sources: Object.keys(RESEARCH_SOURCES),
|
|
2474
|
-
});
|
|
2475
|
-
}
|
|
2476
|
-
const parentSessionID = context?.sessionID ?? dispatchParentSessionID(undefined);
|
|
2477
|
-
if (!parentSessionID) {
|
|
2478
|
-
return JSON.stringify({
|
|
2479
|
-
ok: false,
|
|
2480
|
-
reason: "dispatch_research 호출자의 sessionID를 얻지 못했다.",
|
|
2481
|
-
});
|
|
2482
|
-
}
|
|
2483
|
-
const researcherId = "makdoong2-researcher";
|
|
2484
|
-
// Recursion guard: a research session must never start its own fan-out.
|
|
2485
|
-
// The researcher's frontmatter omits this tool (L1), but a fan-out that
|
|
2486
|
-
// could nest would multiply sessions without bound, so it is refused at
|
|
2487
|
-
// runtime too — same defence-in-depth reasoning as SEALED_SUBAGENTS.
|
|
2488
|
-
if (sessionAgent.get(parentSessionID) === researcherId) {
|
|
2489
|
-
logger.error(`[dispatch_research] BLOCKED: researcher session ${parentSessionID} attempted a nested fan-out`);
|
|
2490
|
-
return JSON.stringify({
|
|
2491
|
-
ok: false,
|
|
2492
|
-
reason: "리서치 세션은 dispatch_research 를 다시 호출할 수 없다 (중첩 fan-out 금지). " +
|
|
2493
|
-
"배정받은 소스만 조사하고 JSON 을 반환하라.",
|
|
2494
|
-
});
|
|
2495
|
-
}
|
|
2496
|
-
const policy = POLICIES[researcherId];
|
|
2497
|
-
if (!policy) {
|
|
2498
|
-
return JSON.stringify({
|
|
2499
|
-
ok: false,
|
|
2500
|
-
reason: `POLICIES 에 ${researcherId} 항목이 없다. 모델 정책 설정을 확인하라.`,
|
|
2501
|
-
});
|
|
2502
|
-
}
|
|
2503
|
-
const [providerID, ...modelRest] = policy.primary.id.split("/");
|
|
2504
|
-
const modelID = modelRest.join("/");
|
|
2505
|
-
const timeoutMs = Math.max(60_000, Math.round((config.research?.timeout_minutes ?? DEFAULT_RESEARCH_TIMEOUT_MINUTES) * 60_000));
|
|
2506
|
-
logger.debug(`[dispatch_research] fan-out start issue=${args.issue} sources=` +
|
|
2507
|
-
`${queries.map((q) => q.spec.source).join(",")} parallel_limit=${parallelLimit} ` +
|
|
2508
|
-
`timeout_ms=${timeoutMs} parentID=${parentSessionID}`);
|
|
2509
|
-
const runOne = async (q) => {
|
|
2510
|
-
const startedAt = Date.now();
|
|
2511
|
-
const base = {
|
|
2512
|
-
source: q.spec.source,
|
|
2513
|
-
label: q.spec.label,
|
|
2514
|
-
focus: q.focus,
|
|
2515
|
-
session_id: null,
|
|
2516
|
-
elapsed_ms: 0,
|
|
2517
|
-
};
|
|
2518
|
-
const fail = (error, sid) => ({
|
|
2519
|
-
...base,
|
|
2520
|
-
session_id: sid,
|
|
2521
|
-
elapsed_ms: Date.now() - startedAt,
|
|
2522
|
-
status: "failed",
|
|
2523
|
-
findings: [],
|
|
2524
|
-
gaps: [],
|
|
2525
|
-
error,
|
|
2526
|
-
});
|
|
2527
|
-
const createResult = await client.session
|
|
2528
|
-
.create({
|
|
2529
|
-
body: {
|
|
2530
|
-
parentID: parentSessionID,
|
|
2531
|
-
title: `research:${q.spec.source} (${args.issue})`,
|
|
2532
|
-
},
|
|
2533
|
-
query: { directory: args.worktree },
|
|
2534
|
-
})
|
|
2535
|
-
.catch((e) => ({ error: e, data: null }));
|
|
2536
|
-
if (createResult.error || !createResult.data) {
|
|
2537
|
-
return fail(`session create 실패: ${JSON.stringify(createResult.error)}`, null);
|
|
2538
|
-
}
|
|
2539
|
-
const sid = createResult.data.id;
|
|
2540
|
-
sessionIssue.set(sid, args.issue);
|
|
2541
|
-
subSessionIds.add(sid);
|
|
2542
|
-
sessionWorktree.set(sid, args.worktree);
|
|
2543
|
-
pendingDispatch.set(sid, {
|
|
2544
|
-
stage: `research:${q.spec.source}`,
|
|
2545
|
-
agent: researcherId,
|
|
2546
|
-
worktree: args.worktree,
|
|
2547
|
-
startedAt,
|
|
2548
|
-
});
|
|
2549
|
-
appendSessionIndex({
|
|
2550
|
-
sessionID: sid,
|
|
2551
|
-
agent: researcherId,
|
|
2552
|
-
worktree: args.worktree,
|
|
2553
|
-
issue: args.issue,
|
|
2554
|
-
stage: `research:${q.spec.source}`,
|
|
2555
|
-
createdAt: new Date().toISOString(),
|
|
2556
|
-
});
|
|
2557
|
-
let outcomeText = "";
|
|
2558
|
-
try {
|
|
2559
|
-
const promptText = buildResearchPrompt(q, {
|
|
2560
|
-
issue: args.issue,
|
|
2561
|
-
scriptsDir: SCRIPTS_DIR,
|
|
2562
|
-
worktree: args.worktree,
|
|
2563
|
-
context: args.context,
|
|
2564
|
-
});
|
|
2565
|
-
const promptPromise = client.session
|
|
2566
|
-
.prompt({
|
|
2567
|
-
path: { id: sid },
|
|
2568
|
-
body: {
|
|
2569
|
-
agent: researcherId,
|
|
2570
|
-
tools: { question: false },
|
|
2571
|
-
parts: [{ type: "text", text: promptText }],
|
|
2572
|
-
model: { providerID, modelID },
|
|
2573
|
-
},
|
|
2574
|
-
query: { directory: args.worktree },
|
|
2575
|
-
})
|
|
2576
|
-
.catch((e) => ({ error: e }));
|
|
2577
|
-
const early = await Promise.race([
|
|
2578
|
-
promptPromise,
|
|
2579
|
-
new Promise((r) => setTimeout(() => r("pending"), 2_000)),
|
|
2580
|
-
]);
|
|
2581
|
-
if (early !== "pending" && early?.error) {
|
|
2582
|
-
return fail(`prompt 실패: ${JSON.stringify(early.error)}`, sid);
|
|
2583
|
-
}
|
|
2584
|
-
await spawnPaneForSession(sid);
|
|
2585
|
-
const outcome = await pollSubSession(sid, timeoutMs, args.worktree);
|
|
2586
|
-
const legacy = pollOutcomeToLegacy(outcome);
|
|
2587
|
-
outcomeText = legacy.text;
|
|
2588
|
-
promptPromise.catch(() => { });
|
|
2589
|
-
if (outcome.kind === "session_gone") {
|
|
2590
|
-
await cleanupSubSession(sid, {
|
|
2591
|
-
success: false,
|
|
2592
|
-
reason: `research ${q.spec.source} session_gone`,
|
|
2593
|
-
skipSessionOps: outcome.reason !== "message_stall",
|
|
2594
|
-
});
|
|
2595
|
-
return fail(`세션 종료 (${outcome.reason ?? "status_absent"})`, sid);
|
|
2596
|
-
}
|
|
2597
|
-
if (outcome.kind === "timeout") {
|
|
2598
|
-
await cleanupSubSession(sid, {
|
|
2599
|
-
success: false,
|
|
2600
|
-
reason: `research ${q.spec.source} timeout`,
|
|
2601
|
-
});
|
|
2602
|
-
return fail(`시간 초과 (${Math.round(timeoutMs / 60_000)}분)`, sid);
|
|
2603
|
-
}
|
|
2604
|
-
const parsed = parseResearchOutput(outcomeText);
|
|
2605
|
-
await cleanupSubSession(sid, { success: parsed.ok });
|
|
2606
|
-
if (!parsed.ok) {
|
|
2607
|
-
return fail(`출력 파싱 실패: ${parsed.reason}`, sid);
|
|
2608
|
-
}
|
|
2609
|
-
return {
|
|
2610
|
-
...base,
|
|
2611
|
-
session_id: sid,
|
|
2612
|
-
elapsed_ms: Date.now() - startedAt,
|
|
2613
|
-
status: "ok",
|
|
2614
|
-
findings: parsed.data.findings,
|
|
2615
|
-
gaps: parsed.data.gaps,
|
|
2616
|
-
error: null,
|
|
2617
|
-
};
|
|
2618
|
-
}
|
|
2619
|
-
catch (e) {
|
|
2620
|
-
await cleanupSubSession(sid, {
|
|
2621
|
-
success: false,
|
|
2622
|
-
reason: `research ${q.spec.source} threw`,
|
|
2623
|
-
}).catch(() => undefined);
|
|
2624
|
-
return fail(`예외: ${e instanceof Error ? e.message : String(e)}`, sid);
|
|
2625
|
-
}
|
|
2626
|
-
};
|
|
2627
|
-
// Promise.all, not a loop: the whole point is that the sources run at
|
|
2628
|
-
// the same time. runOne never rejects (every path returns an outcome),
|
|
2629
|
-
// so one source cannot take the others down with it.
|
|
2630
|
-
const outcomes = await Promise.all(queries.map(runOne));
|
|
2631
|
-
const artifact = mergeResearchFindings(args.issue, new Date().toISOString(), outcomes, rejected, deferred);
|
|
2632
|
-
// state.json 산출물 경로는 상대경로만 저장한다 (ARCHITECTURE.md §5.3).
|
|
2633
|
-
const relPath = `.makdoong2-team/${args.issue}/research-findings.json`;
|
|
2634
|
-
let artifactWritten = false;
|
|
2635
|
-
let artifactError = null;
|
|
2636
|
-
try {
|
|
2637
|
-
const absDir = join(args.worktree, ".makdoong2-team", args.issue);
|
|
2638
|
-
mkdirSync(absDir, { recursive: true });
|
|
2639
|
-
writeFileSync(join(absDir, "research-findings.json"), JSON.stringify(artifact, null, 2));
|
|
2640
|
-
artifactWritten = true;
|
|
2641
|
-
}
|
|
2642
|
-
catch (e) {
|
|
2643
|
-
artifactError = e instanceof Error ? e.message : String(e);
|
|
2644
|
-
logger.error(`[dispatch_research] artifact write 실패: ${artifactError}`);
|
|
2645
|
-
}
|
|
2646
|
-
if (artifactWritten) {
|
|
2647
|
-
// cwd 는 반드시 args.worktree — state.sh root() 가 cwd 의 git toplevel 을
|
|
2648
|
-
// 쓰므로 여기서 어긋나면 다른 state.json 에 기록된다 (ARCHITECTURE.md §10.2).
|
|
2649
|
-
const setR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${'.stages."1_planning".substages."requirements".research_path'} ${JSON.stringify(relPath)}`
|
|
2650
|
-
.cwd(args.worktree).quiet().nothrow();
|
|
2651
|
-
if (setR.exitCode !== 0) {
|
|
2652
|
-
logger.debug(`[dispatch_research] research_path 마커 기록 실패 exit=${setR.exitCode} ` +
|
|
2653
|
-
`stderr=${redactAndTruncate(setR.stderr?.toString() ?? "", 160)}`);
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
const okCount = artifact.counts.ok;
|
|
2657
|
-
logger.debug(`[dispatch_research] fan-out done issue=${args.issue} ok=${okCount}/${outcomes.length} ` +
|
|
2658
|
-
`findings=${artifact.counts.findings_total} elapsed_ms=${Date.now() - startedAll}`);
|
|
2659
|
-
return JSON.stringify({
|
|
2660
|
-
// 부분 성공도 ok=true. 한 소스가 죽었다고 나머지 조사 결과를 버리면
|
|
2661
|
-
// fan-out 의 실패 격리가 의미를 잃는다. 호출자는 failed 배열을 본다.
|
|
2662
|
-
ok: okCount > 0,
|
|
2663
|
-
issue: args.issue,
|
|
2664
|
-
artifact_path: artifactWritten ? relPath : null,
|
|
2665
|
-
artifact_error: artifactError,
|
|
2666
|
-
elapsed_ms: Date.now() - startedAll,
|
|
2667
|
-
counts: artifact.counts,
|
|
2668
|
-
summary: summarizeOutcomes(outcomes),
|
|
2669
|
-
failed: outcomes.filter((o) => o.status === "failed").map((o) => ({
|
|
2670
|
-
source: o.source,
|
|
2671
|
-
error: o.error,
|
|
2672
|
-
})),
|
|
2673
|
-
rejected,
|
|
2674
|
-
deferred,
|
|
2675
|
-
next_action: okCount > 0
|
|
2676
|
-
? `조사 결과를 읽고 요구사항 체크리스트에 반영하라: ${relPath}`
|
|
2677
|
-
: "모든 소스 조사가 실패했다. failed 사유를 사용자에게 보고하라.",
|
|
2678
|
-
});
|
|
2679
|
-
},
|
|
2680
|
-
}),
|
|
2681
2547
|
get_fallback_model: tool({
|
|
2682
2548
|
description: "Return the next model in this agent's fallback chain after a failure (rate_limit/5xx/context_exceeded).",
|
|
2683
2549
|
args: {
|
|
@@ -2771,8 +2637,24 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2771
2637
|
// current=null 로 남아 target_stage="1_planning.jira" 로 되돌아가는
|
|
2772
2638
|
// silent regression 이 발생한다. `.issue` 필드를 probe 하여 이 상황을
|
|
2773
2639
|
// 명시적 에러로 분리한다.
|
|
2774
|
-
|
|
2640
|
+
let probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
|
|
2775
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
|
+
}
|
|
2776
2658
|
if (probe.exitCode !== 0) {
|
|
2777
2659
|
const rootR = await $ `bash ${SCRIPTS_DIR}/state.sh root`
|
|
2778
2660
|
.cwd(effectiveCwd).quiet().nothrow();
|
|
@@ -2785,10 +2667,14 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2785
2667
|
effective_cwd: effectiveCwd,
|
|
2786
2668
|
reason: `state.json 을 판독할 수 없습니다. worktree cwd 기준 예상 경로: ${expectedPath}. ` +
|
|
2787
2669
|
`주요 원인: (1) wt-sync-ignored.sh 로 worktree 동기화 미수행, (2) 다른 cwd(main repo)에서 state.json 을 조작하여 worktree 사본과 불일치, (3) state.json 손상.`,
|
|
2788
|
-
|
|
2670
|
+
// 안내에 **절대경로를 싣지 않는다.** 리더가 그 경로를 명령에 넣어 실행하면
|
|
2671
|
+
// primary 세션이 external_directory 승인을 사용자에게 묻는다. 아래 명령은
|
|
2672
|
+
// 전부 cwd 기준으로 동작하므로 경로 인자가 필요 없다.
|
|
2673
|
+
next_action: `동기화는 이미 1회 자동 시도했고 실패했습니다 — 같은 동기화를 직접 실행하지 마세요. ` +
|
|
2674
|
+
`① 먼저 'bash ${SCRIPTS_DIR}/state.sh status ${args.issue}' 로 존재/유효성을 확인하세요 ` +
|
|
2789
2675
|
`(승인된 읽기 명령입니다 — 훅이 차단하지 않습니다). ` +
|
|
2790
|
-
`② exists=false 면 'bash ${SCRIPTS_DIR}/
|
|
2791
|
-
`
|
|
2676
|
+
`② exists=false 면 'bash ${SCRIPTS_DIR}/state.sh init ${args.issue}' 로 초기화하세요 ` +
|
|
2677
|
+
`(경로 인자 없이 — cwd 의 git toplevel 을 자동으로 씁니다). ` +
|
|
2792
2678
|
`③ readable=false (JSON 손상) 면 사용자에게 에스컬레이션하세요. ` +
|
|
2793
2679
|
`근본 원인이 해소되기 전에는 dispatch_stage 를 호출하지 마세요.`,
|
|
2794
2680
|
});
|