makdoong2-team 3.0.3 → 3.0.4

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 CHANGED
@@ -118,6 +118,7 @@ makdoong2-team doctor # 설치 진단
118
118
  | `timeout.substage_minutes` | 서브에이전트 1회 실행 상한 (기본 30분) |
119
119
  | `timeout.per_agent` | 에이전트별 상한 override (기본 seed: engineer 60분) |
120
120
  | `timeout.stall_escalate_threshold` | substage 누적 hang 상한 (기본 5). 초과 시 dispatch 차단 후 사용자 에스컬레이션 |
121
+ | `timeout.permission_corrections_per_session` | 스코프 밖 경로 요청을 abort 없이 피드백 거부(툴 오류)로 되돌려 주는 세션당 횟수 (기본 3, 0 이면 첫 위반에서 abort) |
121
122
  | `tmux` | 막둥이 pane 모니터. 코드 기본값은 off, seed 되는 설정 파일은 `enabled: true` |
122
123
  | `worktree.extra_exclude` | worktree 동기화 추가 제외 패턴 |
123
124
  | `logging` | `level` / `mode` / `path` / `max_bytes` |
@@ -137,4 +137,5 @@ verifier 가 파일 존재 + JSON 정합 + 마커 3중 검증하며, VERIFIED
137
137
  - **outer-world 에이전트 (Sisyphus / Explore / Librarian / oh-my-openagent 계열 카테고리 등) 위임 금지.** 본 에이전트에는 `Task` 툴이 프론트매터에서 제거되어 있어 물리적으로 스폰 불가. 조사가 필요하면 `skill_mcp` 도 아닌 자체 `Read`/`Grep`/`Glob`/`Bash` 만 사용한다 (본 substage 는 원격 조사 불필요).
138
138
  - 검증 절차 생략 후 `done` 마커 기록 (게이트 우회 행위).
139
139
  - 3단계 scope 를 넘어선 파일 분석 (scope 에서 확정된 대상과 그 직접 의존 파일에 한정).
140
+ - **검색 루트를 저장소 밖으로 넓히지 말 것 (hardrule).** 저장소 안에서 못 찾은 참조(배포 설정·다른 프로젝트의 파일 등)를 상위 디렉토리로 넓혀 찾지 않는다 — `glob`/`grep` 의 `path` 를 `/root/IdeaProjects` 같은 조부모로 넘긴 것이 실제 차단 사례다 (GitHub #12 재발). 자동 승인 범위는 worktree 의 부모 한 단계(worktree 와 형제 디렉토리)뿐이다. 스코프 밖 요청은 툴 오류 `The user rejected permission … with the following feedback: …` 로 돌아오며 그 안내대로 경로를 좁히면 세션은 계속된다; 안내를 받고도 반복하면(세션당 상한) 세션이 종료된다. 저장소 밖 자료가 꼭 필요하면 조사하지 말고 필요한 이유를 최종 출력에 적어 보고한다.
140
141
  - 임의 판단으로 `skipped=true` 마킹 (skip 은 게이트 전용 판정).
@@ -162,5 +162,6 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."2_implementation".substage
162
162
  - 요청 범위 밖 리팩토링·"있으면 좋을" 기능 추가.
163
163
  - worktree 외부 파일 편집.
164
164
  - **`/tmp` 등 워크스페이스 밖 경로에 임시 파일 생성 (hardrule).** opencode 는 bash 명령이 참조하는 디렉토리마다 `external_directory` 승인을 묻는데, 서브에이전트 세션에서 그 요청은 답할 사람이 없어 **자동 거부되고 세션이 즉시 종료된다** — 하던 작업이 통째로 날아간다. 게다가 워크스페이스 밖에 쓴 것은 worktree 동기화·커밋 대상이 아니라 조용히 사라진다. 임시 파일은 반드시 `<worktree>/.makdoong2-team/<이슈키>/tmp/` 에 만든다 (cwd 안이라 승인 불필요, git exclude 이미 등록됨).
165
+ - **검색 루트를 저장소 밖으로 넓히지 말 것 (hardrule).** 저장소 안에서 못 찾은 참조(배포 설정·다른 프로젝트의 파일 등)를 상위 디렉토리로 넓혀 찾지 않는다 — `glob`/`grep` 의 `path` 를 `/root/IdeaProjects` 같은 조부모로 넘긴 것이 실제 차단 사례다 (GitHub #12 재발). 자동 승인 범위는 worktree 의 부모 한 단계(worktree 와 형제 디렉토리)뿐이다. 스코프 밖 요청은 툴 오류 `The user rejected permission … with the following feedback: …` 로 돌아오며 그 안내대로 경로를 좁히면 세션은 계속된다; 안내를 받고도 반복하면(세션당 상한) 세션이 종료된다. 저장소 밖 자료가 꼭 필요하면 조사하지 말고 필요한 이유를 최종 출력에 적어 보고한다.
165
166
  - 테스트 결과 임의 보고 (실제 실행 없이 "pass" 기록 금지 — 게이트 우회).
166
167
  - 프로덕션 코드 변경 (test 실패 시 dev substage로 복귀 필수).
@@ -204,6 +204,7 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."1_planning".substages."req
204
204
  - **쓰기 툴은 세션에 실제로 존재하는 것을 쓴다.** `write` 가 있으면 `write(filePath=…)`, 없으면 `apply_patch` 다 — opencode 는 `gpt-5` 계열 모델 세션에서 `write`·`edit` 를 노출하지 않고 `apply_patch` 로 대체한다. `apply_patch` 본문은 `*** Begin Patch` / `*** Add File: <허용 경로>` / `*** End Patch` 형식이어야 훅이 대상 경로를 파싱해 통과시킨다. **"write 툴 부재" 를 이유로 산출물 생성을 포기하지 말 것** — 그 판단이 워크플로 전체를 정지시킨다 (issue #8).
205
205
  - 같은 경로라도 bash 리디렉션은 어느 경우에도 차단된다.
206
206
  - **`/tmp` 등 워크스페이스 밖 경로 사용 금지.** 승인이 자동 거부되고 세션이 즉시 종료된다. planning 산출물은 전부 `.makdoong2-team/<이슈키>/` 아래에 만들면 되므로 임시 파일이 필요한 상황 자체가 없다.
207
+ - **검색 루트를 저장소 밖으로 넓히지 말 것 (hardrule).** 저장소 안에서 못 찾은 참조(배포 설정·다른 프로젝트의 파일 등)를 상위 디렉토리로 넓혀 찾지 않는다 — `glob`/`grep` 의 `path` 를 `/root/IdeaProjects` 같은 조부모로 넘긴 것이 실제 차단 사례다 (GitHub #12 재발). 자동 승인 범위는 worktree 의 부모 한 단계(worktree 와 형제 디렉토리)뿐이다. 스코프 밖 요청은 툴 오류 `The user rejected permission … with the following feedback: …` 로 돌아오며 그 안내대로 경로를 좁히면 세션은 계속된다; 안내를 받고도 반복하면(세션당 상한) 세션이 종료된다. 저장소 밖 자료가 꼭 필요하면 조사하지 말고 필요한 이유를 최종 출력에 적어 보고한다.
207
208
  - **금지 패턴**: `echo >`, `cat > file`, `cat <<EOF > file`, `tee file`, `sed -i`, `awk ... > file`, `printf > file`, `> file`, `>> file`, `python -c "... open(..., 'w') ..."`, `node -e "... fs.writeFileSync(...) ..."`
208
209
  - **위반 예시**:
209
210
  ```bash
@@ -467,6 +467,7 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."3_delivery".substages."rev
467
467
  ## 금지
468
468
 
469
469
  - **소스코드 파일 편집·생성** — 본 에이전트의 Write 권한은 `change-report.md` 전용. 그 외 파일은 engineer 가 이미 완료했어야 함.
470
+ - **검색 루트를 저장소 밖으로 넓히지 말 것 (hardrule).** 저장소 안에서 못 찾은 참조(배포 설정·다른 프로젝트의 파일 등)를 상위 디렉토리로 넓혀 찾지 않는다 — `glob`/`grep` 의 `path` 를 `/root/IdeaProjects` 같은 조부모로 넘긴 것이 실제 차단 사례다 (GitHub #12 재발). 자동 승인 범위는 worktree 의 부모 한 단계(worktree 와 형제 디렉토리)뿐이다. 스코프 밖 요청은 툴 오류 `The user rejected permission … with the following feedback: …` 로 돌아오며 그 안내대로 경로를 좁히면 세션은 계속된다; 안내를 받고도 반복하면(세션당 상한) 세션이 종료된다. 저장소 밖 자료가 꼭 필요하면 조사하지 말고 필요한 이유를 최종 출력에 적어 보고한다.
470
471
  - **`git add .` / `-A` / `-u` 사용** — 여러 파일을 한 번에 stage. 반드시 파일별 `git add -- "$FILE"` 만 허용.
471
472
  - **1 commit 에 2개 이상 파일 포함** — atomic 원칙 절대 위반. post-commit 게이트가 REJECT.
472
473
  - **결합어(`and`/`&`/`+`/`및`/`그리고`) 를 commit subject 에 사용** — 여러 변경을 한 커밋에 합친 신호. 게이트가 REJECT.
@@ -134,10 +134,11 @@ permission:
134
134
  | `permission_reason` | `outside_allowed_roots` / `non_external_permission` / `tool_call_stall` — **처방이 서로 다르다** |
135
135
  | `permission_patterns` | 실제로 차단된 경로 패턴 |
136
136
  | `permission_scope` | 자동 승인되는 범위 (worktree 의 부모 디렉토리 이하) |
137
+ | `permission_corrections` | abort **전에** 세션 안에서 "경로를 좁혀라" 는 피드백 거부를 받은 횟수. `>0` 이면 안내를 받고도 같은 범위를 반복한 것 |
137
138
 
138
139
  `permission_reason` 별 조치:
139
140
 
140
- - **`outside_allowed_roots`** — 서브에이전트가 워크스페이스 밖 경로를 요청했다. `dispatch_stage` 가 이미 허용 범위를 프롬프트에 주입해 자동 재디스패치를 시도했고(`attempts` 확인), 그 예산까지 소진한 상태다. **같은 인자로 재호출하지 않는다.** `permission_patterns` 와 `permission_scope` 를 그대로 인용해 보고하고 사용자 지시를 기다린다. 스코프를 넓혀 달라는 요청은 하지 않는다 — 조부모 이상을 여는 것은 형제 프로젝트 전체를 사람 확인 없이 승인 대상으로 만드는 일이라 설계상 거부된다.
141
+ - **`outside_allowed_roots`** — 서브에이전트가 워크스페이스 밖 경로를 요청했다. 방어는 이미 세 번 돌았다: ① 첫 프롬프트에 경로 범위 하드룰이 실려 있었고 ② 스코프 밖 요청은 abort 없이 툴 오류(피드백 거부)로 되돌려져 세션 안에서 고칠 기회를 받았으며(`permission_corrections` 회) ③ 그래도 반복해 abort 된 뒤 `dispatch_stage` 가 허용 범위를 프롬프트에 주입해 자동 재디스패치를 시도했고(`attempts` 확인), 그 예산까지 소진한 상태다. **같은 인자로 재호출하지 않는다.** `permission_patterns` 와 `permission_scope` 를 그대로 인용해 보고하고 사용자 지시를 기다린다. 스코프를 넓혀 달라는 요청은 하지 않는다 — 조부모 이상을 여는 것은 형제 프로젝트 전체를 사람 확인 없이 승인 대상으로 만드는 일이라 설계상 거부된다.
141
142
  - **`non_external_permission`** — 경로 문제가 아니라 해당 에이전트 frontmatter 의 `permission:` 블록 문제다 (정식 키는 `edit`; `write` 는 존재하지 않아 조용히 무시된다). 사용자에게 보고한다.
142
143
  - **`tool_call_stall`** — 부분 설치 의심. `npx makdoong2-team doctor` 실행을 안내한다.
143
144
 
@@ -342,7 +343,7 @@ loop (max_substage_retries=3 per substage):
342
343
  - 모델 폴백 시: `[fallback] <agent>: <primary> → <fallback> (reason: ...)`
343
344
  - `retry_disallowed` 감지 시: `[substage X RETRY DISALLOWED] outcome=timeout, transient_failures=0 — sub-agent hang. <retry_disallowed_reason>`
344
345
  - `session_gone` 최종 실패 시: `[substage X SESSION_GONE gone_reason=<message_stall|status_absent>] dispatch_stage 3회 자동 redispatch 후 실패. attempts=<N>, previous_session_ids=[...]`
345
- - `permission_stall` 시: `[substage X PERMISSION BLOCKED reason=<permission_reason>] 서브세션이 차단되어 **이미 종료됨** (승인 대기 아님). 차단 경로=<permission_patterns>, 허용 범위=<permission_scope>, attempts=<N>`
346
+ - `permission_stall` 시: `[substage X PERMISSION BLOCKED reason=<permission_reason>] 서브세션이 차단되어 **이미 종료됨** (승인 대기 아님). 차단 경로=<permission_patterns>, 허용 범위=<permission_scope>, 세션 내 교정=<permission_corrections>회, attempts=<N>`
346
347
  - verifier ERROR 시: `[substage X VERIFIER ERROR source=<verdict_source>] 검증 미수행 — verifier 만 재호출 (streak=<N>/3). stage 는 건드리지 않음`
347
348
  - verifier ERROR streak 초과 시: `[substage X VERIFIER ERROR STREAK EXCEEDED] 판정 3회 연속 실패. source=<verdict_source>. 사용자 개입 필요.`
348
349
  - REJECTED 재시도 시: `[substage X REJECTED retry] streak=<N>, reason_prefix="<40자>" → dispatch_stage 재호출`
@@ -141,6 +141,11 @@
141
141
  "type": "integer",
142
142
  "minimum": 1,
143
143
  "description": "Accumulated hang_history entries (message_stall / status_absent) per substage that block further dispatch_stage calls and force user escalation. Counts across tool calls, unlike the per-call MAX_ATTEMPTS budget which resets on every re-dispatch. Default 5."
144
+ },
145
+ "permission_corrections_per_session": {
146
+ "type": "integer",
147
+ "minimum": 0,
148
+ "description": "How many times per sub-session an external_directory request outside the allowed scope (worktree parent) is answered with a feedback-reject (tool error telling the model to narrow its path) instead of reject + abort. The session keeps running and can self-correct in place. 0 disables and restores abort-on-first-violation. Default 3."
144
149
  }
145
150
  }
146
151
  },
package/dist/config.d.ts CHANGED
@@ -31,8 +31,10 @@ export interface TimeoutConfig {
31
31
  substage_minutes?: number;
32
32
  per_agent?: Record<string, number>;
33
33
  stall_escalate_threshold?: number;
34
+ permission_corrections_per_session?: number;
34
35
  }
35
36
  export declare const DEFAULT_STALL_ESCALATE_THRESHOLD = 5;
37
+ export declare const DEFAULT_PERMISSION_CORRECTIONS_PER_SESSION = 3;
36
38
  export type LogLevel = "silent" | "error" | "warn" | "info" | "debug" | "trace";
37
39
  export type LogMode = "stdin" | "file";
38
40
  export interface LoggingConfig {
package/dist/config.js CHANGED
@@ -13,6 +13,7 @@ import { homedir } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  export const DEFAULT_STALL_ESCALATE_THRESHOLD = 5;
16
+ export const DEFAULT_PERMISSION_CORRECTIONS_PER_SESSION = 3;
16
17
  export const LOG_LEVELS = [
17
18
  "silent",
18
19
  "error",
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
21
21
  import { basename as pathBasename, dirname as pathDirname, join, resolve as pathResolve, sep as pathSep } from "node:path";
22
22
  import { tool } from "@opencode-ai/plugin";
23
23
  import { appendSessionIndex, findWorktreeRoot, lookupSessionFromIndex } from "./session-index.js";
24
- import { activeToolCount, clearToolCalls, forgetSession, isToolExecuting as registryIsToolExecuting, normalizePermissionEvent, notifySessionDeleted, pendingPermissionsFor, permissionAsked, permissionReplied, settleToolCalls, sharedSubSessionRegistry, toolFinished, toolStarted, } from "./sub-session-registry.js";
24
+ import { activeToolCount, clearToolCalls, forgetSession, isToolExecuting as registryIsToolExecuting, normalizePermissionEvent, notifySessionDeleted, pendingPermissionsFor, permissionAsked, permissionReplied, permissionsRejectedCascade, settleToolCalls, sharedSubSessionRegistry, toolFinished, toolStarted, } from "./sub-session-registry.js";
25
25
  import { computeVerdictHash } from "./verdict-hash.js";
26
26
  import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakExceeded, VERIFIER_ERROR_STREAK_LIMIT, } from "./verifier-verdict.js";
27
27
  import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
@@ -30,10 +30,10 @@ import { shouldEscalateStall } from "./stall-escalation.js";
30
30
  import { classifyStageCompletion, INCOMPLETE_HANG_REASON } from "./stage-completion.js";
31
31
  import { buildStateWriteBlockMessage, classifyStateJsonAccess, looksLikeRedirection, splitUnquotedSegments, WRITE_INDICATORS_UNQUOTED, STATE_SH_CALL_RE, stripQuotedSpans, } from "./state-access-guard.js";
32
32
  import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
33
- import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, pluginOwnAllowPatterns, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
33
+ import { resolvePaths, loadConfig, loadOpencodeExternalDirAllows, pluginOwnAllowPatterns, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, DEFAULT_PERMISSION_CORRECTIONS_PER_SESSION, } from "./config.js";
34
34
  import { scanSkillMcpRegistry, extractMcpName, looksLikeMcpNotFound, looksLikeMcpConnectionFailed, } from "./skill-mcp-registry.js";
35
35
  import { injectAllSecrets } from "./mcp-secret-injector.js";
36
- import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
36
+ import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy, buildPathScopePromptBlock, } from "./poll-sub-session.js";
37
37
  import { logger } from "./logger.js";
38
38
  import { redactAndTruncate } from "./redact-secrets.js";
39
39
  import { extractApplyPatchPaths, isApplyPatchTool } from "./apply-patch-paths.js";
@@ -606,6 +606,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
606
606
  // threshold caps the accumulated hang_history across calls — the stall-path
607
607
  // counterpart of same_reason_streak on the REJECTED-verdict path.
608
608
  const stallEscalateThreshold = Math.max(1, Math.round(config.timeout?.stall_escalate_threshold ?? DEFAULT_STALL_ESCALATE_THRESHOLD));
609
+ // 워크스페이스 밖 경로 요청을 세션 안에서 되돌려 주는 예산 (issue #12 재발 보고).
610
+ // abort → 재디스패치는 대화 이력을 잃는 비싼 경로라 이 예산이 소진된 뒤에만 탄다.
611
+ const permissionCorrectionsPerSession = Math.max(0, Math.round(config.timeout?.permission_corrections_per_session ?? DEFAULT_PERMISSION_CORRECTIONS_PER_SESSION));
609
612
  // 자동 승인 대상 외부 디렉터리 패턴 = 플러그인 자기 경로(항상) + 사용자 시드.
610
613
  //
611
614
  // 앞쪽이 2차 방어다: 설치가 opencode.json 패치를 남기지 못해도 서브에이전트가
@@ -792,6 +795,38 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
792
795
  // permission.replied 이벤트는 세션의 사본에 도착하지만, 그 사본이 이벤트를
793
796
  // 놓치더라도 다음 폴이 같은 요청을 다시 처리하지 않도록 즉시 뺀다.
794
797
  permissionReplied(registry, sessionId, req.path.requestID);
798
+ // reject 는 같은 세션의 나머지 대기 요청까지 연쇄 거부한다 — 이벤트가 오기
799
+ // 전에 다음 폴이 그것들에 다시 답하지 않도록 즉시 비운다.
800
+ if (req.body.reply === "reject")
801
+ permissionsRejectedCascade(registry, sessionId);
802
+ return res;
803
+ },
804
+ // 피드백 거부(in-place correction). 세션-라우팅 엔드포인트는 `{response}` 만
805
+ // 받아 문구를 실을 수 없으므로 `POST /permission/{requestID}/reply` 를 쓴다.
806
+ // 그 라우트는 **디렉토리 스코프**(`?directory=` 가 헤더보다 우선)라 worktree
807
+ // 서브세션의 요청에 닿으려면 세션의 worktree 를 query 로 명시해야 한다 —
808
+ // 클라이언트 기본 헤더는 main repo 를 가리키므로 생략하면 NotFound 다.
809
+ // `reject` + `message` 는 opencode 가 `CorrectedError` 로 바꿔 툴 호출 하나만
810
+ // 실패시키고 세션 루프는 계속 돌린다 (`RejectedError` 는 루프를 세운다).
811
+ correct: async (req) => {
812
+ const raw = client._client;
813
+ if (!raw || typeof raw.post !== "function") {
814
+ throw new Error("raw permission reply endpoint unavailable on this client");
815
+ }
816
+ const directory = registry.sessionWorktree.get(sessionId);
817
+ const res = await raw.post({
818
+ url: "/permission/{requestID}/reply",
819
+ path: { requestID: req.path.requestID },
820
+ query: directory ? { directory } : undefined,
821
+ body: { reply: "reject", message: req.body.message },
822
+ headers: { "Content-Type": "application/json" },
823
+ });
824
+ if (res?.error !== undefined) {
825
+ throw new Error(`permission correction rejected by server: status=${res.response?.status ?? "?"} ` +
826
+ `error=${JSON.stringify(res.error).slice(0, 200)}`);
827
+ }
828
+ permissionReplied(registry, sessionId, req.path.requestID);
829
+ permissionsRejectedCascade(registry, sessionId);
795
830
  return res;
796
831
  },
797
832
  });
@@ -803,9 +838,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
803
838
  configuredAllowPatterns,
804
839
  logger: {
805
840
  debug: (msg) => logger.debug(msg),
841
+ warn: (msg) => logger.warn(msg),
806
842
  error: (msg) => logger.error(msg),
807
843
  },
808
844
  permissionCheckIntervalPolls: 1,
845
+ maxPermissionCorrections: permissionCorrectionsPerSession,
809
846
  nudgeAtFraction: onNudge ? 0.8 : undefined,
810
847
  onNudge,
811
848
  messageStallThresholdMs,
@@ -1900,6 +1937,10 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1900
1937
  `Stage spec: read ${specPath} and follow it strictly.`,
1901
1938
  `모든 state.sh / wt-sync-ignored.sh / config.sh 호출은 위 Scripts directory 경로를 사용하시오. \`$HOME/.config/opencode/scripts/\`나 \`scripts/\` 상대경로를 사용하지 마시오.`,
1902
1939
  `Stage 명세 파일은 위 Stages directory 경로에서 읽으시오. \`<SCRIPTS_DIR>/../stages/\` 상대경로를 사용하지 마시오.`,
1940
+ // 경로 범위는 차단이 난 뒤가 아니라 첫 프롬프트부터 알린다. 관측된
1941
+ // 위반은 모델이 저장소 밖을 검색 루트로 **명시적으로** 고른 것이라
1942
+ // (issue #12 재발 보고), 사후 안내만으로는 첫 시도를 막지 못한다.
1943
+ ...buildPathScopePromptBlock(effectiveWorktree, permissionCorrectionsPerSession),
1903
1944
  ];
1904
1945
  if (args.target_stage === "2_implementation.dev") {
1905
1946
  base.push(`\n=== dev substage 요구사항 소스 우선순위 ===`, `구현 착수 전 요구사항은 반드시 다음 순서로 참조한다:`, ` a) FIRST — requirements-draft.md 를 우선 확인 (아래 bash 스니펫 그대로 실행):`, buildDraftPathReadSnippet(args.issue, " "), ` 파일이 존재하고 비어있지 않으면 이 파일이 요구사항의 진실의 원천이다.`, ` b) FALLBACK — draft_path 미기록/파일 부재/빈 파일 인 경우 Jira 이슈 조회:`, ` skill(name="jira-research") 로 works MCP 로드 →`, ` skill_mcp(mcp_name="works", tool_name="getIssue", arguments={"issueKey":"${args.issue}"})`, ` c) 두 소스 모두 접근 불가하면 사용자에게 상황을 보고하고 즉시 종료.`, `주의: requirements-draft.md 없이 Jira 이슈만으로 구현 범위를 재결정하지 말 것 — draft 가 없다는 것은 planning 이 부적절하게 진행된 신호이므로 사용자에게 보고하고 종료하시오.`, `\n=== 테스트 동반 원칙은 requirements 의 선언을 따른다 (issue #11) ===`, `테스트 추가 여부는 스스로 정하지 않는다. 아래를 실행해 1_planning.requirements 가 승인·동결한 선언을 먼저 읽는다:`, ` bash ${SCRIPTS_DIR}/state.sh get ${args.issue} '.stages."1_planning".substages."requirements".test_scope'`, ` - new_tests_required=true (마커 부재·null 도 true 로 간주 — fail-closed) → 변경에 대한 테스트를 함께 추가하고 self_check.new_tests_added=true 로 기록한다.`, ` - new_tests_required=false → 테스트 추가는 이번 이슈의 범위 밖이다. 추가하지 않는 것이 정답이며, self_check.new_tests_added=false 와 함께 dev.new_tests_waived=true 마커를 남긴다.`, `이 마커는 읽기 전용이다 — engineer 가 test_scope 를 쓰거나 고치지 않는다. 선언과 실제 작업이 맞지 않으면 임의로 면제·추가하지 말고 최종 출력에 적어 보고한다.`);
@@ -2240,10 +2281,15 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2240
2281
  const scopeLine = outcome.permissionScope
2241
2282
  ? `자동 승인 범위: ${outcome.permissionScope}/ 이하 — worktree(${effectiveWorktree}) 와 그 형제 디렉토리까지다. 그 위(조부모 이상)는 열리지 않는다.`
2242
2283
  : `자동 승인 범위: worktree(${effectiveWorktree}) 와 그 형제 디렉토리까지다. 그 위는 열리지 않는다.`;
2284
+ const corrections = outcome.permissionCorrections ?? 0;
2285
+ const correctionLine = corrections > 0
2286
+ ? `그 세션은 abort 전에 이미 ${corrections}회 "경로를 좁혀라" 는 피드백 거부를 받았는데도 같은 범위를 다시 요청했다. 이번에는 첫 안내에서 멈춰야 한다.`
2287
+ : null;
2243
2288
  permissionBlockNote = [
2244
2289
  `직전 attempt(${attempt}) 는 워크스페이스 밖 경로에 대한 external_directory 권한 요청 때문에 자동 거부되고 세션이 abort 됐다.`,
2245
2290
  `차단된 요청 패턴: ${JSON.stringify(blockedPatterns)}`,
2246
2291
  scopeLine,
2292
+ ...(correctionLine ? [correctionLine] : []),
2247
2293
  `서브세션에는 이 승인을 받을 채널이 없다 — 같은 경로를 다시 요청하면 또 즉시 중단되고, 그때까지 한 작업은 전부 버려진다.`,
2248
2294
  `조치:`,
2249
2295
  ` - glob / grep / read / list 의 경로 인자를 위 허용 범위 안으로 좁혀라. 경로 인자를 아예 주지 않고 cwd 기준 상대 패턴을 쓰는 것이 가장 안전하다.`,
@@ -2253,7 +2299,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2253
2299
  logger.warn(`[dispatch_stage] PERMISSION_BLOCK session=${subSessionID} stage=${args.target_stage} ` +
2254
2300
  `attempt=${attempt}/${currentMaxAttempts} reason=${outcome.permissionReason} ` +
2255
2301
  `patterns=${JSON.stringify(blockedPatterns)} scope=${outcome.permissionScope ?? "unknown"} ` +
2256
- `— redispatching with allowed-scope guidance injected`);
2302
+ `corrections=${corrections} redispatching with allowed-scope guidance injected`);
2257
2303
  continue;
2258
2304
  }
2259
2305
  // 예산 소진(또는 회복 불가 사유). hang_history 는 dispatch_stage 호출
@@ -2265,6 +2311,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2265
2311
  reason: `permission_stall:${outcome.permissionReason ?? "unknown"}`,
2266
2312
  patterns: blockedPatterns,
2267
2313
  scope: outcome.permissionScope ?? null,
2314
+ corrections: outcome.permissionCorrections ?? 0,
2268
2315
  elapsed_ms: outcome.elapsedMs,
2269
2316
  polls: outcome.polls,
2270
2317
  session_id: subSessionID,
@@ -2293,6 +2340,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2293
2340
  permission_reason: outcome.permissionReason ?? "unknown",
2294
2341
  permission_patterns: blockedPatterns,
2295
2342
  permission_scope: outcome.permissionScope ?? null,
2343
+ // abort 전에 세션 안에서 돌려보낸 횟수. 0 이면 교정 경로가 없었다는
2344
+ // 뜻(0 예산 또는 회복 불가 사유)이고, >0 이면 안내를 받고도 반복한 것.
2345
+ permission_corrections: outcome.permissionCorrections ?? 0,
2296
2346
  permission_id: outcome.permissionID,
2297
2347
  permission_type: outcome.permissionType,
2298
2348
  // 승인 대기가 아니라 "이미 종료됨" 이다. 이 두 필드를 읽고 보고한다.
@@ -67,6 +67,21 @@ export interface PollClientLike {
67
67
  reply: string;
68
68
  };
69
69
  }) => Promise<unknown>;
70
+ /**
71
+ * 피드백을 실은 거부. opencode 는 `reject` + `message` 를 `RejectedError` 가 아니라
72
+ * `CorrectedError` 로 바꾼다 — 툴 호출은 그 문구를 오류로 돌려받고, **세션 루프는
73
+ * 멈추지 않는다** (`processor.ts` 의 `ctx.blocked` 는 `RejectedError` 에만 걸린다).
74
+ * 그래서 서브에이전트가 경로를 좁혀 같은 자리에서 이어갈 수 있다. 없으면(구버전
75
+ * 클라이언트) 종전대로 reject + abort 로 떨어진다.
76
+ */
77
+ correct?: (req: {
78
+ path: {
79
+ requestID: string;
80
+ };
81
+ body: {
82
+ message: string;
83
+ };
84
+ }) => Promise<unknown>;
70
85
  };
71
86
  }
72
87
  export type PermissionStallReason = "outside_allowed_roots" | "non_external_permission" | "tool_call_stall";
@@ -100,6 +115,7 @@ export type PollOutcome = {
100
115
  permissionPatterns?: string[];
101
116
  permissionReason?: PermissionStallReason;
102
117
  permissionScope?: string;
118
+ permissionCorrections?: number;
103
119
  } | {
104
120
  kind: "session_gone";
105
121
  polls: number;
@@ -113,10 +129,12 @@ export interface PollOptions {
113
129
  permissionCheckIntervalPolls?: number;
114
130
  allowedWorktree?: string;
115
131
  configuredAllowPatterns?: string[];
132
+ maxPermissionCorrections?: number;
116
133
  now?: () => number;
117
134
  sleep?: (ms: number) => Promise<void>;
118
135
  logger?: {
119
136
  debug?: (msg: string) => void;
137
+ warn?: (msg: string) => void;
120
138
  error?: (msg: string) => void;
121
139
  };
122
140
  toolCallStallThresholdMs?: number;
@@ -141,6 +159,32 @@ export interface PollOptions {
141
159
  * permission-block 재디스패치)이 맡는다.
142
160
  */
143
161
  export declare function worktreeAllowedScope(worktree: string): string;
162
+ /**
163
+ * 스코프 밖 요청을 abort 없이 돌려보낼 때 툴 오류로 실리는 문구.
164
+ *
165
+ * 서브에이전트가 보는 것은 "The user rejected permission … with the following
166
+ * feedback: <이 문구>" 다. 재디스패치 노트(`permissionBlockNote`)와 같은 내용을
167
+ * 담되, **지금 이 세션에서 바로 고칠 수 있다**는 전제로 쓴다 — 대화 이력이 그대로
168
+ * 남아 있으므로 새 세션에 실었던 "직전 세션이 죽었다" 류 설명은 필요 없다.
169
+ * `remaining` 은 이번 교정 이후 남은 횟수다. 0 이면 다음 위반에서 세션이 종료된다.
170
+ */
171
+ export declare function buildPermissionCorrectionMessage(input: {
172
+ patterns: string[];
173
+ worktree?: string;
174
+ scope?: string;
175
+ remaining: number;
176
+ }): string;
177
+ /**
178
+ * 모든 dispatch_stage 프롬프트에 상시 주입되는 경로 범위 안내 (선제 방어).
179
+ *
180
+ * 재디스패치 노트와 in-place 교정은 둘 다 **차단이 난 뒤**에 작동한다. 관측된
181
+ * 위반은 우연이 아니라 의도된 것이었다 — 저장소 안 검색이 비자 모델이 상위
182
+ * 디렉토리를 검색 루트로 **명시적으로** 넘겼다 (`glob {path:"/root/IdeaProjects"}`,
183
+ * GitHub #12 재발 보고). 그 판단이 내려지기 전에 규칙을 보여주는 것이 가장 싸다.
184
+ * 문구는 교정 메시지·재디스패치 노트와 같은 어휘를 쓴다 — 세 층이 다른 말을
185
+ * 하면 모델은 어느 것이 규칙인지 모른다.
186
+ */
187
+ export declare function buildPathScopePromptBlock(worktree: string, maxCorrections: number): string[];
144
188
  export declare function isWithinWorktreeScope(patterns: string[], worktree: string): boolean;
145
189
  export declare function isMatchedByConfiguredRules(patterns: string[], allowedGlobs: string[]): boolean;
146
190
  /**
@@ -101,6 +101,57 @@ function posixDirname(p) {
101
101
  export function worktreeAllowedScope(worktree) {
102
102
  return posixDirname(worktree);
103
103
  }
104
+ /**
105
+ * 스코프 밖 요청을 abort 없이 돌려보낼 때 툴 오류로 실리는 문구.
106
+ *
107
+ * 서브에이전트가 보는 것은 "The user rejected permission … with the following
108
+ * feedback: <이 문구>" 다. 재디스패치 노트(`permissionBlockNote`)와 같은 내용을
109
+ * 담되, **지금 이 세션에서 바로 고칠 수 있다**는 전제로 쓴다 — 대화 이력이 그대로
110
+ * 남아 있으므로 새 세션에 실었던 "직전 세션이 죽었다" 류 설명은 필요 없다.
111
+ * `remaining` 은 이번 교정 이후 남은 횟수다. 0 이면 다음 위반에서 세션이 종료된다.
112
+ */
113
+ export function buildPermissionCorrectionMessage(input) {
114
+ const blocked = input.patterns.length ? JSON.stringify(input.patterns) : "(패턴 미상)";
115
+ const scope = input.scope
116
+ ? `${input.scope}/ 이하(worktree 와 그 형제 디렉토리)`
117
+ : "worktree 와 그 형제 디렉토리";
118
+ const wt = input.worktree ? `worktree(${input.worktree})` : "worktree";
119
+ const budget = input.remaining > 0
120
+ ? `남은 자동 교정 ${input.remaining}회 — 그 뒤에는 세션이 종료되고 지금까지의 작업이 버려진다.`
121
+ : `자동 교정 한도에 도달했다 — 한 번 더 스코프 밖을 요청하면 세션이 종료되고 지금까지의 작업이 버려진다.`;
122
+ return [
123
+ `워크스페이스 밖 경로 요청이라 자동 거부됐다 (요청 패턴 ${blocked}). 이 세션에는 승인 채널이 없다 — 사용자에게 승인을 요청하지 말 것.`,
124
+ `자동 승인 범위는 ${scope}뿐이고 그 위(조부모 이상)는 열리지 않는다.`,
125
+ `조치: glob / grep / read / list / bash 의 경로 인자를 ${wt} 안으로 좁혀 다시 시도하라. 경로 인자를 아예 주지 않고 cwd 기준 상대 패턴을 쓰는 것이 가장 안전하다.`,
126
+ `저장소 안에서 못 찾은 참조를 상위 디렉토리로 넓혀 찾지 말 것. 저장소 밖 자료가 꼭 필요하면 조사하지 말고, 필요한 이유를 최종 출력에 적어 보고하라.`,
127
+ `임시 파일은 /tmp 가 아니라 worktree 안의 .makdoong2-team/<이슈키>/tmp/ 에 만든다.`,
128
+ budget,
129
+ ].join(" ");
130
+ }
131
+ /**
132
+ * 모든 dispatch_stage 프롬프트에 상시 주입되는 경로 범위 안내 (선제 방어).
133
+ *
134
+ * 재디스패치 노트와 in-place 교정은 둘 다 **차단이 난 뒤**에 작동한다. 관측된
135
+ * 위반은 우연이 아니라 의도된 것이었다 — 저장소 안 검색이 비자 모델이 상위
136
+ * 디렉토리를 검색 루트로 **명시적으로** 넘겼다 (`glob {path:"/root/IdeaProjects"}`,
137
+ * GitHub #12 재발 보고). 그 판단이 내려지기 전에 규칙을 보여주는 것이 가장 싸다.
138
+ * 문구는 교정 메시지·재디스패치 노트와 같은 어휘를 쓴다 — 세 층이 다른 말을
139
+ * 하면 모델은 어느 것이 규칙인지 모른다.
140
+ */
141
+ export function buildPathScopePromptBlock(worktree, maxCorrections) {
142
+ const scope = worktreeAllowedScope(worktree);
143
+ const budget = maxCorrections > 0
144
+ ? `위반하면 툴 호출이 "The user rejected permission … with the following feedback: …" 오류로 실패하고 경로를 좁히라는 안내가 실린다 (세션당 ${maxCorrections}회). 그 이상 반복하면 세션이 종료되고 지금까지의 작업이 버려진다.`
145
+ : `위반하면 세션이 즉시 종료되고 지금까지의 작업이 버려진다.`;
146
+ return [
147
+ `\n=== 경로 범위 (hardrule) ===`,
148
+ `이 세션은 헤드리스라 권한 요청에 답할 사람이 없다. 자동 승인 범위는 ${scope}/ 이하(worktree 와 그 형제 디렉토리)뿐이고, 그 위(조부모 이상)는 열리지 않는다.`,
149
+ `glob / grep / read / list / bash 의 경로 인자는 항상 worktree(${worktree}) 안으로 둔다. 경로 인자를 아예 주지 않고 cwd 기준 상대 패턴을 쓰는 것이 가장 안전하다.`,
150
+ `저장소 안에서 못 찾은 참조(배포 설정·다른 프로젝트의 파일 등)를 상위 디렉토리로 넓혀 찾지 말 것. 저장소 밖 자료가 꼭 필요하면 조사하지 말고, 필요한 이유를 최종 출력에 적어 보고한다.`,
151
+ `임시 파일은 /tmp 가 아니라 worktree 안의 .makdoong2-team/<이슈키>/tmp/ 에 만든다.`,
152
+ budget,
153
+ ];
154
+ }
104
155
  // Returns true when every permission pattern resides within the allowed scope,
105
156
  // defined as the parent directory of the worktree (siblings are the main repo
106
157
  // and other worktrees — all legitimate access targets for engineers).
@@ -158,6 +209,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
158
209
  const sleep = options.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
159
210
  const dbg = options.logger?.debug;
160
211
  const err = options.logger?.error ?? ((msg) => console.error(msg));
212
+ const warn = options.logger?.warn ?? err;
161
213
  const startTime = now();
162
214
  const deadline = startTime + timeoutMs;
163
215
  const expectedPolls = Math.ceil(timeoutMs / pollIntervalMs);
@@ -170,7 +222,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
170
222
  const isRecentlyActive = options.isRecentlyActive;
171
223
  const isToolExecuting = options.isToolExecuting;
172
224
  const permissionCheckIntervalPolls = options.permissionCheckIntervalPolls ?? 5;
225
+ const maxPermissionCorrections = options.maxPermissionCorrections ?? 3;
173
226
  let pollCount = 0;
227
+ let permissionCorrections = 0;
174
228
  let transientFailures = 0;
175
229
  let sessionEverAppeared = false;
176
230
  let hasProducedAssistantMessage = false;
@@ -349,47 +403,97 @@ export async function pollSubSession(client, sessionId, options = {}) {
349
403
  if (client.permission && pollCount % permissionCheckIntervalPolls === 0) {
350
404
  const permResult = await client.permission.list().catch(() => null);
351
405
  const pending = (permResult?.data ?? []).filter(p => p.sessionID === sessionId);
352
- for (const p of pending) {
353
- const withinScope = p.permission === "external_directory" && ((!!options.allowedWorktree && isWithinWorktreeScope(p.patterns, options.allowedWorktree)) ||
354
- isMatchedByConfiguredRules(p.patterns, options.configuredAllowPatterns ?? []));
355
- if (withinScope) {
356
- dbg?.(`[pollSubSession] PERMISSION_ALLOW session=${sessionId} polls=${pollCount}` +
357
- ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
358
- ` scope=dirname(${options.allowedWorktree})`);
359
- await client.permission
360
- .reply({ path: { requestID: p.id }, body: { reply: "once" } })
361
- .catch(() => undefined);
362
- }
363
- else {
364
- // 두 실패를 뭉뚱그리지 않는다. "허용 경로 밖" 은 대안 경로를 알려주면
365
- // 회복 가능하고, "external_directory 아닌 권한" 경로와 무관한
366
- // 에이전트 설정 문제라 조치가 정반대다. 종전에는 한 문장이었다.
367
- const reason = p.permission === "external_directory"
368
- ? "outside_allowed_roots"
369
- : "non_external_permission";
370
- err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount}` +
371
- ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
372
- ` reason=${reason} auto-rejecting`);
373
- await client.permission
374
- .reply({ path: { requestID: p.id }, body: { reply: "reject" } })
375
- .catch(() => undefined);
376
- await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
377
- return {
378
- kind: "permission_stall",
379
- polls: pollCount,
380
- elapsedMs: now() - startTime,
381
- stalledMs,
382
- permissionID: p.id,
383
- permissionType: p.permission,
384
- permissionPatterns: p.patterns,
385
- permissionReason: reason,
386
- // 무엇이 막혔는지(patterns)만으로는 처방이 나온다 **어디까지가
387
- // 허용인지**를 같이 실어야 서브에이전트가 경로를 좁혀 재시도할 수 있다.
388
- permissionScope: options.allowedWorktree
389
- ? worktreeAllowedScope(options.allowedWorktree)
390
- : undefined,
391
- };
406
+ const isWithinScope = (p) => p.permission === "external_directory" && ((!!options.allowedWorktree && isWithinWorktreeScope(p.patterns, options.allowedWorktree)) ||
407
+ isMatchedByConfiguredRules(p.patterns, options.configuredAllowPatterns ?? []));
408
+ // 허용을 먼저 전부 답하고 거부는 그 뒤에 한다. opencode 의 `reject` 는 같은
409
+ // 세션의 **다른 대기 요청까지 연쇄 거부**하므로(`Permission.reply`), 순서를
410
+ // 섞으면 병렬 배치의 정당한 형제 디렉토리 요청이 스코프 밖 요청 하나에
411
+ // 딸려 죽는다.
412
+ const allowed = pending.filter(isWithinScope);
413
+ const denied = pending.filter(p => !isWithinScope(p));
414
+ for (const p of allowed) {
415
+ dbg?.(`[pollSubSession] PERMISSION_ALLOW session=${sessionId} polls=${pollCount}` +
416
+ ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
417
+ ` scope=dirname(${options.allowedWorktree})`);
418
+ await client.permission
419
+ .reply({ path: { requestID: p.id }, body: { reply: "once" } })
420
+ .catch(() => undefined);
421
+ }
422
+ for (const p of denied) {
423
+ // 두 실패를 뭉뚱그리지 않는다. "허용 경로 밖" 은 대안 경로를 알려주면
424
+ // 회복 가능하고, "external_directory 가 아닌 권한" 은 경로와 무관한
425
+ // 에이전트 설정 문제라 조치가 정반대다. 종전에는 한 문장이었다.
426
+ const reason = p.permission === "external_directory"
427
+ ? "outside_allowed_roots"
428
+ : "non_external_permission";
429
+ const permissionScope = options.allowedWorktree
430
+ ? worktreeAllowedScope(options.allowedWorktree)
431
+ : undefined;
432
+ // ── in-place correction: 세션을 죽이지 않고 경로를 좁히게 한다 ─────────
433
+ //
434
+ // 관측된 차단은 전부 모델이 **명시적으로** 조부모를 검색 루트로 넘긴
435
+ // 것이었다 (`glob {path:"/root/IdeaProjects"}` — 저장소 안 검색이 비어
436
+ // 상위로 넓힌 것, GitHub #12 재발 보고). 이건 세션을 abort 할 일이 아니라
437
+ // 툴 오류 하나로 고쳐질 일이다. `reject` 에 `message` 를 실으면 opencode 가
438
+ // `CorrectedError` 로 바꿔 툴 호출만 실패시키고 루프는 계속 돈다 — 모델은
439
+ // 그 문구를 읽고 같은 자리에서 경로를 좁힌다. 대화 이력·진행 중이던 병렬
440
+ // 툴·지금까지의 작업이 전부 보존된다. 재디스패치는 예산이 소진된 뒤의
441
+ // 후순위 경로다.
442
+ //
443
+ // 예산은 세션당 `maxPermissionCorrections` 회. 안내를 받고도 반복하면
444
+ // 프롬프트로 고쳐지지 않는 것이므로 종전대로 abort → 재디스패치 →
445
+ // hang_history 상한으로 넘긴다.
446
+ const canCorrect = reason === "outside_allowed_roots" &&
447
+ typeof client.permission.correct === "function" &&
448
+ permissionCorrections < maxPermissionCorrections;
449
+ if (canCorrect) {
450
+ const remaining = maxPermissionCorrections - permissionCorrections - 1;
451
+ const message = buildPermissionCorrectionMessage({
452
+ patterns: p.patterns,
453
+ worktree: options.allowedWorktree,
454
+ scope: permissionScope,
455
+ remaining,
456
+ });
457
+ const corrected = await client.permission
458
+ .correct({ path: { requestID: p.id }, body: { message } })
459
+ .then(() => true)
460
+ .catch((e) => {
461
+ err(`[pollSubSession] PERMISSION_CORRECT_FAILED session=${sessionId} polls=${pollCount}` +
462
+ ` permissionID=${p.id} error=${e} — falling back to reject + abort`);
463
+ return false;
464
+ });
465
+ if (corrected) {
466
+ permissionCorrections++;
467
+ warn(`[pollSubSession] PERMISSION_CORRECT session=${sessionId} polls=${pollCount}` +
468
+ ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
469
+ ` scope=${permissionScope ?? "unknown"} corrections=${permissionCorrections}/${maxPermissionCorrections}` +
470
+ ` — rejected with feedback, session continues`);
471
+ // 연쇄 거부로 나머지 대기 요청은 이미 opencode 가 정리했다. 이번 폴에서
472
+ // 더 답하면 NotFound 만 난다.
473
+ break;
474
+ }
392
475
  }
476
+ err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount}` +
477
+ ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
478
+ ` reason=${reason} corrections=${permissionCorrections}/${maxPermissionCorrections} — auto-rejecting`);
479
+ await client.permission
480
+ .reply({ path: { requestID: p.id }, body: { reply: "reject" } })
481
+ .catch(() => undefined);
482
+ await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
483
+ return {
484
+ kind: "permission_stall",
485
+ polls: pollCount,
486
+ elapsedMs: now() - startTime,
487
+ stalledMs,
488
+ permissionID: p.id,
489
+ permissionType: p.permission,
490
+ permissionPatterns: p.patterns,
491
+ permissionReason: reason,
492
+ // 무엇이 막혔는지(patterns)만으로는 처방이 안 나온다 — **어디까지가
493
+ // 허용인지**를 같이 실어야 서브에이전트가 경로를 좁혀 재시도할 수 있다.
494
+ permissionScope,
495
+ permissionCorrections,
496
+ };
393
497
  }
394
498
  }
395
499
  // Tool-call stall — "툴 호출이 떠 있는데 아무 진전이 없다".
@@ -699,7 +803,14 @@ export function pollOutcomeToLegacy(outcome) {
699
803
  const scope = outcome.permissionScope
700
804
  ? `\`${outcome.permissionScope}/\` 이하 (worktree 와 그 형제 디렉토리)`
701
805
  : "worktree 와 그 형제 디렉토리";
702
- return `워크스페이스 경로 접근이라 차단됐다 요청 ${blocked}, 자동 승인 범위 ${scope}. ` +
806
+ // in-place 교정이 있었다면 abort "안내를 받고도 같은 범위를 다시
807
+ // 요청했다" 는 뜻이다. 그 횟수를 보고에 싣지 않으면 리더는 첫 차단과
808
+ // 구분할 수 없어 같은 안내를 한 번 더 주입하는 데 그친다.
809
+ const corrections = outcome.permissionCorrections ?? 0;
810
+ const correctionNote = corrections > 0
811
+ ? `세션 안에서 ${corrections}회 경로 교정 안내(피드백 거부)를 받고도 같은 범위를 다시 요청해 abort 됐다. `
812
+ : "";
813
+ return `워크스페이스 밖 경로 접근이라 차단됐다 — 요청 ${blocked}, 자동 승인 범위 ${scope}. ${correctionNote}` +
703
814
  "그 위(조부모 이상)는 설계상 열지 않는다: 한 번 열면 형제 프로젝트 전체가 사람의 확인 없이 승인 대상이 된다. " +
704
815
  "조치는 둘 중 하나다. " +
705
816
  "(1) 조회(glob/grep/read/list)라면 경로 인자를 허용 범위 안으로 좁혀라 — 경로 인자 없이 cwd 기준 상대 패턴을 쓰는 것이 가장 안전하다. " +
@@ -66,6 +66,14 @@ export declare function clearToolCalls(reg: SubSessionRegistry, sessionID: strin
66
66
  export declare function normalizePermissionEvent(type: string, properties: unknown, now?: number): PendingPermission | null;
67
67
  export declare function permissionAsked(reg: SubSessionRegistry, req: PendingPermission): void;
68
68
  export declare function permissionReplied(reg: SubSessionRegistry, sessionID: string, requestID: string): void;
69
+ /**
70
+ * 세션의 대기 요청을 전부 뺀다. opencode 는 `reject` 하나에 같은 세션의 나머지
71
+ * 대기 요청을 **연쇄 거부**하고 각각 `permission.replied` 를 발행한다 — 그 이벤트가
72
+ * 사본에 도착하기 전 다음 폴이 돌면 이미 없는 요청에 다시 답하려다 NotFound 를
73
+ * 받는다 (피드백 거부 경로에서는 그 오류가 abort 로 격상된다). 거부 응답 직후
74
+ * 폴러 쪽에서 선제로 비운다. 반환값은 뺀 개수.
75
+ */
76
+ export declare function permissionsRejectedCascade(reg: SubSessionRegistry, sessionID: string): number;
69
77
  /** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
70
78
  export declare function pendingPermissionsFor(reg: SubSessionRegistry, sessionID: string): PendingPermission[];
71
79
  /** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
@@ -172,6 +172,21 @@ export function permissionReplied(reg, sessionID, requestID) {
172
172
  if (bySession.size === 0)
173
173
  reg.pendingPermissions.delete(sessionID);
174
174
  }
175
+ /**
176
+ * 세션의 대기 요청을 전부 뺀다. opencode 는 `reject` 하나에 같은 세션의 나머지
177
+ * 대기 요청을 **연쇄 거부**하고 각각 `permission.replied` 를 발행한다 — 그 이벤트가
178
+ * 사본에 도착하기 전 다음 폴이 돌면 이미 없는 요청에 다시 답하려다 NotFound 를
179
+ * 받는다 (피드백 거부 경로에서는 그 오류가 abort 로 격상된다). 거부 응답 직후
180
+ * 폴러 쪽에서 선제로 비운다. 반환값은 뺀 개수.
181
+ */
182
+ export function permissionsRejectedCascade(reg, sessionID) {
183
+ const bySession = reg.pendingPermissions.get(sessionID);
184
+ if (!bySession)
185
+ return 0;
186
+ const n = bySession.size;
187
+ reg.pendingPermissions.delete(sessionID);
188
+ return n;
189
+ }
175
190
  /** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
176
191
  export function pendingPermissionsFor(reg, sessionID) {
177
192
  const bySession = reg.pendingPermissions.get(sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "3.0.3",
3
+ "version": "3.0.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },