@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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.25",
4
- "engineVersion": "5.3.25",
5
- "buildId": "1-5.3.25-aarch64-pc-windows-msvc",
3
+ "cliVersion": "5.3.27",
4
+ "engineVersion": "5.3.27",
5
+ "buildId": "1-5.3.27-aarch64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "arm64",
18
18
  "libc": null,
19
- "artifactSha256": "022e46d5af58ce074f52a5af2a0ed12ce77a5843081bdf63a5f00383913186f6",
19
+ "artifactSha256": "b847ff92831078fb9d108cbf191eb39307c49a25d404da070db732dded3fc894",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-arm64-msvc.node.sbom.json",
23
- "sha256": "a86be9b721fbf785a899cf4789452f46c8b66833262aea97c50cb32d6d6713bb"
23
+ "sha256": "eea89ce4eb73df92230c4507c0a182332df56d16e8a32e5409f34a95a7a7f05f"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "d93395c02e0224530b5e52ffd7ec3a64ec462296",
27
+ "sourceCommit": "79788c0b8d9bfbc05294316f3546693af74482ab",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "Xoj5hhRXt8HU4v3XE+ryoCN3pkCkaUh0EFDpceRRICKuxDJ6AFrHg5aWb5FrxByPB1C/4G0lBiBtTGhZzCUqBg=="
36
+ "value": "7TTGhX99A+J+lpXk1+fUcDLgxki0Vmnp9nVkfJkEKS0cgGGLAVAnHAEF0uawZjywCJF1MaYSMn6jEmUUqVTJCg=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.25",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.27",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.25",
10
+ "version": "5.3.27",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "022e46d5af58ce074f52a5af2a0ed12ce77a5843081bdf63a5f00383913186f6"
14
+ "content": "b847ff92831078fb9d108cbf191eb39307c49a25d404da070db732dded3fc894"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.25",
1109
+ "ref": "pkg:cargo/planu-core@5.3.27",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.25",
4
- "engineVersion": "5.3.25",
5
- "buildId": "1-5.3.25-x86_64-pc-windows-msvc",
3
+ "cliVersion": "5.3.27",
4
+ "engineVersion": "5.3.27",
5
+ "buildId": "1-5.3.27-x86_64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "x64",
18
18
  "libc": null,
19
- "artifactSha256": "d3300aa064da7519867fbd9a13f008bfc6b66977f98ac477aa8230825b279a8a",
19
+ "artifactSha256": "557925ec6e5f7f32feb1bb42683e058e974b20032ff19a0807a3c57059af748d",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-x64-msvc.node.sbom.json",
23
- "sha256": "1726726fed651d4c936d048a34cbb213cb2039faff0fd880ed6102c9fbd585f1"
23
+ "sha256": "e61c9fd311db17fcac42bec3d7fa6ab61bf2c7082ac80196bfb6b9ba54977fda"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "d93395c02e0224530b5e52ffd7ec3a64ec462296",
27
+ "sourceCommit": "79788c0b8d9bfbc05294316f3546693af74482ab",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "RJH3JCQyv8Er1ThOgl7Kp2MvxeHgjOqRNuf8JSQwLprkAx9rZZqIJQzd86HnhmxkOTVb7oAWuaABJga3NqBMAg=="
36
+ "value": "O3ZXl5L6qzts4ZRg9WDNIHioY1qH1I/yx9V/aMi0Bb5/Ct3hq+eMS+8njmwY5p9qbhGzQq2yYf4DwJ3vbWH8DQ=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.25",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.27",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.25",
10
+ "version": "5.3.27",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "d3300aa064da7519867fbd9a13f008bfc6b66977f98ac477aa8230825b279a8a"
14
+ "content": "557925ec6e5f7f32feb1bb42683e058e974b20032ff19a0807a3c57059af748d"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.25",
1109
+ "ref": "pkg:cargo/planu-core@5.3.27",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -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(?:build(?:s|ing)?|chang(?:e|es|ing)|modif(?:y|ies|ying)|alter(?:s|ing)?|add(?:s|ing)?|introduc(?:e|es|ing)|implement(?:s|ing)?|enabl(?:e|es|ing)|expos(?:e|es|ing|ed)|mutat(?:e|es|ing)|rewrit(?:e|es|ing|ten))\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
@@ -1,6 +1,7 @@
1
1
  import type { Spec } from '../../types/index.js';
2
2
  import type { ValidationReportV1 } from '../../types/handoff-artifacts.js';
3
3
  import type { MinimalImplementationReport } from '../../types/minimal-implementation-gate.js';
4
+ export declare const AUTOMATED_VALIDATOR_AGENT = "planu-automated-validator";
4
5
  export declare function writeImplementationReviewReport(input: {
5
6
  projectId: string;
6
7
  specId: string;
@@ -1,5 +1,6 @@
1
1
  import { appendArtifact, readArtifact } from '../handoff-artifacts/io.js';
2
2
  import { runSpecCompliance, } from './spec-compliance-runner.js';
3
+ export const AUTOMATED_VALIDATOR_AGENT = 'planu-automated-validator';
3
4
  export async function writeImplementationReviewReport(input) {
4
5
  const specCompliance = await runSpecCompliance(input.spec, input.projectPath, input.signal, input.projectId);
5
6
  const reportScore = resolveExecutableValidationScore(input.score, specCompliance);
@@ -13,8 +14,8 @@ export async function writeImplementationReviewReport(input) {
13
14
  const mergedGates = await preserveUnobservedGates(input, gates);
14
15
  const passed = mergedGates.every((gate) => gate.passed);
15
16
  const reviewer = {
16
- kind: 'implementation-review-agent',
17
- agent: 'planu-implementation-reviewer',
17
+ kind: 'automation',
18
+ agent: AUTOMATED_VALIDATOR_AGENT,
18
19
  verdict: passed ? 'approved' : 'changes-requested',
19
20
  };
20
21
  const reportCompliance = toValidationReportCompliance(specCompliance);
@@ -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
  }