@planu/cli 5.6.0 → 5.7.1

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 (52) hide show
  1. package/CHANGELOG.md +41 -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/detectors/cache-db-detector.js +2 -2
  10. package/dist/engine/detectors/newsql-db-detector.js +2 -2
  11. package/dist/engine/detectors/search-engine-detector.js +2 -2
  12. package/dist/engine/detectors/vector-db-detector.js +2 -2
  13. package/dist/engine/detectors/widecolumn-db-detector.js +2 -2
  14. package/dist/engine/handoff-artifacts/schemas.js +4 -0
  15. package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
  16. package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
  17. package/dist/engine/lifecycle-reconciliation.js +87 -40
  18. package/dist/engine/readiness-checker.js +13 -1
  19. package/dist/engine/telemetry/error-reporter.d.ts +9 -9
  20. package/dist/engine/telemetry/error-reporter.js +15 -34
  21. package/dist/engine/telemetry/event-envelope.d.ts +11 -0
  22. package/dist/engine/telemetry/event-envelope.js +124 -0
  23. package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
  24. package/dist/engine/telemetry/telemetry-client.js +38 -20
  25. package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
  26. package/dist/engine/telemetry/telemetry-store.js +73 -2
  27. package/dist/engine/validator/reliability-gate.d.ts +4 -0
  28. package/dist/engine/validator/reliability-gate.js +93 -0
  29. package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
  30. package/dist/engine/validator/spec-compliance-runner.js +78 -1
  31. package/dist/index.js +26 -0
  32. package/dist/storage/migrations/canonical-storage.js +22 -9
  33. package/dist/tools/challenge-spec.js +25 -10
  34. package/dist/tools/init-project/handler.js +2 -2
  35. package/dist/tools/init-project/legacy-planu.d.ts +2 -0
  36. package/dist/tools/init-project/legacy-planu.js +18 -0
  37. package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
  38. package/dist/tools/init-project/schedule-housekeeping.js +8 -0
  39. package/dist/tools/reconcile-spec.js +29 -2
  40. package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
  41. package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
  42. package/dist/tools/safe-handler.js +6 -12
  43. package/dist/tools/validate.js +36 -1
  44. package/dist/types/handoff-artifacts.d.ts +1 -0
  45. package/dist/types/housekeeping.d.ts +34 -0
  46. package/dist/types/housekeeping.js +0 -1
  47. package/dist/types/scope.d.ts +23 -0
  48. package/dist/types/spec/inputs.d.ts +9 -0
  49. package/dist/types/telemetry.d.ts +39 -1
  50. package/dist/types/validation-evidence.d.ts +18 -0
  51. package/package.json +1 -1
  52. package/planu-plugin.json +1 -1
@@ -1,7 +1,8 @@
1
1
  // Remote telemetry is disabled by default and always crosses consent + redaction boundaries.
2
2
  import { networkFetch, withNetworkConsent } from '../network-policy.js';
3
3
  import { redactSensitiveValue } from '../../security/redactor.js';
4
- import { isTelemetryEnabled } from './telemetry-store.js';
4
+ import { isTelemetryEnabled, getOrCreateAnonymousInstallationId } from './telemetry-store.js';
5
+ import { buildTelemetryEnvelope, TELEMETRY_SESSION_ID } from './event-envelope.js';
5
6
  function isLoopbackHost(hostname) {
6
7
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
7
8
  }
@@ -24,25 +25,19 @@ function isConfiguredTelemetryToken(token) {
24
25
  return Boolean(token?.trim());
25
26
  }
26
27
  const ALLOWED_PROPERTIES = new Set([
27
- 'allowed',
28
- 'context',
29
- 'diagnostic',
30
- 'duplicateCount',
31
- 'durationMs',
32
- 'errorClass',
33
- 'errorType',
34
- 'framework',
35
- 'freeGBObserved',
36
- 'language',
37
- 'nodeVersion',
38
- 'planVersion',
39
- 'platform',
40
- 'pressureLevel',
41
- 'requested',
42
- 'specCount',
43
- 'stack',
44
- 'timestamp',
45
- 'tool',
28
+ 'schemaVersion',
29
+ 'eventId',
30
+ 'eventName',
31
+ 'occurredAt',
32
+ 'anonymousInstallationId',
33
+ 'sessionId',
34
+ 'planuVersion',
35
+ 'toolName',
36
+ 'result',
37
+ 'durationBucket',
38
+ 'operatingSystem',
39
+ 'nodeMajor',
40
+ 'mcpHost',
46
41
  ]);
47
42
  function safeProperties(properties) {
48
43
  const allowlisted = Object.fromEntries(Object.entries(properties).filter(([key]) => ALLOWED_PROPERTIES.has(key)));
@@ -75,4 +70,27 @@ export function sendTelemetryEvent(event) {
75
70
  })
76
71
  .catch(() => undefined);
77
72
  }
73
+ /**
74
+ * Builds a schemaVersion-1 envelope and emits it through sendTelemetryEvent.
75
+ * Fire-and-forget: never awaited by callers, and every failure (disabled consent, no
76
+ * installation id yet, an unknown enum value) resolves to a silent no-op — telemetry
77
+ * never blocks or delays the operation it is describing.
78
+ */
79
+ export function sendTelemetryEnvelopeEvent(name, fields) {
80
+ void (async () => {
81
+ const anonymousInstallationId = await getOrCreateAnonymousInstallationId();
82
+ if (!anonymousInstallationId) {
83
+ return;
84
+ }
85
+ const envelope = buildTelemetryEnvelope(name, {
86
+ ...fields,
87
+ anonymousInstallationId,
88
+ sessionId: TELEMETRY_SESSION_ID,
89
+ });
90
+ sendTelemetryEvent({
91
+ event: envelope.eventName,
92
+ properties: envelope,
93
+ });
94
+ })().catch(() => undefined);
95
+ }
78
96
  //# sourceMappingURL=telemetry-client.js.map
@@ -4,10 +4,23 @@ export declare function readTelemetryConfig(): Promise<TelemetryConfig | null>;
4
4
  export declare function writeTelemetryConfig(config: TelemetryConfig): Promise<void>;
5
5
  /**
6
6
  * Returns true if telemetry is enabled.
7
- * Default: disabled. Both environment and stored opt-ins are versioned so a
8
- * changed privacy contract requires renewed consent.
7
+ * Suppression precedence (each read fresh from process.env on every call, so an
8
+ * env change within a process lifetime is respected immediately):
9
+ * PLANU_TELEMETRY_DISABLED=1 > DO_NOT_TRACK=1 > CI=true > PLANU_TELEMETRY=off
10
+ * > PLANU_TELEMETRY=on (with matching consent version) > stored consent.
11
+ * A corrupted or unparseable telemetry.json reads as consent-unknown (false), never crashes.
9
12
  */
10
13
  export declare function isTelemetryEnabled(): Promise<boolean>;
14
+ /** Enables telemetry and generates a fresh anonymousInstallationId, replacing any prior one. */
15
+ export declare function enableTelemetry(): Promise<TelemetryConfig>;
16
+ /** Disables telemetry and deletes the stored anonymousInstallationId. */
17
+ export declare function disableTelemetry(): Promise<TelemetryConfig>;
18
+ /**
19
+ * Returns the current anonymousInstallationId when telemetry is enabled, lazily generating
20
+ * and persisting one if enabled but none is stored yet (e.g. enabled via PLANU_TELEMETRY=on).
21
+ * Returns undefined when telemetry is not enabled — never creates an id in that case.
22
+ */
23
+ export declare function getOrCreateAnonymousInstallationId(): Promise<string | undefined>;
11
24
  /** Returns true if the user has never been shown the opt-in prompt. */
12
25
  export declare function hasNeverBeenPrompted(): Promise<boolean>;
13
26
  /** Mark that the user was shown the opt-in prompt (so we don't show it again). */
@@ -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
@@ -0,0 +1,4 @@
1
+ import type { ReliabilityGateResult, ReliabilityGateViolation } from '../../types/index.js';
2
+ export type { ReliabilityGateResult, ReliabilityGateViolation };
3
+ export declare function runReliabilityGate(projectPath: string, specFiles: string[]): ReliabilityGateResult;
4
+ //# sourceMappingURL=reliability-gate.d.ts.map
@@ -0,0 +1,93 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { relative, resolve } from 'node:path';
3
+ let cachedProtectedDirectories;
4
+ function fetchProtectedDirectories() {
5
+ if (cachedProtectedDirectories !== undefined) {
6
+ return cachedProtectedDirectories;
7
+ }
8
+ const stdout = execFileSync(process.execPath, ['scripts/check-reliability-policies.mjs', '--print-protected-dirs'], { cwd: process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
9
+ const parsed = JSON.parse(stdout);
10
+ if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === 'string')) {
11
+ throw new Error('reliability gate protected-directories output is not a string array');
12
+ }
13
+ cachedProtectedDirectories = parsed;
14
+ return parsed;
15
+ }
16
+ function normalizeToProjectRelative(projectPath, file) {
17
+ return relative(projectPath, resolve(projectPath, file));
18
+ }
19
+ function isProtected(protectedDirectories, file) {
20
+ return protectedDirectories.some((directory) => file === directory || file.startsWith(`${directory}/`));
21
+ }
22
+ function parseViolations(stdout) {
23
+ const parsed = JSON.parse(stdout);
24
+ if (!Array.isArray(parsed)) {
25
+ throw new Error('reliability gate output is not a JSON array');
26
+ }
27
+ return parsed.map((entry) => {
28
+ if (typeof entry !== 'object' ||
29
+ entry === null ||
30
+ typeof entry.file !== 'string' ||
31
+ typeof entry.line !== 'number' ||
32
+ typeof entry.rule !== 'string') {
33
+ throw new Error('reliability gate output entry is malformed');
34
+ }
35
+ return entry;
36
+ });
37
+ }
38
+ export function runReliabilityGate(projectPath, specFiles) {
39
+ const normalizedSpecFiles = new Set(specFiles.map((file) => normalizeToProjectRelative(projectPath, file)));
40
+ if (normalizedSpecFiles.size === 0) {
41
+ return { skipped: true, passed: true, failures: [], warnings: [] };
42
+ }
43
+ let protectedDirectories;
44
+ try {
45
+ protectedDirectories = fetchProtectedDirectories();
46
+ }
47
+ catch (error) {
48
+ return {
49
+ skipped: false,
50
+ passed: false,
51
+ failures: [],
52
+ warnings: [],
53
+ executionError: `could not resolve protected-directory allowlist: ${error instanceof Error ? error.message : String(error)}`,
54
+ };
55
+ }
56
+ if (![...normalizedSpecFiles].some((file) => isProtected(protectedDirectories, file))) {
57
+ return { skipped: true, passed: true, failures: [], warnings: [] };
58
+ }
59
+ let stdout;
60
+ try {
61
+ stdout = execFileSync(process.execPath, ['scripts/check-reliability-policies.mjs', '--root', projectPath, '--json'], { cwd: process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
62
+ }
63
+ catch (error) {
64
+ const stdoutFromError = error.stdout;
65
+ if (typeof stdoutFromError !== 'string' || stdoutFromError.trim().length === 0) {
66
+ return {
67
+ skipped: false,
68
+ passed: false,
69
+ failures: [],
70
+ warnings: [],
71
+ executionError: error instanceof Error ? error.message : String(error),
72
+ };
73
+ }
74
+ stdout = stdoutFromError;
75
+ }
76
+ let violations;
77
+ try {
78
+ violations = parseViolations(stdout);
79
+ }
80
+ catch (error) {
81
+ return {
82
+ skipped: false,
83
+ passed: false,
84
+ failures: [],
85
+ warnings: [],
86
+ executionError: `unparseable reliability gate output: ${error instanceof Error ? error.message : String(error)}`,
87
+ };
88
+ }
89
+ const failures = violations.filter((violation) => normalizedSpecFiles.has(violation.file));
90
+ const warnings = violations.filter((violation) => !normalizedSpecFiles.has(violation.file));
91
+ return { skipped: false, passed: failures.length === 0, failures, warnings };
92
+ }
93
+ //# sourceMappingURL=reliability-gate.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 */
@@ -61,6 +61,7 @@ async function listFiles(root, current = root) {
61
61
  }
62
62
  return files;
63
63
  }
64
+ const ATOMIC_WRITE_TEMP_SUFFIX = /\.tmp\.\d+\.[0-9a-f]{8}$/;
64
65
  export async function inventoryStorageRoot(rootInput) {
65
66
  const root = assertAbsolute(rootInput, 'inventory root');
66
67
  const info = await lstat(root);
@@ -70,7 +71,19 @@ export async function inventoryStorageRoot(rootInput) {
70
71
  const files = [];
71
72
  let totalBytes = 0;
72
73
  for (const path of await listFiles(root)) {
73
- const content = await readFile(path);
74
+ if (ATOMIC_WRITE_TEMP_SUFFIX.test(path)) {
75
+ continue;
76
+ }
77
+ let content;
78
+ try {
79
+ content = await readFile(path);
80
+ }
81
+ catch (error) {
82
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
83
+ continue;
84
+ }
85
+ throw error;
86
+ }
74
87
  const relativePath = relative(root, path).split(sep).join('/');
75
88
  files.push({ relativePath, size: content.byteLength, sha256: sha256(content) });
76
89
  totalBytes += content.byteLength;
@@ -218,17 +231,17 @@ async function retireCompletedJournalForNewSources(journalPath, expected) {
218
231
  }
219
232
  try {
220
233
  const previous = validateJournal(parsed, { ...expected, legacyRoots: previousRoots });
221
- if (!rootsChanged) {
234
+ if (previous.phase === 'completed') {
235
+ if (rootsChanged) {
236
+ await inventoryStorageRoot(expected.destinationRoot);
237
+ }
238
+ await rm(journalPath, { force: true });
222
239
  return;
223
240
  }
224
- if (previous.phase !== 'completed') {
225
- throw new Error('[Planu] Active migration journal does not match requested roots');
241
+ if (!rootsChanged) {
242
+ return;
226
243
  }
227
- // A completed destination remains live and may legitimately receive newer
228
- // canonical records. Re-inventory its real directory instead of requiring
229
- // the historical generation digest to remain frozen forever.
230
- await inventoryStorageRoot(expected.destinationRoot);
231
- await rm(journalPath, { force: true });
244
+ throw new Error('[Planu] Active migration journal does not match requested roots');
232
245
  }
233
246
  catch (error) {
234
247
  if (!isTerminalPhaseValue(record.phase)) {
@@ -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