@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
@@ -172,15 +172,7 @@ export async function handleChallengeSpec(args, server) {
172
172
  if (!projectId) {
173
173
  return missingProjectIdError;
174
174
  }
175
- // 1. Load spec
176
- const spec = await specStore.getSpec(projectId, specId);
177
- if (!spec) {
178
- return {
179
- content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
180
- isError: true,
181
- };
182
- }
183
- // 2. Load project knowledge — SPEC-1011 Bug F: use disk fingerprint fallback
175
+ // 1. Load project knowledge — SPEC-1011 Bug F: use disk fingerprint fallback
184
176
  let knowledge = await knowledgeStore.getKnowledge(projectId);
185
177
  if (!knowledge && args.projectPath) {
186
178
  const resolved = await resolveProjectFromPath(args.projectPath);
@@ -194,6 +186,15 @@ export async function handleChallengeSpec(args, server) {
194
186
  isError: true,
195
187
  };
196
188
  }
189
+ // 2. Load spec — SPEC-1699: pass canonicalRoot so a stale specs.json record
190
+ // (e.g. spec.md hand-edited after the last write) is refreshed on read.
191
+ const spec = await specStore.getSpec(projectId, specId, knowledge.projectPath);
192
+ if (!spec) {
193
+ return {
194
+ content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
195
+ isError: true,
196
+ };
197
+ }
197
198
  // 2b. Load Constitution for compliance context
198
199
  const constitution = await knowledgeStore.getConstitution(projectId);
199
200
  // 3. Read spec content
@@ -2,6 +2,7 @@ import { checkGate } from '../engine/clarification-gate/gate.js';
2
2
  import { upsertToken, hashQuestions } from '../engine/clarification-gate/token-store.js';
3
3
  import { ti } from '../i18n/index.js';
4
4
  import { knowledgeStore, specStore } from '../storage/index.js';
5
+ import { loadSpecIndex } from '../storage/spec-index.js';
5
6
  import { readTechnologySelectionContract } from '../storage/technology-selection-store.js';
6
7
  import { toolResult, interactiveResult } from './response-helpers.js';
7
8
  import { readFile, stat as fsStat } from 'node:fs/promises';
@@ -9,6 +10,7 @@ import { createHash, randomUUID } from 'node:crypto';
9
10
  import { pathExistsByStat as pathExists } from '../core/shared/fs.js';
10
11
  import { basename as pathBasename, dirname as pathDirname, isAbsolute as pathIsAbsolute, join as pathJoin, relative as pathRelative, sep as pathSeparator, } from 'node:path';
11
12
  import { withFileLock } from '../storage/file-mutex.js';
13
+ import { enforceRetention } from '../storage/retention.js';
12
14
  import { generateBranchName } from './create-spec-helpers.js';
13
15
  import { buildSpecContext, buildSplitResult, computeNextSpecId, } from './create-spec/spec-builder.js';
14
16
  import { validateConstitution } from './create-spec/constitution-validator.js';
@@ -635,6 +637,7 @@ async function commitIdempotencyEvidence(projectPath, key, claim, spec, specPath
635
637
  await executionWriteFileExclusive(evidencePath, JSON.stringify(evidence, null, 2), {
636
638
  encoding: 'utf-8',
637
639
  });
640
+ await enforceRetention({ kind: 'idempotency', path: pathDirname(evidencePath) });
638
641
  }
639
642
  catch (error) {
640
643
  if (error.code !== 'EEXIST') {
@@ -1688,6 +1691,13 @@ export async function handleCreateSpec(inputParams, server) {
1688
1691
  }
1689
1692
  await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
1690
1693
  storeCreated = true;
1694
+ // SPEC-1699: refresh the regenerable spec.md index cache file.
1695
+ // Best-effort — specStore.getSpec/listSpecs independently verify
1696
+ // spec.md's digest on every read that supplies canonicalRoot, so
1697
+ // a failed refresh here only lags this cache file, not a caller.
1698
+ await loadSpecIndex(resolvedPath).catch(() => {
1699
+ /* best-effort */
1700
+ });
1691
1701
  assertExecutionCanCommit(criticalSignal);
1692
1702
  await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
1693
1703
  operationJournal.commitWithOutbox('create_spec', operationKey, committedResult, {
@@ -48,9 +48,18 @@ export async function handleExecuteSddFlow(args) {
48
48
  if (specIds.length === 0 && !args.resumeFrom) {
49
49
  return compactError('specIds is required when not resuming. Provide at least one approved spec ID.');
50
50
  }
51
- let state = args.resumeFrom ? await loadCheckpoint(projectPath, args.resumeFrom) : null;
51
+ let state = null;
52
+ if (args.resumeFrom) {
53
+ try {
54
+ state = await loadCheckpoint(projectPath, args.resumeFrom);
55
+ }
56
+ catch (error) {
57
+ return compactError(error instanceof Error ? error.message : String(error));
58
+ }
59
+ }
52
60
  if (args.resumeFrom && !state) {
53
- return compactError(`Run state not found for runId: ${args.resumeFrom}. Check planu/state/ directory.`);
61
+ return compactError(`Run state not found for runId: ${args.resumeFrom}. ` +
62
+ 'Checked planu/.runtime/state/ and legacy planu/state/.');
54
63
  }
55
64
  if (!state) {
56
65
  const runId = randomUUID();
@@ -8,6 +8,7 @@ import { stripFrontmatter } from '../engine/frontmatter-parser.js';
8
8
  import { specToAgentReady, serializeAgentReady } from '../engine/agent-ready-exporter.js';
9
9
  import { assertPathWithinBase } from '../engine/safety/assert-within-project.js';
10
10
  import { compactResult, formatKeyValue } from './output-formatter.js';
11
+ import { projectRuntimeDir } from '../storage/storage-layout.js';
11
12
  async function readFileContent(filePath) {
12
13
  try {
13
14
  const raw = await readFile(filePath, 'utf-8');
@@ -122,7 +123,7 @@ export async function handleExportSpec(params) {
122
123
  });
123
124
  // Write to output directory
124
125
  const slug = toSlug(spec.title);
125
- const outputDir = params.outputDir ?? join(params.projectPath, 'planu', 'exports', slug);
126
+ const outputDir = params.outputDir ?? join(projectRuntimeDir(params.projectPath, 'reports'), 'exports', slug);
126
127
  let resolvedOutput;
127
128
  try {
128
129
  resolvedOutput = assertPathWithinBase(params.projectPath, outputDir);
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { readForceAnalytics } from '../storage/force-analytics-store.js';
5
5
  import { resolveProjectPath } from '../storage/path-resolver.js';
6
6
  import { hashProjectPath } from '../storage/base-store.js';
7
+ import { projectRuntimeDir } from '../storage/storage-layout.js';
7
8
  function buildMarkdownReport(projectPath, entries, stats) {
8
9
  const lines = [
9
10
  '# Quality Exceptions Report',
@@ -42,7 +43,7 @@ export async function handleForceStatusAnalytics(params) {
42
43
  let reportPath = null;
43
44
  if (generateReport) {
44
45
  try {
45
- const reportDir = join(projectPath, 'planu', 'reports');
46
+ const reportDir = projectRuntimeDir(projectPath, 'reports');
46
47
  await mkdir(reportDir, { recursive: true });
47
48
  reportPath = join(reportDir, 'quality-exceptions.md');
48
49
  const markdown = buildMarkdownReport(projectPath, analytics.forcedTransitions, analytics.stats);
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { rm, access } from 'node:fs/promises';
3
3
  import { generateDocsSite } from '../engine/docs-site-generator/index.js';
4
4
  import { compactResult } from './output-formatter.js';
5
+ import { projectRuntimeDir } from '../storage/storage-layout.js';
5
6
  async function removeMisplacedDocsSite(projectPath) {
6
7
  const stale = join(projectPath, 'docs-site');
7
8
  try {
@@ -17,7 +18,7 @@ export async function handleGenerateDocsSite(params) {
17
18
  const removed = await removeMisplacedDocsSite(params.projectPath);
18
19
  const config = {
19
20
  projectPath: params.projectPath,
20
- outputDir: join(params.projectPath, 'planu', 'docs-site'),
21
+ outputDir: join(projectRuntimeDir(params.projectPath, 'cache'), 'docs-site'),
21
22
  title: params.title,
22
23
  };
23
24
  const result = await generateDocsSite(config);
@@ -6,6 +6,7 @@ import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
6
6
  import { specStore, knowledgeStore, decisionStore, metricsStore } from '../storage/index.js';
7
7
  import { loadAllRiskRegisters } from '../storage/risk-store.js';
8
8
  import { generateProposal } from '../engine/doc-generator/proposal/proposal-generator.js';
9
+ import { projectRuntimeDir } from '../storage/storage-layout.js';
9
10
  import { SpecIdSchema } from './schemas/index.js';
10
11
  // ── Zod schema ────────────────────────────────────────────────────────────────
11
12
  const PhaseSchema = z.object({
@@ -131,8 +132,11 @@ async function handleGenerateProposal(args) {
131
132
  };
132
133
  const html = generateProposal(proposalData);
133
134
  // Resolve output path
134
- const relOutput = args.outputPath ?? 'planu/proposal.html';
135
- const outputFilePath = isAbsolute(relOutput) ? relOutput : join(args.projectPath, relOutput);
135
+ const outputFilePath = args.outputPath
136
+ ? isAbsolute(args.outputPath)
137
+ ? args.outputPath
138
+ : join(args.projectPath, args.outputPath)
139
+ : join(projectRuntimeDir(args.projectPath, 'reports'), 'proposal.html');
136
140
  await mkdir(dirname(outputFilePath), { recursive: true });
137
141
  await writeFile(outputFilePath, html, 'utf-8');
138
142
  const sizeKb = Math.round(html.length / 1024);
@@ -116,7 +116,7 @@ function buildArchitecture(knowledge) {
116
116
  } // only monorepo=No is not useful alone
117
117
  return wrap('architecture', `## Architecture\n${items.join('\n')}`);
118
118
  }
119
- function detectTestFramework(testCommand) {
119
+ function detectTestFrameworkFromCommand(testCommand) {
120
120
  if (testCommand.includes(technologyValue('technology-vitest-a9127f'))) {
121
121
  return technologyValue('technology-vitest-7fc39e');
122
122
  }
@@ -143,11 +143,28 @@ function detectTestFramework(testCommand) {
143
143
  }
144
144
  return 'Unknown';
145
145
  }
146
+ const TOOLING_TEST_FRAMEWORK_LABELS = {
147
+ [technologyValue('technology-vitest-a9127f')]: technologyValue('technology-vitest-7fc39e'),
148
+ [technologyValue('technology-jest-35e99f')]: technologyValue('technology-jest-1f30f0'),
149
+ mocha: 'Mocha',
150
+ [technologyValue('technology-pytest-2b0121')]: technologyValue('technology-pytest-2b0121'),
151
+ 'cargo-test': 'cargo test',
152
+ };
153
+ function detectTestFramework(knowledge, testCommand) {
154
+ const toolingTesting = knowledge.tooling.testing;
155
+ if (toolingTesting) {
156
+ const label = TOOLING_TEST_FRAMEWORK_LABELS[toolingTesting];
157
+ if (label) {
158
+ return label;
159
+ }
160
+ }
161
+ return detectTestFrameworkFromCommand(testCommand);
162
+ }
146
163
  function buildTesting(knowledge) {
147
164
  if (!knowledge.testCommand) {
148
165
  return null;
149
166
  }
150
- const framework = detectTestFramework(knowledge.testCommand);
167
+ const framework = detectTestFramework(knowledge, knowledge.testCommand);
151
168
  const items = [
152
169
  `- **Framework**: ${framework}`,
153
170
  `- **Command**: \`${knowledge.testCommand}\``,
@@ -1,8 +1,11 @@
1
1
  import type { ProjectKnowledge } from '../../types/index.js';
2
+ export type ConventionsSource = Pick<ProjectKnowledge, 'stack' | 'language' | 'framework' | 'projectPath'>;
2
3
  /**
3
4
  * SPEC-530: Write .claude/rules/conventions.md if missing.
4
5
  * Generates project-specific conventions from detected knowledge.
5
- * @returns true if file was created, false if it already existed.
6
+ * SPEC-1699: pass force=true to regenerate even when the file already exists,
7
+ * e.g. after migrateLegacyPlanuRoot folds conventions.json into project.json.
8
+ * @returns true if the file was written, false if it already existed and force was false.
6
9
  */
7
- export declare function generateConventionsMdIfMissing(projectPath: string, knowledge: ProjectKnowledge): Promise<boolean>;
10
+ export declare function generateConventionsMdIfMissing(projectPath: string, knowledge: ConventionsSource, force?: boolean): Promise<boolean>;
8
11
  //# sourceMappingURL=conventions-writer.d.ts.map
@@ -33,21 +33,26 @@ function buildConventionsContent(knowledge) {
33
33
  /**
34
34
  * SPEC-530: Write .claude/rules/conventions.md if missing.
35
35
  * Generates project-specific conventions from detected knowledge.
36
- * @returns true if file was created, false if it already existed.
36
+ * SPEC-1699: pass force=true to regenerate even when the file already exists,
37
+ * e.g. after migrateLegacyPlanuRoot folds conventions.json into project.json.
38
+ * @returns true if the file was written, false if it already existed and force was false.
37
39
  */
38
- export async function generateConventionsMdIfMissing(projectPath, knowledge) {
40
+ export async function generateConventionsMdIfMissing(projectPath, knowledge, force = false) {
39
41
  const conventionsPath = join(projectPath, CONVENTIONS_MD_PATH);
40
- try {
41
- await access(conventionsPath);
42
- return false;
43
- }
44
- catch {
45
- const rulesDir = join(projectPath, '.claude/rules');
46
- await mkdir(rulesDir, { recursive: true });
47
- const content = buildConventionsContent(knowledge);
48
- assertEnglishOnlyArtifactText(content, 'rule');
49
- await writeFile(conventionsPath, content, 'utf-8');
50
- return true;
42
+ if (!force) {
43
+ try {
44
+ await access(conventionsPath);
45
+ return false;
46
+ }
47
+ catch {
48
+ /* file absent fall through and write it */
49
+ }
51
50
  }
51
+ const rulesDir = join(projectPath, '.claude/rules');
52
+ await mkdir(rulesDir, { recursive: true });
53
+ const content = buildConventionsContent(knowledge);
54
+ assertEnglishOnlyArtifactText(content, 'rule');
55
+ await writeFile(conventionsPath, content, 'utf-8');
56
+ return true;
52
57
  }
53
58
  //# sourceMappingURL=conventions-writer.js.map
@@ -113,6 +113,7 @@ async function configureGitignore(projectPath) {
113
113
  }
114
114
  function buildPlanuIgnoreBlock() {
115
115
  return [
116
+ 'planu/.runtime/',
116
117
  'planu/*.html',
117
118
  'planu/status.json',
118
119
  'planu/CHANGELOG.md',
@@ -122,6 +123,14 @@ function buildPlanuIgnoreBlock() {
122
123
  'planu/data/',
123
124
  'planu/state/',
124
125
  'planu/.locks/',
126
+ // SPEC-1699: legacy root files/dir folded into project.json by init_project's
127
+ // migration — ignored so a transitional checkout never re-commits them.
128
+ 'planu/conventions.json',
129
+ 'planu/context.md',
130
+ 'planu/session-context.md',
131
+ 'planu/session.json',
132
+ 'planu/technology-selection.json',
133
+ 'planu/releases/',
125
134
  'planu/specs/**',
126
135
  '!planu/specs/**/',
127
136
  '!planu/specs/**/spec.md',
@@ -46,6 +46,8 @@ import { reconcileInteractiveQuestionHooks } from '../reconcile-interactive-ques
46
46
  import { installSessionSafeguardHook } from '../reconcile-session-safeguard-hook.js';
47
47
  import { resolveNewProjectOnboarding } from '../../engine/onboarding/new-project-resolver.js';
48
48
  import { interactiveResult } from '../response-helpers.js';
49
+ import { scheduleRuntimeResidueSweep } from './runtime-residue.js';
50
+ import { resolveLegacyMigration } from './legacy-root-migration.js';
49
51
  /** Frontend framework groups — mutually exclusive. Two or more detected → multi-stack conflict. */
50
52
  const FRONTEND_FRAMEWORK_GROUPS = [
51
53
  [
@@ -213,6 +215,11 @@ export async function handleInitProject(params, server) {
213
215
  // Check if project is already initialized
214
216
  const existing = await knowledgeStore.getKnowledge(projectId);
215
217
  const isUpdate = hadProjectIdentity || existing !== null;
218
+ // SPEC-1699: fold legacy planu/ root artifacts before the sweep below runs.
219
+ const legacyMigration = await resolveLegacyMigration(isUpdate, projectPath);
220
+ // SPEC-1699: Sweep runtime residue on every init_project run, including the
221
+ // already-initialized early-return path below — not just the fresh-init path.
222
+ scheduleRuntimeResidueSweep(projectPath);
216
223
  const authorizedMigrations = params.authorizedMigrations ?? [];
217
224
  if (isUpdate && authorizedMigrations.length === 0) {
218
225
  const reconciliation = await reconcilePortableSpecIndex(projectPath, projectId);
@@ -234,6 +241,7 @@ export async function handleInitProject(params, server) {
234
241
  failures: reconciliation.failures,
235
242
  skippedLegacy: reconciliation.skippedLegacy,
236
243
  strayRepoRemovals: reconciliation.strayRepoRemovals,
244
+ migratedLegacyPaths: legacyMigration.migrated,
237
245
  },
238
246
  ...(reconciliation.failures.length > 0 ? { isError: true } : {}),
239
247
  };
@@ -463,7 +471,7 @@ export async function handleInitProject(params, server) {
463
471
  /* best-effort */
464
472
  });
465
473
  // SPEC-193: Generate portal pages (fire-and-forget)
466
- void withAudit(projectPath, 'init_project', 'regeneratePages', () => regeneratePages(projectPath, ['knowledge', 'risks', 'decisions']), (pages) => ({ pages: pages.length })).catch(() => {
474
+ void withAudit(projectPath, 'init_project', 'regeneratePages', () => regeneratePages(projectPath, ['knowledge', 'risks', 'decisions']), (pages) => ({ pages })).catch(() => {
467
475
  /* best-effort — never fail init */
468
476
  });
469
477
  // SPEC-645: Detect LLM client and cache in conventions.json (fire-and-forget)
@@ -0,0 +1,15 @@
1
+ export interface LegacyMigrationResult {
2
+ readonly migrated: readonly string[];
3
+ readonly warnings: readonly string[];
4
+ }
5
+ /**
6
+ * Fold conventions.json, technology-selection.json, and releases/pending.json
7
+ * into project.json, then move context.md/session-context.md/session.json
8
+ * under planu/.runtime. Returns the migrated relative paths and any
9
+ * malformed-legacy-artifact warnings. Best-effort — a failure leaves the
10
+ * remaining legacy artifacts for a later retry.
11
+ */
12
+ export declare function migrateLegacyPlanuRoot(projectPath: string): Promise<LegacyMigrationResult>;
13
+ /** Runs `migrateLegacyPlanuRoot` only for an already-initialized project; a no-op otherwise. */
14
+ export declare function resolveLegacyMigration(isUpdate: boolean, projectPath: string): Promise<LegacyMigrationResult>;
15
+ //# sourceMappingURL=legacy-root-migration.d.ts.map
@@ -0,0 +1,213 @@
1
+ // tools/init-project/legacy-root-migration.ts — SPEC-1699
2
+ // Folds legacy planu/ root artifacts into their canonical SPEC-1699 locations:
3
+ // conventions.json and technology-selection.json fold into project.json,
4
+ // releases/pending.json folds into project.json's releases field, and
5
+ // context.md/session-context.md/session.json move under planu/.runtime.
6
+ // Best-effort and idempotent — a project with none of these legacy artifacts
7
+ // is a no-op. A malformed legacy file is left in place for manual repair
8
+ // instead of being silently treated as absent.
9
+ import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { generateConventionsMdIfMissing } from './conventions-writer.js';
12
+ import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
13
+ import { pathExists } from '../../core/shared/fs.js';
14
+ async function readJsonFile(filePath, isExpectedShape) {
15
+ let raw;
16
+ try {
17
+ raw = await readFile(filePath, 'utf-8');
18
+ }
19
+ catch {
20
+ return { status: 'missing' };
21
+ }
22
+ try {
23
+ const parsed = JSON.parse(raw);
24
+ if (!isExpectedShape(parsed)) {
25
+ return { status: 'malformed', error: `${filePath} does not have the expected shape` };
26
+ }
27
+ return { status: 'ok', value: parsed };
28
+ }
29
+ catch (error) {
30
+ return { status: 'malformed', error: error instanceof Error ? error.message : String(error) };
31
+ }
32
+ }
33
+ function isJsonObject(value) {
34
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+ function isJsonArray(value) {
37
+ return Array.isArray(value);
38
+ }
39
+ function malformedWarning(filePath, result) {
40
+ return result.status === 'malformed'
41
+ ? `Legacy artifact ${filePath} is malformed and was left in place: ${result.error}`
42
+ : null;
43
+ }
44
+ async function planConventionsFold(planuDir) {
45
+ const filePath = join(planuDir, 'conventions.json');
46
+ const result = await readJsonFile(filePath, isJsonObject);
47
+ if (result.status !== 'ok') {
48
+ return { step: null, warning: malformedWarning(filePath, result) };
49
+ }
50
+ return {
51
+ step: {
52
+ projectField: 'conventions',
53
+ projectValue: result.value,
54
+ pathsToDelete: [filePath],
55
+ migratedPath: 'planu/conventions.json',
56
+ },
57
+ warning: null,
58
+ };
59
+ }
60
+ async function planTechnologySelectionFold(planuDir) {
61
+ const filePath = join(planuDir, 'technology-selection.json');
62
+ const result = await readJsonFile(filePath, isJsonObject);
63
+ if (result.status !== 'ok') {
64
+ return { step: null, warning: malformedWarning(filePath, result) };
65
+ }
66
+ return {
67
+ step: {
68
+ projectField: 'technologySelection',
69
+ projectValue: result.value,
70
+ pathsToDelete: [filePath],
71
+ migratedPath: 'planu/technology-selection.json',
72
+ },
73
+ warning: null,
74
+ };
75
+ }
76
+ function dedupeReleaseEntriesById(entries) {
77
+ const deduped = new Map();
78
+ const undeduplicated = [];
79
+ for (const entry of entries) {
80
+ const id = isJsonObject(entry) && typeof entry.specId === 'string' ? entry.specId : undefined;
81
+ if (id === undefined) {
82
+ undeduplicated.push(entry);
83
+ continue;
84
+ }
85
+ deduped.set(id, entry);
86
+ }
87
+ return [...undeduplicated, ...deduped.values()];
88
+ }
89
+ async function planReleasesFold(planuDir, existingReleases) {
90
+ const releasesDir = join(planuDir, 'releases');
91
+ const pendingPath = join(releasesDir, 'pending.json');
92
+ const result = await readJsonFile(pendingPath, isJsonArray);
93
+ if (result.status !== 'ok') {
94
+ return { step: null, warning: malformedWarning(pendingPath, result) };
95
+ }
96
+ const existing = Array.isArray(existingReleases) ? existingReleases : [];
97
+ return {
98
+ step: {
99
+ projectField: 'releases',
100
+ // Deduped by specId so an interrupted-and-retried migration (project.json
101
+ // already updated, planu/releases/ not yet deleted) never duplicates entries.
102
+ projectValue: dedupeReleaseEntriesById([...existing, ...result.value]),
103
+ pathsToDelete: [releasesDir],
104
+ migratedPath: 'planu/releases/pending.json',
105
+ },
106
+ warning: null,
107
+ };
108
+ }
109
+ function extractConventionsSource(parsed, projectPath) {
110
+ const stackRaw = parsed.stack;
111
+ const stack = stackRaw !== null && typeof stackRaw === 'object' ? stackRaw : {};
112
+ const stackFields = [
113
+ stack.language,
114
+ stack.framework,
115
+ stack.testRunner,
116
+ stack.linter,
117
+ stack.formatter,
118
+ stack.bundler,
119
+ ].filter((value) => typeof value === 'string');
120
+ return {
121
+ language: typeof stack.language === 'string' ? stack.language : 'unknown',
122
+ framework: typeof stack.framework === 'string' ? stack.framework : null,
123
+ stack: stackFields,
124
+ projectPath,
125
+ };
126
+ }
127
+ async function moveUnderRuntime(planuDir, fileName) {
128
+ const source = join(planuDir, fileName);
129
+ if (!(await pathExists(source))) {
130
+ return null;
131
+ }
132
+ const runtimeDir = join(planuDir, '.runtime');
133
+ await mkdir(runtimeDir, { recursive: true });
134
+ await rename(source, join(runtimeDir, fileName));
135
+ return `planu/${fileName}`;
136
+ }
137
+ async function planJsonFolds(planuDir, project) {
138
+ const outcomes = await Promise.all([
139
+ planConventionsFold(planuDir),
140
+ planTechnologySelectionFold(planuDir),
141
+ planReleasesFold(planuDir, project.releases),
142
+ ]);
143
+ return {
144
+ steps: outcomes
145
+ .map((outcome) => outcome.step)
146
+ .filter((step) => step !== null),
147
+ warnings: outcomes
148
+ .map((outcome) => outcome.warning)
149
+ .filter((warning) => warning !== null),
150
+ };
151
+ }
152
+ async function foldJsonArtifactsIntoProject(projectPath, planuDir, projectJsonPath, project) {
153
+ const { steps, warnings } = await planJsonFolds(planuDir, project);
154
+ if (steps.length === 0) {
155
+ return { migrated: [], warnings };
156
+ }
157
+ const nextProject = { ...project };
158
+ for (const step of steps) {
159
+ nextProject[step.projectField] = step.projectValue;
160
+ }
161
+ // Write project.json, then regenerate conventions.md, and only then delete
162
+ // the legacy source files — a crash between steps must never leave the
163
+ // canonical record updated with the legacy source already gone unread.
164
+ await writeFile(projectJsonPath, `${JSON.stringify(nextProject, null, 2)}\n`, 'utf-8');
165
+ const conventionsStep = steps.find((step) => step.projectField === 'conventions');
166
+ if (conventionsStep) {
167
+ await generateConventionsMdIfMissing(projectPath, extractConventionsSource(conventionsStep.projectValue, projectPath), true);
168
+ }
169
+ for (const step of steps) {
170
+ for (const pathToDelete of step.pathsToDelete) {
171
+ await rm(pathToDelete, { recursive: true, force: true });
172
+ }
173
+ }
174
+ return { migrated: steps.map((step) => step.migratedPath), warnings };
175
+ }
176
+ /**
177
+ * Fold conventions.json, technology-selection.json, and releases/pending.json
178
+ * into project.json, then move context.md/session-context.md/session.json
179
+ * under planu/.runtime. Returns the migrated relative paths and any
180
+ * malformed-legacy-artifact warnings. Best-effort — a failure leaves the
181
+ * remaining legacy artifacts for a later retry.
182
+ */
183
+ export async function migrateLegacyPlanuRoot(projectPath) {
184
+ const planuDir = join(projectPath, 'planu');
185
+ const projectJsonPath = join(planuDir, 'project.json');
186
+ const migrated = [];
187
+ const warnings = [];
188
+ try {
189
+ const projectResult = await readJsonFile(projectJsonPath, isJsonObject);
190
+ const project = projectResult.status === 'ok' ? projectResult.value : {};
191
+ const folded = await foldJsonArtifactsIntoProject(projectPath, planuDir, projectJsonPath, project);
192
+ migrated.push(...folded.migrated);
193
+ warnings.push(...folded.warnings);
194
+ for (const fileName of ['context.md', 'session-context.md', 'session.json']) {
195
+ const movedPath = await moveUnderRuntime(planuDir, fileName);
196
+ if (movedPath) {
197
+ migrated.push(movedPath);
198
+ }
199
+ }
200
+ }
201
+ catch (error) {
202
+ warnings.push(`Legacy migration stopped early: ${error instanceof Error ? error.message : String(error)}`);
203
+ }
204
+ for (const warning of warnings) {
205
+ reportClassifiedDegradation('LEGACY_MIGRATION_ARTIFACT_WARNING', new Error(warning));
206
+ }
207
+ return { migrated, warnings };
208
+ }
209
+ /** Runs `migrateLegacyPlanuRoot` only for an already-initialized project; a no-op otherwise. */
210
+ export async function resolveLegacyMigration(isUpdate, projectPath) {
211
+ return isUpdate ? migrateLegacyPlanuRoot(projectPath) : { migrated: [], warnings: [] };
212
+ }
213
+ //# sourceMappingURL=legacy-root-migration.js.map
@@ -0,0 +1,2 @@
1
+ export declare function scheduleRuntimeResidueSweep(projectPath: string): void;
2
+ //# sourceMappingURL=runtime-residue.d.ts.map
@@ -0,0 +1,11 @@
1
+ // tools/init-project/runtime-residue.ts — SPEC-1695
2
+ // Fire-and-forget trigger for the runtime-residue sweep, called from init_project
3
+ // so existing client checkouts self-heal without a manual cleanup step.
4
+ import { withAudit } from '../../engine/autopilot/audit-logger.js';
5
+ import { sweepRuntimeResidue } from '../../engine/housekeeping/runtime-residue-sweep.js';
6
+ export function scheduleRuntimeResidueSweep(projectPath) {
7
+ void withAudit(projectPath, 'init_project', 'sweepRuntimeResidue', () => sweepRuntimeResidue(projectPath), (result) => ({ removed: result.removed.length })).catch(() => {
8
+ /* best-effort */
9
+ });
10
+ }
11
+ //# sourceMappingURL=runtime-residue.js.map
@@ -9,7 +9,7 @@ import { compactResult } from './output-formatter.js';
9
9
  export async function handleSessionCheckpoint(args) {
10
10
  const snapshot = sessionTracker.getSnapshot();
11
11
  await writeCheckpoint(args.projectPath, snapshot);
12
- return compactResult(`Session checkpoint written to ${args.projectPath}/planu/session-context.md\n` +
12
+ return compactResult(`Session checkpoint written to ${args.projectPath}/planu/.runtime/session-context.md\n` +
13
13
  `Calls recorded: ${String(snapshot.callCount)}, ` +
14
14
  `files tracked: ${String(snapshot.modifiedFiles.length)}.`);
15
15
  }
@@ -7,7 +7,7 @@ import { rollbackTransitionSpec, transitionSpec, } from '../../engine/spec-state
7
7
  import { calculateAccuracy } from '../../engine/estimator.js';
8
8
  import { cascadeCheck } from '../../engine/spec-versioner.js';
9
9
  import { runComplianceGates } from '../update-status-convention-gate.js';
10
- import { runDoneActions, runDoneSideEffects, runImplementingActions, runImplementingSideEffects, } from '../update-status-actions.js';
10
+ import { runDoneActions, runDoneSideEffects, runImplementingActions, runImplementingSideEffects, pruneRuntimeArtifactsOnTerminal, } from '../update-status-actions.js';
11
11
  import { compactObj } from '../../engine/compact-obj.js';
12
12
  import { checkTransition, checkDorGate, checkAmbiguityGate, checkReadinessGate, checkChallengeGate, checkImplementationDependencyGate, checkSpecArtifactCommittedGate, resolveAutoAdvanceSteps, isReverseTransition, validateReverseTransition, } from './transition-guard.js';
13
13
  import { checkApprovedDepGate } from '../../engine/dep-guard/index.js';
@@ -1426,6 +1426,12 @@ export async function handleUpdateStatus(params, server) {
1426
1426
  /* reliability-optional: TERMINAL_VALIDATION_REPORT — canonical receipt is authoritative */
1427
1427
  reportClassifiedDegradation('TERMINAL_VALIDATION_REPORT', error);
1428
1428
  });
1429
+ try {
1430
+ await pruneRuntimeArtifactsOnTerminal(projectId, specId);
1431
+ }
1432
+ catch (error) {
1433
+ reportClassifiedDegradation('RETENTION_TERMINAL_PRUNE', error);
1434
+ }
1429
1435
  }
1430
1436
  const allSpecs = await specStore.listSpecs(projectId);
1431
1437
  const cascadeResult = cascadeCheck(specId, allSpecs, newStatus);
@@ -1,4 +1,10 @@
1
- import type { ConstitutionViolation, DoneSideEffectsReport } from '../types/index.js';
1
+ import type { ConstitutionViolation, DoneSideEffectsReport, RetentionResult } from '../types/index.js';
2
+ /**
3
+ * Remove handoff packages, evidence bundles, and archived context for a spec
4
+ * that just reached a terminal status (done or discarded). Never touches
5
+ * planu/specs/ — the canonical spec.md lives outside runtime storage.
6
+ */
7
+ export declare function pruneRuntimeArtifactsOnTerminal(projectId: string, specId: string): Promise<RetentionResult>;
2
8
  export declare function runImplementingActions(projectId: string, specId: string, options?: {
3
9
  deferSideEffects?: boolean;
4
10
  projectPath?: string;
@@ -1,7 +1,15 @@
1
- import { specStore, knowledgeStore } from '../storage/index.js';
1
+ import { specStore, knowledgeStore, projectDataDir, enforceRetention } from '../storage/index.js';
2
2
  import { checkConstitutionCompliance } from './create-spec-tech.js';
3
3
  import { withAudit } from '../engine/autopilot/audit-logger.js';
4
4
  import { hasPending, markPending, clearPending } from '../engine/autopilot/cascade-deduplicator.js';
5
+ /**
6
+ * Remove handoff packages, evidence bundles, and archived context for a spec
7
+ * that just reached a terminal status (done or discarded). Never touches
8
+ * planu/specs/ — the canonical spec.md lives outside runtime storage.
9
+ */
10
+ export async function pruneRuntimeArtifactsOnTerminal(projectId, specId) {
11
+ return enforceRetention({ kind: 'spec-artifacts', path: projectDataDir(projectId), specId });
12
+ }
5
13
  function normalizeRejectedReasons(results) {
6
14
  return results.map((result) => {
7
15
  const reason = result.reason;
@@ -210,7 +218,7 @@ export async function runDoneActions(projectId, specId, gitBranch, options = {})
210
218
  // SPEC-649: slim cascade — 2 actions only
211
219
  const autopilotSummary = [
212
220
  'session.json: refresh queued',
213
- 'releases/pending.json: refresh queued',
221
+ 'project.json releases: refresh queued',
214
222
  ];
215
223
  return {
216
224
  mergeWarning,