@planu/cli 5.4.0 → 5.5.0

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,25 @@
1
+ ## [5.5.0] - 2026-08-29
2
+
3
+ ### Features
4
+ - feat(SPEC-1681): cache the prettier format gate with content strategy
5
+
6
+ ### Bug Fixes
7
+ - fix(SPEC-1684): cap vitest workers at 6 in preflight related and full-suite runs
8
+ - fix(storage): handle ENOENT explicitly in best-effort path normalization
9
+ - fix(SPEC-1677,SPEC-1678): dedupe AGENTS.md changes and bound legacy skips to contract codes
10
+ - fix(SPEC-1678): classify legacy contract failures as non-fatal skipped entries
11
+ - fix(SPEC-1677): install and refresh planu core host assets on safe update
12
+ - fix(SPEC-1679): normalize both roots in canonical re-check to match heal semantics
13
+ - fix(SPEC-1679): self-heal missing registry logicalProjectId in canonical-root gate
14
+
15
+
16
+ ## [5.4.1] - 2026-08-29
17
+
18
+ ### Bug Fixes
19
+ - fix(SPEC-1675): strip FILES/FUNCTIONS/TEST metadata markers from contradiction analysis
20
+ - fix(SPEC-1676): rebuild expired project knowledge graph and re-stamp cached reuse
21
+
22
+
1
23
  ## [5.4.0] - 2026-08-29
2
24
 
3
25
  ### Features
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"1224d53c6195e75033a881982ac489bc4f2619f3"}
1
+ {"schemaVersion":1,"commit":"2c3e47edf445a0c8422cbeec33a6a929edd764b5"}
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { collectProjectGraphSources, diffSourceHashes, hashText, hydrateProjectGraphSource, loadProjectGraphPolicy, projectGraphCachePath, projectGraphExtractorVersion, projectGraphPath, readProjectGraphArtifact, readProjectGraphCache, withProjectGraphBuildLock, writeProjectGraphArtifacts, } from './cache.js';
2
+ import { collectProjectGraphSources, diffSourceHashes, hashText, hydrateProjectGraphSource, loadProjectGraphPolicy, projectGraphCachePath, projectGraphExtractorVersion, projectGraphPath, readProjectGraphArtifact, readProjectGraphCache, withProjectGraphBuildLock, writeProjectGraph, writeProjectGraphArtifacts, } from './cache.js';
3
3
  import { extractGitGraph, extractReleaseGraph } from './extractors/git-extractor.js';
4
4
  import { extractHandoffGraph } from './extractors/handoff-extractor.js';
5
5
  import { extractDecisionStoreGraph } from './extractors/decision-store-extractor.js';
@@ -210,13 +210,16 @@ async function buildProjectKnowledgeGraphUnlocked(args) {
210
210
  Array.isArray(existingGraph.nodes) &&
211
211
  Array.isArray(existingGraph.edges);
212
212
  if (hasConsistentExistingGraph && diff.changed.length === 0 && diff.removed.length === 0) {
213
+ const refreshedGeneratedAt = new Date().toISOString();
213
214
  const graphWithCurrentOversizedSources = {
214
215
  ...existingGraph,
216
+ generatedAt: refreshedGeneratedAt,
215
217
  oversizedSources,
216
218
  };
219
+ await writeProjectGraph(args.projectId, args.policy, graphWithCurrentOversizedSources);
217
220
  publishFreshProjectGraphProjection({
218
221
  projectId: args.projectId,
219
- generatedAt: existingGraph.generatedAt,
222
+ generatedAt: refreshedGeneratedAt,
220
223
  generation: existingGraph.generation,
221
224
  });
222
225
  return {
@@ -55,7 +55,8 @@ async function ensureFreshProjectGraph(args) {
55
55
  const freshness = await getProjectGraphFreshness(args);
56
56
  if (!freshness.exists ||
57
57
  freshness.reason === 'source_changed' ||
58
- freshness.reason === 'corrupt') {
58
+ freshness.reason === 'corrupt' ||
59
+ freshness.reason === 'expired') {
59
60
  await buildProjectKnowledgeGraph(args);
60
61
  return getProjectGraphFreshness(args);
61
62
  }
@@ -203,14 +203,35 @@ function assertsActionNearScope(clause, outOfScopeItem) {
203
203
  }
204
204
  return false;
205
205
  }
206
+ const METADATA_MARKER_KEYWORD = /\b(?:FILES?|FUNCTIONS?|TESTS?)\s*:/gi;
207
+ const SENTENCE_BOUNDARY_AFTER_MARKER = /\.\s+(?=[A-Z])/;
208
+ function stripMetadataMarkers(text) {
209
+ const markers = [...text.matchAll(METADATA_MARKER_KEYWORD)];
210
+ if (markers.length === 0) {
211
+ return text;
212
+ }
213
+ let result = '';
214
+ let cursor = 0;
215
+ for (let i = 0; i < markers.length; i++) {
216
+ const start = markers[i]?.index ?? 0;
217
+ result += text.slice(cursor, start);
218
+ const nextMarkerStart = markers[i + 1]?.index ?? text.length;
219
+ const searchRegion = text.slice(start, nextMarkerStart);
220
+ const boundaryMatch = SENTENCE_BOUNDARY_AFTER_MARKER.exec(searchRegion);
221
+ cursor = boundaryMatch ? start + boundaryMatch.index + 1 : nextMarkerStart;
222
+ }
223
+ result += text.slice(cursor);
224
+ return result.replace(/\s+/g, ' ').trim();
225
+ }
206
226
  /**
207
227
  * Determine whether a criterion text contradicts an out-of-scope item.
208
228
  * Uses substring match first, then keyword overlap as fuzzy fallback.
209
229
  */
210
230
  function contradicts(criterionText, outOfScopeItem) {
211
- const normCriterion = normalize(criterionText);
231
+ const strippedCriterionText = stripMetadataMarkers(criterionText);
232
+ const normCriterion = normalize(strippedCriterionText);
212
233
  const normScope = normalize(outOfScopeItem);
213
- const clauses = splitClauses(criterionText);
234
+ const clauses = splitClauses(strippedCriterionText);
214
235
  const relevantClauses = clauses.filter((clause) => clauseMentionsScope(clause, outOfScopeItem));
215
236
  const hasConditionalException = /\b(?:unless|except)\b/i.test(criterionText);
216
237
  if (!hasConditionalException &&
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { lstat, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
3
3
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
4
4
  import { resolveStorageLayout } from './storage-layout.js';
5
- import { getCanonicalRoot, readLogicalProjectId } from './global-projects-store.js';
5
+ import { addProject, getCanonicalRoot, getProjects, readLogicalProjectId, } from './global-projects-store.js';
6
6
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
7
7
  import { isEphemeralProject } from '../engine/data-projects-gc/pattern-matcher.js';
8
8
  const IDENTITY_VERSION = 1;
@@ -205,37 +205,64 @@ function rejectsRootEscape(root, candidate) {
205
205
  const rel = relative(root, candidate);
206
206
  return rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel);
207
207
  }
208
- async function realpathClassified(path, notFoundCode) {
208
+ async function realpathOr(path, onEnoent) {
209
209
  try {
210
210
  return await realpath(path);
211
211
  }
212
212
  catch (error) {
213
213
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
214
- throw new PortablePathError(notFoundCode, `[Planu] path not found: ${notFoundCode}`);
214
+ return onEnoent();
215
215
  }
216
216
  throw error;
217
217
  }
218
218
  }
219
+ async function realpathClassified(path, code) {
220
+ return realpathOr(path, () => {
221
+ throw new PortablePathError(code, `[Planu] path not found: ${code}`);
222
+ });
223
+ }
224
+ async function normalizePathBestEffort(path) {
225
+ return realpathOr(path, () => resolve(path));
226
+ }
227
+ /** Bounded self-heal: backfill logicalProjectId via {@link addProject} using the registry entry's own stored path when exactly one path matches. */
228
+ async function healMissingLogicalProjectId(root) {
229
+ const normalizedRoot = await normalizePathBestEffort(root);
230
+ const projects = await getProjects();
231
+ const normalized = await Promise.all(projects.map((p) => normalizePathBestEffort(p.path)));
232
+ const matches = projects.filter((_, i) => normalized[i] === normalizedRoot);
233
+ const [match] = matches;
234
+ if (matches.length !== 1 || !match) {
235
+ return false;
236
+ }
237
+ await addProject(match.path);
238
+ return true;
239
+ }
219
240
  /**
220
- * Reject a candidate root that carries a tracked logical project identity but
221
- * is not the single registry-confirmed canonical root for it a checkout
222
- * left over from a rename/fork, or an ambiguous/unregistered project never
223
- * resolves specs against an arbitrary directory. A candidate with no tracked
224
- * identity (`planu/project.json` absent) is untracked, not unverified, and
225
- * passes through unchanged — as does an ephemeral checkout (test fixture,
226
- * scratch clone), since the global registry deliberately excludes those
227
- * (SPEC-581) and can never confirm a root for them.
241
+ * Verify a candidate root with a tracked identity against the single
242
+ * registry-confirmed canonical root for it, fails closed on a fork/rename
243
+ * checkout. An untracked or ephemeral candidate passes through unchanged
244
+ * (SPEC-581) and skips the heal below. On a zero-match registry entry that
245
+ * predates `logicalProjectId`, self-heals once via {@link healMissingLogicalProjectId}
246
+ * before failing closed.
228
247
  */
229
- async function rejectsUnverifiedRoot(root) {
248
+ async function verifyCanonicalRoot(root) {
230
249
  if (isEphemeralProject(root)) {
231
- return false;
250
+ return undefined;
232
251
  }
233
252
  const logicalProjectId = await readLogicalProjectId(root);
234
253
  if (logicalProjectId === undefined) {
235
- return false;
254
+ return undefined;
255
+ }
256
+ let canonical = await getCanonicalRoot(logicalProjectId);
257
+ if (!canonical.ok && canonical.reason === 'zero' && (await healMissingLogicalProjectId(root))) {
258
+ canonical = await getCanonicalRoot(logicalProjectId);
236
259
  }
237
- const canonical = await getCanonicalRoot(logicalProjectId);
238
- return !canonical.ok || resolve(canonical.root) !== root;
260
+ if (canonical.ok) {
261
+ return (await normalizePathBestEffort(canonical.root)) === (await normalizePathBestEffort(root))
262
+ ? undefined
263
+ : { reason: 'mismatch', roots: [canonical.root] };
264
+ }
265
+ return { reason: canonical.reason, roots: canonical.roots };
239
266
  }
240
267
  /**
241
268
  * Resolve a stored `specPath`/`technicalPath` value against the verified canonical
@@ -281,8 +308,12 @@ export async function resolvePortableSpecPath(specId, storedPath, canonicalRoot)
281
308
  * Delegates to {@link resolvePortableSpecPath} once verified.
282
309
  */
283
310
  export async function resolveVerifiedSpecPath(specId, storedPath, canonicalRoot) {
284
- if (await rejectsUnverifiedRoot(resolve(canonicalRoot))) {
285
- throw new PortablePathError('ROOT_UNVERIFIED', '[Planu] canonical root is not the single registry-confirmed root for this project');
311
+ const root = resolve(canonicalRoot);
312
+ const failure = await verifyCanonicalRoot(root);
313
+ if (failure) {
314
+ const registered = failure.roots.length > 0 ? `, registered=[${failure.roots.join(', ')}]` : '';
315
+ throw new PortablePathError('ROOT_UNVERIFIED', `[Planu] canonical root is not the single registry-confirmed root for this project ` +
316
+ `(reason=${failure.reason}, candidate=${root}${registered}) — run init_project on the canonical checkout`);
286
317
  }
287
318
  return resolvePortableSpecPath(specId, storedPath, canonicalRoot);
288
319
  }
@@ -21,7 +21,8 @@ import { regeneratePages } from '../../engine/doc-generator/portal/index.js';
21
21
  import { detectStackPatterns } from './stack-detector.js';
22
22
  import { buildProjectConfig } from './config-builder.js';
23
23
  import { runSpecMigrations, listProjectSpecs } from './migration-runner.js';
24
- import { reconcilePortableSpecIndex } from './portable-index-reconciler.js';
24
+ import { reconcilePortableSpecIndex, describeReconciliationOutcome, } from './portable-index-reconciler.js';
25
+ import { refreshCoreHostAssets } from './host-assets-writer.js';
25
26
  import { runScaffoldWriter } from './scaffold-writer.js';
26
27
  import { readAutoInstallFlag, orchestrateSkillInstalls, runHealthCheckWithBaseline, runConventionScanSafe, fireTelemetry, } from './lifecycle-helpers.js';
27
28
  import { injectProactiveRules } from '../../engine/claude-md-injector/index.js';
@@ -215,23 +216,23 @@ export async function handleInitProject(params, server) {
215
216
  const authorizedMigrations = params.authorizedMigrations ?? [];
216
217
  if (isUpdate && authorizedMigrations.length === 0) {
217
218
  const reconciliation = await reconcilePortableSpecIndex(projectPath, projectId);
219
+ const repositoryFilesChanged = reconciliation.failures.length === 0 ? await refreshCoreHostAssets(projectPath) : [];
218
220
  return {
219
221
  content: [
220
222
  {
221
223
  type: 'text',
222
- text: reconciliation.failures.length === 0
223
- ? 'Project already initialized. Safe update reconciled the portable spec index without changing repository files.'
224
- : `Portable spec index reconciliation failed for ${String(reconciliation.failures.length)} contract(s).`,
224
+ text: describeReconciliationOutcome(reconciliation, repositoryFilesChanged),
225
225
  },
226
226
  ],
227
227
  structuredContent: {
228
228
  projectId,
229
229
  ...(reconciliation.failures.length === 0 ? { projectPath } : {}),
230
230
  isUpdate: true,
231
- repositoryFilesChanged: [],
231
+ repositoryFilesChanged,
232
232
  authorizedMigrations: [],
233
233
  importedSpecIds: reconciliation.importedSpecIds,
234
234
  failures: reconciliation.failures,
235
+ skippedLegacy: reconciliation.skippedLegacy,
235
236
  },
236
237
  ...(reconciliation.failures.length > 0 ? { isError: true } : {}),
237
238
  };
@@ -17,5 +17,18 @@ export interface InitHostAssetsResult {
17
17
  * host configs that should stay in sync too.
18
18
  */
19
19
  export declare function detectInitAssetHosts(projectPath: string): Promise<InitAssetHost[]>;
20
+ /** Whether `host` already has an on-disk asset surface in `projectPath` (SPEC-1677). */
21
+ export declare function hasCoreSkillSurface(projectPath: string, host: HostId): Promise<boolean>;
22
+ export declare function installCoreSkillsForHost(projectPath: string, host: HostId): Promise<{
23
+ host: HostId;
24
+ name: string;
25
+ path: string;
26
+ }[]>;
20
27
  export declare function installInitHostAssets(projectPath: string, _knowledge: ProjectKnowledge, installSkills?: boolean): Promise<InitHostAssetsResult>;
28
+ /**
29
+ * SPEC-1677: refresh Planu-owned core skills on the init_project safe-update path,
30
+ * restricted to detected hosts whose asset surface already exists on disk. Returns
31
+ * project-relative paths of every file whose content actually changed.
32
+ */
33
+ export declare function refreshCoreHostAssets(projectPath: string): Promise<string[]>;
21
34
  //# sourceMappingURL=host-assets-writer.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // tools/init-project/host-assets-writer.ts — Host-aware rules/skills installed by init_project
2
2
  import { readFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { join, relative } from 'node:path';
4
4
  import { detectHost } from '../../engine/host-detection/detect-host.js';
5
5
  import { handleCreateSkill } from '../create-skill.js';
6
6
  import { fileExists } from '../../core/shared/fs.js';
@@ -119,7 +119,25 @@ async function canRefreshCoreSkill(projectPath, host, name) {
119
119
  return true;
120
120
  }
121
121
  }
122
- async function installCoreSkillsForHost(projectPath, host) {
122
+ /** Whether `host` already has an on-disk asset surface in `projectPath` (SPEC-1677). */
123
+ export async function hasCoreSkillSurface(projectPath, host) {
124
+ if (host === 'codex') {
125
+ return fileExists(join(projectPath, 'AGENTS.md'));
126
+ }
127
+ if (host === 'gemini') {
128
+ return fileExists(join(projectPath, '.gemini'));
129
+ }
130
+ return fileExists(join(projectPath, '.claude'));
131
+ }
132
+ async function readTargetContent(path) {
133
+ try {
134
+ return await readFile(path, 'utf-8');
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ export async function installCoreSkillsForHost(projectPath, host) {
123
141
  const installed = [];
124
142
  for (const fileName of CORE_SKILL_TEMPLATES) {
125
143
  const template = await readCoreSkillTemplate(fileName);
@@ -129,6 +147,8 @@ async function installCoreSkillsForHost(projectPath, host) {
129
147
  if (!(await canRefreshCoreSkill(projectPath, host, template.name))) {
130
148
  continue;
131
149
  }
150
+ const targetPath = coreSkillPath(projectPath, host, template.name);
151
+ const before = await readTargetContent(targetPath);
132
152
  const result = await handleCreateSkill({
133
153
  projectPath,
134
154
  host,
@@ -145,11 +165,11 @@ async function installCoreSkillsForHost(projectPath, host) {
145
165
  'skillFilePath' in result.structuredContent &&
146
166
  typeof result.structuredContent.skillFilePath === 'string'
147
167
  ? result.structuredContent.skillFilePath
148
- : host === 'codex'
149
- ? join(projectPath, 'AGENTS.md')
150
- : host === 'gemini'
151
- ? join(projectPath, '.gemini', 'skills', `${template.name}.md`)
152
- : join(projectPath, '.claude', 'skills', template.name, 'SKILL.md');
168
+ : targetPath;
169
+ const after = await readTargetContent(path);
170
+ if (after === before) {
171
+ continue;
172
+ }
153
173
  installed.push({ host, name: template.name, path });
154
174
  }
155
175
  return installed;
@@ -195,4 +215,28 @@ export async function installInitHostAssets(projectPath, _knowledge, installSkil
195
215
  }
196
216
  return { detectedHosts, rulesInstalled, skillsInstalled, opencodeConfigured };
197
217
  }
218
+ const CORE_SKILL_HOST_IDS = ['claude-code', 'codex', 'gemini'];
219
+ function isCoreSkillHost(host) {
220
+ return CORE_SKILL_HOST_IDS.includes(host);
221
+ }
222
+ /**
223
+ * SPEC-1677: refresh Planu-owned core skills on the init_project safe-update path,
224
+ * restricted to detected hosts whose asset surface already exists on disk. Returns
225
+ * project-relative paths of every file whose content actually changed.
226
+ */
227
+ export async function refreshCoreHostAssets(projectPath) {
228
+ const detectedHosts = await detectInitAssetHosts(projectPath);
229
+ const coreSkillHosts = detectedHosts.filter(isCoreSkillHost);
230
+ const changed = [];
231
+ for (const host of coreSkillHosts) {
232
+ if (!(await hasCoreSkillSurface(projectPath, host))) {
233
+ continue;
234
+ }
235
+ const installed = await installCoreSkillsForHost(projectPath, host);
236
+ for (const skill of installed) {
237
+ changed.push(relative(projectPath, skill.path));
238
+ }
239
+ }
240
+ return [...new Set(changed)];
241
+ }
198
242
  //# sourceMappingURL=host-assets-writer.js.map
@@ -1,4 +1,10 @@
1
- import type { FilesystemImportDeps, PortableIndexReconciliationResult } from '../../types/index.js';
1
+ import type { FilesystemImportDeps, FilesystemImportFailure, PortableIndexReconciliationResult, SkippedLegacyContract } from '../../types/index.js';
2
+ /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
3
+ export declare function describeReconciliationFailures(failures: FilesystemImportFailure[]): string;
4
+ /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
5
+ export declare function describeReconciliationSuccess(repositoryFilesChanged: string[], skippedLegacy: SkippedLegacyContract[]): string;
6
+ /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
7
+ export declare function describeReconciliationOutcome(reconciliation: PortableIndexReconciliationResult, repositoryFilesChanged: string[]): string;
2
8
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
3
9
  export declare function reconcilePortableSpecIndex(projectPath: string, projectId: string, deps?: FilesystemImportDeps): Promise<PortableIndexReconciliationResult>;
4
10
  //# sourceMappingURL=portable-index-reconciler.d.ts.map
@@ -1,5 +1,63 @@
1
1
  import { globalStore, specStore } from '../../storage/index.js';
2
2
  import { importFilesystemSpecs } from '../../engine/spec-migrator/filesystem-import.js';
3
+ function classifyFailures(failures, indexedSpecIds) {
4
+ const remainingFailures = [];
5
+ const skippedLegacy = [];
6
+ const LEGACY_SKIPPABLE_CODES = new Set([
7
+ 'INVALID_CONTRACT',
8
+ 'INCOMPATIBLE_IDENTITY',
9
+ ]);
10
+ for (const failure of failures) {
11
+ if (indexedSpecIds.has(failure.specId) && LEGACY_SKIPPABLE_CODES.has(failure.code)) {
12
+ skippedLegacy.push({
13
+ ...failure,
14
+ reason: `${failure.specId} is already indexed; the on-disk contract is a historic mismatch left as-is.`,
15
+ });
16
+ continue;
17
+ }
18
+ remainingFailures.push(failure);
19
+ }
20
+ return { failures: remainingFailures, skippedLegacy };
21
+ }
22
+ const NEXT_ACTION_BY_CODE = {
23
+ READ_FAILED: 'fix file permissions or contents so spec.md can be read',
24
+ INVALID_CONTRACT: 'add the required portable frontmatter fields (id, title, type, scope, status, risk, target, difficulty)',
25
+ CREATE_FAILED: 'retry init_project once the underlying store write succeeds',
26
+ INCOMPATIBLE_IDENTITY: 'reconcile the spec identity (id, title, slug, uuid) with the indexed record',
27
+ };
28
+ /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
29
+ export function describeReconciliationFailures(failures) {
30
+ const counts = new Map();
31
+ for (const failure of failures) {
32
+ counts.set(failure.code, (counts.get(failure.code) ?? 0) + 1);
33
+ }
34
+ const codeSummary = [...counts.entries()]
35
+ .map(([code, count]) => `${String(count)} ${code}`)
36
+ .join(', ');
37
+ const topPaths = failures
38
+ .slice(0, 3)
39
+ .map((failure) => failure.path)
40
+ .join(', ');
41
+ const remainder = failures.length > 3 ? ', …' : '';
42
+ const nextActions = [...counts.keys()].map((code) => NEXT_ACTION_BY_CODE[code]).join('; ');
43
+ return `Portable spec index reconciliation failed for ${String(failures.length)} contract(s) (${codeSummary}). Affected: ${topPaths}${remainder}. Next: ${nextActions}.`;
44
+ }
45
+ /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
46
+ export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy) {
47
+ const base = repositoryFilesChanged.length === 0
48
+ ? 'Project already initialized. Safe update reconciled the portable spec index without changing repository files.'
49
+ : `Project already initialized. Safe update reconciled the portable spec index and refreshed ${String(repositoryFilesChanged.length)} core host asset file(s).`;
50
+ const skippedSuffix = skippedLegacy.length === 0
51
+ ? ''
52
+ : ` Skipped ${String(skippedLegacy.length)} already-indexed legacy contract(s).`;
53
+ return base + skippedSuffix;
54
+ }
55
+ /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
56
+ export function describeReconciliationOutcome(reconciliation, repositoryFilesChanged) {
57
+ return reconciliation.failures.length === 0
58
+ ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy)
59
+ : describeReconciliationFailures(reconciliation.failures);
60
+ }
3
61
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
4
62
  export async function reconcilePortableSpecIndex(projectPath, projectId, deps = {
5
63
  listSpecs: specStore.listSpecs,
@@ -10,6 +68,8 @@ export async function reconcilePortableSpecIndex(projectPath, projectId, deps =
10
68
  const result = await importFilesystemSpecs(projectPath, deps, projectId, {
11
69
  strictPortableContract: true,
12
70
  });
13
- return { ...result, repositoryFilesChanged: [] };
71
+ const indexedSpecIds = new Set((await deps.listSpecs(projectId)).map((spec) => spec.id));
72
+ const { failures, skippedLegacy } = classifyFailures(result.failures, indexedSpecIds);
73
+ return { ...result, failures, skippedLegacy, repositoryFilesChanged: [] };
14
74
  }
15
75
  //# sourceMappingURL=portable-index-reconciler.js.map
@@ -233,8 +233,15 @@ export interface FilesystemImportDeps {
233
233
  getSpecFresh?: (projectId: string, specId: string) => Promise<Spec | null>;
234
234
  getGlobalConfig?: () => Promise<Partial<GlobalConfig>>;
235
235
  }
236
+ export interface SkippedLegacyContract {
237
+ specId: string;
238
+ path: string;
239
+ code: FilesystemImportFailureCode;
240
+ reason: string;
241
+ }
236
242
  export interface PortableIndexReconciliationResult extends FilesystemImportResult {
237
243
  repositoryFilesChanged: string[];
244
+ skippedLegacy: SkippedLegacyContract[];
238
245
  }
239
246
  export interface ImportEntryContext {
240
247
  projectPath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Planu — MCP Server for Spec Driven Development. Cross-platform (Linux/macOS/Windows, x64/arm64).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,11 +40,11 @@
40
40
  "lint:gate": "eslint src/ tests/ --max-warnings 0",
41
41
  "lint:fix": "eslint src/ tests/ --fix --max-warnings 0",
42
42
  "format": "prettier --write 'src/**/*.ts' 'tests/**/*.test.ts'",
43
- "format:check": "prettier --check 'src/**/*.ts' 'tests/**/*.test.ts'",
43
+ "format:check": "prettier --check --cache --cache-strategy content 'src/**/*.ts' 'tests/**/*.test.ts'",
44
44
  "typecheck": "tsc --noEmit",
45
45
  "typecheck:compat": "tsc6 --noEmit",
46
46
  "verify:typescript-migration": "node scripts/verify-typescript-migration.mjs",
47
- "test": "vitest run",
47
+ "test": "vitest run --maxWorkers=6",
48
48
  "test:watch": "vitest",
49
49
  "test:coverage": "vitest run --coverage --testTimeout=60000 --maxWorkers=4",
50
50
  "test:release-gate": "vitest run --exclude 'tests/integration/**'",
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.4.0",
5
+ "version": "5.5.0",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",