@planu/cli 5.6.0 → 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 (43) hide show
  1. package/CHANGELOG.md +20 -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/contradiction-detector.d.ts +2 -1
  8. package/dist/engine/contradiction-detector.js +215 -0
  9. package/dist/engine/handoff-artifacts/schemas.js +4 -0
  10. package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
  11. package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
  12. package/dist/engine/lifecycle-reconciliation.js +87 -40
  13. package/dist/engine/readiness-checker.js +13 -1
  14. package/dist/engine/telemetry/error-reporter.d.ts +9 -9
  15. package/dist/engine/telemetry/error-reporter.js +15 -34
  16. package/dist/engine/telemetry/event-envelope.d.ts +11 -0
  17. package/dist/engine/telemetry/event-envelope.js +124 -0
  18. package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
  19. package/dist/engine/telemetry/telemetry-client.js +38 -20
  20. package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
  21. package/dist/engine/telemetry/telemetry-store.js +73 -2
  22. package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
  23. package/dist/engine/validator/spec-compliance-runner.js +78 -1
  24. package/dist/index.js +26 -0
  25. package/dist/tools/challenge-spec.js +25 -10
  26. package/dist/tools/init-project/handler.js +2 -2
  27. package/dist/tools/init-project/legacy-planu.d.ts +2 -0
  28. package/dist/tools/init-project/legacy-planu.js +18 -0
  29. package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
  30. package/dist/tools/init-project/schedule-housekeeping.js +8 -0
  31. package/dist/tools/reconcile-spec.js +29 -2
  32. package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
  33. package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
  34. package/dist/tools/safe-handler.js +6 -12
  35. package/dist/types/handoff-artifacts.d.ts +1 -0
  36. package/dist/types/housekeeping.d.ts +34 -0
  37. package/dist/types/housekeeping.js +0 -1
  38. package/dist/types/scope.d.ts +23 -0
  39. package/dist/types/spec/inputs.d.ts +9 -0
  40. package/dist/types/telemetry.d.ts +39 -1
  41. package/dist/types/validation-evidence.d.ts +6 -0
  42. package/package.json +1 -1
  43. package/planu-plugin.json +1 -1
@@ -2,12 +2,14 @@
2
2
  // engine/telemetry/telemetry-store.ts — SPEC-200: Read/write telemetry config from ~/.planu/
3
3
  // Stores opt-in preference and installation ID outside the project directory.
4
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import { join } from 'node:path';
6
7
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
7
8
  import { resolveStorageLayout } from '../../storage/storage-layout.js';
8
9
  const PLANU_DIR = resolveStorageLayout().config;
9
10
  const TELEMETRY_FILE = join(PLANU_DIR, 'telemetry.json');
10
11
  export const TELEMETRY_CONSENT_VERSION = 1;
12
+ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
13
  export async function readTelemetryConfig() {
12
14
  try {
13
15
  const raw = await readFile(TELEMETRY_FILE, 'utf-8');
@@ -22,6 +24,10 @@ export async function readTelemetryConfig() {
22
24
  if (parsed.enableObservatory === true) {
23
25
  config.enableObservatory = true;
24
26
  }
27
+ if (typeof parsed.anonymousInstallationId === 'string' &&
28
+ UUID_V4_PATTERN.test(parsed.anonymousInstallationId)) {
29
+ config.anonymousInstallationId = parsed.anonymousInstallationId;
30
+ }
25
31
  return config;
26
32
  }
27
33
  catch {
@@ -35,15 +41,28 @@ export async function writeTelemetryConfig(config) {
35
41
  promptedAt: config.promptedAt,
36
42
  consentVersion: config.consentVersion,
37
43
  enableObservatory: config.enableObservatory,
44
+ anonymousInstallationId: config.anonymousInstallationId,
38
45
  };
39
46
  await writeFile(TELEMETRY_FILE, JSON.stringify(safeConfig, null, 2) + '\n', 'utf-8');
40
47
  }
41
48
  /**
42
49
  * Returns true if telemetry is enabled.
43
- * Default: disabled. Both environment and stored opt-ins are versioned so a
44
- * changed privacy contract requires renewed consent.
50
+ * Suppression precedence (each read fresh from process.env on every call, so an
51
+ * env change within a process lifetime is respected immediately):
52
+ * PLANU_TELEMETRY_DISABLED=1 > DO_NOT_TRACK=1 > CI=true > PLANU_TELEMETRY=off
53
+ * > PLANU_TELEMETRY=on (with matching consent version) > stored consent.
54
+ * A corrupted or unparseable telemetry.json reads as consent-unknown (false), never crashes.
45
55
  */
46
56
  export async function isTelemetryEnabled() {
57
+ if (process.env.PLANU_TELEMETRY_DISABLED === '1') {
58
+ return false;
59
+ }
60
+ if (process.env.DO_NOT_TRACK === '1') {
61
+ return false;
62
+ }
63
+ if (process.env.CI === 'true') {
64
+ return false;
65
+ }
47
66
  if (process.env.PLANU_TELEMETRY === 'off') {
48
67
  return false;
49
68
  }
@@ -53,6 +72,57 @@ export async function isTelemetryEnabled() {
53
72
  const config = await readTelemetryConfig();
54
73
  return config?.enabled === true && config.consentVersion === TELEMETRY_CONSENT_VERSION;
55
74
  }
75
+ function promptedAtOrNow(promptedAt) {
76
+ return promptedAt !== undefined && promptedAt !== '' ? promptedAt : new Date().toISOString();
77
+ }
78
+ /** Enables telemetry and generates a fresh anonymousInstallationId, replacing any prior one. */
79
+ export async function enableTelemetry() {
80
+ const existing = await readTelemetryConfig();
81
+ const config = {
82
+ enabled: true,
83
+ promptedAt: promptedAtOrNow(existing?.promptedAt),
84
+ consentVersion: TELEMETRY_CONSENT_VERSION,
85
+ enableObservatory: existing?.enableObservatory,
86
+ anonymousInstallationId: randomUUID(),
87
+ };
88
+ await writeTelemetryConfig(config);
89
+ return config;
90
+ }
91
+ /** Disables telemetry and deletes the stored anonymousInstallationId. */
92
+ export async function disableTelemetry() {
93
+ const existing = await readTelemetryConfig();
94
+ const config = {
95
+ enabled: false,
96
+ promptedAt: existing?.promptedAt ?? new Date().toISOString(),
97
+ consentVersion: existing?.consentVersion,
98
+ enableObservatory: existing?.enableObservatory,
99
+ };
100
+ await writeTelemetryConfig(config);
101
+ return config;
102
+ }
103
+ /**
104
+ * Returns the current anonymousInstallationId when telemetry is enabled, lazily generating
105
+ * and persisting one if enabled but none is stored yet (e.g. enabled via PLANU_TELEMETRY=on).
106
+ * Returns undefined when telemetry is not enabled — never creates an id in that case.
107
+ */
108
+ export async function getOrCreateAnonymousInstallationId() {
109
+ if (!(await isTelemetryEnabled())) {
110
+ return undefined;
111
+ }
112
+ const config = await readTelemetryConfig();
113
+ if (config?.anonymousInstallationId) {
114
+ return config.anonymousInstallationId;
115
+ }
116
+ const anonymousInstallationId = randomUUID();
117
+ await writeTelemetryConfig({
118
+ enabled: true,
119
+ promptedAt: promptedAtOrNow(config?.promptedAt),
120
+ consentVersion: config?.consentVersion ?? TELEMETRY_CONSENT_VERSION,
121
+ enableObservatory: config?.enableObservatory,
122
+ anonymousInstallationId,
123
+ });
124
+ return anonymousInstallationId;
125
+ }
56
126
  /** Returns true if the user has never been shown the opt-in prompt. */
57
127
  export async function hasNeverBeenPrompted() {
58
128
  const config = await readTelemetryConfig();
@@ -66,6 +136,7 @@ export async function markAsPrompted() {
66
136
  promptedAt: new Date().toISOString(),
67
137
  consentVersion: existing?.consentVersion,
68
138
  enableObservatory: existing?.enableObservatory,
139
+ anonymousInstallationId: existing?.anonymousInstallationId,
69
140
  });
70
141
  }
71
142
  //# sourceMappingURL=telemetry-store.js.map
@@ -1,5 +1,5 @@
1
1
  import type { Spec } from '../../types/index.js';
2
- import type { FrontmatterScenario, SpecComplianceResult } from '../../types/validation-evidence.js';
2
+ import type { DoneDriftMissingFileFinding, FrontmatterScenario, SpecComplianceResult } from '../../types/validation-evidence.js';
3
3
  /** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
4
4
  export declare class ComplianceCommandTerminalError extends Error {
5
5
  readonly code: string;
@@ -8,4 +8,5 @@ export declare class ComplianceCommandTerminalError extends Error {
8
8
  export declare function parseFrontmatterScenarios(raw: string): FrontmatterScenario[];
9
9
  export declare function runSpecCompliance(spec: Spec, projectPath: string, signal?: AbortSignal, canonicalProjectId?: string): Promise<SpecComplianceResult>;
10
10
  export declare function findScenariosWithoutTests(raw: string): string[];
11
+ export declare function findDoneDriftMissingFiles(spec: Spec, raw: string, projectPath: string, fileExists: (path: string) => boolean): DoneDriftMissingFileFinding[];
11
12
  //# sourceMappingURL=spec-compliance-runner.d.ts.map
@@ -502,7 +502,13 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
502
502
  raw = await readFile(spec.specPath, 'utf-8');
503
503
  }
504
504
  catch {
505
- return { dimensionScore: 0, perScenario: [], command: '', evidenceSource: 'none' };
505
+ return {
506
+ dimensionScore: 0,
507
+ perScenario: [],
508
+ command: '',
509
+ evidenceSource: 'none',
510
+ doneDriftFindings: [],
511
+ };
506
512
  }
507
513
  const frontmatterScenarios = parseFrontmatterScenarios(raw);
508
514
  const executableEvidence = normalizeExecutableEvidence(raw, frontmatterScenarios, (path) => existsSync(join(projectPath, path)));
@@ -513,6 +519,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
513
519
  done: false,
514
520
  tests: scenario.tests.map((test) => ({ path: test.path })).filter(isExecutableTestLink),
515
521
  }));
522
+ const doneDriftFindings = findDoneDriftMissingFiles(spec, raw, projectPath, existsSync);
516
523
  if (scenarios.length === 0) {
517
524
  return {
518
525
  dimensionScore: 0,
@@ -520,6 +527,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
520
527
  command: '',
521
528
  evidenceSource: executableEvidence.source,
522
529
  ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
530
+ doneDriftFindings,
523
531
  };
524
532
  }
525
533
  const allLinks = scenarios.flatMap((scenario) => scenario.tests ?? []);
@@ -633,6 +641,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
633
641
  `${technologyValue('technology-vitest-a9127f')} --reporter=json --run (no test files linked)`,
634
642
  evidenceSource: executableEvidence.source,
635
643
  ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
644
+ doneDriftFindings,
636
645
  };
637
646
  }
638
647
  export function findScenariosWithoutTests(raw) {
@@ -640,4 +649,72 @@ export function findScenariosWithoutTests(raw) {
640
649
  .filter((scenario) => !scenario.tests?.length)
641
650
  .map((scenario) => scenario.title);
642
651
  }
652
+ const DONE_DRIFT_ELIGIBLE_PREFIXES = ['src/', 'scripts/', 'tests/'];
653
+ const FILES_MARKER = /FILES:\s*([^\n]*)/g;
654
+ const NEXT_MARKER_KEYWORD = /\s+(?:TEST|FUNCTIONS|AND|GIVEN|WHEN|THEN):/;
655
+ const FILES_SECTION_HEADING = /^##\s+Files\s*$/m;
656
+ const MARKDOWN_SECTION_HEADING = /^##\s+\S/m;
657
+ const BACKTICKED_LIST_ITEM = /^-\s+`([^`]+)`/;
658
+ function normalizeDoneDriftCandidate(segment) {
659
+ const trimmed = segment.trim().replace(/^`|`$/g, '');
660
+ return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
661
+ }
662
+ function isDoneDriftEligiblePath(path) {
663
+ return DONE_DRIFT_ELIGIBLE_PREFIXES.some((prefix) => path.startsWith(prefix));
664
+ }
665
+ const DONE_DRIFT_GLOB_CHARACTER = /[*?[]/;
666
+ const DONE_DRIFT_FILE_EXTENSION = /\.\w{1,6}$/;
667
+ function isDoneDriftCheckablePath(path) {
668
+ return (!DONE_DRIFT_GLOB_CHARACTER.test(path) &&
669
+ !path.endsWith('/') &&
670
+ DONE_DRIFT_FILE_EXTENSION.test(path));
671
+ }
672
+ function extractFilesMarkerPaths(raw) {
673
+ const paths = [];
674
+ for (const match of raw.matchAll(FILES_MARKER)) {
675
+ const rest = match[1] ?? '';
676
+ const boundary = NEXT_MARKER_KEYWORD.exec(rest);
677
+ const segment = boundary ? rest.slice(0, boundary.index) : rest;
678
+ for (const item of segment.split(',')) {
679
+ const candidate = normalizeDoneDriftCandidate(item);
680
+ if (candidate) {
681
+ paths.push(candidate);
682
+ }
683
+ }
684
+ }
685
+ return paths;
686
+ }
687
+ function extractFilesSectionPaths(raw) {
688
+ const sectionMatch = FILES_SECTION_HEADING.exec(raw);
689
+ if (!sectionMatch) {
690
+ return [];
691
+ }
692
+ const rest = raw.slice(sectionMatch.index + sectionMatch[0].length);
693
+ const nextSection = MARKDOWN_SECTION_HEADING.exec(rest);
694
+ const body = nextSection ? rest.slice(0, nextSection.index) : rest;
695
+ const paths = [];
696
+ for (const line of body.split('\n')) {
697
+ const itemMatch = BACKTICKED_LIST_ITEM.exec(line.trim());
698
+ if (itemMatch?.[1]) {
699
+ paths.push(normalizeDoneDriftCandidate(itemMatch[1]));
700
+ }
701
+ }
702
+ return paths;
703
+ }
704
+ export function findDoneDriftMissingFiles(spec, raw, projectPath, fileExists) {
705
+ if (spec.status !== 'done') {
706
+ return [];
707
+ }
708
+ const candidates = new Set([...extractFilesMarkerPaths(raw), ...extractFilesSectionPaths(raw)]);
709
+ const findings = [];
710
+ for (const candidate of candidates) {
711
+ if (!isDoneDriftEligiblePath(candidate) || !isDoneDriftCheckablePath(candidate)) {
712
+ continue;
713
+ }
714
+ if (!fileExists(join(projectPath, candidate))) {
715
+ findings.push({ kind: 'done-drift-missing-files', specId: spec.id, missingPath: candidate });
716
+ }
717
+ }
718
+ return findings;
719
+ }
643
720
  //# sourceMappingURL=spec-compliance-runner.js.map
package/dist/index.js CHANGED
@@ -84,6 +84,27 @@ function scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasks
84
84
  recoverValidateJobsAtStartup();
85
85
  });
86
86
  }
87
+ const MCP_HOST_MARKERS = [
88
+ ['claude-code', 'claude-code'],
89
+ ['claude-code', 'claude code'],
90
+ ['claude-desktop', 'claude-desktop'],
91
+ ['claude-desktop', 'claude desktop'],
92
+ ['cursor', 'cursor'],
93
+ ['codex', 'codex'],
94
+ ];
95
+ /** Maps the MCP client's reported implementation name to the envelope's allowlisted mcpHost. */
96
+ function resolveMcpHost(clientName) {
97
+ if (!clientName) {
98
+ return 'unknown';
99
+ }
100
+ const normalized = clientName.toLowerCase();
101
+ for (const [host, marker] of MCP_HOST_MARKERS) {
102
+ if (normalized.includes(marker)) {
103
+ return host;
104
+ }
105
+ }
106
+ return 'other';
107
+ }
87
108
  // Loads post-handshake modules after the MCP handshake has completed.
88
109
  // Separated from main() to keep main() within the 80-line function budget.
89
110
  async function setupPostHandshake(handshakeGate, officialToolNames) {
@@ -109,6 +130,11 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
109
130
  if (clientVersion) {
110
131
  setConnectedClient(clientVersion);
111
132
  }
133
+ // SPEC-1704: emit the mcp_server_started envelope now that the client is known.
134
+ const { sendTelemetryEnvelopeEvent } = await import('./engine/telemetry/telemetry-client.js');
135
+ sendTelemetryEnvelopeEvent('mcp_server_started', {
136
+ mcpHost: resolveMcpHost(clientVersion?.name),
137
+ });
112
138
  // SPEC-251: Check npm registry for newer version (fire-and-forget, 24h cache)
113
139
  checkForUpdates().catch(() => {
114
140
  /* best-effort */
@@ -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
  /**
@@ -230,7 +229,8 @@ export async function handleChallengeSpec(args, server) {
230
229
  }
231
230
  failureScenarios.push(...collectCapabilityScenarios({ spec, specContent, knowledge, focusAreas, capabilities }));
232
231
  // SPEC-612: Check for contradictions between outOfScope and acceptance criteria
233
- const resolvedOutOfScope = await resolveOutOfScopeItems(spec, knowledge.projectPath);
232
+ const rawSpecMarkdown = await readRawSpecMarkdown(spec, knowledge.projectPath);
233
+ const resolvedOutOfScope = resolveOutOfScopeItems(spec, rawSpecMarkdown);
234
234
  if (resolvedOutOfScope.length > 0) {
235
235
  const criteriaRange = findMarkdownSectionRange(specContent, 'Acceptance Criteria');
236
236
  const criteriaTexts = criteriaRange === null
@@ -249,6 +249,21 @@ export async function handleChallengeSpec(args, server) {
249
249
  });
250
250
  }
251
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
+ }
252
267
  // SPEC-615 AC3: Check criteria against prior decisions for technology contradictions
253
268
  await runPriorDecisionContradictions(projectId, spec.title, spec.tags, specContent, failureScenarios);
254
269
  // 6. Generate concurrency analysis
@@ -46,7 +46,7 @@ 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';
49
+ import { scheduleHousekeepingSweeps } from './schedule-housekeeping.js';
50
50
  import { resolveLegacyMigration } from './legacy-root-migration.js';
51
51
  /** Frontend framework groups — mutually exclusive. Two or more detected → multi-stack conflict. */
52
52
  const FRONTEND_FRAMEWORK_GROUPS = [
@@ -219,7 +219,7 @@ export async function handleInitProject(params, server) {
219
219
  const legacyMigration = await resolveLegacyMigration(isUpdate, projectPath);
220
220
  // SPEC-1699: Sweep runtime residue on every init_project run, including the
221
221
  // already-initialized early-return path below — not just the fresh-init path.
222
- scheduleRuntimeResidueSweep(projectPath);
222
+ scheduleHousekeepingSweeps(projectPath);
223
223
  const authorizedMigrations = params.authorizedMigrations ?? [];
224
224
  if (isUpdate && authorizedMigrations.length === 0) {
225
225
  const reconciliation = await reconcilePortableSpecIndex(projectPath, projectId);
@@ -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
@@ -0,0 +1,2 @@
1
+ export declare function scheduleHousekeepingSweeps(projectPath: string): void;
2
+ //# sourceMappingURL=schedule-housekeeping.d.ts.map
@@ -0,0 +1,8 @@
1
+ // tools/init-project/schedule-housekeeping.ts — SPEC-1709
2
+ import { scheduleRuntimeResidueSweep } from './runtime-residue.js';
3
+ import { scheduleLegacyPlanuDemolition } from './legacy-planu.js';
4
+ export function scheduleHousekeepingSweeps(projectPath) {
5
+ scheduleRuntimeResidueSweep(projectPath);
6
+ scheduleLegacyPlanuDemolition(projectPath);
7
+ }
8
+ //# sourceMappingURL=schedule-housekeeping.js.map
@@ -1,5 +1,5 @@
1
1
  import { SECTIONS_WITHOUT_LITERAL_BODY_TEXT, } from '../types/index.js';
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { elicitOrFallback, buildEnumSchema } from '../engine/elicitation/elicit-helper.js';
4
4
  import { ti, t } from '../i18n/index.js';
5
5
  import { formatSuccess, addNextSteps } from './response-helpers.js';
@@ -14,6 +14,9 @@ import { analyzeLivingSpec } from '../engine/living-spec-analyzer.js';
14
14
  import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
15
15
  import { applyChangesToSpec } from '../engine/reconcile/apply-changes.js';
16
16
  import { verifyWriteSucceeded } from '../engine/reconcile/verify-write.js';
17
+ import { reconcileImplementingSpec, currentReconciliationInvocationContext, } from '../engine/lifecycle-reconciliation.js';
18
+ import { reconciliationDigest } from '../engine/lifecycle-reconciliation-io.js';
19
+ import { syncSpecFiles } from './update-status/file-sync.js';
17
20
  function detectEstimationDrift(spec) {
18
21
  if (!spec.actuals) {
19
22
  return null;
@@ -322,8 +325,29 @@ function applyConflictResolution(resolution, allChanges, pendingChanges) {
322
325
  }
323
326
  }
324
327
  }
328
+ async function routeDeclaredArchitecturalDrift(spec, projectId, projectPath, declaredDrift) {
329
+ const reason = declaredDrift.reason;
330
+ const input = {
331
+ specId: spec.id,
332
+ projectId,
333
+ projectPath,
334
+ status: 'review',
335
+ reconciliationRequestId: randomUUID(),
336
+ expectedImplementingTransitionId: spec.statusHistory?.at(-1)?.transitionId,
337
+ implementationReviewDigest: reconciliationDigest(reason.trim()),
338
+ reason,
339
+ declaredDriftKind: declaredDrift.kind,
340
+ };
341
+ return reconcileImplementingSpec({
342
+ input,
343
+ projectId,
344
+ projectPath,
345
+ context: currentReconciliationInvocationContext(),
346
+ syncSpecFiles,
347
+ });
348
+ }
325
349
  export async function handleReconcileSpec(params, server) {
326
- const { specId, projectId, autoDetect = true, livingSpec = false, changes: manualChanges, } = params;
350
+ const { specId, projectId, autoDetect = true, livingSpec = false, changes: manualChanges, declaredDrift, } = params;
327
351
  try {
328
352
  const spec = await specStore.getSpec(projectId, specId);
329
353
  if (!spec) {
@@ -333,6 +357,9 @@ export async function handleReconcileSpec(params, server) {
333
357
  };
334
358
  }
335
359
  const knowledge = await knowledgeStore.getKnowledge(projectId);
360
+ if (declaredDrift) {
361
+ return await routeDeclaredArchitecturalDrift(spec, projectId, knowledge?.projectPath, declaredDrift);
362
+ }
336
363
  const allChanges = [];
337
364
  if (autoDetect) {
338
365
  const autoChanges = await autoDetectChanges(spec, knowledge);
@@ -1,4 +1,11 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ export declare const DeclaredDriftInputSchema: z.ZodObject<{
4
+ kind: z.ZodEnum<{
5
+ "architectural-premise": "architectural-premise";
6
+ }>;
7
+ reason: z.ZodString;
8
+ }, z.core.$strip>;
2
9
  /**
3
10
  * Registers drift detection, analysis, and discovery tools (tools 11–19) on the MCP server.
4
11
  */
@@ -7,6 +7,7 @@ import { handleDetectDrift } from '../detect-drift.js';
7
7
  import { handleSummarizeSpec } from '../summarize-spec.js';
8
8
  import { handleGenerateChecklist } from '../generate-checklist.js';
9
9
  import { handleReconcileSpec } from '../reconcile-spec.js';
10
+ import { runWithTrustedLocalMcpContext } from '../../engine/lifecycle-reconciliation.js';
10
11
  import { handleLearn } from '../learn.js';
11
12
  import { handleAudit } from '../audit.js';
12
13
  import { handleConsultDocs } from '../consult-docs.js';
@@ -17,6 +18,16 @@ import { handleSuggestMcpServer } from '../suggest-mcp-server.js';
17
18
  import { handleScanOrphanSpecRefs, ScanOrphanSpecRefsInputSchema, } from '../scan-orphan-spec-refs.js';
18
19
  import { registerGraphSpecsTool } from '../graph-specs.js';
19
20
  import { registerAuditSpecsDriftTool } from '../audit-specs-drift.js';
21
+ export const DeclaredDriftInputSchema = z.object({
22
+ kind: z
23
+ .enum(['architectural-premise'])
24
+ .describe('Drift kind. Only "architectural-premise" is supported: an orchestrator-declared premise contradiction that produces no auto-detectable file/scope drift.'),
25
+ reason: z
26
+ .string()
27
+ .min(100)
28
+ .max(10_000)
29
+ .describe('Why the implementation drifted from the approved architectural premise. Minimum 100 characters — becomes the reconciliation transition reason.'),
30
+ });
20
31
  import { handleSsrBackMigration } from '../ssr-back-migration.js';
21
32
  import { registerMigrateLegacySpecTool } from '../migrate-legacy-spec.js';
22
33
  /**
@@ -130,13 +141,14 @@ export function registerAnalysisTools(server) {
130
141
  .max(1000)
131
142
  .optional()
132
143
  .describe('Manual changes to apply'),
144
+ declaredDrift: DeclaredDriftInputSchema.optional().describe('When the spec is implementing, routes reconcile_spec into the existing implementing→review reconciliation demotion instead of auto-detected drift.'),
133
145
  },
134
146
  }, safeGoverned('reconcile_spec', async (args) => {
135
147
  const pid = resolveProjectId(args);
136
148
  if (!pid) {
137
149
  return missingProjectIdError;
138
150
  }
139
- return handleReconcileSpec({ ...args, projectId: pid });
151
+ return runWithTrustedLocalMcpContext(() => handleReconcileSpec({ ...args, projectId: pid }));
140
152
  }));
141
153
  // 15. learn_pattern
142
154
  server.registerTool('learn_pattern', {
@@ -7,8 +7,7 @@ import { ensureWorkersStarted } from '../engine/workers/index.js';
7
7
  import { recordError } from '../storage/error-telemetry-store.js';
8
8
  import { hashProjectPath } from '../storage/base-store.js';
9
9
  import { reportToolError, reportToolValidationError } from '../engine/telemetry/error-reporter.js';
10
- import { sendTelemetryEvent } from '../engine/telemetry/telemetry-client.js';
11
- import { PLANU_VERSION } from '../config/version.js';
10
+ import { sendTelemetryEnvelopeEvent } from '../engine/telemetry/telemetry-client.js';
12
11
  import { recordToolTokens, extractOutputText } from './token-recording.js';
13
12
  import { DriftCacheStore } from '../storage/drift-cache-store.js';
14
13
  import { consumeUpdateBanner } from '../engine/update-notifier.js';
@@ -509,18 +508,13 @@ function safeWithTelemetry(toolName, handler) {
509
508
  const outputText = extractOutputText(processedResult);
510
509
  recordLlmTokens(toolName, inputText, outputText);
511
510
  }
512
- // Emit tool_used telemetry for successful calls (fire-and-forget)
511
+ // Emit mcp_tool_completed envelope for successful calls (fire-and-forget)
513
512
  /* v8 ignore start */
514
513
  if (toolName !== undefined && processedResult.isError !== true) {
515
- sendTelemetryEvent({
516
- event: 'tool_used',
517
- properties: {
518
- tool: toolName,
519
- planVersion: PLANU_VERSION,
520
- nodeVersion: process.version,
521
- platform: process.platform,
522
- durationMs: Date.now() - startTime,
523
- },
514
+ sendTelemetryEnvelopeEvent('mcp_tool_completed', {
515
+ toolName,
516
+ result: 'success',
517
+ durationMs: Date.now() - startTime,
524
518
  });
525
519
  }
526
520
  /* v8 ignore stop */
@@ -124,6 +124,7 @@ export interface ReconciliationReceiptV1 {
124
124
  transitionId?: string;
125
125
  phase?: 'prepared' | 'status-committed' | 'spec-synced' | 'audit-appended';
126
126
  auditPending?: boolean;
127
+ driftSource?: 'declared-architectural-premise';
127
128
  }
128
129
  /** Injectable receipt persistence boundary used only for reconciliation crash tests. */
129
130
  export interface ReconciliationIo {
@@ -1,3 +1,4 @@
1
+ import type { GlobalProjectsRegistry, RegisteredProject } from './cross-repo-search.js';
1
2
  export interface StaleBranchInfo {
2
3
  /** Short ref name, e.g. 'tmp-foo' or 'feat/spec-100-bar'. */
3
4
  name: string;
@@ -150,4 +151,37 @@ export interface RuntimeResidueSweepResult {
150
151
  /** Absolute paths of every legacy runtime artifact removed from planu/. */
151
152
  removed: string[];
152
153
  }
154
+ export interface LegacyPlanuDemolitionFailure {
155
+ /** Directory name under <legacyRoot>/data/projects. */
156
+ dir: string;
157
+ /** Human-readable reason the directory could not be migrated. */
158
+ reason: string;
159
+ }
160
+ export type LegacyPlanuDemolitionStatus = 'demolished' | 'retained' | 'absent';
161
+ export interface LegacyPlanuDemolitionReport {
162
+ status: LegacyPlanuDemolitionStatus;
163
+ /** Directory names successfully migrated into their canonical destination. */
164
+ migrated: string[];
165
+ /** Count of unmappable directories deleted outright. */
166
+ demolishedUnmappable: number;
167
+ /** Per-directory migration failures; the root is retained when this is non-empty. */
168
+ failures: LegacyPlanuDemolitionFailure[];
169
+ /** Bytes freed by deleting the whole legacy root (0 unless status is 'demolished'). */
170
+ freedBytes: number;
171
+ }
172
+ export interface LegacyPlanuDemolisherOptions {
173
+ /** Legacy root to sweep. Defaults to join(homedir(), '.planu'). */
174
+ legacyRoot?: string;
175
+ /** Global cross-repo registry. Defaults to a live read via getRegistry(). */
176
+ registry?: GlobalProjectsRegistry;
177
+ /** Resolves a registered project's canonical storage destination. */
178
+ resolveDestination?: (project: RegisteredProject) => Promise<string>;
179
+ }
180
+ /** Internal accumulator returned by the data/projects sweep pass. */
181
+ export interface LegacyPlanuSweepResult {
182
+ migrated: string[];
183
+ demolishedUnmappable: number;
184
+ failures: LegacyPlanuDemolitionFailure[];
185
+ freedBytes: number;
186
+ }
153
187
  //# sourceMappingURL=housekeeping.d.ts.map
@@ -1,3 +1,2 @@
1
- // types/housekeeping.ts — SPEC-751: Housekeeping sweep types
2
1
  export {};
3
2
  //# sourceMappingURL=housekeeping.js.map
@@ -126,4 +126,27 @@ export interface ContradictionPatternDef {
126
126
  /** Recommended resolution action. */
127
127
  recommendation: string;
128
128
  }
129
+ /** A pair of antonymic direction phrases used to detect cross-spec premise drift (SPEC-1702). */
130
+ export interface DirectionalPhrasePair {
131
+ /** One side of the directional claim, e.g. "re-roots at". */
132
+ phraseA: string;
133
+ /** The opposite side of the directional claim, e.g. "relocates to". */
134
+ phraseB: string;
135
+ }
136
+ /**
137
+ * A contradiction between a spec's body claim about a done/approved sibling spec
138
+ * and that sibling's own delivered contract (SPEC-1702).
139
+ */
140
+ export interface CrossSpecPremiseFinding {
141
+ /** ID of the spec containing the premise claim (e.g. "SPEC-1698"). */
142
+ targetSpecId: string;
143
+ /** ID of the referenced sibling spec whose contract contradicts the claim (e.g. "SPEC-1695"). */
144
+ siblingSpecId: string;
145
+ /** The exact sentence in the target spec asserting the contradicted claim. */
146
+ targetSentence: string;
147
+ /** The exact sentence in the sibling spec's own body stating the opposite direction. */
148
+ siblingSentence: string;
149
+ /** File path shared between both specs' ## Files sections, evidencing the same subject. */
150
+ sharedFilePath: string;
151
+ }
129
152
  //# sourceMappingURL=scope.d.ts.map