@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
@@ -110,6 +110,41 @@ async function completeForwardReconciliation(args) {
110
110
  await args.persistReceipt(args.receiptPath, committed);
111
111
  return committed;
112
112
  }
113
+ async function resolveReviewDigest(args) {
114
+ const { input, spec, reason } = args;
115
+ if (input.declaredDriftKind === 'architectural-premise') {
116
+ const reviewDigest = digest(reason.trim());
117
+ if (reviewDigest !== input.implementationReviewDigest) {
118
+ return failure('declared drift review digest does not match the declared reason', 'RECONCILIATION_STALE');
119
+ }
120
+ return { reviewDigest, driftSource: 'declared-architectural-premise' };
121
+ }
122
+ const reportPath = join(projectDataDir(args.projectId), 'handoffs', spec.id, 'validation-report.json');
123
+ let reportBytes;
124
+ try {
125
+ const status = await lstat(reportPath);
126
+ if (status.isSymbolicLink() || !status.isFile()) {
127
+ return failure('validation report is unsafe');
128
+ }
129
+ reportBytes = await readFile(reportPath);
130
+ }
131
+ catch {
132
+ return failure('validation report is missing');
133
+ }
134
+ const report = ValidationReportV1Schema.safeParse(JSON.parse(reportBytes.toString('utf8')));
135
+ if (!report.success ||
136
+ report.data.passed ||
137
+ report.data.reviewer.kind !== 'automation' ||
138
+ report.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
139
+ report.data.reviewer.verdict !== 'changes-requested') {
140
+ return failure('validation report is not an automated changes-requested finding');
141
+ }
142
+ const reviewDigest = digest(reportBytes);
143
+ if (reviewDigest !== input.implementationReviewDigest) {
144
+ return failure('implementation review digest does not match validation report', 'RECONCILIATION_STALE');
145
+ }
146
+ return { reviewDigest };
147
+ }
113
148
  async function validateRequest(args) {
114
149
  const { input, spec, context } = args;
115
150
  if (context.surface !== 'local-mcp') {
@@ -136,42 +171,41 @@ async function validateRequest(args) {
136
171
  if (!latest?.transitionId || latest.transitionId !== input.expectedImplementingTransitionId) {
137
172
  return failure('expected implementing transition is stale', 'RECONCILIATION_STALE');
138
173
  }
139
- const reportPath = join(projectDataDir(args.projectId), 'handoffs', spec.id, 'validation-report.json');
140
- let reportBytes;
141
- try {
142
- const status = await lstat(reportPath);
143
- if (status.isSymbolicLink() || !status.isFile()) {
144
- return failure('validation report is unsafe');
145
- }
146
- reportBytes = await readFile(reportPath);
147
- }
148
- catch {
149
- return failure('validation report is missing');
150
- }
151
- const report = ValidationReportV1Schema.safeParse(JSON.parse(reportBytes.toString('utf8')));
152
- if (!report.success ||
153
- report.data.passed ||
154
- report.data.reviewer.kind !== 'automation' ||
155
- report.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
156
- report.data.reviewer.verdict !== 'changes-requested') {
157
- return failure('validation report is not an automated changes-requested finding');
158
- }
159
- const reviewDigest = digest(reportBytes);
160
- if (reviewDigest !== input.implementationReviewDigest) {
161
- return failure('implementation review digest does not match validation report', 'RECONCILIATION_STALE');
174
+ const resolution = await resolveReviewDigest({
175
+ input,
176
+ spec,
177
+ projectId: args.projectId,
178
+ reason: input.reason,
179
+ });
180
+ if (!('reviewDigest' in resolution) || typeof resolution.reviewDigest !== 'string') {
181
+ return resolution;
162
182
  }
183
+ const resolved = resolution;
163
184
  return {
164
- reviewDigest,
165
- bindingDigest: digest([
166
- args.projectId,
167
- spec.id,
168
- input.reconciliationRequestId,
169
- latest.transitionId,
170
- reviewDigest,
171
- digest(input.reason.trim()),
172
- ].join('\0')),
185
+ reviewDigest: resolved.reviewDigest,
186
+ driftSource: resolved.driftSource,
187
+ bindingDigest: computeBindingDigest({
188
+ projectId: args.projectId,
189
+ specId: spec.id,
190
+ requestId: input.reconciliationRequestId,
191
+ transitionId: latest.transitionId,
192
+ reviewDigest: resolved.reviewDigest,
193
+ reasonDigest: digest(input.reason.trim()),
194
+ driftSource: resolved.driftSource,
195
+ }),
173
196
  };
174
197
  }
198
+ function computeBindingDigest(args) {
199
+ return digest([
200
+ args.projectId,
201
+ args.specId,
202
+ args.requestId,
203
+ args.transitionId,
204
+ args.reviewDigest,
205
+ args.reasonDigest,
206
+ ...(args.driftSource ? [args.driftSource] : []),
207
+ ].join('\0'));
208
+ }
175
209
  function validateDurableReceiptBinding(args) {
176
210
  const { input, receipt } = args;
177
211
  if (input.expectedImplementingTransitionId !== receipt.sourceImplementingTransitionId ||
@@ -180,14 +214,15 @@ function validateDurableReceiptBinding(args) {
180
214
  digest(input.reason.trim()) !== receipt.reasonDigest) {
181
215
  return failure('reconciliation retry binding conflicts with the durable receipt', 'RECONCILIATION_BINDING_CONFLICT');
182
216
  }
183
- const bindingDigest = digest([
184
- args.projectId,
185
- args.specId,
186
- receipt.requestId,
187
- receipt.sourceImplementingTransitionId,
188
- receipt.implementationReviewDigest,
189
- receipt.reasonDigest,
190
- ].join('\0'));
217
+ const bindingDigest = computeBindingDigest({
218
+ projectId: args.projectId,
219
+ specId: args.specId,
220
+ requestId: receipt.requestId,
221
+ transitionId: receipt.sourceImplementingTransitionId,
222
+ reviewDigest: receipt.implementationReviewDigest,
223
+ reasonDigest: receipt.reasonDigest,
224
+ driftSource: receipt.driftSource,
225
+ });
191
226
  if (bindingDigest !== receipt.bindingDigest) {
192
227
  return failure('durable reconciliation binding is invalid', 'RECONCILIATION_BINDING_CONFLICT');
193
228
  }
@@ -198,6 +233,11 @@ async function validateStoredBinding(args) {
198
233
  if (durableBindingError) {
199
234
  return durableBindingError;
200
235
  }
236
+ if (args.receipt.driftSource === 'declared-architectural-premise') {
237
+ // No validation-report.json exists for a declared drift; the durable binding
238
+ // check above already re-derives and verifies the receipt's bindingDigest.
239
+ return null;
240
+ }
201
241
  const reportPath = join(projectDataDir(args.projectId), 'handoffs', args.specId, 'validation-report.json');
202
242
  try {
203
243
  const reportBytes = await readFile(reportPath);
@@ -220,6 +260,11 @@ async function validateStoredBinding(args) {
220
260
  export async function readCommittedReconciliations(projectId, specId) {
221
261
  return readCommittedReconciliationReceipts(projectId, specId);
222
262
  }
263
+ function extractDriftSource(validated) {
264
+ return 'driftSource' in validated && validated.driftSource === 'declared-architectural-premise'
265
+ ? validated.driftSource
266
+ : undefined;
267
+ }
223
268
  function receiptMatchesRequest(receipt, projectId, specId, requestId) {
224
269
  return (receipt.projectId === projectId && receipt.specId === specId && receipt.requestId === requestId);
225
270
  }
@@ -315,6 +360,7 @@ export async function reconcileImplementingSpec(args) {
315
360
  typeof validated.reviewDigest !== 'string') {
316
361
  return validated;
317
362
  }
363
+ const driftSource = extractDriftSource(validated);
318
364
  const expectedTransitionId = args.input.expectedImplementingTransitionId;
319
365
  const reason = args.input.reason;
320
366
  if (!expectedTransitionId || !reason) {
@@ -338,6 +384,7 @@ export async function reconcileImplementingSpec(args) {
338
384
  stateHistory: [{ state: 'prepared', at: preparedAt }],
339
385
  preparedAt,
340
386
  phase: 'prepared',
387
+ ...(driftSource ? { driftSource } : {}),
341
388
  };
342
389
  })();
343
390
  if (!existing) {
@@ -10,6 +10,7 @@ import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validat
10
10
  import { normalizeExecutableEvidence } from './validator/executable-evidence.js';
11
11
  import { evaluateImplementationContract } from './implementation-contract/index.js';
12
12
  import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
13
+ import { detectCrossSpecPremiseContradictions } from './contradiction-detector.js';
13
14
  // ── SPEC-784: Technical section quality constants ─────────────────────────────
14
15
  const TECHNICAL_MIN_CHARS = 500;
15
16
  // Detects "See technical.md", "See spec.md", or "See `<file>` technical.md" patterns
@@ -454,7 +455,18 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
454
455
  // concrete file paths, function names, or anticipated test breaks.
455
456
  const specificityBlockers = checkSpecificityGate(spec, specificityEvidenceLines, fichaContent, anticipatedTestBreaksContent);
456
457
  allBlockers.push(...specificityBlockers);
457
- const effectiveScore = specificityBlockers.length > 0 && spec.difficulty >= 3 ? Math.min(totalScore, 60) : totalScore;
458
+ const scoreAfterSpecificityGate = specificityBlockers.length > 0 && spec.difficulty >= 3 ? Math.min(totalScore, 60) : totalScore;
459
+ // SPEC-1702: strict mode cross-checks premise claims about done/approved sibling
460
+ // specs against those siblings' own contracts and caps the score below 100.
461
+ const crossSpecPremiseFindings = mode === 'strict'
462
+ ? await detectCrossSpecPremiseContradictions(spec.id, huRaw, projectPathFromSpecPath(spec.specPath))
463
+ : [];
464
+ for (const finding of crossSpecPremiseFindings) {
465
+ allBlockers.push(`cross-spec-premise: ${finding.targetSpecId} claims "${finding.targetSentence}" but ${finding.siblingSpecId} states "${finding.siblingSentence}" (shared file: ${finding.sharedFilePath})`);
466
+ }
467
+ const effectiveScore = crossSpecPremiseFindings.length > 0
468
+ ? Math.min(scoreAfterSpecificityGate, 90)
469
+ : scoreAfterSpecificityGate;
458
470
  const breakdown = {
459
471
  hu: hu.points,
460
472
  criteria: criteria.points,
@@ -8,20 +8,20 @@ export declare function isErrorReportingEnabled(): boolean;
8
8
  */
9
9
  export declare function sanitizeErrorMessage(message: string): string;
10
10
  /**
11
- * Classifies a telemetry event as tool_blocked or tool_error.
12
- * Gate blocks (DoD, validate score, spec locked, invalid transition) → tool_blocked.
13
- * Unhandled exceptions → tool_error.
11
+ * Classifies a telemetry event as blocked or error, mapping onto the envelope's `result` field.
12
+ * Gate blocks (DoD, validate score, spec locked, invalid transition) → blocked.
13
+ * Unhandled exceptions → error.
14
14
  */
15
- export declare function classifyToolEvent(errorMessage: string, errorType?: string): 'tool_error' | 'tool_blocked';
15
+ export declare function classifyToolEvent(errorMessage: string, errorType?: string): 'error' | 'blocked';
16
16
  /**
17
- * Sends a tool_error event to Supabase. Fire-and-forget — never throws.
17
+ * Emits an mcp_tool_failed envelope for an unhandled exception. Fire-and-forget — never throws.
18
+ * The envelope has no message field in Phase 1, so no error text is ever transmitted.
18
19
  * Call this from safe-handler catch blocks after returning isError:true to the user.
19
20
  */
20
- export declare function reportToolError(toolName: string, error: unknown): void;
21
+ export declare function reportToolError(toolName: string, _error: unknown): void;
21
22
  /**
22
- * Sends a tool_error or tool_blocked event for business-logic validation failures.
23
- * Gate/policy blocks (DoD, spec locked, invalid transitions) emit tool_blocked.
24
- * Other expected errors (missing params, not-found, etc.) emit tool_error.
23
+ * Emits an mcp_tool_failed envelope for a business-logic validation failure, classifying
24
+ * `result` as blocked (gate/policy blocks) or error (other expected failures).
25
25
  * Fire-and-forget — never throws.
26
26
  */
27
27
  export declare function reportToolValidationError(toolName: string, message: string): void;
@@ -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). */