makdoong2-team 2.3.1 → 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.
@@ -102,6 +102,25 @@ permission:
102
102
 
103
103
  에이전트 정의 파일의 frontmatter `tools:` 는 **whitelist 가 아니다** — 목록에 없는 툴도 permission 설정이 허용하면 실행된다. 실제로 planner frontmatter 에 `Write` 가 없다는 이유로 "planner 는 구조적으로 파일 생성이 불가능하다" 고 단정하고 워크플로를 중단시킨 오진단이 있었다 — 로그상 planner 는 `write` 를 정상 실행해 왔고 차단 이력이 0건이었다 (GitHub issue #8). 서브에이전트가 파일을 못 만들었다면 원인은 **훅 차단 메시지·permission 프롬프트 대기(PERMISSION_STALL)·프롬프트 지시** 중에 있다. frontmatter 를 근거로 사용자에게 에이전트 정의 수정을 요구하지 말고, 실패한 세션의 실제 차단 로그를 근거로 판단하라.
104
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
+
105
124
  ## verdict 는 셋이다 — REJECTED 와 ERROR 를 절대 섞지 말 것 (hardrule)
106
125
 
107
126
  `dispatch_verifier` 의 `verdict` 는 `VERIFIED` / `REJECTED` / `ERROR` 세 값이다.
@@ -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 && input.agent) {
1012
- sessionAgent.set(input.sessionID, input.agent);
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`,
@@ -1808,18 +1823,26 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1808
1823
  logger.debug(`[dispatch_stage] engineer session ready — ` +
1809
1824
  `session_id=${subSessionID} stage=${args.target_stage} attempt=${attempt}\n` +
1810
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
+ // 마커 기록은 금지의 예외임을 문구 안에서 명시한다.
1811
1831
  const nudgeText = [
1812
- "⚠ 작업 시한 80% 도달 — 현재 작업을 마무리하고 즉시 세션을 종료하시오.",
1832
+ "⚠ 작업 시한 80% 도달 — 지금부터는 마커 기록과 요약만 하고 즉시 세션을 종료하시오.",
1813
1833
  "",
1814
- "허용된 남은 작업:",
1815
- "1. 진행 중인 단일 tool call 완료",
1816
- `2. bash ${SCRIPTS_DIR}/state.sh .done 마커 확인 후 완료 시 true 설정`,
1834
+ "순서대로 수행:",
1835
+ "1. 진행 중인 단일 tool call 만 마무리한다. 새 조사·탐색·구현은 시작하지 않는다.",
1836
+ `2. **이미 끝낸 작업의 state.json 마커를 지금 전부 기록한다.** bash ${SCRIPTS_DIR}/state.sh set 호출은`,
1837
+ " 아래 금지 규칙의 예외이며 필요한 횟수만큼 호출한다. 완료한 substage 는 .done=true 까지 기록한다.",
1838
+ " 마커 없이 종료하면 그 작업은 수행되지 않은 것으로 판정되어 substage 전체가 처음부터 재실행된다",
1839
+ " — 지금까지의 결과가 통째로 버려진다. 기록할 시간이 없다는 판단은 하지 말 것.",
1817
1840
  "3. 3줄 이상 한국어 요약 텍스트 출력:",
1818
1841
  " - 처리한 substage 결과 (완료/차단/조기종료)",
1819
1842
  " - 변경한 state.json 마커 목록",
1820
1843
  " - 다음 단계 안내",
1821
1844
  "",
1822
- "금지: tool 호출 추가. 요약 텍스트 출력 직후 즉시 종료.",
1845
+ "금지: 새로운 조사·구현 tool 호출. 허용: state.sh 마커 기록. 요약 출력 직후 즉시 종료.",
1823
1846
  ].join("\n");
1824
1847
  const engineerNudge = async (sid, elapsedMs) => {
1825
1848
  logger.debug(`[dispatch_stage] NUDGE sid=${sid} elapsed=${Math.round(elapsedMs / 1000)}s`);
@@ -1827,6 +1850,13 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1827
1850
  .promptAsync({
1828
1851
  path: { id: sid },
1829
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,
1830
1860
  parts: [{ type: "text", text: nudgeText }],
1831
1861
  model: { providerID: activeProviderID, modelID: activeModelID },
1832
1862
  },
@@ -2028,30 +2058,60 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2028
2058
  }
2029
2059
  }
2030
2060
  promptPromise.catch(() => { });
2031
- if (success) {
2032
- // hang_history 리셋 조건은 "dispatch 정상 반환" 이 아니라 "substage
2033
- // 실제 완료(done=true)" 다. 종전에는 세션이 텍스트만 뱉고 done=false
2034
- // 끝나도 리셋됐고, 재-dispatch 반복하는 동안 이력이 매번
2035
- // 비워져 stall_escalate_threshold 사실상 도달 불가였다 (issue #8).
2036
- // cwd effectiveWorktree substage 다른 state.json 접근과
2037
- // 같은 파일을 봐야 한다 (args.worktree 쓰면 교정 발동 시 갈린다).
2038
- const resetDonePath = `${stageJqPath(args.target_stage)}.done`;
2039
- const resetDoneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${resetDonePath}`
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}`}`
2040
2069
  .cwd(effectiveWorktree).quiet().nothrow();
2041
- const resetDoneValue = resetDoneR.exitCode === 0
2042
- ? (resetDoneR.stdout?.toString().trim() ?? null)
2043
- : null;
2044
- if (resetDoneValue === "true") {
2045
- const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
2046
- const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
2047
- .cwd(effectiveWorktree).quiet().nothrow();
2048
- logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
2049
- `exit=${resetR.exitCode} — substage done=true`);
2050
- }
2051
- else {
2052
- logger.debug(`[hang_history] reset skipped issue=${args.issue} stage=${args.target_stage} ` +
2053
- `done=${resetDoneValue} dispatch 정상 반환했지만 substage 미완료`);
2054
- }
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).
2083
+ const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
2084
+ const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
2085
+ .cwd(effectiveWorktree).quiet().nothrow();
2086
+ logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
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}`);
2055
2115
  }
2056
2116
  const retryDisallowed = finalOutcome.kind === "timeout" &&
2057
2117
  finalOutcome.transientFailures === 0;
@@ -2061,7 +2121,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2061
2121
  `동일 dispatch_stage 를 재호출하지 말고 사용자에게 상황을 보고한 뒤 지시를 기다리거나 get_fallback_model 로 다른 모델을 요청하세요.`
2062
2122
  : undefined;
2063
2123
  finalResultJson = JSON.stringify({
2064
- ok: success,
2124
+ ok: completion.ok,
2065
2125
  stage: args.target_stage,
2066
2126
  agent: spec.id,
2067
2127
  model: activeModelFull,
@@ -2071,6 +2131,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2071
2131
  fallback_depth: activeFallbackDepth,
2072
2132
  output: finalLegacy.text.slice(0, 8000),
2073
2133
  outcome_kind: finalOutcome.kind,
2134
+ // 완료 여부는 이 두 필드로 읽는다. output 문구를 해석하지 말 것 (issue #9).
2135
+ stage_done: completion.stageDone,
2136
+ completion: completion.completion,
2074
2137
  polls: finalOutcome.polls,
2075
2138
  elapsed_ms: finalOutcome.elapsedMs,
2076
2139
  transient_failures: finalOutcome.kind === "timeout"
@@ -2078,9 +2141,10 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2078
2141
  : undefined,
2079
2142
  retry_disallowed: retryDisallowed || undefined,
2080
2143
  retry_disallowed_reason: retryDisallowedReason,
2081
- reason: success
2144
+ next_action: completion.nextAction,
2145
+ reason: completion.ok
2082
2146
  ? overriddenReason
2083
- : finalLegacy.text,
2147
+ : (completion.incompleteReason ?? finalLegacy.text),
2084
2148
  });
2085
2149
  }
2086
2150
  finally {
@@ -2654,27 +2718,35 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2654
2718
  }
2655
2719
  }
2656
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));
2657
2723
  logger.debug(`[dispatch_research] fan-out done issue=${args.issue} ok=${okCount}/${outcomes.length} ` +
2658
- `findings=${artifact.counts.findings_total} elapsed_ms=${Date.now() - startedAll}`);
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
+ }
2659
2731
  return JSON.stringify({
2660
2732
  // 부분 성공도 ok=true. 한 소스가 죽었다고 나머지 조사 결과를 버리면
2661
- // fan-out 의 실패 격리가 의미를 잃는다. 호출자는 failed 배열을 본다.
2662
- ok: okCount > 0,
2733
+ // fan-out 의 실패 격리가 의미를 잃는다. 결손은 status/partial 알린다.
2734
+ ok: fanout.ok,
2735
+ status: fanout.status,
2736
+ partial: fanout.partial,
2663
2737
  issue: args.issue,
2664
2738
  artifact_path: artifactWritten ? relPath : null,
2665
2739
  artifact_error: artifactError,
2666
2740
  elapsed_ms: Date.now() - startedAll,
2667
2741
  counts: artifact.counts,
2668
2742
  summary: summarizeOutcomes(outcomes),
2669
- failed: outcomes.filter((o) => o.status === "failed").map((o) => ({
2743
+ failed: failedOutcomes.map((o) => ({
2670
2744
  source: o.source,
2671
2745
  error: o.error,
2672
2746
  })),
2673
2747
  rejected,
2674
2748
  deferred,
2675
- next_action: okCount > 0
2676
- ? `조사 결과를 읽고 요구사항 체크리스트에 반영하라: ${relPath}`
2677
- : "모든 소스 조사가 실패했다. failed 사유를 사용자에게 보고하라.",
2749
+ next_action: fanout.next_action,
2678
2750
  });
2679
2751
  },
2680
2752
  }),
@@ -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;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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",
@@ -132,6 +132,9 @@ Simple 이슈는 조사 A + C만으로 축소 가능. 외부 라이브러리가
132
132
 
133
133
  **결과 읽기**: 반환 JSON 의 `artifact_path` (`.makdoong2-team/<이슈키>/research-findings.json`) 를 Read 로 읽는다. `failed` 가 있어도 **부분 성공이 정상**이므로 나머지 결과로 진행하고, 실패 소스가 요구사항 확정에 필수인 경우에만 사유를 사용자에게 보고한다. 전 소스 실패(`ok: false`)면 추측으로 채우지 말고 보고한다.
134
134
 
135
+ - **`status: "partial"` 은 그 자체로 정상 종료다.** 실패한 소스를 당신이 직접 조사해 메우려 하지 말 것 — `skill_mcp` 순차 호출로 결손을 메우려다 세션 예산을 전부 소진하고 **마커를 하나도 남기지 못한 채** 종료한 사고가 이틀 연속 재현됐다 (GitHub issue #9, 각 27분·17분 소모). 결손을 더 좁히고 싶으면 focus 를 좁혀 `dispatch_research` 를 **1회만** 다시 호출한다.
136
+ - **조사 완결성을 이유로 마커 기록을 미루지 않는다 (hardrule).** 조사가 부분적이면 `gaps` 에 미확인 항목을 남기고 그 상태 그대로 산출물과 substage 마커를 기록한 뒤 종료한다. 마커가 없는 종료는 상위에서 `completion: "incomplete"` 로 분류되어 substage 전체가 재실행된다 — 부분 결과까지 함께 버려진다.
137
+
135
138
  ### 2-4. 요구사항 체크리스트 확인
136
139
 
137
140
  ```
@@ -124,6 +124,8 @@ dispatch_research(
124
124
  - 실패한 소스가 **요구사항 확정에 필수**라면 그 사유(인증 실패·권한 부족 등)를 사용자에게 보고한다. 없어도 되는 소스면 `gaps` 로만 남기고 진행한다.
125
125
  - `deferred` 가 비어 있지 않으면 병렬 상한에 걸려 빠진 조사가 있다는 뜻이다. 필요하면 2차 호출한다.
126
126
  - 모든 소스가 실패하면(`ok: false`) 체크리스트를 추측으로 채우지 말고 사용자에게 보고한다.
127
+ - **`status: "partial"` 은 그 자체로 정상 종료다.** 실패한 소스를 당신이 직접 조사해 메우려 하지 말 것 — `skill_mcp` 순차 호출로 결손을 메우려다 세션 예산을 전부 소진하고 **마커를 하나도 남기지 못한 채** 종료한 사고가 이틀 연속 재현됐다 (GitHub issue #9, 각 27분·17분 소모). 결손을 더 좁히고 싶으면 focus 를 좁혀 `dispatch_research` 를 **1회만** 다시 호출한다.
128
+ - **조사 완결성을 이유로 마커 기록을 미루지 않는다 (hardrule).** 조사가 부분적이면 `gaps` 에 미확인 항목을 남기고 그 상태 그대로 산출물과 substage 마커를 기록한 뒤 종료한다. 마커가 없는 종료는 상위에서 `completion: "incomplete"` 로 분류되어 substage 전체가 재실행된다 — 부분 결과까지 함께 버려진다.
127
129
 
128
130
  ## 2-2. 요구사항 체크리스트
129
131