therookie 0.4.39 → 0.4.41

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.39",
3
+ "version": "0.4.41",
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
@@ -14,12 +14,13 @@ import { runClusterTodos } from './commands/cluster-todos.js'
14
14
  import { runDoneScan } from './commands/done-scan.js'
15
15
  import { runContextDiet } from './commands/context-diet.js'
16
16
  import { runOrphanExtract } from './commands/orphan-extract.js'
17
+ import { runTeam, runHire, runReports, runClaim } from './commands/team.js'
17
18
 
18
19
  const HELP = `rookie — the Rookie Skill/Hook 설치 관리자
19
20
 
20
21
  사용:
21
- rookie init [--local] [--force] [--yes] [--deep-scan] [--claude-md | --no-claude-md]
22
- rookie login [--local] [--claude-md | --no-claude-md]
22
+ rookie init [--local] [--force] [--yes] [--deep-scan] [--claude-md | --no-claude-md] [--codex-md | --no-codex-md]
23
+ rookie login [--local] [--claude-md | --no-claude-md] [--codex-md | --no-codex-md]
23
24
  rookie update [--force] [--claude-md | --no-claude-md] [--codex-md | --no-codex-md]
24
25
  rookie link [--local]
25
26
  rookie save [--file <body.json>] [--json] # 또는 stdin. --json = 서버 raw 응답 출력
@@ -32,6 +33,10 @@ const HELP = `rookie — the Rookie Skill/Hook 설치 관리자
32
33
  rookie done-scan [--force]
33
34
  rookie context-diet [--json] [--force]
34
35
  rookie orphan-extract [--batch=5]
36
+ rookie team [show <이름표> | edit <이름표> | archive <이름표> | restore <이름표> | move <번호> <이름표|default> [--yes]]
37
+ rookie hire <이름표> --name <이름> --role <담당> [--note <역할 메모>]
38
+ rookie reports [--since 2h] [--agent <이름표|default>] [--project <이름>] [--status <상태>] | reports show <번호>
39
+ rookie claim "<붙잡는 자원>" | rookie claim --release
35
40
 
36
41
  옵션:
37
42
  --local 프로젝트 로컬 스코프 — login 은 토큰도 <dir>/.rookie/config.json 에 저장(전역 무변경)
@@ -59,6 +64,10 @@ const HELP = `rookie — the Rookie Skill/Hook 설치 관리자
59
64
  done-scan doing task 를 최근 커밋과 경로 강매칭해 완료 후보를 세션 시작 시 surface(하루 1회, --force 강제).
60
65
  context-diet 세션 주입 컨텍스트 비대 감지(5항목 proxy 측정). hook 이 주1회 권고 블록 주입, --force 는 usage 재스캔.
61
66
  orphan-extract 비정상 종료 세션 tail 추출 초안 생성(오래된 순, 기본 5건). 저장·완료 marker 는 세션 LLM/사용자 판단.
67
+ team 루키 팀 명단·경력(show)·수정·보관·복원·경력 옮기기(move). 세션은 ROOKIE_AGENT=<이름표> 로 띄운다.
68
+ hire 루키 채용. 관리 권한 토큰이 필요하다 — 없으면 대시보드 /settings/team 에서 채용한다.
69
+ reports 보고 대장 — 세션들이 받은 지시와 마지막 보고를 루키별로. show <번호> 로 전문.
70
+ claim 공유 자원(빌드 머신·실기기·배포·대량 디스크)을 쓰기 직전에 알린다. 다른 세션 현황 줄 맨 앞에 뜬다.
62
71
  `
63
72
 
64
73
  export async function run(argv) {
@@ -94,6 +103,10 @@ export async function run(argv) {
94
103
  }
95
104
  case 'orphan-extract': return runOrphanExtract(flags)
96
105
  case 'token': return runToken(flags)
106
+ case 'team': return runTeam(flags)
107
+ case 'hire': return runHire(flags)
108
+ case 'reports': return runReports(flags)
109
+ case 'claim': return runClaim(flags)
97
110
  case 'help':
98
111
  case '--help':
99
112
  case undefined:
@@ -118,6 +131,15 @@ const VALUE_FLAGS = new Set([
118
131
  // spec 2026-09-08 §2.4 — `rookie save --session <id>`. 화이트리스트에 없으면 값이 안 붙어
119
132
  // flags.session === true 가 되고 저장 마커가 조용히 안 남는다(실측으로 잡힌 버그).
120
133
  'session',
134
+ // spec 2026-09-28 다중 루키 — team·hire·reports 의 값 플래그.
135
+ 'name',
136
+ 'role',
137
+ 'note',
138
+ 'since',
139
+ 'agent',
140
+ 'project',
141
+ 'status',
142
+ 'from',
121
143
  ])
122
144
 
123
145
  export function parseFlags(args) {
@@ -5,6 +5,7 @@ import { confirm } from '../prompt.js'
5
5
  import { loadConfig } from '../config.js'
6
6
  import { applyCodexSetup } from '../lib/deploy-artifacts.js'
7
7
  import { runCodexMdStep } from '../lib/claude-md-block.js'
8
+ import { codexHomeDir } from '../lib/codex-home.js'
8
9
 
9
10
  // Codex config.toml 에 더루키 SessionStart hook 을 동의 기반으로 등록한다(managed 블록만).
10
11
  // 명시적 Codex-only 재설정 진입점 — 내부 write 는 applyCodexSetup(3층 adapter) 공유(spec §4 item 6).
@@ -19,7 +20,7 @@ export async function runCodexSetup(flags) {
19
20
  console.error(`✗ hook 스크립트가 없습니다(${hookScript}). \`rookie login\` 또는 \`rookie update\` 를 먼저 실행하세요.`)
20
21
  process.exit(1)
21
22
  }
22
- const configPath = join(homedir(), '.codex', 'config.toml')
23
+ const configPath = join(codexHomeDir(), 'config.toml')
23
24
 
24
25
  console.log('\nCodex 설정 파일에 더루키 SessionStart hook 을 등록합니다:')
25
26
  console.log(` ${configPath}`)
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { claudeMdStatusLine, codexMdStatusLine } from '../lib/claude-md-block.js'
3
+ import { codexMcpAgentEnvLine } from '../lib/codex-mcp-env.js'
3
4
  import { homedir } from 'node:os'
4
5
  import { join } from 'node:path'
5
6
  import { execSync } from 'node:child_process'
@@ -47,6 +48,8 @@ export async function runDoctor(flags) {
47
48
  if (claudeMdLine) console.log(claudeMdLine)
48
49
  const codexMdLine = codexMdStatusLine()
49
50
  if (codexMdLine) console.log(codexMdLine)
51
+ const codexAgentEnvLine = codexMcpAgentEnvLine()
52
+ if (codexAgentEnvLine) console.log(codexAgentEnvLine)
50
53
 
51
54
  if (g && l && g.version !== l.version) {
52
55
  console.log(`⚠ 버전 불일치: 글로벌 v${g.version} vs 로컬 v${l.version}`)
@@ -12,7 +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
+ import { runClaudeMdStep, runCodexMdStep } from '../lib/claude-md-block.js'
16
16
 
17
17
  async function pickChoice(prompt, options, autoValue) {
18
18
  console.log(prompt)
@@ -179,4 +179,9 @@ export async function runInit(flags) {
179
179
  const { lines } = await runClaudeMdStep({ mode: 'connect', flags, interactive, confirmFn: confirm })
180
180
  for (const line of lines) console.log(line)
181
181
  }
182
+ // 2026-09-29 두목 결정 — Codex 전역 지시 파일도 첫 연결에서 기본 적용(끄기 ROOKIE_NO_CODEX_MD=1).
183
+ if (scope === 'global' && report.applied.some((a) => a.id === 'codex')) {
184
+ const { lines } = await runCodexMdStep({ mode: 'connect', flags, interactive, confirmFn: confirm })
185
+ for (const line of lines) console.log(line)
186
+ }
182
187
  }
@@ -10,7 +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
+ import { runClaudeMdStep, runCodexMdStep } from '../lib/claude-md-block.js'
14
14
 
15
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
16
16
 
@@ -170,4 +170,14 @@ export async function runLogin(flags) {
170
170
  })
171
171
  for (const line of lines) console.log(line)
172
172
  }
173
+ // 2026-09-29 두목 결정 — Codex 전역 지시 파일도 첫 연결에서 기본 적용(끄기 ROOKIE_NO_CODEX_MD=1).
174
+ if (scope === 'global' && report.applied.some((a) => a.id === 'codex')) {
175
+ const { lines } = await runCodexMdStep({
176
+ mode: 'connect',
177
+ flags,
178
+ interactive: !!process.stdin.isTTY && !flags.yes,
179
+ confirmFn: confirm,
180
+ })
181
+ for (const line of lines) console.log(line)
182
+ }
173
183
  }
@@ -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 { agentHeader } from '../lib/agent-env.js'
16
17
  import {
17
18
  recordSessionWrite,
18
19
  resolveSessionId,
@@ -110,6 +111,8 @@ export async function runSave(flags) {
110
111
  headers: {
111
112
  'x-rookie-hostname': getHostname(),
112
113
  'x-rookie-surface': 'local_cli',
114
+ // spec 2026-09-28 다중 루키 §5.1 — 세션을 띄운 루키로 저장한다. 모르는 루키면 서버가 400 unknown_agent.
115
+ ...agentHeader(),
113
116
  },
114
117
  })
115
118
  } catch (err) {
@@ -0,0 +1,261 @@
1
+ // spec 2026-09-28 다중 루키 1단계 — 루키 팀·채용·보고 대장·점유 선언 명령.
2
+ // rookie team [show <slug> | edit <slug> | archive <slug> | restore <slug> | move <번호> <slug|default>]
3
+ // rookie hire <slug> --name <이름> --role <담당> [--note <역할 메모>]
4
+ // rookie reports [--since 2h] [--agent <slug>] [--project <이름>] [--status open|reported|short|no_report]
5
+ // rookie reports show <번호>
6
+ // rookie claim "<붙잡는 자원>" | rookie claim --release
7
+ // 채용·수정·경력 옮기기는 admin 토큰이 있어야 된다 — 일하는 세션의 토큰으로는 동료를 늘릴 수 없다(§2.6).
8
+ import { loadConfig, getHostname } from '../config.js'
9
+ import { ask } from '../prompt.js'
10
+ import { agentHeader } from '../lib/agent-env.js'
11
+ import { resolveSessionId } from '../lib/session-write-marker.js'
12
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ // `team show` 로 본 번호를 `team move` 가 쓰려면 어느 루키의 번호였는지 알아야 한다 — 마지막으로 본 이름표를 남긴다.
17
+ const LAST_SHOWN = () => join(process.env.ROOKIE_STATE_DIR || join(homedir(), '.rookie', 'state'), 'team-last-shown.json')
18
+ function rememberShown(slug) {
19
+ try {
20
+ mkdirSync(join(LAST_SHOWN(), '..'), { recursive: true })
21
+ writeFileSync(LAST_SHOWN(), JSON.stringify({ slug, at: new Date().toISOString() }), { mode: 0o600 })
22
+ } catch { /* 기억 못 해도 --from 으로 줄 수 있다 */ }
23
+ }
24
+ function lastShown() {
25
+ try { return JSON.parse(readFileSync(LAST_SHOWN(), 'utf8')).slug || null } catch { return null }
26
+ }
27
+
28
+ const DASHBOARD_HINT = '루키 채용·수정은 대시보드 루키 팀 화면(/settings/team)에서 할 수 있습니다.'
29
+ const OLD_SERVER = '서버가 아직 루키 팀을 지원하지 않습니다 — 서버가 업데이트되면 다시 시도하세요.'
30
+
31
+ async function request(cfg, method, path, body, { fetchImpl = fetch } = {}) {
32
+ const res = await fetchImpl(`${cfg.endpoint}${path}`, {
33
+ method,
34
+ headers: {
35
+ Authorization: `Bearer ${cfg.token}`,
36
+ 'x-rookie-hostname': getHostname(),
37
+ ...agentHeader(),
38
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
39
+ },
40
+ body: body !== undefined ? JSON.stringify(body) : undefined,
41
+ })
42
+ const text = await res.text()
43
+ let json = null
44
+ try { json = JSON.parse(text) } catch { json = null }
45
+ return { status: res.status, json }
46
+ }
47
+
48
+ // 라우트가 없는 구 서버는 JSON 이 아닌 404 를 준다. 우리 라우트의 404 는 항상 { error } JSON 이다.
49
+ const isOldServer = (r) => r.status === 404 && !(r.json && typeof r.json.error === 'string')
50
+
51
+ function fail(message, code = 1) {
52
+ console.error(message)
53
+ process.exit(code)
54
+ }
55
+
56
+ function explain(r, what) {
57
+ if (isOldServer(r)) return fail(OLD_SERVER)
58
+ if (r.status === 401) return fail(`${what} 권한이 없습니다(관리 권한 토큰 필요). ${DASHBOARD_HINT}`)
59
+ const err = r.json?.error
60
+ const map = {
61
+ slug_taken: `이미 쓰고 있는 이름표입니다${r.json?.suggestion ? ` — "${r.json.suggestion}" 은 비어 있습니다` : ''}.`,
62
+ unsafe_text: '지시문처럼 보이는 문장이나 보이지 않는 문자가 들어 있습니다. 평범한 문장으로 적어 주세요.',
63
+ residual_marker: '가려진 비밀값 표시가 남아 있습니다. 그 부분을 지우고 다시 적어 주세요.',
64
+ too_long: '글이 너무 깁니다.',
65
+ no_organization: '루키를 채용하려면 조직이 필요합니다.',
66
+ quota_exceeded: `지금 요금제에서는 루키를 ${r.json?.limit ?? ''}명까지 둘 수 있습니다.`,
67
+ unknown_agent: '그런 이름표의 루키가 없습니다. `rookie team` 으로 확인하세요.',
68
+ not_owned_or_missing: '대상을 찾지 못했습니다.',
69
+ no_open_report: '이 세션에 열린 지시가 없습니다 — 지시를 받은 세션 안에서 부르세요.',
70
+ invalid_body: '입력값을 다시 확인하세요(이름표는 영문 소문자로 시작, 소문자·숫자·하이픈 2~32자).',
71
+ }
72
+ return fail(map[err] ?? `${what}에 실패했습니다 (HTTP ${r.status}).`)
73
+ }
74
+
75
+ const kst = (iso) =>
76
+ iso
77
+ ? new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
78
+ : '-'
79
+
80
+ const pad = (s, n) => {
81
+ const w = [...String(s)].reduce((a, c) => a + (c.charCodeAt(0) > 0x2e80 ? 2 : 1), 0)
82
+ return String(s) + ' '.repeat(Math.max(1, n - w))
83
+ }
84
+
85
+ export function formatTeam(agents) {
86
+ const active = agents.filter((a) => a.status === 'active')
87
+ const archived = agents.filter((a) => a.status === 'archived')
88
+ const line = (a) =>
89
+ ` ${pad(a.slug, 14)}${pad(`${a.name} · ${a.role_title}`, 22)}경력 ${a.evidence_count}건 · 최근 활동 ${kst(a.last_activity_at)}`
90
+ const out = [`루키 팀 — 일하는 루키 ${active.length + 1}명`, ` ${pad('default', 14)}기본 루키 — 이름표 없이 띄운 세션`, ...active.map(line)]
91
+ if (archived.length) out.push('', `보관한 루키 ${archived.length}명`, ...archived.map(line))
92
+ out.push('', '세션 띄우기: ROOKIE_AGENT=<이름표> claude')
93
+ return out.join('\n')
94
+ }
95
+
96
+ export function formatCareer(c) {
97
+ const who = c.agent.name ? `${c.agent.name}(${c.agent.slug})` : '기본 루키'
98
+ const out = [`${who} 경력 ${c.total}건`]
99
+ if (c.by_project.length) {
100
+ out.push('프로젝트별: ' + c.by_project.map((p) => `${p.project_name ?? '(프로젝트 없음)'} ${p.count}`).join(' · '))
101
+ }
102
+ if (c.recent.length) {
103
+ out.push('', '최근 경력 (번호로 `rookie team move <번호> <이름표|default>`)')
104
+ for (const r of c.recent) {
105
+ out.push(` ${String(r.n).padStart(2)}. ${kst(r.confirmed_date)} ${r.project_name ? `[${r.project_name}] ` : ''}${r.capability.slice(0, 70)}${r.reassigned ? ' (옮겨 옴)' : ''}`)
106
+ }
107
+ }
108
+ return out.join('\n')
109
+ }
110
+
111
+ const STATUS_LABEL = { reported: '보고함', short: '짧은 보고', no_report: '보고 없이 끝남', open: '진행 중' }
112
+
113
+ export function formatReports(reports) {
114
+ if (reports.length === 0) return '이 기간에 받은 보고가 없습니다.'
115
+ // 기본 묶음은 루키별(§5.4) — 각 줄에 프로젝트. 번호는 전체 순서라 `reports show <번호>` 와 맞는다.
116
+ const groups = new Map()
117
+ reports.forEach((r, i) => {
118
+ const label = r.agent ? `${r.agent.role_title} · ${r.agent.name} (${r.agent.slug})` : r.agent_unresolved ? '모르는 이름표로 연 세션' : '기본 루키'
119
+ if (!groups.has(label)) groups.set(label, [])
120
+ groups.get(label).push({ n: i + 1, r })
121
+ })
122
+ const out = []
123
+ for (const [label, items] of groups) {
124
+ out.push(`[${label}]`)
125
+ for (const { n, r } of items) {
126
+ let state = STATUS_LABEL[r.status] ?? r.status
127
+ if (r.status === 'open') state = r.stalled ? `30분째 기록 없음 — 끊겼을 수 있음(마지막 ${kst(r.last_activity_at)})` : `진행 중(마지막 ${kst(r.last_activity_at)})`
128
+ if (r.status === 'no_report') state = `보고 없이 끝남 — 마지막 기록 ${kst(r.last_activity_at)}`
129
+ const head = (r.directive_head ?? '').replace(/\s+/g, ' ').slice(0, 50)
130
+ out.push(` ${String(n).padStart(2)}. ${kst(r.opened_at)} ${r.project_name ?? '-'} · "${head}" · ${state}${r.claim ? ` · 점유: ${r.claim}` : ''}${r.agent_switched ? ' · 루키 바뀜' : ''}`)
131
+ if (r.report_first_line) out.push(` ↳ ${r.report_first_line.slice(0, 100)}`)
132
+ }
133
+ }
134
+ out.push('', '전문: rookie reports show <번호>')
135
+ return out.join('\n')
136
+ }
137
+
138
+ async function listAgents(cfg) {
139
+ const r = await request(cfg, 'GET', '/api/rookie/agents')
140
+ if (r.status !== 200) return explain(r, '루키 명단 조회')
141
+ return r.json.agents
142
+ }
143
+
144
+ async function findBySlug(cfg, slug) {
145
+ const agents = await listAgents(cfg)
146
+ const a = agents.find((x) => x.slug === slug)
147
+ if (!a) fail(`"${slug}" 이름표의 루키가 없습니다. \`rookie team\` 으로 확인하세요.`)
148
+ return a
149
+ }
150
+
151
+ export async function runTeam(flags) {
152
+ const cfg = loadConfig()
153
+ const [sub, a1, a2] = flags.positional
154
+ if (!sub) return console.log(formatTeam(await listAgents(cfg)))
155
+
156
+ if (sub === 'show') {
157
+ if (!a1) fail('사용: rookie team show <이름표|default>', 2)
158
+ const r = await request(cfg, 'GET', `/api/rookie/agents/${encodeURIComponent(a1)}/career`)
159
+ if (r.status !== 200) return explain(r, '경력 조회')
160
+ rememberShown(a1)
161
+ return console.log(formatCareer(r.json))
162
+ }
163
+
164
+ if (sub === 'edit') {
165
+ if (!a1) fail('사용: rookie team edit <이름표> [--name <이름>] [--role <담당>] [--note <역할 메모>]', 2)
166
+ const patch = {}
167
+ if (typeof flags.name === 'string') patch.name = flags.name
168
+ if (typeof flags.role === 'string') patch.role_title = flags.role
169
+ if (typeof flags.note === 'string') patch.persona_note = flags.note || null
170
+ if (Object.keys(patch).length === 0) fail('바꿀 칸을 --name·--role·--note 로 주세요.', 2)
171
+ const a = await findBySlug(cfg, a1)
172
+ const r = await request(cfg, 'PATCH', `/api/rookie/agents/${a.id}`, patch)
173
+ if (r.status !== 200) return explain(r, '수정')
174
+ return console.log(`✓ ${r.json.agent.name}(${a1}) 수정했습니다.`)
175
+ }
176
+
177
+ if (sub === 'archive' || sub === 'restore') {
178
+ if (!a1) fail(`사용: rookie team ${sub} <이름표>`, 2)
179
+ const a = await findBySlug(cfg, a1)
180
+ const r = await request(cfg, 'PATCH', `/api/rookie/agents/${a.id}`, { status: sub === 'archive' ? 'archived' : 'active' })
181
+ if (r.status !== 200) return explain(r, sub === 'archive' ? '보관' : '복원')
182
+ return console.log(
183
+ sub === 'archive'
184
+ ? `✓ ${a.name}(${a1})을 보관했습니다. 이 이름표로 띄운 세션은 기본 루키로 일하고 저장은 거절됩니다.`
185
+ : `✓ ${a.name}(${a1})을 복원했습니다. 진입 카드와 경력이 다시 붙습니다.`
186
+ )
187
+ }
188
+
189
+ if (sub === 'move') {
190
+ const n = Number(a1)
191
+ if (!Number.isInteger(n) || n < 1 || !a2) fail('사용: rookie team move <번호> <이름표|default> [--yes] — 번호는 `rookie team show <이름표>` 에서', 2)
192
+ const from = typeof flags.from === 'string' ? flags.from : lastShown()
193
+ if (!from) fail('먼저 `rookie team show <이름표>` 로 번호를 보세요 (또는 --from <이름표|default>).', 2)
194
+ const c = await request(cfg, 'GET', `/api/rookie/agents/${encodeURIComponent(from)}/career`)
195
+ if (c.status !== 200) return explain(c, '경력 조회')
196
+ const item = c.json.recent.find((x) => x.n === n)
197
+ if (!item) fail(`${from} 의 최근 경력에 ${n}번이 없습니다.`)
198
+ console.log(`옮길 경력: ${item.capability.slice(0, 80)}\n ${from} → ${a2}`)
199
+ if (!flags.yes) {
200
+ const ok = await ask('옮길까요? (y/N)', { defaultValue: 'N' })
201
+ if (!/^y(es)?$/i.test(ok)) return console.log('취소했습니다.')
202
+ }
203
+ const r = await request(cfg, 'PATCH', `/api/rookie/evidence/${item.id}/agent`, { agent: a2 === 'default' ? null : a2 })
204
+ if (r.status !== 200) return explain(r, '경력 옮기기')
205
+ return console.log(r.json.changed ? `✓ ${a2} 루키로 옮겼습니다.` : '이미 그 루키의 경력입니다.')
206
+ }
207
+
208
+ fail(`알 수 없는 하위 명령: ${sub} (team · show · edit · archive · restore · move)`, 2)
209
+ }
210
+
211
+ export async function runHire(flags) {
212
+ const cfg = loadConfig()
213
+ const slug = flags.positional[0]
214
+ if (!slug || typeof flags.name !== 'string' || typeof flags.role !== 'string') {
215
+ fail('사용: rookie hire <이름표> --name <이름> --role <담당> [--note <역할 메모>]\n' + DASHBOARD_HINT, 2)
216
+ }
217
+ const body = { slug, name: flags.name, role_title: flags.role }
218
+ if (typeof flags.note === 'string' && flags.note) body.persona_note = flags.note
219
+ const r = await request(cfg, 'POST', '/api/rookie/agents', body)
220
+ if (r.status !== 201 && r.status !== 200) return explain(r, '채용')
221
+ console.log(`✓ ${r.json.agent.name}(${slug}) 채용했습니다. 세션 띄우기: ROOKIE_AGENT=${slug} claude`)
222
+ }
223
+
224
+ function reportsQuery(flags, body) {
225
+ const q = new URLSearchParams()
226
+ q.set('since', typeof flags.since === 'string' ? flags.since : '2h')
227
+ if (typeof flags.agent === 'string') q.set('agent', flags.agent)
228
+ if (typeof flags.project === 'string') q.set('project', flags.project)
229
+ if (typeof flags.status === 'string') q.set('status', flags.status)
230
+ if (body) q.set('body', '1')
231
+ return q.toString()
232
+ }
233
+
234
+ export async function runReports(flags) {
235
+ const cfg = loadConfig()
236
+ const [sub, a1] = flags.positional
237
+ const show = sub === 'show'
238
+ const r = await request(cfg, 'GET', `/api/rookie/reports?${reportsQuery(flags, show)}`)
239
+ if (r.status !== 200) return explain(r, '보고 대장 조회')
240
+ if (flags.json) return console.log(JSON.stringify(r.json.reports))
241
+ if (!show) return console.log(formatReports(r.json.reports))
242
+ const n = Number(a1)
243
+ const item = r.json.reports[n - 1]
244
+ if (!item) fail(`${a1}번 보고가 없습니다 — 같은 --since·--agent 로 \`rookie reports\` 를 먼저 보세요.`)
245
+ const who = item.agent ? `${item.agent.name}(${item.agent.slug})` : '기본 루키'
246
+ console.log(`${who} · ${item.project_name ?? '-'} · ${kst(item.opened_at)} 시작 · ${STATUS_LABEL[item.status] ?? item.status}`)
247
+ console.log(`지시: ${(item.directive_head ?? '').slice(0, 300)}`)
248
+ console.log('')
249
+ console.log(item.report_body ?? '(보고 본문 없음)')
250
+ }
251
+
252
+ export async function runClaim(flags) {
253
+ const cfg = loadConfig()
254
+ const sessionId = resolveSessionId(typeof flags.session === 'string' ? flags.session : '')
255
+ if (!sessionId) fail('세션 번호를 알 수 없습니다 — Claude Code 세션 안에서 부르거나 --session <id> 를 주세요.', 2)
256
+ const claim = flags.release ? '' : flags.positional.join(' ').trim()
257
+ if (!flags.release && !claim) fail('사용: rookie claim "<붙잡는 자원>" (예: "맥미니 iOS 빌드, 디스크 ~10Gi") | rookie claim --release', 2)
258
+ const r = await request(cfg, 'POST', '/api/rookie/report/claim', { session_id: sessionId, claim })
259
+ if (r.status !== 200) return explain(r, '점유 선언')
260
+ console.log(claim ? `✓ 점유를 알렸습니다: ${claim}` : '✓ 점유를 풀었습니다.')
261
+ }
@@ -14,6 +14,7 @@ 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
16
  import { runClaudeMdStep, runCodexMdStep } from '../lib/claude-md-block.js'
17
+ import { refreshCodexSetupIfManaged } from '../lib/deploy-artifacts.js'
17
18
  import { confirm } from '../prompt.js'
18
19
 
19
20
  const __filename = fileURLToPath(import.meta.url)
@@ -63,6 +64,13 @@ export async function runUpdate(flags) {
63
64
  const { lines } = await step({ mode: 'update', flags, interactive, confirmFn: confirm })
64
65
  for (const line of lines) console.log(line)
65
66
  }
67
+ // CLAUDE.md 원칙 11 — 이미 등록한 Codex hook 블록을 새 템플릿으로(manifest "최신" 조기 종료보다 앞).
68
+ const codex = refreshCodexSetupIfManaged()
69
+ if (codex.error === 'malformed_marker') {
70
+ console.log(`⚠ Codex config 의 더루키 블록이 손상돼 갱신을 건너뛰었습니다 — 수동 점검: ${codex.configPath}`)
71
+ } else if (codex.changed) {
72
+ console.log(`✓ Codex hook 블록 갱신 → ${codex.configPath}`)
73
+ }
66
74
  }
67
75
 
68
76
  let serverManifest
@@ -149,6 +157,13 @@ export async function runUpdate(flags) {
149
157
  )
150
158
  chmodSync(join(pretoolBinDir, 'work-recall-gate.sh'), 0o755)
151
159
 
160
+ // spec 2026-09-28 다중 루키 §6.5 — 보고 대장 생존 신호(PostToolUse 모든 도구, 5분 표식).
161
+ cpSync(
162
+ join(TEMPLATES, 'hook', 'bin', 'report-heartbeat.sh'),
163
+ join(pretoolBinDir, 'report-heartbeat.sh')
164
+ )
165
+ chmodSync(join(pretoolBinDir, 'report-heartbeat.sh'), 0o755)
166
+
152
167
  // spec 2026-07-25 §3 — recall 소비 실패율 계측 Stop 파서(순수 관측, flush-session 과 별개 훅).
153
168
  cpSync(
154
169
  join(TEMPLATES, 'hook', 'bin', 'recall-episode-metric.sh'),
@@ -197,6 +212,7 @@ export async function runUpdate(flags) {
197
212
  sessionStartCmd: claudeHookCommand(join(binDir, 'session-start.sh')),
198
213
  pretoolMdReadCmd: claudeHookCommand(join(pretoolBinDir, 'pretool-md-read.sh')),
199
214
  posttoolRecallNudgeCmd: claudeHookCommand(join(pretoolBinDir, 'posttool-recall-nudge.sh')),
215
+ reportHeartbeatCmd: claudeHookCommand(join(pretoolBinDir, 'report-heartbeat.sh')),
200
216
  guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
201
217
  skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
202
218
  workRecallGateCmd: claudeHookCommand(join(pretoolBinDir, 'work-recall-gate.sh')),
@@ -245,6 +261,7 @@ async function mergeSettings(settingsPath, partialPath, subs) {
245
261
  .replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
246
262
  .replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
247
263
  .replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
264
+ .replaceAll('__REPORT_HEARTBEAT_CMD__', escapeJsonStringValue(subs.reportHeartbeatCmd))
248
265
  .replaceAll('__RECALL_EPISODE_METRIC_CMD__', escapeJsonStringValue(subs.recallEpisodeMetricCmd))
249
266
  .replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
250
267
  .replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
package/src/detect.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync, readdirSync, accessSync, constants } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
+ import { codexHomeDir } from './lib/codex-home.js'
4
5
 
5
6
  // 통일 연결 흐름(spec 2026-06-29-unified-connect-flow §3 D1): 이 컴퓨터에 깔린 AI 도구를 감지한다.
6
7
  // registry 형 — Cursor/Gemini 등은 entry 추가만으로 확장. boolean 반환 금지.
@@ -44,7 +45,7 @@ function defaultHasCli(name, env = process.env, platform = process.platform) {
44
45
  export function detectInstalledTools({ home = homedir(), env = process.env, hasCli = defaultHasCli } = {}) {
45
46
  return TOOL_REGISTRY.map((t) => {
46
47
  const cli = t.cliNames.some((n) => hasCli(n, env))
47
- const dir = existsSync(join(home, t.homeDir))
48
+ const dir = existsSync(t.id === 'codex' ? codexHomeDir({ home, env }) : join(home, t.homeDir))
48
49
  const present = cli || dir
49
50
  const signal = cli ? 'strong' : dir ? 'weak' : 'none'
50
51
  const reason = cli ? `${t.id} CLI 발견` : dir ? `~/${t.homeDir} 존재` : '없음'
@@ -0,0 +1,11 @@
1
+ // spec 2026-09-28 다중 루키 §5.1 — 세션을 띄울 때 붙인 ROOKIE_AGENT(루키 이름표). 없으면 기본 루키.
2
+ // hook·CLI·MCP 가 모두 이 값 하나로 x-rookie-agent 를 싣는다.
3
+ export function agentSlug(env = process.env) {
4
+ const v = (env.ROOKIE_AGENT || '').trim()
5
+ return v || null
6
+ }
7
+
8
+ export function agentHeader(env = process.env) {
9
+ const slug = agentSlug(env)
10
+ return slug ? { 'x-rookie-agent': slug } : {}
11
+ }
@@ -3,13 +3,14 @@ import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { installSharedHookRuntime, applyClaudeSetup, applyCodexSetup } from './deploy-artifacts.js'
5
5
  import { ROOKIE_BEGIN } from './codex-config.js'
6
+ import { codexHomeDir } from './codex-home.js'
6
7
 
7
8
  // 도구가 이미 rookie 로 설정됐는지. init 의 도구-추가 모드(early-return 우회) 판정용.
8
- export function isToolConfigured(id, { scope, home = homedir(), cwd = process.cwd() } = {}) {
9
+ export function isToolConfigured(id, { scope, home = homedir(), cwd = process.cwd(), env = process.env } = {}) {
9
10
  const base = scope === 'global' ? home : cwd
10
11
  if (id === 'claude') return existsSync(join(base, '.claude', 'skills', 'rookie', 'SKILL.md'))
11
12
  if (id === 'codex') {
12
- const cfg = join(home, '.codex', 'config.toml')
13
+ const cfg = join(codexHomeDir({ home, env }), 'config.toml')
13
14
  return existsSync(cfg) && readFileSync(cfg, 'utf8').includes(ROOKIE_BEGIN)
14
15
  }
15
16
  return false
@@ -18,6 +18,7 @@ import {
18
18
  import { homedir } from 'node:os'
19
19
  import { join, dirname, basename } from 'node:path'
20
20
  import { fileURLToPath } from 'node:url'
21
+ import { codexHomeDir } from './codex-home.js'
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url))
23
24
  export const DEFAULT_TEMPLATE_PATH = join(__dirname, '..', '..', 'templates', 'claude-md', 'rookie-block.md')
@@ -76,7 +77,7 @@ export function claudeTarget({ home = homedir() } = {}) {
76
77
  // (2026-09-29 codex 0.153.4 바이너리 strings + codex exec 실측).
77
78
  export function codexTarget({ home = homedir(), env = process.env } = {}) {
78
79
  return {
79
- dir: env.CODEX_HOME ? env.CODEX_HOME : join(home, '.codex'),
80
+ dir: codexHomeDir({ home, env }),
80
81
  candidates: ['AGENTS.override.md', 'AGENTS.md'],
81
82
  createName: 'AGENTS.md',
82
83
  needles: [SAME_INTENT_NEEDLE, 'rookie_recall'],
@@ -37,7 +37,8 @@ function block(paths, platform = process.platform) {
37
37
  '',
38
38
  '[[hooks.UserPromptSubmit.hooks]]',
39
39
  'type = "command"',
40
- `command = 'bash "${promptSubmit}"'`,
40
+ // 다중 루키 §8 — codex_cli 를 넘겨 보고 대장 열기를 건너뛴다(Codex 는 Stop hook 이 없어 닫지 못한다).
41
+ `command = 'bash "${promptSubmit}" codex_cli'`,
41
42
  // 파일만 읽고 끝나는 경로라 짧게 — Claude 쪽도 3초다.
42
43
  'timeout = 5'
43
44
  )
@@ -0,0 +1,8 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ // Codex 설정 폴더. Codex 는 $CODEX_HOME 이 있으면 그 폴더를 쓴다(2026-09-29 바이너리 strings 실측).
5
+ // config.toml·AGENTS.md·감지가 모두 이 한 곳을 보게 한다 — 갈리면 hook 과 지시 블록이 서로 다른 폴더에 들어간다.
6
+ export function codexHomeDir({ home = homedir(), env = process.env } = {}) {
7
+ return env.CODEX_HOME ? env.CODEX_HOME : join(home, '.codex')
8
+ }
@@ -0,0 +1,47 @@
1
+ // spec 2026-09-28 다중 루키 §8 — Codex 는 MCP 서버에 환경 변수를 넘기지 않는다. [mcp_servers.rookie] 에
2
+ // env_vars = ["ROOKIE_AGENT"] 가 있어야 rookie-mcp 가 세션의 루키로 주입·저장한다(없으면 둘 다 기본 루키).
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { codexHomeDir } from './codex-home.js'
7
+
8
+ export const CODEX_AGENT_ENV_LINE = 'env_vars = ["ROOKIE_AGENT"]'
9
+
10
+ // 반환: null = rookie MCP 미등록(검사 대상 아님) / { ok } = 등록됨, ROOKIE_AGENT 전달 여부.
11
+ export function codexMcpAgentEnvStatus(toml) {
12
+ const lines = String(toml ?? '').split(/\r?\n/)
13
+ const start = lines.findIndex((l) => /^\s*\[mcp_servers\.rookie\]\s*(#.*)?$/.test(l))
14
+ if (start === -1) return null
15
+ let end = lines.length
16
+ for (let i = start + 1; i < lines.length; i++) {
17
+ if (/^\s*\[/.test(lines[i])) { end = i; break }
18
+ }
19
+ const section = lines.slice(start + 1, end).filter((l) => !/^\s*#/.test(l)).join('\n')
20
+ const m = section.match(/^\s*env_vars\s*=\s*\[([\s\S]*?)\]/m)
21
+ return { ok: !!m && /["']ROOKIE_AGENT["']/.test(m[1]) }
22
+ }
23
+
24
+ // 이 기기에서 루키 여럿을 쓰는가 — ROOKIE_AGENT 를 붙였거나, 채용 계정 세션 표식이 남아 있다.
25
+ export function usesRookies({ home = homedir(), env = process.env } = {}) {
26
+ if ((env.ROOKIE_AGENT || '').trim()) return true
27
+ try {
28
+ return readdirSync(env.ROOKIE_LEDGER_MARK_DIR || join(home, '.rookie', 'state', 'ledger')).length > 0
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ export function codexMcpAgentEnvLine({ home, env } = {}) {
35
+ // CLAUDE.md 원칙 11 — 루키를 쓰지 않는 사용자의 doctor 출력은 이전과 같다(줄을 더하지 않는다).
36
+ if (!usesRookies({ home, env })) return null
37
+ const path = join(codexHomeDir({ home, env }), 'config.toml')
38
+ if (!existsSync(path)) return null
39
+ let st
40
+ try { st = codexMcpAgentEnvStatus(readFileSync(path, 'utf8')) } catch { return null }
41
+ if (!st) return null
42
+ if (st.ok) return '✓ Codex rookie-mcp 가 ROOKIE_AGENT 를 넘깁니다'
43
+ return [
44
+ '⚠ Codex rookie-mcp 가 ROOKIE_AGENT 를 넘기지 않습니다 — 루키를 붙여 띄워도 MCP 주입·저장은 기본 루키로 갑니다.',
45
+ ` → ${path} 의 [mcp_servers.rookie] 아래에 한 줄: ${CODEX_AGENT_ENV_LINE}`,
46
+ ].join('\n')
47
+ }
@@ -1,10 +1,11 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, cpSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
+ import { codexHomeDir } from './codex-home.js'
3
4
  import { join, dirname } from 'node:path'
4
5
  import { fileURLToPath } from 'node:url'
5
6
  import { apiGet } from '../api.js'
6
7
  import { saveClientManifest } from './manifest.js'
7
- import { upsertCodexHookBlockDetailed } from './codex-config.js'
8
+ import { upsertCodexHookBlockDetailed, ROOKIE_BEGIN, ROOKIE_END } from './codex-config.js'
8
9
  import { claudeHookCommand, escapeJsonStringValue } from './hook-command.js'
9
10
  import { mergeHooks } from './merge-hooks.js'
10
11
  import { syncSkillAssets } from './skill-installer.js'
@@ -66,6 +67,13 @@ export function installSharedHookRuntime({ scope, home, cwd } = {}) {
66
67
  )
67
68
  chmodSync(join(pretoolBinDir, 'work-recall-gate.sh'), 0o755)
68
69
 
70
+ // spec 2026-09-28 다중 루키 §6.5 — 보고 대장 생존 신호(PostToolUse 모든 도구, 5분 표식).
71
+ cpSync(
72
+ join(TEMPLATES, 'hook', 'bin', 'report-heartbeat.sh'),
73
+ join(pretoolBinDir, 'report-heartbeat.sh')
74
+ )
75
+ chmodSync(join(pretoolBinDir, 'report-heartbeat.sh'), 0o755)
76
+
69
77
  // spec 2026-07-25 §3 — recall 소비 실패율 계측 Stop 파서(순수 관측, flush-session 과 별개 훅).
70
78
  cpSync(
71
79
  join(TEMPLATES, 'hook', 'bin', 'recall-episode-metric.sh'),
@@ -137,6 +145,7 @@ export async function applyClaudeSetup({ scope, cfg, home, cwd } = {}) {
137
145
  sessionStartCmd: claudeHookCommand(join(binDir, 'session-start.sh')),
138
146
  pretoolMdReadCmd: claudeHookCommand(join(pretoolBinDir, 'pretool-md-read.sh')),
139
147
  posttoolRecallNudgeCmd: claudeHookCommand(join(pretoolBinDir, 'posttool-recall-nudge.sh')),
148
+ reportHeartbeatCmd: claudeHookCommand(join(pretoolBinDir, 'report-heartbeat.sh')),
140
149
  guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
141
150
  skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
142
151
  workRecallGateCmd: claudeHookCommand(join(pretoolBinDir, 'work-recall-gate.sh')),
@@ -163,8 +172,8 @@ export async function applyClaudeSetup({ scope, cfg, home, cwd } = {}) {
163
172
 
164
173
  // ── 3층: Codex adapter — ~/.codex/config.toml 의 managed 블록(upsertCodexHookBlock).
165
174
  // scope=global 전제(D3): Codex config 는 항상 전역이고 전역 hook 을 문다. 호출 게이트는 오케스트레이터 책임.
166
- export function applyCodexSetup({ home = homedir() } = {}) {
167
- const codexDir = join(home, '.codex')
175
+ export function applyCodexSetup({ home = homedir(), env = process.env, promptSubmit = true } = {}) {
176
+ const codexDir = codexHomeDir({ home, env })
168
177
  const configPath = join(codexDir, 'config.toml')
169
178
  const hookScript = join(home, '.rookie', 'bin', 'session-start.sh')
170
179
  const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''
@@ -173,7 +182,7 @@ export function applyCodexSetup({ home = homedir() } = {}) {
173
182
  const upserted = upsertCodexHookBlockDetailed(existing, {
174
183
  sessionStart: hookScript,
175
184
  // 인격 대기열 소비자 — 짝이 없으면 파일만 쌓인다(spec 2026-09-02).
176
- promptSubmit: join(homedir(), '.rookie', 'bin', 'user-prompt-skill-gate.sh'),
185
+ promptSubmit: promptSubmit ? join(home, '.rookie', 'bin', 'user-prompt-skill-gate.sh') : null,
177
186
  })
178
187
  next = upserted.toml
179
188
  // 마커 안에 흘러든 사용자 설정을 밖으로 옮겼으면 조용히 넘기지 않는다.
@@ -193,6 +202,24 @@ export function applyCodexSetup({ home = homedir() } = {}) {
193
202
  return { configPath, hookScript }
194
203
  }
195
204
 
205
+ // rookie update 용 — 이미 동의해 managed 블록이 있는 Codex 설정만 새 템플릿으로 다시 쓴다.
206
+ // 블록이 없으면 손대지 않는다(동의 없이 Codex 설정을 만들지 않는다). 구 블록은 skill-gate 를 인자 없이
207
+ // 부르거나 아예 등록하지 않아, 새 스크립트가 Codex 를 Claude Code 로 오판하던 조합을 update 가 닫는다.
208
+ export function refreshCodexSetupIfManaged({ home = homedir(), env = process.env } = {}) {
209
+ const configPath = join(codexHomeDir({ home, env }), 'config.toml')
210
+ if (!existsSync(configPath)) return { refreshed: false }
211
+ const before = readFileSync(configPath, 'utf8')
212
+ const begin = before.indexOf(ROOKIE_BEGIN)
213
+ if (begin === -1) return { refreshed: false }
214
+ // 기존 블록에 있던 hook 만 갱신한다 — 2026-09-03 이전 블록은 SessionStart 뿐이라, update 가 말없이
215
+ // UserPromptSubmit hook 을 새로 달지 않는다(새 hook 은 `rookie codex-setup` 으로 동의 받고 단다).
216
+ const end = before.indexOf(ROOKIE_END, begin)
217
+ const block = end === -1 ? '' : before.slice(begin, end)
218
+ const r = applyCodexSetup({ home, env, promptSubmit: block.includes('[[hooks.UserPromptSubmit]]') })
219
+ if (r.error) return { refreshed: false, configPath, error: r.error }
220
+ return { refreshed: true, changed: readFileSync(configPath, 'utf8') !== before, configPath }
221
+ }
222
+
196
223
  // init·login 공유 wrapper: 공유 런타임(1층) + Claude adapter(2층). 반환형은 기존과 동일.
197
224
  // PAT 발급은 호출자 책임(여기선 cfg 를 받기만).
198
225
  export async function deployClientArtifacts({ scope, cfg, home, cwd }) {
@@ -208,6 +235,7 @@ async function mergeSettings(settingsPath, partialPath, subs) {
208
235
  .replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
209
236
  .replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
210
237
  .replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
238
+ .replaceAll('__REPORT_HEARTBEAT_CMD__', escapeJsonStringValue(subs.reportHeartbeatCmd))
211
239
  .replaceAll('__RECALL_EPISODE_METRIC_CMD__', escapeJsonStringValue(subs.recallEpisodeMetricCmd))
212
240
  .replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
213
241
  .replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
@@ -8,7 +8,7 @@
8
8
  // refresh-context.sh 포함 — 과거 UserPromptSubmit 로 실배포됐던 legacy hook.
9
9
  // 마커 소실 구버전 설정에서 이를 보존하면 매 turn 대용량 주입 회귀가 남는다(codex R3-P1).
10
10
  export const ROOKIE_HOOK_SCRIPTS =
11
- /[\\/]\.rookie[\\/]bin[\\/](?:bin[\\/])?(?:flush-session|session-start|user-prompt-skill-gate|guard-destructive-db|pretool-md-read|posttool-recall-nudge|work-recall-gate|recall-episode-metric|recall-push|sweep-recovery|refresh-context)\.sh\b/
11
+ /[\\/]\.rookie[\\/]bin[\\/](?:bin[\\/])?(?:flush-session|session-start|user-prompt-skill-gate|guard-destructive-db|pretool-md-read|posttool-recall-nudge|work-recall-gate|report-heartbeat|recall-episode-metric|recall-push|sweep-recovery|refresh-context)\.sh\b/
12
12
 
13
13
  export function isRookieEntry(entry) {
14
14
  if (!entry || typeof entry !== 'object') return false
@@ -0,0 +1,58 @@
1
+ #!/bin/bash
2
+ # PostToolUse(모든 도구) — 보고 대장 생존 신호. spec 2026-09-28 다중 루키 §6.5.
3
+ # 긴 턴(첫 턴이 수십 분) 동안 현황 줄이 "30분째 기록 없음" 으로 잘못 보이지 않게, 5분마다 한 번
4
+ # 서버에 "살아 있다" 를 보낸다. 도구 호출마다 네트워크를 타지 않는다 — 표식 파일 시각만 보고 끝난다.
5
+ # 차단 절대 X — 항상 exit 0. set -e 금지.
6
+ set +e
7
+
8
+ [ "${ROOKIE_INTERNAL_INVOCATION:-}" = "1" ] && exit 0
9
+
10
+ LEDGER_MARK_DIR="${ROOKIE_LEDGER_MARK_DIR:-$HOME/.rookie/state/ledger}"
11
+ # CLAUDE.md 원칙 11 — 대장을 여는 세션(채용 계정 표식 또는 ROOKIE_AGENT)만 생존 신호를 보낸다.
12
+ # 표식 폴더도 ROOKIE_AGENT 도 없으면 payload 를 볼 것도 없다 — stdin 만 비우고 끝낸다.
13
+ # stdin 을 남긴 채 끝내면 큰 도구 출력을 쓰던 쪽이 EPIPE 를 맞는다. 어느 경로든 끝까지 비운다.
14
+ if [ -z "${ROOKIE_AGENT:-}" ] && [ ! -d "$LEDGER_MARK_DIR" ]; then
15
+ cat >/dev/null 2>&1
16
+ exit 0
17
+ fi
18
+
19
+ # payload 는 tool_response 전문까지 담아 클 수 있다 — session_id 는 앞머리에 있으므로 앞부분만 보고 나머지는 버린다.
20
+ HEAD=$(head -c 4096 2>/dev/null)
21
+ cat >/dev/null 2>&1
22
+ SID=$(printf '%s' "$HEAD" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[A-Za-z0-9-]*"' | head -1 | sed 's/.*"\([A-Za-z0-9-]*\)"$/\1/')
23
+ [ -n "$SID" ] || exit 0
24
+ [ -f "$LEDGER_MARK_DIR/$SID" ] || [ -n "${ROOKIE_AGENT:-}" ] || exit 0
25
+
26
+ STATE_DIR="${ROOKIE_HEARTBEAT_DIR:-$HOME/.rookie/state/heartbeat}"
27
+ MARK="$STATE_DIR/$SID"
28
+ INTERVAL_MIN="${ROOKIE_HEARTBEAT_INTERVAL_MIN:-5}"
29
+
30
+ # 첫 도구 호출 — 지시가 방금 열렸으니 표식만 만들고 끝낸다.
31
+ if [ ! -f "$MARK" ]; then
32
+ mkdir -p "$STATE_DIR" 2>/dev/null && touch "$MARK" 2>/dev/null
33
+ exit 0
34
+ fi
35
+ # 표식이 INTERVAL 분 안이면 끝.
36
+ [ -n "$(find "$MARK" -mmin "-$INTERVAL_MIN" 2>/dev/null)" ] && exit 0
37
+ touch "$MARK" 2>/dev/null
38
+
39
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40
+ if [ -f "$SCRIPT_DIR/../lib/config-resolve.sh" ]; then
41
+ # shellcheck source=/dev/null
42
+ . "$SCRIPT_DIR/../lib/config-resolve.sh"
43
+ elif [ -f "$HOME/.rookie/bin/lib/config-resolve.sh" ]; then
44
+ # shellcheck source=/dev/null
45
+ . "$HOME/.rookie/bin/lib/config-resolve.sh"
46
+ fi
47
+ command -v rookie_resolve_credentials >/dev/null 2>&1 || exit 0
48
+ rookie_resolve_credentials "$PWD" || exit 0
49
+ [ -n "$ROOKIE_RESOLVED_ENDPOINT" ] && [ -n "$ROOKIE_RESOLVED_TOKEN" ] || exit 0
50
+
51
+ # 백그라운드로 던지고 기다리지 않는다. 실패는 버린다(다음 5분에 다시).
52
+ ( curl -sS --max-time 3 -o /dev/null -X POST \
53
+ -H "Authorization: Bearer $ROOKIE_RESOLVED_TOKEN" \
54
+ -H "Content-Type: application/json" \
55
+ --data "{\"session_id\":\"$SID\"}" \
56
+ "$ROOKIE_RESOLVED_ENDPOINT/api/rookie/report/heartbeat" >/dev/null 2>&1 & ) 2>/dev/null
57
+
58
+ exit 0
@@ -243,12 +243,16 @@ if [ "$STATUS_CODE" = "200" ]; then
243
243
  fi
244
244
 
245
245
  RESP_FILE=$(mktemp)
246
+ # spec 2026-09-28 다중 루키 §5.1 — ROOKIE_AGENT 로 어느 루키로 출근할지, x-rookie-session 으로 현황 줄에서
247
+ # 자기 세션을 뺀다. 빈 값 헤더는 curl 이 생략하므로 ROOKIE_AGENT 가 없으면 기본 루키다.
246
248
  CTX_CODE=$(curl -sS --max-time 10 \
247
249
  -H "Authorization: Bearer $TOKEN" \
248
250
  -H "x-rookie-hostname: $HOST" \
249
251
  -H "x-rookie-user-id: $USER_ID" \
250
252
  -H "x-rookie-surface: $ROOKIE_SURFACE" \
251
253
  -H "x-rookie-manifest-hash: $LOCAL_HASH" \
254
+ -H "x-rookie-agent: ${ROOKIE_AGENT:-}" \
255
+ -H "x-rookie-session: $SESSION_ID" \
252
256
  -o "$RESP_FILE" \
253
257
  -w "%{http_code}" \
254
258
  "$ENDPOINT/api/rookie/context?$QS" 2>/dev/null) || CTX_CODE="000"
@@ -266,6 +270,21 @@ if [ "$STATUS_CODE" = "200" ]; then
266
270
  pending_inject_write "$SESSION_ID" "$RESP_FILE" "$SOURCE" || true
267
271
  fi
268
272
 
273
+ # CLAUDE.md 원칙 11 — 루키를 채용한 계정만 세션 표식을 남긴다. user-prompt-skill-gate·report-heartbeat 는
274
+ # 표식(또는 ROOKIE_AGENT)이 없으면 서버를 부르지 않는다 — 채용 안 한 사용자는 프롬프트 지연·대장 행이 없다.
275
+ # grep 으로 본다: team_active 는 최상위 필드라 system_prompt 문자열(따옴표가 \" 로 escape)과 겹치지 않는다.
276
+ LEDGER_MARK_DIR="${ROOKIE_LEDGER_MARK_DIR:-$HOME/.rookie/state/ledger}"
277
+ SAFE_SID=$(printf '%s' "$SESSION_ID" | tr -cd 'A-Za-z0-9-')
278
+ if [ -n "$SAFE_SID" ]; then
279
+ if grep -q '"team_active":true' "$RESP_FILE" 2>/dev/null; then
280
+ mkdir -p "$LEDGER_MARK_DIR" 2>/dev/null && touch "$LEDGER_MARK_DIR/$SAFE_SID" 2>/dev/null
281
+ else
282
+ rm -f "$LEDGER_MARK_DIR/$SAFE_SID" 2>/dev/null
283
+ fi
284
+ # 하루 넘게 손대지 않은 표식 정리(세션 표식·heartbeat 표식). 쓰는 쪽이 매번 시각을 갱신한다.
285
+ find "$LEDGER_MARK_DIR" "${ROOKIE_HEARTBEAT_DIR:-$HOME/.rookie/state/heartbeat}" -type f -mmin +1440 -delete 2>/dev/null || true
286
+ fi
287
+
269
288
  QUEUE_AFTER=0
270
289
  if command -v queue_count >/dev/null 2>&1; then
271
290
  QUEUE_AFTER=$(queue_count)
@@ -50,6 +50,13 @@
50
50
  }
51
51
  ],
52
52
  "PostToolUse": [
53
+ {
54
+ "matcher": "*",
55
+ "__rookie_managed": true,
56
+ "hooks": [
57
+ { "type": "command", "command": "__REPORT_HEARTBEAT_CMD__" }
58
+ ]
59
+ },
53
60
  {
54
61
  "matcher": "Read|Bash|Grep",
55
62
  "__rookie_managed": true,
@@ -10,6 +10,10 @@ set -u
10
10
  # self-reference 방지: 차차 internal LLM 호출(claude -p)에서 발사된 hook 은 즉시 종료.
11
11
  [ "${ROOKIE_INTERNAL_INVOCATION:-}" = "1" ] && exit 0
12
12
 
13
+ # spec 2026-09-28 다중 루키 §8 — Codex 는 인자로 codex_cli 를 넘긴다. Codex 는 Stop hook 이 없어 보고 대장을
14
+ # 열면 닫지 못하므로 열기는 건너뛰고 현황만 조회한다.
15
+ ROOKIE_SURFACE="${1:-claude_code}"
16
+
13
17
  # B6-1 (spec 2026-07-11 §B6): config 해석 — env > 프로젝트-로컬(.rookie/, cwd 상향) > 전역.
14
18
  # UserPromptSubmit hook 은 프로젝트 cwd 에서 발사되므로 $PWD 기준. lib 미배포(구설치)면 전역 폴백.
15
19
  _GATE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -23,10 +27,12 @@ fi
23
27
  if command -v rookie_resolve_credentials >/dev/null 2>&1; then
24
28
  rookie_resolve_credentials "$PWD" || exit 0
25
29
  TOKEN="$ROOKIE_RESOLVED_TOKEN"
30
+ ENDPOINT="$ROOKIE_RESOLVED_ENDPOINT"
26
31
  else
27
32
  CONFIG="$HOME/.rookie/config.json"
28
33
  [ -f "$CONFIG" ] || exit 0
29
34
  TOKEN=$(node -e "try{process.stdout.write(String(require('$CONFIG').token||''))}catch{}" 2>/dev/null)
35
+ ENDPOINT=$(node -e "try{process.stdout.write(String(require('$CONFIG').endpoint||''))}catch{}" 2>/dev/null)
30
36
  fi
31
37
  [ -n "$TOKEN" ] || exit 0
32
38
 
@@ -39,9 +45,10 @@ DROP_LOG="${ROOKIE_DROP_LOG:-$HOME/.rookie/dropped-mutations.log}"
39
45
  # node 단일 덩어리라 fail-soft·stderr 억제가 쉽다. code 안에 작은따옴표 미사용(bash single-quote 보호).
40
46
  # 두 갈래(한 turn 한 메시지): skill 미로드 → SKILL_MSG / 로드됨 + 과거-방법 회상 신호 → RECALL_MSG.
41
47
  # marker(.loaded)는 "로드됨 캐시"일 뿐 early-return 아님 — 로드 뒤에도 프롬프트 신호를 매 turn 검사(spec 2026-07-07-recall-first §3.1).
42
- cat | ROOKIE_GATE_DIR="$GATE_DIR" ROOKIE_PI_DIR="$PI_DIR" ROOKIE_DROP_LOG="$DROP_LOG" node -e '
48
+ cat | ROOKIE_GATE_DIR="$GATE_DIR" ROOKIE_PI_DIR="$PI_DIR" ROOKIE_DROP_LOG="$DROP_LOG" \
49
+ ROOKIE_GATE_ENDPOINT="${ENDPOINT:-}" ROOKIE_GATE_TOKEN="$TOKEN" ROOKIE_GATE_SURFACE="$ROOKIE_SURFACE" node -e '
43
50
  let d="";
44
- process.stdin.on("data",c=>d+=c).on("end",()=>{
51
+ process.stdin.on("data",c=>d+=c).on("end",async ()=>{
45
52
  const SKILL_MSG="⚠️ 이 세션에서 아직 rookie Skill 을 로드하지 않았습니다. 다른 작업·응답 전에 먼저 rookie Skill 을 호출하세요 — 회상(recall)·미link 등록 제안·기억 저장 절차가 그 안에 있습니다. (이미 호출했다면 이 안내는 사라집니다.)";
46
53
  const RECALL_MSG="🧠 이 프롬프트에 과거 맥락·방법 회상 신호가 있습니다. grep·파일 열람·자기판단·두목 되묻기 이전에 GET /api/rookie/recall 을 먼저 호출하세요 (brand.ts 회상-순서 룰).";
47
54
  const CKPT_MSG="📌 이 세션이 길어지고 있습니다. 직전 checkpoint 이후 마일스톤(커밋·배포·설계 수렴·단계 전환)이 지났다면 checkpoint 진입카드를 저장하세요 — 지나지 않았다면 무시해도 됩니다.";
@@ -109,6 +116,60 @@ cat | ROOKIE_GATE_DIR="$GATE_DIR" ROOKIE_PI_DIR="$PI_DIR" ROOKIE_DROP_LOG="$DROP
109
116
  return { text: head+"\n\n"+picked.join("\n\n"), commit };
110
117
  }catch{ release(); return null; }
111
118
  };
119
+ // spec 2026-09-28 다중 루키 §5.3·§6.1 — 보고 대장 열기(Claude Code) 또는 현황 조회(Codex).
120
+ // 1.5초 안에 못 받으면 이 부분만 건너뛴다(hook 전체는 3초 제한). 현황은 바뀐 때만 싣는다.
121
+ const LEDGER_TIMEOUT_MS=parseInt(process.env.ROOKIE_LEDGER_TIMEOUT_MS||"1500",10);
122
+ const ledger=async(fs,path,crypto,p,key)=>{
123
+ const ep=process.env.ROOKIE_GATE_ENDPOINT, tk=process.env.ROOKIE_GATE_TOKEN;
124
+ const sid=(p.session_id||"").toString();
125
+ if(!ep||!tk||!sid||typeof fetch!=="function") return [];
126
+ // 원칙 11 — 표식(session-start 가 채용 계정에만 남김)도 ROOKIE_AGENT 도 없으면 서버를 부르지 않는다.
127
+ const agent=(process.env.ROOKIE_AGENT||"").trim();
128
+ const safeSid=sid.replace(/[^A-Za-z0-9-]/g,"");
129
+ const markDir=process.env.ROOKIE_LEDGER_MARK_DIR||path.join(require("os").homedir(),".rookie","state","ledger");
130
+ let team=false;
131
+ if(safeSid){ try{ const now=new Date(); fs.utimesSync(path.join(markDir,safeSid),now,now); team=true; }catch{} }
132
+ if(!team&&!agent) return [];
133
+ // Codex 는 인자(codex_cli)로 알린다. 인자가 없는 구 config.toml 이어도 payload 로 알아본다 —
134
+ // Codex payload 에만 turn_id 가 있고 transcript 는 ~/.codex/sessions/…/rollout-*.jsonl 이다.
135
+ const tp=(p.transcript_path||"").toString();
136
+ const codex=process.env.ROOKIE_GATE_SURFACE==="codex_cli"||p.turn_id!=null||/[\\/]\.codex[\\/]|[\\/]rollout-[^\\/]*\.jsonl$/.test(tp);
137
+ const headers={Authorization:"Bearer "+tk,"x-rookie-hostname":require("os").hostname()};
138
+ if(agent) headers["x-rookie-agent"]=agent;
139
+ const cwd=(p.cwd||"").toString();
140
+ let body=null;
141
+ try{
142
+ const signal=AbortSignal.timeout(LEDGER_TIMEOUT_MS);
143
+ if(!codex){
144
+ let prompt=(p.prompt||"").toString();
145
+ if(!prompt.trim()) return [];
146
+ const buf=Buffer.from(prompt,"utf8");
147
+ if(buf.length>65536){ let e=65536; while(e>0&&(buf[e]&0xc0)===0x80) e--; prompt=buf.subarray(0,e).toString("utf8"); }
148
+ const r=await fetch(ep+"/api/rookie/report/open",{method:"POST",headers:Object.assign({"Content-Type":"application/json"},headers),body:JSON.stringify(Object.assign({session_id:sid,prompt},cwd?{repo_path:cwd}:{})),signal});
149
+ if(r.ok) body=await r.json();
150
+ } else {
151
+ const q=new URLSearchParams({session_id:sid}); if(cwd) q.set("repo_path",cwd);
152
+ const r=await fetch(ep+"/api/rookie/report/concurrent?"+q.toString(),{headers,signal});
153
+ if(r.ok) body=await r.json();
154
+ }
155
+ }catch{ return []; }
156
+ if(!body) return [];
157
+ const out=[];
158
+ if(typeof body.notice==="string"&&body.notice) out.push(body.notice);
159
+ const cc=typeof body.concurrent==="string"?body.concurrent:"";
160
+ if(cc){
161
+ const gateDir=process.env.ROOKIE_GATE_DIR;
162
+ const h=crypto.createHash("sha256").update(cc).digest("hex");
163
+ let prev="";
164
+ const f=key&&gateDir?path.join(gateDir,key+".concurrent"):null;
165
+ if(f){ try{ prev=String(fs.readFileSync(f,"utf8")).trim(); }catch{} }
166
+ if(prev!==h){
167
+ out.push(cc);
168
+ if(f){ try{ fs.mkdirSync(gateDir,{recursive:true,mode:0o700}); fs.writeFileSync(f,h,{mode:0o600}); }catch{} }
169
+ }
170
+ }
171
+ return out;
172
+ };
112
173
  try{
113
174
  const fs=require("fs"), path=require("path"), crypto=require("crypto");
114
175
  const gateDir=process.env.ROOKIE_GATE_DIR;
@@ -210,9 +271,12 @@ cat | ROOKIE_GATE_DIR="$GATE_DIR" ROOKIE_PI_DIR="$PI_DIR" ROOKIE_DROP_LOG="$DROP
210
271
  else if(ckptDue) base=CKPT_MSG; // 로드됨 + 무신호 + 임계 도달 → checkpoint 리마인더
211
272
  let taken=null;
212
273
  try{ taken=takePendingPersona(fs,path,key); }catch{ taken=null; }
274
+ let ledgerParts=[];
275
+ try{ ledgerParts=await ledger(fs,path,crypto,p,key); }catch{ ledgerParts=[]; }
213
276
  const parts=[];
214
277
  if(taken) parts.push(taken.text);
215
278
  if(base) parts.push(base);
279
+ for(const x of ledgerParts) parts.push(x);
216
280
  if(parts.length===0) return; // 둘 다 없음 → 조용
217
281
  injectMsg(parts.join("\n\n"));
218
282
  if(taken) taken.commit(); // 내보낸 뒤에 소비 표시
@@ -141,6 +141,8 @@ curl -s -H "Authorization: Bearer $TOKEN" \
141
141
 
142
142
  **본문 작성 전 `references/save.md` 를 Read 한다(세션 첫 저장 전 1회 필수)** — 스키마·`all_committed` 게이트·귀속 확인·supersede 후보 처리·자기 규칙 검토 게이트·checkpoint/durable 분리 규칙이 거기 있다. 호출은 `rookie save --file <body.json>` (curl 직접 batch-mutate 금지, `rookie:mutate` fence 화면 출력 금지, 토큰 출력 금지).
143
143
 
144
+ **점유 선언** (루키 팀을 쓰는 세션만 — `ROOKIE_AGENT` 로 출근했거나 context 응답 `team_active:true`. 아니면 건너뛴다): 공유 자원(맥미니 빌드·실기기·배포·대량 디스크)을 쓰기 **직전** `rookie claim "맥미니 iOS 빌드, 디스크 ~10Gi"` — 자원 이름으로 쓴다(프로젝트 이름 아님). 다른 세션 현황 줄 맨 앞에 뜨고, 보고가 붙으면 풀린다. 주입에 `## 👥 다른 세션 현황` 의 점유가 보이면 그 자원은 피하거나 먼저 확인한다.
145
+
144
146
  ## 세션 마무리 자동 저장 (추출 책임이 루키에게)
145
147
 
146
148
  서버는 LLM 추출을 하지 않는다 — 세션 핵심 기억은 루키가 직접 저장한다. **마무리 징후 감지 시** (끝내자/수고/오늘 여기까지, 작업 완결, 의미 있는 단락):
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.39",
3
- "skill_version": "1.40.1"
2
+ "cli_version": "0.4.41",
3
+ "skill_version": "1.41.0"
4
4
  }