@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/dist/config/legacy-artifacts.json +26 -0
- package/dist/config/official-sdd-tools.d.ts +1 -1
- package/dist/config/official-sdd-tools.js +1 -0
- package/dist/config/registries/hosts/codex.json +2 -1
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/safety/assert-within-project.d.ts +2 -0
- package/dist/engine/safety/assert-within-project.js +14 -0
- package/dist/engine/scope-boundaries/contradiction-checker.js +9 -3
- package/dist/engine/spec-format/read-technical-section.js +3 -2
- package/dist/engine/spec-format/unified-spec-builder.js +27 -0
- package/dist/engine/spec-migrator/index.d.ts +0 -1
- package/dist/engine/spec-migrator/index.js +0 -1
- package/dist/engine/spec-migrator/legacy-classifier.d.ts +4 -0
- package/dist/engine/spec-migrator/legacy-classifier.js +70 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -9
- package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +4 -2
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +64 -53
- package/dist/storage/spec-store.js +6 -0
- package/dist/tools/export-spec.js +18 -15
- package/dist/tools/heal-spec-docs.js +15 -12
- package/dist/tools/init-project/git-setup.js +44 -40
- package/dist/tools/init-project/handler.js +2 -1
- package/dist/tools/init-project/migration-runner.d.ts +2 -1
- package/dist/tools/init-project/migration-runner.js +18 -5
- package/dist/tools/init-project/result-builder.js +9 -0
- package/dist/tools/list-specs.js +39 -0
- package/dist/tools/migrate-legacy-spec.d.ts +10 -0
- package/dist/tools/migrate-legacy-spec.js +133 -0
- package/dist/tools/register-spec-tools/analysis-tools.js +2 -0
- package/dist/tools/schema-generator-handler.js +3 -0
- package/dist/tools/schemas/output-schemas.d.ts +28 -0
- package/dist/tools/schemas/output-schemas.js +13 -0
- package/dist/tools/spec-export-handler.js +21 -8
- package/dist/tools/storage-bundle-handler.js +35 -4
- package/dist/types/project/planu-config.d.ts +3 -0
- package/dist/types/spec/core.d.ts +0 -7
- package/dist/types/spec-format.d.ts +17 -0
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +3 -2
- package/dist/engine/spec-migrator/unified-migration.d.ts +0 -18
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
106
|
-
'planu/specs
|
|
107
|
-
'planu/specs/**/
|
|
108
|
-
'planu/specs/**/reference/',
|
|
109
|
-
'planu/specs/**/*.bak.*',
|
|
72
|
+
'planu/specs/**',
|
|
73
|
+
'!planu/specs/**/',
|
|
74
|
+
'!planu/specs/**/spec.md',
|
|
110
75
|
];
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
result.
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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:
|
|
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(
|
|
139
|
-
const specMdPath = join(
|
|
140
|
-
const planMdPath = join(
|
|
141
|
-
const tasksMdPath = join(
|
|
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
|
|
85
|
+
let original = '';
|
|
85
86
|
try {
|
|
86
|
-
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|