From a4b05247d84be752028efacd560d489fb8535abc Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Tue, 3 Nov 2026 10:08:51 +0800 Subject: [PATCH] fix: package caveman codex session hook --- README.md | 2 +- config/external-sources.json | 19 +- .../plugins/caveman/.codex-plugin/plugin.json | 2 +- .../caveman/.codex/codex-sessionstart.js | 210 +++++ .../codex/plugins/caveman/hooks/hooks.json | 2 +- .../caveman/src/hooks/caveman-config.js | 880 ++++++++++++++++++ scripts/sync_external_plugins.ps1 | 1 + 7 files changed, 1112 insertions(+), 4 deletions(-) create mode 100644 plugins/codex/plugins/caveman/.codex/codex-sessionstart.js create mode 100644 plugins/codex/plugins/caveman/src/hooks/caveman-config.js diff --git a/README.md b/README.md index 35237a0d..53696e33 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ 如果插件刚安装到当前 Codex 会话中,启动新的 Codex thread 或新的 Codex 调用后再依赖新增 skills/hooks/MCP/app 配置。 -`caveman` 包含 Codex `SessionStart` hook,信任并启用后会在启动、恢复会话时注入精简表达模式。可要求 `normal mode` 停用当前模式;恢复会话时上游 hook 会再次注入。需要持续关闭时,请在 Codex 中禁用该 hook 或插件。 +`caveman` 包含 Codex `SessionStart` hook,需要 Node.js。信任并启用后,启动或恢复会话时会根据配置注入默认模式,未配置时默认为 Caveman。可要求 `normal mode` 停用当前模式;需要持续关闭时,可设置 `CAVEMAN_DEFAULT_MODE=off` 或在 Codex 中禁用该 hook 或插件。 卸载插件: diff --git a/config/external-sources.json b/config/external-sources.json index 7e1a8049..436efac8 100644 --- a/config/external-sources.json +++ b/config/external-sources.json @@ -587,6 +587,8 @@ "sourcePath": "plugins/caveman", "sparseCheckout": [ ".codex/hooks.json", + ".codex/codex-sessionstart.js", + "src/hooks/caveman-config.js", "package.json", "plugins/caveman", "skills/caveman-commit/SKILL.md", @@ -612,6 +614,14 @@ "source": ".codex/hooks.json", "destination": "hooks/hooks.json" }, + { + "source": ".codex/codex-sessionstart.js", + "destination": ".codex/codex-sessionstart.js" + }, + { + "source": "src/hooks/caveman-config.js", + "destination": "src/hooks/caveman-config.js" + }, { "source": "skills/caveman-commit/SKILL.md", "destination": "skills/caveman-commit/SKILL.md" @@ -645,6 +655,13 @@ "destination": "skills/verify-and-stop" } ], + "pluginTextReplacements": [ + { + "path": "hooks/hooks.json", + "old": "$(git rev-parse --show-toplevel)/.codex/codex-sessionstart.js", + "new": "${PLUGIN_ROOT}/.codex/codex-sessionstart.js" + } + ], "manifestVersionPath": "package.json", "manifestOverrides": { "hooks": "./hooks/hooks.json", @@ -652,7 +669,7 @@ "interface": { "displayName": "Caveman", "shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。", - "longDescription": "Caveman 提供精简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。包含 Codex SessionStart hook:信任并启用后,在启动或恢复会话时注入 Caveman 模式;可要求 normal mode 停用,但恢复会话会再次注入。另含可选的 caveman-compress,需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooks、statusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。", + "longDescription": "Caveman 提供精简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。包含 Codex SessionStart hook:需要 Node.js,信任并启用后在启动或恢复会话时按配置注入默认模式,默认 Caveman;可通过 CAVEMAN_DEFAULT_MODE=off 或配置关闭。另含可选的 caveman-compress,需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooks、statusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。", "developerName": "Julius Brussee", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/codex/plugins/caveman/.codex-plugin/plugin.json b/plugins/codex/plugins/caveman/.codex-plugin/plugin.json index 8e55c731..eb3912ff 100644 --- a/plugins/codex/plugins/caveman/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/caveman/.codex-plugin/plugin.json @@ -19,7 +19,7 @@ "interface": { "displayName": "Caveman", "shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。", - "longDescription": "Caveman 提供精简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。包含 Codex SessionStart hook:信任并启用后,在启动或恢复会话时注入 Caveman 模式;可要求 normal mode 停用,但恢复会话会再次注入。另含可选的 caveman-compress,需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooks、statusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。", + "longDescription": "Caveman 提供精简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。包含 Codex SessionStart hook:需要 Node.js,信任并启用后在启动或恢复会话时按配置注入默认模式,默认 Caveman;可通过 CAVEMAN_DEFAULT_MODE=off 或配置关闭。另含可选的 caveman-compress,需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooks、statusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。", "developerName": "Julius Brussee", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/codex/plugins/caveman/.codex/codex-sessionstart.js b/plugins/codex/plugins/caveman/.codex/codex-sessionstart.js new file mode 100644 index 00000000..916cda15 --- /dev/null +++ b/plugins/codex/plugins/caveman/.codex/codex-sessionstart.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// Codex SessionStart hook (#185). +// +// The repo-local `.codex/hooks.json` used to hardcode a single static echo of +// the `full`-level rules, so `CAVEMAN_DEFAULT_MODE`, a repo-local +// `.caveman/config.json` / `.caveman.json`, or a user config `defaultMode` +// had no effect on Codex sessions — and an `off` default still injected +// rules. This script replaces the hardcoded echo and resolves the mode the +// same way the Claude SessionStart hook does, through +// `src/hooks/caveman-config.js`. +// +// The command in `.codex/hooks.json` resolves this file from the git root +// (`$(git rev-parse --show-toplevel)/.codex/...`) because Codex runs command +// hooks with the SESSION cwd as their working directory and can be launched +// from a subdirectory (Codex hooks docs: "prefer resolving from the git root +// instead of using a relative path"). Discovery of `.codex/hooks.json` is +// git-root based, so cwd may legitimately differ from the manifest's home; +// `__dirname` below is what keeps script-relative resolution stable either +// way. +// +// Keep this fail-open and cheap: any error prints nothing and exits 0, so a +// broken install can never block a Codex session. Never call process.exit() +// after the ruleset write — pipe stdout is asynchronous and process.exit() +// can drop pending output, leaving caveman silently unactivated. Exit by +// falling off the end of the script instead (exit code 0 throughout). +// +// Resolution order for default mode (identical to caveman-config.js): +// 1. CAVEMAN_DEFAULT_MODE environment variable +// 2. Repo-local config: /.caveman/config.json or /.caveman.json, +// walking up to the filesystem root +// 3. User config: $XDG_CONFIG_HOME/caveman/config.json, then +// ~/.config/caveman/config.json (macOS/Linux) or +// %APPDATA%\caveman\config.json then ~/.config/caveman/config.json +// (Windows) +// 4. 'caveman' +// +// Deliberate non-goal (#185 scope): this hook resolves the CONFIGURED default +// only. It does not read the legacy `~/.claude/.caveman-active` mirror or the +// per-session store, so a mode switched mid-session is not resumed here — +// Codex has no tracker hook wired in this repo-local setup, so there is +// nothing session-scoped to read without guessing at another host's state. + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Hand-copy of caveman-config.js VALID_MODES, used only when that module is +// unavailable. tests/test_hook_missing_sibling.js asserts the Claude hook's +// copy stays equal to the real one; tests/hooks/codex-sessionstart.test.mjs +// asserts this copy does too. +const FALLBACK_VALID_MODES = [ + 'off', 'caveman', 'ultracave', 'megacave', + 'commit', 'review', 'compress' +]; +// Hand-copy of caveman-config.js LEGACY_MODES: a config still naming a +// pre-three-skill level resolves the same way when degraded. +const FALLBACK_LEGACY_MODES = { + lite: 'caveman', full: 'caveman', ultra: 'ultracave', + wenyan: 'megacave', 'wenyan-lite': 'megacave', + 'wenyan-full': 'megacave', 'wenyan-ultra': 'megacave', +}; +// Default-mode sources also accept the 'manual' startup policy, like the real +// resolver's VALID_DEFAULT_MODES. +function fallbackCanonicalMode(raw) { + if (typeof raw !== 'string') return null; + const m = raw.toLowerCase(); + if (FALLBACK_VALID_MODES.includes(m) || m === 'manual') return m; + return Object.prototype.hasOwnProperty.call(FALLBACK_LEGACY_MODES, m) ? FALLBACK_LEGACY_MODES[m] : null; +} + +// Modes that inject nothing: the user opted out (`off`) or starts inactive +// (`manual`), and rules must not be force-injected (caveman-activate.js treats +// both the same way on the Claude side). +const SILENT_MODES = new Set(['off', 'manual']); + +// Modes with their own independent skill files — not caveman prose modes. +// The Claude hook emits a one-line pointer for them; so does this. +const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + +// Hand-copy of caveman-config.js readModeFromConfigFile, used only when that +// module is unavailable. Refuses symlinked config files, symmetric with the +// real resolver's findRepoConfigPath/readModeFromConfigFile policy. +function fallbackReadMode(file) { + try { + if (!fs.lstatSync(file).isFile()) return null; + // A BOM or UTF-16LE (PowerShell 5.1) must not drop an opt-out here either. + const buf = fs.readFileSync(file); + const text = buf[0] === 0xff && buf[1] === 0xfe ? buf.toString('utf16le', 2) : buf.toString('utf8').replace(/^\uFEFF/, ''); + return fallbackCanonicalMode(JSON.parse(text).defaultMode); + } catch (e) { /* absent, unreadable, or malformed → next source */ } + return null; +} + +function fallbackUserConfigMode() { + let userConfigPath; + if (process.env.XDG_CONFIG_HOME) { + userConfigPath = path.join(process.env.XDG_CONFIG_HOME, 'caveman', 'config.json'); + } else if (process.platform === 'win32') { + userConfigPath = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'caveman', 'config.json'); + // Then ~/.config, the path the docs give for every platform. + return fallbackReadMode(userConfigPath) + || fallbackReadMode(path.join(os.homedir(), '.config', 'caveman', 'config.json')); + } else { + userConfigPath = path.join(os.homedir(), '.config', 'caveman', 'config.json'); + } + return fallbackReadMode(userConfigPath); +} + +// Minimal stand-in for caveman-config.getDefaultMode. Mirrors the real +// resolution order rather than reading only the env var — a degrade that +// ignores a user config saying `defaultMode: "off"` would INVERT the user's +// intent by force-injecting rules, the same reasoning caveman-activate.js +// documents for its own fallback. +function fallbackGetDefaultMode(startDir) { + // 1. Environment variable. No .trim() — the real resolver does not trim, + // and a degraded path accepting " ultra" where the intact one rejects it + // is drift in a whitelist. + const envMode = fallbackCanonicalMode(process.env.CAVEMAN_DEFAULT_MODE); + if (envMode) return envMode; + // 2. Repo-local config, walking up. Bounded at 64 like findRepoConfigPath. + try { + let dir = path.resolve(startDir || process.cwd()); + for (let i = 0; i < 64; i++) { + for (const rel of ['.caveman/config.json', '.caveman.json']) { + const mode = fallbackReadMode(path.join(dir, rel)); + if (mode) return mode; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } catch (e) { /* fall through to user config */ } + // 3. User config, then 4. the built-in default. + return fallbackUserConfigMode() || 'caveman'; +} + +// The hook dir is `/.codex`, so the shared resolver lives one level up. +// Resolve it once; anything missing just degrades to the hand-copied logic. +const hookDir = __dirname; +let shared = null; +try { + // eslint-disable-next-line global-require + shared = require(path.join(hookDir, '..', 'src', 'hooks', 'caveman-config.js')); +} catch (e) { + try { + // Standalone install layout: hooks live beside the lib instead. + // eslint-disable-next-line global-require + shared = require(path.join(hookDir, 'caveman-config.js')); + } catch (e2) { + shared = null; + } +} + +function main() { + const getDefaultMode = (shared && typeof shared.getDefaultMode === 'function') + ? shared.getDefaultMode + : fallbackGetDefaultMode; + const VALID_MODES = (shared && Array.isArray(shared.VALID_MODES)) ? shared.VALID_MODES : FALLBACK_VALID_MODES; + + const mode = getDefaultMode(); + if (!VALID_MODES.includes(mode) || SILENT_MODES.has(mode)) { + return; + } + + if (INDEPENDENT_MODES.has(mode)) { + process.stdout.write('CAVEMAN MODE ACTIVE — mode: ' + mode + '. Behavior defined by /caveman-' + mode + ' skill.'); + return; + } + + const loadRuleset = (shared && typeof shared.loadRuleset === 'function') + ? shared.loadRuleset + : null; + const rulesetBanner = (shared && typeof shared.rulesetBanner === 'function') + ? shared.rulesetBanner + : ((m) => 'CAVEMAN MODE ACTIVE — mode: ' + m); + + // SKILL.md lives at /skills//SKILL.md. skillPathCandidates() + // resolves the plugin layouts; a repo checkout is the first candidate it + // tries. Pass the shared resolver's own directory so the candidates resolve + // relative to src/hooks/, not this file. + const skillContent = loadRuleset + ? loadRuleset(mode, path.join(hookDir, '..', 'src', 'hooks')) + : null; + + let output; + if (skillContent) { + output = rulesetBanner(mode) + '\n\n' + skillContent; + } else { + // Degraded one-liner: name the mode and where its rules come from, so a + // missing SKILL.md degrades the same way on Codex as the Claude hook's + // fallback does. The old static echo's full text would go stale, which is + // the defect class this hook exists to end. + output = rulesetBanner(mode) + + '. Rules: skills/' + mode + '/SKILL.md. Switch: /caveman, /ultracave, /megacave.' + + ' Respond terse like smart caveman. All technical substance stay. Only fluff die.' + + ' Code/commits/security: write normal.'; + } + + process.stdout.write(output); +} + +try { + main(); +} catch (e) { + if (process.env.CAVEMAN_DEBUG === '1') { + process.stderr.write('[caveman] codex-sessionstart: ' + (e && e.message) + '\n'); + } + // Swallow: fail-open, exit 0 by falling off the end. Never process.exit() + // here — pending stdout above must still flush. +} diff --git a/plugins/codex/plugins/caveman/hooks/hooks.json b/plugins/codex/plugins/caveman/hooks/hooks.json index 32c9c235..b7ca38e4 100644 --- a/plugins/codex/plugins/caveman/hooks/hooks.json +++ b/plugins/codex/plugins/caveman/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"$(git rev-parse --show-toplevel)/.codex/codex-sessionstart.js\"", + "command": "node \"${PLUGIN_ROOT}/.codex/codex-sessionstart.js\"", "timeout": 5, "statusMessage": "Loading caveman mode" } diff --git a/plugins/codex/plugins/caveman/src/hooks/caveman-config.js b/plugins/codex/plugins/caveman/src/hooks/caveman-config.js new file mode 100644 index 00000000..ab49c0e6 --- /dev/null +++ b/plugins/codex/plugins/caveman/src/hooks/caveman-config.js @@ -0,0 +1,880 @@ +#!/usr/bin/env node +// caveman — shared configuration resolver +// +// KEEP THIS FILE LOADABLE WITHOUT THE MODULE LOADER. `src/plugins/opencode/ +// plugin.js` cannot `require()` a file from disk (compiled Bun binary), so it +// reads this file's source and evaluates it through `new Function(...)` with a +// `createRequire` rooted at this file. Node built-ins (fs/path/os) resolve +// there; anything heavier does not. Consequence for mode-state work: keep it +// HERE. Parsing already lives in caveman-parse.js (#602) and is loaded the same +// way, so a second parser copy is no longer a risk — but a THIRD home for the +// state primitives would be, because opencode would keep its own drifting +// version of them (the drift #602 was created to end). +// +// Resolution order for default mode: +// 1. CAVEMAN_DEFAULT_MODE environment variable +// 2. Repo-local config (checked-in, per-project default): +// - /.caveman/config.json +// - /.caveman.json +// Walks up from process.cwd() to the nearest file that names a +// defaultMode (stops at filesystem root). A file without one — e.g. a +// .caveman/config.json holding only CLI settings — is skipped. Lets a +// team pin a project's default mode without polluting every +// contributor's user-level config or env. +// 3. User config file defaultMode field: +// - $XDG_CONFIG_HOME/caveman/config.json (any platform, if set; only file) +// - ~/.config/caveman/config.json (macOS / Linux fallback) +// - %APPDATA%\caveman\config.json, then ~/.config/caveman/config.json +// (Windows fallback; the docs give the ~/.config path everywhere) +// 4. 'caveman' +// Config files may carry a BOM, be UTF-16LE, or hold comments and trailing +// commas (see parseConfigFile). + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// One stored id per skill: skills/caveman, skills/ultracave, skills/megacave, +// plus the one-shot modes and the durable off. +const VALID_MODES = [ + 'off', 'caveman', 'ultracave', 'megacave', + 'commit', 'review', 'compress' +]; + +// Startup policy only: manual starts inactive, but explicit activation uses +// caveman. Keep it out of flag validation and the selectable modes. +const VALID_DEFAULT_MODES = [...VALID_MODES, 'manual']; + +// The six intensity levels that preceded the three skills. Session files, +// .caveman-active mirrors, config files, env vars and mode-log rows written +// before the switch still hold these, so every read maps them forward and no +// write ever emits one. +const LEGACY_MODES = { + lite: 'caveman', full: 'caveman', ultra: 'ultracave', + wenyan: 'megacave', 'wenyan-lite': 'megacave', + 'wenyan-full': 'megacave', 'wenyan-ultra': 'megacave', +}; + +// Stored/configured value → current mode id, or null when it is neither a +// mode nor a legacy level. The whitelist every read path goes through. +function canonicalMode(raw) { + if (typeof raw !== 'string') return null; + const m = raw.toLowerCase(); + if (VALID_MODES.includes(m)) return m; + return Object.prototype.hasOwnProperty.call(LEGACY_MODES, m) ? LEGACY_MODES[m] : null; +} + +// Same, for default-mode sources, which also accept the 'manual' policy. +function canonicalDefaultMode(raw) { + if (typeof raw !== 'string') return null; + const m = raw.toLowerCase(); + return VALID_DEFAULT_MODES.includes(m) ? m : canonicalMode(m); +} + +// Legacy machine-wide flag. Kept as a last-write-wins MIRROR of whichever +// session wrote most recently, because INSTALL.md tells users to `cat` it and +// src/hooks/README.md ships a third-party statusline snippet that reads it. +// It never receives the literal string 'off' — see writeSessionMode. +const FLAG_BASENAME = '.caveman-active'; +const PREV_BASENAME = '.caveman-active.prev'; + +// Per-session state lives here, one small file per session id. Dot-prefixed to +// match the rest of the caveman namespace in $CLAUDE_CONFIG_DIR and to avoid +// colliding with Claude Code's own directories (projects/, hooks/, ...). +const SESSIONS_DIRNAME = '.caveman-sessions'; + +function getConfigDir() { + if (process.env.XDG_CONFIG_HOME) { + return path.join(process.env.XDG_CONFIG_HOME, 'caveman'); + } + if (process.platform === 'win32') { + return path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + 'caveman' + ); + } + return path.join(os.homedir(), '.config', 'caveman'); +} + +function getConfigPath() { + return path.join(getConfigDir(), 'config.json'); +} + +// Walk up from `start` looking for a repo-local caveman config. Returns the +// absolute path of the first match, or null. Stops at the filesystem root. +// Candidates per dir (first wins): .caveman/config.json, .caveman.json. +// `accept(path)`, when given, must also return true for a file to match. +// +// Bounded to 64 levels to defend against symlink cycles on pathological mounts. +function findRepoConfigPath(start, accept) { + try { + let dir = path.resolve(start || process.cwd()); + const candidates = ['.caveman/config.json', '.caveman.json']; + for (let i = 0; i < 64; i++) { + for (const rel of candidates) { + const p = path.join(dir, rel); + try { + const st = fs.lstatSync(p); + // Refuse symlinks — symmetric with safeWriteFlag/readFlag policy. + if (st.isSymbolicLink() || !st.isFile()) continue; + if (accept && !accept(p)) continue; + return p; + } catch (e) { + // not present, try next candidate + } + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } catch (e) { + // Defensive: any cwd / fs failure → no repo config + } + return null; +} + +// Config files are hand-edited, often on Windows: PowerShell 5.1's `>` writes +// UTF-16LE with a BOM, `Set-Content -Encoding UTF8` a UTF-8 BOM, and people +// leave comments and trailing commas. Accept all of them — the JSONC +// tolerance installer/lib/settings.js gives settings.json — inlined because +// this file may load only node built-ins (see the top of this file). +function parseConfigFile(configPath) { + const buf = fs.readFileSync(configPath); + const text = (buf[0] === 0xff && buf[1] === 0xfe ? buf.toString('utf16le', 2) : buf.toString('utf8')) + .replace(/^\uFEFF/, ''); + try { + return JSON.parse(text); + } catch (e) { + // Strings match first and are kept whole, so "//" or ",}" inside a value + // survives; outside strings, comments go and trailing commas go. + return JSON.parse(text + .replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, s) => s || '') + .replace(/("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, (m, s, close) => s || close)); + } +} + +function readModeFromConfigFile(configPath) { + try { + const config = parseConfigFile(configPath); + if (config && config.defaultMode) { + return canonicalDefaultMode(String(config.defaultMode)); + } + } catch (e) { + // Missing / unreadable / invalid JSON → caller falls through + } + return null; +} + +// startDir overrides the cwd the repo-config walk starts from (default +// process.cwd(), same as findRepoConfigPath's own fallback) — lets a caller +// resolve the mode for a directory other than its own process cwd (#634: +// the UserPromptSubmit hook's stdin carries the session's cwd, which can +// differ from the hook process's cwd). Every other resolution step is +// cwd-independent, so only the repo-config walk takes it. +function getDefaultMode(startDir) { + // 1. Environment variable (highest priority) + const envMode = canonicalDefaultMode(process.env.CAVEMAN_DEFAULT_MODE); + if (envMode) return envMode; + + // 2. Repo-local config (checked-in, per-project default). The first file + // that names a mode wins: .caveman/config.json is also the CLI's project + // overlay, and one without defaultMode must not hide a .caveman.json. + const repoConfigPath = findRepoConfigPath(startDir, (p) => readModeFromConfigFile(p) !== null); + if (repoConfigPath) return readModeFromConfigFile(repoConfigPath); + + // 3. User config file. Windows also reads ~/.config/caveman/config.json, + // the path the docs give for every platform, after %APPDATA%. + const userMode = readModeFromConfigFile(getConfigPath()) + || (process.platform === 'win32' && !process.env.XDG_CONFIG_HOME + ? readModeFromConfigFile(path.join(os.homedir(), '.config', 'caveman', 'config.json')) + : null); + if (userMode) return userMode; + + // 4. Default + return 'caveman'; +} + +// Symlink-safe flag file write. +// Uses O_NOFOLLOW where available, writes atomically via temp + rename with +// 0600 permissions. Protects against local attackers replacing the predictable +// flag path (~/.claude/.caveman-active) with a symlink to clobber other files. +// +// When the parent directory is itself a symlink (legitimate pattern: ~/.claude +// symlinked to another drive or shared config dir), resolves through to the +// real path and verifies ownership on Unix (uid match). This allows e.g. +// ln -s /opt/shared-claude-config ~/.claude +// while still refusing attacker-planted symlinks pointing to dirs owned by +// another user. +// +// On Windows, uid checks are unavailable — falls back to verifying the resolved +// path lives under the user's home directory. +// +// The flag file itself must never be a symlink (that's the actual clobber vector). +// +// Set CAVEMAN_DEBUG=1 to emit stderr diagnostics when flag writes are refused. +// +// Silent-fails on any filesystem error — the flag is best-effort. +// Blocking sleep with no child process and no busy-wait. Used only to space +// out rename retries under Windows lock contention. +function sleepMs(ms) { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch (e) { /* SharedArrayBuffer unavailable — skip the backoff */ } +} + +function safeWriteFlag(flagPath, content) { + const debug = process.env.CAVEMAN_DEBUG === '1'; + try { + const flagDir = path.dirname(flagPath); + fs.mkdirSync(flagDir, { recursive: true }); + + // When the parent directory is a symlink, resolve it and verify ownership. + // This allows legitimate symlinked ~/.claude dirs while still refusing + // attacker-planted symlinks pointing at dirs owned by another user. + let realFlagDir; + try { + const lstat = fs.lstatSync(flagDir); + if (lstat.isSymbolicLink()) { + realFlagDir = fs.realpathSync(flagDir); + const realStat = fs.statSync(realFlagDir); + if (!realStat.isDirectory()) { + if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} is not a directory\n`); + return; + } + if (typeof process.getuid === 'function') { + if (realStat.uid !== process.getuid()) { + if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} owned by uid ${realStat.uid}, not current user ${process.getuid()}\n`); + return; + } + } else { + // The home-prefix check used to live here, comparing the resolved + // symlink target against os.homedir(). That is the wrong proxy on + // win32: a directory junction (e.g. ~/.claude junctioned to another + // drive, the same "legitimate symlinked config dir" case this branch + // exists to allow) resolves to a realpath that does not start with + // the home directory, so the write was refused through the very + // junction this code's own comments say it should tolerate. Test + // what actually matters instead: can the current user write there. + try { + fs.accessSync(realFlagDir, fs.constants.W_OK); + } catch (e) { + if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} is not writable by current user\n`); + return; + } + } + } else { + realFlagDir = flagDir; + } + } catch (e) { + return; + } + + // The flag file itself must never be a symlink (that's the actual clobber vector). + const realFlagPath = path.join(realFlagDir, path.basename(flagPath)); + try { + if (fs.lstatSync(realFlagPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + + // tempPath is hoisted above the try so the finally below can always find + // it. On Windows, renameSync onto an existing target throws EPERM/EBUSY/ + // EACCES/EEXIST when another process (statusline read, a concurrent + // session's hook) holds the file open without FILE_SHARE_DELETE — + // without the retry + guaranteed cleanup here, every such miss left an + // orphaned .caveman-active.. file behind (#511/#578/#657). + let tempPath; + try { + tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`); + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(tempPath, flags, 0o600); + fs.writeSync(fd, String(content)); + try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + + // Retry brief lock contention a few times, backing off between attempts. + // Three synchronous attempts with no delay all complete inside a few + // microseconds, so whichever handle blocked the first (a statusline read, + // a concurrent session's hook, an AV scan) is still held for the other + // two — effectively one attempt with two extra syscalls. Sleep for real: + // Atomics.wait blocks this thread without pulling in a child process, + // which is what a hook budget can least afford. + let renamed = false; + for (let attempt = 0; attempt < 3; attempt++) { + try { + fs.renameSync(tempPath, realFlagPath); + renamed = true; + break; + } catch (e) { + const transient = e.code === 'EPERM' || e.code === 'EBUSY' || + e.code === 'EACCES' || e.code === 'EEXIST'; + if (!transient) throw e; + if (attempt < 2) sleepMs(10 * (attempt + 1)); + } + } + if (!renamed && debug) { + process.stderr.write('[caveman] safeWriteFlag: rename contended after 3 attempts; flag not updated this write\n'); + } + } finally { + if (tempPath) { + try { + fs.unlinkSync(tempPath); + } catch (error) { + if (debug && error.code !== 'ENOENT') { + process.stderr.write(`[caveman] safeWriteFlag: failed to remove temp flag ${tempPath}: ${error.message}\n`); + } + } + } + } + } catch (e) { + // Silent fail — flag is best-effort + } +} + +// Symlink-safe flag file delete. Symmetric with safeWriteFlag: resolves +// through a symlinked/junctioned parent the same way (ownership check on +// Unix, writable-parent check on win32) and refuses to touch a target that +// is itself a symlink, before unlinking. Without this, any caller that wants +// to clear the flag could delete straight through a junction that +// safeWriteFlag would refuse to write through in the first place — a +// write/delete asymmetry that lets the flag be destroyed but never +// recreated on that machine. +// +// Silent-fails on any filesystem error — the flag is best-effort. +function safeDeleteFlag(flagPath) { + const debug = process.env.CAVEMAN_DEBUG === '1'; + try { + const flagDir = path.dirname(flagPath); + + let realFlagDir; + try { + const lstat = fs.lstatSync(flagDir); + if (lstat.isSymbolicLink()) { + realFlagDir = fs.realpathSync(flagDir); + const realStat = fs.statSync(realFlagDir); + if (!realStat.isDirectory()) { + if (debug) process.stderr.write(`[caveman] safeDeleteFlag: symlink target ${realFlagDir} is not a directory\n`); + return; + } + if (typeof process.getuid === 'function') { + if (realStat.uid !== process.getuid()) { + if (debug) process.stderr.write(`[caveman] safeDeleteFlag: symlink target ${realFlagDir} owned by uid ${realStat.uid}, not current user ${process.getuid()}\n`); + return; + } + } else { + try { + fs.accessSync(realFlagDir, fs.constants.W_OK); + } catch (e) { + if (debug) process.stderr.write(`[caveman] safeDeleteFlag: symlink target ${realFlagDir} is not writable by current user\n`); + return; + } + } + } else { + realFlagDir = flagDir; + } + } catch (e) { + return; + } + + const realFlagPath = path.join(realFlagDir, path.basename(flagPath)); + try { + if (fs.lstatSync(realFlagPath).isSymbolicLink()) return; + } catch (e) { + return; + } + + fs.unlinkSync(realFlagPath); + } catch (e) { + // Silent fail — flag is best-effort + } +} + +// Symlink-safe, size-capped, whitelist-validated flag file read. +// Symmetric with safeWriteFlag: refuses symlinks at the target, caps the read, +// and rejects anything that isn't a known mode. Returns null on any anomaly. +// +// Without this, a local attacker with write access to ~/.claude/ could replace +// the flag with a symlink to ~/.ssh/id_rsa (or any user-readable secret). Every +// reader — statusline, per-turn reinforcement — would slurp that content and +// either echo it to the terminal or inject it into model context. +// +// MAX_FLAG_BYTES is a hard cap. The longest legitimate value is the legacy +// "wenyan-ultra" (12 bytes); 64 leaves slack without enabling exfil. +const MAX_FLAG_BYTES = 64; + +function readFlag(flagPath) { + try { + let st; + try { + st = fs.lstatSync(flagPath); + } catch (e) { + return null; + } + if (st.isSymbolicLink() || !st.isFile()) return null; + if (st.size > MAX_FLAG_BYTES) return null; + + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd; + let out; + try { + fd = fs.openSync(flagPath, flags); + const buf = Buffer.alloc(MAX_FLAG_BYTES); + const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); + out = buf.slice(0, n).toString('utf8'); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + + // Legacy levels come back as their current id, so every caller of + // readFlag (and the session helpers built on it) sees only VALID_MODES. + return canonicalMode(out.trim()); + } catch (e) { + return null; + } +} + +// Symlink-safe append. Same parent-dir + symlink-target rules as safeWriteFlag, +// but opens with O_APPEND so concurrent writers from different sessions don't +// clobber each other. Used for the lifetime stats log +// ($CLAUDE_CONFIG_DIR/.caveman-history.jsonl). +// +// Silent-fails on any filesystem error. +function appendFlag(filePath, line) { + const debug = process.env.CAVEMAN_DEBUG === '1'; + try { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + let realDir; + try { + const lstat = fs.lstatSync(dir); + if (lstat.isSymbolicLink()) { + realDir = fs.realpathSync(dir); + const realStat = fs.statSync(realDir); + if (!realStat.isDirectory()) return; + if (typeof process.getuid === 'function') { + if (realStat.uid !== process.getuid()) { + if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} owned by uid ${realStat.uid}\n`); + return; + } + } else { + // Same reasoning as safeWriteFlag's win32 branch: a directory + // junction resolves to a realpath with no home-directory prefix, so + // the old check refused the legitimate junctioned config dir it was + // meant to allow — and silently stopped the lifetime stats log from + // recording anything. Test what the guard actually cares about. + try { + fs.accessSync(realDir, fs.constants.W_OK); + } catch (e) { + if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} is not writable by current user\n`); + return; + } + } + } else { + realDir = dir; + } + } catch (e) { + return; + } + + const realPath = path.join(realDir, path.basename(filePath)); + try { + if (fs.lstatSync(realPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(realPath, flags, 0o600); + fs.writeSync(fd, String(line).replace(/\n$/, '') + '\n'); + try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + } catch (e) { + // Silent fail — history is best-effort + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-session mode state +// +// The mode is applied per session but used to be stored in ONE file per +// machine. That single fact caused four bugs: parallel sessions shared a mode, +// SessionStart re-derived the default and clobbered an explicit "stop caveman" +// after every auto-compaction, the statusline badge showed the same mode in +// every window, and "off" — expressed as file ABSENCE — could never survive a +// SessionStart. +// +// #691 fixed the compaction case for a mid-session LEVEL change by branching on +// the hook payload's `source`. It cannot fix the other three, because all of +// them are consequences of the storage shape rather than of when the hook +// re-derives: one file cannot hold two windows' modes, and an absent file +// cannot express "deliberately off" distinguishably from "never set". +// +// Storage: $CLAUDE_CONFIG_DIR/.caveman-sessions/.mode (+ .prev), +// with the legacy flag kept as a compat mirror. +// +// Every function below accepts a sessionId that may be null or malformed and +// degrades to the previous machine-wide behavior in that case. That is the +// whole backward-compatibility story: the old code path IS the fallback +// branch, so a caller with no session id behaves exactly as it did before. +// ───────────────────────────────────────────────────────────────────────────── + +// A session id becomes part of a filesystem path, so it is a path-traversal +// vector. Whitelist the alphabet rather than blacklisting separators — same +// philosophy as VALID_MODES. Claude Code sends a UUID, but the regex stays +// deliberately broad so a future id format degrades to the legacy path instead +// of throwing. Length-capped so a hostile id cannot blow past filename limits. +const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; + +function validateSessionId(id) { + if (typeof id !== 'string' || !SESSION_ID_RE.test(id)) return null; + return id; +} + +function legacyFlagPath(claudeDir) { + return path.join(claudeDir, FLAG_BASENAME); +} + +function sessionsDir(claudeDir) { + return path.join(claudeDir, SESSIONS_DIRNAME); +} + +// Returns null for any id the whitelist rejects. The resolved-path containment +// check is redundant while SESSION_ID_RE holds, but it is cheap and it defends +// against a future edit loosening the regex — the same defense-in-depth style +// as the symlink checks in safeWriteFlag. +function sessionStatePath(claudeDir, sessionId, ext) { + const sid = validateSessionId(sessionId); + if (!sid) return null; + const dir = sessionsDir(claudeDir); + const candidate = path.join(dir, sid + ext); + if (path.dirname(path.resolve(candidate)) !== path.resolve(dir)) return null; + return candidate; +} + +function sessionActivePath(claudeDir, sessionId) { + return sessionStatePath(claudeDir, sessionId, '.mode'); +} + +function sessionPrevPath(claudeDir, sessionId) { + return sessionStatePath(claudeDir, sessionId, '.prev'); +} + +// Both "no file" and the literal 'off' mean caveman is off. Collapsing them to +// a single null is what implements the dual-read compat requirement: a missing +// file is the old spelling, a literal 'off' the new durable one. +function offToNull(mode) { + return (!mode || mode === 'off') ? null : mode; +} + +// "What mode is in effect right now" — for every reader (per-turn +// reinforcement gate, ruleset re-emission, statusline, stats). +function resolveActiveMode(claudeDir, sessionId) { + const sessionPath = sessionActivePath(claudeDir, sessionId); + if (sessionPath) { + const stored = readFlag(sessionPath); + if (stored !== null) return offToNull(stored); + } + return offToNull(readFlag(legacyFlagPath(claudeDir))); +} + +// "What is literally written for THIS session" — no legacy fallback, and 'off' +// comes back verbatim. Two callers need exactly this: recordModeChange (whose +// comparison against the shared mirror would diff against whatever some OTHER +// session wrote last and spam the log with phantom transitions) and the +// SessionStart continuation branch, which has to tell "this session chose off" +// apart from "this session has no state yet". +function readSessionModeRaw(claudeDir, sessionId) { + const sessionPath = sessionActivePath(claudeDir, sessionId); + if (!sessionPath) return readFlag(legacyFlagPath(claudeDir)); + return readFlag(sessionPath); +} + +// Single writer for the active mode. Pass null (or 'off') to deactivate. +// +// The session file stores 'off' literally — that is what makes deactivation +// durable across SessionStart. The legacy mirror instead gets UNLINKED, and +// never holds the literal 'off', on purpose: 'off' is already in VALID_MODES, +// so an older caveman-mode-tracker.js reading it from the legacy path would +// clear its !INDEPENDENT_MODES check and inject "CAVEMAN MODE ACTIVE (off)", +// and an older caveman-statusline.sh would render [CAVEMAN:OFF] instead of +// staying silent. Mixed-version installs are real — plugin hooks and +// standalone hooks can both be registered, and settings.json holds a +// statusline path baked in at install time. +function writeSessionMode(claudeDir, sessionId, modeOrNull) { + const canonical = modeOrNull ? canonicalMode(modeOrNull) : 'off'; + if (!canonical) return; + + const sessionPath = sessionActivePath(claudeDir, sessionId); + if (sessionPath) safeWriteFlag(sessionPath, canonical); + + const legacy = legacyFlagPath(claudeDir); + if (canonical === 'off') { + safeDeleteFlag(legacy); + } else { + safeWriteFlag(legacy, canonical); + } +} + +// Displaced-prose-mode memory for one-shot independent modes (#599), scoped to +// the session. Without the scoping, two windows each running /caveman-commit +// would overwrite each other's "where to return to". +// +// Unlike the active mode, prev is stored in EXACTLY ONE place — the session +// file when there is a valid session id, the legacy file otherwise. It is not +// mirrored, and the read/clear helpers must not cross that line either. +// +// Falling back to the legacy prev whenever a session simply has none of its own +// is a real bug, not a convenience: a session where caveman was never on saves +// no prev (there is nothing to displace), so after /caveman-commit it would +// restore from whatever stale machine-wide prev another context left behind and +// switch itself on at that mode. Symmetrically, clearing must not reach across +// and delete machine-wide state on behalf of one session. +function writeSessionPrev(claudeDir, sessionId, mode) { + const canonical = canonicalMode(mode); + if (!canonical) return; + const p = sessionPrevPath(claudeDir, sessionId) || path.join(claudeDir, PREV_BASENAME); + safeWriteFlag(p, canonical); +} + +function readSessionPrev(claudeDir, sessionId) { + const p = sessionPrevPath(claudeDir, sessionId); + if (p) return readFlag(p); + return readFlag(path.join(claudeDir, PREV_BASENAME)); +} + +function clearSessionPrev(claudeDir, sessionId) { + const p = sessionPrevPath(claudeDir, sessionId); + if (p) { + safeDeleteFlag(p); + return; + } + safeDeleteFlag(path.join(claudeDir, PREV_BASENAME)); +} + +// Sweep stale per-session files. Called from SessionStart on a genuinely new +// session only — not on every compaction, which is frequent in a long session +// and would spend the 5s hook budget on directory walks. +// +// maxDeletes caps the first sweep on a machine that already accumulated many +// sessions. TTL is overridable via env so tests need not fake two weeks. +const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000; + +function gcSessionStore(claudeDir, opts) { + const options = opts || {}; + const envTtl = Number(process.env.CAVEMAN_SESSION_TTL_MS); + const maxAgeMs = options.maxAgeMs + || (Number.isFinite(envTtl) && envTtl > 0 ? envTtl : SESSION_TTL_MS); + const maxDeletes = options.maxDeletes || 500; + + try { + const dir = sessionsDir(claudeDir); + const entries = fs.readdirSync(dir); + const cutoff = Date.now() - maxAgeMs; + let deleted = 0; + for (const name of entries) { + if (deleted >= maxDeletes) break; + const p = path.join(dir, name); + try { + const st = fs.lstatSync(p); + // Refuse symlinks rather than following them into someone else's file. + if (st.isSymbolicLink() || !st.isFile()) continue; + if (st.mtimeMs < cutoff) { fs.unlinkSync(p); deleted++; } + } catch (e) { /* vanished or unreadable — skip */ } + } + return deleted; + } catch (e) { + // Missing directory (fresh install) or any fs failure — nothing to do. + return 0; + } +} + +// Mode-transition log (#601). Whenever the active mode actually changes, +// append {ts, mode, prev, session_id} to +// $CLAUDE_CONFIG_DIR/.caveman-mode-log.jsonl so caveman-stats can attribute +// output tokens to the mode that was active when each message was generated, +// instead of whatever mode the flag holds at stats time. mode/prev are a +// VALID_MODES string or null (null = caveman off). prev lets stats attribute +// messages that predate the first logged transition of a session. No-op when +// the mode is unchanged; best-effort like all flag IO. +// +// session_id is omitted (not null) when unknown, so JSON.stringify drops the +// key and readers written before this field existed see the old shape. +const MODE_LOG_BASENAME = '.caveman-mode-log.jsonl'; + +// Normalizing through offToNull before comparing matters here: without it a +// durable-off write looks like a transition from 'off' to null and lands a +// phantom entry in the log — and attributing output tokens to a mode named +// 'off' would be meaningless besides. +function recordModeChange(claudeDir, newMode, sessionId) { + try { + if (newMode && !canonicalMode(newMode)) return; // not a mode: log nothing + const current = offToNull(readSessionModeRaw(claudeDir, sessionId)); + const next = offToNull(canonicalMode(newMode)); + if (current === next) return; + const entry = { ts: Date.now(), mode: next, prev: current }; + const sid = validateSessionId(sessionId); + if (sid) entry.session_id = sid; + appendFlag(path.join(claudeDir, MODE_LOG_BASENAME), JSON.stringify(entry)); + } catch (e) { + // Silent fail — the log is best-effort + } +} + +// Symlink-safe history read. Returns lines (untrimmed) or empty array on any +// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap +// the way readFlag does — history is expected to grow with use. +function readHistory(filePath) { + try { + const st = fs.lstatSync(filePath); + if (st.isSymbolicLink() || !st.isFile()) return []; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd; + let raw; + try { + fd = fs.openSync(filePath, flags); + raw = fs.readFileSync(fd, 'utf8'); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + return raw.split('\n').filter(line => line.trim()); + } catch (e) { + return []; + } +} + +// --------------------------------------------------------------------------- +// Caveman ruleset — skills//SKILL.md, whole body +// +// Each prose mode is its own skill, so the stored id IS the skill id and the +// body needs no per-level filtering. caveman-activate.js injects it at +// SessionStart; caveman-mode-tracker.js re-injects it when the user switches +// mode mid-session (#975) and reads its thesis line for per-turn +// reinforcement. +// +// It lives here rather than in a new `caveman-ruleset.js` sibling for the reason +// the "Keep mode-state logic in caveman-config.js" rule gives: the hook file set +// is pinned in three places (checksums.sha256, verify_repo.py, the install +// lists) and src/plugins/opencode/plugin.js can only evaluate files it knows by +// name, so a second shared sibling is a standing drift risk. This module is +// already the one file every loader resolves. + +const SKILL_MODES = ['caveman', 'ultracave', 'megacave']; + +// Used when no SKILL.md resolves (standalone hook install without skills dir). +// Each is the first line under the skill's `# ` heading. +const FALLBACK_THESIS = { + caveman: 'Respond terse like smart caveman. All technical substance stay. Only fluff die.', + ultracave: 'Respond terse like smart caveman. All technical substance stay. Only fluff die. Then cut again.', + megacave: '以文言答。技術之實皆存,唯贅言去之。', +}; + +// Candidate locations, tried in order (#587/#589 — the old single '..' path +// resolved to /src/skills/, which does not exist, so plugin +// installs silently used the stale fallback ruleset): +// 1. $CLAUDE_PLUGIN_ROOT/skills//SKILL.md — Claude Code sets +// CLAUDE_PLUGIN_ROOT when invoking plugin hooks; authoritative when present. +// 2. ../../skills//SKILL.md — hook at /src/hooks/ +// (plugin.json layout) or a repo checkout. +// 3. ../skills//SKILL.md — standalone install with hooks at +// $CLAUDE_CONFIG_DIR/hooks/ and the skill at $CLAUDE_CONFIG_DIR/skills//. +// skillId becomes a path segment: callers pass a SKILL_MODES id only. +function skillPathCandidates(hookDir, skillId) { + const dir = hookDir || __dirname; + const id = skillId || 'caveman'; + const candidates = []; + if (process.env.CLAUDE_PLUGIN_ROOT) { + candidates.push(path.join(process.env.CLAUDE_PLUGIN_ROOT, 'skills', id, 'SKILL.md')); + } + candidates.push( + path.join(dir, '..', '..', 'skills', id, 'SKILL.md'), + path.join(dir, '..', 'skills', id, 'SKILL.md') + ); + return candidates; +} + +// The mode's SKILL.md body with YAML frontmatter stripped, or null when the +// mode has no skill file (off, one-shot modes) or none resolves — callers +// then use fallbackRuleset() below. +function loadRuleset(mode, hookDir) { + const id = canonicalMode(mode); + if (!SKILL_MODES.includes(id)) return null; + for (const candidate of skillPathCandidates(hookDir, id)) { + try { + return fs.readFileSync(candidate, 'utf8').replace(/^---[\s\S]*?---\s*/, ''); + } catch (e) { /* try next candidate */ } + } + return null; +} + +// First non-empty line after the skill's `# ` heading — the one-sentence +// statement of the mode, used for per-turn reinforcement. +function thesisLine(mode, hookDir) { + const id = canonicalMode(mode); + if (!SKILL_MODES.includes(id)) return null; + const lines = (loadRuleset(id, hookDir) || '').split(/\r?\n/); + const heading = lines.findIndex((line) => line.trim() === '# ' + id); + const line = heading === -1 ? null : lines.slice(heading + 1).find((l) => l.trim()); + return line ? line.trim() : FALLBACK_THESIS[id]; +} + +// The ruleset body when no SKILL.md resolves (standalone hook install without +// skills dir): the caveman thesis plus the nine rule headlines of +// skills/caveman, then the mode's own thesis for ultracave/megacave. Rule 8 +// keeps its "never switch" sentence: a headline alone lost the #812 language +// rule for every fallback-install user. megacave answers in 文言 by design, so +// it gets its own rule 8 instead of one its thesis contradicts. Shared so a +// mid-session switch on that install carries the same rules SessionStart did. +function fallbackRuleset(mode, hookDir) { + const id = canonicalMode(mode) || mode; + const modeThesis = id !== 'caveman' ? thesisLine(id, hookDir) : null; + return 'Respond terse like smart caveman. All technical substance stay. Only fluff die.\n\n' + + '1. Answer first.\n' + + '2. Kill ceremony.\n' + + '3. Short word.\n' + + '4. Articles optional, meaning never.\n' + + '5. One idea per sentence.\n' + + '6. Payload verbatim.\n' + + '7. Tool runs: bounded status.\n' + + (id === 'megacave' + ? '8. Prose in 文言. Code, commands, paths, errors in their original script.\n' + : "8. User's language. Compress the style, not the language. Never switch because of quoted text.\n") + + '9. Never perform caveman.\n\n' + + 'Plain prose for security warnings, irreversible actions, and anything persisted outside chat (code, commits, PRs, docs).' + + (modeThesis ? '\n\n' + modeThesis : ''); +} + +// The banner both loaders put above the ruleset, so the label the model reads +// cannot drift between SessionStart and a mid-session switch. +function rulesetBanner(mode) { + return 'CAVEMAN MODE ACTIVE — mode: ' + (canonicalMode(mode) || mode); +} + +module.exports = { + getDefaultMode, getConfigDir, getConfigPath, findRepoConfigPath, VALID_MODES, + parseConfigFile, + canonicalMode, + safeWriteFlag, safeDeleteFlag, readFlag, appendFlag, readHistory, + recordModeChange, MODE_LOG_BASENAME, + // Per-session state + SESSIONS_DIRNAME, FLAG_BASENAME, PREV_BASENAME, + validateSessionId, legacyFlagPath, sessionsDir, + sessionActivePath, sessionPrevPath, + resolveActiveMode, readSessionModeRaw, writeSessionMode, + writeSessionPrev, readSessionPrev, clearSessionPrev, + gcSessionStore, + // Ruleset injection + skillPathCandidates, loadRuleset, thesisLine, rulesetBanner, fallbackRuleset, +}; diff --git a/scripts/sync_external_plugins.ps1 b/scripts/sync_external_plugins.ps1 index a0ffdf51..e3eff527 100644 --- a/scripts/sync_external_plugins.ps1 +++ b/scripts/sync_external_plugins.ps1 @@ -935,6 +935,7 @@ function Sync-CodexPlugin { Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths + Update-SkillTextReplacements -Root $pluginDir -Replacements $Source.pluginTextReplacements Apply-LocalOverrides -PluginDir $pluginDir -Source $Source if ($Source.PSObject.Properties.Name -contains "manifestVersionPath" -and $Source.manifestVersionPath) { $versionSourceRelative = [string]$Source.manifestVersionPath