@planu/cli 5.4.0 → 5.5.0
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 +22 -0
- package/dist/.planu-build.json +1 -1
- package/dist/engine/project-graph/builder.js +5 -2
- package/dist/engine/project-graph/query.js +2 -1
- package/dist/engine/scope-boundaries/contradiction-checker.js +23 -2
- package/dist/storage/project-identity.js +49 -18
- package/dist/tools/init-project/handler.js +6 -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 +61 -1
- package/dist/types/spec/inputs.d.ts +7 -0
- package/package.json +3 -3
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## [5.5.0] - 2026-08-29
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- feat(SPEC-1681): cache the prettier format gate with content strategy
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
- fix(SPEC-1684): cap vitest workers at 6 in preflight related and full-suite runs
|
|
8
|
+
- fix(storage): handle ENOENT explicitly in best-effort path normalization
|
|
9
|
+
- fix(SPEC-1677,SPEC-1678): dedupe AGENTS.md changes and bound legacy skips to contract codes
|
|
10
|
+
- fix(SPEC-1678): classify legacy contract failures as non-fatal skipped entries
|
|
11
|
+
- fix(SPEC-1677): install and refresh planu core host assets on safe update
|
|
12
|
+
- fix(SPEC-1679): normalize both roots in canonical re-check to match heal semantics
|
|
13
|
+
- fix(SPEC-1679): self-heal missing registry logicalProjectId in canonical-root gate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [5.4.1] - 2026-08-29
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
- fix(SPEC-1675): strip FILES/FUNCTIONS/TEST metadata markers from contradiction analysis
|
|
20
|
+
- fix(SPEC-1676): rebuild expired project knowledge graph and re-stamp cached reuse
|
|
21
|
+
|
|
22
|
+
|
|
1
23
|
## [5.4.0] - 2026-08-29
|
|
2
24
|
|
|
3
25
|
### Features
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
1
|
+
{"schemaVersion":1,"commit":"2c3e47edf445a0c8422cbeec33a6a929edd764b5"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { collectProjectGraphSources, diffSourceHashes, hashText, hydrateProjectGraphSource, loadProjectGraphPolicy, projectGraphCachePath, projectGraphExtractorVersion, projectGraphPath, readProjectGraphArtifact, readProjectGraphCache, withProjectGraphBuildLock, writeProjectGraphArtifacts, } from './cache.js';
|
|
2
|
+
import { collectProjectGraphSources, diffSourceHashes, hashText, hydrateProjectGraphSource, loadProjectGraphPolicy, projectGraphCachePath, projectGraphExtractorVersion, projectGraphPath, readProjectGraphArtifact, readProjectGraphCache, withProjectGraphBuildLock, writeProjectGraph, writeProjectGraphArtifacts, } from './cache.js';
|
|
3
3
|
import { extractGitGraph, extractReleaseGraph } from './extractors/git-extractor.js';
|
|
4
4
|
import { extractHandoffGraph } from './extractors/handoff-extractor.js';
|
|
5
5
|
import { extractDecisionStoreGraph } from './extractors/decision-store-extractor.js';
|
|
@@ -210,13 +210,16 @@ async function buildProjectKnowledgeGraphUnlocked(args) {
|
|
|
210
210
|
Array.isArray(existingGraph.nodes) &&
|
|
211
211
|
Array.isArray(existingGraph.edges);
|
|
212
212
|
if (hasConsistentExistingGraph && diff.changed.length === 0 && diff.removed.length === 0) {
|
|
213
|
+
const refreshedGeneratedAt = new Date().toISOString();
|
|
213
214
|
const graphWithCurrentOversizedSources = {
|
|
214
215
|
...existingGraph,
|
|
216
|
+
generatedAt: refreshedGeneratedAt,
|
|
215
217
|
oversizedSources,
|
|
216
218
|
};
|
|
219
|
+
await writeProjectGraph(args.projectId, args.policy, graphWithCurrentOversizedSources);
|
|
217
220
|
publishFreshProjectGraphProjection({
|
|
218
221
|
projectId: args.projectId,
|
|
219
|
-
generatedAt:
|
|
222
|
+
generatedAt: refreshedGeneratedAt,
|
|
220
223
|
generation: existingGraph.generation,
|
|
221
224
|
});
|
|
222
225
|
return {
|
|
@@ -55,7 +55,8 @@ async function ensureFreshProjectGraph(args) {
|
|
|
55
55
|
const freshness = await getProjectGraphFreshness(args);
|
|
56
56
|
if (!freshness.exists ||
|
|
57
57
|
freshness.reason === 'source_changed' ||
|
|
58
|
-
freshness.reason === 'corrupt'
|
|
58
|
+
freshness.reason === 'corrupt' ||
|
|
59
|
+
freshness.reason === 'expired') {
|
|
59
60
|
await buildProjectKnowledgeGraph(args);
|
|
60
61
|
return getProjectGraphFreshness(args);
|
|
61
62
|
}
|
|
@@ -203,14 +203,35 @@ function assertsActionNearScope(clause, outOfScopeItem) {
|
|
|
203
203
|
}
|
|
204
204
|
return false;
|
|
205
205
|
}
|
|
206
|
+
const METADATA_MARKER_KEYWORD = /\b(?:FILES?|FUNCTIONS?|TESTS?)\s*:/gi;
|
|
207
|
+
const SENTENCE_BOUNDARY_AFTER_MARKER = /\.\s+(?=[A-Z])/;
|
|
208
|
+
function stripMetadataMarkers(text) {
|
|
209
|
+
const markers = [...text.matchAll(METADATA_MARKER_KEYWORD)];
|
|
210
|
+
if (markers.length === 0) {
|
|
211
|
+
return text;
|
|
212
|
+
}
|
|
213
|
+
let result = '';
|
|
214
|
+
let cursor = 0;
|
|
215
|
+
for (let i = 0; i < markers.length; i++) {
|
|
216
|
+
const start = markers[i]?.index ?? 0;
|
|
217
|
+
result += text.slice(cursor, start);
|
|
218
|
+
const nextMarkerStart = markers[i + 1]?.index ?? text.length;
|
|
219
|
+
const searchRegion = text.slice(start, nextMarkerStart);
|
|
220
|
+
const boundaryMatch = SENTENCE_BOUNDARY_AFTER_MARKER.exec(searchRegion);
|
|
221
|
+
cursor = boundaryMatch ? start + boundaryMatch.index + 1 : nextMarkerStart;
|
|
222
|
+
}
|
|
223
|
+
result += text.slice(cursor);
|
|
224
|
+
return result.replace(/\s+/g, ' ').trim();
|
|
225
|
+
}
|
|
206
226
|
/**
|
|
207
227
|
* Determine whether a criterion text contradicts an out-of-scope item.
|
|
208
228
|
* Uses substring match first, then keyword overlap as fuzzy fallback.
|
|
209
229
|
*/
|
|
210
230
|
function contradicts(criterionText, outOfScopeItem) {
|
|
211
|
-
const
|
|
231
|
+
const strippedCriterionText = stripMetadataMarkers(criterionText);
|
|
232
|
+
const normCriterion = normalize(strippedCriterionText);
|
|
212
233
|
const normScope = normalize(outOfScopeItem);
|
|
213
|
-
const clauses = splitClauses(
|
|
234
|
+
const clauses = splitClauses(strippedCriterionText);
|
|
214
235
|
const relevantClauses = clauses.filter((clause) => clauseMentionsScope(clause, outOfScopeItem));
|
|
215
236
|
const hasConditionalException = /\b(?:unless|except)\b/i.test(criterionText);
|
|
216
237
|
if (!hasConditionalException &&
|
|
@@ -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
|
}
|
|
@@ -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,23 @@ 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,
|
|
235
236
|
},
|
|
236
237
|
...(reconciliation.failures.length > 0 ? { isError: true } : {}),
|
|
237
238
|
};
|
|
@@ -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[]): 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,63 @@
|
|
|
1
1
|
import { globalStore, specStore } from '../../storage/index.js';
|
|
2
2
|
import { importFilesystemSpecs } from '../../engine/spec-migrator/filesystem-import.js';
|
|
3
|
+
function classifyFailures(failures, indexedSpecIds) {
|
|
4
|
+
const remainingFailures = [];
|
|
5
|
+
const skippedLegacy = [];
|
|
6
|
+
const LEGACY_SKIPPABLE_CODES = new Set([
|
|
7
|
+
'INVALID_CONTRACT',
|
|
8
|
+
'INCOMPATIBLE_IDENTITY',
|
|
9
|
+
]);
|
|
10
|
+
for (const failure of failures) {
|
|
11
|
+
if (indexedSpecIds.has(failure.specId) && LEGACY_SKIPPABLE_CODES.has(failure.code)) {
|
|
12
|
+
skippedLegacy.push({
|
|
13
|
+
...failure,
|
|
14
|
+
reason: `${failure.specId} is already indexed; the on-disk contract is a historic mismatch left as-is.`,
|
|
15
|
+
});
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
remainingFailures.push(failure);
|
|
19
|
+
}
|
|
20
|
+
return { failures: remainingFailures, skippedLegacy };
|
|
21
|
+
}
|
|
22
|
+
const NEXT_ACTION_BY_CODE = {
|
|
23
|
+
READ_FAILED: 'fix file permissions or contents so spec.md can be read',
|
|
24
|
+
INVALID_CONTRACT: 'add the required portable frontmatter fields (id, title, type, scope, status, risk, target, difficulty)',
|
|
25
|
+
CREATE_FAILED: 'retry init_project once the underlying store write succeeds',
|
|
26
|
+
INCOMPATIBLE_IDENTITY: 'reconcile the spec identity (id, title, slug, uuid) with the indexed record',
|
|
27
|
+
};
|
|
28
|
+
/** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
|
|
29
|
+
export function describeReconciliationFailures(failures) {
|
|
30
|
+
const counts = new Map();
|
|
31
|
+
for (const failure of failures) {
|
|
32
|
+
counts.set(failure.code, (counts.get(failure.code) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
const codeSummary = [...counts.entries()]
|
|
35
|
+
.map(([code, count]) => `${String(count)} ${code}`)
|
|
36
|
+
.join(', ');
|
|
37
|
+
const topPaths = failures
|
|
38
|
+
.slice(0, 3)
|
|
39
|
+
.map((failure) => failure.path)
|
|
40
|
+
.join(', ');
|
|
41
|
+
const remainder = failures.length > 3 ? ', …' : '';
|
|
42
|
+
const nextActions = [...counts.keys()].map((code) => NEXT_ACTION_BY_CODE[code]).join('; ');
|
|
43
|
+
return `Portable spec index reconciliation failed for ${String(failures.length)} contract(s) (${codeSummary}). Affected: ${topPaths}${remainder}. Next: ${nextActions}.`;
|
|
44
|
+
}
|
|
45
|
+
/** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
|
|
46
|
+
export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy) {
|
|
47
|
+
const base = repositoryFilesChanged.length === 0
|
|
48
|
+
? 'Project already initialized. Safe update reconciled the portable spec index without changing repository files.'
|
|
49
|
+
: `Project already initialized. Safe update reconciled the portable spec index and refreshed ${String(repositoryFilesChanged.length)} core host asset file(s).`;
|
|
50
|
+
const skippedSuffix = skippedLegacy.length === 0
|
|
51
|
+
? ''
|
|
52
|
+
: ` Skipped ${String(skippedLegacy.length)} already-indexed legacy contract(s).`;
|
|
53
|
+
return base + skippedSuffix;
|
|
54
|
+
}
|
|
55
|
+
/** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
|
|
56
|
+
export function describeReconciliationOutcome(reconciliation, repositoryFilesChanged) {
|
|
57
|
+
return reconciliation.failures.length === 0
|
|
58
|
+
? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy)
|
|
59
|
+
: describeReconciliationFailures(reconciliation.failures);
|
|
60
|
+
}
|
|
3
61
|
/** Rebuild the mutable external index from the repository-owned portable contracts. */
|
|
4
62
|
export async function reconcilePortableSpecIndex(projectPath, projectId, deps = {
|
|
5
63
|
listSpecs: specStore.listSpecs,
|
|
@@ -10,6 +68,8 @@ export async function reconcilePortableSpecIndex(projectPath, projectId, deps =
|
|
|
10
68
|
const result = await importFilesystemSpecs(projectPath, deps, projectId, {
|
|
11
69
|
strictPortableContract: true,
|
|
12
70
|
});
|
|
13
|
-
|
|
71
|
+
const indexedSpecIds = new Set((await deps.listSpecs(projectId)).map((spec) => spec.id));
|
|
72
|
+
const { failures, skippedLegacy } = classifyFailures(result.failures, indexedSpecIds);
|
|
73
|
+
return { ...result, failures, skippedLegacy, repositoryFilesChanged: [] };
|
|
14
74
|
}
|
|
15
75
|
//# sourceMappingURL=portable-index-reconciler.js.map
|
|
@@ -233,8 +233,15 @@ 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[];
|
|
238
245
|
}
|
|
239
246
|
export interface ImportEntryContext {
|
|
240
247
|
projectPath: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
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.0",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|