fix(mosaic): seed TOOLS.md from defaults on install (#458)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #458.
This commit is contained in:
@@ -222,12 +222,17 @@ sync_framework
|
||||
mkdir -p "$TARGET_DIR/memory"
|
||||
mkdir -p "$TARGET_DIR/credentials"
|
||||
|
||||
# Seed defaults — copy from defaults/ to framework root if not already present.
|
||||
# These are user-editable files that ship with sensible defaults but should
|
||||
# Seed defaults — copy framework contract files from defaults/ to framework
|
||||
# root if not already present. These ship with sensible defaults but must
|
||||
# never be overwritten once the user has customized them.
|
||||
#
|
||||
# This list must match the framework-contract whitelist in
|
||||
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
|
||||
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
|
||||
# by `mosaic init` from templates with user-supplied values.
|
||||
DEFAULTS_DIR="$TARGET_DIR/defaults"
|
||||
if [[ -d "$DEFAULTS_DIR" ]]; then
|
||||
for default_file in AGENTS.md STANDARDS.md; do
|
||||
for default_file in AGENTS.md STANDARDS.md TOOLS.md; do
|
||||
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
|
||||
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
|
||||
ok "Seeded $default_file from defaults"
|
||||
|
||||
@@ -5,32 +5,32 @@ Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||
|
||||
## Mosaic Git Wrappers (Use First)
|
||||
|
||||
Mosaic wrappers at `~/.config/mosaic/rails/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
|
||||
```bash
|
||||
# Issues
|
||||
~/.config/mosaic/rails/git/issue-create.sh
|
||||
~/.config/mosaic/rails/git/issue-close.sh
|
||||
~/.config/mosaic/tools/git/issue-create.sh
|
||||
~/.config/mosaic/tools/git/issue-close.sh
|
||||
|
||||
# PRs
|
||||
~/.config/mosaic/rails/git/pr-create.sh
|
||||
~/.config/mosaic/rails/git/pr-merge.sh
|
||||
~/.config/mosaic/tools/git/pr-create.sh
|
||||
~/.config/mosaic/tools/git/pr-merge.sh
|
||||
|
||||
# Milestones
|
||||
~/.config/mosaic/rails/git/milestone-create.sh
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge)
|
||||
~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
## Code Review (Codex)
|
||||
|
||||
```bash
|
||||
# Code quality review
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
|
||||
# Security review
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
## Git Providers
|
||||
|
||||
134
packages/mosaic/src/config/file-adapter.test.ts
Normal file
134
packages/mosaic/src/config/file-adapter.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
|
||||
|
||||
/**
|
||||
* Regression tests for the `FileConfigAdapter.syncFramework` seed behavior.
|
||||
*
|
||||
* Background: the bash installer (`framework/install.sh`) and this TS wizard
|
||||
* path both seed framework-contract files from `framework/defaults/` into the
|
||||
* user's mosaic home on first install. Before this fix:
|
||||
*
|
||||
* - The bash installer only seeded `AGENTS.md` and `STANDARDS.md`, leaving
|
||||
* `TOOLS.md` missing despite it being listed as mandatory in the
|
||||
* AGENTS.md load order (position 5).
|
||||
* - The TS wizard iterated every file in `defaults/` and copied it to the
|
||||
* mosaic home root — including `defaults/SOUL.md` (hardcoded "Jarvis"),
|
||||
* `defaults/USER.md` (placeholder), and internal framework files like
|
||||
* `README.md` and `AUDIT-*.md`. That clobbered the identity flow on
|
||||
* fresh installs and leaked framework-internal clutter into the user's
|
||||
* home directory.
|
||||
*
|
||||
* This suite pins the whitelist and the preservation semantics so both
|
||||
* regressions stay fixed.
|
||||
*/
|
||||
|
||||
function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-file-adapter-'));
|
||||
const sourceDir = join(root, 'source');
|
||||
const mosaicHome = join(root, 'mosaic-home');
|
||||
const defaultsDir = join(sourceDir, 'defaults');
|
||||
|
||||
mkdirSync(defaultsDir, { recursive: true });
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
// Framework-contract defaults we expect the wizard to seed.
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
||||
|
||||
// Non-contract files we must NOT seed on first install.
|
||||
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||
writeFileSync(join(defaultsDir, 'USER.md'), '# USER default (should not be seeded)\n');
|
||||
writeFileSync(join(defaultsDir, 'README.md'), '# README (framework-internal)\n');
|
||||
writeFileSync(
|
||||
join(defaultsDir, 'AUDIT-2026-02-17-framework-consistency.md'),
|
||||
'# Audit snapshot\n',
|
||||
);
|
||||
|
||||
return { sourceDir, mosaicHome, defaultsDir };
|
||||
}
|
||||
|
||||
describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
let fixture: ReturnType<typeof makeFixture>;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(join(fixture.sourceDir, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('seeds the three framework-contract files on a fresh mosaic home', async () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
for (const name of DEFAULT_SEED_FILES) {
|
||||
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||
}
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
||||
'# TOOLS default',
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
// SOUL.md and USER.md live in defaults/ for historical reasons, but they
|
||||
// are template-rendered per-user by the wizard stages. Seeding them here
|
||||
// would clobber the identity flow and leak placeholder content.
|
||||
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT seed README.md or AUDIT-*.md from defaults/', async () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
expect(existsSync(join(fixture.mosaicHome, 'README.md'))).toBe(false);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'AUDIT-2026-02-17-framework-consistency.md'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||
|
||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
|
||||
'# user-customized TOOLS\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe(
|
||||
'# user-customized AGENTS\n',
|
||||
);
|
||||
// And the missing contract file still gets seeded.
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
|
||||
'# STANDARDS default',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
rmSync(fixture.defaultsDir, { recursive: true });
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await expect(adapter.syncFramework('fresh')).resolves.toBeUndefined();
|
||||
|
||||
expect(existsSync(join(fixture.mosaicHome, 'TOOLS.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,19 @@
|
||||
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
|
||||
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.
|
||||
*/
|
||||
export const DEFAULT_SEED_FILES = ['AGENTS.md', 'STANDARDS.md', 'TOOLS.md'] 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';
|
||||
@@ -131,9 +145,24 @@ export class FileConfigAdapter implements ConfigService {
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
||||
// the bash and TS install paths have the same upgrade-preservation
|
||||
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
||||
// seeded from defaults/ on first install and preserved thereafter;
|
||||
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
||||
// must never be touched by the framework sync.
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
|
||||
? [
|
||||
'AGENTS.md',
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'TOOLS.md',
|
||||
'STANDARDS.md',
|
||||
'memory',
|
||||
'sources',
|
||||
'credentials',
|
||||
]
|
||||
: [];
|
||||
|
||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||
@@ -141,20 +170,23 @@ export class FileConfigAdapter implements ConfigService {
|
||||
excludeGit: true,
|
||||
});
|
||||
|
||||
// Copy default root-level .md files (AGENTS.md, STANDARDS.md, etc.)
|
||||
// from framework/defaults/ into mosaicHome root if they don't exist yet.
|
||||
// These are framework contracts — only written on first install, never
|
||||
// overwritten (user may have customized them).
|
||||
// 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.
|
||||
//
|
||||
// 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)) {
|
||||
for (const entry of readdirSync(defaultsDir)) {
|
||||
for (const entry of DEFAULT_SEED_FILES) {
|
||||
const src = join(defaultsDir, entry);
|
||||
const dest = join(this.mosaicHome, entry);
|
||||
if (!existsSync(dest)) {
|
||||
const src = join(defaultsDir, entry);
|
||||
if (statSync(src).isFile()) {
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
if (existsSync(dest)) continue;
|
||||
if (!existsSync(src) || !statSync(src).isFile()) continue;
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user