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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/bin/rookie.js +6 -0
- package/package.json +40 -0
- package/src/api.js +67 -0
- package/src/cli.js +139 -0
- package/src/commands/cluster-todos.js +74 -0
- package/src/commands/codex-setup.js +36 -0
- package/src/commands/context-diet.js +80 -0
- package/src/commands/doctor.js +710 -0
- package/src/commands/done-scan.js +162 -0
- package/src/commands/init.js +175 -0
- package/src/commands/link.js +53 -0
- package/src/commands/login.js +160 -0
- package/src/commands/orphan-extract.js +44 -0
- package/src/commands/save.js +91 -0
- package/src/commands/scan-specs.js +153 -0
- package/src/commands/status.js +44 -0
- package/src/commands/sweep-recovery.js +33 -0
- package/src/commands/token.js +105 -0
- package/src/commands/unlink.js +40 -0
- package/src/commands/update.js +220 -0
- package/src/config.js +65 -0
- package/src/detect.js +109 -0
- package/src/lib/account-ui.js +28 -0
- package/src/lib/apply-tool-setup.js +89 -0
- package/src/lib/codex-config.js +46 -0
- package/src/lib/connect-ui.js +59 -0
- package/src/lib/context-diet.js +628 -0
- package/src/lib/dead-letter.js +73 -0
- package/src/lib/deploy-artifacts.js +185 -0
- package/src/lib/hook-command.js +31 -0
- package/src/lib/manifest.js +87 -0
- package/src/lib/merge-hooks.js +31 -0
- package/src/lib/orphan-extract-core.js +113 -0
- package/src/lib/orphan-pending.js +49 -0
- package/src/lib/parse-hook-cwd.js +11 -0
- package/src/lib/poll-state.js +14 -0
- package/src/lib/redact-lite.js +55 -0
- package/src/lib/resolve-endpoint.js +8 -0
- package/src/lib/self-update.js +79 -0
- package/src/lib/skill-installer.js +55 -0
- package/src/lib/spec-scanner.js +85 -0
- package/src/lib/sweep-core.mjs +423 -0
- package/src/lib/task-path-match.js +122 -0
- package/src/prompt.js +60 -0
- package/templates/hook/bin/posttool-recall-nudge.sh +96 -0
- package/templates/hook/bin/pretool-md-read.sh +107 -0
- package/templates/hook/flush-session.sh +314 -0
- package/templates/hook/guard-destructive-db.sh +24 -0
- package/templates/hook/lib/config-resolve.sh +106 -0
- package/templates/hook/lib/cross-runtime-sweep.sh +202 -0
- package/templates/hook/lib/dispatcher.sh +423 -0
- package/templates/hook/lib/orphan-recovery.sh +491 -0
- package/templates/hook/lib/queue.sh +314 -0
- package/templates/hook/lib/skill-gate.sh +28 -0
- package/templates/hook/lib/transcript.sh +166 -0
- package/templates/hook/refresh-context.sh +163 -0
- package/templates/hook/session-start.sh +417 -0
- package/templates/hook/settings.partial.json +55 -0
- package/templates/hook/sweep-recovery.sh +43 -0
- package/templates/hook/user-prompt-skill-gate.sh +90 -0
- package/templates/launchd/com.rollingmango.rookie-sweep.plist +38 -0
- package/templates/skill/SKILL.md +214 -0
- 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
|
+
}
|