feat(plugins): rewrite-plan atom impl (Phase 7 entry slice)

Plan O3 / spec §20.3 / §21.3.2.

apps/daemon/src/plugins/atoms/rewrite-plan.ts produces the four
files the SKILL.md fragment promises:

  <cwd>/plan/plan.md          — narrative (heuristic baseline; LLM
                                step overwrites)
  <cwd>/plan/ownership.json   — { file, layer } per file across the
                                imported repo, sorted lexicographically
  <cwd>/plan/steps.json       — ordered { id, files[], rationale, risk }
  <cwd>/plan/meta.json        — generatedAt + atomDigest +
                                tokenMapDigest + intent

Ownership classifier (spec §11.5.1 / §20.3 tiers):

  shell  layout.tsx / _app.tsx / _document.tsx / providers.tsx /
         theme.ts / globals.css / tokens.css / design-tokens.css
  route  app/.../page.tsx, pages/index.tsx, route.ts entries
  shared hooks/ lib/ utils/ providers/ context/ store/ stores/
         services/ api/ shared/ common/, plus repo-root config files
  leaf   everything else (default)

The classifier keeps shell rare so patch-edit's safety gate doesn't
lock the agent out of plain component edits.

Step generator:
  - tokens-alignment   (low risk) when design-extract surfaced
                       inline literals; files[] is the leaf set
                       carrying those literals.
  - rewrite-<slug>     (low risk) one per leaf component file;
                       sibling stylesheets (Button.tsx + Button.css)
                       fold into the same step.
  - shared-and-route-touchups (medium risk) when shared/route files
                       exist, marks the cross-cutting boundary.
  - build-test         (low risk) closes every plan; rewrite-plan
                       refuses to drop this step.

atomDigest is over a canonicalised view of code/index.json so re-walks
that don't change the file roster don't invalidate the plan; intent is
not in the digest. tokenMapDigest is over code/tokens.json or 'none'.

Daemon tests: 1564 → 1571 (+7 cases on plugins-rewrite-plan: tier
classification across leaf/shared/route/shell, tokens-alignment
emission, per-leaf rewrite step + sibling stylesheet bundling,
build-test always last, persisted file layout, digest stability
across runs, missing code/index.json error path).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent 2026-05-09 15:10:47 +00:00
parent c17d4ab952
commit f2b62c0303
No known key found for this signature in database
3 changed files with 437 additions and 0 deletions

View file

@ -0,0 +1,311 @@
// Phase 7 entry slice / spec §20.3 / §21.3.2 — rewrite-plan atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/rewrite-plan/.
// Given a project cwd that already has code/index.json (from
// code-import) + an optional code/tokens.json (from design-extract),
// the runner produces a heuristic ownership classification and a
// per-component step list. The narrative `plan.md` is intentionally
// short — it's a scaffold the agent overwrites once the LLM-driven
// stage runs; the heuristic baseline gives subsequent stages
// (`patch-edit` / `diff-review` / `build-test`) an audit trail to
// reference even if the LLM step is skipped or fails.
//
// Ownership tiers (spec §11.5.1 / §20.3):
// leaf — single-component leaf files (Button.tsx, Card.css)
// shared — shared infrastructure (hooks/, lib/, utils/)
// route — page-level / route entry points (app/page.tsx,
// pages/index.tsx, src/app/(group)/page.tsx)
// shell — top-level layout / framework boundaries
// (layout.tsx, _app.tsx, providers/, root css)
//
// The classifier keeps false-positive `shell` rare: only files that
// match a strict allowlist (layout|root|provider|theme|tokens|globals)
// are tagged `shell`. Everything else collapses to `leaf` so the
// patch-edit safety gate doesn't lock the agent out of plain
// component edits.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import type { CodeImportIndex } from './code-import.js';
import type { DesignExtractReport } from './design-extract.js';
export type OwnershipTier = 'leaf' | 'shared' | 'route' | 'shell';
export interface OwnershipEntry {
file: string;
layer: OwnershipTier;
}
export interface RewriteStep {
id: string;
files: string[];
rationale: string;
risk: 'low' | 'medium' | 'high';
}
export interface RewritePlanReport {
steps: RewriteStep[];
ownership: OwnershipEntry[];
// SHA-1 digests of the inputs we relied on. The handoff atom
// promotes these into ArtifactManifest provenance so a reviewer
// can prove "this plan was generated against this snapshot of
// code-import / token-map".
meta: {
generatedAt: string;
atomDigest: string; // hash of canonicalised code/index.json
tokenMapDigest: string; // hash of code/tokens.json (or 'none')
intent: string; // user-supplied intent string (echoed)
};
// The narrative plan.md scaffold the runner emits. The agent is
// expected to overwrite this with its own narrative; we ship a
// baseline so downstream stages always see a non-empty file.
planMarkdown: string;
}
export interface RewritePlanOptions {
cwd: string;
// The user's brief, copied into plan.md and into steps[].rationale
// when the heuristic can't think of anything better.
intent?: string;
// Override the build-test step id name (default 'build-test').
buildTestStepId?: string;
}
const SHELL_BASENAMES = new Set([
'layout.tsx', 'layout.jsx', 'layout.ts', 'layout.js',
'_app.tsx', '_app.jsx', '_app.ts', '_app.js',
'_document.tsx', '_document.jsx',
'providers.tsx', 'providers.tsx', 'providers.ts',
'theme.ts', 'theme.tsx',
'globals.css', 'global.css',
'tokens.css', 'design-tokens.css',
]);
const ROUTE_DIR_HINT = /(?:^|\/)(?:app|pages)\//;
const ROUTE_BASENAME = /^(?:page|index|route)\.[tj]sx?$/;
const SHARED_DIR_HINT = /(?:^|\/)(?:hooks|lib|utils|providers|context|store|stores|services|api|shared|common)\//;
const COMPONENT_DIR_HINT = /(?:^|\/)components?\//;
export async function runRewritePlan(opts: RewritePlanOptions): Promise<RewritePlanReport> {
const cwd = path.resolve(opts.cwd);
const indexPath = path.join(cwd, 'code', 'index.json');
const tokensPath = path.join(cwd, 'code', 'tokens.json');
const intent = (opts.intent ?? '').trim();
const buildTestStepId = opts.buildTestStepId ?? 'build-test';
let index: CodeImportIndex;
try {
index = JSON.parse(await fsp.readFile(indexPath, 'utf8')) as CodeImportIndex;
} catch (err) {
throw new Error(`rewrite-plan: missing or unreadable code/index.json (run code-import first): ${(err as Error).message}`);
}
let tokens: DesignExtractReport | undefined;
try {
tokens = JSON.parse(await fsp.readFile(tokensPath, 'utf8')) as DesignExtractReport;
} catch {
tokens = undefined;
}
const ownership = classifyOwnership(index);
const steps = composeSteps({ index, ownership, tokens, intent, buildTestStepId });
const meta = {
generatedAt: new Date().toISOString(),
atomDigest: digestObject(canonicaliseIndex(index)),
tokenMapDigest: tokens ? digestObject(tokens) : 'none',
intent: intent || '',
};
const planMarkdown = renderNarrative({ intent, ownership, steps, tokens });
// Persist all four files under <cwd>/plan/.
const planDir = path.join(cwd, 'plan');
await fsp.mkdir(planDir, { recursive: true });
await fsp.writeFile(path.join(planDir, 'plan.md'), planMarkdown, 'utf8');
await fsp.writeFile(path.join(planDir, 'ownership.json'), JSON.stringify(ownership, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(planDir, 'steps.json'), JSON.stringify(steps, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(planDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
return { steps, ownership, meta, planMarkdown };
}
function classifyOwnership(index: CodeImportIndex): OwnershipEntry[] {
const out: OwnershipEntry[] = [];
for (const f of index.files) {
out.push({ file: f.path, layer: classifyOne(f.path) });
}
// Ownership is sorted lexicographically so the JSON output is
// diff-friendly across runs.
out.sort((a, b) => a.file.localeCompare(b.file));
return out;
}
function classifyOne(file: string): OwnershipTier {
const base = path.posix.basename(file);
if (SHELL_BASENAMES.has(base)) return 'shell';
if (ROUTE_DIR_HINT.test(file) && ROUTE_BASENAME.test(base)) return 'route';
// Layout files in the App Router that aren't in SHELL_BASENAMES list:
if (ROUTE_DIR_HINT.test(file) && /^layout\.[tj]sx?$/.test(base)) return 'shell';
if (SHARED_DIR_HINT.test(file)) return 'shared';
if (COMPONENT_DIR_HINT.test(file)) return 'leaf';
// Files at the repo root that aren't shell are usually config — keep them
// as `shared` so the patch-edit gate insists on `risk: 'medium'+'.
if (!file.includes('/')) return 'shared';
return 'leaf';
}
function composeSteps(args: {
index: CodeImportIndex;
ownership: OwnershipEntry[];
tokens: DesignExtractReport | undefined;
intent: string;
buildTestStepId: string;
}): RewriteStep[] {
const steps: RewriteStep[] = [];
// Step 0: token alignment when design-extract found anything. The
// step's files[] enumerates leaf files that contain hex literals;
// patch-edit replaces them with the active DS token references.
if (args.tokens && (args.tokens.colors.length > 0 || args.tokens.spacing.length > 0)) {
const filesWithTokens = new Set<string>();
for (const t of args.tokens.colors) for (const s of t.sources) filesWithTokens.add(s.split(':')[0]!);
for (const t of args.tokens.spacing) for (const s of t.sources) filesWithTokens.add(s.split(':')[0]!);
if (filesWithTokens.size > 0) {
steps.push({
id: 'tokens-alignment',
files: [...filesWithTokens].sort(),
rationale: 'Replace inline literal colours / spacing with active design-system tokens; keep semantic shape unchanged.',
risk: 'low',
});
}
}
// Step 1..N: per-leaf-component step (one step per leaf file in
// the components/ tree). Each step bundles the leaf file with any
// sibling stylesheet of the same basename.
const leafFiles = args.ownership.filter((o) => o.layer === 'leaf' && /\.(?:tsx|jsx|ts|js|vue|svelte)$/.test(o.file));
for (const f of leafFiles) {
const sibling = findSiblingStylesheet(args.index, f.file);
const files = sibling ? [f.file, sibling] : [f.file];
steps.push({
id: `rewrite-${slug(f.file)}`,
files,
rationale: `Rewrite ${f.file} per the user's intent${args.intent ? `: ${args.intent}` : ''}.`,
risk: 'low',
});
}
// Step N+1: shared / route refactors when present, marked medium.
const sharedRouteFiles = args.ownership
.filter((o) => o.layer === 'shared' || o.layer === 'route')
.map((o) => o.file);
if (sharedRouteFiles.length > 0) {
steps.push({
id: 'shared-and-route-touchups',
files: sharedRouteFiles,
rationale: 'Update shared infrastructure / route entry points to reflect leaf rewrites; cross-cutting changes only.',
risk: 'medium',
});
}
// Final step: build-test gate. patch-edit refuses to mark the
// pipeline converged without this step's file list reaching
// build.passing && tests.passing.
steps.push({
id: args.buildTestStepId,
files: [],
rationale: 'Run typecheck + tests; iterate until build.passing && tests.passing.',
risk: 'low',
});
return steps;
}
function findSiblingStylesheet(index: CodeImportIndex, file: string): string | undefined {
const dir = path.posix.dirname(file);
const base = path.posix.basename(file).replace(/\.[^.]+$/, '');
const candidates = [`${base}.css`, `${base}.scss`, `${base}.module.css`];
for (const f of index.files) {
const fdir = path.posix.dirname(f.path);
if (fdir !== dir) continue;
if (candidates.includes(path.posix.basename(f.path))) return f.path;
}
return undefined;
}
function slug(file: string): string {
return file
.replace(/^.*\//, '')
.replace(/\.[^.]+$/, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function canonicaliseIndex(index: CodeImportIndex): unknown {
// Strip the walk-time fields (walkedAt / walkBudgetMs) and
// skipped[] so the digest only changes when the file roster
// changes. Otherwise re-walks would invalidate every plan even
// when the source tree is identical.
return {
framework: index.framework,
packageManager: index.packageManager,
styleSystem: index.styleSystem,
routes: index.routes,
files: index.files
.map((f) => ({ path: f.path, language: f.language, size: f.size, imports: f.imports ?? [] }))
.sort((a, b) => a.path.localeCompare(b.path)),
};
}
function digestObject(obj: unknown): string {
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
}
function renderNarrative(args: {
intent: string;
ownership: OwnershipEntry[];
steps: RewriteStep[];
tokens: DesignExtractReport | undefined;
}): string {
const lines: string[] = [];
lines.push('# Rewrite plan');
lines.push('');
if (args.intent) {
lines.push(`**Intent**: ${args.intent}`);
lines.push('');
}
const tierCount = (tier: OwnershipTier) => args.ownership.filter((o) => o.layer === tier).length;
lines.push('## Ownership snapshot');
lines.push('');
lines.push(`- shell: ${tierCount('shell')} files`);
lines.push(`- route: ${tierCount('route')} files`);
lines.push(`- shared: ${tierCount('shared')} files`);
lines.push(`- leaf: ${tierCount('leaf')} files`);
lines.push('');
if (args.tokens) {
lines.push('## Design tokens detected');
lines.push('');
lines.push(`- colors: ${args.tokens.colors.length}`);
lines.push(`- typography: ${args.tokens.typography.length}`);
lines.push(`- spacing: ${args.tokens.spacing.length}`);
lines.push(`- radius: ${args.tokens.radius.length}`);
lines.push(`- shadow: ${args.tokens.shadow.length}`);
lines.push('');
}
lines.push('## Steps');
lines.push('');
for (const step of args.steps) {
lines.push(`### ${step.id} — risk: ${step.risk}`);
lines.push('');
lines.push(step.rationale);
if (step.files.length > 0) {
lines.push('');
for (const f of step.files) lines.push(`- \`${f}\``);
}
lines.push('');
}
return lines.join('\n');
}

View file

@ -7,6 +7,7 @@ export * from './atoms/build-test.js';
export * from './atoms/code-import.js';
export * from './atoms/design-extract.js';
export * from './atoms/handoff.js';
export * from './atoms/rewrite-plan.js';
export * from './bundled.js';
export * from './connector-gate.js';
export * from './export.js';

View file

@ -0,0 +1,125 @@
// Phase 7 entry slice — rewrite-plan atom impl.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runCodeImport } from '../src/plugins/atoms/code-import.js';
import { runDesignExtract } from '../src/plugins/atoms/design-extract.js';
import { runRewritePlan } from '../src/plugins/atoms/rewrite-plan.js';
let repo: string;
let cwd: string;
beforeEach(async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), 'od-rewrite-plan-'));
repo = path.join(tmp, 'repo');
cwd = path.join(tmp, 'cwd');
await mkdir(repo, { recursive: true });
await mkdir(cwd, { recursive: true });
});
afterEach(async () => {
await rm(path.dirname(repo), { recursive: true, force: true });
});
async function setupNextRepo() {
await writeFile(path.join(repo, 'package.json'), JSON.stringify({
name: 'fixture',
dependencies: { next: '15', react: '18' },
devDependencies: { tailwindcss: '4' },
}));
await writeFile(path.join(repo, 'pnpm-lock.yaml'), '');
await mkdir(path.join(repo, 'app'), { recursive: true });
await mkdir(path.join(repo, 'components'),{ recursive: true });
await mkdir(path.join(repo, 'lib'), { recursive: true });
await writeFile(path.join(repo, 'app', 'layout.tsx'),
`export default function Layout({ children }: any) { return <html><body>{children}</body></html>; }\n`);
await writeFile(path.join(repo, 'app', 'page.tsx'),
`import Button from '@/components/Button';\nexport default function Page() { return <Button />; }\n`);
await writeFile(path.join(repo, 'components', 'Button.tsx'),
`export default function Button() { return <button style={{ color: '#5b8def' }} />; }\n`);
await writeFile(path.join(repo, 'components', 'Button.css'),
`.btn { padding: 16px; color: #5b8def; }`);
await writeFile(path.join(repo, 'lib', 'fetcher.ts'),
`export const fetcher = (u: string) => fetch(u).then((r) => r.json());\n`);
await runCodeImport({ repoPath: repo, cwd });
await runDesignExtract({ cwd, repoPath: repo });
}
describe('runRewritePlan', () => {
it('classifies ownership across leaf / shared / route / shell', async () => {
await setupNextRepo();
const report = await runRewritePlan({ cwd, intent: 'tighten the brand' });
const own = new Map(report.ownership.map((o) => [o.file, o.layer]));
expect(own.get('app/layout.tsx')).toBe('shell');
expect(own.get('app/page.tsx')).toBe('route');
expect(own.get('components/Button.tsx')).toBe('leaf');
expect(own.get('lib/fetcher.ts')).toBe('shared');
// Root-level package.json defaults to shared (config) per the
// safety contract.
expect(own.get('package.json')).toBe('shared');
});
it('produces a tokens-alignment step when design-extract found inline literals', async () => {
await setupNextRepo();
const report = await runRewritePlan({ cwd });
const ids = report.steps.map((s) => s.id);
expect(ids).toContain('tokens-alignment');
const step = report.steps.find((s) => s.id === 'tokens-alignment');
expect(step?.risk).toBe('low');
expect(step?.files.length).toBeGreaterThan(0);
});
it('emits one rewrite-<slug> step per leaf component file', async () => {
await setupNextRepo();
const report = await runRewritePlan({ cwd });
const rewriteIds = report.steps.map((s) => s.id).filter((id) => id.startsWith('rewrite-'));
expect(rewriteIds.length).toBeGreaterThanOrEqual(1);
const buttonStep = report.steps.find((s) => s.id === 'rewrite-button');
expect(buttonStep).toBeDefined();
// Sibling stylesheet bundled into the same step.
expect(buttonStep?.files).toEqual(expect.arrayContaining([
'components/Button.tsx',
'components/Button.css',
]));
});
it('always ends with a build-test step', async () => {
await setupNextRepo();
const report = await runRewritePlan({ cwd });
const last = report.steps[report.steps.length - 1];
expect(last?.id).toBe('build-test');
expect(last?.risk).toBe('low');
});
it('persists plan/{plan.md, ownership.json, steps.json, meta.json} under cwd', async () => {
await setupNextRepo();
await runRewritePlan({ cwd, intent: 'mvp polish' });
const planMd = await readFile(path.join(cwd, 'plan', 'plan.md'), 'utf8');
const ownJson = JSON.parse(await readFile(path.join(cwd, 'plan', 'ownership.json'), 'utf8'));
const stepsJson = JSON.parse(await readFile(path.join(cwd, 'plan', 'steps.json'), 'utf8'));
const metaJson = JSON.parse(await readFile(path.join(cwd, 'plan', 'meta.json'), 'utf8'));
expect(planMd).toContain('# Rewrite plan');
expect(planMd).toContain('mvp polish');
expect(Array.isArray(ownJson)).toBe(true);
expect(stepsJson.some((s: { id: string }) => s.id === 'build-test')).toBe(true);
expect(typeof metaJson.atomDigest).toBe('string');
expect(metaJson.atomDigest.length).toBe(40);
});
it('produces stable atomDigest across two runs over the same code/index.json', async () => {
await setupNextRepo();
const a = await runRewritePlan({ cwd, intent: 'x' });
const b = await runRewritePlan({ cwd, intent: 'y' });
// Intent does not contribute to atomDigest; only the canonicalised
// code/index.json roster does.
expect(a.meta.atomDigest).toBe(b.meta.atomDigest);
// Different intents produce different plan.md though.
expect(a.planMarkdown).not.toBe(b.planMarkdown);
});
it('throws a clear error when code/index.json is missing', async () => {
await expect(runRewritePlan({ cwd })).rejects.toThrow(/code-import first/);
});
});