@planu/cli 5.5.0 → 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,38 @@
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
+
1
36
  ## [5.5.0] - 2026-08-29
2
37
 
3
38
  ### Features
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"2c3e47edf445a0c8422cbeec33a6a929edd764b5"}
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),
@@ -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,
@@ -233,6 +233,7 @@ export async function handleInitProject(params, server) {
233
233
  importedSpecIds: reconciliation.importedSpecIds,
234
234
  failures: reconciliation.failures,
235
235
  skippedLegacy: reconciliation.skippedLegacy,
236
+ strayRepoRemovals: reconciliation.strayRepoRemovals,
236
237
  },
237
238
  ...(reconciliation.failures.length > 0 ? { isError: true } : {}),
238
239
  };
@@ -2,7 +2,7 @@ import type { FilesystemImportDeps, FilesystemImportFailure, PortableIndexReconc
2
2
  /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
3
3
  export declare function describeReconciliationFailures(failures: FilesystemImportFailure[]): string;
4
4
  /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
5
- export declare function describeReconciliationSuccess(repositoryFilesChanged: string[], skippedLegacy: SkippedLegacyContract[]): string;
5
+ export declare function describeReconciliationSuccess(repositoryFilesChanged: string[], skippedLegacy: SkippedLegacyContract[], strayRepoRemovals?: string[]): string;
6
6
  /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
7
7
  export declare function describeReconciliationOutcome(reconciliation: PortableIndexReconciliationResult, repositoryFilesChanged: string[]): string;
8
8
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
@@ -1,5 +1,99 @@
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
+ }
3
97
  function classifyFailures(failures, indexedSpecIds) {
4
98
  const remainingFailures = [];
5
99
  const skippedLegacy = [];
@@ -24,6 +118,7 @@ const NEXT_ACTION_BY_CODE = {
24
118
  INVALID_CONTRACT: 'add the required portable frontmatter fields (id, title, type, scope, status, risk, target, difficulty)',
25
119
  CREATE_FAILED: 'retry init_project once the underlying store write succeeds',
26
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',
27
122
  };
28
123
  /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
29
124
  export function describeReconciliationFailures(failures) {
@@ -43,19 +138,22 @@ export function describeReconciliationFailures(failures) {
43
138
  return `Portable spec index reconciliation failed for ${String(failures.length)} contract(s) (${codeSummary}). Affected: ${topPaths}${remainder}. Next: ${nextActions}.`;
44
139
  }
45
140
  /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
46
- export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy) {
141
+ export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy, strayRepoRemovals = []) {
47
142
  const base = repositoryFilesChanged.length === 0
48
143
  ? 'Project already initialized. Safe update reconciled the portable spec index without changing repository files.'
49
144
  : `Project already initialized. Safe update reconciled the portable spec index and refreshed ${String(repositoryFilesChanged.length)} core host asset file(s).`;
50
145
  const skippedSuffix = skippedLegacy.length === 0
51
146
  ? ''
52
147
  : ` Skipped ${String(skippedLegacy.length)} already-indexed legacy contract(s).`;
53
- return base + skippedSuffix;
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;
54
152
  }
55
153
  /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
56
154
  export function describeReconciliationOutcome(reconciliation, repositoryFilesChanged) {
57
155
  return reconciliation.failures.length === 0
58
- ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy)
156
+ ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy, reconciliation.strayRepoRemovals)
59
157
  : describeReconciliationFailures(reconciliation.failures);
60
158
  }
61
159
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
@@ -70,6 +168,13 @@ export async function reconcilePortableSpecIndex(projectPath, projectId, deps =
70
168
  });
71
169
  const indexedSpecIds = new Set((await deps.listSpecs(projectId)).map((spec) => spec.id));
72
170
  const { failures, skippedLegacy } = classifyFailures(result.failures, indexedSpecIds);
73
- return { ...result, failures, skippedLegacy, repositoryFilesChanged: [] };
171
+ const { removals: strayRepoRemovals, failures: strayRepoFailures } = await healStraySpecRepos(projectPath);
172
+ return {
173
+ ...result,
174
+ failures: [...failures, ...strayRepoFailures],
175
+ skippedLegacy,
176
+ repositoryFilesChanged: [],
177
+ strayRepoRemovals,
178
+ };
74
179
  }
75
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;
@@ -242,6 +242,7 @@ export interface SkippedLegacyContract {
242
242
  export interface PortableIndexReconciliationResult extends FilesystemImportResult {
243
243
  repositoryFilesChanged: string[];
244
244
  skippedLegacy: SkippedLegacyContract[];
245
+ strayRepoRemovals: string[];
245
246
  }
246
247
  export interface ImportEntryContext {
247
248
  projectPath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.5.0",
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",
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.5.0",
5
+ "version": "5.5.3",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",