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
+ # Rookie hook offline queue 라이브러리
3
+ # spec: docs/superpowers/specs/2026-04-23-post-v5-decisions.md §17
4
+ # plan: docs/superpowers/plans/2026-04-30-hook-offline-queue.md
5
+ #
6
+ # 함수:
7
+ # queue_dir_init 큐 디렉토리 자동 생성 + 권한 강제
8
+ # queue_enqueue <session_id> <body> [code] 큐에 1줄 append
9
+ # queue_count 큐 라인 합계
10
+ # queue_oldest_age_seconds 가장 오래된 항목 경과 초
11
+ # queue_drain <endpoint> <token> <user_id> 큐 batch POST → 200 시 파일 삭제
12
+ # queue_record_success 마지막 성공 시각 기록
13
+ # last_success_minutes_ago 마지막 성공 후 경과 분 (없으면 -1)
14
+ # queue_warning_prefix <minutes> <count> 두목 경고 메시지 출력
15
+
16
+ ROOKIE_QUEUE_DIR="${ROOKIE_QUEUE_DIR:-$HOME/.rookie/pending}"
17
+ ROOKIE_DEAD_LETTER_DIR="${ROOKIE_DEAD_LETTER_DIR:-$HOME/.rookie/dead-letter}"
18
+ ROOKIE_LAST_SUCCESS_FILE="${ROOKIE_LAST_SUCCESS_FILE:-$HOME/.rookie/last_success}"
19
+ # spec post-v5-decisions §24 Q-1: 영구 실패 항목 dead-letter 격리.
20
+ # hook 발사 분당 1 미만 → 50회 재시도면 ~50분~수 시간. 그 이상은 재시도 가치 없음.
21
+ ROOKIE_QUEUE_RETRY_CAP="${ROOKIE_QUEUE_RETRY_CAP:-50}"
22
+
23
+ queue_dir_init() {
24
+ if [ ! -d "$ROOKIE_QUEUE_DIR" ]; then
25
+ mkdir -m 700 -p "$ROOKIE_QUEUE_DIR" 2>/dev/null
26
+ else
27
+ chmod 700 "$ROOKIE_QUEUE_DIR" 2>/dev/null
28
+ fi
29
+ }
30
+
31
+ dead_letter_dir_init() {
32
+ if [ ! -d "$ROOKIE_DEAD_LETTER_DIR" ]; then
33
+ mkdir -m 700 -p "$ROOKIE_DEAD_LETTER_DIR" 2>/dev/null
34
+ else
35
+ chmod 700 "$ROOKIE_DEAD_LETTER_DIR" 2>/dev/null
36
+ fi
37
+ }
38
+
39
+ dead_letter_count() {
40
+ if ! compgen -G "$ROOKIE_DEAD_LETTER_DIR/*.jsonl" > /dev/null 2>&1; then
41
+ echo 0
42
+ return
43
+ fi
44
+ local n
45
+ n=$(cat "$ROOKIE_DEAD_LETTER_DIR"/*.jsonl 2>/dev/null | grep -c '.' 2>/dev/null) || n=0
46
+ echo "${n:-0}" | tr -d ' '
47
+ }
48
+
49
+ queue_enqueue() {
50
+ local session_id="$1"
51
+ local body="$2"
52
+ local code="${3:-}"
53
+ [ -z "$session_id" ] && return 1
54
+ [ -z "$body" ] && return 1
55
+ queue_dir_init
56
+ local file="$ROOKIE_QUEUE_DIR/$session_id.jsonl"
57
+ local line
58
+ line=$(node -e "
59
+ try {
60
+ const body = JSON.parse(process.argv[1]);
61
+ body.queued_at = new Date().toISOString();
62
+ body.retry_count = 0;
63
+ body.last_error_status = process.argv[2] || null;
64
+ process.stdout.write(JSON.stringify(body));
65
+ } catch (e) {
66
+ process.exit(1);
67
+ }
68
+ " "$body" "$code" 2>/dev/null) || return 1
69
+ (umask 077; printf '%s\n' "$line" >> "$file") || return 1
70
+ chmod 600 "$file" 2>/dev/null
71
+ return 0
72
+ }
73
+
74
+ queue_count() {
75
+ if ! compgen -G "$ROOKIE_QUEUE_DIR/*.jsonl" > /dev/null 2>&1; then
76
+ echo 0
77
+ return
78
+ fi
79
+ local n
80
+ n=$(cat "$ROOKIE_QUEUE_DIR"/*.jsonl 2>/dev/null | grep -c '.' 2>/dev/null) || n=0
81
+ echo "${n:-0}" | tr -d ' '
82
+ }
83
+
84
+ queue_oldest_age_seconds() {
85
+ if ! compgen -G "$ROOKIE_QUEUE_DIR/*.jsonl" > /dev/null 2>&1; then
86
+ echo 0
87
+ return
88
+ fi
89
+ node -e "
90
+ const fs=require('fs'), path=require('path');
91
+ const dir = process.argv[1];
92
+ let oldest = null;
93
+ try {
94
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.jsonl'))) {
95
+ const data = fs.readFileSync(path.join(dir, f), 'utf8');
96
+ for (const line of data.split('\n').filter(Boolean)) {
97
+ try {
98
+ const obj = JSON.parse(line);
99
+ const t = Date.parse(obj.queued_at);
100
+ if (!isNaN(t) && (oldest === null || t < oldest)) oldest = t;
101
+ } catch {}
102
+ }
103
+ }
104
+ } catch {}
105
+ process.stdout.write(oldest === null ? '0' : String(Math.max(0, Math.floor((Date.now() - oldest) / 1000))));
106
+ " "$ROOKIE_QUEUE_DIR" 2>/dev/null || echo 0
107
+ }
108
+
109
+ # 내부: 파일 내 모든 라인의 retry_count++ + last_error_status 갱신.
110
+ # spec §24 Q-1: retry_count >= cap 라인은 dead-letter 디렉토리로 이동 (영구 실패 격리).
111
+ # 라인 단위 분리: 일부 라인만 cap 초과면 그 라인만 옮기고 나머지는 pending 잔류.
112
+ _queue_increment_retry() {
113
+ local file="$1"
114
+ local code="$2"
115
+ local cap="${ROOKIE_QUEUE_RETRY_CAP:-50}"
116
+ dead_letter_dir_init
117
+ local dead_file="$ROOKIE_DEAD_LETTER_DIR/$(basename "$file")"
118
+ node -e "
119
+ const fs=require('fs');
120
+ const file=process.argv[1];
121
+ const code=process.argv[2] || null;
122
+ const cap=parseInt(process.argv[3] || '50', 10);
123
+ const deadFile=process.argv[4];
124
+ try {
125
+ const data=fs.readFileSync(file,'utf8');
126
+ const keep = [];
127
+ const dead = [];
128
+ for (const line of data.split('\n').filter(Boolean)) {
129
+ try {
130
+ const obj=JSON.parse(line);
131
+ obj.retry_count = (obj.retry_count || 0) + 1;
132
+ obj.last_error_status = code;
133
+ if (obj.retry_count >= cap) {
134
+ obj.dead_letter_at = new Date().toISOString();
135
+ dead.push(JSON.stringify(obj));
136
+ } else {
137
+ keep.push(JSON.stringify(obj));
138
+ }
139
+ } catch { keep.push(line); }
140
+ }
141
+ if (keep.length === 0) {
142
+ try { fs.unlinkSync(file); } catch {}
143
+ } else {
144
+ fs.writeFileSync(file, keep.join('\n') + '\n', { mode: 0o600 });
145
+ }
146
+ if (dead.length > 0) {
147
+ let prev = '';
148
+ try { prev = fs.readFileSync(deadFile, 'utf8'); } catch {}
149
+ const next = (prev ? prev.replace(/\n*$/, '\n') : '') + dead.join('\n') + '\n';
150
+ fs.writeFileSync(deadFile, next, { mode: 0o600 });
151
+ }
152
+ } catch {}
153
+ " "$file" "$code" "$cap" "$dead_file" 2>/dev/null || true
154
+ }
155
+
156
+ # spec 2026-06-18 §3.2 marker 경로 2 — flush 큐 파일 전체 성공 후 flushed marker(source=queue).
157
+ # orphan-recovery.sh 가 source 됐을 때만(hook 컨텍스트) 동작. standalone queue 사용엔 no-op.
158
+ # - mutate 류(*-mutate / *-mutate-retry) 는 flush marker 미생성.
159
+ # - basename ≠ body session_id → 미생성 + telemetry (3차 P2-1).
160
+ # - P2 (2026-06-24 audit): marker 조건 = body message_count>0 (chunks_inserted 무관). orphan 경로와 통일 —
161
+ # 멱등 replay(chunks 0)도 marker 박아 큐는 비웠는데 orphan/sweep scanner 가 같은 transcript 를 재후보화하는 비대칭 차단.
162
+ _queue_maybe_mark_flushed() {
163
+ local file="$1" body_mc="$2" sid_mismatch="$3"
164
+ command -v orphan_mark_flushed >/dev/null 2>&1 || return 0
165
+ local base="${file##*/}"
166
+ base="${base%.jsonl}"
167
+ case "$base" in
168
+ *-mutate|*-mutate-retry) return 0 ;;
169
+ esac
170
+ # P2: 파일 내 한 line 이라도 basename 과 session_id 불일치면 marker 미생성 + telemetry.
171
+ if [ "$sid_mismatch" = "1" ]; then
172
+ if command -v _orphan_log_telemetry >/dev/null 2>&1; then
173
+ _orphan_log_telemetry "$base" "queue_marker_skip_sid_mismatch"
174
+ fi
175
+ return 0
176
+ fi
177
+ if [ "${body_mc:-0}" -gt 0 ] 2>/dev/null; then
178
+ orphan_mark_flushed "$base" "queue" "${body_mc:-0}" || true
179
+ fi
180
+ return 0
181
+ }
182
+
183
+ queue_drain() {
184
+ local endpoint="$1"
185
+ local token="$2"
186
+ local user_id="$3"
187
+ [ -z "$endpoint" ] && return 1
188
+ [ -z "$token" ] && return 1
189
+ if ! compgen -G "$ROOKIE_QUEUE_DIR/*.jsonl" > /dev/null 2>&1; then
190
+ return 0
191
+ fi
192
+ local total_success=0
193
+ local total_failure=0
194
+ for file in "$ROOKIE_QUEUE_DIR"/*.jsonl; do
195
+ [ -f "$file" ] || continue
196
+ local file_failed=0
197
+ local last_code="000"
198
+ local last_body_mc file_sid_mismatch base
199
+ last_body_mc=0
200
+ file_sid_mismatch=0
201
+ base="${file##*/}"; base="${base%.jsonl}"
202
+ local target_endpoint
203
+ # spec post-v5-decisions §17.2 H-2.2 — *-mutate-retry.jsonl 도 batch-mutate 로 라우팅.
204
+ case "$(basename "$file")" in
205
+ *-mutate.jsonl|*-mutate-retry.jsonl) target_endpoint="$endpoint/api/rookie/batch-mutate" ;;
206
+ *) target_endpoint="$endpoint/api/rookie/flush-session" ;;
207
+ esac
208
+ while IFS= read -r line; do
209
+ [ -z "$line" ] && continue
210
+ local body
211
+ body=$(node -e "
212
+ try {
213
+ const obj = JSON.parse(process.argv[1]);
214
+ delete obj.queued_at;
215
+ delete obj.retry_count;
216
+ delete obj.last_error_status;
217
+ process.stdout.write(JSON.stringify(obj));
218
+ } catch (e) { process.exit(1); }
219
+ " "$line" 2>/dev/null) || { file_failed=1; break; }
220
+ local code
221
+ # spec 2026-07-06 §F-1 — x-rookie-hostname 전송(세션 project repo_path 매칭). drain 은
222
+ # origin 호스트에서 실행되므로 $(hostname) == enqueue 시점 호스트.
223
+ code=$(curl -sS --max-time 10 -o /dev/null -w "%{http_code}" \
224
+ -X POST \
225
+ -H "Authorization: Bearer $token" \
226
+ -H "Content-Type: application/json" \
227
+ -H "x-rookie-user-id: $user_id" \
228
+ -H "x-rookie-hostname: $(hostname)" \
229
+ -d "$body" \
230
+ "$target_endpoint" 2>/dev/null) || code="000"
231
+ last_code="$code"
232
+ if [ "$code" = "200" ]; then
233
+ total_success=$((total_success + 1))
234
+ # spec 2026-06-18 §3.2 (P2): 파일의 **모든** line body session_id 를 basename 과 대조.
235
+ # 한 line 이라도 불일치하면 marker 미생성 — 마지막 line 만 보던 누락 차단.
236
+ local body_sid
237
+ body_sid=$(printf '%s' "$body" | node -e "
238
+ let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{
239
+ try{const j=JSON.parse(d);process.stdout.write(String(j.session_id||''));}catch{process.stdout.write('');}});
240
+ " 2>/dev/null || echo "")
241
+ if [ -n "$body_sid" ] && [ "$body_sid" != "$base" ]; then file_sid_mismatch=1; fi
242
+ last_body_mc=$(printf '%s' "$body" | node -e "
243
+ let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{
244
+ try{const j=JSON.parse(d);process.stdout.write(String(Array.isArray(j.messages)?j.messages.length:0));}catch{process.stdout.write('0');}});
245
+ " 2>/dev/null || echo 0)
246
+ else
247
+ file_failed=1
248
+ total_failure=$((total_failure + 1))
249
+ break
250
+ fi
251
+ done < "$file"
252
+ if [ "$file_failed" = "0" ]; then
253
+ # spec 2026-06-18 §3.2 marker 경로 2 — flush 큐 파일 전체 성공 시 flushed marker(source=queue).
254
+ _queue_maybe_mark_flushed "$file" "$last_body_mc" "$file_sid_mismatch"
255
+ rm -f "$file"
256
+ else
257
+ _queue_increment_retry "$file" "$last_code"
258
+ fi
259
+ done
260
+ printf '%d %d\n' "$total_success" "$total_failure"
261
+ }
262
+
263
+ queue_record_success() {
264
+ (umask 077; date -u +"%Y-%m-%dT%H:%M:%SZ" > "$ROOKIE_LAST_SUCCESS_FILE" 2>/dev/null) || true
265
+ chmod 600 "$ROOKIE_LAST_SUCCESS_FILE" 2>/dev/null
266
+ }
267
+
268
+ last_success_minutes_ago() {
269
+ if [ ! -f "$ROOKIE_LAST_SUCCESS_FILE" ]; then
270
+ echo "-1"
271
+ return
272
+ fi
273
+ local last
274
+ last=$(cat "$ROOKIE_LAST_SUCCESS_FILE" 2>/dev/null)
275
+ if [ -z "$last" ]; then
276
+ echo "-1"
277
+ return
278
+ fi
279
+ node -e "
280
+ const last = process.argv[1];
281
+ const t = Date.parse(last);
282
+ if (isNaN(t)) { process.stdout.write('-1'); }
283
+ else process.stdout.write(String(Math.max(0, Math.floor((Date.now() - t) / 60000))));
284
+ " "$last" 2>/dev/null || echo "-1"
285
+ }
286
+
287
+ queue_warning_prefix() {
288
+ local mins="$1"
289
+ local count="$2"
290
+ local mins_str
291
+ if [ "$mins" = "-1" ]; then
292
+ mins_str="이력 없음"
293
+ else
294
+ mins_str="${mins}분 전"
295
+ fi
296
+ # spec post-v5-decisions §17.2 H-3 (2026-05-05 v6 P1 #3 보강) — 큐 카운트를 endpoint 별로 분리.
297
+ # mutate=*-mutate.jsonl / retry=*-mutate-retry.jsonl / flush=그 외
298
+ local mutate_n=0 retry_n=0 flush_n=0
299
+ if compgen -G "$ROOKIE_QUEUE_DIR/*.jsonl" > /dev/null 2>&1; then
300
+ local f base
301
+ for f in "$ROOKIE_QUEUE_DIR"/*.jsonl; do
302
+ [ -f "$f" ] || continue
303
+ base=$(basename "$f")
304
+ case "$base" in
305
+ *-mutate-retry.jsonl) retry_n=$((retry_n + 1)) ;;
306
+ *-mutate.jsonl) mutate_n=$((mutate_n + 1)) ;;
307
+ *) flush_n=$((flush_n + 1)) ;;
308
+ esac
309
+ done
310
+ fi
311
+ printf '⚠️ Rookie 서버 도달 실패 (마지막 성공 %s / 큐 %s건: mutate=%d retry=%d flush=%d).\n' \
312
+ "$mins_str" "$count" "$mutate_n" "$retry_n" "$flush_n"
313
+ printf ' 네트워크·tailscale·:3000 상태 확인 필요. 작업은 로컬 큐에 적재 중.\n'
314
+ }
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # lib/skill-gate.sh — skill-gate marker/epoch 공용 헬퍼.
3
+ # 설계: docs/superpowers/plans/2026-06-24-sessionstart-skill-load-enforcement.md §3.3·§3.4
4
+ # session-start.sh 가 source 해서 /clear·compact 시 epoch 기록에 쓴다.
5
+ # marker 키 = sha256(session_id) — path traversal 방어 + 모든 session_id 형식에서 작동.
6
+
7
+ skill_gate_dir() { printf '%s' "${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"; }
8
+
9
+ # sha256(session_id) hex. 빈 입력이면 빈 문자열.
10
+ skill_gate_key() {
11
+ [ -n "${1:-}" ] || { printf ''; return 0; }
12
+ printf '%s' "$1" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(require("crypto").createHash("sha256").update(d).digest("hex"))}catch{}})' 2>/dev/null
13
+ }
14
+
15
+ # /clear·compact 경계: 현재 transcript byte size 를 <key>.epoch 로 기록 + <key>.loaded marker 삭제.
16
+ # 이후 UserPromptSubmit gate 는 그 offset 이후만 scan → 과거 Skill 호출을 현재 로드로 오인하지 않는다.
17
+ skill_gate_record_epoch() {
18
+ local sid="${1:-}" tpath="${2:-}" dir key size
19
+ [ -n "$sid" ] && [ -n "$tpath" ] || return 0
20
+ dir="$(skill_gate_dir)"
21
+ key="$(skill_gate_key "$sid")"
22
+ [ -n "$key" ] || return 0
23
+ size=$(node -e 'try{process.stdout.write(String(require("fs").statSync(process.argv[1]).size))}catch{process.stdout.write("0")}' "$tpath" 2>/dev/null)
24
+ mkdir -p "$dir" 2>/dev/null
25
+ printf '%s' "${size:-0}" > "$dir/$key.epoch" 2>/dev/null
26
+ rm -f "$dir/$key.loaded" 2>/dev/null
27
+ return 0
28
+ }
@@ -0,0 +1,166 @@
1
+ #!/bin/bash
2
+ # lib/transcript.sh — Claude Code transcript(.jsonl) 변환 공유 helper.
3
+ # spec docs/superpowers/specs/2026-06-18-crash-recovery-orphan-flush.md §3.8 (재리뷰 P2-3):
4
+ # flush-session.sh 와 session-start.sh(orphan recovery) 가 동일 추출 구현을 쓰도록 단일화.
5
+ # 추출 로직은 flush-session.sh §27 D-Multimac-1 / turn limit 200 (D-Multimac-3) 에서 이관.
6
+ #
7
+ # 함수:
8
+ # transcript_messages_json <path> messages[] JSON (user/assistant text, turn≤200)
9
+ # transcript_has_self_reference <path> exit 0 if fact-추출 마커 있음, 1 if 없음
10
+ # transcript_session_id_from_path <path> basename 에서 .jsonl 제거한 session_id
11
+
12
+ # jsonl → messages[] JSON. 없는 파일/빈 파일 → '[]'. 항상 stdout 으로 1줄 출력.
13
+ transcript_messages_json() {
14
+ local path="$1"
15
+ node -e "
16
+ const fs = require('node:fs');
17
+ const path = process.argv[1];
18
+ if (!path || !fs.existsSync(path)) { console.log('[]'); process.exit(0); }
19
+ let raw;
20
+ try { raw = fs.readFileSync(path, 'utf8'); } catch { console.log('[]'); process.exit(0); }
21
+ const out = [];
22
+ for (const line of raw.split('\n')) {
23
+ const t = line.trim(); if (!t) continue;
24
+ try {
25
+ const j = JSON.parse(t);
26
+ const m = j.message || j;
27
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
28
+ let c = m.content;
29
+ if (typeof c !== 'string') {
30
+ if (Array.isArray(c)) {
31
+ c = c.filter(b => b && b.type === 'text' && typeof b.text === 'string')
32
+ .map(b => b.text).join('\n').trim();
33
+ } else { c = ''; }
34
+ } else { c = c.trim(); }
35
+ if (!c) continue;
36
+ out.push({ role: m.role, content: c });
37
+ } catch {}
38
+ }
39
+ const TURN_LIMIT = 200;
40
+ let pairs = 0;
41
+ for (let i = 1; i < out.length; i++) {
42
+ if (out[i-1].role === 'user' && out[i].role === 'assistant') pairs++;
43
+ }
44
+ if (pairs > TURN_LIMIT) {
45
+ let dropPairs = pairs - TURN_LIMIT;
46
+ let dropIdx = 0;
47
+ while (dropPairs > 0 && dropIdx < out.length - 1) {
48
+ if (out[dropIdx].role === 'user' && out[dropIdx+1].role === 'assistant') {
49
+ dropIdx += 2; dropPairs--;
50
+ } else { dropIdx++; }
51
+ }
52
+ out.splice(0, dropIdx);
53
+ }
54
+ console.log(JSON.stringify(out));
55
+ " "$path" 2>/dev/null || echo "[]"
56
+ }
57
+
58
+ # self-reference 방지(spec O-4) 2차 방어선: transcript 첫 user 메시지 content 가
59
+ # ① 센티넬 [[ROOKIE_INTERNAL_INVOCATION::v1]] 로 시작(구조적, 신규 — 모든 internal claude -p 공유) 또는
60
+ # ② legacy fact-extraction 마커 포함(하위호환)
61
+ # 이면 exit 0. SSOT: src/lib/ai/internal-marker.ts. 전역 4KB substring 폐기 — 본문 인용
62
+ # false positive 차단(P1-4): 인시던트를 논의하며 센티넬을 본문에 인용한 일반 대화는 self-ref 가 아니다.
63
+ transcript_has_self_reference() {
64
+ local path="$1"
65
+ [ -n "$path" ] && [ -f "$path" ] || return 1
66
+ node -e '
67
+ const fs=require("fs"); const SENT="[[ROOKIE_INTERNAL_INVOCATION::v1]]", LEG="the Rookie의 기억 정리 모듈";
68
+ let raw=""; try{ raw=fs.readFileSync(process.argv[1],"utf8"); }catch{ process.exit(1); }
69
+ for(const l of raw.split("\n")){ if(!l.trim())continue;
70
+ let j; try{ j=JSON.parse(l); }catch{ continue; }
71
+ const m=j.message||j; if(m.role!=="user")continue;
72
+ const c=m.content;
73
+ const t=typeof c==="string"?c:Array.isArray(c)?((c.find(x=>x&&x.type==="text"&&typeof x.text==="string")||{}).text||""):"";
74
+ process.exit(t.replace(/^\s+/,"").startsWith(SENT)||t.includes(LEG)?0:1);
75
+ }
76
+ process.exit(1);
77
+ ' "$path" 2>/dev/null
78
+ }
79
+
80
+ # Claude transcript jsonl 의 첫 cwd 필드 (없으면 빈 문자열). Codex 의 transcript_codex_cwd 와 대칭.
81
+ # orphan 회수 시 repo 를 실행 셸 $PWD 가 아니라 후보 transcript 의 실제 cwd 로 역산 — 이슈② 재발 차단.
82
+ transcript_claude_cwd() {
83
+ local path="$1"
84
+ [ -n "$path" ] && [ -f "$path" ] || { printf ''; return 0; }
85
+ node -e '
86
+ const fs=require("fs");
87
+ let raw=""; try{ raw=fs.readFileSync(process.argv[1],"utf8"); }catch{ process.exit(0); }
88
+ for(const l of raw.split("\n")){ if(!l.trim())continue;
89
+ try{ const j=JSON.parse(l); if(j.cwd){ process.stdout.write(String(j.cwd)); break; } }catch{}
90
+ }
91
+ ' "$path" 2>/dev/null
92
+ }
93
+
94
+ # transcript 파일 경로 → session_id (Claude Code 는 <session_id>.jsonl 로 저장).
95
+ # 외부 basename 의존 제거 — 순수 파라미터 확장(PATH 미오염 hook 환경 견고성).
96
+ transcript_session_id_from_path() {
97
+ local path="$1"
98
+ local base="${path##*/}"
99
+ printf '%s' "${base%.jsonl}"
100
+ }
101
+
102
+ # ── Codex(rollout) 어댑터 (spec 2026-06-18 §4 Phase 3) ──────────────
103
+ # Codex 세션 포맷은 Claude 와 다르다: 줄마다 {timestamp,type,payload}.
104
+ # message = payload.type==='message', payload.role, payload.content[]{type:input_text|output_text|text, text}
105
+ # session_meta(첫 줄) payload.cwd = 프로젝트 매칭 / payload.id = session_id
106
+ # Claude 파서(transcript_messages_json)는 무변경 — 런타임별 어댑터로 분리.
107
+
108
+ # Codex rollout(.jsonl) → messages[] JSON (user/assistant text, turn≤200). 없/빈 → '[]'.
109
+ transcript_messages_json_codex() {
110
+ local path="$1"
111
+ node -e "
112
+ const fs=require('node:fs');
113
+ const path=process.argv[1];
114
+ if(!path||!fs.existsSync(path)){console.log('[]');process.exit(0);}
115
+ let raw; try{raw=fs.readFileSync(path,'utf8');}catch{console.log('[]');process.exit(0);}
116
+ const out=[];
117
+ for(const line of raw.split('\n')){
118
+ const t=line.trim(); if(!t) continue;
119
+ let j; try{ j=JSON.parse(t); }catch{ continue; }
120
+ const p=j.payload||{};
121
+ if(p.type!=='message') continue;
122
+ if(p.role!=='user'&&p.role!=='assistant') continue;
123
+ let c=p.content;
124
+ if(Array.isArray(c)){
125
+ c=c.filter(b=>b&&typeof b.text==='string'&&(b.type==='input_text'||b.type==='output_text'||b.type==='text'))
126
+ .map(b=>b.text).join('\n').trim();
127
+ } else if(typeof c==='string'){ c=c.trim(); } else { c=''; }
128
+ if(!c) continue;
129
+ out.push({role:p.role, content:c});
130
+ }
131
+ const TURN_LIMIT=200; let pairs=0;
132
+ for(let i=1;i<out.length;i++){ if(out[i-1].role==='user'&&out[i].role==='assistant')pairs++; }
133
+ if(pairs>TURN_LIMIT){
134
+ let dropPairs=pairs-TURN_LIMIT, dropIdx=0;
135
+ while(dropPairs>0&&dropIdx<out.length-1){
136
+ if(out[dropIdx].role==='user'&&out[dropIdx+1].role==='assistant'){dropIdx+=2;dropPairs--;}
137
+ else dropIdx++;
138
+ }
139
+ out.splice(0,dropIdx);
140
+ }
141
+ console.log(JSON.stringify(out));
142
+ " "$path" 2>/dev/null || echo '[]'
143
+ }
144
+
145
+ # Codex rollout 첫 줄 session_meta 의 cwd (프로젝트 매칭용). 첫 64KB 만 읽음.
146
+ transcript_codex_cwd() {
147
+ local path="$1"
148
+ [ -f "$path" ] || return 1
149
+ node -e "
150
+ const fs=require('node:fs');
151
+ try{
152
+ const fd=fs.openSync(process.argv[1],'r'); const buf=Buffer.alloc(65536);
153
+ const n=fs.readSync(fd,buf,0,65536,0); fs.closeSync(fd);
154
+ const first=buf.toString('utf8',0,n).split('\n').find(l=>l.trim());
155
+ const j=JSON.parse(first);
156
+ if(j.type==='session_meta'&&j.payload&&j.payload.cwd) process.stdout.write(j.payload.cwd);
157
+ }catch{}
158
+ " "$path" 2>/dev/null
159
+ }
160
+
161
+ # Codex rollout 파일경로 → session_id. 파일명 rollout-<ts>-<uuid>.jsonl 의 끝 UUID.
162
+ transcript_session_id_from_path_codex() {
163
+ local base="${1##*/}"
164
+ base="${base%.jsonl}"
165
+ printf '%s' "$base" | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || true
166
+ }
@@ -0,0 +1,163 @@
1
+ #!/bin/bash
2
+ # UserPromptSubmit hook — 현재 cwd 기반 컨텍스트를 stdout으로 내보내
3
+ # Claude Code가 system 지시로 취합합니다.
4
+ # Phase A++: stdin에 담긴 사용자 prompt를 user_prompt 쿼리로 전달해 chunk reinforcement + 예전 대화 떠올림 기능을 활성화.
5
+ # spec §17 (2026-04-30): status 헬스체크 + 큐 drain + 두목 경고 prefix.
6
+
7
+ # self-reference 방지(spec O-4): 차차 internal LLM 호출(`claude -p` from cli-provider)에서
8
+ # 발사된 hook은 즉시 종료. 두목과의 진짜 대화에서만 동작.
9
+ if [ "$ROOKIE_INTERNAL_INVOCATION" = "1" ]; then
10
+ exit 0
11
+ fi
12
+
13
+ CONFIG="$HOME/.rookie/config.json"
14
+ if [ ! -f "$CONFIG" ]; then
15
+ exit 0
16
+ fi
17
+
18
+ ENDPOINT=$(node -e "console.log(require('$CONFIG').endpoint)" 2>/dev/null || echo "http://localhost:3000")
19
+ TOKEN=$(node -e "console.log(require('$CONFIG').token)" 2>/dev/null || echo "")
20
+ USER_ID=$(node -e "console.log(require('$CONFIG').user_id || '')" 2>/dev/null || echo "")
21
+ if [ -z "$TOKEN" ]; then
22
+ exit 0
23
+ fi
24
+
25
+ # §17 큐 라이브러리 로드
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ if [ -f "$SCRIPT_DIR/lib/queue.sh" ]; then
28
+ # shellcheck source=/dev/null
29
+ . "$SCRIPT_DIR/lib/queue.sh"
30
+ elif [ -f "$HOME/.rookie/bin/lib/queue.sh" ]; then
31
+ # shellcheck source=/dev/null
32
+ . "$HOME/.rookie/bin/lib/queue.sh"
33
+ fi
34
+
35
+ STDIN_BODY=$(cat)
36
+
37
+ PROMPT=$(echo "$STDIN_BODY" | node -e "
38
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
39
+ try {
40
+ const j = JSON.parse(d);
41
+ const p = (j.prompt || j.user_prompt || '').toString();
42
+ // URL 길이 제한 대비 2000자로 절삭
43
+ process.stdout.write(p.slice(0, 2000));
44
+ } catch {}
45
+ })
46
+ " 2>/dev/null || echo "")
47
+
48
+ REPO_URL=""
49
+ if command -v git >/dev/null; then
50
+ REPO_URL=$(git remote get-url origin 2>/dev/null || echo "")
51
+ fi
52
+ REPO_PATH="$PWD"
53
+ HOST=$(hostname)
54
+
55
+ ENCODE() {
56
+ node -e "console.log(encodeURIComponent(process.argv[1] || ''))" "$1"
57
+ }
58
+
59
+ # §17 H-1 / H-3: status 5s 헬스체크 → UP/DOWN 분기
60
+ # §18 U-3: status body 도 함께 받아 templates manifest 미스매치 비교
61
+ STATUS_FILE=$(mktemp)
62
+ STATUS_CODE=$(curl -sS --max-time 5 -o "$STATUS_FILE" -w "%{http_code}" \
63
+ -H "Authorization: Bearer $TOKEN" \
64
+ -H "x-rookie-user-id: $USER_ID" \
65
+ "$ENDPOINT/api/rookie/status" 2>/dev/null) || STATUS_CODE="000"
66
+
67
+ QUEUE_BEFORE=0
68
+ if command -v queue_count >/dev/null 2>&1; then
69
+ QUEUE_BEFORE=$(queue_count)
70
+ fi
71
+
72
+ if [ "$STATUS_CODE" = "200" ]; then
73
+ # §18 U-3: templates manifest 비교 → 미스매치 prefix 경고 (정상 system_prompt 노출은 그대로 진행)
74
+ SERVER_HASH=$(node -e "
75
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
76
+ try {
77
+ const j = JSON.parse(d);
78
+ process.stdout.write((j.templates && j.templates.manifest_hash) || '');
79
+ } catch {}
80
+ });
81
+ " < "$STATUS_FILE" 2>/dev/null || echo "")
82
+ LOCAL_HASH=""
83
+ if [ -f "$HOME/.rookie/manifest.json" ]; then
84
+ LOCAL_HASH=$(node -e "
85
+ try {
86
+ const j = require('$HOME/.rookie/manifest.json');
87
+ process.stdout.write(j.manifest_hash || '');
88
+ } catch {}
89
+ " 2>/dev/null || echo "")
90
+ fi
91
+ if [ -n "$SERVER_HASH" ] && [ "$LOCAL_HASH" != "$SERVER_HASH" ]; then
92
+ SERVER_SHORT=$(printf '%s' "$SERVER_HASH" | cut -c1-12)
93
+ if [ -n "$LOCAL_HASH" ]; then
94
+ LOCAL_SHORT=$(printf '%s' "$LOCAL_HASH" | cut -c1-12)
95
+ printf '⚠️ Rookie hook 버전 미스매치 — 서버 %s… / 로컬 %s…\n' "$SERVER_SHORT" "$LOCAL_SHORT"
96
+ else
97
+ printf '⚠️ Rookie hook 버전 미스매치 — 서버 %s… / 로컬 manifest 없음 (옛 install)\n' "$SERVER_SHORT"
98
+ fi
99
+ printf ' `node packages/rookie-cli/bin/rookie.js update` 실행 권장. (정상 컨텍스트는 그대로 노출)\n\n'
100
+ fi
101
+
102
+ # §17 H-4: 큐 drain (오래된 작업이 새 컨텍스트보다 우선)
103
+ if command -v queue_drain >/dev/null 2>&1; then
104
+ queue_drain "$ENDPOINT" "$TOKEN" "$USER_ID" >/dev/null 2>&1 || true
105
+ fi
106
+
107
+ QS="repo_path=$(ENCODE "$REPO_PATH")"
108
+ if [ -n "$REPO_URL" ]; then
109
+ QS="$QS&repo_url=$(ENCODE "$REPO_URL")"
110
+ fi
111
+ if [ -n "$PROMPT" ]; then
112
+ QS="$QS&user_prompt=$(ENCODE "$PROMPT")"
113
+ fi
114
+
115
+ RESP_FILE=$(mktemp)
116
+ CTX_CODE=$(curl -sS --max-time 10 \
117
+ -H "Authorization: Bearer $TOKEN" \
118
+ -H "x-rookie-hostname: $HOST" \
119
+ -H "x-rookie-user-id: $USER_ID" \
120
+ -H "x-rookie-surface: ${1:-claude_code}" \
121
+ -o "$RESP_FILE" \
122
+ -w "%{http_code}" \
123
+ "$ENDPOINT/api/rookie/context?$QS" 2>/dev/null) || CTX_CODE="000"
124
+
125
+ if [ "$CTX_CODE" = "200" ]; then
126
+ if command -v queue_record_success >/dev/null 2>&1; then
127
+ queue_record_success
128
+ fi
129
+
130
+ # drain 후 잔여 큐가 있으면 prefix 경고도 함께 출력 (drain 실패 항목 가시화)
131
+ QUEUE_AFTER=0
132
+ if command -v queue_count >/dev/null 2>&1; then
133
+ QUEUE_AFTER=$(queue_count)
134
+ fi
135
+ if [ "$QUEUE_AFTER" -gt 0 ] && command -v queue_warning_prefix >/dev/null 2>&1; then
136
+ queue_warning_prefix "$(last_success_minutes_ago)" "$QUEUE_AFTER"
137
+ printf '\n'
138
+ fi
139
+
140
+ cat "$RESP_FILE" | node -e "
141
+ let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>{
142
+ try {
143
+ const j = JSON.parse(d);
144
+ if (j.system_prompt) process.stdout.write(j.system_prompt);
145
+ } catch {}
146
+ });
147
+ " 2>/dev/null || true
148
+ else
149
+ # status 200 인데 context 실패 — 부분 장애 가능성, 경고만 노출
150
+ if command -v queue_warning_prefix >/dev/null 2>&1; then
151
+ queue_warning_prefix "$(last_success_minutes_ago)" "$QUEUE_BEFORE"
152
+ fi
153
+ fi
154
+ rm -f "$RESP_FILE"
155
+ else
156
+ # §17 H-3: DOWN — 두목께 경고 노출 (silent 금지)
157
+ if command -v queue_warning_prefix >/dev/null 2>&1; then
158
+ queue_warning_prefix "$(last_success_minutes_ago)" "$QUEUE_BEFORE"
159
+ fi
160
+ fi
161
+
162
+ rm -f "$STATUS_FILE"
163
+ exit 0