therookie 0.4.37 → 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.37",
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
+ }
@@ -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 -->
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.37",
2
+ "cli_version": "0.4.38",
3
3
  "skill_version": "1.40.1"
4
4
  }