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,423 @@
1
+ #!/bin/bash
2
+ # Rookie hot-write dispatcher — batch-mutate POST 의 retry + partial failure 처리.
3
+ # spec: docs/superpowers/specs/2026-04-23-post-v5-decisions.md §17.2 H-2.1 / H-2.2
4
+ # plan: docs/superpowers/plans/2026-05-05-v6-p1-3-hot-write-resilience.md Task 1
5
+ #
6
+ # 함수:
7
+ # mutate_dispatch ENDPOINT TOKEN USER_ID SESSION_ID MUTATE_JSON
8
+ # return 0 = 성공 (partial failure 재큐 가능 포함)
9
+ # return 1 = 큐화 (전체 실패)
10
+ # return 2 = no-op (MUTATE_JSON 빈값)
11
+ #
12
+ # 의존:
13
+ # queue.sh : queue_enqueue (없으면 telemetry 로 fallback)
14
+ # curl : --max-time 10 + http_code 회수 (flush-session.sh 패턴 동일)
15
+ # jq : 응답 partial failure 파싱 (없으면 전체 재큐 + warn)
16
+ # node : retry payload 재구성 (queue.sh 와 동일하게 node 의존 가정)
17
+ #
18
+ # 보안 (spec §10):
19
+ # - mutate_json 본문은 stdout 으로 출력 금지 (telemetry 로그도 본문 미포함, status/session 만)
20
+ # - 임시 파일은 mktemp + trap 으로 정리
21
+ # - 큐 디렉토리 권한 chmod 600 은 queue.sh 가 보장
22
+
23
+ ROOKIE_DROP_LOG="${ROOKIE_DROP_LOG:-$HOME/.rookie/dropped-mutations.log}"
24
+ # spec §17.2 H-2.3 dead-letter 격리 디렉토리 — queue.sh 의 ROOKIE_DEAD_LETTER_DIR 와 일관.
25
+ ROOKIE_DEAD_LETTER_DIR="${ROOKIE_DEAD_LETTER_DIR:-$HOME/.rookie/dead-letter}"
26
+
27
+ # drop log 디렉토리 보장 — stderr redirect 가 silent fail 하지 않도록.
28
+ mkdir -p "$(dirname "$ROOKIE_DROP_LOG")" 2>/dev/null || true
29
+
30
+ # 내부: 1회 POST. $1=endpoint $2=token $3=user_id $4=mutate_json $5=output_body_path
31
+ # stdout 으로 http_code 만 출력. curl exit ≠ 0 이면 "000".
32
+ # curl stderr 는 ROOKIE_DROP_LOG 로 보존 — DNS/TLS/timeout 진단을 위해 silent 차단.
33
+ _post_once() {
34
+ local endpoint="$1"
35
+ local token="$2"
36
+ local user_id="$3"
37
+ local mutate_json="$4"
38
+ local out_body="$5"
39
+ local code
40
+ # spec 2026-07-06 §F-1 — x-rookie-hostname 전송(세션 project 의 repo_path 매칭 요구).
41
+ # dispatch 는 origin 호스트에서 실행되므로 $(hostname) == 세션 발생 호스트.
42
+ code=$(curl -sS --max-time 10 \
43
+ -X POST \
44
+ -H "Authorization: Bearer $token" \
45
+ -H "Content-Type: application/json" \
46
+ -H "x-rookie-user-id: $user_id" \
47
+ -H "x-rookie-hostname: $(hostname)" \
48
+ -d "$mutate_json" \
49
+ -o "$out_body" \
50
+ -w "%{http_code}" \
51
+ "$endpoint/api/rookie/batch-mutate" 2>>"$ROOKIE_DROP_LOG") || code="000"
52
+ if [ -z "$code" ]; then
53
+ code="000"
54
+ fi
55
+ printf '%s' "$code"
56
+ }
57
+
58
+ # 내부: failed 배열 + 원본 mutate_json 으로 retry payload 재구성.
59
+ # stdout = retry_json (jq + node 둘 다 사용 가능 시), 실패 시 비어있음 → caller 가 fallback.
60
+ # working_cards 는 object 이므로 failed.type=working_cards 가 한 건이라도 있으면 통째로 보존.
61
+ # self_rules / evidence / verifications 는 array → failed.index 에 해당하는 element 만 추출.
62
+ _build_retry_payload() {
63
+ local mutate_json="$1"
64
+ local failed_json="$2"
65
+ if ! command -v node >/dev/null 2>&1; then
66
+ return 1
67
+ fi
68
+ node -e "
69
+ try {
70
+ const src = JSON.parse(process.argv[1]);
71
+ const failed = JSON.parse(process.argv[2]);
72
+ if (!Array.isArray(failed) || failed.length === 0) { process.exit(1); }
73
+ const out = {};
74
+ const wcFail = failed.some(f => f && f.type === 'working_cards');
75
+ if (wcFail && src.working_cards && typeof src.working_cards === 'object') {
76
+ out.working_cards = src.working_cards;
77
+ }
78
+ for (const arrType of ['self_rules', 'evidence', 'verifications']) {
79
+ const idxs = failed
80
+ .filter(f => f && f.type === arrType && Number.isInteger(f.index))
81
+ .map(f => f.index);
82
+ if (idxs.length === 0) continue;
83
+ const arr = Array.isArray(src[arrType]) ? src[arrType] : [];
84
+ const picked = idxs
85
+ .filter(i => i >= 0 && i < arr.length)
86
+ .map(i => arr[i]);
87
+ if (picked.length > 0) out[arrType] = picked;
88
+ }
89
+ const keys = Object.keys(out);
90
+ if (keys.length === 0) { process.exit(1); }
91
+ // spec 2026-07-06 §F-1(a) — top-level repo 컨텍스트 보존(재시도 저장이 세션 project 를 잃지 않도록).
92
+ // mutation key 유무 판정 이후에 부착 — repo 컨텍스트만으로 재큐되지 않게(empty-check 는 mutation 만).
93
+ if (typeof src.repo_url === 'string') out.repo_url = src.repo_url;
94
+ if (typeof src.repo_path === 'string') out.repo_path = src.repo_path;
95
+ process.stdout.write(JSON.stringify(out));
96
+ } catch (e) {
97
+ process.stderr.write('build_retry_payload_error: ' + (e && e.message ? e.message : String(e)) + '\n');
98
+ process.exit(1);
99
+ }
100
+ " "$mutate_json" "$failed_json" 2>>"$ROOKIE_DROP_LOG"
101
+ }
102
+
103
+ # 내부: failed paths 배열 + 원본 mutate_json 으로 결함 element 를 **제외**한 payload 재구성.
104
+ # spec §17.2 H-2.3 — _build_retry_payload 와 대칭 (그 함수는 failed 만 추출, 본 함수는 failed 만 제외).
105
+ # stdout = filtered_json (성공 시), 실패/empty 시 비어있음 → caller 가 fallback.
106
+ _build_filtered_payload() {
107
+ local mutate_json="$1"
108
+ local failed_paths_json="$2" # 예: [["verifications", 0, "fact_id"], ["self_rules", 1, "category"]]
109
+ if ! command -v node >/dev/null 2>&1; then
110
+ return 1
111
+ fi
112
+ node -e "
113
+ try {
114
+ const src = JSON.parse(process.argv[1]);
115
+ const paths = JSON.parse(process.argv[2]);
116
+ if (!Array.isArray(paths) || paths.length === 0) { process.exit(1); }
117
+ const failedIdxByType = {
118
+ working_cards: false,
119
+ self_rules: new Set(),
120
+ evidence: new Set(),
121
+ verifications: new Set(),
122
+ };
123
+ for (const p of paths) {
124
+ if (!Array.isArray(p) || p.length === 0) continue;
125
+ const t = p[0];
126
+ if (t === 'working_cards') { failedIdxByType.working_cards = true; continue; }
127
+ if (Object.prototype.hasOwnProperty.call(failedIdxByType, t) && Number.isInteger(p[1])) {
128
+ failedIdxByType[t].add(p[1]);
129
+ }
130
+ }
131
+ const out = {};
132
+ if (src.working_cards && !failedIdxByType.working_cards) out.working_cards = src.working_cards;
133
+ for (const arrType of ['self_rules', 'evidence', 'verifications']) {
134
+ const arr = Array.isArray(src[arrType]) ? src[arrType] : [];
135
+ if (arr.length === 0) continue;
136
+ const kept = arr.filter((_, i) => !failedIdxByType[arrType].has(i));
137
+ if (kept.length > 0) out[arrType] = kept;
138
+ }
139
+ const keys = Object.keys(out);
140
+ if (keys.length === 0) { process.exit(1); }
141
+ // spec 2026-07-06 §F-1(a) — top-level repo 컨텍스트 보존(400 재구성 저장도 세션 project 유지).
142
+ if (typeof src.repo_url === 'string') out.repo_url = src.repo_url;
143
+ if (typeof src.repo_path === 'string') out.repo_path = src.repo_path;
144
+ process.stdout.write(JSON.stringify(out));
145
+ } catch (e) {
146
+ process.stderr.write('build_filtered_payload_error: ' + (e && e.message ? e.message : String(e)) + '\n');
147
+ process.exit(1);
148
+ }
149
+ " "$mutate_json" "$failed_paths_json" 2>>"$ROOKIE_DROP_LOG"
150
+ }
151
+
152
+ # 내부: queue_enqueue wrapper. queue.sh 가 source 안 됐으면 telemetry 로 fallback.
153
+ _safe_enqueue() {
154
+ local label="$1"
155
+ local body="$2"
156
+ local code="$3"
157
+ if command -v queue_enqueue >/dev/null 2>&1; then
158
+ queue_enqueue "$label" "$body" "$code" 2>/dev/null
159
+ return $?
160
+ fi
161
+ printf '%s queue_unavailable label=%s code=%s\n' \
162
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$label" "$code" \
163
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
164
+ return 1
165
+ }
166
+
167
+ # 내부: partial failure 처리. http_code=200 응답 body 를 파싱해 failed[] 분기.
168
+ # 0 return = 처리 완료 (정상 또는 retry 재큐 성공/fallback)
169
+ _handle_200() {
170
+ local body_path="$1"
171
+ local mutate_json="$2"
172
+ local session_id="$3"
173
+
174
+ # jq 미설치 → 응답 schema 파싱 불가능. 보수적 처리: 전체 mutate_json 을 retry 큐로 재적재
175
+ # + warn 1줄. silent drop 방지.
176
+ if ! command -v jq >/dev/null 2>&1; then
177
+ _safe_enqueue "$session_id-mutate-retry" "$mutate_json" "200-jq-missing" || true
178
+ printf '%s jq_missing_partial_failure_unknown session=%s\n' \
179
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session_id" \
180
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
181
+ return 0
182
+ fi
183
+
184
+ local failed_count jq_status
185
+ failed_count=$(jq -r '.summary.failed | length // 0' < "$body_path" 2>>"$ROOKIE_DROP_LOG")
186
+ jq_status=$?
187
+ if [ "$jq_status" -ne 0 ] || [ -z "$failed_count" ] || [ "$failed_count" = "null" ]; then
188
+ # jq 파싱 실패 — 응답 schema 비정상. silent 0 처리 금지, fallback 으로 전체 재큐.
189
+ _safe_enqueue "$session_id-mutate-retry" "$mutate_json" "200-jq-parse-fail" || true
190
+ printf '%s jq_parse_failed session=%s\n' \
191
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session_id" \
192
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
193
+ return 0
194
+ fi
195
+
196
+ if [ "$failed_count" -le 0 ] 2>/dev/null; then
197
+ return 0
198
+ fi
199
+
200
+ local failed_json
201
+ failed_json=$(jq -c '.summary.failed' < "$body_path" 2>>"$ROOKIE_DROP_LOG") || failed_json=""
202
+ if [ -z "$failed_json" ] || [ "$failed_json" = "null" ]; then
203
+ # failed length > 0 인데 추출 실패 — 비정상. fallback 으로 전체 재큐.
204
+ _safe_enqueue "$session_id-mutate-retry" "$mutate_json" "200-failed-extract-fail" || true
205
+ return 0
206
+ fi
207
+
208
+ local retry_json
209
+ retry_json=$(_build_retry_payload "$mutate_json" "$failed_json") || retry_json=""
210
+ if [ -z "$retry_json" ]; then
211
+ _safe_enqueue "$session_id-mutate-retry" "$mutate_json" "200-build-fail" || true
212
+ return 0
213
+ fi
214
+
215
+ _safe_enqueue "$session_id-mutate-retry" "$retry_json" "200-partial" || true
216
+ return 0
217
+ }
218
+
219
+ # 내부: HTTP 400 invalid_body 응답 처리 — spec §17.2 H-2.3.
220
+ # stdout = 결함 element 를 제외한 재구성 mutate_json (재시도 가능, non-empty 일 때),
221
+ # 빈 출력 = caller 가 fallback (전체 dead-letter or 기존 400 로직 진입).
222
+ # return 0 = invalid_body 분기 처리됨, return 1 = invalid_body 아님/파싱 불가 → caller 가 기존 400 로직.
223
+ _handle_400_invalid_body() {
224
+ local body_path="$1"
225
+ local mutate_json="$2"
226
+ local session_id="$3"
227
+
228
+ if ! command -v jq >/dev/null 2>&1; then
229
+ return 1
230
+ fi
231
+
232
+ local error_kind
233
+ error_kind=$(jq -r '.error // ""' < "$body_path" 2>>"$ROOKIE_DROP_LOG")
234
+ if [ "$error_kind" != "invalid_body" ]; then
235
+ return 1
236
+ fi
237
+
238
+ local paths_json
239
+ paths_json=$(jq -c '[.detail[]?.path]' < "$body_path" 2>>"$ROOKIE_DROP_LOG") || paths_json=""
240
+ if [ -z "$paths_json" ] || [ "$paths_json" = "null" ] || [ "$paths_json" = "[]" ]; then
241
+ return 1
242
+ fi
243
+
244
+ # 결함 element 들 dead-letter 이동 (전체 mutate_json 보존 — 추적용).
245
+ # spec §17.2 H-2.3: 재시도 무의미 → 즉시 격리. spec §10: chmod 600.
246
+ local ts
247
+ ts=$(date -u +%Y%m%dT%H%M%SZ)
248
+ local dead_letter_path="$ROOKIE_DEAD_LETTER_DIR/${session_id}-mutate-invalid-${ts}.jsonl"
249
+ mkdir -m 700 -p "$ROOKIE_DEAD_LETTER_DIR" 2>/dev/null || true
250
+ printf '%s\n' "$mutate_json" > "$dead_letter_path" 2>>"$ROOKIE_DROP_LOG"
251
+ chmod 600 "$dead_letter_path" 2>/dev/null || true
252
+ # telemetry: path 만 기록, 본문 미포함 (spec §10).
253
+ printf '%s invalid_body session=%s paths=%s dead_letter=%s\n' \
254
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session_id" "$paths_json" "$dead_letter_path" \
255
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
256
+
257
+ # 정상 element 재구성 — 모두 결함이면 빈 출력 (caller 가 fallback).
258
+ local filtered
259
+ filtered=$(_build_filtered_payload "$mutate_json" "$paths_json") || filtered=""
260
+ printf '%s' "$filtered"
261
+ return 0
262
+ }
263
+
264
+ mutate_dispatch() {
265
+ local endpoint="$1"
266
+ local token="$2"
267
+ local user_id="$3"
268
+ local session_id="$4"
269
+ local mutate_json="$5"
270
+ # spec §17.2 H-2.3: 재귀 cap=2. 외부 호출은 depth=0, _handle_400_invalid_body 회수 후 +1.
271
+ local depth="${6:-0}"
272
+
273
+ if [ -z "$mutate_json" ]; then
274
+ return 2
275
+ fi
276
+
277
+ local body_tmp
278
+ body_tmp=$(mktemp -t rookie-mutate-body.XXXXXX 2>>"$ROOKIE_DROP_LOG") || body_tmp=""
279
+ if [ -z "$body_tmp" ]; then
280
+ # mktemp 실패 시 silent drop 차단 — partial failure 회수 불가능하므로
281
+ # 1차 POST 시도조차 안 하고 mutate_json 전체를 재큐에 넣어 다음 drain 으로 회수.
282
+ # spec §17.2 H-2 큐 메커니즘 의존 (서버측 멱등성 + retry cap).
283
+ _safe_enqueue "$session_id-mutate" "$mutate_json" "mktemp-fail" || true
284
+ printf '%s mktemp_fail session=%s\n' \
285
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session_id" \
286
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
287
+ return 1
288
+ fi
289
+ # spec §10: 임시 파일에 응답 body 가 들어가지만 본문은 stdout 출력 금지.
290
+ # trap 으로 함수 종료 시 정리 (bash RETURN trap, dispatcher.sh shebang 보장).
291
+ # set -u 호환을 위해 :- 가드.
292
+ trap 'rm -f "${body_tmp:-}" 2>/dev/null || true' RETURN
293
+
294
+ local http_code
295
+ http_code=$(_post_once "$endpoint" "$token" "$user_id" "$mutate_json" "$body_tmp")
296
+
297
+ if [ "$http_code" = "200" ]; then
298
+ _handle_200 "$body_tmp" "$mutate_json" "$session_id"
299
+ return 0
300
+ fi
301
+
302
+ # spec §17.2 H-2.3 — 400 invalid_body partial validation 회수.
303
+ # depth<2 일 때만 회수 시도. 재구성 JSON 이 있으면 재귀 호출 (sleep/재큐 우회 — 결함 element 는 이미 dead-letter 격리됨).
304
+ # _handle_400_invalid_body 가 return 1 (invalid_body 아님 / jq 미설치 / 파싱 실패) → 기존 H-2.1 retry 로직 진입.
305
+ if [ "$http_code" = "400" ] && [ "$depth" -lt 2 ]; then
306
+ local refiltered
307
+ refiltered=$(_handle_400_invalid_body "$body_tmp" "$mutate_json" "$session_id")
308
+ local handle_status=$?
309
+ if [ "$handle_status" = "0" ]; then
310
+ if [ -n "$refiltered" ]; then
311
+ # 재구성 JSON 재시도 (depth+1)
312
+ mutate_dispatch "$endpoint" "$token" "$user_id" "$session_id" "$refiltered" "$((depth + 1))"
313
+ return $?
314
+ fi
315
+ # 모든 element 결함 → 이미 dead-letter 이동됨, 더 이상 할 일 없음.
316
+ return 0
317
+ fi
318
+ # handle_status=1 → invalid_body 분기 아님 → 아래 기존 retry 로직으로 fall-through
319
+ fi
320
+
321
+ # spec §17.2 H-2.3 무한 루프 방지: depth>=2 도달 했는데도 또 invalid_body → 통째 dead-letter.
322
+ # (재구성을 또 해봐도 같은 결함 클래스 — 재시도 무의미. 큐 재진입 차단.)
323
+ if [ "$http_code" = "400" ] && [ "$depth" -ge 2 ]; then
324
+ if command -v jq >/dev/null 2>&1; then
325
+ local error_kind_cap
326
+ error_kind_cap=$(jq -r '.error // ""' < "$body_tmp" 2>>"$ROOKIE_DROP_LOG")
327
+ if [ "$error_kind_cap" = "invalid_body" ]; then
328
+ local ts_cap
329
+ ts_cap=$(date -u +%Y%m%dT%H%M%SZ)
330
+ local dead_letter_path_cap="$ROOKIE_DEAD_LETTER_DIR/${session_id}-mutate-invalid-cap-${ts_cap}.jsonl"
331
+ mkdir -m 700 -p "$ROOKIE_DEAD_LETTER_DIR" 2>/dev/null || true
332
+ printf '%s\n' "$mutate_json" > "$dead_letter_path_cap" 2>>"$ROOKIE_DROP_LOG"
333
+ chmod 600 "$dead_letter_path_cap" 2>/dev/null || true
334
+ printf '%s invalid_body_cap session=%s depth=%s dead_letter=%s\n' \
335
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session_id" "$depth" "$dead_letter_path_cap" \
336
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
337
+ return 1
338
+ fi
339
+ fi
340
+ # invalid_body 아닌 400 (또는 jq 미설치) → fall-through 으로 기존 H-2.1 retry 로직 진입.
341
+ fi
342
+
343
+ # 1차 실패 → spec §17.2 H-2.1 sleep 1 + 1회 retry.
344
+ sleep 1
345
+ http_code=$(_post_once "$endpoint" "$token" "$user_id" "$mutate_json" "$body_tmp")
346
+
347
+ if [ "$http_code" = "200" ]; then
348
+ _handle_200 "$body_tmp" "$mutate_json" "$session_id"
349
+ return 0
350
+ fi
351
+
352
+ # 2회 모두 실패 → 전체 재큐.
353
+ _safe_enqueue "$session_id-mutate" "$mutate_json" "$http_code" || true
354
+ return 1
355
+ }
356
+
357
+ # v6 P1 #5 — Stage 1 측정 hook buffer flush.
358
+ # spec §7.1 / plan Task 4
359
+ #
360
+ # PreToolUse hook 가 적재한 ~/.rookie/metric-pending/<session>-md-read.jsonl 을
361
+ # entries 배열로 wrap → /api/rookie/metric/md-read 로 batch POST.
362
+ # 200 → buffer 삭제. 실패 → buffer 보존 (다음 세션 재시도, retry cap 안 둠).
363
+ #
364
+ # return 0 = 성공 또는 no-op (buffer 없음/빈값), 1 = POST 실패 (buffer 잔존)
365
+ metric_dispatch_md_read() {
366
+ local endpoint="$1"
367
+ local token="$2"
368
+ local user_id="$3"
369
+ local session_id="$4"
370
+ local buf_dir="${ROOKIE_METRIC_BUFFER_DIR:-$HOME/.rookie/metric-pending}"
371
+ local buf_file="$buf_dir/${session_id}-md-read.jsonl"
372
+
373
+ [ -f "$buf_file" ] || return 0
374
+ if [ ! -s "$buf_file" ]; then
375
+ rm -f "$buf_file"
376
+ return 0
377
+ fi
378
+
379
+ if ! command -v node >/dev/null 2>&1; then
380
+ return 1
381
+ fi
382
+
383
+ local body
384
+ body=$(node -e "
385
+ try {
386
+ const fs = require('fs');
387
+ const lines = fs.readFileSync(process.argv[1], 'utf8').split('\n').filter(Boolean);
388
+ const entries = [];
389
+ for (const line of lines) {
390
+ try { entries.push(JSON.parse(line)); } catch {}
391
+ }
392
+ if (entries.length === 0) process.exit(1);
393
+ process.stdout.write(JSON.stringify({ entries }));
394
+ } catch (e) {
395
+ process.stderr.write('metric_buffer_parse_error: ' + (e && e.message ? e.message : String(e)) + '\n');
396
+ process.exit(1);
397
+ }
398
+ " "$buf_file" 2>>"$ROOKIE_DROP_LOG") || return 1
399
+
400
+ if [ -z "$body" ]; then
401
+ return 1
402
+ fi
403
+
404
+ local code
405
+ code=$(curl -sS --max-time 10 -o /dev/null -w "%{http_code}" \
406
+ -X POST \
407
+ -H "Authorization: Bearer $token" \
408
+ -H "Content-Type: application/json" \
409
+ -H "x-rookie-user-id: $user_id" \
410
+ -d "$body" \
411
+ "$endpoint/api/rookie/metric/md-read" 2>>"$ROOKIE_DROP_LOG") || code="000"
412
+
413
+ if [ "$code" = "200" ]; then
414
+ rm -f "$buf_file"
415
+ return 0
416
+ fi
417
+
418
+ # 실패 — buffer 보존, telemetry 1줄.
419
+ printf '%s metric_md_read_post_failed session=%s code=%s\n' \
420
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session_id" "$code" \
421
+ >> "$ROOKIE_DROP_LOG" 2>/dev/null || true
422
+ return 1
423
+ }