@planu/cli 5.4.1 → 5.5.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,53 @@
1
+ ## [5.5.3] - 2026-08-30
2
+
3
+ ### Bug Fixes
4
+ - fix: reuse canonical pathExistsStrictByStat and give worktree-list test a real project path
5
+ - fix(SPEC-1682): honor Risks and Test Plan section headings in handoff packager
6
+ - fix(SPEC-1683): close guard bypass, fail-closed pristine check, TOCTOU-safe removal
7
+ - fix(SPEC-1683): git command guard and stray embedded repo self-heal
8
+
9
+ ### Chores
10
+ - chore(planu): SPEC-1685 implementing transition state
11
+
12
+
13
+ ## [5.5.2] - 2026-08-30
14
+
15
+ ### Bug Fixes
16
+ - fix: reuse canonical pathExistsStrictByStat and give worktree-list test a real project path
17
+ - fix(SPEC-1682): honor Risks and Test Plan section headings in handoff packager
18
+ - fix(SPEC-1683): close guard bypass, fail-closed pristine check, TOCTOU-safe removal
19
+ - fix(SPEC-1683): git command guard and stray embedded repo self-heal
20
+
21
+ ### Chores
22
+ - chore(planu): SPEC-1685 implementing transition state
23
+
24
+
25
+ ## [5.5.1] - 2026-08-30
26
+
27
+ ### Bug Fixes
28
+ - fix(SPEC-1682): honor Risks and Test Plan section headings in handoff packager
29
+ - fix(SPEC-1683): close guard bypass, fail-closed pristine check, TOCTOU-safe removal
30
+ - fix(SPEC-1683): git command guard and stray embedded repo self-heal
31
+
32
+ ### Chores
33
+ - chore(planu): SPEC-1685 implementing transition state
34
+
35
+
36
+ ## [5.5.0] - 2026-08-29
37
+
38
+ ### Features
39
+ - feat(SPEC-1681): cache the prettier format gate with content strategy
40
+
41
+ ### Bug Fixes
42
+ - fix(SPEC-1684): cap vitest workers at 6 in preflight related and full-suite runs
43
+ - fix(storage): handle ENOENT explicitly in best-effort path normalization
44
+ - fix(SPEC-1677,SPEC-1678): dedupe AGENTS.md changes and bound legacy skips to contract codes
45
+ - fix(SPEC-1678): classify legacy contract failures as non-fatal skipped entries
46
+ - fix(SPEC-1677): install and refresh planu core host assets on safe update
47
+ - fix(SPEC-1679): normalize both roots in canonical re-check to match heal semantics
48
+ - fix(SPEC-1679): self-heal missing registry logicalProjectId in canonical-root gate
49
+
50
+
1
51
  ## [5.4.1] - 2026-08-29
2
52
 
3
53
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"c920f236390f64d91a02ca3e0566e59210f00a49"}
1
+ {"schemaVersion":1,"commit":"5060255598e14cab792b6b5b0d75a077f23fe018"}
@@ -0,0 +1,2 @@
1
+ export declare function assertSafeGitInvocation(cwd: string, args: readonly string[]): void;
2
+ //# sourceMappingURL=git-command-guard.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { normalize, sep } from 'node:path';
2
+ const VALUE_TAKING_GLOBAL_OPTIONS = new Set([
3
+ '-C',
4
+ '-c',
5
+ '--config-env',
6
+ '--exec-path',
7
+ '--git-dir',
8
+ '--work-tree',
9
+ '--namespace',
10
+ '--super-prefix',
11
+ '--attr-source',
12
+ '--list-cmds',
13
+ ]);
14
+ const REPO_CREATING_SUBCOMMANDS = new Set(['init', 'clone']);
15
+ function resolveSubcommand(args) {
16
+ let index = 0;
17
+ while (index < args.length) {
18
+ const arg = args[index];
19
+ if (!arg?.startsWith('-')) {
20
+ return arg;
21
+ }
22
+ if (!arg.includes('=') && VALUE_TAKING_GLOBAL_OPTIONS.has(arg)) {
23
+ index += 2;
24
+ continue;
25
+ }
26
+ index += 1;
27
+ }
28
+ return undefined;
29
+ }
30
+ function cwdTargetsSpecFolder(cwd) {
31
+ const segments = normalize(cwd).split(sep);
32
+ return segments.some((segment, i) => segment === 'planu' && segments[i + 1] === 'specs');
33
+ }
34
+ export function assertSafeGitInvocation(cwd, args) {
35
+ const subcommand = resolveSubcommand(args);
36
+ if (subcommand !== undefined && REPO_CREATING_SUBCOMMANDS.has(subcommand)) {
37
+ throw new Error(`Refusing to run repo-creating git subcommand "${subcommand}" (cwd: ${cwd}). Planu's git wrappers never create git repositories.`);
38
+ }
39
+ if (cwdTargetsSpecFolder(cwd)) {
40
+ throw new Error(`Refusing to run git with cwd inside a planu/specs folder (cwd: ${cwd}). Git commands must target the project root, never a spec folder.`);
41
+ }
42
+ }
43
+ //# sourceMappingURL=git-command-guard.js.map
@@ -2,11 +2,13 @@
2
2
  import { access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { runAbortableProcess } from '../abortable-process-runner.js';
5
+ import { assertSafeGitInvocation } from './git-command-guard.js';
5
6
  export function isPlanuAutocommitEnabled() {
6
7
  return process.env.PLANU_ENABLE_AUTOCOMMIT === 'true';
7
8
  }
8
9
  /** Run git through the request-aware process-group supervisor. */
9
10
  async function runGit(cwd, args) {
11
+ assertSafeGitInvocation(cwd, args);
10
12
  const result = await runAbortableProcess('git', args, {
11
13
  cwd,
12
14
  maxBufferBytes: 1024 * 1024,
@@ -484,10 +484,46 @@ function extractLinesByKeywords(content, keywords) {
484
484
  .filter((line) => line.length > 0 && !/^#{1,6}\s/.test(line) && keywords.test(line));
485
485
  return [...new Set(lines)].slice(0, 12);
486
486
  }
487
+ const LIST_ITEM_PATTERN = /^(?:[-*]|\d+\.)\s+(?:\[[ x]\]\s+)?(.+)$/;
488
+ function sectionBullets(content, headingRegex) {
489
+ const lines = content.split('\n');
490
+ const bullets = [];
491
+ let inSection = false;
492
+ for (const line of lines) {
493
+ const trimmed = line.trim();
494
+ if (headingRegex.test(trimmed)) {
495
+ inSection = true;
496
+ continue;
497
+ }
498
+ if (/^#{1,6}\s/.test(trimmed)) {
499
+ inSection = false;
500
+ continue;
501
+ }
502
+ if (!inSection) {
503
+ continue;
504
+ }
505
+ const match = LIST_ITEM_PATTERN.exec(trimmed);
506
+ if (match?.[1]) {
507
+ bullets.push(match[1].trim());
508
+ }
509
+ }
510
+ return bullets;
511
+ }
512
+ function dedupeMerge(primary, fallback) {
513
+ const seen = new Set();
514
+ const merged = [];
515
+ for (const item of [...primary, ...fallback]) {
516
+ if (!seen.has(item)) {
517
+ seen.add(item);
518
+ merged.push(item);
519
+ }
520
+ }
521
+ return merged.slice(0, 12);
522
+ }
487
523
  function extractOperationalSections(content, spec) {
488
524
  return {
489
- testPlan: extractLinesByKeywords(content, /\b(test|typecheck|lint|vitest|playwright|verification|validate|pnpm|npm)\b/i),
490
- risks: extractLinesByKeywords(content, /\b(risk|edge|failure|security|drift|migration|breaking|compatibility|rollback)\b/i),
525
+ testPlan: dedupeMerge(sectionBullets(content, /^#{2,6}\s*test plan\b/i), extractLinesByKeywords(content, /\b(test|typecheck|lint|vitest|playwright|verification|validate|pnpm|npm)\b/i)),
526
+ risks: dedupeMerge(sectionBullets(content, /^#{2,6}\s*risks?\b/i), extractLinesByKeywords(content, /\b(risk|edge|failure|security|drift|migration|breaking|compatibility|rollback)\b/i)),
491
527
  ownership: extractLinesByKeywords(content, /\b(owner|ownership|wave|agent|reviewer|arbiter|files?|responsible)\b/i),
492
528
  currentState: `Spec ${spec.id} is ${spec.status}; implementation must follow the approved spec artifact, not chat history.`,
493
529
  nextAction: lifecycleNextAction(spec.status),
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import { knowledgeStore } from '../../storage/index.js';
2
2
  import { runAbortableProcess } from '../../engine/abortable-process-runner.js';
3
+ import { assertSafeGitInvocation } from '../../engine/git/git-command-guard.js';
3
4
  /** Default branch prefixes by spec type. */
4
5
  export const DEFAULT_BRANCH_PREFIXES = {
5
6
  feature: 'feat',
@@ -15,6 +16,7 @@ export const DEFAULT_PROTECTED_BRANCHES = ['main', 'master', 'develop', 'product
15
16
  export const DEFAULT_STALENESS_THRESHOLD = 50;
16
17
  /** Execute a git command in the project directory. */
17
18
  export async function git(projectPath, args, options = {}) {
19
+ assertSafeGitInvocation(projectPath, args);
18
20
  try {
19
21
  const result = await runAbortableProcess('git', args, {
20
22
  cwd: projectPath,
@@ -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,24 @@ 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,
236
+ strayRepoRemovals: reconciliation.strayRepoRemovals,
235
237
  },
236
238
  ...(reconciliation.failures.length > 0 ? { isError: true } : {}),
237
239
  };
@@ -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[], strayRepoRemovals?: string[]): 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,161 @@
1
+ import { readdir, rename, rm, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { pathExistsStrictByStat } from '../../core/shared/fs.js';
1
4
  import { globalStore, specStore } from '../../storage/index.js';
2
5
  import { importFilesystemSpecs } from '../../engine/spec-migrator/filesystem-import.js';
6
+ function isErrnoCode(error, code) {
7
+ return (error instanceof Error && 'code' in error && error.code === code);
8
+ }
9
+ async function countFilesRecursive(dir) {
10
+ let entries;
11
+ try {
12
+ entries = await readdir(dir, { withFileTypes: true });
13
+ }
14
+ catch (error) {
15
+ if (isErrnoCode(error, 'ENOENT')) {
16
+ return 0;
17
+ }
18
+ throw error;
19
+ }
20
+ let count = 0;
21
+ for (const entry of entries) {
22
+ count += entry.isDirectory()
23
+ ? await countFilesRecursive(join(dir, entry.name))
24
+ : Number(entry.isFile());
25
+ }
26
+ return count;
27
+ }
28
+ async function isPristineInitRepo(gitDir) {
29
+ try {
30
+ const [objectsCount, refsCount, hasPackedRefs, hasIndex, logsCount, hasAlternates] = await Promise.all([
31
+ countFilesRecursive(join(gitDir, 'objects')),
32
+ countFilesRecursive(join(gitDir, 'refs')),
33
+ pathExistsStrictByStat(join(gitDir, 'packed-refs')),
34
+ pathExistsStrictByStat(join(gitDir, 'index')),
35
+ countFilesRecursive(join(gitDir, 'logs')),
36
+ pathExistsStrictByStat(join(gitDir, 'objects', 'info', 'alternates')),
37
+ ]);
38
+ return (objectsCount === 0 &&
39
+ refsCount === 0 &&
40
+ !hasPackedRefs &&
41
+ !hasIndex &&
42
+ logsCount === 0 &&
43
+ !hasAlternates);
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ async function quarantineVerifyAndDelete(gitDir, specDirectory) {
50
+ const quarantinePath = join(specDirectory, `.git.stray-${String(process.pid)}`);
51
+ try {
52
+ await rename(gitDir, quarantinePath);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ if (await isPristineInitRepo(quarantinePath)) {
58
+ await rm(quarantinePath, { recursive: true, force: true });
59
+ return true;
60
+ }
61
+ await rename(quarantinePath, gitDir).catch(() => undefined);
62
+ return false;
63
+ }
64
+ async function healStraySpecRepos(projectPath) {
65
+ const specsRoot = join(projectPath, 'planu', 'specs');
66
+ let entries;
67
+ try {
68
+ entries = await readdir(specsRoot, { withFileTypes: true });
69
+ }
70
+ catch {
71
+ return { removals: [], failures: [] };
72
+ }
73
+ const removals = [];
74
+ const failures = [];
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory()) {
77
+ continue;
78
+ }
79
+ const specDirectory = join(specsRoot, entry.name);
80
+ const gitDir = join(specDirectory, '.git');
81
+ const gitDirStat = await stat(gitDir).catch(() => undefined);
82
+ if (!gitDirStat?.isDirectory()) {
83
+ continue;
84
+ }
85
+ const relativePath = join('planu', 'specs', entry.name, '.git');
86
+ const isCandidate = await isPristineInitRepo(gitDir);
87
+ const removed = isCandidate && (await quarantineVerifyAndDelete(gitDir, specDirectory));
88
+ if (removed) {
89
+ removals.push(relativePath);
90
+ }
91
+ else {
92
+ failures.push({ specId: entry.name, path: relativePath, code: 'STRAY_REPO_NOT_PRISTINE' });
93
+ }
94
+ }
95
+ return { removals, failures };
96
+ }
97
+ function classifyFailures(failures, indexedSpecIds) {
98
+ const remainingFailures = [];
99
+ const skippedLegacy = [];
100
+ const LEGACY_SKIPPABLE_CODES = new Set([
101
+ 'INVALID_CONTRACT',
102
+ 'INCOMPATIBLE_IDENTITY',
103
+ ]);
104
+ for (const failure of failures) {
105
+ if (indexedSpecIds.has(failure.specId) && LEGACY_SKIPPABLE_CODES.has(failure.code)) {
106
+ skippedLegacy.push({
107
+ ...failure,
108
+ reason: `${failure.specId} is already indexed; the on-disk contract is a historic mismatch left as-is.`,
109
+ });
110
+ continue;
111
+ }
112
+ remainingFailures.push(failure);
113
+ }
114
+ return { failures: remainingFailures, skippedLegacy };
115
+ }
116
+ const NEXT_ACTION_BY_CODE = {
117
+ READ_FAILED: 'fix file permissions or contents so spec.md can be read',
118
+ INVALID_CONTRACT: 'add the required portable frontmatter fields (id, title, type, scope, status, risk, target, difficulty)',
119
+ CREATE_FAILED: 'retry init_project once the underlying store write succeeds',
120
+ INCOMPATIBLE_IDENTITY: 'reconcile the spec identity (id, title, slug, uuid) with the indexed record',
121
+ STRAY_REPO_NOT_PRISTINE: 'inspect the embedded .git manually — it holds refs, staged state, or history and was left untouched',
122
+ };
123
+ /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
124
+ export function describeReconciliationFailures(failures) {
125
+ const counts = new Map();
126
+ for (const failure of failures) {
127
+ counts.set(failure.code, (counts.get(failure.code) ?? 0) + 1);
128
+ }
129
+ const codeSummary = [...counts.entries()]
130
+ .map(([code, count]) => `${String(count)} ${code}`)
131
+ .join(', ');
132
+ const topPaths = failures
133
+ .slice(0, 3)
134
+ .map((failure) => failure.path)
135
+ .join(', ');
136
+ const remainder = failures.length > 3 ? ', …' : '';
137
+ const nextActions = [...counts.keys()].map((code) => NEXT_ACTION_BY_CODE[code]).join('; ');
138
+ return `Portable spec index reconciliation failed for ${String(failures.length)} contract(s) (${codeSummary}). Affected: ${topPaths}${remainder}. Next: ${nextActions}.`;
139
+ }
140
+ /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
141
+ export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy, strayRepoRemovals = []) {
142
+ const base = repositoryFilesChanged.length === 0
143
+ ? 'Project already initialized. Safe update reconciled the portable spec index without changing repository files.'
144
+ : `Project already initialized. Safe update reconciled the portable spec index and refreshed ${String(repositoryFilesChanged.length)} core host asset file(s).`;
145
+ const skippedSuffix = skippedLegacy.length === 0
146
+ ? ''
147
+ : ` Skipped ${String(skippedLegacy.length)} already-indexed legacy contract(s).`;
148
+ const strayRepoSuffix = strayRepoRemovals.length === 0
149
+ ? ''
150
+ : ` Removed ${String(strayRepoRemovals.length)} stray embedded git repositor${strayRepoRemovals.length === 1 ? 'y' : 'ies'} from spec folders.`;
151
+ return base + skippedSuffix + strayRepoSuffix;
152
+ }
153
+ /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
154
+ export function describeReconciliationOutcome(reconciliation, repositoryFilesChanged) {
155
+ return reconciliation.failures.length === 0
156
+ ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy, reconciliation.strayRepoRemovals)
157
+ : describeReconciliationFailures(reconciliation.failures);
158
+ }
3
159
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
4
160
  export async function reconcilePortableSpecIndex(projectPath, projectId, deps = {
5
161
  listSpecs: specStore.listSpecs,
@@ -10,6 +166,15 @@ export async function reconcilePortableSpecIndex(projectPath, projectId, deps =
10
166
  const result = await importFilesystemSpecs(projectPath, deps, projectId, {
11
167
  strictPortableContract: true,
12
168
  });
13
- return { ...result, repositoryFilesChanged: [] };
169
+ const indexedSpecIds = new Set((await deps.listSpecs(projectId)).map((spec) => spec.id));
170
+ const { failures, skippedLegacy } = classifyFailures(result.failures, indexedSpecIds);
171
+ const { removals: strayRepoRemovals, failures: strayRepoFailures } = await healStraySpecRepos(projectPath);
172
+ return {
173
+ ...result,
174
+ failures: [...failures, ...strayRepoFailures],
175
+ skippedLegacy,
176
+ repositoryFilesChanged: [],
177
+ strayRepoRemovals,
178
+ };
14
179
  }
15
180
  //# sourceMappingURL=portable-index-reconciler.js.map
@@ -215,7 +215,7 @@ export interface SpecMigrationDeps {
215
215
  listSpecs: (projectId: string) => Promise<Spec[]>;
216
216
  updateSpec: (projectId: string, specId: string, updates: Partial<Spec>) => Promise<Spec>;
217
217
  }
218
- export type FilesystemImportFailureCode = 'READ_FAILED' | 'INVALID_CONTRACT' | 'CREATE_FAILED' | 'INCOMPATIBLE_IDENTITY';
218
+ export type FilesystemImportFailureCode = 'READ_FAILED' | 'INVALID_CONTRACT' | 'CREATE_FAILED' | 'INCOMPATIBLE_IDENTITY' | 'STRAY_REPO_NOT_PRISTINE';
219
219
  export interface FilesystemImportFailure {
220
220
  specId: string;
221
221
  path: string;
@@ -233,8 +233,16 @@ 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[];
245
+ strayRepoRemovals: string[];
238
246
  }
239
247
  export interface ImportEntryContext {
240
248
  projectPath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.4.1",
3
+ "version": "5.5.3",
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.1",
5
+ "version": "5.5.3",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",