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 +1 -1
- package/src/commands/doctor.js +8 -1
- package/src/commands/save.js +7 -0
- package/src/lib/orphan-extract-core.js +8 -1
- package/src/lib/session-write-marker.js +57 -0
- package/templates/hook/flush-session.sh +8 -0
- package/templates/hook/lib/orphan-recovery.sh +42 -0
- package/version.json +1 -1
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/commands/save.js
CHANGED
|
@@ -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
|
-
|
|
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