@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
@@ -29,8 +29,11 @@ export declare function invalidateSpecsCache(projectId: string): void;
29
29
  export declare function discoverCanonicalSpecPath(specId: string, canonicalRoot: string): Promise<string | null>;
30
30
  /**
31
31
  * List every spec for a project.
32
+ *
33
+ * When `canonicalRoot` is supplied, every record's content-derived fields are
34
+ * verified against the current spec.md before being returned (SPEC-1699).
32
35
  */
33
- export declare function listSpecs(projectId: string): Promise<Spec[]>;
36
+ export declare function listSpecs(projectId: string, canonicalRoot?: string): Promise<Spec[]>;
34
37
  /**
35
38
  * Run a read-modify side effect against a fresh spec snapshot while holding the
36
39
  * same cross-process lock used by status mutations.
@@ -39,9 +42,11 @@ export declare function withFreshSpecsLock<T>(projectId: string, fn: (specs: Spe
39
42
  /**
40
43
  * Get a single spec by ID. Returns `null` when not found.
41
44
  *
42
- * When `canonicalRoot` is supplied, a legacy absolute `specPath`/`technicalPath`
45
+ * When `canonicalRoot` is supplied: a legacy absolute `specPath`/`technicalPath`
43
46
  * for this spec is migrated to its portable project-relative identity first
44
- * (best-effort — a failed migration attempt never blocks the read).
47
+ * (best-effort — a failed migration attempt never blocks the read), and the
48
+ * returned record's content-derived fields are verified against the current
49
+ * spec.md before being returned (SPEC-1699).
45
50
  */
46
51
  export declare function getSpec(projectId: string, specId: string, canonicalRoot?: string): Promise<Spec | null>;
47
52
  /** Result of a {@link migrateLegacySpecPaths} run. */
@@ -1,5 +1,6 @@
1
1
  import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
2
2
  import { CriticalJsonReadError, readCriticalJson, readJson, writeJson, projectDataDir, } from './base-store.js';
3
+ import { enforceRetention } from './retention.js';
3
4
  import { decodeCriticalSchema, registerCriticalSchema } from './schema-registry.js';
4
5
  import { BoundaryFailure } from '../errors/error-taxonomy.js';
5
6
  import { withFileLock } from './file-mutex.js';
@@ -8,11 +9,13 @@ import { computeHealthScore } from '../engine/spec-health-scorer.js';
8
9
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
9
10
  import { loadSpecContent } from '../engine/validation-loop.js';
10
11
  import { createHash, randomUUID } from 'node:crypto';
12
+ import { readFile } from 'node:fs/promises';
11
13
  import { basename, dirname, isAbsolute, join } from 'node:path';
12
14
  import { glob } from 'glob';
13
15
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
14
16
  import { acquireLock, releaseLock } from '../engine/safety/cross-process-lock.js';
15
- import { resolvePortableSpecPath, toPortableSpecPath, PortablePathError, } from './project-identity.js';
17
+ import { resolvePortableSpecPath, resolveVerifiedSpecPath, toPortableSpecPath, PortablePathError, } from './project-identity.js';
18
+ import { digestOf, readFrontmatterScalars } from './spec-index.js';
16
19
  import { appendEntry, getLastHash } from './audit-trail-store.js';
17
20
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
18
21
  import { hashEntry } from '../engine/audit-trail/hasher.js';
@@ -200,6 +203,7 @@ async function saveAll(projectId, specs) {
200
203
  const normalized = specs.map((spec) => normalizeSpec(spec));
201
204
  validateCanonicalSpecRows(projectId, normalized);
202
205
  await writeJson(specsFile(projectId), { schemaVersion: 1, data: normalized });
206
+ await enforceRetention({ kind: 'spec-store-backup', path: specsFile(projectId) });
203
207
  specsCache.set(projectId, normalized);
204
208
  }
205
209
  /** Evict the cache for a project (e.g. after external file changes). */
@@ -221,12 +225,72 @@ export async function discoverCanonicalSpecPath(specId, canonicalRoot) {
221
225
  });
222
226
  return candidates.length === 1 ? (candidates[0] ?? null) : null;
223
227
  }
228
+ /**
229
+ * Persist a digest-refresh in isolation from whatever mutation the caller of
230
+ * getSpec/listSpecs is otherwise doing. Best-effort: failure to persist never
231
+ * blocks the caller — the freshly parsed value is already what gets returned.
232
+ */
233
+ async function persistDigestRefresh(projectId, specId, title, contentDigest) {
234
+ await withSpecMutationLock(projectId, async () => {
235
+ const specs = await loadAll(projectId);
236
+ const idx = specs.findIndex((s) => s.id === specId);
237
+ if (idx === -1) {
238
+ return;
239
+ }
240
+ const nextSpecs = specs.map((current, currentIdx) => currentIdx === idx ? { ...current, title, contentDigest } : current);
241
+ await saveAll(projectId, nextSpecs);
242
+ });
243
+ }
244
+ /**
245
+ * SPEC-1699: single-source-of-truth choke point. spec.md is authoritative for
246
+ * the fields it carries — before returning a record, verify the persisted
247
+ * `contentDigest` still matches the current spec.md on disk. On a mismatch or
248
+ * missing digest, re-parse spec.md and return the fresh title/digest instead
249
+ * of the possibly-stale specs.json copy. Requires `canonicalRoot` to locate
250
+ * spec.md; callers that omit it get the specs.json record unchecked (matching
251
+ * the existing canonicalRoot-gated legacy-migration behavior on this same path).
252
+ */
253
+ async function refreshSpecFromDisk(projectId, spec, canonicalRoot) {
254
+ let specMdPath;
255
+ try {
256
+ specMdPath = await resolveVerifiedSpecPath(spec.id, spec.specPath, canonicalRoot);
257
+ }
258
+ catch (error) {
259
+ reportClassifiedDegradation('SPEC_CONTENT_DIGEST_CHECK_SKIPPED', error);
260
+ return spec;
261
+ }
262
+ let content;
263
+ try {
264
+ content = await readFile(specMdPath, 'utf-8');
265
+ }
266
+ catch (error) {
267
+ reportClassifiedDegradation('SPEC_CONTENT_DIGEST_CHECK_SKIPPED', error);
268
+ return spec;
269
+ }
270
+ const digest = digestOf(content);
271
+ if (digest === spec.contentDigest) {
272
+ return spec;
273
+ }
274
+ const fields = readFrontmatterScalars(content);
275
+ const refreshed = { ...spec, title: fields.title ?? spec.title, contentDigest: digest };
276
+ await persistDigestRefresh(projectId, spec.id, refreshed.title, digest).catch((error) => {
277
+ reportClassifiedDegradation('SPEC_CONTENT_DIGEST_REFRESH_PERSIST', error);
278
+ });
279
+ return refreshed;
280
+ }
224
281
  // --- public API ---
225
282
  /**
226
283
  * List every spec for a project.
284
+ *
285
+ * When `canonicalRoot` is supplied, every record's content-derived fields are
286
+ * verified against the current spec.md before being returned (SPEC-1699).
227
287
  */
228
- export async function listSpecs(projectId) {
229
- return loadAll(projectId);
288
+ export async function listSpecs(projectId, canonicalRoot) {
289
+ const specs = await loadAll(projectId);
290
+ if (canonicalRoot === undefined) {
291
+ return specs;
292
+ }
293
+ return Promise.all(specs.map((spec) => refreshSpecFromDisk(projectId, spec, canonicalRoot)));
230
294
  }
231
295
  /**
232
296
  * Run a read-modify side effect against a fresh spec snapshot while holding the
@@ -238,9 +302,11 @@ export async function withFreshSpecsLock(projectId, fn) {
238
302
  /**
239
303
  * Get a single spec by ID. Returns `null` when not found.
240
304
  *
241
- * When `canonicalRoot` is supplied, a legacy absolute `specPath`/`technicalPath`
305
+ * When `canonicalRoot` is supplied: a legacy absolute `specPath`/`technicalPath`
242
306
  * for this spec is migrated to its portable project-relative identity first
243
- * (best-effort — a failed migration attempt never blocks the read).
307
+ * (best-effort — a failed migration attempt never blocks the read), and the
308
+ * returned record's content-derived fields are verified against the current
309
+ * spec.md before being returned (SPEC-1699).
244
310
  */
245
311
  export async function getSpec(projectId, specId, canonicalRoot) {
246
312
  if (canonicalRoot !== undefined) {
@@ -249,7 +315,11 @@ export async function getSpec(projectId, specId, canonicalRoot) {
249
315
  });
250
316
  }
251
317
  const specs = await loadAll(projectId);
252
- return specs.find((s) => s.id === specId) ?? null;
318
+ const spec = specs.find((s) => s.id === specId) ?? null;
319
+ if (spec && canonicalRoot !== undefined) {
320
+ return refreshSpecFromDisk(projectId, spec, canonicalRoot);
321
+ }
322
+ return spec;
253
323
  }
254
324
  /**
255
325
  * Record one hash-chained audit event for a legacy specPath migration batch.
@@ -69,9 +69,9 @@ export function createStorageCatalog(context = {}) {
69
69
  },
70
70
  {
71
71
  id: 'portable-release-evidence',
72
- templatePath: '<project>/planu/releases/',
73
- resolvedPath: portableRoot ? join(portableRoot, 'releases') : undefined,
74
- purpose: 'Versioned release declarations and portable verification evidence.',
72
+ templatePath: '<project>/planu/project.json#releases',
73
+ resolvedPath: portableRoot ? join(portableRoot, 'project.json') : undefined,
74
+ purpose: 'Pending release declarations tracked in the releases field of project.json.',
75
75
  owner: 'project',
76
76
  sourceOfTruth: true,
77
77
  lifecycle: 'portable',
@@ -19,6 +19,14 @@ export interface StorageLayoutOptions {
19
19
  readonly homeDirectory?: string;
20
20
  readonly platform?: NodeJS.Platform;
21
21
  }
22
+ export type ProjectRuntimeSubdir = 'state' | 'cache' | 'logs' | 'reports';
23
+ /**
24
+ * Resolve the per-project runtime directory for a subdir kind.
25
+ * Rooted at <projectPath>/planu/.runtime/ — the only per-project runtime root.
26
+ * Platform storage roots from resolveStorageLayout are machine-local and do not
27
+ * apply to per-project artifacts.
28
+ */
29
+ export declare function projectRuntimeDir(projectPath: string, subdir: ProjectRuntimeSubdir): string;
22
30
  /** Resolve every machine-local Planu path independently from the current directory. */
23
31
  export declare function resolveStorageLayout(options?: StorageLayoutOptions): StorageLayout;
24
32
  //# sourceMappingURL=storage-layout.d.ts.map
@@ -73,6 +73,15 @@ function resolvePlatformRoots(platform, home, env) {
73
73
  }
74
74
  return xdgRoots(home, env);
75
75
  }
76
+ /**
77
+ * Resolve the per-project runtime directory for a subdir kind.
78
+ * Rooted at <projectPath>/planu/.runtime/ — the only per-project runtime root.
79
+ * Platform storage roots from resolveStorageLayout are machine-local and do not
80
+ * apply to per-project artifacts.
81
+ */
82
+ export function projectRuntimeDir(projectPath, subdir) {
83
+ return join(projectPath, 'planu', '.runtime', subdir);
84
+ }
76
85
  /** Resolve every machine-local Planu path independently from the current directory. */
77
86
  export function resolveStorageLayout(options = {}) {
78
87
  const env = options.env ?? process.env;
@@ -9,6 +9,7 @@ import { appendFile, mkdir } from 'node:fs/promises';
9
9
  import { createReadStream } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { projectDataDir } from './base-store.js';
12
+ import { enforceRetention } from './retention.js';
12
13
  import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
13
14
  // ---------------------------------------------------------------------------
14
15
  // Path helper
@@ -56,6 +57,7 @@ function computeEntrySha(entry) {
56
57
  const writeQueues = new Map();
57
58
  async function doAppend(filePath, input) {
58
59
  await mkdir(dirname(filePath), { recursive: true });
60
+ await enforceRetention({ kind: 'transition-log', path: filePath });
59
61
  let lastLine;
60
62
  for await (const line of streamJsonlLines(filePath)) {
61
63
  lastLine = line;
@@ -15,6 +15,7 @@ import { t, ti } from '../i18n/index.js';
15
15
  import { generateConcurrencyAnalysis, buildScalabilityAssessment, calculateOverallRisk, readSpecContent, } from './challenge-spec-helpers.js';
16
16
  import { prioritizeScenarios, buildPrioritizedSummary } from '../engine/challenge-prioritizer.js';
17
17
  import { checkContradictions as checkScopeContradictions } from '../engine/scope-boundaries/index.js';
18
+ import { detectCrossSpecPremiseContradictions } from '../engine/contradiction-detector.js';
18
19
  import { buildChallengeSpecSummary } from '../engine/human-summary.js';
19
20
  import { detectContradictions as detectDecisionContradictions, searchPriorDecisions, } from '../engine/prior-decisions/index.js';
20
21
  import { getDecisions } from '../storage/decision-store.js';
@@ -85,25 +86,23 @@ function extractOutOfScopeSection(content) {
85
86
  }
86
87
  return found ? captured.join('\n') : null;
87
88
  }
88
- async function resolveDocumentOutOfScope(spec, projectPath) {
89
+ async function readRawSpecMarkdown(spec, projectPath) {
89
90
  if (!spec.specPath) {
90
- return [];
91
+ return null;
91
92
  }
92
- let fileContent;
93
93
  try {
94
94
  const resolvedPath = !isAbsolute(spec.specPath) && projectPath
95
95
  ? await resolveVerifiedSpecPath(spec.id, spec.specPath, projectPath)
96
96
  : spec.specPath;
97
- fileContent = await readFile(resolvedPath, 'utf-8');
97
+ return await readFile(resolvedPath, 'utf-8');
98
98
  }
99
99
  catch {
100
- return [];
100
+ return null;
101
101
  }
102
- const sectionContent = extractOutOfScopeSection(fileContent);
103
- return sectionContent === null ? [] : extractListItems(sectionContent);
104
102
  }
105
- async function resolveOutOfScopeItems(spec, projectPath) {
106
- const documentItems = await resolveDocumentOutOfScope(spec, projectPath);
103
+ function resolveOutOfScopeItems(spec, rawSpecMarkdown) {
104
+ const sectionContent = rawSpecMarkdown === null ? null : extractOutOfScopeSection(rawSpecMarkdown);
105
+ const documentItems = sectionContent === null ? [] : extractListItems(sectionContent);
107
106
  return documentItems.length > 0 ? documentItems : (spec.outOfScope ?? []);
108
107
  }
109
108
  /**
@@ -172,15 +171,7 @@ export async function handleChallengeSpec(args, server) {
172
171
  if (!projectId) {
173
172
  return missingProjectIdError;
174
173
  }
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
174
+ // 1. Load project knowledge — SPEC-1011 Bug F: use disk fingerprint fallback
184
175
  let knowledge = await knowledgeStore.getKnowledge(projectId);
185
176
  if (!knowledge && args.projectPath) {
186
177
  const resolved = await resolveProjectFromPath(args.projectPath);
@@ -194,6 +185,15 @@ export async function handleChallengeSpec(args, server) {
194
185
  isError: true,
195
186
  };
196
187
  }
188
+ // 2. Load spec — SPEC-1699: pass canonicalRoot so a stale specs.json record
189
+ // (e.g. spec.md hand-edited after the last write) is refreshed on read.
190
+ const spec = await specStore.getSpec(projectId, specId, knowledge.projectPath);
191
+ if (!spec) {
192
+ return {
193
+ content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
194
+ isError: true,
195
+ };
196
+ }
197
197
  // 2b. Load Constitution for compliance context
198
198
  const constitution = await knowledgeStore.getConstitution(projectId);
199
199
  // 3. Read spec content
@@ -229,7 +229,8 @@ export async function handleChallengeSpec(args, server) {
229
229
  }
230
230
  failureScenarios.push(...collectCapabilityScenarios({ spec, specContent, knowledge, focusAreas, capabilities }));
231
231
  // SPEC-612: Check for contradictions between outOfScope and acceptance criteria
232
- const resolvedOutOfScope = await resolveOutOfScopeItems(spec, knowledge.projectPath);
232
+ const rawSpecMarkdown = await readRawSpecMarkdown(spec, knowledge.projectPath);
233
+ const resolvedOutOfScope = resolveOutOfScopeItems(spec, rawSpecMarkdown);
233
234
  if (resolvedOutOfScope.length > 0) {
234
235
  const criteriaRange = findMarkdownSectionRange(specContent, 'Acceptance Criteria');
235
236
  const criteriaTexts = criteriaRange === null
@@ -248,6 +249,21 @@ export async function handleChallengeSpec(args, server) {
248
249
  });
249
250
  }
250
251
  }
252
+ // SPEC-1702: Check for premise contradictions against done/approved sibling specs
253
+ const crossSpecPremiseFindings = rawSpecMarkdown !== null
254
+ ? await detectCrossSpecPremiseContradictions(spec.id, rawSpecMarkdown, knowledge.projectPath)
255
+ : [];
256
+ for (const finding of crossSpecPremiseFindings) {
257
+ failureScenarios.push({
258
+ scenario: `cross-spec-premise: ${finding.targetSpecId} contradicts ${finding.siblingSpecId}`,
259
+ probability: 'high',
260
+ impact: 'critical',
261
+ currentHandling: `${finding.targetSpecId} claims: "${finding.targetSentence}"`,
262
+ requiredHandling: `${finding.siblingSpecId} states: "${finding.siblingSentence}" — reconcile the premise before implementation (shared file: ${finding.sharedFilePath})`,
263
+ dataConsistency: 'Cross-spec premise drift',
264
+ userExperience: 'Implementing on a contradicted premise risks a wasted implementation round',
265
+ });
266
+ }
251
267
  // SPEC-615 AC3: Check criteria against prior decisions for technology contradictions
252
268
  await runPriorDecisionContradictions(projectId, spec.title, spec.tags, specContent, failureScenarios);
253
269
  // 6. Generate concurrency analysis
@@ -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 { scheduleHousekeepingSweeps } from './schedule-housekeeping.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
+ scheduleHousekeepingSweeps(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,2 @@
1
+ export declare function scheduleLegacyPlanuDemolition(projectPath: string): void;
2
+ //# sourceMappingURL=legacy-planu.d.ts.map
@@ -0,0 +1,18 @@
1
+ // tools/init-project/legacy-planu.ts — SPEC-1709
2
+ import { withAudit } from '../../engine/autopilot/audit-logger.js';
3
+ import { demolishLegacyPlanuRoot } from '../../engine/housekeeping/legacy-planu-demolisher.js';
4
+ export function scheduleLegacyPlanuDemolition(projectPath) {
5
+ const isTestEnvironment = Boolean(process.env.VITEST) || Boolean(process.env.PLANU_TEST_HOME);
6
+ if (isTestEnvironment) {
7
+ return;
8
+ }
9
+ void withAudit(projectPath, 'init_project', 'demolishLegacyPlanuRoot', () => demolishLegacyPlanuRoot(), (result) => ({
10
+ status: result.status,
11
+ migrated: result.migrated.length,
12
+ demolishedUnmappable: result.demolishedUnmappable,
13
+ freedBytes: result.freedBytes,
14
+ })).catch(() => {
15
+ /* best-effort */
16
+ });
17
+ }
18
+ //# sourceMappingURL=legacy-planu.js.map