@ai-dev-methodologies/rlp-desk 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ # Plan — Claude worker `.claude/` sensitive prompt hang 수정
2
+
3
+ > **Source bug report**: `/Users/kyjin/dev/doul/bos/docs/exec-plans/active/2026-05-01-rlp-desk-bug-report.md`
4
+ > **Severity**: HIGH — `--mode tmux` + `--worker-model sonnet/haiku/opus` 조합에서 모든 campaign blocking
5
+ > **Target version**: 0.13.0 (breaking — project-local sentinel 경로 이동)
6
+
7
+ ---
8
+
9
+ ## 1. Context
10
+
11
+ ### 문제
12
+
13
+ `<project>/.claude/ralph-desk/memos/<slug>-done-claim.json` 등 sentinel 작성 시
14
+ Claude Code가 `.claude/` 경로를 self-modification suspect로 hardcoded 처리하여
15
+ permission prompt를 띄움. `--dangerously-skip-permissions`로도 우회 X.
16
+ Worker hang → Leader pollForSignal 30분 timeout → BLOCKED(`infra_failure`).
17
+
18
+ Codex worker(gpt-5.5:* 등)에서는 미발생 — Claude Code의 sensitive 정책 외부.
19
+ 즉 **현재는 Claude 계열 worker가 사실상 사용 불가**.
20
+
21
+ ### 핵심 결정
22
+
23
+ 프로젝트-로컬 runtime 디렉토리를 `<project>/.claude/ralph-desk/`에서
24
+ `<project>/.rlp-desk/`로 이동.
25
+
26
+ **근거**:
27
+ - Claude Code의 sensitive 검사 트리거는 `.claude/` 디렉토리명 자체.
28
+ - 디렉토리명만 바꾸면 회피 (영감 출처 design-desk도 `.claude/` 안에 sentinel을 두지 않음).
29
+ - `~/.claude/ralph-desk/`(설치 위치 + cross-project analytics)는 변경 없음 — Leader가
30
+ 자기 자신의 install dir을 self-modify할 일은 없으므로 sensitive 검사 트리거 안 함.
31
+
32
+ ### 비-목표
33
+
34
+ - `~/.claude/ralph-desk/` 설치 경로 변경 (registry, analytics, leader binaries 유지)
35
+ - `--mode agent` 폐지 (Fix-1로 자동 해결되므로 그대로 유지)
36
+
37
+ ---
38
+
39
+ ## 2. Approach (3단계)
40
+
41
+ ### Phase 1 — Fix-1: 프로젝트-로컬 sentinel 경로 이동 (`.claude/ralph-desk/` → `.rlp-desk/`)
42
+
43
+ **변경 대상 파일** (Explore 결과):
44
+
45
+ | File | 위치 | 변경 내용 |
46
+ |------|------|----------|
47
+ | `src/node/init/campaign-initializer.mjs` | L5, L13 | `GITIGNORE_RULE` + `deskRoot` 상수 |
48
+ | `src/scripts/init_ralph_desk.zsh` | L77, L1091, L1100, L1105-1137 | `DESK` 변수 + permission marker 패턴 |
49
+ | `src/scripts/run_ralph_desk.zsh` | L255 | `DESK` 변수 |
50
+ | `src/scripts/lib_ralph_desk.zsh` | L57 | 홈 디렉토리 변수 주석 명확화 (변경 없음, 주석만) |
51
+ | `src/node/runner/campaign-main-loop.mjs` | L44-80 | 경로 빌드 함수 |
52
+ | `src/commands/rlp-desk.md` | 24개 라인 | 모든 `.claude/ralph-desk/` → `.rlp-desk/` 참조 |
53
+ | `src/governance.md` | 6개 라인 | 경로 문서화 |
54
+
55
+ **유지(변경 없음)**:
56
+ - `src/node/runner/leader-registry.mjs` (홈 디렉토리 `~/.claude/ralph-desk/registry.jsonl`)
57
+ - `install.sh`, `scripts/postinstall.js` (홈 디렉토리 설치)
58
+
59
+ **Worker/Verifier `--add-dir` whitelist**:
60
+ - 기존: `--add-dir "$HOME/.claude/ralph-desk" "$ROOT"` (lib_ralph_desk.zsh:57-58).
61
+ - `$ROOT`가 이미 whitelist이므로 `$ROOT/.rlp-desk`는 **자동 포함** — 별도 추가 불필요.
62
+ - 핵심은 디렉토리명 변경 자체로 sensitive 검사 trigger를 회피하는 것이지, sandbox/permission 변경이 아님.
63
+
64
+ **Runtime dir override (Synthesis — 미래 회피책)**:
65
+
66
+ `deskRoot`를 환경변수 `RLP_DESK_RUNTIME_DIR`로 외부화. 기본값 `.rlp-desk/`. 향후 platform이 또 sensitive 검사를 확장하면 사용자가 즉시 `RLP_DESK_RUNTIME_DIR=.rlp-runtime/` 등으로 우회 가능. P1(don't fight platform)을 코드 단에 영속화.
67
+
68
+ **Migration race-safety (atomic — Codex Critic 반영)**:
69
+
70
+ 이 절차는 **init 모드 진입 시에만** 실행. run 모드는 §2 Phase 3 정책대로 자동 mv 수행 안 함(경고 + 수동 안내).
71
+
72
+ - 락 파일 위치: `<project>/.rlp-desk-migration.lock` (target dir 외부 — target dir이 아직 없을 수 있으므로 parent `<project>/`에 둠).
73
+ - 락 획득: `fs.openSync(lockPath, 'wx')` (exclusive create — TOCTOU 없음). 이미 존재하면 "다른 프로세스가 마이그레이션 중" 에러로 즉시 abort.
74
+ - init 모드 마이그레이션 절차 (락 보유 상태):
75
+ 1. 양쪽(legacy `.claude/ralph-desk/` + new `.rlp-desk/`) 존재 여부 검사.
76
+ 2. 둘 다 존재 → 자동 mv **거부** + 사용자 정리 안내 (pre-mortem #1 binding).
77
+ 3. legacy만 존재 → `fs.renameSync(legacy, new)` (원자적, 같은 파일시스템 내).
78
+ 4. 둘 다 없음 → noop (정상 init).
79
+ - run 모드(legacy 발견 시): mv 시도하지 않고 비-zero exit + 수동 명령 안내. 진행 중 캠페인 보호.
80
+ - 락 해제: `try/finally`로 `fs.unlinkSync(lockPath)` 보장. 프로세스 crash 시 다음 실행에서 stale 락 감지(mtime > N분) 시 경고 후 제거.
81
+ - `fresh` 모드(`campaign-initializer.mjs:20`의 `fs.rm({recursive:true})`)는 마이그레이션 완료 후 새 경로에서만 실행.
82
+
83
+ ### Phase 2 — Fix-2: Claude worker + tmux 조합 경고
84
+
85
+ **위치**: `src/node/cli/command-builder.mjs` (이미 `CLAUDE_MODELS = Set(['haiku','sonnet','opus'])` 존재).
86
+
87
+ **로직**: `parseRunOptions()` (`src/node/run.mjs:101-180`) 파싱 후
88
+ `runRunCommand` 진입 시점에 다음 검증 추가:
89
+
90
+ ```js
91
+ // src/node/run.mjs (파싱 후 검증 단계)
92
+ if (mode === 'tmux' && isClaudeEngine(workerModel)) {
93
+ console.warn(
94
+ 'WARNING: Claude worker in tmux mode may hang on .claude/ sentinel writes.\n' +
95
+ 'After v0.13.0, sentinels live in <project>/.rlp-desk/ which avoids this.\n' +
96
+ 'If hang persists, switch to --worker-model gpt-5.5:high (codex) or --mode agent.'
97
+ );
98
+ }
99
+ ```
100
+
101
+ PRD brainstorm 플로우(`src/commands/rlp-desk.md`)에도 동일 경고 문구 노출.
102
+
103
+ **Observability — sentinel hang early-detect 휴리스틱** (Architect synthesis):
104
+
105
+ 기존 Leader pollForSignal은 30분 timeout으로만 감지 → silent failure. 보강:
106
+ - Worker pane stdout에 `Do you want to ` / `❯ 1. Yes` 등 prompt 시그니처 grep → 즉시 BLOCKED + `category=permission_prompt`로 라벨링.
107
+ - 위치: `src/node/runner/prompt-dismisser.mjs` 또는 별도 `prompt-detector.mjs`.
108
+ - 효과: 다음 platform 변화 시에도 30분이 아니라 수 초 내 발견.
109
+
110
+ ### Phase 3 — 마이그레이션 도우미 (legacy `.claude/ralph-desk/` 감지)
111
+
112
+ **위치**: `src/node/init/campaign-initializer.mjs` 진입 시 + `src/node/runner/campaign-main-loop.mjs` `ensureScaffold()` 직전.
113
+
114
+ **로직**:
115
+ 1. `<project>/.claude/ralph-desk/`가 존재하고 `<project>/.rlp-desk/`가 없으면
116
+ 감지 후 다음 중 하나:
117
+ - **자동 mv** (init 모드): scaffold가 새로 만들어지는 단계라면 §2 Migration race-safety 절차로 자동 이동.
118
+ - **경고 + 수동 명령 안내** (run 모드): 비-zero exit + "기존 캠페인이 있습니다. `mv .claude/ralph-desk .rlp-desk` 후 재실행하세요."
119
+ 2. 양쪽 다 존재 시 — 모드 무관하게 자동 mv **거부** + 비-zero exit + 사용자 정리 안내(stderr에 "both directories exist" 포함). §2 Migration race-safety + §3a MEDIUM-B 검증과 일치.
120
+ 3. `.gitignore`에서 `.claude/ralph-desk/` 라인 제거 + `.rlp-desk/` 라인 추가 (init 시점, mv 성공 후).
121
+
122
+ ---
123
+
124
+ ## 3. Verification
125
+
126
+ CLAUDE.md mandate에 따라 commit 전 다음을 모두 통과해야 함:
127
+
128
+ ### 3a. Self-Verification (6 scenarios — `src/governance.md`/`init_ralph_desk.zsh` 변경 시 mandatory; executable commands)
129
+
130
+ 각 시나리오: Worker(execution_steps) → Verifier(reasoning, 5 categories) → PASS.
131
+
132
+ #### LOW (단위 — `isClaudeEngine()` + env 해석)
133
+ ```bash
134
+ node --test tests/node/test-claude-engine-detect.mjs
135
+ # expected: tests passed; isClaudeEngine('sonnet') === true; resolveDeskRoot(env={RLP_DESK_RUNTIME_DIR:'.x'}) === '.x'
136
+ ```
137
+
138
+ #### MEDIUM-A (auto-mv 정상 케이스 — pre-mortem #2 part)
139
+ ```bash
140
+ TMP=$(mktemp -d); cd "$TMP"; git init -q
141
+ mkdir -p .claude/ralph-desk/memos && echo data > .claude/ralph-desk/memos/x.md
142
+ node ~/.claude/ralph-desk/node/run.mjs init testslug --autonomous
143
+ test ! -d .claude/ralph-desk && test -f .rlp-desk/memos/x.md && \
144
+ grep -q '"Read(.rlp-desk/\*\*)"' .claude/settings.local.json
145
+ # expected: exit 0, all assertions PASS
146
+ ```
147
+
148
+ #### MEDIUM-B (conflict 거부 — pre-mortem #1 binding)
149
+ ```bash
150
+ TMP=$(mktemp -d); cd "$TMP"; git init -q
151
+ mkdir -p .claude/ralph-desk .rlp-desk
152
+ node ~/.claude/ralph-desk/node/run.mjs init testslug --autonomous 2> stderr.log
153
+ test $? -ne 0 && grep -q 'both directories exist' stderr.log
154
+ # expected: non-zero exit, conflict 안내
155
+ ```
156
+
157
+ #### HIGH-A (claude+tmux E2E — AC4 binding, primary fix 검증)
158
+ ```bash
159
+ TMP=$(mktemp -d); cd "$TMP"; git init -q
160
+ node ~/.claude/ralph-desk/node/run.mjs init testslug --autonomous
161
+ timeout 600 node ~/.claude/ralph-desk/node/run.mjs run testslug \
162
+ --mode tmux --worker-model sonnet --max-iter 1 --iter-timeout 300
163
+ test $? -eq 0 && test -f .rlp-desk/memos/testslug-done-claim.json
164
+ # expected: exit 0, sentinel hang 없이 완료
165
+ ```
166
+
167
+ #### HIGH-B (codex+tmux 회귀 — AC5 binding, P3 first-class)
168
+ ```bash
169
+ TMP=$(mktemp -d); cd "$TMP"; git init -q
170
+ node ~/.claude/ralph-desk/node/run.mjs init testslug --autonomous
171
+ timeout 600 node ~/.claude/ralph-desk/node/run.mjs run testslug \
172
+ --mode tmux --worker-model gpt-5.5:high --max-iter 1 --iter-timeout 300
173
+ test $? -eq 0 && test -f .rlp-desk/memos/testslug-done-claim.json
174
+ # expected: exit 0, codex worker 회귀 없음
175
+ ```
176
+
177
+ #### OBSERVABILITY (prompt 조기 감지 — AC6 binding)
178
+ ```bash
179
+ # 모의 worker stdout에 "❯ 1. Yes" 라인 주입 → prompt-detector가 5초 이내 BLOCKED 작성
180
+ node tests/node/test-prompt-detector-e2e.mjs
181
+ jq -r .category .rlp-desk/memos/testslug-blocked.json
182
+ # expected: "permission_prompt"
183
+ ```
184
+
185
+ ### 3b. Review
186
+
187
+ - **ralplan** (Planner→Architect→Critic): governance/template 변경이므로 mandatory.
188
+ - **codex review**: 0 issue 도달까지 반복 (CLAUDE.md mandate).
189
+
190
+ ### 3c. Local sync 검증
191
+
192
+ CLAUDE.md `Local File Sync` 섹션의 banner-aware verification 절차로
193
+ 모든 `src/` 변경분이 `~/.claude/ralph-desk/`에 sync되었는지 확인:
194
+
195
+ ```bash
196
+ diff -rq src/node ~/.claude/ralph-desk/node | grep -v 'DO NOT EDIT'
197
+ # expected: empty output (모든 파일이 banner 차이 외에 동일)
198
+ ```
199
+
200
+ ### 3d. 수동 reproduction (버그 리포터 시나리오 재현)
201
+
202
+ ```bash
203
+ # legacy 경로 시뮬레이션
204
+ mkdir -p /tmp/test-rlp-desk/.claude/ralph-desk
205
+ cd /tmp/test-rlp-desk
206
+
207
+ # init → 마이그레이션 또는 신규 .rlp-desk 생성 확인
208
+ node ~/.claude/ralph-desk/node/run.mjs init test-slug --autonomous
209
+
210
+ # 검증: .rlp-desk/ 존재 + .gitignore 갱신
211
+ test -d .rlp-desk && echo PASS || echo FAIL
212
+ grep -q '^.rlp-desk/$' .gitignore && echo PASS || echo FAIL
213
+
214
+ # 1-iter campaign with claude worker
215
+ node ~/.claude/ralph-desk/node/run.mjs run test-slug \
216
+ --mode tmux --worker-model sonnet --max-iter 1 --iter-timeout 600
217
+ # 기대: sentinel hang 없이 1 iteration 완료
218
+ ```
219
+
220
+ ---
221
+
222
+ ## 4. Release Plan
223
+
224
+ - **버전**: `0.13.0` (npm minor bump — 자동 마이그레이션 + run 모드 명확한 안내로 사용자 영향 흡수).
225
+ - **Release notes** (user-facing only — CLAUDE.md mandate; 최상단에 BREAKING 라벨 강조):
226
+ - **BREAKING**: project-local runtime이 `.claude/ralph-desk/` → `.rlp-desk/`로 이동.
227
+ init 모드는 자동 마이그레이션, run 모드는 경고 + 수동 `mv .claude/ralph-desk .rlp-desk` 안내.
228
+ - **NEW**: `RLP_DESK_RUNTIME_DIR` 환경변수로 runtime 디렉토리 override 가능 (미래 platform 변화 회피용).
229
+ - **FIX**: Claude worker + tmux 조합 sentinel write hang 해결.
230
+ - **NEW**: claude worker + tmux 조합 경고 + permission prompt 조기 감지(BLOCKED `category=permission_prompt`).
231
+ - **Roadmap note**: 1.0.0에서 legacy 감지 로직 deprecation 예정 (deprecation cycle 약속).
232
+
233
+ ---
234
+
235
+ ## 5. Critical files (이 plan 실행 시 수정 대상 요약)
236
+
237
+ ```
238
+ src/node/init/campaign-initializer.mjs # deskRoot 상수 + GITIGNORE_RULE + 마이그레이션 감지
239
+ src/node/runner/campaign-main-loop.mjs # 경로 빌드 함수 + ensureScaffold() 전 legacy 검사
240
+ src/node/cli/command-builder.mjs # isClaudeEngine() helper export
241
+ src/node/run.mjs # parseRunOptions() 후 tmux+claude 경고
242
+ src/scripts/init_ralph_desk.zsh # DESK 변수 + permission marker (.rlp-desk/**)
243
+ src/scripts/run_ralph_desk.zsh # DESK 변수
244
+ src/scripts/lib_ralph_desk.zsh # 변경 없음 ($ROOT 이미 whitelist이므로 .rlp-desk 자동 포함; 주석만 명확화)
245
+ src/commands/rlp-desk.md # 24개 라인 경로 참조 갱신
246
+ src/governance.md # 6개 라인 경로 문서화
247
+ package.json # version 0.13.0
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 6. Resolved decisions (사용자 확정 — 추천안)
253
+
254
+ - **마이그레이션 정책**: init 모드 = 자동 mv, run 모드 = 경고 + 수동 mv 안내. 사용자 데이터(memos, plans)
255
+ 존재 시 init은 새 scaffold가 만들어지는 시점이라 안전하게 이동 가능, run은 진행 중인 campaign일 수
256
+ 있으므로 명시적 사용자 확인 필요.
257
+ - **버전 강도**: `0.13.0` (npm minor). Project-local 경로 변경은 breaking이지만 자동 마이그레이션
258
+ 도우미 + run 모드 명확한 안내가 있으므로 minor로 충분. major(1.0.0)는 후속 안정화 단계에서.
259
+ - **`--mode agent` 처리**: Fix-1(경로 이동)으로 자동 해결. agent mode worker도 동일하게 `.rlp-desk/`에
260
+ 쓰므로 Claude Code sensitive trigger 발생 안 함. 별도 작업 불필요.
261
+
262
+ ---
263
+
264
+ ## 7. RALPLAN-DR Deliberation Summary
265
+
266
+ ### Principles
267
+
268
+ 1. **Don't fight platform-reserved namespaces** — Claude Code hardcoded sensitive policy 우회 불가; 회피만이 답.
269
+ 2. **Project-local runtime은 git 트리에 머물러야** — 캠페인 memory/plans는 iteration 간 영속 필요.
270
+ 3. **Cross-engine fallback은 first-class** — codex worker는 영구적 회피책이 아닌 동등한 옵션.
271
+ 4. **마이그레이션 안전성 우선, 자동화는 명확히 안전한 시점에만** — init 모드는 fresh scaffold 시점이라 자동 mv, run 모드는 진행 중 캠페인 보호를 위해 경고 + 수동 mv. "default 자동"이 아닌 "context-aware 자동/수동 선택".
272
+
273
+ ### Decision Drivers (top 3)
274
+
275
+ 1. **Unblock HIGH severity blocker** — claude-worker + tmux 모든 캠페인 차단 중.
276
+ 2. **Minimize breaking surface** — 진행 중 campaign 손실 방지.
277
+ 3. **Reference parity** — design-desk(영감 출처)도 sentinel을 `.claude/` 밖에 둠.
278
+
279
+ ### Viable Options
280
+
281
+ | Option | Pros | Cons |
282
+ |---|---|---|
283
+ | **A. `.rlp-desk/` 이동 (권장)** | `.claude/` trigger 완전 회피, design-desk 패턴 일치, git 트리 유지 | 모든 사용자 마이그레이션 필요 (자동화로 완화) |
284
+ | **B. `.claude/ralph-desk/` 유지 + 권한 escape** | 경로 변경 없음 | 사용자 repro에서 permission allowlist도 우회 실패. Claude Code 내부 동작 의존 → brittle |
285
+ | **C. `$TMPDIR/rlp-desk-<slug>/`** | 프로젝트 트리 청결 | git 추적 끊김, campaign memory 영속성 깨짐, resume 취약 |
286
+
287
+ ### Invalidation rationale
288
+
289
+ - **B**: 버그 리포트 §2 표에서 `Read/Edit/Write(.claude/ralph-desk/**)` allowlist 추가가 실패함이 입증. Claude Code의 sensitive 게이트는 일반 permission 시스템과 별도로 동작 → 의존 불가.
290
+ - **C**: campaign memory(`memos/<slug>-memory.md` 등)는 iteration 간 영속이 핵심 설계. tmpfs 기반은 OS 재부팅/clean 시 유실되어 resume 불가능.
291
+
292
+ → **A가 유일한 viable option**.
293
+
294
+ ### Pre-mortem (3 scenarios — verification §3a에 명시 binding)
295
+
296
+ 1. **자동 mv가 사용자 데이터 덮어쓰기**: 양쪽 디렉토리 모두 존재 시 mv 충돌 → mitigation: §2 Phase 3의 atomic lock + 충돌 거부. **검증**: §3a MEDIUM-B.
297
+ 2. **권한 marker 누락**: `.rlp-desk/**` permission이 `init_ralph_desk.zsh`에 추가 안 되면 worker가 새 경로에서도 prompt 발생 → mitigation: permission marker 패턴 갱신 + assertion. **검증**: §3a MEDIUM-A의 `grep -q '"Read(.rlp-desk/\*\*)"' .claude/settings.local.json` 단언 + §3a HIGH-A의 1-iter 캠페인 sentinel write 성공(end-to-end).
298
+ 3. **Sandbox `--add-dir` 미커버**: `$ROOT`가 이미 whitelist이므로 자동 포함이지만, 만약 `--add-dir` 인자 변경으로 회귀하면 sandbox가 새 경로 거부 → mitigation: 통합 테스트에서 worker 명령 빌드 결과 단언. **검증**: §3a HIGH-A의 worker spawn 단계에서 `claude --add-dir "$ROOT" ...` 명시 확인 + §3d 수동 1-iter 재현.
299
+
300
+ ### Acceptance Criteria (자동 검증 가능 — pass 신호 명시)
301
+
302
+ - [ ] **AC1** — `<project>/.rlp-desk/`만 사용. 검증: `find . -type d -path '*.claude/ralph-desk' -newer <campaign-start-marker> | wc -l` == 0.
303
+ - [ ] **AC2** — Legacy `.claude/ralph-desk/` 존재 시 init은 자동 mv 후 `.gitignore`에 `.rlp-desk/` 라인 존재. 검증: `test ! -d .claude/ralph-desk && test -d .rlp-desk && grep -q '^\.rlp-desk/$' .gitignore`.
304
+ - [ ] **AC3** — Run 모드에서 legacy 발견 시 비-zero exit + stderr에 `mv .claude/ralph-desk .rlp-desk` 안내 문자열 포함. 검증: `node run.mjs run ...; echo $? != 0; grep -q "mv .claude/ralph-desk" stderr.log`.
305
+ - [ ] **AC4** — `--mode tmux --worker-model sonnet` 1-iter 캠페인 600초 이내 종료 + `done-claim.json` 존재 + exit 0. 검증: `timeout 600 node run.mjs run ... --max-iter 1; test $? == 0 && test -f .rlp-desk/memos/<slug>-done-claim.json`.
306
+ - [ ] **AC5** — `--worker-model gpt-5.5:high` 1-iter 캠페인 동일 단언(AC4 패턴) — 회귀 없음.
307
+ - [ ] **AC6** — Permission prompt 조기 감지: worker에 mock prompt 주입 시 5초 이내 BLOCKED + sentinel `category=permission_prompt`. 검증: `jq -r .category .rlp-desk/memos/<slug>-blocked.json == "permission_prompt"`.
308
+
309
+ ---
310
+
311
+ ## 8. ADR (Architectural Decision Record)
312
+
313
+ - **Decision**: Project-local sentinel/runtime을 `.claude/ralph-desk/` → `.rlp-desk/`로 이동.
314
+ - **Drivers**: Claude Code hardcoded sensitive policy로 worker hang. 우회 불가 → 디렉토리 명 변경.
315
+ - **Alternatives considered**: B(`.claude/` 유지 + escape) — 사용자 repro에서 입증 실패. C(`$TMPDIR/`) — campaign memory 영속성 손상.
316
+ - **Why chosen**: A는 design-desk 참조 패턴과 일치하고, sensitive trigger의 root cause(`.claude/` 디렉토리명)를 직접 회피. 자동 마이그레이션으로 사용자 영향 최소화.
317
+ - **Consequences**: 0.13.0 minor breaking. 모든 사용자 `.gitignore` + 디렉토리 갱신 필요(자동화). 문서 업데이트 광범위(rlp-desk.md 24 lines, governance.md 6 lines).
318
+ - **Follow-ups**:
319
+ 1. **1.0.0 deprecation cycle**: legacy `.claude/ralph-desk/` 감지 로직 제거 (사용자에게 1 minor 사이클 마이그레이션 시간 확보).
320
+ 2. **`~/.claude/ralph-desk/` 이동 검토**: 현재 sensitive trigger 미발생이지만 platform 변화 대비 1.x에서 검토.
321
+ 3. **Permission prompt 조기 감지**: `prompt-detector.mjs` 추가 후 다른 platform-shaped silent failure에도 재사용 (예: codex CLI의 미래 정책 변화).
322
+ 4. **Steelman 대응**: Architect 지적("`.rlp-desk/`도 미래 sensitive화 가능") — `RLP_DESK_RUNTIME_DIR` env 외부화로 기술적 대응 완료. 정책 모니터링은 운영 영역.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ai-dev-methodologies/rlp-desk",
3
- "version": "0.12.0",
4
- "description": "Fresh-context iterative loops for Claude Code autonomous task completion with independent verification",
3
+ "version": "0.13.0",
4
+ "description": "Fresh-context iterative loops for Claude Code \u2014 autonomous task completion with independent verification",
5
5
  "scripts": {
6
6
  "postinstall": "node scripts/postinstall.js",
7
7
  "uninstall": "node scripts/uninstall.js",
@@ -135,7 +135,7 @@ After all items are confirmed:
135
135
  Present the score table to the user before proceeding.
136
136
  2. Present the full contract summary.
137
137
  3. **Self-Verification** — Ask: "Enable self-verification? Worker records step-by-step evidence, Verifier cross-validates process. Recommended for MEDIUM+ risk." Default: yes for HIGH/CRITICAL, no for LOW/MEDIUM.
138
- 4. **Re-execution check**: After slug is confirmed, check if `.claude/ralph-desk/plans/prd-<slug>.md` already exists. If a PRD already exists for this slug, ask: "A PRD already exists for this slug. Improve the existing PRD or start fresh (delete and recreate)?"
138
+ 4. **Re-execution check**: After slug is confirmed, check if `.rlp-desk/plans/prd-<slug>.md` already exists. If a PRD already exists for this slug, ask: "A PRD already exists for this slug. Improve the existing PRD or start fresh (delete and recreate)?"
139
139
  - "improve" → pass `--mode improve` to init
140
140
  - "start fresh" → pass `--mode fresh` to init
141
141
  - If no PRD exists: standard first-run (no --mode needed)
@@ -282,7 +282,7 @@ Parse the `--mode` flag. If absent or `agent`, use the Agent() path below. If `t
282
282
 
283
283
  When `--mode tmux` is specified (v0.12.0+ — v5.7 §4.1 routes to Node leader for flywheel + SV support):
284
284
 
285
- 1. **Validate scaffold** — same as Agent() mode: check `.claude/ralph-desk/prompts/<slug>.worker.prompt.md` etc.
285
+ 1. **Validate scaffold** — same as Agent() mode: check `.rlp-desk/prompts/<slug>.worker.prompt.md` etc.
286
286
  2. **Check sentinels** — same as Agent() mode.
287
287
  3. **Check prerequisites** — verify `tmux`, `jq`, and `node` (>= 16) are installed. If not, report what is missing and stop.
288
288
  4. **Locate Node leader** — find `~/.claude/ralph-desk/node/run.mjs`. If not found, tell the user to reinstall (`npm install` or `bash install.sh`).
@@ -348,11 +348,11 @@ prompt at the SDK level, the call hangs indefinitely with no rlp-desk-side
348
348
  watchdog. Use `--mode tmux` if you need bounded execution time.
349
349
 
350
350
  ### Preparation
351
- 1. Validate scaffold: `.claude/ralph-desk/prompts/<slug>.worker.prompt.md` etc.
351
+ 1. Validate scaffold: `.rlp-desk/prompts/<slug>.worker.prompt.md` etc.
352
352
  2. **Codex CLI pre-validation**: If `--consensus` is not `off` OR `--worker-model` uses codex format (contains `:`) OR `--verifier-model` / `--final-verifier-model` / `--consensus-model` / `--final-consensus-model` uses codex format, check that `codex` CLI exists in PATH. If codex CLI not found → STOP immediately, print install instructions (`npm install -g @openai/codex`), do not start the loop.
353
353
  3. Check sentinels (complete/blocked). Found → tell user `/rlp-desk clean <slug>`.
354
354
  4. Clean previous `done-claim.json`, `verify-verdict.json`.
355
- 5. **Always**: write baseline log entry to `.claude/ralph-desk/logs/<slug>/baseline.log`: `[timestamp] iter=0 phase=start slug=<slug> worker_model=<model> verifier_model=<model>`. Baseline.log captures 1 line per iteration for lightweight post-mortem (always-on, no flag needed).
355
+ 5. **Always**: write baseline log entry to `.rlp-desk/logs/<slug>/baseline.log`: `[timestamp] iter=0 phase=start slug=<slug> worker_model=<model> verifier_model=<model>`. Baseline.log captures 1 line per iteration for lightweight post-mortem (always-on, no flag needed).
356
356
  6. If `--debug`: also create/clear `~/.claude/ralph-desk/analytics/<slug>/debug.log`. Define a helper: to "debug_log" means append a timestamped line to this file via `Bash("echo \"[$(date '+%Y-%m-%d %H:%M:%S')] $msg\" >> ~/.claude/ralph-desk/analytics/<slug>/debug.log")`. When `--debug` is active, debug.log contains all baseline.log fields plus detailed phase logs.
357
357
  - **4-category log system**: all debug_log entries use exactly one of: `[GOV]` (governance checks: IL enforcement, CB triggers, scope lock, verdict evaluation), `[DECIDE]` (leader decisions: model selection, fix contracts, escalation), `[OPTION]` (configuration snapshot at loop start: thresholds, modes, models), `[FLOW]` (execution progress: worker/verifier dispatch, signal reads, phase transitions)
358
358
  - **Re-execution versioning**: If `debug.log` already exists at `--debug` start, rename it to `debug-v{N}.log` (N = next available integer ≥ 1) before creating a fresh `debug.log`.
@@ -378,14 +378,14 @@ For each iteration (1 to max_iter):
378
378
 
379
379
  **① Check sentinels**
380
380
  ```bash
381
- test -f .claude/ralph-desk/memos/<slug>-complete.md # → done
382
- test -f .claude/ralph-desk/memos/<slug>-blocked.md # → stop
381
+ test -f .rlp-desk/memos/<slug>-complete.md # → done
382
+ test -f .rlp-desk/memos/<slug>-blocked.md # → stop
383
383
  ```
384
384
 
385
385
  **①½ Prep-stage cleanup**
386
386
  ```bash
387
- rm -f .claude/ralph-desk/memos/<slug>-done-claim.json
388
- rm -f .claude/ralph-desk/memos/<slug>-verify-verdict.json
387
+ rm -f .rlp-desk/memos/<slug>-done-claim.json
388
+ rm -f .rlp-desk/memos/<slug>-verify-verdict.json
389
389
  ```
390
390
 
391
391
  **② Read memory.md** → Stop Status, Next Iteration Contract
@@ -401,15 +401,15 @@ rm -f .claude/ralph-desk/memos/<slug>-verify-verdict.json
401
401
 
402
402
  **④ Build worker prompt (Prompt Assembly Protocol)**
403
403
  1. Capture `WORKING_DIR` once: use `$PWD` from when `/rlp-desk run` was invoked. Store for all prompt construction.
404
- 2. Read `.claude/ralph-desk/prompts/<slug>.worker.prompt.md` — use its content **verbatim**. Do NOT rewrite, paraphrase, or regenerate paths. The prompt file contains correct absolute paths from init.
404
+ 2. Read `.rlp-desk/prompts/<slug>.worker.prompt.md` — use its content **verbatim**. Do NOT rewrite, paraphrase, or regenerate paths. The prompt file contains correct absolute paths from init.
405
405
  2a. **Per-US PRD injection** (when targeting a specific `us_id`, not "ALL"):
406
- - Check if `.claude/ralph-desk/plans/prd-<slug>-{us_id}.md` exists (created by init split)
406
+ - Check if `.rlp-desk/plans/prd-<slug>-{us_id}.md` exists (created by init split)
407
407
  - If yes: in the assembled prompt text, replace the full PRD reference (`prd-<slug>.md`) with the per-US file path (`prd-<slug>-{us_id}.md`) — so Worker reads only the relevant US section
408
408
  - If no per-US file: fall back to full PRD (`prd-<slug>.md`) with no change needed
409
409
  - Note: this absolute-path substitution is permitted — only absolute→relative rewrites are forbidden.
410
410
  3. Prepend meta comment: `## WORKING_DIR: {absolute path}` — Worker must use this as its working directory.
411
411
  4. Append iteration number + memory contract.
412
- 5. Write to `.claude/ralph-desk/logs/<slug>/iter-NNN.worker-prompt.md` (audit trail).
412
+ 5. Write to `.rlp-desk/logs/<slug>/iter-NNN.worker-prompt.md` (audit trail).
413
413
  - Note: Worker ALWAYS records execution_steps in done-claim.json per governance §1f. No flag needed.
414
414
  - **Rewriting paths from absolute to relative WILL break worktree campaigns. Only additions (WORKING_DIR header, iteration context) are allowed.**
415
415
 
@@ -660,7 +660,7 @@ When `--consensus` is not `off`, also track in `status.json`:
660
660
  ---
661
661
 
662
662
  ## `status <slug>`
663
- Read `.claude/ralph-desk/logs/<slug>/runtime/status.json` and display a detailed report:
663
+ Read `.rlp-desk/logs/<slug>/runtime/status.json` and display a detailed report:
664
664
 
665
665
  ```
666
666
  Campaign: <slug>
@@ -683,22 +683,22 @@ Read the last `verify-verdict.json` to show the most recent verdict summary and
683
683
 
684
684
  ## `clean <slug> [--kill-session]`
685
685
  Remove:
686
- - `.claude/ralph-desk/memos/<slug>-complete.md`
687
- - `.claude/ralph-desk/memos/<slug>-blocked.md`
688
- - `.claude/ralph-desk/memos/<slug>-done-claim.json`
689
- - `.claude/ralph-desk/memos/<slug>-verify-verdict.json`
690
- - `.claude/ralph-desk/memos/<slug>-iter-signal.json`
691
- - `.claude/ralph-desk/logs/<slug>/circuit-breaker.json`
692
- - `.claude/ralph-desk/logs/<slug>/runtime/session-config.json`
693
- - `.claude/ralph-desk/logs/<slug>/runtime/worker-heartbeat.json`
694
- - `.claude/ralph-desk/logs/<slug>/runtime/verifier-heartbeat.json`
695
- - `.claude/ralph-desk/memos/<slug>-escalation.md`
686
+ - `.rlp-desk/memos/<slug>-complete.md`
687
+ - `.rlp-desk/memos/<slug>-blocked.md`
688
+ - `.rlp-desk/memos/<slug>-done-claim.json`
689
+ - `.rlp-desk/memos/<slug>-verify-verdict.json`
690
+ - `.rlp-desk/memos/<slug>-iter-signal.json`
691
+ - `.rlp-desk/logs/<slug>/circuit-breaker.json`
692
+ - `.rlp-desk/logs/<slug>/runtime/session-config.json`
693
+ - `.rlp-desk/logs/<slug>/runtime/worker-heartbeat.json`
694
+ - `.rlp-desk/logs/<slug>/runtime/verifier-heartbeat.json`
695
+ - `.rlp-desk/memos/<slug>-escalation.md`
696
696
  Note: `campaign-report.md`, `campaign-report-v{N}.md`, `iter-NNN-done-claim.json`, and `iter-NNN-verify-verdict.json` are intentionally preserved across clean for historical comparison. Analytics files (`debug.log`, `campaign.jsonl`, `self-verification-data.json`, `self-verification-report-NNN.md`) at `~/.claude/ralph-desk/analytics/<slug>/` are NOT affected by project-level clean.
697
697
 
698
698
  If `--kill-session` is passed, clean up Worker/Verifier tmux panes using session-config.json:
699
699
  ```bash
700
700
  # Read pane IDs from session-config.json (safe — targets only Worker/Verifier panes)
701
- SESSION_CONFIG=".claude/ralph-desk/logs/<slug>/runtime/session-config.json"
701
+ SESSION_CONFIG=".rlp-desk/logs/<slug>/runtime/session-config.json"
702
702
  if [ -f "$SESSION_CONFIG" ] && command -v jq &>/dev/null; then
703
703
  WORKER_PANE=$(jq -r '.panes.worker // empty' "$SESSION_CONFIG")
704
704
  VERIFIER_PANE=$(jq -r '.panes.verifier // empty' "$SESSION_CONFIG")
@@ -738,8 +738,8 @@ Data sources:
738
738
 
739
739
  Resume a previously interrupted campaign. Equivalent to `run <slug>` but explicitly restores state:
740
740
 
741
- 1. Read `.claude/ralph-desk/logs/<slug>/runtime/status.json` for `verified_us`, `iteration`, `consecutive_failures`
742
- 2. Read `.claude/ralph-desk/memos/<slug>-memory.md` for completed stories and next iteration contract
741
+ 1. Read `.rlp-desk/logs/<slug>/runtime/status.json` for `verified_us`, `iteration`, `consecutive_failures`
742
+ 2. Read `.rlp-desk/memos/<slug>-memory.md` for completed stories and next iteration contract
743
743
  3. Check for sentinels (`complete.md`, `blocked.md`) — if present, inform user and stop
744
744
  4. If no sentinels, invoke `run <slug>` with the same options from the previous session (stored in status.json fields: `worker_model`, `verifier_model`, `final_verifier_model`, `verify_mode`, `consensus_mode`)
745
745
  5. The runner automatically restores `verified_us` from memory or status.json on startup
package/src/governance.md CHANGED
@@ -509,7 +509,7 @@ Characteristics:
509
509
 
510
510
  ### Project-local
511
511
  ```
512
- .claude/ralph-desk/
512
+ .rlp-desk/
513
513
  ├── prompts/
514
514
  │ ├── <slug>.worker.prompt.md # Worker base prompt (regenerated on re-execution)
515
515
  │ └── <slug>.verifier.prompt.md # Verifier base prompt (regenerated on re-execution)
@@ -5,6 +5,24 @@ const CLAUDE_BIN = 'claude';
5
5
  const CODEX_BIN = 'codex';
6
6
  const CLAUDE_MODELS = new Set(['haiku', 'sonnet', 'opus']);
7
7
 
8
+ // v0.13.0: surface engine classification for tmux+claude warning + observability.
9
+ export function isClaudeEngine(modelFlag) {
10
+ if (typeof modelFlag !== 'string' || modelFlag.length === 0) {
11
+ return false;
12
+ }
13
+
14
+ const head = modelFlag.split(':', 1)[0];
15
+ if (!head) {
16
+ return false;
17
+ }
18
+
19
+ if (CLAUDE_MODELS.has(head)) {
20
+ return true;
21
+ }
22
+
23
+ return head.startsWith('claude-');
24
+ }
25
+
8
26
  function assertTuiMode(mode, builderName) {
9
27
  if (mode !== 'tui') {
10
28
  throw new Error(`${builderName} unknown mode '${mode}'`);
@@ -1,8 +1,79 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
5
+ import { LEGACY_DESK_REL, resolveDeskRoot } from '../util/desk-root.mjs';
6
+
4
7
  const GITIGNORE_MARKER = '# RLP Desk runtime artifacts';
5
- const GITIGNORE_RULE = '.claude/ralph-desk/';
8
+ const GITIGNORE_RULE = '.rlp-desk/';
9
+ const LEGACY_GITIGNORE_RULE = '.claude/ralph-desk/';
10
+ const MIGRATION_LOCK_FILE = '.rlp-desk-migration.lock';
11
+ const STALE_LOCK_MS = 5 * 60 * 1000;
12
+
13
+ export function migrateLegacyDesk(rootDir, env = process.env) {
14
+ const legacyPath = path.join(rootDir, LEGACY_DESK_REL);
15
+ const newPath = resolveDeskRoot(rootDir, env);
16
+ const lockPath = path.join(rootDir, MIGRATION_LOCK_FILE);
17
+
18
+ // Pre-lock cheap check: skip the lock entirely when there is nothing to do.
19
+ // Re-check the same conditions inside the lock — a competing process may
20
+ // have moved or created files between this check and the lock acquisition.
21
+ if (!fsSync.existsSync(legacyPath)) {
22
+ return { action: 'noop', reason: fsSync.existsSync(newPath) ? 'new-only' : 'neither-exists' };
23
+ }
24
+
25
+ let lockFd;
26
+ try {
27
+ lockFd = fsSync.openSync(lockPath, 'wx');
28
+ } catch (error) {
29
+ if (error.code === 'EEXIST') {
30
+ try {
31
+ const stats = fsSync.statSync(lockPath);
32
+ const age = Date.now() - stats.mtimeMs;
33
+ if (age > STALE_LOCK_MS) {
34
+ fsSync.unlinkSync(lockPath);
35
+ lockFd = fsSync.openSync(lockPath, 'wx');
36
+ } else {
37
+ throw new Error(`Migration already in progress (lock at ${lockPath}, age ${Math.round(age / 1000)}s)`);
38
+ }
39
+ } catch (statError) {
40
+ if (statError.code === 'ENOENT') {
41
+ lockFd = fsSync.openSync(lockPath, 'wx');
42
+ } else {
43
+ throw statError;
44
+ }
45
+ }
46
+ } else {
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ try {
52
+ fsSync.writeSync(lockFd, String(process.pid));
53
+
54
+ // Re-check inside the lock — another process may have already migrated
55
+ // while we were waiting for the lock.
56
+ const legacyExistsLocked = fsSync.existsSync(legacyPath);
57
+ const newExistsLocked = fsSync.existsSync(newPath);
58
+
59
+ if (!legacyExistsLocked) {
60
+ return { action: 'noop', reason: newExistsLocked ? 'new-only' : 'neither-exists' };
61
+ }
62
+
63
+ if (newExistsLocked) {
64
+ throw new Error(
65
+ `Migration aborted: both directories exist. Remove one before re-run. legacy=${legacyPath}, new=${newPath}`,
66
+ );
67
+ }
68
+
69
+ fsSync.mkdirSync(path.dirname(newPath), { recursive: true });
70
+ fsSync.renameSync(legacyPath, newPath);
71
+ return { action: 'migrated', from: legacyPath, to: newPath };
72
+ } finally {
73
+ try { fsSync.closeSync(lockFd); } catch (_) { /* noop */ }
74
+ try { fsSync.unlinkSync(lockPath); } catch (_) { /* noop */ }
75
+ }
76
+ }
6
77
 
7
78
  export async function initCampaign(slug, objective, options = {}) {
8
79
  const normalizedSlug = normalizeSlug(slug);
@@ -10,17 +81,21 @@ export async function initCampaign(slug, objective, options = {}) {
10
81
  const mode = options.mode ?? 'agent';
11
82
  const rootDir = path.resolve(options.rootDir ?? process.cwd());
12
83
  const tmuxEnv = options.tmuxEnv ?? process.env.TMUX ?? '';
13
- const deskRoot = path.join(rootDir, '.claude', 'ralph-desk');
84
+ const env = options.env ?? process.env;
14
85
 
15
86
  if (mode === 'tmux' && !tmuxEnv) {
16
87
  throw new Error('tmux required');
17
88
  }
18
89
 
90
+ migrateLegacyDesk(rootDir, env);
91
+
92
+ const deskRoot = resolveDeskRoot(rootDir, env);
93
+
19
94
  if (mode === 'fresh') {
20
95
  await fs.rm(deskRoot, { recursive: true, force: true });
21
96
  }
22
97
 
23
- const paths = buildPaths(rootDir, normalizedSlug);
98
+ const paths = buildPaths(rootDir, normalizedSlug, env);
24
99
  await ensureDirectories(paths);
25
100
  await ensureGitignore(rootDir);
26
101
 
@@ -55,8 +130,8 @@ function normalizeSlug(value) {
55
130
  return slug;
56
131
  }
57
132
 
58
- function buildPaths(rootDir, slug) {
59
- const deskRoot = path.join(rootDir, '.claude', 'ralph-desk');
133
+ function buildPaths(rootDir, slug, env = process.env) {
134
+ const deskRoot = resolveDeskRoot(rootDir, env);
60
135
  const promptsDir = path.join(deskRoot, 'prompts');
61
136
  const plansDir = path.join(deskRoot, 'plans');
62
137
  const memosDir = path.join(deskRoot, 'memos');
@@ -105,13 +180,28 @@ async function ensureGitignore(rootDir) {
105
180
  }
106
181
  }
107
182
 
108
- if (content.includes(GITIGNORE_MARKER) && content.includes(GITIGNORE_RULE)) {
109
- return;
183
+ let updated = content;
184
+ let changed = false;
185
+
186
+ // v0.13.0: drop the legacy .claude/ralph-desk/ rule if present.
187
+ if (updated.includes(LEGACY_GITIGNORE_RULE)) {
188
+ const legacyLineRegex = new RegExp(
189
+ `^${LEGACY_GITIGNORE_RULE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\r?\\n`,
190
+ 'gm',
191
+ );
192
+ updated = updated.replace(legacyLineRegex, '');
193
+ changed = true;
110
194
  }
111
195
 
112
- const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
113
- const block = `${prefix}${GITIGNORE_MARKER}\n${GITIGNORE_RULE}\n`;
114
- await fs.writeFile(gitignorePath, `${content}${block}`, 'utf8');
196
+ if (!(updated.includes(GITIGNORE_MARKER) && updated.includes(GITIGNORE_RULE))) {
197
+ const prefix = updated.length > 0 && !updated.endsWith('\n') ? '\n' : '';
198
+ updated = `${updated}${prefix}${GITIGNORE_MARKER}\n${GITIGNORE_RULE}\n`;
199
+ changed = true;
200
+ }
201
+
202
+ if (changed) {
203
+ await fs.writeFile(gitignorePath, updated, 'utf8');
204
+ }
115
205
  }
116
206
 
117
207
  async function writeIfMissing(targetPath, content) {
@@ -143,6 +143,26 @@ export async function pollForSignal(
143
143
  // v5.7 §4.17 (Node parity): default-No prompts must NOT be auto-Entered;
144
144
  // they raise a PromptBlockedError so the caller writes BLOCKED and aborts.
145
145
  if (paneId) {
146
+ // v0.13.0: detect Claude Code self-modification permission prompts in
147
+ // pane stdout BEFORE attempting auto-dismiss. These cannot be dismissed
148
+ // by --dangerously-skip-permissions and would otherwise hang the worker
149
+ // for the full pollForSignal timeout.
150
+ try {
151
+ const paneContent = await capturePane(paneId);
152
+ const { detectPermissionPrompt } = await import('../runner/prompt-detector.mjs');
153
+ if (detectPermissionPrompt(paneContent)) {
154
+ throw new PromptBlockedError(
155
+ `Permission prompt detected on pane ${paneId} (Claude Code self-modification gate)`,
156
+ { paneId, category: 'permission_prompt', snippet: paneContent.split(/\r?\n/).slice(-10).join('\n') },
157
+ );
158
+ }
159
+ } catch (err) {
160
+ if (err instanceof PromptBlockedError) {
161
+ throw err;
162
+ }
163
+ // capture failure is non-fatal; fall through to auto-dismiss path.
164
+ }
165
+
146
166
  await autoDismissPrompts(paneId, {
147
167
  capturePane,
148
168
  sendKeys,
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
 
6
+ import { resolveDeskRoot } from '../util/desk-root.mjs';
7
+
6
8
  const execFileAsync = promisify(execFile);
7
9
  const REQUIRED_ANALYTICS_FIELDS = [
8
10
  'iter',
@@ -596,7 +598,9 @@ export async function generateSVReport({
596
598
 
597
599
  export async function readStatus(slug, options = {}) {
598
600
  const rootDir = path.resolve(options.rootDir ?? process.cwd());
599
- const statusFile = path.join(rootDir, '.claude', 'ralph-desk', 'logs', slug, 'runtime', 'status.json');
601
+ const env = options.env ?? process.env;
602
+ const deskRoot = resolveDeskRoot(rootDir, env);
603
+ const statusFile = path.join(deskRoot, 'logs', slug, 'runtime', 'status.json');
600
604
 
601
605
  if (!(await exists(statusFile))) {
602
606
  return `No active campaign for ${slug}.`;
package/src/node/run.mjs CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { initCampaign } from './init/campaign-initializer.mjs';
5
5
  import { readStatus } from './reporting/campaign-reporting.mjs';
6
6
  import { run as runCampaignMain } from './runner/campaign-main-loop.mjs';
7
+ import { isClaudeEngine } from './cli/command-builder.mjs';
7
8
 
8
9
  const RUN_DEFAULTS = {
9
10
  mode: 'agent',
@@ -194,8 +195,9 @@ async function runInit(args, deps) {
194
195
 
195
196
  const slug = args[0];
196
197
  const objective = args.slice(1).join(' ').trim() || 'TBD - fill in the objective';
197
- await deps.initCampaign(slug, objective, { rootDir: deps.cwd });
198
- write(deps.stdout, `Initialized ${slug} in ${path.join(deps.cwd, '.claude', 'ralph-desk')}`);
198
+ const result = await deps.initCampaign(slug, objective, { rootDir: deps.cwd });
199
+ const deskRoot = result?.paths?.deskRoot ?? path.join(deps.cwd, '.rlp-desk');
200
+ write(deps.stdout, `Initialized ${slug} in ${deskRoot}`);
199
201
  return 0;
200
202
  }
201
203
 
@@ -221,6 +223,33 @@ async function runRunCommand(args, deps) {
221
223
 
222
224
  const slug = args[0];
223
225
  const options = parseRunOptions(args.slice(1), deps.cwd);
226
+
227
+ // v0.13.0: warn when Claude worker runs in tmux mode. Claude Code's
228
+ // hardcoded sensitive policy used to hang sentinel writes inside
229
+ // <project>/.claude/. After v0.13.0, sentinels live in
230
+ // <project>/.rlp-desk/, but if the user pinned RLP_DESK_RUNTIME_DIR
231
+ // back inside .claude/, the hang can return — surface the warning so
232
+ // they can switch to gpt-5.5:* or --mode agent quickly.
233
+ if (
234
+ !process.env.RLP_DESK_QUIET_WARNINGS
235
+ && process.env.NODE_ENV !== 'test'
236
+ && options.mode === 'tmux'
237
+ && isClaudeEngine(options.workerModel)
238
+ ) {
239
+ write(
240
+ deps.stderr,
241
+ 'WARNING: Claude worker in tmux mode may hang on .claude/ sentinel writes.',
242
+ );
243
+ write(
244
+ deps.stderr,
245
+ 'After v0.13.0, sentinels live in <project>/.rlp-desk/ which avoids this.',
246
+ );
247
+ write(
248
+ deps.stderr,
249
+ 'If hang persists, switch to --worker-model gpt-5.5:high (codex) or --mode agent.',
250
+ );
251
+ }
252
+
224
253
  const result = await deps.runCampaign(slug, options);
225
254
  // governance §1f BLOCKED Surfacing: surface the blocked reason on stderr so
226
255
  // the operator (or wrapper script) does not have to grep memo files.
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { execFile } from 'node:child_process';
@@ -8,6 +9,7 @@ import { buildClaudeCmd, buildCodexCmd, parseModelFlag } from '../cli/command-bu
8
9
  import { shellQuote } from '../util/shell-quote.mjs';
9
10
  import { OPUS_1M_BETA, isOpusModel } from '../constants.mjs';
10
11
  import { initCampaign } from '../init/campaign-initializer.mjs';
12
+ import { LEGACY_DESK_REL, resolveDeskRoot } from '../util/desk-root.mjs';
11
13
  import { writeSentinelExclusive } from '../shared/fs.mjs';
12
14
  import {
13
15
  TimeoutError,
@@ -41,8 +43,23 @@ const MODEL_UPGRADES = {
41
43
  'gpt-5.3-codex-spark:xhigh': 'BLOCKED',
42
44
  };
43
45
 
44
- function buildPaths(rootDir, slug) {
45
- const deskRoot = path.join(rootDir, '.claude', 'ralph-desk');
46
+ // v0.13.0: legacy .claude/ralph-desk/ guidance for run mode (no auto-mv).
47
+ export function detectLegacyDeskInRunMode(rootDir, env = process.env) {
48
+ const legacyPath = path.join(rootDir, LEGACY_DESK_REL);
49
+ if (!fsSync.existsSync(legacyPath)) {
50
+ return null;
51
+ }
52
+
53
+ const newPath = resolveDeskRoot(rootDir, env);
54
+ const newRel = path.relative(rootDir, newPath) || path.basename(newPath);
55
+ const message =
56
+ `Legacy ${LEGACY_DESK_REL}/ detected. Run mode does not auto-migrate to protect in-flight campaigns. ` +
57
+ `Run: mv ${LEGACY_DESK_REL} ${newRel} then re-run.`;
58
+ return { legacyPath, newPath, message };
59
+ }
60
+
61
+ function buildPaths(rootDir, slug, env = process.env) {
62
+ const deskRoot = resolveDeskRoot(rootDir, env);
46
63
  const campaignLogDir = path.join(deskRoot, 'logs', slug);
47
64
 
48
65
  return {
@@ -448,6 +465,10 @@ export const BLOCK_TAGS = Object.freeze({
448
465
  GUARD_EXITED: 'guard_pane_exited_without_artifacts',
449
466
  // Auto-Enter unsafe (default-No prompt)
450
467
  PROMPT_BLOCKED: 'prompt_blocked',
468
+ // v0.13.0: Claude Code self-modification permission prompt (cannot be
469
+ // dismissed by --dangerously-skip-permissions). Surfaced separately so
470
+ // wrappers know to switch worker engine, not retry.
471
+ PERMISSION_PROMPT: 'permission_prompt',
451
472
  // Persistent timeout without exit (different from EXITED)
452
473
  WORKER_TIMEOUT: 'worker_timeout',
453
474
  VERIFIER_TIMEOUT: 'verifier_timeout',
@@ -509,6 +530,13 @@ function _classifyBlock(source, { verdict, state, slug } = {}) {
509
530
  action = 'manual_prompt_response';
510
531
  failureCategory = 'prompt_blocked';
511
532
  break;
533
+ // v0.13.0: Claude Code self-modification gate — switch worker engine.
534
+ case BLOCK_TAGS.PERMISSION_PROMPT:
535
+ category = 'infra_failure';
536
+ recoverable = false;
537
+ action = 'switch_worker_to_codex_or_use_agent_mode';
538
+ failureCategory = 'permission_prompt';
539
+ break;
512
540
  // Persistent timeout (no exit detected) — different from EXITED.
513
541
  case BLOCK_TAGS.WORKER_TIMEOUT:
514
542
  case BLOCK_TAGS.VERIFIER_TIMEOUT:
@@ -582,8 +610,16 @@ async function _handlePollFailure(error, ctx) {
582
610
  })[role] ?? BLOCK_TAGS.WORKER_EXITED;
583
611
  reason = `${error.reason ?? 'pane exited without artifacts'}: ${error.message}`;
584
612
  } else if (error instanceof PromptBlockedError) {
585
- tag = BLOCK_TAGS.PROMPT_BLOCKED;
586
- reason = `${error.reason ?? 'default-No prompt'}: ${error.message}`;
613
+ // v0.13.0: error.category is set by signal-poller when Claude Code
614
+ // self-modification prompt is detected. Distinct tag drives a different
615
+ // failure_category + suggested_action than the default-No prompt path.
616
+ if (error.category === 'permission_prompt') {
617
+ tag = BLOCK_TAGS.PERMISSION_PROMPT;
618
+ reason = `${error.reason ?? 'permission prompt'}: ${error.message}`;
619
+ } else {
620
+ tag = BLOCK_TAGS.PROMPT_BLOCKED;
621
+ reason = `${error.reason ?? 'default-No prompt'}: ${error.message}`;
622
+ }
587
623
  } else if (error instanceof MalformedArtifactError) {
588
624
  tag = BLOCK_TAGS.MALFORMED_ARTIFACT;
589
625
  reason = `Malformed artifact at ${error.field}: expected ${error.expected}, got ${error.got}`;
@@ -896,7 +932,19 @@ export function shouldRunGuard(flywheelGuard, state, usId) {
896
932
 
897
933
  export async function run(slug, options = {}) {
898
934
  const rootDir = path.resolve(options.rootDir ?? process.cwd());
899
- const paths = buildPaths(rootDir, slug);
935
+ const env = options.env ?? process.env;
936
+
937
+ // v0.13.0: refuse to run when legacy .claude/ralph-desk/ is present.
938
+ // init mode auto-migrates; run mode protects in-flight campaigns and
939
+ // surfaces a clear manual command to the operator.
940
+ const legacy = detectLegacyDeskInRunMode(rootDir, env);
941
+ if (legacy) {
942
+ const err = new Error(legacy.message);
943
+ err.code = 'LEGACY_DESK_DETECTED';
944
+ throw err;
945
+ }
946
+
947
+ const paths = buildPaths(rootDir, slug, env);
900
948
  // v5.7 §4.24 §1g — runtime invariant: every terminal exit of run() MUST
901
949
  // leave exactly one sentinel on disk (blocked.md XOR complete.md). The
902
950
  // try/finally below is the last-resort backstop that writes a synthetic
@@ -0,0 +1,41 @@
1
+ // v0.13.0: early-detect Claude Code permission prompts in worker stdout.
2
+ // Pre-v0.13.0 the leader only noticed via 30-min pollForSignal timeout, which
3
+ // hid the failure category. Now we surface BLOCKED with category=permission_prompt
4
+ // within seconds so wrappers can react.
5
+
6
+ const SIGNATURES = [
7
+ /Do you want to /,
8
+ /\u276F\s*1\.\s*Yes/,
9
+ /allow Claude to edit its own settings/,
10
+ /1\.\s*Yes(?:,?\s*and allow Claude)/,
11
+ ];
12
+
13
+ export function detectPermissionPrompt(chunk) {
14
+ if (typeof chunk !== 'string' || chunk.length === 0) {
15
+ return false;
16
+ }
17
+
18
+ for (const pattern of SIGNATURES) {
19
+ if (pattern.test(chunk)) {
20
+ return true;
21
+ }
22
+ }
23
+ return false;
24
+ }
25
+
26
+ export const PERMISSION_PROMPT_CATEGORY = 'permission_prompt';
27
+
28
+ export function buildPermissionPromptBlocked(slug, iteration, snippet) {
29
+ const trimmedSnippet = typeof snippet === 'string'
30
+ ? snippet.split(/\r?\n/).slice(0, 5).join('\n').slice(0, 600)
31
+ : '';
32
+ return {
33
+ slug,
34
+ iteration: iteration ?? 0,
35
+ reason_category: 'infra_failure',
36
+ failure_category: PERMISSION_PROMPT_CATEGORY,
37
+ recoverable: false,
38
+ suggested_action: 'switch_worker_to_codex_or_use_agent_mode',
39
+ evidence_snippet: trimmedSnippet,
40
+ };
41
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+
3
+ export const DEFAULT_DESK_REL = '.rlp-desk';
4
+ export const LEGACY_DESK_REL = path.join('.claude', 'ralph-desk');
5
+
6
+ export function resolveDeskRoot(rootDir, env = process.env) {
7
+ const override = (env && typeof env.RLP_DESK_RUNTIME_DIR === 'string') ? env.RLP_DESK_RUNTIME_DIR : '';
8
+ const trimmed = override.trim();
9
+
10
+ if (!trimmed) {
11
+ return path.join(rootDir, DEFAULT_DESK_REL);
12
+ }
13
+
14
+ if (path.isAbsolute(trimmed)) {
15
+ throw new Error('RLP_DESK_RUNTIME_DIR must be relative to project root, not absolute');
16
+ }
17
+
18
+ const segments = trimmed.split(/[\\/]/);
19
+ if (segments.includes('..')) {
20
+ throw new Error('RLP_DESK_RUNTIME_DIR must not contain parent traversal (..)');
21
+ }
22
+
23
+ return path.join(rootDir, trimmed);
24
+ }