Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions LifeOS/Tools/InstallEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,28 @@ function normalizeCommand(cmd: string): string {
.trim();
}

/**
* Rewrite the default `~/.claude` config-root spellings inside hook commands to
* the actually-detected configRoot. The payload's hooks.json ships commands
* against the default root (`$HOME/.claude/...`); on a multi-profile setup
* (Claude Code's CLAUDE_CONFIG_DIR) those paths point at a different — usually
* nonexistent — profile, so every hook errors on startup. No-op when configRoot
* IS the default, keeping default installs byte-identical. Pure (no I/O).
*/
export function rewriteHooksConfigRoot(hooks: HooksMap, configRoot: string, homeDir: string): HooksMap {
if (resolve(configRoot) === resolve(join(homeDir, ".claude"))) return hooks;
const defaultRootPattern = /(\$\{HOME\}|\$HOME|~)\/\.claude(?=\/|\s|$)/g;
const rewritten: HooksMap = JSON.parse(JSON.stringify(hooks ?? {}));
for (const groups of Object.values(rewritten)) {
for (const group of groups) {
for (const h of group.hooks ?? []) {
if (typeof h.command === "string") h.command = h.command.replace(defaultRootPattern, configRoot);
}
}
}
return rewritten;
}

/** Identity key for a hook entry: http → url, else normalized command. */
function hookKey(h: HookEntry): string {
if (h.type === "http" && h.url) return `http:${h.url}`;
Expand Down
11 changes: 9 additions & 2 deletions LifeOS/Tools/InstallHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { detectDevTree, mergeHooks } from "./InstallEngine";
import { detectDevTree, mergeHooks, rewriteHooksConfigRoot } from "./InstallEngine";

interface Args { configRoot: string; skillRoot: string; apply: boolean; allowDev: boolean; }

Expand Down Expand Up @@ -59,7 +59,14 @@ function main(): void {
console.log(JSON.stringify({ ok: false, error: `payload hooks.json not found at ${hooksJsonPath}` }, null, 2));
process.exit(1);
}
const incoming = JSON.parse(readFileSync(hooksJsonPath, "utf-8"))?.hooks ?? {};
// Payload commands are written against the default ~/.claude root; retarget
// them at the detected configRoot so non-default CLAUDE_CONFIG_DIR profiles
// get hooks that point at the files this installer actually deploys.
const incoming = rewriteHooksConfigRoot(
JSON.parse(readFileSync(hooksJsonPath, "utf-8"))?.hooks ?? {},
configRoot,
process.env.HOME || "",
);

// The hook SCRIPTS (*.hook.ts|sh + lib/**) live beside hooks.json in the payload.
// Merging hooks.json into settings.json wires commands that point at these files,
Expand Down
4 changes: 2 additions & 2 deletions LifeOS/install/LIFEOS/TOOLS/FreshnessCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
import { writeFileSync, renameSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
import { readContextFreshness } from "./TelosFreshness";
import { getLifeosDir } from "./Paths";

const HOME = process.env.HOME || "";
const LIFEOS_DIR = process.env.LIFEOS_DIR || join(HOME, ".claude", "LIFEOS");
const LIFEOS_DIR = getLifeosDir();
const CACHE_DIR = join(LIFEOS_DIR, "USER", "CACHE");
const CACHE_PATH = join(CACHE_DIR, "freshness.json");

Expand Down
12 changes: 6 additions & 6 deletions LifeOS/install/LIFEOS/TOOLS/ISARender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@
import { readFileSync, writeFileSync, existsSync, statSync, renameSync, readdirSync } from "node:fs";
import { resolve, dirname, basename, join } from "node:path";
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { getLifeosDir } from "./Paths";

const HOME = process.env.HOME || homedir();
const TOOLS_DIR = resolve(HOME, ".claude/LIFEOS/TOOLS");
const LIFEOS_DIR = getLifeosDir();
const TOOLS_DIR = join(LIFEOS_DIR, "TOOLS");
const TEMPLATE_HTML = join(TOOLS_DIR, "ISARender/template.html");
const TEMPLATE_CSS = join(TOOLS_DIR, "ISARender/template.css");
// Brand logo: user override via LIFEOS_BRAND_LOGO_PATH env var (absolute path),
// else system default under PAI/ASSETS/, else inert (empty src).
const BRAND_LOGO_PATH_OVERRIDE = process.env.LIFEOS_BRAND_LOGO_PATH ?? "";
const BRAND_LOGO_PATH_DEFAULT = resolve(HOME, ".claude/LIFEOS/ASSETS/pai-logo.png");
const WORK_DIR = resolve(HOME, ".claude/LIFEOS/MEMORY/WORK");
const WORK_JSON = resolve(HOME, ".claude/LIFEOS/MEMORY/STATE/work.json");
const BRAND_LOGO_PATH_DEFAULT = join(LIFEOS_DIR, "ASSETS/pai-logo.png");
const WORK_DIR = join(LIFEOS_DIR, "MEMORY/WORK");
const WORK_JSON = join(LIFEOS_DIR, "MEMORY/STATE/work.json");

const PHASES = ["observe", "think", "plan", "build", "execute", "verify", "learn", "complete"];

Expand Down
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/TOOLS/IntegrityMaintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { spawn } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join, basename, dirname } from 'path';
import { inference } from './Inference';
import { getIdentity } from '../../../.claude/hooks/lib/identity';
import { getIdentity } from '../../hooks/lib/identity';

// ============================================================================
// Types
Expand Down
9 changes: 5 additions & 4 deletions LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { getClaudeDir } from "./Paths";

// Expand leading `~` (and `~/`) to the user's home directory. node:fs APIs do
// not expand tildes, so any path returned from this loader must be absolute.
Expand Down Expand Up @@ -85,7 +86,7 @@ export interface LifeosConfig {
// ─────────── Resolution ───────────

const DEFAULT_HOME = process.env.HOME || homedir();
const DEFAULT_CONFIG_PATH = resolve(DEFAULT_HOME, ".claude/LIFEOS/USER/CONFIG/LIFEOS_CONFIG.toml");
const DEFAULT_CONFIG_PATH = resolve(getClaudeDir(), "LIFEOS/USER/CONFIG/LIFEOS_CONFIG.toml");

let cache: { config: LifeosConfig; mtime: number; path: string } | null = null;

Expand Down Expand Up @@ -133,7 +134,7 @@ export function paiUserDir(): string {
try {
return loadLifeosConfig().paths.userDir;
} catch {
return resolve(DEFAULT_HOME, ".claude/LIFEOS/USER");
return resolve(getClaudeDir(), "LIFEOS/USER");
}
}

Expand Down Expand Up @@ -188,10 +189,10 @@ function validateAndNormalize(raw: unknown, path: string): LifeosConfig {
},
paths: {
userDir: expandHome(
root.paths?.userDir ?? root.paths?.user_dir ?? resolve(DEFAULT_HOME, ".claude/LIFEOS/USER"),
root.paths?.userDir ?? root.paths?.user_dir ?? resolve(getClaudeDir(), "LIFEOS/USER"),
),
memoryDir: expandHome(
root.paths?.memoryDir ?? root.paths?.memory_dir ?? resolve(DEFAULT_HOME, ".claude/LIFEOS/MEMORY"),
root.paths?.memoryDir ?? root.paths?.memory_dir ?? resolve(getClaudeDir(), "LIFEOS/MEMORY"),
),
projectsDir: expandHome(
root.paths?.projectsDir ?? root.paths?.projects_dir ?? resolve(DEFAULT_HOME, "Projects"),
Expand Down
4 changes: 2 additions & 2 deletions LifeOS/install/LIFEOS/TOOLS/MemoryHealthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@

import { existsSync, readFileSync, appendFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { getClaudeDir } from "./Paths";

const HOME = process.env.HOME || "";
const CLAUDE = join(HOME, ".claude");
const CLAUDE = getClaudeDir();
const HOOKS_DIR = join(CLAUDE, "hooks");
const TOOLS_DIR = join(CLAUDE, "LIFEOS/TOOLS");
const OBS_DIR = join(CLAUDE, "LIFEOS/MEMORY/OBSERVABILITY");
Expand Down
8 changes: 4 additions & 4 deletions LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
writeFileSync,
} from "node:fs";
import { dirname, join as pathJoin, resolve as pathResolve } from "node:path";
import { homedir } from "node:os";

import { add as memoryAdd, type AddResult } from "./MemorySystem";
import { read as memoryWriterRead } from "./MemoryWriter";
Expand All @@ -51,11 +50,12 @@ import {
markProposal,
logProposalEvent,
} from "../PULSE/lib/telegram-proposals";
import { getClaudeDir } from "./Paths";

// ── Constants ──

const CLAUDE_ROOT = pathResolve(homedir(), ".claude");
const HARNESS_PROJECTS_DIR = pathResolve(homedir(), ".claude", "projects");
const CLAUDE_ROOT = getClaudeDir();
const HARNESS_PROJECTS_DIR = pathJoin(CLAUDE_ROOT, "projects");
const RUNS_LOG_PATH = pathResolve(CLAUDE_ROOT, "LIFEOS/MEMORY/OBSERVABILITY/reviewer-runs.jsonl");
const RUNS_DEBUG_DIR = pathResolve(CLAUDE_ROOT, "LIFEOS/MEMORY/OBSERVABILITY/reviewer-runs");
const REVIEW_CONFIG_PATH = pathResolve(CLAUDE_ROOT, "LIFEOS/USER/CONFIG/memory-review.json");
Expand Down Expand Up @@ -648,7 +648,7 @@ async function smokeTest(): Promise<number> {
const mockResponse = JSON.stringify({
items: [
{ type: "memory", actor: "principal", content: "PREFERENCE: smoke E2E mock" },
{ type: "proposal", target_file: pathJoin(homedir(), ".claude/LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md"), edit: "RULE: E2E mock", confidence: 0.5, rationale: "smoke" },
{ type: "proposal", target_file: pathJoin(CLAUDE_ROOT, "LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md"), edit: "RULE: E2E mock", confidence: 0.5, rationale: "smoke" },
],
});

Expand Down
58 changes: 58 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/Paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Paths.ts — centralized config-root resolution for LIFEOS/TOOLS.
*
* Mirrors hooks/lib/paths.ts (the hooks' resolver) so runtime tools agree with
* the hooks about where the Claude config root lives:
*
* 1. CLAUDE_PLUGIN_ROOT (plugin install) — the flattened plugin root
* 2. CLAUDE_CONFIG_DIR — Claude Code's own config-dir override
* (multi-profile setups)
* 3. ~/.claude — the default, byte-identical to prior behavior
*
* LIFEOS data resolves via LIFEOS_DIR when set, else <claude dir>/LIFEOS.
*/

import { homedir } from "node:os";
import { join } from "node:path";

/** Expand $HOME, ${HOME}, and ~ prefixes in a path string. */
export function expandPath(path: string): string {
const home = homedir();

return path
.replace(/^\$HOME(?=\/|$)/, home)
.replace(/^\$\{HOME\}(?=\/|$)/, home)
.replace(/^~(?=\/|$)/, home);
}

/** Get the Claude Code config directory (see resolution order above). */
export function getClaudeDir(): string {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;

if (pluginRoot) {
return expandPath(pluginRoot);
}

const configDir = process.env.CLAUDE_CONFIG_DIR;

if (configDir) {
return expandPath(configDir);
}

return join(homedir(), ".claude");
}

/** Get the LifeOS data directory: LIFEOS_DIR when set, else <claude dir>/LIFEOS. */
export function getLifeosDir(): string {
if (process.env.CLAUDE_PLUGIN_ROOT) {
return join(getClaudeDir(), "LIFEOS");
}

const envLifeosDir = process.env.LIFEOS_DIR;

if (envLifeosDir) {
return expandPath(envLifeosDir);
}

return join(getClaudeDir(), "LIFEOS");
}
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/TOOLS/SessionHarvester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import { parseArgs } from "util";
import * as fs from "fs";
import * as path from "path";
import { getLearningCategory, isLearningCapture } from "../../../.claude/hooks/lib/learning-utils";
import { getLearningCategory, isLearningCapture } from "../../hooks/lib/learning-utils";

// ============================================================================
// Configuration
Expand Down
3 changes: 2 additions & 1 deletion LifeOS/install/LIFEOS/TOOLS/SettingsBackport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { mergeSettings, deepEqual, parseJsonFileOrThrow } from "./MergeSettings";
import { getClaudeDir } from "./Paths";

const CLAUDE_DIR = path.join(os.homedir(), ".claude");
const CLAUDE_DIR = getClaudeDir();
const SYSTEM_PATH = path.join(CLAUDE_DIR, "settings.system.json");
const USER_PATH = path.join(CLAUDE_DIR, "LIFEOS", "USER", "CONFIG", "settings.user.json");
const GENERATED_PATH = path.join(CLAUDE_DIR, "settings.json");
Expand Down
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/TOOLS/TranscriptParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/

import { readFileSync } from 'fs';
import { getIdentity } from '../../../.claude/hooks/lib/identity';
import { getIdentity } from '../../hooks/lib/identity';

const DA_IDENTITY = getIdentity();

Expand Down
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/TOOLS/algorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, append
import { resolve, basename, join, dirname } from "path";
import { spawnSync, spawn } from "child_process";
import { randomUUID } from "crypto";
import { generateISATemplate } from "../../../.claude/hooks/lib/isa-template";
import { generateISATemplate } from "../../hooks/lib/isa-template";

// ─── Paths ───────────────────────────────────────────────────────────────────

Expand Down
7 changes: 4 additions & 3 deletions LifeOS/install/LIFEOS/TOOLS/lifeos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,20 @@
*/

import { spawn, spawnSync } from "bun";
import { getIdentity, getStartupCatchphrase } from "../../../.claude/hooks/lib/identity";
import { getIdentity, getStartupCatchphrase } from "../../hooks/lib/identity";
import { existsSync, readFileSync, writeFileSync, readdirSync, symlinkSync, unlinkSync, lstatSync } from "fs";
import { homedir } from "os";
import { join, basename } from "path";
import { getClaudeDir, getLifeosDir } from "./Paths";

// ============================================================================
// Configuration
// ============================================================================

const CLAUDE_DIR = join(homedir(), ".claude");
const CLAUDE_DIR = getClaudeDir();
const MCP_DIR = join(CLAUDE_DIR, "MCPs");
const ACTIVE_MCP = join(CLAUDE_DIR, ".mcp.json");
const BANNER_SCRIPT = join(homedir(), ".claude", "LIFEOS", "TOOLS", "Banner.ts");
const BANNER_SCRIPT = join(getLifeosDir(), "TOOLS", "Banner.ts");
const VOICE_SERVER = "http://localhost:31337/notify/personality";
const WALLPAPER_DIR = join(homedir(), "Projects", "Wallpaper");
// Note: RAW archiving removed - Claude Code handles its own cleanup (30-day retention in projects/)
Expand Down
5 changes: 2 additions & 3 deletions LifeOS/install/hooks/AgentInvocation.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@

import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { paiPath } from './lib/paths';
import { getClaudeDir, paiPath } from './lib/paths';
import { getISOTimestamp } from './lib/time';
import { EFFORT_MODEL, ALIAS, CROSS_VENDOR } from '../LIFEOS/TOOLS/models';

Expand Down Expand Up @@ -49,7 +48,7 @@ function resolveDispatch(subagentType: string, inputModel?: string): { model: st
if (CROSS_VENDOR[cvKey]) return { model: CROSS_VENDOR[cvKey], level: 'cross-vendor' };
if (inputModel) return { model: inputModel, level: levelForModel(inputModel) };
try {
const fm = readFileSync(join(homedir(), '.claude', 'agents', `${subagentType}.md`), 'utf-8').slice(0, 4000);
const fm = readFileSync(join(getClaudeDir(), 'agents', `${subagentType}.md`), 'utf-8').slice(0, 4000);
const m = fm.match(/^model:\s*(\S+)/m);
if (m) return { model: m[1], level: `${levelForModel(m[1])}-pin` };
} catch { /* no agent file — built-in type */ }
Expand Down
8 changes: 4 additions & 4 deletions LifeOS/install/hooks/ArtWorkflowGuard.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
*/

import { readFileSync, readdirSync } from "node:fs";
import { displayPath, getSkillsDir } from "./lib/paths";

interface HookInput {
session_id?: string;
tool_name?: string;
tool_input?: { command?: string; description?: string };
}

const HOME = process.env.HOME ?? "";
const ART_TOOL_PATH_FRAGMENTS = [
"skills/Art/Tools/Generate.ts",
".claude/skills/Art/Tools/Generate.ts",
Expand All @@ -39,7 +39,7 @@ function readHookInput(): HookInput {
}

function listWorkflows(): string[] {
const dir = `${HOME}/.claude/skills/Art/Workflows`;
const dir = `${getSkillsDir()}/Art/Workflows`;
try {
return readdirSync(dir)
.filter((f) => f.endsWith(".md"))
Expand Down Expand Up @@ -83,7 +83,7 @@ function main(): never {
" ArtWorkflowGuard — Generate.ts call BLOCKED before execution.",
"═══════════════════════════════════════════════════════════════════════════",
"",
" This Bash command invokes ~/.claude/skills/Art/Tools/Generate.ts but",
` This Bash command invokes ${displayPath(getSkillsDir())}/Art/Tools/Generate.ts but`,
" does not name a workflow. The Art skill requires every image generation",
" to run through a named workflow. Freeform prompts are documented to fail.",
"",
Expand All @@ -93,7 +93,7 @@ function main(): never {
"",
" Available workflows (each is a file under skills/Art/Workflows/):",
...workflows.map(
(w) => ` --workflow=${w.padEnd(28)} → ~/.claude/skills/Art/Workflows/${w}.md`
(w) => ` --workflow=${w.padEnd(28)} → ${displayPath(getSkillsDir())}/Art/Workflows/${w}.md`
),
"",
" Recommended next step: Read the workflow file matching your task,",
Expand Down
3 changes: 2 additions & 1 deletion LifeOS/install/hooks/CheckpointPerISC.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ import { execFileSync } from 'node:child_process';
import { basename, dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { parseFrontmatter, parseCriteriaList, ARTIFACT_FILENAME, LEGACY_ARTIFACT_FILENAME } from './lib/isa-utils';
import { getClaudeDir } from './lib/paths';

// Allowlist path: top of ~/.claude per spec. One absolute repo path per line;
// '#' comments and blank
// lines are ignored. Tilde and $HOME prefixes are expanded as a quality-of-
// life feature so users can write `~/Projects/foo` instead of the long form.
const ALLOWLIST_PATH = join(homedir(), '.claude', 'checkpoint-repos.txt');
const ALLOWLIST_PATH = join(getClaudeDir(), 'checkpoint-repos.txt');
const GIT_TIMEOUT_MS = 5000;

interface CheckpointState {
Expand Down
3 changes: 2 additions & 1 deletion LifeOS/install/hooks/DriftReminder.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { firstBannedHit } from "./lib/banned-vocab";
import { getLifeosDir } from './lib/paths';

interface HookInput {
prompt?: string;
Expand All @@ -27,7 +28,7 @@ interface DriftState {

const STDIN_TIMEOUT_MS = 300;
const MIN_TURNS_BETWEEN_FIRES = 5;
const LIFEOS_DIR = process.env.LIFEOS_DIR || join(process.env.HOME || "", ".claude", "LIFEOS");
const LIFEOS_DIR = getLifeosDir();
const LAST_RESPONSE_PATH = join(LIFEOS_DIR, "MEMORY", "STATE", "last-response.txt");
const STATE_PATH = join(LIFEOS_DIR, "MEMORY", "STATE", "drift-reminder.json");
const INITIAL_STATE: DriftState = {
Expand Down
Loading