@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
@@ -12,11 +12,12 @@ import { addLesson, getLessons } from '../../storage/lessons-store.js';
12
12
  import { dispatchFeedbackEvent } from '../learn.js';
13
13
  import { auditDeps } from '../../engine/dep-auditor/index.js';
14
14
  import { withEscalation } from '../../engine/escalator/with-escalation.js';
15
- import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
15
+ import { AUTOMATED_VALIDATOR_AGENT, writeImplementationReviewReport, } from '../../engine/validator/validation-report-writer.js';
16
16
  import { formatKeyValue } from '../output-formatter.js';
17
17
  import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
18
18
  import { verifyCurrentDoneReceipt } from './done-receipt-verifier.js';
19
19
  import { projectDataDir } from '../../storage/base-store.js';
20
+ import { readTransitionLog } from '../../storage/transition-log.js';
20
21
  import { ValidationReportV1Schema } from '../../engine/handoff-artifacts/schemas.js';
21
22
  export { completedJobPublishesReceipt } from './done-receipt-verifier.js';
22
23
  /**
@@ -447,10 +448,10 @@ async function checkDoneReviewDigest(specId, projectId, implementationReviewDige
447
448
  !parsed.data.passed ||
448
449
  parsed.data.gates.some((gate) => !gate.passed) ||
449
450
  parsed.data.minimalityReport?.blocked === true ||
450
- parsed.data.reviewer.kind !== 'implementation-review-agent' ||
451
- parsed.data.reviewer.agent !== 'planu-implementation-reviewer' ||
451
+ parsed.data.reviewer.kind !== 'automation' ||
452
+ parsed.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
452
453
  parsed.data.reviewer.verdict !== 'approved') {
453
- return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report schema or independent reviewer evidence is invalid.');
454
+ return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report schema or automated validator evidence is invalid.');
454
455
  }
455
456
  return null;
456
457
  }
@@ -616,6 +617,116 @@ export async function checkSpecReviewGate(specId, projectId, _forceApprove) {
616
617
  });
617
618
  }
618
619
  }
620
+ export async function checkImplementationReviewGate(specId, projectId, _force) {
621
+ try {
622
+ const { readArtifact } = await import('../../engine/handoff-artifacts/io.js');
623
+ const result = await readArtifact({ projectId, specId, kind: 'implementation_review' });
624
+ if (!result.ok) {
625
+ const firstErr = result.errors[0];
626
+ return implementationReviewGateError({
627
+ specId,
628
+ error: firstErr?.code === 'ARTIFACT_NOT_FOUND'
629
+ ? 'implementation_review_missing'
630
+ : 'implementation_review_invalid',
631
+ message: firstErr?.code === 'ARTIFACT_NOT_FOUND'
632
+ ? 'No implementation review exists for this spec. An independent planu-implementation-reviewer must review the implementation and write its own evidence before done.'
633
+ : 'Implementation review evidence is malformed or uses an obsolete schema.',
634
+ blockers: [],
635
+ fixHint: firstErr?.code === 'ARTIFACT_NOT_FOUND'
636
+ ? 'Have planu-implementation-reviewer write implementation_review.json (ImplementationReviewV1) into the handoff store, then retry update_status(done).'
637
+ : 'Have planu-implementation-reviewer rewrite implementation_review.json as valid ImplementationReviewV1 evidence, then retry done.',
638
+ });
639
+ }
640
+ const review = result.payload;
641
+ if (isLifecycleStubBody(specId, review.body)) {
642
+ return implementationReviewGateError({
643
+ specId,
644
+ error: 'implementation_review_stub',
645
+ message: 'Implementation review evidence is a lifecycle-generated stub, not evidence from a real reviewer.',
646
+ blockers: [],
647
+ fixHint: 'Replace the stub with genuine planu-implementation-reviewer evidence in the handoff store, then retry done.',
648
+ });
649
+ }
650
+ if (review.verdict !== 'approved' || review.reviewer.verdict !== 'approved') {
651
+ return implementationReviewGateError({
652
+ specId,
653
+ error: 'implementation_review_changes_requested',
654
+ message: 'Implementation reviewer requested changes. Done is blocked until the review passes.',
655
+ blockers: review.blockers,
656
+ fixHint: 'Resolve the implementation review blockers and have planu-implementation-reviewer write updated evidence, then retry done.',
657
+ });
658
+ }
659
+ if (review.reviewer.kind !== 'implementation-review-agent' ||
660
+ review.reviewer.agent !== 'planu-implementation-reviewer') {
661
+ return implementationReviewGateError({
662
+ specId,
663
+ error: 'implementation_review_wrong_reviewer',
664
+ message: 'Done requires the recognized independent implementation reviewer identity.',
665
+ blockers: [
666
+ `Reviewer was ${review.reviewer.agent}. Expected planu-implementation-reviewer.`,
667
+ ],
668
+ fixHint: 'Have planu-implementation-reviewer (with the recognized identity) write the review artifact, then retry done.',
669
+ });
670
+ }
671
+ const implementerActor = await findImplementingActor(specId, projectId);
672
+ if (implementerActor !== undefined && implementerActor === review.reviewer.agent) {
673
+ return implementationReviewGateError({
674
+ specId,
675
+ error: 'implementation_review_self_review',
676
+ message: 'The implementation reviewer must be a different identity from the implementer. Self-review is blocked.',
677
+ blockers: [`Implementer and reviewer share the identity "${implementerActor}".`],
678
+ fixHint: 'Have a different, independent agent write the implementation review evidence, then retry done.',
679
+ });
680
+ }
681
+ return null;
682
+ }
683
+ catch (err) {
684
+ /* reliability-optional: IMPLEMENTATION_REVIEW_GATE_READ — typed gate error blocks done */
685
+ reportClassifiedDegradation('IMPLEMENTATION_REVIEW_GATE_READ', err);
686
+ return implementationReviewGateError({
687
+ specId,
688
+ error: 'implementation_review_unreadable',
689
+ message: `Could not read implementation review evidence: ${err instanceof Error ? err.message : String(err)}`,
690
+ blockers: [],
691
+ fixHint: 'Fix the artifact store issue so implementation_review.json is readable, then retry update_status(done).',
692
+ });
693
+ }
694
+ }
695
+ async function findImplementingActor(specId, projectId) {
696
+ const entries = await readTransitionLog(projectId, specId);
697
+ for (let i = entries.length - 1; i >= 0; i--) {
698
+ const entry = entries[i];
699
+ if (entry?.to === 'implementing' && entry.actor) {
700
+ return entry.actor;
701
+ }
702
+ }
703
+ return undefined;
704
+ }
705
+ function implementationReviewGateError(args) {
706
+ const artifactPath = `external Planu project data: handoffs/${args.specId}/implementation_review.json`;
707
+ return {
708
+ content: [
709
+ {
710
+ type: 'text',
711
+ text: formatKeyValue({
712
+ error: args.error,
713
+ message: args.message,
714
+ artifactPath,
715
+ blockers: args.blockers.length,
716
+ firstBlocker: args.blockers[0],
717
+ fixHint: args.fixHint,
718
+ }, 'Implementation review gate failed'),
719
+ },
720
+ ],
721
+ isError: true,
722
+ structuredContent: {
723
+ error: args.error,
724
+ code: 422,
725
+ context: { specId: args.specId, artifactPath, blockers: args.blockers },
726
+ fixHint: args.fixHint,
727
+ },
728
+ };
729
+ }
619
730
  function validationReportGateError(args) {
620
731
  const artifactPath = `external Planu project data: handoffs/${args.specId}/validation-report.json`;
621
732
  return {
@@ -673,6 +784,14 @@ function specReviewGateError(args) {
673
784
  */
674
785
  export async function checkDoneGates(spec, specId, projectId, projectPath, _force, _forceReason = 'No force reason provided', verifyReceipt = verifyCurrentDoneReceipt, implementationReviewDigest) {
675
786
  try {
787
+ const reviewGateError = await checkImplementationReviewGate(specId, projectId, _force);
788
+ if (reviewGateError) {
789
+ return {
790
+ blocked: reviewGateError,
791
+ forcedBypassWarning: null,
792
+ freshnessLease: null,
793
+ };
794
+ }
676
795
  if (implementationReviewDigest !== undefined) {
677
796
  const digestError = await checkDoneReviewDigest(specId, projectId, implementationReviewDigest);
678
797
  if (digestError) {
@@ -1,6 +1,6 @@
1
1
  import type { Spec } from './spec/core.js';
2
2
  /** Artifact kinds produced/consumed by each role transition */
3
- export type ArtifactKind = 'intake' | 'spec.lock' | 'review_feedback' | 'implementation-report' | 'validation-report';
3
+ export type ArtifactKind = 'intake' | 'spec.lock' | 'review_feedback' | 'implementation-report' | 'validation-report' | 'implementation_review';
4
4
  /** Triagier → Elicitor: describes the intent to be elaborated */
5
5
  export interface IntakeV1 {
6
6
  schema_version: string;
@@ -39,6 +39,21 @@ export interface ReviewFeedbackV1 {
39
39
  body: string;
40
40
  reviewedAt: string;
41
41
  }
42
+ /** Independent reviewer → Planner: evidence that an implementation review actually happened */
43
+ export interface ImplementationReviewV1 {
44
+ schema_version: string;
45
+ specId: string;
46
+ verdict: 'approved' | 'changes-requested';
47
+ reviewer: {
48
+ kind: 'implementation-review-agent' | 'human';
49
+ agent: string;
50
+ verdict: 'approved' | 'changes-requested';
51
+ };
52
+ blockers: string[];
53
+ suggestions: string[];
54
+ body: string;
55
+ reviewedAt: string;
56
+ }
42
57
  /** Implementer → Validator: result of implementation run */
43
58
  export interface ImplementationReportV1 {
44
59
  schema_version: string;
@@ -125,6 +140,7 @@ export interface ArtifactPayloadMap {
125
140
  review_feedback: ReviewFeedbackV1;
126
141
  'implementation-report': ImplementationReportV1;
127
142
  'validation-report': ValidationReportV1;
143
+ implementation_review: ImplementationReviewV1;
128
144
  }
129
145
  export type ArtifactPayload<K extends ArtifactKind> = ArtifactPayloadMap[K];
130
146
  import type { MinimalImplementationReport } from './minimal-implementation-gate.js';
@@ -8,6 +8,7 @@ import type { OrchestratorInfo, ComposeMultiplatformInfo } from './platform-spec
8
8
  import type { ProjectKnowledge, ProjectCompleteness } from './core.js';
9
9
  import type { ProjectHealthReport } from './health-checker.js';
10
10
  import type { DiscoveredRegistrySkill } from '../skill-bootstrap.js';
11
+ import type { LegacyMigrationDirective } from '../spec-format.js';
11
12
  export interface PlanuConfig {
12
13
  version: string;
13
14
  specLocation: string;
@@ -79,6 +80,8 @@ export interface InitProjectResultInput {
79
80
  migratedCount: number;
80
81
  errors: string[];
81
82
  } | null;
83
+ /** SPEC-1570: content-bearing/transient legacy artifacts pending host-LLM migration. */
84
+ legacyMigrationDirectives: LegacyMigrationDirective[];
82
85
  criticalMigrationFailures?: {
83
86
  phase: string;
84
87
  severity: 'critical' | 'nonCritical';
@@ -179,13 +179,6 @@ export interface ModelBudget {
179
179
  model: 'haiku' | 'sonnet' | 'opus';
180
180
  budget: 800 | 2000 | 4000;
181
181
  }
182
- /** SPEC-630: Result of merging technical.md into spec.md. */
183
- export interface UnifiedMigrationResult {
184
- specDir: string;
185
- merged: boolean;
186
- reason: 'ok' | 'already-unified' | 'no-technical-md' | 'error';
187
- error?: string;
188
- }
189
182
  export interface SpecSummary {
190
183
  specId: string;
191
184
  title: string;
@@ -91,6 +91,23 @@ export interface StrictPlanuValidationOptions {
91
91
  specPath?: string;
92
92
  includeRoot?: boolean;
93
93
  }
94
+ export interface LegacyArtifactRule {
95
+ id: string;
96
+ pattern: string;
97
+ classification: 'content-bearing' | 'transient';
98
+ targetSection?: string;
99
+ }
100
+ export interface LegacyArtifactHit {
101
+ path: string;
102
+ classification: 'content-bearing' | 'transient';
103
+ targetSection?: string;
104
+ inlinedContent?: string;
105
+ }
106
+ export interface LegacyMigrationDirective {
107
+ specId: string;
108
+ legacyFiles: LegacyArtifactHit[];
109
+ directive: 'migrate_legacy_spec';
110
+ }
94
111
  export interface UpdateStatusBatchInput {
95
112
  specIds: string[];
96
113
  status: Exclude<SpecStatus, 'done'>;
@@ -1,5 +1,5 @@
1
1
  /** Event types recorded in the transition log. */
2
- export type TransitionEventType = 'transitioned_to_terminal' | 'frontmatter_resealed' | 'reopen' | 'terminal_drift_detected' | 'transition' | 'handoff.intake' | 'handoff.spec.lock' | 'handoff.review_feedback' | 'handoff.implementation-report' | 'handoff.validation-report' | 'retro_audit' | 'tdd_red_locked' | 'tdd_green_achieved' | 'status_reconciled' | 'housekeeping' | 'ghost_spec_quarantined';
2
+ export type TransitionEventType = 'transitioned_to_terminal' | 'frontmatter_resealed' | 'reopen' | 'terminal_drift_detected' | 'transition' | 'handoff.intake' | 'handoff.spec.lock' | 'handoff.review_feedback' | 'handoff.implementation-report' | 'handoff.validation-report' | 'handoff.implementation_review' | 'retro_audit' | 'tdd_red_locked' | 'tdd_green_achieved' | 'status_reconciled' | 'housekeeping' | 'ghost_spec_quarantined';
3
3
  /** SPEC-734: Outcome of a gate execution in a transition. */
4
4
  export type GateOutcome = 'pass' | 'fail' | 'skip' | 'forced';
5
5
  /** SPEC-734: Result of verifying the hash chain integrity of a project's transition log. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.25",
3
+ "version": "5.3.27",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,14 +36,14 @@
36
36
  "packageName": "@planu/core"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@planu/core-darwin-arm64": "5.3.25",
40
- "@planu/core-darwin-x64": "5.3.25",
41
- "@planu/core-linux-arm64-gnu": "5.3.25",
42
- "@planu/core-linux-arm64-musl": "5.3.25",
43
- "@planu/core-linux-x64-gnu": "5.3.25",
44
- "@planu/core-linux-x64-musl": "5.3.25",
45
- "@planu/core-win32-arm64-msvc": "5.3.25",
46
- "@planu/core-win32-x64-msvc": "5.3.25"
39
+ "@planu/core-darwin-arm64": "5.3.27",
40
+ "@planu/core-darwin-x64": "5.3.27",
41
+ "@planu/core-linux-arm64-gnu": "5.3.27",
42
+ "@planu/core-linux-arm64-musl": "5.3.27",
43
+ "@planu/core-linux-x64-gnu": "5.3.27",
44
+ "@planu/core-linux-x64-musl": "5.3.27",
45
+ "@planu/core-win32-arm64-msvc": "5.3.27",
46
+ "@planu/core-win32-x64-msvc": "5.3.27"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=24.0.0"
package/planu-native.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "5.3.25",
4
+ "version": "5.3.27",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "5.3.25",
5
+ "version": "5.3.27",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",
@@ -36,7 +36,8 @@
36
36
  "resolve_feedback",
37
37
  "sync_feedback",
38
38
  "feedback_status",
39
- "bump_spec_version"
39
+ "bump_spec_version",
40
+ "migrate_legacy_spec"
40
41
  ],
41
42
  "resources": ["planu://specs/list", "planu://specs/{id}", "planu://project/status", "planu://roadmap"],
42
43
  "prompts": ["create-spec-from-idea", "review-spec-readiness", "generate-implementation-plan"],
@@ -1,18 +0,0 @@
1
- import type { UnifiedMigrationResult } from '../../types/index.js';
2
- export type { UnifiedMigrationResult };
3
- /**
4
- * Merge technical.md into spec.md under a `## Technical` section, then delete technical.md.
5
- * Idempotent: skips if spec.md already contains `## Technical`.
6
- *
7
- * @param specDir Absolute path to the spec directory.
8
- * @param projectPath Project root used for `safeUnlink` (git-aware deletion). Optional:
9
- * when omitted, falls back to plain unlink.
10
- */
11
- export declare function migrateToUnified(specDir: string, projectPath?: string): Promise<UnifiedMigrationResult>;
12
- /** Migrate all SPEC-* dirs under projectPath to unified format. Best-effort: never throws. */
13
- export declare function migrateAllSpecsToUnified(projectPath: string): Promise<{
14
- mergedCount: number;
15
- skippedCount: number;
16
- errors: string[];
17
- }>;
18
- //# sourceMappingURL=unified-migration.d.ts.map
@@ -1,105 +0,0 @@
1
- // engine/spec-migrator/unified-migration.ts — Merge technical.md into spec.md (SPEC-630)
2
- import { readFile } from 'node:fs/promises';
3
- import { atomicWriteFile } from '../safety/atomic-write-file.js';
4
- /** Write a verified backup of a file before overwriting. Returns backup path. Throws on failure. */
5
- async function writeVerifiedBackup(filePath) {
6
- const content = await readFile(filePath, 'utf-8');
7
- const bkPath = `${filePath}.bak.${Date.now()}`;
8
- await atomicWriteFile(bkPath, content);
9
- const verify = await readFile(bkPath, 'utf-8');
10
- if (verify !== content) {
11
- throw new Error(`backup verify failed for ${filePath}: content mismatch`);
12
- }
13
- return bkPath;
14
- }
15
- import { join } from 'node:path';
16
- import { safeUnlink } from './git-aware-fs.js';
17
- /**
18
- * Merge technical.md into spec.md under a `## Technical` section, then delete technical.md.
19
- * Idempotent: skips if spec.md already contains `## Technical`.
20
- *
21
- * @param specDir Absolute path to the spec directory.
22
- * @param projectPath Project root used for `safeUnlink` (git-aware deletion). Optional:
23
- * when omitted, falls back to plain unlink.
24
- */
25
- export async function migrateToUnified(specDir, projectPath) {
26
- const specPath = join(specDir, 'spec.md');
27
- const techPath = join(specDir, 'technical.md');
28
- try {
29
- const specContent = await readFile(specPath, 'utf-8');
30
- if (/\n## Technical(\n|$)/.test(specContent)) {
31
- try {
32
- await readFile(techPath, 'utf-8');
33
- if (projectPath) {
34
- await safeUnlink(projectPath, techPath);
35
- }
36
- else {
37
- const { unlink } = await import('node:fs/promises');
38
- await unlink(techPath);
39
- }
40
- return { specDir, merged: true, reason: 'already-unified' };
41
- }
42
- catch {
43
- /* no residual technical.md */
44
- }
45
- return { specDir, merged: false, reason: 'already-unified' };
46
- }
47
- let techContent;
48
- try {
49
- techContent = await readFile(techPath, 'utf-8');
50
- }
51
- catch {
52
- return { specDir, merged: false, reason: 'no-technical-md' };
53
- }
54
- // Strip YAML frontmatter block from technical.md before appending
55
- const techBody = techContent.replace(/^---\n[\s\S]*?\n---\n/, '').trim();
56
- const unified = `${specContent.trimEnd()}\n\n## Technical\n\n${techBody}\n`;
57
- // SPEC-771: backup spec.md before overwriting (verified write-back)
58
- try {
59
- await writeVerifiedBackup(specPath);
60
- }
61
- catch (backupErr) {
62
- return {
63
- specDir,
64
- merged: false,
65
- reason: 'error',
66
- error: `backup failed: ${String(backupErr)}`,
67
- };
68
- }
69
- // Write spec.md first, delete technical.md second — never leave both inconsistent
70
- await atomicWriteFile(specPath, unified);
71
- if (projectPath) {
72
- await safeUnlink(projectPath, techPath);
73
- }
74
- else {
75
- const { unlink } = await import('node:fs/promises');
76
- await unlink(techPath);
77
- }
78
- return { specDir, merged: true, reason: 'ok' };
79
- }
80
- catch (err) {
81
- return { specDir, merged: false, reason: 'error', error: String(err) };
82
- }
83
- }
84
- /** Migrate all SPEC-* dirs under projectPath to unified format. Best-effort: never throws. */
85
- export async function migrateAllSpecsToUnified(projectPath) {
86
- const { glob } = await import('glob');
87
- const dirs = await glob(join(projectPath, 'planu/specs/SPEC-*'));
88
- let mergedCount = 0;
89
- let skippedCount = 0;
90
- const errors = [];
91
- for (const dir of dirs) {
92
- const r = await migrateToUnified(dir, projectPath);
93
- if (r.merged) {
94
- mergedCount++;
95
- }
96
- else if (!r.error) {
97
- skippedCount++;
98
- }
99
- if (r.error) {
100
- errors.push(`${dir}: ${r.error}`);
101
- }
102
- }
103
- return { mergedCount, skippedCount, errors };
104
- }
105
- //# sourceMappingURL=unified-migration.js.map