makdoong2-team 1.3.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/LICENSE +21 -0
- package/README.md +193 -0
- package/agents/makdoong2-analyzer.md +135 -0
- package/agents/makdoong2-engineer.md +165 -0
- package/agents/makdoong2-planner.md +267 -0
- package/agents/makdoong2-publisher.md +481 -0
- package/agents/makdoong2-team-leader.md +249 -0
- package/agents/makdoong2-verifier.md +353 -0
- package/assets/makdoong2-team.default.json +46 -0
- package/assets/makdoong2-team.schema.json +357 -0
- package/bin/cli.js +404 -0
- package/dist/agent-stage-config.d.ts +15 -0
- package/dist/agent-stage-config.js +119 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +96 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +131 -0
- package/dist/mcp-secret-injector.d.ts +56 -0
- package/dist/mcp-secret-injector.js +89 -0
- package/dist/model-chain-cli.d.ts +1 -0
- package/dist/model-chain-cli.js +21 -0
- package/dist/model-fallback-policy.d.ts +69 -0
- package/dist/model-fallback-policy.js +211 -0
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +2457 -0
- package/dist/poll-sub-session.d.ts +139 -0
- package/dist/poll-sub-session.js +494 -0
- package/dist/redact-secrets.d.ts +3 -0
- package/dist/redact-secrets.js +68 -0
- package/dist/session-index.d.ts +11 -0
- package/dist/session-index.js +71 -0
- package/dist/skill-mcp-registry.d.ts +59 -0
- package/dist/skill-mcp-registry.js +178 -0
- package/dist/stall-escalation.d.ts +1 -0
- package/dist/stall-escalation.js +22 -0
- package/dist/tmux-monitor.d.ts +193 -0
- package/dist/tmux-monitor.js +694 -0
- package/dist/verdict-hash.d.ts +1 -0
- package/dist/verdict-hash.js +62 -0
- package/gates/stage-analysis-verify.sh +84 -0
- package/gates/stage2-requirements-verify.sh +13 -0
- package/gates/stage3-scope-verify.sh +45 -0
- package/gates/stage4-dev-post-verify.sh +64 -0
- package/gates/stage4-dev-verify.sh +36 -0
- package/gates/stage5-coverage-verify.sh +36 -0
- package/gates/stage5-test-verify.sh +24 -0
- package/gates/stage6-commit-verify.sh +41 -0
- package/gates/stage6-post-commit-verify.sh +131 -0
- package/gates/stage7-post-pr-verify.sh +53 -0
- package/gates/stage7-pr-verify.sh +48 -0
- package/gates/stage8-post-review-verify.sh +84 -0
- package/gates/stage8-review-verify.sh +45 -0
- package/gates/verify.sh +44 -0
- package/opencode.json.example +40 -0
- package/package.json +84 -0
- package/postinstall.mjs +56 -0
- package/references/commit-convention.md +130 -0
- package/references/jira-issue-templates.md +203 -0
- package/references/pr-template.md +381 -0
- package/scripts/config.sh +46 -0
- package/scripts/coverage-record.sh +67 -0
- package/scripts/gate-policy-test.sh +152 -0
- package/scripts/install-lib.mjs +1029 -0
- package/scripts/lint-agent-prompts.sh +74 -0
- package/scripts/log-event.sh +44 -0
- package/scripts/model-policy.mjs +183 -0
- package/scripts/publish-if-changed.sh +207 -0
- package/scripts/release.sh +276 -0
- package/scripts/rollback-commits.sh +35 -0
- package/scripts/smoke-test.mjs +194 -0
- package/scripts/state.sh +192 -0
- package/scripts/test-postinstall.mjs +141 -0
- package/scripts/with-fallback.sh +56 -0
- package/scripts/wt-sync-ignored.sh +193 -0
- package/skills/_lib/load-secret.sh +149 -0
- package/skills/bamboo-ci/SKILL.md +81 -0
- package/skills/bamboo-ci/run-bamboo.sh +23 -0
- package/skills/bitbucket-research/SKILL.md +87 -0
- package/skills/bitbucket-research/run-repos.sh +23 -0
- package/skills/confluence-research/SKILL.md +75 -0
- package/skills/confluence-research/run-docs.sh +23 -0
- package/skills/github-oss-research/SKILL.md +59 -0
- package/skills/jira-research/SKILL.md +75 -0
- package/skills/jira-research/run-works.sh +23 -0
- package/src/hooks/guard-bash.sh +67 -0
- package/src/hooks/session-start.sh +96 -0
- package/src/hooks/sync-state.sh +47 -0
- package/stages/01-jira.md +43 -0
- package/stages/01-planning.md +229 -0
- package/stages/02-requirements.md +298 -0
- package/stages/03-scope.md +81 -0
- package/stages/04-analysis.md +281 -0
- package/stages/05-worktree-dev.md +124 -0
- package/stages/06-test.md +161 -0
- package/stages/07-commit.md +229 -0
- package/stages/08-pr.md +177 -0
- package/stages/09-review-comments.md +277 -0
|
@@ -0,0 +1,2457 @@
|
|
|
1
|
+
// opencode-plugin.ts — makdoong2-team opencode plugin (omo-free).
|
|
2
|
+
//
|
|
3
|
+
// Replaces oh-my-openagent's responsibilities for the workflow pipeline:
|
|
4
|
+
// 1. Hook bridging — reuse existing guard-bash.sh / sync-state.sh / verify.sh
|
|
5
|
+
// via opencode's tool.execute.before / .after events.
|
|
6
|
+
// 2. Stage gate + agent dispatch — `verify_stage` checks entry gates; `dispatch_stage`
|
|
7
|
+
// spawns the per-stage sub-agent (막둥이) via client.session.create/prompt SDK API.
|
|
8
|
+
// 3. Model fallback advisor — expose a `get_fallback_model` tool so agents can
|
|
9
|
+
// self-switch on rate limits / 5xx without omo's wrapper.
|
|
10
|
+
// 4. Per-agent permission enforcement — agent frontmatter `permission:` is the primary
|
|
11
|
+
// layer; guard-bash.sh (secondary) handles push gates and destructive commands.
|
|
12
|
+
// Note: @opencode-ai/plugin hook input does NOT include the calling agent ID
|
|
13
|
+
// (input shape: { tool, sessionID, callID }), so sub-agent identity checks at
|
|
14
|
+
// hook level are not possible. Rely on frontmatter permissions.
|
|
15
|
+
//
|
|
16
|
+
// Install path (default): ~/.config/opencode/plugins/makdoong2-team/
|
|
17
|
+
// Place this file at ~/.config/opencode/plugins/makdoong2-team/src/opencode-plugin.ts
|
|
18
|
+
// (or load the npm package via opencode.json "plugin": ["./plugins/makdoong2-team/src/opencode-plugin.ts"]).
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, resolve as pathResolve, sep as pathSep } from "node:path";
|
|
22
|
+
import { tool } from "@opencode-ai/plugin";
|
|
23
|
+
import { appendSessionIndex, findWorktreeRoot, lookupSessionFromIndex } from "./session-index.js";
|
|
24
|
+
import { computeVerdictHash } from "./verdict-hash.js";
|
|
25
|
+
import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
|
|
26
|
+
import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
|
|
27
|
+
import { shouldEscalateStall } from "./stall-escalation.js";
|
|
28
|
+
import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
|
|
29
|
+
import { resolvePaths, loadConfig, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
|
|
30
|
+
import { scanSkillMcpRegistry, extractMcpName, looksLikeMcpNotFound, looksLikeMcpConnectionFailed, } from "./skill-mcp-registry.js";
|
|
31
|
+
import { injectAllSecrets } from "./mcp-secret-injector.js";
|
|
32
|
+
import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
|
|
33
|
+
import { logger } from "./logger.js";
|
|
34
|
+
import { redactAndTruncate } from "./redact-secrets.js";
|
|
35
|
+
// All runtime paths come from makdoong2-team.json (paths.* overrides) or are
|
|
36
|
+
// derived from the opencode config dir. No MAKDOONG2 environment variables.
|
|
37
|
+
const { hooks: HOOKS_DIR, gates: GATES_DIR, scripts: SCRIPTS_DIR, stages: STAGES_DIR, skills: SKILLS_DIR, } = resolvePaths();
|
|
38
|
+
// Console output is centralized through the `logger` module. Level is
|
|
39
|
+
// controlled by makdoong2-team.json .logging.level (default "error"; set to
|
|
40
|
+
// "debug" or "trace" for full audit trail).
|
|
41
|
+
// ── OMO tmux 관리 여부 감지 ─────────────────────────────────────────────────
|
|
42
|
+
// 배경: oh-my-openagent(OMO) 가 opencode.json plugin 배열에 존재하면
|
|
43
|
+
// (문자열이든 튜플이든) OMO 가 직접 pane 을 관리하도록 위임하고
|
|
44
|
+
// makdoong2-team 은 pane spawn 을 완전히 skip 한다.
|
|
45
|
+
//
|
|
46
|
+
// 이력:
|
|
47
|
+
// v1 (detectOmoActive): "OMO 이름이 있으면 skip" — 그러나 문자열 등록 시
|
|
48
|
+
// OMO tmux 가 dormant 여서 아무도 pane 을 만들지 않는 vacuum 이 됐다.
|
|
49
|
+
// v2 (isOmoTmuxManaged, 튜플+enabled=true 감지): "enabled=true 명시 시만 skip"
|
|
50
|
+
// — 그러나 Opt 3 liveness false-positive 가 같은 세션에 pane 을 2개 spawn
|
|
51
|
+
// 하는 부작용을 낳았고, 근본 해결을 위해 단순화로 회귀한다.
|
|
52
|
+
// v3 (현재): OMO 존재 자체를 감지 → skip. OMO 가 없는 환경에서만 우리가 spawn.
|
|
53
|
+
// - OMO 설치: OMO 가 sub-session pane 을 책임짐 (중복 제거)
|
|
54
|
+
// - OMO 미설치: makdoong2-team 이 spawn (vacuum 없음)
|
|
55
|
+
export function isOmoTmuxManaged(pluginEntries) {
|
|
56
|
+
try {
|
|
57
|
+
let entries = pluginEntries;
|
|
58
|
+
if (!entries) {
|
|
59
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
60
|
+
const configDir = xdg && xdg.trim()
|
|
61
|
+
? `${xdg}/opencode`
|
|
62
|
+
: join(homedir(), ".config", "opencode");
|
|
63
|
+
const configPath = `${configDir}/opencode.json`;
|
|
64
|
+
if (!existsSync(configPath))
|
|
65
|
+
return false;
|
|
66
|
+
const raw = readFileSync(configPath, "utf8");
|
|
67
|
+
const cfg = JSON.parse(raw);
|
|
68
|
+
entries = cfg.plugin;
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(entries))
|
|
71
|
+
return false;
|
|
72
|
+
return entries.some((p) => {
|
|
73
|
+
const name = typeof p === "string"
|
|
74
|
+
? p
|
|
75
|
+
: Array.isArray(p) && typeof p[0] === "string" ? p[0] : null;
|
|
76
|
+
return typeof name === "string" && name.includes("oh-my-openagent");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const OMO_TMUX_MANAGED = isOmoTmuxManaged();
|
|
84
|
+
if (OMO_TMUX_MANAGED) {
|
|
85
|
+
logger.debug("[makdoong2-team] oh-my-openagent detected — " +
|
|
86
|
+
"skipping our own tmux pane spawn to avoid duplicates. OMO will manage sub-agent panes; " +
|
|
87
|
+
"makdoong2-team continues to own dispatch/gate/state logic.");
|
|
88
|
+
}
|
|
89
|
+
// Pure predicate — dispatch_stage 의 empty-output override 결정 로직.
|
|
90
|
+
// 로컬 LLM(qwen 계열 등)이 tool_call 로 작업을 마치고 최종 text 요약을
|
|
91
|
+
// 생성하지 않으면 pollSubSession 은 kind:"empty" 로 판정하지만 실제 작업은
|
|
92
|
+
// 완료된 경우가 많다. sub-agent 가 state.json 의 `.done=true` 마커를
|
|
93
|
+
// 기록했다면 완료 신호로 간주하고 성공으로 override 한다. state.sh runtime
|
|
94
|
+
// guard 가 state.json 직접 편집을 차단하고 sealed subagent 규약이 자기
|
|
95
|
+
// stage 만 done 처리를 허용하므로 false positive 위험이 낮다.
|
|
96
|
+
export function shouldOverrideEmptyOutcome(outcomeKind, currentSuccess, doneMarkerValue) {
|
|
97
|
+
return !currentSuccess && outcomeKind === "empty" && doneMarkerValue === "true";
|
|
98
|
+
}
|
|
99
|
+
// dispatch_stage 의 session_gone-override 결정 로직.
|
|
100
|
+
// pollSubSession 이 kind:"session_gone" 을 반환했을 때, sub-agent 가 이미
|
|
101
|
+
// state.json 의 `.done=true` 마커를 기록했다면 실제 작업은 완료되고
|
|
102
|
+
// 세션 tail 만 사라진 것으로 간주해 성공으로 override 한다. state.sh runtime
|
|
103
|
+
// guard 와 sealed subagent 규약이 done 마커 위조를 차단하므로 false positive
|
|
104
|
+
// 위험은 empty override 와 동일한 수준이다. shouldOverrideEmptyOutcome 과
|
|
105
|
+
// 별도 함수로 유지해 kind 별 재정의를 독립적으로 확장 가능하게 한다.
|
|
106
|
+
export function shouldOverrideSessionGoneOutcome(outcomeKind, currentSuccess, doneMarkerValue) {
|
|
107
|
+
return !currentSuccess && outcomeKind === "session_gone" && doneMarkerValue === "true";
|
|
108
|
+
}
|
|
109
|
+
const STAGE_ORDER = [
|
|
110
|
+
"1_planning.jira",
|
|
111
|
+
"1_planning.requirements",
|
|
112
|
+
"1_planning.scope",
|
|
113
|
+
"2_implementation.analysis",
|
|
114
|
+
"2_implementation.dev",
|
|
115
|
+
"2_implementation.test",
|
|
116
|
+
"3_delivery.commit",
|
|
117
|
+
"3_delivery.pr",
|
|
118
|
+
"3_delivery.review",
|
|
119
|
+
];
|
|
120
|
+
function nextStage(current) {
|
|
121
|
+
if (current === null)
|
|
122
|
+
return STAGE_ORDER[0];
|
|
123
|
+
const idx = STAGE_ORDER.indexOf(current);
|
|
124
|
+
return idx < 0 || idx + 1 >= STAGE_ORDER.length ? null : STAGE_ORDER[idx + 1];
|
|
125
|
+
}
|
|
126
|
+
function stageJqPath(stage) {
|
|
127
|
+
const dot = stage.indexOf(".");
|
|
128
|
+
if (dot < 0)
|
|
129
|
+
return `.stages."${stage}"`;
|
|
130
|
+
const phase = stage.slice(0, dot);
|
|
131
|
+
const substage = stage.slice(dot + 1);
|
|
132
|
+
return `.stages."${phase}".substages."${substage}"`;
|
|
133
|
+
}
|
|
134
|
+
// dev substage 프롬프트용 draft_path Read 스니펫.
|
|
135
|
+
// 3-step (legacy 절대경로→상대 마이그레이션 / root() 해석 / 존재 검증) 로직은
|
|
136
|
+
// opencode 1.4.17 Read tool 의 worktree 밖 절대경로 hang 을 원천 차단하기 위함이다.
|
|
137
|
+
// 이 로직을 단순 raw read 로 회귀시키면 hang 버그가 재발한다.
|
|
138
|
+
// 배경: .sisyphus/plans/2026-07-27-worktree-path-refactor.md
|
|
139
|
+
function buildDraftPathReadSnippet(issue, indent = "") {
|
|
140
|
+
const jq = `.stages."1_planning".substages."requirements".draft_path`;
|
|
141
|
+
const lines = [
|
|
142
|
+
`DRAFT_REL=$(bash ${SCRIPTS_DIR}/state.sh get ${issue} '${jq}' | tr -d '"')`,
|
|
143
|
+
`# Legacy 절대경로 감지 시 상대경로로 자동 마이그레이션 (idempotent)`,
|
|
144
|
+
`if [ -n "$DRAFT_REL" ] && [ "$DRAFT_REL" != "null" ] && [[ "$DRAFT_REL" == /* ]] && [[ "$DRAFT_REL" == */.makdoong2-team/* ]]; then`,
|
|
145
|
+
` DRAFT_REL=".makdoong2-team/\${DRAFT_REL##*/.makdoong2-team/}"`,
|
|
146
|
+
` bash ${SCRIPTS_DIR}/state.sh set ${issue} '${jq}' "\\"$DRAFT_REL\\""`,
|
|
147
|
+
`fi`,
|
|
148
|
+
`# 상대경로를 root() 기준으로 절대경로 해석`,
|
|
149
|
+
`if [ -n "$DRAFT_REL" ] && [ "$DRAFT_REL" != "null" ]; then`,
|
|
150
|
+
` if [[ "$DRAFT_REL" == /* ]]; then DRAFT_PATH="$DRAFT_REL"; else DRAFT_PATH="$(bash ${SCRIPTS_DIR}/state.sh root)/$DRAFT_REL"; fi`,
|
|
151
|
+
` [ -f "$DRAFT_PATH" ] && [ -s "$DRAFT_PATH" ] && Read "$DRAFT_PATH"`,
|
|
152
|
+
`fi`,
|
|
153
|
+
];
|
|
154
|
+
return lines.map((l) => indent + l).join("\n");
|
|
155
|
+
}
|
|
156
|
+
async function createWorktree($, issue, cwd, wtLogger) {
|
|
157
|
+
const branchName = `feature/${issue}`;
|
|
158
|
+
wtLogger?.info?.(`[createWorktree] ENTER issue=${issue} cwd=${cwd} branch=${branchName}`);
|
|
159
|
+
const mainRepoResult = await $ `git worktree list --porcelain`
|
|
160
|
+
.cwd(cwd).quiet().nothrow();
|
|
161
|
+
if (mainRepoResult.exitCode !== 0) {
|
|
162
|
+
wtLogger?.error?.(`[createWorktree] FAIL issue=${issue} reason="git worktree list exit=${mainRepoResult.exitCode}"`);
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: "git worktree list 실패",
|
|
166
|
+
hint: "git repository가 아니거나 worktree 기능을 사용할 수 없습니다.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const worktreeListOutput = mainRepoResult.stdout?.toString() || "";
|
|
170
|
+
const lines = worktreeListOutput.split("\n");
|
|
171
|
+
let mainRepo = "";
|
|
172
|
+
let existingWorktreePath = "";
|
|
173
|
+
// Parse worktree list --porcelain
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const line = lines[i].trim();
|
|
176
|
+
if (line.startsWith("worktree ")) {
|
|
177
|
+
const path = line.replace(/^worktree\s+/, "");
|
|
178
|
+
if (!mainRepo) {
|
|
179
|
+
mainRepo = path; // 첫 항목이 메인 repo
|
|
180
|
+
}
|
|
181
|
+
// 브랜치 체크 (다음 줄)
|
|
182
|
+
if (i + 1 < lines.length) {
|
|
183
|
+
const nextLine = lines[i + 1].trim();
|
|
184
|
+
if (nextLine === `branch refs/heads/${branchName}`) {
|
|
185
|
+
existingWorktreePath = path;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!mainRepo) {
|
|
191
|
+
wtLogger?.error?.(`[createWorktree] FAIL issue=${issue} reason="main repo not parsed from worktree list"`);
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
error: "메인 repo 식별 실패",
|
|
195
|
+
hint: "git worktree list 출력을 파싱할 수 없습니다.",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const path = require("node:path");
|
|
199
|
+
const parentDir = path.dirname(mainRepo);
|
|
200
|
+
const repoName = path.basename(mainRepo);
|
|
201
|
+
const targetWorktree = path.join(parentDir, `${repoName}-${issue}`);
|
|
202
|
+
if (existingWorktreePath) {
|
|
203
|
+
if (existingWorktreePath === targetWorktree) {
|
|
204
|
+
if (!existsSync(targetWorktree)) {
|
|
205
|
+
// git metadata 는 남아있지만 실제 디렉토리가 삭제된 phantom 상태.
|
|
206
|
+
// prune 으로 stale entry 를 제거한 뒤 아래 addCommand 로 재생성한다.
|
|
207
|
+
wtLogger?.info?.(`[createWorktree] PHANTOM_REUSED — dir gone, pruning and recreating ` +
|
|
208
|
+
`issue=${issue} path=${targetWorktree}`);
|
|
209
|
+
await $ `git worktree prune`.cwd(cwd).quiet().nothrow();
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
wtLogger?.info?.(`[createWorktree] REUSED issue=${issue} path=${targetWorktree} branch=${branchName}`);
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
path: targetWorktree,
|
|
216
|
+
reused: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
wtLogger?.error?.(`[createWorktree] FAIL issue=${issue} reason="branch ${branchName} already checked out elsewhere" ` +
|
|
222
|
+
`existing=${existingWorktreePath} target=${targetWorktree}`);
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
error: `브랜치 '${branchName}'가 이미 다른 worktree에서 사용 중`,
|
|
226
|
+
hint: `기존 경로: ${existingWorktreePath}\n수동 정리: git worktree remove "${existingWorktreePath}" 또는 해당 경로 재사용`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const branchExistsResult = await $ `git show-ref --verify --quiet refs/heads/${branchName}`
|
|
231
|
+
.cwd(cwd).quiet().nothrow();
|
|
232
|
+
const branchExists = branchExistsResult.exitCode === 0;
|
|
233
|
+
const addCommand = branchExists
|
|
234
|
+
? $ `git worktree add ${targetWorktree} ${branchName}`.cwd(cwd).quiet().nothrow()
|
|
235
|
+
: $ `git worktree add ${targetWorktree} -b ${branchName}`.cwd(cwd).quiet().nothrow();
|
|
236
|
+
const addResult = await addCommand;
|
|
237
|
+
if (addResult.exitCode !== 0) {
|
|
238
|
+
wtLogger?.error?.(`[createWorktree] FAIL issue=${issue} target=${targetWorktree} branch=${branchName} ` +
|
|
239
|
+
`branch_existed=${branchExists} exit=${addResult.exitCode} stderr=${redactAndTruncate(addResult.stderr?.toString() ?? "", 200)}`);
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: "git worktree add 실패",
|
|
243
|
+
hint: addResult.stderr?.toString() || "알 수 없는 오류",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
wtLogger?.info?.(`[createWorktree] CREATED issue=${issue} path=${targetWorktree} branch=${branchName} ` +
|
|
247
|
+
`branch_existed=${branchExists}`);
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
path: targetWorktree,
|
|
251
|
+
reused: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export function looksLikeFileWrite(cmd) {
|
|
255
|
+
if (/\.makdoong2-team\/[^/\s]+\/state\.json/.test(cmd) && !/state\.sh\s+(get|set|init)/.test(cmd))
|
|
256
|
+
return true;
|
|
257
|
+
if (/^\s*(git\s+(commit|push|add|rm|status|log|diff|show|branch|checkout|fetch|worktree|config|remote))/i.test(cmd))
|
|
258
|
+
return false;
|
|
259
|
+
if (/state\.sh\s+(set|init|get|issue|root|update)/.test(cmd))
|
|
260
|
+
return false;
|
|
261
|
+
if (/(^|[|&;])\s*(tee|dd)\b/.test(cmd))
|
|
262
|
+
return true;
|
|
263
|
+
if (/\bsed\b[^|;&]*\s-i(?:\b|['"])/.test(cmd))
|
|
264
|
+
return true;
|
|
265
|
+
if (/\bawk\b[^|;&]*?(?<![0-9])>\s*(?![&/])\S/.test(cmd))
|
|
266
|
+
return true;
|
|
267
|
+
if (/(^|[|;&\s])(cat|printf|echo)\b[^|;&]*?(<<\S+.*?)?(?<![0-9])>\s*(?![&/])\S/.test(cmd))
|
|
268
|
+
return true;
|
|
269
|
+
if (/(^|[|&;\s])>\s*(?!&|\/dev\/(?:null|stderr|stdout|tty)\b)\S/.test(cmd))
|
|
270
|
+
return true;
|
|
271
|
+
if (/(^|[|&;\s])>>\s*(?!\/dev\/(?:null|stderr|stdout|tty)\b)\S/.test(cmd))
|
|
272
|
+
return true;
|
|
273
|
+
if (/\bpython3?\s+-c\s+["'][^"']*open\s*\([^)]*["']\s*,\s*["'][wa]/.test(cmd))
|
|
274
|
+
return true;
|
|
275
|
+
if (/\bnode\s+-e\s+["'].*?(?:writeFileSync|writeFile\b|appendFileSync|createWriteStream)/.test(cmd))
|
|
276
|
+
return true;
|
|
277
|
+
if (/(^|[|&;])\s*(cp|mv)\s/.test(cmd))
|
|
278
|
+
return true;
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
export function looksLikeSealedStateWrite(cmd) {
|
|
282
|
+
return /\.makdoong2-team\/[^/\s]+\/state\.json/.test(cmd)
|
|
283
|
+
&& !/state\.sh\s+(get|set|init|issue|root|update)/.test(cmd);
|
|
284
|
+
}
|
|
285
|
+
export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) => {
|
|
286
|
+
const cwd = worktree || directory || ".";
|
|
287
|
+
const config = loadConfig();
|
|
288
|
+
applyConfigOverrides(config.agents, config.model_policy);
|
|
289
|
+
// Sub-session monitor — splits a tmux pane next to 부장님 for each spawned
|
|
290
|
+
// 막둥이 when invoked inside tmux with tmux.enabled=true in makdoong2-team.json.
|
|
291
|
+
// No-op otherwise, so non-tmux runs are unaffected.
|
|
292
|
+
const tmuxCfg = readTmuxConfig(config.tmux);
|
|
293
|
+
if (!tmuxCfg.serverUrl) {
|
|
294
|
+
const clientUrl = client?.baseURL
|
|
295
|
+
?? client?._options?.baseURL
|
|
296
|
+
?? client?._client?.baseURL
|
|
297
|
+
?? undefined;
|
|
298
|
+
if (clientUrl) {
|
|
299
|
+
try {
|
|
300
|
+
tmuxCfg.serverUrl = new URL(clientUrl).origin;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
tmuxCfg.serverUrl = clientUrl;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const tmuxMonitor = new TmuxMonitor($, tmuxCfg);
|
|
308
|
+
if (tmuxMonitor.active) {
|
|
309
|
+
(async () => {
|
|
310
|
+
try {
|
|
311
|
+
await tmuxMonitor.checkTmuxVersion();
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
logger.error(`[tmux-monitor] ${e.message}`);
|
|
315
|
+
throw e;
|
|
316
|
+
}
|
|
317
|
+
const reaped = await tmuxMonitor.reapDeadOwnerPanes();
|
|
318
|
+
if (reaped > 0) {
|
|
319
|
+
logger.debug(`[tmux-monitor] reaped ${reaped} orphan pane(s) from previous plugin instance(s)`);
|
|
320
|
+
}
|
|
321
|
+
})().catch((e) => {
|
|
322
|
+
logger.error(`[tmux-monitor] init failed: ${e.message}`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const subSessionIds = new Set();
|
|
326
|
+
// session.abort() 후 지연 삭제 대기 큐. key=sid, value=abortedAt(ms).
|
|
327
|
+
// session.delete 즉시 호출 시 opencode 내부 event handler(opencode-auto-session-export 등)가
|
|
328
|
+
// 아직 세션에 접근 중일 수 있어 NotFoundError가 team-leader runLoop까지 전파된다.
|
|
329
|
+
// orphanScanTimer(60초 주기)가 DELETE_GRACE_MS 경과 후 안전하게 삭제한다.
|
|
330
|
+
const pendingDelete = new Map();
|
|
331
|
+
// makdoong2-team sub-session title 패턴 (dispatch_stage/dispatch_verifier 공통).
|
|
332
|
+
// 형식: `${stage} (@makdoong2-${agent})` 또는 `verifier:${stage} (@makdoong2-verifier)`
|
|
333
|
+
const SUB_SESSION_TITLE_PATTERN = /\(@makdoong2-/;
|
|
334
|
+
// 지속적 감시 주기. 5분(이전값)은 hang 감지가 너무 늦었고, poll-sub-session
|
|
335
|
+
// deadline(기본 30분)과 겹쳐 정리가 필요한 시점에도 다음 tick 까지 대기해야 했다.
|
|
336
|
+
// 60초로 단축해 timeout 직후 zombie session/pane 을 즉시 회수한다. 짧은
|
|
337
|
+
// interval 이지만 session.status() 는 in-memory 조회이므로 부하가 낮다.
|
|
338
|
+
const ORPHAN_SCAN_INTERVAL_MS = 60_000;
|
|
339
|
+
const orphanScanTimer = setInterval(async () => {
|
|
340
|
+
try {
|
|
341
|
+
const staleThresholdMs = substageTimeoutMs + 10 * 60_000;
|
|
342
|
+
const statusResult = await client.session.status().catch(() => null);
|
|
343
|
+
const statuses = (statusResult?.data ?? {});
|
|
344
|
+
const nowMs = Date.now();
|
|
345
|
+
// 정리 대상 sid → { reason, sessionGone } 매핑.
|
|
346
|
+
// sessionGone=true 는 opencode 세션이 이미 사라진 경우로, session.abort/delete
|
|
347
|
+
// 호출 시 부모 세션(team-leader) 에 NotFoundError 이벤트가 fire 되어 hang 을
|
|
348
|
+
// 유발하므로 tmux pane 만 kill 하고 session ops 는 skip 해야 한다.
|
|
349
|
+
const toCleanup = new Map();
|
|
350
|
+
for (const [sid, pd] of pendingDispatch) {
|
|
351
|
+
if (statuses[sid]?.type !== "busy")
|
|
352
|
+
continue;
|
|
353
|
+
const elapsedMs = nowMs - pd.startedAt;
|
|
354
|
+
if (elapsedMs > staleThresholdMs) {
|
|
355
|
+
toCleanup.set(sid, {
|
|
356
|
+
reason: `stale busy ${Math.round(elapsedMs / 60_000)}min stage=${pd.stage}`,
|
|
357
|
+
sessionGone: false,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const sid of subSessionIds) {
|
|
362
|
+
if (pendingDispatch.has(sid))
|
|
363
|
+
continue;
|
|
364
|
+
if (statuses[sid]?.type === "busy") {
|
|
365
|
+
toCleanup.set(sid, {
|
|
366
|
+
reason: "orphan busy (no pending dispatch entry)",
|
|
367
|
+
sessionGone: false,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const tmuxOrphans = await tmuxMonitor.scanOrphans().catch(() => []);
|
|
372
|
+
for (const p of tmuxOrphans) {
|
|
373
|
+
const sid = p.sessionId;
|
|
374
|
+
if (toCleanup.has(sid))
|
|
375
|
+
continue;
|
|
376
|
+
if (pendingDispatch.has(sid))
|
|
377
|
+
continue;
|
|
378
|
+
// status=undefined 는 세션 소멸이 아니라 정상 상태일 수 있다. session.status()
|
|
379
|
+
// 는 요청 디렉토리 스코프로 필터링되므로 worktree 에서 생성된 서브세션은
|
|
380
|
+
// 부모의 status map 에 영구히 나타나지 않는다.
|
|
381
|
+
const guard = orphanCleanupGuard(p, {
|
|
382
|
+
nowMs,
|
|
383
|
+
ownerPid: tmuxMonitor.ownerProcessId,
|
|
384
|
+
lastToolExecuteAtMs: sessionLastToolExecuteAt.get(sid),
|
|
385
|
+
activeToolCount: sessionActiveToolCount.get(sid),
|
|
386
|
+
toolAliveWindowMs: TOOL_EXECUTE_ALIVE_WINDOW_MS,
|
|
387
|
+
});
|
|
388
|
+
if (guard) {
|
|
389
|
+
logger.debug(`[orphan-scan] skip sid=${sid} pane=${p.paneId} guard=${guard}`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const s = statuses[sid]?.type;
|
|
393
|
+
if (s === "idle") {
|
|
394
|
+
toCleanup.set(sid, {
|
|
395
|
+
reason: "tmux pane orphan (opencode session status=idle)",
|
|
396
|
+
sessionGone: false,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
else if (s === undefined) {
|
|
400
|
+
toCleanup.set(sid, {
|
|
401
|
+
reason: "tmux pane orphan (opencode session status=gone)",
|
|
402
|
+
sessionGone: true,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const [sid, { reason, sessionGone }] of toCleanup) {
|
|
407
|
+
if (sessionGone) {
|
|
408
|
+
logger.debug(`[orphan-scan] pane-kill-only (session gone) sid=${sid} reason=${reason}`);
|
|
409
|
+
await tmuxMonitor.forceKillBySessionId(sid).catch(() => undefined);
|
|
410
|
+
sessionIssue.delete(sid);
|
|
411
|
+
pendingDispatch.delete(sid);
|
|
412
|
+
subSessionIds.delete(sid);
|
|
413
|
+
sessionLastToolExecuteAt.delete(sid);
|
|
414
|
+
sessionActiveToolCount.delete(sid);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
logger.debug(`[orphan-scan] cleaning sid=${sid} reason=${reason}`);
|
|
418
|
+
await cleanupSubSession(sid, { success: false, reason });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (toCleanup.size > 0) {
|
|
422
|
+
logger.debug(`[orphan-scan] cleaned ${toCleanup.size} orphan/stale session(s)`);
|
|
423
|
+
}
|
|
424
|
+
const DELETE_GRACE_MS = 60_000;
|
|
425
|
+
for (const [sid, abortedAt] of pendingDelete) {
|
|
426
|
+
if (nowMs - abortedAt < DELETE_GRACE_MS)
|
|
427
|
+
continue;
|
|
428
|
+
await client.session
|
|
429
|
+
.delete({ path: { id: sid } })
|
|
430
|
+
.catch(() => undefined);
|
|
431
|
+
pendingDelete.delete(sid);
|
|
432
|
+
logger.debug(`[orphan-scan] pendingDelete flushed sid=${sid}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
logger.warn(`[orphan-scan] scan error: ${e}`);
|
|
437
|
+
}
|
|
438
|
+
}, ORPHAN_SCAN_INTERVAL_MS);
|
|
439
|
+
orphanScanTimer.unref?.();
|
|
440
|
+
// ── Phantom busy scanner (10분 주기) ─────────────────────────────────────
|
|
441
|
+
// session.status()는 타임스탬프가 없어 "언제부터 busy인지" 알 수 없으므로
|
|
442
|
+
// session.list()의 Session.time.updated로 실질적 활동 여부를 판단한다.
|
|
443
|
+
// 임계값은 orphanScanTimer의 staleThresholdMs와 동일하게 유지한다 (느린 모델 고려).
|
|
444
|
+
const PHANTOM_BUSY_SCAN_INTERVAL_MS = 10 * 60_000;
|
|
445
|
+
const phantomBusyTimer = setInterval(async () => {
|
|
446
|
+
try {
|
|
447
|
+
const nowMs = Date.now();
|
|
448
|
+
const staleThresholdMs = substageTimeoutMs + 10 * 60_000;
|
|
449
|
+
const statusResult = await client.session.status().catch(() => null);
|
|
450
|
+
const statuses = (statusResult?.data ?? {});
|
|
451
|
+
const listResult = await client.session.list().catch(() => null);
|
|
452
|
+
const sessions = (listResult?.data ?? []);
|
|
453
|
+
for (const s of sessions) {
|
|
454
|
+
if (!SUB_SESSION_TITLE_PATTERN.test(s.title ?? ""))
|
|
455
|
+
continue;
|
|
456
|
+
if (pendingDispatch.has(s.id))
|
|
457
|
+
continue;
|
|
458
|
+
if (pendingDelete.has(s.id))
|
|
459
|
+
continue;
|
|
460
|
+
if (statuses[s.id]?.type !== "busy")
|
|
461
|
+
continue;
|
|
462
|
+
const staleSinceMs = nowMs - (s.time?.updated ?? 0);
|
|
463
|
+
if (staleSinceMs <= staleThresholdMs)
|
|
464
|
+
continue;
|
|
465
|
+
logger.warn(`[phantom-busy] sid=${s.id} title="${s.title}" ` +
|
|
466
|
+
`stale=${Math.round(staleSinceMs / 60_000)}min → cleanup`);
|
|
467
|
+
await cleanupSubSession(s.id, {
|
|
468
|
+
success: false,
|
|
469
|
+
reason: `phantom busy: time.updated ${Math.round(staleSinceMs / 60_000)}min ago`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
logger.warn(`[phantom-busy] scan error: ${e}`);
|
|
475
|
+
}
|
|
476
|
+
}, PHANTOM_BUSY_SCAN_INTERVAL_MS);
|
|
477
|
+
phantomBusyTimer.unref?.();
|
|
478
|
+
const runScript = async (dir, script, ...args) => {
|
|
479
|
+
const r = await $ `bash ${dir}/${script} ${args}`.cwd(cwd).quiet().nothrow();
|
|
480
|
+
return {
|
|
481
|
+
ok: r.exitCode === 0,
|
|
482
|
+
code: r.exitCode,
|
|
483
|
+
stdout: r.stdout?.toString() ?? "",
|
|
484
|
+
stderr: r.stderr?.toString() ?? "",
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
const runScriptCwd = async (runCwd, dir, script, ...args) => {
|
|
488
|
+
if (!existsSync(runCwd)) {
|
|
489
|
+
return { ok: false, code: -1, stdout: "", stderr: `worktree_path_missing: ${runCwd}` };
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const r = await $ `bash ${dir}/${script} ${args}`.cwd(runCwd).quiet().nothrow();
|
|
493
|
+
return {
|
|
494
|
+
ok: r.exitCode === 0,
|
|
495
|
+
code: r.exitCode,
|
|
496
|
+
stdout: r.stdout?.toString() ?? "",
|
|
497
|
+
stderr: r.stderr?.toString() ?? "",
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
502
|
+
return { ok: false, code: -1, stdout: "", stderr: msg };
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
// ── Helper: semantic gates beyond verify.sh (LLM-judgement markers). ──
|
|
506
|
+
const checkExtensionGates = async (issue, target, baseCwd = cwd) => {
|
|
507
|
+
if (target === "1_planning.requirements") {
|
|
508
|
+
const jiraValidationPath = `${stageJqPath("1_planning.jira")}.validation_passed`;
|
|
509
|
+
const r = await $ `bash ${SCRIPTS_DIR}/state.sh get ${issue} ${jiraValidationPath}`
|
|
510
|
+
.cwd(baseCwd).quiet().nothrow();
|
|
511
|
+
const passed = r.exitCode === 0 && r.stdout?.toString().trim() === "true";
|
|
512
|
+
if (!passed) {
|
|
513
|
+
const policy = await readPolicy(issue, baseCwd);
|
|
514
|
+
const autoApprove = policy?.auto_approve?.[target] ?? null;
|
|
515
|
+
if (autoApprove === true) {
|
|
516
|
+
return { ok: true };
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
reason: "MAKDOONG2-GATE BLOCKED [1_planning.requirements]: Jira 검증 미통과 — " +
|
|
521
|
+
"planner(jira substage)가 template_validation 6개 항목(content_template_match / " +
|
|
522
|
+
"content_quality_adequate / priority_set / assignee_set / reporter_set / " +
|
|
523
|
+
"fix_version_handled)을 모두 통과시키거나, 실패 항목에 대해 사용자 인터뷰 " +
|
|
524
|
+
`후 ${jiraValidationPath}=true 마커를 기록해야 함. ` +
|
|
525
|
+
"참조: references/jira-issue-templates.md",
|
|
526
|
+
marker_path: jiraValidationPath,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return { ok: true };
|
|
531
|
+
};
|
|
532
|
+
// ── Helper: read the issue-level work-categorization policy (.policy). ──
|
|
533
|
+
// Set by stage 2 (makdoong2-requirements). null/absent for legacy issues.
|
|
534
|
+
// category="major" → publisher generates change-report.md as an artifact (no approval gate).
|
|
535
|
+
const readPolicy = async (issue, baseCwd = cwd) => {
|
|
536
|
+
const r = await $ `bash ${SCRIPTS_DIR}/state.sh get ${issue} ${".policy"}`
|
|
537
|
+
.cwd(baseCwd).quiet().nothrow();
|
|
538
|
+
if (r.exitCode !== 0)
|
|
539
|
+
return null; // null/absent → state.sh get (jq -e) exits non-zero
|
|
540
|
+
try {
|
|
541
|
+
const v = JSON.parse(r.stdout?.toString().trim() || "null");
|
|
542
|
+
return v && typeof v === "object" ? v : null;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const substageTimeoutMs = Math.max(60_000, Math.round((config.timeout?.substage_minutes ?? 30) * 60_000));
|
|
549
|
+
const agentTimeoutMs = Object.fromEntries(Object.entries(config.timeout?.per_agent ?? {}).map(([agent, minutes]) => [
|
|
550
|
+
agent,
|
|
551
|
+
Math.max(60_000, Math.round(minutes * 60_000)),
|
|
552
|
+
]));
|
|
553
|
+
const getEffectiveTimeoutMs = (agentId) => agentTimeoutMs[agentId] ?? substageTimeoutMs;
|
|
554
|
+
// MAX_ATTEMPTS bounds retries *within* one dispatch_stage call. It cannot
|
|
555
|
+
// bound the orchestrator re-calling dispatch_stage after a failure, which
|
|
556
|
+
// resets the budget and produces an unbounded stall→redispatch loop. This
|
|
557
|
+
// threshold caps the accumulated hang_history across calls — the stall-path
|
|
558
|
+
// counterpart of same_reason_streak on the REJECTED-verdict path.
|
|
559
|
+
const stallEscalateThreshold = Math.max(1, Math.round(config.timeout?.stall_escalate_threshold ?? DEFAULT_STALL_ESCALATE_THRESHOLD));
|
|
560
|
+
const externalDirConfig = config.permission?.external_directory ?? {};
|
|
561
|
+
const configuredAllowPatterns = typeof externalDirConfig === "string"
|
|
562
|
+
? []
|
|
563
|
+
: Object.entries(externalDirConfig)
|
|
564
|
+
.filter(([, action]) => action === "allow")
|
|
565
|
+
.map(([pattern]) => pattern);
|
|
566
|
+
const sessionLastToolExecuteAt = new Map();
|
|
567
|
+
const sessionActiveToolCount = new Map();
|
|
568
|
+
const TOOL_EXECUTE_ALIVE_WINDOW_MS = 300_000;
|
|
569
|
+
// MESSAGE_STALL 후 client.session.abort() 는 즉시 반환하지만 opencode 서버는 잠시 후
|
|
570
|
+
// session.deleted 이벤트를 fire 한다. 그 사이(관측 최대 112s) sub-agent 가 tool call 을
|
|
571
|
+
// 계속 발사할 수 있어 좀비 실행이 발생한다. abort 직후 session.deleted 를 대기하는
|
|
572
|
+
// 헬퍼로 이 race window 를 닫는다. event 핸들러가 session.deleted 를 수신하면 이 map 의
|
|
573
|
+
// pending waiter 를 resolve 한다.
|
|
574
|
+
const sessionDeletedWaiters = new Map();
|
|
575
|
+
const waitForSessionDeleted = (sessionId, maxWaitMs) => {
|
|
576
|
+
return new Promise((resolve) => {
|
|
577
|
+
let done = false;
|
|
578
|
+
const settle = (deleted) => {
|
|
579
|
+
if (done)
|
|
580
|
+
return;
|
|
581
|
+
done = true;
|
|
582
|
+
const arr = sessionDeletedWaiters.get(sessionId);
|
|
583
|
+
if (arr) {
|
|
584
|
+
const idx = arr.indexOf(onDeleted);
|
|
585
|
+
if (idx >= 0)
|
|
586
|
+
arr.splice(idx, 1);
|
|
587
|
+
if (arr.length === 0)
|
|
588
|
+
sessionDeletedWaiters.delete(sessionId);
|
|
589
|
+
}
|
|
590
|
+
resolve(deleted);
|
|
591
|
+
};
|
|
592
|
+
const onDeleted = () => settle(true);
|
|
593
|
+
const arr = sessionDeletedWaiters.get(sessionId) ?? [];
|
|
594
|
+
arr.push(onDeleted);
|
|
595
|
+
sessionDeletedWaiters.set(sessionId, arr);
|
|
596
|
+
setTimeout(() => settle(false), maxWaitMs).unref?.();
|
|
597
|
+
});
|
|
598
|
+
};
|
|
599
|
+
const MESSAGE_STALL_BACKOFF_MS = [300_000, 600_000, 1_200_000];
|
|
600
|
+
const VERIFIER_STALL_THRESHOLD_MS = MESSAGE_STALL_BACKOFF_MS[MESSAGE_STALL_BACKOFF_MS.length - 1];
|
|
601
|
+
const SESSION_DELETED_WAIT_MS = 30_000;
|
|
602
|
+
const eventMaxChars = readLoggingConfig(config.logging).eventMaxChars;
|
|
603
|
+
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch"]);
|
|
604
|
+
const sessionWorktree = new Map();
|
|
605
|
+
const extractFilePathFromToolArgs = (args) => {
|
|
606
|
+
if (!args || typeof args !== "object")
|
|
607
|
+
return undefined;
|
|
608
|
+
const fp = args.filePath;
|
|
609
|
+
return typeof fp === "string" && fp.length > 0 ? fp : undefined;
|
|
610
|
+
};
|
|
611
|
+
const trackAndStageEngineerWrite = async (sessionID, filePath) => {
|
|
612
|
+
let wt = sessionWorktree.get(sessionID) ?? pendingDispatch.get(sessionID)?.worktree;
|
|
613
|
+
let issue = sessionIssue.get(sessionID);
|
|
614
|
+
if (!wt || !issue) {
|
|
615
|
+
const worktreeRoot = findWorktreeRoot(filePath);
|
|
616
|
+
if (worktreeRoot) {
|
|
617
|
+
const indexed = lookupSessionFromIndex(worktreeRoot, sessionID);
|
|
618
|
+
if (indexed) {
|
|
619
|
+
wt = wt ?? indexed.worktree;
|
|
620
|
+
issue = issue ?? indexed.issue;
|
|
621
|
+
if (wt)
|
|
622
|
+
sessionWorktree.set(sessionID, wt);
|
|
623
|
+
if (issue)
|
|
624
|
+
sessionIssue.set(sessionID, issue);
|
|
625
|
+
logger.debug(`[auto-git-add] recovered from index session=${sessionID} ` +
|
|
626
|
+
`wt=${wt} issue=${issue}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!wt || !issue) {
|
|
631
|
+
logger.debug(`[auto-git-add] skip session=${sessionID} reason=no_wt_or_issue wt=${wt ?? "N/A"} issue=${issue ?? "N/A"}`);
|
|
632
|
+
return { ok: true };
|
|
633
|
+
}
|
|
634
|
+
const resolvedWt = pathResolve(wt);
|
|
635
|
+
const resolvedFile = pathResolve(wt, filePath);
|
|
636
|
+
if (resolvedFile === resolvedWt) {
|
|
637
|
+
logger.debug(`[auto-git-add] skip session=${sessionID} reason=file_equals_wt file=${filePath}`);
|
|
638
|
+
return { ok: true };
|
|
639
|
+
}
|
|
640
|
+
if (!resolvedFile.startsWith(resolvedWt + pathSep)) {
|
|
641
|
+
logger.debug(`[auto-git-add] skip session=${sessionID} reason=outside_wt ` +
|
|
642
|
+
`file=${filePath} resolved=${resolvedFile} wt=${resolvedWt}`);
|
|
643
|
+
return { ok: true };
|
|
644
|
+
}
|
|
645
|
+
const relPath = resolvedFile.slice(resolvedWt.length + 1);
|
|
646
|
+
if (!relPath || relPath.startsWith(".git" + pathSep) || relPath.startsWith(".makdoong2-team" + pathSep)) {
|
|
647
|
+
logger.debug(`[auto-git-add] skip session=${sessionID} reason=excluded_path relPath=${relPath}`);
|
|
648
|
+
return { ok: true };
|
|
649
|
+
}
|
|
650
|
+
const addR = await $ `git -C ${resolvedWt} add -- ${relPath}`.quiet().nothrow();
|
|
651
|
+
if (addR.exitCode === 0) {
|
|
652
|
+
logger.debug(`[auto-git-add] session=${sessionID} file=${relPath} exit=0`);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
const stderrRaw = addR.stderr?.toString() ?? "";
|
|
656
|
+
logger.warn(`[auto-git-add] session=${sessionID} file=${relPath} exit=${addR.exitCode} ` +
|
|
657
|
+
`stderr=${redactAndTruncate(stderrRaw, 200)}`);
|
|
658
|
+
try {
|
|
659
|
+
const dir = `${resolvedWt}/.makdoong2-team/${issue}`;
|
|
660
|
+
mkdirSync(dir, { recursive: true });
|
|
661
|
+
appendFileSync(`${dir}/dev-written-files.txt`, `${relPath}\n`);
|
|
662
|
+
}
|
|
663
|
+
catch { /* ignore */ }
|
|
664
|
+
return { ok: false, relPath, error: stderrRaw.trim() || `git add exit=${addR.exitCode}` };
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
const dir = `${resolvedWt}/.makdoong2-team/${issue}`;
|
|
668
|
+
mkdirSync(dir, { recursive: true });
|
|
669
|
+
appendFileSync(`${dir}/dev-written-files.txt`, `${relPath}\n`);
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
logger.debug(`[auto-git-add] track-append failed session=${sessionID} file=${relPath} ` +
|
|
673
|
+
`error=${err.message}`);
|
|
674
|
+
}
|
|
675
|
+
return { ok: true, relPath };
|
|
676
|
+
};
|
|
677
|
+
const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) => pollSubSessionCore(client, sessionId, {
|
|
678
|
+
timeoutMs,
|
|
679
|
+
allowedWorktree,
|
|
680
|
+
configuredAllowPatterns,
|
|
681
|
+
logger: {
|
|
682
|
+
debug: (msg) => logger.debug(msg),
|
|
683
|
+
error: (msg) => logger.error(msg),
|
|
684
|
+
},
|
|
685
|
+
permissionCheckIntervalPolls: 1,
|
|
686
|
+
nudgeAtFraction: onNudge ? 0.8 : undefined,
|
|
687
|
+
onNudge,
|
|
688
|
+
messageStallThresholdMs,
|
|
689
|
+
contentStableCompletionMs: 300_000,
|
|
690
|
+
preambleOnlyTextThreshold: 200,
|
|
691
|
+
isRecentlyActive: () => {
|
|
692
|
+
if ((sessionActiveToolCount.get(sessionId) ?? 0) > 0)
|
|
693
|
+
return true;
|
|
694
|
+
const last = sessionLastToolExecuteAt.get(sessionId);
|
|
695
|
+
return typeof last === "number" && Date.now() - last < TOOL_EXECUTE_ALIVE_WINDOW_MS;
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
// ── sessionID → agent 매핑. chat.params hook에서 채우고, tool.execute.before
|
|
699
|
+
// hook에서 조회한다. 이 매핑을 통해 hook input에 없는 agent 식별을 우회한다.
|
|
700
|
+
// Primary agent(team-leader)만 직접 파일 쓰기를 차단하기 위한 최소 필요 조건.
|
|
701
|
+
const sessionAgent = new Map();
|
|
702
|
+
// ── sessionID → Jira Issue Key 매핑 ─────────────────────────────────────────
|
|
703
|
+
// 모든 워크플로우는 Jira Issue Key를 중심으로 설계된다. dispatch_stage /
|
|
704
|
+
// dispatch_verifier / auto_advance_stage 는 항상 args.issue 를 명시적으로
|
|
705
|
+
// 받으므로, 이를 sessionID에 바인딩해두면 guard-bash.sh / sync-state.sh 가
|
|
706
|
+
// git branch 이름이나 worktree 경로를 추론하지 않고 신뢰할 수 있는 ISSUE를
|
|
707
|
+
// 직접 전달받을 수 있다. 이 설계로 multi-worktree / 비-makdoong2-team 워크트리
|
|
708
|
+
// 오탐 문제가 근본적으로 해결된다.
|
|
709
|
+
const sessionIssue = new Map();
|
|
710
|
+
// ── skill_mcp lazy-load 방어 (mcp_name → skill_name 매핑).
|
|
711
|
+
// SKILL.md frontmatter 를 스캔해 각 MCP 서버가 어떤 skill 에 embedded 인지
|
|
712
|
+
// 캐시한다. `skill(name="...")` 로 skill 이 로드되기 전에는 opencode 가
|
|
713
|
+
// "MCP server ... not found" 로 튕기지만 어떤 skill 을 로드해야 하는지는
|
|
714
|
+
// 알려주지 않는다. 이 매핑으로 tool.execute.before / .after 훅이 정확한
|
|
715
|
+
// skill 이름을 사용자에게 지시하는 에러를 낼 수 있다.
|
|
716
|
+
// 스캔은 플러그인 초기화 시 한 번만 수행하며 skills 디렉토리가 없으면
|
|
717
|
+
// fail-open (빈 registry) 으로 폴백한다.
|
|
718
|
+
const skillMcpRegistry = scanSkillMcpRegistry(SKILLS_DIR);
|
|
719
|
+
logger.debug(`[makdoong2-team hook] skill_mcp registry scanned: root=${skillMcpRegistry.root} ` +
|
|
720
|
+
`skills=${skillMcpRegistry.bySkill.size} mcp_servers=${skillMcpRegistry.byMcp.size}`);
|
|
721
|
+
// 사용자가 아직 알려지지 않은 outer MCP 서버(chrome-devtools-mcp 등)나
|
|
722
|
+
// opencode.json 의 site-wide MCP 를 skill_mcp 로 부를 수도 있다. 이런 이름은
|
|
723
|
+
// registry 에는 없지만 opencode 가 성공적으로 처리할 수 있다. 확실히 우리가
|
|
724
|
+
// 관리하는 MCP 인 경우에만 사전 힌트를 낸다 — 사전 throw 는 하지 않는다.
|
|
725
|
+
// (registry 는 정보성이지 whitelist 가 아니다).
|
|
726
|
+
const knownMcpNames = skillMcpRegistry.byMcp;
|
|
727
|
+
const parentSessionByCallID = new Map();
|
|
728
|
+
const parentSessionByToolStack = [];
|
|
729
|
+
const dispatchParentSessionID = (callID) => {
|
|
730
|
+
if (callID && parentSessionByCallID.has(callID)) {
|
|
731
|
+
return parentSessionByCallID.get(callID);
|
|
732
|
+
}
|
|
733
|
+
return parentSessionByToolStack.length > 0
|
|
734
|
+
? parentSessionByToolStack[parentSessionByToolStack.length - 1]
|
|
735
|
+
: null;
|
|
736
|
+
};
|
|
737
|
+
const pendingDispatch = new Map();
|
|
738
|
+
const spawnPaneForSession = async (sessionId) => {
|
|
739
|
+
if (OMO_TMUX_MANAGED)
|
|
740
|
+
return;
|
|
741
|
+
const pd = pendingDispatch.get(sessionId);
|
|
742
|
+
if (!pd)
|
|
743
|
+
return;
|
|
744
|
+
try {
|
|
745
|
+
await tmuxMonitor.spawnPane(sessionId, pd.stage, pd.agent, pd.worktree);
|
|
746
|
+
}
|
|
747
|
+
catch (e) {
|
|
748
|
+
logger.error(`[event] spawnPane failed for session=${sessionId}: ${e}`);
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
// Permission 방어 (SDK 1.18): session.create body 는 parentID/title 만 허용하고
|
|
752
|
+
// Session 응답 타입에도 permission 필드가 없어, create 시점 permission 상속이
|
|
753
|
+
// 불가능하다 (0.x 의 resolveInheritedPermission 방식은 1.18 에서 no-op).
|
|
754
|
+
// PROJ-40406 (headless sub-session 의 external_directory ask hang) 방어는
|
|
755
|
+
// pollSubSession 의 permission auto-reply 가 전담한다:
|
|
756
|
+
// - external_directory + worktree scope 내 → 자동 allow (PERMISSION_ALLOW)
|
|
757
|
+
// - 그 외 → 자동 reject + abort (PERMISSION_STALL → redispatch)
|
|
758
|
+
// question tool 은 prompt body 의 `tools: { question: false }` 로 차단 유지.
|
|
759
|
+
// sub-session lifecycle 정리 단일 진입점: abort(진행 중 prompt 중단) → pane kill
|
|
760
|
+
// (tracked path 우선, marker fallback) → force kill (plugin 재시작으로 in-memory
|
|
761
|
+
// Map 손실 시 marker 기반 복구) → session.delete (opencode DB zombie 방지).
|
|
762
|
+
// dispatch_stage/dispatch_verifier finally 블록, orphanScanTimer, 사용자가 부르는
|
|
763
|
+
// inspect_sub_sessions/cleanup_panes 툴이 모두 이 함수를 공유한다.
|
|
764
|
+
// 개별 단계는 idempotent 하므로 (이미 끝난 세션 abort 등은 no-op) 순서 보장을
|
|
765
|
+
// 위해 순차 await 한다 - concurrent 정리는 tmux marker race 를 유발할 수 있다.
|
|
766
|
+
const cleanupSubSession = async (sid, opts) => {
|
|
767
|
+
try {
|
|
768
|
+
// skipSessionOps=true 는 opencode 세션이 이미 gone 인 경우 사용한다.
|
|
769
|
+
// gone 세션에 session.abort/delete 를 호출하면 opencode 가 부모(team-leader)
|
|
770
|
+
// 이벤트 스트림에 NotFoundError 를 fire 하여 부장님 runLoop 가 hang 된다.
|
|
771
|
+
// 이 경우에는 tmux pane 만 제거하고 in-memory 추적 맵만 정리한다.
|
|
772
|
+
if (!opts.skipSessionOps) {
|
|
773
|
+
await client.session
|
|
774
|
+
.abort({ path: { id: sid } })
|
|
775
|
+
.catch(() => undefined);
|
|
776
|
+
}
|
|
777
|
+
await tmuxMonitor.closePane(sid, opts).catch(() => undefined);
|
|
778
|
+
if (!opts.success) {
|
|
779
|
+
await tmuxMonitor.forceKillBySessionId(sid).catch(() => undefined);
|
|
780
|
+
}
|
|
781
|
+
if (!opts.skipSessionOps) {
|
|
782
|
+
pendingDelete.set(sid, Date.now());
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
finally {
|
|
786
|
+
sessionIssue.delete(sid);
|
|
787
|
+
pendingDispatch.delete(sid);
|
|
788
|
+
subSessionIds.delete(sid);
|
|
789
|
+
sessionLastToolExecuteAt.delete(sid);
|
|
790
|
+
sessionActiveToolCount.delete(sid);
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
// team-leader가 직접 실행 시 물리적으로 차단할 툴 목록.
|
|
794
|
+
// 하드룰 1: Read 외 파일 조작은 dispatch_stage로만 위임.
|
|
795
|
+
const LEADER_FORBIDDEN_TOOLS = new Set(["write", "edit", "patch", "multiedit"]);
|
|
796
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
797
|
+
// Sealed Workflow Enforcement — Outer-World Tool Blocking
|
|
798
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
799
|
+
// makdoong2 서브에이전트가 outer-world(Sisyphus / Explore / Librarian /
|
|
800
|
+
// Oracle / Metis / Momus 등 oh-my-openagent 계열)로 위임하는 툴을
|
|
801
|
+
// 런타임에 물리적으로 차단한다. 프론트매터 whitelist(1차 방어) 위의
|
|
802
|
+
// 2차 방어층 — 프론트매터가 오설정되어도 훅이 잡는다.
|
|
803
|
+
//
|
|
804
|
+
// Blocklist current as of oh-my-openagent@3.17.15 (2026-05).
|
|
805
|
+
// oh-my-openagent 업그레이드 시 dist/tools/ 를 재검토하여 새 위임 툴이
|
|
806
|
+
// 추가되었는지 확인한다.
|
|
807
|
+
const OUTER_WORLD_TOOLS = new Set([
|
|
808
|
+
"call_omo_agent",
|
|
809
|
+
"delegate_task",
|
|
810
|
+
"background_task",
|
|
811
|
+
"task_create",
|
|
812
|
+
"task_update",
|
|
813
|
+
"task_get",
|
|
814
|
+
"task_list",
|
|
815
|
+
]);
|
|
816
|
+
// outer-world 위임이 금지된 makdoong2 서브에이전트 목록.
|
|
817
|
+
// team-leader는 오케스트레이터 역할로 EXEMPT — 자체 하드룰 1/2 (파일
|
|
818
|
+
// 쓰기 금지)로 이미 제약됨. 다른 서브에이전트는 sealed workflow 원칙에
|
|
819
|
+
// 따라 skill_mcp + dispatch_stage 만으로 작업을 완결해야 한다.
|
|
820
|
+
const SEALED_SUBAGENTS = new Set([
|
|
821
|
+
"makdoong2-planner",
|
|
822
|
+
"makdoong2-analyzer",
|
|
823
|
+
"makdoong2-engineer",
|
|
824
|
+
"makdoong2-publisher",
|
|
825
|
+
"makdoong2-verifier",
|
|
826
|
+
]);
|
|
827
|
+
// 미래의 oh-my-openagent 위임 툴을 조기 발견하기 위한 이름 패턴.
|
|
828
|
+
// 알려진 툴이 아니면서 위임/스폰을 시사하는 이름이 감지되면 경고한다.
|
|
829
|
+
// Blocklist 갱신 여부를 판단하는 용도로만 사용 — 차단은 하지 않는다.
|
|
830
|
+
const DELEGATION_LIKE_NAME = /^(delegate|spawn|background|task_(create|update|delete))/i;
|
|
831
|
+
const KNOWN_SAFE_TOOLS = new Set([
|
|
832
|
+
"dispatch_stage",
|
|
833
|
+
"dispatch_verifier",
|
|
834
|
+
"verify_stage",
|
|
835
|
+
"auto_advance_stage",
|
|
836
|
+
"get_fallback_model",
|
|
837
|
+
"skill",
|
|
838
|
+
"skill_mcp",
|
|
839
|
+
]);
|
|
840
|
+
// Bash 우회 파일 쓰기 감지 정규식 (하드룰 2).
|
|
841
|
+
// 허용: `git commit|push|add|rm`, `state.sh set`, `mkdir/touch/rm/ls/find/cat` (읽기)
|
|
842
|
+
// 차단: `>`, `>>`, `tee`, `sed -i`, `awk ... > file`, `cat <<EOF > file`, `printf ... >`
|
|
843
|
+
//
|
|
844
|
+
// FD-redirect 방어: `2>/dev/null`, `1>&2`, `command 2>err.log` 같은
|
|
845
|
+
// 파일 디스크립터 리다이렉트는 파일 쓰기가 아니거나(전자·중간자) 정책상 허용한다.
|
|
846
|
+
// 모든 `>` 매칭에 `(?<![0-9])` negative lookbehind 를 걸어 FD 번호를 제외한다.
|
|
847
|
+
// 또한 `> /dev/null` 처럼 명시적 /dev/null 리다이렉트도 파일 쓰기가 아니므로 허용한다.
|
|
848
|
+
// ── Plugin init sweep ────────────────────────────────────────────────────
|
|
849
|
+
(async () => {
|
|
850
|
+
try {
|
|
851
|
+
const ZOMBIE_IDLE_THRESHOLD_MS = 24 * 60 * 60_000;
|
|
852
|
+
const listResult = await client.session.list().catch(() => null);
|
|
853
|
+
const initSessions = (listResult?.data ?? []);
|
|
854
|
+
const initStatusResult = await client.session.status().catch(() => null);
|
|
855
|
+
const initStatuses = (initStatusResult?.data ?? {});
|
|
856
|
+
const initNowMs = Date.now();
|
|
857
|
+
let swept = 0;
|
|
858
|
+
for (const s of initSessions) {
|
|
859
|
+
if (!SUB_SESSION_TITLE_PATTERN.test(s.title ?? ""))
|
|
860
|
+
continue;
|
|
861
|
+
if (pendingDispatch.has(s.id) || pendingDelete.has(s.id))
|
|
862
|
+
continue;
|
|
863
|
+
if (initStatuses[s.id]?.type === "busy")
|
|
864
|
+
continue;
|
|
865
|
+
if (initNowMs - (s.time?.updated ?? 0) < ZOMBIE_IDLE_THRESHOLD_MS)
|
|
866
|
+
continue;
|
|
867
|
+
await client.session.delete({ path: { id: s.id } }).catch(() => undefined);
|
|
868
|
+
swept++;
|
|
869
|
+
}
|
|
870
|
+
if (swept > 0)
|
|
871
|
+
logger.debug(`[init-sweep] zombie sub-session ${swept}개 정리 완료`);
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
logger.warn(`[init-sweep] 초기화 sweep 오류: ${e}`);
|
|
875
|
+
}
|
|
876
|
+
})();
|
|
877
|
+
return {
|
|
878
|
+
event: async ({ event }) => {
|
|
879
|
+
if (logger.isDebug() && event?.type?.startsWith("session.")) {
|
|
880
|
+
try {
|
|
881
|
+
logger.debug(`[event] type=${event.type} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
|
|
882
|
+
}
|
|
883
|
+
catch { /* ignore */ }
|
|
884
|
+
}
|
|
885
|
+
if (event?.type === "session.deleted") {
|
|
886
|
+
const props = event.properties;
|
|
887
|
+
const sid = props?.info?.id ?? props?.sessionID;
|
|
888
|
+
if (sid) {
|
|
889
|
+
const waiters = sessionDeletedWaiters.get(sid);
|
|
890
|
+
if (waiters && waiters.length > 0) {
|
|
891
|
+
for (const w of [...waiters]) {
|
|
892
|
+
try {
|
|
893
|
+
w();
|
|
894
|
+
}
|
|
895
|
+
catch { /* ignore */ }
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (event?.type !== "session.created")
|
|
902
|
+
return;
|
|
903
|
+
const props = event.properties;
|
|
904
|
+
const sid = props?.info?.id;
|
|
905
|
+
logger.debug(`[event] session.created sid=${sid} pendingDispatch.has=${sid ? pendingDispatch.has(sid) : "no-sid"}`);
|
|
906
|
+
if (!sid)
|
|
907
|
+
return;
|
|
908
|
+
if (!pendingDispatch.has(sid))
|
|
909
|
+
return;
|
|
910
|
+
await spawnPaneForSession(sid);
|
|
911
|
+
},
|
|
912
|
+
"chat.params": async (input) => {
|
|
913
|
+
if (input.sessionID && input.agent) {
|
|
914
|
+
sessionAgent.set(input.sessionID, input.agent);
|
|
915
|
+
}
|
|
916
|
+
},
|
|
917
|
+
// ─────────────────────────────────────────────────────────────
|
|
918
|
+
// PreToolUse — block destructive bash, gate `git push`,
|
|
919
|
+
// and physically forbid team-leader from direct file writes.
|
|
920
|
+
// hook input shape (1.3.3): { tool, sessionID, callID }
|
|
921
|
+
// Per-agent enforcement: sessionAgent Map을 통해 sessionID → agent 조회.
|
|
922
|
+
// ─────────────────────────────────────────────────────────────
|
|
923
|
+
"tool.execute.before": async (input, output) => {
|
|
924
|
+
// Hard-block oh-my-openagent's call_omo_agent regardless of opencode.json
|
|
925
|
+
// tools setting. makdoong2-team has its own dispatch_stage / get_fallback_model
|
|
926
|
+
// pipeline; OmO's loop-style fallback would break stage gating and double-spawn
|
|
927
|
+
// sub-agents. Throwing here cancels the tool call before it reaches OmO.
|
|
928
|
+
const sessionID = input.sessionID;
|
|
929
|
+
// chat.params 와 tool.execute.before 사이의 race 가능성:
|
|
930
|
+
// sub-session이 promptAsync 직후 첫 tool을 호출할 때 chat.params 가 아직
|
|
931
|
+
// 미발화된 상태일 수 있다. pendingDispatch 는 promptAsync 이전에 채워지므로
|
|
932
|
+
// 이를 fallback으로 사용하면 race window 를 완전히 닫는다.
|
|
933
|
+
const agent = sessionID
|
|
934
|
+
? (sessionAgent.get(sessionID) ?? pendingDispatch.get(sessionID)?.agent)
|
|
935
|
+
: undefined;
|
|
936
|
+
const toolLower = (input.tool || "").toLowerCase();
|
|
937
|
+
logger.debug(`[makdoong2-team hook] tool.execute.before fired: tool="${input.tool}" sessionID="${sessionID}" agent="${agent ?? "unknown"}" callID="${input.callID}"`);
|
|
938
|
+
if (sessionID) {
|
|
939
|
+
sessionLastToolExecuteAt.set(sessionID, Date.now());
|
|
940
|
+
sessionActiveToolCount.set(sessionID, (sessionActiveToolCount.get(sessionID) ?? 0) + 1);
|
|
941
|
+
}
|
|
942
|
+
if (sessionID && (toolLower === "dispatch_stage" || toolLower === "dispatch_verifier")) {
|
|
943
|
+
const callID = input.callID;
|
|
944
|
+
if (callID)
|
|
945
|
+
parentSessionByCallID.set(callID, sessionID);
|
|
946
|
+
parentSessionByToolStack.push(sessionID);
|
|
947
|
+
logger.debug(`[makdoong2-team hook] captured parentSessionID=${sessionID} for ${input.tool} callID=${callID}`);
|
|
948
|
+
}
|
|
949
|
+
if (input.tool === "call_omo_agent") {
|
|
950
|
+
logger.error(`[makdoong2-team hook] BLOCKED: call_omo_agent invocation detected`);
|
|
951
|
+
throw new Error("[makdoong2-team] call_omo_agent is forbidden in this workflow. " +
|
|
952
|
+
"Use dispatch_stage / dispatch_verifier / get_fallback_model instead. " +
|
|
953
|
+
"If dispatch_stage is missing, the makdoong2-team plugin failed to load — " +
|
|
954
|
+
"fix the plugin (likely: cd ~/.config/opencode/plugins/makdoong2-team && npm install --ignore-scripts).");
|
|
955
|
+
}
|
|
956
|
+
if (OUTER_WORLD_TOOLS.has(toolLower)) {
|
|
957
|
+
if (!agent) {
|
|
958
|
+
logger.debug(`[makdoong2-team hook] outer-world tool "${input.tool}" called, agent unknown ` +
|
|
959
|
+
`(sessionAgent not yet populated). Allowing — primary session passthrough. ` +
|
|
960
|
+
`sessionID="${sessionID}" callID="${input.callID}".`);
|
|
961
|
+
}
|
|
962
|
+
else if (SEALED_SUBAGENTS.has(agent)) {
|
|
963
|
+
logger.error(`[makdoong2-team hook] BLOCKED: sealed sub-agent "${agent}" attempted outer-world tool "${input.tool}" (sessionID="${sessionID}")`);
|
|
964
|
+
throw new Error(`[makdoong2-team sealed workflow violation]\n` +
|
|
965
|
+
`Agent "${agent}" cannot call "${input.tool}".\n\n` +
|
|
966
|
+
`Makdoong2 sub-agents are SEALED and must not delegate to outer-world agents ` +
|
|
967
|
+
`(Sisyphus / Explore / Librarian / Oracle / Metis / Momus 등 oh-my-openagent 계열).\n\n` +
|
|
968
|
+
`**허용된 대안:**\n` +
|
|
969
|
+
`• 조사/리서치: \`skill_mcp\` 툴로 아래 스킬 사용\n` +
|
|
970
|
+
` - jira-research (Jira 이슈)\n` +
|
|
971
|
+
` - confluence-research (설계 문서, 위키)\n` +
|
|
972
|
+
` - bitbucket-research (소스 코드, PR, 커밋)\n` +
|
|
973
|
+
` - github-oss-research (공개 저장소, OSS 참조)\n` +
|
|
974
|
+
`• 다른 substage 작업 필요 시: 결과·스펙을 team-leader에게 반환하면 team-leader가 dispatch_stage로 라우팅.\n` +
|
|
975
|
+
`• 상태 공유: \`state.sh set\` 으로 state.json 마커에 기록. team-leader가 읽어서 다음 단계 결정.\n\n` +
|
|
976
|
+
`**아키텍처 원칙:** 각 substage는 self-contained. Inter-substage 의존은 team-leader 오케스트레이션을 통해서만 흐르며, ` +
|
|
977
|
+
`agent-to-agent 직접 호출은 금지된다. 참조: AGENTS.md "워크플로우 상태 & 위임 규약".`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
else if (agent && SEALED_SUBAGENTS.has(agent) && DELEGATION_LIKE_NAME.test(toolLower) && !KNOWN_SAFE_TOOLS.has(toolLower)) {
|
|
981
|
+
logger.debug(`[makdoong2-team hook] sealed sub-agent "${agent}" called delegation-like tool "${input.tool}" not in blocklist. ` +
|
|
982
|
+
`Consider adding to OUTER_WORLD_TOOLS if this is a new oh-my-openagent delegation tool.`);
|
|
983
|
+
}
|
|
984
|
+
// ── skill_mcp lazy-load 사전 힌트 ──
|
|
985
|
+
// opencode 는 skill 이 로드되기 전에 skill_mcp 를 호출하면
|
|
986
|
+
// "MCP server not found" 로 튕기지만 정작 어떤 skill 을 로드해야 하는지는
|
|
987
|
+
// 알려주지 않는다. 우리가 관리하는 mcp_name 이면 콘솔에 정확한 skill 이름을
|
|
988
|
+
// 미리 남긴다 — 성공 시엔 아무 영향 없고, 실패 시 사용자·디버거가 로그에서
|
|
989
|
+
// 즉시 원인을 볼 수 있다. 사전 throw 는 하지 않는다 — outer MCP (site-wide)
|
|
990
|
+
// 나 chrome-devtools-mcp 처럼 skill 밖의 MCP 도 정상 케이스다.
|
|
991
|
+
if (toolLower === "skill_mcp") {
|
|
992
|
+
const mcpName = extractMcpName(output.args);
|
|
993
|
+
if (mcpName && knownMcpNames.has(mcpName)) {
|
|
994
|
+
const skillName = knownMcpNames.get(mcpName);
|
|
995
|
+
logger.debug(`[makdoong2-team hook] skill_mcp lazy-load hint: mcp_name="${mcpName}" ` +
|
|
996
|
+
`is embedded in skill "${skillName}". If the call fails with "not found", ` +
|
|
997
|
+
`first invoke skill(name="${skillName}") in the current session, then retry.`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
|
|
1001
|
+
if (agent === "makdoong2-team-leader" && LEADER_FORBIDDEN_TOOLS.has(toolLower)) {
|
|
1002
|
+
logger.error(`[makdoong2-team hook] BLOCKED: team-leader가 ${input.tool} 툴 호출을 시도했다.`);
|
|
1003
|
+
throw new Error(`[makdoong2-team hardrule 1] team-leader는 '${input.tool}' 툴을 직접 호출할 수 없다. ` +
|
|
1004
|
+
"파일 조작은 반드시 dispatch_stage로 engineer/planner/publisher 막둥이에 위임하라. " +
|
|
1005
|
+
"auto_advance_stage의 next_action 필드가 지시하는 dispatch_stage 호출을 우선 실행하라.");
|
|
1006
|
+
}
|
|
1007
|
+
if (input.tool !== "bash")
|
|
1008
|
+
return;
|
|
1009
|
+
const cmd = output.args?.command ?? "";
|
|
1010
|
+
if (!cmd)
|
|
1011
|
+
return;
|
|
1012
|
+
// ── Universal state.json hardrule: agent 식별 결과와 무관하게 차단 ──
|
|
1013
|
+
// sessionAgent map race, primary/outer session, undefined agent 모두 포함.
|
|
1014
|
+
if (looksLikeSealedStateWrite(cmd)) {
|
|
1015
|
+
logger.error(`[makdoong2-team hook] BLOCKED: state.json 우회 조작 시도 (agent="${agent ?? "unknown"}"). cmd="${redactAndTruncate(cmd, 200)}"`);
|
|
1016
|
+
throw new Error(`[makdoong2-team state hardrule] state.json 은 오직 state.sh (get/set/init/append/update/migrate) 로만 조작할 수 있다.\n` +
|
|
1017
|
+
`직접 편집 (python -c open, node -e writeFileSync, sed -i, jq > , tee, cat > , echo > 등)은 workflow 정합성을 훼손하므로 금지된다.\n` +
|
|
1018
|
+
`caller agent="${agent ?? "unknown"}"`);
|
|
1019
|
+
}
|
|
1020
|
+
// ── Leader hardrule 2: Bash 우회 파일 쓰기 금지 (state.json 제외) ──
|
|
1021
|
+
if (agent === "makdoong2-team-leader" && looksLikeFileWrite(cmd)) {
|
|
1022
|
+
logger.error(`[makdoong2-team hook] BLOCKED: team-leader가 bash 파일 쓰기 우회를 시도했다. cmd="${redactAndTruncate(cmd, 200)}"`);
|
|
1023
|
+
throw new Error("[makdoong2-team hardrule 2] team-leader는 bash를 통한 파일 쓰기(>, >>, tee, sed -i, cat > 등)도 금지된다. " +
|
|
1024
|
+
"허용: git commit/push/add/rm, state.sh set. " +
|
|
1025
|
+
"그 외 파일 생성·수정은 dispatch_stage로 engineer 막둥이에 위임하라.");
|
|
1026
|
+
}
|
|
1027
|
+
// ── Planner/Analyzer hardrule: READ-ONLY 원칙 - bash 파일 쓰기 일체 금지 ──
|
|
1028
|
+
if ((agent === "makdoong2-planner" || agent === "makdoong2-analyzer") && looksLikeFileWrite(cmd)) {
|
|
1029
|
+
logger.error(`[makdoong2-team hook] BLOCKED: ${agent}가 bash 파일 쓰기를 시도했다 (READ-ONLY 위반). cmd="${redactAndTruncate(cmd, 200)}"`);
|
|
1030
|
+
throw new Error(`[makdoong2-team READ-ONLY hardrule] ${agent}는 Read-only 에이전트로 파일 생성·수정이 일체 금지된다.\n\n` +
|
|
1031
|
+
`**허용**: state.sh set (state.json 마커 기록만)\n` +
|
|
1032
|
+
`**금지**: bash 리디렉션 (>, >>, tee, cat >, echo >, sed -i, awk > 등), Python/Node 인터프리터 파일 쓰기\n\n` +
|
|
1033
|
+
`**올바른 절차**:\n` +
|
|
1034
|
+
`• Planning 단계 (planner): "무엇을 만들지" 결정만. 실제 파일 생성은 implementation 단계로 위임.\n` +
|
|
1035
|
+
`• Analysis 단계 (analyzer): workspace-analysis.json 1개만 생성 가능 (Write 툴 사용).\n` +
|
|
1036
|
+
`• 초안 파일 필요 시: spec을 team-leader에게 반환 → dev 단계에서 engineer가 구현.\n\n` +
|
|
1037
|
+
`**참조**: agents/${agent}.md "금지" 섹션, AGENTS.md "워크플로우 상태 & 위임 규약"`);
|
|
1038
|
+
}
|
|
1039
|
+
const hookIssue = sessionIssue.get(sessionID ?? "") ?? "";
|
|
1040
|
+
const r = await runScript(HOOKS_DIR, "guard-bash.sh", cmd, hookIssue);
|
|
1041
|
+
if (!r.ok) {
|
|
1042
|
+
throw new Error((r.stderr || "makdoong2-team gate blocked").trim());
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
// ─────────────────────────────────────────────────────────────
|
|
1046
|
+
// PostToolUse — sync mechanical facts (commit done, etc.).
|
|
1047
|
+
// hook input shape (1.3.3): { tool, sessionID, callID, args }
|
|
1048
|
+
// output shape (1.3.3): { title, output: string, metadata }
|
|
1049
|
+
// ─────────────────────────────────────────────────────────────
|
|
1050
|
+
"tool.execute.after": async (input, output) => {
|
|
1051
|
+
const toolLowerAfter = (input.tool || "").toLowerCase();
|
|
1052
|
+
const afterSessionID = input.sessionID;
|
|
1053
|
+
if (afterSessionID) {
|
|
1054
|
+
sessionLastToolExecuteAt.set(afterSessionID, Date.now());
|
|
1055
|
+
const cur = sessionActiveToolCount.get(afterSessionID) ?? 0;
|
|
1056
|
+
if (cur <= 1)
|
|
1057
|
+
sessionActiveToolCount.delete(afterSessionID);
|
|
1058
|
+
else
|
|
1059
|
+
sessionActiveToolCount.set(afterSessionID, cur - 1);
|
|
1060
|
+
}
|
|
1061
|
+
if (afterSessionID && WRITE_TOOLS.has(toolLowerAfter)) {
|
|
1062
|
+
const agentAfter = sessionAgent.get(afterSessionID)
|
|
1063
|
+
?? pendingDispatch.get(afterSessionID)?.agent;
|
|
1064
|
+
const filePath = extractFilePathFromToolArgs(input.args);
|
|
1065
|
+
logger.debug(`[auto-git-add-hook] tool=${toolLowerAfter} session=${afterSessionID} ` +
|
|
1066
|
+
`agent=${agentAfter ?? "unknown"} filePath=${filePath ?? "N/A"} ` +
|
|
1067
|
+
`wt=${sessionWorktree.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.worktree ?? "N/A"} ` +
|
|
1068
|
+
`issue=${sessionIssue.get(afterSessionID) ?? "N/A"}`);
|
|
1069
|
+
if (agentAfter === "makdoong2-engineer" && filePath) {
|
|
1070
|
+
const result = await trackAndStageEngineerWrite(afterSessionID, filePath)
|
|
1071
|
+
.catch((err) => ({
|
|
1072
|
+
ok: false,
|
|
1073
|
+
relPath: filePath,
|
|
1074
|
+
error: `hook exception: ${err?.message ?? String(err)}`,
|
|
1075
|
+
}));
|
|
1076
|
+
if (!result.ok && result.relPath) {
|
|
1077
|
+
const warn = `\n\n[makdoong2-team] auto git add FAILED for "${result.relPath}": ${redactAndTruncate(result.error ?? "", 200)}\n` +
|
|
1078
|
+
`→ 조치: bash 로 'git add -- ${result.relPath}' 를 직접 실행해 stage 하시오. dev exit gate 가 unstaged 파일을 차단한다.`;
|
|
1079
|
+
const outAny = output;
|
|
1080
|
+
outAny.output = (outAny.output ?? "") + warn;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (toolLowerAfter === "dispatch_stage" || toolLowerAfter === "dispatch_verifier") {
|
|
1085
|
+
const callID = input.callID;
|
|
1086
|
+
if (callID)
|
|
1087
|
+
parentSessionByCallID.delete(callID);
|
|
1088
|
+
if (parentSessionByToolStack.length > 0)
|
|
1089
|
+
parentSessionByToolStack.pop();
|
|
1090
|
+
}
|
|
1091
|
+
if (input.tool === "skill_mcp") {
|
|
1092
|
+
const outAny = output;
|
|
1093
|
+
const raw = outAny.output ?? "";
|
|
1094
|
+
const mcpName = extractMcpName(input.args);
|
|
1095
|
+
const skillName = mcpName ? knownMcpNames.get(mcpName) : undefined;
|
|
1096
|
+
if (looksLikeMcpNotFound(raw)) {
|
|
1097
|
+
if (skillName) {
|
|
1098
|
+
outAny.output = [
|
|
1099
|
+
`[makdoong2-team] MCP server "${mcpName}" is embedded in skill "${skillName}" and must be loaded first.`,
|
|
1100
|
+
`Next step:`,
|
|
1101
|
+
` 1. Call skill(name="${skillName}") in the current session to spawn the MCP server.`,
|
|
1102
|
+
` 2. Then retry skill_mcp(mcp_name="${mcpName}", tool_name=..., arguments=...).`,
|
|
1103
|
+
`Reason: lazy-load MCP — declared in SKILL.md frontmatter, not spawned until the skill is loaded.`,
|
|
1104
|
+
``,
|
|
1105
|
+
`Original opencode output:`,
|
|
1106
|
+
raw,
|
|
1107
|
+
].join("\n");
|
|
1108
|
+
logger.debug(`[makdoong2-team hook] skill_mcp not-found rewritten: mcp_name="${mcpName}" → skill="${skillName}"`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
else if (looksLikeMcpConnectionFailed(raw)) {
|
|
1112
|
+
if (skillName) {
|
|
1113
|
+
outAny.output = [
|
|
1114
|
+
`[makdoong2-team] skill MCP server "${mcpName}" (skill: ${skillName}) connected but process exited immediately.`,
|
|
1115
|
+
`Possible causes:`,
|
|
1116
|
+
` 1. credentials: ~/.config/opencode/makdoong2-team.json 의 .secrets 토큰 미설정 또는 만료`,
|
|
1117
|
+
` 2. dependencies: jq 또는 npx 가 PATH 에 없음`,
|
|
1118
|
+
` 3. run: npx makdoong2-team doctor — 설정 상태 진단`,
|
|
1119
|
+
``,
|
|
1120
|
+
`Original error:`,
|
|
1121
|
+
raw,
|
|
1122
|
+
].join("\n");
|
|
1123
|
+
logger.debug(`[makdoong2-team hook] skill_mcp connection-failed rewritten: mcp_name="${mcpName}" skill="${skillName}"`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (input.tool !== "bash")
|
|
1128
|
+
return;
|
|
1129
|
+
const cmd = input.args?.command ?? "";
|
|
1130
|
+
if (!cmd)
|
|
1131
|
+
return;
|
|
1132
|
+
const out = output.output ?? "";
|
|
1133
|
+
const hookIssueAfter = sessionIssue.get(input.sessionID ?? "") ?? "";
|
|
1134
|
+
await runScript(HOOKS_DIR, "sync-state.sh", cmd, out, hookIssueAfter);
|
|
1135
|
+
},
|
|
1136
|
+
// ─────────────────────────────────────────────────────────────
|
|
1137
|
+
// Config hook — inject model settings into opencode agent registry AND
|
|
1138
|
+
// override MCP tokens from makdoong2-team.json (.secrets.*) into the
|
|
1139
|
+
// shared config object BEFORE MCP.state() spawns servers.
|
|
1140
|
+
//
|
|
1141
|
+
// Rationale for MCP env mutation: opencode 1.4.14 Config.get() returns
|
|
1142
|
+
// the live config object by reference (Config.Info is DeepMutable). The
|
|
1143
|
+
// MCP layer later reads cfg.mcp[key].environment on connectLocal(). By
|
|
1144
|
+
// mutating here, we make makdoong2-team.json the SSoT for direct MCP
|
|
1145
|
+
// tool calls (repos_*, works_*, docs_*, bamboo_*) — the same policy that
|
|
1146
|
+
// skills/_lib/load-secret.sh already enforces for skill_mcp calls.
|
|
1147
|
+
// See src/mcp-secret-injector.ts for the full rationale.
|
|
1148
|
+
// ─────────────────────────────────────────────────────────────
|
|
1149
|
+
config: async (opencodeConfig) => {
|
|
1150
|
+
if (!opencodeConfig.agent) {
|
|
1151
|
+
opencodeConfig.agent = {};
|
|
1152
|
+
}
|
|
1153
|
+
for (const [agentName, policy] of Object.entries(POLICIES)) {
|
|
1154
|
+
if (!policy?.primary?.id)
|
|
1155
|
+
continue;
|
|
1156
|
+
if (!opencodeConfig.agent[agentName]) {
|
|
1157
|
+
opencodeConfig.agent[agentName] = {
|
|
1158
|
+
name: agentName,
|
|
1159
|
+
description: `Makdoong2 workflow agent (${agentName})`,
|
|
1160
|
+
mode: agentName === "makdoong2-team-leader" ? "primary" : "subagent",
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
opencodeConfig.agent[agentName].model = policy.primary.id;
|
|
1164
|
+
if (policy.primary.variant) {
|
|
1165
|
+
opencodeConfig.agent[agentName].variant = policy.primary.variant;
|
|
1166
|
+
}
|
|
1167
|
+
logger.debug(`[makdoong2-team config] Injected model for ${agentName}: ${policy.primary.id}` +
|
|
1168
|
+
(policy.primary.variant ? ` (variant: ${policy.primary.variant})` : ""));
|
|
1169
|
+
}
|
|
1170
|
+
const secrets = (config.secrets ?? {});
|
|
1171
|
+
const results = injectAllSecrets(opencodeConfig, secrets);
|
|
1172
|
+
for (const r of results) {
|
|
1173
|
+
if (r.status === "overridden") {
|
|
1174
|
+
logger.warn(`[makdoong2-team config] MCP secret OVERRIDDEN: mcp="${r.mcpKey}" var="${r.varName}" ` +
|
|
1175
|
+
`— opencode.json value differed from makdoong2-team.json (SSoT wins). ` +
|
|
1176
|
+
`Reconcile the two files to remove this warning.`);
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
const suffix = r.tokenPrefix ? ` (prefix=${r.tokenPrefix}…)` : "";
|
|
1180
|
+
logger.debug(`[makdoong2-team config] MCP secret ${r.status}: mcp="${r.mcpKey}" var="${r.varName}"${suffix}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
// ─────────────────────────────────────────────────────────────
|
|
1185
|
+
// Custom tools — called by the orchestrator (부장님).
|
|
1186
|
+
// SDK 1.3.3 requires tool() factory with Zod args.
|
|
1187
|
+
// execute() must return Promise<string> (serialize objects as JSON).
|
|
1188
|
+
// ─────────────────────────────────────────────────────────────
|
|
1189
|
+
tool: {
|
|
1190
|
+
/**
|
|
1191
|
+
* verify_stage — gate-only check. Returns ok/reason without dispatching.
|
|
1192
|
+
*/
|
|
1193
|
+
verify_stage: tool({
|
|
1194
|
+
description: "Verify entry gate for a stage; return ok/blocked without spawning any agent.",
|
|
1195
|
+
args: {
|
|
1196
|
+
issue: tool.schema.string().describe("Jira issue key, e.g. PROJ-12345"),
|
|
1197
|
+
target_stage: tool.schema.enum(STAGE_ORDER),
|
|
1198
|
+
worktree: tool.schema.string().optional().describe("Absolute worktree path; defaults to plugin cwd"),
|
|
1199
|
+
},
|
|
1200
|
+
async execute(args) {
|
|
1201
|
+
const gateCwd = args.worktree ?? cwd;
|
|
1202
|
+
const verify = await runScriptCwd(gateCwd, GATES_DIR, "verify.sh", args.issue, args.target_stage);
|
|
1203
|
+
if (!verify.ok) {
|
|
1204
|
+
return JSON.stringify({
|
|
1205
|
+
ok: false,
|
|
1206
|
+
gate: args.target_stage,
|
|
1207
|
+
reason: verify.stderr.trim(),
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
const ext = await checkExtensionGates(args.issue, args.target_stage, gateCwd);
|
|
1211
|
+
if (!ext.ok) {
|
|
1212
|
+
return JSON.stringify({
|
|
1213
|
+
ok: false,
|
|
1214
|
+
gate: args.target_stage,
|
|
1215
|
+
reason: ext.reason,
|
|
1216
|
+
marker_path: ext.marker_path,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
const spec = agentForStage(args.target_stage);
|
|
1220
|
+
const policy = POLICIES[spec.id];
|
|
1221
|
+
const workPolicy = await readPolicy(args.issue, gateCwd);
|
|
1222
|
+
return JSON.stringify({
|
|
1223
|
+
ok: true,
|
|
1224
|
+
gate: args.target_stage,
|
|
1225
|
+
agent: spec.id,
|
|
1226
|
+
primary_only: spec.primary_only,
|
|
1227
|
+
model: policy?.primary,
|
|
1228
|
+
category: workPolicy?.category ?? null,
|
|
1229
|
+
auto_approve: workPolicy?.auto_approve?.[args.target_stage] ?? null,
|
|
1230
|
+
});
|
|
1231
|
+
},
|
|
1232
|
+
}),
|
|
1233
|
+
/**
|
|
1234
|
+
* dispatch_stage — spawn the per-stage agent via opencode CLI.
|
|
1235
|
+
*/
|
|
1236
|
+
dispatch_stage: tool({
|
|
1237
|
+
description: "Verify gate and spawn the per-stage sub-agent via opencode CLI. Returns execution result.",
|
|
1238
|
+
args: {
|
|
1239
|
+
issue: tool.schema.string().describe("Jira issue key, e.g. PROJ-12345"),
|
|
1240
|
+
target_stage: tool.schema.enum(STAGE_ORDER),
|
|
1241
|
+
worktree: tool.schema.string().describe("Absolute path to the worktree"),
|
|
1242
|
+
context: tool.schema.string().optional().describe("Extra context to append to the agent prompt"),
|
|
1243
|
+
model_override: tool.schema.string().optional().describe("Override model ID (e.g. for fallback)"),
|
|
1244
|
+
},
|
|
1245
|
+
async execute(args, context) {
|
|
1246
|
+
let success = false;
|
|
1247
|
+
const spec = agentForStage(args.target_stage);
|
|
1248
|
+
if (spec.primary_only) {
|
|
1249
|
+
return JSON.stringify({
|
|
1250
|
+
ok: false,
|
|
1251
|
+
gate: args.target_stage,
|
|
1252
|
+
reason: "PRIMARY_ONLY_STAGE",
|
|
1253
|
+
agent: spec.id,
|
|
1254
|
+
primary_only: true,
|
|
1255
|
+
hint: "This stage must be executed by the orchestrator (team-leader) directly; sub-agent spawn is not permitted.",
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
// done=true stage 재-dispatch 방지 (sub-agent tool-call loop → timeout/empty output).
|
|
1259
|
+
// 3_delivery.* 는 hybrid stage (publisher = spec provider) 로 재-진입이 정상 흐름이라 제외.
|
|
1260
|
+
const isHybridDelivery = args.target_stage.startsWith("3_delivery.");
|
|
1261
|
+
if (!isHybridDelivery) {
|
|
1262
|
+
const doneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageJqPath(args.target_stage) + ".done"}`
|
|
1263
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
1264
|
+
if (doneR.exitCode === 0 && doneR.stdout?.toString().trim() === "true") {
|
|
1265
|
+
return JSON.stringify({
|
|
1266
|
+
ok: false,
|
|
1267
|
+
gate: args.target_stage,
|
|
1268
|
+
stage: args.target_stage,
|
|
1269
|
+
agent: spec.id,
|
|
1270
|
+
already_done: true,
|
|
1271
|
+
reason: `Stage '${args.target_stage}' is already done=true. ` +
|
|
1272
|
+
`Re-dispatching a completed stage causes sub-agent tool-call loops (timeout/empty output). ` +
|
|
1273
|
+
`Call auto_advance_stage to obtain the correct next stage instead.`,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
// Cross-call stall cap. hang_history accumulates across every
|
|
1278
|
+
// dispatch_stage invocation for this substage, so it is the only
|
|
1279
|
+
// signal that survives the per-call attempt budget reset. Refuse to
|
|
1280
|
+
// spawn yet another doomed session once it is saturated — swapping
|
|
1281
|
+
// models does not clear an upstream LLM hang, so the correct action
|
|
1282
|
+
// is to stop and escalate to a human.
|
|
1283
|
+
const hangCountR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${stageJqPath(args.target_stage)}.hang_history // [] | length`}`
|
|
1284
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
1285
|
+
const hangCount = hangCountR.exitCode === 0
|
|
1286
|
+
? Number.parseInt(hangCountR.stdout?.toString().trim() ?? "", 10)
|
|
1287
|
+
: Number.NaN;
|
|
1288
|
+
if (shouldEscalateStall(hangCount, stallEscalateThreshold)) {
|
|
1289
|
+
logger.error(`[dispatch_stage] STALL_ESCALATE issue=${args.issue} stage=${args.target_stage} ` +
|
|
1290
|
+
`hang_history=${hangCount}/${stallEscalateThreshold} — refusing to redispatch`);
|
|
1291
|
+
return JSON.stringify({
|
|
1292
|
+
ok: false,
|
|
1293
|
+
escalate: true,
|
|
1294
|
+
stall_streak_exceeded: true,
|
|
1295
|
+
stage: args.target_stage,
|
|
1296
|
+
agent: spec.id,
|
|
1297
|
+
hang_history_len: hangCount,
|
|
1298
|
+
threshold: stallEscalateThreshold,
|
|
1299
|
+
reason: `Substage '${args.target_stage}' 이 누적 ${hangCount}회 hang (message_stall / status_absent) 했다. ` +
|
|
1300
|
+
`dispatch_stage 재호출은 attempt 예산만 리셋할 뿐 원인을 해소하지 못하므로 차단한다. ` +
|
|
1301
|
+
`모델 교체도 해법이 아니다 (동일 stall 이 fallback 모델에서도 재현됨). ` +
|
|
1302
|
+
`사용자에게 hang_history 를 보고하고 지시를 기다려라. ` +
|
|
1303
|
+
`원인 조사 후 재개하려면 state.sh set 으로 hang_history 를 [] 로 초기화해야 한다.`,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
const WORKTREE_ISOLATED_STAGES = new Set([
|
|
1307
|
+
"2_implementation.dev", "2_implementation.test",
|
|
1308
|
+
"3_delivery.commit", "3_delivery.pr", "3_delivery.review",
|
|
1309
|
+
]);
|
|
1310
|
+
let effectiveWorktree = args.worktree;
|
|
1311
|
+
if (WORKTREE_ISOLATED_STAGES.has(args.target_stage)) {
|
|
1312
|
+
const storedWtR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".worktree"}`
|
|
1313
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
1314
|
+
const storedWt = storedWtR.stdout?.toString().trim();
|
|
1315
|
+
if (storedWt && storedWt !== "null" && storedWt !== "" && storedWt !== args.worktree) {
|
|
1316
|
+
if (!existsSync(storedWt)) {
|
|
1317
|
+
return JSON.stringify({
|
|
1318
|
+
ok: false,
|
|
1319
|
+
error: "worktree_missing",
|
|
1320
|
+
state_worktree: storedWt,
|
|
1321
|
+
reason: `state.json worktree "${storedWt}" 가 파일 시스템에 존재하지 않습니다.`,
|
|
1322
|
+
next_action: `auto_advance_stage(issue: "${args.issue}") 를 호출하면 worktree 를 자동 재생성합니다. dispatch_stage 를 먼저 호출하지 마세요.`,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
logger.warn(`[dispatch_stage] worktree 불일치 감지 — ` +
|
|
1326
|
+
`LLM 인자: "${args.worktree}", state.json: "${storedWt}". 자동 수정.`);
|
|
1327
|
+
effectiveWorktree = storedWt;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const verify = await runScriptCwd(effectiveWorktree, GATES_DIR, "verify.sh", args.issue, args.target_stage);
|
|
1331
|
+
if (!verify.ok) {
|
|
1332
|
+
return JSON.stringify({
|
|
1333
|
+
ok: false,
|
|
1334
|
+
gate: args.target_stage,
|
|
1335
|
+
reason: verify.stderr.trim(),
|
|
1336
|
+
agent: spec.id,
|
|
1337
|
+
stage: args.target_stage,
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
const gateAutoSkipped = verify.stdout.includes("MAKDOONG2-GATE SKIP");
|
|
1341
|
+
if (gateAutoSkipped) {
|
|
1342
|
+
return JSON.stringify({
|
|
1343
|
+
ok: true,
|
|
1344
|
+
skipped: true,
|
|
1345
|
+
stage: args.target_stage,
|
|
1346
|
+
message: verify.stdout.trim(),
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
const ext = await checkExtensionGates(args.issue, args.target_stage, effectiveWorktree);
|
|
1350
|
+
if (!ext.ok) {
|
|
1351
|
+
return JSON.stringify({
|
|
1352
|
+
ok: false,
|
|
1353
|
+
gate: args.target_stage,
|
|
1354
|
+
reason: ext.reason,
|
|
1355
|
+
marker_path: ext.marker_path,
|
|
1356
|
+
agent: spec.id,
|
|
1357
|
+
stage: args.target_stage,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
// Model selection: override wins, then policy primary.
|
|
1361
|
+
// activeXxx variables are mutated on fallback switch (session_gone
|
|
1362
|
+
// recovery after MAX_ATTEMPTS on primary). All references inside the
|
|
1363
|
+
// dispatch loop MUST use activeModelFull/activeProviderID/activeModelID
|
|
1364
|
+
// so a switch takes effect immediately on the next iteration.
|
|
1365
|
+
const policy = POLICIES[spec.id];
|
|
1366
|
+
let activeModelFull = args.model_override ?? policy?.primary?.id ?? "github-copilot/claude-sonnet-4.6";
|
|
1367
|
+
const parseModelId = (full) => {
|
|
1368
|
+
const idx = full.indexOf("/");
|
|
1369
|
+
return {
|
|
1370
|
+
providerID: idx > 0 ? full.slice(0, idx) : "github-copilot",
|
|
1371
|
+
modelID: idx > 0 ? full.slice(idx + 1) : full,
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
let { providerID: activeProviderID, modelID: activeModelID } = parseModelId(activeModelFull);
|
|
1375
|
+
const specPath = `${STAGES_DIR}/${STAGE_SPEC_FILES[args.target_stage]}`;
|
|
1376
|
+
if (!existsSync(specPath)) {
|
|
1377
|
+
return JSON.stringify({
|
|
1378
|
+
ok: false,
|
|
1379
|
+
error: "stage_spec_missing",
|
|
1380
|
+
stage: args.target_stage,
|
|
1381
|
+
spec_path: specPath,
|
|
1382
|
+
stages_dir: STAGES_DIR,
|
|
1383
|
+
reason: `Stage spec file not found at resolved path. STAGES_DIR was resolved to '${STAGES_DIR}'. ` +
|
|
1384
|
+
`Check: (1) makdoong2-team.json paths.stages override is correct, ` +
|
|
1385
|
+
`(2) npm package includes stages/ directory (see package.json files field), ` +
|
|
1386
|
+
`(3) reinstall via 'npx makdoong2-team install' if stages/ was removed.`,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
const lastVerdictReasonR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageJqPath(args.target_stage) + ".last_verdict_reason"}`
|
|
1390
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
1391
|
+
let lastVerdictReason = null;
|
|
1392
|
+
if (lastVerdictReasonR.exitCode === 0) {
|
|
1393
|
+
const raw = (lastVerdictReasonR.stdout?.toString() ?? "").trimEnd();
|
|
1394
|
+
if (raw && raw !== "null")
|
|
1395
|
+
lastVerdictReason = raw;
|
|
1396
|
+
}
|
|
1397
|
+
const lastVerdictStreakR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageJqPath(args.target_stage) + ".same_reason_streak"}`
|
|
1398
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
1399
|
+
const lastVerdictStreak = lastVerdictStreakR.exitCode === 0
|
|
1400
|
+
? (parseInt(lastVerdictStreakR.stdout?.toString().trim() || "0", 10) || 0)
|
|
1401
|
+
: 0;
|
|
1402
|
+
const buildPromptText = (attemptNum, priorSessionIds) => {
|
|
1403
|
+
const base = [
|
|
1404
|
+
`Working directory (ABSOLUTE): ${effectiveWorktree}`,
|
|
1405
|
+
`Scripts directory (ABSOLUTE): ${SCRIPTS_DIR}`,
|
|
1406
|
+
`Stages directory (ABSOLUTE): ${STAGES_DIR}`,
|
|
1407
|
+
`Issue: ${args.issue}`,
|
|
1408
|
+
`Stage spec: read ${specPath} and follow it strictly.`,
|
|
1409
|
+
`모든 state.sh / wt-sync-ignored.sh / config.sh 호출은 위 Scripts directory 경로를 사용하시오. \`$HOME/.config/opencode/scripts/\`나 \`scripts/\` 상대경로를 사용하지 마시오.`,
|
|
1410
|
+
`Stage 명세 파일은 위 Stages directory 경로에서 읽으시오. \`<SCRIPTS_DIR>/../stages/\` 상대경로를 사용하지 마시오.`,
|
|
1411
|
+
];
|
|
1412
|
+
if (args.target_stage === "2_implementation.dev") {
|
|
1413
|
+
base.push(`\n=== dev substage 요구사항 소스 우선순위 ===`, `구현 착수 전 요구사항은 반드시 다음 순서로 참조한다:`, ` a) FIRST — requirements-draft.md 를 우선 확인 (아래 bash 스니펫 그대로 실행):`, buildDraftPathReadSnippet(args.issue, " "), ` 파일이 존재하고 비어있지 않으면 이 파일이 요구사항의 진실의 원천이다.`, ` b) FALLBACK — draft_path 미기록/파일 부재/빈 파일 인 경우 Jira 이슈 조회:`, ` skill(name="jira-research") 로 works MCP 로드 →`, ` skill_mcp(mcp_name="works", tool_name="getIssue", arguments={"issueKey":"${args.issue}"})`, ` c) 두 소스 모두 접근 불가하면 사용자에게 상황을 보고하고 즉시 종료.`, `주의: requirements-draft.md 없이 Jira 이슈만으로 구현 범위를 재결정하지 말 것 — draft 가 없다는 것은 planning 이 부적절하게 진행된 신호이므로 사용자에게 보고하고 종료하시오.`);
|
|
1414
|
+
}
|
|
1415
|
+
if (attemptNum > 1) {
|
|
1416
|
+
base.push(`\n=== 재개(resume) 지시 — 이전 세션 중단됨 ===`, `이전 세션 ID: ${priorSessionIds.join(", ")} (attempt ${attemptNum - 1})`, `중단 원인: 이전 sub-session이 stall/gone 감지되어 새 세션으로 이어서 진행합니다.`, `context 승계 방식: opencode API는 세션 간 대화 이력을 옮기지 못하므로 state.json 을 진실의 원천으로 사용합니다.`, `첫 번째 필수 작업:`, ` 1) bash ${SCRIPTS_DIR}/state.sh get ${args.issue} '.' 로 현재 상태 전량 조회`, ` 2) 이미 done=true 로 기록된 substage / 마커는 재실행하지 말고 skip`, ` 3) 미완료 substage 부터 stage spec 순서대로 이어서 진행`, ` 4) 완료 시 관례대로 요약 출력 후 종료`, `주의: state.json 마커가 이미 target substage 완료를 나타내면 즉시 요약만 출력하고 종료하시오 (재작업 금지).`);
|
|
1417
|
+
}
|
|
1418
|
+
if (lastVerdictReason) {
|
|
1419
|
+
base.push(`\n=== 이전 검증 실패 사유 (재작업 시 참고) ===`, `직전 attempt 가 makdoong2-verifier 에 의해 REJECTED 되었다. 같은 사유로 다시 실패하면 무한 루프로 판정된다 (동일 사유 ${lastVerdictStreak}회 연속 감지 중, 5회 도달 시 자동 중단).`, `아래 verifier 판정 원문을 읽고 지적된 규칙 위반·마커 누락·검증 실패 항목을 최우선으로 해결하시오. 동일한 접근을 반복하지 말고 finding 에 지적된 대안 (파일 재분할·마커 재기록·형식 수정 등) 을 시도하시오.`, `--- verifier 판정 원문 (앞 4000자) ---`, lastVerdictReason, `--- 원문 끝 ---`);
|
|
1420
|
+
}
|
|
1421
|
+
if (args.context)
|
|
1422
|
+
base.push(args.context);
|
|
1423
|
+
return base.join("\n");
|
|
1424
|
+
};
|
|
1425
|
+
const parentSessionID = context?.sessionID ?? dispatchParentSessionID(undefined);
|
|
1426
|
+
if (!parentSessionID) {
|
|
1427
|
+
return JSON.stringify({
|
|
1428
|
+
ok: false,
|
|
1429
|
+
reason: "dispatch_stage 호출자의 sessionID를 얻지 못했다. ToolContext.sessionID 미제공 및 hook fallback 실패.",
|
|
1430
|
+
stage: args.target_stage,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
logger.debug(`[dispatch_stage] creating sub-session with parentID=${parentSessionID} target=${args.target_stage} agent=${spec.id}`);
|
|
1434
|
+
// session_gone auto-redispatch: pollSubSession이 kind:"session_gone" 을
|
|
1435
|
+
// 반환하는 두 경로 모두 여기서 재시도된다:
|
|
1436
|
+
// - status-absent: orphan detection이 세션을 status map에서 제거한 경우
|
|
1437
|
+
// - reason="message_stall": 세션은 살아있지만 LLM API 무응답 (upstream
|
|
1438
|
+
// provider silent, quota 소진, 로컬 LLM 서버 hang 등)
|
|
1439
|
+
// opencode SDK가 세션 간 대화 이력 이관을 지원하지 않으므로 새 세션은
|
|
1440
|
+
// state.json 완료 마커를 진실의 원천으로 사용해 이어서 진행한다.
|
|
1441
|
+
// 지수 백오프: attempt별 messageStallThresholdMs 를 1분→2분→4분으로
|
|
1442
|
+
// 늘려 slow-first-token 모델에 여유를 준다.
|
|
1443
|
+
//
|
|
1444
|
+
// fallback_model auto-switch: primary 모델이 MAX_ATTEMPTS(3) 동안 모두
|
|
1445
|
+
// status_absent session_gone 으로 실패하면 model-fallback-policy 의
|
|
1446
|
+
// nextModel() 이 반환하는 fallback (설정에서 fallback_models 로 지정)
|
|
1447
|
+
// 로 자동 전환한다. 한 단계만 허용 (activeFallbackDepth=1) 하여
|
|
1448
|
+
// 무한 fallback 루프를 방지하고, fallback 은 MAX_FALLBACK_ATTEMPTS(2)
|
|
1449
|
+
// 만 재시도한다. 다음 케이스에서는 fallback switch 를 skip 한다:
|
|
1450
|
+
// - user model_override 존재: 사용자가 명시 선택한 모델을 무시하지 않는다
|
|
1451
|
+
// - message_stall: LLM API hang 은 모델 교체로 해소되지 않는 문제일
|
|
1452
|
+
// 가능성이 높으므로 primary 만 재시도하고 종료
|
|
1453
|
+
const MAX_ATTEMPTS = 3;
|
|
1454
|
+
const MAX_FALLBACK_ATTEMPTS = 2;
|
|
1455
|
+
let attempt = 0;
|
|
1456
|
+
let activeFallbackDepth = 0;
|
|
1457
|
+
const maxAttemptsForCurrentModel = () => activeFallbackDepth === 0 ? MAX_ATTEMPTS : MAX_FALLBACK_ATTEMPTS;
|
|
1458
|
+
let finalResultJson = null;
|
|
1459
|
+
const attemptSessionIds = [];
|
|
1460
|
+
while (attempt < maxAttemptsForCurrentModel() && finalResultJson === null) {
|
|
1461
|
+
attempt++;
|
|
1462
|
+
const isRetry = attempt > 1;
|
|
1463
|
+
const createResult = await client.session
|
|
1464
|
+
.create({
|
|
1465
|
+
body: {
|
|
1466
|
+
parentID: parentSessionID,
|
|
1467
|
+
title: `${args.target_stage} (@${spec.id})${isRetry ? ` [retry ${attempt}]` : ""}`,
|
|
1468
|
+
},
|
|
1469
|
+
query: { directory: effectiveWorktree },
|
|
1470
|
+
})
|
|
1471
|
+
.catch((e) => ({ error: e, data: null }));
|
|
1472
|
+
logger.debug(`[dispatch_stage] session.create: directory=${effectiveWorktree} ` +
|
|
1473
|
+
`parentID=${parentSessionID} title="${args.target_stage} (@${spec.id})"`);
|
|
1474
|
+
if (createResult.error || !createResult.data) {
|
|
1475
|
+
finalResultJson = JSON.stringify({
|
|
1476
|
+
ok: false,
|
|
1477
|
+
reason: `session create failed (attempt=${attempt}): ${JSON.stringify(createResult.error)}`,
|
|
1478
|
+
stage: args.target_stage,
|
|
1479
|
+
attempts: attempt,
|
|
1480
|
+
});
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
const subSessionID = createResult.data.id;
|
|
1484
|
+
attemptSessionIds.push(subSessionID);
|
|
1485
|
+
logger.debug(`[dispatch_stage] sub-session created: subSessionID=${subSessionID} ` +
|
|
1486
|
+
`parentID=${parentSessionID} attempt=${attempt}`);
|
|
1487
|
+
if (isRetry) {
|
|
1488
|
+
logger.warn(`[dispatch_stage] REDISPATCH attempt=${attempt}/${MAX_ATTEMPTS} ` +
|
|
1489
|
+
`previous_session_gone → new_session_id=${subSessionID} stage=${args.target_stage}`);
|
|
1490
|
+
}
|
|
1491
|
+
sessionIssue.set(subSessionID, args.issue);
|
|
1492
|
+
subSessionIds.add(subSessionID);
|
|
1493
|
+
pendingDispatch.set(subSessionID, {
|
|
1494
|
+
stage: args.target_stage,
|
|
1495
|
+
agent: spec.id,
|
|
1496
|
+
worktree: effectiveWorktree,
|
|
1497
|
+
startedAt: Date.now(),
|
|
1498
|
+
});
|
|
1499
|
+
sessionWorktree.set(subSessionID, effectiveWorktree);
|
|
1500
|
+
appendSessionIndex({
|
|
1501
|
+
sessionID: subSessionID,
|
|
1502
|
+
agent: spec.id,
|
|
1503
|
+
worktree: effectiveWorktree,
|
|
1504
|
+
issue: args.issue,
|
|
1505
|
+
stage: args.target_stage,
|
|
1506
|
+
createdAt: new Date().toISOString(),
|
|
1507
|
+
});
|
|
1508
|
+
let sessionGone = false;
|
|
1509
|
+
let success = false;
|
|
1510
|
+
try {
|
|
1511
|
+
// fire-and-observe: sync `prompt` drives model execution inline without
|
|
1512
|
+
// relying on an external wake signal (OMO or otherwise). We do NOT await
|
|
1513
|
+
// here so that spawnPaneForSession + pollSubSession run concurrently and
|
|
1514
|
+
// provide real-time tmux monitoring. pollSubSession remains the authoritative
|
|
1515
|
+
// completion signal; prompt drives the work, poll observes it.
|
|
1516
|
+
const attemptPromptText = buildPromptText(attempt, attemptSessionIds.slice(0, -1));
|
|
1517
|
+
const promptPromise = client.session
|
|
1518
|
+
.prompt({
|
|
1519
|
+
path: { id: subSessionID },
|
|
1520
|
+
body: {
|
|
1521
|
+
agent: spec.id,
|
|
1522
|
+
tools: { question: false },
|
|
1523
|
+
parts: [{ type: "text", text: attemptPromptText }],
|
|
1524
|
+
model: { providerID: activeProviderID, modelID: activeModelID },
|
|
1525
|
+
},
|
|
1526
|
+
query: { directory: effectiveWorktree },
|
|
1527
|
+
})
|
|
1528
|
+
.catch((e) => ({ error: e }));
|
|
1529
|
+
// Fast-fail: a successful model run holds the connection open for the full
|
|
1530
|
+
// run duration, so any response arriving within 2 s is an HTTP-level failure
|
|
1531
|
+
// (404 unknown session, 400 invalid agent, etc.).
|
|
1532
|
+
const earlyResult = await Promise.race([
|
|
1533
|
+
promptPromise,
|
|
1534
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 2_000)),
|
|
1535
|
+
]);
|
|
1536
|
+
if (earlyResult !== "pending" && earlyResult?.error) {
|
|
1537
|
+
finalResultJson = JSON.stringify({
|
|
1538
|
+
ok: false,
|
|
1539
|
+
reason: `prompt failed (attempt=${attempt}): ${JSON.stringify(earlyResult.error)}`,
|
|
1540
|
+
stage: args.target_stage,
|
|
1541
|
+
attempts: attempt,
|
|
1542
|
+
});
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
await spawnPaneForSession(subSessionID);
|
|
1546
|
+
logger.debug(`[dispatch_stage] engineer session ready — ` +
|
|
1547
|
+
`session_id=${subSessionID} stage=${args.target_stage} attempt=${attempt}\n` +
|
|
1548
|
+
` monitor: opencode attach http://127.0.0.1:44707 --session ${subSessionID}`);
|
|
1549
|
+
const nudgeText = [
|
|
1550
|
+
"⚠ 작업 시한 80% 도달 — 현재 작업을 마무리하고 즉시 세션을 종료하시오.",
|
|
1551
|
+
"",
|
|
1552
|
+
"허용된 남은 작업:",
|
|
1553
|
+
"1. 진행 중인 단일 tool call 완료",
|
|
1554
|
+
`2. bash ${SCRIPTS_DIR}/state.sh 로 .done 마커 확인 후 완료 시 true 설정`,
|
|
1555
|
+
"3. 3줄 이상 한국어 요약 텍스트 출력:",
|
|
1556
|
+
" - 처리한 substage 결과 (완료/차단/조기종료)",
|
|
1557
|
+
" - 변경한 state.json 마커 목록",
|
|
1558
|
+
" - 다음 단계 안내",
|
|
1559
|
+
"",
|
|
1560
|
+
"금지: 새 tool 호출 추가. 요약 텍스트 출력 직후 즉시 종료.",
|
|
1561
|
+
].join("\n");
|
|
1562
|
+
const engineerNudge = async (sid, elapsedMs) => {
|
|
1563
|
+
logger.debug(`[dispatch_stage] NUDGE sid=${sid} elapsed=${Math.round(elapsedMs / 1000)}s`);
|
|
1564
|
+
await client.session
|
|
1565
|
+
.promptAsync({
|
|
1566
|
+
path: { id: sid },
|
|
1567
|
+
body: {
|
|
1568
|
+
parts: [{ type: "text", text: nudgeText }],
|
|
1569
|
+
model: { providerID: activeProviderID, modelID: activeModelID },
|
|
1570
|
+
},
|
|
1571
|
+
query: { directory: effectiveWorktree },
|
|
1572
|
+
})
|
|
1573
|
+
.catch(() => undefined);
|
|
1574
|
+
};
|
|
1575
|
+
const messageStallForAttempt = MESSAGE_STALL_BACKOFF_MS[Math.min(attempt - 1, MESSAGE_STALL_BACKOFF_MS.length - 1)];
|
|
1576
|
+
const outcome = await pollSubSession(subSessionID, getEffectiveTimeoutMs(spec.id), effectiveWorktree, engineerNudge, messageStallForAttempt);
|
|
1577
|
+
let finalOutcome = outcome;
|
|
1578
|
+
let finalLegacy = pollOutcomeToLegacy(outcome);
|
|
1579
|
+
if (outcome.kind === "session_gone") {
|
|
1580
|
+
const isMessageStall = outcome.reason === "message_stall";
|
|
1581
|
+
// status-absent gone 은 session.abort/delete 시 opencode NotFoundError 를
|
|
1582
|
+
// 부모 세션에 fire 하므로 skipSessionOps=true. message_stall 은 세션이
|
|
1583
|
+
// 살아있어 정상적으로 abort 가능하므로 일반 cleanup 경로를 탄다.
|
|
1584
|
+
sessionGone = !isMessageStall ? true : false;
|
|
1585
|
+
promptPromise.catch(() => { });
|
|
1586
|
+
// pollSubSession 이 이미 abort() 를 fire 했지만 opencode 서버는
|
|
1587
|
+
// 잠시 후 session.deleted 이벤트를 fire 한다. 그 전에 redispatch 하면
|
|
1588
|
+
// 좀비 sub-agent 가 계속 tool call 을 발사해 새 세션과 worktree 상태가
|
|
1589
|
+
// 충돌할 수 있다. session.deleted 이벤트를 최대 30s 대기해 race window 를 닫는다.
|
|
1590
|
+
if (isMessageStall) {
|
|
1591
|
+
const deleted = await waitForSessionDeleted(subSessionID, SESSION_DELETED_WAIT_MS);
|
|
1592
|
+
logger.debug(`[dispatch_stage] MESSAGE_STALL abort settled — session=${subSessionID} ` +
|
|
1593
|
+
`deleted_event_received=${deleted} wait_ms=${SESSION_DELETED_WAIT_MS}`);
|
|
1594
|
+
}
|
|
1595
|
+
const goneLabel = isMessageStall ? "MESSAGE_STALL" : "SESSION_GONE";
|
|
1596
|
+
const currentMaxAttempts = maxAttemptsForCurrentModel();
|
|
1597
|
+
const hangEntry = JSON.stringify({
|
|
1598
|
+
attempt,
|
|
1599
|
+
at: new Date().toISOString(),
|
|
1600
|
+
reason: isMessageStall ? "message_stall" : "status_absent",
|
|
1601
|
+
elapsed_ms: outcome.elapsedMs,
|
|
1602
|
+
polls: outcome.polls,
|
|
1603
|
+
session_id: subSessionID,
|
|
1604
|
+
model: activeModelFull,
|
|
1605
|
+
fallback_depth: activeFallbackDepth,
|
|
1606
|
+
final: attempt >= currentMaxAttempts,
|
|
1607
|
+
});
|
|
1608
|
+
const hangJqPath = stageJqPath(args.target_stage) + ".hang_history";
|
|
1609
|
+
const hangR = await $ `bash ${SCRIPTS_DIR}/state.sh append ${args.issue} ${hangJqPath} ${hangEntry}`
|
|
1610
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
1611
|
+
if (hangR.exitCode !== 0) {
|
|
1612
|
+
logger.debug(`[hang_history] append failed issue=${args.issue} stage=${args.target_stage} ` +
|
|
1613
|
+
`exit=${hangR.exitCode} stderr=${redactAndTruncate(hangR.stderr?.toString() ?? "", 120)}`);
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
logger.debug(`[hang_history] recorded issue=${args.issue} stage=${args.target_stage} ` +
|
|
1617
|
+
`attempt=${attempt} reason=${isMessageStall ? "message_stall" : "status_absent"} ` +
|
|
1618
|
+
`model=${activeModelFull} fallback_depth=${activeFallbackDepth} final=${attempt >= currentMaxAttempts}`);
|
|
1619
|
+
}
|
|
1620
|
+
if (attempt < currentMaxAttempts) {
|
|
1621
|
+
logger.warn(`[dispatch_stage] ${goneLabel} session=${subSessionID} stage=${args.target_stage} ` +
|
|
1622
|
+
`attempt=${attempt}/${currentMaxAttempts} model=${activeModelFull} ` +
|
|
1623
|
+
`fallback_depth=${activeFallbackDepth} threshold_ms=${messageStallForAttempt} ` +
|
|
1624
|
+
`— will redispatch new session with state.json resume instructions`);
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
// Current model exhausted its attempt budget. Before terminating,
|
|
1628
|
+
// try (1) session_gone done-override, then (2) fallback_model switch.
|
|
1629
|
+
const donePath = `${stageJqPath(args.target_stage)}.done`;
|
|
1630
|
+
const doneResult = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${donePath}`
|
|
1631
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
1632
|
+
const doneValue = doneResult.exitCode === 0
|
|
1633
|
+
? (doneResult.stdout?.toString().trim() ?? null)
|
|
1634
|
+
: null;
|
|
1635
|
+
if (shouldOverrideSessionGoneOutcome(outcome.kind, false, doneValue)) {
|
|
1636
|
+
logger.warn(`[dispatch_stage] session_gone override: session=${subSessionID} ` +
|
|
1637
|
+
`stage=${args.target_stage} model=${activeModelFull} .done=true — treating as success`);
|
|
1638
|
+
finalResultJson = JSON.stringify({
|
|
1639
|
+
ok: true,
|
|
1640
|
+
stage: args.target_stage,
|
|
1641
|
+
agent: spec.id,
|
|
1642
|
+
model: activeModelFull,
|
|
1643
|
+
session_id: subSessionID,
|
|
1644
|
+
previous_session_ids: attemptSessionIds.slice(0, -1),
|
|
1645
|
+
outcome_kind: "session_gone",
|
|
1646
|
+
gone_reason: isMessageStall ? "message_stall" : "status_absent",
|
|
1647
|
+
polls: finalOutcome.polls,
|
|
1648
|
+
elapsed_ms: finalOutcome.elapsedMs,
|
|
1649
|
+
attempts: attempt,
|
|
1650
|
+
fallback_depth: activeFallbackDepth,
|
|
1651
|
+
reason: `sub-session disappeared but ${donePath}=true in state.json — ` +
|
|
1652
|
+
`treated as success (session_gone override)`,
|
|
1653
|
+
});
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
// Fallback switch: only from primary (depth=0), no user override,
|
|
1657
|
+
// and only on status_absent (message_stall stays on same model).
|
|
1658
|
+
if (activeFallbackDepth === 0 &&
|
|
1659
|
+
!isMessageStall &&
|
|
1660
|
+
!args.model_override) {
|
|
1661
|
+
const next = nextModel({
|
|
1662
|
+
agent: spec.id,
|
|
1663
|
+
current: activeModelFull,
|
|
1664
|
+
reason: "session_gone_after_max_attempts",
|
|
1665
|
+
});
|
|
1666
|
+
if (!next.exhausted && next.next) {
|
|
1667
|
+
const previousModel = activeModelFull;
|
|
1668
|
+
activeModelFull = next.next.id;
|
|
1669
|
+
const parsed = parseModelId(activeModelFull);
|
|
1670
|
+
activeProviderID = parsed.providerID;
|
|
1671
|
+
activeModelID = parsed.modelID;
|
|
1672
|
+
activeFallbackDepth = 1;
|
|
1673
|
+
attempt = 0;
|
|
1674
|
+
logger.warn(`[dispatch_stage] FALLBACK_SWITCH agent=${spec.id} ` +
|
|
1675
|
+
`from=${previousModel} to=${activeModelFull} ` +
|
|
1676
|
+
`reason=session_gone_after_${MAX_ATTEMPTS}_attempts stage=${args.target_stage} ` +
|
|
1677
|
+
`— retrying with fallback model (max ${MAX_FALLBACK_ATTEMPTS} attempts)`);
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
logger.debug(`[dispatch_stage] fallback exhausted for agent=${spec.id} model=${activeModelFull} ` +
|
|
1681
|
+
`— no next model available in policy chain`);
|
|
1682
|
+
}
|
|
1683
|
+
finalResultJson = JSON.stringify({
|
|
1684
|
+
ok: false,
|
|
1685
|
+
stage: args.target_stage,
|
|
1686
|
+
agent: spec.id,
|
|
1687
|
+
model: activeModelFull,
|
|
1688
|
+
session_id: subSessionID,
|
|
1689
|
+
previous_session_ids: attemptSessionIds.slice(0, -1),
|
|
1690
|
+
outcome_kind: "session_gone",
|
|
1691
|
+
gone_reason: isMessageStall ? "message_stall" : "status_absent",
|
|
1692
|
+
polls: finalOutcome.polls,
|
|
1693
|
+
elapsed_ms: finalOutcome.elapsedMs,
|
|
1694
|
+
attempts: attempt,
|
|
1695
|
+
fallback_depth: activeFallbackDepth,
|
|
1696
|
+
reason: isMessageStall
|
|
1697
|
+
? `sub-session busy but produced no assistant message across ${MAX_ATTEMPTS} attempts ` +
|
|
1698
|
+
`(exponential backoff ${MESSAGE_STALL_BACKOFF_MS.join("/")}ms). ` +
|
|
1699
|
+
`LLM API hang suspected (upstream provider silent, quota exhausted, or local model server down). ` +
|
|
1700
|
+
`Report to user and try get_fallback_model or wait for provider recovery.`
|
|
1701
|
+
: activeFallbackDepth > 0
|
|
1702
|
+
? `sub-session repeatedly disappeared across primary (${MAX_ATTEMPTS}) + fallback (${MAX_FALLBACK_ATTEMPTS}) attempts. ` +
|
|
1703
|
+
`Both models failed with status_absent gone. ` +
|
|
1704
|
+
`Likely opencode server-side issue (session storage corruption, OOM, crash) or persistent provider outage. ` +
|
|
1705
|
+
`Check opencode server logs. Do NOT retry immediately; report to user.`
|
|
1706
|
+
: `sub-session repeatedly disappeared across ${MAX_ATTEMPTS} attempts. ` +
|
|
1707
|
+
`Likely opencode server-side issue (session storage corruption, OOM, crash). ` +
|
|
1708
|
+
`Check opencode server logs. Do NOT retry immediately; report to user.`,
|
|
1709
|
+
});
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
if (outcome.kind === "empty") {
|
|
1713
|
+
const isPreambleOnly = outcome.reason === "preamble_only";
|
|
1714
|
+
logger.debug(`[dispatch_stage] empty output detected — sending one-time ${isPreambleOnly ? "action" : "summary"} re-prompt: ` +
|
|
1715
|
+
`session=${subSessionID} stage=${args.target_stage} reason=${outcome.reason}`);
|
|
1716
|
+
const retryText = isPreambleOnly
|
|
1717
|
+
? `직전 응답이 서두 텍스트만 있었고 실제 substage 작업이 수행되지 않았다. ` +
|
|
1718
|
+
`설명 없이 지금 즉시 필요한 tool 을 호출하여 ${args.target_stage} substage 를 완결하라. ` +
|
|
1719
|
+
`완료 조건은 stages/*.md 정의를 따르고, state.json 의 해당 substage 마커 (self_check, done 등) 를 반드시 세팅해야 한다. ` +
|
|
1720
|
+
`Tool 호출부터 시작 — preamble 금지.`
|
|
1721
|
+
: "이전 작업 결과를 한국어로 최소 3줄 요약 후 종료하라:\n1. 처리한 substage 이름과 결과 (완료/차단/조기종료)\n2. 변경한 state.json 마커 목록\n3. 다음 단계 안내";
|
|
1722
|
+
const retryPromptPromise = client.session
|
|
1723
|
+
.prompt({
|
|
1724
|
+
path: { id: subSessionID },
|
|
1725
|
+
body: {
|
|
1726
|
+
agent: spec.id,
|
|
1727
|
+
tools: { question: false },
|
|
1728
|
+
parts: [{ type: "text", text: retryText }],
|
|
1729
|
+
model: { providerID: activeProviderID, modelID: activeModelID },
|
|
1730
|
+
},
|
|
1731
|
+
query: { directory: effectiveWorktree },
|
|
1732
|
+
})
|
|
1733
|
+
.catch((e) => ({ error: e }));
|
|
1734
|
+
const retryEarly = await Promise.race([
|
|
1735
|
+
retryPromptPromise,
|
|
1736
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 2_000)),
|
|
1737
|
+
]);
|
|
1738
|
+
if (!(retryEarly !== "pending" && retryEarly?.error)) {
|
|
1739
|
+
const retryOutcome = await pollSubSession(subSessionID, substageTimeoutMs, effectiveWorktree);
|
|
1740
|
+
retryPromptPromise.catch(() => { });
|
|
1741
|
+
if (retryOutcome.kind === "text") {
|
|
1742
|
+
finalOutcome = retryOutcome;
|
|
1743
|
+
finalLegacy = pollOutcomeToLegacy(retryOutcome);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
else {
|
|
1747
|
+
retryPromptPromise.catch(() => { });
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
success = finalLegacy.success;
|
|
1751
|
+
let overriddenReason;
|
|
1752
|
+
if (!success && finalOutcome.kind === "empty") {
|
|
1753
|
+
const donePath = `${stageJqPath(args.target_stage)}.done`;
|
|
1754
|
+
const doneResult = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${donePath}`
|
|
1755
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
1756
|
+
const doneValue = doneResult.exitCode === 0
|
|
1757
|
+
? (doneResult.stdout?.toString().trim() ?? null)
|
|
1758
|
+
: null;
|
|
1759
|
+
if (shouldOverrideEmptyOutcome(finalOutcome.kind, success, doneValue)) {
|
|
1760
|
+
logger.debug(`[dispatch_stage] empty output override: session=${subSessionID} ` +
|
|
1761
|
+
`stage=${args.target_stage} .done=true — treating as success`);
|
|
1762
|
+
success = true;
|
|
1763
|
+
overriddenReason =
|
|
1764
|
+
`sub-agent produced no final text but ${donePath}=true in state.json — ` +
|
|
1765
|
+
`treated as success (local-model text-omission compensation)`;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
promptPromise.catch(() => { });
|
|
1769
|
+
if (success) {
|
|
1770
|
+
const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
|
|
1771
|
+
const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
|
|
1772
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
1773
|
+
logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
|
|
1774
|
+
`exit=${resetR.exitCode} — substage succeeded`);
|
|
1775
|
+
}
|
|
1776
|
+
const retryDisallowed = finalOutcome.kind === "timeout" &&
|
|
1777
|
+
finalOutcome.transientFailures === 0;
|
|
1778
|
+
const retryDisallowedReason = retryDisallowed
|
|
1779
|
+
? `sub-agent 이 ${Math.round(finalOutcome.elapsedMs / 60_000)}분 동안 응답 없음 (polls=${finalOutcome.polls}, transient_failures=0). ` +
|
|
1780
|
+
`네트워크·API 오류 없이 hang 이므로 model/prompt 이슈일 가능성이 높음. ` +
|
|
1781
|
+
`동일 dispatch_stage 를 재호출하지 말고 사용자에게 상황을 보고한 뒤 지시를 기다리거나 get_fallback_model 로 다른 모델을 요청하세요.`
|
|
1782
|
+
: undefined;
|
|
1783
|
+
finalResultJson = JSON.stringify({
|
|
1784
|
+
ok: success,
|
|
1785
|
+
stage: args.target_stage,
|
|
1786
|
+
agent: spec.id,
|
|
1787
|
+
model: activeModelFull,
|
|
1788
|
+
session_id: subSessionID,
|
|
1789
|
+
previous_session_ids: attemptSessionIds.slice(0, -1),
|
|
1790
|
+
attempts: attempt,
|
|
1791
|
+
fallback_depth: activeFallbackDepth,
|
|
1792
|
+
output: finalLegacy.text.slice(0, 8000),
|
|
1793
|
+
outcome_kind: finalOutcome.kind,
|
|
1794
|
+
polls: finalOutcome.polls,
|
|
1795
|
+
elapsed_ms: finalOutcome.elapsedMs,
|
|
1796
|
+
transient_failures: finalOutcome.kind === "timeout"
|
|
1797
|
+
? finalOutcome.transientFailures
|
|
1798
|
+
: undefined,
|
|
1799
|
+
retry_disallowed: retryDisallowed || undefined,
|
|
1800
|
+
retry_disallowed_reason: retryDisallowedReason,
|
|
1801
|
+
reason: success
|
|
1802
|
+
? overriddenReason
|
|
1803
|
+
: finalLegacy.text,
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
finally {
|
|
1807
|
+
if (effectiveWorktree !== cwd) {
|
|
1808
|
+
logger.debug(`[wt-sync] REVERSE issue=${args.issue} worktree=${effectiveWorktree} ` +
|
|
1809
|
+
`caller=dispatch_stage stage=${args.target_stage} attempt=${attempt}`);
|
|
1810
|
+
const syncResult = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh --reverse ${effectiveWorktree} ${args.issue}`
|
|
1811
|
+
.cwd(cwd).quiet().nothrow();
|
|
1812
|
+
if (syncResult.exitCode !== 0) {
|
|
1813
|
+
logger.warn(`[wt-sync] REVERSE FAIL issue=${args.issue} exit=${syncResult.exitCode} ` +
|
|
1814
|
+
`stderr=${redactAndTruncate(syncResult.stderr?.toString() ?? "", 200)}`);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
await cleanupSubSession(subSessionID, {
|
|
1818
|
+
success,
|
|
1819
|
+
reason: `dispatch_stage finally stage=${args.target_stage} attempt=${attempt}`,
|
|
1820
|
+
skipSessionOps: sessionGone,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return finalResultJson ?? JSON.stringify({
|
|
1825
|
+
ok: false,
|
|
1826
|
+
stage: args.target_stage,
|
|
1827
|
+
reason: `dispatch_stage exited loop without result (attempts=${attempt})`,
|
|
1828
|
+
attempts: attempt,
|
|
1829
|
+
});
|
|
1830
|
+
},
|
|
1831
|
+
}),
|
|
1832
|
+
/**
|
|
1833
|
+
* dispatch_verifier — spawn makdoong2-verifier to second-check a stage that
|
|
1834
|
+
* just reported done. Read-only sub-agent reads state.json self_check markers,
|
|
1835
|
+
* the stage spec, and the dispatcher's output, and emits a structured verdict
|
|
1836
|
+
* of `<verifier-verdict>VERIFIED</verifier-verdict>` or `REJECTED`.
|
|
1837
|
+
*/
|
|
1838
|
+
dispatch_verifier: tool({
|
|
1839
|
+
description: "Spawn the makdoong2-verifier sub-agent to second-check a stage's completion. " +
|
|
1840
|
+
"Returns { ok, verdict: 'VERIFIED' | 'REJECTED', raw, session_id }.",
|
|
1841
|
+
args: {
|
|
1842
|
+
issue: tool.schema.string().describe("Jira issue key, e.g. PROJ-12345"),
|
|
1843
|
+
stage: tool.schema.enum(STAGE_ORDER),
|
|
1844
|
+
worktree: tool.schema.string().describe("Absolute worktree path"),
|
|
1845
|
+
sub_agent_output: tool.schema.string().describe("Last assistant text from the just-dispatched stage agent (≤ 8000 chars)"),
|
|
1846
|
+
model_override: tool.schema.string().optional(),
|
|
1847
|
+
},
|
|
1848
|
+
async execute(args, context) {
|
|
1849
|
+
let success = false;
|
|
1850
|
+
const verifierId = "makdoong2-verifier";
|
|
1851
|
+
const policy = POLICIES[verifierId];
|
|
1852
|
+
const modelFull = args.model_override ?? policy?.primary?.id ?? "github-copilot/claude-sonnet-4.6";
|
|
1853
|
+
const slashIdx = modelFull.indexOf("/");
|
|
1854
|
+
const providerID = slashIdx > 0 ? modelFull.slice(0, slashIdx) : "github-copilot";
|
|
1855
|
+
const modelID = slashIdx > 0 ? modelFull.slice(slashIdx + 1) : modelFull;
|
|
1856
|
+
const verifierSpecPath = `${STAGES_DIR}/${STAGE_SPEC_FILES[args.stage]}`;
|
|
1857
|
+
if (!existsSync(verifierSpecPath)) {
|
|
1858
|
+
return JSON.stringify({
|
|
1859
|
+
ok: false,
|
|
1860
|
+
error: "stage_spec_missing",
|
|
1861
|
+
stage: args.stage,
|
|
1862
|
+
spec_path: verifierSpecPath,
|
|
1863
|
+
stages_dir: STAGES_DIR,
|
|
1864
|
+
reason: `Stage spec file not found at resolved path. STAGES_DIR was resolved to '${STAGES_DIR}'. ` +
|
|
1865
|
+
`Check: (1) makdoong2-team.json paths.stages override is correct, ` +
|
|
1866
|
+
`(2) npm package includes stages/ directory (see package.json files field), ` +
|
|
1867
|
+
`(3) reinstall via 'npx makdoong2-team install' if stages/ was removed.`,
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
if (args.worktree !== cwd) {
|
|
1871
|
+
logger.debug(`[wt-sync] FORWARD issue=${args.issue} worktree=${args.worktree} ` +
|
|
1872
|
+
`caller=dispatch_verifier stage=${args.stage}`);
|
|
1873
|
+
const fwdSync = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${args.worktree} ${args.issue}`
|
|
1874
|
+
.cwd(cwd).quiet().nothrow();
|
|
1875
|
+
if (fwdSync.exitCode !== 0) {
|
|
1876
|
+
logger.warn(`[wt-sync] FORWARD FAIL issue=${args.issue} caller=dispatch_verifier exit=${fwdSync.exitCode} ` +
|
|
1877
|
+
`stderr=${redactAndTruncate(fwdSync.stderr?.toString() ?? "", 200)}`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const promptText = [
|
|
1881
|
+
`Working directory (ABSOLUTE): ${args.worktree}`,
|
|
1882
|
+
`Scripts directory (ABSOLUTE): ${SCRIPTS_DIR}`,
|
|
1883
|
+
`Issue: ${args.issue}`,
|
|
1884
|
+
`Stage: ${args.stage}`,
|
|
1885
|
+
`Stage spec: read ${verifierSpecPath} and the agents/makdoong2-verifier.md system prompt for the verdict format.`,
|
|
1886
|
+
`모든 state.sh / wt-sync-ignored.sh / config.sh 호출은 위 Scripts directory 경로를 사용하시오. \`$HOME/.config/opencode/scripts/\`나 \`scripts/\` 상대경로를 사용하지 마시오.`,
|
|
1887
|
+
`Sub-agent output:`,
|
|
1888
|
+
"```",
|
|
1889
|
+
args.sub_agent_output.slice(0, 8000),
|
|
1890
|
+
"```",
|
|
1891
|
+
].join("\n");
|
|
1892
|
+
const parentSessionID = context?.sessionID ?? dispatchParentSessionID(undefined);
|
|
1893
|
+
if (!parentSessionID) {
|
|
1894
|
+
return JSON.stringify({
|
|
1895
|
+
ok: false,
|
|
1896
|
+
reason: "dispatch_verifier 호출자의 sessionID를 얻지 못했다. ToolContext.sessionID 미제공 및 hook fallback 실패.",
|
|
1897
|
+
stage: args.stage,
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
logger.debug(`[dispatch_verifier] creating sub-session with parentID=${parentSessionID} stage=${args.stage}`);
|
|
1901
|
+
const createResult = await client.session
|
|
1902
|
+
.create({
|
|
1903
|
+
body: {
|
|
1904
|
+
parentID: parentSessionID,
|
|
1905
|
+
title: `verifier:${args.stage} (@${verifierId})`,
|
|
1906
|
+
},
|
|
1907
|
+
query: { directory: args.worktree },
|
|
1908
|
+
})
|
|
1909
|
+
.catch((e) => ({ error: e, data: null }));
|
|
1910
|
+
if (createResult.error || !createResult.data) {
|
|
1911
|
+
return JSON.stringify({
|
|
1912
|
+
ok: false,
|
|
1913
|
+
reason: `verifier session create failed: ${JSON.stringify(createResult.error)}`,
|
|
1914
|
+
stage: args.stage,
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
const subSessionID = createResult.data.id;
|
|
1918
|
+
logger.debug(`[dispatch_verifier] sub-session created: subSessionID=${subSessionID} ` +
|
|
1919
|
+
`parentID=${parentSessionID}`);
|
|
1920
|
+
sessionIssue.set(subSessionID, args.issue);
|
|
1921
|
+
sessionWorktree.set(subSessionID, args.worktree);
|
|
1922
|
+
subSessionIds.add(subSessionID);
|
|
1923
|
+
pendingDispatch.set(subSessionID, {
|
|
1924
|
+
stage: `verify:${args.stage}`,
|
|
1925
|
+
agent: verifierId,
|
|
1926
|
+
worktree: args.worktree,
|
|
1927
|
+
startedAt: Date.now(),
|
|
1928
|
+
});
|
|
1929
|
+
appendSessionIndex({
|
|
1930
|
+
sessionID: subSessionID,
|
|
1931
|
+
agent: verifierId,
|
|
1932
|
+
worktree: args.worktree,
|
|
1933
|
+
issue: args.issue,
|
|
1934
|
+
stage: `verify:${args.stage}`,
|
|
1935
|
+
createdAt: new Date().toISOString(),
|
|
1936
|
+
});
|
|
1937
|
+
let raw = "";
|
|
1938
|
+
let verifierSessionGone = false;
|
|
1939
|
+
try {
|
|
1940
|
+
const promptPromise = client.session
|
|
1941
|
+
.prompt({
|
|
1942
|
+
path: { id: subSessionID },
|
|
1943
|
+
body: {
|
|
1944
|
+
agent: verifierId,
|
|
1945
|
+
tools: { question: false },
|
|
1946
|
+
parts: [{ type: "text", text: promptText }],
|
|
1947
|
+
model: { providerID, modelID },
|
|
1948
|
+
},
|
|
1949
|
+
query: { directory: args.worktree },
|
|
1950
|
+
})
|
|
1951
|
+
.catch((e) => ({ error: e }));
|
|
1952
|
+
const earlyResult = await Promise.race([
|
|
1953
|
+
promptPromise,
|
|
1954
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 2_000)),
|
|
1955
|
+
]);
|
|
1956
|
+
if (earlyResult !== "pending" && earlyResult?.error) {
|
|
1957
|
+
return JSON.stringify({
|
|
1958
|
+
ok: false,
|
|
1959
|
+
reason: `verifier prompt failed: ${JSON.stringify(earlyResult.error)}`,
|
|
1960
|
+
stage: args.stage,
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
await spawnPaneForSession(subSessionID);
|
|
1964
|
+
const outcome = await pollSubSession(subSessionID, getEffectiveTimeoutMs("makdoong2-verifier"), args.worktree, undefined, VERIFIER_STALL_THRESHOLD_MS);
|
|
1965
|
+
const legacy = pollOutcomeToLegacy(outcome);
|
|
1966
|
+
raw = legacy.text;
|
|
1967
|
+
success = legacy.success;
|
|
1968
|
+
if (outcome.kind === "session_gone") {
|
|
1969
|
+
verifierSessionGone = true;
|
|
1970
|
+
const stallReason = outcome.reason === "message_stall"
|
|
1971
|
+
? " (message_stall detected)"
|
|
1972
|
+
: "";
|
|
1973
|
+
logger.warn(`[dispatch_verifier] SESSION_GONE session=${subSessionID} stage=${args.stage}` +
|
|
1974
|
+
`${stallReason} — defaulting to REJECTED verdict, no retry ` +
|
|
1975
|
+
`(verifier is idempotent, team-leader can redispatch)`);
|
|
1976
|
+
const vHangEntry = JSON.stringify({
|
|
1977
|
+
attempt: 1,
|
|
1978
|
+
at: new Date().toISOString(),
|
|
1979
|
+
reason: outcome.reason === "message_stall" ? "message_stall" : "status_absent",
|
|
1980
|
+
elapsed_ms: outcome.elapsedMs,
|
|
1981
|
+
polls: outcome.polls,
|
|
1982
|
+
session_id: subSessionID,
|
|
1983
|
+
final: true,
|
|
1984
|
+
role: "verifier",
|
|
1985
|
+
});
|
|
1986
|
+
const vHangJqPath = stageJqPath(args.stage) + ".hang_history";
|
|
1987
|
+
const vHangR = await $ `bash ${SCRIPTS_DIR}/state.sh append ${args.issue} ${vHangJqPath} ${vHangEntry}`
|
|
1988
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
1989
|
+
if (vHangR.exitCode !== 0) {
|
|
1990
|
+
logger.debug(`[hang_history] verifier append failed issue=${args.issue} stage=${args.stage} ` +
|
|
1991
|
+
`exit=${vHangR.exitCode} stderr=${redactAndTruncate(vHangR.stderr?.toString() ?? "", 120)}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (outcome.reason === "message_stall") {
|
|
1994
|
+
const deleted = await waitForSessionDeleted(subSessionID, SESSION_DELETED_WAIT_MS);
|
|
1995
|
+
logger.debug(`[dispatch_verifier] MESSAGE_STALL abort settled — session=${subSessionID} ` +
|
|
1996
|
+
`deleted_event_received=${deleted} wait_ms=${SESSION_DELETED_WAIT_MS}`);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
promptPromise.catch(() => { });
|
|
2000
|
+
}
|
|
2001
|
+
finally {
|
|
2002
|
+
if (args.worktree !== cwd) {
|
|
2003
|
+
logger.debug(`[wt-sync] REVERSE issue=${args.issue} worktree=${args.worktree} ` +
|
|
2004
|
+
`caller=dispatch_verifier stage=${args.stage}`);
|
|
2005
|
+
const revSync = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh --reverse ${args.worktree} ${args.issue}`
|
|
2006
|
+
.cwd(cwd).quiet().nothrow();
|
|
2007
|
+
if (revSync.exitCode !== 0) {
|
|
2008
|
+
logger.warn(`[wt-sync] REVERSE FAIL issue=${args.issue} caller=dispatch_verifier exit=${revSync.exitCode} ` +
|
|
2009
|
+
`stderr=${redactAndTruncate(revSync.stderr?.toString() ?? "", 200)}`);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
await cleanupSubSession(subSessionID, {
|
|
2013
|
+
success,
|
|
2014
|
+
reason: `dispatch_verifier finally stage=${args.stage}`,
|
|
2015
|
+
skipSessionOps: verifierSessionGone,
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
const m = raw.match(/<verifier-verdict>\s*(VERIFIED|REJECTED)\s*<\/verifier-verdict>/i);
|
|
2019
|
+
const jsonFallback = !m ? raw.match(/"verdict"\s*:\s*"(VERIFIED|REJECTED)"/i) : null;
|
|
2020
|
+
const verdict = m
|
|
2021
|
+
? m[1].toUpperCase()
|
|
2022
|
+
: jsonFallback
|
|
2023
|
+
? jsonFallback[1].toUpperCase()
|
|
2024
|
+
: "REJECTED";
|
|
2025
|
+
const verdictSource = verifierSessionGone
|
|
2026
|
+
? "session_gone_default"
|
|
2027
|
+
: m
|
|
2028
|
+
? "verdict_tag"
|
|
2029
|
+
: jsonFallback
|
|
2030
|
+
? "json_fallback"
|
|
2031
|
+
: success
|
|
2032
|
+
? "malformed_output_default"
|
|
2033
|
+
: "session_failed_default";
|
|
2034
|
+
logger.debug(`[dispatch_verifier] VERDICT ${verdict} — issue=${args.issue} stage=${args.stage} ` +
|
|
2035
|
+
`session=${subSessionID} source=${verdictSource} success=${success}`);
|
|
2036
|
+
const stageBase = stageJqPath(args.stage);
|
|
2037
|
+
let sameReasonStreak = 0;
|
|
2038
|
+
let sameReasonStreakExceeded = false;
|
|
2039
|
+
let rejectedCount = 0;
|
|
2040
|
+
const SAME_REASON_STREAK_LIMIT = 5;
|
|
2041
|
+
if (verdict === "REJECTED") {
|
|
2042
|
+
const reasonText = raw.trim().slice(0, 4000);
|
|
2043
|
+
const reasonHash = computeVerdictHash(raw, args.stage);
|
|
2044
|
+
const prevHashR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".last_verdict_reason_hash"}`
|
|
2045
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2046
|
+
const prevHash = prevHashR.exitCode === 0
|
|
2047
|
+
? (prevHashR.stdout?.toString().trim() ?? "").replace(/^"|"$/g, "")
|
|
2048
|
+
: "";
|
|
2049
|
+
const prevStreakR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".same_reason_streak"}`
|
|
2050
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2051
|
+
const prevStreak = prevStreakR.exitCode === 0
|
|
2052
|
+
? (parseInt(prevStreakR.stdout?.toString().trim() || "0", 10) || 0)
|
|
2053
|
+
: 0;
|
|
2054
|
+
const prevCountR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".rejected_count"}`
|
|
2055
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2056
|
+
const prevCount = prevCountR.exitCode === 0
|
|
2057
|
+
? (parseInt(prevCountR.stdout?.toString().trim() || "0", 10) || 0)
|
|
2058
|
+
: 0;
|
|
2059
|
+
sameReasonStreak = prevHash === reasonHash ? prevStreak + 1 : 1;
|
|
2060
|
+
rejectedCount = prevCount + 1;
|
|
2061
|
+
sameReasonStreakExceeded = sameReasonStreak >= SAME_REASON_STREAK_LIMIT;
|
|
2062
|
+
const nowIso = new Date().toISOString();
|
|
2063
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".last_verdict_reason"} ${JSON.stringify(reasonText)}`
|
|
2064
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2065
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".last_verdict_reason_hash"} ${JSON.stringify(reasonHash)}`
|
|
2066
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2067
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".last_verdict_at"} ${JSON.stringify(nowIso)}`
|
|
2068
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2069
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".same_reason_streak"} ${String(sameReasonStreak)}`
|
|
2070
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2071
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".rejected_count"} ${String(rejectedCount)}`
|
|
2072
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2073
|
+
logger.debug(`[dispatch_verifier] REJECTED reason recorded — issue=${args.issue} stage=${args.stage} ` +
|
|
2074
|
+
`hash=${reasonHash} streak=${sameReasonStreak}/${SAME_REASON_STREAK_LIMIT} ` +
|
|
2075
|
+
`rejected_count=${rejectedCount}` +
|
|
2076
|
+
(sameReasonStreakExceeded ? ` STREAK_EXCEEDED` : ``));
|
|
2077
|
+
}
|
|
2078
|
+
else if (verdict === "VERIFIED") {
|
|
2079
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".last_verdict_reason"} null`
|
|
2080
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2081
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".last_verdict_reason_hash"} null`
|
|
2082
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2083
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".same_reason_streak"} 0`
|
|
2084
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2085
|
+
}
|
|
2086
|
+
await $ `bash ${SCRIPTS_DIR}/log-event.sh ${args.issue} verifier_verdict stage=${args.stage} verdict=${verdict} session=${subSessionID}`
|
|
2087
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2088
|
+
return JSON.stringify({
|
|
2089
|
+
ok: success,
|
|
2090
|
+
verdict,
|
|
2091
|
+
stage: args.stage,
|
|
2092
|
+
agent: verifierId,
|
|
2093
|
+
model: modelFull,
|
|
2094
|
+
session_id: subSessionID,
|
|
2095
|
+
raw: raw.slice(0, 8000),
|
|
2096
|
+
same_reason_streak: sameReasonStreak,
|
|
2097
|
+
same_reason_streak_exceeded: sameReasonStreakExceeded,
|
|
2098
|
+
rejected_count: rejectedCount,
|
|
2099
|
+
parsed: m
|
|
2100
|
+
? "verdict tag found"
|
|
2101
|
+
: jsonFallback
|
|
2102
|
+
? `verdict tag missing — extracted from JSON body (${jsonFallback[1]})`
|
|
2103
|
+
: success
|
|
2104
|
+
? "verdict tag missing — defaulted to REJECTED (verifier output malformed)"
|
|
2105
|
+
: `verifier session failed (${raw.slice(0, 120)})`,
|
|
2106
|
+
});
|
|
2107
|
+
},
|
|
2108
|
+
}),
|
|
2109
|
+
/**
|
|
2110
|
+
* get_fallback_model — fallback advisor.
|
|
2111
|
+
* Returns the next model in the chain, or exhausted=true.
|
|
2112
|
+
*/
|
|
2113
|
+
get_fallback_model: tool({
|
|
2114
|
+
description: "Return the next model in this agent's fallback chain after a failure (rate_limit/5xx/context_exceeded).",
|
|
2115
|
+
args: {
|
|
2116
|
+
agent: tool.schema.string(),
|
|
2117
|
+
current: tool.schema.string().describe("model id that just failed"),
|
|
2118
|
+
reason: tool.schema.string().optional().describe("rate_limit | context_exceeded | 5xx | other"),
|
|
2119
|
+
},
|
|
2120
|
+
async execute(args) {
|
|
2121
|
+
return JSON.stringify(nextModel(args));
|
|
2122
|
+
},
|
|
2123
|
+
}),
|
|
2124
|
+
/**
|
|
2125
|
+
* inspect_sub_sessions — list and optionally clean up child sessions for an issue.
|
|
2126
|
+
*/
|
|
2127
|
+
inspect_sub_sessions: tool({
|
|
2128
|
+
description: "이슈에 연결된 child 세션의 상태를 조회하고, orphan(부모 dispatch 완료 후 잔존 busy) 또는 " +
|
|
2129
|
+
"stale(지정 시간 초과 busy) 세션을 감지·정리한다. dispatch 사이 또는 워크플로우 이상 감지 시 호출.",
|
|
2130
|
+
args: {
|
|
2131
|
+
issue: tool.schema.string().describe("Jira issue key"),
|
|
2132
|
+
abort_orphans: tool.schema.boolean().optional()
|
|
2133
|
+
.describe("true: orphan 세션 abort. 기본 false (조회만)"),
|
|
2134
|
+
stale_minutes: tool.schema.number().optional()
|
|
2135
|
+
.describe("N분 초과 busy 세션을 stale로 판정. 미지정 시 stale 체크 안 함"),
|
|
2136
|
+
},
|
|
2137
|
+
async execute(args) {
|
|
2138
|
+
const statusResult = await client.session.status().catch(() => null);
|
|
2139
|
+
const statuses = (statusResult?.data ?? {});
|
|
2140
|
+
const nowMs = Date.now();
|
|
2141
|
+
const staleThresholdMs = args.stale_minutes != null ? args.stale_minutes * 60_000 : null;
|
|
2142
|
+
const rows = [];
|
|
2143
|
+
const toCleanup = [];
|
|
2144
|
+
for (const sid of subSessionIds) {
|
|
2145
|
+
const issueKey = sessionIssue.get(sid);
|
|
2146
|
+
if (!issueKey || issueKey !== args.issue)
|
|
2147
|
+
continue;
|
|
2148
|
+
const pd = pendingDispatch.get(sid);
|
|
2149
|
+
const rawStatus = statuses[sid]?.type ?? "gone";
|
|
2150
|
+
const elapsedMs = pd ? nowMs - pd.startedAt : 0;
|
|
2151
|
+
const elapsedMin = Math.round(elapsedMs / 60_000);
|
|
2152
|
+
const isOrphan = rawStatus === "busy" && !pd;
|
|
2153
|
+
const isStale = rawStatus === "busy" && pd != null && staleThresholdMs != null && elapsedMs > staleThresholdMs;
|
|
2154
|
+
rows.push({
|
|
2155
|
+
session_id: sid,
|
|
2156
|
+
issue: issueKey,
|
|
2157
|
+
stage: pd?.stage ?? "unknown",
|
|
2158
|
+
agent: pd?.agent ?? "unknown",
|
|
2159
|
+
status: rawStatus,
|
|
2160
|
+
elapsed_min: elapsedMin,
|
|
2161
|
+
is_orphan: isOrphan,
|
|
2162
|
+
is_stale: isStale,
|
|
2163
|
+
aborted: (isOrphan || isStale) && args.abort_orphans === true,
|
|
2164
|
+
});
|
|
2165
|
+
if ((isOrphan || isStale) && args.abort_orphans) {
|
|
2166
|
+
toCleanup.push({
|
|
2167
|
+
sid,
|
|
2168
|
+
reason: isOrphan
|
|
2169
|
+
? "inspect_sub_sessions: orphan (no pending dispatch)"
|
|
2170
|
+
: `inspect_sub_sessions: stale busy ${elapsedMin}min`,
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
for (const { sid, reason } of toCleanup) {
|
|
2175
|
+
await cleanupSubSession(sid, { success: false, reason });
|
|
2176
|
+
}
|
|
2177
|
+
return JSON.stringify({
|
|
2178
|
+
total: rows.length,
|
|
2179
|
+
orphans: rows.filter(r => r.is_orphan).length,
|
|
2180
|
+
stale: rows.filter(r => r.is_stale).length,
|
|
2181
|
+
aborted: rows.filter(r => r.aborted).length,
|
|
2182
|
+
sessions: rows,
|
|
2183
|
+
});
|
|
2184
|
+
},
|
|
2185
|
+
}),
|
|
2186
|
+
/**
|
|
2187
|
+
* auto_advance_stage — read state, compute next stage, return dispatch info.
|
|
2188
|
+
*/
|
|
2189
|
+
auto_advance_stage: tool({
|
|
2190
|
+
description: "Read current state, determine next stage, run gate, and return dispatch instruction.",
|
|
2191
|
+
args: {
|
|
2192
|
+
issue: tool.schema.string(),
|
|
2193
|
+
worktree: tool.schema.string().optional().describe("Absolute worktree path; defaults to plugin cwd"),
|
|
2194
|
+
},
|
|
2195
|
+
async execute(args, context) {
|
|
2196
|
+
const callerSession = context?.sessionID ?? dispatchParentSessionID(undefined);
|
|
2197
|
+
if (callerSession)
|
|
2198
|
+
sessionIssue.set(callerSession, args.issue);
|
|
2199
|
+
const effectiveCwd = args.worktree ?? cwd;
|
|
2200
|
+
let resolvedWt = effectiveCwd;
|
|
2201
|
+
// ── Defensive: state.json 존재/판독 가능성 사전 검증 ──
|
|
2202
|
+
// state.json 이 없거나 손상된 상태에서 stage.done 조회가 모두 실패하면
|
|
2203
|
+
// current=null 로 남아 target_stage="1_planning.jira" 로 되돌아가는
|
|
2204
|
+
// silent regression 이 발생한다. `.issue` 필드를 probe 하여 이 상황을
|
|
2205
|
+
// 명시적 에러로 분리한다.
|
|
2206
|
+
const probe = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${".issue"}`
|
|
2207
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2208
|
+
if (probe.exitCode !== 0) {
|
|
2209
|
+
const rootR = await $ `bash ${SCRIPTS_DIR}/state.sh root`
|
|
2210
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2211
|
+
const expectedRoot = rootR.stdout?.toString().trim() || effectiveCwd;
|
|
2212
|
+
const expectedPath = `${expectedRoot}/.makdoong2-team/${args.issue}/state.json`;
|
|
2213
|
+
return JSON.stringify({
|
|
2214
|
+
ok: false,
|
|
2215
|
+
error: "state_unreadable",
|
|
2216
|
+
expected_path: expectedPath,
|
|
2217
|
+
effective_cwd: effectiveCwd,
|
|
2218
|
+
reason: `state.json 을 판독할 수 없습니다. worktree cwd 기준 예상 경로: ${expectedPath}. ` +
|
|
2219
|
+
`주요 원인: (1) wt-sync-ignored.sh 로 worktree 동기화 미수행, (2) 다른 cwd(main repo)에서 state.json 을 조작하여 worktree 사본과 불일치, (3) state.json 손상.`,
|
|
2220
|
+
next_action: `state.json 존재/유효성을 먼저 확인하세요. 필요 시 'bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${effectiveCwd} ${args.issue}' 로 재동기화하거나, ` +
|
|
2221
|
+
`'bash ${SCRIPTS_DIR}/state.sh init ${args.issue} ${effectiveCwd}' 로 초기화하세요. ` +
|
|
2222
|
+
`근본 원인이 해소되기 전에는 dispatch_stage 를 호출하지 마세요.`,
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
let current = null;
|
|
2226
|
+
for (const s of STAGE_ORDER) {
|
|
2227
|
+
const testBase = stageJqPath(s);
|
|
2228
|
+
if (s === "2_implementation.analysis") {
|
|
2229
|
+
// analysis 는 게이트가 SKIP 판정 시 skipped=true 로만 마킹하고
|
|
2230
|
+
// done 도 함께 true 로 세팅한다. 따라서 done=true 만 확인해도 충분하지만,
|
|
2231
|
+
// 방어적으로 skipped=true 도 완료로 인정한다.
|
|
2232
|
+
const skR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${testBase}.skipped`}`
|
|
2233
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2234
|
+
const dnR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${testBase}.done`}`
|
|
2235
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2236
|
+
const sk = skR.stdout?.toString().trim();
|
|
2237
|
+
const dn = dnR.stdout?.toString().trim();
|
|
2238
|
+
if (sk === "true" || dn === "true") {
|
|
2239
|
+
current = s;
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
2242
|
+
else
|
|
2243
|
+
break;
|
|
2244
|
+
}
|
|
2245
|
+
if (s === "2_implementation.test") {
|
|
2246
|
+
const unitR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${testBase}.unit`}`
|
|
2247
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2248
|
+
const intR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${testBase}.integration`}`
|
|
2249
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2250
|
+
const covR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${testBase}.coverage`}`
|
|
2251
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2252
|
+
const unit = unitR.stdout?.toString().trim();
|
|
2253
|
+
const integration = intR.stdout?.toString().trim();
|
|
2254
|
+
const coverage = covR.stdout?.toString().trim();
|
|
2255
|
+
const testDone = (unit === "pass" || unit === "skip") &&
|
|
2256
|
+
(integration === "pass" || integration === "skip") &&
|
|
2257
|
+
(coverage === "pass" || coverage === "exempt");
|
|
2258
|
+
if (testDone) {
|
|
2259
|
+
current = s;
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
else
|
|
2263
|
+
break;
|
|
2264
|
+
}
|
|
2265
|
+
const r = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${testBase}.done`}`
|
|
2266
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2267
|
+
if (r.exitCode === 0 && r.stdout?.toString().trim() === "true")
|
|
2268
|
+
current = s;
|
|
2269
|
+
else
|
|
2270
|
+
break;
|
|
2271
|
+
}
|
|
2272
|
+
const target = nextStage(current);
|
|
2273
|
+
if (target === null) {
|
|
2274
|
+
return JSON.stringify({
|
|
2275
|
+
ok: true,
|
|
2276
|
+
done: true,
|
|
2277
|
+
message: "all stages complete",
|
|
2278
|
+
next_action: "모든 stage가 완료되었습니다. 사용자에게 최종 요약을 보고하고 종료하세요.",
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
// ── Pre-gate: 2_implementation.dev 진입 전 worktree 자동 생성 ──
|
|
2282
|
+
if (target === "2_implementation.dev") {
|
|
2283
|
+
const wtR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} '.worktree'`
|
|
2284
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2285
|
+
const existingWt = wtR.stdout?.toString().trim().replace(/^"|"$/g, "");
|
|
2286
|
+
const mainRepoR = await $ `git worktree list --porcelain`
|
|
2287
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2288
|
+
let mainRepoPath = "";
|
|
2289
|
+
if (mainRepoR.exitCode === 0) {
|
|
2290
|
+
const lines = mainRepoR.stdout?.toString().split("\n") || [];
|
|
2291
|
+
for (const line of lines) {
|
|
2292
|
+
if (line.startsWith("worktree ")) {
|
|
2293
|
+
mainRepoPath = line.replace(/^worktree\s+/, "").trim();
|
|
2294
|
+
break;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
const isWorktreeMissing = !existingWt || existingWt === "__MISSING__" || existingWt === "null";
|
|
2299
|
+
const isWorktreePathGone = Boolean(existingWt && !isWorktreeMissing && !existsSync(existingWt));
|
|
2300
|
+
const isWorktreePointingToMainRepo = existingWt === mainRepoPath || existingWt === effectiveCwd;
|
|
2301
|
+
const needsWorktree = isWorktreeMissing || isWorktreePathGone || isWorktreePointingToMainRepo;
|
|
2302
|
+
if (needsWorktree) {
|
|
2303
|
+
const wtResult = await createWorktree($, args.issue, effectiveCwd, {
|
|
2304
|
+
info: (msg) => logger.debug(msg),
|
|
2305
|
+
warn: (msg) => logger.warn(msg),
|
|
2306
|
+
error: (msg) => logger.error(msg),
|
|
2307
|
+
});
|
|
2308
|
+
if (!wtResult.ok) {
|
|
2309
|
+
const isConflict = wtResult.error?.includes("이미 다른 worktree에서 사용 중");
|
|
2310
|
+
const recoverySteps = isConflict
|
|
2311
|
+
? [
|
|
2312
|
+
`\n**자동 복구 불가** — 브랜치가 이미 다른 경로에 체크아웃되어 있습니다.`,
|
|
2313
|
+
`\n수동 조치:`,
|
|
2314
|
+
`1. 기존 worktree 제거: git worktree remove "${wtResult.hint?.match(/기존 경로: (.+)/)?.[1] || '<기존경로>'}"`,
|
|
2315
|
+
`2. 다시 auto_advance_stage 호출 → 올바른 경로에 자동 생성됩니다.`,
|
|
2316
|
+
`\n**주의**: 기존 worktree에 커밋되지 않은 변경사항이 있으면 먼저 백업하세요.`,
|
|
2317
|
+
].join("\n")
|
|
2318
|
+
: `\n${wtResult.hint || "상세: git worktree 기능이 지원되지 않거나 권한 문제일 수 있습니다."}\n사용자에게 보고하고 수동 조치를 안내하세요.`;
|
|
2319
|
+
return JSON.stringify({
|
|
2320
|
+
ok: false,
|
|
2321
|
+
gate: target,
|
|
2322
|
+
reason: `Worktree 생성 실패: ${wtResult.error}`,
|
|
2323
|
+
next_action: `Worktree 자동 생성 실패.${recoverySteps}`,
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
const setResult = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} '.worktree' '"${wtResult.path}"'`
|
|
2327
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2328
|
+
if (setResult.exitCode !== 0) {
|
|
2329
|
+
return JSON.stringify({
|
|
2330
|
+
ok: false,
|
|
2331
|
+
gate: target,
|
|
2332
|
+
reason: "Worktree 경로 state.json 기록 실패",
|
|
2333
|
+
next_action: "state.json 업데이트 실패. 사용자에게 보고하세요.",
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
// ── resolvedWt 교정: dev 이후 단계에서 state.json .worktree 로 전환 ──
|
|
2339
|
+
const DEV_OR_LATER_STAGES = [
|
|
2340
|
+
"2_implementation.dev", "2_implementation.test",
|
|
2341
|
+
"3_delivery.commit", "3_delivery.pr", "3_delivery.review",
|
|
2342
|
+
];
|
|
2343
|
+
if (DEV_OR_LATER_STAGES.includes(target)) {
|
|
2344
|
+
const wtStateR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} '.worktree'`
|
|
2345
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2346
|
+
const rawWt = (wtStateR.stdout?.toString().trim() ?? "").replace(/^"|"$/g, "");
|
|
2347
|
+
if (rawWt && rawWt !== "null" && rawWt !== "__MISSING__") {
|
|
2348
|
+
resolvedWt = rawWt;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
// ── Pre-gate forward sync: worktree state.json을 항상 최신화 ──
|
|
2352
|
+
// createWorktree(신규/reused) + needsWorktree=false 재진입 경로 모두 커버.
|
|
2353
|
+
// state.sh set .worktree가 완료된 이후 실행되므로 올바른 .worktree 값이
|
|
2354
|
+
// worktree state.json에 전파된다 (Fix C+B: PROJ-40406).
|
|
2355
|
+
if (DEV_OR_LATER_STAGES.includes(target) && resolvedWt !== effectiveCwd) {
|
|
2356
|
+
if (!existsSync(resolvedWt)) {
|
|
2357
|
+
return JSON.stringify({
|
|
2358
|
+
ok: false,
|
|
2359
|
+
gate: target,
|
|
2360
|
+
error: "worktree_missing",
|
|
2361
|
+
reason: `resolvedWt "${resolvedWt}" 가 존재하지 않습니다.`,
|
|
2362
|
+
next_action: `auto_advance_stage(issue: "${args.issue}") 를 재호출하면 worktree 를 자동 재생성합니다.`,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
logger.debug(`[wt-sync] FORWARD issue=${args.issue} worktree=${resolvedWt} ` +
|
|
2366
|
+
`caller=auto_advance_stage target=${target}`);
|
|
2367
|
+
const fwdSync = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${resolvedWt} ${args.issue}`
|
|
2368
|
+
.cwd(effectiveCwd).quiet().nothrow();
|
|
2369
|
+
if (fwdSync.exitCode !== 0) {
|
|
2370
|
+
logger.warn(`[wt-sync] FORWARD FAIL issue=${args.issue} caller=auto_advance_stage exit=${fwdSync.exitCode} ` +
|
|
2371
|
+
`stderr=${redactAndTruncate(fwdSync.stderr?.toString() ?? "", 200)}`);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
const verify = await runScriptCwd(resolvedWt, GATES_DIR, "verify.sh", args.issue, target);
|
|
2375
|
+
if (!verify.ok) {
|
|
2376
|
+
const reason = verify.stderr.trim() || verify.stdout.trim();
|
|
2377
|
+
const isWorktreeSiblingError = reason.includes("형제 디렉토리가 아님");
|
|
2378
|
+
const isWorktreePathMissing = reason.includes("No such file or directory")
|
|
2379
|
+
|| reason.includes("worktree_path_missing");
|
|
2380
|
+
const worktreeRecovery = isWorktreeSiblingError
|
|
2381
|
+
? [
|
|
2382
|
+
`\n\n**Worktree 위치 오류 복구**:`,
|
|
2383
|
+
`1. 현재 잘못된 위치의 worktree 제거`,
|
|
2384
|
+
`2. state.json의 .worktree 필드를 메인 repo 경로 또는 null로 재설정`,
|
|
2385
|
+
`3. auto_advance_stage 재호출 → 올바른 위치(형제 디렉토리)에 자동 생성`,
|
|
2386
|
+
`\n명령:`,
|
|
2387
|
+
` git worktree remove <잘못된경로>`,
|
|
2388
|
+
` bash ${SCRIPTS_DIR}/state.sh set ${args.issue} '.worktree' 'null'`,
|
|
2389
|
+
].join("\n")
|
|
2390
|
+
: isWorktreePathMissing
|
|
2391
|
+
? `\n\n**Worktree 경로 없음 — 자동 복구 가능**: auto_advance_stage(issue: "${args.issue}") 를 즉시 재호출하면 worktree 를 자동 재생성합니다. dispatch_stage 를 먼저 호출하지 마세요.`
|
|
2392
|
+
: "";
|
|
2393
|
+
return JSON.stringify({
|
|
2394
|
+
ok: false,
|
|
2395
|
+
gate: target,
|
|
2396
|
+
reason,
|
|
2397
|
+
next_action: isWorktreePathMissing
|
|
2398
|
+
? `Worktree 경로가 파일 시스템에 없습니다. auto_advance_stage(issue: "${args.issue}") 를 재호출하면 자동 재생성됩니다.${worktreeRecovery}`
|
|
2399
|
+
: `게이트 차단: ${reason.slice(0, 200)}. 이 사유를 사용자에게 그대로 보고하고 dispatch_stage를 호출하지 마세요. 필요 시 이전 substage로 복귀하세요.${worktreeRecovery}`,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
const ext = await checkExtensionGates(args.issue, target, resolvedWt);
|
|
2403
|
+
if (!ext.ok) {
|
|
2404
|
+
const extReason = ext.reason;
|
|
2405
|
+
const extMarker = ext.marker_path;
|
|
2406
|
+
return JSON.stringify({
|
|
2407
|
+
ok: false,
|
|
2408
|
+
gate: target,
|
|
2409
|
+
reason: extReason,
|
|
2410
|
+
marker_path: extMarker,
|
|
2411
|
+
next_action: `게이트 차단: ${extReason.slice(0, 200)}. marker_path=${extMarker ?? 'unknown'}. 해당 마커를 검토·수정한 뒤 재시도하세요. dispatch_stage 호출 금지.`,
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
const spec = agentForStage(target);
|
|
2415
|
+
const isPublisherHybrid = target.startsWith("3_delivery.");
|
|
2416
|
+
const dispatchInstruction = isPublisherHybrid
|
|
2417
|
+
? `당신은 Publisher 하이브리드 stage에 진입했습니다. 지금 즉시 dispatch_stage(issue: "${args.issue}", target_stage: "${target}", worktree: "${resolvedWt}") 툴을 호출해 publisher가 spec을 반환하게 하고, 반환된 spec을 부장님이 직접 git 명령으로 실행하세요. 파일을 직접 편집하지 마세요.`
|
|
2418
|
+
: `당신은 직접 구현하지 마세요. 지금 즉시 dispatch_stage(issue: "${args.issue}", target_stage: "${target}", worktree: "${resolvedWt}") 툴을 호출하세요. Read/Bash 이외의 어떤 도구도 사용하지 말고, 파일을 직접 편집하지 마세요.`;
|
|
2419
|
+
const workPolicy = await readPolicy(args.issue, resolvedWt);
|
|
2420
|
+
const autoApprove = workPolicy?.auto_approve?.[target] ?? null;
|
|
2421
|
+
return JSON.stringify({
|
|
2422
|
+
ok: true,
|
|
2423
|
+
current_stage: current,
|
|
2424
|
+
target_stage: target,
|
|
2425
|
+
agent: spec.id,
|
|
2426
|
+
primary_only: spec.primary_only,
|
|
2427
|
+
model: POLICIES[spec.id]?.primary,
|
|
2428
|
+
category: workPolicy?.category ?? null,
|
|
2429
|
+
auto_approve: autoApprove,
|
|
2430
|
+
next_action: dispatchInstruction,
|
|
2431
|
+
});
|
|
2432
|
+
},
|
|
2433
|
+
}),
|
|
2434
|
+
cleanup_panes: tool({
|
|
2435
|
+
description: "Close tmux panes managed by makdoong2-team. Kills only panes carrying " +
|
|
2436
|
+
"the @mdn2_session ownership marker (safe). Parent opencode panes (--port) " +
|
|
2437
|
+
"are never killed. Panes younger than grace_seconds are skipped (default 0).",
|
|
2438
|
+
args: {
|
|
2439
|
+
grace_seconds: tool.schema.number().optional().describe("Skip panes younger than this many seconds (race window guard). Default: 0."),
|
|
2440
|
+
},
|
|
2441
|
+
async execute(args) {
|
|
2442
|
+
const report = await tmuxMonitor.cleanupOrphans({
|
|
2443
|
+
graceSeconds: typeof args.grace_seconds === "number" ? args.grace_seconds : 0,
|
|
2444
|
+
});
|
|
2445
|
+
return JSON.stringify({
|
|
2446
|
+
ok: true,
|
|
2447
|
+
...report,
|
|
2448
|
+
message: `Closed ${report.total_closed} pane(s): ` +
|
|
2449
|
+
`tracked=${report.tracked_closed} marked_orphans=${report.orphans_closed} ` +
|
|
2450
|
+
`(fresh_skipped=${report.fresh_skipped})`,
|
|
2451
|
+
});
|
|
2452
|
+
},
|
|
2453
|
+
}),
|
|
2454
|
+
},
|
|
2455
|
+
};
|
|
2456
|
+
};
|
|
2457
|
+
export default Makdoong2TeamPlugin;
|