diff --git a/src/genie-commands/doctor.test.ts b/src/genie-commands/doctor.test.ts new file mode 100644 index 000000000..cada023e0 --- /dev/null +++ b/src/genie-commands/doctor.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for `checkLegacyAgentFrontmatter` — Group 6 of wish + * `dir-sync-frontmatter-refresh`. + * + * The check walks every agent directory under `agents/` and warns when an + * AGENTS.md starts with a `---` fence while an `agent.yaml` is also + * present — the configuration-in-wrong-file scenario that sync silently + * ignores post-migration. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { checkLegacyAgentFrontmatter } from './doctor.js'; + +let workspaceRoot: string; + +beforeEach(() => { + workspaceRoot = mkdtempSync(join(tmpdir(), 'doctor-legacy-fm-')); +}); + +afterEach(() => { + try { + rmSync(workspaceRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +function seedAgent(name: string, files: { agentYaml?: string; agentsMd?: string }): void { + const agentDir = join(workspaceRoot, 'agents', name); + mkdirSync(agentDir, { recursive: true }); + if (files.agentYaml !== undefined) writeFileSync(join(agentDir, 'agent.yaml'), files.agentYaml); + if (files.agentsMd !== undefined) writeFileSync(join(agentDir, 'AGENTS.md'), files.agentsMd); +} + +describe('checkLegacyAgentFrontmatter', () => { + test('warns when AGENTS.md has frontmatter AND agent.yaml exists', async () => { + seedAgent('drifted', { + agentYaml: 'promptMode: append\nmodel: opus\n', + agentsMd: '---\nmodel: sonnet\n---\n\n# body', + }); + + const results = checkLegacyAgentFrontmatter(workspaceRoot); + expect(results).toHaveLength(1); + expect(results[0].status).toBe('warn'); + expect(results[0].name).toBe('agents/drifted/AGENTS.md'); + expect(results[0].message).toMatch(/legacy frontmatter/i); + expect(results[0].suggestion).toMatch(/agent\.yaml/); + }); + + test('silent (pass) when agent.yaml absent — pre-migration state', async () => { + seedAgent('pre-migration', { + agentsMd: '---\nmodel: sonnet\n---\n\n# body', + }); + + const results = checkLegacyAgentFrontmatter(workspaceRoot); + expect(results).toHaveLength(1); + expect(results[0].status).toBe('pass'); + expect(results[0].name).toMatch(/No legacy frontmatter/); + }); + + test('silent (pass) when AGENTS.md has no fence — clean post-migration', async () => { + seedAgent('clean', { + agentYaml: 'promptMode: append\n', + agentsMd: '# Agent: clean\n\nBody content.', + }); + + const results = checkLegacyAgentFrontmatter(workspaceRoot); + expect(results).toHaveLength(1); + expect(results[0].status).toBe('pass'); + }); + + test('silent when agents/ directory does not exist', async () => { + // No seeding — workspace has no agents/ dir. + const results = checkLegacyAgentFrontmatter(workspaceRoot); + expect(results).toHaveLength(0); + }); + + test('flags multiple agents independently', async () => { + seedAgent('drifted-one', { + agentYaml: 'promptMode: append\n', + agentsMd: '---\nmodel: opus\n---\n# body', + }); + seedAgent('clean', { + agentYaml: 'promptMode: append\n', + agentsMd: '# Agent: clean\n\nBody.', + }); + seedAgent('drifted-two', { + agentYaml: 'promptMode: append\n', + agentsMd: '---\ncolor: red\n---\n# body', + }); + + const results = checkLegacyAgentFrontmatter(workspaceRoot); + // 2 warnings + 0 pass fillers (pass filler only emitted when zero warnings) + expect(results).toHaveLength(2); + const names = results.map((r) => r.name).sort(); + expect(names).toEqual(['agents/drifted-one/AGENTS.md', 'agents/drifted-two/AGENTS.md']); + }); + + test('ignores non-directory entries in agents/', async () => { + // Seed one drifted agent so we get a warning to assert. + seedAgent('real', { + agentYaml: 'promptMode: append\n', + agentsMd: '---\nmodel: opus\n---\n', + }); + // Drop a file inside agents/ — should be skipped, not crash. + writeFileSync(join(workspaceRoot, 'agents', 'README'), 'not an agent'); + + const results = checkLegacyAgentFrontmatter(workspaceRoot); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('agents/real/AGENTS.md'); + }); + + test('skips agent dirs that only have agent.yaml (no AGENTS.md)', async () => { + seedAgent('yaml-only', { agentYaml: 'promptMode: append\n' }); + + const results = checkLegacyAgentFrontmatter(workspaceRoot); + expect(results).toHaveLength(1); + expect(results[0].status).toBe('pass'); + }); +}); diff --git a/src/genie-commands/doctor.ts b/src/genie-commands/doctor.ts index 50cba4e25..bc382cb0e 100644 --- a/src/genie-commands/doctor.ts +++ b/src/genie-commands/doctor.ts @@ -5,7 +5,7 @@ * Checks prerequisites, configuration, and tmux connectivity. */ -import { existsSync, unlinkSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { $ } from 'bun'; @@ -14,6 +14,7 @@ import { contractClaudePath, getClaudeSettingsPath } from '../lib/claude-setting import { tmuxBin } from '../lib/ensure-tmux.js'; import { genieConfigExists, getGenieConfigPath, isSetupComplete, loadGenieConfig } from '../lib/genie-config.js'; import { checkCommand } from '../lib/system-detect.js'; +import { findWorkspace } from '../lib/workspace.js'; interface CheckResult { name: string; @@ -395,6 +396,73 @@ async function checkBridge(): Promise { return results; } +/** + * Check that no agent has leftover `---` frontmatter in its `AGENTS.md` + * when `agent.yaml` is also present. Wish `dir-sync-frontmatter-refresh` + * (Group 6) eliminates frontmatter entirely post-migration — if a user + * pastes config back into AGENTS.md, sync silently ignores it, which is + * confusing. This check surfaces the drift loudly. + * + * Exported for unit tests and for callers that want to run this check + * outside `genie doctor` (e.g. a pre-sync lint). + */ +export function checkLegacyAgentFrontmatter(workspaceRoot?: string): CheckResult[] { + const results: CheckResult[] = []; + const root = workspaceRoot ?? findWorkspace()?.root; + if (!root) return []; + + const agentsDir = join(root, 'agents'); + if (!existsSync(agentsDir)) return []; + + let entries: string[]; + try { + entries = readdirSync(agentsDir); + } catch { + return []; + } + + for (const name of entries) { + const agentDir = join(agentsDir, name); + try { + if (!statSync(agentDir).isDirectory()) continue; + } catch { + continue; + } + + const yamlPath = join(agentDir, 'agent.yaml'); + const agentsMdPath = join(agentDir, 'AGENTS.md'); + + // Only flag when both files exist AND AGENTS.md starts with a fence. + // Pre-migration state (no yaml, frontmatter present) is NOT a warning — + // sync will migrate it on the next run. + if (!existsSync(yamlPath) || !existsSync(agentsMdPath)) continue; + + let headContent: string; + try { + // Read only the first handful of bytes to cheaply detect the fence. + headContent = readFileSync(agentsMdPath, 'utf-8').slice(0, 4); + } catch { + continue; + } + + if (!headContent.startsWith('---')) continue; + + results.push({ + name: `agents/${name}/AGENTS.md`, + status: 'warn', + message: 'legacy frontmatter detected (ignored by sync)', + suggestion: `Move config into agents/${name}/agent.yaml — AGENTS.md is prompt-only post-migration.`, + }); + } + + // Single positive result when nothing drifted, so the section prints a + // clean "✓" row. + if (results.length === 0) { + results.push({ name: 'No legacy frontmatter in agents/*/AGENTS.md', status: 'pass' }); + } + return results; +} + /** * Main doctor command */ @@ -424,6 +492,7 @@ export async function doctorCommand(options?: { fix?: boolean }): Promise runCheckSection('Tmux', await checkTmux(), counts); runCheckSection('Worker Profiles', await checkWorkerProfiles(), counts); runCheckSection('Omni Bridge', await checkBridge(), counts); + runCheckSection('Agent Config', checkLegacyAgentFrontmatter(), counts); // Summary console.log();