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,491 @@
1
+ #!/bin/bash
2
+ # lib/orphan-recovery.sh — 비정상 세션 종료 복구(orphan flush recovery).
3
+ # spec docs/superpowers/specs/2026-06-18-crash-recovery-orphan-flush.md §3.
4
+ # 비정상 종료로 Stop hook 이 못 돈 세션의 transcript 를 다음 SessionStart 에서 사후 회수.
5
+ # 리뷰 3회 반영: marker lifecycle 3경로 / content-bearing success(chunks_inserted>0)
6
+ # / bootstrap "T창 안 최신 K개" / flushed·baseline·deferred 분리 / latency cap / 파일 allowlist.
7
+ #
8
+ # 의존: lib/transcript.sh (messages 추출), curl(flush 재전송).
9
+
10
+ ROOKIE_ORPHAN_DIR="${ROOKIE_ORPHAN_DIR:-$HOME/.rookie/orphan-recovery}"
11
+ ROOKIE_ORPHAN_AGE_HOLD="${ROOKIE_ORPHAN_AGE_HOLD:-60}" # 최근 N초 수정 파일 보류
12
+ ROOKIE_ORPHAN_BOOTSTRAP_T="${ROOKIE_ORPHAN_BOOTSTRAP_T:-172800}" # 첫 도입 T창 (48h)
13
+ ROOKIE_ORPHAN_CAP="${ROOKIE_ORPHAN_CAP:-3}" # latency cap = bootstrap K
14
+ ROOKIE_ORPHAN_MAX_BYTES="${ROOKIE_ORPHAN_MAX_BYTES:-52428800}" # 50MB
15
+ ROOKIE_ORPHAN_DEFER_CAP="${ROOKIE_ORPHAN_DEFER_CAP:-3}" # marker write 실패 보류 임계
16
+ # pending tail 추출 TTL(일). prod 가 서버 fact 추출 off 라 orphan fact 는 차차 수동 처리뿐인데,
17
+ # chunk 는 이미 recall 복구되므로 오래된 orphan fact 구조화는 비용 대비 가치 낮다. 미처리 N일+ 는
18
+ # expired/ 로 감사 이동(삭제 아님)해 큐가 자동 수렴하게 한다(두목 결정 2026-06-23, 14→3).
19
+ ROOKIE_ORPHAN_PENDING_TTL_DAYS="${ROOKIE_ORPHAN_PENDING_TTL_DAYS:-3}"
20
+
21
+ # cwd → Claude Code transcript 디렉토리명. URL 인코딩 아님: 영숫자·hyphen 외 전부 '-'.
22
+ # 예: /Volumes/HoOn/01_work/10_the-rookie → -Volumes-HoOn-01-work-10-the-rookie
23
+ claude_project_dir_for_cwd() {
24
+ printf '%s' "$1" | sed 's/[^a-zA-Z0-9-]/-/g'
25
+ }
26
+
27
+ _orphan_flushed_dir() { printf '%s/flushed' "$ROOKIE_ORPHAN_DIR"; }
28
+ _orphan_baseline_dir() { printf '%s/baseline' "$ROOKIE_ORPHAN_DIR"; }
29
+ _orphan_deferred_dir() { printf '%s/deferred' "$ROOKIE_ORPHAN_DIR"; }
30
+ # Phase 2 (client-assisted tail extraction): chunk 복구(flushed)와 별도로
31
+ # pending-extraction = 차차가 tail fact 를 추출해야 할 orphan 세션 큐
32
+ # client_extracted = 차차가 추출 완료한 세션 (반복 저장 차단 idempotency marker)
33
+ _orphan_pending_dir() { printf '%s/pending-extraction' "$ROOKIE_ORPHAN_DIR"; }
34
+ _orphan_client_extracted_dir() { printf '%s/client_extracted' "$ROOKIE_ORPHAN_DIR"; }
35
+ # P0.4: 적재 가드 — 짧고 내용 없는 세션은 low_value/, 14일+ 미처리 pending 은 expired/ 로 이동.
36
+ # 둘 다 삭제 아닌 marker(감사) + scanner 재진입 방지(orphan_is_marked·marked() 제외 집합).
37
+ _orphan_low_value_dir() { printf '%s/low_value' "$ROOKIE_ORPHAN_DIR"; }
38
+ _orphan_expired_dir() { printf '%s/expired' "$ROOKIE_ORPHAN_DIR"; }
39
+ _orphan_log() { printf '%s/recovery.log' "$ROOKIE_ORPHAN_DIR"; }
40
+
41
+ _orphan_dir_init() {
42
+ local d="$1"
43
+ [ -d "$d" ] || mkdir -m 700 -p "$d" 2>/dev/null
44
+ chmod 700 "$ROOKIE_ORPHAN_DIR" 2>/dev/null
45
+ chmod 700 "$d" 2>/dev/null
46
+ }
47
+
48
+ # session_id 가 UUID-like 인지 검증 (P2: marker path traversal 차단 — spec §3.4 "UUID-like filename만").
49
+ _orphan_is_uuid() {
50
+ [[ "$1" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]
51
+ }
52
+
53
+ # 원자적 marker write (tmp + mv). 실패 시 비-0. sid 비-UUID 면 거부(traversal 차단).
54
+ _orphan_write_marker() {
55
+ local dir="$1" sid="$2" payload="$3"
56
+ _orphan_is_uuid "$sid" || return 1
57
+ _orphan_dir_init "$dir"
58
+ local tmp
59
+ tmp="$(mktemp "$dir/.tmp.XXXXXX" 2>/dev/null)" || return 1
60
+ printf '%s' "$payload" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
61
+ chmod 600 "$tmp" 2>/dev/null
62
+ mv -f "$tmp" "$dir/$sid.json" 2>/dev/null || { rm -f "$tmp"; return 1; }
63
+ return 0
64
+ }
65
+
66
+ orphan_mark_flushed() {
67
+ local sid="$1" source="${2:-stop}" mc="${3:-0}"
68
+ [ -n "$sid" ] || return 1
69
+ _orphan_write_marker "$(_orphan_flushed_dir)" "$sid" \
70
+ "$(printf '{"status":"flushed","source":"%s","message_count":%s}' "$source" "$mc")"
71
+ }
72
+
73
+ orphan_mark_baseline() {
74
+ local sid="$1"
75
+ [ -n "$sid" ] || return 1
76
+ _orphan_write_marker "$(_orphan_baseline_dir)" "$sid" \
77
+ '{"status":"baseline_skipped","reason":"bootstrap_old"}'
78
+ }
79
+
80
+ # P0.4 — 짧고 내용 없는(low_value) 세션 marker. record_pending 대신 호출 → 재적재 차단.
81
+ orphan_mark_low_value() {
82
+ local sid="$1" reason="${2:-low_value}" mc="${3:-0}"
83
+ [ -n "$sid" ] || return 1
84
+ _orphan_write_marker "$(_orphan_low_value_dir)" "$sid" \
85
+ "$(printf '{"status":"low_value","reason":"%s","message_count":%s}' "$reason" "$mc")"
86
+ }
87
+
88
+ # P0.4 — pending 14일+ 미처리(expired) marker. orphan_prune_expired 가 pending 정리 시 호출.
89
+ orphan_mark_expired() {
90
+ local sid="$1"
91
+ [ -n "$sid" ] || return 1
92
+ _orphan_write_marker "$(_orphan_expired_dir)" "$sid" \
93
+ '{"status":"expired","reason":"pending_ttl"}'
94
+ }
95
+
96
+ # flushed·baseline·low_value·expired marker 가 있으면 0 (scanner 후보 제외 기준).
97
+ # P0.4: low_value·expired 추가 — 짧은 세션·만료 pending 의 sweep 재진입 차단(§9-#4).
98
+ orphan_is_marked() {
99
+ local sid="$1"
100
+ [ -n "$sid" ] || return 1
101
+ [ -f "$(_orphan_flushed_dir)/$sid.json" ] && return 0
102
+ [ -f "$(_orphan_baseline_dir)/$sid.json" ] && return 0
103
+ [ -f "$(_orphan_low_value_dir)/$sid.json" ] && return 0
104
+ [ -f "$(_orphan_expired_dir)/$sid.json" ] && return 0
105
+ return 1
106
+ }
107
+
108
+ # ── Phase 2: client-assisted tail extraction marker (2026-06-18) ────
109
+ # 차차가 추출 완료했는지.
110
+ orphan_is_client_extracted() {
111
+ local sid="$1"
112
+ [ -n "$sid" ] || return 1
113
+ [ -f "$(_orphan_client_extracted_dir)/$sid.json" ]
114
+ }
115
+
116
+ # 복구된 orphan 세션을 client 추출 큐에 등록 (이미 추출 완료면 no-op — 반복 저장 차단).
117
+ # P0.1(P2-5): message_count 를 payload 에 보존 → session-start 요약이 정렬·표시에 활용.
118
+ orphan_record_pending() {
119
+ local sid="$1" tpath="$2" mc="${3:-0}"
120
+ _orphan_is_uuid "$sid" || return 1
121
+ orphan_is_client_extracted "$sid" && return 0
122
+ local payload
123
+ payload=$(node -e 'process.stdout.write(JSON.stringify({status:"pending_extraction",transcript_path:process.argv[1]||"",recovered_at:new Date().toISOString(),message_count:Number(process.argv[2]||0)}))' "$tpath" "$mc" 2>/dev/null) || return 1
124
+ _orphan_write_marker "$(_orphan_pending_dir)" "$sid" "$payload"
125
+ }
126
+
127
+ # B5 (spec 2026-07-11 §B5) — directive 준수율 계측 POST. fail-soft + 백그라운드(&) —
128
+ # 계측이 marker·세션 시작 흐름을 절대 막지 않는다(리뷰 F3: batch 마킹 20건 × --max-time 5 =
129
+ # 최대 100초 직렬 지연 차단). origin 은 서버가 local_hook 으로 고정하므로 body 는 directive/phase 만.
130
+ # surface 헤더 필수 — served(session-start)와 acted(여기)가 같은 표면 버킷에 적재돼야
131
+ # bySurface 분리 집계가 성립한다(리뷰 F2).
132
+ # 자격증명은 config-resolve.sh 의 rookie_resolve_credentials(env > 로컬 상향 > 전역)로만 해석 —
133
+ # 전역 config 경로 하드코딩은 B6-1 위반(계정 오염 재발 경로)이라 두지 않는다(리뷰 F4).
134
+ _orphan_directive_metric() {
135
+ local phase="$1"
136
+ if ! command -v rookie_resolve_credentials >/dev/null 2>&1; then
137
+ # 단독 source 환경(차차 셸 등)이면 같은 디렉토리의 config-resolve.sh 지연 로드.
138
+ local _lib_dir; _lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
139
+ # shellcheck source=/dev/null
140
+ [ -n "$_lib_dir" ] && [ -f "$_lib_dir/config-resolve.sh" ] && . "$_lib_dir/config-resolve.sh"
141
+ fi
142
+ command -v rookie_resolve_credentials >/dev/null 2>&1 || return 0
143
+ rookie_resolve_credentials "$PWD" 2>/dev/null || return 0
144
+ [ -n "$ROOKIE_RESOLVED_ENDPOINT" ] && [ -n "$ROOKIE_RESOLVED_TOKEN" ] || return 0
145
+ curl -sS --max-time 5 -X POST \
146
+ -H "Authorization: Bearer $ROOKIE_RESOLVED_TOKEN" -H "Content-Type: application/json" \
147
+ -H "x-rookie-hostname: $(hostname 2>/dev/null || echo unknown)" \
148
+ -H "x-rookie-surface: ${ROOKIE_SURFACE:-claude_code}" \
149
+ -d "{\"directive\":\"orphan_extract\",\"phase\":\"$phase\"}" \
150
+ -o /dev/null "$ROOKIE_RESOLVED_ENDPOINT/api/rookie/metric/directive" >/dev/null 2>&1 &
151
+ disown 2>/dev/null || true
152
+ return 0
153
+ }
154
+
155
+ # 차차가 tail 추출·저장 완료 표시. client_extracted marker + pending 큐에서 제거.
156
+ orphan_mark_client_extracted() {
157
+ local sid="$1"
158
+ _orphan_is_uuid "$sid" || return 1
159
+ _orphan_write_marker "$(_orphan_client_extracted_dir)" "$sid" '{"status":"client_extracted"}' || return 1
160
+ rm -f "$(_orphan_pending_dir)/$sid.json" 2>/dev/null
161
+ _orphan_directive_metric "acted" # B5 acted 계측 (fail-soft, 백그라운드)
162
+ return 0
163
+ }
164
+
165
+ # 처리 대기 목록(아직 client 추출 안 된 orphan 세션)을 1줄당 JSONL 로.
166
+ # P0.1(P2-5): TSV 대신 JSONL `{sid,path,recovered_at,message_count}` — TSV 는 shell read 실수 유발.
167
+ # client_extracted 된 stale pending 은 즉시 정리하고 skip. 깨진 marker 줄은 조용히 skip(fail-soft).
168
+ orphan_pending_client_extraction() {
169
+ local d; d="$(_orphan_pending_dir)"
170
+ [ -d "$d" ] || return 0
171
+ local f sid
172
+ for f in "$d"/*.json; do
173
+ [ -f "$f" ] || continue
174
+ sid="${f##*/}"; sid="${sid%.json}"
175
+ _orphan_is_uuid "$sid" || continue
176
+ if orphan_is_client_extracted "$sid"; then rm -f "$f" 2>/dev/null; continue; fi
177
+ node -e 'try{const fs=require("fs");const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify({sid:process.argv[2],path:j.transcript_path||"",recovered_at:j.recovered_at||"",message_count:j.message_count||0})+"\n");}catch{}' "$f" "$sid" 2>/dev/null
178
+ done
179
+ }
180
+
181
+ # P0.4 — tail 추출 큐(pending) 적재 가치 판정. 0=적재(record_pending), 1=low_value(skip).
182
+ # 메시지 ≤3 그리고 본문에 운영사실 흔적(코드블록·SQL·URL·명령·포트·경로·tool)이 없을 때만 skip.
183
+ # 짧아도 운영사실 있으면 적재. count-only 금지(doc P2-2). 파싱 실패는 보수적으로 적재(가치 간주).
184
+ orphan_pending_is_worthwhile() {
185
+ local messages="$1" mc="${2:-0}"
186
+ [ "${mc:-0}" -gt 3 ] 2>/dev/null && return 0 # 메시지 많으면 무조건 가치
187
+ printf '%s' "$messages" | node -e '
188
+ let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{
189
+ try {
190
+ const a=JSON.parse(d);
191
+ const text=(Array.isArray(a)?a:[]).map(m=>typeof m.content==="string"?m.content:JSON.stringify(m.content||"")).join("\n");
192
+ // 운영사실 흔적 — 하나라도 있으면 적재 가치(exit 0).
193
+ const re=/```|\bSELECT\b|\bINSERT\b|\bUPDATE\b|\bCREATE\b|https?:\/\/|\bnpm\b|\bgit\b|\bcurl\b|\bssh\b|:\d{2,5}\b|\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+|포트|배포|결정/i;
194
+ process.exit(re.test(text)?0:1);
195
+ } catch { process.exit(0); } // 파싱 실패 → 보수적 적재
196
+ });
197
+ ' 2>/dev/null
198
+ }
199
+
200
+ # P0.4 — pending TTL(기본 3일)+ 미처리 → expired marker + pending 제거(삭제 아닌 감사 이동).
201
+ # scanner 가 expired 를 제외하므로 만료 세션의 원본 transcript 재스캔도 막힌다.
202
+ # TTL = ROOKIE_ORPHAN_PENDING_TTL_DAYS(기본 3). find -mtime +N 은 N*24h 초과 의미.
203
+ orphan_prune_expired() {
204
+ local pdir; pdir="$(_orphan_pending_dir)"
205
+ [ -d "$pdir" ] || return 0
206
+ local ttl; ttl="${ROOKIE_ORPHAN_PENDING_TTL_DAYS:-3}"
207
+ local f sid
208
+ while IFS= read -r f; do
209
+ [ -n "$f" ] || continue
210
+ sid="${f##*/}"; sid="${sid%.json}"
211
+ _orphan_is_uuid "$sid" || continue
212
+ orphan_mark_expired "$sid" && rm -f "$f" 2>/dev/null
213
+ done <<EOF
214
+ $(find "$pdir" -maxdepth 1 -name '*.json' -type f -mtime +"$ttl" 2>/dev/null)
215
+ EOF
216
+ return 0
217
+ }
218
+
219
+ # 현재 프로젝트 transcript 디렉토리에서 복구 후보 경로를 stdout 으로(1줄당 1개).
220
+ # allowlist(regular·uuid·≤50MB·symlink제외) + marker 제외 + 현재 sid 제외 + 최근 보류
221
+ # + bootstrap(첫 도입: T창 안 최신 K개만, 나머지 baseline) / 정상(marker 없는 최신 K개).
222
+ orphan_scan_candidates() {
223
+ local pdir="${ROOKIE_ORPHAN_PROJECT_DIR:-$HOME/.claude/projects/$(claude_project_dir_for_cwd "$PWD")}"
224
+ [ -d "$pdir" ] || return 0
225
+ local bootstrap=0
226
+ [ -d "$(_orphan_flushed_dir)" ] || bootstrap=1
227
+ node -e '
228
+ const fs=require("fs"), path=require("path");
229
+ const [pdir, orphanDir, holdS, Ts, Ks, maxBytes, cur, bootstrap, deferCapS] = process.argv.slice(1);
230
+ const now=Date.now()/1000, hold=+holdS, T=+Ts, K=+Ks, maxB=+maxBytes, isBoot=bootstrap==="1";
231
+ const flushedDir=path.join(orphanDir,"flushed"), baselineDir=path.join(orphanDir,"baseline");
232
+ const deferredDir=path.join(orphanDir,"deferred"), deferCap=+deferCapS;
233
+ // P0.4: low_value(짧은 세션)·expired(만료 pending) 도 후보 제외 — sweep 재진입 차단(§9-#4).
234
+ const lowValueDir=path.join(orphanDir,"low_value"), expiredDir=path.join(orphanDir,"expired");
235
+ // §3.2 scanner 는 flushed·baseline·유효 deferred 모두 후보 제외.
236
+ // 유효 deferred = fail_count≥cap(영구 보류) 또는 next_retry_at 미경과(backoff 창).
237
+ const deferBlocked=(sid)=>{ try{
238
+ const j=JSON.parse(fs.readFileSync(path.join(deferredDir,sid+".json"),"utf8"));
239
+ if((j.fail_count||0)>=deferCap) return true;
240
+ const nr=Date.parse(j.next_retry_at); return !isNaN(nr)&&nr/1000>now;
241
+ }catch{ return false; } };
242
+ 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"))||deferBlocked(sid);
243
+ const uuidRe=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
244
+ const writeBaseline=(sid,reason)=>{ try{
245
+ fs.mkdirSync(baselineDir,{recursive:true,mode:0o700});
246
+ const tmp=path.join(baselineDir,"."+sid+".tmp");
247
+ fs.writeFileSync(tmp,JSON.stringify({status:"baseline_skipped",reason}),{mode:0o600});
248
+ fs.renameSync(tmp,path.join(baselineDir,sid+".json"));
249
+ }catch{} };
250
+ // self-reference(claude -p internal) transcript 는 진짜 세션이 아니다. cap 차지 전에
251
+ // scanner 단계에서 제외 + baseline 처리(P1) — 안 그러면 self-ref 최신 파일이 K개 cap 을
252
+ // 모두 먹어 실제 orphan 복구가 굶는다. 첫 user 메시지 선두 센티넬(신규) OR legacy 마커(하위호환).
253
+ // SSOT: src/lib/ai/internal-marker.ts. 전역 substring 폐기 — 본문 인용 false positive 차단(P1-4).
254
+ const SENT="[[ROOKIE_INTERNAL_INVOCATION::v1]]", LEG="the Rookie의 기억 정리 모듈";
255
+ const isSelfRef=(fp)=>{ try{
256
+ const raw=fs.readFileSync(fp,"utf8");
257
+ for(const l of raw.split("\n")){ if(!l.trim())continue;
258
+ let j; try{ j=JSON.parse(l); }catch{ continue; }
259
+ const m=j.message||j; if(m.role!=="user")continue;
260
+ const c=m.content;
261
+ const t=typeof c==="string"?c:Array.isArray(c)?((c.find(x=>x&&x.type==="text"&&typeof x.text==="string")||{}).text||""):"";
262
+ return t.replace(/^\s+/,"").startsWith(SENT)||t.includes(LEG);
263
+ }
264
+ return false;
265
+ }catch{ return false; } };
266
+ let entries=[];
267
+ let names=[]; try { names=fs.readdirSync(pdir); } catch { process.exit(0); }
268
+ for(const name of names){
269
+ if(!name.endsWith(".jsonl")) continue;
270
+ const sid=name.slice(0,-6);
271
+ if(!uuidRe.test(sid)) continue;
272
+ if(sid===cur) continue;
273
+ const fp=path.join(pdir,name);
274
+ let st; try{ st=fs.lstatSync(fp); }catch{ continue; }
275
+ if(!st.isFile()) continue; // symlink/dir 제외
276
+ if(st.size>maxB) continue;
277
+ if((now-st.mtimeMs/1000)<hold) continue; // 최근 수정 보류
278
+ if(marked(sid)) continue;
279
+ if(isSelfRef(fp)){ writeBaseline(sid,"self_reference"); continue; } // P1: cap 차지 전 제외
280
+ entries.push({fp,sid,mtime:st.mtimeMs});
281
+ }
282
+ entries.sort((a,b)=>b.mtime-a.mtime); // newest first
283
+ let pick;
284
+ if(isBoot){
285
+ const inWindow=entries.filter(e=>(now-e.mtime/1000)<=T);
286
+ pick=inWindow.slice(0,K);
287
+ const pickSet=new Set(pick.map(e=>e.sid));
288
+ for(const e of entries){
289
+ if(pickSet.has(e.sid)) continue; // 자동 후보는 baseline 처리 안 함
290
+ writeBaseline(e.sid,"bootstrap_old");
291
+ }
292
+ } else {
293
+ pick=entries.slice(0,K); // latency cap; 나머지는 다음 SessionStart 이월
294
+ }
295
+ for(const e of pick) process.stdout.write(e.fp+"\n");
296
+ ' "$pdir" "$ROOKIE_ORPHAN_DIR" "$ROOKIE_ORPHAN_AGE_HOLD" "$ROOKIE_ORPHAN_BOOTSTRAP_T" \
297
+ "$ROOKIE_ORPHAN_CAP" "$ROOKIE_ORPHAN_MAX_BYTES" "${ROOKIE_ORPHAN_CURRENT_SID:-}" "$bootstrap" \
298
+ "$ROOKIE_ORPHAN_DEFER_CAP" \
299
+ 2>/dev/null
300
+ }
301
+
302
+ # ── telemetry / deferred backoff (§3.4) ─────────────────────────────
303
+ # 모든 로그·deferred 는 stdout 미사용(§3.7) — recovery.log / deferred subdir 로만.
304
+ _orphan_log_telemetry() {
305
+ local sid="$1" msg="$2"
306
+ _orphan_dir_init "$ROOKIE_ORPHAN_DIR"
307
+ printf '%s sid=%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$sid" "$msg" \
308
+ >> "$(_orphan_log)" 2>/dev/null || true
309
+ chmod 600 "$(_orphan_log)" 2>/dev/null || true
310
+ }
311
+
312
+ _orphan_deferred_path() { printf '%s/%s.json' "$(_orphan_deferred_dir)" "$1"; }
313
+
314
+ # deferred 가 유효(영구 보류 or backoff 창)면 exit 0 → recovery 건너뜀.
315
+ _orphan_deferred_blocked() {
316
+ local sid="$1" f
317
+ f="$(_orphan_deferred_path "$sid")"
318
+ [ -f "$f" ] || return 1
319
+ node -e '
320
+ const fs=require("fs");
321
+ try{
322
+ const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
323
+ const cap=+process.argv[2], now=Date.now()/1000;
324
+ if((j.fail_count||0)>=cap) process.exit(0);
325
+ const nr=Date.parse(j.next_retry_at);
326
+ process.exit(!isNaN(nr)&&nr/1000>now ? 0 : 1);
327
+ }catch{ process.exit(1); }
328
+ ' "$f" "${ROOKIE_ORPHAN_DEFER_CAP:-3}" 2>/dev/null
329
+ }
330
+
331
+ # marker write 실패 시 fail_count++ + next_retry_at(6h*fail_count) 기록. 원자적 tmp+mv.
332
+ _orphan_record_deferred() {
333
+ local sid="$1" err="$2" dir tmp f
334
+ _orphan_is_uuid "$sid" || return 1
335
+ dir="$(_orphan_deferred_dir)"
336
+ _orphan_dir_init "$dir"
337
+ f="$dir/$sid.json"
338
+ tmp="$(mktemp "$dir/.tmp.XXXXXX" 2>/dev/null)" || return 1
339
+ ROOKIE_DEFER_ERR="$err" node -e '
340
+ const fs=require("fs");
341
+ const f=process.argv[1], tmp=process.argv[2];
342
+ let prev={fail_count:0};
343
+ try{ prev=JSON.parse(fs.readFileSync(f,"utf8")); }catch{}
344
+ const fc=(prev.fail_count||0)+1;
345
+ const next=new Date(Date.now()+fc*21600*1000).toISOString(); // 6h * fail_count
346
+ fs.writeFileSync(tmp,JSON.stringify({fail_count:fc,next_retry_at:next,last_error:process.env.ROOKIE_DEFER_ERR||""}),{mode:0o600});
347
+ ' "$f" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
348
+ chmod 600 "$tmp" 2>/dev/null
349
+ mv -f "$tmp" "$f" 2>/dev/null || { rm -f "$tmp"; return 1; }
350
+ return 0
351
+ }
352
+
353
+ _orphan_clear_deferred() {
354
+ local sid="$1"
355
+ _orphan_is_uuid "$sid" || return 0 # 비-UUID 면 rm 자체를 안 함 (traversal 차단)
356
+ rm -f "$(_orphan_deferred_path "$sid")" 2>/dev/null
357
+ return 0
358
+ }
359
+
360
+ # transcript messages JSON 의 배열 길이.
361
+ _orphan_messages_count() {
362
+ printf '%s' "$1" | node -e '
363
+ let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{
364
+ try{ const a=JSON.parse(d); process.stdout.write(String(Array.isArray(a)?a.length:0)); }
365
+ catch{ process.stdout.write("0"); }
366
+ });
367
+ ' 2>/dev/null || printf '0'
368
+ }
369
+
370
+ # ── orphan_recover (§3.1·§3.6) ──────────────────────────────────────
371
+ # scan → 각 candidate 를 기존 flush-session 으로 재전송 → content-bearing 성공 시 flushed marker.
372
+ # stdout 미사용(§3.7). 복구 건수는 전역 ROOKIE_ORPHAN_RECOVERED_COUNT.
373
+ # 의존: lib/transcript.sh (source 선행 필요).
374
+ orphan_recover() {
375
+ local endpoint="$1" token="$2" user_id="$3"
376
+ ROOKIE_ORPHAN_RECOVERED_COUNT=0
377
+ [ -n "$endpoint" ] && [ -n "$token" ] || return 0
378
+ command -v transcript_messages_json >/dev/null 2>&1 || return 0
379
+
380
+ local deadline
381
+ deadline=$(( $(date +%s) + ${ROOKIE_ORPHAN_TIME_BUDGET:-8} ))
382
+
383
+ # 이슈② 재발 차단: repo 를 실행 셸 $PWD 로 고정하지 않는다. 후보 transcript 마다 실제 cwd 로
384
+ # 역산(while 루프 안). $PWD 폴백 절대 금지 — sweep_all 이 the-rookie 본진에서 전 프로젝트를
385
+ # 순회하므로 $PWD 고정이면 모든 회수분이 the-rookie 로 흡수된다(인시던트 §5.5).
386
+ local host repo_url repo_path
387
+ host="$(hostname)"
388
+
389
+ local candidates
390
+ candidates="$(orphan_scan_candidates)" || return 0
391
+ [ -n "$candidates" ] || return 0
392
+
393
+ local fp sid messages mc msg_file body_file code
394
+ while IFS= read -r fp; do
395
+ [ -n "$fp" ] || continue
396
+ [ "$(date +%s)" -ge "$deadline" ] && break # §3.6 latency budget
397
+
398
+ sid="$(transcript_session_id_from_path "$fp")"
399
+ [ -n "$sid" ] || continue
400
+ _orphan_deferred_blocked "$sid" && continue # backoff 보류 (scanner 이중 안전)
401
+
402
+ if transcript_has_self_reference "$fp"; then # §3.4 self-ref 오판 차단
403
+ _orphan_log_telemetry "$sid" "skip_self_reference"; continue
404
+ fi
405
+
406
+ # 이슈② repo 역산 — 후보 transcript 의 실제 cwd. ⚠️ $PWD 폴백 절대 금지.
407
+ repo_path="$(transcript_claude_cwd "$fp")"
408
+
409
+ # B6-4 (spec 2026-07-11 §B6): cwd 불명 orphan 은 보류 — 어느 config 소속인지 판별 불가한
410
+ # 것을 현재 config 로 flush 하지 않는다(오귀속 방지). marker 를 안 남기므로 다음 기회에 재시도.
411
+ if [ -z "$repo_path" ]; then
412
+ _orphan_log_telemetry "$sid" "skip_unknown_cwd"; continue
413
+ fi
414
+ # B6-4 (codex R1 P1-2): cwd 디렉토리가 삭제·이동됐으면 판별 불가로 보류 — 로컬 config 를 못
415
+ # 찾는다는 이유로 전역 계정에 폴백 flush 하면 테스트 폴더 삭제 순간 본체로 오귀속된다.
416
+ if [ ! -d "$repo_path" ]; then
417
+ _orphan_log_telemetry "$sid" "skip_missing_cwd_dir"; continue
418
+ fi
419
+ # B6-4 partition: 후보 cwd 기준 유효 config(로컬>전역)가 현재 실행 config 와 다르면 skip —
420
+ # 그 config 의 sweep 몫으로 남긴다(테스트↔본체 계정 교차 전송 차단).
421
+ if command -v rookie_config_path_for_cwd >/dev/null 2>&1 && [ -n "${ROOKIE_ACTIVE_CONFIG:-}" ]; then
422
+ if [ "$(rookie_config_path_for_cwd "$repo_path")" != "$ROOKIE_ACTIVE_CONFIG" ]; then
423
+ _orphan_log_telemetry "$sid" "skip_other_config"; continue
424
+ fi
425
+ fi
426
+ # B6-3 (codex R1 P1-1): 세션 identity pin 이 있고 현재 토큰과 다르면 보류 — Stop flush 를
427
+ # 막아도 orphan/sweep 이 새 계정 토큰으로 재전송하면 같은 오귀속. pin 주인 계정으로 다시
428
+ # 로그인되면 그때 회수된다(pin TTL 14일 = pending prune 과 동일 감각).
429
+ if command -v identity_pin_check >/dev/null 2>&1 && ! identity_pin_check "$sid" "$token"; then
430
+ _orphan_log_telemetry "$sid" "skip_identity_mismatch"; continue
431
+ fi
432
+
433
+ repo_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null || echo "")"
434
+
435
+ messages="$(transcript_messages_json "$fp")"
436
+ mc="$(_orphan_messages_count "$messages")"
437
+ if [ "${mc:-0}" -le 0 ] 2>/dev/null; then # §3.2 messages 0 → POST 생략
438
+ _orphan_log_telemetry "$sid" "skip_empty_messages"; continue
439
+ fi
440
+
441
+ # P3: messages 를 env/argv 가 아닌 임시 파일로 전달 — 큰 transcript(긴 content)에서
442
+ # ARG_MAX/env 한계로 body build·POST 가 깨지는 것 방지. curl 도 -d @file 로 송신.
443
+ msg_file="$(mktemp 2>/dev/null)" || continue
444
+ printf '%s' "$messages" > "$msg_file" 2>/dev/null
445
+ body_file="$(mktemp 2>/dev/null)" || { rm -f "$msg_file"; continue; }
446
+ node -e '
447
+ const fs=require("fs");
448
+ const b={session_id:process.argv[1],hostname:process.argv[2],repo_path:process.argv[4],transcript_path:process.argv[5]};
449
+ if(process.argv[3]) b.repo_url=process.argv[3];
450
+ try{ const m=JSON.parse(fs.readFileSync(process.argv[6],"utf8")||"[]"); if(Array.isArray(m)&&m.length) b.messages=m; }catch{}
451
+ process.stdout.write(JSON.stringify(b));
452
+ ' "$sid" "$host" "$repo_url" "$repo_path" "$fp" "$msg_file" > "$body_file" 2>/dev/null
453
+ rm -f "$msg_file"
454
+ if [ ! -s "$body_file" ]; then
455
+ rm -f "$body_file"; _orphan_log_telemetry "$sid" "skip_body_build"; continue
456
+ fi
457
+
458
+ code="$(curl -sS --max-time 10 -X POST \
459
+ -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
460
+ -H "x-rookie-user-id: $user_id" --data-binary @"$body_file" \
461
+ -o /dev/null -w "%{http_code}" \
462
+ "$endpoint/api/rookie/flush-session" 2>/dev/null)" || code="000"
463
+ rm -f "$body_file"
464
+
465
+ # P0.4 Step 4 (codex #10): marker 조건 = code 200 + message_count>0 (chunks_inserted 무관).
466
+ # mc 는 line "skip_empty_messages" 게이트로 이미 >0 보장. 멱등 재전송(chunks 0)도 marker 박아
467
+ # 복구 가능 세션이 영구 재시도되지 않게 한다. transcript 파싱 실패·빈 본문은 그 게이트가 차단.
468
+ if [ "$code" = "200" ] && [ "${mc:-0}" -gt 0 ] 2>/dev/null; then
469
+ if orphan_mark_flushed "$sid" "orphan" "$mc"; then
470
+ _orphan_clear_deferred "$sid"
471
+ # Phase 2: chunk 복구는 끝 — fact tail 큐 등록. 단 P0.4 Step 1: 짧고 내용 없는 세션은
472
+ # 큐에 안 올리고 low_value marker(차차 추출 작업 굶기지 않게). 운영사실 있으면 적재.
473
+ if orphan_pending_is_worthwhile "$messages" "$mc"; then
474
+ orphan_record_pending "$sid" "$fp" "$mc" || true
475
+ else
476
+ orphan_mark_low_value "$sid" "short_no_content" "$mc" || true
477
+ fi
478
+ ROOKIE_ORPHAN_RECOVERED_COUNT=$((ROOKIE_ORPHAN_RECOVERED_COUNT + 1))
479
+ else
480
+ # 전송은 멱등이라 데이터 안전 — marker write 실패만 backoff (§3.4).
481
+ _orphan_record_deferred "$sid" "marker_write_failed"
482
+ _orphan_log_telemetry "$sid" "marker_write_failed"
483
+ fi
484
+ else
485
+ _orphan_log_telemetry "$sid" "flush_failed code=$code mc=$mc"
486
+ fi
487
+ done <<EOF
488
+ $candidates
489
+ EOF
490
+ return 0
491
+ }