@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 +50 -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/storage/project-identity.js +49 -18
- package/dist/tools/git/git-helpers.js +2 -0
- package/dist/tools/init-project/handler.js +7 -5
- package/dist/tools/init-project/host-assets-writer.d.ts +13 -0
- package/dist/tools/init-project/host-assets-writer.js +51 -7
- package/dist/tools/init-project/portable-index-reconciler.d.ts +7 -1
- package/dist/tools/init-project/portable-index-reconciler.js +166 -1
- package/dist/types/spec/inputs.d.ts +9 -1
- package/package.json +3 -3
- package/planu-plugin.json +1 -1
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
|
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),
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
|
248
|
+
async function verifyCanonicalRoot(root) {
|
|
230
249
|
if (isEphemeralProject(root)) {
|
|
231
|
-
return
|
|
250
|
+
return undefined;
|
|
232
251
|
}
|
|
233
252
|
const logicalProjectId = await readLogicalProjectId(root);
|
|
234
253
|
if (logicalProjectId === undefined) {
|
|
235
|
-
return
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
5
|
+
"version": "5.5.3",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|