makdoong2-team 2.3.0 → 2.3.1

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.
@@ -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 원칙).** 예외: `<SCRIPTS_DIR>/state.sh set ...` 을 통한 state.json 마커 기록만 허용.
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
- - **올바른 절차**: 초안 파일 생성이 필요하면 spec을 team-leader에게 반환하여 dev 단계로 위임한다. Planning 단계는 "무엇을 만들지"만 결정하고, "실제로 만드는 것"은 implementation 단계의 책임이다.
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,10 @@ 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
+
101
105
  ## verdict 는 셋이다 — REJECTED 와 ERROR 를 절대 섞지 말 것 (hardrule)
102
106
 
103
107
  `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
- let pluginOk = false, toolsOk = false, ocParseOk = false, ocParseErr = "";
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
- let pluginOk = false, toolsOk = false, ocParseOk = false, ocParseErr = "";
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", "opencode.json missing the plugin entry");
176
- check(toolsOk, "opencode.json enables custom tools", "opencode.json missing custom tools");
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
@@ -1163,9 +1163,12 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1163
1163
  `${filePath ? `"${filePath}" 에 ` : ""}쓸 수 없다.\n` +
1164
1164
  (allowed === null
1165
1165
  ? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
1166
- : `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일 하나뿐이다 ` +
1167
- `(패턴: ${allowed.source}).\n`) +
1168
- `코드 변경이 필요하면 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
1166
+ : `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
1167
+ `(패턴: ${allowed.source}).\n` +
1168
+ `그 경로에는 지금 즉시 'write' 툴(filePath 인자)로 있다 — ` +
1169
+ `bash 리디렉션·apply_patch 우회가 차단된 것이지 산출물 쓰기 자체가 금지된 것이 아니다. ` +
1170
+ `산출물이 필요하면 포기하지 말고 write 툴로 재시도하라.\n`) +
1171
+ `소스 코드 변경이 필요하면 그 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
1169
1172
  }
1170
1173
  }
1171
1174
  // ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
@@ -2026,11 +2029,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2026
2029
  }
2027
2030
  promptPromise.catch(() => { });
2028
2031
  if (success) {
2029
- const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
2030
- const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
2031
- .cwd(args.worktree).quiet().nothrow();
2032
- logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
2033
- `exit=${resetR.exitCode} — substage succeeded`);
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}`
2040
+ .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
+ }
2034
2055
  }
2035
2056
  const retryDisallowed = finalOutcome.kind === "timeout" &&
2036
2057
  finalOutcome.transientFailures === 0;
@@ -83,6 +83,7 @@ export type PollOutcome = {
83
83
  stalledMs: number;
84
84
  permissionID?: string;
85
85
  permissionType?: string;
86
+ permissionPatterns?: string[];
86
87
  } | {
87
88
  kind: "session_gone";
88
89
  polls: number;
@@ -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
- err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}`);
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 (id=${outcome.permissionID}) — auto-rejected and aborted)`
591
- : `(permission_stall: sub-agent tool call stalled for ${outcome.stalledMs}ms likely waiting for external_directory permission approval that cannot be answered in subagent context)`,
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":
@@ -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}) worktree 동기화 누락이거나 파일이 삭제됐다"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
  # 요구사항 초안 — <이슈키>
@@ -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>