@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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// storage/retention.ts — SPEC-1697: enforce per-artifact retention budgets on runtime storage.
|
|
2
|
+
import { readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
5
|
+
function isMissingPath(error) {
|
|
6
|
+
return error instanceof Error && error.code === 'ENOENT';
|
|
7
|
+
}
|
|
8
|
+
export const TRANSITION_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
export const GRAPH_CACHE_MAX_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
export const SPEC_STORE_BACKUPS_KEPT = 1;
|
|
11
|
+
export const IDEMPOTENCY_TTL_DAYS = 30;
|
|
12
|
+
export const SNAPSHOTS_KEPT = 10;
|
|
13
|
+
export const ARCHIVED_CONTEXT_SUBDIR = 'archived-context';
|
|
14
|
+
const EMPTY_RESULT = { freedBytes: 0, removedPaths: [] };
|
|
15
|
+
function escapeRegExp(value) {
|
|
16
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
async function statOrNull(path) {
|
|
19
|
+
try {
|
|
20
|
+
const info = await stat(path);
|
|
21
|
+
return { size: info.size, mtimeMs: info.mtimeMs, isDirectory: info.isDirectory() };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (!isMissingPath(error)) {
|
|
25
|
+
reportClassifiedDegradation('RETENTION_STAT_FAILED', error);
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function listFilesRecursive(dir) {
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!isMissingPath(error)) {
|
|
37
|
+
reportClassifiedDegradation('RETENTION_READDIR_FAILED', error);
|
|
38
|
+
}
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
42
|
+
const path = join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
return listFilesRecursive(path);
|
|
45
|
+
}
|
|
46
|
+
return entry.isFile() ? [path] : [];
|
|
47
|
+
}));
|
|
48
|
+
return nested.flat();
|
|
49
|
+
}
|
|
50
|
+
async function pathSize(path) {
|
|
51
|
+
const info = await statOrNull(path);
|
|
52
|
+
if (info === null) {
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (!info.isDirectory) {
|
|
56
|
+
return info.size;
|
|
57
|
+
}
|
|
58
|
+
const files = await listFilesRecursive(path);
|
|
59
|
+
const sizes = await Promise.all(files.map((file) => statOrNull(file).then((s) => s?.size ?? 0)));
|
|
60
|
+
return sizes.reduce((sum, size) => sum + size, 0);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Rotate a transition log file when it has grown beyond TRANSITION_LOG_MAX_BYTES.
|
|
64
|
+
* The active file is moved aside to `${filePath}.1`; any pre-existing rotated
|
|
65
|
+
* predecessor is discarded first, so at most one predecessor is ever kept.
|
|
66
|
+
* Must be called before the caller appends its next entry to `filePath`.
|
|
67
|
+
*/
|
|
68
|
+
async function rotateTransitionLog(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
const active = await statOrNull(filePath);
|
|
71
|
+
if (active === null || active.isDirectory || active.size <= TRANSITION_LOG_MAX_BYTES) {
|
|
72
|
+
return EMPTY_RESULT;
|
|
73
|
+
}
|
|
74
|
+
const rotatedPath = `${filePath}.1`;
|
|
75
|
+
const removedPaths = [];
|
|
76
|
+
let freedBytes = 0;
|
|
77
|
+
const rotated = await statOrNull(rotatedPath);
|
|
78
|
+
if (rotated !== null) {
|
|
79
|
+
await unlink(rotatedPath);
|
|
80
|
+
freedBytes += rotated.size;
|
|
81
|
+
removedPaths.push(rotatedPath);
|
|
82
|
+
}
|
|
83
|
+
await rename(filePath, rotatedPath);
|
|
84
|
+
return { freedBytes, removedPaths };
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!isMissingPath(error)) {
|
|
88
|
+
reportClassifiedDegradation('RETENTION_LOG_ROTATION_FAILED', error);
|
|
89
|
+
}
|
|
90
|
+
return EMPTY_RESULT;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Evict least-recently-used entries under `dir` until it fits GRAPH_CACHE_MAX_BYTES.
|
|
95
|
+
* Paths in `protectedPaths` (e.g. the artifact just written) are never evicted.
|
|
96
|
+
*/
|
|
97
|
+
async function enforceGraphCacheBudget(dir, protectedPaths) {
|
|
98
|
+
try {
|
|
99
|
+
const files = await listFilesRecursive(dir);
|
|
100
|
+
const stats = await Promise.all(files.map(async (path) => ({ path, info: await statOrNull(path) })));
|
|
101
|
+
const entries = stats
|
|
102
|
+
.filter((entry) => entry.info !== null)
|
|
103
|
+
.map((entry) => ({ path: entry.path, size: entry.info.size, mtimeMs: entry.info.mtimeMs }));
|
|
104
|
+
let totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0);
|
|
105
|
+
if (totalBytes <= GRAPH_CACHE_MAX_BYTES) {
|
|
106
|
+
return EMPTY_RESULT;
|
|
107
|
+
}
|
|
108
|
+
const protectedSet = new Set(protectedPaths);
|
|
109
|
+
const candidates = entries
|
|
110
|
+
.filter((entry) => !protectedSet.has(entry.path))
|
|
111
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
112
|
+
const removedPaths = [];
|
|
113
|
+
let freedBytes = 0;
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (totalBytes <= GRAPH_CACHE_MAX_BYTES) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
await unlink(candidate.path);
|
|
119
|
+
totalBytes -= candidate.size;
|
|
120
|
+
freedBytes += candidate.size;
|
|
121
|
+
removedPaths.push(candidate.path);
|
|
122
|
+
}
|
|
123
|
+
return { freedBytes, removedPaths };
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!isMissingPath(error)) {
|
|
127
|
+
reportClassifiedDegradation('RETENTION_GRAPH_CACHE_FAILED', error);
|
|
128
|
+
}
|
|
129
|
+
return EMPTY_RESULT;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Keep exactly SPEC_STORE_BACKUPS_KEPT `.bak.N` siblings of `filePath`, newest first. */
|
|
133
|
+
async function enforceSpecStoreBackupBudget(filePath) {
|
|
134
|
+
try {
|
|
135
|
+
const dir = dirname(filePath);
|
|
136
|
+
const base = basename(filePath);
|
|
137
|
+
const bakPattern = new RegExp(`^${escapeRegExp(base)}\\.bak\\.(\\d+)$`);
|
|
138
|
+
const entries = await readdir(dir);
|
|
139
|
+
const backups = entries
|
|
140
|
+
.map((name) => {
|
|
141
|
+
const match = bakPattern.exec(name);
|
|
142
|
+
return match ? { name, generation: Number(match[1]) } : null;
|
|
143
|
+
})
|
|
144
|
+
.filter((entry) => entry !== null)
|
|
145
|
+
.sort((a, b) => a.generation - b.generation);
|
|
146
|
+
const toRemove = backups.filter((entry) => entry.generation > SPEC_STORE_BACKUPS_KEPT);
|
|
147
|
+
const removedPaths = [];
|
|
148
|
+
let freedBytes = 0;
|
|
149
|
+
for (const entry of toRemove) {
|
|
150
|
+
const path = join(dir, entry.name);
|
|
151
|
+
const size = await pathSize(path);
|
|
152
|
+
await unlink(path);
|
|
153
|
+
freedBytes += size;
|
|
154
|
+
removedPaths.push(path);
|
|
155
|
+
}
|
|
156
|
+
return { freedBytes, removedPaths };
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!isMissingPath(error)) {
|
|
160
|
+
reportClassifiedDegradation('RETENTION_SPEC_BACKUP_FAILED', error);
|
|
161
|
+
}
|
|
162
|
+
return EMPTY_RESULT;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Drop idempotency evidence records older than IDEMPOTENCY_TTL_DAYS from `dir`. */
|
|
166
|
+
async function enforceIdempotencyTtl(dir) {
|
|
167
|
+
try {
|
|
168
|
+
const entries = await readdir(dir);
|
|
169
|
+
const cutoffMs = Date.now() - IDEMPOTENCY_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
170
|
+
const removedPaths = [];
|
|
171
|
+
let freedBytes = 0;
|
|
172
|
+
for (const name of entries) {
|
|
173
|
+
if (!name.endsWith('.json') || name.endsWith('.claim.json')) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const path = join(dir, name);
|
|
177
|
+
const info = await statOrNull(path);
|
|
178
|
+
if (info === null || info.isDirectory || info.mtimeMs >= cutoffMs) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
await unlink(path);
|
|
182
|
+
freedBytes += info.size;
|
|
183
|
+
removedPaths.push(path);
|
|
184
|
+
}
|
|
185
|
+
return { freedBytes, removedPaths };
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (!isMissingPath(error)) {
|
|
189
|
+
reportClassifiedDegradation('RETENTION_IDEMPOTENCY_TTL_FAILED', error);
|
|
190
|
+
}
|
|
191
|
+
return EMPTY_RESULT;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Keep the SNAPSHOTS_KEPT most recent top-level entries under `dir`. */
|
|
195
|
+
async function enforceSnapshotBudget(dir) {
|
|
196
|
+
try {
|
|
197
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
198
|
+
const stats = await Promise.all(entries.map(async (entry) => {
|
|
199
|
+
const path = join(dir, entry.name);
|
|
200
|
+
const info = await statOrNull(path);
|
|
201
|
+
return info === null ? null : { path, mtimeMs: info.mtimeMs };
|
|
202
|
+
}));
|
|
203
|
+
const items = stats.filter((entry) => entry !== null);
|
|
204
|
+
if (items.length <= SNAPSHOTS_KEPT) {
|
|
205
|
+
return EMPTY_RESULT;
|
|
206
|
+
}
|
|
207
|
+
const toRemove = items.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(SNAPSHOTS_KEPT);
|
|
208
|
+
const removedPaths = [];
|
|
209
|
+
let freedBytes = 0;
|
|
210
|
+
for (const item of toRemove) {
|
|
211
|
+
freedBytes += await pathSize(item.path);
|
|
212
|
+
await rm(item.path, { recursive: true, force: true });
|
|
213
|
+
removedPaths.push(item.path);
|
|
214
|
+
}
|
|
215
|
+
return { freedBytes, removedPaths };
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!isMissingPath(error)) {
|
|
219
|
+
reportClassifiedDegradation('RETENTION_SNAPSHOT_BUDGET_FAILED', error);
|
|
220
|
+
}
|
|
221
|
+
return EMPTY_RESULT;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove handoff packages, evidence bundles, and archived context for `specId`
|
|
226
|
+
* under `dataDir`. Never touches planu/specs/ — those live outside `dataDir`.
|
|
227
|
+
*/
|
|
228
|
+
async function pruneSpecArtifacts(dataDir, specId) {
|
|
229
|
+
try {
|
|
230
|
+
const targets = [
|
|
231
|
+
join(dataDir, 'handoffs', specId),
|
|
232
|
+
join(dataDir, 'handoffs', `${specId}.md`),
|
|
233
|
+
join(dataDir, ARCHIVED_CONTEXT_SUBDIR, `${specId}.json`),
|
|
234
|
+
];
|
|
235
|
+
const removedPaths = [];
|
|
236
|
+
let freedBytes = 0;
|
|
237
|
+
for (const target of targets) {
|
|
238
|
+
const info = await statOrNull(target);
|
|
239
|
+
if (info === null) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
freedBytes += await pathSize(target);
|
|
243
|
+
await rm(target, { recursive: true, force: true });
|
|
244
|
+
removedPaths.push(target);
|
|
245
|
+
}
|
|
246
|
+
return { freedBytes, removedPaths };
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (!isMissingPath(error)) {
|
|
250
|
+
reportClassifiedDegradation('RETENTION_SPEC_ARTIFACTS_FAILED', error);
|
|
251
|
+
}
|
|
252
|
+
return EMPTY_RESULT;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Enforce the retention budget for one artifact kind. Never throws — a failure
|
|
257
|
+
* to prune is swallowed and reported as an empty result so the primary write
|
|
258
|
+
* that triggered enforcement is never affected.
|
|
259
|
+
*/
|
|
260
|
+
export async function enforceRetention(input) {
|
|
261
|
+
const kind = input.kind;
|
|
262
|
+
switch (kind) {
|
|
263
|
+
case 'transition-log':
|
|
264
|
+
return rotateTransitionLog(input.path);
|
|
265
|
+
case 'graph-cache':
|
|
266
|
+
return enforceGraphCacheBudget(input.path, input.protectedPaths ?? []);
|
|
267
|
+
case 'spec-store-backup':
|
|
268
|
+
return enforceSpecStoreBackupBudget(input.path);
|
|
269
|
+
case 'idempotency':
|
|
270
|
+
return enforceIdempotencyTtl(input.path);
|
|
271
|
+
case 'snapshot':
|
|
272
|
+
return enforceSnapshotBudget(input.path);
|
|
273
|
+
case 'spec-artifacts':
|
|
274
|
+
return input.specId === undefined
|
|
275
|
+
? EMPTY_RESULT
|
|
276
|
+
: pruneSpecArtifacts(input.path, input.specId);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=retention.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SpecIndex, SpecIndexRecord } from '../types/index.js';
|
|
2
|
+
/** Exported so other storage/ modules (e.g. spec-store.ts) can verify a spec.md digest without re-implementing the hash. */
|
|
3
|
+
export declare function digestOf(content: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Storage-layer-only scalar frontmatter reader — deliberately not the shared
|
|
6
|
+
* engine/frontmatter-parser.ts, since storage/ may only import from types/
|
|
7
|
+
* (SPEC-1660 layering). Only reads the flat top-level fields this index needs.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readFrontmatterScalars(content: string): Record<string, string>;
|
|
10
|
+
/**
|
|
11
|
+
* Rebuild the spec index from every spec.md file under planu/specs/ of
|
|
12
|
+
* `projectPath` and persist it to planu/.runtime/index.json.
|
|
13
|
+
*
|
|
14
|
+
* spec.md is always the source of truth: this function re-reads and re-parses
|
|
15
|
+
* every spec.md on every call, so a record can never be stale relative to disk.
|
|
16
|
+
* A project with no spec.md files, or a deleted index.json, rebuilds cleanly.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadSpecIndex(projectPath: string): Promise<SpecIndex>;
|
|
19
|
+
/** Read the last persisted index without rebuilding it. Used only for diagnostics. */
|
|
20
|
+
export declare function readPersistedSpecIndex(projectPath: string): Promise<SpecIndex>;
|
|
21
|
+
/** List spec index records sorted by id — convenience for callers needing an array. */
|
|
22
|
+
export declare function listSpecIndexRecords(index: SpecIndex): SpecIndexRecord[];
|
|
23
|
+
//# sourceMappingURL=spec-index.d.ts.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// storage/spec-index.ts — SPEC-1699
|
|
2
|
+
// Regenerable read cache over planu/specs/*/spec.md. spec.md is the authoritative
|
|
3
|
+
// record for every field it carries; this index only avoids re-deriving it from
|
|
4
|
+
// scratch elsewhere. Every read re-parses spec.md from disk, so a stale cached
|
|
5
|
+
// record can never be served — the persisted index.json exists so writers can
|
|
6
|
+
// verify their own write landed (digest match) without a caller re-reading spec.md.
|
|
7
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { join, relative } from 'node:path';
|
|
10
|
+
import { glob } from 'glob';
|
|
11
|
+
import { readJson, writeJson } from './base-store.js';
|
|
12
|
+
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
13
|
+
function specIndexPath(projectPath) {
|
|
14
|
+
return join(projectPath, 'planu', '.runtime', 'index.json');
|
|
15
|
+
}
|
|
16
|
+
/** Exported so other storage/ modules (e.g. spec-store.ts) can verify a spec.md digest without re-implementing the hash. */
|
|
17
|
+
export function digestOf(content) {
|
|
18
|
+
return createHash('sha256').update(content).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
|
|
21
|
+
const SCALAR_LINE_RE = /^(\w+):\s*(.+)$/;
|
|
22
|
+
const SCENARIO_LINE_RE = /^-?\s*GIVEN\b.*$/;
|
|
23
|
+
const TEST_MARKER_RE = /^TEST:\s*(.+)$/;
|
|
24
|
+
/**
|
|
25
|
+
* Storage-layer-only scalar frontmatter reader — deliberately not the shared
|
|
26
|
+
* engine/frontmatter-parser.ts, since storage/ may only import from types/
|
|
27
|
+
* (SPEC-1660 layering). Only reads the flat top-level fields this index needs.
|
|
28
|
+
*/
|
|
29
|
+
export function readFrontmatterScalars(content) {
|
|
30
|
+
const match = FRONTMATTER_RE.exec(content);
|
|
31
|
+
if (!match?.[1]) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
const fields = {};
|
|
35
|
+
for (const line of match[1].split('\n')) {
|
|
36
|
+
if (/^\s/.test(line)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const kv = SCALAR_LINE_RE.exec(line);
|
|
40
|
+
if (!kv?.[1] || kv[2] === undefined) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
fields[kv[1]] = kv[2].trim().replace(/^"(.*)"$/, '$1');
|
|
44
|
+
}
|
|
45
|
+
return fields;
|
|
46
|
+
}
|
|
47
|
+
function extractScenarios(content) {
|
|
48
|
+
return content
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter((line) => SCENARIO_LINE_RE.test(line))
|
|
52
|
+
.map((line) => line.replace(/^-\s*/, ''));
|
|
53
|
+
}
|
|
54
|
+
function extractTestMarkers(content) {
|
|
55
|
+
const tests = [];
|
|
56
|
+
for (const line of content.split('\n')) {
|
|
57
|
+
const match = TEST_MARKER_RE.exec(line.trim());
|
|
58
|
+
if (match?.[1]) {
|
|
59
|
+
tests.push(match[1].trim());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tests;
|
|
63
|
+
}
|
|
64
|
+
async function parseSpecRecord(projectPath, specMdPath) {
|
|
65
|
+
const content = await readFile(specMdPath, 'utf-8');
|
|
66
|
+
const fields = readFrontmatterScalars(content);
|
|
67
|
+
const id = fields.id ?? '';
|
|
68
|
+
if (!id) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const fileStat = await stat(specMdPath);
|
|
72
|
+
const difficulty = fields.difficulty !== undefined ? Number(fields.difficulty) : NaN;
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
title: fields.title ?? '',
|
|
76
|
+
status: fields.status ?? 'draft',
|
|
77
|
+
type: fields.type ?? '',
|
|
78
|
+
target: fields.target ?? '',
|
|
79
|
+
scope: fields.scope ?? '',
|
|
80
|
+
difficulty: Number.isFinite(difficulty) ? difficulty : null,
|
|
81
|
+
risk: fields.risk ?? '',
|
|
82
|
+
createdAt: fields.created ?? null,
|
|
83
|
+
updatedAt: fileStat.mtime.toISOString(),
|
|
84
|
+
specPath: relative(projectPath, specMdPath),
|
|
85
|
+
digest: digestOf(content),
|
|
86
|
+
scenarios: extractScenarios(content),
|
|
87
|
+
tests: extractTestMarkers(content),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Rebuild the spec index from every spec.md file under planu/specs/ of
|
|
92
|
+
* `projectPath` and persist it to planu/.runtime/index.json.
|
|
93
|
+
*
|
|
94
|
+
* spec.md is always the source of truth: this function re-reads and re-parses
|
|
95
|
+
* every spec.md on every call, so a record can never be stale relative to disk.
|
|
96
|
+
* A project with no spec.md files, or a deleted index.json, rebuilds cleanly.
|
|
97
|
+
*/
|
|
98
|
+
export async function loadSpecIndex(projectPath) {
|
|
99
|
+
const specFiles = await glob(join(projectPath, 'planu', 'specs', '*', 'spec.md'));
|
|
100
|
+
const parsed = await Promise.all(specFiles.map((specMdPath) => parseSpecRecord(projectPath, specMdPath)));
|
|
101
|
+
const specs = {};
|
|
102
|
+
for (const record of parsed) {
|
|
103
|
+
if (record) {
|
|
104
|
+
specs[record.id] = record;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const index = { generatedAt: new Date().toISOString(), specs };
|
|
108
|
+
// A read-only checkout (or a concurrent writer) must not turn a successful
|
|
109
|
+
// parse into a failed read — the caller gets the parsed result either way.
|
|
110
|
+
await writeJson(specIndexPath(projectPath), index).catch((error) => {
|
|
111
|
+
reportClassifiedDegradation('SPEC_INDEX_CACHE_PERSIST', error);
|
|
112
|
+
});
|
|
113
|
+
return index;
|
|
114
|
+
}
|
|
115
|
+
/** Read the last persisted index without rebuilding it. Used only for diagnostics. */
|
|
116
|
+
export async function readPersistedSpecIndex(projectPath) {
|
|
117
|
+
return readJson(specIndexPath(projectPath), { generatedAt: '', specs: {} });
|
|
118
|
+
}
|
|
119
|
+
/** List spec index records sorted by id — convenience for callers needing an array. */
|
|
120
|
+
export function listSpecIndexRecords(index) {
|
|
121
|
+
return Object.values(index.specs).sort((left, right) => left.id.localeCompare(right.id));
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=spec-index.js.map
|
|
@@ -29,8 +29,11 @@ export declare function invalidateSpecsCache(projectId: string): void;
|
|
|
29
29
|
export declare function discoverCanonicalSpecPath(specId: string, canonicalRoot: string): Promise<string | null>;
|
|
30
30
|
/**
|
|
31
31
|
* List every spec for a project.
|
|
32
|
+
*
|
|
33
|
+
* When `canonicalRoot` is supplied, every record's content-derived fields are
|
|
34
|
+
* verified against the current spec.md before being returned (SPEC-1699).
|
|
32
35
|
*/
|
|
33
|
-
export declare function listSpecs(projectId: string): Promise<Spec[]>;
|
|
36
|
+
export declare function listSpecs(projectId: string, canonicalRoot?: string): Promise<Spec[]>;
|
|
34
37
|
/**
|
|
35
38
|
* Run a read-modify side effect against a fresh spec snapshot while holding the
|
|
36
39
|
* same cross-process lock used by status mutations.
|
|
@@ -39,9 +42,11 @@ export declare function withFreshSpecsLock<T>(projectId: string, fn: (specs: Spe
|
|
|
39
42
|
/**
|
|
40
43
|
* Get a single spec by ID. Returns `null` when not found.
|
|
41
44
|
*
|
|
42
|
-
* When `canonicalRoot` is supplied
|
|
45
|
+
* When `canonicalRoot` is supplied: a legacy absolute `specPath`/`technicalPath`
|
|
43
46
|
* for this spec is migrated to its portable project-relative identity first
|
|
44
|
-
* (best-effort — a failed migration attempt never blocks the read)
|
|
47
|
+
* (best-effort — a failed migration attempt never blocks the read), and the
|
|
48
|
+
* returned record's content-derived fields are verified against the current
|
|
49
|
+
* spec.md before being returned (SPEC-1699).
|
|
45
50
|
*/
|
|
46
51
|
export declare function getSpec(projectId: string, specId: string, canonicalRoot?: string): Promise<Spec | null>;
|
|
47
52
|
/** Result of a {@link migrateLegacySpecPaths} run. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
2
2
|
import { CriticalJsonReadError, readCriticalJson, readJson, writeJson, projectDataDir, } from './base-store.js';
|
|
3
|
+
import { enforceRetention } from './retention.js';
|
|
3
4
|
import { decodeCriticalSchema, registerCriticalSchema } from './schema-registry.js';
|
|
4
5
|
import { BoundaryFailure } from '../errors/error-taxonomy.js';
|
|
5
6
|
import { withFileLock } from './file-mutex.js';
|
|
@@ -8,11 +9,13 @@ import { computeHealthScore } from '../engine/spec-health-scorer.js';
|
|
|
8
9
|
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
9
10
|
import { loadSpecContent } from '../engine/validation-loop.js';
|
|
10
11
|
import { createHash, randomUUID } from 'node:crypto';
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
11
13
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
12
14
|
import { glob } from 'glob';
|
|
13
15
|
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
14
16
|
import { acquireLock, releaseLock } from '../engine/safety/cross-process-lock.js';
|
|
15
|
-
import { resolvePortableSpecPath, toPortableSpecPath, PortablePathError, } from './project-identity.js';
|
|
17
|
+
import { resolvePortableSpecPath, resolveVerifiedSpecPath, toPortableSpecPath, PortablePathError, } from './project-identity.js';
|
|
18
|
+
import { digestOf, readFrontmatterScalars } from './spec-index.js';
|
|
16
19
|
import { appendEntry, getLastHash } from './audit-trail-store.js';
|
|
17
20
|
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
18
21
|
import { hashEntry } from '../engine/audit-trail/hasher.js';
|
|
@@ -200,6 +203,7 @@ async function saveAll(projectId, specs) {
|
|
|
200
203
|
const normalized = specs.map((spec) => normalizeSpec(spec));
|
|
201
204
|
validateCanonicalSpecRows(projectId, normalized);
|
|
202
205
|
await writeJson(specsFile(projectId), { schemaVersion: 1, data: normalized });
|
|
206
|
+
await enforceRetention({ kind: 'spec-store-backup', path: specsFile(projectId) });
|
|
203
207
|
specsCache.set(projectId, normalized);
|
|
204
208
|
}
|
|
205
209
|
/** Evict the cache for a project (e.g. after external file changes). */
|
|
@@ -221,12 +225,72 @@ export async function discoverCanonicalSpecPath(specId, canonicalRoot) {
|
|
|
221
225
|
});
|
|
222
226
|
return candidates.length === 1 ? (candidates[0] ?? null) : null;
|
|
223
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Persist a digest-refresh in isolation from whatever mutation the caller of
|
|
230
|
+
* getSpec/listSpecs is otherwise doing. Best-effort: failure to persist never
|
|
231
|
+
* blocks the caller — the freshly parsed value is already what gets returned.
|
|
232
|
+
*/
|
|
233
|
+
async function persistDigestRefresh(projectId, specId, title, contentDigest) {
|
|
234
|
+
await withSpecMutationLock(projectId, async () => {
|
|
235
|
+
const specs = await loadAll(projectId);
|
|
236
|
+
const idx = specs.findIndex((s) => s.id === specId);
|
|
237
|
+
if (idx === -1) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const nextSpecs = specs.map((current, currentIdx) => currentIdx === idx ? { ...current, title, contentDigest } : current);
|
|
241
|
+
await saveAll(projectId, nextSpecs);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* SPEC-1699: single-source-of-truth choke point. spec.md is authoritative for
|
|
246
|
+
* the fields it carries — before returning a record, verify the persisted
|
|
247
|
+
* `contentDigest` still matches the current spec.md on disk. On a mismatch or
|
|
248
|
+
* missing digest, re-parse spec.md and return the fresh title/digest instead
|
|
249
|
+
* of the possibly-stale specs.json copy. Requires `canonicalRoot` to locate
|
|
250
|
+
* spec.md; callers that omit it get the specs.json record unchecked (matching
|
|
251
|
+
* the existing canonicalRoot-gated legacy-migration behavior on this same path).
|
|
252
|
+
*/
|
|
253
|
+
async function refreshSpecFromDisk(projectId, spec, canonicalRoot) {
|
|
254
|
+
let specMdPath;
|
|
255
|
+
try {
|
|
256
|
+
specMdPath = await resolveVerifiedSpecPath(spec.id, spec.specPath, canonicalRoot);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
reportClassifiedDegradation('SPEC_CONTENT_DIGEST_CHECK_SKIPPED', error);
|
|
260
|
+
return spec;
|
|
261
|
+
}
|
|
262
|
+
let content;
|
|
263
|
+
try {
|
|
264
|
+
content = await readFile(specMdPath, 'utf-8');
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
reportClassifiedDegradation('SPEC_CONTENT_DIGEST_CHECK_SKIPPED', error);
|
|
268
|
+
return spec;
|
|
269
|
+
}
|
|
270
|
+
const digest = digestOf(content);
|
|
271
|
+
if (digest === spec.contentDigest) {
|
|
272
|
+
return spec;
|
|
273
|
+
}
|
|
274
|
+
const fields = readFrontmatterScalars(content);
|
|
275
|
+
const refreshed = { ...spec, title: fields.title ?? spec.title, contentDigest: digest };
|
|
276
|
+
await persistDigestRefresh(projectId, spec.id, refreshed.title, digest).catch((error) => {
|
|
277
|
+
reportClassifiedDegradation('SPEC_CONTENT_DIGEST_REFRESH_PERSIST', error);
|
|
278
|
+
});
|
|
279
|
+
return refreshed;
|
|
280
|
+
}
|
|
224
281
|
// --- public API ---
|
|
225
282
|
/**
|
|
226
283
|
* List every spec for a project.
|
|
284
|
+
*
|
|
285
|
+
* When `canonicalRoot` is supplied, every record's content-derived fields are
|
|
286
|
+
* verified against the current spec.md before being returned (SPEC-1699).
|
|
227
287
|
*/
|
|
228
|
-
export async function listSpecs(projectId) {
|
|
229
|
-
|
|
288
|
+
export async function listSpecs(projectId, canonicalRoot) {
|
|
289
|
+
const specs = await loadAll(projectId);
|
|
290
|
+
if (canonicalRoot === undefined) {
|
|
291
|
+
return specs;
|
|
292
|
+
}
|
|
293
|
+
return Promise.all(specs.map((spec) => refreshSpecFromDisk(projectId, spec, canonicalRoot)));
|
|
230
294
|
}
|
|
231
295
|
/**
|
|
232
296
|
* Run a read-modify side effect against a fresh spec snapshot while holding the
|
|
@@ -238,9 +302,11 @@ export async function withFreshSpecsLock(projectId, fn) {
|
|
|
238
302
|
/**
|
|
239
303
|
* Get a single spec by ID. Returns `null` when not found.
|
|
240
304
|
*
|
|
241
|
-
* When `canonicalRoot` is supplied
|
|
305
|
+
* When `canonicalRoot` is supplied: a legacy absolute `specPath`/`technicalPath`
|
|
242
306
|
* for this spec is migrated to its portable project-relative identity first
|
|
243
|
-
* (best-effort — a failed migration attempt never blocks the read)
|
|
307
|
+
* (best-effort — a failed migration attempt never blocks the read), and the
|
|
308
|
+
* returned record's content-derived fields are verified against the current
|
|
309
|
+
* spec.md before being returned (SPEC-1699).
|
|
244
310
|
*/
|
|
245
311
|
export async function getSpec(projectId, specId, canonicalRoot) {
|
|
246
312
|
if (canonicalRoot !== undefined) {
|
|
@@ -249,7 +315,11 @@ export async function getSpec(projectId, specId, canonicalRoot) {
|
|
|
249
315
|
});
|
|
250
316
|
}
|
|
251
317
|
const specs = await loadAll(projectId);
|
|
252
|
-
|
|
318
|
+
const spec = specs.find((s) => s.id === specId) ?? null;
|
|
319
|
+
if (spec && canonicalRoot !== undefined) {
|
|
320
|
+
return refreshSpecFromDisk(projectId, spec, canonicalRoot);
|
|
321
|
+
}
|
|
322
|
+
return spec;
|
|
253
323
|
}
|
|
254
324
|
/**
|
|
255
325
|
* Record one hash-chained audit event for a legacy specPath migration batch.
|
|
@@ -69,9 +69,9 @@ export function createStorageCatalog(context = {}) {
|
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
id: 'portable-release-evidence',
|
|
72
|
-
templatePath: '<project>/planu/releases
|
|
73
|
-
resolvedPath: portableRoot ? join(portableRoot, '
|
|
74
|
-
purpose: '
|
|
72
|
+
templatePath: '<project>/planu/project.json#releases',
|
|
73
|
+
resolvedPath: portableRoot ? join(portableRoot, 'project.json') : undefined,
|
|
74
|
+
purpose: 'Pending release declarations tracked in the releases field of project.json.',
|
|
75
75
|
owner: 'project',
|
|
76
76
|
sourceOfTruth: true,
|
|
77
77
|
lifecycle: 'portable',
|
|
@@ -19,6 +19,14 @@ export interface StorageLayoutOptions {
|
|
|
19
19
|
readonly homeDirectory?: string;
|
|
20
20
|
readonly platform?: NodeJS.Platform;
|
|
21
21
|
}
|
|
22
|
+
export type ProjectRuntimeSubdir = 'state' | 'cache' | 'logs' | 'reports';
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the per-project runtime directory for a subdir kind.
|
|
25
|
+
* Rooted at <projectPath>/planu/.runtime/ — the only per-project runtime root.
|
|
26
|
+
* Platform storage roots from resolveStorageLayout are machine-local and do not
|
|
27
|
+
* apply to per-project artifacts.
|
|
28
|
+
*/
|
|
29
|
+
export declare function projectRuntimeDir(projectPath: string, subdir: ProjectRuntimeSubdir): string;
|
|
22
30
|
/** Resolve every machine-local Planu path independently from the current directory. */
|
|
23
31
|
export declare function resolveStorageLayout(options?: StorageLayoutOptions): StorageLayout;
|
|
24
32
|
//# sourceMappingURL=storage-layout.d.ts.map
|
|
@@ -73,6 +73,15 @@ function resolvePlatformRoots(platform, home, env) {
|
|
|
73
73
|
}
|
|
74
74
|
return xdgRoots(home, env);
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the per-project runtime directory for a subdir kind.
|
|
78
|
+
* Rooted at <projectPath>/planu/.runtime/ — the only per-project runtime root.
|
|
79
|
+
* Platform storage roots from resolveStorageLayout are machine-local and do not
|
|
80
|
+
* apply to per-project artifacts.
|
|
81
|
+
*/
|
|
82
|
+
export function projectRuntimeDir(projectPath, subdir) {
|
|
83
|
+
return join(projectPath, 'planu', '.runtime', subdir);
|
|
84
|
+
}
|
|
76
85
|
/** Resolve every machine-local Planu path independently from the current directory. */
|
|
77
86
|
export function resolveStorageLayout(options = {}) {
|
|
78
87
|
const env = options.env ?? process.env;
|
|
@@ -9,6 +9,7 @@ import { appendFile, mkdir } from 'node:fs/promises';
|
|
|
9
9
|
import { createReadStream } from 'node:fs';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { projectDataDir } from './base-store.js';
|
|
12
|
+
import { enforceRetention } from './retention.js';
|
|
12
13
|
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Path helper
|
|
@@ -56,6 +57,7 @@ function computeEntrySha(entry) {
|
|
|
56
57
|
const writeQueues = new Map();
|
|
57
58
|
async function doAppend(filePath, input) {
|
|
58
59
|
await mkdir(dirname(filePath), { recursive: true });
|
|
60
|
+
await enforceRetention({ kind: 'transition-log', path: filePath });
|
|
59
61
|
let lastLine;
|
|
60
62
|
for await (const line of streamJsonlLines(filePath)) {
|
|
61
63
|
lastLine = line;
|