@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.
- package/CHANGELOG.md +58 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/telemetry.d.ts +3 -0
- package/dist/cli/commands/telemetry.js +118 -0
- package/dist/cli/router.js +3 -1
- package/dist/config/environment-schema.json +14 -0
- package/dist/engine/autopilot/bootstrap.js +1 -1
- package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
- package/dist/engine/contradiction-detector.d.ts +2 -1
- package/dist/engine/contradiction-detector.js +215 -0
- package/dist/engine/detection-utils.d.ts +1 -0
- package/dist/engine/detection-utils.js +33 -0
- package/dist/engine/doc-generator/portal/index.d.ts +1 -1
- package/dist/engine/doc-generator/portal/index.js +1 -1
- package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
- package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
- package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
- package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
- package/dist/engine/framework-detector.js +8 -6
- package/dist/engine/handoff-artifacts/schemas.js +4 -0
- package/dist/engine/housekeeping/history-log.d.ts +1 -0
- package/dist/engine/housekeeping/history-log.js +58 -3
- package/dist/engine/housekeeping/index.d.ts +2 -1
- package/dist/engine/housekeeping/index.js +2 -1
- package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
- package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
- package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
- package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
- package/dist/engine/lifecycle-reconciliation.js +87 -40
- package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
- package/dist/engine/next-spec-resolver/session-writer.js +1 -1
- package/dist/engine/project-graph/cache.js +23 -3
- package/dist/engine/readiness-checker.js +14 -2
- package/dist/engine/sdd-flow/checkpoints.js +29 -2
- package/dist/engine/session/checkpoint-writer.d.ts +1 -1
- package/dist/engine/session/checkpoint-writer.js +6 -5
- package/dist/engine/session-state/writer.js +4 -3
- package/dist/engine/spec-format/lean-spec-generator.js +1 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
- package/dist/engine/telemetry/error-reporter.d.ts +9 -9
- package/dist/engine/telemetry/error-reporter.js +15 -34
- package/dist/engine/telemetry/event-envelope.d.ts +11 -0
- package/dist/engine/telemetry/event-envelope.js +124 -0
- package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
- package/dist/engine/telemetry/telemetry-client.js +38 -20
- package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
- package/dist/engine/telemetry/telemetry-store.js +73 -2
- package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
- package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
- package/dist/engine/validator/spec-compliance-runner.js +123 -1
- package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
- package/dist/index.js +26 -0
- package/dist/resources/specs.js +12 -33
- package/dist/storage/current-project.d.ts +3 -0
- package/dist/storage/current-project.js +21 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/migrations/canonical-storage.js +5 -0
- package/dist/storage/retention.d.ts +14 -0
- package/dist/storage/retention.js +279 -0
- package/dist/storage/spec-index.d.ts +23 -0
- package/dist/storage/spec-index.js +123 -0
- package/dist/storage/spec-store.d.ts +8 -3
- package/dist/storage/spec-store.js +76 -6
- package/dist/storage/storage-catalog.js +3 -3
- package/dist/storage/storage-layout.d.ts +8 -0
- package/dist/storage/storage-layout.js +9 -0
- package/dist/storage/transition-log.js +2 -0
- package/dist/tools/challenge-spec.js +35 -19
- package/dist/tools/create-spec.js +10 -0
- package/dist/tools/execute-sdd-flow.js +11 -2
- package/dist/tools/export-spec.js +2 -1
- package/dist/tools/force-status-analytics.js +2 -1
- package/dist/tools/generate-docs-site.js +2 -1
- package/dist/tools/generate-proposal.js +6 -2
- package/dist/tools/init-project/claude-md-generator.js +19 -2
- package/dist/tools/init-project/conventions-writer.d.ts +5 -2
- package/dist/tools/init-project/conventions-writer.js +18 -13
- package/dist/tools/init-project/git-setup.js +9 -0
- package/dist/tools/init-project/handler.js +9 -1
- package/dist/tools/init-project/legacy-planu.d.ts +2 -0
- package/dist/tools/init-project/legacy-planu.js +18 -0
- package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
- package/dist/tools/init-project/legacy-root-migration.js +213 -0
- package/dist/tools/init-project/runtime-residue.d.ts +2 -0
- package/dist/tools/init-project/runtime-residue.js +11 -0
- package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
- package/dist/tools/init-project/schedule-housekeeping.js +8 -0
- package/dist/tools/reconcile-spec.js +29 -2
- package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
- package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
- package/dist/tools/safe-handler.js +6 -12
- package/dist/tools/session-checkpoint.js +1 -1
- package/dist/tools/update-status/index.js +7 -1
- package/dist/tools/update-status-actions.d.ts +7 -1
- package/dist/tools/update-status-actions.js +10 -2
- package/dist/types/handoff-artifacts.d.ts +1 -0
- package/dist/types/housekeeping.d.ts +38 -0
- package/dist/types/housekeeping.js +0 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/retention.d.ts +12 -0
- package/dist/types/retention.js +3 -0
- package/dist/types/scope.d.ts +23 -0
- package/dist/types/spec/core.d.ts +7 -0
- package/dist/types/spec/index-cache.d.ts +25 -0
- package/dist/types/spec/index-cache.js +3 -0
- package/dist/types/spec/index.d.ts +1 -0
- package/dist/types/spec/index.js +1 -0
- package/dist/types/spec/inputs.d.ts +9 -0
- package/dist/types/spec-format.d.ts +1 -0
- package/dist/types/telemetry.d.ts +39 -1
- package/dist/types/validation-evidence.d.ts +6 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
- 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 {
|
|
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
|
|
35
|
-
* Gate blocks (DoD, validate score, spec locked, invalid transition) →
|
|
36
|
-
* Unhandled exceptions →
|
|
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 '
|
|
39
|
+
return 'blocked';
|
|
41
40
|
}
|
|
42
41
|
if (BLOCKED_PATTERNS.some((pattern) => errorMessage.includes(pattern))) {
|
|
43
|
-
return '
|
|
42
|
+
return 'blocked';
|
|
44
43
|
}
|
|
45
|
-
return '
|
|
44
|
+
return 'error';
|
|
46
45
|
}
|
|
47
46
|
/**
|
|
48
|
-
*
|
|
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,
|
|
51
|
+
export function reportToolError(toolName, _error) {
|
|
52
52
|
if (process.env.PLANU_TELEMETRY === 'off') {
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
|
-
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
|
78
|
-
|
|
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
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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/
|
|
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 {
|
|
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
|