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 +1 -1
- package/src/cli.js +5 -3
- package/src/commands/doctor.js +3 -0
- package/src/commands/init.js +7 -0
- package/src/commands/login.js +13 -0
- package/src/commands/update.js +15 -0
- package/src/lib/claude-md-block.js +243 -0
- package/src/lib/orphan-extract-core.js +9 -1
- package/src/lib/session-write-marker.js +5 -2
- package/templates/claude-md/rookie-block.md +7 -0
- package/templates/hook/flush-session.sh +10 -3
- package/templates/hook/lib/orphan-recovery.sh +82 -3
- package/version.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "therookie",
|
|
3
|
-
"version": "0.4.
|
|
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 배포.
|
package/src/commands/doctor.js
CHANGED
|
@@ -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}`)
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
310
|
-
|
|
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":"
|
|
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