#!/usr/bin/env node /** * project-context-injector.js · R3 · 2026-04-26 * * UserPromptSubmit Hook · 项目级稳定上下文自动注入 * * 触发条件 (全部满足): * 1. cwd 是项目根 (含 .git/package.json/pyproject.toml/go.mod/Cargo.toml/CLAUDE.md 之一) * 2. /.bookworm-context.md 文件存在 * 3. 本会话尚未为该项目注入过 (per-session-per-project 去重) * * 注入内容: .bookworm-context.md 头 100 行 (可被文件首行 `` 覆盖) * * 行为约束: * - 始终 exit 0 (fail-open, 不阻断 prompt) * - 失败/无文件时无输出 * - 单次 IO ≤ 50KB, 超大文件被截断 * - 去重缓存路径: ~/.claude/session-state/project-context-injected.json */ 'use strict'; const fs = require('fs'); const path = require('path'); const CLAUDE_ROOT = require('./lib/root.js'); const readStdin = require('./lib/read-stdin.js'); const STATE_DIR = path.join(CLAUDE_ROOT, 'session-state'); const CACHE_PATH = path.join(STATE_DIR, 'project-context-injected.json'); const CONTEXT_FILENAME = '.bookworm-context.md'; const MAX_BYTES = 50 * 1024; const DEFAULT_MAX_LINES = 100; const ROOT_MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'CLAUDE.md']; function isProjectRoot(dir) { for (const m of ROOT_MARKERS) { if (fs.existsSync(path.join(dir, m))) return true; } return false; } function loadCache() { try { if (!fs.existsSync(CACHE_PATH)) return {}; const raw = fs.readFileSync(CACHE_PATH, 'utf8'); return JSON.parse(raw) || {}; } catch { return {}; } } function saveCache(cache) { try { if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); const tmp = CACHE_PATH + '.tmp.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), 'utf8'); fs.renameSync(tmp, CACHE_PATH); } catch {} } function pruneStaleCache(cache) { const now = Date.now(); const TTL = 7 * 24 * 3600 * 1000; for (const k of Object.keys(cache)) { if (!cache[k] || !cache[k].ts || now - cache[k].ts > TTL) delete cache[k]; } return cache; } (async () => { try { let hookData = {}; try { hookData = await readStdin(); } catch {} const cwd = hookData.cwd || process.cwd(); if (!cwd || !isProjectRoot(cwd)) { process.exit(0); } const ctxPath = path.join(cwd, CONTEXT_FILENAME); if (!fs.existsSync(ctxPath)) { process.exit(0); } // R3-FALLBACK-V2: 无 session_id 直接放弃, 防 'unknown-session' 跨会话缓存污染 if (!hookData.session_id) process.exit(0); const sessionId = hookData.session_id; const cacheKey = sessionId + '::' + cwd; const cache = pruneStaleCache(loadCache()); if (cache[cacheKey]) { process.exit(0); } let raw = fs.readFileSync(ctxPath, 'utf8'); if (Buffer.byteLength(raw, 'utf8') > MAX_BYTES) { raw = raw.slice(0, MAX_BYTES); } let maxLines = DEFAULT_MAX_LINES; const m = raw.match(/^/); if (m) maxLines = Math.min(parseInt(m[1], 10) || DEFAULT_MAX_LINES, 500); const lines = raw.split(/\r?\n/); const truncated = lines.length > maxLines; const body = lines.slice(0, maxLines).join('\n'); const tail = truncated ? '\n\n... [项目上下文截断, 完整内容见 ' + CONTEXT_FILENAME + ' (' + lines.length + ' 行)]' : ''; const additionalContext = '[PROJECT_CONTEXT · ' + path.basename(cwd) + ']\n' + '源文件: ' + ctxPath + '\n' + '─────────────────────────────────────\n' + body + tail; cache[cacheKey] = { ts: Date.now(), ctxPath: ctxPath, lines: lines.length }; saveCache(cache); process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true, hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: additionalContext } })); process.exit(0); } catch { process.exit(0); } })();