fix: package caveman codex session hook
This commit is contained in:
1 parent
565b6c7482
commit
a4b05247d8
7 files changed
+1112
-4
No files matched your search
@@ -57,7 +57,7 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ
|
|||||||
|
|
||||||
如果插件刚安装到当前 Codex 会话中,启动新的 Codex thread 或新的 Codex 调用后再依赖新增 skills/hooks/MCP/app 配置。
|
如果插件刚安装到当前 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 或插件。
|
||||||
|
|
||||||
卸载插件:
|
卸载插件:
|
||||||
|
|
||||||
|
|||||||
@@ -587,6 +587,8 @@
|
|||||||
"sourcePath": "plugins/caveman",
|
"sourcePath": "plugins/caveman",
|
||||||
"sparseCheckout": [
|
"sparseCheckout": [
|
||||||
".codex/hooks.json",
|
".codex/hooks.json",
|
||||||
|
".codex/codex-sessionstart.js",
|
||||||
|
"src/hooks/caveman-config.js",
|
||||||
"package.json",
|
"package.json",
|
||||||
"plugins/caveman",
|
"plugins/caveman",
|
||||||
"skills/caveman-commit/SKILL.md",
|
"skills/caveman-commit/SKILL.md",
|
||||||
@@ -612,6 +614,14 @@
|
|||||||
"source": ".codex/hooks.json",
|
"source": ".codex/hooks.json",
|
||||||
"destination": "hooks/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",
|
"source": "skills/caveman-commit/SKILL.md",
|
||||||
"destination": "skills/caveman-commit/SKILL.md"
|
"destination": "skills/caveman-commit/SKILL.md"
|
||||||
@@ -645,6 +655,13 @@
|
|||||||
"destination": "skills/verify-and-stop"
|
"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",
|
"manifestVersionPath": "package.json",
|
||||||
"manifestOverrides": {
|
"manifestOverrides": {
|
||||||
"hooks": "./hooks/hooks.json",
|
"hooks": "./hooks/hooks.json",
|
||||||
@@ -652,7 +669,7 @@
|
|||||||
"interface": {
|
"interface": {
|
||||||
"displayName": "Caveman",
|
"displayName": "Caveman",
|
||||||
"shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。",
|
"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",
|
"developerName": "Julius Brussee",
|
||||||
"category": "Developer Tools",
|
"category": "Developer Tools",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"interface": {
|
"interface": {
|
||||||
"displayName": "Caveman",
|
"displayName": "Caveman",
|
||||||
"shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。",
|
"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",
|
"developerName": "Julius Brussee",
|
||||||
"category": "Developer Tools",
|
"category": "Developer Tools",
|
||||||
"capabilities": [
|
"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": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node \"$(git rev-parse --show-toplevel)/.codex/codex-sessionstart.js\"",
|
"command": "node \"${PLUGIN_ROOT}/.codex/codex-sessionstart.js\"",
|
||||||
"timeout": 5,
|
"timeout": 5,
|
||||||
"statusMessage": "Loading caveman mode"
|
"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,
|
||||||
|
};
|
||||||
@@ -935,6 +935,7 @@ function Sync-CodexPlugin {
|
|||||||
Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes
|
Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes
|
||||||
Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes
|
Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes
|
||||||
Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths
|
Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths
|
||||||
|
Update-SkillTextReplacements -Root $pluginDir -Replacements $Source.pluginTextReplacements
|
||||||
Apply-LocalOverrides -PluginDir $pluginDir -Source $Source
|
Apply-LocalOverrides -PluginDir $pluginDir -Source $Source
|
||||||
if ($Source.PSObject.Properties.Name -contains "manifestVersionPath" -and $Source.manifestVersionPath) {
|
if ($Source.PSObject.Properties.Name -contains "manifestVersionPath" -and $Source.manifestVersionPath) {
|
||||||
$versionSourceRelative = [string]$Source.manifestVersionPath
|
$versionSourceRelative = [string]$Source.manifestVersionPath
|
||||||
|
|||||||
Reference in new issue
Block a user