@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.
- package/docs/plans/spicy-booping-galaxy.md +322 -0
- package/package.json +2 -2
- package/src/commands/rlp-desk.md +25 -25
- package/src/governance.md +1 -1
- package/src/node/cli/command-builder.mjs +18 -0
- package/src/node/init/campaign-initializer.mjs +100 -10
- package/src/node/polling/signal-poller.mjs +20 -0
- package/src/node/reporting/campaign-reporting.mjs +5 -1
- package/src/node/run.mjs +31 -2
- package/src/node/runner/campaign-main-loop.mjs +53 -5
- package/src/node/runner/prompt-detector.mjs +41 -0
- package/src/node/util/desk-root.mjs +24 -0
|
@@ -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.
|
|
4
|
-
"description": "Fresh-context iterative loops for Claude Code
|
|
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",
|
package/src/commands/rlp-desk.md
CHANGED
|
@@ -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 `.
|
|
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 `.
|
|
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: `.
|
|
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 `.
|
|
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 .
|
|
382
|
-
test -f .
|
|
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 .
|
|
388
|
-
rm -f .
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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
|
-
- `.
|
|
687
|
-
- `.
|
|
688
|
-
- `.
|
|
689
|
-
- `.
|
|
690
|
-
- `.
|
|
691
|
-
- `.
|
|
692
|
-
- `.
|
|
693
|
-
- `.
|
|
694
|
-
- `.
|
|
695
|
-
- `.
|
|
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=".
|
|
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 `.
|
|
742
|
-
2. Read `.
|
|
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
|
-
.
|
|
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 = '.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
|
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
|
+
}
|