fix(init): guide project.md migration (#1999)

* feat(init): offer project.md migration

* fix(config): preserve context newline state

* test(init): prove migration preserves files

* docs(setup): document project.md migration

* fix(init): guide project.md migration

* fix(init): cover migration destinations
This commit is contained in:
Clay Good
2026-09-29 18:25:31 +00:00
committed by GitHub
parent 28f864351b
commit bda85565ef
6 changed files with 86 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---
Guide users through an AI-assisted migration from legacy `project.md` to `config.yaml`.
+16
View File
@@ -34,6 +34,22 @@ Re-running init is safe:
- Running init again with a new tool selected adds that tool.
- The `--tools` flag skips the picker ([CLI reference](../reference/cli.md)).
### Migrate an existing `project.md`
Init does not copy legacy `openspec/project.md` into `config.yaml`. It keeps the file and prints an AI-assisted migration request.
In your AI chat:
```
Review openspec/project.md and migrate its useful content to openspec/config.yaml.
Keep context concise: include only project-wide facts needed during artifact creation, apply, and archive.
Move artifact-specific guidance into rules for the matching artifacts.
Move guidance for apply or archive into the matching operations entry.
Leave out generic, outdated, or verbose material. Do not delete project.md.
```
Review `config.yaml`, then delete `project.md` when ready.
## What init installs
Running init creates two things in your project:
+9 -5
View File
@@ -1185,11 +1185,15 @@ export function formatProjectMdMigrationHint(): string {
lines.push(' • openspec/project.md');
lines.push(chalk.dim(' We won\'t delete this file. It may contain useful project context.'));
lines.push('');
lines.push(chalk.dim(' The new openspec/config.yaml has a "context:" section for planning'));
lines.push(chalk.dim(' context. This is included in every OpenSpec request and works more'));
lines.push(chalk.dim(' reliably than the old project.md approach.'));
lines.push(chalk.dim(' Ask your AI assistant:'));
lines.push('');
lines.push(chalk.dim(' Review project.md, move any useful content to config.yaml\'s context'));
lines.push(chalk.dim(' section, then delete the file when ready.'));
lines.push(chalk.dim(' Review openspec/project.md and migrate its useful content to'));
lines.push(chalk.dim(' openspec/config.yaml. Keep context concise: include only project-wide'));
lines.push(chalk.dim(' facts needed during artifact creation, apply, and archive. Move'));
lines.push(chalk.dim(' artifact-specific guidance into rules for the matching artifacts.'));
lines.push(chalk.dim(' Move guidance for apply or archive into the matching operations entry.'));
lines.push(chalk.dim(' Leave out generic, outdated, or verbose material. Do not delete project.md.'));
lines.push('');
lines.push(chalk.dim(' Review config.yaml, then delete project.md when ready.'));
return lines.join('\n');
}
+16
View File
@@ -156,6 +156,22 @@ describe('InitCommand', () => {
expect(content).toContain('schema: spec-driven');
});
it('should guide project.md migration without copying or deleting it', async () => {
const openspecPath = path.join(testDir, 'openspec');
const projectMdPath = path.join(openspecPath, 'project.md');
await fs.mkdir(openspecPath, { recursive: true });
await fs.writeFile(projectMdPath, '# Migrate me later\n');
await new InitCommand({ tools: 'none', force: true }).execute(testDir);
expect(readProjectConfig(testDir)?.context).toBeUndefined();
expect(await fs.readFile(projectMdPath, 'utf-8')).toBe('# Migrate me later\n');
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(
'Ask your AI assistant'
);
expect(confirmMock).not.toHaveBeenCalled();
});
it('should add the requested artifact language to a new config', async () => {
const initCommand = new InitCommand({
tools: 'none',
+16 -8
View File
@@ -1046,7 +1046,8 @@ ${OPENSPEC_MARKERS.end}`);
expect(summary).toContain('• openspec/project.md');
expect(summary).toContain('won\'t delete this file');
expect(summary).toContain('config.yaml');
expect(summary).toContain('"context:"');
expect(summary).toContain('Ask your AI assistant');
expect(summary).toContain('rules for the matching artifacts');
});
it('should include attention section with other legacy artifacts', () => {
@@ -1148,19 +1149,26 @@ ${OPENSPEC_MARKERS.end}`);
expect(hint).toContain('openspec/project.md');
expect(hint).toContain('won\'t delete this file');
expect(hint).toContain('config.yaml');
expect(hint).toContain('"context:"');
expect(hint).toContain('Ask your AI assistant');
});
it('should include actionable instructions', () => {
it('should include a pasteable AI-assisted migration request', () => {
const hint = formatProjectMdMigrationHint();
expect(hint).toContain('move any useful content');
expect(hint).toContain('delete the file when ready');
expect(hint).toContain('Review openspec/project.md');
expect(hint).toContain('migrate its useful content to');
expect(hint).toContain('Do not delete project.md');
expect(hint).toContain('Review config.yaml, then delete project.md when ready');
});
it('should explain the new context section benefits', () => {
it('should guide the agent to distill and route the content', () => {
const hint = formatProjectMdMigrationHint();
expect(hint).toContain('included in every OpenSpec request');
expect(hint).toContain('reliably');
expect(hint).toContain('Keep context concise');
expect(hint).toContain('only project-wide');
expect(hint).toContain('artifact creation, apply, and archive');
expect(hint).toContain('rules for the matching artifacts');
expect(hint).toContain('matching operations entry');
expect(hint).toContain('Leave out generic');
expect(hint).toContain('outdated, or verbose material');
});
});
+24
View File
@@ -4,6 +4,7 @@ import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { claudeAdapter } from '../src/core/command-generation/adapters/claude.js';
import { AI_TOOLS } from '../src/core/config.js';
import { formatProjectMdMigrationHint } from '../src/core/legacy-cleanup.js';
import { CORE_WORKFLOWS } from '../src/core/profiles.js';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -20,6 +21,29 @@ const CORE_SECTION = PROFILES.split('## The core set')[1].split(
)[0];
describe('setup documentation', () => {
it('matches the AI-assisted project.md migration guidance', () => {
const hint = formatProjectMdMigrationHint();
const claims = [
'Review openspec/project.md and migrate its useful content to',
'Keep context concise',
'only project-wide',
'artifact creation, apply, and archive',
'rules for the matching artifacts',
'matching operations entry',
'Leave out generic',
'outdated, or verbose material',
'Do not delete project.md',
];
expect(SETUP).toContain('Init does not copy legacy `openspec/project.md`');
for (const claim of claims) {
expect(hint).toContain(claim);
expect(SETUP).toContain(claim);
}
expect(hint).toContain('Review config.yaml, then delete project.md when ready.');
expect(SETUP).toContain('Review `config.yaml`, then delete `project.md` when ready.');
});
it('keeps the Claude Code paths and recovery commands aligned with OpenSpec', () => {
const claude = AI_TOOLS.find((tool) => tool.value === 'claude');
const claudeCommandPath = claudeAdapter.getFilePath('<id>').split(path.sep).join('/');