Summary
Two bugs in the v7.1.1 release installer that contradict the tool's own documented "additive, never clobbering" contract. Found while doing a manual, checkpointed install against a live, populated ~/.claude (pre-existing PAI 5.0 install with ~30+ custom hooks). Both are fixable with small, targeted patches.
Bug 1: InstallHooks.ts overwrites existing hook files instead of skipping them
File: LifeOS/Tools/InstallHooks.ts, line ~101
cpSync(hooksPayloadDir, hooksDestDir, { recursive: true });
fs.cpSync defaults to force: true, so this recursively overwrites every file in hooksDestDir that shares a name with a file in the payload — including files that already existed and belong to the user, not LifeOS.
This directly contradicts:
Workflows/Setup.md: "hooks → bun Tools/InstallHooks.ts (trust-gated): ... merges additively per matcher bucket"
SKILL.md's own stated hard rule: "Additive, never clobbering... Never overwrite or rm a populated dir or a foreign file."
The settings.json merge logic in the same file (lines ~75-93) is correctly additive/guarded — this is purely a bug in the hook script file copy step, which is a separate operation from the settings.json hooks-array merge.
Repro
- Have an existing
~/.claude/hooks/ directory with any file whose name collides with a shipped LifeOS hook (e.g. AgentInvocation.hook.ts, SessionCleanup.hook.ts, VoiceCompletion.hook.ts — 19 collided in our case).
- Run
bun Tools/InstallHooks.ts --apply.
- Every colliding file is silently replaced with LifeOS's version, no backup, no diff shown, no consent for that specific file (only the settings.json merge is shown/gated).
Impact (real-world)
In our case this broke a user-owned SecurityPipeline.hook.ts indirectly — one of the overwritten files it depended on changed, and the security hook began fail-closing on every tool call (Bash, Read, everything), effectively locking the session. Recovered via git checkout <pre-install-commit> -- hooks/ since we'd taken a full backup commit first. Without that backup this would have been a silent, hard-to-diagnose data-loss/breakage event.
Suggested fix
Copy file-by-file with an existsSync guard (matching the pattern already used everywhere else in the codebase — DeployCore.ts's copyMissing, ScaffoldUser.ts, DeployComponents.ts's agents/commands deployers):
function copyMissingRecursive(src: string, dst: string): { copied: number } {
let copied = 0;
for (const entry of readdirSync(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dst, entry.name);
if (entry.isDirectory()) {
mkdirSync(d, { recursive: true });
copied += copyMissingRecursive(s, d).copied;
} else if (!existsSync(d)) {
copyFileSync(s, d);
copied++;
}
}
return { copied };
}
If overwriting existing hook files is ever intentional (e.g. for Update.md's idempotent re-overlay of system-owned files), that should be a distinct code path from the fresh-install path, and should only apply to files LifeOS itself previously placed (tracked via a manifest), never to files that pre-existed the first LifeOS install.
Bug 2: settings.system.json ships LIFEOS_CONFIG_DIR identical to LIFEOS_DIR
File: LifeOS/install/settings.system.json, lines ~5 and ~9
"LIFEOS_DIR": "$HOME/.claude/LIFEOS",
"LIFEOS_CONFIG_DIR": "$HOME/.claude/LIFEOS",
Both env vars resolve to the exact same path. But Tools/LinkUser.ts's own default/fallback treats these as two conceptually separate locations — LIFEOS_DIR = the harness-tree runtime, LIFEOS_CONFIG_DIR = the private data home (its own hardcoded fallback is join(home, ".config", "LIFEOS"), i.e. ~/.config/LIFEOS, deliberately outside the harness tree). This mirrors the already-shipped PAI_CONFIG_DIR pattern (~/.config/PAI) from the predecessor system.
Impact
Running LinkUser.ts --apply with the shipped default env produces:
{ "willLink": "/Users/x/.claude/LIFEOS/USER → /Users/x/.claude/LIFEOS/USER" }
i.e. it would attempt to symlink LIFEOS/USER to itself — a no-op at best, or a broken/self-referential symlink depending on setupUserSeparation's exact behavior when src === dst. We caught this only by inspecting the dry-run willLink output before applying and cross-checking against the tool's own default fallback logic; someone running the one-line curl | bash install without a code review would hit this silently.
Suggested fix
"LIFEOS_DIR": "$HOME/.claude/LIFEOS",
"LIFEOS_CONFIG_DIR": "$HOME/.config/LIFEOS",
Worth adding an explicit runtime assertion in LinkUser.ts too — refuse to proceed (loud error, not silent no-op) if configRoot/LIFEOS and configDir resolve to the same path, since that's never a valid state given the tool's own design intent.
Environment
- LifeOS v7.1.1 (tag), macOS 26.5.2, bun 1.3.11
- Installed manually (release tarball + individual Tools/*.ts steps) against a pre-existing, populated Claude Code
~/.claude — not a clean machine
- Both bugs reproduced via the shipped tools directly, not an artifact of the manual install path (the manual path just made them visible/interceptable instead of silent)
Happy to send a PR for either/both if useful — the fixes above are small and match existing patterns already in the codebase.
Summary
Two bugs in the v7.1.1 release installer that contradict the tool's own documented "additive, never clobbering" contract. Found while doing a manual, checkpointed install against a live, populated
~/.claude(pre-existing PAI 5.0 install with ~30+ custom hooks). Both are fixable with small, targeted patches.Bug 1:
InstallHooks.tsoverwrites existing hook files instead of skipping themFile:
LifeOS/Tools/InstallHooks.ts, line ~101fs.cpSyncdefaults toforce: true, so this recursively overwrites every file inhooksDestDirthat shares a name with a file in the payload — including files that already existed and belong to the user, not LifeOS.This directly contradicts:
Workflows/Setup.md: "hooks →bun Tools/InstallHooks.ts(trust-gated): ... merges additively per matcher bucket"SKILL.md's own stated hard rule: "Additive, never clobbering... Never overwrite orrma populated dir or a foreign file."The
settings.jsonmerge logic in the same file (lines ~75-93) is correctly additive/guarded — this is purely a bug in the hook script file copy step, which is a separate operation from the settings.json hooks-array merge.Repro
~/.claude/hooks/directory with any file whose name collides with a shipped LifeOS hook (e.g.AgentInvocation.hook.ts,SessionCleanup.hook.ts,VoiceCompletion.hook.ts— 19 collided in our case).bun Tools/InstallHooks.ts --apply.Impact (real-world)
In our case this broke a user-owned
SecurityPipeline.hook.tsindirectly — one of the overwritten files it depended on changed, and the security hook began fail-closing on every tool call (Bash, Read, everything), effectively locking the session. Recovered viagit checkout <pre-install-commit> -- hooks/since we'd taken a full backup commit first. Without that backup this would have been a silent, hard-to-diagnose data-loss/breakage event.Suggested fix
Copy file-by-file with an
existsSyncguard (matching the pattern already used everywhere else in the codebase —DeployCore.ts'scopyMissing,ScaffoldUser.ts,DeployComponents.ts'sagents/commandsdeployers):If overwriting existing hook files is ever intentional (e.g. for
Update.md's idempotent re-overlay of system-owned files), that should be a distinct code path from the fresh-install path, and should only apply to files LifeOS itself previously placed (tracked via a manifest), never to files that pre-existed the first LifeOS install.Bug 2:
settings.system.jsonshipsLIFEOS_CONFIG_DIRidentical toLIFEOS_DIRFile:
LifeOS/install/settings.system.json, lines ~5 and ~9Both env vars resolve to the exact same path. But
Tools/LinkUser.ts's own default/fallback treats these as two conceptually separate locations —LIFEOS_DIR= the harness-tree runtime,LIFEOS_CONFIG_DIR= the private data home (its own hardcoded fallback isjoin(home, ".config", "LIFEOS"), i.e.~/.config/LIFEOS, deliberately outside the harness tree). This mirrors the already-shippedPAI_CONFIG_DIRpattern (~/.config/PAI) from the predecessor system.Impact
Running
LinkUser.ts --applywith the shipped default env produces:{ "willLink": "/Users/x/.claude/LIFEOS/USER → /Users/x/.claude/LIFEOS/USER" }i.e. it would attempt to symlink
LIFEOS/USERto itself — a no-op at best, or a broken/self-referential symlink depending onsetupUserSeparation's exact behavior when src === dst. We caught this only by inspecting the dry-runwillLinkoutput before applying and cross-checking against the tool's own default fallback logic; someone running the one-linecurl | bashinstall without a code review would hit this silently.Suggested fix
Worth adding an explicit runtime assertion in
LinkUser.tstoo — refuse to proceed (loud error, not silent no-op) ifconfigRoot/LIFEOSandconfigDirresolve to the same path, since that's never a valid state given the tool's own design intent.Environment
~/.claude— not a clean machineHappy to send a PR for either/both if useful — the fixes above are small and match existing patterns already in the codebase.