@planu/cli 5.5.0 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/.planu-build.json +1 -1
  3. package/dist/engine/autopilot/bootstrap.js +1 -1
  4. package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
  5. package/dist/engine/detection-utils.d.ts +1 -0
  6. package/dist/engine/detection-utils.js +33 -0
  7. package/dist/engine/doc-generator/portal/index.d.ts +1 -1
  8. package/dist/engine/doc-generator/portal/index.js +1 -1
  9. package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
  10. package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
  11. package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
  12. package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
  13. package/dist/engine/framework-detector.js +8 -6
  14. package/dist/engine/git/git-command-guard.d.ts +2 -0
  15. package/dist/engine/git/git-command-guard.js +43 -0
  16. package/dist/engine/git/planu-autocommit.js +2 -0
  17. package/dist/engine/handoff-packager.js +38 -2
  18. package/dist/engine/housekeeping/history-log.d.ts +1 -0
  19. package/dist/engine/housekeeping/history-log.js +58 -3
  20. package/dist/engine/housekeeping/index.d.ts +2 -1
  21. package/dist/engine/housekeeping/index.js +2 -1
  22. package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
  23. package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
  24. package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
  25. package/dist/engine/next-spec-resolver/session-writer.js +1 -1
  26. package/dist/engine/project-graph/cache.js +23 -3
  27. package/dist/engine/readiness-checker.js +1 -1
  28. package/dist/engine/sdd-flow/checkpoints.js +29 -2
  29. package/dist/engine/session/checkpoint-writer.d.ts +1 -1
  30. package/dist/engine/session/checkpoint-writer.js +6 -5
  31. package/dist/engine/session-state/writer.js +4 -3
  32. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  33. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
  34. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
  35. package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
  36. package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
  37. package/dist/engine/validator/spec-compliance-runner.js +45 -0
  38. package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
  39. package/dist/resources/specs.js +12 -33
  40. package/dist/storage/current-project.d.ts +3 -0
  41. package/dist/storage/current-project.js +21 -0
  42. package/dist/storage/index.d.ts +1 -0
  43. package/dist/storage/index.js +1 -0
  44. package/dist/storage/migrations/canonical-storage.js +5 -0
  45. package/dist/storage/retention.d.ts +14 -0
  46. package/dist/storage/retention.js +279 -0
  47. package/dist/storage/spec-index.d.ts +23 -0
  48. package/dist/storage/spec-index.js +123 -0
  49. package/dist/storage/spec-store.d.ts +8 -3
  50. package/dist/storage/spec-store.js +76 -6
  51. package/dist/storage/storage-catalog.js +3 -3
  52. package/dist/storage/storage-layout.d.ts +8 -0
  53. package/dist/storage/storage-layout.js +9 -0
  54. package/dist/storage/transition-log.js +2 -0
  55. package/dist/tools/challenge-spec.js +10 -9
  56. package/dist/tools/create-spec.js +10 -0
  57. package/dist/tools/execute-sdd-flow.js +11 -2
  58. package/dist/tools/export-spec.js +2 -1
  59. package/dist/tools/force-status-analytics.js +2 -1
  60. package/dist/tools/generate-docs-site.js +2 -1
  61. package/dist/tools/generate-proposal.js +6 -2
  62. package/dist/tools/git/git-helpers.js +2 -0
  63. package/dist/tools/init-project/claude-md-generator.js +19 -2
  64. package/dist/tools/init-project/conventions-writer.d.ts +5 -2
  65. package/dist/tools/init-project/conventions-writer.js +18 -13
  66. package/dist/tools/init-project/git-setup.js +9 -0
  67. package/dist/tools/init-project/handler.js +10 -1
  68. package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
  69. package/dist/tools/init-project/legacy-root-migration.js +213 -0
  70. package/dist/tools/init-project/portable-index-reconciler.d.ts +1 -1
  71. package/dist/tools/init-project/portable-index-reconciler.js +109 -4
  72. package/dist/tools/init-project/runtime-residue.d.ts +2 -0
  73. package/dist/tools/init-project/runtime-residue.js +11 -0
  74. package/dist/tools/session-checkpoint.js +1 -1
  75. package/dist/tools/update-status/index.js +7 -1
  76. package/dist/tools/update-status-actions.d.ts +7 -1
  77. package/dist/tools/update-status-actions.js +10 -2
  78. package/dist/types/housekeeping.d.ts +4 -0
  79. package/dist/types/index.d.ts +1 -0
  80. package/dist/types/index.js +1 -0
  81. package/dist/types/retention.d.ts +12 -0
  82. package/dist/types/retention.js +3 -0
  83. package/dist/types/spec/core.d.ts +7 -0
  84. package/dist/types/spec/index-cache.d.ts +25 -0
  85. package/dist/types/spec/index-cache.js +3 -0
  86. package/dist/types/spec/index.d.ts +1 -0
  87. package/dist/types/spec/index.js +1 -0
  88. package/dist/types/spec/inputs.d.ts +2 -1
  89. package/dist/types/spec-format.d.ts +1 -0
  90. package/package.json +1 -1
  91. package/planu-plugin.json +1 -1
  92. package/scripts/lib/pending-release-file.mjs +20 -4
@@ -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
@@ -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, a legacy absolute `specPath`/`technicalPath`
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
- return loadAll(projectId);
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, a legacy absolute `specPath`/`technicalPath`
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
- return specs.find((s) => s.id === specId) ?? null;
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, 'releases') : undefined,
74
- purpose: 'Versioned release declarations and portable verification evidence.',
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;