All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Invert the framework updater from a denylist ("framework owns everything unless
preserved") to an explicit allow-list manifest ("operator owns everything unless
framework"). A path the manifest never anticipated resolves to operator-owned by
the fail-safe default, so it is structurally unreachable by any write or prune.
Root cause (#791): `mosaic update` re-seeds via `install.sh` keep-mode, whose
`rsync -a --delete` + hand-maintained PRESERVE_PATHS denylist wiped operator
paths the denylist forgot (agents/*.conf, policy/*.md, *.local.md, harvester
SOP, tools/_lib/credentials.json, unanticipated fleet files).
- framework-manifest.txt: single SSOT ([framework]/[operator], deny-wins,
UNKNOWN=>operator fail-safe), read by BOTH installers.
- src/framework/manifest.ts: pure resolver (parse/matchGlob/resolveOwnership/
frameworkSubtreeRoots/planPrune) — the testable seam.
- tools/_lib/manifest.sh: bash resolver (compiled globs, fork-free hot path),
sourced by install.sh; parity-tested against the TS resolver.
- install.sh keep mode is now manifest-driven (no --delete): overlay-copy
framework files, scoped-prune only retired framework files inside shipped
subtrees. Operator + unknown paths are never written or deleted.
- file-ops.syncDirectory gains an isOperatorOwned guard; file-adapter derives it
from the shared manifest, replacing the drifted hardcoded preservePaths.
Tests (TDD, red->green):
- HARD GATE test-upgrade-manifest-guard.sh: 10 operator sentinels (incl. an
unanticipated one) survive a keep-mode reseed byte-identical + mtime-unchanged;
retired framework file pruned; secret value absent from output. RED 31 fail on
the old installer -> GREEN 48 pass. Wired merge-blocking into CI.
- manifest-parity.spec.ts (§6.1): bash<->TS agree on 34 paths + subtree roots.
- manifest.spec.ts: 18 tests incl. planPrune property test + shipped-tree
completeness (§6.2).
- test-install-migration.sh F6 flipped: an unanticipated operator fleet file now
MUST survive keep-mode reseed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { readFileSync, existsSync, statSync, copyFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
/**
|
|
* Framework-contract files that `syncFramework` seeds from `framework/defaults/`
|
|
* into the mosaic home root on first install. These are the only files the
|
|
* wizard is allowed to touch as a one-time seed — SOUL.md and USER.md are
|
|
* generated from templates by their respective wizard stages with
|
|
* user-supplied values, and anything else under `defaults/` (README.md,
|
|
* audit snapshots, etc.) is framework-internal and must not leak into the
|
|
* user's mosaic home.
|
|
*
|
|
* This list must match the explicit seed loop in
|
|
* packages/mosaic/framework/install.sh.
|
|
*/
|
|
// Framework-owned contract files: re-copied from defaults/ on every upgrade (a
|
|
// divergent existing copy is backed up once to <file>.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';
|
|
import { renderTemplate } from '../template/engine.js';
|
|
import {
|
|
buildSoulTemplateVars,
|
|
buildUserTemplateVars,
|
|
buildToolsTemplateVars,
|
|
} from '../template/builders.js';
|
|
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
|
|
import { loadManifest, resolveOwnership } from '../framework/manifest.js';
|
|
|
|
/**
|
|
* Parse a SoulConfig from an existing SOUL.md file.
|
|
*/
|
|
function parseSoulFromMarkdown(content: string): SoulConfig {
|
|
const config: SoulConfig = {};
|
|
|
|
const nameMatch = content.match(/You are \*\*(.+?)\*\*/);
|
|
if (nameMatch?.[1]) config.agentName = nameMatch[1];
|
|
|
|
const roleMatch = content.match(/Role identity: (.+)/);
|
|
if (roleMatch?.[1]) config.roleDescription = roleMatch[1];
|
|
|
|
if (content.includes('Be direct, concise')) {
|
|
config.communicationStyle = 'direct';
|
|
} else if (content.includes('Be warm and conversational')) {
|
|
config.communicationStyle = 'friendly';
|
|
} else if (content.includes('Use professional, structured')) {
|
|
config.communicationStyle = 'formal';
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Parse a UserConfig from an existing USER.md file.
|
|
*/
|
|
function parseUserFromMarkdown(content: string): UserConfig {
|
|
const config: UserConfig = {};
|
|
|
|
const nameMatch = content.match(/\*\*Name:\*\* (.+)/);
|
|
if (nameMatch?.[1]) config.userName = nameMatch[1];
|
|
|
|
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
|
|
if (pronounsMatch?.[1]) config.pronouns = pronounsMatch[1];
|
|
|
|
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
|
|
if (tzMatch?.[1]) config.timezone = tzMatch[1];
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Parse a ToolsConfig from an existing TOOLS.md file.
|
|
*/
|
|
function parseToolsFromMarkdown(content: string): ToolsConfig {
|
|
const config: ToolsConfig = {};
|
|
|
|
const credsMatch = content.match(/\*\*Location:\*\* (.+)/);
|
|
if (credsMatch?.[1]) config.credentialsLocation = credsMatch[1];
|
|
|
|
return config;
|
|
}
|
|
|
|
export class FileConfigAdapter implements ConfigService {
|
|
constructor(
|
|
private mosaicHome: string,
|
|
private sourceDir: string,
|
|
) {}
|
|
|
|
async readSoul(): Promise<SoulConfig> {
|
|
const path = join(this.mosaicHome, 'SOUL.md');
|
|
if (!existsSync(path)) return {};
|
|
return parseSoulFromMarkdown(readFileSync(path, 'utf-8'));
|
|
}
|
|
|
|
async readUser(): Promise<UserConfig> {
|
|
const path = join(this.mosaicHome, 'USER.md');
|
|
if (!existsSync(path)) return {};
|
|
return parseUserFromMarkdown(readFileSync(path, 'utf-8'));
|
|
}
|
|
|
|
async readTools(): Promise<ToolsConfig> {
|
|
const path = join(this.mosaicHome, 'TOOLS.md');
|
|
if (!existsSync(path)) return {};
|
|
return parseToolsFromMarkdown(readFileSync(path, 'utf-8'));
|
|
}
|
|
|
|
async writeSoul(config: SoulConfig): Promise<void> {
|
|
const validated = soulSchema.parse(config);
|
|
const templatePath = this.findTemplate('SOUL.md.template');
|
|
if (!templatePath) return;
|
|
|
|
const template = readFileSync(templatePath, 'utf-8');
|
|
const vars = buildSoulTemplateVars(validated);
|
|
const output = renderTemplate(template, vars);
|
|
|
|
const outPath = join(this.mosaicHome, 'SOUL.md');
|
|
backupFile(outPath);
|
|
atomicWrite(outPath, output);
|
|
}
|
|
|
|
async writeUser(config: UserConfig): Promise<void> {
|
|
const validated = userSchema.parse(config);
|
|
const templatePath = this.findTemplate('USER.md.template');
|
|
if (!templatePath) return;
|
|
|
|
const template = readFileSync(templatePath, 'utf-8');
|
|
const vars = buildUserTemplateVars(validated);
|
|
const output = renderTemplate(template, vars);
|
|
|
|
const outPath = join(this.mosaicHome, 'USER.md');
|
|
backupFile(outPath);
|
|
atomicWrite(outPath, output);
|
|
}
|
|
|
|
async writeTools(config: ToolsConfig): Promise<void> {
|
|
const validated = toolsSchema.parse(config);
|
|
const templatePath = this.findTemplate('TOOLS.md.template');
|
|
if (!templatePath) return;
|
|
|
|
const template = readFileSync(templatePath, 'utf-8');
|
|
const vars = buildToolsTemplateVars(validated);
|
|
const output = renderTemplate(template, vars);
|
|
|
|
const outPath = join(this.mosaicHome, 'TOOLS.md');
|
|
backupFile(outPath);
|
|
atomicWrite(outPath, output);
|
|
}
|
|
|
|
async syncFramework(action: InstallAction): Promise<void> {
|
|
// #791: ownership is derived from the shared framework manifest
|
|
// (packages/mosaic/framework/framework-manifest.txt) — the SAME file the
|
|
// bash installer reads — so the TS and bash paths can never drift. On an
|
|
// upgrade (keep/reconfigure) the sync must NEVER write an operator-owned
|
|
// path: every operator file, and any path the manifest never anticipated
|
|
// (which resolves to operator by the fail-safe default), is left untouched.
|
|
// A fresh install ('overwrite'/'reconfigure' onto an empty home) seeds the
|
|
// full tree, so the guard applies only when preserving an existing home.
|
|
const guardOwnership = action === 'keep' || action === 'reconfigure';
|
|
const manifest = guardOwnership ? loadManifest(this.sourceDir) : undefined;
|
|
|
|
syncDirectory(this.sourceDir, this.mosaicHome, {
|
|
excludeGit: true,
|
|
isOperatorOwned: manifest
|
|
? (relPath) => resolveOwnership(manifest, relPath) === 'operator'
|
|
: undefined,
|
|
});
|
|
|
|
// 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
|
|
// user-supplied values; seeding them from defaults would clobber the
|
|
// identity flow and leak placeholder content into the mosaic home.
|
|
const defaultsDir = join(this.sourceDir, 'defaults');
|
|
if (existsSync(defaultsDir)) {
|
|
// Framework-owned: overwrite from defaults/ every sync; back up a divergent
|
|
// existing copy ONCE to <file>.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;
|
|
// Already current — skip to avoid mtime churn.
|
|
if (existsSync(dest) && readFileSync(src).equals(readFileSync(dest))) continue;
|
|
const bak = `${dest}.pre-constitution.bak`;
|
|
if (existsSync(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;
|
|
if (!existsSync(src) || !statSync(src).isFile()) continue;
|
|
copyFileSync(src, dest);
|
|
}
|
|
}
|
|
}
|
|
|
|
async readAll(): Promise<ResolvedConfig> {
|
|
const [soul, user, tools] = await Promise.all([
|
|
this.readSoul(),
|
|
this.readUser(),
|
|
this.readTools(),
|
|
]);
|
|
return { soul, user, tools };
|
|
}
|
|
|
|
async getValue(dottedKey: string): Promise<unknown> {
|
|
const parts = dottedKey.split('.');
|
|
const section = parts[0] ?? '';
|
|
const field = parts.slice(1).join('.');
|
|
const config = await this.readAll();
|
|
if (!this.isValidSection(section)) return undefined;
|
|
const sectionData = config[section as ConfigSection] as Record<string, unknown>;
|
|
return field ? sectionData[field] : sectionData;
|
|
}
|
|
|
|
async setValue(dottedKey: string, value: string): Promise<unknown> {
|
|
const parts = dottedKey.split('.');
|
|
const section = parts[0] ?? '';
|
|
const field = parts.slice(1).join('.');
|
|
if (!this.isValidSection(section) || !field) {
|
|
throw new Error(
|
|
`Invalid key "${dottedKey}". Use format <section>.<field> (e.g. soul.agentName).`,
|
|
);
|
|
}
|
|
|
|
const previous = await this.getValue(dottedKey);
|
|
|
|
if (section === 'soul') {
|
|
const current = await this.readSoul();
|
|
await this.writeSoul({ ...current, [field]: value });
|
|
} else if (section === 'user') {
|
|
const current = await this.readUser();
|
|
await this.writeUser({ ...current, [field]: value });
|
|
} else {
|
|
const current = await this.readTools();
|
|
await this.writeTools({ ...current, [field]: value });
|
|
}
|
|
|
|
return previous;
|
|
}
|
|
|
|
getConfigPath(section?: ConfigSection): string {
|
|
if (!section) return this.mosaicHome;
|
|
const fileMap: Record<ConfigSection, string> = {
|
|
soul: join(this.mosaicHome, 'SOUL.md'),
|
|
user: join(this.mosaicHome, 'USER.md'),
|
|
tools: join(this.mosaicHome, 'TOOLS.md'),
|
|
};
|
|
return fileMap[section];
|
|
}
|
|
|
|
isInitialized(): boolean {
|
|
return (
|
|
existsSync(join(this.mosaicHome, 'SOUL.md')) ||
|
|
existsSync(join(this.mosaicHome, 'USER.md')) ||
|
|
existsSync(join(this.mosaicHome, 'TOOLS.md'))
|
|
);
|
|
}
|
|
|
|
private isValidSection(s: string): s is ConfigSection {
|
|
return s === 'soul' || s === 'user' || s === 'tools';
|
|
}
|
|
|
|
/**
|
|
* Look for template in source dir first, then mosaic home.
|
|
*/
|
|
private findTemplate(name: string): string | null {
|
|
const candidates = [
|
|
join(this.sourceDir, 'templates', name),
|
|
join(this.mosaicHome, 'templates', name),
|
|
];
|
|
for (const path of candidates) {
|
|
if (existsSync(path)) return path;
|
|
}
|
|
return null;
|
|
}
|
|
}
|