fix: package caveman codex session hook

This commit is contained in:
KeyInfo Bot committed 2026-11-03 10:08:51 +08:00
1 parent 565b6c7482
commit a4b05247d8
7 files changed
+1112 -4

No files matched your search

+1 -1
View File
@@ -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 或插件。
卸载插件:
+18 -1
View File
@@ -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": [
@@ -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": [
@@ -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: <cwd>/.caveman/config.json or <cwd>/.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 `<repo>/.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 <repo>/skills/<mode>/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.
}
@@ -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"
}
@@ -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):
// - <cwd>/.caveman/config.json
// - <cwd>/.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.<pid>.<ts> 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/<session_id>.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/<mode>/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 `# <id>` 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 <plugin_root>/src/skills/, which does not exist, so plugin
// installs silently used the stale fallback ruleset):
// 1. $CLAUDE_PLUGIN_ROOT/skills/<id>/SKILL.md — Claude Code sets
// CLAUDE_PLUGIN_ROOT when invoking plugin hooks; authoritative when present.
// 2. ../../skills/<id>/SKILL.md — hook at <plugin_root>/src/hooks/
// (plugin.json layout) or a repo checkout.
// 3. ../skills/<id>/SKILL.md — standalone install with hooks at
// $CLAUDE_CONFIG_DIR/hooks/ and the skill at $CLAUDE_CONFIG_DIR/skills/<id>/.
// 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 `# <id>` 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,
};
+1
View File
@@ -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