@planu/cli 5.3.25 → 5.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/config/legacy-artifacts.json +26 -0
  4. package/dist/config/official-sdd-tools.d.ts +1 -1
  5. package/dist/config/official-sdd-tools.js +1 -0
  6. package/dist/config/registries/hosts/codex.json +2 -1
  7. package/dist/engine/handoff-artifacts/io.js +1 -0
  8. package/dist/engine/handoff-artifacts/schemas.d.ts +46 -0
  9. package/dist/engine/handoff-artifacts/schemas.js +15 -0
  10. package/dist/engine/lifecycle-reconciliation.js +6 -5
  11. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  12. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  13. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  14. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  15. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  21. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  23. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  25. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  26. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  27. package/dist/engine/scope-boundaries/contradiction-checker.js +9 -3
  28. package/dist/engine/spec-format/read-technical-section.js +3 -2
  29. package/dist/engine/spec-format/unified-spec-builder.js +27 -0
  30. package/dist/engine/spec-migrator/index.d.ts +0 -1
  31. package/dist/engine/spec-migrator/index.js +0 -1
  32. package/dist/engine/spec-migrator/legacy-classifier.d.ts +4 -0
  33. package/dist/engine/spec-migrator/legacy-classifier.js +70 -0
  34. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -9
  35. package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +4 -2
  36. package/dist/engine/spec-migrator/strict-planu-cleanup.js +64 -53
  37. package/dist/engine/validator/validation-report-writer.d.ts +1 -0
  38. package/dist/engine/validator/validation-report-writer.js +3 -2
  39. package/dist/tools/heal-spec-docs.js +15 -12
  40. package/dist/tools/init-project/git-setup.js +44 -40
  41. package/dist/tools/init-project/handler.js +2 -1
  42. package/dist/tools/init-project/migration-runner.d.ts +2 -1
  43. package/dist/tools/init-project/migration-runner.js +18 -5
  44. package/dist/tools/init-project/result-builder.js +9 -0
  45. package/dist/tools/list-specs.js +39 -0
  46. package/dist/tools/migrate-legacy-spec.d.ts +10 -0
  47. package/dist/tools/migrate-legacy-spec.js +133 -0
  48. package/dist/tools/register-spec-tools/analysis-tools.js +2 -0
  49. package/dist/tools/schemas/output-schemas.d.ts +28 -0
  50. package/dist/tools/schemas/output-schemas.js +13 -0
  51. package/dist/tools/update-status/dod-gates.d.ts +1 -0
  52. package/dist/tools/update-status/dod-gates.js +123 -4
  53. package/dist/types/handoff-artifacts.d.ts +17 -1
  54. package/dist/types/project/planu-config.d.ts +3 -0
  55. package/dist/types/spec/core.d.ts +0 -7
  56. package/dist/types/spec-format.d.ts +17 -0
  57. package/dist/types/transition-log.d.ts +1 -1
  58. package/package.json +9 -9
  59. package/planu-native.json +1 -1
  60. package/planu-plugin.json +3 -2
  61. package/dist/engine/spec-migrator/unified-migration.d.ts +0 -18
  62. package/dist/engine/spec-migrator/unified-migration.js +0 -105
@@ -78,65 +78,69 @@ async function untrackHtmlFiles(projectPath) {
78
78
  export async function configureGitignoreForPlanu(projectPath) {
79
79
  return configureGitignore(projectPath);
80
80
  }
81
+ const PLANU_GITIGNORE_BLOCK_START = '# Planu (auto-configured)';
81
82
  async function configureGitignore(projectPath) {
82
83
  const targetPath = join(projectPath, '.gitignore');
83
84
  try {
84
- let gitignoreContent = '';
85
+ let original = '';
85
86
  try {
86
- gitignoreContent = await readFile(targetPath, 'utf-8');
87
+ original = await readFile(targetPath, 'utf-8');
87
88
  }
88
89
  catch {
89
90
  /* file doesn't exist */
90
91
  }
91
92
  // Remove planu/ from .gitignore if present — specs MUST be tracked in git
92
93
  const protectedPaths = ['planu/', 'planu.json', 'planu/*'];
93
- const lines = gitignoreContent.split('\n');
94
- const cleaned = lines.filter((line) => !protectedPaths.some((p) => line.trim() === p));
95
- let updated = cleaned.length !== lines.length;
96
- if (updated) {
97
- gitignoreContent = cleaned.join('\n');
98
- }
99
- // Add data/ if missing — runtime data should NOT be tracked
94
+ const cleaned = removePlanuGitignoreBlock(original.split('\n')).filter((line) => !protectedPaths.some((p) => line.trim() === p));
95
+ let gitignoreContent = cleaned.join('\n');
100
96
  const linesToAdd = [];
101
97
  if (!gitignoreContent.includes('data/')) {
102
98
  linesToAdd.push('data/');
103
99
  }
104
- // SPEC-466: Gitignore regenerable planu/ files to prevent merge conflicts
105
- // SPEC-724: Gitignore heal_spec_docs backup files (.bak.<ts>)
106
- const planuIgnores = [
107
- 'planu/*.html',
108
- 'planu/status.json',
109
- 'planu/CHANGELOG.md',
110
- 'planu/.housekeeping-history.jsonl',
111
- 'planu/audits/',
112
- 'planu/handoffs/',
113
- 'planu/data/',
114
- 'planu/state/',
115
- 'planu/.locks/',
116
- 'planu/specs/data/',
117
- 'planu/specs/**/.analysis.json',
118
- 'planu/specs/**/technical-report.html',
119
- 'planu/specs/**/reference/',
120
- 'planu/specs/**/*.bak.*',
121
- ];
122
- for (const entry of planuIgnores) {
123
- if (!gitignoreContent.includes(entry)) {
124
- linesToAdd.push(entry);
125
- }
126
- }
127
- if (linesToAdd.length > 0 || updated) {
128
- const separator = gitignoreContent.endsWith('\n') || gitignoreContent === '' ? '' : '\n';
129
- const addition = linesToAdd.length > 0
130
- ? `${separator}# Planu (auto-configured)\n${linesToAdd.join('\n')}\n`
131
- : '';
132
- await writeFile(targetPath, gitignoreContent + addition, 'utf-8');
133
- updated = true;
100
+ linesToAdd.push(...buildPlanuIgnoreBlock());
101
+ const separator = gitignoreContent.endsWith('\n') || gitignoreContent === '' ? '' : '\n';
102
+ gitignoreContent += `${separator}${PLANU_GITIGNORE_BLOCK_START}\n${linesToAdd.join('\n')}\n`;
103
+ if (gitignoreContent === original) {
104
+ return false;
134
105
  }
135
- return updated;
106
+ await writeFile(targetPath, gitignoreContent, 'utf-8');
107
+ return true;
136
108
  }
137
109
  catch {
138
110
  /* best-effort */
139
111
  return false;
140
112
  }
141
113
  }
114
+ function buildPlanuIgnoreBlock() {
115
+ return [
116
+ 'planu/*.html',
117
+ 'planu/status.json',
118
+ 'planu/CHANGELOG.md',
119
+ 'planu/.housekeeping-history.jsonl',
120
+ 'planu/audits/',
121
+ 'planu/handoffs/',
122
+ 'planu/data/',
123
+ 'planu/state/',
124
+ 'planu/.locks/',
125
+ 'planu/specs/**',
126
+ '!planu/specs/**/',
127
+ '!planu/specs/**/spec.md',
128
+ ];
129
+ }
130
+ function removePlanuGitignoreBlock(lines) {
131
+ let result = lines;
132
+ let startIndex = result.findIndex((line) => line.trim() === PLANU_GITIGNORE_BLOCK_START);
133
+ while (startIndex !== -1) {
134
+ let endIndex = result.length;
135
+ for (let i = startIndex + 1; i < result.length; i++) {
136
+ if (result[i]?.trim() === '') {
137
+ endIndex = i;
138
+ break;
139
+ }
140
+ }
141
+ result = [...result.slice(0, startIndex), ...result.slice(endIndex)];
142
+ startIndex = result.findIndex((line) => line.trim() === PLANU_GITIGNORE_BLOCK_START);
143
+ }
144
+ return result;
145
+ }
142
146
  //# sourceMappingURL=git-setup.js.map
@@ -422,7 +422,7 @@ export async function handleInitProject(params, server) {
422
422
  .map((outcome) => `${outcome.name}: ${outcome.error ?? 'unknown error'}`);
423
423
  const { configResult, migrations } = await configAndMigrations;
424
424
  const { planuConfigPath, planuConfigGenerated, whatsNew } = configResult;
425
- const { discoveryResult, migrationResult, folderMigrationResult, criticalMigrationFailures, nonCriticalWarnings: nonCriticalMigrationWarnings, migrationReportPath, changedPaths: migrationChangedPaths = [], } = migrations;
425
+ const { discoveryResult, migrationResult, folderMigrationResult, legacyMigrationDirectives, criticalMigrationFailures, nonCriticalWarnings: nonCriticalMigrationWarnings, migrationReportPath, changedPaths: migrationChangedPaths = [], } = migrations;
426
426
  // Auto-install or queue skills (opt-out via planu.json or input param, SPEC-185)
427
427
  const autoInstallFromConfig = autoInstallSkills ?? (await readAutoInstallFlag(planuConfigPath));
428
428
  const { skillsAutoInstalled, skillsPendingInstall, skillsSkipped } = await orchestrateSkillInstalls(recommendedSkills, projectPath, autoInstallFromConfig);
@@ -578,6 +578,7 @@ export async function handleInitProject(params, server) {
578
578
  migrationResult,
579
579
  discoveryResult,
580
580
  folderMigrationResult,
581
+ legacyMigrationDirectives,
581
582
  criticalMigrationFailures,
582
583
  nonCriticalMigrationWarnings,
583
584
  migrationReportPath,
@@ -1,5 +1,5 @@
1
1
  import { reconcilePortableSpecIndex } from './portable-index-reconciler.js';
2
- import type { ProjectKnowledge, Spec } from '../../types/index.js';
2
+ import type { LegacyMigrationDirective, ProjectKnowledge, Spec } from '../../types/index.js';
3
3
  export interface MigrationRunIssue {
4
4
  phase: string;
5
5
  severity: 'critical' | 'nonCritical';
@@ -25,6 +25,7 @@ export interface MigrationRunResult {
25
25
  nonCriticalWarnings: MigrationRunIssue[];
26
26
  migrationReportPath: string | null;
27
27
  changedPaths?: string[];
28
+ legacyMigrationDirectives: LegacyMigrationDirective[];
28
29
  }
29
30
  interface SpecMigrationOptions {
30
31
  repositoryMigration?: 'initialization' | 'planu-spec-format-v1';
@@ -3,9 +3,10 @@ import { specStore, knowledgeStore } from '../../storage/index.js';
3
3
  import { projectDataDir, writeJson } from '../../storage/base-store.js';
4
4
  import { filterLegacySpecs, migrateSpecs, filterUnprefixedSpecs, migrateSpecFolderNames, discoverAndFlattenSpecs, reconcileSpecPaths, migrateAllSpecsToLean, } from '../../engine/spec-migrator.js';
5
5
  import { reconcilePortableSpecIndex } from './portable-index-reconciler.js';
6
+ import { scanLegacyArtifacts, buildMigrationDirective, } from '../../engine/spec-migrator/legacy-classifier.js';
6
7
  import { createHash } from 'node:crypto';
7
8
  import { lstat, readFile, readdir, readlink } from 'node:fs/promises';
8
- import { join, relative } from 'node:path';
9
+ import { basename, join, relative } from 'node:path';
9
10
  async function snapshotPlanuTree(projectPath) {
10
11
  const root = join(projectPath, 'planu');
11
12
  const snapshot = new Map();
@@ -155,13 +156,24 @@ export async function runSpecMigrations(projectPath, projectId, knowledge, optio
155
156
  catch (err) {
156
157
  criticalMigrationFailures.push(issueFromError('lean-spec-migration', 'critical', err));
157
158
  }
158
- // SPEC-630: Merge separate technical.md into unified spec.md
159
+ const legacyMigrationDirectives = [];
159
160
  try {
160
- const { migrateAllSpecsToUnified } = await import('../../engine/spec-migrator/index.js');
161
- await migrateAllSpecsToUnified(projectPath);
161
+ const { glob } = await import('glob');
162
+ const specDirs = await glob(join(projectPath, 'planu/specs/SPEC-*'));
163
+ for (const specDir of specDirs) {
164
+ const specId = /^(SPEC-\d+)/i.exec(basename(specDir))?.[1]?.toUpperCase();
165
+ if (specId === undefined) {
166
+ continue;
167
+ }
168
+ const hits = await scanLegacyArtifacts(specDir);
169
+ const directive = await buildMigrationDirective(specId, specDir, hits);
170
+ if (directive !== null) {
171
+ legacyMigrationDirectives.push(directive);
172
+ }
173
+ }
162
174
  }
163
175
  catch (err) {
164
- criticalMigrationFailures.push(issueFromError('unified-spec-migration', 'critical', err));
176
+ criticalMigrationFailures.push(issueFromError('legacy-artifact-scan', 'critical', err));
165
177
  }
166
178
  // SPEC-1017: strict managed planu/ cleanup after all legacy migrations.
167
179
  try {
@@ -208,6 +220,7 @@ export async function runSpecMigrations(projectPath, projectId, knowledge, optio
208
220
  nonCriticalWarnings,
209
221
  migrationReportPath,
210
222
  changedPaths: repositoryFilesChanged,
223
+ legacyMigrationDirectives,
211
224
  };
212
225
  }
213
226
  /** Return all specs for the project (needed by caller for health check refs). */
@@ -149,6 +149,7 @@ export function buildInitProjectResult(input) {
149
149
  specMigration: input.migrationResult,
150
150
  specDiscovery: input.discoveryResult,
151
151
  specFolderMigration: input.folderMigrationResult,
152
+ legacyMigrationDirectives: input.legacyMigrationDirectives,
152
153
  criticalMigrationFailures: input.criticalMigrationFailures ?? [],
153
154
  bestEffortWarnings: input.nonCriticalMigrationWarnings ?? [],
154
155
  migrationReportPath: input.migrationReportPath ?? null,
@@ -197,6 +198,12 @@ export function buildInitProjectResult(input) {
197
198
  : '';
198
199
  const tokenTip = `\n\n💡 Token tip: Add CLAUDE_CODE_SUBAGENT_MODEL=haiku to your .env — makes all read-only subagents ` +
199
200
  `(search, grep, file reads) use Haiku instead of your session model (4–18x cheaper for those tasks).`;
201
+ const legacyMigrationNextAction = input.legacyMigrationDirectives.length > 0
202
+ ? `Legacy spec artifacts found in ${String(input.legacyMigrationDirectives.length)} spec folder(s): ` +
203
+ `${input.legacyMigrationDirectives.map((d) => d.specId).join(', ')}. For each, synthesize the ` +
204
+ `content-bearing legacyFiles (see legacyMigrationDirectives in structuredContent) into their ` +
205
+ `targetSection, then call migrate_legacy_spec with the merged spec.md body and filesToRetire.`
206
+ : null;
200
207
  const narrative = ti('tools.init_project.success', { projectPath: result.projectPath }) +
201
208
  `\n\nDetected: **${result.language}** / ${framework} — project ${action}. ` +
202
209
  `Run \`create_spec\` to start planning your first feature.` +
@@ -204,6 +211,7 @@ export function buildInitProjectResult(input) {
204
211
  ? `\n\n⚠️ Critical spec migration warnings: ${String(input.criticalMigrationFailures?.length ?? 0)} issue(s). ` +
205
212
  `See migrationReportPath in structuredContent${input.migrationReportPath ? ` (${input.migrationReportPath})` : ''}.`
206
213
  : '') +
214
+ (legacyMigrationNextAction ? `\n\n📦 ${legacyMigrationNextAction}` : '') +
207
215
  telemetryNote +
208
216
  tokenTip;
209
217
  // SPEC-491: Build interactiveQuestions for decisions that require user input
@@ -253,6 +261,7 @@ export function buildInitProjectResult(input) {
253
261
  structuredContent: {
254
262
  ...result,
255
263
  ...(hasInteractive ? { interactiveQuestions } : {}),
264
+ ...(legacyMigrationNextAction ? { nextAction: legacyMigrationNextAction } : {}),
256
265
  },
257
266
  };
258
267
  }
@@ -1,3 +1,5 @@
1
+ // tools/list-specs.ts — List specs with filters
2
+ import { dirname } from 'node:path';
1
3
  import { ti } from '../i18n/index.js';
2
4
  import { specStore, knowledgeStore } from '../storage/index.js';
3
5
  import { buildListSpecsSummary } from '../engine/human-summary.js';
@@ -5,9 +7,34 @@ import { checkBundledVersionGap } from '../engine/version-detector/bundled-versi
5
7
  import { formatRelativeDate } from '../engine/time/relative-date.js';
6
8
  import { scanForAmbiguousCriteria } from '../engine/spec-migrator.js';
7
9
  import { detectDrift, formatDriftMessage } from '../engine/spec-migrator/drift-detector.js';
10
+ import { buildMigrationDirective, scanLegacyArtifacts, } from '../engine/spec-migrator/legacy-classifier.js';
8
11
  import { AutopilotSummaryCollector } from '../engine/autopilot/summary-collector.js';
9
12
  import { trackCost } from '../engine/cost-tracking/operation-tracker.js';
10
13
  import { resolveProjectIdOrAutoDetect } from './resolve-project-id.js';
14
+ async function scanLegacyMigrationDirectives(specs) {
15
+ const directives = [];
16
+ for (const spec of specs) {
17
+ if (!spec.specPath) {
18
+ continue;
19
+ }
20
+ const specDir = dirname(spec.specPath);
21
+ const hits = await scanLegacyArtifacts(specDir);
22
+ const directive = await buildMigrationDirective(spec.id, specDir, hits);
23
+ if (directive !== null) {
24
+ directives.push(directive);
25
+ }
26
+ }
27
+ return directives;
28
+ }
29
+ function buildLegacyMigrationNextAction(directives) {
30
+ if (directives.length === 0) {
31
+ return undefined;
32
+ }
33
+ return (`Legacy spec artifacts found in ${String(directives.length)} spec folder(s): ` +
34
+ `${directives.map((d) => d.specId).join(', ')}. For each, synthesize the content-bearing ` +
35
+ `legacyFiles into their targetSection, then call migrate_legacy_spec with the merged spec.md ` +
36
+ `body and filesToRetire.`);
37
+ }
11
38
  /** Track which projects have been auto-discovered this session (once per project). */
12
39
  const discoveredProjects = new Set();
13
40
  const MAX_STRUCTURED_SPECS = 50;
@@ -264,6 +291,16 @@ export async function handleListSpecs(params) {
264
291
  /* best-effort — never block list_specs */
265
292
  }
266
293
  }
294
+ let legacyMigrationDirectives = [];
295
+ if (detail === 'full') {
296
+ try {
297
+ legacyMigrationDirectives = await scanLegacyMigrationDirectives(allSpecs);
298
+ }
299
+ catch {
300
+ /* best-effort — never block list_specs */
301
+ }
302
+ }
303
+ const legacyMigrationNextAction = buildLegacyMigrationNextAction(legacyMigrationDirectives);
267
304
  const structuredContent = {
268
305
  projectId,
269
306
  filters: {
@@ -291,6 +328,8 @@ export async function handleListSpecs(params) {
291
328
  migrationIssues: migrationIssues ?? undefined,
292
329
  interactiveQuestions: interactiveQuestions ?? undefined,
293
330
  staleStatusWarnings: staleStatusWarnings.length > 0 ? staleStatusWarnings : undefined,
331
+ legacyMigrationDirectives: legacyMigrationDirectives.length > 0 ? legacyMigrationDirectives : undefined,
332
+ nextAction: legacyMigrationNextAction,
294
333
  };
295
334
  return {
296
335
  content: [
@@ -0,0 +1,10 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolResult } from '../types/index.js';
3
+ export declare function migrateLegacySpec(params: {
4
+ projectPath: string;
5
+ specId: string;
6
+ mergedBody?: string;
7
+ filesToRetire: string[];
8
+ }): Promise<ToolResult>;
9
+ export declare function registerMigrateLegacySpecTool(server: McpServer): void;
10
+ //# sourceMappingURL=migrate-legacy-spec.d.ts.map
@@ -0,0 +1,133 @@
1
+ import { z } from 'zod';
2
+ import { cp, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { glob } from 'glob';
8
+ import { atomicWriteFile } from '../engine/safety/atomic-write-file.js';
9
+ import { safeUnlink } from '../engine/spec-migrator/git-aware-fs.js';
10
+ import { safe } from './safe-handler.js';
11
+ const execFileAsync = promisify(execFile);
12
+ const migrateLegacySpecInputSchema = {
13
+ projectPath: z.string().describe('Absolute path to the project root containing planu/specs'),
14
+ specId: z.string().min(1).describe('Spec identifier, e.g. "SPEC-1570"'),
15
+ mergedBody: z
16
+ .string()
17
+ .optional()
18
+ .describe('Full replacement content for spec.md, synthesized by the host LLM from the retired ' +
19
+ 'content-bearing legacy files. Omit when only retiring transient files.'),
20
+ filesToRetire: z
21
+ .array(z.string().min(1))
22
+ .min(1)
23
+ .describe('Paths relative to the spec folder to back up, delete, and git-untrack (files or directories)'),
24
+ };
25
+ async function resolveSpecDir(specId, projectPath) {
26
+ const pattern = join(projectPath, 'planu', 'specs', `${specId}-*/`);
27
+ const dirs = await glob(pattern, { absolute: true });
28
+ return dirs[0] ?? null;
29
+ }
30
+ async function gitRmCachedRecursive(projectPath, relPath) {
31
+ if (!existsSync(join(projectPath, '.git'))) {
32
+ return;
33
+ }
34
+ try {
35
+ await execFileAsync('git', ['rm', '--cached', '--quiet', '--ignore-unmatch', '-r', relPath], {
36
+ cwd: projectPath,
37
+ timeout: 5_000,
38
+ });
39
+ }
40
+ catch {
41
+ /* best-effort */
42
+ }
43
+ }
44
+ function escapesSpecDir(specDir, relPath) {
45
+ const resolvedSpecDir = resolve(specDir);
46
+ const absolute = resolve(specDir, relPath);
47
+ const relativeToSpecDir = relative(resolvedSpecDir, absolute);
48
+ return (relativeToSpecDir === '..' ||
49
+ relativeToSpecDir.startsWith(`..${sep}`) ||
50
+ isAbsolute(relativeToSpecDir));
51
+ }
52
+ async function retireEntry(projectPath, specDir, relPath) {
53
+ const absolute = join(specDir, relPath);
54
+ const timestamp = Date.now();
55
+ const isDirectory = (await stat(absolute).catch(() => null))?.isDirectory() ?? false;
56
+ if (isDirectory) {
57
+ await cp(absolute, `${absolute}.bak.${timestamp}`, { recursive: true });
58
+ await gitRmCachedRecursive(projectPath, relative(projectPath, absolute));
59
+ await rm(absolute, { recursive: true, force: true });
60
+ return;
61
+ }
62
+ const content = await readFile(absolute).catch(() => null);
63
+ if (content !== null) {
64
+ await writeFile(`${absolute}.bak.${timestamp}`, content);
65
+ }
66
+ await safeUnlink(projectPath, absolute);
67
+ await rm(absolute, { force: true });
68
+ }
69
+ export async function migrateLegacySpec(params) {
70
+ const { projectPath, specId, mergedBody, filesToRetire } = params;
71
+ const specDir = await resolveSpecDir(specId, projectPath);
72
+ if (specDir === null) {
73
+ return {
74
+ content: [
75
+ {
76
+ type: 'text',
77
+ text: `Spec folder for ${specId} not found under ${join(projectPath, 'planu', 'specs')}. Verify the specId and projectPath, then retry.`,
78
+ },
79
+ ],
80
+ isError: true,
81
+ };
82
+ }
83
+ if (mergedBody !== undefined) {
84
+ await atomicWriteFile(join(specDir, 'spec.md'), mergedBody, {
85
+ forceEdit: {
86
+ reason: `SPEC-1570 migrate_legacy_spec is merging legacy content into spec.md for ${specId}.`,
87
+ },
88
+ });
89
+ }
90
+ const retired = [];
91
+ const rejected = [];
92
+ for (const relPath of filesToRetire) {
93
+ if (escapesSpecDir(specDir, relPath)) {
94
+ rejected.push(relPath);
95
+ continue;
96
+ }
97
+ await retireEntry(projectPath, specDir, relPath);
98
+ retired.push(relPath);
99
+ }
100
+ if (rejected.length > 0) {
101
+ return {
102
+ content: [
103
+ {
104
+ type: 'text',
105
+ text: `Migrated ${specId}: ${mergedBody !== undefined ? 'spec.md updated, ' : ''}retired ${String(retired.length)} legacy artifact(s) (${retired.join(', ')}). Rejected ${String(rejected.length)} filesToRetire entr${rejected.length === 1 ? 'y' : 'ies'} escaping the spec directory: ${rejected.join(', ')}. Pass paths relative to the spec folder only.`,
106
+ },
107
+ ],
108
+ isError: true,
109
+ structuredContent: { specId, specMdUpdated: mergedBody !== undefined, retired, rejected },
110
+ };
111
+ }
112
+ return {
113
+ content: [
114
+ {
115
+ type: 'text',
116
+ text: `Migrated ${specId}: ${mergedBody !== undefined ? 'spec.md updated, ' : ''}retired ${String(retired.length)} legacy artifact(s) (${retired.join(', ')}).`,
117
+ },
118
+ ],
119
+ isError: false,
120
+ structuredContent: { specId, specMdUpdated: mergedBody !== undefined, retired },
121
+ };
122
+ }
123
+ export function registerMigrateLegacySpecTool(server) {
124
+ server.registerTool('migrate_legacy_spec', {
125
+ description: 'Persist a host-LLM-synthesized spec.md body and retire the legacy files it replaces: ' +
126
+ 'each retired file/directory is backed up with a timestamped .bak copy, deleted, and ' +
127
+ 'git-untracked. Call this after synthesizing mergedBody from the legacyFiles listed in a ' +
128
+ 'migrate_legacy_spec directive (from init_project, list_specs, or heal_spec_docs).',
129
+ inputSchema: migrateLegacySpecInputSchema,
130
+ annotations: { readOnlyHint: false, destructiveHint: true, title: 'Migrate Legacy Spec' },
131
+ }, safe((args) => migrateLegacySpec(args)));
132
+ }
133
+ //# sourceMappingURL=migrate-legacy-spec.js.map
@@ -18,6 +18,7 @@ import { handleScanOrphanSpecRefs, ScanOrphanSpecRefsInputSchema, } from '../sca
18
18
  import { registerGraphSpecsTool } from '../graph-specs.js';
19
19
  import { registerAuditSpecsDriftTool } from '../audit-specs-drift.js';
20
20
  import { handleSsrBackMigration } from '../ssr-back-migration.js';
21
+ import { registerMigrateLegacySpecTool } from '../migrate-legacy-spec.js';
21
22
  /**
22
23
  * Registers drift detection, analysis, and discovery tools (tools 11–19) on the MCP server.
23
24
  */
@@ -294,6 +295,7 @@ export function registerAnalysisTools(server) {
294
295
  registerGraphSpecsTool(server);
295
296
  // SPEC-744: audit_specs_drift
296
297
  registerAuditSpecsDriftTool(server);
298
+ registerMigrateLegacySpecTool(server);
297
299
  // SPEC-752: ssr_back_migration — fold legacy split-file specs into unified spec.md
298
300
  server.registerTool('ssr_back_migration', {
299
301
  description: 'Migrate legacy 2-file format specs into the unified spec.md format. ' +
@@ -76,6 +76,20 @@ export declare const ListSpecsOutputSchema: {
76
76
  commitSha: z.ZodString;
77
77
  suggestedAction: z.ZodLiteral<"auto-fix-available">;
78
78
  }, z.core.$strip>>>;
79
+ legacyMigrationDirectives: z.ZodOptional<z.ZodArray<z.ZodObject<{
80
+ specId: z.ZodString;
81
+ legacyFiles: z.ZodArray<z.ZodObject<{
82
+ path: z.ZodString;
83
+ classification: z.ZodEnum<{
84
+ "content-bearing": "content-bearing";
85
+ transient: "transient";
86
+ }>;
87
+ targetSection: z.ZodOptional<z.ZodString>;
88
+ inlinedContent: z.ZodOptional<z.ZodString>;
89
+ }, z.core.$strip>>;
90
+ directive: z.ZodLiteral<"migrate_legacy_spec">;
91
+ }, z.core.$strip>>>;
92
+ nextAction: z.ZodOptional<z.ZodString>;
79
93
  };
80
94
  /** Canonical DTO inferred from the exact MCP output contract. */
81
95
  export declare const ListSpecsOutputObjectSchema: z.ZodObject<{
@@ -155,6 +169,20 @@ export declare const ListSpecsOutputObjectSchema: z.ZodObject<{
155
169
  commitSha: z.ZodString;
156
170
  suggestedAction: z.ZodLiteral<"auto-fix-available">;
157
171
  }, z.core.$strip>>>;
172
+ legacyMigrationDirectives: z.ZodOptional<z.ZodArray<z.ZodObject<{
173
+ specId: z.ZodString;
174
+ legacyFiles: z.ZodArray<z.ZodObject<{
175
+ path: z.ZodString;
176
+ classification: z.ZodEnum<{
177
+ "content-bearing": "content-bearing";
178
+ transient: "transient";
179
+ }>;
180
+ targetSection: z.ZodOptional<z.ZodString>;
181
+ inlinedContent: z.ZodOptional<z.ZodString>;
182
+ }, z.core.$strip>>;
183
+ directive: z.ZodLiteral<"migrate_legacy_spec">;
184
+ }, z.core.$strip>>>;
185
+ nextAction: z.ZodOptional<z.ZodString>;
158
186
  }, z.core.$strip>;
159
187
  export type ListSpecsStructuredContent = z.infer<typeof ListSpecsOutputObjectSchema>;
160
188
  export declare const EstimateOutputSchema: {
@@ -92,6 +92,19 @@ export const ListSpecsOutputSchema = {
92
92
  suggestedAction: z.literal('auto-fix-available'),
93
93
  }))
94
94
  .optional(),
95
+ legacyMigrationDirectives: z
96
+ .array(z.object({
97
+ specId: z.string(),
98
+ legacyFiles: z.array(z.object({
99
+ path: z.string(),
100
+ classification: z.enum(['content-bearing', 'transient']),
101
+ targetSection: z.string().optional(),
102
+ inlinedContent: z.string().optional(),
103
+ })),
104
+ directive: z.literal('migrate_legacy_spec'),
105
+ }))
106
+ .optional(),
107
+ nextAction: z.string().optional(),
95
108
  };
96
109
  /** Canonical DTO inferred from the exact MCP output contract. */
97
110
  export const ListSpecsOutputObjectSchema = z.object(ListSpecsOutputSchema);
@@ -107,6 +107,7 @@ export declare function checkValidationReportGate(specId: string, projectId: str
107
107
  export declare function readApprovedValidationReportGate(specId: string, projectId: string, force: boolean | undefined): Promise<ValidationReportGateResult>;
108
108
  /** SPEC-1051: Approval requires a dedicated spec reviewer artifact. */
109
109
  export declare function checkSpecReviewGate(specId: string, projectId: string, _forceApprove: boolean | undefined): Promise<ToolResult | null>;
110
+ export declare function checkImplementationReviewGate(specId: string, projectId: string, _force: boolean | undefined): Promise<ToolResult | null>;
110
111
  /**
111
112
  * SPEC-335: Combined done-gates runner — DoD + security.
112
113
  * When force=true, gates are not blocking but failing items are recorded in