@planu/cli 4.10.2 → 4.10.3

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## [4.10.3] - 2026-07-02
2
+
3
+ ### Bug Fixes
4
+ - fix(SPEC-1106): reduce validate response duplication
5
+ - fix(SPEC-1106): harden critical fallback paths
6
+
7
+
1
8
  ## [4.10.2] - 2026-07-02
2
9
 
3
10
  ### Bug Fixes
@@ -1,4 +1,4 @@
1
- import type { Spec, AuditFinding, CodeState, VerifyBlock } from '../../types/index.js';
1
+ import type { Spec, AuditFinding, CodeState, VerifyBlock, CriterionEvidenceResult } from '../../types/index.js';
2
2
  /**
3
3
  * Scan the project directory to build a CodeState for the given spec.
4
4
  */
@@ -12,6 +12,7 @@ export declare function scanCodeForSpec(spec: Spec, projectPath: string): Promis
12
12
  * buildFileContentsCache from criterion-matcher) to avoid a redundant
13
13
  * glob+readFile pass when checking multiple criteria in the same call.
14
14
  */
15
+ export declare function checkCriterionEvidence(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>): Promise<CriterionEvidenceResult>;
15
16
  export declare function checkCriterion(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>): Promise<boolean>;
16
17
  /**
17
18
  * Classify the severity of a missing criterion based on its content.
@@ -164,16 +164,16 @@ export async function scanCodeForSpec(spec, projectPath) {
164
164
  * buildFileContentsCache from criterion-matcher) to avoid a redundant
165
165
  * glob+readFile pass when checking multiple criteria in the same call.
166
166
  */
167
- export async function checkCriterion(criterion, projectPath, codeState, verifyBlock, sharedFileContents) {
167
+ export async function checkCriterionEvidence(criterion, projectPath, codeState, verifyBlock, sharedFileContents) {
168
168
  // Check for file existence criteria
169
169
  const fileMatch = /(?:create|add|implement)\s+(?:file\s+)?["']?([^\s"']+\.\w+)["']?/i.exec(criterion);
170
170
  if (fileMatch?.[1]) {
171
171
  try {
172
172
  await stat(join(projectPath, fileMatch[1]));
173
- return true;
173
+ return { status: 'proven' };
174
174
  }
175
175
  catch {
176
- return false;
176
+ return { status: 'missing' };
177
177
  }
178
178
  }
179
179
  // Check for component/function existence in code
@@ -183,7 +183,7 @@ export async function checkCriterion(criterion, projectPath, codeState, verifyBl
183
183
  // Search in already-loaded codeState contents first (zero extra I/O)
184
184
  for (const [, content] of codeState.fileContents) {
185
185
  if (content.includes(name)) {
186
- return true;
186
+ return { status: 'proven' };
187
187
  }
188
188
  }
189
189
  // Broader search: reuse sharedFileContents when provided to avoid a
@@ -192,17 +192,17 @@ export async function checkCriterion(criterion, projectPath, codeState, verifyBl
192
192
  const broadContents = sharedFileContents ?? (await buildBroadCache(projectPath));
193
193
  for (const [, content] of broadContents) {
194
194
  if (content.includes(name)) {
195
- return true;
195
+ return { status: 'proven' };
196
196
  }
197
197
  }
198
- return false;
198
+ return { status: 'missing' };
199
199
  }
200
200
  // Check for API endpoint criteria
201
201
  const endpointMatch = /(?:endpoint|route|api)\s+(?:for\s+)?["']?(\w+)["']?/i.exec(criterion);
202
202
  if (endpointMatch?.[1]) {
203
203
  for (const [, content] of codeState.fileContents) {
204
204
  if (content.toLowerCase().includes(endpointMatch[1].toLowerCase())) {
205
- return true;
205
+ return { status: 'proven' };
206
206
  }
207
207
  }
208
208
  }
@@ -212,13 +212,17 @@ export async function checkCriterion(criterion, projectPath, codeState, verifyBl
212
212
  // For manual checks (type === 'manual'), we fall back to the optimistic heuristic.
213
213
  const deepResult = await deepCheckCriterion(criterion, projectPath, verifyBlock);
214
214
  if (deepResult.checkType !== 'manual') {
215
- return deepResult.passed;
215
+ return { status: deepResult.passed ? 'proven' : 'missing' };
216
216
  }
217
- // If we have affected files, assume basic criteria are met (optimistic fallback)
217
+ // Affected files alone show scope, not proof. Keep this visible so validate
218
+ // cannot report score 100 for manual/unclassified criteria without evidence.
218
219
  if (codeState.affectedFiles.length > 0) {
219
- return true;
220
+ return { status: 'indeterminate' };
220
221
  }
221
- return false;
222
+ return { status: 'missing' };
223
+ }
224
+ export async function checkCriterion(criterion, projectPath, codeState, verifyBlock, sharedFileContents) {
225
+ return ((await checkCriterionEvidence(criterion, projectPath, codeState, verifyBlock, sharedFileContents)).status === 'proven');
222
226
  }
223
227
  /**
224
228
  * Classify the severity of a missing criterion based on its content.
@@ -3,7 +3,7 @@
3
3
  // All imports from '../engine/validator.js' continue to work unchanged.
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { extractCriteria, extractVerifyBlocks } from './validator/extractors.js';
6
- import { scanCodeForSpec, checkCriterion, classifyDriftSeverity, quickQualityCheck, } from './validator/analyzer.js';
6
+ import { scanCodeForSpec, checkCriterionEvidence, classifyDriftSeverity, quickQualityCheck, } from './validator/analyzer.js';
7
7
  import { resolveApplicableDimensions } from './validator/scope-resolver.js';
8
8
  import { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
9
9
  import { readEvidenceArtifacts } from './evidence-gates/artifact-reader.js';
@@ -71,6 +71,7 @@ export async function validateSpec(spec, projectPath) {
71
71
  return {
72
72
  matches: [],
73
73
  missing: [],
74
+ indeterminate: [],
74
75
  extra: [],
75
76
  fieldsImplemented: 0,
76
77
  fieldsTotal: 0,
@@ -93,15 +94,22 @@ export async function validateSpec(spec, projectPath) {
93
94
  }
94
95
  const matches = [];
95
96
  const missing = [];
97
+ const indeterminate = [];
96
98
  const extra = [];
97
99
  const qualityIssues = [];
98
100
  for (const criterion of criteria) {
99
101
  const verifyBlock = verifyBlockMap.get(criterion);
100
102
  const evidenceRow = traceabilityEvidence.get(normalizeCriterionForEvidence(criterion));
101
- const found = evidenceRow !== undefined ||
102
- (await checkCriterion(criterion, projectPath, codeState, verifyBlock));
103
- if (found) {
103
+ if (evidenceRow !== undefined) {
104
104
  matches.push(criterion);
105
+ continue;
106
+ }
107
+ const evidence = await checkCriterionEvidence(criterion, projectPath, codeState, verifyBlock);
108
+ if (evidence.status === 'proven') {
109
+ matches.push(criterion);
110
+ }
111
+ else if (evidence.status === 'indeterminate') {
112
+ indeterminate.push(criterion);
105
113
  }
106
114
  else {
107
115
  missing.push(criterion);
@@ -131,6 +139,7 @@ export async function validateSpec(spec, projectPath) {
131
139
  return {
132
140
  matches,
133
141
  missing,
142
+ indeterminate,
134
143
  extra,
135
144
  fieldsImplemented,
136
145
  fieldsTotal,
@@ -157,6 +166,17 @@ export async function detectDrift(spec, projectPath, _mode = 'full', threshold =
157
166
  autoFixable: false,
158
167
  });
159
168
  }
169
+ for (const criterion of validation.indeterminate ?? []) {
170
+ drifts.push({
171
+ file: '',
172
+ specCriterion: criterion,
173
+ expected: 'Proven implementation evidence',
174
+ actual: 'Indeterminate',
175
+ severity: classifyDriftSeverity(criterion),
176
+ autoFixable: false,
177
+ suggestedFix: 'Add explicit validation evidence or implement a recognizable check.',
178
+ });
179
+ }
160
180
  // Quality issues as drift items
161
181
  for (const issue of validation.qualityIssues) {
162
182
  drifts.push({
@@ -14,6 +14,13 @@
14
14
  * JSON_PARSE_UNSAFE_CAST.
15
15
  */
16
16
  export declare function safeJsonParse<T>(raw: string, fallback: T): T;
17
+ export declare class CriticalJsonReadError extends Error {
18
+ readonly filePath: string;
19
+ readonly reason: string;
20
+ constructor(filePath: string, reason: string, options?: {
21
+ cause?: unknown;
22
+ });
23
+ }
17
24
  /**
18
25
  * Hash a project path into a deterministic projectId using SHA-256.
19
26
  */
@@ -23,6 +30,15 @@ export declare function hashProjectPath(projectPath: string): string;
23
30
  * default value when the file does not exist.
24
31
  */
25
32
  export declare function readJson<T>(filePath: string, defaultValue: T): Promise<T>;
33
+ /**
34
+ * Read a critical JSON store. Missing files still return the initialization
35
+ * default, but malformed JSON or wrong top-level shape throw an observable
36
+ * recoverable error instead of silently replacing state with an empty value.
37
+ */
38
+ export declare function readCriticalJson<T>(filePath: string, defaultValue: T, options?: {
39
+ validate?: (value: unknown) => value is T;
40
+ expectedDescription?: string;
41
+ }): Promise<T>;
26
42
  /**
27
43
  * Write a value to a JSON file, creating parent directories as needed.
28
44
  */
@@ -32,6 +32,16 @@ export function safeJsonParse(raw, fallback) {
32
32
  return fallback;
33
33
  }
34
34
  }
35
+ export class CriticalJsonReadError extends Error {
36
+ filePath;
37
+ reason;
38
+ constructor(filePath, reason, options) {
39
+ super(`[Planu] Critical JSON store is unreadable at ${filePath}: ${reason}`, options);
40
+ this.filePath = filePath;
41
+ this.reason = reason;
42
+ this.name = 'CriticalJsonReadError';
43
+ }
44
+ }
35
45
  /**
36
46
  * Hash a project path into a deterministic projectId using SHA-256.
37
47
  */
@@ -60,6 +70,34 @@ export async function readJson(filePath, defaultValue) {
60
70
  throw err;
61
71
  }
62
72
  }
73
+ /**
74
+ * Read a critical JSON store. Missing files still return the initialization
75
+ * default, but malformed JSON or wrong top-level shape throw an observable
76
+ * recoverable error instead of silently replacing state with an empty value.
77
+ */
78
+ export async function readCriticalJson(filePath, defaultValue, options = {}) {
79
+ let raw;
80
+ try {
81
+ raw = await readFile(filePath, 'utf-8');
82
+ }
83
+ catch (err) {
84
+ if (isNodeError(err) && err.code === 'ENOENT') {
85
+ return defaultValue;
86
+ }
87
+ throw err;
88
+ }
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(raw);
92
+ }
93
+ catch (err) {
94
+ throw new CriticalJsonReadError(filePath, 'malformed JSON', { cause: err });
95
+ }
96
+ if (options.validate && !options.validate(parsed)) {
97
+ throw new CriticalJsonReadError(filePath, `expected ${options.expectedDescription ?? 'valid critical JSON shape'}`);
98
+ }
99
+ return parsed;
100
+ }
63
101
  /**
64
102
  * Write a value to a JSON file, creating parent directories as needed.
65
103
  */
@@ -1,10 +1,13 @@
1
- import { readJson, writeJson, projectDataDir } from '../base-store.js';
1
+ import { readCriticalJson, writeJson, projectDataDir } from '../base-store.js';
2
2
  import { withFileLock } from '../file-mutex.js';
3
3
  import { discoverProjectDataFile } from '../data-dir-discovery.js';
4
4
  import { migrateLegacyDataDir } from '../migrations/cwd-to-absolute.js';
5
5
  function knowledgeFile(projectId) {
6
6
  return `${projectDataDir(projectId)}/knowledge.json`;
7
7
  }
8
+ function isKnowledgeOrNull(value) {
9
+ return value === null || (typeof value === 'object' && !Array.isArray(value));
10
+ }
8
11
  /**
9
12
  * Get project knowledge. Returns `null` when no knowledge has been stored yet.
10
13
  *
@@ -16,7 +19,10 @@ function knowledgeFile(projectId) {
16
19
  export async function getKnowledge(projectId) {
17
20
  // SPEC-1007: migrate legacy <cwd>/data/projects/{id} to absolute home once.
18
21
  await migrateLegacyDataDir(projectId);
19
- const direct = await readJson(knowledgeFile(projectId), null);
22
+ const direct = await readCriticalJson(knowledgeFile(projectId), null, {
23
+ validate: isKnowledgeOrNull,
24
+ expectedDescription: 'a project knowledge object or null',
25
+ });
20
26
  if (direct !== null) {
21
27
  return direct;
22
28
  }
@@ -24,7 +30,10 @@ export async function getKnowledge(projectId) {
24
30
  if (discovered === null) {
25
31
  return null;
26
32
  }
27
- return readJson(discovered, null);
33
+ return readCriticalJson(discovered, null, {
34
+ validate: isKnowledgeOrNull,
35
+ expectedDescription: 'a project knowledge object or null',
36
+ });
28
37
  }
29
38
  /**
30
39
  * Save or overwrite the full project knowledge object.
@@ -41,7 +50,10 @@ export async function saveKnowledge(projectId, knowledge) {
41
50
  */
42
51
  export async function updateKnowledge(projectId, updates) {
43
52
  return withFileLock(knowledgeFile(projectId), async () => {
44
- const existing = await readJson(knowledgeFile(projectId), null);
53
+ const existing = await readCriticalJson(knowledgeFile(projectId), null, {
54
+ validate: isKnowledgeOrNull,
55
+ expectedDescription: 'a project knowledge object or null',
56
+ });
45
57
  if (!existing) {
46
58
  throw new Error(`No knowledge found for project "${projectId}". Use saveKnowledge first.`);
47
59
  }
@@ -60,7 +72,10 @@ export async function updateKnowledge(projectId, updates) {
60
72
  */
61
73
  export async function deleteKnowledge(projectId) {
62
74
  return withFileLock(knowledgeFile(projectId), async () => {
63
- const existing = await readJson(knowledgeFile(projectId), null);
75
+ const existing = await readCriticalJson(knowledgeFile(projectId), null, {
76
+ validate: isKnowledgeOrNull,
77
+ expectedDescription: 'a project knowledge object or null',
78
+ });
64
79
  if (!existing) {
65
80
  return false;
66
81
  }
@@ -1,4 +1,4 @@
1
- import { readJson, writeJson, projectDataDir } from './base-store.js';
1
+ import { readCriticalJson, readJson, writeJson, projectDataDir } from './base-store.js';
2
2
  import { migrateLegacyDataDir } from './migrations/cwd-to-absolute.js';
3
3
  import { withFileLock } from './file-mutex.js';
4
4
  import { discoverProjectDataFile } from './data-dir-discovery.js';
@@ -60,11 +60,17 @@ async function loadAll(projectId) {
60
60
  // SPEC-1007: migrate legacy <cwd>/data/projects/{id} to absolute home once.
61
61
  await migrateLegacyDataDir(projectId);
62
62
  const directFile = specsFile(projectId);
63
- let raw = await readJson(directFile, []);
63
+ let raw = await readCriticalJson(directFile, [], {
64
+ validate: (value) => Array.isArray(value),
65
+ expectedDescription: 'an array of specs',
66
+ });
64
67
  if (raw.length === 0) {
65
68
  const discovered = await discoverProjectDataFile(projectId, 'specs.json');
66
69
  if (discovered !== null && discovered !== directFile) {
67
- raw = await readJson(discovered, []);
70
+ raw = await readCriticalJson(discovered, [], {
71
+ validate: (value) => Array.isArray(value),
72
+ expectedDescription: 'an array of specs',
73
+ });
68
74
  }
69
75
  }
70
76
  const specs = raw.map(normalizeSpec);
@@ -271,7 +271,7 @@ export async function handleInitProject(params, server) {
271
271
  const configResult = await buildProjectConfig(projectPath, projectId, knowledge, workMode);
272
272
  const { planuConfigPath, planuConfigGenerated, whatsNew } = configResult;
273
273
  // Run spec migrations (v0.30 -> v0.40, legacy paths, prefix normalization)
274
- const { discoveryResult, migrationResult, folderMigrationResult } = await runSpecMigrations(projectPath, projectId, knowledge);
274
+ const { discoveryResult, migrationResult, folderMigrationResult, criticalMigrationFailures, nonCriticalWarnings: nonCriticalMigrationWarnings, migrationReportPath, } = await runSpecMigrations(projectPath, projectId, knowledge);
275
275
  // Auto-fetch recommended skills based on detected stack
276
276
  const recommendedSkills = await fetchRecommendedSkills(knowledge);
277
277
  // Auto-install or queue skills (opt-out via planu.json or input param, SPEC-185)
@@ -434,6 +434,9 @@ export async function handleInitProject(params, server) {
434
434
  migrationResult,
435
435
  discoveryResult,
436
436
  folderMigrationResult,
437
+ criticalMigrationFailures,
438
+ nonCriticalMigrationWarnings,
439
+ migrationReportPath,
437
440
  hooksInstalled,
438
441
  gitFlowType,
439
442
  gitignoreUpdated,
@@ -1,4 +1,9 @@
1
1
  import type { ProjectKnowledge, Spec } from '../../types/index.js';
2
+ export interface MigrationRunIssue {
3
+ phase: string;
4
+ severity: 'critical' | 'nonCritical';
5
+ message: string;
6
+ }
2
7
  export interface MigrationRunResult {
3
8
  discoveryResult: {
4
9
  flattenedCount: number;
@@ -14,6 +19,9 @@ export interface MigrationRunResult {
14
19
  migratedCount: number;
15
20
  errors: string[];
16
21
  } | null;
22
+ criticalMigrationFailures: MigrationRunIssue[];
23
+ nonCriticalWarnings: MigrationRunIssue[];
24
+ migrationReportPath: string | null;
17
25
  }
18
26
  /**
19
27
  * Run all spec migration passes during init_project:
@@ -1,6 +1,17 @@
1
1
  // tools/init-project/migration-runner.ts — Spec migration orchestration during init_project
2
2
  import { specStore, knowledgeStore } from '../../storage/index.js';
3
+ import { projectDataDir, writeJson } from '../../storage/base-store.js';
3
4
  import { filterLegacySpecs, migrateSpecs, filterUnprefixedSpecs, migrateSpecFolderNames, discoverAndFlattenSpecs, reconcileSpecPaths, migrateAllSpecsToLean, } from '../../engine/spec-migrator.js';
5
+ function issueFromError(phase, severity, err) {
6
+ return {
7
+ phase,
8
+ severity,
9
+ message: err instanceof Error ? err.message : String(err),
10
+ };
11
+ }
12
+ function issuesFromMessages(phase, severity, messages) {
13
+ return (messages ?? []).map((message) => ({ phase, severity, message }));
14
+ }
4
15
  /**
5
16
  * Run all spec migration passes during init_project:
6
17
  * 1. Discover and flatten specs from subcategory folders (v0.30 → v0.40)
@@ -11,23 +22,38 @@ import { filterLegacySpecs, migrateSpecs, filterUnprefixedSpecs, migrateSpecFold
11
22
  export async function runSpecMigrations(projectPath, projectId, knowledge) {
12
23
  // Auto-discover and flatten specs in subcategory folders (v0.30 → v0.40 migration)
13
24
  let discoveryResult = null;
25
+ const criticalMigrationFailures = [];
26
+ const nonCriticalWarnings = [];
14
27
  try {
15
28
  discoveryResult = await discoverAndFlattenSpecs(projectPath);
29
+ criticalMigrationFailures.push(...issuesFromMessages('discover-and-flatten-specs', 'critical', discoveryResult.errors));
16
30
  }
17
- catch {
18
- /* best-effort — don't fail init */
31
+ catch (err) {
32
+ criticalMigrationFailures.push(issueFromError('discover-and-flatten-specs', 'critical', err));
19
33
  }
20
34
  // Auto-migrate legacy specs if detected (docs/sdd/specs/ → planu/specs/)
21
- const allSpecs = await specStore.listSpecs(projectId);
35
+ let allSpecs = [];
36
+ try {
37
+ allSpecs = await specStore.listSpecs(projectId);
38
+ }
39
+ catch (err) {
40
+ criticalMigrationFailures.push(issueFromError('list-specs-before-migration', 'critical', err));
41
+ }
22
42
  const legacySpecs = filterLegacySpecs(allSpecs);
23
43
  let migrationResult = null;
24
44
  if (legacySpecs.length > 0) {
25
- migrationResult = await migrateSpecs(projectPath, legacySpecs, {
26
- listSpecs: specStore.listSpecs,
27
- updateSpec: specStore.updateSpec,
28
- });
29
- // Update knowledge specLocation after migration (create new object to avoid param mutation)
30
- await knowledgeStore.saveKnowledge(projectId, { ...knowledge, specLocation: 'planu/specs' });
45
+ try {
46
+ migrationResult = await migrateSpecs(projectPath, legacySpecs, {
47
+ listSpecs: specStore.listSpecs,
48
+ updateSpec: specStore.updateSpec,
49
+ });
50
+ criticalMigrationFailures.push(...issuesFromMessages('legacy-spec-migration', 'critical', migrationResult.errors));
51
+ // Update knowledge specLocation after migration (create new object to avoid param mutation)
52
+ await knowledgeStore.saveKnowledge(projectId, { ...knowledge, specLocation: 'planu/specs' });
53
+ }
54
+ catch (err) {
55
+ criticalMigrationFailures.push(issueFromError('legacy-spec-migration', 'critical', err));
56
+ }
31
57
  }
32
58
  // Reconcile specs.json paths with actual filesystem (fixes stale paths after rename/flatten)
33
59
  try {
@@ -36,42 +62,80 @@ export async function runSpecMigrations(projectPath, projectId, knowledge) {
36
62
  updateSpec: specStore.updateSpec,
37
63
  }, projectId);
38
64
  }
39
- catch {
40
- /* best-effort */
65
+ catch (err) {
66
+ criticalMigrationFailures.push(issueFromError('reconcile-spec-paths', 'critical', err));
41
67
  }
42
68
  // Auto-migrate spec folders without SPEC-XXX- prefix
43
- const unprefixedSpecs = filterUnprefixedSpecs(await specStore.listSpecs(projectId));
69
+ let specsAfterReconcile = [];
70
+ try {
71
+ specsAfterReconcile = await specStore.listSpecs(projectId);
72
+ }
73
+ catch (err) {
74
+ criticalMigrationFailures.push(issueFromError('list-specs-before-folder-migration', 'critical', err));
75
+ }
76
+ const unprefixedSpecs = filterUnprefixedSpecs(specsAfterReconcile);
44
77
  let folderMigrationResult = null;
45
78
  if (unprefixedSpecs.length > 0) {
46
- folderMigrationResult = await migrateSpecFolderNames(projectPath, unprefixedSpecs, {
47
- listSpecs: specStore.listSpecs,
48
- updateSpec: specStore.updateSpec,
49
- });
79
+ try {
80
+ folderMigrationResult = await migrateSpecFolderNames(projectPath, unprefixedSpecs, {
81
+ listSpecs: specStore.listSpecs,
82
+ updateSpec: specStore.updateSpec,
83
+ });
84
+ criticalMigrationFailures.push(...issuesFromMessages('spec-folder-migration', 'critical', folderMigrationResult.errors));
85
+ }
86
+ catch (err) {
87
+ criticalMigrationFailures.push(issueFromError('spec-folder-migration', 'critical', err));
88
+ }
50
89
  }
51
90
  // SPEC-461 Phase 2: Auto-migrate old verbose specs to lean format
52
91
  try {
53
92
  await migrateAllSpecsToLean(projectPath);
54
93
  }
55
- catch {
56
- /* best-effort never block init_project */
94
+ catch (err) {
95
+ criticalMigrationFailures.push(issueFromError('lean-spec-migration', 'critical', err));
57
96
  }
58
97
  // SPEC-630: Merge separate technical.md into unified spec.md
59
98
  try {
60
99
  const { migrateAllSpecsToUnified } = await import('../../engine/spec-migrator/index.js');
61
- await migrateAllSpecsToUnified(projectPath).catch(() => null);
100
+ await migrateAllSpecsToUnified(projectPath);
62
101
  }
63
- catch {
64
- /* best-effort never block init_project */
102
+ catch (err) {
103
+ criticalMigrationFailures.push(issueFromError('unified-spec-migration', 'critical', err));
65
104
  }
66
105
  // SPEC-1017: strict managed planu/ cleanup after all legacy migrations.
67
106
  try {
68
107
  const { runStrictPlanuCleanup } = await import('../../engine/spec-migrator/index.js');
69
108
  await runStrictPlanuCleanup(projectPath);
70
109
  }
71
- catch {
72
- /* best-effort never block init_project */
110
+ catch (err) {
111
+ criticalMigrationFailures.push(issueFromError('strict-planu-cleanup', 'critical', err));
112
+ }
113
+ let migrationReportPath = null;
114
+ try {
115
+ const reportPath = `${projectDataDir(projectId)}/reports/init-migrations/latest.json`;
116
+ await writeJson(reportPath, {
117
+ version: 1,
118
+ projectId,
119
+ generatedAt: new Date().toISOString(),
120
+ discoveryResult,
121
+ migrationResult,
122
+ folderMigrationResult,
123
+ criticalMigrationFailures,
124
+ nonCriticalWarnings,
125
+ });
126
+ migrationReportPath = reportPath;
127
+ }
128
+ catch (err) {
129
+ nonCriticalWarnings.push(issueFromError('write-migration-report', 'nonCritical', err));
73
130
  }
74
- return { discoveryResult, migrationResult, folderMigrationResult };
131
+ return {
132
+ discoveryResult,
133
+ migrationResult,
134
+ folderMigrationResult,
135
+ criticalMigrationFailures,
136
+ nonCriticalWarnings,
137
+ migrationReportPath,
138
+ };
75
139
  }
76
140
  /** Return all specs for the project (needed by caller for health check refs). */
77
141
  export async function listProjectSpecs(projectId) {
@@ -149,6 +149,9 @@ export function buildInitProjectResult(input) {
149
149
  specMigration: input.migrationResult,
150
150
  specDiscovery: input.discoveryResult,
151
151
  specFolderMigration: input.folderMigrationResult,
152
+ criticalMigrationFailures: input.criticalMigrationFailures ?? [],
153
+ bestEffortWarnings: input.nonCriticalMigrationWarnings ?? [],
154
+ migrationReportPath: input.migrationReportPath ?? null,
152
155
  gitAutomation: {
153
156
  hooksInstalled: input.hooksInstalled,
154
157
  gitFlowType: input.gitFlowType,
@@ -196,6 +199,10 @@ export function buildInitProjectResult(input) {
196
199
  const narrative = ti('tools.init_project.success', { projectPath: result.projectPath }) +
197
200
  `\n\nDetected: **${result.language}** / ${framework} — project ${action}. ` +
198
201
  `Run \`create_spec\` to start planning your first feature.` +
202
+ ((input.criticalMigrationFailures?.length ?? 0) > 0
203
+ ? `\n\n⚠️ Critical spec migration warnings: ${String(input.criticalMigrationFailures?.length ?? 0)} issue(s). ` +
204
+ `See migrationReportPath in structuredContent${input.migrationReportPath ? ` (${input.migrationReportPath})` : ''}.`
205
+ : '') +
199
206
  telemetryNote +
200
207
  trialNote +
201
208
  tokenTip;
@@ -38,6 +38,7 @@ import { detectHost } from '../../engine/host-detection/detect-host.js';
38
38
  import { shellHygieneReminder } from '../../hosts/claude-code/coach.js';
39
39
  import { buildTransitionEvidenceMeta, checkSddModelRoutingGate, } from '../../engine/sdd-model-routing.js';
40
40
  import { acquireLock, releaseLock, isLocked as isCrossProcessLocked, LockBusyError, } from '../../engine/safety/cross-process-lock.js';
41
+ import { validateStrictLayoutOrError } from '../validate-helpers.js';
41
42
  /**
42
43
  * SPEC-280: Silently traverse intermediate states so callers can jump forward
43
44
  * (e.g. draft → approved) without receiving an error.
@@ -231,6 +232,9 @@ function shouldSkipSddRoutingGateForLegacyTestHarness() {
231
232
  function shouldSkipEvidenceGateForLegacyTestHarness() {
232
233
  return process.env.VITEST === 'true' && process.env.PLANU_TEST_STRICT_EVIDENCE_GATES !== 'true';
233
234
  }
235
+ function shouldSkipStrictLayoutGateForLegacyTestHarness() {
236
+ return process.env.VITEST === 'true' && process.env.PLANU_TEST_STRICT_LAYOUT_GATE !== 'true';
237
+ }
234
238
  /* eslint-disable complexity, max-lines-per-function */
235
239
  export async function handleUpdateStatus(params, server) {
236
240
  return trackCost(params.projectPath ?? '', 'update_status', async () => {
@@ -545,6 +549,19 @@ export async function handleUpdateStatus(params, server) {
545
549
  return validationReportError;
546
550
  }
547
551
  }
552
+ if (newStatus === 'done' &&
553
+ effectiveGatePath &&
554
+ !shouldSkipStrictLayoutGateForLegacyTestHarness()) {
555
+ const strictLayoutError = await validateStrictLayoutOrError({
556
+ projectPath: effectiveGatePath,
557
+ specId,
558
+ specPath: spec.specPath,
559
+ failClosedOnCrash: true,
560
+ });
561
+ if (strictLayoutError) {
562
+ return strictLayoutError;
563
+ }
564
+ }
548
565
  // Process crash shield result — record run timestamp on success (SPEC-628)
549
566
  /* c8 ignore next */
550
567
  if (crashRisksReport !== null) {
@@ -4,6 +4,7 @@ export declare function validateStrictLayoutOrError(args: {
4
4
  projectPath: string;
5
5
  specId: string;
6
6
  specPath?: string;
7
+ failClosedOnCrash?: boolean;
7
8
  }): Promise<ToolResult | null>;
8
9
  export declare function buildValidateFailureFallback(failedCount: number): InteractiveQuestion;
9
10
  //# sourceMappingURL=validate-helpers.d.ts.map
@@ -25,7 +25,25 @@ export async function validateStrictLayoutOrError(args) {
25
25
  },
26
26
  };
27
27
  }
28
- catch {
28
+ catch (err) {
29
+ if (args.failClosedOnCrash) {
30
+ const message = err instanceof Error ? err.message : String(err);
31
+ return {
32
+ content: [
33
+ {
34
+ type: 'text',
35
+ text: `Strict Planu layout validation crashed for ${args.specId}.\n\n` +
36
+ `The done transition is blocked because canonical layout could not be verified.\n\n` +
37
+ `Error: ${message}`,
38
+ },
39
+ ],
40
+ isError: true,
41
+ structuredContent: {
42
+ error: 'strict_planu_layout_check_crashed',
43
+ message,
44
+ },
45
+ };
46
+ }
29
47
  return null;
30
48
  }
31
49
  }
@@ -303,25 +303,21 @@ export async function handleValidate(args, server) {
303
303
  const totalCriteria = result.fieldsTotal;
304
304
  const passedCount = result.fieldsImplemented;
305
305
  const failedCount = totalCriteria - passedCount;
306
+ const indeterminate = result.indeterminate ?? [];
306
307
  const compactText = buildCompactValidateText({
307
308
  score: scoreValue,
308
309
  passedCount,
309
310
  failedCount,
310
311
  totalCriteria,
311
312
  missing: result.missing,
313
+ indeterminate,
312
314
  commitReminder,
313
315
  planuReminder,
314
316
  });
315
317
  const graphText = formatGraphCoverageText(graphCoverage);
316
318
  // SPEC-512: Compact structuredContent — essential fields only at top level
317
- const compactSummary = {
318
- specId,
319
- score: result.score,
320
- passed: passedCount,
321
- failed: failedCount,
322
- total: totalCriteria,
323
- status: failedCount === 0 ? 'passing' : 'failing',
324
- };
319
+ const compactSummary = buildCompactSummary(specId, result.score, passedCount, failedCount, totalCriteria);
320
+ const structuredBase = { ...outputWithSuggestions, summary: compactSummary, indeterminate };
325
321
  // SPEC-595: Elicit next action when validation fails and a server is available
326
322
  if (server !== undefined && failedCount > 0) {
327
323
  const { field, property } = buildEnumSchema('action', ['re-implement', 'ignore', 'mark-manual'], ['Fix failing criteria (Recommended)', 'Ignore failures', 'Mark as manually reviewed'], 'Next action', 're-implement');
@@ -339,8 +335,7 @@ export async function handleValidate(args, server) {
339
335
  },
340
336
  ],
341
337
  structuredContent: {
342
- ...outputWithSuggestions,
343
- summary: compactSummary,
338
+ ...structuredBase,
344
339
  interactiveQuestions: outcome.interactiveQuestions,
345
340
  },
346
341
  };
@@ -352,8 +347,7 @@ export async function handleValidate(args, server) {
352
347
  phaseEvent: createBasicPhaseEvent('validate', 'completed', 100, 'validating'),
353
348
  content: [{ type: 'text', text: compactText + graphText }],
354
349
  structuredContent: {
355
- ...outputWithSuggestions,
356
- summary: compactSummary,
350
+ ...structuredBase,
357
351
  elicitedAction,
358
352
  },
359
353
  };
@@ -361,14 +355,21 @@ export async function handleValidate(args, server) {
361
355
  return {
362
356
  phaseEvent: createBasicPhaseEvent('validate', 'completed', 100, 'validating'),
363
357
  content: [{ type: 'text', text: compactText + graphText }],
364
- structuredContent: {
365
- ...outputWithSuggestions,
366
- summary: compactSummary,
367
- },
358
+ structuredContent: structuredBase,
359
+ };
360
+ }
361
+ function buildCompactSummary(specId, score, passedCount, failedCount, totalCriteria) {
362
+ return {
363
+ specId,
364
+ score,
365
+ passed: passedCount,
366
+ failed: failedCount,
367
+ total: totalCriteria,
368
+ status: failedCount === 0 ? 'passing' : 'failing',
368
369
  };
369
370
  }
370
371
  function buildCompactValidateText(args) {
371
- const { score, passedCount, failedCount, totalCriteria, missing, commitReminder, planuReminder } = args;
372
+ const { score, passedCount, failedCount, totalCriteria, missing, indeterminate, commitReminder, planuReminder, } = args;
372
373
  if (failedCount === 0) {
373
374
  // All passing — single line
374
375
  return (`✅ Validate: ${String(score)}/100 — ${String(passedCount)}/${String(totalCriteria)} criteria passing` +
@@ -377,11 +378,20 @@ function buildCompactValidateText(args) {
377
378
  }
378
379
  // Failures exist — list only the failing ones (max 5)
379
380
  const MAX_FAILURES = 5;
380
- const shownFailures = missing.slice(0, MAX_FAILURES);
381
- const remainingCount = missing.length - shownFailures.length;
382
- const failureList = shownFailures.map((f) => ` - ${f}`).join('\n');
381
+ const labelledFailures = [
382
+ ...missing.map((criterion) => ({ label: 'missing', criterion })),
383
+ ...indeterminate.map((criterion) => ({ label: 'indeterminate', criterion })),
384
+ ];
385
+ const shownFailures = labelledFailures.slice(0, MAX_FAILURES);
386
+ const remainingCount = labelledFailures.length - shownFailures.length;
387
+ const failureList = shownFailures
388
+ .map((f) => ` - ${f.label === 'missing' ? f.criterion : `${f.label}: ${f.criterion}`}`)
389
+ .join('\n');
383
390
  const moreLine = remainingCount > 0 ? `\n ... and ${String(remainingCount)} more` : '';
384
- const failNames = shownFailures.slice(0, 3).join(', ');
391
+ const failNames = shownFailures
392
+ .slice(0, 3)
393
+ .map((failure) => `${failure.label}: ${failure.criterion}`)
394
+ .join(', ');
385
395
  const summaryLine = `❌ Validate: ${String(score)}/100 — ${String(failedCount)} failing: [${failNames}${remainingCount + shownFailures.length > 3 ? '...' : ''}]`;
386
396
  return summaryLine + '\n' + failureList + moreLine + commitReminder + planuReminder;
387
397
  }
@@ -183,6 +183,8 @@ export interface HolisticValidateReport {
183
183
  export interface ValidateResult {
184
184
  matches: string[];
185
185
  missing: string[];
186
+ /** Criteria that could not be proven or disproven with available evidence. */
187
+ indeterminate?: string[];
186
188
  extra: string[];
187
189
  fieldsImplemented: number;
188
190
  fieldsTotal: number;
@@ -198,6 +200,10 @@ export interface ValidateResult {
198
200
  */
199
201
  holisticReport?: HolisticValidateReport;
200
202
  }
203
+ export type CriterionEvidenceStatus = 'proven' | 'missing' | 'indeterminate';
204
+ export interface CriterionEvidenceResult {
205
+ status: CriterionEvidenceStatus;
206
+ }
201
207
  export interface CodeState {
202
208
  affectedFiles: string[];
203
209
  unexpectedFiles: string[];
@@ -79,6 +79,17 @@ export interface InitProjectResultInput {
79
79
  migratedCount: number;
80
80
  errors: string[];
81
81
  } | null;
82
+ criticalMigrationFailures?: {
83
+ phase: string;
84
+ severity: 'critical' | 'nonCritical';
85
+ message: string;
86
+ }[];
87
+ nonCriticalMigrationWarnings?: {
88
+ phase: string;
89
+ severity: 'critical' | 'nonCritical';
90
+ message: string;
91
+ }[];
92
+ migrationReportPath?: string | null;
82
93
  hooksInstalled: string[];
83
94
  gitFlowType: string;
84
95
  gitignoreUpdated: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.10.2",
3
+ "version": "4.10.3",
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",
@@ -34,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.10.2",
38
- "@planu/core-darwin-x64": "4.10.2",
39
- "@planu/core-linux-arm64-gnu": "4.10.2",
40
- "@planu/core-linux-arm64-musl": "4.10.2",
41
- "@planu/core-linux-x64-gnu": "4.10.2",
42
- "@planu/core-linux-x64-musl": "4.10.2",
43
- "@planu/core-win32-arm64-msvc": "4.10.2",
44
- "@planu/core-win32-x64-msvc": "4.10.2"
37
+ "@planu/core-darwin-arm64": "4.10.3",
38
+ "@planu/core-darwin-x64": "4.10.3",
39
+ "@planu/core-linux-arm64-gnu": "4.10.3",
40
+ "@planu/core-linux-arm64-musl": "4.10.3",
41
+ "@planu/core-linux-x64-gnu": "4.10.3",
42
+ "@planu/core-linux-x64-musl": "4.10.3",
43
+ "@planu/core-win32-arm64-msvc": "4.10.3",
44
+ "@planu/core-win32-x64-msvc": "4.10.3"
45
45
  },
46
46
  "engines": {
47
47
  "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": "4.10.2",
4
+ "version": "4.10.3",
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": "4.10.2",
5
+ "version": "4.10.3",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": [
8
8
  "npx",