therookie 0.4.5 → 0.4.13

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,7 +1,7 @@
1
1
  {
2
2
  "name": "therookie",
3
- "version": "0.4.5",
4
- "description": "the Rookie \u2014 the AI new hire that remembers what you teach it. A CLI that connects a personal AI memory to AI tools like Claude and Codex.",
3
+ "version": "0.4.13",
4
+ "description": "the Rookie — the AI new hire that remembers what you teach it. A CLI that connects a personal AI memory to AI tools like Claude and Codex.",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "ai",
@@ -129,6 +129,20 @@ export async function runUpdate(flags) {
129
129
  )
130
130
  chmodSync(join(pretoolBinDir, 'posttool-recall-nudge.sh'), 0o755)
131
131
 
132
+ // spec 2026-07-22 §D3 — 회상 없이 파일 수정 착수 시 보정 넛지.
133
+ cpSync(
134
+ join(TEMPLATES, 'hook', 'bin', 'work-recall-gate.sh'),
135
+ join(pretoolBinDir, 'work-recall-gate.sh')
136
+ )
137
+ chmodSync(join(pretoolBinDir, 'work-recall-gate.sh'), 0o755)
138
+
139
+ // spec 2026-07-25 §3 — recall 소비 실패율 계측 Stop 파서(순수 관측, flush-session 과 별개 훅).
140
+ cpSync(
141
+ join(TEMPLATES, 'hook', 'bin', 'recall-episode-metric.sh'),
142
+ join(pretoolBinDir, 'recall-episode-metric.sh')
143
+ )
144
+ chmodSync(join(pretoolBinDir, 'recall-episode-metric.sh'), 0o755)
145
+
132
146
  const libDir = join(binDir, 'lib')
133
147
  mkdirSync(libDir, { recursive: true })
134
148
  cpSync(join(TEMPLATES, 'hook', 'lib', 'queue.sh'), join(libDir, 'queue.sh'))
@@ -160,6 +174,8 @@ export async function runUpdate(flags) {
160
174
  posttoolRecallNudgeCmd: claudeHookCommand(join(pretoolBinDir, 'posttool-recall-nudge.sh')),
161
175
  guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
162
176
  skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
177
+ workRecallGateCmd: claudeHookCommand(join(pretoolBinDir, 'work-recall-gate.sh')),
178
+ recallEpisodeMetricCmd: claudeHookCommand(join(pretoolBinDir, 'recall-episode-metric.sh')),
163
179
  })
164
180
 
165
181
  // v6 P0b — 글로벌 SKILL.md 갱신 (backup-and-overwrite). 같은 본문이면 no-op.
@@ -171,6 +187,8 @@ export async function runUpdate(flags) {
171
187
  if (skillResult.installed) {
172
188
  console.log(`✓ Skill 갱신 → ${skillResult.path}`)
173
189
  if (skillResult.backup) console.log(` · 이전 본문 backup → ${skillResult.backup}`)
190
+ } else if (skillResult.assets_synced > 0) {
191
+ console.log(`✓ Skill 본문 변경 없음 · 부속 문서 ${skillResult.assets_synced}건 동기화`)
174
192
  } else {
175
193
  console.log(`✓ Skill 본문 변경 없음 (manifest 동기화만)`)
176
194
  }
@@ -201,8 +219,10 @@ async function mergeSettings(settingsPath, partialPath, subs) {
201
219
  .replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
202
220
  .replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
203
221
  .replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
222
+ .replaceAll('__RECALL_EPISODE_METRIC_CMD__', escapeJsonStringValue(subs.recallEpisodeMetricCmd))
204
223
  .replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
205
224
  .replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
225
+ .replaceAll('__WORK_RECALL_GATE_CMD__', escapeJsonStringValue(subs.workRecallGateCmd))
206
226
  const partial = JSON.parse(partialRaw)
207
227
 
208
228
  let current = {}
@@ -7,6 +7,7 @@ import { saveClientManifest } from './manifest.js'
7
7
  import { upsertCodexHookBlock } from './codex-config.js'
8
8
  import { claudeHookCommand, escapeJsonStringValue } from './hook-command.js'
9
9
  import { mergeHooks } from './merge-hooks.js'
10
+ import { syncSkillAssets } from './skill-installer.js'
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url)
12
13
  const __dirname = dirname(__filename)
@@ -58,6 +59,20 @@ export function installSharedHookRuntime({ scope, home, cwd } = {}) {
58
59
  )
59
60
  chmodSync(join(pretoolBinDir, 'posttool-recall-nudge.sh'), 0o755)
60
61
 
62
+ // spec 2026-07-22 §D3 — 회상 없이 파일 수정 착수 시 보정 넛지(PostToolUse Bash 마커 + PreToolUse Edit 판정).
63
+ cpSync(
64
+ join(TEMPLATES, 'hook', 'bin', 'work-recall-gate.sh'),
65
+ join(pretoolBinDir, 'work-recall-gate.sh')
66
+ )
67
+ chmodSync(join(pretoolBinDir, 'work-recall-gate.sh'), 0o755)
68
+
69
+ // spec 2026-07-25 §3 — recall 소비 실패율 계측 Stop 파서(순수 관측, flush-session 과 별개 훅).
70
+ cpSync(
71
+ join(TEMPLATES, 'hook', 'bin', 'recall-episode-metric.sh'),
72
+ join(pretoolBinDir, 'recall-episode-metric.sh')
73
+ )
74
+ chmodSync(join(pretoolBinDir, 'recall-episode-metric.sh'), 0o755)
75
+
61
76
  // spec post-v5-decisions §17 — hook offline queue 라이브러리 배포
62
77
  const libDir = join(binDir, 'lib')
63
78
  mkdirSync(libDir, { recursive: true })
@@ -102,6 +117,7 @@ export async function applyClaudeSetup({ scope, cfg, home, cwd } = {}) {
102
117
  .replaceAll('__INSTALLED_AT__', new Date().toISOString())
103
118
  .replaceAll('__INSTALLED_BY__', `rookie-cli@${CLI_VERSION}`)
104
119
  writeFileSync(join(skillDir, 'SKILL.md'), skillRendered)
120
+ syncSkillAssets({ targetDir: skillDir, templateDir: join(TEMPLATES, 'skill') })
105
121
 
106
122
  // Windows 는 claudeHookCommand 가 `bash "<posix>"` 로 감싼다(아래 hook-command.js). macOS/Linux 무변경.
107
123
  const settingsResult = await mergeSettings(settingsPath, join(TEMPLATES, 'hook', 'settings.partial.json'), {
@@ -111,6 +127,8 @@ export async function applyClaudeSetup({ scope, cfg, home, cwd } = {}) {
111
127
  posttoolRecallNudgeCmd: claudeHookCommand(join(pretoolBinDir, 'posttool-recall-nudge.sh')),
112
128
  guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
113
129
  skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
130
+ workRecallGateCmd: claudeHookCommand(join(pretoolBinDir, 'work-recall-gate.sh')),
131
+ recallEpisodeMetricCmd: claudeHookCommand(join(pretoolBinDir, 'recall-episode-metric.sh')),
114
132
  })
115
133
 
116
134
  // §18 U-3 — 클라이언트 manifest 저장. 다음 hook turn 부터 미스매치 경고 비교 가능.
@@ -165,8 +183,10 @@ async function mergeSettings(settingsPath, partialPath, subs) {
165
183
  .replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
166
184
  .replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
167
185
  .replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
186
+ .replaceAll('__RECALL_EPISODE_METRIC_CMD__', escapeJsonStringValue(subs.recallEpisodeMetricCmd))
168
187
  .replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
169
188
  .replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
189
+ .replaceAll('__WORK_RECALL_GATE_CMD__', escapeJsonStringValue(subs.workRecallGateCmd))
170
190
  const partial = JSON.parse(partialRaw)
171
191
 
172
192
  let current = {}
@@ -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|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|recall-episode-metric|sweep-recovery|refresh-context)\.sh\b/
12
12
 
13
13
  export function isRookieEntry(entry) {
14
14
  if (!entry || typeof entry !== 'object') return false
@@ -1,8 +1,9 @@
1
1
  // v6 P0b — Skill 본문 install/upgrade. 기존 init/update 의 인라인 skill 쓰기 로직을
2
2
  // backup 옵션과 함께 모듈화. 같은 내용이면 no-op, 다른 내용이면 .bak.<ts> 백업 후 덮어쓰기.
3
+ // 2026-07-18 컨텍스트 다이어트 — SKILL.md 를 코어 + references/ 로 분할하며 부속 파일 동기화 추가.
3
4
 
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
5
- import { join, dirname } from 'node:path'
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
6
+ import { join, dirname, relative, sep } from 'node:path'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { homedir } from 'node:os'
8
9
 
@@ -16,6 +17,39 @@ const DEFAULT_TEMPLATE = join(
16
17
  'SKILL.md'
17
18
  )
18
19
 
20
+ // templates/skill/ 의 SKILL.md 외 부속 파일(references/*.md 등)을 skill 폴더에 동기화.
21
+ // placeholder 렌더 없음(부속 문서는 정적) — 내용이 다를 때만 덮어쓴다. backup 없음(제품 관리 파일).
22
+ // 템플릿에서 사라진 부속 파일은 지우지 않는다(보수적 — 사용자 추가 파일 보호).
23
+ export function syncSkillAssets({ targetDir, templateDir }) {
24
+ let synced = 0
25
+ const walk = (dir) => {
26
+ let entries
27
+ try {
28
+ entries = readdirSync(dir)
29
+ } catch {
30
+ return
31
+ }
32
+ for (const entry of entries) {
33
+ const full = join(dir, entry)
34
+ const st = statSync(full)
35
+ if (st.isDirectory()) {
36
+ walk(full)
37
+ continue
38
+ }
39
+ const rel = relative(templateDir, full)
40
+ if (rel === 'SKILL.md') continue
41
+ const target = join(targetDir, ...rel.split(sep))
42
+ const content = readFileSync(full)
43
+ if (existsSync(target) && readFileSync(target).equals(content)) continue
44
+ mkdirSync(dirname(target), { recursive: true })
45
+ writeFileSync(target, content)
46
+ synced++
47
+ }
48
+ }
49
+ walk(templateDir)
50
+ return synced
51
+ }
52
+
19
53
  export function installSkill({
20
54
  home = homedir(),
21
55
  scope = 'global',
@@ -39,17 +73,18 @@ export function installSkill({
39
73
  .replaceAll('__INSTALLED_BY__', installedBy)
40
74
 
41
75
  mkdirSync(targetDir, { recursive: true })
76
+ const assetsSynced = syncSkillAssets({ targetDir, templateDir: dirname(templatePath) })
42
77
 
43
78
  let backupPath = null
44
79
  if (existsSync(targetPath)) {
45
80
  const existing = readFileSync(targetPath, 'utf8')
46
81
  if (existing === rendered) {
47
- return { installed: false, reason: 'no_change', path: targetPath }
82
+ return { installed: false, reason: 'no_change', path: targetPath, assets_synced: assetsSynced }
48
83
  }
49
84
  backupPath = `${targetPath}.bak.${Date.now()}`
50
85
  writeFileSync(backupPath, existing)
51
86
  }
52
87
 
53
88
  writeFileSync(targetPath, rendered)
54
- return { installed: true, path: targetPath, backup: backupPath }
89
+ return { installed: true, path: targetPath, backup: backupPath, assets_synced: assetsSynced }
55
90
  }
@@ -81,15 +81,32 @@ export function parseCodexMessages(filePath) {
81
81
  return capTurns(out)
82
82
  }
83
83
 
84
- export function codexCwd(filePath) {
84
+ // rollout 첫 non-empty 줄의 session_meta.payload 에서 sweep 판정에 쓰는 필드를 한 번에 뽑는다.
85
+ // spec 2026-07-20-codex-exec-prompt-pollution §4.2 — cwd 만 읽던 것을 originator/source 까지 확장.
86
+ // 파싱 실패·session_meta 부재는 빈 값(fail-open) — 캡처 누락보다 과캡처가 낫다는 기존 sweep 기조.
87
+ export function codexSessionMeta(filePath) {
85
88
  try {
86
89
  const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(65536)
87
90
  const n = fs.readSync(fd, buf, 0, 65536, 0); fs.closeSync(fd)
88
91
  const first = buf.toString('utf8', 0, n).split('\n').find(l => l.trim())
89
92
  const j = JSON.parse(first)
90
- if (j.type === 'session_meta' && j.payload && j.payload.cwd) return j.payload.cwd
93
+ if (j.type === 'session_meta' && j.payload) {
94
+ const p = j.payload
95
+ return { cwd: p.cwd || '', originator: p.originator || '', source: p.source || '' }
96
+ }
91
97
  } catch {}
92
- return ''
98
+ return { cwd: '', originator: '', source: '' }
99
+ }
100
+
101
+ export function codexCwd(filePath) {
102
+ return codexSessionMeta(filePath).cwd
103
+ }
104
+
105
+ // `codex exec` = 비대화형 one-shot. 차차(에이전트)가 도구로 부른 호출이지 사람의 대화가 아니다.
106
+ // 이 rollout 의 role='user' 블록은 에이전트가 쓴 프롬프트라, 저장하면 두목 발화로 오귀속된다.
107
+ // 사람의 Codex 사용(Codex Desktop·codex-tui)은 여기 걸리지 않으므로 그대로 캡처된다.
108
+ export function isCodexExecRollout(meta) {
109
+ return meta.originator === 'codex_exec' || meta.source === 'exec'
93
110
  }
94
111
 
95
112
  // Claude transcript jsonl 의 첫 cwd 필드 (없으면 ''). codexCwd 와 대칭 — 이슈② repo 역산용.
@@ -393,13 +410,21 @@ export async function recoverCodex({ endpoint, token, userId, sessionsDir, hostn
393
410
  for (const fp of cands) {
394
411
  if (Date.now() >= deadline) break
395
412
  const sid = sessionIdFromPath(fp, 'codex'); if (!sid || deferBlocked(sid, codexOpts)) continue
396
- const messages = parseCodexMessages(fp); if (!messages.length) continue
397
- const repoPath = codexCwd(fp) || undefined
413
+ // spec 2026-07-20-codex-exec-prompt-pollution §4.2.1 — session_meta 를 먼저 읽는다.
414
+ // 메시지 파싱은 skip 대상이 아닐 때로 미룬다(계약 순서 = 코드 순서, 불필요한 파싱 제거).
415
+ const meta = codexSessionMeta(fp)
416
+ const repoPath = meta.cwd || undefined
398
417
  // B6-4: Claude 경로(recoverProject)와 대칭 — cwd 불명·디렉토리 소실 보류 + 타 config 소속 skip.
399
418
  if (!repoPath || !fs.existsSync(repoPath)) continue
400
419
  if (opts.activeConfigPath && configPathForCwd(repoPath) !== opts.activeConfigPath) continue
401
420
  // B6-3: identity pin 불일치 보류 (recoverProject 와 대칭).
402
421
  if (identityPinMismatch(sid, token, opts)) continue
422
+ // §4.2.1 — 격리 게이트(위 3줄)를 **통과한 뒤에만** 소비 완료 처리한다. 앞에서 marker 를 박으면
423
+ // 타 계정 소유 세션을 현재 config 가 소비해버려 B6-4 를 깬다.
424
+ // marker 없이 그냥 continue 하면 매 sweep 재후보가 되어 최신순+cap 아래 사람 세션을 굶긴다.
425
+ // 재시도 대상이 아니므로 pending·deferred·dead-letter 는 남기지 않는다.
426
+ if (isCodexExecRollout(meta)) { markBaseline(sid, 'codex_exec', codexOpts); continue }
427
+ const messages = parseCodexMessages(fp); if (!messages.length) continue
403
428
  if (await recoverOne({ endpoint, token, userId, fp, sid, messages, hostname, repoPath, opts: codexOpts })) count++
404
429
  }
405
430
  return count
@@ -0,0 +1,231 @@
1
+ #!/bin/bash
2
+ # Stop hook — 세션 raw transcript 를 episode 단위로 사후 파싱해 recall 소비 실패율을 계측한다.
3
+ # spec: docs/superpowers/specs/2026-07-25-recall-consumption-enforcement.md §3
4
+ #
5
+ # 8회 반복된 "회상 안 함 / 해놓고 안 믿고 밖에서 찾음" 문제의 근본 = 재발률을 한 번도 측정하지
6
+ # 않아 "고쳤다"가 반증 불가능했던 것. 이 훅은 관측만 한다 — 어떤 도구도 차단하지 않는다.
7
+ #
8
+ # flush-session.sh 와 별개의 Stop 훅(무간섭). Stop 은 매 assistant 응답마다 발사되고 transcript 는
9
+ # 누적이라, 파서가 매번 전체를 재계산해 세션의 모든 episode 를 upsert 로 보낸다(서버 UNIQUE 멱등).
10
+ #
11
+ # IMPORTANT: set -e 금지. 차단 절대 X — 항상 exit 0.
12
+ set +e
13
+
14
+ # self-reference 방지: 차차 internal LLM 호출에서 발사된 hook 은 즉시 종료.
15
+ [ "${ROOKIE_INTERNAL_INVOCATION:-}" = "1" ] && exit 0
16
+ command -v node >/dev/null 2>&1 || exit 0
17
+
18
+ INPUT=$(cat 2>/dev/null) || exit 0
19
+ [ -z "$INPUT" ] && exit 0
20
+
21
+ SESSION_ID=$(printf '%s' "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write((j.session_id||j.sessionId||'').toString())}catch{}})" 2>/dev/null)
22
+ TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write((j.transcript_path||j.transcriptPath||'').toString())}catch{}})" 2>/dev/null)
23
+ [ -z "$SESSION_ID" ] && exit 0
24
+ [ -z "$TRANSCRIPT_PATH" ] && exit 0
25
+ [ -f "$TRANSCRIPT_PATH" ] || exit 0
26
+
27
+ # config 해석 (env > 프로젝트-로컬 > 전역) — session-start.sh 패턴.
28
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
29
+ if [ -f "$SCRIPT_DIR/lib/config-resolve.sh" ]; then
30
+ # shellcheck source=/dev/null
31
+ . "$SCRIPT_DIR/lib/config-resolve.sh"
32
+ elif [ -f "$HOME/.rookie/bin/lib/config-resolve.sh" ]; then
33
+ # shellcheck source=/dev/null
34
+ . "$HOME/.rookie/bin/lib/config-resolve.sh"
35
+ fi
36
+ if command -v rookie_resolve_credentials >/dev/null 2>&1; then
37
+ rookie_resolve_credentials "$PWD" || exit 0
38
+ ENDPOINT="$ROOKIE_RESOLVED_ENDPOINT"; TOKEN="$ROOKIE_RESOLVED_TOKEN"; USER_ID="$ROOKIE_RESOLVED_USER_ID"
39
+ else
40
+ CONFIG="$HOME/.rookie/config.json"; [ -f "$CONFIG" ] || exit 0
41
+ ENDPOINT=$(node -e "console.log(require('$CONFIG').endpoint)" 2>/dev/null || echo "")
42
+ TOKEN=$(node -e "console.log(require('$CONFIG').token)" 2>/dev/null || echo "")
43
+ USER_ID=$(node -e "console.log(require('$CONFIG').user_id||'')" 2>/dev/null || echo "")
44
+ fi
45
+ [ -z "$TOKEN" ] && exit 0
46
+ [ -z "$ENDPOINT" ] && exit 0
47
+
48
+ # identity pin — 세션 도중 계정 전환 시 이전 계정 transcript 로 계산한 metric 이 현재 owner 로
49
+ # 오귀속되는 교차 오염 차단(flush-session.sh 패턴, codex 코드검토 P1). pin 없음(구세션)은 통과.
50
+ if command -v identity_pin_check >/dev/null 2>&1; then
51
+ if ! identity_pin_check "$SESSION_ID" "$TOKEN"; then
52
+ command -v identity_pin_log_mismatch >/dev/null 2>&1 && identity_pin_log_mismatch "$SESSION_ID" "recall-episode" || true
53
+ exit 0
54
+ fi
55
+ fi
56
+
57
+ BND_DIR="${ROOKIE_RECALL_EPISODE_DIR:-$HOME/.rookie/recall-episode}"
58
+
59
+ # transcript 를 episode[] JSON 으로 재구성. 회상 신호 정규식은 user-prompt-skill-gate.sh 와 정합(spec §3.2).
60
+ EPISODES=$(ROOKIE_TP="$TRANSCRIPT_PATH" ROOKIE_SID="$SESSION_ID" ROOKIE_BND_DIR="$BND_DIR" node -e '
61
+ try {
62
+ const fs=require("fs"), path=require("path"), crypto=require("crypto");
63
+ const tp=process.env.ROOKIE_TP, sid=process.env.ROOKIE_SID, bndDir=process.env.ROOKIE_BND_DIR;
64
+
65
+ // ── 회상 신호 정규식 (user-prompt-skill-gate.sh L59-70 정합) ──────────────
66
+ const PAST_TIME=/어제|그때|저번|지난번|예전|아까|(방금|(\d+|한|두|두어|세|네|몇)\s*(분|시간|일|주|주일|달|개월|년))\s*전(?=[\s,.!?)\]]|$|에|부터|까지|의|엔|은|는|이|가|도|만|과|와|랑)|(전|이전)에\s*(만든|만들었|했던|하던|쓰던|썼던|정한|결정한|작업한|구현한|짠)/;
67
+ const PAST_HOW=/어떻게[\s\S]{0,15}?(했|였|만들었|제작했|구현했|처리했|뽑았|짰|썼|캡[처쳐]했)(?!으면|으려|으실|으세|었으면)|어떻게\s*(하더라|했더라|됐)/;
68
+ const REPRO=/재현|기억\s*나(?!면|다면|시|실|셔|세)|했었(?!으면)|했잖|하지\s*않았(?:나|어|었)/;
69
+ const AMNESIA=/기억(을|은|이)?\s*(못|안)\s*(하|해|되|나|난)(?!면|다면|시|실|셔|세)|까먹(?!지)|처음\s*(인\s*것|인|것)\s*처럼|또\s*(잊(?!지)|까먹(?!지))/;
70
+ const CONTINUE=/이어서|이어가|계속\s*(하|해|진행)|마저|어디까지|재개|하던\s*(거|것|작업)/;
71
+ const DECIDED=/뭐였|뭐더라|왜\s*였|왜\s*그랬|어떻게\s*됐|결정했|정했었/;
72
+ const ENVQ=/(포트|유저명|계정|경로|도메인|호스트|엔드포인트|서버\s*주소)[^\n]{0,12}(뭐|몇|어디|알아|기억|였)/;
73
+ const isSignal=(t)=>PAST_TIME.test(t)||PAST_HOW.test(t)||REPRO.test(t)||CONTINUE.test(t)||DECIDED.test(t)||ENVQ.test(t)||AMNESIA.test(t);
74
+
75
+ // ── compact/clear boundary offsets (이 transcript 것만, 정렬) ──────────────
76
+ let boundaries=[];
77
+ try {
78
+ const key=crypto.createHash("sha256").update(sid).digest("hex");
79
+ const raw=fs.readFileSync(path.join(bndDir,key+".boundaries"),"utf8");
80
+ for(const line of raw.split("\n")){ const s=line.trim(); if(!s) continue;
81
+ try{ const b=JSON.parse(s); if(b && b.transcript_path===tp && typeof b.offset_bytes==="number") boundaries.push(b.offset_bytes); }catch{} }
82
+ boundaries.sort((a,b)=>a-b);
83
+ } catch {}
84
+ // episode 시작 offset 이후 처음 나오는 boundary (episode 를 그 지점에서 강제 종료).
85
+ const firstBoundaryAfter=(start)=>{ for(const b of boundaries){ if(b>start) return b; } return Infinity; };
86
+
87
+ // ── transcript 순차 파싱 (byte offset 추적) ──────────────
88
+ const buf=fs.readFileSync(tp);
89
+ const full=buf.toString("utf8");
90
+ const rawLines=full.split("\n");
91
+ const episodes=[];
92
+ let cur=null; // 현재 episode
93
+ let seq=0;
94
+ let off=0; // 현재 라인 시작 byte offset
95
+ const userText=(m)=>{
96
+ const c=m.content;
97
+ if(typeof c==="string") return c.trim();
98
+ if(Array.isArray(c)) return c.filter(b=>b&&b.type==="text"&&typeof b.text==="string").map(b=>b.text).join("\n").trim();
99
+ return "";
100
+ };
101
+ const closeIfPastBoundary=(lineOff)=>{ if(cur && lineOff>=cur._boundary){ cur=null; } };
102
+
103
+ for(const raw of rawLines){
104
+ const lineOff=off;
105
+ off+=Buffer.byteLength(raw,"utf8")+1; // +1 = split 로 사라진 \n
106
+ const t=raw.trim(); if(!t) continue;
107
+ let j; try{ j=JSON.parse(t); }catch{ continue; }
108
+ const m=j.message||j;
109
+ const role=m&&m.role;
110
+
111
+ // boundary 를 넘어선 라인은 이전 episode 에 귀속시키지 않는다.
112
+ closeIfPastBoundary(lineOff);
113
+
114
+ // recall 응답의 verified_solution 판정(codex P1) — recall 호출 이후 + "tool_result 성격 라인"에서만
115
+ // `"verified_solution"` JSON key 를 스캔한다. 일반 대화(text 블록)의 단어 언급은 tool_result 가
116
+ // 아니라 배제된다. transcript shape 다양성 커버: ① user content[] 의 tool_result 블록
117
+ // ② top-level toolUseResult 필드 ③ type==="tool_result" 라인. (MCP recall 응답도 results+meta 를
118
+ // stringify 해 이 JSON key 를 남긴다 — packages/rookie-mcp/src/tools.js.)
119
+ if(cur && cur.recall_called && !cur.verified_solution_served){
120
+ // raw 라인 t 는 JSON 이스케이프(\"verified_solution\")라 key 패턴이 안 맞는다 → tool_result 의
121
+ // **파싱된** 필드만 모아 스캔(이스케이프 해제 상태). 이게 tool_result 한정 + JSON key 정확도를 동시에 준다.
122
+ let probe="";
123
+ const cc=m&&m.content;
124
+ if(Array.isArray(cc)){
125
+ for(const b of cc){
126
+ if(b && b.type==="tool_result"){
127
+ probe += typeof b.content==="string" ? b.content
128
+ : Array.isArray(b.content) ? b.content.map(x=>x&&typeof x.text==="string"?x.text:"").join(" ")
129
+ : JSON.stringify(b.content||"");
130
+ }
131
+ }
132
+ }
133
+ if(j.toolUseResult!==undefined) probe += " " + (typeof j.toolUseResult==="string"?j.toolUseResult:JSON.stringify(j.toolUseResult));
134
+ if(j.type==="tool_result" && j.content!==undefined) probe += " " + (typeof j.content==="string"?j.content:JSON.stringify(j.content));
135
+ if(probe && /"verified_solution"/.test(probe)) cur.verified_solution_served=true;
136
+ }
137
+
138
+ if(role==="user"){
139
+ const txt=userText(m);
140
+ if(!txt) continue; // tool_result-only user 라인 등은 프롬프트 아님
141
+ const sig=isSignal(txt), amn=AMNESIA.test(txt);
142
+ // 직전 회상 episode 의 "다음 프롬프트"가 기억무시 지적이면 표시(관련 기억 있었는데 되물음).
143
+ if(cur && amn && cur.recall_called) cur.amnesia_next_prompt=true;
144
+ if(sig){
145
+ cur={
146
+ episode_seq: seq++,
147
+ episode_start_byte_offset: lineOff,
148
+ recall_signal: true, recall_called: false, verified_solution_served: false,
149
+ external_search: false, amnesia_next_prompt: false,
150
+ first_action_after_recall: null, failure_classes: [],
151
+ _boundary: firstBoundaryAfter(lineOff),
152
+ _recallSeen: false,
153
+ };
154
+ episodes.push(cur);
155
+ }
156
+ // 회상 신호 아닌 user 프롬프트는 현재 episode 를 이어간다(별 처리 없음, spec §3.2).
157
+ continue;
158
+ }
159
+
160
+ if(role==="assistant" && cur){
161
+ const c=m.content;
162
+ const blocks=Array.isArray(c)?c:[];
163
+ let hadText=false, sawToolThisMsg=false;
164
+ for(const b of blocks){
165
+ if(!b||typeof b!=="object") continue;
166
+ if(b.type==="text" && typeof b.text==="string" && b.text.trim()) hadText=true;
167
+ if(b.type==="tool_use"){
168
+ sawToolThisMsg=true;
169
+ const name=String(b.name||"");
170
+ const cmd=b.input&&typeof b.input.command==="string"?b.input.command:"";
171
+ let action="other";
172
+ if(/recall/i.test(name) || /api\/rookie\/recall/.test(cmd)) action="recall";
173
+ else if(/^(WebSearch|WebFetch)$/.test(name)) action="external_search";
174
+ // 외부 Bash HTTP(curl/wget 로 밖에서 답 찾기)도 외부검색 — recall/내부 API·로컬 dev 는 제외(codex P1·P2).
175
+ else if(name==="Bash" && /\b(curl|wget)\b/.test(cmd) && /https?:\/\//.test(cmd) && !/api\/rookie/.test(cmd) && !/(localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(cmd)) action="external_search";
176
+ else if(/^(Edit|Write|NotebookEdit)$/.test(name)) action="edit";
177
+ else if(name==="AskUserQuestion") action="ask";
178
+ if(action==="recall"){ cur.recall_called=true; cur._recallSeen=true; }
179
+ if(action==="external_search"){ cur.external_search=true; }
180
+ // recall 이후의 첫 실질 행동을 1회만 기록.
181
+ if(cur._recallSeen && action!=="recall" && cur.first_action_after_recall===null){
182
+ cur.first_action_after_recall=action;
183
+ }
184
+ }
185
+ }
186
+ // recall 을 받고서 도구 없이 텍스트로만 답하면(=자체 지식/재조사) self_answer 근사.
187
+ if(cur._recallSeen && !sawToolThisMsg && hadText && cur.first_action_after_recall===null){
188
+ cur.first_action_after_recall="self_answer";
189
+ }
190
+ continue;
191
+ }
192
+
193
+ }
194
+
195
+ // ── 실패 클래스 판정 (프록시 — spec §2·§8) ──────────────
196
+ for(const e of episodes){
197
+ const fc=[];
198
+ if(e.recall_signal && !e.recall_called) fc.push("missed_recall");
199
+ if(e.recall_called && (e.first_action_after_recall==="external_search" || (e.external_search && !["consume","edit","ask"].includes(e.first_action_after_recall||""))))
200
+ fc.push("external_search_before_consumption");
201
+ // self_answer(도구 없이 텍스트 답변)는 정상 소비일 수도 있어 이탈로 단정하지 않는다(codex P1) —
202
+ // 명확한 이탈(외부검색)만 ignored 로 센다. 프록시 한계상 보수적으로(false positive < false negative).
203
+ if(e.verified_solution_served && e.first_action_after_recall==="external_search")
204
+ fc.push("ignored_verified_solution");
205
+ if(e.recall_called && e.amnesia_next_prompt) fc.push("user_correction_after_memory_available");
206
+ e.failure_classes=fc;
207
+ delete e._boundary; delete e._recallSeen;
208
+ }
209
+
210
+ // route schema 는 max 500 — 긴 세션이 400 으로 전량 드롭되지 않게 최근 500 episode 만 보낸다(codex P2).
211
+ process.stdout.write(JSON.stringify(episodes.slice(-500)));
212
+ } catch { process.stdout.write("[]"); }
213
+ ' 2>/dev/null)
214
+
215
+ [ -z "$EPISODES" ] && exit 0
216
+ [ "$EPISODES" = "[]" ] && exit 0
217
+
218
+ BODY=$(ROOKIE_SID="$SESSION_ID" ROOKIE_EP="$EPISODES" node -e 'try{process.stdout.write(JSON.stringify({session_id:process.env.ROOKIE_SID,episodes:JSON.parse(process.env.ROOKIE_EP)}))}catch{}' 2>/dev/null)
219
+ [ -z "$BODY" ] && exit 0
220
+
221
+ # 계측 POST — 세션 종료를 지연시키지 않게 background + fail-soft.
222
+ ( curl -sS --max-time 8 -X POST \
223
+ -H "Authorization: Bearer $TOKEN" \
224
+ -H "x-rookie-hostname: $(hostname)" \
225
+ -H "x-rookie-user-id: $USER_ID" \
226
+ -H "Content-Type: application/json" \
227
+ -d "$BODY" \
228
+ "$ENDPOINT/api/rookie/metric/recall-episode" >/dev/null 2>&1 || true ) &
229
+ disown 2>/dev/null || true
230
+
231
+ exit 0
@@ -0,0 +1,123 @@
1
+ #!/bin/bash
2
+ # 작업 착수 회상 보정 넛지 — spec docs/superpowers/specs/2026-07-22-pre-work-recall-gate.md §D3
3
+ # + 되묻기 전 회상 게이트 — docs/superpowers/plans/2026-07-24-ask-before-recall-gate.md
4
+ #
5
+ # 한 스크립트가 세 갈래를 맡는다:
6
+ # PostToolUse(Bash) → 명령이 실제 recall 호출이면 `.recalled` 마커 기록
7
+ # PreToolUse(Edit|Write) → 마커가 없으면(이 세션 recall 0회) 세션당 1회 넛지 주입
8
+ # PreToolUse(AskUserQuestion) → soft-deny(pass-토글): 1차 deny + 회상 재확인 지시, 재시도 통과
9
+ #
10
+ # ⚠️ Edit/Write 는 **보정 넛지**다(additionalContext = 다음 모델 입력 리마인더, 차단 아님).
11
+ # AskUserQuestion 만 permissionDecision deny — 질문은 사용자에게 나가는 순간이 사고라
12
+ # 사후 리마인더로는 못 막는다(2026-07-24: recall 해놓고 안 믿고 되물은 사고, codex R1 P1).
13
+ # "착수 *전*" 판정은 SKILL.md 의 작업 착수 전 회상 게이트(규범)가 맡는다 — 이 훅은 하한선.
14
+ #
15
+ # 한계(설계 명시): 세션 단위로만 본다. 세션 초반에 무관한 recall 1회를 했으면 이후 다른 기능을
16
+ # 수정해도 침묵한다. 대상 단위 판정은 규범 축 담당.
17
+ #
18
+ # IMPORTANT: set -e 금지. 차단 절대 X — 항상 exit 0.
19
+ set +e
20
+
21
+ # self-reference 방지: 차차 internal LLM 호출에서 발사된 hook 은 즉시 종료.
22
+ [ "${ROOKIE_INTERNAL_INVOCATION:-}" = "1" ] && exit 0
23
+
24
+ INPUT=$(cat 2>/dev/null) || exit 0
25
+ [ -z "$INPUT" ] && exit 0
26
+ command -v node >/dev/null 2>&1 || exit 0
27
+
28
+ GATE_DIR="${ROOKIE_RECALL_GATE_DIR:-$HOME/.rookie/recall-gate}"
29
+
30
+ # payload 는 tool_response 까지 포함해 커질 수 있다 — argv 금지, stdin 으로 넘긴다(ARG_MAX).
31
+ printf '%s' "$INPUT" | ROOKIE_GATE_DIR="$GATE_DIR" node -e '
32
+ let raw="";
33
+ process.stdin.on("data",c=>raw+=c).on("end",()=>{
34
+ const MSG="✋ 이 세션에서 recall 을 한 번도 안 한 채 파일 수정에 들어갑니다. 이 파일·기능이 전에 하던 작업과 이어지는 것이라면 GET /api/rookie/recall 로 먼저 확인하세요 — 이미 고친 걸 다시 고치는 사고의 방지선입니다.";
35
+ try{
36
+ const fs=require("fs"), path=require("path"), crypto=require("crypto");
37
+ const gateDir=process.env.ROOKIE_GATE_DIR;
38
+ let j; try{ j=JSON.parse(raw); }catch{ return; }
39
+ if(!j || typeof j!=="object") return;
40
+
41
+ const sid=String(j.session_id||j.sessionId||process.env.CLAUDE_SESSION_ID||"");
42
+ if(!sid) return; // sid 없음 → 미주입(공유 마커로 영구 침묵하는 것보다 안전)
43
+ const key=crypto.createHash("sha256").update(sid).digest("hex");
44
+ const tool=j.tool_name;
45
+ const input=j.tool_input||{};
46
+
47
+ // 마커 원자적 생성 — 같은 batch 에 Edit 가 병렬로 여러 개 와도 한 번만 통과한다.
48
+ const claim=(name)=>{
49
+ try{
50
+ fs.mkdirSync(gateDir,{recursive:true,mode:0o700});
51
+ fs.writeFileSync(path.join(gateDir,key+name),JSON.stringify({session_id:sid}),{flag:"wx",mode:0o600});
52
+ return true;
53
+ }catch{ return false; } // 이미 있음(EEXIST) 또는 쓰기 실패 → 통과 못 함
54
+ };
55
+ const has=(name)=>{ try{ return fs.existsSync(path.join(gateDir,key+name)); }catch{ return false; } };
56
+
57
+ // ── recall 마커 기록 ────────────────────────────────────────────────
58
+ // MCP 경로(rookie_recall 툴)도 실제 회상이다 — 툴 이름으로 인정(codex 코드 R1 P2).
59
+ if(/recall/i.test(String(tool||""))){ claim(".recalled"); return; }
60
+
61
+ // PostToolUse(Bash): 실제 recall 호출만 인정.
62
+ // "api/rookie/recall" 문자열만 보면 rg 검색·문서 열람도 회상으로 세어지고(설계 R2 P2),
63
+ // curl 동반만 요구하면 `rg "curl.*api/rookie/recall"`·`echo curl …` 도 통과한다(코드 R1 P2).
64
+ // → curl 이 **명령어 자리**에 있어야 하고, 명령이 검색·출력 도구로 시작하면 배제한다.
65
+ // 남는 한계(허용): 셸 파서가 아니라 문자열 스캔이라, heredoc·따옴표 안의 "&& curl …/recall" 은
66
+ // 실제 호출 없이 마커를 만든다. 넛지 1건을 놓치는 비용뿐이고 셸 파싱 없이는 못 가른다.
67
+ if(tool==="Bash"){
68
+ const cmd=String(input.command||"");
69
+ // 구분자 뒤 env 대입(TOKEN=x)·sudo 옵션·개행까지 허용 — 전부 실제 command-position curl 이다(코드 R2 P2).
70
+ const curlIsCommand=/(^|[;&|(\n]|&&|\|\|)[ \t]*(?:\w+=\S*[ \t]+)*(?:sudo(?:[ \t]+-\w+)*[ \t]+)*curl\b/.test(cmd);
71
+ const startsWithProbe=/^\s*(rg|grep|egrep|fgrep|ag|ack|echo|printf|cat|less|more|head|tail|sed|awk|find)\b/.test(cmd);
72
+ if(curlIsCommand && !startsWithProbe && /api\/rookie\/recall/.test(cmd)) claim(".recalled");
73
+ return; // Bash 는 넛지 대상 아님
74
+ }
75
+
76
+ // ── PreToolUse(AskUserQuestion): soft-deny pass-토글 ────────────────────
77
+ // 되묻기는 나가는 순간이 사고 — 1차는 deny + 회상 재확인 지시, 재시도(.ask-pass 소비)는 통과.
78
+ // plan 2026-07-24 §4.1. deny 는 이 정상 판정 경로에서만 — 예외·IO 실패는 전부 통과(fail-safe).
79
+ if(tool==="AskUserQuestion"){
80
+ const DENY_A="✋ 되묻기 전에 회상부터: 이 세션에서 recall 을 한 번도 안 했습니다. 물으려는 내용으로 GET /api/rookie/recall 을 먼저 호출해 관련 있는 최신 결과가 있는지 확인하세요. 없거나 0건이면 그대로 다시 AskUserQuestion 을 호출하세요 — 이번 한 번만 멈춥니다.";
81
+ const DENY_B="✋ 이 세션에서 이미 recall 을 했습니다. 지금 물으려는 내용의 답이 그 회상 결과에 이미 있진 않은지 다시 보세요 — 단, 그 결과가 이 주제와 무관하거나 0건이었다면 이 주제로 재-recall 하세요. 회상해 놓고 안 믿고 되묻는 것이 \"처음인 것처럼\" 사고의 핵심입니다. 확인 후에도 답이 없으면 그대로 다시 AskUserQuestion 을 호출하세요 — 이번 한 번만 멈춥니다.";
82
+ const passPath=path.join(gateDir,key+".ask-pass");
83
+ const NOT_BEFORE=2000; // 같은 batch 병렬 형제가 방금 만든 pass 즉시 소비 방지(codex R2 P2)
84
+ const TTL=10*60*1000; // deny 후 재시도 없이 방치된 stale pass 무검사 통과 방지(codex R2 P2)
85
+ let st=null; try{ st=fs.statSync(passPath); }catch{}
86
+ if(st){
87
+ const age=Date.now()-st.mtimeMs;
88
+ if(age>=NOT_BEFORE && age<=TTL){
89
+ // unlink 성공 = 1회 통과권의 원자적 소비(코드검토 R1 P2). ENOENT 만 "형제가 먼저
90
+ // 소비" — pass 없는 상태로 재판정(아래 발급+deny). 그 외(EACCES·EROFS 등 실제 IO
91
+ // 실패)는 소비 불가능한 pass 가 남아 deny 가 무한 반복된다 → fail-safe 통과(R2 P1).
92
+ try{ fs.unlinkSync(passPath); return; }
93
+ catch(e){ if(e&&e.code==="ENOENT"){ st=null; } else return; }
94
+ }
95
+ else if(age>TTL){
96
+ // stale 폐기도 동일 — 못 지우는 stale pass 를 남긴 채 deny 하면 영구 봉쇄(R2 P1).
97
+ try{ fs.unlinkSync(passPath); st=null; }
98
+ catch(e){ if(e&&e.code==="ENOENT"){ st=null; } else return; }
99
+ }
100
+ // age<NOT_BEFORE: 병렬 형제 — pass 는 남기고 이 호출도 deny (아래로)
101
+ }
102
+ if(!st){
103
+ // pass 발급(wx). 발급 실패 + 실존도 안 하면(gate dir 권한·FS 오류) deny 금지 —
104
+ // 재시도 통과권 없는 deny 는 AskUserQuestion 을 영구 봉쇄한다(코드검토 R1 P1). fail-safe 통과.
105
+ let issued=false;
106
+ try{ fs.mkdirSync(gateDir,{recursive:true,mode:0o700}); fs.writeFileSync(passPath,JSON.stringify({session_id:sid}),{flag:"wx",mode:0o600}); issued=true; }catch{}
107
+ if(!issued && !has(".ask-pass")) return;
108
+ }
109
+ const reason=has(".recalled")?DENY_B:DENY_A;
110
+ process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:reason}}));
111
+ return;
112
+ }
113
+
114
+ // ── PreToolUse(Edit|Write): recall 0회면 세션당 1회 넛지 ────────────────
115
+ if(tool!=="Edit" && tool!=="Write" && tool!=="NotebookEdit") return;
116
+ if(has(".recalled")) return; // 이 세션에서 이미 회상함 → 조용
117
+ if(!claim(".nudged")) return; // 이미 넛지함(또는 마커 못 씀) → 조용
118
+ process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:MSG}}));
119
+ }catch{ /* silent — fail-safe. tool 흐름 차단 절대 X */ }
120
+ });
121
+ ' 2>/dev/null
122
+
123
+ exit 0
@@ -103,6 +103,15 @@ codex_recover() {
103
103
  if command -v identity_pin_check >/dev/null 2>&1 && ! identity_pin_check "$sid" "$token"; then
104
104
  _orphan_log_telemetry "$sid" "codex_skip_identity_mismatch"; continue
105
105
  fi
106
+ # spec 2026-07-20-codex-exec-prompt-pollution §4.2.1 — 격리 게이트(위 cwd·config·identity)를
107
+ # **통과한 뒤에만** 소비 완료 처리한다. 앞에서 marker 를 박으면 타 계정 소유 세션을 현재
108
+ # config 가 소비해버려 B6-4 를 깬다. 반대로 marker 없이 continue 하면 매 sweep 재후보가 되어
109
+ # 최신순+cap 아래 사람 세션(Desktop·tui) 회수를 굶긴다.
110
+ # 재시도 대상이 아니므로 pending·deferred 는 남기지 않는다.
111
+ if transcript_codex_is_exec "$fp"; then
112
+ orphan_mark_baseline "$sid" "codex_exec"
113
+ _orphan_log_telemetry "$sid" "codex_skip_agent_invocation"; continue
114
+ fi
106
115
  messages="$(transcript_messages_json_codex "$fp")"
107
116
  mc="$(_orphan_messages_count "$messages")"
108
117
  if [ "${mc:-0}" -le 0 ] 2>/dev/null; then