therookie 0.4.27 → 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.27",
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": [
@@ -117,6 +117,7 @@ export async function runDoctor(flags) {
117
117
  printTemplateManifestStatus(serverStatus)
118
118
  printQueueStatus()
119
119
  printOrphanPending()
120
+ printRecallPush()
120
121
  printIdentityMismatch()
121
122
  await printStage1Metric(cfg)
122
123
  await printDedupMetric(cfg)
@@ -423,8 +424,12 @@ function printOrphanPending() {
423
424
  }
424
425
  const expiredCount =
425
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
426
431
  const s = summarizeOrphanBacklog({
427
- pendingMarkers: [...claude.markers, ...codex.markers],
432
+ pendingMarkers: allMarkers,
428
433
  expiredCount,
429
434
  nowMs: Date.now(),
430
435
  })
@@ -443,11 +448,65 @@ function printOrphanPending() {
443
448
  )
444
449
  }
445
450
  }
451
+ if (noWrites > 0) {
452
+ console.log(`— 저장 0건 세션: ${noWrites}건 대기 — 그 세션의 결정·사실이 아직 어디에도 없습니다. rookie orphan-extract 로 확인하세요.`)
453
+ }
446
454
  if (s.expiredCount > 0) {
447
455
  console.log(`— orphan expired(손실): 누적 ${s.expiredCount}건 — TTL 초과로 tail 미추출 상태로 만료된 세션`)
448
456
  }
449
457
  }
450
458
 
459
+ // spec 2026-09-08 §5.1 — 회상 푸시 무주입률. recall-push.sh 가 로컬에 남긴 계측을 읽는다.
460
+ // 서버 전송 없음. 파일이 없으면(아직 안 돌았거나 구버전) 침묵한다.
461
+ export function summarizeRecallPush(lines, max = 1000) {
462
+ const rows = []
463
+ // 파일은 append 전용이라 최신이 뒤에 온다 — 뒤에서부터 max 줄만 집계한다.
464
+ for (let i = lines.length - 1; i >= 0 && rows.length < max; i--) {
465
+ try {
466
+ const o = JSON.parse(lines[i])
467
+ if (o && typeof o.injected === 'number') rows.push(o)
468
+ } catch {
469
+ /* 깨진 줄은 건너뛴다 */
470
+ }
471
+ }
472
+ if (rows.length === 0) return null
473
+ const silent = rows.filter((r) => r.injected === 0).length
474
+ // 중앙값은 chars 기준이다 — 컨텍스트 예산은 문자·토큰 감각이고, 한국어는 바이트가 3배라
475
+ // 바이트로 재면 합격 기준(§4.1)이 왜곡된다. bytes 는 파일에만 남긴다.
476
+ const chars = rows.map((r) => (typeof r.chars === 'number' ? r.chars : 0)).sort((a, b) => a - b)
477
+ const median = chars[Math.floor(chars.length / 2)]
478
+ const epochs = new Set(rows.map((r) => String(r.epoch ?? '')))
479
+ return {
480
+ turns: rows.length,
481
+ silent,
482
+ silentRatio: silent / rows.length,
483
+ medianChars: median,
484
+ epochs: epochs.size,
485
+ }
486
+ }
487
+
488
+ function printRecallPush() {
489
+ const dir = join(homedir(), '.rookie', 'recall-push')
490
+ const f = join(dir, 'metrics.jsonl')
491
+ // 훅이 크기 초과 시 .1 로 로테이트한다 — 직후에는 현재 파일이 거의 비어 있어
492
+ // 이것만 읽으면 최근 1000턴을 과소 집계한다(codex 코드검토 R2 P1). 앞에 이어 붙인다.
493
+ const rotated = f + '.1'
494
+ if (!existsSync(f) && !existsSync(rotated)) return
495
+ let s = null
496
+ try {
497
+ const read = (p) => (existsSync(p) ? readFileSync(p, 'utf8').split('\n').filter(Boolean) : [])
498
+ s = summarizeRecallPush([...read(rotated), ...read(f)])
499
+ } catch {
500
+ console.log('— 회상 푸시: 계측 읽기 실패')
501
+ return
502
+ }
503
+ if (!s) return
504
+ const pct = Math.round(s.silentRatio * 100)
505
+ console.log(
506
+ `— 회상 푸시: 최근 ${s.turns}턴 중 무주입 ${s.silent}건(${pct}%), 주입 길이 중앙값 ${s.medianChars}자, epoch ${s.epochs}개`
507
+ )
508
+ }
509
+
451
510
  // B6-3 (spec 2026-07-11 §B6): 세션 identity pinning 위반 가시화 — 세션 도중 계정 전환으로
452
511
  // flush 가 중단된 이력. Stop hook(flush-session.sh)이 남긴 로그를 집계한다. 0건이면 침묵.
453
512
  function printIdentityMismatch() {
@@ -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
+ }
@@ -1,14 +1,16 @@
1
1
  #!/bin/bash
2
2
  # UserPromptSubmit hook — 무조건 회상 푸시 (unconditional recall push).
3
- # 설계: docs/superpowers/specs/2026-08-08-unconditional-recall-push-design.md
3
+ # 설계: docs/superpowers/specs/2026-09-08-recall-always-inject-redesign.md (선행 2026-08-08)
4
4
  #
5
5
  # - 발동 조건 없음(E1 결정 제거): 매 사용자 발화를 그대로 recall 질의로 던진다. 빈 발화만 skip —
6
- # 길이 하한·낱말 필터는 결정의 재도입이라 두지 않는다(§1.1).
7
- # - hit(THRESH 이상 상위 K)만 additionalContext 로 그 턴의 생성 컨텍스트에 주입(E2). 무-hit → 0바이트.
8
- # - THRESH 기본 0.0165 = rrf k=60 단일 신호 최대치 1/61 바로 위 — "FTS·벡터 두 신호가 합의한
9
- # 결과만 주입"이라는 기계적 의미(worklog §96 분포 실측). 모델 판단·어휘 패턴 매칭이 아니다.
6
+ # 길이 하한·낱말 필터는 결정의 재도입이라 두지 않는다.
7
+ # - 주입 판정 없음: 상위 K 를 점수와 무관하게 넣는다. 옛 THRESH(0.0165)는 관련도가 아니라
8
+ # "어휘 암과 벡터 암이 같은 문서를 물었는가" 라는 검색 내부 사정을 재는 불리언이었고,
9
+ # 실측에서 주입의 78%를 죽였다(2026-09-08 §1 — '이어서 진행해' 0/5, '안녕' 4/5).
10
+ # - 관련도를 보증하지 않으므로 라벨에 그렇게 적고, 날짜·출처를 문장 앞에 놓아 절삭에도 살아남게 한다.
10
11
  # - 세션 dedup: 같은 기억 id 는 세션당 1회. /clear·compact 는 skill-gate <key>.epoch 값 변화로
11
12
  # 감지해 리셋한다(컨텍스트가 비워졌으니 재주입이 옳다).
13
+ # - 계측은 로컬 전용 — <push_dir>/metrics.jsonl. 서버 전송·스키마 변경 없다(§5.1).
12
14
  # - fail-soft: 타임아웃(늦으면 그 턴 무주입)·네트워크·파싱 실패 전부 조용히 무주입 종료.
13
15
  # 아무것도 막지 않는다 — deny 0, 기존 user-prompt-skill-gate.sh 불변.
14
16
  set -u
@@ -37,7 +39,7 @@ PUSH_DIR="${ROOKIE_RECALL_PUSH_DIR:-$HOME/.rookie/recall-push}"
37
39
  GATE_DIR="${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"
38
40
  HOST_VAL="$(hostname 2>/dev/null || echo "")"
39
41
 
40
- # payload 는 stdin 으로(ARG_MAX 회피). 질의·필터·dedup·주입 결정 전부 node 한 덩어리 —
42
+ # payload 는 stdin 으로(ARG_MAX 회피). 질의·dedup·주입 전부 node 한 덩어리 —
41
43
  # code 안에 작은따옴표 미사용(bash single-quote 보호).
42
44
  printf '%s' "$INPUT" | \
43
45
  ROOKIE_PUSH_DIR="$PUSH_DIR" ROOKIE_GATE_DIR="$GATE_DIR" \
@@ -45,29 +47,51 @@ printf '%s' "$INPUT" | \
45
47
  ROOKIE_HOST="$HOST_VAL" ROOKIE_CWD="$PWD" node -e '
46
48
  let d="";
47
49
  process.stdin.on("data",c=>d+=c).on("end",async()=>{
50
+ const fs=require("fs"),path=require("path"),crypto=require("crypto");
51
+ const METRIC_MAX_BYTES=parseInt(process.env.ROOKIE_RECALL_PUSH_METRIC_MAX_BYTES||"262144",10);
52
+ let epoch="";
53
+ // 로컬 계측 — 무주입도 반드시 남긴다(무주입률이 이 설계의 1차 관측 대상). 실패는 무시.
54
+ //
55
+ // 병렬 세션 안전: 쓰기는 append 전용(O_APPEND, 한 줄은 원자적)이고, 크기 초과 시에만
56
+ // rename 으로 로테이트한다. 파일을 읽어 다시 쓰는 방식은 두 세션이 같은 스냅샷을 보고
57
+ // 서로의 줄을 덮어쓸 수 있어 쓰지 않는다(codex 코드검토 R1 P1).
58
+ const metric=(layer,candidates,injected,text)=>{
59
+ try{
60
+ const dir=process.env.ROOKIE_PUSH_DIR;
61
+ const f=path.join(dir,"metrics.jsonl");
62
+ const s=String(text||"");
63
+ const line=JSON.stringify({
64
+ ts:new Date().toISOString(),epoch,layer,candidates,injected,
65
+ chars:s.length,bytes:Buffer.byteLength(s)
66
+ })+"\n";
67
+ fs.mkdirSync(dir,{recursive:true,mode:0o700});
68
+ try{
69
+ if(fs.statSync(f).size>METRIC_MAX_BYTES) fs.renameSync(f,f+".1");
70
+ }catch{} // 파일 없음·경합으로 이미 로테이트됨 — 둘 다 정상
71
+ fs.appendFileSync(f,line,{mode:0o600});
72
+ }catch{}
73
+ };
48
74
  try{
49
- const fs=require("fs"),path=require("path"),crypto=require("crypto");
50
75
  const p=JSON.parse(d);
51
76
  const sid=String(p.session_id||p.sessionId||"");
52
77
  let prompt=String(p.prompt||"").trim();
53
78
  if(!prompt) return; // 빈 발화 = 질의 불성립(유일한 기계 조건)
54
- prompt=prompt.slice(0,500); // RecallQuerySchema q max(500) — 초과는 400 무주입(R1 P1)
55
- const THRESH=Number(process.env.ROOKIE_RECALL_PUSH_THRESH||"0.0165");
79
+ prompt=prompt.slice(0,500); // RecallQuerySchema q max(500) — 초과는 400 무주입
56
80
  const K=parseInt(process.env.ROOKIE_RECALL_PUSH_TOPK||"3",10);
57
- const CAP=parseInt(process.env.ROOKIE_RECALL_PUSH_CAP||"1200",10);
81
+ const CAP=parseInt(process.env.ROOKIE_RECALL_PUSH_CAP||"600",10);
82
+ const ITEM=parseInt(process.env.ROOKIE_RECALL_PUSH_ITEM_CHARS||"200",10);
58
83
  const TIMEOUT=parseInt(process.env.ROOKIE_RECALL_PUSH_TIMEOUT_MS||"3500",10);
59
84
 
60
85
  // 세션 dedup 상태 — epoch(skill-gate 기록) 가 바뀌었으면 /clear·compact 경계로 보고 리셋.
61
86
  const key=sid?crypto.createHash("sha256").update(sid).digest("hex"):"";
62
- let epoch="";
63
87
  if(key){ try{ epoch=String(fs.readFileSync(path.join(process.env.ROOKIE_GATE_DIR,key+".epoch"),"utf8")).trim(); }catch{} }
64
88
  const stFile=key?path.join(process.env.ROOKIE_PUSH_DIR,key+".injected"):null;
65
89
  let st={epoch,ids:[]};
66
90
  if(stFile){ try{ const j=JSON.parse(fs.readFileSync(stFile,"utf8")); if(j&&j.epoch===epoch&&Array.isArray(j.ids)) st=j; }catch{} }
67
91
  const seen=new Set(st.ids);
68
92
 
69
- // 무조건 질의 — scope=all(현 구현상 project boost 없음, R1 P2).
70
- // x-rookie-hostname + repo_path 동시 전달 = workspace 격리 기억 가시성 계약(R2 P2, fail-closed).
93
+ // 무조건 질의 — scope=all(현 구현상 project boost 없음).
94
+ // x-rookie-hostname + repo_path 동시 전달 = workspace 격리 기억 가시성 계약(fail-closed).
71
95
  const u=new URL("/api/rookie/recall",process.env.ROOKIE_EP);
72
96
  u.searchParams.set("q",prompt);
73
97
  u.searchParams.set("scope","all");
@@ -80,32 +104,33 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
80
104
  if(!res.ok) return;
81
105
  const body=await res.json();
82
106
  const results=Array.isArray(body&&body.results)?body.results:[];
107
+ // 임계 없음 — 걸러내는 것은 형식 불량(id·요약 없음, 점수 비수치)과 기주입뿐이다.
83
108
  const cands=results
84
- .filter(r=>r&&typeof r.relevance==="number"&&r.relevance>=THRESH&&r.id&&typeof r.summary==="string"&&r.summary.trim()&&!seen.has(r.id))
109
+ .filter(r=>r&&typeof r.relevance==="number"&&r.id&&typeof r.summary==="string"&&r.summary.trim()&&!seen.has(r.id))
85
110
  .sort((a,b)=>b.relevance-a.relevance)
86
111
  .slice(0,K);
87
- if(!cands.length) return; // 무-hit(또는 전부 기주입) → 0바이트
112
+ if(!cands.length){ metric("B",results.length,0,""); return; }
88
113
 
89
- // 주입 문구는 지시가 아니라 자료다(§3.2) — 요약 + 시각 + 출처 프로젝트(전역이면 명시).
90
- const MARK=["①","②","③","④","⑤"];
91
- let msg="🧠 관련 기억:";
114
+ // 주입 문구는 지시가 아니라 자료다. 관련도를 보증하지 않으므로 라벨에 명시하고,
115
+ // 날짜·출처를 앞에 놓아 ITEM 절삭에 잘려 나가지 않게 한다.
116
+ let msg="🧠 저장소 항목 (질의 연동, 관련도 미보증):";
92
117
  const used=[];
93
- for(let i=0;i<cands.length;i++){
94
- const r=cands[i];
118
+ for(const r of cands){
95
119
  const when=r.memory_at?String(r.memory_at).slice(0,10):"시각 미상";
96
120
  const src=r.project_name?String(r.project_name):"전역";
97
- const summary=String(r.summary).replace(/\s+/g," ").trim().slice(0,400);
98
- const item="\n"+(MARK[i]||"-")+" "+summary+" ("+when+", "+src+")";
121
+ const summary=String(r.summary).replace(/\s+/g," ").trim().slice(0,ITEM);
122
+ const item="\n- ["+when+" · "+src+"] "+summary;
99
123
  if(msg.length+item.length>CAP) break; // 총량 상한 — relevance 순 절삭
100
124
  msg+=item; used.push(r.id);
101
125
  }
102
- if(!used.length) return;
126
+ if(!used.length){ metric("B",results.length,0,""); return; }
103
127
  if(stFile){
104
128
  try{
105
129
  fs.mkdirSync(process.env.ROOKIE_PUSH_DIR,{recursive:true,mode:0o700});
106
130
  fs.writeFileSync(stFile,JSON.stringify({epoch,ids:st.ids.concat(used)}),{mode:0o600});
107
131
  }catch{} // dedup 기록 실패 → 다음 턴 중복 주입 위험만, 주입은 진행
108
132
  }
133
+ metric("B",results.length,used.length,msg);
109
134
  process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:msg}}));
110
135
  }catch{ /* fail-soft — 조용히 무주입 */ }
111
136
  });
@@ -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.27",
2
+ "cli_version": "0.4.29",
3
3
  "skill_version": "1.39.0"
4
4
  }