therookie 0.4.28 → 0.4.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "therookie",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "the Rookie — the AI new hire that remembers what you teach it. A CLI that connects a personal AI memory to AI tools like Claude and Codex.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -424,8 +424,12 @@ function printOrphanPending() {
424
424
  }
425
425
  const expiredCount =
426
426
  countDirEntries(join(root, 'expired')) + countDirEntries(join(root, 'codex', 'expired'))
427
+ // spec 2026-09-08 §2.4 — 사유별로 나눠 센다. 저장 0건이 쌓이는 것과 비정상 종료가 쌓이는 것은
428
+ // 원인도 조치도 다르다. 한 숫자로 뭉치면 진짜 복구 대기가 묻힌다.
429
+ const allMarkers = [...claude.markers, ...codex.markers]
430
+ const noWrites = allMarkers.filter((m) => m?.reason === 'no_writes').length
427
431
  const s = summarizeOrphanBacklog({
428
- pendingMarkers: [...claude.markers, ...codex.markers],
432
+ pendingMarkers: allMarkers,
429
433
  expiredCount,
430
434
  nowMs: Date.now(),
431
435
  })
@@ -444,6 +448,9 @@ function printOrphanPending() {
444
448
  )
445
449
  }
446
450
  }
451
+ if (noWrites > 0) {
452
+ console.log(`— 저장 0건 세션: ${noWrites}건 대기 — 그 세션의 결정·사실이 아직 어디에도 없습니다. rookie orphan-extract 로 확인하세요.`)
453
+ }
447
454
  if (s.expiredCount > 0) {
448
455
  console.log(`— orphan expired(손실): 누적 ${s.expiredCount}건 — TTL 초과로 tail 미추출 상태로 만료된 세션`)
449
456
  }
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process'
13
13
  import { readFileSync } from 'node:fs'
14
14
  import { loadConfig, getHostname } from '../config.js'
15
15
  import { apiPost } from '../api.js'
16
+ import { recordSessionWrite } from '../lib/session-write-marker.js'
16
17
 
17
18
  // 저장된 층을 **빠짐없이** 화면에 센다. 여기 없는 층은 서버가 저장해도 0건처럼 보인다 —
18
19
  // 2026-09-02 저장에서 durable fact 3건이 그렇게 안 보였고, metric·recall 로 따로 확인해야
@@ -116,6 +117,12 @@ export async function runSave(flags) {
116
117
  throw err
117
118
  }
118
119
 
120
+ // spec 2026-09-08 §2.4 — 이 세션에 저장이 있었다는 로컬 마커. Stop hook 이 없으면 미추출 큐에 넣는다.
121
+ // --session 미전달이면 아무것도 안 한다(기존 호출자 호환).
122
+ if (res.all_committed !== false) {
123
+ recordSessionWrite(typeof flags.session === 'string' ? flags.session : '')
124
+ }
125
+
119
126
  if (flags.json) {
120
127
  // 서버 raw 응답 그대로 — 재가공 없음. SKILL 은 summary.working_cards.ids[0]·superseded_count 를 읽는다.
121
128
  console.log(JSON.stringify(res))
@@ -32,6 +32,9 @@ export function listPendingExtractions({ orphanRoot }) {
32
32
  sid,
33
33
  runtime,
34
34
  transcriptPath: m.transcript_path || '',
35
+ // spec 2026-09-08 §2.4 — 'no_writes'(저장 0건) 와 비정상 종료를 구분해 표시한다.
36
+ // 섞이면 진짜 복구 대기가 묻힌다.
37
+ reason: m.reason || 'crash',
35
38
  recoveredAt: m.recovered_at || null,
36
39
  // codex marker 는 codex 서브루트에 산다 — marker 명령이 이 루트를 겨냥해야
37
40
  // 큐에서 실제로 빠진다(코드검토 P2). orphan-recovery.sh 의 ROOKIE_ORPHAN_DIR 규약.
@@ -101,7 +104,11 @@ export function buildDraft(messages, { userMax = 300, assistantMax = 400, pathCa
101
104
  export function renderDraft(entry, draft) {
102
105
  const lines = []
103
106
  const clean = (s) => redactDraftText(s).redacted.replace(/\s+/g, ' ').trim()
104
- lines.push(`◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} (recovered ${entry.recoveredAt ?? 'unknown'})`)
107
+ // spec 2026-09-08 §2.4 — 사유를 앞에 붙인다. 저장 0건과 비정상 종료는 봐야 할 것이 다르다.
108
+ const why = entry.reason === 'no_writes' ? '저장 0건' : '비정상 종료'
109
+ lines.push(
110
+ `◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} · ${why} (recovered ${entry.recoveredAt ?? 'unknown'})`
111
+ )
105
112
  if (draft.lastUser) lines.push(` 마지막 user: ${clean(draft.lastUser)}`)
106
113
  if (draft.lastAssistant) lines.push(` 마지막 assistant: ${clean(draft.lastAssistant)}`)
107
114
  if (draft.filePaths.length) lines.push(` 파일 경로: ${draft.filePaths.map((p) => clean(p)).join(' · ')}`)
@@ -0,0 +1,57 @@
1
+ // spec 2026-09-08-session-continuity-two-gaps.md §2.4 — 저장 0건 세션 감지용 로컬 마커.
2
+ //
3
+ // 서버는 그 세션에 저장이 있었는지 모른다 — `rookie save` 가 세션 식별자를 보내지 않아
4
+ // batch-mutate 를 세션에 귀속시킬 수 없다. 로컬에서 아는 편이 싸고 정확하다.
5
+ //
6
+ // 키에 epoch 를 넣는 이유: session_id 는 /clear·compact 후에도 같은 값이다(그래서 recall-push 가
7
+ // 구간 경계를 skill-gate 의 .epoch 로 따로 감지한다). 세션 단위 키만 쓰면 clear 이전 구간에
8
+ // 저장이 있었다는 이유로 이후 구간이 통째로 면제된다.
9
+ import { createHash } from 'node:crypto'
10
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+
14
+ export function markerDir() {
15
+ return process.env.ROOKIE_SESSION_WRITES_DIR || join(homedir(), '.rookie', 'session-writes')
16
+ }
17
+
18
+ function gateDir() {
19
+ return process.env.ROOKIE_SKILL_GATE_DIR || join(homedir(), '.rookie', 'skill-gate')
20
+ }
21
+
22
+ /** 세션의 현재 구간 epoch. 파일이 없으면 빈 문자열(현행 recall-push 관행과 동일). */
23
+ export function readEpoch(sessionId) {
24
+ if (!sessionId) return ''
25
+ try {
26
+ const key = createHash('sha256').update(sessionId).digest('hex')
27
+ return String(readFileSync(join(gateDir(), key + '.epoch'), 'utf8')).trim()
28
+ } catch {
29
+ return ''
30
+ }
31
+ }
32
+
33
+ export function markerKey(sessionId, epoch) {
34
+ return createHash('sha256').update(`${sessionId}:${epoch}`).digest('hex')
35
+ }
36
+
37
+ /** 저장 성공 기록. sessionId 미전달이면 조용히 넘어간다(기존 호출자 호환). */
38
+ export function recordSessionWrite(sessionId) {
39
+ if (!sessionId) return false
40
+ try {
41
+ const dir = markerDir()
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
43
+ writeFileSync(join(dir, markerKey(sessionId, readEpoch(sessionId))), '', { mode: 0o600 })
44
+ return true
45
+ } catch {
46
+ return false // 마커 실패가 저장을 되돌리지는 않는다
47
+ }
48
+ }
49
+
50
+ export function hasSessionWrite(sessionId, epoch) {
51
+ if (!sessionId) return true // 판정 불가 — 큐에 넣지 않는다(오탐보다 무동작이 안전)
52
+ try {
53
+ return existsSync(join(markerDir(), markerKey(sessionId, epoch ?? readEpoch(sessionId))))
54
+ } catch {
55
+ return true
56
+ }
57
+ }
@@ -301,6 +301,14 @@ if [ "$HTTP_CODE" = "200" ]; then
301
301
  if command -v orphan_mark_flushed >/dev/null 2>&1 && [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
302
302
  orphan_mark_flushed "$SESSION_ID" "stop" "$MESSAGE_COUNT" || true
303
303
  fi
304
+ # spec 2026-09-08 §2.4 — 이 세션 구간에 `rookie save` 성공이 한 번도 없었으면 결정·사실이
305
+ # 어디에도 남지 않는다. transcript 는 있으므로 다음 세션이 건지도록 미추출 큐에 올린다.
306
+ # 내용 없는 세션(MESSAGE_COUNT=0)은 제외 — 건질 게 없다.
307
+ if command -v orphan_session_had_write >/dev/null 2>&1 \
308
+ && [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null \
309
+ && ! orphan_session_had_write "$SESSION_ID"; then
310
+ orphan_mark_no_writes "$SESSION_ID" "$TRANSCRIPT_PATH" || true
311
+ fi
304
312
  else
305
313
  # §17 H-2: 큐 적재 + 1회 chain drain (일시적 깜빡임 회복)
306
314
  if command -v queue_enqueue >/dev/null 2>&1; then
@@ -84,6 +84,48 @@ orphan_mark_baseline() {
84
84
  "$(printf '{"status":"baseline_skipped","reason":"%s"}' "$reason")"
85
85
  }
86
86
 
87
+ # spec 2026-09-08-session-continuity-two-gaps.md §2.4 — 저장 0건 세션을 미추출 큐에 넣는다.
88
+ #
89
+ # 세션 안에서 `rookie save` 가 한 번도 성공하지 않았으면 그 세션의 결정·사실은 어디에도 없다.
90
+ # transcript 는 남아 있으므로 다음 세션이 orphan-extract 로 건질 수 있게 큐에 올린다.
91
+ #
92
+ # 마커 키에 epoch 를 넣는 이유: session_id 는 /clear·compact 후에도 같은 값이라(그래서 recall-push 가
93
+ # 구간 경계를 .epoch 로 따로 본다), 세션 단위 키만 쓰면 clear 이전 구간의 저장이 이후 구간을 면제한다.
94
+ _orphan_session_write_key() {
95
+ local sid="$1" gate_dir epoch
96
+ gate_dir="${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"
97
+ local sid_hash
98
+ sid_hash="$(printf '%s' "$sid" | shasum -a 256 2>/dev/null | cut -d" " -f1)"
99
+ epoch="$(cat "$gate_dir/$sid_hash.epoch" 2>/dev/null | tr -d "[:space:]")"
100
+ printf '%s:%s' "$sid" "$epoch" | shasum -a 256 2>/dev/null | cut -d" " -f1
101
+ }
102
+
103
+ # 이 세션 구간에 저장이 있었는가. 판정 불가(빈 sid·shasum 없음)면 "있었다"로 본다 —
104
+ # 무동작이 오탐보다 안전하다.
105
+ orphan_session_had_write() {
106
+ local sid="$1"
107
+ [ -n "$sid" ] || return 0
108
+ command -v shasum >/dev/null 2>&1 || return 0
109
+ local dir="${ROOKIE_SESSION_WRITES_DIR:-$HOME/.rookie/session-writes}"
110
+ local key
111
+ key="$(_orphan_session_write_key "$sid")"
112
+ [ -n "$key" ] || return 0
113
+ [ -f "$dir/$key" ]
114
+ }
115
+
116
+ # 저장 0건이면 pending-extraction 에 reason=no_writes 로 올린다.
117
+ # 비정상 종료(crash) 사유와 섞이지 않게 reason 을 남긴다 — 섞이면 진짜 복구 대기가 묻힌다.
118
+ orphan_mark_no_writes() {
119
+ local sid="$1" transcript="${2:-}"
120
+ [ -n "$sid" ] || return 1
121
+ # 이미 추출을 마쳤거나 대기 중이면 덮지 않는다.
122
+ [ -f "$(_orphan_client_extracted_dir)/$sid.json" ] && return 0
123
+ [ -f "$(_orphan_pending_dir)/$sid.json" ] && return 0
124
+ _orphan_write_marker "$(_orphan_pending_dir)" "$sid" \
125
+ "$(printf '{"status":"pending_extraction","reason":"no_writes","transcript_path":"%s","recovered_at":"%s"}' \
126
+ "$transcript" "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
127
+ }
128
+
87
129
  # P0.4 — 짧고 내용 없는(low_value) 세션 marker. record_pending 대신 호출 → 재적재 차단.
88
130
  orphan_mark_low_value() {
89
131
  local sid="$1" reason="${2:-low_value}" mc="${3:-0}"
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.28",
2
+ "cli_version": "0.4.29",
3
3
  "skill_version": "1.39.0"
4
4
  }