From 03a53c543a79c518b7ea3f8745eda0eb333317f2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 11 Apr 2026 20:56:55 -0500 Subject: [PATCH] fix(mosaic): seed TOOLS.md from defaults on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #457. The bash framework installer only seeded AGENTS.md and STANDARDS.md from defaults/, even though TOOLS.md is listed in PRESERVE_PATHS and AGENTS.md declares it as mandatory reading at position 5 of the load order. A fresh bootstrap install therefore left ~/.config/mosaic/TOOLS.md missing and the agent contract pointing at a non-existent file. Fixes: - packages/mosaic/framework/install.sh — extend the explicit defaults-seed loop from "AGENTS.md STANDARDS.md" to "AGENTS.md STANDARDS.md TOOLS.md". - packages/mosaic/src/config/file-adapter.ts — replace the greedy readdirSync loop in syncFramework with an exported DEFAULT_SEED_FILES whitelist, so the TS wizard no longer silently seeds the Jarvis-flavored defaults/SOUL.md, placeholder defaults/USER.md, or internal README.md/AUDIT-*.md into the mosaic home. Also align preservePaths with the bash PRESERVE_PATHS list (AGENTS.md, STANDARDS.md, sources, and credentials were previously missing) so both install paths have the same upgrade-preservation semantics. - packages/mosaic/framework/templates/TOOLS.md.template — replace stale ~/.config/mosaic/rails/ references with ~/.config/mosaic/tools/. The rails/ tree was renamed to tools/ in the v1→v2 framework migration. Tests: - packages/mosaic/src/config/file-adapter.test.ts (new, 5 tests): pins the whitelist, asserts SOUL.md/USER.md/README.md/AUDIT-*.md are not seeded, verifies existing user contract files (including AGENTS.md) survive a keep-mode sync, and asserts a no-op when defaults/ is absent. Baselines: mosaic typecheck / lint green. Full mosaic vitest 275/276 — the one failure (src/commands/uninstall.spec.ts:138) is a pre-existing EACCES issue on main and is unrelated to this change. Repo-wide typecheck / lint / format:check green. Live smoke of `bash framework/install.sh` against a tmp MOSAIC_HOME confirms the installer now prints "Seeded TOOLS.md from defaults" and the file lands. Ships in @mosaicstack/mosaic 0.0.30. Co-Authored-By: Claude Opus 4.6 --- docs/scratchpads/tools-md-seeding-20260411.md | 110 ++++++++++++++ packages/mosaic/framework/install.sh | 11 +- .../framework/templates/TOOLS.md.template | 18 +-- .../mosaic/src/config/file-adapter.test.ts | 134 ++++++++++++++++++ packages/mosaic/src/config/file-adapter.ts | 58 ++++++-- 5 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 docs/scratchpads/tools-md-seeding-20260411.md create mode 100644 packages/mosaic/src/config/file-adapter.test.ts diff --git a/docs/scratchpads/tools-md-seeding-20260411.md b/docs/scratchpads/tools-md-seeding-20260411.md new file mode 100644 index 0000000..976f603 --- /dev/null +++ b/docs/scratchpads/tools-md-seeding-20260411.md @@ -0,0 +1,110 @@ +# Hotfix Scratchpad — `install.sh` does not seed `TOOLS.md` + +- **Issue:** mosaicstack/stack#457 +- **Branch:** `fix/tools-md-seeding` +- **Type:** Out-of-mission hotfix (not part of Install UX v2 mission) +- **Started:** 2026-04-11 +- **Ships in:** `@mosaicstack/mosaic` 0.0.30 + +## Objective + +Ensure `~/.config/mosaic/TOOLS.md` is created on every supported install path so the mandatory AGENTS.md load order actually resolves. The load order lists `TOOLS.md` at position 5 but the bash installer never seeds it. + +## Root cause + +`packages/mosaic/framework/install.sh:228-236` — the post-sync "Seed defaults" loop explicitly lists `AGENTS.md STANDARDS.md`: + +```bash +DEFAULTS_DIR="$TARGET_DIR/defaults" +if [[ -d "$DEFAULTS_DIR" ]]; then + for default_file in AGENTS.md STANDARDS.md; do # ← missing TOOLS.md + 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" + fi + done +fi +``` + +`TOOLS.md` is listed in `PRESERVE_PATHS` (line 24) but never created in the first place. A fresh bootstrap install via `tools/install.sh → framework/install.sh` leaves `~/.config/mosaic/TOOLS.md` absent, and the agent load order then points at a missing file. + +### Secondary: TypeScript `syncFramework` is too greedy + +`packages/mosaic/src/config/file-adapter.ts:133-160` — `FileConfigAdapter.syncFramework` correctly seeds TOOLS.md, but it does so by iterating _every_ file in `framework/defaults/`: + +```ts +for (const entry of readdirSync(defaultsDir)) { + const dest = join(this.mosaicHome, entry); + if (!existsSync(dest)) { + copyFileSync(join(defaultsDir, entry), dest); + } +} +``` + +`framework/defaults/` contains: + +``` +AGENTS.md +AUDIT-2026-02-17-framework-consistency.md +README.md +SOUL.md ← hardcoded "Jarvis" +STANDARDS.md +TOOLS.md +USER.md +``` + +So on a fresh install the TS wizard would silently copy the `Jarvis`-flavored `SOUL.md` + placeholder `USER.md` + internal `AUDIT-*.md` and `README.md` into the user's mosaic home before `mosaic init` ever prompts them. That's a latent identity bug as well as a root-clutter bug — the wizard's own stages are responsible for generating `SOUL.md`/`USER.md` via templates. + +### Tertiary: stale `TOOLS.md.template` + +`packages/mosaic/framework/templates/TOOLS.md.template` still references `~/.config/mosaic/rails/git/…` and `~/.config/mosaic/rails/codex/…`. The `rails/` tree was renamed to `tools/` in the v1→v2 migration (see `run_migrations` in `install.sh`, which removes the old `rails/` symlink). Any user who does run `mosaic init` ends up with a `TOOLS.md` that points to paths that no longer exist. + +## Scope of this fix + +1. **`packages/mosaic/framework/install.sh`** — extend the explicit seed list to include `TOOLS.md`. +2. **`packages/mosaic/src/config/file-adapter.ts`** — restrict `syncFramework` defaults-seeding to an explicit whitelist (`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`) so the TS wizard never accidentally seeds `SOUL.md`/`USER.md`/`README.md`/`AUDIT-*.md` into the mosaic home. +3. **`packages/mosaic/framework/templates/TOOLS.md.template`** — replace `rails/` with `tools/` in the wrapper-path examples (minimal surgical fix; full template modernization is out of scope for a 0.0.30 hotfix). +4. **Regression test** — unit test around `FileConfigAdapter.syncFramework` that runs against a tmpdir fixture asserting: + - `TOOLS.md` is seeded when absent + - `AGENTS.md` / `STANDARDS.md` are still seeded when absent + - `SOUL.md` / `USER.md` are **not** seeded from `defaults/` (the wizard stages own those) + - Existing root files are not clobbered. + +Out of scope (tracked separately / future work): + +- Regenerating `defaults/SOUL.md` and `defaults/USER.md` so they no longer contain Jarvis-specific content. +- Fully modernizing `TOOLS.md.template` to match the rich canonical `defaults/TOOLS.md` reference. +- `issue-create.sh` / `pr-create.sh` `eval` bugs (already captured to OpenBrain from the prior hotfix). + +## Plan / checklist + +- [ ] Branch `fix/tools-md-seeding` from `main` (at `b2cbf89`) +- [ ] File Gitea issue (direct API; wrappers broken for bodies with backticks) +- [ ] Scratchpad created (this file) +- [ ] `install.sh` seed loop extended to `AGENTS.md STANDARDS.md TOOLS.md` +- [ ] `file-adapter.ts` seeding restricted to explicit whitelist +- [ ] `TOOLS.md.template` `rails/` → `tools/` +- [ ] Regression test added (`file-adapter.test.ts`) — failing first, then green +- [ ] `pnpm --filter @mosaicstack/mosaic run typecheck` green +- [ ] `pnpm --filter @mosaicstack/mosaic run lint` green +- [ ] `pnpm --filter @mosaicstack/mosaic exec vitest run` — new test green, no new failures beyond the known pre-existing `uninstall.spec.ts:138` +- [ ] Repo baselines: `pnpm typecheck` / `pnpm lint` / `pnpm format:check` +- [ ] Independent code review (`feature-dev:code-reviewer`, sonnet tier) +- [ ] Commit + push +- [ ] PR opened via Gitea API +- [ ] CI queue guard cleared (bypass local `ci-queue-wait.sh` if stale origin URL breaks it; query Gitea API directly) +- [ ] CI green on PR +- [ ] PR merged (squash) +- [ ] CI green on main +- [ ] Issue closed with link to merge commit +- [ ] `chore/release-mosaic-0.0.30` branch bumps `packages/mosaic/package.json` 0.0.29 → 0.0.30 +- [ ] Release PR opened + merged +- [ ] `.woodpecker/publish.yml` auto-publishes to Gitea npm registry +- [ ] Publish verified (`npm view @mosaicstack/mosaic version` or registry check) + +## Risks / blockers + +- `ci-queue-wait.sh` wrapper may still crash on stale `origin` URL (captured in OpenBrain from prior hotfix). Workaround: query Gitea API directly for running/queued pipelines. +- `issue-create.sh` / `pr-create.sh` `eval` bugs. Workaround: Gitea API direct call. +- `uninstall.spec.ts:138` is a pre-existing failure on main; not this change's problem. +- Publish flow is fire-and-forget on main push — if `publish.yml` fails, rollback means republishing a follow-up patch, not reverting the version bump. diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 4a9ecc1..386160f 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -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" diff --git a/packages/mosaic/framework/templates/TOOLS.md.template b/packages/mosaic/framework/templates/TOOLS.md.template index 2f44d83..e8ee36b 100644 --- a/packages/mosaic/framework/templates/TOOLS.md.template +++ b/packages/mosaic/framework/templates/TOOLS.md.template @@ -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 diff --git a/packages/mosaic/src/config/file-adapter.test.ts b/packages/mosaic/src/config/file-adapter.test.ts new file mode 100644 index 0000000..6864159 --- /dev/null +++ b/packages/mosaic/src/config/file-adapter.test.ts @@ -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; + + 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); + }); +}); diff --git a/packages/mosaic/src/config/file-adapter.ts b/packages/mosaic/src/config/file-adapter.ts index 147b73b..3096b49 100644 --- a/packages/mosaic/src/config/file-adapter.ts +++ b/packages/mosaic/src/config/file-adapter.ts @@ -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 { + // 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); } } } -- 2.49.1