diff --git a/src/core/skillpack/bundle.ts b/src/core/skillpack/bundle.ts index 8cc3eb697..ec1130bc5 100644 --- a/src/core/skillpack/bundle.ts +++ b/src/core/skillpack/bundle.ts @@ -10,6 +10,8 @@ import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; import { join, dirname, isAbsolute, resolve } from 'path'; +import { parseMarkdown } from '../markdown.ts'; + export interface BundleManifest { name: string; version: string; @@ -224,3 +226,240 @@ export function pathSlug(relPath: string): string { export function bundledSkillSlugs(manifest: BundleManifest): string[] { return manifest.skills.map(pathSlug).sort(); } + +// --------------------------------------------------------------------------- +// Frontmatter `sources:` — paired source files declared by a skill (v0.33+) +// --------------------------------------------------------------------------- +// +// A skill that ships paired implementation (e.g. book-mirror's +// `src/commands/book-mirror.ts`) declares it in its SKILL.md frontmatter: +// +// --- +// name: book-mirror +// sources: +// - src/commands/book-mirror.ts +// --- +// +// The bundler reads this on every enumerate; scaffold copies the paired +// files alongside the skill markdown. Single source of truth co-located +// with the skill — no parallel manifest in openclaw.plugin.json. + +/** A skill's declared paired-source paths (repo-relative). */ +export interface SkillSources { + slug: string; + sources: string[]; +} + +/** + * Read `//SKILL.md` and return its `sources:` + * frontmatter array. Empty array when absent or empty. + * + * Fail-loud validation (throws `BundleError` with `manifest_malformed`): + * - every entry must be a string + * - relative path only (no leading `/`, no `../` traversal) + * - every referenced file must exist under `` + * + * Skills without `sources:` declared, or with `sources: []`, return an + * empty array (no validation work performed). + */ +export function loadSkillSources(gbrainRoot: string, skillRel: string): SkillSources { + const slug = pathSlug(skillRel); + const skillMd = join(gbrainRoot, skillRel, 'SKILL.md'); + if (!existsSync(skillMd)) { + // Some bundled "skills" are markdown-only without a SKILL.md (rare, + // e.g. shared-conventions directories). Treat as no sources. + return { slug, sources: [] }; + } + let content: string; + try { + content = readFileSync(skillMd, 'utf-8'); + } catch (err) { + throw new BundleError( + `Failed to read ${skillMd}: ${(err as Error).message}`, + 'manifest_malformed', + ); + } + let parsed; + try { + parsed = parseMarkdown(content, skillMd); + } catch (err) { + throw new BundleError( + `${skillMd}: frontmatter parse error — ${(err as Error).message}`, + 'manifest_malformed', + ); + } + const raw = parsed.frontmatter.sources; + if (raw === undefined || raw === null) { + return { slug, sources: [] }; + } + if (!Array.isArray(raw)) { + throw new BundleError( + `${skillMd}: frontmatter \`sources:\` must be an array of strings`, + 'manifest_malformed', + ); + } + const sources: string[] = []; + for (const entry of raw) { + if (typeof entry !== 'string') { + throw new BundleError( + `${skillMd}: every entry in frontmatter \`sources:\` must be a string`, + 'manifest_malformed', + ); + } + if (entry.length === 0) { + throw new BundleError( + `${skillMd}: empty string in frontmatter \`sources:\``, + 'manifest_malformed', + ); + } + if (isAbsolute(entry)) { + throw new BundleError( + `${skillMd}: frontmatter \`sources:\` entry "${entry}" must be relative to the repo root, not absolute`, + 'manifest_malformed', + ); + } + if (entry.includes('..')) { + throw new BundleError( + `${skillMd}: frontmatter \`sources:\` entry "${entry}" contains \`..\` traversal — refusing for safety`, + 'manifest_malformed', + ); + } + const abs = join(gbrainRoot, entry); + if (!existsSync(abs)) { + throw new BundleError( + `${skillMd}: frontmatter \`sources:\` declares "${entry}" but the file is missing from ${gbrainRoot}`, + 'manifest_malformed', + ); + } + sources.push(entry); + } + return { slug, sources }; +} + +// --------------------------------------------------------------------------- +// ScaffoldEntry — workspace-rooted, includes paired-source files +// --------------------------------------------------------------------------- + +/** + * Like `BundleEntry`, but `relWorkspaceTarget` is rooted at the target + * workspace (not the target skills dir). Lets scaffold place paired + * source files at their mirror path (`src/commands/foo.ts`) alongside + * the skill markdown (`skills//SKILL.md`). + */ +export interface ScaffoldEntry { + source: string; + relWorkspaceTarget: string; + sharedDep: boolean; + /** Whether from a skill's frontmatter `sources:` declaration. */ + pairedSource: boolean; +} + +/** + * Enumerate every file the new scaffold model would copy. Workspace- + * rooted targets: + * - skill files → `skills//` + * - shared deps → `skills/` + * - paired sources → `` (e.g. `src/commands/book-mirror.ts`) + * + * Fail-loud on missing declared paired sources via `loadSkillSources`. + */ +export function enumerateScaffoldEntries(opts: EnumerateOptions): ScaffoldEntry[] { + const { gbrainRoot, skillSlug, manifest } = opts; + const entries: ScaffoldEntry[] = []; + + const skillsToIncludePaths = skillSlug + ? manifest.skills.filter(p => pathSlug(p) === skillSlug) + : manifest.skills; + + if (skillSlug && skillsToIncludePaths.length === 0) { + throw new BundleError( + `Skill '${skillSlug}' is not listed in openclaw.plugin.json#skills`, + 'skill_not_found', + ); + } + + // 1. Skill files — every file under `/skills//`. + // relWorkspaceTarget = `skills//` (workspace-rooted). + for (const rel of skillsToIncludePaths) { + const abs = join(gbrainRoot, rel); + if (!existsSync(abs)) { + throw new BundleError( + `Bundle lists '${rel}' but the path does not exist in ${gbrainRoot}`, + 'skill_not_found', + ); + } + walkScaffoldFiles(abs, rel, entries, false, false); + } + + // 2. Paired sources — declared via each skill's frontmatter `sources:`. + // relWorkspaceTarget = `` (already workspace-relative). + for (const rel of skillsToIncludePaths) { + const { sources } = loadSkillSources(gbrainRoot, rel); + for (const src of sources) { + entries.push({ + source: join(gbrainRoot, src), + relWorkspaceTarget: src, + sharedDep: false, + pairedSource: true, + }); + } + } + + // 3. Shared deps — convention files etc. relWorkspaceTarget = `skills/`. + for (const dep of manifest.shared_deps) { + const abs = join(gbrainRoot, dep); + if (!existsSync(abs)) continue; // missing shared dep is a warning, not fatal + let stat; + try { + stat = statSync(abs); + } catch { + continue; + } + if (stat.isDirectory()) { + walkScaffoldFiles(abs, dep, entries, true, false); + } else if (stat.isFile()) { + entries.push({ + source: abs, + relWorkspaceTarget: dep, + sharedDep: true, + pairedSource: false, + }); + } + } + + return entries; +} + +function walkScaffoldFiles( + absDir: string, + workspaceRelPrefix: string, + out: ScaffoldEntry[], + sharedDep: boolean, + pairedSource: boolean, +): void { + let entries: string[]; + try { + entries = readdirSync(absDir); + } catch { + return; + } + for (const e of entries) { + const abs = join(absDir, e); + let stat; + try { + stat = statSync(abs); + } catch { + continue; + } + if (stat.isDirectory()) { + walkScaffoldFiles(abs, join(workspaceRelPrefix, e), out, sharedDep, pairedSource); + } else if (stat.isFile()) { + out.push({ + source: abs, + relWorkspaceTarget: join(workspaceRelPrefix, e), + sharedDep, + pairedSource, + }); + } + } +} diff --git a/src/core/skillpack/scaffold.ts b/src/core/skillpack/scaffold.ts new file mode 100644 index 000000000..53f43117b --- /dev/null +++ b/src/core/skillpack/scaffold.ts @@ -0,0 +1,149 @@ +/** + * skillpack/scaffold.ts — `gbrain skillpack scaffold `. + * + * One-time, additive copy of a bundled skill into a host workspace. The + * file-copy primitive is shared with harvest (host→gbrain) via + * `copyArtifacts`. The bundle enumeration is shared with the bundle + * manifest itself via `enumerateScaffoldEntries` — which now also picks + * up paired source files declared in each skill's frontmatter + * `sources:` array. + * + * Contracts (the new model): + * 1. **No managed-block writes.** The host's RESOLVER.md / AGENTS.md + * stays untouched. Routing happens via each skill's frontmatter + * `triggers:` array, which downstream agents walk at runtime. + * 2. **Refuses to overwrite existing files.** Once a file lands, the + * user owns it. To update, run `gbrain skillpack reference ` + * and decide. + * 3. **Partial-state policy.** When `skills//` already exists + * but the skill's frontmatter declares paired `sources:` that are + * missing on host, scaffold copies the missing paired files into + * place. Existing files are still preserved. Closes the + * "skill shipped, later gained a paired source" gap. + * 4. **No lockfile, no cumulative-slugs receipt, no `--all` prune.** + * All deleted. The new model lets the user own the files; nothing + * to lock or to track. + */ + +import { join } from 'path'; + +import { copyArtifacts, walkSourceDir } from './copy.ts'; +import type { CopyItem } from './copy.ts'; +import { enumerateScaffoldEntries, loadBundleManifest } from './bundle.ts'; +import type { ScaffoldEntry } from './bundle.ts'; + +export interface ScaffoldOptions { + /** Absolute path to gbrain repo root (source-of-truth bundle). */ + gbrainRoot: string; + /** Absolute path to the target workspace (e.g. ~/git/wintermute). */ + targetWorkspace: string; + /** Single skill slug, or `null` for --all. */ + skillSlug: string | null; + /** Dry-run: validate + report; no writes. */ + dryRun?: boolean; +} + +export type ScaffoldOutcome = 'wrote_new' | 'skipped_existing'; + +export interface ScaffoldFileResult { + source: string; + target: string; + outcome: ScaffoldOutcome; + sharedDep: boolean; + pairedSource: boolean; +} + +export interface ScaffoldResult { + dryRun: boolean; + files: ScaffoldFileResult[]; + summary: { + wroteNew: number; + skippedExisting: number; + /** Among `wroteNew`, how many were paired source files (frontmatter + * `sources:`) — useful for partial-state cases where the skill + * already existed but a paired source was missing. */ + pairedSourcesWritten: number; + }; +} + +export class ScaffoldError extends Error { + constructor( + message: string, + public code: 'bundle_error' | 'target_missing' | 'unknown_skill', + ) { + super(message); + this.name = 'ScaffoldError'; + } +} + +/** + * Run a scaffold. Loads the bundle manifest, enumerates every file the + * skill (or all skills when slug=null) would land, and copies them into + * the target workspace at their mirror paths. Refuses to overwrite any + * existing file. + * + * Idempotent: re-running on a fully-scaffolded workspace is a no-op. + * + * Partial-state handled naturally: if `skills//` exists but a + * declared paired source is missing, the missing item is copied while + * the present ones are skipped. + */ +export function runScaffold(opts: ScaffoldOptions): ScaffoldResult { + const manifest = loadBundleManifest(opts.gbrainRoot); + + // enumerateScaffoldEntries throws BundleError if the slug is unknown + // or if any declared paired source is missing on disk. Surface those + // as ScaffoldError with the matching code for caller ergonomics. + let entries: ScaffoldEntry[]; + try { + entries = enumerateScaffoldEntries({ + gbrainRoot: opts.gbrainRoot, + skillSlug: opts.skillSlug ?? undefined, + manifest, + }); + } catch (err) { + const e = err as Error & { code?: string }; + if (e.code === 'skill_not_found') { + throw new ScaffoldError(e.message, 'unknown_skill'); + } + throw new ScaffoldError(e.message, 'bundle_error'); + } + + // Map ScaffoldEntry → CopyItem (workspace-rooted target path). + const items: CopyItem[] = entries.map(e => ({ + source: e.source, + target: join(opts.targetWorkspace, e.relWorkspaceTarget), + })); + + // Shared copy primitive. Refuses to overwrite existing files; no + // symlink check or path confinement (the scaffold source is gbrain's + // own trusted bundle). + const copyResult = copyArtifacts(items, { dryRun: opts.dryRun }); + + // Stitch outcomes back to ScaffoldEntry metadata so callers can tell + // sharedDep / pairedSource per file. + const files: ScaffoldFileResult[] = copyResult.files.map((f, i) => ({ + source: f.source, + target: f.target, + outcome: f.outcome, + sharedDep: entries[i].sharedDep, + pairedSource: entries[i].pairedSource, + })); + + return { + dryRun: copyResult.dryRun, + files, + summary: { + wroteNew: copyResult.summary.wroteNew, + skippedExisting: copyResult.summary.skippedExisting, + pairedSourcesWritten: files.filter( + f => f.outcome === 'wrote_new' && f.pairedSource, + ).length, + }, + }; +} + +// Re-export the public symbols from copy.ts so callers can `import +// { walkSourceDir } from 'scaffold.ts'` if they prefer one entry point. +// (Not load-bearing — direct imports from copy.ts are equally fine.) +export { copyArtifacts, walkSourceDir }; diff --git a/test/skillpack-frontmatter-sources.test.ts b/test/skillpack-frontmatter-sources.test.ts new file mode 100644 index 000000000..4679fa753 --- /dev/null +++ b/test/skillpack-frontmatter-sources.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for `loadSkillSources` in src/core/skillpack/bundle.ts — + * the per-skill paired-source declaration via SKILL.md frontmatter + * `sources:` array (v0.33+, D2). + * + * Pins the fail-loud validation contract: + * - empty/absent `sources:` → empty array, no error + * - non-string entry → BundleError(manifest_malformed) + * - absolute path → BundleError + * - `..` traversal → BundleError + * - declared file missing on disk → BundleError + */ + +import { describe, expect, it, afterEach } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { BundleError, loadSkillSources } from '../src/core/skillpack/bundle.ts'; + +const created: string[] = []; +afterEach(() => { + while (created.length) { + const p = created.pop()!; + try { + rmSync(p, { recursive: true, force: true }); + } catch {} + } +}); + +function scratchGbrain(): string { + const root = mkdtempSync(join(tmpdir(), 'fms-gbrain-')); + created.push(root); + mkdirSync(join(root, 'src', 'commands'), { recursive: true }); + mkdirSync(join(root, 'skills', 'sample'), { recursive: true }); + return root; +} + +function writeSkill(root: string, name: string, frontmatter: string): void { + const dir = join(root, 'skills', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n# ${name}\n`); +} + +describe('loadSkillSources', () => { + it('returns empty array when frontmatter has no `sources:` field', () => { + const root = scratchGbrain(); + writeSkill(root, 'plain', 'name: plain\nversion: 0.1.0'); + + const out = loadSkillSources(root, 'skills/plain'); + expect(out.slug).toBe('plain'); + expect(out.sources).toEqual([]); + }); + + it('returns empty array when SKILL.md is missing (shared-conventions dirs)', () => { + const root = scratchGbrain(); + mkdirSync(join(root, 'skills', 'no-md-dir')); + writeFileSync(join(root, 'skills', 'no-md-dir', 'README.md'), 'no skill here'); + + const out = loadSkillSources(root, 'skills/no-md-dir'); + expect(out.sources).toEqual([]); + }); + + it('reads a valid `sources:` array and returns repo-relative paths', () => { + const root = scratchGbrain(); + writeFileSync(join(root, 'src', 'commands', 'demo.ts'), '// stub'); + writeSkill( + root, + 'demo', + 'name: demo\nsources:\n - src/commands/demo.ts', + ); + + const out = loadSkillSources(root, 'skills/demo'); + expect(out.sources).toEqual(['src/commands/demo.ts']); + }); + + it('returns empty array when `sources: []` is explicitly empty', () => { + const root = scratchGbrain(); + writeSkill(root, 'empty-sources', 'name: empty-sources\nsources: []'); + + const out = loadSkillSources(root, 'skills/empty-sources'); + expect(out.sources).toEqual([]); + }); + + it('throws BundleError when `sources:` is not an array', () => { + const root = scratchGbrain(); + writeSkill(root, 'bad', 'name: bad\nsources: not-an-array'); + + expect(() => loadSkillSources(root, 'skills/bad')).toThrow(BundleError); + }); + + it('throws when an entry is not a string', () => { + const root = scratchGbrain(); + writeSkill(root, 'bad', 'name: bad\nsources:\n - 42'); + + expect(() => loadSkillSources(root, 'skills/bad')).toThrow(BundleError); + }); + + it('throws on absolute paths', () => { + const root = scratchGbrain(); + writeSkill(root, 'bad', 'name: bad\nsources:\n - /etc/passwd'); + + expect(() => loadSkillSources(root, 'skills/bad')).toThrow(/absolute/); + }); + + it('throws on `..` traversal', () => { + const root = scratchGbrain(); + writeSkill(root, 'bad', 'name: bad\nsources:\n - ../other-repo/src/leak.ts'); + + expect(() => loadSkillSources(root, 'skills/bad')).toThrow(/traversal/); + }); + + it('throws when a declared source file is missing on disk', () => { + const root = scratchGbrain(); + writeSkill( + root, + 'gone', + 'name: gone\nsources:\n - src/commands/never-built.ts', + ); + + expect(() => loadSkillSources(root, 'skills/gone')).toThrow(/missing from/); + }); + + it('throws on empty string entries', () => { + const root = scratchGbrain(); + writeSkill(root, 'bad', 'name: bad\nsources:\n - ""'); + + expect(() => loadSkillSources(root, 'skills/bad')).toThrow(BundleError); + }); +}); diff --git a/test/skillpack-scaffold.test.ts b/test/skillpack-scaffold.test.ts new file mode 100644 index 000000000..b42d18d9e --- /dev/null +++ b/test/skillpack-scaffold.test.ts @@ -0,0 +1,274 @@ +/** + * Tests for src/core/skillpack/scaffold.ts — the new (v0.33) scaffold + * model that replaces the managed-block installer. + * + * Pins: + * - happy path: skill files + shared deps + paired sources land at + * workspace-rooted paths + * - refuses to overwrite existing files (the user owns them) + * - partial-state policy: skill present, paired source missing → fill + * - --all (skillSlug: null) installs every bundled skill + * - dry-run reports outcomes but writes nothing + * - IRON-RULE regressions: no managed block, no lockfile, no + * cumulative-slugs receipt, no .gbrain-skillpack.lock file + */ + +import { describe, expect, it, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { runScaffold, ScaffoldError } from '../src/core/skillpack/scaffold.ts'; + +const created: string[] = []; +afterEach(() => { + while (created.length) { + const p = created.pop()!; + try { + rmSync(p, { recursive: true, force: true }); + } catch {} + } +}); + +interface GbrainFixture { + gbrainRoot: string; +} + +function scratchGbrain(opts: { withPairedSource?: boolean } = {}): GbrainFixture { + const root = mkdtempSync(join(tmpdir(), 'sp-scaffold-gbrain-')); + created.push(root); + mkdirSync(join(root, 'src', 'cli.ts').replace('cli.ts', ''), { recursive: true }); + writeFileSync(join(root, 'src', 'cli.ts'), '// stub'); + mkdirSync(join(root, 'src', 'commands'), { recursive: true }); + + // book-mirror with paired source + mkdirSync(join(root, 'skills', 'book-mirror'), { recursive: true }); + const bmFm = opts.withPairedSource + ? '---\nname: book-mirror\ntriggers:\n - bm trigger\nsources:\n - src/commands/book-mirror.ts\n---\n# book-mirror\n' + : '---\nname: book-mirror\ntriggers:\n - bm trigger\n---\n# book-mirror\n'; + writeFileSync(join(root, 'skills', 'book-mirror', 'SKILL.md'), bmFm); + writeFileSync(join(root, 'skills', 'book-mirror', 'routing-eval.jsonl'), '{"intent":"bm"}\n'); + + if (opts.withPairedSource) { + writeFileSync(join(root, 'src', 'commands', 'book-mirror.ts'), '// real impl\n'); + } + + // plain second skill (no paired source) + mkdirSync(join(root, 'skills', 'query'), { recursive: true }); + writeFileSync( + join(root, 'skills', 'query', 'SKILL.md'), + '---\nname: query\ntriggers:\n - q trigger\n---\n# query\n', + ); + + // shared deps + mkdirSync(join(root, 'skills', 'conventions'), { recursive: true }); + writeFileSync(join(root, 'skills', 'conventions', 'quality.md'), '# quality\n'); + writeFileSync(join(root, 'skills', '_brain-filing-rules.md'), '# filing rules\n'); + + // bundle manifest + writeFileSync( + join(root, 'openclaw.plugin.json'), + JSON.stringify( + { + name: 'gbrain-test', + version: '0.33.0-test', + skills: ['skills/book-mirror', 'skills/query'], + shared_deps: ['skills/conventions', 'skills/_brain-filing-rules.md'], + }, + null, + 2, + ), + ); + return { gbrainRoot: root }; +} + +function scratchWorkspace(): string { + const ws = mkdtempSync(join(tmpdir(), 'sp-scaffold-ws-')); + created.push(ws); + return ws; +} + +describe('runScaffold — happy path', () => { + it('copies a single skill plus shared deps to the workspace', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + const result = runScaffold({ + gbrainRoot, + targetWorkspace: ws, + skillSlug: 'book-mirror', + }); + + expect(result.summary.wroteNew).toBeGreaterThan(0); + expect(existsSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'))).toBe(true); + expect(existsSync(join(ws, 'skills', 'book-mirror', 'routing-eval.jsonl'))).toBe(true); + expect(existsSync(join(ws, 'skills', 'conventions', 'quality.md'))).toBe(true); + expect(existsSync(join(ws, 'skills', '_brain-filing-rules.md'))).toBe(true); + }); + + it('copies paired source files declared in frontmatter `sources:`', () => { + const { gbrainRoot } = scratchGbrain({ withPairedSource: true }); + const ws = scratchWorkspace(); + + const result = runScaffold({ + gbrainRoot, + targetWorkspace: ws, + skillSlug: 'book-mirror', + }); + + expect(existsSync(join(ws, 'src', 'commands', 'book-mirror.ts'))).toBe(true); + expect(readFileSync(join(ws, 'src', 'commands', 'book-mirror.ts'), 'utf-8')).toBe( + '// real impl\n', + ); + expect(result.summary.pairedSourcesWritten).toBe(1); + }); + + it('--all (skillSlug: null) installs every bundled skill', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: null }); + + expect(existsSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'))).toBe(true); + expect(existsSync(join(ws, 'skills', 'query', 'SKILL.md'))).toBe(true); + }); +}); + +describe('runScaffold — refuses to overwrite (user owns the files)', () => { + it('re-running is idempotent (every file skipped_existing)', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + const second = runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + + expect(second.summary.wroteNew).toBe(0); + expect(second.summary.skippedExisting).toBeGreaterThan(0); + expect(second.files.every(f => f.outcome === 'skipped_existing')).toBe(true); + }); + + it('preserves local edits to a scaffolded file', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + writeFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), 'MY EDITS'); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + expect(readFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), 'utf-8')).toBe('MY EDITS'); + }); + + it('does not overwrite existing shared-dep files', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + mkdirSync(join(ws, 'skills', 'conventions'), { recursive: true }); + writeFileSync(join(ws, 'skills', 'conventions', 'quality.md'), 'USER OWNS THIS'); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + expect(readFileSync(join(ws, 'skills', 'conventions', 'quality.md'), 'utf-8')).toBe( + 'USER OWNS THIS', + ); + }); +}); + +describe('runScaffold — partial-state policy (F-CDX-6)', () => { + it('skill dir exists but paired source missing → copies the paired source only', () => { + const { gbrainRoot } = scratchGbrain({ withPairedSource: true }); + const ws = scratchWorkspace(); + + // First, scaffold without the paired source declared (simulating "skill + // shipped before sources: was added"). We model this by pre-creating + // the skill files manually: + mkdirSync(join(ws, 'skills', 'book-mirror'), { recursive: true }); + writeFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), '# pre-existing skill content\n'); + writeFileSync(join(ws, 'skills', 'book-mirror', 'routing-eval.jsonl'), '{}\n'); + + // Now scaffold (gbrain bundle has the paired source declared); the + // existing skill files are preserved, the missing paired source lands. + const result = runScaffold({ + gbrainRoot, + targetWorkspace: ws, + skillSlug: 'book-mirror', + }); + + expect(existsSync(join(ws, 'src', 'commands', 'book-mirror.ts'))).toBe(true); + expect(readFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), 'utf-8')).toBe( + '# pre-existing skill content\n', + ); + expect(result.summary.pairedSourcesWritten).toBe(1); + }); +}); + +describe('runScaffold — dry-run', () => { + it('reports outcomes without writing anything', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + const result = runScaffold({ + gbrainRoot, + targetWorkspace: ws, + skillSlug: 'book-mirror', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.summary.wroteNew).toBeGreaterThan(0); + expect(existsSync(join(ws, 'skills'))).toBe(false); + }); +}); + +describe('runScaffold — error paths', () => { + it('unknown skill slug → ScaffoldError(unknown_skill)', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + try { + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'does-not-exist' }); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ScaffoldError); + expect((err as ScaffoldError).code).toBe('unknown_skill'); + } + }); +}); + +describe('runScaffold — IRON-RULE regressions (R1, R2)', () => { + it('R1: never writes managed-block markers to RESOLVER.md/AGENTS.md', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + // Pre-create a RESOLVER.md so we can check it survives untouched. + writeFileSync(join(ws, 'RESOLVER.md'), '# my routing\n'); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + + const resolver = readFileSync(join(ws, 'RESOLVER.md'), 'utf-8'); + expect(resolver).toBe('# my routing\n'); + expect(resolver).not.toContain('gbrain:skillpack:begin'); + expect(resolver).not.toContain('gbrain:skillpack:end'); + expect(resolver).not.toContain('gbrain:skillpack:manifest'); + }); + + it('R2: never writes a .gbrain-skillpack.lock file', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + + expect(existsSync(join(ws, '.gbrain-skillpack.lock'))).toBe(false); + // Lock should also not exist anywhere in the workspace tree. + expect(readdirSync(ws)).not.toContain('.gbrain-skillpack.lock'); + }); + + it('R2: never writes a cumulative-slugs receipt anywhere in workspace', () => { + const { gbrainRoot } = scratchGbrain(); + const ws = scratchWorkspace(); + + writeFileSync(join(ws, 'AGENTS.md'), 'existing agents content\n'); + + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' }); + + expect(readFileSync(join(ws, 'AGENTS.md'), 'utf-8')).not.toContain('cumulative-slugs'); + }); +});