@planu/cli 5.5.3 → 5.6.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 +38 -0
- package/dist/.planu-build.json +1 -1
- package/dist/engine/autopilot/bootstrap.js +1 -1
- package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
- package/dist/engine/detection-utils.d.ts +1 -0
- package/dist/engine/detection-utils.js +33 -0
- package/dist/engine/doc-generator/portal/index.d.ts +1 -1
- package/dist/engine/doc-generator/portal/index.js +1 -1
- package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
- package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
- package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
- package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
- package/dist/engine/framework-detector.js +8 -6
- package/dist/engine/housekeeping/history-log.d.ts +1 -0
- package/dist/engine/housekeeping/history-log.js +58 -3
- package/dist/engine/housekeeping/index.d.ts +2 -1
- package/dist/engine/housekeeping/index.js +2 -1
- package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
- package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
- package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
- package/dist/engine/next-spec-resolver/session-writer.js +1 -1
- package/dist/engine/project-graph/cache.js +23 -3
- package/dist/engine/readiness-checker.js +1 -1
- package/dist/engine/sdd-flow/checkpoints.js +29 -2
- package/dist/engine/session/checkpoint-writer.d.ts +1 -1
- package/dist/engine/session/checkpoint-writer.js +6 -5
- package/dist/engine/session-state/writer.js +4 -3
- package/dist/engine/spec-format/lean-spec-generator.js +1 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
- package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
- package/dist/engine/validator/spec-compliance-runner.js +45 -0
- package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
- package/dist/resources/specs.js +12 -33
- package/dist/storage/current-project.d.ts +3 -0
- package/dist/storage/current-project.js +21 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/migrations/canonical-storage.js +5 -0
- package/dist/storage/retention.d.ts +14 -0
- package/dist/storage/retention.js +279 -0
- package/dist/storage/spec-index.d.ts +23 -0
- package/dist/storage/spec-index.js +123 -0
- package/dist/storage/spec-store.d.ts +8 -3
- package/dist/storage/spec-store.js +76 -6
- package/dist/storage/storage-catalog.js +3 -3
- package/dist/storage/storage-layout.d.ts +8 -0
- package/dist/storage/storage-layout.js +9 -0
- package/dist/storage/transition-log.js +2 -0
- package/dist/tools/challenge-spec.js +10 -9
- package/dist/tools/create-spec.js +10 -0
- package/dist/tools/execute-sdd-flow.js +11 -2
- package/dist/tools/export-spec.js +2 -1
- package/dist/tools/force-status-analytics.js +2 -1
- package/dist/tools/generate-docs-site.js +2 -1
- package/dist/tools/generate-proposal.js +6 -2
- package/dist/tools/init-project/claude-md-generator.js +19 -2
- package/dist/tools/init-project/conventions-writer.d.ts +5 -2
- package/dist/tools/init-project/conventions-writer.js +18 -13
- package/dist/tools/init-project/git-setup.js +9 -0
- package/dist/tools/init-project/handler.js +9 -1
- package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
- package/dist/tools/init-project/legacy-root-migration.js +213 -0
- package/dist/tools/init-project/runtime-residue.d.ts +2 -0
- package/dist/tools/init-project/runtime-residue.js +11 -0
- package/dist/tools/session-checkpoint.js +1 -1
- package/dist/tools/update-status/index.js +7 -1
- package/dist/tools/update-status-actions.d.ts +7 -1
- package/dist/tools/update-status-actions.js +10 -2
- package/dist/types/housekeeping.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/retention.d.ts +12 -0
- package/dist/types/retention.js +3 -0
- package/dist/types/spec/core.d.ts +7 -0
- package/dist/types/spec/index-cache.d.ts +25 -0
- package/dist/types/spec/index-cache.js +3 -0
- package/dist/types/spec/index.d.ts +1 -0
- package/dist/types/spec/index.js +1 -0
- package/dist/types/spec-format.d.ts +1 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
- package/scripts/lib/pending-release-file.mjs +20 -4
|
@@ -2,12 +2,28 @@
|
|
|
2
2
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
|
|
6
|
+
import { projectRuntimeDir } from '../../storage/storage-layout.js';
|
|
7
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
8
|
+
function assertValidRunId(runId) {
|
|
9
|
+
if (!RUN_ID_PATTERN.test(runId)) {
|
|
10
|
+
throw new Error(`Invalid runId: "${runId}". Run IDs must match ${RUN_ID_PATTERN.source} (no path separators).`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
5
13
|
function getStateDir(projectPath) {
|
|
14
|
+
return projectRuntimeDir(projectPath, 'state');
|
|
15
|
+
}
|
|
16
|
+
function getLegacyStateDir(projectPath) {
|
|
6
17
|
return join(projectPath, 'planu', 'state');
|
|
7
18
|
}
|
|
8
19
|
function getStatePath(projectPath, runId) {
|
|
20
|
+
assertValidRunId(runId);
|
|
9
21
|
return join(getStateDir(projectPath), `sdd-flow-${runId}.json`);
|
|
10
22
|
}
|
|
23
|
+
function getLegacyStatePath(projectPath, runId) {
|
|
24
|
+
assertValidRunId(runId);
|
|
25
|
+
return join(getLegacyStateDir(projectPath), `sdd-flow-${runId}.json`);
|
|
26
|
+
}
|
|
11
27
|
export async function saveCheckpoint(projectPath, state) {
|
|
12
28
|
const dir = getStateDir(projectPath);
|
|
13
29
|
if (!existsSync(dir)) {
|
|
@@ -18,11 +34,22 @@ export async function saveCheckpoint(projectPath, state) {
|
|
|
18
34
|
}
|
|
19
35
|
export async function loadCheckpoint(projectPath, runId) {
|
|
20
36
|
const path = getStatePath(projectPath, runId);
|
|
21
|
-
if (
|
|
37
|
+
if (existsSync(path)) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await readFile(path, 'utf-8');
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// SPEC-1695: one-time legacy fallback for runs persisted before the relocation.
|
|
47
|
+
const legacyPath = getLegacyStatePath(projectPath, runId);
|
|
48
|
+
if (!existsSync(legacyPath)) {
|
|
22
49
|
return null;
|
|
23
50
|
}
|
|
24
51
|
try {
|
|
25
|
-
const raw = await readFile(
|
|
52
|
+
const raw = await readFile(legacyPath, 'utf-8');
|
|
26
53
|
return JSON.parse(raw);
|
|
27
54
|
}
|
|
28
55
|
catch {
|
|
@@ -4,7 +4,7 @@ export declare function buildCheckpointBlock(snapshot: AutoSessionSnapshot): str
|
|
|
4
4
|
/** Remove old checkpoint sections and prepend the new one to keep the file small. */
|
|
5
5
|
export declare function replaceLatestCheckpoint(existing: string, newBlock: string): string;
|
|
6
6
|
/**
|
|
7
|
-
* Write a compact checkpoint to `<projectPath>/planu/session-context.md`.
|
|
7
|
+
* Write a compact checkpoint to `<projectPath>/planu/.runtime/session-context.md`.
|
|
8
8
|
* Replaces any previous checkpoint section — idempotent.
|
|
9
9
|
* May throw — callers should catch or use maybeWriteCheckpoint.
|
|
10
10
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// engine/session/checkpoint-writer.ts — Writes a compact session checkpoint to
|
|
2
|
-
// planu/session-context.md (SPEC-563
|
|
2
|
+
// planu/.runtime/session-context.md (SPEC-563, relocated under SPEC-1699).
|
|
3
|
+
// Fire-and-forget safe — never throws to caller.
|
|
3
4
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { sessionTracker } from './session-tracker.js';
|
|
@@ -56,13 +57,13 @@ export function replaceLatestCheckpoint(existing, newBlock) {
|
|
|
56
57
|
return `${newBlock}\n${trimmed}`.trimEnd() + '\n';
|
|
57
58
|
}
|
|
58
59
|
/**
|
|
59
|
-
* Write a compact checkpoint to `<projectPath>/planu/session-context.md`.
|
|
60
|
+
* Write a compact checkpoint to `<projectPath>/planu/.runtime/session-context.md`.
|
|
60
61
|
* Replaces any previous checkpoint section — idempotent.
|
|
61
62
|
* May throw — callers should catch or use maybeWriteCheckpoint.
|
|
62
63
|
*/
|
|
63
64
|
export async function writeCheckpoint(projectPath, snapshot) {
|
|
64
|
-
const
|
|
65
|
-
const filePath = join(
|
|
65
|
+
const runtimeDir = join(projectPath, 'planu', '.runtime');
|
|
66
|
+
const filePath = join(runtimeDir, 'session-context.md');
|
|
66
67
|
let existing = '';
|
|
67
68
|
try {
|
|
68
69
|
existing = await readFile(filePath, 'utf8');
|
|
@@ -72,7 +73,7 @@ export async function writeCheckpoint(projectPath, snapshot) {
|
|
|
72
73
|
}
|
|
73
74
|
const newBlock = buildCheckpointBlock(snapshot);
|
|
74
75
|
const updated = replaceLatestCheckpoint(existing, newBlock);
|
|
75
|
-
await mkdir(
|
|
76
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
76
77
|
await writeFile(filePath, updated, 'utf8');
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
@@ -4,16 +4,17 @@ import { readFile, mkdir } from 'node:fs/promises';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
6
6
|
import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
|
|
7
|
-
|
|
7
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
|
|
8
|
+
import { projectRuntimeDir } from '../../storage/storage-layout.js';
|
|
8
9
|
const ROOT_SESSION = 'session.json';
|
|
9
10
|
function stateDir(projectPath) {
|
|
10
|
-
return
|
|
11
|
+
return projectRuntimeDir(projectPath, 'state');
|
|
11
12
|
}
|
|
12
13
|
function specFile(projectPath, specId) {
|
|
13
14
|
return join(stateDir(projectPath), `session-${specId}.json`);
|
|
14
15
|
}
|
|
15
16
|
export function rootFile(projectPath) {
|
|
16
|
-
return join(projectPath, 'planu', ROOT_SESSION);
|
|
17
|
+
return join(projectPath, 'planu', '.runtime', ROOT_SESSION);
|
|
17
18
|
}
|
|
18
19
|
function getModifiedFiles(projectPath) {
|
|
19
20
|
try {
|
|
@@ -166,7 +166,7 @@ function extractCheckboxCriteria(description) {
|
|
|
166
166
|
function extractGivenWhenThenCriteria(description) {
|
|
167
167
|
const criteria = [];
|
|
168
168
|
// Single-line: GIVEN ... WHEN ... THEN ...
|
|
169
|
-
const singleLineRegex = /^[ \t]*(?:[-*>]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
|
|
169
|
+
const singleLineRegex = /^[ \t]*(?:[-*>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
|
|
170
170
|
let match = singleLineRegex.exec(description);
|
|
171
171
|
while (match) {
|
|
172
172
|
const thenText = match[1]?.trim() ?? '';
|
|
@@ -5,6 +5,13 @@ export declare function isCanonicalPlanuRootDir(name: string): boolean;
|
|
|
5
5
|
export declare function isCanonicalSpecFile(name: string): boolean;
|
|
6
6
|
export declare function isCanonicalSpecDir(name: string): boolean;
|
|
7
7
|
export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Legacy-transition carve-out ONLY: strict-planu-cleanup.ts still walks a
|
|
10
|
+
* not-yet-migrated planu/releases/ directory and must not delete
|
|
11
|
+
* pending.json before the migration folds it into project.json's
|
|
12
|
+
* `releases` field. This is not part of the canonical contract — see
|
|
13
|
+
* canonicalContractText().
|
|
14
|
+
*/
|
|
15
|
+
export declare function isLegacyReleasePendingFile(relativeToPlanu: string): boolean;
|
|
9
16
|
export declare function canonicalContractText(): string;
|
|
10
17
|
//# sourceMappingURL=planu-canonical-policy.d.ts.map
|
|
@@ -14,18 +14,12 @@ const LEGACY_CONTENT_BEARING_PATTERNS = legacyArtifactRulesLoader
|
|
|
14
14
|
.filter((rule) => rule.classification === 'content-bearing')
|
|
15
15
|
.map((rule) => rule.pattern);
|
|
16
16
|
export const PLANU_CANONICAL_POLICY = {
|
|
17
|
-
canonicalRootFiles: [
|
|
18
|
-
|
|
19
|
-
'context.md',
|
|
20
|
-
'project.json',
|
|
21
|
-
'session-context.md',
|
|
22
|
-
'session.json',
|
|
23
|
-
'technology-selection.json',
|
|
24
|
-
],
|
|
25
|
-
canonicalRootDirs: ['releases', 'specs'],
|
|
17
|
+
canonicalRootFiles: ['project.json'],
|
|
18
|
+
canonicalRootDirs: ['specs'],
|
|
26
19
|
canonicalSpecFiles: ['spec.md'],
|
|
27
20
|
canonicalSpecDirs: [],
|
|
28
21
|
forbiddenHostAssetRootDirs: ['agents', 'skills', 'rules', 'hooks'],
|
|
22
|
+
runtimeHomeDir: 'planu/.runtime/',
|
|
29
23
|
generatedRuntimePatterns: [
|
|
30
24
|
'planu/index.html',
|
|
31
25
|
'planu/roadmap.html',
|
|
@@ -36,6 +30,7 @@ export const PLANU_CANONICAL_POLICY = {
|
|
|
36
30
|
'planu/handoffs/',
|
|
37
31
|
'planu/data/',
|
|
38
32
|
'planu/state/',
|
|
33
|
+
'planu/reports/',
|
|
39
34
|
'planu/.locks/',
|
|
40
35
|
'planu/specs/data/',
|
|
41
36
|
'planu/specs/*/technical-report.html',
|
|
@@ -68,7 +63,14 @@ export function isCanonicalSpecDir(name) {
|
|
|
68
63
|
export function mustMergeBeforeDeleteSpecFile(name) {
|
|
69
64
|
return LEGACY_MERGE_SET.has(name);
|
|
70
65
|
}
|
|
71
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Legacy-transition carve-out ONLY: strict-planu-cleanup.ts still walks a
|
|
68
|
+
* not-yet-migrated planu/releases/ directory and must not delete
|
|
69
|
+
* pending.json before the migration folds it into project.json's
|
|
70
|
+
* `releases` field. This is not part of the canonical contract — see
|
|
71
|
+
* canonicalContractText().
|
|
72
|
+
*/
|
|
73
|
+
export function isLegacyReleasePendingFile(relativeToPlanu) {
|
|
72
74
|
return relativeToPlanu === 'releases/pending.json';
|
|
73
75
|
}
|
|
74
76
|
export function canonicalContractText() {
|
|
@@ -76,15 +78,14 @@ export function canonicalContractText() {
|
|
|
76
78
|
'planu/',
|
|
77
79
|
' conventions.json',
|
|
78
80
|
' context.md',
|
|
79
|
-
' project.json',
|
|
81
|
+
' project.json (pending release records live in its `releases` field)',
|
|
80
82
|
' session-context.md',
|
|
81
83
|
' session.json',
|
|
82
84
|
' technology-selection.json',
|
|
83
|
-
' releases/',
|
|
84
|
-
' pending.json',
|
|
85
85
|
' specs/',
|
|
86
86
|
' SPEC-XXX-slug/',
|
|
87
87
|
' spec.md',
|
|
88
|
+
' .runtime/ (generated runtime home — session/index data, not committed)',
|
|
88
89
|
'',
|
|
89
90
|
'Evidence artifacts are Planu runtime data and are stored outside planu/specs/.',
|
|
90
91
|
'',
|
|
@@ -8,8 +8,21 @@ import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
|
8
8
|
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
9
9
|
import { safeUnlink } from './git-aware-fs.js';
|
|
10
10
|
import { scanLegacyArtifacts } from './legacy-classifier.js';
|
|
11
|
-
import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile,
|
|
11
|
+
import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isLegacyReleasePendingFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
|
+
// SPEC-1699: legacy planu/ root files folded into project.json by init_project's
|
|
14
|
+
// migration. Destructive cleanup must never delete them outright — only the
|
|
15
|
+
// migration step, which reads their content first, may retire them.
|
|
16
|
+
const PENDING_ROOT_MIGRATION_FILES = new Set([
|
|
17
|
+
'conventions.json',
|
|
18
|
+
'context.md',
|
|
19
|
+
'session-context.md',
|
|
20
|
+
'session.json',
|
|
21
|
+
'technology-selection.json',
|
|
22
|
+
]);
|
|
23
|
+
function isPendingRootMigrationFile(entry) {
|
|
24
|
+
return PENDING_ROOT_MIGRATION_FILES.has(entry);
|
|
25
|
+
}
|
|
13
26
|
async function pathIsDirectory(path) {
|
|
14
27
|
try {
|
|
15
28
|
return (await stat(path)).isDirectory();
|
|
@@ -176,11 +189,23 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
|
|
|
176
189
|
for (const entry of entries) {
|
|
177
190
|
const full = join(planuDir, entry);
|
|
178
191
|
const isDir = await pathIsDirectory(full);
|
|
192
|
+
if (isDir && entry === 'releases') {
|
|
193
|
+
// SPEC-1699: 'releases' is no longer a canonical root dir, but its
|
|
194
|
+
// pending.json still needs the dedicated fold-into-project.json
|
|
195
|
+
// migration below before its directory can be removed.
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
179
198
|
if (isDir && !isCanonicalPlanuRootDir(entry)) {
|
|
180
199
|
await removePath(projectPath, full);
|
|
181
200
|
result.deleted.push(relative(projectPath, full));
|
|
182
201
|
continue;
|
|
183
202
|
}
|
|
203
|
+
if (!isDir && isPendingRootMigrationFile(entry)) {
|
|
204
|
+
// SPEC-1699: content-bearing legacy root file — init_project's migration
|
|
205
|
+
// folds it into project.json before this cleanup may delete it.
|
|
206
|
+
result.proposed.push(relative(projectPath, full));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
184
209
|
if (!isDir && !isCanonicalPlanuRootFile(entry)) {
|
|
185
210
|
await removePath(projectPath, full);
|
|
186
211
|
result.deleted.push(relative(projectPath, full));
|
|
@@ -189,7 +214,7 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
|
|
|
189
214
|
const releasesDir = join(planuDir, 'releases');
|
|
190
215
|
for (const entry of await readDirectory(releasesDir)) {
|
|
191
216
|
const rel = `releases/${entry}`;
|
|
192
|
-
if (!
|
|
217
|
+
if (!isLegacyReleasePendingFile(rel)) {
|
|
193
218
|
const full = join(releasesDir, entry);
|
|
194
219
|
await removePath(projectPath, full);
|
|
195
220
|
result.deleted.push(relative(projectPath, full));
|
|
@@ -235,7 +260,7 @@ export async function validateStrictPlanuLayout(projectPath, options = {}) {
|
|
|
235
260
|
}
|
|
236
261
|
for (const entry of await readDirectory(join(planuDir, 'releases'))) {
|
|
237
262
|
const rel = `releases/${entry}`;
|
|
238
|
-
if (!
|
|
263
|
+
if (!isLegacyReleasePendingFile(rel)) {
|
|
239
264
|
offenders.push(`planu/${rel}`);
|
|
240
265
|
}
|
|
241
266
|
}
|
|
@@ -19,7 +19,7 @@ When implementation changes are complete and validated, always address release e
|
|
|
19
19
|
- local validation commands passed
|
|
20
20
|
- changelog or release notes updated when applicable
|
|
21
21
|
- package version and git tag stay aligned
|
|
22
|
-
- release metadata under \`planu/
|
|
22
|
+
- release metadata under the \`releases\` field of \`planu/project.json\` contains only genuinely pending entries
|
|
23
23
|
- published package smoke check is run after release when applicable
|
|
24
24
|
|
|
25
25
|
## Hard Blocks
|
|
@@ -24,6 +24,41 @@ const FILE_EXTENSION = /\.[A-Za-z0-9]+$/;
|
|
|
24
24
|
function isPathShapedSegment(segment) {
|
|
25
25
|
return segment.length > 0 && (PATH_SHAPED.test(segment) || FILE_EXTENSION.test(segment));
|
|
26
26
|
}
|
|
27
|
+
function splitInlineArrayItems(content) {
|
|
28
|
+
const items = [];
|
|
29
|
+
let current = '';
|
|
30
|
+
let quote = null;
|
|
31
|
+
for (const char of content) {
|
|
32
|
+
if (quote) {
|
|
33
|
+
if (char === quote) {
|
|
34
|
+
quote = null;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
current += char;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (char === '"' || char === "'") {
|
|
42
|
+
quote = char;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (char === ',') {
|
|
46
|
+
items.push(current);
|
|
47
|
+
current = '';
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
current += char;
|
|
51
|
+
}
|
|
52
|
+
items.push(current);
|
|
53
|
+
return items;
|
|
54
|
+
}
|
|
55
|
+
function parseInlineTestLinks(bracketContent) {
|
|
56
|
+
return splitInlineArrayItems(bracketContent)
|
|
57
|
+
.map((item) => item.trim())
|
|
58
|
+
.filter((item) => item.length > 0)
|
|
59
|
+
.map((item) => parseTestLinkString(item))
|
|
60
|
+
.filter((link) => link !== null);
|
|
61
|
+
}
|
|
27
62
|
function parseTestLinkString(raw) {
|
|
28
63
|
if (WINDOWS_DRIVE_PATH.test(raw)) {
|
|
29
64
|
return { path: raw };
|
|
@@ -104,6 +139,16 @@ export function parseFrontmatterScenarios(raw) {
|
|
|
104
139
|
inSteps = false;
|
|
105
140
|
continue;
|
|
106
141
|
}
|
|
142
|
+
const inlineTestsMatch = /^\s+tests:\s*\[(.*)\]\s*$/.exec(trimmed);
|
|
143
|
+
if (inlineTestsMatch) {
|
|
144
|
+
currentScenario.tests = [
|
|
145
|
+
...(currentScenario.tests ?? []),
|
|
146
|
+
...parseInlineTestLinks(inlineTestsMatch[1] ?? ''),
|
|
147
|
+
];
|
|
148
|
+
inTests = false;
|
|
149
|
+
inSteps = false;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
107
152
|
if (/^\s+steps:\s*$/.test(trimmed)) {
|
|
108
153
|
currentScenario.steps ??= [];
|
|
109
154
|
inSteps = true;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { glob } from 'glob';
|
|
6
6
|
import { resolveStorageLayout } from '../../../storage/storage-layout.js';
|
|
7
|
+
import { loadSpecIndex, listSpecIndexRecords } from '../../../storage/spec-index.js';
|
|
8
|
+
import { resolveCurrentProjectPath } from '../../../storage/current-project.js';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
/** Planu-namespaced resource URIs exposed by SPEC-588. */
|
|
9
11
|
export const PLANU_UX_RESOURCE_URIS = {
|
|
@@ -56,30 +58,12 @@ function safeStr(value) {
|
|
|
56
58
|
return typeof value === 'string' ? value : '';
|
|
57
59
|
}
|
|
58
60
|
async function loadAllSpecEntries() {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for (const file of files) {
|
|
63
|
-
try {
|
|
64
|
-
const raw = await readFile(file, 'utf-8');
|
|
65
|
-
const parsed = JSON.parse(raw);
|
|
66
|
-
if (Array.isArray(parsed)) {
|
|
67
|
-
for (const entry of parsed) {
|
|
68
|
-
if (entry !== null && typeof entry === 'object' && 'id' in entry) {
|
|
69
|
-
specs.push(toSpecDataEntry(entry));
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
// skip malformed files
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
// data/ not present yet
|
|
61
|
+
const projectPath = await resolveCurrentProjectPath();
|
|
62
|
+
if (!projectPath) {
|
|
63
|
+
return [];
|
|
81
64
|
}
|
|
82
|
-
|
|
65
|
+
const index = await loadSpecIndex(projectPath);
|
|
66
|
+
return listSpecIndexRecords(index).map((record) => toSpecDataEntry(record));
|
|
83
67
|
}
|
|
84
68
|
async function buildSpecListContent() {
|
|
85
69
|
const all = await loadAllSpecEntries();
|
package/dist/resources/specs.js
CHANGED
|
@@ -1,46 +1,25 @@
|
|
|
1
1
|
// resources/specs.ts — Current project specs list resource
|
|
2
|
-
// Returns the list of specs for the current project from
|
|
3
|
-
//
|
|
4
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
-
import { glob } from 'glob';
|
|
2
|
+
// Returns the list of specs for the current project, read from spec.md via
|
|
3
|
+
// the regenerable spec index (SPEC-1699). spec.md is the authoritative record.
|
|
6
4
|
import { t } from '../i18n/index.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { loadSpecIndex, listSpecIndexRecords } from '../storage/spec-index.js';
|
|
6
|
+
import { resolveCurrentProjectPath } from '../storage/current-project.js';
|
|
9
7
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Load every spec for the current project (cwd) from its spec.md files under
|
|
9
|
+
* planu/specs/. Returns an empty list when cwd is not an initialized Planu project.
|
|
12
10
|
*/
|
|
13
|
-
async function
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
for (const file of specFiles) {
|
|
18
|
-
try {
|
|
19
|
-
const raw = await readFile(file, 'utf-8');
|
|
20
|
-
const parsed = JSON.parse(raw);
|
|
21
|
-
if (Array.isArray(parsed)) {
|
|
22
|
-
for (const entry of parsed) {
|
|
23
|
-
if (entry !== null && typeof entry === 'object' && 'id' in entry) {
|
|
24
|
-
specs.push(entry);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
// Skip malformed files
|
|
31
|
-
}
|
|
32
|
-
}
|
|
11
|
+
async function loadCurrentProjectSpecs() {
|
|
12
|
+
const projectPath = await resolveCurrentProjectPath();
|
|
13
|
+
if (!projectPath) {
|
|
14
|
+
return [];
|
|
33
15
|
}
|
|
34
|
-
|
|
35
|
-
// No data directory yet — that is fine
|
|
36
|
-
}
|
|
37
|
-
return specs;
|
|
16
|
+
return listSpecIndexRecords(await loadSpecIndex(projectPath));
|
|
38
17
|
}
|
|
39
18
|
/**
|
|
40
19
|
* MCP resource handler for planu://specs
|
|
41
20
|
*/
|
|
42
21
|
export async function handleSpecsResource() {
|
|
43
|
-
const specs = await
|
|
22
|
+
const specs = await loadCurrentProjectSpecs();
|
|
44
23
|
if (specs.length === 0) {
|
|
45
24
|
return JSON.stringify({
|
|
46
25
|
count: 0,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// storage/current-project.ts — SPEC-1699
|
|
2
|
+
// Resolves the current project from cwd's planu/project.json. Used by MCP
|
|
3
|
+
// resources that used to scan every registered project's specs.json store;
|
|
4
|
+
// they now read only the project the server is actually running in.
|
|
5
|
+
import { access } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
8
|
+
/** Return process.cwd() when it is an initialized Planu project, else null. */
|
|
9
|
+
export async function resolveCurrentProjectPath(cwd = process.cwd()) {
|
|
10
|
+
try {
|
|
11
|
+
await access(join(cwd, 'planu', 'project.json'));
|
|
12
|
+
return cwd;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (!(error instanceof Error && error.code === 'ENOENT')) {
|
|
16
|
+
reportClassifiedDegradation('CURRENT_PROJECT_PROBE_FAILED', error);
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=current-project.js.map
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -24,4 +24,5 @@ export * as actualsStore from './actuals-store.js';
|
|
|
24
24
|
export * as approvalStore from './approval-store.js';
|
|
25
25
|
export * as technologySelectionStore from './technology-selection-store.js';
|
|
26
26
|
export * from './skill-registry-storage.js';
|
|
27
|
+
export * from './retention.js';
|
|
27
28
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/storage/index.js
CHANGED
|
@@ -25,4 +25,5 @@ export * as actualsStore from './actuals-store.js';
|
|
|
25
25
|
export * as approvalStore from './approval-store.js';
|
|
26
26
|
export * as technologySelectionStore from './technology-selection-store.js';
|
|
27
27
|
export * from './skill-registry-storage.js';
|
|
28
|
+
export * from './retention.js';
|
|
28
29
|
//# sourceMappingURL=index.js.map
|
|
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } fr
|
|
|
7
7
|
import { acquireLock as acquireFileMutex, releaseLock as releaseFileMutex, } from '../../engine/safety/file-mutex.js';
|
|
8
8
|
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
9
9
|
import { assertNoObsoleteFileLock } from '../../engine/safety/obsolete-lock-guard.js';
|
|
10
|
+
import { enforceRetention } from '../retention.js';
|
|
10
11
|
function sha256(value) {
|
|
11
12
|
return createHash('sha256').update(value).digest('hex');
|
|
12
13
|
}
|
|
@@ -715,6 +716,10 @@ export async function migrateCanonicalStorage(input) {
|
|
|
715
716
|
await rm(journal.backupRoot, { recursive: true, force: true });
|
|
716
717
|
journal.phase = 'completed';
|
|
717
718
|
await writeJournal(paths.journalPath, journal);
|
|
719
|
+
await enforceRetention({
|
|
720
|
+
kind: 'snapshot',
|
|
721
|
+
path: join(journal.destinationRoot, 'migration-conflicts'),
|
|
722
|
+
});
|
|
718
723
|
return result(resumed ? 'resumed' : 'migrated', journal, paths.journalPath, resumed, verified);
|
|
719
724
|
}
|
|
720
725
|
catch (error) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EnforceRetentionInput, RetentionResult } from '../types/index.js';
|
|
2
|
+
export declare const TRANSITION_LOG_MAX_BYTES: number;
|
|
3
|
+
export declare const GRAPH_CACHE_MAX_BYTES: number;
|
|
4
|
+
export declare const SPEC_STORE_BACKUPS_KEPT = 1;
|
|
5
|
+
export declare const IDEMPOTENCY_TTL_DAYS = 30;
|
|
6
|
+
export declare const SNAPSHOTS_KEPT = 10;
|
|
7
|
+
export declare const ARCHIVED_CONTEXT_SUBDIR = "archived-context";
|
|
8
|
+
/**
|
|
9
|
+
* Enforce the retention budget for one artifact kind. Never throws — a failure
|
|
10
|
+
* to prune is swallowed and reported as an empty result so the primary write
|
|
11
|
+
* that triggered enforcement is never affected.
|
|
12
|
+
*/
|
|
13
|
+
export declare function enforceRetention(input: EnforceRetentionInput): Promise<RetentionResult>;
|
|
14
|
+
//# sourceMappingURL=retention.d.ts.map
|