@planu/cli 5.7.2 → 5.7.4

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,21 @@
1
+ ## [5.7.4] - 2026-08-31
2
+
3
+ ### Bug Fixes
4
+ - fix(cleanup): run runtime hygiene in report mode and exempt .runtime from offender scans
5
+ - fix(cleanup): ignore planu/.runtime and untrack leaked runtime files in strict cleanup
6
+
7
+ ### Chores
8
+ - chore(runtime): untrack leaked planu runtime state and pin repo-local ignores
9
+
10
+
11
+ ## [5.7.3] - 2026-08-31
12
+
13
+ ### Bug Fixes
14
+ - fix(release): validate ledger shape before abort version derivation
15
+ - fix(release): fail-closed recovery ledger writer and tolerant abort for corrupted releaseVersion
16
+ - fix(build): report real git failure in provenance stamp and retry transient errors
17
+
18
+
1
19
  ## [5.7.2] - 2026-08-31
2
20
 
3
21
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"a6e3e4beee6e286f9f7e2e336c4eb14beafe141c"}
1
+ {"schemaVersion":1,"commit":"bae55ef85d50f56c6406a9a52291bdff9fcca4e3"}
@@ -3,7 +3,7 @@ import { readdir, stat, access, readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { isOldFormat } from './lean-migration.js';
5
5
  import { fastScanSpecsAsync } from '../core-bridge.js';
6
- import { isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalSpecFile, } from './planu-canonical-policy.js';
6
+ import { isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalSpecFile, isRuntimeHomeRootDir, } from './planu-canonical-policy.js';
7
7
  async function scanPlanuRoot(projectPath) {
8
8
  const rootFiles = [];
9
9
  const planuDir = join(projectPath, 'planu');
@@ -14,7 +14,7 @@ async function scanPlanuRoot(projectPath) {
14
14
  try {
15
15
  const info = await stat(fullPath);
16
16
  if (info.isDirectory()) {
17
- if (!isCanonicalPlanuRootDir(entry)) {
17
+ if (!isCanonicalPlanuRootDir(entry) && !isRuntimeHomeRootDir(entry)) {
18
18
  rootFiles.push(entry);
19
19
  }
20
20
  }
@@ -2,6 +2,7 @@ import type { PlanuCanonicalPathPolicy } from '../../types/index.js';
2
2
  export declare const PLANU_CANONICAL_POLICY: PlanuCanonicalPathPolicy;
3
3
  export declare function isCanonicalPlanuRootFile(name: string): boolean;
4
4
  export declare function isCanonicalPlanuRootDir(name: string): boolean;
5
+ export declare function isRuntimeHomeRootDir(name: string): boolean;
5
6
  export declare function isCanonicalSpecFile(name: string): boolean;
6
7
  export declare function isCanonicalSpecDir(name: string): boolean;
7
8
  export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
@@ -48,12 +48,18 @@ const ROOT_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootDirs);
48
48
  const SPEC_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecFiles);
49
49
  const SPEC_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecDirs);
50
50
  const LEGACY_MERGE_SET = new Set(PLANU_CANONICAL_POLICY.legacyMergeBeforeDeleteFiles);
51
+ const RUNTIME_HOME_TOP_DIR = PLANU_CANONICAL_POLICY.runtimeHomeDir
52
+ .replace(/^planu\//, '')
53
+ .replace(/\/$/, '');
51
54
  export function isCanonicalPlanuRootFile(name) {
52
55
  return ROOT_FILE_SET.has(name);
53
56
  }
54
57
  export function isCanonicalPlanuRootDir(name) {
55
58
  return ROOT_DIR_SET.has(name);
56
59
  }
60
+ export function isRuntimeHomeRootDir(name) {
61
+ return name === RUNTIME_HOME_TOP_DIR;
62
+ }
57
63
  export function isCanonicalSpecFile(name) {
58
64
  return SPEC_FILE_SET.has(name);
59
65
  }
@@ -8,7 +8,7 @@ import { dirname, isAbsolute, join, relative } from 'node:path';
8
8
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
9
9
  import { safeUnlink } from './git-aware-fs.js';
10
10
  import { scanLegacyArtifacts } from './legacy-classifier.js';
11
- import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isLegacyReleasePendingFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
11
+ import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isLegacyReleasePendingFile, isCanonicalSpecDir, isCanonicalSpecFile, isRuntimeHomeRootDir, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
12
12
  const execFileAsync = promisify(execFile);
13
13
  // SPEC-1699: legacy planu/ root files folded into project.json by init_project's
14
14
  // migration. Destructive cleanup must never delete them outright — only the
@@ -44,7 +44,6 @@ async function gitRmCached(projectPath, relPath) {
44
44
  return;
45
45
  }
46
46
  try {
47
- // This helper is only called for directories from removePath.
48
47
  const args = ['rm', '--cached', '--quiet', '--ignore-unmatch', '-r', relPath];
49
48
  await execFileAsync('git', args, {
50
49
  cwd: projectPath,
@@ -82,6 +81,7 @@ function buildPlanuIgnoreBlock() {
82
81
  'planu/data/',
83
82
  'planu/state/',
84
83
  'planu/.locks/',
84
+ 'planu/.runtime/',
85
85
  'planu/specs/**',
86
86
  '!planu/specs/**/',
87
87
  '!planu/specs/**/spec.md',
@@ -163,16 +163,23 @@ async function shouldDeleteOrphanSpecDir(specDir) {
163
163
  // Keeping them without spec.md leaves a permanently non-canonical orphan behind.
164
164
  return !entries.includes('spec.md');
165
165
  }
166
+ async function runReportModeCleanup(projectPath) {
167
+ const validation = await validateStrictPlanuLayout(projectPath);
168
+ const planuExists = existsSync(join(projectPath, 'planu'));
169
+ const gitignoreUpdated = planuExists ? await updateGitignore(projectPath) : false;
170
+ const untracked = planuExists ? await untrackRuntimeFiles(projectPath) : [];
171
+ return {
172
+ mode: 'report',
173
+ deleted: [],
174
+ merged: [],
175
+ proposed: validation.offenders,
176
+ gitignoreUpdated,
177
+ untracked,
178
+ };
179
+ }
166
180
  export async function runStrictPlanuCleanup(projectPath, authority = { mode: 'report' }) {
167
181
  if (authority.mode === 'report') {
168
- const validation = await validateStrictPlanuLayout(projectPath);
169
- return {
170
- mode: 'report',
171
- deleted: [],
172
- merged: [],
173
- proposed: validation.offenders,
174
- gitignoreUpdated: false,
175
- };
182
+ return runReportModeCleanup(projectPath);
176
183
  }
177
184
  const result = {
178
185
  mode: 'execute',
@@ -180,6 +187,7 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
180
187
  merged: [],
181
188
  proposed: [],
182
189
  gitignoreUpdated: false,
190
+ untracked: [],
183
191
  };
184
192
  const planuDir = join(projectPath, 'planu');
185
193
  if (!existsSync(planuDir)) {
@@ -189,10 +197,7 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
189
197
  for (const entry of entries) {
190
198
  const full = join(planuDir, entry);
191
199
  const isDir = await pathIsDirectory(full);
192
- if (isDir && entry === 'releases') {
193
- // SPEC-1699: 'releases' is no longer a canonical root dir, but its
194
- // pending.json still needs the dedicated fold-into-project.json
195
- // migration below before its directory can be removed.
200
+ if (isDir && (entry === 'releases' || isRuntimeHomeRootDir(entry))) {
196
201
  continue;
197
202
  }
198
203
  if (isDir && !isCanonicalPlanuRootDir(entry)) {
@@ -236,8 +241,20 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
236
241
  await walkSpecDirectory(projectPath, full, result);
237
242
  }
238
243
  result.gitignoreUpdated = await updateGitignore(projectPath);
244
+ result.untracked = await untrackRuntimeFiles(projectPath);
239
245
  return result;
240
246
  }
247
+ async function untrackRuntimeFiles(projectPath) {
248
+ const trackedRuntimeFiles = await listTrackedFiles(projectPath, 'planu/.runtime');
249
+ if (trackedRuntimeFiles.length === 0) {
250
+ return trackedRuntimeFiles;
251
+ }
252
+ for (const relPath of trackedRuntimeFiles) {
253
+ await gitRmCached(projectPath, relPath);
254
+ }
255
+ const stillTracked = new Set(await listTrackedFiles(projectPath, 'planu/.runtime'));
256
+ return trackedRuntimeFiles.filter((relPath) => !stillTracked.has(relPath));
257
+ }
241
258
  function resolveSpecDirsForValidation(projectPath, specPath) {
242
259
  if (!specPath?.trim()) {
243
260
  return null;
@@ -253,6 +270,9 @@ export async function validateStrictPlanuLayout(projectPath, options = {}) {
253
270
  for (const entry of entries) {
254
271
  const full = join(planuDir, entry);
255
272
  const isDir = await pathIsDirectory(full);
273
+ if (isDir && isRuntimeHomeRootDir(entry)) {
274
+ continue;
275
+ }
256
276
  if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
257
277
  (!isDir && !isCanonicalPlanuRootFile(entry))) {
258
278
  offenders.push(relative(projectPath, full));
@@ -82,6 +82,7 @@ export interface StrictPlanuCleanupResult {
82
82
  merged: string[];
83
83
  proposed: string[];
84
84
  gitignoreUpdated: boolean;
85
+ untracked: string[];
85
86
  }
86
87
  export interface StrictPlanuValidationResult {
87
88
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.7.2",
3
+ "version": "5.7.4",
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",
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.7.2",
5
+ "version": "5.7.4",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",