@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
@@ -3,8 +3,7 @@
3
3
  // Fire-and-forget. Sends sanitized error info to Supabase on unhandled tool exceptions.
4
4
  // Opt-out: set PLANU_TELEMETRY=off to disable remote reporting.
5
5
  // No PII: only tool name, sanitized error message, version, and Node version are sent.
6
- import { sendTelemetryEvent } from './telemetry-client.js';
7
- import { PLANU_VERSION } from '../../config/version.js';
6
+ import { sendTelemetryEnvelopeEvent } from './telemetry-client.js';
8
7
  import { redactText } from '../../security/redactor.js';
9
8
  import { TELEMETRY_CONSENT_VERSION } from './telemetry-store.js';
10
9
  /**
@@ -31,58 +30,40 @@ const BLOCKED_PATTERNS = [
31
30
  'does not meet',
32
31
  ];
33
32
  /**
34
- * Classifies a telemetry event as tool_blocked or tool_error.
35
- * Gate blocks (DoD, validate score, spec locked, invalid transition) → tool_blocked.
36
- * Unhandled exceptions → tool_error.
33
+ * Classifies a telemetry event as blocked or error, mapping onto the envelope's `result` field.
34
+ * Gate blocks (DoD, validate score, spec locked, invalid transition) → blocked.
35
+ * Unhandled exceptions → error.
37
36
  */
38
37
  export function classifyToolEvent(errorMessage, errorType) {
39
38
  if (errorType === 'validation' || errorType === 'gate') {
40
- return 'tool_blocked';
39
+ return 'blocked';
41
40
  }
42
41
  if (BLOCKED_PATTERNS.some((pattern) => errorMessage.includes(pattern))) {
43
- return 'tool_blocked';
42
+ return 'blocked';
44
43
  }
45
- return 'tool_error';
44
+ return 'error';
46
45
  }
47
46
  /**
48
- * Sends a tool_error event to Supabase. Fire-and-forget — never throws.
47
+ * Emits an mcp_tool_failed envelope for an unhandled exception. Fire-and-forget — never throws.
48
+ * The envelope has no message field in Phase 1, so no error text is ever transmitted.
49
49
  * Call this from safe-handler catch blocks after returning isError:true to the user.
50
50
  */
51
- export function reportToolError(toolName, error) {
51
+ export function reportToolError(toolName, _error) {
52
52
  if (process.env.PLANU_TELEMETRY === 'off') {
53
53
  return;
54
54
  }
55
- const errorClass = error instanceof Error ? error.name : typeof error;
56
- sendTelemetryEvent({
57
- event: 'tool_error',
58
- properties: {
59
- tool: toolName,
60
- errorType: 'exception',
61
- errorClass,
62
- planVersion: PLANU_VERSION,
63
- nodeVersion: process.version,
64
- },
65
- });
55
+ sendTelemetryEnvelopeEvent('mcp_tool_failed', { toolName, result: 'error' });
66
56
  }
67
57
  /**
68
- * Sends a tool_error or tool_blocked event for business-logic validation failures.
69
- * Gate/policy blocks (DoD, spec locked, invalid transitions) emit tool_blocked.
70
- * Other expected errors (missing params, not-found, etc.) emit tool_error.
58
+ * Emits an mcp_tool_failed envelope for a business-logic validation failure, classifying
59
+ * `result` as blocked (gate/policy blocks) or error (other expected failures).
71
60
  * Fire-and-forget — never throws.
72
61
  */
73
62
  export function reportToolValidationError(toolName, message) {
74
63
  if (process.env.PLANU_TELEMETRY === 'off') {
75
64
  return;
76
65
  }
77
- const event = classifyToolEvent(message, 'validation');
78
- sendTelemetryEvent({
79
- event,
80
- properties: {
81
- tool: toolName,
82
- errorType: 'validation',
83
- planVersion: PLANU_VERSION,
84
- nodeVersion: process.version,
85
- },
86
- });
66
+ const result = classifyToolEvent(message, 'validation');
67
+ sendTelemetryEnvelopeEvent('mcp_tool_failed', { toolName, result });
87
68
  }
88
69
  //# sourceMappingURL=error-reporter.js.map
@@ -0,0 +1,11 @@
1
+ import type { TelemetryEnvelopeV1, TelemetryEnvelopeEventName, TelemetryEnvelopeInput, TelemetryEnvelopeBuildOptions, TelemetryDurationBucket } from '../../types/index.js';
2
+ /** Ephemeral per-process session id, generated once at module load. */
3
+ export declare const TELEMETRY_SESSION_ID: `${string}-${string}-${string}-${string}-${string}`;
4
+ export declare function bucketDuration(durationMs: number): TelemetryDurationBucket;
5
+ /**
6
+ * Builds a schemaVersion-1 telemetry envelope with exactly the allowlisted fields.
7
+ * Unknown fields on `fields` and unknown enum values are rejected (throw), never stored.
8
+ * `options.uuid`/`options.clock` make eventId/occurredAt deterministic for tests and `show`.
9
+ */
10
+ export declare function buildTelemetryEnvelope(name: TelemetryEnvelopeEventName, fields: TelemetryEnvelopeInput, options?: TelemetryEnvelopeBuildOptions): TelemetryEnvelopeV1;
11
+ //# sourceMappingURL=event-envelope.d.ts.map
@@ -0,0 +1,124 @@
1
+ // engine/telemetry/event-envelope.ts — SPEC-1704: versioned, allowlisted telemetry envelope v1.
2
+ // Every field is typed and closed; unknown fields or enum values are rejected rather than
3
+ // stored, so no free-form data (paths, args, spec content) has a code path into the envelope.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { PLANU_VERSION } from '../../config/version.js';
6
+ /** Ephemeral per-process session id, generated once at module load. */
7
+ export const TELEMETRY_SESSION_ID = randomUUID();
8
+ const EVENT_NAMES = new Set([
9
+ 'installation_activated',
10
+ 'mcp_server_started',
11
+ 'mcp_tool_completed',
12
+ 'mcp_tool_failed',
13
+ 'spec_lifecycle_transitioned',
14
+ 'release_check_completed',
15
+ ]);
16
+ const RESULTS = new Set([
17
+ 'success',
18
+ 'error',
19
+ 'blocked',
20
+ 'cancelled',
21
+ ]);
22
+ const MCP_HOSTS = new Set([
23
+ 'claude-code',
24
+ 'claude-desktop',
25
+ 'cursor',
26
+ 'codex',
27
+ 'other',
28
+ 'unknown',
29
+ ]);
30
+ const OPERATING_SYSTEMS = new Set(['darwin', 'linux', 'win32']);
31
+ const ALLOWED_INPUT_KEYS = new Set([
32
+ 'anonymousInstallationId',
33
+ 'sessionId',
34
+ 'toolName',
35
+ 'result',
36
+ 'durationMs',
37
+ 'mcpHost',
38
+ ]);
39
+ const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
40
+ const MAX_TOOL_NAME_LENGTH = 64;
41
+ 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;
42
+ const FAST_MS = 100;
43
+ const MEDIUM_MS = 1_000;
44
+ const SLOW_MS = 5_000;
45
+ export function bucketDuration(durationMs) {
46
+ if (durationMs < FAST_MS) {
47
+ return 'lt_100ms';
48
+ }
49
+ if (durationMs < MEDIUM_MS) {
50
+ return '100ms_1s';
51
+ }
52
+ if (durationMs < SLOW_MS) {
53
+ return '1s_5s';
54
+ }
55
+ return 'gte_5s';
56
+ }
57
+ function resolveOperatingSystem() {
58
+ return OPERATING_SYSTEMS.has(process.platform)
59
+ ? process.platform
60
+ : 'other';
61
+ }
62
+ function resolveNodeMajor() {
63
+ return Number.parseInt(process.versions.node.split('.')[0] ?? '0', 10);
64
+ }
65
+ function rejectUnknownFields(fields) {
66
+ for (const key of Object.keys(fields)) {
67
+ if (!ALLOWED_INPUT_KEYS.has(key)) {
68
+ throw new Error(`Rejected telemetry event: unknown field "${key}"`);
69
+ }
70
+ }
71
+ }
72
+ function rejectUnknownEnumValues(fields) {
73
+ if (fields.result !== undefined && !RESULTS.has(fields.result)) {
74
+ throw new Error(`Rejected telemetry event: unknown result "${fields.result}"`);
75
+ }
76
+ if (fields.mcpHost !== undefined && !MCP_HOSTS.has(fields.mcpHost)) {
77
+ throw new Error(`Rejected telemetry event: unknown mcpHost "${fields.mcpHost}"`);
78
+ }
79
+ if (fields.toolName !== undefined &&
80
+ fields.toolName !== '' &&
81
+ (fields.toolName.length > MAX_TOOL_NAME_LENGTH || !TOOL_NAME_PATTERN.test(fields.toolName))) {
82
+ throw new Error(`Rejected telemetry event: unknown toolName "${fields.toolName}"`);
83
+ }
84
+ if (!UUID_V4_PATTERN.test(fields.anonymousInstallationId)) {
85
+ throw new Error('Rejected telemetry event: malformed anonymousInstallationId');
86
+ }
87
+ }
88
+ /**
89
+ * Builds a schemaVersion-1 telemetry envelope with exactly the allowlisted fields.
90
+ * Unknown fields on `fields` and unknown enum values are rejected (throw), never stored.
91
+ * `options.uuid`/`options.clock` make eventId/occurredAt deterministic for tests and `show`.
92
+ */
93
+ export function buildTelemetryEnvelope(name, fields, options = {}) {
94
+ if (!EVENT_NAMES.has(name)) {
95
+ throw new Error(`Rejected telemetry event: unknown eventName "${name}"`);
96
+ }
97
+ rejectUnknownFields(fields);
98
+ rejectUnknownEnumValues(fields);
99
+ const uuid = options.uuid ?? randomUUID;
100
+ const clock = options.clock ?? (() => new Date());
101
+ const envelope = {
102
+ schemaVersion: 1,
103
+ eventId: uuid(),
104
+ eventName: name,
105
+ occurredAt: clock().toISOString(),
106
+ anonymousInstallationId: fields.anonymousInstallationId,
107
+ sessionId: fields.sessionId,
108
+ planuVersion: PLANU_VERSION,
109
+ operatingSystem: resolveOperatingSystem(),
110
+ nodeMajor: resolveNodeMajor(),
111
+ mcpHost: fields.mcpHost ?? 'unknown',
112
+ };
113
+ if (fields.toolName !== undefined && fields.toolName !== '') {
114
+ envelope.toolName = fields.toolName;
115
+ }
116
+ if (fields.result !== undefined) {
117
+ envelope.result = fields.result;
118
+ }
119
+ if (fields.durationMs !== undefined) {
120
+ envelope.durationBucket = bucketDuration(fields.durationMs);
121
+ }
122
+ return envelope;
123
+ }
124
+ //# sourceMappingURL=event-envelope.js.map
@@ -1,4 +1,11 @@
1
- import type { TelemetryEvent } from '../../types/index.js';
1
+ import type { TelemetryEvent, TelemetryEnvelopeEventName, TelemetryEnvelopeInput } from '../../types/index.js';
2
2
  /** Fire-and-forget; absence or staleness of opt-in prevents any network access. */
3
3
  export declare function sendTelemetryEvent(event: TelemetryEvent): void;
4
+ /**
5
+ * Builds a schemaVersion-1 envelope and emits it through sendTelemetryEvent.
6
+ * Fire-and-forget: never awaited by callers, and every failure (disabled consent, no
7
+ * installation id yet, an unknown enum value) resolves to a silent no-op — telemetry
8
+ * never blocks or delays the operation it is describing.
9
+ */
10
+ export declare function sendTelemetryEnvelopeEvent(name: TelemetryEnvelopeEventName, fields: Omit<TelemetryEnvelopeInput, 'anonymousInstallationId' | 'sessionId'>): void;
4
11
  //# sourceMappingURL=telemetry-client.d.ts.map
@@ -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
@@ -19,7 +19,7 @@ When implementation changes are complete and validated, always address release e
19
19
  - local validation commands passed
20
20
  - changelog or release notes updated when applicable
21
21
  - package version and git tag stay aligned
22
- - release metadata under \`planu/releases/pending.json\` contains only genuinely pending entries
22
+ - release metadata under the \`releases\` field of \`planu/project.json\` contains only genuinely pending entries
23
23
  - published package smoke check is run after release when applicable
24
24
 
25
25
  ## Hard Blocks
@@ -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
@@ -24,6 +24,41 @@ const FILE_EXTENSION = /\.[A-Za-z0-9]+$/;
24
24
  function isPathShapedSegment(segment) {
25
25
  return segment.length > 0 && (PATH_SHAPED.test(segment) || FILE_EXTENSION.test(segment));
26
26
  }
27
+ function splitInlineArrayItems(content) {
28
+ const items = [];
29
+ let current = '';
30
+ let quote = null;
31
+ for (const char of content) {
32
+ if (quote) {
33
+ if (char === quote) {
34
+ quote = null;
35
+ }
36
+ else {
37
+ current += char;
38
+ }
39
+ continue;
40
+ }
41
+ if (char === '"' || char === "'") {
42
+ quote = char;
43
+ continue;
44
+ }
45
+ if (char === ',') {
46
+ items.push(current);
47
+ current = '';
48
+ continue;
49
+ }
50
+ current += char;
51
+ }
52
+ items.push(current);
53
+ return items;
54
+ }
55
+ function parseInlineTestLinks(bracketContent) {
56
+ return splitInlineArrayItems(bracketContent)
57
+ .map((item) => item.trim())
58
+ .filter((item) => item.length > 0)
59
+ .map((item) => parseTestLinkString(item))
60
+ .filter((link) => link !== null);
61
+ }
27
62
  function parseTestLinkString(raw) {
28
63
  if (WINDOWS_DRIVE_PATH.test(raw)) {
29
64
  return { path: raw };
@@ -104,6 +139,16 @@ export function parseFrontmatterScenarios(raw) {
104
139
  inSteps = false;
105
140
  continue;
106
141
  }
142
+ const inlineTestsMatch = /^\s+tests:\s*\[(.*)\]\s*$/.exec(trimmed);
143
+ if (inlineTestsMatch) {
144
+ currentScenario.tests = [
145
+ ...(currentScenario.tests ?? []),
146
+ ...parseInlineTestLinks(inlineTestsMatch[1] ?? ''),
147
+ ];
148
+ inTests = false;
149
+ inSteps = false;
150
+ continue;
151
+ }
107
152
  if (/^\s+steps:\s*$/.test(trimmed)) {
108
153
  currentScenario.steps ??= [];
109
154
  inSteps = true;
@@ -457,7 +502,13 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
457
502
  raw = await readFile(spec.specPath, 'utf-8');
458
503
  }
459
504
  catch {
460
- return { dimensionScore: 0, perScenario: [], command: '', evidenceSource: 'none' };
505
+ return {
506
+ dimensionScore: 0,
507
+ perScenario: [],
508
+ command: '',
509
+ evidenceSource: 'none',
510
+ doneDriftFindings: [],
511
+ };
461
512
  }
462
513
  const frontmatterScenarios = parseFrontmatterScenarios(raw);
463
514
  const executableEvidence = normalizeExecutableEvidence(raw, frontmatterScenarios, (path) => existsSync(join(projectPath, path)));
@@ -468,6 +519,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
468
519
  done: false,
469
520
  tests: scenario.tests.map((test) => ({ path: test.path })).filter(isExecutableTestLink),
470
521
  }));
522
+ const doneDriftFindings = findDoneDriftMissingFiles(spec, raw, projectPath, existsSync);
471
523
  if (scenarios.length === 0) {
472
524
  return {
473
525
  dimensionScore: 0,
@@ -475,6 +527,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
475
527
  command: '',
476
528
  evidenceSource: executableEvidence.source,
477
529
  ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
530
+ doneDriftFindings,
478
531
  };
479
532
  }
480
533
  const allLinks = scenarios.flatMap((scenario) => scenario.tests ?? []);
@@ -588,6 +641,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
588
641
  `${technologyValue('technology-vitest-a9127f')} --reporter=json --run (no test files linked)`,
589
642
  evidenceSource: executableEvidence.source,
590
643
  ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
644
+ doneDriftFindings,
591
645
  };
592
646
  }
593
647
  export function findScenariosWithoutTests(raw) {
@@ -595,4 +649,72 @@ export function findScenariosWithoutTests(raw) {
595
649
  .filter((scenario) => !scenario.tests?.length)
596
650
  .map((scenario) => scenario.title);
597
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
+ }
598
720
  //# sourceMappingURL=spec-compliance-runner.js.map