therookie 0.4.21 → 0.4.24

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.21",
3
+ "version": "0.4.24",
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": [
@@ -129,6 +129,7 @@ export async function runDoctor(flags) {
129
129
  await printViolationTop(cfg)
130
130
  await printEmbeddingGuard(cfg)
131
131
  await printInjectionSize(cfg)
132
+ printPendingInject()
132
133
  printContextDiet()
133
134
 
134
135
  if (flags['deep-scan']) {
@@ -193,11 +194,51 @@ async function printInjectionSize(cfg) {
193
194
  } else {
194
195
  console.log(`✓ SessionStart 주입 크기 ${size}자 (임계 ${CLAUDE_HOOK_OUTPUT_CAP}자 이내)`)
195
196
  }
197
+ // spec 2026-09-02 (2단계 로드 강제) — 예산에서 빠진 것이 무엇이고 얼마인지.
198
+ // 이 줄이 없으면 "무엇이 안 들어갔는지"를 세션 밖에서 알 길이 없다.
199
+ const omitted = Array.isArray(ctx?.omitted_sections) ? ctx.omitted_sections : []
200
+ if (omitted.length > 0) {
201
+ const chars = omitted.reduce((n, o) => n + (o?.text?.length ?? 0), 0)
202
+ const labels = omitted.map((o) => o?.label).filter(Boolean).join(' · ')
203
+ console.log(` 생략 ${omitted.length}개 ${chars}자 (${labels}) → 첫 발화에 이어서 주입`)
204
+ }
196
205
  } catch {
197
206
  // silent — endpoint 미응답 시 본 라인만 누락.
198
207
  }
199
208
  }
200
209
 
210
+ // spec 2026-09-02 (2단계 로드 강제) — 대기 중인 인격 주입 상태.
211
+ // 조용한 실패가 이 기능이 고치려는 병이라, 이 기능 자신의 상태를 볼 수 있어야 한다.
212
+ function printPendingInject() {
213
+ const dir = process.env.ROOKIE_PENDING_INJECT_DIR || join(homedir(), '.rookie', 'pending-inject')
214
+ if (!existsSync(dir)) return
215
+ let files = []
216
+ try {
217
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'))
218
+ } catch {
219
+ return
220
+ }
221
+ if (files.length === 0) return
222
+ let waiting = 0
223
+ let stale = 0
224
+ for (const f of files) {
225
+ try {
226
+ const b = JSON.parse(readFileSync(join(dir, f), 'utf8'))
227
+ const total = Array.isArray(b?.sections) ? b.sections.length : 0
228
+ const done = Number.isInteger(b?.next_index) ? b.next_index : 0
229
+ waiting += Math.max(0, total - done)
230
+ const age = Date.now() - new Date(b?.created_at ?? 0).getTime()
231
+ if (age > 24 * 3600 * 1000) stale += 1
232
+ } catch {
233
+ stale += 1
234
+ }
235
+ }
236
+ console.log(`— 인격 2단계 주입 대기: 세션 ${files.length}개 · 섹션 ${waiting}개`)
237
+ if (stale > 0) {
238
+ console.log(` ⚠ 하루 넘게 남았거나 읽을 수 없는 것 ${stale}건 — 첫 발화가 없었던 세션일 수 있습니다`)
239
+ }
240
+ }
241
+
201
242
  // spec post-v5-decisions §18 — templates manifest 비교 가시화
202
243
  function printTemplateManifestStatus(serverStatus) {
203
244
  const local = loadClientManifest()
@@ -14,6 +14,34 @@ import { readFileSync } from 'node:fs'
14
14
  import { loadConfig, getHostname } from '../config.js'
15
15
  import { apiPost } from '../api.js'
16
16
 
17
+ // 저장된 층을 **빠짐없이** 화면에 센다. 여기 없는 층은 서버가 저장해도 0건처럼 보인다 —
18
+ // 2026-09-02 저장에서 durable fact 3건이 그렇게 안 보였고, metric·recall 로 따로 확인해야
19
+ // 들어간 걸 알 수 있었다. 어제 닫은 사고("문은 열었는데 아무도 안 본다")와 같은 모양이다.
20
+ //
21
+ // 중복 스킵도 함께 센다. applied 만 보면 "보냈는데 전부 중복이라 0건" 과 "안 보냈다" 가
22
+ // 화면에서 구분되지 않는다(그게 이 결함이 오래 안 보인 이유다).
23
+ export function formatSaveSummary(s) {
24
+ const parts = []
25
+ if (s.working_cards?.ids?.length) parts.push(`카드 ${s.working_cards.ids.length}`)
26
+ const factsApplied = s.facts?.applied ?? 0
27
+ const factsDup = s.facts?.skipped_duplicate ?? 0
28
+ if (factsApplied || factsDup) {
29
+ parts.push(factsDup > 0 ? `사실 ${factsApplied}(중복 ${factsDup})` : `사실 ${factsApplied}`)
30
+ }
31
+ if (s.evidence?.applied) parts.push(`증거 ${s.evidence.applied}`)
32
+ if (s.personal_facts?.applied) parts.push(`취향 ${s.personal_facts.applied}`)
33
+ if (s.self_rules?.applied) {
34
+ const reinforced = s.self_rules.reinforced ?? 0
35
+ parts.push(
36
+ reinforced > 0
37
+ ? `자기규칙 ${s.self_rules.applied}(강화 ${reinforced})`
38
+ : `자기규칙 ${s.self_rules.applied}`
39
+ )
40
+ }
41
+ if (s.verifications?.applied) parts.push(`검증 ${s.verifications.applied}`)
42
+ return parts.length > 0 ? parts.join(' · ') : '0건'
43
+ }
44
+
17
45
  function readStdin() {
18
46
  // TTY 면 파이프가 없다 — EOF 까지 무한 대기하지 않도록 즉시 빈 값으로 빠진다.
19
47
  if (process.stdin.isTTY) return ''
@@ -119,19 +147,7 @@ export async function runSave(flags) {
119
147
  '→ 같은 취지가 있으면 reinforces:"<id>"(강화) 또는 supersedes:"<id>"(정정), 없으면 peers_reviewed:true 로 재전송하세요.'
120
148
  )
121
149
  }
122
- const parts = []
123
- if (s.working_cards?.ids?.length) parts.push(`카드 ${s.working_cards.ids.length}`)
124
- if (s.evidence?.applied) parts.push(`증거 ${s.evidence.applied}`)
125
- if (s.self_rules?.applied) {
126
- const reinforced = s.self_rules.reinforced ?? 0
127
- parts.push(
128
- reinforced > 0
129
- ? `자기규칙 ${s.self_rules.applied}(강화 ${reinforced})`
130
- : `자기규칙 ${s.self_rules.applied}`
131
- )
132
- }
133
- if (s.verifications?.applied) parts.push(`검증 ${s.verifications.applied}`)
134
- const label = parts.length > 0 ? parts.join(' · ') : '0건'
150
+ const label = formatSaveSummary(s)
135
151
 
136
152
  if (res.all_committed === false) {
137
153
  // 검토 게이트만 걸린 저장은 "미저장 0건" 이 아니라 다음 행동이 남은 상태다.
@@ -171,6 +171,11 @@ export async function runUpdate(flags) {
171
171
  // B6 (spec 2026-07-11) — config 상향 탐색 + 세션 identity pinning 공용 헬퍼.
172
172
  cpSync(join(TEMPLATES, 'hook', 'lib', 'config-resolve.sh'), join(libDir, 'config-resolve.sh'))
173
173
  chmodSync(join(libDir, 'config-resolve.sh'), 0o755)
174
+ // spec 2026-09-02 — 예산에서 생략된 인격 층을 첫 발화에 주입하는 대기열 헬퍼.
175
+ // 이 목록이 하드코딩이라 새 lib 을 빠뜨리면 manifest 는 맞는데 파일이 없는
176
+ // 조용한 실패가 난다(2026-09-02 실제로 겪었다). 새 lib 추가 시 여기도 같이.
177
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'pending-inject.sh'), join(libDir, 'pending-inject.sh'))
178
+ chmodSync(join(libDir, 'pending-inject.sh'), 0o755)
174
179
 
175
180
  // settings.json 재병합 (글로벌)
176
181
  const settingsPath = join(homedir(), '.claude', 'settings.json')
@@ -102,6 +102,11 @@ export function installSharedHookRuntime({ scope, home, cwd } = {}) {
102
102
  // B6 (spec 2026-07-11) — config 상향 탐색 + 세션 identity pinning 공용 헬퍼.
103
103
  cpSync(join(TEMPLATES, 'hook', 'lib', 'config-resolve.sh'), join(libDir, 'config-resolve.sh'))
104
104
  chmodSync(join(libDir, 'config-resolve.sh'), 0o755)
105
+ // spec 2026-09-02 — 예산에서 생략된 인격 층을 첫 발화에 주입하는 대기열 헬퍼.
106
+ // 이 목록이 하드코딩이라 새 lib 을 빠뜨리면 manifest 는 맞는데 파일이 없는
107
+ // 조용한 실패가 난다(2026-09-02 실제로 겪었다). 새 lib 추가 시 여기도 같이.
108
+ cpSync(join(TEMPLATES, 'hook', 'lib', 'pending-inject.sh'), join(libDir, 'pending-inject.sh'))
109
+ chmodSync(join(libDir, 'pending-inject.sh'), 0o755)
105
110
 
106
111
  return { binDir }
107
112
  }
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # lib/pending-inject.sh — 예산에서 생략된 페르소나 섹션을 첫 발화에 주입하기 위한 대기열.
3
+ # 설계: docs/superpowers/specs/2026-09-02-two-stage-load-enforcement.md
4
+ #
5
+ # 왜 필요한가: SessionStart 훅은 additionalContext 10,000자 벽 때문에 예산 8,500 으로
6
+ # 조립하고, 전문(약 17,300자)에서 가장 큰 섹션(voice 등)을 통째로 건너뛴다. 지금까지는
7
+ # SKILL.md 문구가 세션에게 되받아 오라고 시켰을 뿐이라, 안 따르면 인격이 통째로 빠졌다.
8
+ # 여기서는 서버가 같은 응답에 실어 준 생략분 본문을 파일로 두었다가 UserPromptSubmit 이
9
+ # 직접 주입한다 — 조회와 섭취 사이에 세션의 판단이 끼지 않게 하는 것이 요점.
10
+ #
11
+ # 키 = sha256(session_id). skill-gate 와 같은 규약이며, 빈 session_id 는 쓰지 않는다
12
+ # (빈 값을 해시하면 모든 세션이 한 파일을 공유해 남의 인격을 받는다).
13
+
14
+ pending_inject_dir() {
15
+ printf '%s' "${ROOKIE_PENDING_INJECT_DIR:-$HOME/.rookie/pending-inject}"
16
+ }
17
+
18
+ # pending_inject_write <session_id> <context_response_file> <source>
19
+ # source 가 resume 이면 쓰지 않는다 — 컨텍스트가 살아 있어 중복 주입이 된다.
20
+ # startup·clear·compact(및 빈 값)만 새 주입 회차로 본다.
21
+ # 응답에 omitted_sections 가 없거나 비어 있으면 조용히 아무것도 하지 않는다.
22
+ pending_inject_write() {
23
+ local sid="${1:-}" resp="${2:-}" src="${3:-}" dir key
24
+ [ -n "$sid" ] || return 0
25
+ [ -f "$resp" ] || return 0
26
+ [ "$src" != "resume" ] || return 0
27
+ dir="$(pending_inject_dir)"
28
+ if command -v skill_gate_key >/dev/null 2>&1; then
29
+ key="$(skill_gate_key "$sid")"
30
+ else
31
+ key=$(printf '%s' "$sid" | 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)
32
+ fi
33
+ [ -n "$key" ] || return 0
34
+ mkdir -p "$dir" 2>/dev/null
35
+ chmod 700 "$dir" 2>/dev/null
36
+
37
+ ROOKIE_PI_DIR="$dir" ROOKIE_PI_KEY="$key" ROOKIE_PI_RESP="$resp" node -e '
38
+ try {
39
+ const fs=require("fs"), path=require("path");
40
+ const dir=process.env.ROOKIE_PI_DIR, key=process.env.ROOKIE_PI_KEY;
41
+ const j=JSON.parse(fs.readFileSync(process.env.ROOKIE_PI_RESP,"utf8"));
42
+ const raw=Array.isArray(j.omitted_sections)?j.omitted_sections:[];
43
+ const sections=raw
44
+ .filter(s=>s&&typeof s.label==="string"&&typeof s.text==="string"&&s.text.trim())
45
+ .map(s=>({label:s.label,text:s.text}));
46
+ const target=path.join(dir,key+".json");
47
+ if(sections.length===0){ try{ fs.unlinkSync(target); }catch{} process.exit(0); }
48
+ // symlink 로 바꿔치기된 자리에는 쓰지 않는다 — 지우고 새로 만든다.
49
+ try{ const st=fs.lstatSync(target); if(!st.isFile()) fs.unlinkSync(target); }catch{}
50
+ const body={
51
+ version:1,
52
+ created_at:new Date().toISOString(),
53
+ next_index:0,
54
+ injected_at:[],
55
+ sections,
56
+ };
57
+ const tmp=path.join(dir,key+".json.tmp");
58
+ fs.writeFileSync(tmp,JSON.stringify(body),{mode:0o600});
59
+ fs.renameSync(tmp,target); // atomic — 부분 기록 상태가 남지 않는다
60
+ } catch {}
61
+ ' 2>/dev/null || true
62
+ return 0
63
+ }
@@ -30,7 +30,7 @@ fi
30
30
  # spec 2026-06-18 crash-recovery: orphan flush recovery helper (transcript.sh → orphan-recovery.sh).
31
31
  # 비정상 종료(Stop hook 미발사) 세션의 transcript 를 다음 SessionStart 에서 사후 회수.
32
32
  # B6-1 (spec 2026-07-11): config-resolve.sh — env > 로컬(.rookie/, cwd 상향) > 전역 해석 + identity pin.
33
- for _lib in transcript.sh orphan-recovery.sh skill-gate.sh config-resolve.sh; do
33
+ for _lib in transcript.sh orphan-recovery.sh skill-gate.sh config-resolve.sh pending-inject.sh; do
34
34
  if [ -f "$SCRIPT_DIR/lib/$_lib" ]; then
35
35
  # shellcheck source=/dev/null
36
36
  . "$SCRIPT_DIR/lib/$_lib"
@@ -258,6 +258,12 @@ if [ "$STATUS_CODE" = "200" ]; then
258
258
  queue_record_success
259
259
  fi
260
260
 
261
+ # spec 2026-09-02 (2단계 로드 강제) — 예산에서 생략된 섹션 본문을 대기열에 둔다.
262
+ # UserPromptSubmit 이 첫 발화에 직접 주입한다. resume 은 컨텍스트가 살아 있어 제외.
263
+ if command -v pending_inject_write >/dev/null 2>&1; then
264
+ pending_inject_write "$SESSION_ID" "$RESP_FILE" "$SOURCE" || true
265
+ fi
266
+
261
267
  QUEUE_AFTER=0
262
268
  if command -v queue_count >/dev/null 2>&1; then
263
269
  QUEUE_AFTER=$(queue_count)
@@ -31,12 +31,15 @@ fi
31
31
  [ -n "$TOKEN" ] || exit 0
32
32
 
33
33
  GATE_DIR="${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"
34
+ PI_DIR="${ROOKIE_PENDING_INJECT_DIR:-$HOME/.rookie/pending-inject}"
35
+ # 조용한 실패가 이 기능이 고치려는 병이라, 이 기능 자신의 실패는 남긴다(dispatcher 와 같은 로그).
36
+ DROP_LOG="${ROOKIE_DROP_LOG:-$HOME/.rookie/dropped-mutations.log}"
34
37
 
35
38
  # stdin(UserPromptSubmit payload) → node 가 전부 처리(파싱·sha256·marker·epoch·scan·신호·출력 결정).
36
39
  # node 단일 덩어리라 fail-soft·stderr 억제가 쉽다. code 안에 작은따옴표 미사용(bash single-quote 보호).
37
40
  # 두 갈래(한 turn 한 메시지): skill 미로드 → SKILL_MSG / 로드됨 + 과거-방법 회상 신호 → RECALL_MSG.
38
41
  # marker(.loaded)는 "로드됨 캐시"일 뿐 early-return 아님 — 로드 뒤에도 프롬프트 신호를 매 turn 검사(spec 2026-07-07-recall-first §3.1).
39
- cat | ROOKIE_GATE_DIR="$GATE_DIR" node -e '
42
+ cat | ROOKIE_GATE_DIR="$GATE_DIR" ROOKIE_PI_DIR="$PI_DIR" ROOKIE_DROP_LOG="$DROP_LOG" node -e '
40
43
  let d="";
41
44
  process.stdin.on("data",c=>d+=c).on("end",()=>{
42
45
  const SKILL_MSG="⚠️ 이 세션에서 아직 rookie Skill 을 로드하지 않았습니다. 다른 작업·응답 전에 먼저 rookie Skill 을 호출하세요 — 회상(recall)·미link 등록 제안·기억 저장 절차가 그 안에 있습니다. (이미 호출했다면 이 안내는 사라집니다.)";
@@ -46,6 +49,63 @@ cat | ROOKIE_GATE_DIR="$GATE_DIR" node -e '
46
49
  // 충돌하고, recall 절차 자체가 SKILL.md 안에 있어 미로드 세션은 그 절차를 아직 못 읽었다 — 순서를 못박는다.
47
50
  const COMBO_MSG="🧠 이 프롬프트에 과거 맥락·방법 회상 신호가 있는데, 이 세션은 아직 rookie Skill 을 로드하지 않았습니다. 순서대로 하세요 — ① rookie Skill 을 먼저 호출하고 ② 그 안의 회상 절차로 이번 프롬프트에 대해 GET /api/rookie/recall 을 최소 1회 호출한 뒤 ③ 답하세요. grep·파일 열람·git log 추론·두목 되묻기는 그 다음입니다.";
48
51
  const injectMsg=(msg)=>{ try{ process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:msg}})); }catch{} };
52
+ // spec 2026-09-02 (2단계 로드 강제) — SessionStart 가 남긴 생략 섹션을 꺼내 온다.
53
+ // 조회와 섭취 사이에 세션의 판단이 끼지 않게, 훅이 본문을 직접 실어 보낸다.
54
+ // 한 번에 CHUNK_MAX 까지만 — 10,000자 벽 아래로 유지하고 나머지는 다음 발화에서.
55
+ const CHUNK_MAX=8000, LOCK_STALE_MS=60000;
56
+ const dropLog=(fs,reason)=>{
57
+ try{ const p=process.env.ROOKIE_DROP_LOG; if(p) fs.appendFileSync(p,new Date().toISOString()+" pending_inject "+reason+"\n"); }catch{}
58
+ };
59
+ // 반환: { text, commit } — **주입을 내보낸 뒤에** commit 을 부른다.
60
+ // 소비 표시를 출력 전에 하면 그 사이 훅이 죽었을 때 인격이 영영 사라진다
61
+ // (이 기능이 막으려는 바로 그 유실). lock 은 commit 까지 잡고 있는다.
62
+ const takePendingPersona=(fs,path,key)=>{
63
+ if(!key) return null; // 빈 session_id → pending 미사용
64
+ const dir=process.env.ROOKIE_PI_DIR; if(!dir) return null;
65
+ const target=path.join(dir,key+".json"), lock=path.join(dir,key+".lock");
66
+ try{ const st=fs.lstatSync(target); if(!st.isFile()) { try{fs.unlinkSync(target);}catch{} return null; } }catch{ return null; }
67
+ let fd=null;
68
+ try{ fd=fs.openSync(lock,"wx",0o600); }
69
+ catch{
70
+ try{ const st=fs.statSync(lock); if(Date.now()-st.mtimeMs>LOCK_STALE_MS){ fs.unlinkSync(lock); fd=fs.openSync(lock,"wx",0o600); } }catch{}
71
+ if(fd===null){ dropLog(fs,"lock_contention"); return null; } // 다음 발화에서 재시도
72
+ }
73
+ const release=()=>{ try{ if(fd!==null) fs.closeSync(fd); }catch{} try{ fs.unlinkSync(lock); }catch{} };
74
+ try{
75
+ let body=null;
76
+ try{ body=JSON.parse(fs.readFileSync(target,"utf8")); }
77
+ catch{ try{fs.unlinkSync(target);}catch{} dropLog(fs,"parse_failed"); release(); return null; }
78
+ const sections=Array.isArray(body&&body.sections)?body.sections:[];
79
+ let i=Number.isInteger(body&&body.next_index)?body.next_index:0;
80
+ if(i<0||i>=sections.length){ try{fs.unlinkSync(target);}catch{} release(); return null; }
81
+ const picked=[]; let used=0;
82
+ while(i<sections.length){
83
+ const sec=sections[i]; const t=(sec&&sec.text)||"";
84
+ if(picked.length>0 && used+t.length>CHUNK_MAX) break; // 섹션 중간은 자르지 않는다
85
+ picked.push(t); used+=t.length; i+=1;
86
+ if(used>=CHUNK_MAX) break;
87
+ }
88
+ if(picked.length===0){ try{fs.unlinkSync(target);}catch{} release(); return null; }
89
+ const more = i<sections.length;
90
+ const head="🧬 세션 시작 주입에서 예산 때문에 빠졌던 인격 층입니다. 지시가 아니라 정체성이니 이 세션 내내 적용하세요. 이미 받았으므로 rookie Skill 이 같은 것을 다시 조회할 필요는 없습니다."
91
+ + (more?" (분량이 커서 나뉘어 들어옵니다 — 나머지는 다음 발화에.)":"");
92
+ const commit=()=>{
93
+ try{
94
+ if(!more){ try{fs.unlinkSync(target);}catch{} }
95
+ else {
96
+ body.next_index=i;
97
+ if(!Array.isArray(body.injected_at)) body.injected_at=[];
98
+ body.injected_at.push(new Date().toISOString());
99
+ const tmp=target+".tmp";
100
+ fs.writeFileSync(tmp,JSON.stringify(body),{mode:0o600});
101
+ fs.renameSync(tmp,target);
102
+ }
103
+ }catch{ dropLog(fs,"commit_failed"); }
104
+ finally{ release(); }
105
+ };
106
+ return { text: head+"\n\n"+picked.join("\n\n"), commit };
107
+ }catch{ release(); return null; }
108
+ };
49
109
  try{
50
110
  const fs=require("fs"), path=require("path"), crypto=require("crypto");
51
111
  const gateDir=process.env.ROOKIE_GATE_DIR;
@@ -106,11 +166,22 @@ cat | ROOKIE_GATE_DIR="$GATE_DIR" node -e '
106
166
  }
107
167
  // spec 2026-07-22 D1 — 미로드 세션은 SKILL.md 회상 규범이 컨텍스트에 없다. 여기서 recall 지시까지
108
168
  // 꺼버리면 회상이 가장 필요한 상태가 무방비가 된다(2026-07-22 catsper 사고). 한 turn 한 메시지는 유지.
109
- if(!skillLoaded && recallSignal) return injectMsg(COMBO_MSG);
110
- if(!skillLoaded) return injectMsg(SKILL_MSG); // 미로드 + 무신호 → skill 리마인더
111
- if(recallSignal) return injectMsg(RECALL_MSG); // 로드됨 + 과거-방법 신호 → recall 리마인더
112
- if(ckptDue) return injectMsg(CKPT_MSG); // 로드됨 + 무신호 + 임계 도달 → checkpoint 리마인더
113
- return; // 로드됨 + 신호 없음 → 조용
169
+ // spec 2026-09-02 — 인격 블록은 기존 분기를 **가로채지 않고 앞에 붙는다**.
170
+ // 단독 분기로 끼우면 미로드+회상신호 세션이 COMBO 의 순서 지시를 못 받는 turn 이 생긴다.
171
+ let base=null;
172
+ if(!skillLoaded && recallSignal) base=COMBO_MSG;
173
+ else if(!skillLoaded) base=SKILL_MSG; // 미로드 + 무신호 → skill 리마인더
174
+ else if(recallSignal) base=RECALL_MSG; // 로드됨 + 과거-방법 신호 → recall 리마인더
175
+ else if(ckptDue) base=CKPT_MSG; // 로드됨 + 무신호 + 임계 도달 → checkpoint 리마인더
176
+ let taken=null;
177
+ try{ taken=takePendingPersona(fs,path,key); }catch{ taken=null; }
178
+ const parts=[];
179
+ if(taken) parts.push(taken.text);
180
+ if(base) parts.push(base);
181
+ if(parts.length===0) return; // 둘 다 없음 → 조용
182
+ injectMsg(parts.join("\n\n"));
183
+ if(taken) taken.commit(); // 내보낸 뒤에 소비 표시
184
+ return;
114
185
  }catch{ return injectMsg(SKILL_MSG); } // fail-soft
115
186
  });
116
187
  ' 2>/dev/null
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.21",
2
+ "cli_version": "0.4.24",
3
3
  "skill_version": "1.39.0"
4
4
  }