makdoong2-team 3.0.2 → 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 +1 -0
- package/agents/makdoong2-analyzer.md +1 -0
- package/agents/makdoong2-engineer.md +1 -0
- package/agents/makdoong2-planner.md +1 -0
- package/agents/makdoong2-publisher.md +1 -0
- package/agents/makdoong2-team-leader.md +24 -0
- package/assets/makdoong2-team.schema.json +5 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/opencode-plugin.js +153 -3
- package/dist/poll-sub-session.d.ts +66 -0
- package/dist/poll-sub-session.js +204 -39
- package/dist/sub-session-registry.d.ts +8 -0
- package/dist/sub-session-registry.js +15 -0
- package/package.json +1 -1
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.
|
|
@@ -121,6 +121,29 @@ permission:
|
|
|
121
121
|
|
|
122
122
|
`ok: true` 만 보고 넘어가지 말 것 — `paused` 와 `unknown` 도 `ok: true` 이며, 둘 다 substage 는 끝나지 않았다.
|
|
123
123
|
|
|
124
|
+
## `permission_stall` 은 "승인 대기" 가 아니다 — 이미 종료된 것이다 (hardrule)
|
|
125
|
+
|
|
126
|
+
`dispatch_stage` 가 `outcome_kind: "permission_stall"` 을 반환하면, 그 시점에 이미 **권한 요청은 자동 거부됐고 서브세션은 abort 됐다.** 헤드리스 서브세션에는 승인을 받을 채널 자체가 없어서 훅이 대신 거부한 것이다 — 사용자가 승인할 대상이 애초에 존재하지 않는다.
|
|
127
|
+
|
|
128
|
+
**금지**: "worktree 접근 권한 승인 대기 — 승인한 뒤 재개해 주세요" 류의 보고. 실제로 이 오판이 워크플로를 세웠다. 도구가 반환한 `output` 에는 `aborted after {N}ms` 로 **종료됐다는 사실**이 적혀 있었고 "승인을 기다리라" 는 지시는 한 글자도 없었다 (GitHub issue #12). 응답의 `awaiting_user_approval: false` / `session_aborted: true` 가 그 점을 못 박는다.
|
|
129
|
+
|
|
130
|
+
읽어야 할 필드는 셋이다:
|
|
131
|
+
|
|
132
|
+
| 필드 | 뜻 |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `permission_reason` | `outside_allowed_roots` / `non_external_permission` / `tool_call_stall` — **처방이 서로 다르다** |
|
|
135
|
+
| `permission_patterns` | 실제로 차단된 경로 패턴 |
|
|
136
|
+
| `permission_scope` | 자동 승인되는 범위 (worktree 의 부모 디렉토리 이하) |
|
|
137
|
+
| `permission_corrections` | abort **전에** 세션 안에서 "경로를 좁혀라" 는 피드백 거부를 받은 횟수. `>0` 이면 안내를 받고도 같은 범위를 반복한 것 |
|
|
138
|
+
|
|
139
|
+
`permission_reason` 별 조치:
|
|
140
|
+
|
|
141
|
+
- **`outside_allowed_roots`** — 서브에이전트가 워크스페이스 밖 경로를 요청했다. 방어는 이미 세 번 돌았다: ① 첫 프롬프트에 경로 범위 하드룰이 실려 있었고 ② 스코프 밖 요청은 abort 없이 툴 오류(피드백 거부)로 되돌려져 세션 안에서 고칠 기회를 받았으며(`permission_corrections` 회) ③ 그래도 반복해 abort 된 뒤 `dispatch_stage` 가 허용 범위를 프롬프트에 주입해 자동 재디스패치를 시도했고(`attempts` 확인), 그 예산까지 소진한 상태다. **같은 인자로 재호출하지 않는다.** `permission_patterns` 와 `permission_scope` 를 그대로 인용해 보고하고 사용자 지시를 기다린다. 스코프를 넓혀 달라는 요청은 하지 않는다 — 조부모 이상을 여는 것은 형제 프로젝트 전체를 사람 확인 없이 승인 대상으로 만드는 일이라 설계상 거부된다.
|
|
142
|
+
- **`non_external_permission`** — 경로 문제가 아니라 해당 에이전트 frontmatter 의 `permission:` 블록 문제다 (정식 키는 `edit`; `write` 는 존재하지 않아 조용히 무시된다). 사용자에게 보고한다.
|
|
143
|
+
- **`tool_call_stall`** — 부분 설치 의심. `npx makdoong2-team doctor` 실행을 안내한다.
|
|
144
|
+
|
|
145
|
+
세 경우 모두 `next_action` 을 그대로 따른다 (하드룰 4). 이 실패는 `hang_history` 에 `reason: "permission_stall:<사유>"` 로 기록되므로, 반복 호출하면 `stall_escalate_threshold` 에서 차단된다.
|
|
146
|
+
|
|
124
147
|
## verdict 는 셋이다 — REJECTED 와 ERROR 를 절대 섞지 말 것 (hardrule)
|
|
125
148
|
|
|
126
149
|
`dispatch_verifier` 의 `verdict` 는 `VERIFIED` / `REJECTED` / `ERROR` 세 값이다.
|
|
@@ -320,6 +343,7 @@ loop (max_substage_retries=3 per substage):
|
|
|
320
343
|
- 모델 폴백 시: `[fallback] <agent>: <primary> → <fallback> (reason: ...)`
|
|
321
344
|
- `retry_disallowed` 감지 시: `[substage X RETRY DISALLOWED] outcome=timeout, transient_failures=0 — sub-agent hang. <retry_disallowed_reason>`
|
|
322
345
|
- `session_gone` 최종 실패 시: `[substage X SESSION_GONE gone_reason=<message_stall|status_absent>] dispatch_stage 3회 자동 redispatch 후 실패. attempts=<N>, previous_session_ids=[...]`
|
|
346
|
+
- `permission_stall` 시: `[substage X PERMISSION BLOCKED reason=<permission_reason>] 서브세션이 차단되어 **이미 종료됨** (승인 대기 아님). 차단 경로=<permission_patterns>, 허용 범위=<permission_scope>, 세션 내 교정=<permission_corrections>회, attempts=<N>`
|
|
323
347
|
- verifier ERROR 시: `[substage X VERIFIER ERROR source=<verdict_source>] 검증 미수행 — verifier 만 재호출 (streak=<N>/3). stage 는 건드리지 않음`
|
|
324
348
|
- verifier ERROR streak 초과 시: `[substage X VERIFIER ERROR STREAK EXCEEDED] 판정 3회 연속 실패. source=<verdict_source>. 사용자 개입 필요.`
|
|
325
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",
|
package/dist/opencode-plugin.js
CHANGED
|
@@ -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 패치를 남기지 못해도 서브에이전트가
|
|
@@ -622,7 +625,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
622
625
|
// 같이 남겨 어느 사본이 몇 개를 읽었는지 바로 대응시킨다.
|
|
623
626
|
logger.debug(`[permission] plugin-own allows: ${pluginOwnPatterns.length}개 ` +
|
|
624
627
|
`(${pluginOwnPatterns.join(", ")}) directory=${directory}`);
|
|
628
|
+
// 두 줄의 개수가 다른 것은 정상이다 — 아래가 위를 **포함**한다. 내역을
|
|
629
|
+
// (plugin-own N + opencode.json M) 로 분해해 두지 않으면 "5개 / 7개" 두 줄이
|
|
630
|
+
// 같은 목록의 불일치처럼 읽힌다 (GitHub #12 부수 관찰).
|
|
625
631
|
logger.debug(`[permission] configured external_directory allows: ${configuredAllowPatterns.length}개` +
|
|
632
|
+
` (= plugin-own ${pluginOwnPatterns.length} + opencode.json ${configuredAllowPatterns.length - pluginOwnPatterns.length})` +
|
|
626
633
|
(configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : "") +
|
|
627
634
|
` directory=${directory}`);
|
|
628
635
|
// 툴 실행 신호는 레지스트리(`registry.activeToolCalls` / `lastToolExecuteAt`)에
|
|
@@ -788,6 +795,38 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
788
795
|
// permission.replied 이벤트는 세션의 사본에 도착하지만, 그 사본이 이벤트를
|
|
789
796
|
// 놓치더라도 다음 폴이 같은 요청을 다시 처리하지 않도록 즉시 뺀다.
|
|
790
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);
|
|
791
830
|
return res;
|
|
792
831
|
},
|
|
793
832
|
});
|
|
@@ -799,9 +838,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
799
838
|
configuredAllowPatterns,
|
|
800
839
|
logger: {
|
|
801
840
|
debug: (msg) => logger.debug(msg),
|
|
841
|
+
warn: (msg) => logger.warn(msg),
|
|
802
842
|
error: (msg) => logger.error(msg),
|
|
803
843
|
},
|
|
804
844
|
permissionCheckIntervalPolls: 1,
|
|
845
|
+
maxPermissionCorrections: permissionCorrectionsPerSession,
|
|
805
846
|
nudgeAtFraction: onNudge ? 0.8 : undefined,
|
|
806
847
|
onNudge,
|
|
807
848
|
messageStallThresholdMs,
|
|
@@ -1882,6 +1923,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1882
1923
|
const lastVerdictStreak = lastVerdictStreakR.exitCode === 0
|
|
1883
1924
|
? (parseInt(lastVerdictStreakR.stdout?.toString().trim() || "0", 10) || 0)
|
|
1884
1925
|
: 0;
|
|
1926
|
+
// 직전 attempt 가 워크스페이스 밖 경로 요청으로 자동 거부·abort 된 경우,
|
|
1927
|
+
// 새 세션 프롬프트에 "무엇이 막혔고 어디까지가 허용인지" 를 주입한다.
|
|
1928
|
+
// 이것이 없으면 재디스패치는 같은 경로를 다시 요청해 같은 지점에서 죽는다
|
|
1929
|
+
// — 서브세션은 이전 세션의 대화 이력을 이어받지 못하기 때문이다 (GitHub #12).
|
|
1930
|
+
let permissionBlockNote = null;
|
|
1885
1931
|
const buildPromptText = (attemptNum, priorSessionIds) => {
|
|
1886
1932
|
const base = [
|
|
1887
1933
|
`Working directory (ABSOLUTE): ${effectiveWorktree}`,
|
|
@@ -1891,6 +1937,10 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1891
1937
|
`Stage spec: read ${specPath} and follow it strictly.`,
|
|
1892
1938
|
`모든 state.sh / wt-sync-ignored.sh / config.sh 호출은 위 Scripts directory 경로를 사용하시오. \`$HOME/.config/opencode/scripts/\`나 \`scripts/\` 상대경로를 사용하지 마시오.`,
|
|
1893
1939
|
`Stage 명세 파일은 위 Stages directory 경로에서 읽으시오. \`<SCRIPTS_DIR>/../stages/\` 상대경로를 사용하지 마시오.`,
|
|
1940
|
+
// 경로 범위는 차단이 난 뒤가 아니라 첫 프롬프트부터 알린다. 관측된
|
|
1941
|
+
// 위반은 모델이 저장소 밖을 검색 루트로 **명시적으로** 고른 것이라
|
|
1942
|
+
// (issue #12 재발 보고), 사후 안내만으로는 첫 시도를 막지 못한다.
|
|
1943
|
+
...buildPathScopePromptBlock(effectiveWorktree, permissionCorrectionsPerSession),
|
|
1894
1944
|
];
|
|
1895
1945
|
if (args.target_stage === "2_implementation.dev") {
|
|
1896
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 를 쓰거나 고치지 않는다. 선언과 실제 작업이 맞지 않으면 임의로 면제·추가하지 말고 최종 출력에 적어 보고한다.`);
|
|
@@ -1901,6 +1951,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1901
1951
|
if (lastVerdictReason) {
|
|
1902
1952
|
base.push(`\n=== 이전 검증 실패 사유 (재작업 시 참고) ===`, `직전 attempt 가 makdoong2-verifier 에 의해 REJECTED 되었다. 같은 사유로 다시 실패하면 무한 루프로 판정된다 (동일 사유 ${lastVerdictStreak}회 연속 감지 중, 5회 도달 시 자동 중단).`, `아래 verifier 판정 원문을 읽고 지적된 규칙 위반·마커 누락·검증 실패 항목을 최우선으로 해결하시오. 동일한 접근을 반복하지 말고 finding 에 지적된 대안 (파일 재분할·마커 재기록·형식 수정 등) 을 시도하시오.`, `--- verifier 판정 원문 (앞 4000자) ---`, lastVerdictReason, `--- 원문 끝 ---`);
|
|
1903
1953
|
}
|
|
1954
|
+
if (permissionBlockNote) {
|
|
1955
|
+
base.push(`\n=== 경로 접근 차단 — 직전 세션이 이 사유로 즉시 중단됐다 (반복 금지) ===`, permissionBlockNote);
|
|
1956
|
+
}
|
|
1904
1957
|
if (args.context)
|
|
1905
1958
|
base.push(args.context);
|
|
1906
1959
|
return base.join("\n");
|
|
@@ -2207,6 +2260,103 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2207
2260
|
});
|
|
2208
2261
|
continue;
|
|
2209
2262
|
}
|
|
2263
|
+
// ── permission_stall: 하드 실패가 아니라 회복 가능한 차단이다 ──────────
|
|
2264
|
+
//
|
|
2265
|
+
// 종전에는 여기서 곧장 최종 결과로 떨어졌다. `attempts` 가 1에서 늘지
|
|
2266
|
+
// 않았고, `next_action` 도 비어 있었다 — 그 공백을 team-leader 가
|
|
2267
|
+
// "사용자 승인 대기" 로 채워 넣어, 헤드리스 서브세션에는 존재하지도 않는
|
|
2268
|
+
// 승인 행위를 사용자에게 요구하며 워크플로가 멈췄다 (GitHub #12).
|
|
2269
|
+
//
|
|
2270
|
+
// `outside_allowed_roots` 만 재디스패치한다. 이 사유는 "경로를 좁히면
|
|
2271
|
+
// 끝날 일" 이라 새 세션에 허용 범위를 알려주면 진행할 수 있다. 나머지
|
|
2272
|
+
// 둘은 경로 문제가 아니다 — `non_external_permission` 은 에이전트
|
|
2273
|
+
// frontmatter 설정, `tool_call_stall` 은 설치 상태의 문제라 같은 조건의
|
|
2274
|
+
// 재시도가 결과를 바꾸지 못한다.
|
|
2275
|
+
if (outcome.kind === "permission_stall") {
|
|
2276
|
+
const blockedPatterns = outcome.permissionPatterns ?? [];
|
|
2277
|
+
const recoverable = outcome.permissionReason === "outside_allowed_roots";
|
|
2278
|
+
const currentMaxAttempts = maxAttemptsForCurrentModel();
|
|
2279
|
+
promptPromise.catch(() => { });
|
|
2280
|
+
if (recoverable && attempt < currentMaxAttempts) {
|
|
2281
|
+
const scopeLine = outcome.permissionScope
|
|
2282
|
+
? `자동 승인 범위: ${outcome.permissionScope}/ 이하 — worktree(${effectiveWorktree}) 와 그 형제 디렉토리까지다. 그 위(조부모 이상)는 열리지 않는다.`
|
|
2283
|
+
: `자동 승인 범위: worktree(${effectiveWorktree}) 와 그 형제 디렉토리까지다. 그 위는 열리지 않는다.`;
|
|
2284
|
+
const corrections = outcome.permissionCorrections ?? 0;
|
|
2285
|
+
const correctionLine = corrections > 0
|
|
2286
|
+
? `그 세션은 abort 전에 이미 ${corrections}회 "경로를 좁혀라" 는 피드백 거부를 받았는데도 같은 범위를 다시 요청했다. 이번에는 첫 안내에서 멈춰야 한다.`
|
|
2287
|
+
: null;
|
|
2288
|
+
permissionBlockNote = [
|
|
2289
|
+
`직전 attempt(${attempt}) 는 워크스페이스 밖 경로에 대한 external_directory 권한 요청 때문에 자동 거부되고 세션이 abort 됐다.`,
|
|
2290
|
+
`차단된 요청 패턴: ${JSON.stringify(blockedPatterns)}`,
|
|
2291
|
+
scopeLine,
|
|
2292
|
+
...(correctionLine ? [correctionLine] : []),
|
|
2293
|
+
`서브세션에는 이 승인을 받을 채널이 없다 — 같은 경로를 다시 요청하면 또 즉시 중단되고, 그때까지 한 작업은 전부 버려진다.`,
|
|
2294
|
+
`조치:`,
|
|
2295
|
+
` - glob / grep / read / list 의 경로 인자를 위 허용 범위 안으로 좁혀라. 경로 인자를 아예 주지 않고 cwd 기준 상대 패턴을 쓰는 것이 가장 안전하다.`,
|
|
2296
|
+
` - 저장소 밖을 훑어야만 하는 작업이라면 수행하지 말고, 그 사실과 이유를 최종 출력에 적어 보고하라.`,
|
|
2297
|
+
` - 임시 파일은 /tmp 가 아니라 ${effectiveWorktree}/.makdoong2-team/${args.issue}/tmp/ 에 만든다.`,
|
|
2298
|
+
].join("\n");
|
|
2299
|
+
logger.warn(`[dispatch_stage] PERMISSION_BLOCK session=${subSessionID} stage=${args.target_stage} ` +
|
|
2300
|
+
`attempt=${attempt}/${currentMaxAttempts} reason=${outcome.permissionReason} ` +
|
|
2301
|
+
`patterns=${JSON.stringify(blockedPatterns)} scope=${outcome.permissionScope ?? "unknown"} ` +
|
|
2302
|
+
`corrections=${corrections} — redispatching with allowed-scope guidance injected`);
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
// 예산 소진(또는 회복 불가 사유). hang_history 는 dispatch_stage 호출
|
|
2306
|
+
// 사이를 넘어 살아남는 유일한 카운터다 — 여기에 남기지 않으면 리더가
|
|
2307
|
+
// 같은 조건으로 무한히 재호출해도 cross-call 상한에 영영 닿지 않는다.
|
|
2308
|
+
const stallEntry = JSON.stringify({
|
|
2309
|
+
attempt,
|
|
2310
|
+
at: new Date().toISOString(),
|
|
2311
|
+
reason: `permission_stall:${outcome.permissionReason ?? "unknown"}`,
|
|
2312
|
+
patterns: blockedPatterns,
|
|
2313
|
+
scope: outcome.permissionScope ?? null,
|
|
2314
|
+
corrections: outcome.permissionCorrections ?? 0,
|
|
2315
|
+
elapsed_ms: outcome.elapsedMs,
|
|
2316
|
+
polls: outcome.polls,
|
|
2317
|
+
session_id: subSessionID,
|
|
2318
|
+
model: activeModelFull,
|
|
2319
|
+
fallback_depth: activeFallbackDepth,
|
|
2320
|
+
final: true,
|
|
2321
|
+
});
|
|
2322
|
+
const stallJqPath = stageJqPath(args.target_stage) + ".hang_history";
|
|
2323
|
+
const stallAppend = await $ `bash ${SCRIPTS_DIR}/state.sh append ${args.issue} ${stallJqPath} ${stallEntry}`
|
|
2324
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2325
|
+
logger.error(`[dispatch_stage] PERMISSION_STALL_FINAL session=${subSessionID} stage=${args.target_stage} ` +
|
|
2326
|
+
`attempts=${attempt} reason=${outcome.permissionReason} recoverable=${recoverable} ` +
|
|
2327
|
+
`patterns=${JSON.stringify(blockedPatterns)} scope=${outcome.permissionScope ?? "unknown"} ` +
|
|
2328
|
+
`hang_history append exit=${stallAppend.exitCode}`);
|
|
2329
|
+
finalResultJson = JSON.stringify({
|
|
2330
|
+
ok: false,
|
|
2331
|
+
stage: args.target_stage,
|
|
2332
|
+
agent: spec.id,
|
|
2333
|
+
model: activeModelFull,
|
|
2334
|
+
session_id: subSessionID,
|
|
2335
|
+
previous_session_ids: attemptSessionIds.slice(0, -1),
|
|
2336
|
+
attempts: attempt,
|
|
2337
|
+
fallback_depth: activeFallbackDepth,
|
|
2338
|
+
output: finalLegacy.text.slice(0, 8000),
|
|
2339
|
+
outcome_kind: "permission_stall",
|
|
2340
|
+
permission_reason: outcome.permissionReason ?? "unknown",
|
|
2341
|
+
permission_patterns: blockedPatterns,
|
|
2342
|
+
permission_scope: outcome.permissionScope ?? null,
|
|
2343
|
+
// abort 전에 세션 안에서 돌려보낸 횟수. 0 이면 교정 경로가 없었다는
|
|
2344
|
+
// 뜻(0 예산 또는 회복 불가 사유)이고, >0 이면 안내를 받고도 반복한 것.
|
|
2345
|
+
permission_corrections: outcome.permissionCorrections ?? 0,
|
|
2346
|
+
permission_id: outcome.permissionID,
|
|
2347
|
+
permission_type: outcome.permissionType,
|
|
2348
|
+
// 승인 대기가 아니라 "이미 종료됨" 이다. 이 두 필드를 읽고 보고한다.
|
|
2349
|
+
awaiting_user_approval: false,
|
|
2350
|
+
session_aborted: true,
|
|
2351
|
+
stage_done: null,
|
|
2352
|
+
completion: "incomplete",
|
|
2353
|
+
polls: outcome.polls,
|
|
2354
|
+
elapsed_ms: outcome.elapsedMs,
|
|
2355
|
+
next_action: finalLegacy.nextAction,
|
|
2356
|
+
reason: finalLegacy.text,
|
|
2357
|
+
});
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2210
2360
|
if (outcome.kind === "empty") {
|
|
2211
2361
|
const isPreambleOnly = outcome.reason === "preamble_only";
|
|
2212
2362
|
logger.debug(`[dispatch_stage] empty output detected — sending one-time ${isPreambleOnly ? "action" : "summary"} re-prompt: ` +
|
|
@@ -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";
|
|
@@ -99,6 +114,8 @@ export type PollOutcome = {
|
|
|
99
114
|
permissionType?: string;
|
|
100
115
|
permissionPatterns?: string[];
|
|
101
116
|
permissionReason?: PermissionStallReason;
|
|
117
|
+
permissionScope?: string;
|
|
118
|
+
permissionCorrections?: number;
|
|
102
119
|
} | {
|
|
103
120
|
kind: "session_gone";
|
|
104
121
|
polls: number;
|
|
@@ -112,10 +129,12 @@ export interface PollOptions {
|
|
|
112
129
|
permissionCheckIntervalPolls?: number;
|
|
113
130
|
allowedWorktree?: string;
|
|
114
131
|
configuredAllowPatterns?: string[];
|
|
132
|
+
maxPermissionCorrections?: number;
|
|
115
133
|
now?: () => number;
|
|
116
134
|
sleep?: (ms: number) => Promise<void>;
|
|
117
135
|
logger?: {
|
|
118
136
|
debug?: (msg: string) => void;
|
|
137
|
+
warn?: (msg: string) => void;
|
|
119
138
|
error?: (msg: string) => void;
|
|
120
139
|
};
|
|
121
140
|
toolCallStallThresholdMs?: number;
|
|
@@ -128,6 +147,44 @@ export interface PollOptions {
|
|
|
128
147
|
nudgeAtFraction?: number;
|
|
129
148
|
onNudge?: (sessionId: string, elapsedMs: number) => Promise<void>;
|
|
130
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* 자동 승인 범위의 루트 = worktree 의 **직계 부모 한 단계**.
|
|
152
|
+
*
|
|
153
|
+
* 형제(메인 저장소·다른 worktree)까지는 열고 그 위는 닫는다. 조부모를 열면
|
|
154
|
+
* `/root/IdeaProjects/*` 하나가 모든 프로젝트 그룹을, 극단적으로 `/` 가 파일시스템
|
|
155
|
+
* 전체를 자동 승인 대상으로 만든다 — 이 함수의 반환값은 사람의 확인 없이 `allow`
|
|
156
|
+
* 로 응답되므로 그 확장은 승인 게이트 자체를 무력화한다 (GitHub #12 의 제안 1을
|
|
157
|
+
* 채택하지 않은 이유). 조부모 요청은 **차단이 정답**이고, 회복은 범위를 넓히는 것이
|
|
158
|
+
* 아니라 서브에이전트에게 허용 범위를 알려주고 재시도시키는 쪽(dispatch_stage 의
|
|
159
|
+
* permission-block 재디스패치)이 맡는다.
|
|
160
|
+
*/
|
|
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[];
|
|
131
188
|
export declare function isWithinWorktreeScope(patterns: string[], worktree: string): boolean;
|
|
132
189
|
export declare function isMatchedByConfiguredRules(patterns: string[], allowedGlobs: string[]): boolean;
|
|
133
190
|
/**
|
|
@@ -148,6 +205,14 @@ export declare function isMatchedByConfiguredRules(patterns: string[], allowedGl
|
|
|
148
205
|
* distinction so `dispatch_stage` can return `ok:false` on empty output.
|
|
149
206
|
*/
|
|
150
207
|
export declare function pollSubSession(client: PollClientLike, sessionId: string, options?: PollOptions): Promise<PollOutcome>;
|
|
208
|
+
/**
|
|
209
|
+
* `permission_stall` 보고 시 team-leader 가 그대로 따라야 하는 지시.
|
|
210
|
+
*
|
|
211
|
+
* dispatch_stage 응답의 `next_action` 으로 실린다. 하드룰 4("next_action 을 그대로
|
|
212
|
+
* 따른다")가 적용되는 자리이며, 문구를 바꿀 때는 agents/makdoong2-team-leader.md 의
|
|
213
|
+
* permission_stall 하드룰과 함께 고친다 (test/poll-permission-scope.test.ts 가 강제).
|
|
214
|
+
*/
|
|
215
|
+
export declare const PERMISSION_STALL_NEXT_ACTION: string;
|
|
151
216
|
/**
|
|
152
217
|
* Convert a {@link PollOutcome} into a display string plus a boolean success
|
|
153
218
|
* flag. Used by callers that need a single string for downstream serialization
|
|
@@ -157,4 +222,5 @@ export declare function pollSubSession(client: PollClientLike, sessionId: string
|
|
|
157
222
|
export declare function pollOutcomeToLegacy(outcome: PollOutcome): {
|
|
158
223
|
text: string;
|
|
159
224
|
success: boolean;
|
|
225
|
+
nextAction?: string;
|
|
160
226
|
};
|
package/dist/poll-sub-session.js
CHANGED
|
@@ -87,6 +87,71 @@ function posixDirname(p) {
|
|
|
87
87
|
const idx = trimmed.lastIndexOf("/");
|
|
88
88
|
return idx <= 0 ? "/" : trimmed.slice(0, idx);
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* 자동 승인 범위의 루트 = worktree 의 **직계 부모 한 단계**.
|
|
92
|
+
*
|
|
93
|
+
* 형제(메인 저장소·다른 worktree)까지는 열고 그 위는 닫는다. 조부모를 열면
|
|
94
|
+
* `/root/IdeaProjects/*` 하나가 모든 프로젝트 그룹을, 극단적으로 `/` 가 파일시스템
|
|
95
|
+
* 전체를 자동 승인 대상으로 만든다 — 이 함수의 반환값은 사람의 확인 없이 `allow`
|
|
96
|
+
* 로 응답되므로 그 확장은 승인 게이트 자체를 무력화한다 (GitHub #12 의 제안 1을
|
|
97
|
+
* 채택하지 않은 이유). 조부모 요청은 **차단이 정답**이고, 회복은 범위를 넓히는 것이
|
|
98
|
+
* 아니라 서브에이전트에게 허용 범위를 알려주고 재시도시키는 쪽(dispatch_stage 의
|
|
99
|
+
* permission-block 재디스패치)이 맡는다.
|
|
100
|
+
*/
|
|
101
|
+
export function worktreeAllowedScope(worktree) {
|
|
102
|
+
return posixDirname(worktree);
|
|
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
|
+
}
|
|
90
155
|
// Returns true when every permission pattern resides within the allowed scope,
|
|
91
156
|
// defined as the parent directory of the worktree (siblings are the main repo
|
|
92
157
|
// and other worktrees — all legitimate access targets for engineers).
|
|
@@ -94,7 +159,7 @@ function posixDirname(p) {
|
|
|
94
159
|
export function isWithinWorktreeScope(patterns, worktree) {
|
|
95
160
|
if (!worktree || patterns.length === 0)
|
|
96
161
|
return false;
|
|
97
|
-
const scope =
|
|
162
|
+
const scope = worktreeAllowedScope(worktree);
|
|
98
163
|
return patterns.every(pat => {
|
|
99
164
|
const base = pat.replace(/\/?(\*+\/?)+$/, "");
|
|
100
165
|
return base === scope || base.startsWith(scope + "/");
|
|
@@ -144,6 +209,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
144
209
|
const sleep = options.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
|
|
145
210
|
const dbg = options.logger?.debug;
|
|
146
211
|
const err = options.logger?.error ?? ((msg) => console.error(msg));
|
|
212
|
+
const warn = options.logger?.warn ?? err;
|
|
147
213
|
const startTime = now();
|
|
148
214
|
const deadline = startTime + timeoutMs;
|
|
149
215
|
const expectedPolls = Math.ceil(timeoutMs / pollIntervalMs);
|
|
@@ -156,7 +222,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
156
222
|
const isRecentlyActive = options.isRecentlyActive;
|
|
157
223
|
const isToolExecuting = options.isToolExecuting;
|
|
158
224
|
const permissionCheckIntervalPolls = options.permissionCheckIntervalPolls ?? 5;
|
|
225
|
+
const maxPermissionCorrections = options.maxPermissionCorrections ?? 3;
|
|
159
226
|
let pollCount = 0;
|
|
227
|
+
let permissionCorrections = 0;
|
|
160
228
|
let transientFailures = 0;
|
|
161
229
|
let sessionEverAppeared = false;
|
|
162
230
|
let hasProducedAssistantMessage = false;
|
|
@@ -335,42 +403,97 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
335
403
|
if (client.permission && pollCount % permissionCheckIntervalPolls === 0) {
|
|
336
404
|
const permResult = await client.permission.list().catch(() => null);
|
|
337
405
|
const pending = (permResult?.data ?? []).filter(p => p.sessionID === sessionId);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|
|
373
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
|
+
};
|
|
374
497
|
}
|
|
375
498
|
}
|
|
376
499
|
// Tool-call stall — "툴 호출이 떠 있는데 아무 진전이 없다".
|
|
@@ -621,6 +744,21 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
621
744
|
transientFailures,
|
|
622
745
|
};
|
|
623
746
|
}
|
|
747
|
+
/**
|
|
748
|
+
* `permission_stall` 보고 시 team-leader 가 그대로 따라야 하는 지시.
|
|
749
|
+
*
|
|
750
|
+
* dispatch_stage 응답의 `next_action` 으로 실린다. 하드룰 4("next_action 을 그대로
|
|
751
|
+
* 따른다")가 적용되는 자리이며, 문구를 바꿀 때는 agents/makdoong2-team-leader.md 의
|
|
752
|
+
* permission_stall 하드룰과 함께 고친다 (test/poll-permission-scope.test.ts 가 강제).
|
|
753
|
+
*/
|
|
754
|
+
export const PERMISSION_STALL_NEXT_ACTION = "이 실패는 사용자 승인으로 해소되지 않는다 — 권한 요청은 이미 자동 거부됐고 서브세션도 abort 된 뒤다. " +
|
|
755
|
+
"헤드리스 서브세션에는 승인을 받을 채널 자체가 없으므로, 사용자에게 '권한을 승인한 뒤 재개해 달라' 고 " +
|
|
756
|
+
"요청하는 것은 실행 불가능한 지시다. 절대 그렇게 보고하지 말 것. " +
|
|
757
|
+
"output 원문과 permission_patterns / permission_scope 를 그대로 인용해 '차단되어 종료됨' 으로 보고하고, " +
|
|
758
|
+
"permission_reason 별 처방을 따르라: outside_allowed_roots → 자동 재디스패치(허용 범위 안내 주입)가 이미 " +
|
|
759
|
+
"소진된 상태다. 같은 인자로 재호출하지 말고 차단된 경로와 허용 범위를 보고한 뒤 사용자 지시를 기다린다. " +
|
|
760
|
+
"non_external_permission → 해당 에이전트 frontmatter 의 permission 블록 문제다 (정식 키는 `edit`). " +
|
|
761
|
+
"tool_call_stall → `npx makdoong2-team doctor` 로 설치 상태를 점검한다.";
|
|
624
762
|
/**
|
|
625
763
|
* Convert a {@link PollOutcome} into a display string plus a boolean success
|
|
626
764
|
* flag. Used by callers that need a single string for downstream serialization
|
|
@@ -654,11 +792,33 @@ export function pollOutcomeToLegacy(outcome) {
|
|
|
654
792
|
: "an unidentified permission request";
|
|
655
793
|
const remedy = (() => {
|
|
656
794
|
switch (outcome.permissionReason) {
|
|
657
|
-
case "outside_allowed_roots":
|
|
658
|
-
|
|
659
|
-
|
|
795
|
+
case "outside_allowed_roots": {
|
|
796
|
+
// 종전에는 처방이 "임시 파일은 worktree 안에 써라" 하나뿐이었다. 정작
|
|
797
|
+
// 실제로 막힌 것은 read-only `glob` 이었고(GitHub #12), 그 안내는 상황과
|
|
798
|
+
// 무관해 아무 조치도 유도하지 못했다. 무엇이 막혔는지(patterns)와
|
|
799
|
+
// **어디까지가 허용인지**(scope)를 먼저 못 박고, 조회/쓰기 두 갈래로 나눈다.
|
|
800
|
+
const blocked = outcome.permissionPatterns?.length
|
|
801
|
+
? JSON.stringify(outcome.permissionPatterns)
|
|
802
|
+
: "(패턴 미상)";
|
|
803
|
+
const scope = outcome.permissionScope
|
|
804
|
+
? `\`${outcome.permissionScope}/\` 이하 (worktree 와 그 형제 디렉토리)`
|
|
805
|
+
: "worktree 와 그 형제 디렉토리";
|
|
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}` +
|
|
814
|
+
"그 위(조부모 이상)는 설계상 열지 않는다: 한 번 열면 형제 프로젝트 전체가 사람의 확인 없이 승인 대상이 된다. " +
|
|
815
|
+
"조치는 둘 중 하나다. " +
|
|
816
|
+
"(1) 조회(glob/grep/read/list)라면 경로 인자를 허용 범위 안으로 좁혀라 — 경로 인자 없이 cwd 기준 상대 패턴을 쓰는 것이 가장 안전하다. " +
|
|
817
|
+
"저장소 밖을 훑어야만 하는 작업이면 수행하지 말고 그 사실을 최종 출력에 적어 보고하라. " +
|
|
818
|
+
"(2) 임시 파일이 필요하면 `/tmp` 이 아니라 worktree 안의 " +
|
|
660
819
|
"`.makdoong2-team/<이슈키>/tmp/` 에 만들어라 — 그 경로는 cwd 안이라 승인이 필요 없고, " +
|
|
661
820
|
"git exclude 와 worktree 동기화 대상이다. `/tmp` 에 쓴 것은 동기화도 커밋도 되지 않아 조용히 사라진다.";
|
|
821
|
+
}
|
|
662
822
|
case "non_external_permission":
|
|
663
823
|
return "external_directory 가 아닌 권한 요청이 서브에이전트에 도달했다 — 경로 문제가 아니라 " +
|
|
664
824
|
"에이전트 permission 설정 문제다. 해당 에이전트의 frontmatter `permission:` 블록을 점검하라 " +
|
|
@@ -675,6 +835,11 @@ export function pollOutcomeToLegacy(outcome) {
|
|
|
675
835
|
`aborted after ${outcome.stalledMs}ms. reason=${outcome.permissionReason ?? "unknown"})\n` +
|
|
676
836
|
`→ ${remedy}`,
|
|
677
837
|
success: false,
|
|
838
|
+
// 이 한 줄이 없어서 team-leader 가 "이미 abort 됨" 을 "승인 대기 중" 으로
|
|
839
|
+
// 재해석해, 존재하지 않는 승인 행위를 사용자에게 요구하며 워크플로를
|
|
840
|
+
// 세웠다 (GitHub #12). 헤드리스 서브세션에는 승인 채널이 없다 — 그래서
|
|
841
|
+
// 훅이 대신 거부한 것이고, 사용자가 할 수 있는 일은 애초에 없다.
|
|
842
|
+
nextAction: PERMISSION_STALL_NEXT_ACTION,
|
|
678
843
|
};
|
|
679
844
|
}
|
|
680
845
|
case "session_gone":
|
|
@@ -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);
|