makdoong2-team 2.3.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -12
- package/agents/makdoong2-engineer.md +1 -0
- package/agents/makdoong2-planner.md +17 -61
- package/agents/makdoong2-team-leader.md +14 -4
- package/agents/makdoong2-verifier.md +6 -6
- package/assets/makdoong2-team.schema.json +0 -18
- package/bin/cli.js +27 -1
- package/bin/cli.ts +27 -1
- package/dist/agent-stage-config.d.ts +1 -1
- package/dist/agent-stage-config.js +0 -11
- package/dist/apply-patch-paths.d.ts +46 -0
- package/dist/apply-patch-paths.js +114 -0
- package/dist/config.d.ts +21 -8
- package/dist/config.js +34 -0
- package/dist/model-fallback-policy.js +0 -4
- package/dist/opencode-plugin.js +121 -307
- package/dist/poll-sub-session.d.ts +2 -0
- package/dist/poll-sub-session.js +37 -9
- package/gates/stage-analysis-verify.sh +63 -5
- package/gates/stage4-dev-verify.sh +6 -6
- package/gates/verify.sh +0 -2
- package/opencode.json.example +0 -1
- package/package.json +1 -1
- package/scripts/gate-policy-test.sh +15 -14
- package/scripts/install-lib.mjs +1 -1
- package/scripts/install-lib.mts +1 -1
- package/scripts/model-policy.mjs +0 -4
- package/scripts/model-policy.mts +0 -4
- package/scripts/release.sh +13 -1
- package/scripts/run-tests.mts +3 -1
- package/scripts/state.sh +24 -2
- package/skills/_lib/load-secret.sh +15 -5
- package/src/hooks/session-start.sh +0 -1
- package/stages/01-planning.md +35 -54
- package/stages/02-requirements.md +53 -35
- package/stages/04-analysis.md +2 -2
- package/stages/05-worktree-dev.md +15 -0
- package/agents/makdoong2-researcher.md +0 -68
- package/dist/research-fanout.d.ts +0 -165
- package/dist/research-fanout.js +0 -341
- package/gates/stage3-scope-verify.sh +0 -69
- package/stages/03-scope.md +0 -81
package/dist/research-fanout.js
DELETED
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
// research-fanout.ts — pure helpers for the parallel multi-source research fan-out.
|
|
2
|
-
//
|
|
3
|
-
// Why a separate module: the opencode plugin loader calls EVERY named export of
|
|
4
|
-
// the entry file as a plugin factory (see ARCHITECTURE.md §2). 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
|
-
// Everything here is deterministic and side-effect free so the fan-out contract
|
|
9
|
-
// (source registry, query normalisation, output parsing, merge) is unit-testable
|
|
10
|
-
// without spawning sessions.
|
|
11
|
-
export const RESEARCH_SOURCES = {
|
|
12
|
-
jira: {
|
|
13
|
-
source: "jira",
|
|
14
|
-
skill: "jira-research",
|
|
15
|
-
mcp: "works",
|
|
16
|
-
label: "Jira",
|
|
17
|
-
scope: "에픽·상위 이슈·링크 이슈·서브태스크·코멘트에서 구체화된 요구사항과 결정 사항",
|
|
18
|
-
},
|
|
19
|
-
confluence: {
|
|
20
|
-
source: "confluence",
|
|
21
|
-
skill: "confluence-research",
|
|
22
|
-
mcp: "docs",
|
|
23
|
-
label: "Confluence",
|
|
24
|
-
scope: "설계 문서·ADR·API 스펙·운영 가이드에서 지켜야 할 제약과 합의된 규약",
|
|
25
|
-
},
|
|
26
|
-
bitbucket: {
|
|
27
|
-
source: "bitbucket",
|
|
28
|
-
skill: "bitbucket-research",
|
|
29
|
-
mcp: "repos",
|
|
30
|
-
label: "Bitbucket",
|
|
31
|
-
scope: "수정 대상 파일·클래스의 현재 구현, 관련 PR 이력, 기존 테스트 패턴",
|
|
32
|
-
},
|
|
33
|
-
"github-oss": {
|
|
34
|
-
source: "github-oss",
|
|
35
|
-
skill: "github-oss-research",
|
|
36
|
-
// SKILL.md 에 embedded MCP 선언이 없다 — WebFetch / site-wide chrome-devtools-mcp 사용.
|
|
37
|
-
mcp: null,
|
|
38
|
-
label: "GitHub OSS",
|
|
39
|
-
scope: "외부 오픈소스 라이브러리의 사용 예시·알려진 이슈·업스트림 변경",
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
/** Hard ceiling on simultaneously spawned research sessions, regardless of config. */
|
|
43
|
-
export const MAX_RESEARCH_PARALLEL = 6;
|
|
44
|
-
/** Default when `research.max_parallel` is unset. */
|
|
45
|
-
export const DEFAULT_RESEARCH_PARALLEL = 3;
|
|
46
|
-
/** Default per-source wall-clock budget when `research.timeout_minutes` is unset. */
|
|
47
|
-
export const DEFAULT_RESEARCH_TIMEOUT_MINUTES = 10;
|
|
48
|
-
/** Focus text longer than this is truncated before it reaches the prompt. */
|
|
49
|
-
export const MAX_FOCUS_CHARS = 600;
|
|
50
|
-
/** Per-source cap on findings kept in the merged artifact. */
|
|
51
|
-
export const MAX_FINDINGS_PER_SOURCE = 20;
|
|
52
|
-
export function resolveResearchSource(name) {
|
|
53
|
-
if (typeof name !== "string")
|
|
54
|
-
return null;
|
|
55
|
-
const key = name.trim().toLowerCase();
|
|
56
|
-
return RESEARCH_SOURCES[key] ?? null;
|
|
57
|
-
}
|
|
58
|
-
/** Clamp the configured parallelism into [1, MAX_RESEARCH_PARALLEL]. */
|
|
59
|
-
export function resolveParallelism(configured) {
|
|
60
|
-
const n = typeof configured === "number" && Number.isFinite(configured)
|
|
61
|
-
? Math.round(configured)
|
|
62
|
-
: DEFAULT_RESEARCH_PARALLEL;
|
|
63
|
-
return Math.min(MAX_RESEARCH_PARALLEL, Math.max(1, n));
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Validate + de-duplicate the caller's queries.
|
|
67
|
-
*
|
|
68
|
-
* Rules:
|
|
69
|
-
* - unknown source → rejected (the caller mistyped; failing loudly beats a silent no-op)
|
|
70
|
-
* - empty focus → rejected
|
|
71
|
-
* - same (source, focus) twice → the duplicate is rejected
|
|
72
|
-
* - more than `limit` survivors → the tail is DEFERRED, never silently dropped
|
|
73
|
-
*/
|
|
74
|
-
export function normalizeQueries(raw, limit) {
|
|
75
|
-
const queries = [];
|
|
76
|
-
const rejected = [];
|
|
77
|
-
const deferred = [];
|
|
78
|
-
if (!Array.isArray(raw) || raw.length === 0) {
|
|
79
|
-
return { queries, rejected: [{ source: "(none)", reason: "queries 배열이 비어 있다" }], deferred };
|
|
80
|
-
}
|
|
81
|
-
const seen = new Set();
|
|
82
|
-
for (const item of raw) {
|
|
83
|
-
const rawSource = typeof item?.source === "string" ? item.source : String(item?.source ?? "");
|
|
84
|
-
const spec = resolveResearchSource(item?.source);
|
|
85
|
-
if (!spec) {
|
|
86
|
-
rejected.push({
|
|
87
|
-
source: rawSource || "(unset)",
|
|
88
|
-
reason: `알 수 없는 source. 허용: ${Object.keys(RESEARCH_SOURCES).join(", ")}`,
|
|
89
|
-
});
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
const focus = typeof item?.focus === "string" ? item.focus.trim() : "";
|
|
93
|
-
if (!focus) {
|
|
94
|
-
rejected.push({ source: spec.source, reason: "focus 가 비어 있다" });
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
const key = `${spec.source}::${focus.toLowerCase()}`;
|
|
98
|
-
if (seen.has(key)) {
|
|
99
|
-
rejected.push({ source: spec.source, reason: "동일 source+focus 중복" });
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
seen.add(key);
|
|
103
|
-
queries.push({ spec, focus: focus.slice(0, MAX_FOCUS_CHARS) });
|
|
104
|
-
}
|
|
105
|
-
if (queries.length > limit) {
|
|
106
|
-
for (const q of queries.slice(limit)) {
|
|
107
|
-
deferred.push({ source: q.spec.source, reason: `병렬 상한 ${limit} 초과 — 이번 라운드에서 제외` });
|
|
108
|
-
}
|
|
109
|
-
queries.length = limit;
|
|
110
|
-
}
|
|
111
|
-
return { queries, rejected, deferred };
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Prompt for one research sub-session.
|
|
115
|
-
*
|
|
116
|
-
* The session is deliberately narrow: one source, one focus, fixed output schema.
|
|
117
|
-
* That is what makes the fan-out worth its cost — each session's context holds
|
|
118
|
-
* only its own source's material instead of all three (DESIGN.md §3.7).
|
|
119
|
-
*/
|
|
120
|
-
export function buildResearchPrompt(q, ctx) {
|
|
121
|
-
const lines = [
|
|
122
|
-
`Issue: ${ctx.issue}`,
|
|
123
|
-
`Working directory (ABSOLUTE): ${ctx.worktree}`,
|
|
124
|
-
`Scripts directory (ABSOLUTE): ${ctx.scriptsDir}`,
|
|
125
|
-
`Research source: ${q.spec.label} (${q.spec.source})`,
|
|
126
|
-
"",
|
|
127
|
-
`당신은 **${q.spec.label} 한 곳만** 조사하는 리서치 막둥이다. 다른 소스는 조사하지 않는다.`,
|
|
128
|
-
"",
|
|
129
|
-
"## 조사 지시",
|
|
130
|
-
"",
|
|
131
|
-
`1. **가장 먼저** \`skill(name="${q.spec.skill}")\` 로 스킬을 세션에 로드한다.`,
|
|
132
|
-
...(q.spec.mcp
|
|
133
|
-
? [
|
|
134
|
-
` 로드 전에 \`skill_mcp\` 를 부르면 \`MCP server "${q.spec.mcp}" not found\` 로 실패한다.`,
|
|
135
|
-
`2. \`skill_mcp(mcp_name="${q.spec.mcp}", ...)\` 로 아래 focus 를 조사한다.`,
|
|
136
|
-
]
|
|
137
|
-
: [
|
|
138
|
-
" 이 스킬은 전용 MCP 가 없다. SKILL.md 의 절차대로 WebFetch / chrome-devtools-mcp 를 사용한다.",
|
|
139
|
-
"2. 위 수단으로 아래 focus 를 조사한다.",
|
|
140
|
-
]),
|
|
141
|
-
`3. 조사 범위: ${q.spec.scope}`,
|
|
142
|
-
"",
|
|
143
|
-
"## Focus",
|
|
144
|
-
"",
|
|
145
|
-
q.focus,
|
|
146
|
-
"",
|
|
147
|
-
"## 출력 형식 (엄수)",
|
|
148
|
-
"",
|
|
149
|
-
"마지막 assistant turn 에 아래 스키마의 JSON 을 ```json 펜스로 감싸 **하나만** 출력한다.",
|
|
150
|
-
"펜스 밖 설명 문장은 자유지만, JSON 블록은 정확히 하나여야 한다.",
|
|
151
|
-
"",
|
|
152
|
-
"```json",
|
|
153
|
-
"{",
|
|
154
|
-
` "source": "${q.spec.source}",`,
|
|
155
|
-
' "findings": [',
|
|
156
|
-
' {"title": "<한 줄 제목>", "detail": "<300자 이내 요약>", "url": "<출처 URL 또는 null>"}',
|
|
157
|
-
" ],",
|
|
158
|
-
' "gaps": ["<이 소스에서 확인하지 못한 항목>"]',
|
|
159
|
-
"}",
|
|
160
|
-
"```",
|
|
161
|
-
"",
|
|
162
|
-
"## 규약",
|
|
163
|
-
"",
|
|
164
|
-
"- **읽기 전용.** 파일 생성·수정, state.json 조작, git 명령을 일절 수행하지 않는다.",
|
|
165
|
-
"- 조사 결과가 없으면 `findings: []` 로 두고 `gaps` 에 이유를 적는다. 추측으로 채우지 않는다.",
|
|
166
|
-
"- 근거가 있는 항목에만 `url` 을 넣는다. 지어내지 않는다.",
|
|
167
|
-
`- findings 는 최대 ${MAX_FINDINGS_PER_SOURCE}개. 중요도 순으로 자른다.`,
|
|
168
|
-
];
|
|
169
|
-
if (ctx.context) {
|
|
170
|
-
lines.push("", "## 추가 컨텍스트", "", ctx.context);
|
|
171
|
-
}
|
|
172
|
-
return lines.join("\n");
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Pull the research JSON out of an LLM turn.
|
|
176
|
-
*
|
|
177
|
-
* Order matters: prefer the LAST fenced ```json block (models often show a draft
|
|
178
|
-
* before the final answer), then fall back to a balanced brace scan. A parse
|
|
179
|
-
* failure is reported, never coerced into an empty-but-successful result — a
|
|
180
|
-
* silently empty source reads as "nothing to find" when it means "nothing parsed".
|
|
181
|
-
*/
|
|
182
|
-
export function parseResearchOutput(raw) {
|
|
183
|
-
if (typeof raw !== "string" || raw.trim() === "") {
|
|
184
|
-
return { ok: false, reason: "빈 응답" };
|
|
185
|
-
}
|
|
186
|
-
const candidates = [];
|
|
187
|
-
const fence = /```(?:json)?\s*\n([\s\S]*?)```/gi;
|
|
188
|
-
let m;
|
|
189
|
-
while ((m = fence.exec(raw)) !== null)
|
|
190
|
-
candidates.push(m[1]);
|
|
191
|
-
candidates.reverse(); // last fenced block first
|
|
192
|
-
const braced = extractBalancedObject(raw);
|
|
193
|
-
if (braced)
|
|
194
|
-
candidates.push(braced);
|
|
195
|
-
for (const c of candidates) {
|
|
196
|
-
let parsed;
|
|
197
|
-
try {
|
|
198
|
-
parsed = JSON.parse(c.trim());
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
204
|
-
continue;
|
|
205
|
-
const obj = parsed;
|
|
206
|
-
if (!("findings" in obj) && !("gaps" in obj))
|
|
207
|
-
continue;
|
|
208
|
-
return { ok: true, data: coerceParsed(obj) };
|
|
209
|
-
}
|
|
210
|
-
return { ok: false, reason: "JSON 블록을 찾지 못했다 (findings/gaps 키 부재)" };
|
|
211
|
-
}
|
|
212
|
-
function extractBalancedObject(text) {
|
|
213
|
-
const start = text.indexOf("{");
|
|
214
|
-
if (start < 0)
|
|
215
|
-
return null;
|
|
216
|
-
let depth = 0;
|
|
217
|
-
let inStr = false;
|
|
218
|
-
let esc = false;
|
|
219
|
-
for (let i = start; i < text.length; i++) {
|
|
220
|
-
const ch = text[i];
|
|
221
|
-
if (inStr) {
|
|
222
|
-
if (esc)
|
|
223
|
-
esc = false;
|
|
224
|
-
else if (ch === "\\")
|
|
225
|
-
esc = true;
|
|
226
|
-
else if (ch === '"')
|
|
227
|
-
inStr = false;
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (ch === '"')
|
|
231
|
-
inStr = true;
|
|
232
|
-
else if (ch === "{")
|
|
233
|
-
depth++;
|
|
234
|
-
else if (ch === "}") {
|
|
235
|
-
depth--;
|
|
236
|
-
if (depth === 0)
|
|
237
|
-
return text.slice(start, i + 1);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
function coerceParsed(obj) {
|
|
243
|
-
const rawFindings = Array.isArray(obj.findings) ? obj.findings : [];
|
|
244
|
-
const findings = [];
|
|
245
|
-
for (const f of rawFindings.slice(0, MAX_FINDINGS_PER_SOURCE)) {
|
|
246
|
-
if (!f || typeof f !== "object")
|
|
247
|
-
continue;
|
|
248
|
-
const r = f;
|
|
249
|
-
const title = typeof r.title === "string" ? r.title.trim() : "";
|
|
250
|
-
if (!title)
|
|
251
|
-
continue;
|
|
252
|
-
findings.push({
|
|
253
|
-
title,
|
|
254
|
-
detail: typeof r.detail === "string" ? r.detail.trim() : "",
|
|
255
|
-
url: typeof r.url === "string" && r.url.trim() !== "" ? r.url.trim() : null,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
const gaps = (Array.isArray(obj.gaps) ? obj.gaps : [])
|
|
259
|
-
.filter((g) => typeof g === "string" && g.trim() !== "")
|
|
260
|
-
.map((g) => g.trim());
|
|
261
|
-
return { findings, gaps };
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Merge per-source outcomes into the artifact a gate can check deterministically.
|
|
265
|
-
*
|
|
266
|
-
* `rejected` / `deferred` are carried into the artifact on purpose: a fan-out that
|
|
267
|
-
* quietly covered 2 of 3 sources looks identical to one that covered all 3 unless
|
|
268
|
-
* the shortfall is written down.
|
|
269
|
-
*/
|
|
270
|
-
export function mergeResearchFindings(issue, generatedAt, outcomes, rejected, deferred) {
|
|
271
|
-
const ok = outcomes.filter((o) => o.status === "ok").length;
|
|
272
|
-
return {
|
|
273
|
-
issue,
|
|
274
|
-
generated_at: generatedAt,
|
|
275
|
-
sources: outcomes,
|
|
276
|
-
rejected,
|
|
277
|
-
deferred,
|
|
278
|
-
counts: {
|
|
279
|
-
requested: outcomes.length + rejected.length + deferred.length,
|
|
280
|
-
ok,
|
|
281
|
-
failed: outcomes.length - ok,
|
|
282
|
-
findings_total: outcomes.reduce((n, o) => n + o.findings.length, 0),
|
|
283
|
-
},
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
/** Human-readable one-liner per source for the tool's text return. */
|
|
287
|
-
export function summarizeOutcomes(outcomes) {
|
|
288
|
-
return outcomes.map((o) => o.status === "ok"
|
|
289
|
-
? `${o.label}: findings ${o.findings.length}건, gaps ${o.gaps.length}건 (${Math.round(o.elapsed_ms / 1000)}s)`
|
|
290
|
-
: `${o.label}: 실패 — ${o.error ?? "unknown"} (${Math.round(o.elapsed_ms / 1000)}s)`);
|
|
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
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# stage3-scope-verify.sh <issue> — 3단계(범위 확정) 진입 게이트
|
|
3
|
-
set -euo pipefail
|
|
4
|
-
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
-
ISSUE="${1:?usage: stage3-scope-verify.sh <issue>}"
|
|
6
|
-
P="$("$HERE/../scripts/state.sh" root)/.makdoong2-team/$ISSUE/state.json"
|
|
7
|
-
fail(){ echo "MAKDOONG2-GATE BLOCKED [1_planning.scope]: $1" >&2; exit 2; }
|
|
8
|
-
[ -f "$P" ] || fail "state.json 없음 — jira substage부터 시작하라"
|
|
9
|
-
# state.sh get 은 실패해도 stdout 에 "null" 한 줄을 찍고 exit 1 한다 (게이트들이
|
|
10
|
-
# 의존하는 계약). 종전 `|| echo "__MISSING__"` 은 그 위에 한 줄을 **덧붙여**
|
|
11
|
-
# `null\\n__MISSING__` 두 줄을 만들었고, 그 값은 `= "null"` 에도 `= "__MISSING__"`
|
|
12
|
-
# 에도 걸리지 않아 **부재/손상이 "값이 있음" 으로 통과**했다. 실측 확인.
|
|
13
|
-
# if 형태로 성공 출력만 취하고, 실패 시에는 sentinel 하나만 낸다.
|
|
14
|
-
q(){ local __v; if __v="$("$HERE/../scripts/state.sh" get "$ISSUE" "$1" 2>/dev/null)"; then printf "%s" "$__v"; else printf "__MISSING__"; fi; }
|
|
15
|
-
[ "$(q '.stages."1_planning".substages."scope".done')" != "true" ] || fail "이미 done=true 완료됨 — auto_advance_stage 로 다음 단계 진행"
|
|
16
|
-
[ "$(q '.stages."1_planning".substages."requirements".done')" = "true" ] || fail "requirements substage 미완료"
|
|
17
|
-
|
|
18
|
-
if [ "$(q '.policy.auto_approve."1_planning.requirements"')" != "true" ]; then
|
|
19
|
-
[ "$(q '.stages."1_planning".substages."requirements".approved_by_user')" = "true" ] \
|
|
20
|
-
|| fail "requirements substage 사용자 승인 없음 (또는 .policy.auto_approve.\"1_planning.requirements\" 미설정)"
|
|
21
|
-
if [ "$(q '.stages."1_planning".substages."requirements".verification_pending')" = "true" ]; then
|
|
22
|
-
fail "requirements substage 검증 대기 중 (verification_pending) — 사용자 승인 후 approved_by_user를 기록하라"
|
|
23
|
-
fi
|
|
24
|
-
fi
|
|
25
|
-
|
|
26
|
-
if [ "$(q '.stages."1_planning".substages."requirements".interview_required')" = "true" ]; then
|
|
27
|
-
[ "$(q '.stages."1_planning".substages."requirements".interview_completed')" = "true" ] \
|
|
28
|
-
|| fail "requirements substage 인터뷰 미완료 — 모든 미결 항목 해소 후 interview_completed=true 기록 필요"
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
# --- 요구사항 품질 게이트 (조건부 — 마커 존재 시에만 검사, 구형 state 호환) ---
|
|
32
|
-
# 1) Ambiguity Score 수렴: 기록된 값이 0.2 초과면 요구사항 미수렴 (stages/02-requirements.md §2-3-2b)
|
|
33
|
-
AMB="$(q '.stages."1_planning".substages."requirements".ambiguity_score')"
|
|
34
|
-
if [ "$AMB" != "__MISSING__" ] && [ "$AMB" != "null" ] && [ -n "$AMB" ]; then
|
|
35
|
-
awk -v a="$AMB" 'BEGIN { exit (a + 0 <= 0.2) ? 0 : 1 }' \
|
|
36
|
-
|| fail "ambiguity_score=$AMB > 0.2 — 요구사항 미수렴. 인터뷰로 미결 항목 해소 후 재산정하라"
|
|
37
|
-
fi
|
|
38
|
-
|
|
39
|
-
# 2) 명세 동결(spec drift) 검증: spec_hash 기록 시 draft 파일 해시 재계산 일치 필요 (§2-4a)
|
|
40
|
-
SPEC_HASH="$(q '.stages."1_planning".substages."requirements".spec_hash')"
|
|
41
|
-
if [ "$SPEC_HASH" != "__MISSING__" ] && [ "$SPEC_HASH" != "null" ] && [ -n "$SPEC_HASH" ]; then
|
|
42
|
-
DRAFT="$(q '.stages."1_planning".substages."requirements".draft_path')"
|
|
43
|
-
ROOT="$("$HERE/../scripts/state.sh" root)"
|
|
44
|
-
# 마커 누락과 파일 부재를 구분해서 알린다. 종전에는 둘 다 "확정 명세 파일 없음" 으로
|
|
45
|
-
# 뭉뚱그려서, 파일은 멀쩡히 있고 마커만 빠진 흔한 경우(issue #6-①)에 무엇을 해야
|
|
46
|
-
# 하는지 알 수 없었다. 마커 기록은 team-leader 에게 허용된 state.sh set 경로다.
|
|
47
|
-
DEFAULT_DRAFT=".makdoong2-team/${ISSUE}/requirements-draft.md"
|
|
48
|
-
if [ "$DRAFT" = "__MISSING__" ] || [ "$DRAFT" = "null" ] || [ -z "$DRAFT" ]; then
|
|
49
|
-
if [ -f "$ROOT/$DEFAULT_DRAFT" ]; then
|
|
50
|
-
fail "spec_hash 는 기록됐는데 draft_path 마커가 없다 — 파일은 ${DEFAULT_DRAFT} 에 있다.
|
|
51
|
-
복구: (1) sha256sum \"${ROOT}/${DEFAULT_DRAFT}\" 가 spec_hash(${SPEC_HASH}) 와 같은지 대조하고,
|
|
52
|
-
(2) 같으면 state.sh set 으로 requirements.draft_path 에 \"${DEFAULT_DRAFT}\" 를 기록한다 (stages/02-requirements.md §2-0),
|
|
53
|
-
(3) 다르면 명세가 동결 후 변경된 것이므로 requirements substage 를 재작업한다 (§2-4a)."
|
|
54
|
-
fi
|
|
55
|
-
fail "spec_hash 는 기록됐는데 draft_path 마커도 확정 명세 파일도 없다 — requirements substage 를 재작업하라 (stages/02-requirements.md §2-0, §2-5 9번)"
|
|
56
|
-
fi
|
|
57
|
-
# "생성된 적 없음" 을 안내에서 빼면 복구 방향을 잘못 잡는다 — 실제로 가장 흔한
|
|
58
|
-
# 경우인데 종전 메시지는 동기화 누락·삭제만 언급해 리더가 동기화 문제부터
|
|
59
|
-
# 의심했다 (issue #8).
|
|
60
|
-
[ -f "$ROOT/$DRAFT" ] \
|
|
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 재작업"
|
|
65
|
-
ACTUAL="$(sha256sum "$ROOT/$DRAFT" | cut -d' ' -f1)"
|
|
66
|
-
[ "$ACTUAL" = "$SPEC_HASH" ] \
|
|
67
|
-
|| fail "확정 명세 무단 변경 감지 (spec drift) — 동결 후 변경은 사용자 재승인 + spec_hash 재기록 절차만 허용 (stages/02-requirements.md §2-4a)"
|
|
68
|
-
fi
|
|
69
|
-
echo "MAKDOONG2-GATE OK: 1_planning.scope"
|
package/stages/03-scope.md
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# 3단계: 개발 범위 파악
|
|
2
|
-
|
|
3
|
-
**목적**: 어떤 코드를 어떻게 고칠지 범위를 확정한다.
|
|
4
|
-
**진입 게이트**: `verify.sh <이슈키> 1_planning.scope` (2단계 완료 + 사용자 승인 필요).
|
|
5
|
-
|
|
6
|
-
> `<SCRIPTS_DIR>`는 부장님이 dispatch_stage 프롬프트로 주입한 절대경로다. 이 값을 그대로 대입하여 실행한다.
|
|
7
|
-
|
|
8
|
-
2단계 조사 결과로 코드 수정 계획을 수립한다. 2단계에서 `bitbucket-research`로 코드 탐색을 이미 했으므로 본 단계는 **변경 단위 확정**에 집중한다.
|
|
9
|
-
|
|
10
|
-
## 출력 형식
|
|
11
|
-
|
|
12
|
-
```
|
|
13
|
-
### 개발 범위
|
|
14
|
-
**수정 파일**: <path>: <변경 요지> ...
|
|
15
|
-
**추가 파일**: <path>: <목적> ...
|
|
16
|
-
**테스트 범위**: 단위(대상 클래스/메서드), 통합(빌드 플랜명/시나리오)
|
|
17
|
-
**영향 범위**: <모듈/시스템>: <영향 요지> ...
|
|
18
|
-
**예상 작업 단위(커밋 후보)**: 1. <단위1> 2. <단위2> ...
|
|
19
|
-
**2단계에서 확정한 가정**: <가정1> ...
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## 범주 재평가 (escalation — 하향 금지)
|
|
23
|
-
|
|
24
|
-
실제 수정/추가 파일과 작업 단위가 확정된 뒤, 2단계(§2-4b)가 추정으로 기록한 `.policy.scope_size`·`.policy.criticality`를 **재평가**한다. 2단계 추정은 명세 기준 추정치이므로, 본 단계에서 코드 변경 단위가 드러난 직후가 유일한 정정 시점이다.
|
|
25
|
-
|
|
26
|
-
| 차원 | 재평가 기준 |
|
|
27
|
-
|---|---|
|
|
28
|
-
| `scope_size` | 확정된 수정/추가 파일 수·작업 단위(커밋 후보)·영향 모듈이 다수에 걸치면 `large` |
|
|
29
|
-
| `criticality` | 인증·결제·보안·데이터 무결성·마이그레이션·대외 API 등 실패 시 파급이 큰 영역이 실제 변경에 포함되면 `critical` |
|
|
30
|
-
|
|
31
|
-
도출 규칙은 2-4b와 동일하다: `criticality == "critical" OR scope_size == "large"`이면 `category`는 **major**.
|
|
32
|
-
|
|
33
|
-
- 2단계에서 **minor**였으나 위 재평가로 `large` 또는 `critical`이 확정되면 **major로 상향(escalation)** 한다. 상향은 위험도 라벨 정정에 그치며 `auto_approve` 맵은 건드리지 않는다 — 흐름은 여전히 무인 진행이다:
|
|
34
|
-
```bash
|
|
35
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.policy.category' '"major"'
|
|
36
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.policy.scope_size' '"large"' # 또는 criticality
|
|
37
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.policy.categorized_by' '"1_planning.scope"'
|
|
38
|
-
```
|
|
39
|
-
`auto_approve` 는 그대로 두므로 게이트는 무인 통과한다. HITL 이 필요한 특수 상황(예: 이슈 유형별 opt-in 정책)에서만 별도 지시로 `auto_approve."3_delivery.commit"` 를 `false` 로 재설정할 수 있다. 이 경우 커밋 직전 변경 보고서(`change-report.md`) + 사용자 승인이 요구된다(6단계 §6-0).
|
|
40
|
-
- **상향만 허용한다. major → minor 하향은 절대 금지.** 이미 major면 그대로 둔다.
|
|
41
|
-
- 재평가 결과 변동이 없으면(여전히 minor·동일 범주) `.policy`를 건드리지 않는다.
|
|
42
|
-
|
|
43
|
-
## 최종 자가 검증 (Pre-Completion Checklist)
|
|
44
|
-
|
|
45
|
-
`done=true` 직전, 다음 5항목을 자체 확인하고 state.json에 결과를 기록한다.
|
|
46
|
-
하나라도 false면 완료 기록 금지.
|
|
47
|
-
|
|
48
|
-
| 항목 | 확인 |
|
|
49
|
-
|---|---|
|
|
50
|
-
| 1 | 수정/추가 파일이 모두 절대경로(또는 명확한 상대경로)로 명시되었다 |
|
|
51
|
-
| 2 | 테스트 범위(단위 클래스/메서드 + 통합 시나리오)가 함께 정의되었다 |
|
|
52
|
-
| 3 | 예상 작업 단위가 1 commit = 1 change 원칙에 맞게 쪼개졌다 |
|
|
53
|
-
| 4 | 스코프 아웃(이번 이슈에서 다루지 않는 것)이 명시적으로 적혔다 |
|
|
54
|
-
| 5 | 사용자 명시 승인("이대로 진행하세요" 등)을 받았다 |
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".self_check' \
|
|
58
|
-
'{"paths_explicit": true, "test_scope_defined": true, "atomic_units": true, "scope_out_listed": true, "user_approved": true}'
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## 완료 기록
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".done' 'true'
|
|
65
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".done_at' "\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
**승인 경로** — (위 escalation 반영 후) `.policy.auto_approve."1_planning.scope"`를 따른다:
|
|
69
|
-
|
|
70
|
-
- **auto_approve == true** (정상 — minor/major 공통): 사람 대기 없이 자동 진행한다. `verification_pending`을 즉시 `false`로 둔다. 게이트(`stage4-dev-verify.sh`)가 정책을 보고 사용자 승인 없이 4단계로 통과시킨다.
|
|
71
|
-
```bash
|
|
72
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".verification_pending' 'false'
|
|
73
|
-
```
|
|
74
|
-
- **auto_approve 미설정**(구형 state — 범주화 폴백): 기존대로 사용자가 "이대로 진행하세요" 같은 명시적 승인을 준 뒤에만 4단계로 넘어간다.
|
|
75
|
-
```bash
|
|
76
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".verification_pending' 'true'
|
|
77
|
-
# 사용자 명시 승인 직후에만:
|
|
78
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".approved_by_user' 'true'
|
|
79
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".approved_at' "\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
|
|
80
|
-
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."scope".verification_pending' 'false'
|
|
81
|
-
```
|