@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 +35 -0
- package/dist/.planu-build.json +1 -1
- package/dist/engine/git/git-command-guard.d.ts +2 -0
- package/dist/engine/git/git-command-guard.js +43 -0
- package/dist/engine/git/planu-autocommit.js +2 -0
- package/dist/engine/handoff-packager.js +38 -2
- package/dist/tools/git/git-helpers.js +2 -0
- package/dist/tools/init-project/handler.js +1 -0
- package/dist/tools/init-project/portable-index-reconciler.d.ts +1 -1
- package/dist/tools/init-project/portable-index-reconciler.js +109 -4
- package/dist/types/spec/inputs.d.ts +2 -1
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
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
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
1
|
+
{"schemaVersion":1,"commit":"5060255598e14cab792b6b5b0d75a077f23fe018"}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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.
|
|
5
|
+
"version": "5.5.3",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|