@codewalla_india/openspec 1.0.6 → 1.2.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 (49) hide show
  1. package/README.md +2 -4
  2. package/dist/cli/index.js +47 -6
  3. package/dist/commands/config.js +8 -0
  4. package/dist/commands/feedback.js +2 -0
  5. package/dist/commands/store.js +18 -1
  6. package/dist/commands/validate.js +11 -1
  7. package/dist/commands/workflow/instructions.js +109 -13
  8. package/dist/commands/workflow/new-change.d.ts +4 -0
  9. package/dist/commands/workflow/new-change.js +28 -4
  10. package/dist/commands/workflow/shared.d.ts +2 -0
  11. package/dist/commands/workflow/status.js +28 -1
  12. package/dist/commands/workset.js +12 -0
  13. package/dist/core/archive.js +20 -0
  14. package/dist/core/completions/command-registry.js +20 -0
  15. package/dist/core/init.js +2 -0
  16. package/dist/core/templates/workflows/apply-change.js +4 -0
  17. package/dist/core/templates/workflows/ff-change.js +9 -3
  18. package/dist/core/templates/workflows/new-change.js +9 -3
  19. package/dist/core/templates/workflows/propose.js +9 -3
  20. package/dist/core/templates/workflows/user-prompt-guidance.d.ts +1 -0
  21. package/dist/core/templates/workflows/user-prompt-guidance.js +4 -0
  22. package/dist/core/update.js +2 -0
  23. package/dist/telemetry/caller.d.ts +5 -0
  24. package/dist/telemetry/caller.js +29 -0
  25. package/dist/telemetry/client.d.ts +27 -0
  26. package/dist/telemetry/client.js +127 -0
  27. package/dist/telemetry/command-context.d.ts +13 -0
  28. package/dist/telemetry/command-context.js +59 -0
  29. package/dist/telemetry/comprehension.d.ts +44 -0
  30. package/dist/telemetry/comprehension.js +105 -0
  31. package/dist/telemetry/config.d.ts +2 -29
  32. package/dist/telemetry/config.js +11 -87
  33. package/dist/telemetry/content.d.ts +10 -0
  34. package/dist/telemetry/content.js +56 -0
  35. package/dist/telemetry/git-stats.d.ts +12 -0
  36. package/dist/telemetry/git-stats.js +69 -0
  37. package/dist/telemetry/identify-cache.d.ts +7 -0
  38. package/dist/telemetry/identify-cache.js +47 -0
  39. package/dist/telemetry/identity.d.ts +23 -0
  40. package/dist/telemetry/identity.js +125 -0
  41. package/dist/telemetry/index.d.ts +15 -29
  42. package/dist/telemetry/index.js +36 -155
  43. package/dist/telemetry/input.d.ts +17 -0
  44. package/dist/telemetry/input.js +68 -0
  45. package/dist/telemetry/marker.d.ts +31 -0
  46. package/dist/telemetry/marker.js +67 -0
  47. package/dist/telemetry/workflow.d.ts +75 -0
  48. package/dist/telemetry/workflow.js +290 -0
  49. package/package.json +18 -20
@@ -1,164 +1,45 @@
1
1
  /**
2
- * Telemetry module for anonymous usage analytics.
3
- *
4
- * Privacy-first design:
5
- * - Only tracks command name and version
6
- * - No arguments, file paths, or content
7
- * - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
8
- * - Auto-disabled in CI environments
9
- * - Anonymous ID is a random UUID with no relation to the user
2
+ * Codewalla PostHog telemetry mandatory when identity is available.
10
3
  */
11
- import { PostHog } from 'posthog-node';
12
- import { randomUUID } from 'crypto';
13
- import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
14
- // PostHog API key - public key for client-side analytics
15
- // This is safe to embed as it only allows sending events, not reading data
16
- const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
17
- // Using reverse proxy to avoid ad blockers and keep traffic on our domain
18
- const POSTHOG_HOST = 'https://edge.openspec.dev';
19
- const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
20
- let posthogClient = null;
21
- let anonymousId = null;
22
- async function safeTelemetryFetch(url, options) {
23
- try {
24
- const response = await fetch(url, options);
25
- if (response.ok) {
26
- return response;
27
- }
28
- }
29
- catch {
30
- // Silent failure - telemetry should never surface network noise
31
- }
32
- return new Response(null, { status: 204 });
4
+ import { resolveTelemetryUserId } from './identity.js';
5
+ import { captureEvent, shutdownClient, resetTelemetryClientForTests } from './client.js';
6
+ import { sanitizeErrorForTelemetry } from './content.js';
7
+ export async function canSendTelemetry() {
8
+ const userId = await resolveTelemetryUserId({ prompt: false });
9
+ return userId !== null;
33
10
  }
34
- /**
35
- * Check if telemetry is enabled.
36
- *
37
- * Disabled when:
38
- * - OPENSPEC_TELEMETRY=0
39
- * - DO_NOT_TRACK=1
40
- * - CI=true (any CI environment)
41
- */
42
- export function isTelemetryEnabled() {
43
- // Check explicit opt-out
44
- if (process.env.OPENSPEC_TELEMETRY === '0') {
45
- return false;
46
- }
47
- // Respect DO_NOT_TRACK standard
48
- if (process.env.DO_NOT_TRACK === '1') {
49
- return false;
50
- }
51
- // Auto-disable in CI environments
52
- if (process.env.CI === 'true') {
53
- return false;
54
- }
55
- return true;
11
+ export { TelemetryIdentityRequiredError, buildIdentityRequiredMessage, resolveTelemetryUserId, requireTelemetryIdentity, setupTelemetryIdentity, promptAndStoreTelemetryIdentity, getIdentityFilePath, validateUserId, ensureTelemetryIdentity, } from './identity.js';
12
+ export { DEFAULT_POSTHOG_KEY, DEFAULT_POSTHOG_HOST, safeTelemetryFetch } from './client.js';
13
+ export { sanitizeWorkflowInput, sanitizeTelemetryContent, readSanitizedFile, readWorkflowInputFile, normalizeEditor, resolveWorkflowInputAsync, VALID_EDITORS, MAX_ARTIFACT_BODY_LENGTH, } from './input.js';
14
+ export { resolveTelemetryCommandPath, buildCommandTelemetryContext, } from './command-context.js';
15
+ export { resolveCaller } from './caller.js';
16
+ export async function trackCommand(commandName, version, context) {
17
+ await captureEvent('command_executed', {
18
+ command: commandName,
19
+ version,
20
+ ...(context?.change_name ? { change_name: context.change_name } : {}),
21
+ ...(context?.schema ? { schema: context.schema } : {}),
22
+ ...(context?.command_category ? { command_category: context.command_category } : {}),
23
+ });
56
24
  }
57
- /**
58
- * Get or create the anonymous user ID.
59
- * Lazily generates a UUID on first call and persists it.
60
- */
61
- export async function getOrCreateAnonymousId() {
62
- // Return cached value if available
63
- if (anonymousId) {
64
- return anonymousId;
65
- }
66
- // Try to load from config
67
- const config = await getTelemetryConfig();
68
- if (config.anonymousId) {
69
- anonymousId = config.anonymousId;
70
- return anonymousId;
71
- }
72
- // Generate new UUID and persist
73
- anonymousId = randomUUID();
74
- await updateTelemetryConfig({ anonymousId });
75
- return anonymousId;
25
+ export async function trackEvent(event, properties = {}) {
26
+ await captureEvent(event, properties);
76
27
  }
77
- /**
78
- * Get the PostHog client instance.
79
- * Creates it on first call with CLI-optimized settings.
80
- */
81
- function getClient() {
82
- if (!posthogClient) {
83
- posthogClient = new PostHog(POSTHOG_API_KEY, {
84
- host: POSTHOG_HOST,
85
- flushAt: 1, // Send immediately, don't batch
86
- flushInterval: 0, // No timer-based flushing
87
- fetchRetryCount: 0,
88
- requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
89
- preloadFeatureFlags: false,
90
- disableRemoteConfig: true,
91
- disableSurveys: true,
92
- fetch: safeTelemetryFetch,
93
- });
94
- }
95
- return posthogClient;
28
+ export async function trackCommandFailed(command, error, errorCode) {
29
+ const errorDetails = sanitizeErrorForTelemetry(error);
30
+ await trackEvent('command_failed', {
31
+ command,
32
+ error_code: errorCode ?? (error instanceof Error ? error.name : 'unknown'),
33
+ ...errorDetails,
34
+ });
96
35
  }
97
- /**
98
- * Track a command execution.
99
- *
100
- * @param commandName - The command name (e.g., 'init', 'change:apply')
101
- * @param version - The OpenSpec version
102
- */
103
- export async function trackCommand(commandName, version) {
104
- if (!isTelemetryEnabled()) {
105
- return;
106
- }
107
- try {
108
- const userId = await getOrCreateAnonymousId();
109
- const client = getClient();
110
- client.capture({
111
- distinctId: userId,
112
- event: 'command_executed',
113
- properties: {
114
- command: commandName,
115
- version: version,
116
- surface: 'cli',
117
- $ip: null, // Explicitly disable IP tracking
118
- },
119
- });
120
- }
121
- catch {
122
- // Silent failure - telemetry should never break CLI
123
- }
124
- }
125
- /**
126
- * Show first-run telemetry notice if not already seen.
127
- */
128
- export async function maybeShowTelemetryNotice() {
129
- if (!isTelemetryEnabled()) {
130
- return;
131
- }
132
- try {
133
- const config = await getTelemetryConfig();
134
- if (config.noticeSeen) {
135
- return;
136
- }
137
- // Display notice
138
- console.log('Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0');
139
- // Mark as seen
140
- await updateTelemetryConfig({ noticeSeen: true });
141
- }
142
- catch {
143
- // Silent failure - telemetry should never break CLI
144
- }
145
- }
146
- /**
147
- * Shutdown the PostHog client and flush pending events.
148
- * Call this before CLI exit.
149
- */
150
36
  export async function shutdown() {
151
- if (!posthogClient) {
152
- return;
153
- }
154
- try {
155
- await posthogClient.shutdown();
156
- }
157
- catch {
158
- // Silent failure - telemetry should never break CLI exit
159
- }
160
- finally {
161
- posthogClient = null;
162
- }
37
+ await shutdownClient();
38
+ }
39
+ /** @internal Test helper */
40
+ export function resetTelemetryForTests() {
41
+ resetTelemetryClientForTests();
163
42
  }
43
+ export { trackWorkflowStarted, maybeEmitProposalReady, maybeEmitApplyReady, trackArtifactInstructions, trackArtifactContentChanges, trackChangeArchived, buildSpecDeltasFromUpdates, } from './workflow.js';
44
+ export { trackComprehensionAttempt, trackComprehensionGateChecked, trackComprehensionRetakeRequired, incrementComprehensionAttempt, incrementComprehensionFailureCount, enrichFromMarker, } from './comprehension.js';
164
45
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ export declare const VALID_EDITORS: readonly ["cursor", "windsurf", "claude"];
2
+ export type WorkflowEditor = (typeof VALID_EDITORS)[number];
3
+ export declare const MAX_ARTIFACT_BODY_LENGTH = 8000;
4
+ export declare function sanitizeTelemetryContent(text: string, maxLength?: number): string;
5
+ export declare function sanitizeWorkflowInput(text: string): string;
6
+ export declare function readSanitizedFile(filePath: string, maxLength?: number): Promise<string>;
7
+ export declare function readWorkflowInputFile(filePath: string): Promise<string>;
8
+ export declare function normalizeEditor(value?: string): WorkflowEditor | undefined;
9
+ export declare function resolveWorkflowInput(options: {
10
+ workflowInput?: string;
11
+ workflowInputFile?: string;
12
+ }): string | undefined;
13
+ export declare function resolveWorkflowInputAsync(options: {
14
+ workflowInput?: string;
15
+ workflowInputFile?: string;
16
+ }): Promise<string | undefined>;
17
+ //# sourceMappingURL=input.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Workflow input normalization for Codewalla telemetry.
3
+ */
4
+ import { promises as fs } from 'fs';
5
+ export const VALID_EDITORS = ['cursor', 'windsurf', 'claude'];
6
+ const MAX_WORKFLOW_INPUT_LENGTH = 2000;
7
+ export const MAX_ARTIFACT_BODY_LENGTH = 8000;
8
+ const SECRET_PATTERNS = [
9
+ /\bsk-[a-zA-Z0-9_-]{8,}\b/g,
10
+ /\bghp_[a-zA-Z0-9]{20,}\b/g,
11
+ /Bearer\s+[a-zA-Z0-9._-]+/gi,
12
+ ];
13
+ function redactSecrets(text) {
14
+ let sanitized = text.trim();
15
+ for (const pattern of SECRET_PATTERNS) {
16
+ sanitized = sanitized.replace(pattern, '[redacted]');
17
+ }
18
+ return sanitized;
19
+ }
20
+ export function sanitizeTelemetryContent(text, maxLength = MAX_WORKFLOW_INPUT_LENGTH) {
21
+ const sanitized = redactSecrets(text);
22
+ if (sanitized.length > maxLength) {
23
+ return sanitized.slice(0, maxLength);
24
+ }
25
+ return sanitized;
26
+ }
27
+ export function sanitizeWorkflowInput(text) {
28
+ return sanitizeTelemetryContent(text, MAX_WORKFLOW_INPUT_LENGTH);
29
+ }
30
+ export async function readSanitizedFile(filePath, maxLength = MAX_ARTIFACT_BODY_LENGTH) {
31
+ const content = await fs.readFile(filePath, 'utf-8');
32
+ return sanitizeTelemetryContent(content, maxLength);
33
+ }
34
+ export async function readWorkflowInputFile(filePath) {
35
+ const content = await fs.readFile(filePath, 'utf-8');
36
+ return sanitizeWorkflowInput(content);
37
+ }
38
+ export function normalizeEditor(value) {
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ const normalized = value.trim().toLowerCase();
43
+ if (VALID_EDITORS.includes(normalized)) {
44
+ return normalized;
45
+ }
46
+ throw new Error(`Invalid --editor "${value}". Use: ${VALID_EDITORS.join(', ')}.`);
47
+ }
48
+ export function resolveWorkflowInput(options) {
49
+ if (options.workflowInput !== undefined && options.workflowInputFile !== undefined) {
50
+ throw new Error('Pass only one of --workflow-input or --workflow-input-file.');
51
+ }
52
+ if (options.workflowInput !== undefined) {
53
+ const sanitized = sanitizeWorkflowInput(options.workflowInput);
54
+ return sanitized.length > 0 ? sanitized : undefined;
55
+ }
56
+ return undefined;
57
+ }
58
+ export async function resolveWorkflowInputAsync(options) {
59
+ if (options.workflowInput !== undefined && options.workflowInputFile !== undefined) {
60
+ throw new Error('Pass only one of --workflow-input or --workflow-input-file.');
61
+ }
62
+ if (options.workflowInputFile !== undefined) {
63
+ const sanitized = await readWorkflowInputFile(options.workflowInputFile);
64
+ return sanitized.length > 0 ? sanitized : undefined;
65
+ }
66
+ return resolveWorkflowInput(options);
67
+ }
68
+ //# sourceMappingURL=input.js.map
@@ -0,0 +1,31 @@
1
+ export declare const CHANGE_TELEMETRY_FILENAME = ".openspec-telemetry.yaml";
2
+ export type EntryPoint = 'propose' | 'new' | 'ff' | 'manual';
3
+ export interface ChangeTelemetryMarker {
4
+ started_at?: string;
5
+ entry_point?: EntryPoint;
6
+ workflow_input?: string;
7
+ editor?: string;
8
+ git_head_at_start?: string;
9
+ proposal_ready_at?: string;
10
+ proposal_ready_emitted?: boolean;
11
+ apply_ready_emitted?: boolean;
12
+ artifact_hashes?: Record<string, string>;
13
+ artifact_body_cache?: Record<string, string>;
14
+ revision_counts?: Record<string, number>;
15
+ comprehension_attempt_count?: number;
16
+ comprehension_failure_count?: number;
17
+ comprehension_gate_last_emitted?: {
18
+ passed: boolean;
19
+ best_score_percent?: number;
20
+ };
21
+ }
22
+ export declare function markerPath(changeDir: string): string;
23
+ export declare function readMarker(changeDir: string): Promise<ChangeTelemetryMarker>;
24
+ export declare function writeMarker(changeDir: string, marker: ChangeTelemetryMarker): Promise<void>;
25
+ export declare function updateMarker(changeDir: string, update: (current: ChangeTelemetryMarker) => ChangeTelemetryMarker): Promise<ChangeTelemetryMarker>;
26
+ export declare function hashFileContent(content: string): string;
27
+ export declare function hashFileAt(filePath: string): Promise<string | null>;
28
+ export declare function durationSince(isoStart: string | undefined): number | undefined;
29
+ export declare function durationBetween(isoStart: string | undefined, isoEnd: string | undefined): number | undefined;
30
+ export declare function totalRevisions(marker: ChangeTelemetryMarker): number;
31
+ //# sourceMappingURL=marker.d.ts.map
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Change-local telemetry marker (.openspec-telemetry.yaml) for dedupe and durations.
3
+ */
4
+ import { createHash } from 'crypto';
5
+ import { promises as fs } from 'fs';
6
+ import path from 'path';
7
+ import { parse, stringify } from 'yaml';
8
+ export const CHANGE_TELEMETRY_FILENAME = '.openspec-telemetry.yaml';
9
+ export function markerPath(changeDir) {
10
+ return path.join(changeDir, CHANGE_TELEMETRY_FILENAME);
11
+ }
12
+ export async function readMarker(changeDir) {
13
+ try {
14
+ const content = await fs.readFile(markerPath(changeDir), 'utf-8');
15
+ return parse(content) ?? {};
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ export async function writeMarker(changeDir, marker) {
22
+ await fs.writeFile(markerPath(changeDir), stringify(marker), 'utf-8');
23
+ }
24
+ export async function updateMarker(changeDir, update) {
25
+ const current = await readMarker(changeDir);
26
+ const next = update(current);
27
+ await writeMarker(changeDir, next);
28
+ return next;
29
+ }
30
+ export function hashFileContent(content) {
31
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
32
+ }
33
+ export async function hashFileAt(filePath) {
34
+ try {
35
+ const content = await fs.readFile(filePath, 'utf-8');
36
+ return hashFileContent(content);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function durationSince(isoStart) {
43
+ if (!isoStart) {
44
+ return undefined;
45
+ }
46
+ const start = Date.parse(isoStart);
47
+ if (Number.isNaN(start)) {
48
+ return undefined;
49
+ }
50
+ return Date.now() - start;
51
+ }
52
+ export function durationBetween(isoStart, isoEnd) {
53
+ if (!isoStart || !isoEnd) {
54
+ return undefined;
55
+ }
56
+ const start = Date.parse(isoStart);
57
+ const end = Date.parse(isoEnd);
58
+ if (Number.isNaN(start) || Number.isNaN(end)) {
59
+ return undefined;
60
+ }
61
+ return end - start;
62
+ }
63
+ export function totalRevisions(marker) {
64
+ const counts = marker.revision_counts ?? {};
65
+ return Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
66
+ }
67
+ //# sourceMappingURL=marker.js.map
@@ -0,0 +1,75 @@
1
+ import { type ChangeTelemetryMarker, type EntryPoint } from './marker.js';
2
+ export declare function trackWorkflowStarted(params: {
3
+ changeDir: string;
4
+ changeName: string;
5
+ schema: string;
6
+ entryPoint: EntryPoint;
7
+ storeSelected: boolean;
8
+ projectRoot: string;
9
+ workflowInput?: string;
10
+ description?: string;
11
+ goal?: string;
12
+ editor?: string;
13
+ }): Promise<void>;
14
+ export declare function maybeEmitProposalReady(params: {
15
+ changeDir: string;
16
+ changeName: string;
17
+ schema: string;
18
+ missingArtifacts: string[];
19
+ artifactCount: number;
20
+ contextFiles?: Record<string, string[]>;
21
+ }): Promise<void>;
22
+ export declare function maybeEmitApplyReady(params: {
23
+ changeDir: string;
24
+ changeName: string;
25
+ state: string;
26
+ contextFiles?: Record<string, string[]>;
27
+ }): Promise<boolean>;
28
+ export declare function trackArtifactInstructions(params: {
29
+ changeDir: string;
30
+ changeName: string;
31
+ artifactId: string;
32
+ artifactWasDone: boolean;
33
+ artifactPaths?: string[];
34
+ }): Promise<void>;
35
+ export declare function trackArtifactContentChanges(params: {
36
+ changeDir: string;
37
+ changeName: string;
38
+ contextFiles: Record<string, string[]>;
39
+ }): Promise<void>;
40
+ interface SpecDeltaInfo {
41
+ capability: string;
42
+ counts: {
43
+ added: number;
44
+ modified: number;
45
+ removed: number;
46
+ renamed: number;
47
+ };
48
+ deltaSpecLinesChanged?: number;
49
+ }
50
+ export declare function trackChangeArchived(params: {
51
+ changeDir: string;
52
+ changeName: string;
53
+ schema: string;
54
+ specsUpdated: boolean;
55
+ totals?: {
56
+ added: number;
57
+ modified: number;
58
+ removed: number;
59
+ renamed: number;
60
+ };
61
+ tasksComplete: boolean;
62
+ specDeltas: SpecDeltaInfo[];
63
+ projectRoot: string;
64
+ }): Promise<void>;
65
+ export declare function buildSpecDeltasFromUpdates(specUpdates: Array<{
66
+ source: string;
67
+ counts: {
68
+ added: number;
69
+ modified: number;
70
+ removed: number;
71
+ renamed: number;
72
+ };
73
+ }>): Promise<SpecDeltaInfo[]>;
74
+ export type { ChangeTelemetryMarker, EntryPoint };
75
+ //# sourceMappingURL=workflow.d.ts.map