@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
@@ -0,0 +1,57 @@
1
+ // engine/housekeeping/runtime-residue-sweep.ts — SPEC-1695
2
+ // Removes legacy runtime artifacts that PLANU_CANONICAL_POLICY already
3
+ // classifies as regenerable (generatedRuntimePatterns), so a client checkout
4
+ // self-heals on init_project / session start instead of accumulating residue.
5
+ import { rm } from 'node:fs/promises';
6
+ import { join, relative, sep } from 'node:path';
7
+ import { glob } from 'glob';
8
+ import { PLANU_CANONICAL_POLICY, isCanonicalPlanuRootFile, } from '../spec-migrator/planu-canonical-policy.js';
9
+ import { migrateLegacyHousekeepingHistory } from './history-log.js';
10
+ const HOUSEKEEPING_HISTORY_PATTERN = 'planu/.housekeeping-history.jsonl';
11
+ const RUNTIME_HOME_TOP_DIR = PLANU_CANONICAL_POLICY.runtimeHomeDir
12
+ .replace(/^planu\//, '')
13
+ .replace(/\/$/, '');
14
+ function isProtectedPath(planuDir, absolutePath) {
15
+ const rel = relative(planuDir, absolutePath);
16
+ if (rel === '' || rel.startsWith('..')) {
17
+ return true;
18
+ }
19
+ const top = rel.split(sep)[0] ?? '';
20
+ if (isCanonicalPlanuRootFile(top)) {
21
+ return true;
22
+ }
23
+ return top === RUNTIME_HOME_TOP_DIR || rel === 'releases' || rel === 'specs';
24
+ }
25
+ async function removeMatches(projectPath, pattern) {
26
+ const planuDir = join(projectPath, 'planu');
27
+ const relativePattern = pattern.replace(/^planu\//, '').replace(/\/$/, '');
28
+ const matches = await glob(relativePattern, { cwd: planuDir, dot: true, absolute: true });
29
+ const removed = [];
30
+ for (const absolutePath of matches) {
31
+ if (isProtectedPath(planuDir, absolutePath)) {
32
+ continue;
33
+ }
34
+ await rm(absolutePath, { recursive: true, force: true });
35
+ removed.push(absolutePath);
36
+ }
37
+ return removed;
38
+ }
39
+ /**
40
+ * Delete every legacy runtime-residue path from planu/ without ever touching
41
+ * canonicalRootFiles / canonicalRootDirs. Idempotent — a checkout with no
42
+ * residue returns an empty removed list. Wired into init_project and the
43
+ * session-start housekeeping path so existing client checkouts self-heal.
44
+ */
45
+ export async function sweepRuntimeResidue(projectPath) {
46
+ const removed = [];
47
+ const patterns = PLANU_CANONICAL_POLICY.generatedRuntimePatterns.filter((pattern) => pattern !== HOUSEKEEPING_HISTORY_PATTERN);
48
+ for (const pattern of patterns) {
49
+ removed.push(...(await removeMatches(projectPath, pattern)));
50
+ }
51
+ const migratedHistoryPath = await migrateLegacyHousekeepingHistory(projectPath);
52
+ if (migratedHistoryPath) {
53
+ removed.push(migratedHistoryPath);
54
+ }
55
+ return { removed };
56
+ }
57
+ //# sourceMappingURL=runtime-residue-sweep.js.map
@@ -196,7 +196,7 @@ function normalizeLegacyTier(value) {
196
196
  }
197
197
  async function readPlanVersion(projectPath) {
198
198
  try {
199
- const raw = await readFile(join(projectPath, 'planu', 'session.json'), 'utf-8');
199
+ const raw = await readFile(join(projectPath, 'planu', '.runtime', 'session.json'), 'utf-8');
200
200
  const index = JSON.parse(raw);
201
201
  return index.orchestrationPlan?.planVersion ?? 0;
202
202
  }
@@ -68,7 +68,7 @@ export async function writeOrchestrationPlan(projectPath, plan) {
68
68
  */
69
69
  export async function patchSessionJson(projectPath, patch) {
70
70
  try {
71
- const filePath = join(projectPath, 'planu', 'session.json');
71
+ const filePath = join(projectPath, 'planu', '.runtime', 'session.json');
72
72
  let existingContent = null;
73
73
  let current = { activeSpecs: [], updatedAt: '' };
74
74
  try {
@@ -8,6 +8,8 @@ import { setTimeout as delay } from 'node:timers/promises';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
10
10
  import { projectDataDir, readJson, writeJson } from '../../storage/base-store.js';
11
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
12
+ import { enforceRetention } from '../../storage/retention.js';
11
13
  import { LockBusyError, withSpecLock } from '../safety/cross-process-lock.js';
12
14
  const POLICY_PATH = join(dirname(fileURLToPath(import.meta.url)), '../../config/project-knowledge-graph.json');
13
15
  const CACHE_VERSION = 2;
@@ -200,7 +202,13 @@ export async function readProjectGraph(projectId, policy) {
200
202
  return null;
201
203
  }
202
204
  export async function writeProjectGraph(projectId, policy, graph) {
203
- await writeJson(projectGraphPath(projectId, policy), graph);
205
+ const graphPath = projectGraphPath(projectId, policy);
206
+ await writeJson(graphPath, graph);
207
+ await enforceRetention({
208
+ kind: 'graph-cache',
209
+ path: projectGraphDir(projectId, policy),
210
+ protectedPaths: [graphPath],
211
+ });
204
212
  }
205
213
  export async function readProjectGraphSourceHashes(projectId, policy) {
206
214
  const active = activeBuilds.get(projectGraphDir(projectId, policy));
@@ -217,6 +225,11 @@ export async function writeProjectGraphSourceHashes(projectId, policy, hashes) {
217
225
  records: hashes,
218
226
  fragments: current.cache?.fragments ?? {},
219
227
  });
228
+ await enforceRetention({
229
+ kind: 'graph-cache',
230
+ path: projectGraphDir(projectId, policy),
231
+ protectedPaths: [projectGraphCachePath(projectId, policy)],
232
+ });
220
233
  }
221
234
  export async function writeProjectGraphArtifacts(args) {
222
235
  if (args.graph.generation.length === 0 ||
@@ -225,8 +238,15 @@ export async function writeProjectGraphArtifacts(args) {
225
238
  throw new Error('[Planu] Project graph/cache generation mismatch');
226
239
  }
227
240
  // Readers verify the shared generation, so neither write order can expose a mixed snapshot.
228
- await writeJson(projectGraphCachePath(args.projectId, args.policy), args.cache);
229
- await writeJson(projectGraphPath(args.projectId, args.policy), args.graph);
241
+ const cachePath = projectGraphCachePath(args.projectId, args.policy);
242
+ const graphPath = projectGraphPath(args.projectId, args.policy);
243
+ await writeJson(cachePath, args.cache);
244
+ await writeJson(graphPath, args.graph);
245
+ await enforceRetention({
246
+ kind: 'graph-cache',
247
+ path: projectGraphDir(args.projectId, args.policy),
248
+ protectedPaths: [cachePath, graphPath],
249
+ });
230
250
  }
231
251
  export async function withProjectGraphBuildLock(projectId, policy, task) {
232
252
  const key = projectGraphDir(projectId, policy);
@@ -428,7 +428,7 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
428
428
  if (bddScenarioCount > 0) {
429
429
  const scenariosWithoutTests = findScenariosWithoutTests(huRaw);
430
430
  for (const title of scenariosWithoutTests) {
431
- allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries (SPEC-732)`);
431
+ allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries as a block list (tests: with one "- path" line per test) or an inline array (tests: ["path"]) (SPEC-732)`);
432
432
  }
433
433
  }
434
434
  // SPEC-1214: BDD-criteria executable-evidence gate — when a spec has no frontmatter
@@ -2,12 +2,28 @@
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
6
+ import { projectRuntimeDir } from '../../storage/storage-layout.js';
7
+ const RUN_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
8
+ function assertValidRunId(runId) {
9
+ if (!RUN_ID_PATTERN.test(runId)) {
10
+ throw new Error(`Invalid runId: "${runId}". Run IDs must match ${RUN_ID_PATTERN.source} (no path separators).`);
11
+ }
12
+ }
5
13
  function getStateDir(projectPath) {
14
+ return projectRuntimeDir(projectPath, 'state');
15
+ }
16
+ function getLegacyStateDir(projectPath) {
6
17
  return join(projectPath, 'planu', 'state');
7
18
  }
8
19
  function getStatePath(projectPath, runId) {
20
+ assertValidRunId(runId);
9
21
  return join(getStateDir(projectPath), `sdd-flow-${runId}.json`);
10
22
  }
23
+ function getLegacyStatePath(projectPath, runId) {
24
+ assertValidRunId(runId);
25
+ return join(getLegacyStateDir(projectPath), `sdd-flow-${runId}.json`);
26
+ }
11
27
  export async function saveCheckpoint(projectPath, state) {
12
28
  const dir = getStateDir(projectPath);
13
29
  if (!existsSync(dir)) {
@@ -18,11 +34,22 @@ export async function saveCheckpoint(projectPath, state) {
18
34
  }
19
35
  export async function loadCheckpoint(projectPath, runId) {
20
36
  const path = getStatePath(projectPath, runId);
21
- if (!existsSync(path)) {
37
+ if (existsSync(path)) {
38
+ try {
39
+ const raw = await readFile(path, 'utf-8');
40
+ return JSON.parse(raw);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ // SPEC-1695: one-time legacy fallback for runs persisted before the relocation.
47
+ const legacyPath = getLegacyStatePath(projectPath, runId);
48
+ if (!existsSync(legacyPath)) {
22
49
  return null;
23
50
  }
24
51
  try {
25
- const raw = await readFile(path, 'utf-8');
52
+ const raw = await readFile(legacyPath, 'utf-8');
26
53
  return JSON.parse(raw);
27
54
  }
28
55
  catch {
@@ -4,7 +4,7 @@ export declare function buildCheckpointBlock(snapshot: AutoSessionSnapshot): str
4
4
  /** Remove old checkpoint sections and prepend the new one to keep the file small. */
5
5
  export declare function replaceLatestCheckpoint(existing: string, newBlock: string): string;
6
6
  /**
7
- * Write a compact checkpoint to `<projectPath>/planu/session-context.md`.
7
+ * Write a compact checkpoint to `<projectPath>/planu/.runtime/session-context.md`.
8
8
  * Replaces any previous checkpoint section — idempotent.
9
9
  * May throw — callers should catch or use maybeWriteCheckpoint.
10
10
  */
@@ -1,5 +1,6 @@
1
1
  // engine/session/checkpoint-writer.ts — Writes a compact session checkpoint to
2
- // planu/session-context.md (SPEC-563). Fire-and-forget safe — never throws to caller.
2
+ // planu/.runtime/session-context.md (SPEC-563, relocated under SPEC-1699).
3
+ // Fire-and-forget safe — never throws to caller.
3
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
5
6
  import { sessionTracker } from './session-tracker.js';
@@ -56,13 +57,13 @@ export function replaceLatestCheckpoint(existing, newBlock) {
56
57
  return `${newBlock}\n${trimmed}`.trimEnd() + '\n';
57
58
  }
58
59
  /**
59
- * Write a compact checkpoint to `<projectPath>/planu/session-context.md`.
60
+ * Write a compact checkpoint to `<projectPath>/planu/.runtime/session-context.md`.
60
61
  * Replaces any previous checkpoint section — idempotent.
61
62
  * May throw — callers should catch or use maybeWriteCheckpoint.
62
63
  */
63
64
  export async function writeCheckpoint(projectPath, snapshot) {
64
- const planuDir = join(projectPath, 'planu');
65
- const filePath = join(planuDir, 'session-context.md');
65
+ const runtimeDir = join(projectPath, 'planu', '.runtime');
66
+ const filePath = join(runtimeDir, 'session-context.md');
66
67
  let existing = '';
67
68
  try {
68
69
  existing = await readFile(filePath, 'utf8');
@@ -72,7 +73,7 @@ export async function writeCheckpoint(projectPath, snapshot) {
72
73
  }
73
74
  const newBlock = buildCheckpointBlock(snapshot);
74
75
  const updated = replaceLatestCheckpoint(existing, newBlock);
75
- await mkdir(planuDir, { recursive: true });
76
+ await mkdir(runtimeDir, { recursive: true });
76
77
  await writeFile(filePath, updated, 'utf8');
77
78
  }
78
79
  /**
@@ -4,16 +4,17 @@ import { readFile, mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { execSync } from 'node:child_process';
6
6
  import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
7
- const STATE_DIR = 'state';
7
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
8
+ import { projectRuntimeDir } from '../../storage/storage-layout.js';
8
9
  const ROOT_SESSION = 'session.json';
9
10
  function stateDir(projectPath) {
10
- return join(projectPath, 'planu', STATE_DIR);
11
+ return projectRuntimeDir(projectPath, 'state');
11
12
  }
12
13
  function specFile(projectPath, specId) {
13
14
  return join(stateDir(projectPath), `session-${specId}.json`);
14
15
  }
15
16
  export function rootFile(projectPath) {
16
- return join(projectPath, 'planu', ROOT_SESSION);
17
+ return join(projectPath, 'planu', '.runtime', ROOT_SESSION);
17
18
  }
18
19
  function getModifiedFiles(projectPath) {
19
20
  try {
@@ -166,7 +166,7 @@ function extractCheckboxCriteria(description) {
166
166
  function extractGivenWhenThenCriteria(description) {
167
167
  const criteria = [];
168
168
  // Single-line: GIVEN ... WHEN ... THEN ...
169
- const singleLineRegex = /^[ \t]*(?:[-*>]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
169
+ const singleLineRegex = /^[ \t]*(?:[-*>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
170
170
  let match = singleLineRegex.exec(description);
171
171
  while (match) {
172
172
  const thenText = match[1]?.trim() ?? '';
@@ -5,6 +5,13 @@ export declare function isCanonicalPlanuRootDir(name: string): boolean;
5
5
  export declare function isCanonicalSpecFile(name: string): boolean;
6
6
  export declare function isCanonicalSpecDir(name: string): boolean;
7
7
  export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
8
- export declare function isCanonicalReleaseFile(relativeToPlanu: string): boolean;
8
+ /**
9
+ * Legacy-transition carve-out ONLY: strict-planu-cleanup.ts still walks a
10
+ * not-yet-migrated planu/releases/ directory and must not delete
11
+ * pending.json before the migration folds it into project.json's
12
+ * `releases` field. This is not part of the canonical contract — see
13
+ * canonicalContractText().
14
+ */
15
+ export declare function isLegacyReleasePendingFile(relativeToPlanu: string): boolean;
9
16
  export declare function canonicalContractText(): string;
10
17
  //# sourceMappingURL=planu-canonical-policy.d.ts.map
@@ -14,18 +14,12 @@ const LEGACY_CONTENT_BEARING_PATTERNS = legacyArtifactRulesLoader
14
14
  .filter((rule) => rule.classification === 'content-bearing')
15
15
  .map((rule) => rule.pattern);
16
16
  export const PLANU_CANONICAL_POLICY = {
17
- canonicalRootFiles: [
18
- 'conventions.json',
19
- 'context.md',
20
- 'project.json',
21
- 'session-context.md',
22
- 'session.json',
23
- 'technology-selection.json',
24
- ],
25
- canonicalRootDirs: ['releases', 'specs'],
17
+ canonicalRootFiles: ['project.json'],
18
+ canonicalRootDirs: ['specs'],
26
19
  canonicalSpecFiles: ['spec.md'],
27
20
  canonicalSpecDirs: [],
28
21
  forbiddenHostAssetRootDirs: ['agents', 'skills', 'rules', 'hooks'],
22
+ runtimeHomeDir: 'planu/.runtime/',
29
23
  generatedRuntimePatterns: [
30
24
  'planu/index.html',
31
25
  'planu/roadmap.html',
@@ -36,6 +30,7 @@ export const PLANU_CANONICAL_POLICY = {
36
30
  'planu/handoffs/',
37
31
  'planu/data/',
38
32
  'planu/state/',
33
+ 'planu/reports/',
39
34
  'planu/.locks/',
40
35
  'planu/specs/data/',
41
36
  'planu/specs/*/technical-report.html',
@@ -68,7 +63,14 @@ export function isCanonicalSpecDir(name) {
68
63
  export function mustMergeBeforeDeleteSpecFile(name) {
69
64
  return LEGACY_MERGE_SET.has(name);
70
65
  }
71
- export function isCanonicalReleaseFile(relativeToPlanu) {
66
+ /**
67
+ * Legacy-transition carve-out ONLY: strict-planu-cleanup.ts still walks a
68
+ * not-yet-migrated planu/releases/ directory and must not delete
69
+ * pending.json before the migration folds it into project.json's
70
+ * `releases` field. This is not part of the canonical contract — see
71
+ * canonicalContractText().
72
+ */
73
+ export function isLegacyReleasePendingFile(relativeToPlanu) {
72
74
  return relativeToPlanu === 'releases/pending.json';
73
75
  }
74
76
  export function canonicalContractText() {
@@ -76,15 +78,14 @@ export function canonicalContractText() {
76
78
  'planu/',
77
79
  ' conventions.json',
78
80
  ' context.md',
79
- ' project.json',
81
+ ' project.json (pending release records live in its `releases` field)',
80
82
  ' session-context.md',
81
83
  ' session.json',
82
84
  ' technology-selection.json',
83
- ' releases/',
84
- ' pending.json',
85
85
  ' specs/',
86
86
  ' SPEC-XXX-slug/',
87
87
  ' spec.md',
88
+ ' .runtime/ (generated runtime home — session/index data, not committed)',
88
89
  '',
89
90
  'Evidence artifacts are Planu runtime data and are stored outside planu/specs/.',
90
91
  '',
@@ -8,8 +8,21 @@ import { dirname, isAbsolute, join, relative } from 'node:path';
8
8
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
9
9
  import { safeUnlink } from './git-aware-fs.js';
10
10
  import { scanLegacyArtifacts } from './legacy-classifier.js';
11
- import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
11
+ import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isLegacyReleasePendingFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
12
12
  const execFileAsync = promisify(execFile);
13
+ // SPEC-1699: legacy planu/ root files folded into project.json by init_project's
14
+ // migration. Destructive cleanup must never delete them outright — only the
15
+ // migration step, which reads their content first, may retire them.
16
+ const PENDING_ROOT_MIGRATION_FILES = new Set([
17
+ 'conventions.json',
18
+ 'context.md',
19
+ 'session-context.md',
20
+ 'session.json',
21
+ 'technology-selection.json',
22
+ ]);
23
+ function isPendingRootMigrationFile(entry) {
24
+ return PENDING_ROOT_MIGRATION_FILES.has(entry);
25
+ }
13
26
  async function pathIsDirectory(path) {
14
27
  try {
15
28
  return (await stat(path)).isDirectory();
@@ -176,11 +189,23 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
176
189
  for (const entry of entries) {
177
190
  const full = join(planuDir, entry);
178
191
  const isDir = await pathIsDirectory(full);
192
+ if (isDir && entry === 'releases') {
193
+ // SPEC-1699: 'releases' is no longer a canonical root dir, but its
194
+ // pending.json still needs the dedicated fold-into-project.json
195
+ // migration below before its directory can be removed.
196
+ continue;
197
+ }
179
198
  if (isDir && !isCanonicalPlanuRootDir(entry)) {
180
199
  await removePath(projectPath, full);
181
200
  result.deleted.push(relative(projectPath, full));
182
201
  continue;
183
202
  }
203
+ if (!isDir && isPendingRootMigrationFile(entry)) {
204
+ // SPEC-1699: content-bearing legacy root file — init_project's migration
205
+ // folds it into project.json before this cleanup may delete it.
206
+ result.proposed.push(relative(projectPath, full));
207
+ continue;
208
+ }
184
209
  if (!isDir && !isCanonicalPlanuRootFile(entry)) {
185
210
  await removePath(projectPath, full);
186
211
  result.deleted.push(relative(projectPath, full));
@@ -189,7 +214,7 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
189
214
  const releasesDir = join(planuDir, 'releases');
190
215
  for (const entry of await readDirectory(releasesDir)) {
191
216
  const rel = `releases/${entry}`;
192
- if (!isCanonicalReleaseFile(rel)) {
217
+ if (!isLegacyReleasePendingFile(rel)) {
193
218
  const full = join(releasesDir, entry);
194
219
  await removePath(projectPath, full);
195
220
  result.deleted.push(relative(projectPath, full));
@@ -235,7 +260,7 @@ export async function validateStrictPlanuLayout(projectPath, options = {}) {
235
260
  }
236
261
  for (const entry of await readDirectory(join(planuDir, 'releases'))) {
237
262
  const rel = `releases/${entry}`;
238
- if (!isCanonicalReleaseFile(rel)) {
263
+ if (!isLegacyReleasePendingFile(rel)) {
239
264
  offenders.push(`planu/${rel}`);
240
265
  }
241
266
  }
@@ -19,7 +19,7 @@ When implementation changes are complete and validated, always address release e
19
19
  - local validation commands passed
20
20
  - changelog or release notes updated when applicable
21
21
  - package version and git tag stay aligned
22
- - release metadata under \`planu/releases/pending.json\` contains only genuinely pending entries
22
+ - release metadata under the \`releases\` field of \`planu/project.json\` contains only genuinely pending entries
23
23
  - published package smoke check is run after release when applicable
24
24
 
25
25
  ## Hard Blocks
@@ -24,6 +24,41 @@ const FILE_EXTENSION = /\.[A-Za-z0-9]+$/;
24
24
  function isPathShapedSegment(segment) {
25
25
  return segment.length > 0 && (PATH_SHAPED.test(segment) || FILE_EXTENSION.test(segment));
26
26
  }
27
+ function splitInlineArrayItems(content) {
28
+ const items = [];
29
+ let current = '';
30
+ let quote = null;
31
+ for (const char of content) {
32
+ if (quote) {
33
+ if (char === quote) {
34
+ quote = null;
35
+ }
36
+ else {
37
+ current += char;
38
+ }
39
+ continue;
40
+ }
41
+ if (char === '"' || char === "'") {
42
+ quote = char;
43
+ continue;
44
+ }
45
+ if (char === ',') {
46
+ items.push(current);
47
+ current = '';
48
+ continue;
49
+ }
50
+ current += char;
51
+ }
52
+ items.push(current);
53
+ return items;
54
+ }
55
+ function parseInlineTestLinks(bracketContent) {
56
+ return splitInlineArrayItems(bracketContent)
57
+ .map((item) => item.trim())
58
+ .filter((item) => item.length > 0)
59
+ .map((item) => parseTestLinkString(item))
60
+ .filter((link) => link !== null);
61
+ }
27
62
  function parseTestLinkString(raw) {
28
63
  if (WINDOWS_DRIVE_PATH.test(raw)) {
29
64
  return { path: raw };
@@ -104,6 +139,16 @@ export function parseFrontmatterScenarios(raw) {
104
139
  inSteps = false;
105
140
  continue;
106
141
  }
142
+ const inlineTestsMatch = /^\s+tests:\s*\[(.*)\]\s*$/.exec(trimmed);
143
+ if (inlineTestsMatch) {
144
+ currentScenario.tests = [
145
+ ...(currentScenario.tests ?? []),
146
+ ...parseInlineTestLinks(inlineTestsMatch[1] ?? ''),
147
+ ];
148
+ inTests = false;
149
+ inSteps = false;
150
+ continue;
151
+ }
107
152
  if (/^\s+steps:\s*$/.test(trimmed)) {
108
153
  currentScenario.steps ??= [];
109
154
  inSteps = true;
@@ -4,6 +4,8 @@
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { glob } from 'glob';
6
6
  import { resolveStorageLayout } from '../../../storage/storage-layout.js';
7
+ import { loadSpecIndex, listSpecIndexRecords } from '../../../storage/spec-index.js';
8
+ import { resolveCurrentProjectPath } from '../../../storage/current-project.js';
7
9
  import { join } from 'node:path';
8
10
  /** Planu-namespaced resource URIs exposed by SPEC-588. */
9
11
  export const PLANU_UX_RESOURCE_URIS = {
@@ -56,30 +58,12 @@ function safeStr(value) {
56
58
  return typeof value === 'string' ? value : '';
57
59
  }
58
60
  async function loadAllSpecEntries() {
59
- const 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();
@@ -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