therookie 0.4.3

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/bin/rookie.js +6 -0
  4. package/package.json +40 -0
  5. package/src/api.js +67 -0
  6. package/src/cli.js +139 -0
  7. package/src/commands/cluster-todos.js +74 -0
  8. package/src/commands/codex-setup.js +36 -0
  9. package/src/commands/context-diet.js +80 -0
  10. package/src/commands/doctor.js +710 -0
  11. package/src/commands/done-scan.js +162 -0
  12. package/src/commands/init.js +175 -0
  13. package/src/commands/link.js +53 -0
  14. package/src/commands/login.js +160 -0
  15. package/src/commands/orphan-extract.js +44 -0
  16. package/src/commands/save.js +91 -0
  17. package/src/commands/scan-specs.js +153 -0
  18. package/src/commands/status.js +44 -0
  19. package/src/commands/sweep-recovery.js +33 -0
  20. package/src/commands/token.js +105 -0
  21. package/src/commands/unlink.js +40 -0
  22. package/src/commands/update.js +220 -0
  23. package/src/config.js +65 -0
  24. package/src/detect.js +109 -0
  25. package/src/lib/account-ui.js +28 -0
  26. package/src/lib/apply-tool-setup.js +89 -0
  27. package/src/lib/codex-config.js +46 -0
  28. package/src/lib/connect-ui.js +59 -0
  29. package/src/lib/context-diet.js +628 -0
  30. package/src/lib/dead-letter.js +73 -0
  31. package/src/lib/deploy-artifacts.js +185 -0
  32. package/src/lib/hook-command.js +31 -0
  33. package/src/lib/manifest.js +87 -0
  34. package/src/lib/merge-hooks.js +31 -0
  35. package/src/lib/orphan-extract-core.js +113 -0
  36. package/src/lib/orphan-pending.js +49 -0
  37. package/src/lib/parse-hook-cwd.js +11 -0
  38. package/src/lib/poll-state.js +14 -0
  39. package/src/lib/redact-lite.js +55 -0
  40. package/src/lib/resolve-endpoint.js +8 -0
  41. package/src/lib/self-update.js +79 -0
  42. package/src/lib/skill-installer.js +55 -0
  43. package/src/lib/spec-scanner.js +85 -0
  44. package/src/lib/sweep-core.mjs +423 -0
  45. package/src/lib/task-path-match.js +122 -0
  46. package/src/prompt.js +60 -0
  47. package/templates/hook/bin/posttool-recall-nudge.sh +96 -0
  48. package/templates/hook/bin/pretool-md-read.sh +107 -0
  49. package/templates/hook/flush-session.sh +314 -0
  50. package/templates/hook/guard-destructive-db.sh +24 -0
  51. package/templates/hook/lib/config-resolve.sh +106 -0
  52. package/templates/hook/lib/cross-runtime-sweep.sh +202 -0
  53. package/templates/hook/lib/dispatcher.sh +423 -0
  54. package/templates/hook/lib/orphan-recovery.sh +491 -0
  55. package/templates/hook/lib/queue.sh +314 -0
  56. package/templates/hook/lib/skill-gate.sh +28 -0
  57. package/templates/hook/lib/transcript.sh +166 -0
  58. package/templates/hook/refresh-context.sh +163 -0
  59. package/templates/hook/session-start.sh +417 -0
  60. package/templates/hook/settings.partial.json +55 -0
  61. package/templates/hook/sweep-recovery.sh +43 -0
  62. package/templates/hook/user-prompt-skill-gate.sh +90 -0
  63. package/templates/launchd/com.rollingmango.rookie-sweep.plist +38 -0
  64. package/templates/skill/SKILL.md +214 -0
  65. package/version.json +4 -0
@@ -0,0 +1,314 @@
1
+ #!/bin/bash
2
+ # Stop hook — 매 assistant 응답 종료마다 Rookie 앱으로 transcript 플러시.
3
+ # 입력: stdin JSON with session_id (Claude Code Stop hook 계약).
4
+ # spec §17 (2026-04-30): silent failure 차단 + 로컬 큐 + 회복 flush.
5
+
6
+ set -e
7
+
8
+ # self-reference 방지(spec O-4) 1차 방어선:
9
+ # 차차 internal LLM 호출(`claude -p` from cli-provider)에서 발사된 hook은 즉시 종료.
10
+ if [ "$ROOKIE_INTERNAL_INVOCATION" = "1" ]; then
11
+ exit 0
12
+ fi
13
+
14
+ # §17 큐 라이브러리 로드 (best-effort — 미발견 시에도 hook 자체는 동작)
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+
17
+ # B6-1 (spec 2026-07-11 §B6): config 해석 — env > 프로젝트-로컬(.rookie/, cwd 상향) > 전역.
18
+ # Stop hook 은 프로젝트 cwd 에서 발사되므로 $PWD 기준. config-resolve.sh 미배포(구설치)면 전역 폴백.
19
+ if [ -f "$SCRIPT_DIR/lib/config-resolve.sh" ]; then
20
+ # shellcheck source=/dev/null
21
+ . "$SCRIPT_DIR/lib/config-resolve.sh"
22
+ elif [ -f "$HOME/.rookie/bin/lib/config-resolve.sh" ]; then
23
+ # shellcheck source=/dev/null
24
+ . "$HOME/.rookie/bin/lib/config-resolve.sh"
25
+ fi
26
+ if command -v rookie_resolve_credentials >/dev/null 2>&1; then
27
+ rookie_resolve_credentials "$PWD" || exit 0
28
+ ENDPOINT="$ROOKIE_RESOLVED_ENDPOINT"
29
+ TOKEN="$ROOKIE_RESOLVED_TOKEN"
30
+ USER_ID="$ROOKIE_RESOLVED_USER_ID"
31
+ else
32
+ CONFIG="$HOME/.rookie/config.json"
33
+ if [ ! -f "$CONFIG" ]; then
34
+ exit 0
35
+ fi
36
+ ENDPOINT=$(node -e "console.log(require('$CONFIG').endpoint)" 2>/dev/null || echo "http://localhost:3000")
37
+ TOKEN=$(node -e "console.log(require('$CONFIG').token)" 2>/dev/null || echo "")
38
+ USER_ID=$(node -e "console.log(require('$CONFIG').user_id || '')" 2>/dev/null || echo "")
39
+ fi
40
+ if [ -z "$TOKEN" ]; then
41
+ exit 0
42
+ fi
43
+ if [ -f "$SCRIPT_DIR/lib/queue.sh" ]; then
44
+ # shellcheck source=/dev/null
45
+ . "$SCRIPT_DIR/lib/queue.sh"
46
+ elif [ -f "$HOME/.rookie/bin/lib/queue.sh" ]; then
47
+ # shellcheck source=/dev/null
48
+ . "$HOME/.rookie/bin/lib/queue.sh"
49
+ fi
50
+
51
+ # §17.2 H-2.1/H-2.2 dispatcher 라이브러리 로드 (queue.sh 보다 뒤 — _safe_enqueue 가 queue_enqueue 의존)
52
+ if [ -f "$SCRIPT_DIR/lib/dispatcher.sh" ]; then
53
+ # shellcheck source=/dev/null
54
+ . "$SCRIPT_DIR/lib/dispatcher.sh"
55
+ elif [ -f "$HOME/.rookie/bin/lib/dispatcher.sh" ]; then
56
+ # shellcheck source=/dev/null
57
+ . "$HOME/.rookie/bin/lib/dispatcher.sh"
58
+ fi
59
+
60
+ # spec 2026-06-18 crash-recovery: transcript 변환 공유 helper + orphan marker.
61
+ # transcript.sh(§3.8 messages 추출 단일화) → orphan-recovery.sh(content-bearing marker).
62
+ for _lib in transcript.sh orphan-recovery.sh; do
63
+ if [ -f "$SCRIPT_DIR/lib/$_lib" ]; then
64
+ # shellcheck source=/dev/null
65
+ . "$SCRIPT_DIR/lib/$_lib"
66
+ elif [ -f "$HOME/.rookie/bin/lib/$_lib" ]; then
67
+ # shellcheck source=/dev/null
68
+ . "$HOME/.rookie/bin/lib/$_lib"
69
+ fi
70
+ done
71
+
72
+ STDIN_BODY=$(cat)
73
+
74
+ SESSION_ID=$(echo "$STDIN_BODY" | node -e "
75
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
76
+ try { const j = JSON.parse(d); console.log(j.session_id || j.sessionId || ''); } catch { console.log(''); }
77
+ })
78
+ ")
79
+
80
+ if [ -z "$SESSION_ID" ]; then
81
+ exit 0
82
+ fi
83
+
84
+ # B6-3 (spec 2026-07-11 §B6): 세션 identity pinning 비교 — SessionStart 가 기록한 유효 토큰
85
+ # hash 와 현재 토큰이 다르면(세션 도중 계정 전환) flush 를 중단하고 경고 로그를 남긴다.
86
+ # 진행 중 세션의 기억이 새 계정으로 오귀속되는 교차 오염 차단. pin 없음(구세션)은 통과.
87
+ if command -v identity_pin_check >/dev/null 2>&1; then
88
+ if ! identity_pin_check "$SESSION_ID" "$TOKEN"; then
89
+ identity_pin_log_mismatch "$SESSION_ID" "stop" || true
90
+ exit 0
91
+ fi
92
+ fi
93
+
94
+ TRANSCRIPT_PATH=$(echo "$STDIN_BODY" | node -e "
95
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
96
+ try { const j = JSON.parse(d); console.log(j.transcript_path || ''); } catch { console.log(''); }
97
+ })
98
+ ")
99
+
100
+ # self-reference 방지(spec O-4) 2차 방어선:
101
+ # env 마커가 끊어진 경우라도 transcript 첫 4KB에 fact 추출 system prompt 시그니처가 있으면 skip.
102
+ # 시그니처 = src/lib/context/fact-extraction.ts FACT_EXTRACTION_MARKER. (lib/transcript.sh 공유)
103
+ if command -v transcript_has_self_reference >/dev/null 2>&1; then
104
+ if transcript_has_self_reference "$TRANSCRIPT_PATH"; then
105
+ exit 0
106
+ fi
107
+ elif [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
108
+ if head -c 4096 "$TRANSCRIPT_PATH" | grep -q "the Rookie의 기억 정리 모듈"; then
109
+ exit 0
110
+ fi
111
+ fi
112
+
113
+ REPO_URL=""
114
+ if command -v git >/dev/null; then
115
+ REPO_URL=$(git remote get-url origin 2>/dev/null || echo "")
116
+ fi
117
+ REPO_PATH="$PWD"
118
+ HOST=$(hostname)
119
+
120
+ # spec §27 D-Multimac-1: 멀티 mac 운영에서 Rookie 서버는 hook 발사 mac 의 transcript file 을
121
+ # read 할 수 없다. hook 측에서 jsonl 을 직접 read → messages array → POST body 본문 송신.
122
+ # turn limit 200 (D-Multimac-3). spec 2026-06-18 §3.8: 추출 로직을 lib/transcript.sh 로 단일화
123
+ # (session-start.sh orphan recovery 와 동일 구현 보장). helper 미로드 시 인라인 폴백 유지.
124
+ MESSAGES_JSON='[]'
125
+ if command -v transcript_messages_json >/dev/null 2>&1; then
126
+ MESSAGES_JSON=$(transcript_messages_json "$TRANSCRIPT_PATH")
127
+ elif [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
128
+ MESSAGES_JSON=$(node -e "
129
+ const fs = require('node:fs');
130
+ const path = process.argv[1];
131
+ if (!path || !fs.existsSync(path)) { console.log('[]'); process.exit(0); }
132
+ let raw;
133
+ try { raw = fs.readFileSync(path, 'utf8'); } catch { console.log('[]'); process.exit(0); }
134
+ const out = [];
135
+ for (const line of raw.split('\n')) {
136
+ const t = line.trim(); if (!t) continue;
137
+ try {
138
+ const j = JSON.parse(t);
139
+ const m = j.message || j;
140
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
141
+ let c = m.content;
142
+ if (typeof c !== 'string') {
143
+ if (Array.isArray(c)) {
144
+ c = c.filter(b => b && b.type === 'text' && typeof b.text === 'string')
145
+ .map(b => b.text).join('\n').trim();
146
+ } else { c = ''; }
147
+ } else { c = c.trim(); }
148
+ if (!c) continue;
149
+ out.push({ role: m.role, content: c });
150
+ } catch {}
151
+ }
152
+ const TURN_LIMIT = 200;
153
+ let pairs = 0;
154
+ for (let i = 1; i < out.length; i++) {
155
+ if (out[i-1].role === 'user' && out[i].role === 'assistant') pairs++;
156
+ }
157
+ if (pairs > TURN_LIMIT) {
158
+ let dropPairs = pairs - TURN_LIMIT;
159
+ let dropIdx = 0;
160
+ while (dropPairs > 0 && dropIdx < out.length - 1) {
161
+ if (out[dropIdx].role === 'user' && out[dropIdx+1].role === 'assistant') {
162
+ dropIdx += 2; dropPairs--;
163
+ } else { dropIdx++; }
164
+ }
165
+ out.splice(0, dropIdx);
166
+ }
167
+ console.log(JSON.stringify(out));
168
+ " "$TRANSCRIPT_PATH" 2>/dev/null || echo "[]")
169
+ fi
170
+
171
+ # spec 2026-06-18 §3.2 marker 경로 1: 정상 flush-session 의 content-bearing success 시
172
+ # orphan flushed marker(source=stop) 생성 → 다음 SessionStart orphan scanner 가 이 세션 제외.
173
+ MESSAGE_COUNT=$(printf '%s' "$MESSAGES_JSON" | node -e "
174
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
175
+ try { const a=JSON.parse(d); process.stdout.write(String(Array.isArray(a)?a.length:0)); }
176
+ catch { process.stdout.write('0'); }
177
+ });
178
+ " 2>/dev/null || echo 0)
179
+
180
+ # P3 (2026-06-18 코드리뷰): messages 를 argv 대신 stdin 으로 전달 — 큰 transcript(긴 content)에서
181
+ # ARG_MAX/env 한계로 body build 가 깨지는 것 방지 (orphan_recover 와 동일 취지).
182
+ BODY=$(printf '%s' "$MESSAGES_JSON" | node -e "
183
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
184
+ const b = {
185
+ session_id: process.argv[1],
186
+ hostname: process.argv[2],
187
+ repo_url: process.argv[3] || undefined,
188
+ repo_path: process.argv[4],
189
+ transcript_path: process.argv[5] || undefined,
190
+ };
191
+ let msgs = [];
192
+ try { msgs = JSON.parse(d || '[]'); } catch {}
193
+ if (Array.isArray(msgs) && msgs.length > 0) b.messages = msgs;
194
+ process.stdout.write(JSON.stringify(b));
195
+ });
196
+ " "$SESSION_ID" "$HOST" "$REPO_URL" "$REPO_PATH" "$TRANSCRIPT_PATH")
197
+
198
+ # spec §35 PR-1 Task 3: flush-session 보다 먼저 batch-mutate 를 POST 해야
199
+ # cold dedup 게이트(Task 2)가 hot write 결과를 SELECT 할 수 있다.
200
+ # Latency: 본 dispatcher curl + flush-session curl 모두 --max-time 10 → 합산 최대 20s.
201
+ # Stop hook latency budget 안 — 추가 sequential curl 도입 시 재산정 필요.
202
+ DROP_LOG="$HOME/.rookie/dropped-mutations.log"
203
+ # P3: messages 를 argv 대신 stdin 으로 (위 BODY build 와 동일 — ARG_MAX 회피).
204
+ MUTATE_JSON=$(printf '%s' "$MESSAGES_JSON" | node -e "
205
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
206
+ let msgs = [];
207
+ try { msgs = JSON.parse(d || '[]'); } catch {}
208
+ if (!Array.isArray(msgs) || msgs.length === 0) return;
209
+ let last = null;
210
+ for (let i = msgs.length - 1; i >= 0; i--) {
211
+ if (msgs[i].role === 'assistant') { last = msgs[i]; break; }
212
+ }
213
+ if (!last || typeof last.content !== 'string') return;
214
+ const tick = String.fromCharCode(96).repeat(3);
215
+ const re = new RegExp(tick + 'rookie:mutate\\\\s*\\\\n([\\\\s\\\\S]*?)\\\\n' + tick);
216
+ const m = last.content.match(re);
217
+ if (!m) return;
218
+ try {
219
+ const j = JSON.parse(m[1]);
220
+ if (j && typeof j === 'object') process.stdout.write(JSON.stringify(j));
221
+ else process.stdout.write('PARSE_FAILED');
222
+ } catch { process.stdout.write('PARSE_FAILED'); }
223
+ });
224
+ " 2>/dev/null) || MUTATE_JSON=""
225
+
226
+ # fence 가 있었지만 JSON.parse 실패 → 무성 silent drop 대신 telemetry 로그.
227
+ if [ "$MUTATE_JSON" = "PARSE_FAILED" ]; then
228
+ printf '%s fence_parse_failed session=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SESSION_ID" >> "$DROP_LOG" 2>/dev/null || true
229
+ MUTATE_JSON=""
230
+ fi
231
+
232
+ # spec 2026-07-06 §F-1(a) — fence 경유 저장의 세션 project 귀속. fence 에서 뽑은 MUTATE_JSON 엔
233
+ # repo 컨텍스트가 없다 → top-level 에 repo_url/repo_path merge(이미 명시된 값은 보존). 서버가
234
+ # 이걸로 세션 project 를 확정해 미지정 카드를 NULL 대신 세션 project 로 귀속(진입카드 stale 근본 차단).
235
+ if [ -n "$MUTATE_JSON" ]; then
236
+ _MERGED_MUTATE=$(printf '%s' "$MUTATE_JSON" | node -e "
237
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
238
+ let j; try { j = JSON.parse(d); } catch { process.stdout.write(d); return; }
239
+ if (j && typeof j === 'object' && !Array.isArray(j)) {
240
+ if (process.argv[1] && j.repo_url === undefined) j.repo_url = process.argv[1];
241
+ if (process.argv[2] && j.repo_path === undefined) j.repo_path = process.argv[2];
242
+ process.stdout.write(JSON.stringify(j));
243
+ } else { process.stdout.write(d); }
244
+ });
245
+ " "$REPO_URL" "$REPO_PATH" 2>/dev/null)
246
+ if [ -n "$_MERGED_MUTATE" ]; then MUTATE_JSON="$_MERGED_MUTATE"; fi
247
+ fi
248
+
249
+ if [ -n "$MUTATE_JSON" ]; then
250
+ if command -v mutate_dispatch >/dev/null 2>&1; then
251
+ # spec §17.2 H-2.1 (retry 1회) + H-2.2 (partial failure 재큐) 책임을 lib/dispatcher.sh 로 위임.
252
+ # return 0 = 성공 (partial failure 재큐 가능 포함), 1 = 큐화 (전체 실패), 2 = no-op.
253
+ mutate_dispatch "$ENDPOINT" "$TOKEN" "$USER_ID" "$SESSION_ID" "$MUTATE_JSON" || true
254
+ else
255
+ # dispatcher.sh source 실패 → silent drop 대신 telemetry. queue 라도 살아있으면 큐화 시도.
256
+ printf '%s dispatcher_unavailable session=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SESSION_ID" >> "$DROP_LOG" 2>/dev/null || true
257
+ if command -v queue_enqueue >/dev/null 2>&1; then
258
+ queue_enqueue "$SESSION_ID-mutate" "$MUTATE_JSON" "dispatcher-missing" 2>/dev/null || true
259
+ fi
260
+ fi
261
+ fi
262
+
263
+ # v6 P1 #5 — Stage 1 측정 hook buffer flush.
264
+ # PreToolUse hook 가 적재한 metric jsonl 을 mutate_dispatch 와 무관하게 항상 flush 시도.
265
+ # silent fail (buffer 잔존 시 다음 세션 재시도).
266
+ if command -v metric_dispatch_md_read >/dev/null 2>&1; then
267
+ metric_dispatch_md_read "$ENDPOINT" "$TOKEN" "$USER_ID" "$SESSION_ID" || true
268
+ fi
269
+
270
+ # §17 H-1: silent (`> /dev/null || true`) → fail-aware (HTTP code + 큐화)
271
+ # spec 2026-06-18 §3.2: 응답 body 도 캡처(chunks_inserted) → content-bearing marker 판정.
272
+ # P3: body 를 argv(-d "$BODY") 대신 파일(--data-binary @file)로 송신 — 큰 body ARG_MAX 회피.
273
+ # mktemp 실패 시에만 기존 -d 폴백.
274
+ RESP_FILE=$(mktemp 2>/dev/null || echo "")
275
+ BODY_FILE=$(mktemp 2>/dev/null || echo "")
276
+ if [ -n "$BODY_FILE" ]; then
277
+ printf '%s' "$BODY" > "$BODY_FILE" 2>/dev/null
278
+ DATA_ARG=(--data-binary "@$BODY_FILE")
279
+ else
280
+ DATA_ARG=(-d "$BODY")
281
+ fi
282
+ HTTP_CODE=$(curl -sS --max-time 10 \
283
+ -X POST \
284
+ -H "Authorization: Bearer $TOKEN" \
285
+ -H "Content-Type: application/json" \
286
+ -H "x-rookie-user-id: $USER_ID" \
287
+ "${DATA_ARG[@]}" \
288
+ -o "${RESP_FILE:-/dev/null}" \
289
+ -w "%{http_code}" \
290
+ "$ENDPOINT/api/rookie/flush-session" 2>/dev/null) || HTTP_CODE="000"
291
+
292
+ if [ "$HTTP_CODE" = "200" ]; then
293
+ # 정상 — 마지막 성공 시각 갱신 + 큐가 있으면 chain drain
294
+ if command -v queue_record_success >/dev/null 2>&1; then
295
+ queue_record_success
296
+ queue_drain "$ENDPOINT" "$TOKEN" "$USER_ID" >/dev/null 2>&1 || true
297
+ fi
298
+ # P0.4 Step 4 (codex #10): marker 조건 = code 200(이 분기) + message_count>0 (chunks_inserted 무관).
299
+ # 멱등 재flush(chunks 0)도 marker 박아 orphan scan 이 같은 세션을 영구 재적재하지 않게 한다.
300
+ # 빈 messages 는 MESSAGE_COUNT=0 으로 미부착(복구 여지 보존).
301
+ if command -v orphan_mark_flushed >/dev/null 2>&1 && [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
302
+ orphan_mark_flushed "$SESSION_ID" "stop" "$MESSAGE_COUNT" || true
303
+ fi
304
+ else
305
+ # §17 H-2: 큐 적재 + 1회 chain drain (일시적 깜빡임 회복)
306
+ if command -v queue_enqueue >/dev/null 2>&1; then
307
+ queue_enqueue "$SESSION_ID" "$BODY" "$HTTP_CODE" 2>/dev/null || true
308
+ queue_drain "$ENDPOINT" "$TOKEN" "$USER_ID" >/dev/null 2>&1 || true
309
+ fi
310
+ fi
311
+
312
+ if [ -n "$RESP_FILE" ]; then rm -f "$RESP_FILE" 2>/dev/null || true; fi
313
+ if [ -n "$BODY_FILE" ]; then rm -f "$BODY_FILE" 2>/dev/null || true; fi
314
+ exit 0
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse(Bash) 가드 — 라이브/원격 DB·git 파괴적 명령을 하네스 레벨에서 차단.
3
+ #
4
+ # 배경(2026-06-02 KST): 검증으로 `supabase db reset --linked`를 라이브 원격 DB에 돌려
5
+ # auth.users(실 로그인 계정) 전체가 소실됨. 모델 판단에 의존하지 않고
6
+ # 명령 문자열 전체를 검사해 차단한다(파이프/래핑 우회 방지 — `printf y | npx supabase db reset --linked`도 잡힘).
7
+ #
8
+ # 출력: 위험 시 PreToolUse permissionDecision=deny JSON, 아니면 침묵(exit 0).
9
+ # 정당한 경우엔 사용자가 직접 `!` 명령으로 실행(이 hook은 모델의 Bash 도구만 가로챔).
10
+
11
+ cmd=$(jq -r '.tool_input.command // ""' 2>/dev/null)
12
+ [ -z "$cmd" ] && exit 0
13
+
14
+ # 위험 패턴(대소문자 무시). db reset --local 등 로컬 안전 변형은 매칭하지 않는다.
15
+ # git push 의 -f 는 push 직후(`git push -f`)·인자 뒤(`git push origin -f`) 둘 다 잡는다.
16
+ # drop role 은 database|schema|table 과 동일하게 앞 공백 요구 없이 잡는다(따옴표/문두 우회 방지).
17
+ deny_re='(supabase[[:space:]]+db[[:space:]]+reset[[:space:]]+--linked|db[[:space:]]+reset[[:space:]]+--remote|drop[[:space:]]+(database|schema|table|role)|truncate[[:space:]]+(table|only)|git[[:space:]]+push[[:space:]]+(.*[[:space:]])?(--force([[:space:]=]|$)|--force-with-lease|-f([[:space:]]|$))|git[[:space:]]+reset[[:space:]]+--hard)'
18
+
19
+ if printf '%s' "$cmd" | grep -qiE "$deny_re"; then
20
+ reason="🛑 하네스 차단(guard-destructive-db): 라이브/원격 DB 또는 git 파괴적 명령(supabase db reset --linked·drop·truncate·git push --force·git reset --hard)은 모델이 자동 실행할 수 없습니다. 스키마 검증은 로컬(supabase db reset --local)에서. 정말 필요하면 사용자가 직접 ! 명령으로 실행하세요."
21
+ jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
22
+ exit 0
23
+ fi
24
+ exit 0
@@ -0,0 +1,106 @@
1
+ #!/bin/bash
2
+ # lib/config-resolve.sh — B6 한 PC 다계정 격리 (spec 2026-07-11-memory-reliability-fix-design §B6).
3
+ # (1) config 상향 탐색: env(ROOKIE_ENDPOINT+ROOKIE_TOKEN) > <dir>/.rookie/config.json (cwd 상향) > 전역.
4
+ # 모든 hook 진입 스크립트가 이 함수 하나로 해석한다 — 한 곳이라도 전역 고정 경로가 남으면
5
+ # 그 경로로 계정 오염이 재발한다(§B6-1 파일 목록 원칙).
6
+ # (2) 세션 identity pinning(§B6-3): SessionStart 가 유효 토큰 hash 를 기록, Stop(flush)이 비교 —
7
+ # 세션 도중 계정 전환 시 flush 를 중단해 진행 중 세션의 기억이 새 계정으로 오귀속되는 것을 차단.
8
+
9
+ # cwd 기준 유효 config 경로를 stdout 으로. 로컬(.rookie/config.json, 상향 탐색) > 전역.
10
+ # 전역 경로(~/.rookie/config.json) 자체는 로컬 후보에서 제외($HOME 도달 시 이중 판정 방지).
11
+ rookie_config_path_for_cwd() {
12
+ local dir="$1"
13
+ local global="$HOME/.rookie/config.json"
14
+ if [ -z "$dir" ] || [ ! -d "$dir" ]; then
15
+ printf '%s' "$global"
16
+ return 0
17
+ fi
18
+ dir="$(cd "$dir" 2>/dev/null && pwd)" || { printf '%s' "$global"; return 0; }
19
+ while [ -n "$dir" ] && [ "$dir" != "/" ]; do
20
+ if [ "$dir/.rookie/config.json" != "$global" ] && [ -f "$dir/.rookie/config.json" ]; then
21
+ printf '%s' "$dir/.rookie/config.json"
22
+ return 0
23
+ fi
24
+ dir="$(dirname "$dir")"
25
+ done
26
+ printf '%s' "$global"
27
+ }
28
+
29
+ # env > 로컬 > 전역 해석. 결과를 전역 변수로 내보낸다:
30
+ # ROOKIE_RESOLVED_ENDPOINT / ROOKIE_RESOLVED_TOKEN / ROOKIE_RESOLVED_USER_ID
31
+ # ROOKIE_ACTIVE_CONFIG (해석된 config 파일 경로 — env 스코프는 "env", sweep partition 기준)
32
+ # 반환 1 = 자격증명 없음(hook 은 조용히 exit 0 해야 함).
33
+ rookie_resolve_credentials() {
34
+ local start_dir="$1"
35
+ ROOKIE_RESOLVED_ENDPOINT=""
36
+ ROOKIE_RESOLVED_TOKEN=""
37
+ ROOKIE_RESOLVED_USER_ID=""
38
+ ROOKIE_ACTIVE_CONFIG=""
39
+ # set -u 환경(user-prompt-skill-gate.sh)에서도 안전하게 :- 기본값 참조.
40
+ if [ -n "${ROOKIE_ENDPOINT:-}" ] && [ -n "${ROOKIE_TOKEN:-}" ]; then
41
+ ROOKIE_RESOLVED_ENDPOINT="$ROOKIE_ENDPOINT"
42
+ ROOKIE_RESOLVED_TOKEN="$ROOKIE_TOKEN"
43
+ ROOKIE_ACTIVE_CONFIG="env"
44
+ return 0
45
+ fi
46
+ local cfg
47
+ cfg="$(rookie_config_path_for_cwd "$start_dir")"
48
+ [ -f "$cfg" ] || return 1
49
+ ROOKIE_RESOLVED_ENDPOINT=$(node -e "console.log(require(process.argv[1]).endpoint || '')" "$cfg" 2>/dev/null || echo "")
50
+ ROOKIE_RESOLVED_TOKEN=$(node -e "console.log(require(process.argv[1]).token || '')" "$cfg" 2>/dev/null || echo "")
51
+ ROOKIE_RESOLVED_USER_ID=$(node -e "console.log(require(process.argv[1]).user_id || '')" "$cfg" 2>/dev/null || echo "")
52
+ [ -n "$ROOKIE_RESOLVED_TOKEN" ] || return 1
53
+ [ -n "$ROOKIE_RESOLVED_ENDPOINT" ] || ROOKIE_RESOLVED_ENDPOINT="http://localhost:3000"
54
+ ROOKIE_ACTIVE_CONFIG="$cfg"
55
+ return 0
56
+ }
57
+
58
+ # ── B6-3 세션 identity pinning ──
59
+ # pinning 기준 = 유효 토큰 hash (config user_id 아님 — env 주입 세션에서도 동일 기준, 3R P2).
60
+ ROOKIE_IDENTITY_PIN_DIR="${ROOKIE_IDENTITY_PIN_DIR:-$HOME/.rookie/session-identity}"
61
+ ROOKIE_IDENTITY_MISMATCH_LOG="${ROOKIE_IDENTITY_MISMATCH_LOG:-$HOME/.rookie/identity-mismatch.log}"
62
+
63
+ _rookie_token_hash() {
64
+ printf '%s' "$1" | node -e '
65
+ const c=require("crypto"); let d="";
66
+ process.stdin.on("data",x=>d+=x).on("end",()=>process.stdout.write(c.createHash("sha256").update(d).digest("hex").slice(0,16)));
67
+ ' 2>/dev/null
68
+ }
69
+
70
+ # SessionStart: 세션의 effective identity 기록 (+14일 지난 pin 기회적 정리).
71
+ identity_pin_write() {
72
+ local sid="$1" token="$2"
73
+ [ -n "$sid" ] && [ -n "$token" ] || return 0
74
+ mkdir -p "$ROOKIE_IDENTITY_PIN_DIR" 2>/dev/null || return 0
75
+ local hash
76
+ hash="$(_rookie_token_hash "$token")"
77
+ [ -n "$hash" ] || return 0
78
+ ( umask 077; printf '%s' "$hash" > "$ROOKIE_IDENTITY_PIN_DIR/$sid" 2>/dev/null ) || true
79
+ # 14일+ pin 정리 — pin 은 세션 수명 추적용 소모품(마커 TTL 과 동일 감각).
80
+ find "$ROOKIE_IDENTITY_PIN_DIR" -type f -mtime +14 -delete 2>/dev/null || true
81
+ return 0
82
+ }
83
+
84
+ # Stop(flush): pin 과 현재 토큰 비교. 0 = 통과(pin 없음 포함 — 구세션 fail-open), 1 = 불일치.
85
+ identity_pin_check() {
86
+ local sid="$1" token="$2"
87
+ [ -n "$sid" ] && [ -n "$token" ] || return 0
88
+ local pin_file="$ROOKIE_IDENTITY_PIN_DIR/$sid"
89
+ [ -f "$pin_file" ] || return 0
90
+ local pinned current
91
+ pinned="$(cat "$pin_file" 2>/dev/null)"
92
+ [ -n "$pinned" ] || return 0
93
+ current="$(_rookie_token_hash "$token")"
94
+ [ -n "$current" ] || return 0
95
+ [ "$pinned" = "$current" ] && return 0
96
+ return 1
97
+ }
98
+
99
+ # 불일치 로그 — doctor 가 집계·경고. 토큰·hash 원문은 남기지 않는다(§10d).
100
+ identity_pin_log_mismatch() {
101
+ local sid="$1" source="${2:-stop}"
102
+ mkdir -p "$(dirname "$ROOKIE_IDENTITY_MISMATCH_LOG")" 2>/dev/null || return 0
103
+ printf '%s session=%s source=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$sid" "$source" \
104
+ >> "$ROOKIE_IDENTITY_MISMATCH_LOG" 2>/dev/null || true
105
+ return 0
106
+ }
@@ -0,0 +1,202 @@
1
+ #!/bin/bash
2
+ # lib/cross-runtime-sweep.sh — Phase 3 cross-runtime 로컬 sweep.
3
+ # spec docs/superpowers/specs/2026-06-18-crash-recovery-orphan-flush.md §4 Phase 3.
4
+ # launchd 가 주기 실행: Claude 전체 프로젝트 + Codex 세션을 사후 회수(chunk 복구).
5
+ # fact 는 Phase 2 client-assisted(pending → 차차) 로 위임 — 여기선 chunk capture 까지만.
6
+ #
7
+ # 의존: lib/transcript.sh (Claude·Codex 파서) + lib/orphan-recovery.sh (marker·orphan_recover).
8
+ # marker namespace 분리(§3.2): Codex 는 ROOKIE_ORPHAN_DIR/codex 하위(dynamic scope override)로
9
+ # flushed/baseline/deferred/pending/client_extracted 를 둔다 → "어느 런타임 포맷" 구분 보존.
10
+
11
+ ROOKIE_CODEX_SESSIONS_DIR="${ROOKIE_CODEX_SESSIONS_DIR:-$HOME/.codex/sessions}"
12
+ ROOKIE_CLAUDE_PROJECTS_DIR="${ROOKIE_CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects}"
13
+ ROOKIE_CODEX_SWEEP_DAYS="${ROOKIE_CODEX_SWEEP_DAYS:-7}" # 날짜 파티션 순회 창
14
+ ROOKIE_CODEX_SWEEP_CAP="${ROOKIE_CODEX_SWEEP_CAP:-20}" # 1회 sweep 최대 Codex 세션 수
15
+ ROOKIE_SWEEP_TIME_BUDGET="${ROOKIE_SWEEP_TIME_BUDGET:-30}" # 총 sweep 예산(초)
16
+
17
+ # 최근 N일 Codex rollout 중 복구 후보 경로를 stdout(1줄당 1개). 최신순, marker·hold 제외, cap.
18
+ # marker root = ROOKIE_ORPHAN_DIR 를 그대로 사용한다 — codex_recover 가 codex namespace
19
+ # (base/codex)로 설정해 호출하므로, 여기선 /codex 를 다시 붙이지 않는다(이중 방지).
20
+ # allowlist: regular file·≤MAXBYTES·rollout-*.jsonl. session_meta.cwd 는 recover 단계에서 읽는다.
21
+ codex_scan_candidates() {
22
+ local root="$ROOKIE_CODEX_SESSIONS_DIR"
23
+ [ -d "$root" ] || return 0
24
+ local cdir="${ROOKIE_ORPHAN_DIR:-$HOME/.rookie/orphan-recovery}"
25
+ node -e '
26
+ const fs=require("fs"), path=require("path");
27
+ const [root, cdir, daysS, capS, holdS, maxBytesS] = process.argv.slice(1);
28
+ const now=Date.now()/1000, days=+daysS, cap=+capS, hold=+holdS, maxB=+maxBytesS;
29
+ const flushedDir=path.join(cdir,"flushed"), baselineDir=path.join(cdir,"baseline"), deferredDir=path.join(cdir,"deferred");
30
+ // P0.4: Claude scanner 와 대칭으로 low_value·expired 도 제외(한쪽만 고치면 런타임 비대칭 재진입 — §12-P2-11).
31
+ const lowValueDir=path.join(cdir,"low_value"), expiredDir=path.join(cdir,"expired");
32
+ const minMtime=now-days*86400;
33
+ const uuidRe=/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
34
+ const marked=(sid)=>fs.existsSync(path.join(flushedDir,sid+".json"))||fs.existsSync(path.join(baselineDir,sid+".json"))||fs.existsSync(path.join(lowValueDir,sid+".json"))||fs.existsSync(path.join(expiredDir,sid+".json"));
35
+ const deferBlocked=(sid)=>{ try{
36
+ const j=JSON.parse(fs.readFileSync(path.join(deferredDir,sid+".json"),"utf8"));
37
+ if((j.fail_count||0)>=3) return true;
38
+ const nr=Date.parse(j.next_retry_at); return !isNaN(nr)&&nr/1000>now;
39
+ }catch{ return false; } };
40
+ // 날짜 파티션 재귀 walk → rollout-*.jsonl 수집.
41
+ const found=[];
42
+ const walk=(d)=>{ let names=[]; try{names=fs.readdirSync(d);}catch{return;}
43
+ for(const n of names){ const fp=path.join(d,n); let st; try{st=fs.lstatSync(fp);}catch{continue;}
44
+ if(st.isDirectory()){ walk(fp); continue; }
45
+ if(!st.isFile()) continue;
46
+ if(!/^rollout-.*\.jsonl$/.test(n)) continue;
47
+ if(st.size>maxB) continue;
48
+ if(st.mtimeMs/1000<minMtime) continue; // 날짜 창 밖
49
+ if((now-st.mtimeMs/1000)<hold) continue; // 최근 수정 보류
50
+ const m=n.slice(0,-6).match(uuidRe); if(!m) continue; // .jsonl 제거 후 끝 UUID
51
+ const sid=m[0];
52
+ if(marked(sid)||deferBlocked(sid)) continue;
53
+ found.push({fp,sid,mtime:st.mtimeMs});
54
+ }
55
+ };
56
+ walk(root);
57
+ found.sort((a,b)=>b.mtime-a.mtime);
58
+ for(const e of found.slice(0,cap)) process.stdout.write(e.fp+"\n");
59
+ ' "$root" "$cdir" "$ROOKIE_CODEX_SWEEP_DAYS" "$ROOKIE_CODEX_SWEEP_CAP" \
60
+ "${ROOKIE_ORPHAN_AGE_HOLD:-60}" "${ROOKIE_ORPHAN_MAX_BYTES:-52428800}" 2>/dev/null
61
+ }
62
+
63
+ # Codex 세션 사후 회수. orphan_recover 의 Codex 판 — Codex 파서 + codex marker namespace(source=sweep).
64
+ # stdout 미사용. 복구 건수는 ROOKIE_SWEEP_CODEX_COUNT.
65
+ codex_recover() {
66
+ local endpoint="$1" token="$2" user_id="$3"
67
+ ROOKIE_SWEEP_CODEX_COUNT=0
68
+ [ -n "$endpoint" ] && [ -n "$token" ] || return 0
69
+ command -v transcript_messages_json_codex >/dev/null 2>&1 || return 0
70
+ # marker·scan 이 codex namespace(base/codex)를 쓰도록 dynamic scope override.
71
+ # base 를 먼저 읽고(별도 변수) shadow — 한 줄 분리 시 local 이 변수를 비워 $HOME 폴백되는 함정 회피.
72
+ local _base="${ROOKIE_ORPHAN_DIR:-$HOME/.rookie/orphan-recovery}"
73
+ local ROOKIE_ORPHAN_DIR="$_base/codex"
74
+
75
+ local deadline; deadline=$(( $(date +%s) + ROOKIE_SWEEP_TIME_BUDGET ))
76
+ local host; host="$(hostname)"
77
+ local candidates; candidates="$(codex_scan_candidates)" || return 0
78
+ [ -n "$candidates" ] || return 0
79
+
80
+ local fp sid cwd messages mc msg_file body_file code
81
+ while IFS= read -r fp; do
82
+ [ -n "$fp" ] || continue
83
+ [ "$(date +%s)" -ge "$deadline" ] && break
84
+ sid="$(transcript_session_id_from_path_codex "$fp")"
85
+ [ -n "$sid" ] || continue
86
+ _orphan_deferred_blocked "$sid" && continue
87
+ cwd="$(transcript_codex_cwd "$fp")" # 프로젝트 매칭(repo_path)
88
+ # B6-4 (spec 2026-07-11 §B6): cwd 불명 orphan 보류 + 타 config 소속 skip — Claude 경로
89
+ # (orphan_recover)와 대칭. 판별 불가·타 계정 몫을 현재 config 로 flush 하지 않는다.
90
+ if [ -z "$cwd" ]; then
91
+ _orphan_log_telemetry "$sid" "codex_skip_unknown_cwd"; continue
92
+ fi
93
+ # codex R1 P1-2: cwd 디렉토리 삭제·이동 → 판별 불가 보류 (전역 폴백 flush 금지).
94
+ if [ ! -d "$cwd" ]; then
95
+ _orphan_log_telemetry "$sid" "codex_skip_missing_cwd_dir"; continue
96
+ fi
97
+ if command -v rookie_config_path_for_cwd >/dev/null 2>&1 && [ -n "${ROOKIE_ACTIVE_CONFIG:-}" ]; then
98
+ if [ "$(rookie_config_path_for_cwd "$cwd")" != "$ROOKIE_ACTIVE_CONFIG" ]; then
99
+ _orphan_log_telemetry "$sid" "codex_skip_other_config"; continue
100
+ fi
101
+ fi
102
+ # codex R1 P1-1: 세션 identity pin 불일치 → 보류 (orphan_recover 와 대칭).
103
+ if command -v identity_pin_check >/dev/null 2>&1 && ! identity_pin_check "$sid" "$token"; then
104
+ _orphan_log_telemetry "$sid" "codex_skip_identity_mismatch"; continue
105
+ fi
106
+ messages="$(transcript_messages_json_codex "$fp")"
107
+ mc="$(_orphan_messages_count "$messages")"
108
+ if [ "${mc:-0}" -le 0 ] 2>/dev/null; then
109
+ _orphan_log_telemetry "$sid" "codex_skip_empty_messages"; continue
110
+ fi
111
+ # P3: messages 를 임시 파일로 전달 (argv ARG_MAX 회피).
112
+ msg_file="$(mktemp 2>/dev/null)" || continue
113
+ printf '%s' "$messages" > "$msg_file" 2>/dev/null
114
+ body_file="$(mktemp 2>/dev/null)" || { rm -f "$msg_file"; continue; }
115
+ node -e '
116
+ const fs=require("fs");
117
+ const b={session_id:process.argv[1],hostname:process.argv[2],repo_path:process.argv[3]||undefined,transcript_path:process.argv[4]};
118
+ try{ const m=JSON.parse(fs.readFileSync(process.argv[5],"utf8")||"[]"); if(Array.isArray(m)&&m.length) b.messages=m; }catch{}
119
+ process.stdout.write(JSON.stringify(b));
120
+ ' "$sid" "$host" "$cwd" "$fp" "$msg_file" > "$body_file" 2>/dev/null
121
+ rm -f "$msg_file"
122
+ if [ ! -s "$body_file" ]; then rm -f "$body_file"; continue; fi
123
+ code="$(curl -sS --max-time 10 -X POST \
124
+ -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
125
+ -H "x-rookie-user-id: $user_id" --data-binary @"$body_file" \
126
+ -o /dev/null -w "%{http_code}" \
127
+ "$endpoint/api/rookie/flush-session" 2>/dev/null)" || code="000"
128
+ rm -f "$body_file"
129
+ # P0.4 Step 4: marker 조건 = code 200 + message_count>0 (Claude 경로와 대칭).
130
+ if [ "$code" = "200" ] && [ "${mc:-0}" -gt 0 ] 2>/dev/null; then
131
+ if orphan_mark_flushed "$sid" "sweep" "$mc"; then
132
+ _orphan_clear_deferred "$sid"
133
+ # P0.4 Step 1: 짧고 내용 없는 Codex 세션은 low_value, 운영사실 있으면 pending 적재.
134
+ if orphan_pending_is_worthwhile "$messages" "$mc"; then
135
+ orphan_record_pending "$sid" "$fp" "$mc" || true # Codex tail 도 차차가 추출(codex ns pending)
136
+ else
137
+ orphan_mark_low_value "$sid" "short_no_content" "$mc" || true
138
+ fi
139
+ ROOKIE_SWEEP_CODEX_COUNT=$((ROOKIE_SWEEP_CODEX_COUNT + 1))
140
+ else
141
+ _orphan_record_deferred "$sid" "marker_write_failed"
142
+ fi
143
+ else
144
+ _orphan_log_telemetry "$sid" "codex_flush_failed code=$code mc=$mc"
145
+ fi
146
+ done <<EOF
147
+ $candidates
148
+ EOF
149
+ return 0
150
+ }
151
+
152
+ # ── opportunistic 하루 1회 가드 (spec 2026-06-18 — launchd 대신 진입점 sweep 기본값) ──
153
+ # OS 스케줄러 없이 사용자 진입점(Claude SessionStart·Codex rookie_context·수동 CLI)마다
154
+ # "오늘 아직 안 했으면" sweep → 꺼져 있던 PC도 다음 사용 때 따라잡는다.
155
+ _sweep_stamp_file() { printf '%s/last-sweep-date' "${ROOKIE_ORPHAN_DIR:-$HOME/.rookie/orphan-recovery}"; }
156
+
157
+ # 오늘 아직 sweep 안 했으면 exit 0 (due). 이미 했으면 1.
158
+ sweep_due_today() {
159
+ local f today
160
+ f="$(_sweep_stamp_file)"
161
+ today="$(date +%Y-%m-%d)"
162
+ [ -f "$f" ] || return 0
163
+ [ "$(cat "$f" 2>/dev/null)" = "$today" ] && return 1 || return 0
164
+ }
165
+
166
+ # 오늘 sweep 했음을 기록 (날짜 스탬프).
167
+ sweep_mark_ran_today() {
168
+ local f; f="$(_sweep_stamp_file)"
169
+ mkdir -p "$(dirname "$f")" 2>/dev/null
170
+ ( umask 077; date +%Y-%m-%d > "$f" 2>/dev/null ) || true
171
+ }
172
+
173
+ # 전체 sweep: Claude 모든 프로젝트(orphan_recover 재사용) + Codex(codex_recover).
174
+ # 복구 합계는 ROOKIE_SWEEP_TOTAL.
175
+ sweep_all() {
176
+ local endpoint="$1" token="$2" user_id="$3"
177
+ ROOKIE_SWEEP_TOTAL=0
178
+ [ -n "$endpoint" ] && [ -n "$token" ] || return 0
179
+
180
+ # Claude: 프로젝트 디렉토리별로 orphan_recover (Claude namespace 기본 ROOKIE_ORPHAN_DIR).
181
+ if command -v orphan_recover >/dev/null 2>&1 && [ -d "$ROOKIE_CLAUDE_PROJECTS_DIR" ]; then
182
+ local d
183
+ for d in "$ROOKIE_CLAUDE_PROJECTS_DIR"/*/; do
184
+ [ -d "$d" ] || continue
185
+ ROOKIE_ORPHAN_PROJECT_DIR="$d" orphan_recover "$endpoint" "$token" "$user_id" >/dev/null 2>&1 || true
186
+ ROOKIE_SWEEP_TOTAL=$((ROOKIE_SWEEP_TOTAL + ${ROOKIE_ORPHAN_RECOVERED_COUNT:-0}))
187
+ done
188
+ fi
189
+
190
+ # Codex: 최근 N일 세션.
191
+ if command -v codex_recover >/dev/null 2>&1; then
192
+ codex_recover "$endpoint" "$token" "$user_id" || true
193
+ ROOKIE_SWEEP_TOTAL=$((ROOKIE_SWEEP_TOTAL + ${ROOKIE_SWEEP_CODEX_COUNT:-0}))
194
+ fi
195
+
196
+ # P0.4 Step 3: 14일+ 미처리 pending 정리(expired marker) — base(Claude) + codex namespace 둘 다.
197
+ if command -v orphan_prune_expired >/dev/null 2>&1; then
198
+ orphan_prune_expired || true
199
+ ROOKIE_ORPHAN_DIR="${ROOKIE_ORPHAN_DIR:-$HOME/.rookie/orphan-recovery}/codex" orphan_prune_expired 2>/dev/null || true
200
+ fi
201
+ return 0
202
+ }