@planu/cli 5.5.3 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +38 -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/housekeeping/history-log.d.ts +1 -0
  15. package/dist/engine/housekeeping/history-log.js +58 -3
  16. package/dist/engine/housekeeping/index.d.ts +2 -1
  17. package/dist/engine/housekeeping/index.js +2 -1
  18. package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
  19. package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
  20. package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
  21. package/dist/engine/next-spec-resolver/session-writer.js +1 -1
  22. package/dist/engine/project-graph/cache.js +23 -3
  23. package/dist/engine/readiness-checker.js +1 -1
  24. package/dist/engine/sdd-flow/checkpoints.js +29 -2
  25. package/dist/engine/session/checkpoint-writer.d.ts +1 -1
  26. package/dist/engine/session/checkpoint-writer.js +6 -5
  27. package/dist/engine/session-state/writer.js +4 -3
  28. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  29. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
  30. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
  31. package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
  32. package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
  33. package/dist/engine/validator/spec-compliance-runner.js +45 -0
  34. package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
  35. package/dist/resources/specs.js +12 -33
  36. package/dist/storage/current-project.d.ts +3 -0
  37. package/dist/storage/current-project.js +21 -0
  38. package/dist/storage/index.d.ts +1 -0
  39. package/dist/storage/index.js +1 -0
  40. package/dist/storage/migrations/canonical-storage.js +5 -0
  41. package/dist/storage/retention.d.ts +14 -0
  42. package/dist/storage/retention.js +279 -0
  43. package/dist/storage/spec-index.d.ts +23 -0
  44. package/dist/storage/spec-index.js +123 -0
  45. package/dist/storage/spec-store.d.ts +8 -3
  46. package/dist/storage/spec-store.js +76 -6
  47. package/dist/storage/storage-catalog.js +3 -3
  48. package/dist/storage/storage-layout.d.ts +8 -0
  49. package/dist/storage/storage-layout.js +9 -0
  50. package/dist/storage/transition-log.js +2 -0
  51. package/dist/tools/challenge-spec.js +10 -9
  52. package/dist/tools/create-spec.js +10 -0
  53. package/dist/tools/execute-sdd-flow.js +11 -2
  54. package/dist/tools/export-spec.js +2 -1
  55. package/dist/tools/force-status-analytics.js +2 -1
  56. package/dist/tools/generate-docs-site.js +2 -1
  57. package/dist/tools/generate-proposal.js +6 -2
  58. package/dist/tools/init-project/claude-md-generator.js +19 -2
  59. package/dist/tools/init-project/conventions-writer.d.ts +5 -2
  60. package/dist/tools/init-project/conventions-writer.js +18 -13
  61. package/dist/tools/init-project/git-setup.js +9 -0
  62. package/dist/tools/init-project/handler.js +9 -1
  63. package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
  64. package/dist/tools/init-project/legacy-root-migration.js +213 -0
  65. package/dist/tools/init-project/runtime-residue.d.ts +2 -0
  66. package/dist/tools/init-project/runtime-residue.js +11 -0
  67. package/dist/tools/session-checkpoint.js +1 -1
  68. package/dist/tools/update-status/index.js +7 -1
  69. package/dist/tools/update-status-actions.d.ts +7 -1
  70. package/dist/tools/update-status-actions.js +10 -2
  71. package/dist/types/housekeeping.d.ts +4 -0
  72. package/dist/types/index.d.ts +1 -0
  73. package/dist/types/index.js +1 -0
  74. package/dist/types/retention.d.ts +12 -0
  75. package/dist/types/retention.js +3 -0
  76. package/dist/types/spec/core.d.ts +7 -0
  77. package/dist/types/spec/index-cache.d.ts +25 -0
  78. package/dist/types/spec/index-cache.js +3 -0
  79. package/dist/types/spec/index.d.ts +1 -0
  80. package/dist/types/spec/index.js +1 -0
  81. package/dist/types/spec-format.d.ts +1 -0
  82. package/package.json +1 -1
  83. package/planu-plugin.json +1 -1
  84. package/scripts/lib/pending-release-file.mjs +20 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,41 @@
1
+ ## [5.6.0] - 2026-08-31
2
+
3
+ ### Features
4
+ - feat(init-project): regenerate conventions.md from folded conventions.json (SPEC-1699)
5
+ - feat(init-project): fold legacy planu/ root artifacts on update (SPEC-1699)
6
+ - feat(init-project): gitignore legacy planu/ root files (SPEC-1699)
7
+ - feat(create-spec): refresh the regenerable spec index after writing spec.md (SPEC-1699)
8
+ - feat(storage): add regenerable spec.md index (SPEC-1699)
9
+
10
+ ### Bug Fixes
11
+ - fix(test): reconcile suites with SPEC-1699 runtime relocation and serialize frozen-lockfile suite
12
+ - fix(storage): classify retention and current-project catch degradations
13
+ - fix(release): require the exact tsgo package dir, not any node_modules content
14
+ - fix(release): drop CI=true from frozen-lockfile install, probe tsgo native package
15
+ - fix(SPEC-1696): accept inline YAML arrays for scenario tests in frontmatter
16
+ - fix(storage): close retention review gaps from Codex CHANGES_REQUIRED
17
+ - fix(storage): enforce retention budgets on runtime storage writers
18
+ - fix(SPEC-1694): report the actual test runner instead of Unknown
19
+ - fix(SPEC-1694): promote framework/database claims only from runtime dependencies
20
+ - fix: parse ordered-list markers in BDD criteria extraction (SPEC-1688)
21
+ - fix: repair release-gate collateral fallout from SPEC-1699 fold (blocker 6 sweep)
22
+ - fix: release.sh repoints version marker to planu/project.json (SPEC-1699 blocker 6)
23
+ - fix: reconcile-release-pending falls back to legacy pending.json (SPEC-1699 blocker 4)
24
+ - fix: legacy-root-migration idempotency and fail-safe malformed handling (SPEC-1699 blocker 3)
25
+ - fix: append-releases fails closed on ledger errors (SPEC-1699 blocker 5)
26
+ - fix: verify spec.md digest at the spec-store read choke point (SPEC-1699 blocker 2)
27
+ - fix(housekeeping): protect planu/.runtime as the canonical runtime home (SPEC-1699)
28
+ - fix: address implementation-review blockers for SPEC-1695 runtime relocation
29
+ - fix: reroute default proposal/export/docs-site outputs to planu/.runtime (SPEC-1695)
30
+ - fix: relocate runtime artifacts to planu/.runtime (SPEC-1695)
31
+
32
+ ### Refactoring
33
+ - refactor(release): fold pending release ledger into planu/project.json (SPEC-1699)
34
+ - refactor: relocate session.json and session-context.md under planu/.runtime (SPEC-1699)
35
+ - refactor: route spec resources through the current-project spec index (SPEC-1699)
36
+ - refactor: canonical planu/ set shrinks to project.json + specs/ (SPEC-1699)
37
+
38
+
1
39
  ## [5.5.3] - 2026-08-30
2
40
 
3
41
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"5060255598e14cab792b6b5b0d75a077f23fe018"}
1
+ {"schemaVersion":1,"commit":"fa03e03df9ac0a344ac184a06f67f4de5956d0d6"}
@@ -52,7 +52,7 @@ function registerImplementingCascadeListener() {
52
52
  });
53
53
  }
54
54
  // SPEC-649: registerDoneCascadeListener (optimize_context on done) removed — cascade reduced to
55
- // exactly 2 actions: session.json update + releases/pending.json append.
55
+ // exactly 2 actions: session.json update + project.json releases append.
56
56
  /**
57
57
  * SPEC-628: Fire-and-forget auto_fix_health on cron:daily.
58
58
  * Moved from update_status(done) cascade to reduce latency on done transitions.
@@ -35,13 +35,17 @@ export function normalizePendingReleaseEntries(value) {
35
35
  throw new Error('Pending release ledger is not an array');
36
36
  }
37
37
  const deduped = new Map();
38
+ const preserved = [];
38
39
  for (const entry of value) {
39
40
  if (!isValidPendingReleaseEntry(entry) || !hasParsableCompletionDate(entry)) {
41
+ // Unknown/invalid shape: never silently drop ledger data — pass it
42
+ // through verbatim so a human can inspect and fix it later.
43
+ preserved.push(entry);
40
44
  continue;
41
45
  }
42
46
  deduped.set(entry.specId, entry);
43
47
  }
44
- return [...deduped.values()];
48
+ return [...preserved, ...deduped.values()];
45
49
  }
46
50
  export function reconcilePublishedPendingEntries(entries, release, isCommitReachable = requireIsCommitReachable) {
47
51
  if (!release) {
@@ -78,16 +82,23 @@ async function captureHeadCommit(projectPath) {
78
82
  return undefined;
79
83
  }
80
84
  }
85
+ function extractReleasesField(parsedProject) {
86
+ return parsedProject !== null &&
87
+ typeof parsedProject === 'object' &&
88
+ !Array.isArray(parsedProject)
89
+ ? parsedProject.releases
90
+ : undefined;
91
+ }
81
92
  async function rewritePendingLedger(input) {
82
93
  let pendingList = [];
83
94
  try {
84
- const raw = await readFile(input.pendingPath, 'utf-8');
85
- pendingList = normalizePendingReleaseEntries(JSON.parse(raw));
95
+ const raw = await readFile(input.projectJsonPath, 'utf-8');
96
+ pendingList = normalizePendingReleaseEntries(extractReleasesField(JSON.parse(raw)) ?? []);
86
97
  }
87
98
  catch (error) {
88
99
  if (error.code !== 'ENOENT') {
89
100
  reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
90
- return;
101
+ throw error instanceof Error ? error : new Error(String(error));
91
102
  }
92
103
  }
93
104
  pendingList = reconcilePublishedPendingEntries(pendingList, input.releaseInfo, input.isCommitReachable).filter((entry) => entry.specId !== input.specId);
@@ -97,7 +108,7 @@ async function rewritePendingLedger(input) {
97
108
  completedAt: new Date().toISOString().substring(0, 10),
98
109
  ...(input.implementationCommit ? { implementationCommit: input.implementationCommit } : {}),
99
110
  });
100
- await input.writePendingReleaseLedger(input.pendingPath, pendingList);
111
+ await input.writePendingReleaseLedger(input.projectJsonPath, pendingList);
101
112
  }
102
113
  async function actuallyAppendReleasesPending(ctx, opts) {
103
114
  const { projectPath, projectId, specId } = ctx;
@@ -116,19 +127,19 @@ async function actuallyAppendReleasesPending(ctx, opts) {
116
127
  return;
117
128
  }
118
129
  const { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } = helpers;
119
- const releasesDir = join(projectPath, 'planu', 'releases');
120
- const pendingPath = join(releasesDir, 'pending.json');
130
+ const planuDir = join(projectPath, 'planu');
131
+ const projectJsonPath = join(planuDir, 'project.json');
121
132
  await new Promise((resolve, reject) => {
122
133
  opts.signal.addEventListener('abort', () => {
123
134
  reject(new Error('CoreActionTimeoutError: append-releases-pending timed out'));
124
135
  });
125
136
  (async () => {
126
- await mkdir(releasesDir, { recursive: true });
137
+ await mkdir(planuDir, { recursive: true });
127
138
  const spec = await specStore.getSpec(projectId, specId);
128
139
  const releaseInfo = await resolveLatestReleaseTag(projectPath);
129
140
  const implementationCommit = await captureHeadCommit(projectPath);
130
- await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
131
- pendingPath,
141
+ await withPendingReleaseLock(projectJsonPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
142
+ projectJsonPath,
132
143
  specId,
133
144
  title: spec?.title ?? specId,
134
145
  implementationCommit,
@@ -150,8 +161,7 @@ async function actuallyAppendReleasesPending(ctx, opts) {
150
161
  resolve();
151
162
  })
152
163
  .catch((err) => {
153
- void err; // best-effort surface as result, not throw
154
- resolve();
164
+ reject(err instanceof Error ? err : new Error(String(err)));
155
165
  });
156
166
  });
157
167
  }
@@ -4,6 +4,7 @@ export { fileExists };
4
4
  declare const IGNORE_DIRS: string[];
5
5
  export declare function hasAnyFile(basePath: string, patterns: string[]): Promise<boolean>;
6
6
  export declare function collectDependencies(projectPath: string): Promise<Set<string>>;
7
+ export declare function collectRuntimeDependencies(projectPath: string): Promise<Set<string>>;
7
8
  export declare function loadStackSignatures(): Promise<StackSignatures>;
8
9
  export declare function loadArchPatterns(): Promise<ArchPatternsFile>;
9
10
  export declare function readJsonFile(filePath: string): Promise<Record<string, unknown> | null>;
@@ -53,6 +53,17 @@ export async function collectDependencies(projectPath) {
53
53
  await collectPhpDeps(projectPath, deps);
54
54
  return deps;
55
55
  }
56
+ export async function collectRuntimeDependencies(projectPath) {
57
+ const deps = new Set();
58
+ await collectNodeRuntimeDeps(projectPath, deps);
59
+ await collectPythonRequirements(projectPath, deps);
60
+ await collectPyprojectDeps(projectPath, deps);
61
+ await collectGoDeps(projectPath, deps);
62
+ await collectRubyDeps(projectPath, deps);
63
+ await collectRustDeps(projectPath, deps);
64
+ await collectPhpRuntimeDeps(projectPath, deps);
65
+ return deps;
66
+ }
56
67
  async function collectNodeDeps(projectPath, deps) {
57
68
  try {
58
69
  const pkgJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
@@ -67,6 +78,17 @@ async function collectNodeDeps(projectPath, deps) {
67
78
  // No package.json
68
79
  }
69
80
  }
81
+ async function collectNodeRuntimeDeps(projectPath, deps) {
82
+ try {
83
+ const pkgJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
84
+ for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
85
+ deps.add(dep);
86
+ }
87
+ }
88
+ catch {
89
+ // No package.json
90
+ }
91
+ }
70
92
  async function collectPythonRequirements(projectPath, deps) {
71
93
  try {
72
94
  const content = await readFile(join(projectPath, 'requirements.txt'), 'utf-8');
@@ -181,6 +203,17 @@ async function collectPhpDeps(projectPath, deps) {
181
203
  // No composer.json
182
204
  }
183
205
  }
206
+ async function collectPhpRuntimeDeps(projectPath, deps) {
207
+ try {
208
+ const composerJson = JSON.parse(await readFile(join(projectPath, 'composer.json'), 'utf-8'));
209
+ for (const dep of Object.keys(composerJson.require ?? {})) {
210
+ deps.add(dep);
211
+ }
212
+ }
213
+ catch {
214
+ // No composer.json
215
+ }
216
+ }
184
217
  export async function loadStackSignatures() {
185
218
  try {
186
219
  const configPath = new URL('../config/stack-signatures.json', import.meta.url);
@@ -16,7 +16,7 @@ export { computeVelocity } from './data-aggregators/velocity-aggregator.js';
16
16
  export { computeBurndown } from './data-aggregators/burndown-aggregator.js';
17
17
  export { detectBottlenecks } from './data-aggregators/bottleneck-detector.js';
18
18
  export { computeAccuracy } from './data-aggregators/accuracy-aggregator.js';
19
- export { scheduleRegeneration, regeneratePages } from './portal-regenerator.js';
19
+ export { scheduleRegeneration, regeneratePages, portalPageOutputPath, } from './portal-regenerator.js';
20
20
  export { notifyStoreChange } from './regen-hook.js';
21
21
  export { getAffectedPages } from './dirty-flag-map.js';
22
22
  //# sourceMappingURL=index.d.ts.map
@@ -19,7 +19,7 @@ export { computeBurndown } from './data-aggregators/burndown-aggregator.js';
19
19
  export { detectBottlenecks } from './data-aggregators/bottleneck-detector.js';
20
20
  export { computeAccuracy } from './data-aggregators/accuracy-aggregator.js';
21
21
  // SPEC-146 — Auto-regeneration on store changes
22
- export { scheduleRegeneration, regeneratePages } from './portal-regenerator.js';
22
+ export { scheduleRegeneration, regeneratePages, portalPageOutputPath, } from './portal-regenerator.js';
23
23
  export { notifyStoreChange } from './regen-hook.js';
24
24
  export { getAffectedPages } from './dirty-flag-map.js';
25
25
  //# sourceMappingURL=index.js.map
@@ -1,13 +1,18 @@
1
- import type { StoreType } from '../../../types/portal.js';
1
+ import type { RegeneratedPortalPage, StoreType } from '../../../types/portal.js';
2
2
  /**
3
3
  * Schedule a debounced regeneration (500 ms window).
4
4
  * Multiple calls within the window are coalesced per project.
5
5
  */
6
6
  export declare function scheduleRegeneration(projectPath: string, changedStores: StoreType[]): void;
7
+ /**
8
+ * SPEC-1695: Absolute path of a portal page's HTML output, under the
9
+ * per-project storage-layout cache directory (planu/.runtime/cache).
10
+ */
11
+ export declare function portalPageOutputPath(projectPath: string, page: RegeneratedPortalPage): string;
7
12
  /**
8
13
  * Immediately regenerate all portal pages affected by the given store changes.
9
- * Creates planu/ directory if it does not exist.
10
- * Returns the list of page keys that were successfully written.
14
+ * Creates the storage-layout cache directory if it does not exist.
15
+ * Returns the absolute output paths of the pages that were successfully written.
11
16
  * Never throws.
12
17
  */
13
18
  export declare function regeneratePages(projectPath: string, changedStores: StoreType[]): Promise<string[]>;
@@ -3,6 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
5
5
  import { hashProjectPath } from '../../../storage/base-store.js';
6
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
7
+ import { projectRuntimeDir } from '../../../storage/storage-layout.js';
6
8
  import { getAffectedPages } from './dirty-flag-map.js';
7
9
  import { withAudit } from '../../../engine/autopilot/audit-logger.js';
8
10
  // Debounce state keyed by projectPath to avoid cross-project interference
@@ -30,16 +32,24 @@ export function scheduleRegeneration(projectPath, changedStores) {
30
32
  }, 500);
31
33
  debounceTimers.set(projectPath, timer);
32
34
  }
35
+ /**
36
+ * SPEC-1695: Absolute path of a portal page's HTML output, under the
37
+ * per-project storage-layout cache directory (planu/.runtime/cache).
38
+ */
39
+ export function portalPageOutputPath(projectPath, page) {
40
+ const filename = page === 'index' ? 'index.html' : `${page}.html`;
41
+ return join(projectRuntimeDir(projectPath, 'cache'), filename);
42
+ }
33
43
  /**
34
44
  * Immediately regenerate all portal pages affected by the given store changes.
35
- * Creates planu/ directory if it does not exist.
36
- * Returns the list of page keys that were successfully written.
45
+ * Creates the storage-layout cache directory if it does not exist.
46
+ * Returns the absolute output paths of the pages that were successfully written.
37
47
  * Never throws.
38
48
  */
39
49
  export async function regeneratePages(projectPath, changedStores) {
40
- const planuDir = join(projectPath, 'planu');
50
+ const cacheDir = projectRuntimeDir(projectPath, 'cache');
41
51
  try {
42
- await mkdir(planuDir, { recursive: true });
52
+ await mkdir(cacheDir, { recursive: true });
43
53
  }
44
54
  catch {
45
55
  return [];
@@ -51,10 +61,9 @@ export async function regeneratePages(projectPath, changedStores) {
51
61
  await withAudit(projectPath, 'portal_regeneration', `regeneratePage(${page})`, async () => {
52
62
  const html = await generatePage(projectPath, page);
53
63
  if (html) {
54
- const filename = page === 'index' ? 'index.html' : `${page}.html`;
55
- const filePath = join(planuDir, filename);
64
+ const filePath = portalPageOutputPath(projectPath, page);
56
65
  await writeFile(filePath, html, 'utf-8');
57
- regenerated.push(page);
66
+ regenerated.push(filePath);
58
67
  }
59
68
  });
60
69
  }
@@ -1,6 +1,6 @@
1
1
  import type { AutofillTraceabilityMatrixArgs, AutofillTraceabilityMatrixResult } from '../../types/evidence-autofill.js';
2
2
  /** Exact Planu lifecycle files every spec branch mutates; never counted as implementation drift. */
3
- export declare const PLANU_BOOKKEEPING_FILES: readonly ['planu/context.md', 'planu/session-context.md', 'planu/session.json', 'planu/status.json', 'planu/releases/pending.json', 'planu/conventions.json'];
3
+ export declare const PLANU_BOOKKEEPING_FILES: readonly ['planu/context.md', 'planu/session-context.md', 'planu/session.json', 'planu/status.json', 'planu/project.json', 'planu/conventions.json'];
4
4
  /** Normalizes a spec.md path (absolute or already repo-relative) to a repo-relative,
5
5
  * forward-slash path so it can be compared against `git diff` output. */
6
6
  export declare function toRepoRelativePath(specPath: string, projectPath?: string): string;
@@ -42,7 +42,7 @@ export const PLANU_BOOKKEEPING_FILES = [
42
42
  'planu/session-context.md',
43
43
  'planu/session.json',
44
44
  'planu/status.json',
45
- 'planu/releases/pending.json',
45
+ 'planu/project.json',
46
46
  'planu/conventions.json',
47
47
  ];
48
48
  const OTHER_SPEC_MD_PATTERN = /^planu\/specs\/[^/]+\/spec\.md$/;
@@ -1,7 +1,7 @@
1
1
  import { technologyValue } from './technology-registry.js';
2
2
  // Planu — Framework, ORM, and database detection
3
3
  import { join } from 'node:path';
4
- import { fileExists, hasAnyFile, collectDependencies, readJsonFile } from './detection-utils.js';
4
+ import { fileExists, hasAnyFile, collectDependencies, collectRuntimeDependencies, readJsonFile, } from './detection-utils.js';
5
5
  /**
6
6
  * Detect the primary framework from indicator files and dependencies.
7
7
  */
@@ -14,8 +14,9 @@ export async function detectFramework(projectPath, signatures) {
14
14
  }
15
15
  }
16
16
  }
17
- // Check dependencies
18
- const deps = await collectDependencies(projectPath);
17
+ // Check runtime dependencies only — a framework declared solely in
18
+ // devDependencies is tooling, not the project's runtime stack.
19
+ const deps = await collectRuntimeDependencies(projectPath);
19
20
  for (const [fw, sig] of Object.entries(signatures.frameworks)) {
20
21
  for (const dep of sig.dependencies) {
21
22
  if (deps.has(dep)) {
@@ -55,9 +56,9 @@ export async function detectFrameworkVersion(projectPath, framework, signatures)
55
56
  * Detect the database engine from indicator files and dependencies.
56
57
  */
57
58
  export async function detectDatabase(projectPath, signatures) {
58
- const deps = await collectDependencies(projectPath);
59
+ const deps = await collectRuntimeDependencies(projectPath);
59
60
  for (const [, sig] of Object.entries(signatures.databases)) {
60
- // Check indicator files
61
+ // Check indicator files (migration dirs, connection configs)
61
62
  for (const indicator of sig.indicators) {
62
63
  if (await hasAnyFile(projectPath, [indicator])) {
63
64
  if (isKnownEngine(sig.engine)) {
@@ -65,7 +66,8 @@ export async function detectDatabase(projectPath, signatures) {
65
66
  }
66
67
  }
67
68
  }
68
- // Check dependencies
69
+ // Check runtime dependencies only — a driver declared solely in
70
+ // devDependencies never produces a Database claim.
69
71
  for (const dep of sig.dependencies) {
70
72
  if (deps.has(dep)) {
71
73
  if (isKnownEngine(sig.engine)) {
@@ -1,4 +1,5 @@
1
1
  import type { HousekeepingHistoryEntry } from '../../types/housekeeping.js';
2
2
  export declare function housekeepingHistoryPath(projectPath: string): string;
3
+ export declare function migrateLegacyHousekeepingHistory(projectPath: string): Promise<string | null>;
3
4
  export declare function appendHousekeepingEntry(projectPath: string, input: Omit<HousekeepingHistoryEntry, 'id' | 'deletedAt' | 'prevSha' | 'sha256'>): Promise<HousekeepingHistoryEntry>;
4
5
  //# sourceMappingURL=history-log.d.ts.map
@@ -1,14 +1,68 @@
1
1
  // engine/housekeeping/history-log.ts — SPEC-751
2
2
  // Hash-chained JSONL backup log for deleted housekeeping items.
3
3
  // Mirrors the SPEC-723/734 transition-log pattern for recoverability.
4
+ import { existsSync } from 'node:fs';
4
5
  import { createHash, randomUUID } from 'node:crypto';
5
- import { appendFile, readFile, mkdir } from 'node:fs/promises';
6
+ import { appendFile, readFile, mkdir, rm, writeFile } from 'node:fs/promises';
6
7
  import { dirname, join } from 'node:path';
8
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
9
+ import { projectRuntimeDir } from '../../storage/storage-layout.js';
7
10
  // ---------------------------------------------------------------------------
8
- // Path helper
11
+ // Path helpers
9
12
  // ---------------------------------------------------------------------------
13
+ const HISTORY_FILENAME = '.housekeeping-history.jsonl';
10
14
  export function housekeepingHistoryPath(projectPath) {
11
- return join(projectPath, 'planu', '.housekeeping-history.jsonl');
15
+ return join(projectRuntimeDir(projectPath, 'state'), HISTORY_FILENAME);
16
+ }
17
+ function legacyHousekeepingHistoryPath(projectPath) {
18
+ return join(projectPath, 'planu', HISTORY_FILENAME);
19
+ }
20
+ /**
21
+ * SPEC-1695: One-time migration of the legacy planu/.housekeeping-history.jsonl
22
+ * into the storage-layout location. Copies content, verifies it round-tripped,
23
+ * then deletes the legacy file. Idempotent — a missing legacy file or an
24
+ * already-populated new file is a no-op.
25
+ * Returns the legacy path when a migration happened, otherwise null.
26
+ */
27
+ function entryId(line) {
28
+ try {
29
+ return JSON.parse(line).id ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function jsonlLines(content) {
36
+ return content.split('\n').filter((line) => line.trim().length > 0);
37
+ }
38
+ export async function migrateLegacyHousekeepingHistory(projectPath) {
39
+ const legacyPath = legacyHousekeepingHistoryPath(projectPath);
40
+ const newPath = housekeepingHistoryPath(projectPath);
41
+ if (!existsSync(legacyPath)) {
42
+ return null;
43
+ }
44
+ const legacyContent = await readFile(legacyPath, 'utf-8');
45
+ if (!existsSync(newPath)) {
46
+ await mkdir(dirname(newPath), { recursive: true });
47
+ await writeFile(newPath, legacyContent, 'utf-8');
48
+ const verified = await readFile(newPath, 'utf-8');
49
+ if (verified !== legacyContent) {
50
+ throw new Error('[Planu] housekeeping history migration verification failed');
51
+ }
52
+ await rm(legacyPath, { force: true });
53
+ return legacyPath;
54
+ }
55
+ const destinationContent = await readFile(newPath, 'utf-8');
56
+ const destinationIds = new Set(jsonlLines(destinationContent).map(entryId));
57
+ const missingLines = jsonlLines(legacyContent).filter((line) => {
58
+ const id = entryId(line);
59
+ return id === null || !destinationIds.has(id);
60
+ });
61
+ if (missingLines.length > 0) {
62
+ await appendFile(newPath, missingLines.map((line) => `${line}\n`).join(''), 'utf-8');
63
+ }
64
+ await rm(legacyPath, { force: true });
65
+ return legacyPath;
12
66
  }
13
67
  // ---------------------------------------------------------------------------
14
68
  // Hash chain helpers
@@ -47,6 +101,7 @@ async function readLastSha(filePath) {
47
101
  }
48
102
  }
49
103
  async function doAppend(filePath, input) {
104
+ await migrateLegacyHousekeepingHistory(input.projectPath);
50
105
  await mkdir(dirname(filePath), { recursive: true });
51
106
  const prevSha = await readLastSha(filePath);
52
107
  const partial = {
@@ -2,7 +2,8 @@ export { findStaleBranches } from './find-stale-branches.js';
2
2
  export { findStaleWorktrees } from './find-stale-worktrees.js';
3
3
  export { findStaleStashes } from './find-stale-stashes.js';
4
4
  export { runHousekeepingSweep } from './sweep-runner.js';
5
- export { appendHousekeepingEntry, housekeepingHistoryPath } from './history-log.js';
5
+ export { appendHousekeepingEntry, housekeepingHistoryPath, migrateLegacyHousekeepingHistory, } from './history-log.js';
6
+ export { sweepRuntimeResidue } from './runtime-residue-sweep.js';
6
7
  export { cleanOrphanBrainstormingMarkdowns } from './orphan-brainstorming-cleaner.js';
7
8
  export { cleanEphemeralArtifacts } from './ephemeral-artifacts-cleaner.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -4,7 +4,8 @@ export { findStaleBranches } from './find-stale-branches.js';
4
4
  export { findStaleWorktrees } from './find-stale-worktrees.js';
5
5
  export { findStaleStashes } from './find-stale-stashes.js';
6
6
  export { runHousekeepingSweep } from './sweep-runner.js';
7
- export { appendHousekeepingEntry, housekeepingHistoryPath } from './history-log.js';
7
+ export { appendHousekeepingEntry, housekeepingHistoryPath, migrateLegacyHousekeepingHistory, } from './history-log.js';
8
+ export { sweepRuntimeResidue } from './runtime-residue-sweep.js';
8
9
  export { cleanOrphanBrainstormingMarkdowns } from './orphan-brainstorming-cleaner.js';
9
10
  export { cleanEphemeralArtifacts } from './ephemeral-artifacts-cleaner.js';
10
11
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ import type { RuntimeResidueSweepResult } from '../../types/housekeeping.js';
2
+ /**
3
+ * Delete every legacy runtime-residue path from planu/ without ever touching
4
+ * canonicalRootFiles / canonicalRootDirs. Idempotent — a checkout with no
5
+ * residue returns an empty removed list. Wired into init_project and the
6
+ * session-start housekeeping path so existing client checkouts self-heal.
7
+ */
8
+ export declare function sweepRuntimeResidue(projectPath: string): Promise<RuntimeResidueSweepResult>;
9
+ //# sourceMappingURL=runtime-residue-sweep.d.ts.map
@@ -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