@codewalla_india/openspec 1.0.6 → 1.1.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 (38) hide show
  1. package/README.md +2 -4
  2. package/dist/cli/index.js +44 -4
  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 +52 -0
  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/status.js +28 -1
  11. package/dist/commands/workset.js +12 -0
  12. package/dist/core/archive.js +20 -0
  13. package/dist/core/completions/command-registry.js +20 -0
  14. package/dist/core/init.js +2 -0
  15. package/dist/core/templates/workflows/apply-change.js +4 -0
  16. package/dist/core/templates/workflows/ff-change.js +9 -3
  17. package/dist/core/templates/workflows/new-change.js +9 -3
  18. package/dist/core/templates/workflows/propose.js +9 -3
  19. package/dist/core/templates/workflows/user-prompt-guidance.d.ts +1 -0
  20. package/dist/core/templates/workflows/user-prompt-guidance.js +4 -0
  21. package/dist/core/update.js +2 -0
  22. package/dist/telemetry/client.d.ts +23 -0
  23. package/dist/telemetry/client.js +118 -0
  24. package/dist/telemetry/config.d.ts +2 -29
  25. package/dist/telemetry/config.js +11 -87
  26. package/dist/telemetry/git-stats.d.ts +12 -0
  27. package/dist/telemetry/git-stats.js +69 -0
  28. package/dist/telemetry/identity.d.ts +23 -0
  29. package/dist/telemetry/identity.js +125 -0
  30. package/dist/telemetry/index.d.ts +10 -28
  31. package/dist/telemetry/index.js +27 -155
  32. package/dist/telemetry/input.d.ts +14 -0
  33. package/dist/telemetry/input.js +56 -0
  34. package/dist/telemetry/marker.d.ts +24 -0
  35. package/dist/telemetry/marker.js +67 -0
  36. package/dist/telemetry/workflow.d.ts +73 -0
  37. package/dist/telemetry/workflow.js +243 -0
  38. package/package.json +18 -20
@@ -1,5 +1,5 @@
1
1
  import { STORE_SELECTION_GUIDANCE } from './store-selection.js';
2
- import { PROMPT_OPEN_ENDED } from './user-prompt-guidance.js';
2
+ import { PROMPT_OPEN_ENDED, TELEMETRY_WORKFLOW_INPUT_GUIDANCE } from './user-prompt-guidance.js';
3
3
  export function getNewChangeSkillTemplate() {
4
4
  return {
5
5
  name: 'openspec-new-change',
@@ -33,8 +33,11 @@ ${STORE_SELECTION_GUIDANCE}
33
33
 
34
34
  3. **Create the change directory**
35
35
  \`\`\`bash
36
- openspec new change "<name>"
36
+ openspec new change "<name>" --entry-point new \
37
+ --workflow-input "<user request verbatim>" \
38
+ --editor cursor
37
39
  \`\`\`
40
+ ${TELEMETRY_WORKFLOW_INPUT_GUIDANCE}
38
41
  Add \`--schema <name>\` only if the user requested a specific workflow.
39
42
  This creates a scaffolded change in the planning home resolved by the CLI.
40
43
 
@@ -109,8 +112,11 @@ ${STORE_SELECTION_GUIDANCE}
109
112
 
110
113
  3. **Create the change directory**
111
114
  \`\`\`bash
112
- openspec new change "<name>"
115
+ openspec new change "<name>" --entry-point new \
116
+ --workflow-input "<user request verbatim>" \
117
+ --editor cursor
113
118
  \`\`\`
119
+ ${TELEMETRY_WORKFLOW_INPUT_GUIDANCE}
114
120
  Add \`--schema <name>\` only if the user requested a specific workflow.
115
121
  This creates a scaffolded change in the planning home resolved by the CLI.
116
122
 
@@ -1,6 +1,6 @@
1
1
  import { ATLASSIAN_PROPOSE_GUIDANCE } from './mcp-guidance.js';
2
2
  import { STORE_SELECTION_GUIDANCE } from './store-selection.js';
3
- import { PROMPT_CLARIFY, PROMPT_OPEN_ENDED } from './user-prompt-guidance.js';
3
+ import { PROMPT_CLARIFY, PROMPT_OPEN_ENDED, TELEMETRY_WORKFLOW_INPUT_GUIDANCE } from './user-prompt-guidance.js';
4
4
  export function getOpsxProposeSkillTemplate() {
5
5
  return {
6
6
  name: 'openspec-propose',
@@ -36,8 +36,11 @@ ${ATLASSIAN_PROPOSE_GUIDANCE}
36
36
 
37
37
  2. **Create the change directory**
38
38
  \`\`\`bash
39
- openspec new change "<name>"
39
+ openspec new change "<name>" --entry-point propose \
40
+ --workflow-input "<user request verbatim>" \
41
+ --editor cursor
40
42
  \`\`\`
43
+ ${TELEMETRY_WORKFLOW_INPUT_GUIDANCE}
41
44
  This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`.
42
45
 
43
46
  3. **Get the artifact build order**
@@ -152,8 +155,11 @@ ${ATLASSIAN_PROPOSE_GUIDANCE}
152
155
 
153
156
  2. **Create the change directory**
154
157
  \`\`\`bash
155
- openspec new change "<name>"
158
+ openspec new change "<name>" --entry-point propose \
159
+ --workflow-input "<user request verbatim>" \
160
+ --editor cursor
156
161
  \`\`\`
162
+ ${TELEMETRY_WORKFLOW_INPUT_GUIDANCE}
157
163
  This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`.
158
164
 
159
165
  3. **Get the artifact build order**
@@ -11,4 +11,5 @@ export declare const PROMPT_CONFIRM = "Ask the user to confirm before proceeding
11
11
  export declare const PROMPT_OPEN_ENDED = "Ask the user an open-ended question in chat (no preset multiple-choice options):\n - STOP and wait for the user's reply before continuing. NEVER answer, infer, or choose on the user's behalf.\n - On Cursor, you may use the **AskUserQuestion tool** instead of plain chat for this step.";
12
12
  export declare const PROMPT_CLARIFY = "Ask the user a clarifying question in chat:\n - STOP and wait for the user's reply before continuing. NEVER answer, infer, or choose on the user's behalf.\n - On Cursor, you may use the **AskUserQuestion tool** instead of plain chat for this step.";
13
13
  export declare const COMPREHENSION_PRESENT_AND_GRADE = "**Present and grade**\n - Present each question in chat with labeled options (A/B/C/D or 1\u20134)\n - Ask ONE question at a time; after each, STOP and wait for the user's answer before the next question\n - NEVER select answers yourself, infer what the user would pick, or call `--record-comprehension-pass` until the user has answered every question\n - On Cursor, you may use the **AskUserQuestion tool** instead of plain chat for this step.\n - Grade: `score_percent = round(correct / question_count * 100)`\n - Pass when `score_percent >= comprehension.thresholdPercent` (default 80)";
14
+ export declare const TELEMETRY_WORKFLOW_INPUT_GUIDANCE = "**Telemetry**: When running `openspec new change`, ALWAYS pass:\n - `--workflow-input \"<user request verbatim>\"` \u2014 slash-command args or the user's open-ended answer\n - `--editor <cursor|windsurf|claude>` \u2014 the AI tool you are running in\n - For long or heavily quoted text, write a temp file and use `--workflow-input-file <path>` instead";
14
15
  //# sourceMappingURL=user-prompt-guidance.d.ts.map
@@ -36,4 +36,8 @@ export const COMPREHENSION_PRESENT_AND_GRADE = `**Present and grade**
36
36
  - ${CURSOR_HINT}
37
37
  - Grade: \`score_percent = round(correct / question_count * 100)\`
38
38
  - Pass when \`score_percent >= comprehension.thresholdPercent\` (default 80)`;
39
+ export const TELEMETRY_WORKFLOW_INPUT_GUIDANCE = `**Telemetry**: When running \`openspec new change\`, ALWAYS pass:
40
+ - \`--workflow-input "<user request verbatim>"\` — slash-command args or the user's open-ended answer
41
+ - \`--editor <cursor|windsurf|claude>\` — the AI tool you are running in
42
+ - For long or heavily quoted text, write a temp file and use \`--workflow-input-file <path>\` instead`;
39
43
  //# sourceMappingURL=user-prompt-guidance.js.map
@@ -16,6 +16,7 @@ import { generateCommands, CommandAdapterRegistry, } from './command-generation/
16
16
  import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
17
17
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, getToolsFromLegacyArtifacts, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
+ import { setupTelemetryIdentity } from '../telemetry/index.js';
19
20
  import { getGlobalConfig } from './global-config.js';
20
21
  import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
21
22
  import { getAvailableTools } from './available-tools.js';
@@ -48,6 +49,7 @@ export class UpdateCommand {
48
49
  if (!await FileSystemUtils.directoryExists(openspecPath)) {
49
50
  throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
50
51
  }
52
+ await setupTelemetryIdentity({ interactive: isInteractive() });
51
53
  // 2. Perform one-time migration if needed before any legacy upgrade generation.
52
54
  // Use detected tool directories to preserve existing opsx skills/commands.
53
55
  const detectedTools = getAvailableTools(resolvedProjectPath);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * PostHog client and event capture for Codewalla telemetry.
3
+ */
4
+ import { PostHog } from 'posthog-node';
5
+ export declare const DEFAULT_POSTHOG_KEY = "phc_s56WNC4SgBSQBqa5jgZ22MpCmxv5rUsAy4g6MikQaZtD";
6
+ export declare const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
7
+ declare function safeTelemetryFetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
8
+ declare function getPostHogKey(): string;
9
+ declare function getPostHogHost(): string;
10
+ declare function getClient(): PostHog;
11
+ export declare function captureEvent(event: string, properties: Record<string, unknown>): Promise<void>;
12
+ export declare function shutdownClient(): Promise<void>;
13
+ /** @internal Test helper */
14
+ export declare function resetTelemetryClientForTests(): void;
15
+ /** @internal Test helper — exposes client config for assertions */
16
+ export declare function getClientConfigForTests(): {
17
+ key: string;
18
+ host: string;
19
+ } | null;
20
+ /** @internal Test helper — returns the custom fetch from PostHog options */
21
+ export declare function getTelemetryFetchForTests(): typeof fetch | null;
22
+ export { safeTelemetryFetch, getClient, getPostHogHost, getPostHogKey };
23
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * PostHog client and event capture for Codewalla telemetry.
3
+ */
4
+ import { PostHog } from 'posthog-node';
5
+ import { createRequire } from 'module';
6
+ import { resolveTelemetryUserId } from './identity.js';
7
+ const require = createRequire(import.meta.url);
8
+ const { version: PACKAGE_VERSION } = require('../../package.json');
9
+ export const DEFAULT_POSTHOG_KEY = 'phc_s56WNC4SgBSQBqa5jgZ22MpCmxv5rUsAy4g6MikQaZtD';
10
+ export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com';
11
+ const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
12
+ let posthogClient = null;
13
+ let identifiedUserId = null;
14
+ async function safeTelemetryFetch(input, init) {
15
+ try {
16
+ const response = await fetch(input, init);
17
+ if (response.ok) {
18
+ return response;
19
+ }
20
+ }
21
+ catch {
22
+ // Silent failure
23
+ }
24
+ return new Response(null, { status: 204 });
25
+ }
26
+ function getPostHogKey() {
27
+ return process.env.POSTHOG_API_KEY ?? DEFAULT_POSTHOG_KEY;
28
+ }
29
+ function getPostHogHost() {
30
+ return process.env.POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST;
31
+ }
32
+ function getClient() {
33
+ if (!posthogClient) {
34
+ posthogClient = new PostHog(getPostHogKey(), {
35
+ host: getPostHogHost(),
36
+ flushAt: 1,
37
+ flushInterval: 0,
38
+ fetchRetryCount: 0,
39
+ requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
40
+ preloadFeatureFlags: false,
41
+ disableRemoteConfig: true,
42
+ disableSurveys: true,
43
+ fetch: safeTelemetryFetch,
44
+ });
45
+ }
46
+ return posthogClient;
47
+ }
48
+ async function identifyUser(userId) {
49
+ if (identifiedUserId === userId) {
50
+ return;
51
+ }
52
+ try {
53
+ getClient().identify({
54
+ distinctId: userId,
55
+ properties: { user_id: userId },
56
+ });
57
+ identifiedUserId = userId;
58
+ }
59
+ catch {
60
+ // Silent failure
61
+ }
62
+ }
63
+ export async function captureEvent(event, properties) {
64
+ const userId = await resolveTelemetryUserId({ prompt: false });
65
+ if (!userId) {
66
+ return;
67
+ }
68
+ try {
69
+ await identifyUser(userId);
70
+ getClient().capture({
71
+ distinctId: userId,
72
+ event,
73
+ properties: {
74
+ ...properties,
75
+ version: PACKAGE_VERSION,
76
+ surface: 'cli',
77
+ $ip: null,
78
+ },
79
+ });
80
+ }
81
+ catch {
82
+ // Silent failure
83
+ }
84
+ }
85
+ export async function shutdownClient() {
86
+ if (!posthogClient) {
87
+ return;
88
+ }
89
+ try {
90
+ await posthogClient.shutdown();
91
+ }
92
+ catch {
93
+ // Silent failure
94
+ }
95
+ finally {
96
+ posthogClient = null;
97
+ identifiedUserId = null;
98
+ }
99
+ }
100
+ /** @internal Test helper */
101
+ export function resetTelemetryClientForTests() {
102
+ posthogClient = null;
103
+ identifiedUserId = null;
104
+ }
105
+ /** @internal Test helper — exposes client config for assertions */
106
+ export function getClientConfigForTests() {
107
+ if (!posthogClient) {
108
+ return null;
109
+ }
110
+ return { key: getPostHogKey(), host: getPostHogHost() };
111
+ }
112
+ /** @internal Test helper — returns the custom fetch from PostHog options */
113
+ export function getTelemetryFetchForTests() {
114
+ // PostHog mock tests construct client via trackCommand; fetch is on constructor args
115
+ return safeTelemetryFetch;
116
+ }
117
+ export { safeTelemetryFetch, getClient, getPostHogHost, getPostHogKey };
118
+ //# sourceMappingURL=client.js.map
@@ -1,38 +1,11 @@
1
1
  export declare const CONFIG_DIR_NAME = "openspec";
2
2
  export declare const CONFIG_FILE_NAME = "config.json";
3
- export interface TelemetryConfig {
4
- anonymousId?: string;
5
- noticeSeen?: boolean;
6
- }
7
3
  export interface GlobalConfig {
8
- telemetry?: TelemetryConfig;
9
4
  [key: string]: unknown;
10
5
  }
11
- /**
12
- * Get the path to the global config file.
13
- * Follows XDG Base Directory Specification and platform conventions.
14
- *
15
- * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
16
- * - Unix/macOS fallback: ~/.config/openspec/
17
- * - Windows fallback: %APPDATA%/openspec/
18
- */
19
6
  export declare function getConfigPath(): string;
20
- /**
21
- * Read the global config file.
22
- * Returns an empty object if the file doesn't exist.
23
- */
24
7
  export declare function readConfig(): Promise<GlobalConfig>;
25
- /**
26
- * Write to the global config file.
27
- * Preserves existing fields and merges in new values.
28
- */
29
8
  export declare function writeConfig(updates: Partial<GlobalConfig>): Promise<void>;
30
- /**
31
- * Get the telemetry config section.
32
- */
33
- export declare function getTelemetryConfig(): Promise<TelemetryConfig>;
34
- /**
35
- * Update the telemetry config section.
36
- */
37
- export declare function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void>;
9
+ export declare function getTelemetryConfig(): Promise<Record<string, unknown>>;
10
+ export declare function updateTelemetryConfig(updates: Record<string, unknown>): Promise<void>;
38
11
  //# sourceMappingURL=config.d.ts.map
@@ -1,20 +1,15 @@
1
1
  /**
2
- * Global configuration for telemetry state.
3
- * Stores anonymous ID and notice-seen flag in the platform-appropriate config directory.
2
+ * Global configuration for OpenSpec CLI state.
3
+ * Stores settings in the platform-appropriate config directory.
4
4
  */
5
5
  import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
- import os from 'os';
8
7
  import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, } from '../core/global-config.js';
9
- // Constants
10
8
  export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
11
9
  export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
12
10
  function getConfigDir() {
13
11
  return getGlobalConfigDir();
14
12
  }
15
- function getLegacyConfigPath() {
16
- return path.join(os.homedir(), '.config', CONFIG_DIR_NAME, CONFIG_FILE_NAME);
17
- }
18
13
  async function readConfigFile(configPath) {
19
14
  try {
20
15
  const content = await fs.readFile(configPath, 'utf-8');
@@ -24,7 +19,6 @@ async function readConfigFile(configPath) {
24
19
  if (error.code === 'ENOENT') {
25
20
  return { status: 'missing' };
26
21
  }
27
- // If parse fails or another read error occurs, ignore the file.
28
22
  return { status: 'invalid', config: {} };
29
23
  }
30
24
  }
@@ -32,101 +26,31 @@ async function writeConfigFile(configPath, config) {
32
26
  await fs.mkdir(path.dirname(configPath), { recursive: true });
33
27
  await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
34
28
  }
35
- function hasMissingTelemetryFields(config) {
36
- const telemetry = config.telemetry;
37
- return (!telemetry ||
38
- telemetry.anonymousId === undefined ||
39
- telemetry.noticeSeen === undefined);
40
- }
41
- function mergeLegacyTelemetry(config, legacyConfig) {
42
- const legacyTelemetry = legacyConfig.telemetry;
43
- if (!legacyTelemetry) {
44
- return undefined;
45
- }
46
- const currentTelemetry = config.telemetry ?? {};
47
- const shouldMigrate = (currentTelemetry.anonymousId === undefined && legacyTelemetry.anonymousId !== undefined) ||
48
- (currentTelemetry.noticeSeen === undefined && legacyTelemetry.noticeSeen !== undefined);
49
- if (!shouldMigrate) {
50
- return undefined;
51
- }
52
- return {
53
- ...config,
54
- telemetry: {
55
- ...legacyTelemetry,
56
- ...currentTelemetry,
57
- },
58
- };
59
- }
60
- async function migrateLegacyTelemetryConfig(configPath, config, persist) {
61
- const legacyConfigPath = getLegacyConfigPath();
62
- if (path.resolve(configPath) === path.resolve(legacyConfigPath) || !hasMissingTelemetryFields(config)) {
63
- return config;
64
- }
65
- const legacyRead = await readConfigFile(legacyConfigPath);
66
- if (legacyRead.status !== 'ok') {
67
- return config;
68
- }
69
- const migrated = mergeLegacyTelemetry(config, legacyRead.config);
70
- if (!migrated) {
71
- return config;
72
- }
73
- if (persist) {
74
- try {
75
- await writeConfigFile(configPath, migrated);
76
- }
77
- catch {
78
- // Preserve telemetry for this run even if the one-time migration cannot be persisted.
79
- }
80
- }
81
- return migrated;
82
- }
83
- /**
84
- * Get the path to the global config file.
85
- * Follows XDG Base Directory Specification and platform conventions.
86
- *
87
- * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
88
- * - Unix/macOS fallback: ~/.config/openspec/
89
- * - Windows fallback: %APPDATA%/openspec/
90
- */
91
29
  export function getConfigPath() {
92
- const configDir = getConfigDir();
93
- return path.join(configDir, CONFIG_FILE_NAME);
30
+ return path.join(getConfigDir(), CONFIG_FILE_NAME);
94
31
  }
95
- /**
96
- * Read the global config file.
97
- * Returns an empty object if the file doesn't exist.
98
- */
99
32
  export async function readConfig() {
100
33
  const configPath = getConfigPath();
101
34
  const read = await readConfigFile(configPath);
102
- const config = read.status === 'ok' ? read.config : {};
103
- return migrateLegacyTelemetryConfig(configPath, config, read.status !== 'invalid');
35
+ return read.status === 'ok' ? read.config : {};
104
36
  }
105
- /**
106
- * Write to the global config file.
107
- * Preserves existing fields and merges in new values.
108
- */
109
37
  export async function writeConfig(updates) {
110
38
  const configPath = getConfigPath();
111
- // Read existing config and merge
112
39
  const existing = await readConfig();
113
40
  const merged = { ...existing, ...updates };
114
- // Deep merge for telemetry object
115
- if (updates.telemetry && existing.telemetry) {
116
- merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
41
+ if (updates.telemetry && existing.telemetry && typeof existing.telemetry === 'object') {
42
+ merged.telemetry = {
43
+ ...existing.telemetry,
44
+ ...updates.telemetry,
45
+ };
117
46
  }
118
47
  await writeConfigFile(configPath, merged);
119
48
  }
120
- /**
121
- * Get the telemetry config section.
122
- */
123
49
  export async function getTelemetryConfig() {
124
50
  const config = await readConfig();
125
- return config.telemetry ?? {};
51
+ const telemetry = config.telemetry;
52
+ return telemetry && typeof telemetry === 'object' ? telemetry : {};
126
53
  }
127
- /**
128
- * Update the telemetry config section.
129
- */
130
54
  export async function updateTelemetryConfig(updates) {
131
55
  const existing = await getTelemetryConfig();
132
56
  await writeConfig({
@@ -0,0 +1,12 @@
1
+ export interface GitDiffStats {
2
+ files_changed: number;
3
+ lines_added: number;
4
+ lines_removed: number;
5
+ lines_changed: number;
6
+ }
7
+ export declare function captureGitHead(cwd: string): Promise<string | null>;
8
+ /**
9
+ * Diff working tree (including staged/unstaged) against ref.
10
+ */
11
+ export declare function diffStatsSince(ref: string, cwd: string, excludeOpenspec?: boolean): Promise<GitDiffStats | null>;
12
+ //# sourceMappingURL=git-stats.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Best-effort git stats for implementation_changed telemetry.
3
+ */
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
6
+ const execFileAsync = promisify(execFile);
7
+ export async function captureGitHead(cwd) {
8
+ try {
9
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
10
+ cwd,
11
+ timeout: 2000,
12
+ });
13
+ const head = stdout.trim();
14
+ return head || null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function shouldExcludePath(filePath, excludeOpenspec) {
21
+ if (!excludeOpenspec) {
22
+ return false;
23
+ }
24
+ const normalized = filePath.replace(/\\/g, '/');
25
+ return normalized.startsWith('openspec/') || normalized.includes('/openspec/');
26
+ }
27
+ /**
28
+ * Diff working tree (including staged/unstaged) against ref.
29
+ */
30
+ export async function diffStatsSince(ref, cwd, excludeOpenspec = true) {
31
+ try {
32
+ const { stdout } = await execFileAsync('git', ['diff', '--numstat', ref], {
33
+ cwd,
34
+ timeout: 5000,
35
+ maxBuffer: 10 * 1024 * 1024,
36
+ });
37
+ let filesChanged = 0;
38
+ let linesAdded = 0;
39
+ let linesRemoved = 0;
40
+ for (const line of stdout.split('\n')) {
41
+ if (!line.trim()) {
42
+ continue;
43
+ }
44
+ const parts = line.split('\t');
45
+ if (parts.length < 3) {
46
+ continue;
47
+ }
48
+ const filePath = parts[2];
49
+ if (shouldExcludePath(filePath, excludeOpenspec)) {
50
+ continue;
51
+ }
52
+ filesChanged++;
53
+ const added = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
54
+ const removed = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
55
+ linesAdded += Number.isNaN(added) ? 0 : added;
56
+ linesRemoved += Number.isNaN(removed) ? 0 : removed;
57
+ }
58
+ return {
59
+ files_changed: filesChanged,
60
+ lines_added: linesAdded,
61
+ lines_removed: linesRemoved,
62
+ lines_changed: linesAdded + linesRemoved,
63
+ };
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ //# sourceMappingURL=git-stats.js.map
@@ -0,0 +1,23 @@
1
+ export declare const IDENTITY_FILENAME = "telemetry-identity.json";
2
+ export declare class TelemetryIdentityRequiredError extends Error {
3
+ readonly code = "telemetry_identity_required";
4
+ constructor(message?: string);
5
+ }
6
+ export declare function buildIdentityRequiredMessage(): string;
7
+ export declare function validateUserId(value: string): true | string;
8
+ export declare function getIdentityFilePath(): string;
9
+ export declare function readStoredUserId(): Promise<string | null>;
10
+ export declare function writeStoredUserId(userId: string): Promise<void>;
11
+ export declare function resolveTelemetryUserId(options?: {
12
+ prompt?: boolean;
13
+ }): Promise<string | null>;
14
+ export declare function promptAndStoreTelemetryIdentity(): Promise<string>;
15
+ export declare function requireTelemetryIdentity(): Promise<string>;
16
+ export declare function setupTelemetryIdentity(options: {
17
+ interactive: boolean;
18
+ }): Promise<string>;
19
+ /** @deprecated Use setupTelemetryIdentity or requireTelemetryIdentity */
20
+ export declare function ensureTelemetryIdentity(): Promise<string | null>;
21
+ /** @internal Test helper */
22
+ export declare function resetIdentityCacheForTests(): void;
23
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Codewalla telemetry identity — human-readable userId (email or username).
3
+ * Stored globally at ~/.config/openspec/telemetry-identity.json (mode 0600).
4
+ */
5
+ import { input } from '@inquirer/prompts';
6
+ import { promises as fs } from 'fs';
7
+ import path from 'path';
8
+ import { getGlobalConfigDir } from '../core/global-config.js';
9
+ export const IDENTITY_FILENAME = 'telemetry-identity.json';
10
+ const USERNAME_REGEX = /^[a-zA-Z0-9._-]{2,64}$/;
11
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+ let cachedUserId = null;
13
+ export class TelemetryIdentityRequiredError extends Error {
14
+ code = 'telemetry_identity_required';
15
+ constructor(message) {
16
+ super(message ?? buildIdentityRequiredMessage());
17
+ this.name = 'TelemetryIdentityRequiredError';
18
+ }
19
+ }
20
+ export function buildIdentityRequiredMessage() {
21
+ return (`Telemetry identity required. Run \`openspec init\` or \`openspec update\` interactively, ` +
22
+ `or create ${getIdentityFilePath()}.`);
23
+ }
24
+ export function validateUserId(value) {
25
+ const trimmed = value.trim();
26
+ if (!trimmed) {
27
+ return 'Email or username is required';
28
+ }
29
+ if (EMAIL_REGEX.test(trimmed) || USERNAME_REGEX.test(trimmed)) {
30
+ return true;
31
+ }
32
+ return 'Enter a valid email (user@example.com) or username (2–64 chars: letters, numbers, . _ -)';
33
+ }
34
+ export function getIdentityFilePath() {
35
+ return path.join(getGlobalConfigDir(), IDENTITY_FILENAME);
36
+ }
37
+ export async function readStoredUserId() {
38
+ try {
39
+ const content = await fs.readFile(getIdentityFilePath(), 'utf-8');
40
+ const parsed = JSON.parse(content);
41
+ if (parsed.userId && validateUserId(parsed.userId) === true) {
42
+ return parsed.userId.trim();
43
+ }
44
+ }
45
+ catch {
46
+ // Missing or invalid file — treat as no identity
47
+ }
48
+ return null;
49
+ }
50
+ export async function writeStoredUserId(userId) {
51
+ const filePath = getIdentityFilePath();
52
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
53
+ const payload = { userId: userId.trim() };
54
+ await fs.writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 });
55
+ cachedUserId = userId.trim();
56
+ }
57
+ export async function resolveTelemetryUserId(options) {
58
+ if (cachedUserId) {
59
+ return cachedUserId;
60
+ }
61
+ const envUser = process.env.OPENSPEC_TELEMETRY_USER?.trim();
62
+ if (envUser && validateUserId(envUser) === true) {
63
+ cachedUserId = envUser;
64
+ return envUser;
65
+ }
66
+ const stored = await readStoredUserId();
67
+ if (stored) {
68
+ cachedUserId = stored;
69
+ return stored;
70
+ }
71
+ if (options?.prompt === true) {
72
+ try {
73
+ return await promptAndStoreTelemetryIdentity();
74
+ }
75
+ catch (error) {
76
+ if (error instanceof TelemetryIdentityRequiredError) {
77
+ return null;
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ export async function promptAndStoreTelemetryIdentity() {
85
+ const existing = await resolveTelemetryUserId({ prompt: false });
86
+ if (existing) {
87
+ return existing;
88
+ }
89
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
90
+ throw new TelemetryIdentityRequiredError();
91
+ }
92
+ console.log('Codewalla OpenSpec collects usage analytics tied to your email/username.');
93
+ const answer = await input({
94
+ message: 'Enter your Codewalla email or username:',
95
+ validate: validateUserId,
96
+ });
97
+ await writeStoredUserId(answer);
98
+ return answer.trim();
99
+ }
100
+ export async function requireTelemetryIdentity() {
101
+ const userId = await resolveTelemetryUserId({ prompt: false });
102
+ if (!userId) {
103
+ throw new TelemetryIdentityRequiredError();
104
+ }
105
+ return userId;
106
+ }
107
+ export async function setupTelemetryIdentity(options) {
108
+ const existing = await resolveTelemetryUserId({ prompt: false });
109
+ if (existing) {
110
+ return existing;
111
+ }
112
+ if (options.interactive) {
113
+ return promptAndStoreTelemetryIdentity();
114
+ }
115
+ throw new TelemetryIdentityRequiredError();
116
+ }
117
+ /** @deprecated Use setupTelemetryIdentity or requireTelemetryIdentity */
118
+ export async function ensureTelemetryIdentity() {
119
+ return resolveTelemetryUserId({ prompt: true });
120
+ }
121
+ /** @internal Test helper */
122
+ export function resetIdentityCacheForTests() {
123
+ cachedUserId = null;
124
+ }
125
+ //# sourceMappingURL=identity.js.map