makdoong2-team 2.3.0 → 2.3.2
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/agents/makdoong2-planner.md +7 -3
- package/agents/makdoong2-team-leader.md +23 -0
- package/bin/cli.js +17 -4
- package/bin/cli.ts +22 -3
- package/dist/opencode-plugin.js +117 -24
- package/dist/poll-sub-session.d.ts +1 -0
- package/dist/poll-sub-session.js +24 -3
- package/dist/research-fanout.d.ts +32 -0
- package/dist/research-fanout.js +50 -0
- package/dist/stage-completion.d.ts +55 -0
- package/dist/stage-completion.js +91 -0
- package/gates/stage3-scope-verify.sh +7 -1
- package/package.json +1 -1
- package/scripts/run-tests.mts +1 -0
- package/stages/01-planning.md +5 -0
- package/stages/02-requirements.md +4 -0
|
@@ -245,7 +245,9 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."1_planning".substages."sco
|
|
|
245
245
|
- 범주 하향(major→minor) — escalation만 허용.
|
|
246
246
|
- 다음 phase 작업(dev/test) 선행.
|
|
247
247
|
- **outer-world 에이전트(Sisyphus / Explore / Librarian / oh-my-openagent 계열 카테고리 등) 위임 금지.** 본 에이전트에는 `Task` 툴이 프론트매터에서 제거되어 있어 물리적으로 스폰 불가. 조사가 필요하면 반드시 `skill_mcp` 로 `jira-research` / `confluence-research` / `bitbucket-research` / `github-oss-research` 스킬만 사용. 이는 planning phase가 makdoong2 서브에이전트 체계 내에서 봉인되어야 한다는 아키텍처 원칙이다.
|
|
248
|
-
- **bash를 통한 파일 쓰기 리디렉션 일체 금지 (READ-ONLY 원칙).**
|
|
248
|
+
- **bash를 통한 파일 쓰기 리디렉션 일체 금지 (READ-ONLY 원칙).** 예외 2가지:
|
|
249
|
+
- `<SCRIPTS_DIR>/state.sh set ...` 을 통한 state.json 마커 기록.
|
|
250
|
+
- **planning 산출물(`.makdoong2-team/<이슈키>/` 아래 `*.md`·`*.json` — `requirements-draft.md` 등)은 `write` 툴로 직접 생성·갱신한다.** 이것은 READ-ONLY 위반이 아니라 stage spec(02-requirements.md §2-0b)이 부과한 **의무**이며, 훅도 이 경로의 `write` 를 명시적으로 허용한다. 단 같은 경로라도 bash 리디렉션·`apply_patch` 는 차단되므로 반드시 `write` 툴을 쓴다.
|
|
249
251
|
- **금지 패턴**: `echo >`, `cat > file`, `cat <<EOF > file`, `tee file`, `sed -i`, `awk ... > file`, `printf > file`, `> file`, `>> file`, `python -c "... open(..., 'w') ..."`, `node -e "... fs.writeFileSync(...) ..."`
|
|
250
252
|
- **위반 예시**:
|
|
251
253
|
```bash
|
|
@@ -264,5 +266,7 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."1_planning".substages."sco
|
|
|
264
266
|
# ✅ 허용: state.json 마커 기록 (state.sh 경유)
|
|
265
267
|
bash <SCRIPTS_DIR>/state.sh set PROJ-123 '.stages."1_planning".substages."scope".done' 'true'
|
|
266
268
|
```
|
|
267
|
-
- **위반 결과**: verifier가 `git status`로 untracked 파일 감지 → REJECTED 판정 → 워크플로우 중단
|
|
268
|
-
- **올바른 절차**:
|
|
269
|
+
- **위반 결과**: 훅이 bash 쓰기·`apply_patch` 를 차단하고, 우회에 성공하더라도 verifier가 `git status`로 untracked 파일 감지 → REJECTED 판정 → 워크플로우 중단
|
|
270
|
+
- **올바른 절차**:
|
|
271
|
+
- **planning 산출물(요구사항 초안 등 `.makdoong2-team/<이슈키>/*.md|json`)은 본인이 `write` 툴로 직접 생성한다.** team-leader 반환·dev 위임 대상이 아니다 — 그 시점 파이프라인에는 초안을 대신 만들 역할이 없어 워크플로가 정지한다 (issue #8).
|
|
272
|
+
- **소스 코드** 파일 생성·변경이 필요하면 spec을 team-leader에게 반환하여 dev 단계로 위임한다. Planning 단계는 "무엇을 만들지"만 결정하고, "실제로 만드는 것"은 implementation 단계의 책임이다.
|
|
@@ -98,6 +98,29 @@ permission:
|
|
|
98
98
|
|
|
99
99
|
`MAX_ATTEMPTS` 는 dispatch_stage **호출 1회 내부**의 예산이므로 재호출하면 리셋된다. `hang_history` 누적 상한은 그 리셋을 무력화하기 위한 **호출 간(cross-call) 차단막**이며, 부장님이 우회하면 무한 루프가 된다.
|
|
100
100
|
|
|
101
|
+
## 서브에이전트 능력을 frontmatter `tools:` 목록으로 판정하지 말 것 (hardrule)
|
|
102
|
+
|
|
103
|
+
에이전트 정의 파일의 frontmatter `tools:` 는 **whitelist 가 아니다** — 목록에 없는 툴도 permission 설정이 허용하면 실행된다. 실제로 planner frontmatter 에 `Write` 가 없다는 이유로 "planner 는 구조적으로 파일 생성이 불가능하다" 고 단정하고 워크플로를 중단시킨 오진단이 있었다 — 로그상 planner 는 `write` 를 정상 실행해 왔고 차단 이력이 0건이었다 (GitHub issue #8). 서브에이전트가 파일을 못 만들었다면 원인은 **훅 차단 메시지·permission 프롬프트 대기(PERMISSION_STALL)·프롬프트 지시** 중에 있다. frontmatter 를 근거로 사용자에게 에이전트 정의 수정을 요구하지 말고, 실패한 세션의 실제 차단 로그를 근거로 판단하라.
|
|
104
|
+
|
|
105
|
+
## substage 완료 판정은 `stage_done` 으로 한다 — 출력 문구로 판단하지 말 것 (hardrule)
|
|
106
|
+
|
|
107
|
+
`dispatch_stage` 반환 JSON 의 `completion` / `stage_done` 이 완료 판정의 유일한 근거다. `output` 은 서브에이전트가 쓴 자연어이고, 자기 작업을 실제보다 후하게 서술한다.
|
|
108
|
+
|
|
109
|
+
| `completion` | `stage_done` | `ok` | 뜻 | 올바른 조치 |
|
|
110
|
+
|---|---|---|---|---|
|
|
111
|
+
| `done` | `true` | `true` | substage 완료 | `dispatch_verifier` 로 진행 |
|
|
112
|
+
| `paused` | `false` | `true` | 서브에이전트가 `interview_required=true` 를 기록하고 **의도적으로** 중단 | 사용자 인터뷰 수행 후 답변을 `context` 에 실어 재dispatch |
|
|
113
|
+
| `incomplete` | `false` | `false` | 최종 텍스트는 나왔지만 **마커가 하나도 없음** | 아래 규약 |
|
|
114
|
+
| `unknown` | `null` | `true` | `.done` 마커를 읽지 못함 | `state.sh status <이슈키>` 로 상태 먼저 확인 |
|
|
115
|
+
|
|
116
|
+
**`completion: "incomplete"` 규약**:
|
|
117
|
+
|
|
118
|
+
1. **사용자에게 "완료" 로 보고하지 않는다.** 소요 시간(`elapsed_ms`)과 `output` 의 미완료 사유를 그대로 전달한다. 실제로 27분을 소모하고 마커가 0개인 dispatch 를 "조회 및 템플릿 검증 완료 / 조사 완료" 로 보고한 사고가 있었다 (GitHub issue #9).
|
|
119
|
+
2. `next_action` 을 그대로 따른다 (하드룰 4). 해결 가능한 사유면 `context` 에 지시를 실어 재dispatch 하고, 사용자 개입이 필요하면 보고 후 대기한다.
|
|
120
|
+
3. 이 경우는 `hang_history` 에 `reason: "no_done_marker"` 로 자동 기록된다. 반복하면 `stall_escalate_threshold` 에서 재dispatch 가 차단되므로, 같은 조건으로 무한히 재호출하지 않는다.
|
|
121
|
+
|
|
122
|
+
`ok: true` 만 보고 넘어가지 말 것 — `paused` 와 `unknown` 도 `ok: true` 이며, 둘 다 substage 는 끝나지 않았다.
|
|
123
|
+
|
|
101
124
|
## verdict 는 셋이다 — REJECTED 와 ERROR 를 절대 섞지 말 것 (hardrule)
|
|
102
125
|
|
|
103
126
|
`dispatch_verifier` 의 `verdict` 는 `VERIFIED` / `REJECTED` / `ERROR` 세 값이다.
|
package/bin/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
import { execFileSync } from "node:child_process";
|
|
22
22
|
import { buildPoliciesFromConfig, DEFAULT_ALLOWED_PRIMARIES, } from "../scripts/model-policy.mjs";
|
|
23
|
-
import { install, uninstall, resolveConfigDir, parseJsonc, readCachedVersion, opencodeCacheRoot, } from "../scripts/install-lib.mjs";
|
|
23
|
+
import { install, uninstall, resolveConfigDir, parseJsonc, readCachedVersion, opencodeCacheRoot, computeExternalDirPaths, } from "../scripts/install-lib.mjs";
|
|
24
24
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const PKG_ROOT = resolve(HERE, "..");
|
|
26
26
|
const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
|
|
@@ -135,7 +135,14 @@ function doDoctor(flags) {
|
|
|
135
135
|
check(existsSync(join(PKG_ROOT, d)), `${d}/ in package`, `${d}/ missing in package (reinstall: npm install -g makdoong2-team)`);
|
|
136
136
|
}
|
|
137
137
|
const ocPath = join(DEST, "opencode.json");
|
|
138
|
-
|
|
138
|
+
// 설치가 심도록 설계된 external_directory 시드. 에이전트 프롬프트가 서브에이전트
|
|
139
|
+
// 세션에 워크스페이스 외부 경로(<pkgRoot>/stages/*.md 등) 읽기를 지시하므로,
|
|
140
|
+
// 이 allow 가 없으면 서브에이전트가 응답 불가한 권한 프롬프트에 걸려
|
|
141
|
+
// PERMISSION_STALL 로 중단된다 (issue #8). 파일 배포만 성공하고 opencode.json
|
|
142
|
+
// 패치가 남지 않은 부분 설치를 doctor 가 잡아야 한다.
|
|
143
|
+
const requiredExtDirPaths = computeExternalDirPaths(PKG_ROOT, DEST);
|
|
144
|
+
let pluginOk = false, toolsOk = false, extDirOk = false, ocParseOk = false, ocParseErr = "";
|
|
145
|
+
let extDirMissing = requiredExtDirPaths;
|
|
139
146
|
if (existsSync(ocPath)) {
|
|
140
147
|
try {
|
|
141
148
|
const oc = parseJsonc(readFileSync(ocPath, "utf8"));
|
|
@@ -155,14 +162,20 @@ function doDoctor(flags) {
|
|
|
155
162
|
});
|
|
156
163
|
const ocTools = oc.tools;
|
|
157
164
|
toolsOk = !!ocTools && TOOLS.every((t) => ocTools[t] === true);
|
|
165
|
+
const extDir = oc.permission?.external_directory;
|
|
166
|
+
extDirMissing = requiredExtDirPaths.filter((p) => !extDir || typeof extDir !== "object" || extDir[p] !== "allow");
|
|
167
|
+
extDirOk = extDirMissing.length === 0;
|
|
158
168
|
}
|
|
159
169
|
catch (e) {
|
|
160
170
|
ocParseErr = e instanceof Error ? e.message : String(e);
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
check(!existsSync(ocPath) || ocParseOk, "opencode.json parses (JSONC tolerated)", `opencode.json is unparseable — install silently skips patching it: ${ocParseErr}`);
|
|
164
|
-
check(pluginOk, "opencode.json registers the plugin", "opencode.json missing the plugin entry");
|
|
165
|
-
check(toolsOk, "opencode.json enables custom tools", "opencode.json missing custom tools");
|
|
174
|
+
check(pluginOk, "opencode.json registers the plugin", "opencode.json missing the plugin entry (run: npx makdoong2-team install)");
|
|
175
|
+
check(toolsOk, "opencode.json enables custom tools", "opencode.json missing custom tools (run: npx makdoong2-team install)");
|
|
176
|
+
check(extDirOk, "opencode.json allows external_directory (pkgRoot + configDir)", `opencode.json missing permission.external_directory allow(s): ${extDirMissing.join(", ")} ` +
|
|
177
|
+
`— sub-agents will hit unanswerable permission prompts and abort with PERMISSION_STALL ` +
|
|
178
|
+
`(run: npx makdoong2-team install)`);
|
|
166
179
|
// The plugin opencode actually loads lives in the `<name>@latest` cache dir,
|
|
167
180
|
// not in the globally installed npm module. When those drift the user runs
|
|
168
181
|
// old code while `npm ls -g` reports the new version — invisible without
|
package/bin/cli.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
parseJsonc,
|
|
34
34
|
readCachedVersion,
|
|
35
35
|
opencodeCacheRoot,
|
|
36
|
+
computeExternalDirPaths,
|
|
36
37
|
} from "../scripts/install-lib.mts";
|
|
37
38
|
|
|
38
39
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -150,7 +151,14 @@ function doDoctor(flags: Flags): never {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
const ocPath = join(DEST, "opencode.json");
|
|
153
|
-
|
|
154
|
+
// 설치가 심도록 설계된 external_directory 시드. 에이전트 프롬프트가 서브에이전트
|
|
155
|
+
// 세션에 워크스페이스 외부 경로(<pkgRoot>/stages/*.md 등) 읽기를 지시하므로,
|
|
156
|
+
// 이 allow 가 없으면 서브에이전트가 응답 불가한 권한 프롬프트에 걸려
|
|
157
|
+
// PERMISSION_STALL 로 중단된다 (issue #8). 파일 배포만 성공하고 opencode.json
|
|
158
|
+
// 패치가 남지 않은 부분 설치를 doctor 가 잡아야 한다.
|
|
159
|
+
const requiredExtDirPaths = computeExternalDirPaths(PKG_ROOT, DEST);
|
|
160
|
+
let pluginOk = false, toolsOk = false, extDirOk = false, ocParseOk = false, ocParseErr = "";
|
|
161
|
+
let extDirMissing: string[] = requiredExtDirPaths;
|
|
154
162
|
if (existsSync(ocPath)) {
|
|
155
163
|
try {
|
|
156
164
|
const oc = parseJsonc(readFileSync(ocPath, "utf8"));
|
|
@@ -168,12 +176,23 @@ function doDoctor(flags: Flags): never {
|
|
|
168
176
|
});
|
|
169
177
|
const ocTools = oc.tools;
|
|
170
178
|
toolsOk = !!ocTools && TOOLS.every((t) => ocTools[t] === true);
|
|
179
|
+
const extDir = oc.permission?.external_directory;
|
|
180
|
+
extDirMissing = requiredExtDirPaths.filter(
|
|
181
|
+
(p: string) => !extDir || typeof extDir !== "object" || extDir[p] !== "allow",
|
|
182
|
+
);
|
|
183
|
+
extDirOk = extDirMissing.length === 0;
|
|
171
184
|
} catch (e) { ocParseErr = e instanceof Error ? e.message : String(e); }
|
|
172
185
|
}
|
|
173
186
|
check(!existsSync(ocPath) || ocParseOk, "opencode.json parses (JSONC tolerated)",
|
|
174
187
|
`opencode.json is unparseable — install silently skips patching it: ${ocParseErr}`);
|
|
175
|
-
check(pluginOk, "opencode.json registers the plugin",
|
|
176
|
-
|
|
188
|
+
check(pluginOk, "opencode.json registers the plugin",
|
|
189
|
+
"opencode.json missing the plugin entry (run: npx makdoong2-team install)");
|
|
190
|
+
check(toolsOk, "opencode.json enables custom tools",
|
|
191
|
+
"opencode.json missing custom tools (run: npx makdoong2-team install)");
|
|
192
|
+
check(extDirOk, "opencode.json allows external_directory (pkgRoot + configDir)",
|
|
193
|
+
`opencode.json missing permission.external_directory allow(s): ${extDirMissing.join(", ")} ` +
|
|
194
|
+
`— sub-agents will hit unanswerable permission prompts and abort with PERMISSION_STALL ` +
|
|
195
|
+
`(run: npx makdoong2-team install)`);
|
|
177
196
|
|
|
178
197
|
// The plugin opencode actually loads lives in the `<name>@latest` cache dir,
|
|
179
198
|
// not in the globally installed npm module. When those drift the user runs
|
package/dist/opencode-plugin.js
CHANGED
|
@@ -26,8 +26,9 @@ import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakEx
|
|
|
26
26
|
import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
|
|
27
27
|
import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
|
|
28
28
|
import { shouldEscalateStall } from "./stall-escalation.js";
|
|
29
|
+
import { classifyStageCompletion, INCOMPLETE_HANG_REASON } from "./stage-completion.js";
|
|
29
30
|
import { buildStateWriteBlockMessage, classifyStateJsonAccess, looksLikeRedirection, splitUnquotedSegments, WRITE_INDICATORS_UNQUOTED, STATE_SH_CALL_RE, stripQuotedSpans, } from "./state-access-guard.js";
|
|
30
|
-
import { RESEARCH_SOURCES, DEFAULT_RESEARCH_TIMEOUT_MINUTES, buildResearchPrompt, mergeResearchFindings, normalizeQueries, parseResearchOutput, resolveParallelism, summarizeOutcomes, } from "./research-fanout.js";
|
|
31
|
+
import { RESEARCH_SOURCES, DEFAULT_RESEARCH_TIMEOUT_MINUTES, buildResearchPrompt, classifyFanoutOutcome, mergeResearchFindings, normalizeQueries, parseResearchOutput, resolveParallelism, summarizeOutcomes, } from "./research-fanout.js";
|
|
31
32
|
import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
|
|
32
33
|
import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
|
|
33
34
|
import { scanSkillMcpRegistry, extractMcpName, looksLikeMcpNotFound, looksLikeMcpConnectionFailed, } from "./skill-mcp-registry.js";
|
|
@@ -1008,9 +1009,23 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1008
1009
|
await spawnPaneForSession(sid);
|
|
1009
1010
|
},
|
|
1010
1011
|
"chat.params": async (input) => {
|
|
1011
|
-
if (input.sessionID
|
|
1012
|
-
|
|
1012
|
+
if (!input.sessionID || !input.agent)
|
|
1013
|
+
return;
|
|
1014
|
+
// 정체성은 downgrade 하지 않는다 (2차 방어).
|
|
1015
|
+
// sessionAgent 는 sealed 서브에이전트 판정의 입력이므로, 한 번 sealed 로
|
|
1016
|
+
// 확정된 세션이 makdoong2 소속이 아닌 이름으로 덮어써지면 그 세션의
|
|
1017
|
+
// outer-world 차단·산출물 경로 제한이 조용히 풀린다. agent 를 빠뜨린
|
|
1018
|
+
// 프롬프트 하나로 그렇게 되어선 안 된다 — 실제로 NUDGE 가 그랬다 (issue #9).
|
|
1019
|
+
// 호출부(1차 방어)는 전부 agent 를 싣지만, 새 호출부가 또 빠뜨려도
|
|
1020
|
+
// 보안 속성은 유지되어야 한다.
|
|
1021
|
+
const known = sessionAgent.get(input.sessionID);
|
|
1022
|
+
if (known && SEALED_SUBAGENTS.has(known) && !SEALED_SUBAGENTS.has(input.agent)) {
|
|
1023
|
+
logger.warn(`[makdoong2-team hook] chat.params agent downgrade ignored: session=${input.sessionID} ` +
|
|
1024
|
+
`known="${known}" incoming="${input.agent}" — sealed 정체성을 유지한다. ` +
|
|
1025
|
+
`이 프롬프트 호출부가 agent 를 싣지 않았을 가능성이 높다.`);
|
|
1026
|
+
return;
|
|
1013
1027
|
}
|
|
1028
|
+
sessionAgent.set(input.sessionID, input.agent);
|
|
1014
1029
|
},
|
|
1015
1030
|
// ─────────────────────────────────────────────────────────────
|
|
1016
1031
|
// PreToolUse — block destructive bash, gate `git push`,
|
|
@@ -1163,9 +1178,12 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1163
1178
|
`${filePath ? `"${filePath}" 에 ` : ""}쓸 수 없다.\n` +
|
|
1164
1179
|
(allowed === null
|
|
1165
1180
|
? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
|
|
1166
|
-
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된
|
|
1167
|
-
`(패턴: ${allowed.source}).\n`
|
|
1168
|
-
|
|
1181
|
+
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
|
|
1182
|
+
`(패턴: ${allowed.source}).\n` +
|
|
1183
|
+
`그 경로에는 지금 즉시 'write' 툴(filePath 인자)로 쓸 수 있다 — ` +
|
|
1184
|
+
`bash 리디렉션·apply_patch 우회가 차단된 것이지 산출물 쓰기 자체가 금지된 것이 아니다. ` +
|
|
1185
|
+
`산출물이 필요하면 포기하지 말고 write 툴로 재시도하라.\n`) +
|
|
1186
|
+
`소스 코드 변경이 필요하면 그 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
|
|
1169
1187
|
}
|
|
1170
1188
|
}
|
|
1171
1189
|
// ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
|
|
@@ -1805,18 +1823,26 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1805
1823
|
logger.debug(`[dispatch_stage] engineer session ready — ` +
|
|
1806
1824
|
`session_id=${subSessionID} stage=${args.target_stage} attempt=${attempt}\n` +
|
|
1807
1825
|
` monitor: opencode attach http://127.0.0.1:44707 --session ${subSessionID}`);
|
|
1826
|
+
// 종전 문구는 2번에서 state.sh 마커 기록을 요구하면서 마지막 줄에서
|
|
1827
|
+
// "새 tool 호출 추가 금지" 라고 못박아 서로 모순됐다. 실제로 planner 가
|
|
1828
|
+
// Jira 검증 6/6 을 끝내고도 "마커 기록 전 시한 도달" 이라며 마커를
|
|
1829
|
+
// 하나도 남기지 않고 종료해 27분이 통째로 버려졌다 (GitHub issue #9).
|
|
1830
|
+
// 마커 기록은 금지의 예외임을 문구 안에서 명시한다.
|
|
1808
1831
|
const nudgeText = [
|
|
1809
|
-
"⚠ 작업 시한 80% 도달 —
|
|
1832
|
+
"⚠ 작업 시한 80% 도달 — 지금부터는 마커 기록과 요약만 하고 즉시 세션을 종료하시오.",
|
|
1810
1833
|
"",
|
|
1811
|
-
"
|
|
1812
|
-
"1. 진행 중인 단일 tool call
|
|
1813
|
-
`2. bash ${SCRIPTS_DIR}/state.sh
|
|
1834
|
+
"순서대로 수행:",
|
|
1835
|
+
"1. 진행 중인 단일 tool call 만 마무리한다. 새 조사·탐색·구현은 시작하지 않는다.",
|
|
1836
|
+
`2. **이미 끝낸 작업의 state.json 마커를 지금 전부 기록한다.** bash ${SCRIPTS_DIR}/state.sh set 호출은`,
|
|
1837
|
+
" 아래 금지 규칙의 예외이며 필요한 횟수만큼 호출한다. 완료한 substage 는 .done=true 까지 기록한다.",
|
|
1838
|
+
" 마커 없이 종료하면 그 작업은 수행되지 않은 것으로 판정되어 substage 전체가 처음부터 재실행된다",
|
|
1839
|
+
" — 지금까지의 결과가 통째로 버려진다. 기록할 시간이 없다는 판단은 하지 말 것.",
|
|
1814
1840
|
"3. 3줄 이상 한국어 요약 텍스트 출력:",
|
|
1815
1841
|
" - 처리한 substage 결과 (완료/차단/조기종료)",
|
|
1816
1842
|
" - 변경한 state.json 마커 목록",
|
|
1817
1843
|
" - 다음 단계 안내",
|
|
1818
1844
|
"",
|
|
1819
|
-
"금지:
|
|
1845
|
+
"금지: 새로운 조사·구현 tool 호출. 허용: state.sh 마커 기록. 요약 출력 직후 즉시 종료.",
|
|
1820
1846
|
].join("\n");
|
|
1821
1847
|
const engineerNudge = async (sid, elapsedMs) => {
|
|
1822
1848
|
logger.debug(`[dispatch_stage] NUDGE sid=${sid} elapsed=${Math.round(elapsedMs / 1000)}s`);
|
|
@@ -1824,6 +1850,13 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1824
1850
|
.promptAsync({
|
|
1825
1851
|
path: { id: sid },
|
|
1826
1852
|
body: {
|
|
1853
|
+
// agent 를 빼면 opencode 가 기본 에이전트(`build`)로 이 turn 을
|
|
1854
|
+
// 돌리고, `chat.params` 가 sessionAgent[sid] 를 그 값으로 덮어쓴다.
|
|
1855
|
+
// 그 순간부터 이 세션은 sealed sub-agent 로 인식되지 않아
|
|
1856
|
+
// outer-world 위임 차단과 산출물 경로 제한이 전부 풀린다.
|
|
1857
|
+
// 실측 로그에서 NUDGE 직후 bash 호출이 agent="build" 로 기록됐다
|
|
1858
|
+
// (GitHub issue #9 부수 관찰). 여기서만 누락돼 있었다.
|
|
1859
|
+
agent: spec.id,
|
|
1827
1860
|
parts: [{ type: "text", text: nudgeText }],
|
|
1828
1861
|
model: { providerID: activeProviderID, modelID: activeModelID },
|
|
1829
1862
|
},
|
|
@@ -2025,12 +2058,60 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2025
2058
|
}
|
|
2026
2059
|
}
|
|
2027
2060
|
promptPromise.catch(() => { });
|
|
2028
|
-
|
|
2061
|
+
// 완료 판정은 sub-agent 의 문장이 아니라 substage 마커로 한다 (issue #9).
|
|
2062
|
+
// pollSubSession 의 kind="text" 는 "최종 turn 이 나왔다" 일 뿐이고,
|
|
2063
|
+
// 예산을 다 쓰고 "조기종료 — 마커 기록 없음" 이라고 말한 세션도 같은 kind 를
|
|
2064
|
+
// 낸다. 그 둘을 구분하는 유일한 값이 게이트·verifier 가 읽는 그 .done 이다.
|
|
2065
|
+
// cwd 는 effectiveWorktree — 이 substage 의 다른 state.json 접근과
|
|
2066
|
+
// 같은 파일을 봐야 한다 (args.worktree 를 쓰면 교정 발동 시 갈린다).
|
|
2067
|
+
const readMarker = async (field) => {
|
|
2068
|
+
const r = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${`${stageJqPath(args.target_stage)}.${field}`}`
|
|
2069
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2070
|
+
return r.exitCode === 0 ? (r.stdout?.toString().trim() ?? null) : null;
|
|
2071
|
+
};
|
|
2072
|
+
const completion = classifyStageCompletion({
|
|
2073
|
+
outcomeKind: finalOutcome.kind,
|
|
2074
|
+
success,
|
|
2075
|
+
doneValue: success ? await readMarker("done") : null,
|
|
2076
|
+
interviewRequiredValue: success ? await readMarker("interview_required") : null,
|
|
2077
|
+
});
|
|
2078
|
+
if (completion.resetHangHistory) {
|
|
2079
|
+
// 리셋 조건은 "dispatch 정상 반환" 이 아니라 "substage 실제 완료(done=true)"
|
|
2080
|
+
// 다. 종전에는 세션이 텍스트만 뱉고 done=false 로 끝나도 리셋됐고,
|
|
2081
|
+
// 재-dispatch 를 반복하는 동안 이력이 매번 비워져
|
|
2082
|
+
// stall_escalate_threshold 가 사실상 도달 불가였다 (issue #8).
|
|
2029
2083
|
const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
|
|
2030
2084
|
const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
|
|
2031
|
-
.cwd(
|
|
2085
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2032
2086
|
logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
|
|
2033
|
-
`exit=${resetR.exitCode} — substage
|
|
2087
|
+
`exit=${resetR.exitCode} — substage done=true`);
|
|
2088
|
+
}
|
|
2089
|
+
else if (success) {
|
|
2090
|
+
logger.debug(`[hang_history] reset skipped issue=${args.issue} stage=${args.target_stage} ` +
|
|
2091
|
+
`completion=${completion.completion} — dispatch 는 정상 반환했지만 substage 미완료`);
|
|
2092
|
+
}
|
|
2093
|
+
if (completion.recordHang) {
|
|
2094
|
+
// hang_history 는 dispatch_stage 호출 사이를 넘어 살아남는 유일한
|
|
2095
|
+
// 카운터다. 여기에 남기지 않으면 이 실패 모드는 cross-call 상한
|
|
2096
|
+
// (stall_escalate_threshold) 에 영영 도달하지 못하고, 매 호출이
|
|
2097
|
+
// 타임아웃 전체를 태우며 무한히 재실행된다 (issue #9).
|
|
2098
|
+
const incompleteEntry = JSON.stringify({
|
|
2099
|
+
attempt,
|
|
2100
|
+
at: new Date().toISOString(),
|
|
2101
|
+
reason: INCOMPLETE_HANG_REASON,
|
|
2102
|
+
elapsed_ms: finalOutcome.elapsedMs,
|
|
2103
|
+
polls: finalOutcome.polls,
|
|
2104
|
+
session_id: subSessionID,
|
|
2105
|
+
model: activeModelFull,
|
|
2106
|
+
fallback_depth: activeFallbackDepth,
|
|
2107
|
+
final: true,
|
|
2108
|
+
});
|
|
2109
|
+
const incompleteJqPath = stageJqPath(args.target_stage) + ".hang_history";
|
|
2110
|
+
const incompleteR = await $ `bash ${SCRIPTS_DIR}/state.sh append ${args.issue} ${incompleteJqPath} ${incompleteEntry}`
|
|
2111
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2112
|
+
logger.warn(`[dispatch_stage] STAGE_INCOMPLETE issue=${args.issue} stage=${args.target_stage} ` +
|
|
2113
|
+
`session=${subSessionID} outcome_kind=${finalOutcome.kind} elapsed_ms=${finalOutcome.elapsedMs} ` +
|
|
2114
|
+
`— 최종 텍스트는 나왔으나 .done=false. hang_history append exit=${incompleteR.exitCode}`);
|
|
2034
2115
|
}
|
|
2035
2116
|
const retryDisallowed = finalOutcome.kind === "timeout" &&
|
|
2036
2117
|
finalOutcome.transientFailures === 0;
|
|
@@ -2040,7 +2121,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2040
2121
|
`동일 dispatch_stage 를 재호출하지 말고 사용자에게 상황을 보고한 뒤 지시를 기다리거나 get_fallback_model 로 다른 모델을 요청하세요.`
|
|
2041
2122
|
: undefined;
|
|
2042
2123
|
finalResultJson = JSON.stringify({
|
|
2043
|
-
ok:
|
|
2124
|
+
ok: completion.ok,
|
|
2044
2125
|
stage: args.target_stage,
|
|
2045
2126
|
agent: spec.id,
|
|
2046
2127
|
model: activeModelFull,
|
|
@@ -2050,6 +2131,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2050
2131
|
fallback_depth: activeFallbackDepth,
|
|
2051
2132
|
output: finalLegacy.text.slice(0, 8000),
|
|
2052
2133
|
outcome_kind: finalOutcome.kind,
|
|
2134
|
+
// 완료 여부는 이 두 필드로 읽는다. output 문구를 해석하지 말 것 (issue #9).
|
|
2135
|
+
stage_done: completion.stageDone,
|
|
2136
|
+
completion: completion.completion,
|
|
2053
2137
|
polls: finalOutcome.polls,
|
|
2054
2138
|
elapsed_ms: finalOutcome.elapsedMs,
|
|
2055
2139
|
transient_failures: finalOutcome.kind === "timeout"
|
|
@@ -2057,9 +2141,10 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2057
2141
|
: undefined,
|
|
2058
2142
|
retry_disallowed: retryDisallowed || undefined,
|
|
2059
2143
|
retry_disallowed_reason: retryDisallowedReason,
|
|
2060
|
-
|
|
2144
|
+
next_action: completion.nextAction,
|
|
2145
|
+
reason: completion.ok
|
|
2061
2146
|
? overriddenReason
|
|
2062
|
-
: finalLegacy.text,
|
|
2147
|
+
: (completion.incompleteReason ?? finalLegacy.text),
|
|
2063
2148
|
});
|
|
2064
2149
|
}
|
|
2065
2150
|
finally {
|
|
@@ -2633,27 +2718,35 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2633
2718
|
}
|
|
2634
2719
|
}
|
|
2635
2720
|
const okCount = artifact.counts.ok;
|
|
2721
|
+
const failedOutcomes = outcomes.filter((o) => o.status === "failed");
|
|
2722
|
+
const fanout = classifyFanoutOutcome(artifact.counts, artifactWritten ? relPath : null, failedOutcomes.map((o) => o.label));
|
|
2636
2723
|
logger.debug(`[dispatch_research] fan-out done issue=${args.issue} ok=${okCount}/${outcomes.length} ` +
|
|
2637
|
-
`findings=${artifact.counts.findings_total}
|
|
2724
|
+
`findings=${artifact.counts.findings_total} status=${fanout.status} ` +
|
|
2725
|
+
`elapsed_ms=${Date.now() - startedAll}`);
|
|
2726
|
+
if (fanout.partial) {
|
|
2727
|
+
// debug 가 아니라 warn — 기본 로깅 레벨에서도 보여야 하는 결손이다.
|
|
2728
|
+
logger.warn(`[dispatch_research] PARTIAL issue=${args.issue} ok=${okCount}/${artifact.counts.requested} ` +
|
|
2729
|
+
`failed=${failedOutcomes.map((o) => `${o.source}:${o.error ?? "unknown"}`).join(" | ")}`);
|
|
2730
|
+
}
|
|
2638
2731
|
return JSON.stringify({
|
|
2639
2732
|
// 부분 성공도 ok=true. 한 소스가 죽었다고 나머지 조사 결과를 버리면
|
|
2640
|
-
// fan-out 의 실패 격리가 의미를 잃는다.
|
|
2641
|
-
ok:
|
|
2733
|
+
// fan-out 의 실패 격리가 의미를 잃는다. 결손은 status/partial 로 알린다.
|
|
2734
|
+
ok: fanout.ok,
|
|
2735
|
+
status: fanout.status,
|
|
2736
|
+
partial: fanout.partial,
|
|
2642
2737
|
issue: args.issue,
|
|
2643
2738
|
artifact_path: artifactWritten ? relPath : null,
|
|
2644
2739
|
artifact_error: artifactError,
|
|
2645
2740
|
elapsed_ms: Date.now() - startedAll,
|
|
2646
2741
|
counts: artifact.counts,
|
|
2647
2742
|
summary: summarizeOutcomes(outcomes),
|
|
2648
|
-
failed:
|
|
2743
|
+
failed: failedOutcomes.map((o) => ({
|
|
2649
2744
|
source: o.source,
|
|
2650
2745
|
error: o.error,
|
|
2651
2746
|
})),
|
|
2652
2747
|
rejected,
|
|
2653
2748
|
deferred,
|
|
2654
|
-
next_action:
|
|
2655
|
-
? `조사 결과를 읽고 요구사항 체크리스트에 반영하라: ${relPath}`
|
|
2656
|
-
: "모든 소스 조사가 실패했다. failed 사유를 사용자에게 보고하라.",
|
|
2749
|
+
next_action: fanout.next_action,
|
|
2657
2750
|
});
|
|
2658
2751
|
},
|
|
2659
2752
|
}),
|
package/dist/poll-sub-session.js
CHANGED
|
@@ -328,6 +328,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
328
328
|
stalledMs,
|
|
329
329
|
permissionID: p.id,
|
|
330
330
|
permissionType: p.permission,
|
|
331
|
+
permissionPatterns: p.patterns,
|
|
331
332
|
};
|
|
332
333
|
}
|
|
333
334
|
}
|
|
@@ -355,13 +356,29 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
355
356
|
`— 툴이 실행 중이므로 permission_stall 로 판정하지 않는다`);
|
|
356
357
|
}
|
|
357
358
|
if (hasPendingToolCall && !toolExecuting && stalledMs >= toolCallStallThresholdMs) {
|
|
358
|
-
|
|
359
|
+
// abort 전에 대기 중인 permission 요청을 1회 조회해 어떤 카테고리·경로가
|
|
360
|
+
// 대기 중인지 abort 메시지에 남긴다 (issue #8 제안 2). 종전에는 폴러
|
|
361
|
+
// 로그만으로 대기 대상을 특정할 수 없어 원인 규명에 코드 독해가 필요했다.
|
|
362
|
+
// best-effort — client.permission 이 없거나(구버전 SDK) 조회가 실패해도
|
|
363
|
+
// 종전과 동일한 무정보 outcome 으로 진행한다.
|
|
364
|
+
let stalledPerm;
|
|
365
|
+
if (client.permission) {
|
|
366
|
+
const permResult = await client.permission.list().catch(() => null);
|
|
367
|
+
stalledPerm = (permResult?.data ?? []).find(p => p.sessionID === sessionId);
|
|
368
|
+
}
|
|
369
|
+
err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}` +
|
|
370
|
+
(stalledPerm
|
|
371
|
+
? ` pending permissionID=${stalledPerm.id} type=${stalledPerm.permission} patterns=${JSON.stringify(stalledPerm.patterns)}`
|
|
372
|
+
: ` pending permission unknown (permission.list unavailable or empty)`));
|
|
359
373
|
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
360
374
|
return {
|
|
361
375
|
kind: "permission_stall",
|
|
362
376
|
polls: pollCount,
|
|
363
377
|
elapsedMs: now() - startTime,
|
|
364
378
|
stalledMs,
|
|
379
|
+
permissionID: stalledPerm?.id,
|
|
380
|
+
permissionType: stalledPerm?.permission,
|
|
381
|
+
permissionPatterns: stalledPerm?.patterns,
|
|
365
382
|
};
|
|
366
383
|
}
|
|
367
384
|
// Message stall: sub-agent registered (sessionEverAppeared) and appears
|
|
@@ -587,8 +604,12 @@ export function pollOutcomeToLegacy(outcome) {
|
|
|
587
604
|
case "permission_stall":
|
|
588
605
|
return {
|
|
589
606
|
text: outcome.permissionType
|
|
590
|
-
? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission
|
|
591
|
-
|
|
607
|
+
? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission ` +
|
|
608
|
+
`(id=${outcome.permissionID}${outcome.permissionPatterns ? `, patterns=${JSON.stringify(outcome.permissionPatterns)}` : ""}) ` +
|
|
609
|
+
`— cannot be answered in subagent context; aborted after ${outcome.stalledMs}ms)`
|
|
610
|
+
: `(permission_stall: sub-agent tool call stalled for ${outcome.stalledMs}ms — likely waiting for a permission approval ` +
|
|
611
|
+
`that cannot be answered in subagent context; pending permission could not be identified (permission.list unavailable or empty). ` +
|
|
612
|
+
`점검: opencode.json 의 permission.external_directory 시드 존재 여부 (npx makdoong2-team doctor))`,
|
|
592
613
|
success: false,
|
|
593
614
|
};
|
|
594
615
|
case "session_gone":
|
|
@@ -131,3 +131,35 @@ export interface ResearchFindingsArtifact {
|
|
|
131
131
|
export declare function mergeResearchFindings(issue: string, generatedAt: string, outcomes: SourceOutcome[], rejected: RejectedQuery[], deferred: RejectedQuery[]): ResearchFindingsArtifact;
|
|
132
132
|
/** Human-readable one-liner per source for the tool's text return. */
|
|
133
133
|
export declare function summarizeOutcomes(outcomes: SourceOutcome[]): string[];
|
|
134
|
+
export type FanoutStatus = "ok" | "partial" | "failed";
|
|
135
|
+
export interface FanoutOutcome {
|
|
136
|
+
status: FanoutStatus;
|
|
137
|
+
/** `false` only when NO source produced anything. */
|
|
138
|
+
ok: boolean;
|
|
139
|
+
/** `true` when some sources succeeded and others did not. */
|
|
140
|
+
partial: boolean;
|
|
141
|
+
next_action: string;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Turn the merged counts into the caller-facing verdict.
|
|
145
|
+
*
|
|
146
|
+
* Why this is not just `ok = okCount > 0`: a fan-out that covered 1 of 3 sources
|
|
147
|
+
* returned the same shape as one that covered all 3, and the only difference was
|
|
148
|
+
* a `failed` array the caller had to notice on its own. It did not — on two
|
|
149
|
+
* consecutive days Confluence and Bitbucket both timed out at exactly 10 minutes,
|
|
150
|
+
* the planner treated the Jira-only result as its evidence base, and then spent
|
|
151
|
+
* the rest of its budget trying to make up the difference by hand and recorded
|
|
152
|
+
* no markers at all (GitHub #9). A shortfall has to arrive as its own field with
|
|
153
|
+
* its own instruction, not as something to infer.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately NOT an automatic retry of the failed sources: both failures were
|
|
156
|
+
* full-budget timeouts, so retrying in place spends another `timeout_ms` for the
|
|
157
|
+
* same result and pushes the parent session past its own deadline. The caller
|
|
158
|
+
* gets the shortfall and decides — narrower focus, a later round, or proceed
|
|
159
|
+
* with recorded gaps.
|
|
160
|
+
*/
|
|
161
|
+
export declare function classifyFanoutOutcome(counts: {
|
|
162
|
+
requested: number;
|
|
163
|
+
ok: number;
|
|
164
|
+
failed: number;
|
|
165
|
+
}, artifactPath: string | null, failedSources: string[]): FanoutOutcome;
|
package/dist/research-fanout.js
CHANGED
|
@@ -289,3 +289,53 @@ export function summarizeOutcomes(outcomes) {
|
|
|
289
289
|
? `${o.label}: findings ${o.findings.length}건, gaps ${o.gaps.length}건 (${Math.round(o.elapsed_ms / 1000)}s)`
|
|
290
290
|
: `${o.label}: 실패 — ${o.error ?? "unknown"} (${Math.round(o.elapsed_ms / 1000)}s)`);
|
|
291
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Turn the merged counts into the caller-facing verdict.
|
|
294
|
+
*
|
|
295
|
+
* Why this is not just `ok = okCount > 0`: a fan-out that covered 1 of 3 sources
|
|
296
|
+
* returned the same shape as one that covered all 3, and the only difference was
|
|
297
|
+
* a `failed` array the caller had to notice on its own. It did not — on two
|
|
298
|
+
* consecutive days Confluence and Bitbucket both timed out at exactly 10 minutes,
|
|
299
|
+
* the planner treated the Jira-only result as its evidence base, and then spent
|
|
300
|
+
* the rest of its budget trying to make up the difference by hand and recorded
|
|
301
|
+
* no markers at all (GitHub #9). A shortfall has to arrive as its own field with
|
|
302
|
+
* its own instruction, not as something to infer.
|
|
303
|
+
*
|
|
304
|
+
* Deliberately NOT an automatic retry of the failed sources: both failures were
|
|
305
|
+
* full-budget timeouts, so retrying in place spends another `timeout_ms` for the
|
|
306
|
+
* same result and pushes the parent session past its own deadline. The caller
|
|
307
|
+
* gets the shortfall and decides — narrower focus, a later round, or proceed
|
|
308
|
+
* with recorded gaps.
|
|
309
|
+
*/
|
|
310
|
+
export function classifyFanoutOutcome(counts, artifactPath, failedSources) {
|
|
311
|
+
if (counts.ok === 0) {
|
|
312
|
+
return {
|
|
313
|
+
status: "failed",
|
|
314
|
+
ok: false,
|
|
315
|
+
partial: false,
|
|
316
|
+
next_action: "모든 소스 조사가 실패했다. failed 사유를 사용자에게 그대로 보고하라. " +
|
|
317
|
+
"조사 결과를 추측으로 대체하지 말 것.",
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (counts.failed === 0) {
|
|
321
|
+
return {
|
|
322
|
+
status: "ok",
|
|
323
|
+
ok: true,
|
|
324
|
+
partial: false,
|
|
325
|
+
next_action: `조사 결과를 읽고 요구사항 체크리스트에 반영하라: ${artifactPath ?? "(artifact 미기록)"}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
status: "partial",
|
|
330
|
+
ok: true,
|
|
331
|
+
partial: true,
|
|
332
|
+
next_action: `부분 성공 — ${counts.requested} 개 소스 중 ${counts.failed} 개 실패 (${failedSources.join(", ")}). ` +
|
|
333
|
+
`성공한 소스의 결과로 진행하되 다음 셋을 반드시 지킨다: ` +
|
|
334
|
+
`(1) 실패한 소스에서 확인하려던 항목을 산출물의 gaps/미확인 항목에 명시적으로 남긴다. ` +
|
|
335
|
+
`(2) 실패한 소스를 직접 조사해 메우려 하지 말 것 — 세션 예산을 소진하고 마커를 하나도 남기지 못한 ` +
|
|
336
|
+
`실패 사례가 있다. 필요하면 focus 를 좁혀 dispatch_research 를 1회만 다시 호출한다. ` +
|
|
337
|
+
`(3) 실패 소스가 요구사항 확정에 필수면 마커를 먼저 기록한 뒤 사용자에게 보고한다. ` +
|
|
338
|
+
`조사 완결성과 무관하게 substage 마커 기록은 생략하지 않는다. ` +
|
|
339
|
+
`조사 결과: ${artifactPath ?? "(artifact 미기록)"}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* - `done` — `.done=true`. The substage finished; hang_history resets.
|
|
3
|
+
* - `paused` — the sub-agent stopped on purpose and recorded why
|
|
4
|
+
* (`interview_required=true`). Not a failure: the orchestrator
|
|
5
|
+
* runs the interview and re-dispatches with `context`.
|
|
6
|
+
* - `incomplete` — a final turn, but `.done=false` and no pause marker. The
|
|
7
|
+
* session spent its budget and left nothing behind.
|
|
8
|
+
* - `unknown` — the marker could not be read. Never downgraded to a failure:
|
|
9
|
+
* an unreadable state.json must not turn finished work into a
|
|
10
|
+
* retry loop (same fail-open reasoning as `shouldEscalateStall`).
|
|
11
|
+
*/
|
|
12
|
+
export type StageCompletion = "done" | "paused" | "incomplete" | "unknown";
|
|
13
|
+
/** hang_history `reason` for a text-but-no-marker exit. */
|
|
14
|
+
export declare const INCOMPLETE_HANG_REASON = "no_done_marker";
|
|
15
|
+
export interface StageCompletionInput {
|
|
16
|
+
/** `pollSubSession` outcome kind of the final turn (`text` / `empty` / …). */
|
|
17
|
+
outcomeKind: string;
|
|
18
|
+
/** dispatch_stage's legacy success flag (final text turn, or a done-override). */
|
|
19
|
+
success: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Raw stdout of `state.sh get <stage>.done`, trimmed — or `null` when the read
|
|
22
|
+
* failed. `state.sh get` prints the literal `null` on a missing key, so `"null"`
|
|
23
|
+
* and `null` both mean "not readable as a decision", never "false".
|
|
24
|
+
*/
|
|
25
|
+
doneValue: string | null;
|
|
26
|
+
/** Raw stdout of `state.sh get <stage>.interview_required`, trimmed, or `null`. */
|
|
27
|
+
interviewRequiredValue?: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface StageCompletionResult {
|
|
30
|
+
completion: StageCompletion;
|
|
31
|
+
/** `true` only on a marker we actually read as `true`; `null` when unreadable. */
|
|
32
|
+
stageDone: boolean | null;
|
|
33
|
+
/** Should dispatch_stage report `ok:true` to the orchestrator? */
|
|
34
|
+
ok: boolean;
|
|
35
|
+
/** Reset the substage's `hang_history` (only a real completion clears it). */
|
|
36
|
+
resetHangHistory: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Append a `hang_history` entry. hang_history is the ONLY counter that survives
|
|
39
|
+
* across dispatch_stage calls, so an incomplete exit has to land there or the
|
|
40
|
+
* cross-call `stall_escalate_threshold` can never arm for this failure mode —
|
|
41
|
+
* the substage re-dispatches forever, each call burning a full timeout.
|
|
42
|
+
*/
|
|
43
|
+
recordHang: boolean;
|
|
44
|
+
/** One-line cause, surfaced as `reason` when `ok` is false. */
|
|
45
|
+
incompleteReason?: string;
|
|
46
|
+
/** Literal instruction for team-leader. Hardrule 4 says it follows this verbatim. */
|
|
47
|
+
nextAction?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Classify a dispatch_stage outcome by the substage's markers.
|
|
51
|
+
*
|
|
52
|
+
* Only a definite `"false"` flips `ok` to false. A read failure (`null`) or a
|
|
53
|
+
* literal `"null"` leaves `ok` alone and reports `unknown` — see StageCompletion.
|
|
54
|
+
*/
|
|
55
|
+
export declare function classifyStageCompletion(input: StageCompletionInput): StageCompletionResult;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// stage-completion.ts — did the dispatched substage actually finish?
|
|
2
|
+
//
|
|
3
|
+
// Why a separate module: the opencode plugin loader calls EVERY named export of
|
|
4
|
+
// the entry file as a plugin factory (ARCHITECTURE.md §2), so new helpers must
|
|
5
|
+
// live outside opencode-plugin.ts and be imported. `test/plugin-exports-shape.test.ts`
|
|
6
|
+
// pins the entry file's export set.
|
|
7
|
+
//
|
|
8
|
+
// The problem this solves (GitHub #9): `pollSubSession` returning `kind:"text"`
|
|
9
|
+
// means "the sub-session produced a final assistant turn", NOT "the substage is
|
|
10
|
+
// done". A planner that burned its whole budget on research and exited with the
|
|
11
|
+
// words "조기종료 — 마커 기록 없음" produces exactly the same outcome kind as one
|
|
12
|
+
// that completed every phase. dispatch_stage reported `ok:true` for the former,
|
|
13
|
+
// team-leader read the prose and told the user the stage had progressed, and
|
|
14
|
+
// 27 minutes of wall clock left `state.json` byte-identical.
|
|
15
|
+
//
|
|
16
|
+
// The authoritative completion signal is the substage's own `.done` marker —
|
|
17
|
+
// the same value the gates and the verifier read. Everything here is a pure
|
|
18
|
+
// function of the markers so the classification is unit-testable without
|
|
19
|
+
// spawning sessions.
|
|
20
|
+
/** hang_history `reason` for a text-but-no-marker exit. */
|
|
21
|
+
export const INCOMPLETE_HANG_REASON = "no_done_marker";
|
|
22
|
+
/**
|
|
23
|
+
* Classify a dispatch_stage outcome by the substage's markers.
|
|
24
|
+
*
|
|
25
|
+
* Only a definite `"false"` flips `ok` to false. A read failure (`null`) or a
|
|
26
|
+
* literal `"null"` leaves `ok` alone and reports `unknown` — see StageCompletion.
|
|
27
|
+
*/
|
|
28
|
+
export function classifyStageCompletion(input) {
|
|
29
|
+
const done = normalizeMarker(input.doneValue);
|
|
30
|
+
const interview = normalizeMarker(input.interviewRequiredValue ?? null);
|
|
31
|
+
if (!input.success) {
|
|
32
|
+
// The failure paths (session_gone / timeout / empty) already build their own
|
|
33
|
+
// reason and their own hang_history entries. Nothing to add or reset here.
|
|
34
|
+
return {
|
|
35
|
+
completion: done === true ? "done" : "incomplete",
|
|
36
|
+
stageDone: done,
|
|
37
|
+
ok: false,
|
|
38
|
+
resetHangHistory: false,
|
|
39
|
+
recordHang: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (done === true) {
|
|
43
|
+
return { completion: "done", stageDone: true, ok: true, resetHangHistory: true, recordHang: false };
|
|
44
|
+
}
|
|
45
|
+
if (interview === true) {
|
|
46
|
+
return {
|
|
47
|
+
completion: "paused",
|
|
48
|
+
stageDone: false,
|
|
49
|
+
ok: true,
|
|
50
|
+
resetHangHistory: false,
|
|
51
|
+
recordHang: false,
|
|
52
|
+
nextAction: "서브에이전트가 interview_required=true 를 기록하고 의도적으로 중단했다. 재dispatch 전에 " +
|
|
53
|
+
"사용자 인터뷰를 먼저 수행하고, 답변을 dispatch_stage 의 context 파라미터에 실어 재호출하라.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (done === false) {
|
|
57
|
+
return {
|
|
58
|
+
completion: "incomplete",
|
|
59
|
+
stageDone: false,
|
|
60
|
+
ok: false,
|
|
61
|
+
resetHangHistory: false,
|
|
62
|
+
recordHang: true,
|
|
63
|
+
incompleteReason: "sub-session 은 최종 텍스트를 남겼지만 substage 의 .done 마커가 false 다 — 작업이 완료되지 않았다. " +
|
|
64
|
+
"출력 문구가 아니라 이 필드가 완료 판정의 근거다.",
|
|
65
|
+
nextAction: "이 substage 는 완료되지 않았다. 사용자에게 '완료' 로 보고하지 말 것. " +
|
|
66
|
+
"output 의 미완료 사유를 읽고 (a) 해결 가능하면 context 에 지시를 실어 dispatch_stage 를 재호출하거나, " +
|
|
67
|
+
"(b) 사용자 개입이 필요하면 소요 시간과 미완료 사유를 그대로 보고하고 대기하라. " +
|
|
68
|
+
"hang_history 에 기록되므로 반복하면 stall_escalate_threshold 에서 차단된다.",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
completion: "unknown",
|
|
73
|
+
stageDone: null,
|
|
74
|
+
ok: true,
|
|
75
|
+
resetHangHistory: false,
|
|
76
|
+
recordHang: false,
|
|
77
|
+
nextAction: "substage 의 .done 마커를 읽지 못했다. 다음 단계로 넘어가기 전에 " +
|
|
78
|
+
"`bash <SCRIPTS_DIR>/state.sh status <이슈키>` 로 state.json 상태를 먼저 확인하라.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** `"true"` / `"false"` → boolean. Everything else (`"null"`, `null`, 잡음) → null. */
|
|
82
|
+
function normalizeMarker(raw) {
|
|
83
|
+
if (raw === null)
|
|
84
|
+
return null;
|
|
85
|
+
const v = raw.trim().toLowerCase();
|
|
86
|
+
if (v === "true")
|
|
87
|
+
return true;
|
|
88
|
+
if (v === "false")
|
|
89
|
+
return false;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
@@ -54,8 +54,14 @@ if [ "$SPEC_HASH" != "__MISSING__" ] && [ "$SPEC_HASH" != "null" ] && [ -n "$SPE
|
|
|
54
54
|
fi
|
|
55
55
|
fail "spec_hash 는 기록됐는데 draft_path 마커도 확정 명세 파일도 없다 — requirements substage 를 재작업하라 (stages/02-requirements.md §2-0, §2-5 9번)"
|
|
56
56
|
fi
|
|
57
|
+
# "생성된 적 없음" 을 안내에서 빼면 복구 방향을 잘못 잡는다 — 실제로 가장 흔한
|
|
58
|
+
# 경우인데 종전 메시지는 동기화 누락·삭제만 언급해 리더가 동기화 문제부터
|
|
59
|
+
# 의심했다 (issue #8).
|
|
57
60
|
[ -f "$ROOT/$DRAFT" ] \
|
|
58
|
-
|| fail "draft_path=${DRAFT} 마커는 있으나 파일이 없다 (기준 경로 ${ROOT})
|
|
61
|
+
|| fail "draft_path=${DRAFT} 마커는 있으나 파일이 없다 (기준 경로 ${ROOT}). 가능한 원인 순서대로:
|
|
62
|
+
(1) 애초에 생성된 적 없음 — planner 가 마커만 기록하고 파일 생성에 실패한 경우. requirements substage 를 재작업해 write 툴로 초안부터 생성하라 (stages/02-requirements.md §2-0b),
|
|
63
|
+
(2) worktree 동기화 누락 — 다른 cwd(main repo/worktree)의 같은 상대경로에 파일이 있는지 확인,
|
|
64
|
+
(3) 파일이 삭제됨 — 삭제 경위 확인 후 requirements 재작업"
|
|
59
65
|
ACTUAL="$(sha256sum "$ROOT/$DRAFT" | cut -d' ' -f1)"
|
|
60
66
|
[ "$ACTUAL" = "$SPEC_HASH" ] \
|
|
61
67
|
|| fail "확정 명세 무단 변경 감지 (spec drift) — 동결 후 변경은 사용자 재승인 + spec_hash 재기록 절차만 허용 (stages/02-requirements.md §2-4a)"
|
package/package.json
CHANGED
package/scripts/run-tests.mts
CHANGED
|
@@ -83,6 +83,7 @@ const STEPS = [
|
|
|
83
83
|
"node --test test/doctor-exit-code.test.ts",
|
|
84
84
|
"node --test test/example-config-portability.test.ts",
|
|
85
85
|
"node --test test/research-fanout.test.ts",
|
|
86
|
+
"node --test test/stage-completion.test.ts",
|
|
86
87
|
"node --test test/state-sh-write-atomicity.test.ts",
|
|
87
88
|
"node --test test/gate-locale-and-path.test.ts",
|
|
88
89
|
"node --test test/model-policy-parity.test.ts",
|
package/stages/01-planning.md
CHANGED
|
@@ -89,6 +89,8 @@ mkdir -p .makdoong2-team/<이슈키>
|
|
|
89
89
|
|
|
90
90
|
파일 (repo/worktree root 기준 상대경로): `.makdoong2-team/<이슈키>/requirements-draft.md`
|
|
91
91
|
|
|
92
|
+
**초안 파일은 반드시 `write` 툴(filePath 인자)로 생성·갱신한다.** bash 리디렉션(`cat > …`, `printf > …` 등)과 `apply_patch` 는 같은 경로라도 훅이 차단한다 — planner 의 유일한 파일 쓰기 수단은 `write` 다. 이 경로의 `write` 는 planner READ-ONLY 원칙의 명시적 예외이며 훅이 허용한다 (issue #8).
|
|
93
|
+
|
|
92
94
|
초안 구조:
|
|
93
95
|
```markdown
|
|
94
96
|
# 요구사항 초안 — <이슈키>
|
|
@@ -130,6 +132,9 @@ Simple 이슈는 조사 A + C만으로 축소 가능. 외부 라이브러리가
|
|
|
130
132
|
|
|
131
133
|
**결과 읽기**: 반환 JSON 의 `artifact_path` (`.makdoong2-team/<이슈키>/research-findings.json`) 를 Read 로 읽는다. `failed` 가 있어도 **부분 성공이 정상**이므로 나머지 결과로 진행하고, 실패 소스가 요구사항 확정에 필수인 경우에만 사유를 사용자에게 보고한다. 전 소스 실패(`ok: false`)면 추측으로 채우지 말고 보고한다.
|
|
132
134
|
|
|
135
|
+
- **`status: "partial"` 은 그 자체로 정상 종료다.** 실패한 소스를 당신이 직접 조사해 메우려 하지 말 것 — `skill_mcp` 순차 호출로 결손을 메우려다 세션 예산을 전부 소진하고 **마커를 하나도 남기지 못한 채** 종료한 사고가 이틀 연속 재현됐다 (GitHub issue #9, 각 27분·17분 소모). 결손을 더 좁히고 싶으면 focus 를 좁혀 `dispatch_research` 를 **1회만** 다시 호출한다.
|
|
136
|
+
- **조사 완결성을 이유로 마커 기록을 미루지 않는다 (hardrule).** 조사가 부분적이면 `gaps` 에 미확인 항목을 남기고 그 상태 그대로 산출물과 substage 마커를 기록한 뒤 종료한다. 마커가 없는 종료는 상위에서 `completion: "incomplete"` 로 분류되어 substage 전체가 재실행된다 — 부분 결과까지 함께 버려진다.
|
|
137
|
+
|
|
133
138
|
### 2-4. 요구사항 체크리스트 확인
|
|
134
139
|
|
|
135
140
|
```
|
|
@@ -62,6 +62,8 @@ mkdir -p .makdoong2-team/<ISSUE_KEY>
|
|
|
62
62
|
|
|
63
63
|
파일 경로 (repo/worktree root 기준 상대경로): `.makdoong2-team/<ISSUE_KEY>/requirements-draft.md`
|
|
64
64
|
|
|
65
|
+
**초안 파일은 반드시 `write` 툴(filePath 인자)로 생성·갱신한다.** bash 리디렉션(`cat > …`, `printf > …` 등)과 `apply_patch` 는 같은 경로라도 훅이 차단한다 — planner 의 유일한 파일 쓰기 수단은 `write` 다. 이 경로의 `write` 는 planner READ-ONLY 원칙의 명시적 예외이며 훅이 허용한다 (issue #8).
|
|
66
|
+
|
|
65
67
|
초안 초기 구조:
|
|
66
68
|
```markdown
|
|
67
69
|
# 요구사항 초안 — <ISSUE_KEY>
|
|
@@ -122,6 +124,8 @@ dispatch_research(
|
|
|
122
124
|
- 실패한 소스가 **요구사항 확정에 필수**라면 그 사유(인증 실패·권한 부족 등)를 사용자에게 보고한다. 없어도 되는 소스면 `gaps` 로만 남기고 진행한다.
|
|
123
125
|
- `deferred` 가 비어 있지 않으면 병렬 상한에 걸려 빠진 조사가 있다는 뜻이다. 필요하면 2차 호출한다.
|
|
124
126
|
- 모든 소스가 실패하면(`ok: false`) 체크리스트를 추측으로 채우지 말고 사용자에게 보고한다.
|
|
127
|
+
- **`status: "partial"` 은 그 자체로 정상 종료다.** 실패한 소스를 당신이 직접 조사해 메우려 하지 말 것 — `skill_mcp` 순차 호출로 결손을 메우려다 세션 예산을 전부 소진하고 **마커를 하나도 남기지 못한 채** 종료한 사고가 이틀 연속 재현됐다 (GitHub issue #9, 각 27분·17분 소모). 결손을 더 좁히고 싶으면 focus 를 좁혀 `dispatch_research` 를 **1회만** 다시 호출한다.
|
|
128
|
+
- **조사 완결성을 이유로 마커 기록을 미루지 않는다 (hardrule).** 조사가 부분적이면 `gaps` 에 미확인 항목을 남기고 그 상태 그대로 산출물과 substage 마커를 기록한 뒤 종료한다. 마커가 없는 종료는 상위에서 `completion: "incomplete"` 로 분류되어 substage 전체가 재실행된다 — 부분 결과까지 함께 버려진다.
|
|
125
129
|
|
|
126
130
|
## 2-2. 요구사항 체크리스트
|
|
127
131
|
|