therookie 0.4.36 → 0.4.38

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.36",
3
+ "version": "0.4.38",
4
4
  "description": "Persistent memory for Claude Code and Codex CLI — teach your AI once and it remembers across sessions, tools, and machines. The AI new hire that remembers what you teach it.",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/cli.js CHANGED
@@ -18,9 +18,9 @@ import { runOrphanExtract } from './commands/orphan-extract.js'
18
18
  const HELP = `rookie — the Rookie Skill/Hook 설치 관리자
19
19
 
20
20
  사용:
21
- rookie init [--local] [--force] [--yes] [--deep-scan]
22
- rookie login [--local]
23
- rookie update [--force]
21
+ rookie init [--local] [--force] [--yes] [--deep-scan] [--claude-md | --no-claude-md]
22
+ rookie login [--local] [--claude-md | --no-claude-md]
23
+ rookie update [--force] [--claude-md | --no-claude-md]
24
24
  rookie link [--local]
25
25
  rookie save [--file <body.json>] [--json] # 또는 stdin. --json = 서버 raw 응답 출력
26
26
  rookie status
@@ -38,6 +38,8 @@ const HELP = `rookie — the Rookie Skill/Hook 설치 관리자
38
38
  --force 경고 무시, 재설치/덮어쓰기
39
39
  --yes 비대화형 자동 승인
40
40
  --deep-scan 홈 디렉토리에서 로컬 Skill 전수 스캔
41
+ --claude-md 글로벌 ~/.claude/CLAUDE.md 에 더루키 관리 블록을 넣는다(심링크·이미 같은 규칙이 있어도, update 에서도)
42
+ --no-claude-md 글로벌 CLAUDE.md 를 건드리지 않는다 (환경 변수 ROOKIE_NO_CLAUDE_MD=1 과 같다)
41
43
 
42
44
  명령:
43
45
  init 첫 설치. 비밀번호 입력 받아 PAT 발급 + Skill/Hook 배포.
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { claudeMdStatusLine } from '../lib/claude-md-block.js'
2
3
  import { homedir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
  import { execSync } from 'node:child_process'
@@ -42,6 +43,8 @@ export async function runDoctor(flags) {
42
43
  const lSettings = join(process.cwd(), '.claude', 'settings.json')
43
44
  console.log(hasRookieHook(gSettings) ? '✓ 글로벌 hook 등록됨' : '✗ 글로벌 hook 없음')
44
45
  console.log(hasRookieHook(lSettings) ? '✓ 로컬 hook 등록됨' : ' 로컬 hook 없음')
46
+ const claudeMdLine = claudeMdStatusLine()
47
+ if (claudeMdLine) console.log(claudeMdLine)
45
48
 
46
49
  if (g && l && g.version !== l.version) {
47
50
  console.log(`⚠ 버전 불일치: 글로벌 v${g.version} vs 로컬 v${l.version}`)
@@ -12,6 +12,7 @@ import { apiGet, apiPostNoAuth } from '../api.js'
12
12
  import { applyToolSetup, pendingToolSetup } from '../lib/apply-tool-setup.js'
13
13
  import { renderDetected, renderConnectOutcome, toolName } from '../lib/connect-ui.js'
14
14
  import { resolveEndpoint } from '../lib/resolve-endpoint.js'
15
+ import { runClaudeMdStep } from '../lib/claude-md-block.js'
15
16
 
16
17
  async function pickChoice(prompt, options, autoValue) {
17
18
  console.log(prompt)
@@ -172,4 +173,10 @@ export async function runInit(flags) {
172
173
  confirmWeak: (t) => confirm(`${toolName(t.id)} 디렉토리만 감지됨(CLI 미확인). 연결할까요?`, { defaultYes: true }),
173
174
  })
174
175
  for (const line of renderConnectOutcome(report, tools)) console.log(line)
176
+
177
+ // spec 2026-09-29 — 글로벌 CLAUDE.md 에 "더루키 먼저" 관리 블록(글로벌 설치·Claude 적용 시만).
178
+ if (scope === 'global' && report.applied.some((a) => a.id === 'claude')) {
179
+ const { lines } = await runClaudeMdStep({ mode: 'connect', flags, interactive, confirmFn: confirm })
180
+ for (const line of lines) console.log(line)
181
+ }
175
182
  }
@@ -10,6 +10,7 @@ import { renderDetected, renderConnectOutcome, toolName } from '../lib/connect-u
10
10
  import { confirm } from '../prompt.js'
11
11
  import { resolveEndpoint } from '../lib/resolve-endpoint.js'
12
12
  import { accountLine, accountWarning } from '../lib/account-ui.js'
13
+ import { runClaudeMdStep } from '../lib/claude-md-block.js'
13
14
 
14
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
15
16
 
@@ -157,4 +158,16 @@ export async function runLogin(flags) {
157
158
  confirmWeak: (t) => confirm(`${toolName(t.id)} 디렉토리만 감지됨(CLI 미확인). 연결할까요?`, { defaultYes: true }),
158
159
  })
159
160
  for (const line of renderConnectOutcome(report, tools, cfg.agent_name)) console.log(line)
161
+
162
+ // spec 2026-09-29 — 글로벌 CLAUDE.md 에 "더루키 먼저" 관리 블록. install.sh 의 비대화 login 도
163
+ // 여기를 지난다 — 처음 연결은 사용자가 설치를 고른 순간이라 기본 적용(끄기: ROOKIE_NO_CLAUDE_MD=1).
164
+ if (scope === 'global' && report.applied.some((a) => a.id === 'claude')) {
165
+ const { lines } = await runClaudeMdStep({
166
+ mode: 'connect',
167
+ flags,
168
+ interactive: !!process.stdin.isTTY && !flags.yes,
169
+ confirmFn: confirm,
170
+ })
171
+ for (const line of lines) console.log(line)
172
+ }
160
173
  }
@@ -13,6 +13,8 @@ import { semverGt, fetchReleaseManifest, selfUpdateAndRerun, isNpmManagedInstall
13
13
  import { installSkill } from '../lib/skill-installer.js'
14
14
  import { claudeHookCommand, escapeJsonStringValue } from '../lib/hook-command.js'
15
15
  import { mergeHooks } from '../lib/merge-hooks.js'
16
+ import { runClaudeMdStep } from '../lib/claude-md-block.js'
17
+ import { confirm } from '../prompt.js'
16
18
 
17
19
  const __filename = fileURLToPath(import.meta.url)
18
20
  const __dirname = dirname(__filename)
@@ -52,6 +54,19 @@ export async function runUpdate(flags) {
52
54
  }
53
55
  }
54
56
 
57
+ // spec 2026-09-29 — 글로벌 CLAUDE.md 더루키 블록 점검. 서버를 부르지 않는 로컬 단계라
58
+ // manifest "최신" 조기 종료·서버 실패보다 앞에 둔다. 표식이 있으면 갱신, 없으면 대화형 확인 또는
59
+ // --claude-md 일 때만 붙인다 — update 는 모델이 비대화로 돌리는 경로가 있다.
60
+ {
61
+ const { lines } = await runClaudeMdStep({
62
+ mode: 'update',
63
+ flags,
64
+ interactive: !!process.stdin.isTTY && !flags.yes,
65
+ confirmFn: confirm,
66
+ })
67
+ for (const line of lines) console.log(line)
68
+ }
69
+
55
70
  let serverManifest
56
71
  try {
57
72
  const status = await apiGet(cfg.endpoint, cfg.token, '/api/rookie/status')
@@ -0,0 +1,243 @@
1
+ // 글로벌 ~/.claude/CLAUDE.md 에 "더루키 먼저" 관리 블록을 넣고 갱신한다.
2
+ // spec docs/superpowers/specs/2026-09-29-claude-md-rookie-block.md
3
+ //
4
+ // 표식(<!-- rookie:begin vN --> … <!-- rookie:end -->) 사이만 관리하고 밖은 바이트 그대로 둔다.
5
+ // 심링크는 끊지 않는다 — 실제 대상 파일(realpath)에 쓴다.
6
+ import {
7
+ existsSync,
8
+ lstatSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ statSync,
12
+ writeFileSync,
13
+ renameSync,
14
+ copyFileSync,
15
+ chmodSync,
16
+ unlinkSync,
17
+ } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join, dirname, basename } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ export const DEFAULT_TEMPLATE_PATH = join(__dirname, '..', '..', 'templates', 'claude-md', 'rookie-block.md')
24
+
25
+ const BEGIN_RE = /<!-- rookie:begin v(\d+) -->/g
26
+ const END_MARK = '<!-- rookie:end -->'
27
+ // 더루키에만 있는 경로라 "같은 취지 규칙이 이미 있다" 판정에 쓴다(spec §3.1).
28
+ export const SAME_INTENT_NEEDLE = '/api/rookie/recall'
29
+
30
+ export function readTemplateBlock(templatePath = DEFAULT_TEMPLATE_PATH) {
31
+ const raw = readFileSync(templatePath, 'utf8')
32
+ const parsed = parseRookieBlock(raw)
33
+ if (parsed.state !== 'ok') throw new Error(`rookie-block 템플릿 표식 이상: ${parsed.state}`)
34
+ return { version: parsed.version, text: raw.slice(parsed.start, parsed.end) }
35
+ }
36
+
37
+ // { state: 'none' } | { state: 'malformed' } | { state: 'ok', version, start, end, text }
38
+ // start/end 는 begin 표식 첫 글자 ~ end 표식 마지막 글자 다음 위치.
39
+ export function parseRookieBlock(content) {
40
+ const begins = [...content.matchAll(BEGIN_RE)]
41
+ const endCount = content.split(END_MARK).length - 1
42
+ if (begins.length === 0 && endCount === 0) return { state: 'none' }
43
+ if (begins.length !== 1 || endCount !== 1) return { state: 'malformed' }
44
+ const start = begins[0].index
45
+ const endIdx = content.indexOf(END_MARK)
46
+ if (endIdx < start) return { state: 'malformed' }
47
+ const end = endIdx + END_MARK.length
48
+ return { state: 'ok', version: Number(begins[0][1]), start, end, text: content.slice(start, end) }
49
+ }
50
+
51
+ // 순수 함수. 블록 밖 바이트는 그대로 둔다(append 도 기존 내용 뒤에 붙이기만 한다).
52
+ export function upsertRookieBlock(content, blockText) {
53
+ const p = parseRookieBlock(content)
54
+ if (p.state === 'malformed') throw Object.assign(new Error('rookie 블록 표식 손상'), { code: 'MALFORMED_ROOKIE_BLOCK' })
55
+ if (p.state === 'ok') return content.slice(0, p.start) + blockText + content.slice(p.end)
56
+ if (content.length === 0) return blockText + '\n'
57
+ const sep = content.endsWith('\n') ? '\n' : '\n\n'
58
+ return content + sep + blockText + '\n'
59
+ }
60
+
61
+ // 무엇을 할지 정한다(쓰지 않는다). force = --claude-md.
62
+ // action: create | append | replace | noop | noop_edited | skip_same_intent |
63
+ // skip_symlink_unmanaged | skip_broken_symlink | skip_newer | malformed | no_claude_dir
64
+ export function planClaudeMd({ home = homedir(), force = false, template } = {}) {
65
+ const tpl = template ?? readTemplateBlock()
66
+ const claudeDir = join(home, '.claude')
67
+ const path = join(claudeDir, 'CLAUDE.md')
68
+ const base = { path, target: path, isSymlink: false, template: tpl }
69
+ if (!existsSync(claudeDir)) return { ...base, action: 'no_claude_dir' }
70
+
71
+ let lst = null
72
+ try { lst = lstatSync(path) } catch { lst = null }
73
+ if (!lst) return { ...base, action: 'create' }
74
+
75
+ let target = path
76
+ const isSymlink = lst.isSymbolicLink()
77
+ if (isSymlink) {
78
+ try { target = realpathSync(path) } catch { return { ...base, isSymlink, action: 'skip_broken_symlink' } }
79
+ }
80
+ const content = readFileSync(target, 'utf8')
81
+ const p = parseRookieBlock(content)
82
+ const ctx = { ...base, target, isSymlink, content, existing: p }
83
+
84
+ if (p.state === 'malformed') return { ...ctx, action: 'malformed' }
85
+ if (p.state === 'ok') {
86
+ if (p.version > tpl.version) return { ...ctx, action: 'skip_newer' }
87
+ if (p.version < tpl.version) return { ...ctx, action: 'replace' }
88
+ if (p.text === tpl.text) return { ...ctx, action: 'noop' }
89
+ return { ...ctx, action: force ? 'replace' : 'noop_edited' }
90
+ }
91
+ // 표식 없음
92
+ if (isSymlink && !force) return { ...ctx, action: 'skip_symlink_unmanaged' }
93
+ if (content.includes(SAME_INTENT_NEEDLE) && !force) return { ...ctx, action: 'skip_same_intent' }
94
+ return { ...ctx, action: 'append' }
95
+ }
96
+
97
+ export const WRITING_ACTIONS = new Set(['create', 'append', 'replace'])
98
+
99
+ // plan 대로 쓴다. 대상 파일 폴더에 임시 파일을 만들고 rename — 심링크 경로 자체는 건드리지 않는다.
100
+ export function applyClaudeMd(plan) {
101
+ if (!WRITING_ACTIONS.has(plan.action)) return { written: false, action: plan.action }
102
+ const target = plan.target
103
+ const before = plan.action === 'create' ? '' : plan.content
104
+ const next = upsertRookieBlock(before, plan.template.text)
105
+ if (next === before) return { written: false, action: 'noop' }
106
+
107
+ let mode = null
108
+ if (plan.action !== 'create') {
109
+ mode = statSync(target).mode & 0o777
110
+ copyFileSync(target, join(dirname(target), `${basename(target)}.rookie-bak`))
111
+ }
112
+ const tmp = join(dirname(target), `.${basename(target)}.rookie-tmp-${process.pid}`)
113
+ try {
114
+ writeFileSync(tmp, next)
115
+ if (mode !== null) chmodSync(tmp, mode)
116
+ renameSync(tmp, target)
117
+ } catch (e) {
118
+ try { unlinkSync(tmp) } catch { /* 임시 파일이 없으면 그만 */ }
119
+ throw e
120
+ }
121
+ return { written: true, action: plan.action, target, backup: mode !== null ? `${target}.rookie-bak` : null }
122
+ }
123
+
124
+ // 명령(init·login·update)이 공유하는 한 단계. 출력 줄 목록을 돌려준다(테스트 가능하게).
125
+ // mode 'connect'(login·init): 기본 적용. 'update': 표식이 있을 때만 갱신, 없으면 대화형 확인 또는 --claude-md.
126
+ export async function runClaudeMdStep({
127
+ mode,
128
+ flags = {},
129
+ env = process.env,
130
+ interactive = false,
131
+ confirmFn = async () => false,
132
+ home = homedir(),
133
+ template,
134
+ } = {}) {
135
+ const lines = []
136
+ if (flags['no-claude-md'] || env.ROOKIE_NO_CLAUDE_MD === '1') return { lines, result: { written: false, action: 'opted_out' } }
137
+
138
+ const force = !!flags['claude-md']
139
+ let plan
140
+ try {
141
+ plan = planClaudeMd({ home, force, template })
142
+ } catch (e) {
143
+ lines.push(`⚠ 글로벌 CLAUDE.md 점검 실패: ${e.message}`)
144
+ return { lines, result: { written: false, action: 'error' } }
145
+ }
146
+ const where = plan.isSymlink ? `${plan.path} → ${plan.target}` : plan.path
147
+
148
+ switch (plan.action) {
149
+ case 'no_claude_dir':
150
+ case 'noop':
151
+ return { lines, result: { written: false, action: plan.action } }
152
+ case 'noop_edited':
153
+ lines.push(` 글로벌 CLAUDE.md 의 더루키 블록이 수정돼 있어 그대로 둡니다 — 템플릿으로 되돌리려면 rookie update --claude-md`)
154
+ return { lines, result: { written: false, action: plan.action } }
155
+ case 'skip_same_intent':
156
+ lines.push(` 글로벌 CLAUDE.md 에 더루키 회상 규칙이 이미 있어 블록을 넣지 않았습니다 — 관리 블록으로 두려면 --claude-md`)
157
+ return { lines, result: { written: false, action: plan.action } }
158
+ case 'skip_symlink_unmanaged':
159
+ lines.push(` 글로벌 CLAUDE.md 가 심링크라 블록을 넣지 않았습니다(${where}) — 넣으려면 --claude-md`)
160
+ return { lines, result: { written: false, action: plan.action } }
161
+ case 'skip_broken_symlink':
162
+ lines.push(`⚠ 글로벌 CLAUDE.md 심링크의 대상이 없습니다(${plan.path}) — 건드리지 않았습니다`)
163
+ return { lines, result: { written: false, action: plan.action } }
164
+ case 'skip_newer':
165
+ lines.push(` 글로벌 CLAUDE.md 의 더루키 블록(v${plan.existing.version})이 이 CLI(v${plan.template.version})보다 새 버전이라 그대로 둡니다`)
166
+ return { lines, result: { written: false, action: plan.action } }
167
+ case 'malformed':
168
+ lines.push(`⚠ 글로벌 CLAUDE.md 의 더루키 블록 표식이 손상돼 건드리지 않았습니다 — 수동 점검: ${plan.target}`)
169
+ return { lines, result: { written: false, action: plan.action } }
170
+ default:
171
+ break
172
+ }
173
+
174
+ // 여기부터 create·append·replace
175
+ const isNewBlock = plan.action === 'create' || plan.action === 'append'
176
+ if (mode === 'update' && isNewBlock && !force) {
177
+ if (!interactive) {
178
+ lines.push(` 글로벌 CLAUDE.md 에 더루키 블록이 없습니다 — 넣으려면 rookie update --claude-md (rookie doctor 로 확인)`)
179
+ return { lines, result: { written: false, action: 'needs_consent' } }
180
+ }
181
+ console.log(describePlan(plan))
182
+ if (!(await confirmFn('글로벌 CLAUDE.md 에 이 블록을 넣을까요?', { defaultYes: false }))) {
183
+ lines.push(' 글로벌 CLAUDE.md 블록 추가를 건너뛰었습니다')
184
+ return { lines, result: { written: false, action: 'declined' } }
185
+ }
186
+ } else if (mode === 'connect' && isNewBlock && interactive) {
187
+ console.log(describePlan(plan))
188
+ if (!(await confirmFn('글로벌 CLAUDE.md 에 이 블록을 넣을까요?', { defaultYes: true }))) {
189
+ lines.push(' 글로벌 CLAUDE.md 블록 추가를 건너뛰었습니다 (나중에: rookie update --claude-md)')
190
+ return { lines, result: { written: false, action: 'declined' } }
191
+ }
192
+ }
193
+
194
+ let result
195
+ try {
196
+ result = applyClaudeMd(plan)
197
+ } catch (e) {
198
+ lines.push(`⚠ 글로벌 CLAUDE.md 쓰기 실패(원본 보존): ${e.message}`)
199
+ return { lines, result: { written: false, action: 'error' } }
200
+ }
201
+ if (result.written) {
202
+ const verb = plan.action === 'replace' ? `갱신(v${plan.existing.version} → v${plan.template.version})` : `추가(v${plan.template.version})`
203
+ lines.push(`✓ 글로벌 CLAUDE.md 더루키 블록 ${verb}: ${where}`)
204
+ if (result.backup) lines.push(` · 이전 본문 backup → ${result.backup}`)
205
+ if (mode === 'connect' && !interactive && isNewBlock) lines.push(' (넣지 않으려면 표식 사이를 지우고 ROOKIE_NO_CLAUDE_MD=1 로 두세요)')
206
+ }
207
+ return { lines, result }
208
+ }
209
+
210
+ // 쓰기 전에 보여 줄 설명.
211
+ export function describePlan(plan) {
212
+ const where = plan.isSymlink ? `${plan.path} → ${plan.target}` : plan.path
213
+ const what = plan.action === 'create' ? '새 파일로 만들고' : plan.action === 'append' ? '파일 끝에' : '기존 블록을 바꿔'
214
+ return [
215
+ `\n글로벌 CLAUDE.md 에 더루키 관리 블록을 둡니다 — ${where}`,
216
+ ` ${what} 아래 블록을 넣습니다(표식 밖의 글은 건드리지 않습니다):`,
217
+ ...plan.template.text.split('\n').map((l) => ` | ${l}`),
218
+ ].join('\n')
219
+ }
220
+
221
+ // doctor 용 한 줄(읽기만).
222
+ export function claudeMdStatusLine({ home = homedir(), template } = {}) {
223
+ let plan
224
+ try { plan = planClaudeMd({ home, force: false, template }) } catch (e) { return `⚠ 글로벌 CLAUDE.md 점검 실패: ${e.message}` }
225
+ const v = plan.template.version
226
+ switch (plan.action) {
227
+ case 'no_claude_dir': return null
228
+ case 'noop': return `✓ 글로벌 CLAUDE.md 더루키 블록 v${v}`
229
+ case 'replace': return `⚠ 글로벌 CLAUDE.md 더루키 블록 v${plan.existing.version} → v${v} — rookie update`
230
+ case 'noop_edited': return '⚠ 글로벌 CLAUDE.md 더루키 블록이 수정됨 — 되돌리려면 rookie update --claude-md'
231
+ case 'skip_newer': return `✓ 글로벌 CLAUDE.md 더루키 블록 v${plan.existing.version} (이 CLI 보다 새 버전)`
232
+ case 'skip_same_intent': return '✓ 글로벌 CLAUDE.md 에 더루키 회상 규칙 있음(관리 블록 아님)'
233
+ case 'skip_symlink_unmanaged':
234
+ return plan.content.includes(SAME_INTENT_NEEDLE)
235
+ ? '✓ 글로벌 CLAUDE.md(심링크)에 더루키 회상 규칙 있음(관리 블록 아님)'
236
+ : '⚠ 글로벌 CLAUDE.md(심링크)에 더루키 블록 없음 — rookie update --claude-md'
237
+ case 'skip_broken_symlink': return `⚠ 글로벌 CLAUDE.md 심링크 대상 없음 — ${plan.path}`
238
+ case 'malformed': return `✗ 글로벌 CLAUDE.md 더루키 블록 손상(begin/end 불일치) — 수동 점검: ${plan.target}`
239
+ case 'create': return '⚠ 글로벌 CLAUDE.md 없음 — rookie update --claude-md'
240
+ case 'append': return '⚠ 글로벌 CLAUDE.md 에 더루키 블록 없음 — rookie update --claude-md'
241
+ default: return null
242
+ }
243
+ }
@@ -35,6 +35,9 @@ export function listPendingExtractions({ orphanRoot }) {
35
35
  // spec 2026-09-08 §2.4 — 'no_writes'(저장 0건) 와 비정상 종료를 구분해 표시한다.
36
36
  // 섞이면 진짜 복구 대기가 묻힌다.
37
37
  reason: m.reason || 'crash',
38
+ // 'stale_writes' 는 마지막 저장 시각을 같이 싣는다 — 어디부터가 미저장인지가
39
+ // 초안을 읽는 쪽의 첫 질문이다.
40
+ since: m.since || null,
38
41
  recoveredAt: m.recovered_at || null,
39
42
  // codex marker 는 codex 서브루트에 산다 — marker 명령이 이 루트를 겨냥해야
40
43
  // 큐에서 실제로 빠진다(코드검토 P2). orphan-recovery.sh 의 ROOKIE_ORPHAN_DIR 규약.
@@ -105,7 +108,12 @@ export function renderDraft(entry, draft) {
105
108
  const lines = []
106
109
  const clean = (s) => redactDraftText(s).redacted.replace(/\s+/g, ' ').trim()
107
110
  // spec 2026-09-08 §2.4 — 사유를 앞에 붙인다. 저장 0건과 비정상 종료는 봐야 할 것이 다르다.
108
- const why = entry.reason === 'no_writes' ? '저장 0건' : '비정상 종료'
111
+ const why =
112
+ entry.reason === 'no_writes'
113
+ ? '저장 0건'
114
+ : entry.reason === 'stale_writes'
115
+ ? `마지막 저장 뒤 미저장 구간${entry.since ? ` (저장 ${entry.since} 이후)` : ''}`
116
+ : '비정상 종료'
109
117
  lines.push(
110
118
  `◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} · ${why} (recovered ${entry.recoveredAt ?? 'unknown'})`
111
119
  )
@@ -35,14 +35,17 @@ export function resolveSessionId(explicit) {
35
35
  }
36
36
 
37
37
  /**
38
- * 저장이 생겼으니 "저장 0건" 대기는 더 이상 참이 아니다 — 그 표시만 지운다.
38
+ * 저장이 생겼으니 "저장 0건"·"마지막 저장 뒤 미저장 구간" 대기는 더 이상 참이 아니다 —
39
+ * 그 표시만 지운다(이어 연 세션이 그 구간을 저장한 경우).
39
40
  * 비정상 종료 복구 대기(reason 없음)는 tail 이 아직 안 건져졌으므로 건드리지 않는다.
40
41
  */
42
+ const SAVE_CLEARABLE = new Set(['no_writes', 'stale_writes'])
43
+
41
44
  export function clearNoWritesPending(sessionId) {
42
45
  if (!sessionId) return false
43
46
  try {
44
47
  const f = join(orphanRoot(), 'pending-extraction', sessionId + '.json')
45
- if (JSON.parse(readFileSync(f, 'utf8'))?.reason !== 'no_writes') return false
48
+ if (!SAVE_CLEARABLE.has(JSON.parse(readFileSync(f, 'utf8'))?.reason)) return false
46
49
  rmSync(f, { force: true })
47
50
  return true
48
51
  } catch {
@@ -0,0 +1,7 @@
1
+ <!-- rookie:begin v1 -->
2
+ ## 더루키(the Rookie) 기억 — 로컬보다 먼저
3
+ - 전에 한 일·결정·맥락이 필요하면(어제·전에·이어서 같은 말이 없어도) 로컬 md·auto-memory 를 뒤지기 **전에** 더루키 회상(`GET /api/rookie/recall`)을 먼저 부른다. 로컬 파일은 보조다.
4
+ - 새로 기억할 것(운영 사실·결정·규칙·취향)은 **더루키에 먼저** 저장하고(`rookie save`), 로컬 md 는 필요할 때만 보조로 남긴다.
5
+ - 예외: 작업 폴더에 `.rookie-ignore` 가 있거나, `ROOKIE_DISABLED=1` 이거나, `~/.rookie/config.json` 이 없으면 위 두 줄을 따르지 않는다.
6
+ - 세부 절차는 rookie Skill 과 세션 시작 주입이 정본이다 — 여기에 옮겨 적지 않는다.
7
+ <!-- rookie:end -->
@@ -305,9 +305,16 @@ if [ "$HTTP_CODE" = "200" ]; then
305
305
  # 어디에도 남지 않는다. transcript 는 있으므로 다음 세션이 건지도록 미추출 큐에 올린다.
306
306
  # 내용 없는 세션(MESSAGE_COUNT=0)은 제외 — 건질 게 없다.
307
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
308
+ && [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
309
+ if ! orphan_session_had_write "$SESSION_ID"; then
310
+ orphan_mark_no_writes "$SESSION_ID" "$TRANSCRIPT_PATH" || true
311
+ elif command -v orphan_session_has_unsaved_tail >/dev/null 2>&1 \
312
+ && orphan_session_has_unsaved_tail "$SESSION_ID" "$TRANSCRIPT_PATH"; then
313
+ # 저장은 있었지만 그 뒤로 대화가 임계 이상 더 쌓였다 — 마지막 저장 뒤 구간은
314
+ # 아무 데도 안 남는다(2026-09-22 실측). 같은 큐에 사유만 갈라 올린다.
315
+ orphan_mark_no_writes "$SESSION_ID" "$TRANSCRIPT_PATH" "stale_writes" \
316
+ "$(orphan_session_last_write_at "$SESSION_ID")" || true
317
+ fi
311
318
  fi
312
319
  else
313
320
  # §17 H-2: 큐 적재 + 1회 chain drain (일시적 깜빡임 회복)
@@ -113,17 +113,96 @@ orphan_session_had_write() {
113
113
  [ -f "$dir/$key" ]
114
114
  }
115
115
 
116
+ # 저장 마커 경로 — had_write 와 같은 키를 쓴다(세션 구간 단위).
117
+ _orphan_session_write_marker_path() {
118
+ local sid="$1"
119
+ [ -n "$sid" ] || return 1
120
+ command -v shasum >/dev/null 2>&1 || return 1
121
+ local key
122
+ key="$(_orphan_session_write_key "$sid")"
123
+ [ -n "$key" ] || return 1
124
+ printf '%s/%s' "${ROOKIE_SESSION_WRITES_DIR:-$HOME/.rookie/session-writes}" "$key"
125
+ }
126
+
127
+ # 마지막 저장 시각(ISO UTC). 마커가 없거나 못 읽으면 빈 출력.
128
+ # date -r 은 BSD/GNU 해석이 갈려 쓰지 않는다 — node 로 mtime 을 읽는다.
129
+ orphan_session_last_write_at() {
130
+ local sid="$1" marker
131
+ marker="$(_orphan_session_write_marker_path "$sid")" || return 1
132
+ [ -f "$marker" ] || return 1
133
+ node -e "
134
+ const fs = require('node:fs');
135
+ try { console.log(new Date(fs.statSync(process.argv[1]).mtimeMs).toISOString()); }
136
+ catch { console.log(''); }
137
+ " "$marker" 2>/dev/null
138
+ }
139
+
140
+ # 마지막 저장 이후 쌓인 대화 수. 셀 수 없으면 0 — 무동작이 오탐보다 안전하다.
141
+ orphan_session_tail_count() {
142
+ local sid="$1" transcript="$2" marker
143
+ [ -n "$transcript" ] && [ -f "$transcript" ] || { printf '0'; return 0; }
144
+ marker="$(_orphan_session_write_marker_path "$sid")" || { printf '0'; return 0; }
145
+ [ -f "$marker" ] || { printf '0'; return 0; }
146
+ node -e "
147
+ const fs = require('node:fs');
148
+ const out = (n) => { console.log(String(n)); process.exit(0); };
149
+ let since;
150
+ try { since = fs.statSync(process.argv[2]).mtimeMs; } catch { out(0); }
151
+ let raw;
152
+ try { raw = fs.readFileSync(process.argv[1], 'utf8'); } catch { out(0); }
153
+ let n = 0;
154
+ for (const line of raw.split('\n')) {
155
+ const t = line.trim(); if (!t) continue;
156
+ let j; try { j = JSON.parse(t); } catch { continue; }
157
+ const m = j.message || j;
158
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
159
+ const ts = Date.parse(j.timestamp || m.timestamp || '');
160
+ if (!Number.isFinite(ts) || ts <= since) continue;
161
+ let c = m.content;
162
+ if (typeof c !== 'string') {
163
+ c = Array.isArray(c)
164
+ ? c.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n').trim()
165
+ : '';
166
+ } else { c = c.trim(); }
167
+ if (!c) continue;
168
+ n++;
169
+ }
170
+ out(n);
171
+ " "$transcript" "$marker" 2>/dev/null || printf '0'
172
+ }
173
+
174
+ # 마지막 저장 뒤로 임계 이상이 쌓였는가 — Stop hook 이 돌았다는 것과 그 구간이 기억에
175
+ # 남았다는 것은 다른 일이다(2026-09-22 실측: 세션 5개가 flushed source=stop 으로 닫히고
176
+ # 마지막 저장 뒤 30분 넘는 구간이 카드 없이 사라졌다).
177
+ #
178
+ # 기본 3 인 이유: 세는 것은 본문 있는 user·assistant 메시지라 도구 호출만 한 턴은 안 세인다.
179
+ # 같은 실측에서 두목 지시 한 건을 받아 처리하고 끝난 세션의 tail 이 3 이었고, 42·10 인 세션과
180
+ # 함께 잡혀야 했다. 저장으로 잘 닫은 세션은 뒤에 인사 한두 줄만 남아 3 에 못 미친다 —
181
+ # 임계를 낮게 잡되 0 으로 두지 않는 이유가 그것이다(큐가 넓어지면 다음 세션이 큐를 안 믿는다).
182
+ orphan_session_has_unsaved_tail() {
183
+ local sid="$1" transcript="$2" count min
184
+ min="${ROOKIE_UNSAVED_TAIL_MIN:-3}"
185
+ case "$min" in (*[!0-9]*|'') min=3 ;; esac
186
+ count="$(orphan_session_tail_count "$sid" "$transcript")"
187
+ case "$count" in (*[!0-9]*|'') return 1 ;; esac
188
+ [ "$count" -ge "$min" ]
189
+ }
190
+
116
191
  # 저장 0건이면 pending-extraction 에 reason=no_writes 로 올린다.
117
192
  # 비정상 종료(crash) 사유와 섞이지 않게 reason 을 남긴다 — 섞이면 진짜 복구 대기가 묻힌다.
118
193
  orphan_mark_no_writes() {
119
- local sid="$1" transcript="${2:-}"
194
+ local sid="$1" transcript="${2:-}" reason="${3:-no_writes}" since="${4:-}"
120
195
  [ -n "$sid" ] || return 1
196
+ # reason·since 는 marker JSON 에 그대로 박힌다 — 따옴표·백슬래시가 섞이면 읽는 쪽이 깨진다.
197
+ reason="$(printf '%s' "$reason" | tr -cd 'A-Za-z0-9_.:-')"
198
+ [ -n "$reason" ] || reason="no_writes"
199
+ since="$(printf '%s' "$since" | tr -cd 'A-Za-z0-9_.:-')"
121
200
  # 이미 추출을 마쳤거나 대기 중이면 덮지 않는다.
122
201
  [ -f "$(_orphan_client_extracted_dir)/$sid.json" ] && return 0
123
202
  [ -f "$(_orphan_pending_dir)/$sid.json" ] && return 0
124
203
  _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)")"
204
+ "$(printf '{"status":"pending_extraction","reason":"%s","since":"%s","transcript_path":"%s","recovered_at":"%s"}' \
205
+ "$reason" "$since" "$transcript" "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
127
206
  }
128
207
 
129
208
  # P0.4 — 짧고 내용 없는(low_value) 세션 marker. record_pending 대신 호출 → 재적재 차단.
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.36",
2
+ "cli_version": "0.4.38",
3
3
  "skill_version": "1.40.1"
4
4
  }