diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-init b/packages/mosaic/framework/tools/_scripts/mosaic-init index 57704c9..08830f9 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-init +++ b/packages/mosaic/framework/tools/_scripts/mosaic-init @@ -274,6 +274,13 @@ detect_existing_config echo "[mosaic-init] Generating SOUL.md — agent identity contract" echo "" +# Fail-closed persona: in non-interactive mode the agent NAME must be supplied +# explicitly (--name) — never silently ship an agent named "Assistant". +if [[ $NON_INTERACTIVE -eq 1 && -z "$AGENT_NAME" ]]; then + echo "[mosaic-init] ERROR: --name (agent name) is required in non-interactive mode." >&2 + exit 1 +fi + prompt_if_empty AGENT_NAME "What name should agents use" "Assistant" prompt_if_empty ROLE_DESCRIPTION "Agent role description" "execution partner and visibility engine" diff --git a/packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh b/packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh index 4324f8e..9de53c7 100755 --- a/packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh +++ b/packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh @@ -53,9 +53,15 @@ _selftest() { local tmp; tmp="$(mktemp -d)" || return 1 printf 'contact jason.woltje at jarvis-brain (PDA-friendly)\n' > "$tmp/planted.md" printf 'X="${VAR:-$HOME/src/whatever/x.json}"\n' > "$tmp/planted.sh" + printf 'name: jason-woltje\n' > "$tmp/planted.yaml" + printf '[Service]\nUser=jarvis\n' > "$tmp/planted.service" local rc=0 grep -qIEi "$DENYLIST" "$tmp/planted.md" || { echo "✗ SELF-TEST: identity denylist regex broken" >&2; rc=1; } grep -qIE "$STRUCTURAL_SH" "$tmp/planted.sh" || { echo "✗ SELF-TEST: structural regex broken" >&2; rc=1; } + # Prove the identity scan covers the config formats it claims to (yaml/service/etc). + local n_ext + n_ext=$(find "$tmp" -type f \( -name '*.yaml' -o -name '*.service' \) -print0 | xargs -0 -r grep -lIEi "$DENYLIST" 2>/dev/null | wc -l) + [[ "$n_ext" -eq 2 ]] || { echo "✗ SELF-TEST: identity scan does not cover .yaml/.service extensions" >&2; rc=1; } rm -rf "$tmp"; return $rc } _selftest || exit 2 diff --git a/packages/mosaic/src/config/file-adapter.test.ts b/packages/mosaic/src/config/file-adapter.test.ts index 48f411a..b8629d7 100644 --- a/packages/mosaic/src/config/file-adapter.test.ts +++ b/packages/mosaic/src/config/file-adapter.test.ts @@ -99,11 +99,8 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => { ); }); - it('preserves existing contract files — never overwrites user customization', async () => { - // Also plant a root-level AGENTS.md in sourceDir so that `syncDirectory` - // itself (not just the seed loop) has something to try to overwrite. - // Without this, the test would silently pass even if preserve semantics - // were broken in syncDirectory. + it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => { + // Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised. writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n'); writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n'); @@ -112,18 +109,50 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => { const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('keep'); + // User-seeded TOOLS.md is preserved. expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe( '# user-customized TOOLS\n', ); - expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe( + // Framework-owned AGENTS.md is overwritten from defaults/ ... + expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n'); + // ... and the user's prior copy is backed up exactly once. + expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe( '# user-customized AGENTS\n', ); - // And the missing contract file still gets seeded. + // Framework-owned STANDARDS.md (absent) gets installed. expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain( '# STANDARDS default', ); }); + it('backs up a divergent framework-owned file only once (idempotent across re-sync)', async () => { + writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n'); + const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); + + await adapter.syncFramework('keep'); // 1st: backup created, AGENTS overwritten + await adapter.syncFramework('keep'); // 2nd: AGENTS already == default, no new backup + + expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe( + '# user-customized AGENTS\n', + ); + }); + + it('preserves SOUL.md and credentials through a framework-owned overwrite', async () => { + writeFileSync(join(fixture.mosaicHome, 'SOUL.md'), '# my persona\n'); + writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n'); + mkdirSync(join(fixture.mosaicHome, 'credentials'), { recursive: true }); + writeFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'token\n'); + + const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); + await adapter.syncFramework('keep'); + + expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf-8')).toBe('# my persona\n'); + expect(readFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'utf-8')).toBe( + 'token\n', + ); + expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n'); + }); + it('is a no-op for seeding when defaults/ dir does not exist', async () => { rmSync(fixture.defaultsDir, { recursive: true }); diff --git a/packages/mosaic/src/config/file-adapter.ts b/packages/mosaic/src/config/file-adapter.ts index 3b6cd9c..ea92f6d 100644 --- a/packages/mosaic/src/config/file-adapter.ts +++ b/packages/mosaic/src/config/file-adapter.ts @@ -13,12 +13,17 @@ import { join } from 'node:path'; * This list must match the explicit seed loop in * packages/mosaic/framework/install.sh. */ -export const DEFAULT_SEED_FILES = [ - 'CONSTITUTION.md', - 'AGENTS.md', - 'STANDARDS.md', - 'TOOLS.md', -] as const; +// Framework-owned contract files: re-copied from defaults/ on every upgrade (a +// divergent existing copy is backed up once to .pre-constitution.bak first). +// MUST match FRAMEWORK_OWNED in packages/mosaic/framework/install.sh (append-friendly). +export const FRAMEWORK_OWNED_FILES = ['CONSTITUTION.md', 'AGENTS.md', 'STANDARDS.md'] as const; + +// User-seeded contract files: written once on first install, then owned by the user. +// MUST match USER_SEEDED in packages/mosaic/framework/install.sh. +export const USER_SEEDED_FILES = ['TOOLS.md'] as const; + +// Union, retained for callers/tests that assert the full seed set on a fresh install. +export const DEFAULT_SEED_FILES = [...FRAMEWORK_OWNED_FILES, ...USER_SEEDED_FILES] as const; import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js'; import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js'; import { soulSchema, userSchema, toolsSchema } from './schemas.js'; @@ -159,6 +164,7 @@ export class FileConfigAdapter implements ConfigService { const preservePaths = action === 'keep' || action === 'reconfigure' ? [ + 'CONSTITUTION.md', 'AGENTS.md', 'SOUL.md', 'USER.md', @@ -175,10 +181,10 @@ export class FileConfigAdapter implements ConfigService { excludeGit: true, }); - // Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md) - // from framework/defaults/ into the mosaic home root if they don't - // exist yet. These are written on first install only and are never - // overwritten afterwards — the user may have customized them. + // Reconcile framework-contract files from framework/defaults/ into the mosaic + // home root: framework-owned files (CONSTITUTION/AGENTS/STANDARDS) are overwritten + // every upgrade (backup-once); user-seeded files (TOOLS) are written on first + // install only. Mirrors reconcile_framework_files() in install.sh. // // SOUL.md and USER.md are deliberately NOT seeded here. They are // generated from templates by the soul/user wizard stages with @@ -186,7 +192,20 @@ export class FileConfigAdapter implements ConfigService { // identity flow and leak placeholder content into the mosaic home. const defaultsDir = join(this.sourceDir, 'defaults'); if (existsSync(defaultsDir)) { - for (const entry of DEFAULT_SEED_FILES) { + // Framework-owned: overwrite from defaults/ every sync; back up a divergent + // existing copy ONCE to .pre-constitution.bak before the first overwrite. + for (const entry of FRAMEWORK_OWNED_FILES) { + const src = join(defaultsDir, entry); + const dest = join(this.mosaicHome, entry); + if (!existsSync(src) || !statSync(src).isFile()) continue; + const bak = `${dest}.pre-constitution.bak`; + if (existsSync(dest) && !readFileSync(src).equals(readFileSync(dest)) && !existsSync(bak)) { + copyFileSync(dest, bak); + } + copyFileSync(src, dest); + } + // User-seeded: write only if absent. + for (const entry of USER_SEEDED_FILES) { const src = join(defaultsDir, entry); const dest = join(this.mosaicHome, entry); if (existsSync(dest)) continue;