From 67d6988780dc9eb7fe0750f80fd0c6fd1d5599c1 Mon Sep 17 00:00:00 2001 From: alex anikin <60673011+anikinsasha@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:43:05 -0700 Subject: [PATCH] fix(identity): resolve DA and principal from LIFEOS_CONFIG.toml LifeosConfig refuses to load without a non-empty [da].name and [principal].name/timezone, and setup points the principal at those fields. getIdentity()/getPrincipal() read neither: settings.daidentity -> identity-file frontmatter -> DEFAULT_IDENTITY. identity.ts already imports loadLifeosConfig, but only to resolve paths.userDir. Config now wins for the fields it carries. The existing chain is preserved as legacyIdentity()/legacyPrincipal(), so an install with no config resolves exactly as it did before. test/hooks/Identity.test.ts is the falsifier: four of its five cases fail against the unpatched file, and the fifth (no config present) passes both ways by design. --- LifeOS/install/hooks/lib/identity.ts | 77 ++++++++++++++- LifeOS/install/test/hooks/Identity.test.ts | 106 +++++++++++++++++++++ 2 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 LifeOS/install/test/hooks/Identity.test.ts diff --git a/LifeOS/install/hooks/lib/identity.ts b/LifeOS/install/hooks/lib/identity.ts index 0ae796f9cc..9628435afb 100755 --- a/LifeOS/install/hooks/lib/identity.ts +++ b/LifeOS/install/hooks/lib/identity.ts @@ -13,7 +13,11 @@ import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { parse as parseYaml } from 'yaml'; -import { loadLifeosConfig } from '../../LIFEOS/TOOLS/LifeosConfig'; +import { + loadLifeosConfig, + type LifeosDa, + type LifeosPrincipal, +} from '../../LIFEOS/TOOLS/LifeosConfig'; const HOME = process.env.HOME!; const SETTINGS_PATH = join(HOME, '.claude/settings.json'); @@ -171,10 +175,53 @@ function mapFrontmatterPersonality(traits: any, baseVoice: string | undefined): /** * Get DA (Digital Assistant) identity. - * Reads settings.daidentity first (canonical runtime read point), - * falls back to DA_IDENTITY.md frontmatter (authoring source). + * + * LIFEOS_CONFIG.toml `[da]` wins for every field it carries. It is the typed + * loader, it is where setup tells the principal to name their DA, and + * LifeosConfig itself refuses to load without a non-empty `[da].name` — so it + * is the one source guaranteed to be both present and current. + * + * The settings.daidentity → DA_IDENTITY.md frontmatter → DEFAULT_IDENTITY chain + * below stays the fallback: it covers the fields config does not carry, and an + * install that has no config yet resolves exactly as it did before. */ export function getIdentity(): Identity { + const base = legacyIdentity(); + + let da: LifeosDa | undefined; + try { + da = loadLifeosConfig().da; + } catch { + return base; // no config yet (fresh install) — legacy chain governs + } + if (!da?.name) return base; + + const main = da.voices?.main; + return { + ...base, + name: da.name, + fullName: da.fullName || da.name, + displayName: da.displayName || da.name, + color: da.color || base.color, + mainDAVoiceID: main?.voiceId || base.mainDAVoiceID, + voice: main + ? { + stability: main.stability ?? 0, + similarityBoost: main.similarityBoost ?? 0, + style: main.style ?? 0, + speed: main.speed ?? 1, + useSpeakerBoost: main.useSpeakerBoost ?? false, + volume: main.volume, + } + : base.voice, + }; +} + +/** + * The pre-config resolution chain: settings.daidentity (runtime read point), + * then DA_IDENTITY.md frontmatter (authoring source), then the placeholder. + */ +function legacyIdentity(): Identity { const settings = loadSettings(); const daidentity = (settings.daidentity || {}) as any; const voices = daidentity.voices || {}; @@ -212,9 +259,31 @@ export function getIdentity(): Identity { /** * Get Principal (human owner) identity. - * Reads frontmatter from PRINCIPAL_IDENTITY.md first, falls back to settings.principal. + * + * Same contract as getIdentity(): LIFEOS_CONFIG.toml `[principal]` wins, and + * LifeosConfig requires a non-empty name and timezone there. The + * PRINCIPAL_IDENTITY.md frontmatter → settings.principal chain is the fallback. */ export function getPrincipal(): Principal { + const base = legacyPrincipal(); + + let p: LifeosPrincipal | undefined; + try { + p = loadLifeosConfig().principal; + } catch { + return base; // no config yet (fresh install) — legacy chain governs + } + if (!p?.name) return base; + + return { + name: p.name, + pronunciation: p.pronunciation || base.pronunciation, + timezone: p.timezone || base.timezone, + }; +} + +/** The pre-config chain: PRINCIPAL_IDENTITY.md frontmatter, then settings. */ +function legacyPrincipal(): Principal { const fm = loadPrincipalFrontmatter(); const core = fm.core ?? {}; diff --git a/LifeOS/install/test/hooks/Identity.test.ts b/LifeOS/install/test/hooks/Identity.test.ts new file mode 100644 index 0000000000..d0a3b8079a --- /dev/null +++ b/LifeOS/install/test/hooks/Identity.test.ts @@ -0,0 +1,106 @@ +/** + * Falsifiers for identity resolution (hooks/lib/identity.ts). + * + * LifeosConfig refuses to load without a non-empty `[da].name` and + * `[principal].name`/`timezone`, and setup points the principal at those fields + * to name their DA. Before this change getIdentity()/getPrincipal() read none of + * them, so a rename in LIFEOS_CONFIG.toml changed nothing and `[da].color` had + * no effect at all. + * + * Resolution runs in subprocesses because identity.ts derives its identity-file + * paths at module load, so an in-process env change would not reproduce a real + * session. + */ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const INSTALL_DIR = join(import.meta.dir, '..', '..'); +const IDENTITY = join(INSTALL_DIR, 'hooks/lib/identity.ts'); + +const PROBE = ` +import { getIdentity, getDAName, getPrincipal } from ${JSON.stringify(IDENTITY)}; +const i = getIdentity(); +const p = getPrincipal(); +console.log(JSON.stringify({ + name: i.name, fullName: i.fullName, displayName: i.displayName, + color: i.color, voiceId: i.mainDAVoiceID, prosody: i.voice, + daName: getDAName(), + principalName: p.name, principalTimezone: p.timezone, +})); +`; + +function resolveWith(configPath: string | undefined) { + const env = { ...process.env }; + if (configPath === undefined) delete env.LIFEOS_CONFIG_PATH; + else env.LIFEOS_CONFIG_PATH = configPath; + + const r = Bun.spawnSync(['bun', '-e', PROBE], { env, cwd: INSTALL_DIR }); + if (r.exitCode !== 0) { + throw new Error(`probe exited ${r.exitCode}: ${r.stderr.toString()}`); + } + return JSON.parse(r.stdout.toString().trim().split('\n').at(-1)!); +} + +function fixtureConfig(daName: string): string { + const path = join( + mkdtempSync(join(tmpdir(), 'lifeos-identity-')), + 'LIFEOS_CONFIG.toml', + ); + writeFileSync( + path, + [ + '[principal]', + 'name = "Fixture Principal"', + 'timezone = "UTC"', + '', + '[da]', + `name = "${daName}"`, + 'color = "#123456"', + '', + '[da.voices.main]', + 'voice_id = "fixture-voice-id"', + 'stability = 0.5', + '', + ].join('\n'), + ); + return path; +} + +describe('DA identity resolution', () => { + test('[da].name governs the identity, not the placeholder', () => { + const id = resolveWith(fixtureConfig('Testbot')); + expect(id.name).toBe('Testbot'); + expect(id.daName).toBe('Testbot'); + // full_name/display_name are optional in config and derive from the + // configured name — never from DEFAULT_IDENTITY. + expect(id.fullName).toBe('Testbot'); + expect(id.displayName).toBe('Testbot'); + }); + + test('[da].color and [da.voices.main] reach the identity', () => { + const id = resolveWith(fixtureConfig('Testbot')); + expect(id.color).toBe('#123456'); + expect(id.voiceId).toBe('fixture-voice-id'); + expect(id.prosody.stability).toBe(0.5); + }); + + test('a rename in config is what a rename means', () => { + expect(resolveWith(fixtureConfig('First')).name).toBe('First'); + expect(resolveWith(fixtureConfig('Second')).name).toBe('Second'); + }); + + test('[principal] governs the principal, not the placeholder', () => { + const id = resolveWith(fixtureConfig('Testbot')); + expect(id.principalName).toBe('Fixture Principal'); + expect(id.principalTimezone).toBe('UTC'); + }); + + test('an install with no config resolves exactly as before', () => { + const id = resolveWith(join(tmpdir(), 'lifeos-identity-absent', 'nope.toml')); + expect(typeof id.name).toBe('string'); + expect(id.name.length).toBeGreaterThan(0); + expect(id.daName).toBe(id.name); + }); +});