fix(plan-canvas): route session state through CLAUDE_CONFIG_DIR

The Plan Canvas library wrote ~/.claude/plan-canvas while the SessionStart
and Stop hooks read CLAUDE_CONFIG_DIR/plan-canvas. Under a second profile
SessionStart missed open reviews and Stop let a turn end with feedback
still queued.

Expand a leading ~ in CLAUDE_CONFIG_DIR the way scripts/lib/agent-data-home.js
does, so a profile written as ~/.claude-work resolves to one directory rather
than two.

Add hook-level coverage that spawns each hook with a distinct
CLAUDE_CONFIG_DIR and no ECC_* override, plus a producer-to-consumer round
trip. Eight of the nine cases fail against the previous revision.
This commit is contained in:
basil-k-aji-dev
2026-10-03 20:20:40 +05:30
parent 14d54a9285
commit cd85014b9e
7 changed files with 351 additions and 22 deletions
+13 -5
View File
@@ -47,14 +47,22 @@ function envNumber(name, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set.
// Writing to ~/.claude regardless leaks this profile's data into the default one.
// Resolve the Claude profile directory the same way everywhere.
//
// Duplicated rather than imported on purpose: hooks ship in managed installs
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
// require into lib/ would make the hook unloadable there. `~` is expanded the
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
// `~/.claude-work` resolves to one place rather than two.
function claudeConfigDir() {
const configured = process.env.CLAUDE_CONFIG_DIR;
if (configured && String(configured).trim().length > 0) {
return path.resolve(String(configured).trim());
const trimmed = configured ? String(configured).trim() : '';
if (!trimmed) return path.join(os.homedir(), '.claude');
if (trimmed.startsWith('~')) {
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
}
return path.join(os.homedir(), '.claude');
return path.resolve(trimmed);
}
function stateFilePath() {
+13 -5
View File
@@ -34,14 +34,22 @@ const path = require('path');
const SERVER_TIMEOUT_MS = 1000;
const MAX_ITEMS_REPORTED = 20;
// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set.
// Writing to ~/.claude regardless leaks this profile's data into the default one.
// Resolve the Claude profile directory the same way everywhere.
//
// Duplicated rather than imported on purpose: hooks ship in managed installs
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
// require into lib/ would make the hook unloadable there. `~` is expanded the
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
// `~/.claude-work` resolves to one place rather than two.
function claudeConfigDir() {
const configured = process.env.CLAUDE_CONFIG_DIR;
if (configured && String(configured).trim().length > 0) {
return path.resolve(String(configured).trim());
const trimmed = configured ? String(configured).trim() : '';
if (!trimmed) return path.join(os.homedir(), '.claude');
if (trimmed.startsWith('~')) {
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
}
return path.join(os.homedir(), '.claude');
return path.resolve(trimmed);
}
function stateDir() {
+13 -5
View File
@@ -19,14 +19,22 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set.
// Writing to ~/.claude regardless leaks this profile's data into the default one.
// Resolve the Claude profile directory the same way everywhere.
//
// Duplicated rather than imported on purpose: hooks ship in managed installs
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
// require into lib/ would make the hook unloadable there. `~` is expanded the
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
// `~/.claude-work` resolves to one place rather than two.
function claudeConfigDir() {
const configured = process.env.CLAUDE_CONFIG_DIR;
if (configured && String(configured).trim().length > 0) {
return path.resolve(String(configured).trim());
const trimmed = configured ? String(configured).trim() : '';
if (!trimmed) return path.join(os.homedir(), '.claude');
if (trimmed.startsWith('~')) {
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
}
return path.join(os.homedir(), '.claude');
return path.resolve(trimmed);
}
function stateDir() {
+13 -5
View File
@@ -38,14 +38,22 @@ function appendLine(filePath, line) {
fs.appendFileSync(filePath, `${line}\n`, 'utf8');
}
// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set.
// Writing to ~/.claude regardless leaks this profile's data into the default one.
// Resolve the Claude profile directory the same way everywhere.
//
// Duplicated rather than imported on purpose: hooks ship in managed installs
// that carry top-level `scripts/` without `scripts/lib/` (see #3259), so a
// require into lib/ would make the hook unloadable there. `~` is expanded the
// way `scripts/lib/agent-data-home.js` expands it, so a profile written as
// `~/.claude-work` resolves to one place rather than two.
function claudeConfigDir() {
const configured = process.env.CLAUDE_CONFIG_DIR;
if (configured && String(configured).trim().length > 0) {
return path.resolve(String(configured).trim());
const trimmed = configured ? String(configured).trim() : '';
if (!trimmed) return path.join(os.homedir(), '.claude');
if (trimmed.startsWith('~')) {
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
}
return path.join(os.homedir(), '.claude');
return path.resolve(trimmed);
}
function run(rawInput, mode = 'audit') {
+17 -1
View File
@@ -16,10 +16,26 @@ const path = require('path');
const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']);
const VERDICTS = new Set(['approve', 'request-changes']);
// The producer and the SessionStart/Stop hooks must land on one directory. The
// hooks resolve CLAUDE_CONFIG_DIR (they cannot require this file: managed
// installs ship scripts/ without scripts/lib/, see #3259), so writing to
// ~/.claude here regardless would hide queued feedback from them whenever a
// session runs under a second profile.
function resolveClaudeConfigDir(env = process.env) {
const configured = env.CLAUDE_CONFIG_DIR;
const trimmed = configured ? String(configured).trim() : '';
if (!trimmed) return path.join(os.homedir(), '.claude');
if (trimmed.startsWith('~')) {
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
return remainder ? path.join(os.homedir(), remainder) : os.homedir();
}
return path.resolve(trimmed);
}
function resolveStateDir(env = process.env) {
const override = env.ECC_PLAN_CANVAS_STATE_DIR;
if (override && String(override).trim()) return path.resolve(String(override).trim());
return path.join(os.homedir(), '.claude', 'plan-canvas');
return path.join(resolveClaudeConfigDir(env), 'plan-canvas');
}
// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the