@planu/cli 5.3.24 → 5.3.26

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 (59) hide show
  1. package/CHANGELOG.md +15 -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/planu-core.darwin-arm64.node.manifest.json +7 -7
  8. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  9. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  10. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  11. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  12. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  13. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  14. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  15. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  21. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  23. package/dist/engine/safety/assert-within-project.d.ts +2 -0
  24. package/dist/engine/safety/assert-within-project.js +14 -0
  25. package/dist/engine/scope-boundaries/contradiction-checker.js +9 -3
  26. package/dist/engine/spec-format/read-technical-section.js +3 -2
  27. package/dist/engine/spec-format/unified-spec-builder.js +27 -0
  28. package/dist/engine/spec-migrator/index.d.ts +0 -1
  29. package/dist/engine/spec-migrator/index.js +0 -1
  30. package/dist/engine/spec-migrator/legacy-classifier.d.ts +4 -0
  31. package/dist/engine/spec-migrator/legacy-classifier.js +70 -0
  32. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -9
  33. package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +4 -2
  34. package/dist/engine/spec-migrator/strict-planu-cleanup.js +64 -53
  35. package/dist/storage/spec-store.js +6 -0
  36. package/dist/tools/export-spec.js +18 -15
  37. package/dist/tools/heal-spec-docs.js +15 -12
  38. package/dist/tools/init-project/git-setup.js +44 -40
  39. package/dist/tools/init-project/handler.js +2 -1
  40. package/dist/tools/init-project/migration-runner.d.ts +2 -1
  41. package/dist/tools/init-project/migration-runner.js +18 -5
  42. package/dist/tools/init-project/result-builder.js +9 -0
  43. package/dist/tools/list-specs.js +39 -0
  44. package/dist/tools/migrate-legacy-spec.d.ts +10 -0
  45. package/dist/tools/migrate-legacy-spec.js +133 -0
  46. package/dist/tools/register-spec-tools/analysis-tools.js +2 -0
  47. package/dist/tools/schema-generator-handler.js +3 -0
  48. package/dist/tools/schemas/output-schemas.d.ts +28 -0
  49. package/dist/tools/schemas/output-schemas.js +13 -0
  50. package/dist/tools/spec-export-handler.js +21 -8
  51. package/dist/tools/storage-bundle-handler.js +35 -4
  52. package/dist/types/project/planu-config.d.ts +3 -0
  53. package/dist/types/spec/core.d.ts +0 -7
  54. package/dist/types/spec-format.d.ts +17 -0
  55. package/package.json +9 -9
  56. package/planu-native.json +1 -1
  57. package/planu-plugin.json +3 -2
  58. package/dist/engine/spec-migrator/unified-migration.d.ts +0 -18
  59. package/dist/engine/spec-migrator/unified-migration.js +0 -105
@@ -66,12 +66,16 @@ function splitClauses(text) {
66
66
  .map((clause) => clause.trim())
67
67
  .filter(Boolean);
68
68
  }
69
+ const AFFIRMATIVE_ACTION_VERBS = /\b(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed|mutat(?:e|es|ing))\b/;
70
+ function assertsForbiddenAction(text) {
71
+ return AFFIRMATIVE_ACTION_VERBS.test(normalize(text));
72
+ }
69
73
  function hasAffirmativeDrift(text, outOfScopeItem) {
70
- const action = /\b(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed)\b/g;
74
+ const action = new RegExp(AFFIRMATIVE_ACTION_VERBS, 'g');
71
75
  const actionSegments = text.split(/\band\b/i).map(normalize);
72
76
  for (const [index, segment] of actionSegments.entries()) {
73
77
  const inheritsRelevantSubject = index > 0 &&
74
- /^(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed)\b/.test(segment) &&
78
+ new RegExp(`^${AFFIRMATIVE_ACTION_VERBS.source}`).test(segment) &&
75
79
  clauseMatchesScope(actionSegments[index - 1] ?? '', outOfScopeItem);
76
80
  for (const match of segment.matchAll(action)) {
77
81
  const before = segment.slice(Math.max(0, match.index - 24), match.index);
@@ -133,7 +137,9 @@ function contradicts(criterionText, outOfScopeItem) {
133
137
  if (keywords(outOfScopeItem).length === 0) {
134
138
  return false;
135
139
  }
136
- return clauses.some((clause) => clauseMatchesScope(clause, outOfScopeItem));
140
+ const scopeAssertsForbiddenAction = assertsForbiddenAction(outOfScopeItem);
141
+ return clauses.some((clause) => clauseMatchesScope(clause, outOfScopeItem) &&
142
+ (!scopeAssertsForbiddenAction || assertsForbiddenAction(clause)));
137
143
  }
138
144
  /**
139
145
  * Check acceptance criteria against the spec's outOfScope declarations.
@@ -41,13 +41,14 @@ export function extractSectionBody(body, sectionName) {
41
41
  // match `## Technical Notes` etc. Use `[ \\t]*` (not `\\s*`) to avoid
42
42
  // consuming the line terminator via greedy backtrack. (SPEC-1010 PR-C
43
43
  // dual-Opus review)
44
- const headingRe = new RegExp(`^##\\s+${escapeRegex(sectionName)}[ \\t]*$`, 'm');
44
+ const headingRe = new RegExp(`^(#{1,6})[ \\t]+${escapeRegex(sectionName)}[ \\t]*$`, 'm');
45
45
  const match = headingRe.exec(masked);
46
46
  if (!match) {
47
47
  return '';
48
48
  }
49
+ const level = match[1]?.length ?? 2;
49
50
  const afterHeading = match.index + match[0].length;
50
- const nextRe = /^##\s+\S/gm;
51
+ const nextRe = new RegExp(`^#{1,${level}}[ \\t]+\\S`, 'gm');
51
52
  nextRe.lastIndex = afterHeading;
52
53
  const next = nextRe.exec(masked);
53
54
  const end = next ? next.index : normalized.length;
@@ -37,9 +37,36 @@ export function buildCanonicalUnifiedSpecContent(input) {
37
37
  renderSection('Acceptance Criteria', acceptance),
38
38
  renderSection('Files', renderOwnedFiles(input.files)),
39
39
  renderSection('Out of scope', outOfScope),
40
+ ...extractAuthorSections(source),
40
41
  ];
41
42
  return `${frontmatter ? `${frontmatter}\n\n` : ''}${sections.join('\n\n')}\n`;
42
43
  }
44
+ const CANONICAL_SECTION_NAMES = new Set([
45
+ 'Problem',
46
+ 'Goal',
47
+ 'Technical',
48
+ 'Implementation Contract',
49
+ 'Acceptance Criteria',
50
+ 'Files',
51
+ 'Out of scope',
52
+ ].map((name) => name.toLowerCase()));
53
+ function extractAuthorSections(source) {
54
+ const scannable = stripFencedCodeBlocks(source);
55
+ const headingRe = /^##[ \t]+(\S.*?)[ \t]*$/gm;
56
+ const preserved = [];
57
+ let match;
58
+ while ((match = headingRe.exec(scannable)) !== null) {
59
+ const name = match[1] ?? '';
60
+ if (CANONICAL_SECTION_NAMES.has(name.toLowerCase())) {
61
+ continue;
62
+ }
63
+ const body = extractTopLevelSectionBody(source, name);
64
+ if (body !== null && body.length > 0) {
65
+ preserved.push(renderSection(name, body));
66
+ }
67
+ }
68
+ return preserved;
69
+ }
43
70
  /**
44
71
  * Combine a generated lean spec.md body and a generated lean technical.md body
45
72
  * into a single unified spec.md content. The technical body's YAML frontmatter
@@ -9,7 +9,6 @@ export { hasLegacyConfig } from './version-detection.js';
9
9
  export { validateMigrationResult, mergeMigrationResults, emptyMigrationResult, } from './migration-validator.js';
10
10
  export { migrateAllSpecsToLean, migrateSpecToLean, isOldFormat } from './lean-migration.js';
11
11
  export { scanForAmbiguousCriteria } from './criteria-scanner.js';
12
- export { migrateToUnified, migrateAllSpecsToUnified } from './unified-migration.js';
13
12
  export { findLegacyMultiFileSpecs } from './find-legacy-multifile-specs.js';
14
13
  export { foldTechnicalIntoSpec } from './fold-technical.js';
15
14
  export { foldProgressIntoSpec, isBoilerplateProgress } from './fold-progress.js';
@@ -21,7 +21,6 @@ export { migrateAllSpecsToLean, migrateSpecToLean, isOldFormat } from './lean-mi
21
21
  // SPEC-487: Scan lean specs for ambiguous criteria (score < 50)
22
22
  export { scanForAmbiguousCriteria } from './criteria-scanner.js';
23
23
  // SPEC-630: Merge technical.md into unified spec.md
24
- export { migrateToUnified, migrateAllSpecsToUnified } from './unified-migration.js';
25
24
  // SPEC-752: SSR back-migration — fold legacy technical.md/progress.md into spec.md
26
25
  export { findLegacyMultiFileSpecs } from './find-legacy-multifile-specs.js';
27
26
  export { foldTechnicalIntoSpec } from './fold-technical.js';
@@ -0,0 +1,4 @@
1
+ import type { LegacyArtifactHit, LegacyMigrationDirective } from '../../types/index.js';
2
+ export declare function scanLegacyArtifacts(specDir: string): Promise<LegacyArtifactHit[]>;
3
+ export declare function buildMigrationDirective(specId: string, specDir: string, hits: LegacyArtifactHit[]): Promise<LegacyMigrationDirective | null>;
4
+ //# sourceMappingURL=legacy-classifier.d.ts.map
@@ -0,0 +1,70 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { ConfigLoader } from '../config-loader.js';
5
+ const legacyArtifactRuleSchema = z.object({
6
+ id: z.string().min(1).describe('Unique identifier for this legacy artifact rule'),
7
+ pattern: z
8
+ .string()
9
+ .min(1)
10
+ .describe('Basename (file) or directory name to match inside a spec folder'),
11
+ classification: z
12
+ .enum(['content-bearing', 'transient'])
13
+ .describe('content-bearing requires host-LLM synthesis into targetSection; transient is deleted with no synthesis'),
14
+ targetSection: z
15
+ .string()
16
+ .optional()
17
+ .describe('spec.md section this artifact folds into, required for content-bearing rules'),
18
+ });
19
+ const legacyArtifactRulesSchema = z
20
+ .array(legacyArtifactRuleSchema)
21
+ .describe('Registry of legacy spec-folder artifact classifications');
22
+ const loader = new ConfigLoader('legacy-artifacts', legacyArtifactRulesSchema);
23
+ function loadRules() {
24
+ return loader.load();
25
+ }
26
+ export async function scanLegacyArtifacts(specDir) {
27
+ const rules = loadRules();
28
+ let entries;
29
+ try {
30
+ entries = await readdir(specDir);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ const hits = [];
36
+ for (const entry of entries) {
37
+ if (entry === 'spec.md') {
38
+ continue;
39
+ }
40
+ const rule = rules.find((r) => r.pattern === entry);
41
+ if (!rule) {
42
+ continue;
43
+ }
44
+ hits.push({
45
+ path: entry,
46
+ classification: rule.classification,
47
+ ...(rule.targetSection !== undefined ? { targetSection: rule.targetSection } : {}),
48
+ });
49
+ }
50
+ return hits;
51
+ }
52
+ export async function buildMigrationDirective(specId, specDir, hits) {
53
+ if (hits.length === 0) {
54
+ return null;
55
+ }
56
+ const legacyFiles = [];
57
+ for (const hit of hits) {
58
+ if (hit.classification !== 'content-bearing') {
59
+ legacyFiles.push(hit);
60
+ continue;
61
+ }
62
+ const inlinedContent = await readFile(join(specDir, hit.path), 'utf-8').catch(() => undefined);
63
+ legacyFiles.push({
64
+ ...hit,
65
+ ...(inlinedContent !== undefined ? { inlinedContent } : {}),
66
+ });
67
+ }
68
+ return { specId, legacyFiles, directive: 'migrate_legacy_spec' };
69
+ }
70
+ //# sourceMappingURL=legacy-classifier.js.map
@@ -1,5 +1,18 @@
1
1
  // engine/spec-migrator/planu-canonical-policy.ts — SPEC-1017
2
2
  // Single source of truth for the strict Planu managed directory contract.
3
+ import { z } from 'zod';
4
+ import { ConfigLoader } from '../config-loader.js';
5
+ const legacyArtifactRuleSchema = z.object({
6
+ id: z.string().min(1),
7
+ pattern: z.string().min(1),
8
+ classification: z.enum(['content-bearing', 'transient']),
9
+ targetSection: z.string().optional(),
10
+ });
11
+ const legacyArtifactRulesLoader = new ConfigLoader('legacy-artifacts', z.array(legacyArtifactRuleSchema));
12
+ const LEGACY_CONTENT_BEARING_PATTERNS = legacyArtifactRulesLoader
13
+ .load()
14
+ .filter((rule) => rule.classification === 'content-bearing')
15
+ .map((rule) => rule.pattern);
3
16
  export const PLANU_CANONICAL_POLICY = {
4
17
  canonicalRootFiles: [
5
18
  'conventions.json',
@@ -33,15 +46,7 @@ export const PLANU_CANONICAL_POLICY = {
33
46
  'planu/specs/*/implementation-brief.md',
34
47
  'planu/specs/*/risk-register.md',
35
48
  ],
36
- legacyMergeBeforeDeleteFiles: [
37
- 'technical.md',
38
- 'plan.md',
39
- 'PLAN.md',
40
- 'progress.md',
41
- 'HU.md',
42
- 'FICHA-TECNICA.md',
43
- 'PROGRESS.md',
44
- ],
49
+ legacyMergeBeforeDeleteFiles: LEGACY_CONTENT_BEARING_PATTERNS,
45
50
  };
46
51
  const ROOT_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootFiles);
47
52
  const ROOT_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootDirs);
@@ -1,6 +1,8 @@
1
- import type { StrictPlanuCleanupResult, StrictPlanuValidationOptions, StrictPlanuValidationResult, HousekeepingAuthority } from '../../types/index.js';
1
+ import type { StrictPlanuCleanupResult, StrictPlanuValidationOptions, StrictPlanuValidationResult, HousekeepingAuthority, LegacyArtifactHit } from '../../types/index.js';
2
2
  import { PLANU_CANONICAL_POLICY } from './planu-canonical-policy.js';
3
+ declare function classifyPendingLegacySpecFile(specDir: string, fileName: string): Promise<LegacyArtifactHit>;
4
+ export declare function findTrackedNonCanonicalSpecFiles(projectPath: string): Promise<string[]>;
3
5
  export declare function runStrictPlanuCleanup(projectPath: string, authority?: HousekeepingAuthority): Promise<StrictPlanuCleanupResult>;
4
6
  export declare function validateStrictPlanuLayout(projectPath: string, options?: StrictPlanuValidationOptions): Promise<StrictPlanuValidationResult>;
5
- export { PLANU_CANONICAL_POLICY };
7
+ export { PLANU_CANONICAL_POLICY, classifyPendingLegacySpecFile };
6
8
  //# sourceMappingURL=strict-planu-cleanup.d.ts.map
@@ -7,6 +7,7 @@ import { promisify } from 'node:util';
7
7
  import { dirname, isAbsolute, join, relative } from 'node:path';
8
8
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
9
9
  import { safeUnlink } from './git-aware-fs.js';
10
+ import { scanLegacyArtifacts } from './legacy-classifier.js';
10
11
  import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
11
12
  const execFileAsync = promisify(execFile);
12
13
  async function pathIsDirectory(path) {
@@ -41,37 +42,10 @@ async function gitRmCached(projectPath, relPath) {
41
42
  /* best-effort */
42
43
  }
43
44
  }
44
- function stripFrontmatter(content) {
45
- return content.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
46
- }
47
- function appendSectionIfMissing(specContent, heading, body) {
48
- if (body.trim().length === 0 || new RegExp(`\\n## ${heading}(\\n|$)`).test(specContent)) {
49
- return specContent;
50
- }
51
- return `${specContent.trimEnd()}\n\n## ${heading}\n\n${body.trim()}\n`;
52
- }
53
- async function mergeLegacySpecFile(projectPath, specDir, fileName) {
54
- const specPath = join(specDir, 'spec.md');
55
- const legacyPath = join(specDir, fileName);
56
- const [specContent, legacyContent] = await Promise.all([
57
- readFile(specPath, 'utf-8'),
58
- readFile(legacyPath, 'utf-8'),
59
- ]);
60
- const body = stripFrontmatter(legacyContent);
61
- const section = fileName === 'progress.md' || fileName === 'PROGRESS.md'
62
- ? 'Progress'
63
- : fileName === 'technical.md' || fileName === 'FICHA-TECNICA.md'
64
- ? 'Technical'
65
- : 'Files';
66
- const merged = appendSectionIfMissing(specContent, section, body);
67
- if (merged !== specContent) {
68
- await atomicWriteFile(specPath, merged, {
69
- forceEdit: {
70
- reason: `SPEC-1017 strict cleanup is merging legacy ${fileName} into canonical spec.md.`,
71
- },
72
- });
73
- }
74
- await safeUnlink(projectPath, legacyPath);
45
+ async function classifyPendingLegacySpecFile(specDir, fileName) {
46
+ const hits = await scanLegacyArtifacts(specDir);
47
+ const hit = hits.find((h) => h.path === fileName);
48
+ return hit ?? { path: fileName, classification: 'content-bearing' };
75
49
  }
76
50
  async function removePath(projectPath, absolutePath) {
77
51
  const rel = relative(projectPath, absolutePath);
@@ -83,16 +57,9 @@ async function removePath(projectPath, absolutePath) {
83
57
  await safeUnlink(projectPath, absolutePath);
84
58
  await rm(absolutePath, { force: true });
85
59
  }
86
- async function updateGitignore(projectPath) {
87
- const gitignorePath = join(projectPath, '.gitignore');
88
- let current = '';
89
- try {
90
- current = await readFile(gitignorePath, 'utf-8');
91
- }
92
- catch {
93
- /* missing .gitignore is fine */
94
- }
95
- const required = [
60
+ const PLANU_GITIGNORE_BLOCK_HEADER = '# Planu generated/runtime';
61
+ function buildPlanuIgnoreBlock() {
62
+ return [
96
63
  'planu/*.html',
97
64
  'planu/status.json',
98
65
  'planu/CHANGELOG.md',
@@ -102,27 +69,71 @@ async function updateGitignore(projectPath) {
102
69
  'planu/data/',
103
70
  'planu/state/',
104
71
  'planu/.locks/',
105
- 'planu/specs/data/',
106
- 'planu/specs/**/.analysis.json',
107
- 'planu/specs/**/technical-report.html',
108
- 'planu/specs/**/reference/',
109
- 'planu/specs/**/*.bak.*',
72
+ 'planu/specs/**',
73
+ '!planu/specs/**/',
74
+ '!planu/specs/**/spec.md',
110
75
  ];
111
- const missing = required.filter((entry) => !current.split('\n').includes(entry));
112
- if (missing.length === 0) {
76
+ }
77
+ function removePlanuGitignoreBlock(lines) {
78
+ let result = lines;
79
+ let startIndex = result.findIndex((line) => line.trim() === PLANU_GITIGNORE_BLOCK_HEADER);
80
+ while (startIndex !== -1) {
81
+ let endIndex = result.length;
82
+ for (let i = startIndex + 1; i < result.length; i++) {
83
+ if (result[i]?.trim() === '') {
84
+ endIndex = i;
85
+ break;
86
+ }
87
+ }
88
+ result = [...result.slice(0, startIndex), ...result.slice(endIndex)];
89
+ startIndex = result.findIndex((line) => line.trim() === PLANU_GITIGNORE_BLOCK_HEADER);
90
+ }
91
+ return result;
92
+ }
93
+ async function updateGitignore(projectPath) {
94
+ const gitignorePath = join(projectPath, '.gitignore');
95
+ let original = '';
96
+ try {
97
+ original = await readFile(gitignorePath, 'utf-8');
98
+ }
99
+ catch {
100
+ /* missing .gitignore is fine */
101
+ }
102
+ const cleaned = removePlanuGitignoreBlock(original.split('\n')).join('\n');
103
+ const separator = cleaned === '' || cleaned.endsWith('\n') ? '' : '\n';
104
+ const updatedContent = `${cleaned}${separator}${PLANU_GITIGNORE_BLOCK_HEADER}\n${buildPlanuIgnoreBlock().join('\n')}\n`;
105
+ if (updatedContent === original) {
113
106
  return false;
114
107
  }
115
- const separator = current === '' || current.endsWith('\n') ? '' : '\n';
116
- await atomicWriteFile(gitignorePath, `${current}${separator}# Planu generated/runtime\n${missing.join('\n')}\n`);
108
+ await atomicWriteFile(gitignorePath, updatedContent);
117
109
  return true;
118
110
  }
111
+ async function listTrackedFiles(projectPath, relPath) {
112
+ if (!existsSync(join(projectPath, '.git'))) {
113
+ return [];
114
+ }
115
+ try {
116
+ const { stdout } = await execFileAsync('git', ['ls-files', relPath], {
117
+ cwd: projectPath,
118
+ timeout: 5_000,
119
+ });
120
+ return stdout.trim().split('\n').filter(Boolean);
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ export async function findTrackedNonCanonicalSpecFiles(projectPath) {
127
+ const tracked = await listTrackedFiles(projectPath, 'planu/specs');
128
+ return tracked.filter((relPath) => !isCanonicalSpecFile(relPath.split('/').pop() ?? ''));
129
+ }
119
130
  async function walkSpecDirectory(projectPath, specDir, result) {
120
131
  const entries = await readDirectory(specDir);
121
132
  for (const entry of entries) {
122
133
  const full = join(specDir, entry);
123
134
  if (mustMergeBeforeDeleteSpecFile(entry)) {
124
- await mergeLegacySpecFile(projectPath, specDir, entry);
125
- result.merged.push(relative(projectPath, full));
135
+ await classifyPendingLegacySpecFile(specDir, entry);
136
+ result.proposed.push(relative(projectPath, full));
126
137
  continue;
127
138
  }
128
139
  const isDir = await pathIsDirectory(full);
@@ -251,5 +262,5 @@ export async function validateStrictPlanuLayout(projectPath, options = {}) {
251
262
  }
252
263
  return { ok: offenders.length === 0, offenders, contract: canonicalContractText() };
253
264
  }
254
- export { PLANU_CANONICAL_POLICY };
265
+ export { PLANU_CANONICAL_POLICY, classifyPendingLegacySpecFile };
255
266
  //# sourceMappingURL=strict-planu-cleanup.js.map
@@ -50,6 +50,12 @@ function normalizeSpec(raw) {
50
50
  }
51
51
  // SPEC-601: Lazy UUID migration — generate if missing
52
52
  spec.uuid ??= crypto.randomUUID();
53
+ if (!Array.isArray(raw.dependencies)) {
54
+ spec.dependencies = [];
55
+ }
56
+ if (!Array.isArray(raw.blockedBy)) {
57
+ spec.blockedBy = [];
58
+ }
53
59
  if (spec.uuidAliases !== undefined) {
54
60
  spec.uuidAliases = [
55
61
  ...new Set(spec.uuidAliases.filter((alias) => typeof alias === 'string' && alias)),
@@ -1,11 +1,12 @@
1
1
  // tools/export-spec.ts — MCP tool handler for export_spec (SPEC-097, SPEC-318)
2
2
  import { readFile, mkdir, writeFile } from 'node:fs/promises';
3
- import { join, resolve, relative } from 'node:path';
3
+ import { join } from 'node:path';
4
4
  import { specStore, knowledgeStore } from '../storage/index.js';
5
5
  import { hashProjectPath } from '../storage/base-store.js';
6
6
  import { exportToSpecKit } from '../engine/spec-kit-exporter.js';
7
7
  import { stripFrontmatter } from '../engine/frontmatter-parser.js';
8
8
  import { specToAgentReady, serializeAgentReady } from '../engine/agent-ready-exporter.js';
9
+ import { assertPathWithinBase } from '../engine/safety/assert-within-project.js';
9
10
  import { compactResult, formatKeyValue } from './output-formatter.js';
10
11
  async function readFileContent(filePath) {
11
12
  try {
@@ -65,15 +66,16 @@ export async function handleExportSpec(params) {
65
66
  const content = serializeAgentReady(agentSpec, target);
66
67
  let filePath;
67
68
  if (params.outputDir) {
68
- const resolvedOutput = resolve(params.outputDir);
69
- const resolvedProject = resolve(params.projectPath);
70
- const rel = relative(resolvedProject, resolvedOutput);
71
- if (rel.startsWith('..') || rel.startsWith('/')) {
69
+ let resolvedOutput;
70
+ try {
71
+ resolvedOutput = assertPathWithinBase(params.projectPath, params.outputDir);
72
+ }
73
+ catch (error) {
72
74
  return {
73
75
  content: [
74
76
  {
75
77
  type: 'text',
76
- text: `Invalid outputDir: path escapes the project directory. Resolved to "${resolvedOutput}".`,
78
+ text: `Invalid outputDir: ${error instanceof Error ? error.message : String(error)}`,
77
79
  },
78
80
  ],
79
81
  isError: true,
@@ -121,24 +123,25 @@ export async function handleExportSpec(params) {
121
123
  // Write to output directory
122
124
  const slug = toSlug(spec.title);
123
125
  const outputDir = params.outputDir ?? join(params.projectPath, 'planu', 'exports', slug);
124
- const resolvedOutput = resolve(outputDir);
125
- const resolvedProject = resolve(params.projectPath);
126
- const rel = relative(resolvedProject, resolvedOutput);
127
- if (rel.startsWith('..') || rel.startsWith('/')) {
126
+ let resolvedOutput;
127
+ try {
128
+ resolvedOutput = assertPathWithinBase(params.projectPath, outputDir);
129
+ }
130
+ catch (error) {
128
131
  return {
129
132
  content: [
130
133
  {
131
134
  type: 'text',
132
- text: `Invalid outputDir: path escapes the project directory. Resolved to "${resolvedOutput}".`,
135
+ text: `Invalid outputDir: ${error instanceof Error ? error.message : String(error)}`,
133
136
  },
134
137
  ],
135
138
  isError: true,
136
139
  };
137
140
  }
138
- await mkdir(outputDir, { recursive: true });
139
- const specMdPath = join(outputDir, 'spec.md');
140
- const planMdPath = join(outputDir, 'plan.md');
141
- const tasksMdPath = join(outputDir, 'tasks.md');
141
+ await mkdir(resolvedOutput, { recursive: true });
142
+ const specMdPath = join(resolvedOutput, 'spec.md');
143
+ const planMdPath = join(resolvedOutput, 'plan.md');
144
+ const tasksMdPath = join(resolvedOutput, 'tasks.md');
142
145
  await Promise.all([
143
146
  writeFile(specMdPath, result.specMd, 'utf-8'),
144
147
  writeFile(planMdPath, result.planMd, 'utf-8'),
@@ -11,6 +11,7 @@ import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-pop
11
11
  import { jaccard } from '../engine/spec-format/jaccard.js';
12
12
  import { replaceSectionInSpec } from '../engine/spec-format/replace-section.js';
13
13
  import { executionRemove } from '../engine/execution/deadline-io.js';
14
+ import { buildMigrationDirective, scanLegacyArtifacts, } from '../engine/spec-migrator/legacy-classifier.js';
14
15
  const SPANISH_INDICATORS = [
15
16
  'de',
16
17
  'del',
@@ -350,8 +351,8 @@ export async function handleHealSpecDocs(params) {
350
351
  // (so any regenerated technical.md files are also merged and cleaned up).
351
352
  // Migrations are git-aware via safeUnlink; previously they ran in list_specs (read-only).
352
353
  let leanMigratedCount = 0;
353
- let unifiedMigratedCount = 0;
354
354
  let rootCleanedCount = 0;
355
+ const migrationDirectives = [];
355
356
  // Only run full migrations when no specific specId is requested (mass healing)
356
357
  if (!specId && !dryRun) {
357
358
  try {
@@ -362,14 +363,6 @@ export async function handleHealSpecDocs(params) {
362
363
  catch {
363
364
  // best-effort
364
365
  }
365
- try {
366
- const { migrateAllSpecsToUnified } = await import('../engine/spec-migrator/unified-migration.js');
367
- const unifiedResult = await migrateAllSpecsToUnified(projectPath);
368
- unifiedMigratedCount = unifiedResult.mergedCount;
369
- }
370
- catch {
371
- // best-effort
372
- }
373
366
  try {
374
367
  const { cleanPlanuRoot } = await import('../engine/spec-migrator/planu-root-cleaner.js');
375
368
  const planuDir = join(projectPath, 'planu');
@@ -380,6 +373,15 @@ export async function handleHealSpecDocs(params) {
380
373
  // best-effort
381
374
  }
382
375
  }
376
+ if (!dryRun) {
377
+ for (const spec of specs) {
378
+ const hits = await scanLegacyArtifacts(spec.dir);
379
+ const directive = await buildMigrationDirective(spec.specId, spec.dir, hits);
380
+ if (directive !== null) {
381
+ migrationDirectives.push(directive);
382
+ }
383
+ }
384
+ }
383
385
  const parts = [];
384
386
  parts.push(`status.json repaired: ${statusJsonRepaired ? 'yes' : 'no'}`);
385
387
  parts.push(`technical.md healed: ${String(healed)}`);
@@ -392,12 +394,12 @@ export async function handleHealSpecDocs(params) {
392
394
  if (leanMigratedCount > 0) {
393
395
  parts.push(`lean migrations: ${String(leanMigratedCount)}`);
394
396
  }
395
- if (unifiedMigratedCount > 0) {
396
- parts.push(`unified migrations (technical.md merged+deleted): ${String(unifiedMigratedCount)}`);
397
- }
398
397
  if (rootCleanedCount > 0) {
399
398
  parts.push(`planu/ root cleaned: ${String(rootCleanedCount)} file(s)`);
400
399
  }
400
+ if (migrationDirectives.length > 0) {
401
+ parts.push(`legacy artifacts pending host-LLM migration: ${String(migrationDirectives.length)} spec(s) — call migrate_legacy_spec`);
402
+ }
401
403
  if (titlesNonEnglish > 0) {
402
404
  parts.push(`non-English titles detected (not modified): ${String(titlesNonEnglish)}`);
403
405
  }
@@ -410,6 +412,7 @@ export async function handleHealSpecDocs(params) {
410
412
  dryRunDiff,
411
413
  backupPath,
412
414
  warnings,
415
+ migrationDirectives,
413
416
  },
414
417
  };
415
418
  }
@@ -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