@planu/cli 4.4.0 → 4.4.1

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,13 @@
1
+ ## [4.4.1] - 2026-06-04
2
+
3
+ ### Bug Fixes
4
+ - fix: make validate spec-scoped and non-mutating
5
+
6
+ ### Chores
7
+ - chore(deps): sync lockfile
8
+ - chore(deps): update patch and minor dependencies
9
+
10
+
1
11
  ## [4.4.0] - 2026-06-03
2
12
 
3
13
  ### Features
@@ -50,13 +50,18 @@ const ContractValidationSchema = z.object({
50
50
  summary: z.string().optional(),
51
51
  });
52
52
  function resolveSpecPath(spec, projectPath) {
53
- if (isAbsolute(spec.specPath) || !projectPath) {
54
- return spec.specPath;
53
+ const specPath = spec.specPath;
54
+ if (!specPath?.trim()) {
55
+ return null;
55
56
  }
56
- return join(projectPath, spec.specPath);
57
+ if (isAbsolute(specPath) || !projectPath) {
58
+ return specPath;
59
+ }
60
+ return join(projectPath, specPath);
57
61
  }
58
62
  function specEvidencePath(spec, filename, projectPath) {
59
- return join(dirname(resolveSpecPath(spec, projectPath)), 'evidence', filename);
63
+ const specPath = resolveSpecPath(spec, projectPath);
64
+ return specPath ? join(dirname(specPath), 'evidence', filename) : null;
60
65
  }
61
66
  function handoffEvidencePath(projectId, specId, filename) {
62
67
  return join(projectDataDir(projectId), 'handoffs', specId, filename);
@@ -81,7 +86,7 @@ async function readUnknown(paths) {
81
86
  }
82
87
  async function readOptional(args) {
83
88
  try {
84
- const found = await readUnknown(args.paths);
89
+ const found = await readUnknown(args.paths.filter((path) => Boolean(path)));
85
90
  if (!found) {
86
91
  return undefined;
87
92
  }
@@ -3,6 +3,7 @@ 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
5
  export declare function isCanonicalSpecFile(name: string): boolean;
6
+ export declare function isCanonicalSpecDir(name: string): boolean;
6
7
  export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
7
8
  export declare function isCanonicalReleaseFile(relativeToPlanu: string): boolean;
8
9
  export declare function canonicalContractText(): string;
@@ -4,6 +4,7 @@ export const PLANU_CANONICAL_POLICY = {
4
4
  canonicalRootFiles: ['conventions.json', 'context.md', 'session-context.md', 'session.json'],
5
5
  canonicalRootDirs: ['releases', 'specs'],
6
6
  canonicalSpecFiles: ['spec.md'],
7
+ canonicalSpecDirs: ['evidence'],
7
8
  forbiddenHostAssetRootDirs: ['agents', 'skills', 'rules', 'hooks'],
8
9
  generatedRuntimePatterns: [
9
10
  'planu/index.html',
@@ -38,6 +39,7 @@ export const PLANU_CANONICAL_POLICY = {
38
39
  const ROOT_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootFiles);
39
40
  const ROOT_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootDirs);
40
41
  const SPEC_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecFiles);
42
+ const SPEC_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecDirs);
41
43
  const LEGACY_MERGE_SET = new Set(PLANU_CANONICAL_POLICY.legacyMergeBeforeDeleteFiles);
42
44
  export function isCanonicalPlanuRootFile(name) {
43
45
  return ROOT_FILE_SET.has(name);
@@ -48,6 +50,9 @@ export function isCanonicalPlanuRootDir(name) {
48
50
  export function isCanonicalSpecFile(name) {
49
51
  return SPEC_FILE_SET.has(name);
50
52
  }
53
+ export function isCanonicalSpecDir(name) {
54
+ return SPEC_DIR_SET.has(name);
55
+ }
51
56
  export function mustMergeBeforeDeleteSpecFile(name) {
52
57
  return LEGACY_MERGE_SET.has(name);
53
58
  }
@@ -66,6 +71,8 @@ export function canonicalContractText() {
66
71
  ' specs/',
67
72
  ' SPEC-XXX-slug/',
68
73
  ' spec.md',
74
+ ' evidence/',
75
+ ' *.json',
69
76
  '',
70
77
  'Host adapters are written outside planu/:',
71
78
  ' Claude Code: .claude/agents, .claude/skills, .claude/rules',
@@ -1,6 +1,6 @@
1
- import type { StrictPlanuCleanupResult, StrictPlanuValidationResult } from '../../types/index.js';
1
+ import type { StrictPlanuCleanupResult, StrictPlanuValidationOptions, StrictPlanuValidationResult } from '../../types/index.js';
2
2
  import { PLANU_CANONICAL_POLICY } from './planu-canonical-policy.js';
3
3
  export declare function runStrictPlanuCleanup(projectPath: string): Promise<StrictPlanuCleanupResult>;
4
- export declare function validateStrictPlanuLayout(projectPath: string): Promise<StrictPlanuValidationResult>;
4
+ export declare function validateStrictPlanuLayout(projectPath: string, options?: StrictPlanuValidationOptions): Promise<StrictPlanuValidationResult>;
5
5
  export { PLANU_CANONICAL_POLICY };
6
6
  //# sourceMappingURL=strict-planu-cleanup.d.ts.map
@@ -4,10 +4,10 @@ import { readdir, readFile, rm, stat } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
- import { join, relative } from 'node:path';
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
- import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
10
+ import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  async function pathIsDirectory(path) {
13
13
  try {
@@ -125,7 +125,9 @@ async function walkSpecDirectory(projectPath, specDir, result) {
125
125
  }
126
126
  continue;
127
127
  }
128
- if (entry === 'reference' || !isCanonicalSpecFile(entry)) {
128
+ const isDir = await pathIsDirectory(full);
129
+ if (entry === 'reference' ||
130
+ (isDir ? !isCanonicalSpecDir(entry) : !isCanonicalSpecFile(entry))) {
129
131
  await removePath(projectPath, full);
130
132
  result.deleted.push(relative(projectPath, full));
131
133
  }
@@ -173,33 +175,46 @@ export async function runStrictPlanuCleanup(projectPath) {
173
175
  result.gitignoreUpdated = await updateGitignore(projectPath);
174
176
  return result;
175
177
  }
176
- export async function validateStrictPlanuLayout(projectPath) {
178
+ function resolveSpecDirsForValidation(projectPath, specPath) {
179
+ if (!specPath?.trim()) {
180
+ return null;
181
+ }
182
+ const resolved = isAbsolute(specPath) ? specPath : join(projectPath, specPath);
183
+ return [dirname(resolved)];
184
+ }
185
+ export async function validateStrictPlanuLayout(projectPath, options = {}) {
177
186
  const offenders = [];
178
187
  const planuDir = join(projectPath, 'planu');
179
- const entries = await readdir(planuDir).catch(() => []);
180
- for (const entry of entries) {
181
- const full = join(planuDir, entry);
182
- const isDir = await pathIsDirectory(full);
183
- if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
184
- (!isDir && !isCanonicalPlanuRootFile(entry))) {
185
- offenders.push(relative(projectPath, full));
188
+ if (options.includeRoot !== false) {
189
+ const entries = await readdir(planuDir).catch(() => []);
190
+ for (const entry of entries) {
191
+ const full = join(planuDir, entry);
192
+ const isDir = await pathIsDirectory(full);
193
+ if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
194
+ (!isDir && !isCanonicalPlanuRootFile(entry))) {
195
+ offenders.push(relative(projectPath, full));
196
+ }
186
197
  }
187
- }
188
- for (const entry of await readdir(join(planuDir, 'releases')).catch(() => [])) {
189
- const rel = `releases/${entry}`;
190
- if (!isCanonicalReleaseFile(rel)) {
191
- offenders.push(`planu/${rel}`);
198
+ for (const entry of await readdir(join(planuDir, 'releases')).catch(() => [])) {
199
+ const rel = `releases/${entry}`;
200
+ if (!isCanonicalReleaseFile(rel)) {
201
+ offenders.push(`planu/${rel}`);
202
+ }
192
203
  }
193
204
  }
194
- for (const specDir of await readdir(join(planuDir, 'specs')).catch(() => [])) {
195
- const full = join(planuDir, 'specs', specDir);
205
+ const scopedSpecDirs = resolveSpecDirsForValidation(projectPath, options.specPath) ??
206
+ (await readdir(join(planuDir, 'specs')).catch(() => [])).map((specDir) => join(planuDir, 'specs', specDir));
207
+ for (const full of scopedSpecDirs) {
208
+ const specDir = relative(join(planuDir, 'specs'), full);
196
209
  if (!(await pathIsDirectory(full)) || specDir === 'data') {
197
210
  offenders.push(relative(projectPath, full));
198
211
  continue;
199
212
  }
200
213
  for (const entry of await readdir(full).catch(() => [])) {
201
- if (!isCanonicalSpecFile(entry)) {
202
- offenders.push(relative(projectPath, join(full, entry)));
214
+ const entryPath = join(full, entry);
215
+ const isDir = await pathIsDirectory(entryPath);
216
+ if (isDir ? !isCanonicalSpecDir(entry) : !isCanonicalSpecFile(entry)) {
217
+ offenders.push(relative(projectPath, entryPath));
203
218
  }
204
219
  }
205
220
  }
@@ -73,9 +73,11 @@ export async function handleValidate(args, server) {
73
73
  const projectPath = knowledge.projectPath;
74
74
  // SPEC-1017: fail closed when planu/ contains non-canonical artifacts.
75
75
  try {
76
- const { runStrictPlanuCleanup, validateStrictPlanuLayout } = await import('../engine/spec-migrator/index.js');
77
- await runStrictPlanuCleanup(projectPath);
78
- const layout = await validateStrictPlanuLayout(projectPath);
76
+ const { validateStrictPlanuLayout } = await import('../engine/spec-migrator/index.js');
77
+ const layout = await validateStrictPlanuLayout(projectPath, {
78
+ specPath: spec.specPath,
79
+ includeRoot: false,
80
+ });
79
81
  if (!layout.ok) {
80
82
  return {
81
83
  content: [
@@ -54,6 +54,7 @@ export interface PlanuCanonicalPathPolicy {
54
54
  readonly canonicalRootFiles: readonly string[];
55
55
  readonly canonicalRootDirs: readonly string[];
56
56
  readonly canonicalSpecFiles: readonly string[];
57
+ readonly canonicalSpecDirs: readonly string[];
57
58
  readonly forbiddenHostAssetRootDirs: readonly string[];
58
59
  readonly generatedRuntimePatterns: readonly string[];
59
60
  readonly legacyMergeBeforeDeleteFiles: readonly string[];
@@ -68,6 +69,10 @@ export interface StrictPlanuValidationResult {
68
69
  offenders: string[];
69
70
  contract: string;
70
71
  }
72
+ export interface StrictPlanuValidationOptions {
73
+ specPath?: string;
74
+ includeRoot?: boolean;
75
+ }
71
76
  export interface UpdateStatusBatchInput {
72
77
  specIds: string[];
73
78
  status: SpecStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.4.0",
3
+ "version": "4.4.1",
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.4.0",
38
- "@planu/core-darwin-x64": "4.4.0",
39
- "@planu/core-linux-arm64-gnu": "4.4.0",
40
- "@planu/core-linux-arm64-musl": "4.4.0",
41
- "@planu/core-linux-x64-gnu": "4.4.0",
42
- "@planu/core-linux-x64-musl": "4.4.0",
43
- "@planu/core-win32-arm64-msvc": "4.4.0",
44
- "@planu/core-win32-x64-msvc": "4.4.0"
37
+ "@planu/core-darwin-arm64": "4.4.1",
38
+ "@planu/core-darwin-x64": "4.4.1",
39
+ "@planu/core-linux-arm64-gnu": "4.4.1",
40
+ "@planu/core-linux-arm64-musl": "4.4.1",
41
+ "@planu/core-linux-x64-gnu": "4.4.1",
42
+ "@planu/core-linux-x64-musl": "4.4.1",
43
+ "@planu/core-win32-arm64-msvc": "4.4.1",
44
+ "@planu/core-win32-x64-msvc": "4.4.1"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
@@ -185,12 +185,12 @@
185
185
  "@types/node": "^25.9.1",
186
186
  "@vitejs/plugin-vue": "^6.0.7",
187
187
  "@vitest/coverage-v8": "^4.1.8",
188
- "@vue/test-utils": "^2.4.10",
188
+ "@vue/test-utils": "^2.4.11",
189
189
  "eslint": "^10.4.1",
190
190
  "eslint-config-prettier": "^10.1.8",
191
191
  "eslint-import-resolver-typescript": "^4.4.5",
192
192
  "eslint-plugin-import": "^2.32.0",
193
- "happy-dom": "^20.9.0",
193
+ "happy-dom": "^20.10.1",
194
194
  "husky": "^9.1.7",
195
195
  "javascript-obfuscator": "^5.4.3",
196
196
  "knip": "^6.15.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.4.0",
4
+ "version": "4.4.1",
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.4.0",
5
+ "version": "4.4.1",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": [
8
8
  "npx",