@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
@@ -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)) {
@@ -168,6 +168,10 @@ export const ReconciliationReceiptV1Schema = z.object({
168
168
  transitionId: z.string().min(1).optional(),
169
169
  phase: z.enum(['prepared', 'status-committed', 'spec-synced', 'audit-appended']).optional(),
170
170
  auditPending: z.boolean().optional(),
171
+ driftSource: z
172
+ .literal('declared-architectural-premise')
173
+ .optional()
174
+ .describe('Set when the reconciliation demotion evidence is an orchestrator-declared drift rather than an automated validation-report finding.'),
171
175
  });
172
176
  // ---------------------------------------------------------------------------
173
177
  // Schema registry
@@ -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,3 @@
1
+ import type { LegacyPlanuDemolisherOptions, LegacyPlanuDemolitionReport } from '../../types/housekeeping.js';
2
+ export declare function demolishLegacyPlanuRoot(options?: LegacyPlanuDemolisherOptions): Promise<LegacyPlanuDemolitionReport>;
3
+ //# sourceMappingURL=legacy-planu-demolisher.d.ts.map
@@ -0,0 +1,164 @@
1
+ // engine/housekeeping/legacy-planu-demolisher.ts — SPEC-1709
2
+ import { homedir } from 'node:os';
3
+ import { lstat, readdir, realpath, rm, stat } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
5
+ import { isAbsolute, join, relative } from 'node:path';
6
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
7
+ import { globalDataDir, projectDataDir } from '../../storage/base-store.js';
8
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
9
+ import { getRegistry, readLogicalProjectId } from '../../storage/global-projects-store.js';
10
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
11
+ import { migrateCanonicalStorage } from '../../storage/migrations/canonical-storage.js';
12
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
13
+ import { resolveStorageLayout } from '../../storage/storage-layout.js';
14
+ import { pathExistsStrictByStat as pathExists } from '../../core/shared/fs.js';
15
+ const CANONICAL_ROOT_COLLISION_REASON = 'legacy root coincides with the canonical storage root; refusing to demolish';
16
+ function sha256Hex(value) {
17
+ return createHash('sha256').update(value).digest('hex');
18
+ }
19
+ function isInsideDirectory(parent, candidate) {
20
+ const rel = relative(parent, candidate);
21
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
22
+ }
23
+ async function realpathOrSelf(path) {
24
+ try {
25
+ return await realpath(path);
26
+ }
27
+ catch {
28
+ return path;
29
+ }
30
+ }
31
+ async function isRealPathContained(parentReal, candidate) {
32
+ const candidateReal = await realpathOrSelf(candidate);
33
+ return candidateReal === parentReal || isInsideDirectory(parentReal, candidateReal);
34
+ }
35
+ async function canonicalRootCollidesWithLegacy(legacyRoot) {
36
+ const legacyReal = await realpathOrSelf(legacyRoot);
37
+ const canonicalCandidates = [resolveStorageLayout().data, globalDataDir()];
38
+ for (const candidate of canonicalCandidates) {
39
+ if (await isRealPathContained(legacyReal, candidate)) {
40
+ return true;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+ async function directorySizeBytes(root) {
46
+ let total = 0;
47
+ const entries = await readdir(root, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ const path = join(root, entry.name);
50
+ if (entry.isDirectory()) {
51
+ total += await directorySizeBytes(path);
52
+ }
53
+ else if (entry.isFile()) {
54
+ total += (await stat(path)).size;
55
+ }
56
+ }
57
+ return total;
58
+ }
59
+ async function defaultResolveDestination(project) {
60
+ const logicalProjectId = await readLogicalProjectId(project.path);
61
+ const projectId = logicalProjectId !== undefined
62
+ ? logicalProjectId.replaceAll('-', '').toLowerCase()
63
+ : project.hash;
64
+ return projectDataDir(projectId);
65
+ }
66
+ async function buildDestinationMap(registry, resolveDestination) {
67
+ const map = new Map();
68
+ for (const project of registry.projects) {
69
+ const destination = await resolveDestination(project);
70
+ const fullDigest = sha256Hex(project.path);
71
+ map.set(fullDigest, destination);
72
+ map.set(fullDigest.slice(0, 16), destination);
73
+ }
74
+ return map;
75
+ }
76
+ async function migrateOrRecordFailure(dirName, dirPath, destination, migrated, failures) {
77
+ const sizeBeforeMigration = await directorySizeBytes(dirPath);
78
+ try {
79
+ const migration = await migrateCanonicalStorage({
80
+ legacyRoots: [dirPath],
81
+ destinationRoot: destination,
82
+ });
83
+ if (migration.status === 'error' || migration.status === 'rolled-back') {
84
+ failures.push({ dir: dirName, reason: migration.reason ?? migration.status });
85
+ return 0;
86
+ }
87
+ migrated.push(dirName);
88
+ return sizeBeforeMigration;
89
+ }
90
+ catch (error) {
91
+ failures.push({ dir: dirName, reason: error instanceof Error ? error.message : String(error) });
92
+ return 0;
93
+ }
94
+ }
95
+ async function sweepLegacyProjectsDir(legacyProjectsDir, trustedProjectsDirReal, destinationMap) {
96
+ const migrated = [];
97
+ const failures = [];
98
+ let demolishedUnmappable = 0;
99
+ let freedBytes = 0;
100
+ if (!(await pathExists(legacyProjectsDir))) {
101
+ return { migrated, demolishedUnmappable, failures, freedBytes };
102
+ }
103
+ const entries = await readdir(legacyProjectsDir, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ const dirPath = join(legacyProjectsDir, entry.name);
106
+ if (entry.isSymbolicLink()) {
107
+ freedBytes += (await lstat(dirPath)).size;
108
+ await rm(dirPath, { force: true });
109
+ demolishedUnmappable += 1;
110
+ continue;
111
+ }
112
+ if (!entry.isDirectory()) {
113
+ continue;
114
+ }
115
+ if (!(await isRealPathContained(trustedProjectsDirReal, dirPath))) {
116
+ failures.push({ dir: entry.name, reason: 'path escapes the legacy projects directory' });
117
+ continue;
118
+ }
119
+ const destination = destinationMap.get(entry.name);
120
+ if (destination !== undefined) {
121
+ freedBytes += await migrateOrRecordFailure(entry.name, dirPath, destination, migrated, failures);
122
+ continue;
123
+ }
124
+ freedBytes += await directorySizeBytes(dirPath);
125
+ await rm(dirPath, { recursive: true, force: true });
126
+ demolishedUnmappable += 1;
127
+ }
128
+ return { migrated, demolishedUnmappable, failures, freedBytes };
129
+ }
130
+ async function remainingProjectEntryCount(legacyProjectsDir) {
131
+ if (!(await pathExists(legacyProjectsDir))) {
132
+ return 0;
133
+ }
134
+ const entries = await readdir(legacyProjectsDir, { withFileTypes: true });
135
+ return entries.length;
136
+ }
137
+ export async function demolishLegacyPlanuRoot(options = {}) {
138
+ const legacyRoot = options.legacyRoot ?? join(homedir(), '.planu');
139
+ if (await canonicalRootCollidesWithLegacy(legacyRoot)) {
140
+ return {
141
+ status: 'retained',
142
+ migrated: [],
143
+ demolishedUnmappable: 0,
144
+ failures: [{ dir: '.', reason: CANONICAL_ROOT_COLLISION_REASON }],
145
+ freedBytes: 0,
146
+ };
147
+ }
148
+ if (!(await pathExists(legacyRoot))) {
149
+ return { status: 'absent', migrated: [], demolishedUnmappable: 0, failures: [], freedBytes: 0 };
150
+ }
151
+ const registry = options.registry ?? (await getRegistry());
152
+ const resolveDestination = options.resolveDestination ?? defaultResolveDestination;
153
+ const destinationMap = await buildDestinationMap(registry, resolveDestination);
154
+ const legacyProjectsDir = join(legacyRoot, 'data', 'projects');
155
+ const trustedProjectsDirReal = join(await realpathOrSelf(legacyRoot), 'data', 'projects');
156
+ const { migrated, demolishedUnmappable, failures, freedBytes: sweptBytes, } = await sweepLegacyProjectsDir(legacyProjectsDir, trustedProjectsDirReal, destinationMap);
157
+ if ((await remainingProjectEntryCount(legacyProjectsDir)) > 0) {
158
+ return { status: 'retained', migrated, demolishedUnmappable, failures, freedBytes: 0 };
159
+ }
160
+ const freedBytes = sweptBytes + (await directorySizeBytes(legacyRoot));
161
+ await rm(legacyRoot, { recursive: true, force: true });
162
+ return { status: 'demolished', migrated, demolishedUnmappable, failures, freedBytes };
163
+ }
164
+ //# sourceMappingURL=legacy-planu-demolisher.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