@planu/cli 5.5.3 → 5.7.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 (118) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/.planu-build.json +1 -1
  3. package/dist/cli/commands/telemetry.d.ts +3 -0
  4. package/dist/cli/commands/telemetry.js +118 -0
  5. package/dist/cli/router.js +3 -1
  6. package/dist/config/environment-schema.json +14 -0
  7. package/dist/engine/autopilot/bootstrap.js +1 -1
  8. package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
  9. package/dist/engine/contradiction-detector.d.ts +2 -1
  10. package/dist/engine/contradiction-detector.js +215 -0
  11. package/dist/engine/detection-utils.d.ts +1 -0
  12. package/dist/engine/detection-utils.js +33 -0
  13. package/dist/engine/doc-generator/portal/index.d.ts +1 -1
  14. package/dist/engine/doc-generator/portal/index.js +1 -1
  15. package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
  16. package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
  17. package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
  18. package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
  19. package/dist/engine/framework-detector.js +8 -6
  20. package/dist/engine/handoff-artifacts/schemas.js +4 -0
  21. package/dist/engine/housekeeping/history-log.d.ts +1 -0
  22. package/dist/engine/housekeeping/history-log.js +58 -3
  23. package/dist/engine/housekeeping/index.d.ts +2 -1
  24. package/dist/engine/housekeeping/index.js +2 -1
  25. package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
  26. package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
  27. package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
  28. package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
  29. package/dist/engine/lifecycle-reconciliation.js +87 -40
  30. package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
  31. package/dist/engine/next-spec-resolver/session-writer.js +1 -1
  32. package/dist/engine/project-graph/cache.js +23 -3
  33. package/dist/engine/readiness-checker.js +14 -2
  34. package/dist/engine/sdd-flow/checkpoints.js +29 -2
  35. package/dist/engine/session/checkpoint-writer.d.ts +1 -1
  36. package/dist/engine/session/checkpoint-writer.js +6 -5
  37. package/dist/engine/session-state/writer.js +4 -3
  38. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  39. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
  40. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
  41. package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
  42. package/dist/engine/telemetry/error-reporter.d.ts +9 -9
  43. package/dist/engine/telemetry/error-reporter.js +15 -34
  44. package/dist/engine/telemetry/event-envelope.d.ts +11 -0
  45. package/dist/engine/telemetry/event-envelope.js +124 -0
  46. package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
  47. package/dist/engine/telemetry/telemetry-client.js +38 -20
  48. package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
  49. package/dist/engine/telemetry/telemetry-store.js +73 -2
  50. package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
  51. package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
  52. package/dist/engine/validator/spec-compliance-runner.js +123 -1
  53. package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
  54. package/dist/index.js +26 -0
  55. package/dist/resources/specs.js +12 -33
  56. package/dist/storage/current-project.d.ts +3 -0
  57. package/dist/storage/current-project.js +21 -0
  58. package/dist/storage/index.d.ts +1 -0
  59. package/dist/storage/index.js +1 -0
  60. package/dist/storage/migrations/canonical-storage.js +5 -0
  61. package/dist/storage/retention.d.ts +14 -0
  62. package/dist/storage/retention.js +279 -0
  63. package/dist/storage/spec-index.d.ts +23 -0
  64. package/dist/storage/spec-index.js +123 -0
  65. package/dist/storage/spec-store.d.ts +8 -3
  66. package/dist/storage/spec-store.js +76 -6
  67. package/dist/storage/storage-catalog.js +3 -3
  68. package/dist/storage/storage-layout.d.ts +8 -0
  69. package/dist/storage/storage-layout.js +9 -0
  70. package/dist/storage/transition-log.js +2 -0
  71. package/dist/tools/challenge-spec.js +35 -19
  72. package/dist/tools/create-spec.js +10 -0
  73. package/dist/tools/execute-sdd-flow.js +11 -2
  74. package/dist/tools/export-spec.js +2 -1
  75. package/dist/tools/force-status-analytics.js +2 -1
  76. package/dist/tools/generate-docs-site.js +2 -1
  77. package/dist/tools/generate-proposal.js +6 -2
  78. package/dist/tools/init-project/claude-md-generator.js +19 -2
  79. package/dist/tools/init-project/conventions-writer.d.ts +5 -2
  80. package/dist/tools/init-project/conventions-writer.js +18 -13
  81. package/dist/tools/init-project/git-setup.js +9 -0
  82. package/dist/tools/init-project/handler.js +9 -1
  83. package/dist/tools/init-project/legacy-planu.d.ts +2 -0
  84. package/dist/tools/init-project/legacy-planu.js +18 -0
  85. package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
  86. package/dist/tools/init-project/legacy-root-migration.js +213 -0
  87. package/dist/tools/init-project/runtime-residue.d.ts +2 -0
  88. package/dist/tools/init-project/runtime-residue.js +11 -0
  89. package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
  90. package/dist/tools/init-project/schedule-housekeeping.js +8 -0
  91. package/dist/tools/reconcile-spec.js +29 -2
  92. package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
  93. package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
  94. package/dist/tools/safe-handler.js +6 -12
  95. package/dist/tools/session-checkpoint.js +1 -1
  96. package/dist/tools/update-status/index.js +7 -1
  97. package/dist/tools/update-status-actions.d.ts +7 -1
  98. package/dist/tools/update-status-actions.js +10 -2
  99. package/dist/types/handoff-artifacts.d.ts +1 -0
  100. package/dist/types/housekeeping.d.ts +38 -0
  101. package/dist/types/housekeeping.js +0 -1
  102. package/dist/types/index.d.ts +1 -0
  103. package/dist/types/index.js +1 -0
  104. package/dist/types/retention.d.ts +12 -0
  105. package/dist/types/retention.js +3 -0
  106. package/dist/types/scope.d.ts +23 -0
  107. package/dist/types/spec/core.d.ts +7 -0
  108. package/dist/types/spec/index-cache.d.ts +25 -0
  109. package/dist/types/spec/index-cache.js +3 -0
  110. package/dist/types/spec/index.d.ts +1 -0
  111. package/dist/types/spec/index.js +1 -0
  112. package/dist/types/spec/inputs.d.ts +9 -0
  113. package/dist/types/spec-format.d.ts +1 -0
  114. package/dist/types/telemetry.d.ts +39 -1
  115. package/dist/types/validation-evidence.d.ts +6 -0
  116. package/package.json +1 -1
  117. package/planu-plugin.json +1 -1
  118. package/scripts/lib/pending-release-file.mjs +20 -4
@@ -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 specs = [];
60
- try {
61
- const files = await glob(join(resolveStorageLayout().projects, '*', 'specs.json'));
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
- return specs;
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/index.js CHANGED
@@ -84,6 +84,27 @@ function scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasks
84
84
  recoverValidateJobsAtStartup();
85
85
  });
86
86
  }
87
+ const MCP_HOST_MARKERS = [
88
+ ['claude-code', 'claude-code'],
89
+ ['claude-code', 'claude code'],
90
+ ['claude-desktop', 'claude-desktop'],
91
+ ['claude-desktop', 'claude desktop'],
92
+ ['cursor', 'cursor'],
93
+ ['codex', 'codex'],
94
+ ];
95
+ /** Maps the MCP client's reported implementation name to the envelope's allowlisted mcpHost. */
96
+ function resolveMcpHost(clientName) {
97
+ if (!clientName) {
98
+ return 'unknown';
99
+ }
100
+ const normalized = clientName.toLowerCase();
101
+ for (const [host, marker] of MCP_HOST_MARKERS) {
102
+ if (normalized.includes(marker)) {
103
+ return host;
104
+ }
105
+ }
106
+ return 'other';
107
+ }
87
108
  // Loads post-handshake modules after the MCP handshake has completed.
88
109
  // Separated from main() to keep main() within the 80-line function budget.
89
110
  async function setupPostHandshake(handshakeGate, officialToolNames) {
@@ -109,6 +130,11 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
109
130
  if (clientVersion) {
110
131
  setConnectedClient(clientVersion);
111
132
  }
133
+ // SPEC-1704: emit the mcp_server_started envelope now that the client is known.
134
+ const { sendTelemetryEnvelopeEvent } = await import('./engine/telemetry/telemetry-client.js');
135
+ sendTelemetryEnvelopeEvent('mcp_server_started', {
136
+ mcpHost: resolveMcpHost(clientVersion?.name),
137
+ });
112
138
  // SPEC-251: Check npm registry for newer version (fire-and-forget, 24h cache)
113
139
  checkForUpdates().catch(() => {
114
140
  /* best-effort */
@@ -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 the data store.
3
- // Storage format: data/projects/{projectId}/specs.json (single array per project)
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 { resolveStorageLayout } from '../storage/storage-layout.js';
8
- import { join } from 'node:path';
5
+ import { loadSpecIndex, listSpecIndexRecords } from '../storage/spec-index.js';
6
+ import { resolveCurrentProjectPath } from '../storage/current-project.js';
9
7
  /**
10
- * Scan the data/projects/ directory for all specs.json files and return them.
11
- * Each project stores ALL specs in a single data/projects/{projectId}/specs.json as an array.
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 loadAllSpecs() {
14
- const specs = [];
15
- try {
16
- const specFiles = await glob(join(resolveStorageLayout().projects, '*', 'specs.json'));
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
- catch {
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 loadAllSpecs();
22
+ const specs = await loadCurrentProjectSpecs();
44
23
  if (specs.length === 0) {
45
24
  return JSON.stringify({
46
25
  count: 0,
@@ -0,0 +1,3 @@
1
+ /** Return process.cwd() when it is an initialized Planu project, else null. */
2
+ export declare function resolveCurrentProjectPath(cwd?: string): Promise<string | null>;
3
+ //# sourceMappingURL=current-project.d.ts.map
@@ -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
@@ -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
@@ -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
@@ -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