{
  "phase1_survey": {
    "purpose": "Initial project categorization",
    "files": [
      {
        "path": "repo-structure.json",
        "tokens": 2000
      }
    ],
    "estimatedTokens": 2000
  },
  "phase2_deep": {
    "purpose": "Core entity and logic extraction",
    "files": [
      {
        "path": "src/core/completions/types.ts",
        "content": "import { SupportedShell } from '../../utils/shell-detection.js';\n\n/**\n * Definition of a command-line flag/option\n */\nexport interface FlagDefinition {\n  /**\n   * Flag name without dashes (e.g., \"json\", \"strict\", \"no-interactive\")\n   */\n  name: string;\n\n  /**\n   * Short flag name without dash (e.g., \"y\" for \"-y\")\n   */\n  short?: string;\n\n  /**\n   * Human-readable description of what the flag does\n   */\n  description: string;\n\n  /**\n   * Whether the flag takes an argument value\n   */\n  takesValue?: boolean;\n\n  /**\n   * Possible values for the flag (for completion suggestions)\n   */\n  values?: string[];\n}\n\n/**\n * Definition of a CLI command\n */\nexport interface CommandDefinition {\n  /**\n   * Command name (e.g., \"init\", \"validate\", \"show\")\n   */\n  name: string;\n\n  /**\n   * Human-readable description of the command\n   */\n  description: string;\n\n  /**\n   * Flags/options supported by this command\n   */\n  flags: FlagDefinition[];\n\n  /**\n   * Subcommands (e.g., \"change show\", \"spec validate\")\n   */\n  subcommands?: CommandDefinition[];\n\n  /**\n   * Whether this command accepts a positional argument (e.g., item name, path)\n   */\n  acceptsPositional?: boolean;\n\n  /**\n   * Type of positional argument for dynamic completion\n   * - 'change-id': Complete with active change IDs\n   * - 'spec-id': Complete with spec IDs\n   * - 'change-or-spec-id': Complete with both changes and specs\n   * - 'path': Complete with file paths\n   * - 'shell': Complete with supported shell names\n   * - 'schema-name': Complete with available schema names\n   * - undefined: No specific completion\n   */\n  positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell' | 'schema-name';\n}\n\n/**\n * Interface for shell-specific completion script generators\n */\nexport interface CompletionGenerator {\n  /**\n   * The shell type this generator targets\n   */\n  readonly shell: SupportedShell;\n\n  /**\n   * Generate the completion script content\n   *\n   * @param commands - Command definitions to generate completions for\n   * @returns The shell-specific completion script as a string\n   */\n  generate(commands: CommandDefinition[]): string;\n}\n",
        "tokens": 534
      },
      {
        "path": "src/core/command-generation/types.ts",
        "content": "/**\n * Command Generation Types\n *\n * Tool-agnostic interfaces for command generation.\n * These types separate \"what to generate\" from \"how to format it\".\n */\n\n/**\n * Tool-agnostic command data.\n * Represents the content of a command without any tool-specific formatting.\n */\nexport interface CommandContent {\n  /** Command identifier (e.g., 'explore', 'apply', 'new') */\n  id: string;\n  /** Human-readable name (e.g., 'OpenSpec Explore') */\n  name: string;\n  /** Brief description of command purpose */\n  description: string;\n  /** Grouping category (e.g., 'Workflow') */\n  category: string;\n  /** Array of tag strings */\n  tags: string[];\n  /** The command instruction content (body text) */\n  body: string;\n}\n\n/**\n * Per-tool formatting strategy.\n * Each AI tool implements this interface to handle its specific file path\n * and frontmatter format requirements.\n */\nexport interface ToolCommandAdapter {\n  /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */\n  toolId: string;\n  /**\n   * Returns the file path for a command.\n   * @param commandId - The command identifier (e.g., 'explore')\n   * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md').\n   *          May be absolute for tools with global-scoped prompts (e.g., Codex).\n   */\n  getFilePath(commandId: string): string;\n  /**\n   * Formats the complete file content including frontmatter.\n   * @param content - The tool-agnostic command content\n   * @returns Complete file content ready to write\n   */\n  formatFile(content: CommandContent): string;\n}\n\n/**\n * Result of generating a command file.\n */\nexport interface GeneratedCommand {\n  /** File path from project root, or absolute for global-scoped tools */\n  path: string;\n  /** Complete file content (frontmatter + body) */\n  fileContent: string;\n}\n",
        "tokens": 453
      },
      {
        "path": "src/core/artifact-graph/types.ts",
        "content": "import { z } from 'zod';\n\n// Artifact definition schema\nexport const ArtifactSchema = z.object({\n  id: z.string().min(1, { error: 'Artifact ID is required' }),\n  generates: z.string().min(1, { error: 'generates field is required' }),\n  description: z.string(),\n  template: z.string().min(1, { error: 'template field is required' }),\n  instruction: z.string().optional(),\n  requires: z.array(z.string()).default([]),\n});\n\n// Apply phase configuration for schema-aware apply instructions\nexport const ApplyPhaseSchema = z.object({\n  // Artifact IDs that must exist before apply is available\n  requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),\n  // Path to file with checkboxes for progress (relative to change dir), or null if no tracking\n  tracks: z.string().nullable().optional(),\n  // Custom guidance for the apply phase\n  instruction: z.string().optional(),\n});\n\n// Full schema YAML structure\nexport const SchemaYamlSchema = z.object({\n  name: z.string().min(1, { error: 'Schema name is required' }),\n  version: z.number().int().positive({ error: 'Version must be a positive integer' }),\n  description: z.string().optional(),\n  artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),\n  // Optional apply phase configuration (for schema-aware apply instructions)\n  apply: ApplyPhaseSchema.optional(),\n});\n\n// Derived TypeScript types\nexport type Artifact = z.infer<typeof ArtifactSchema>;\nexport type ApplyPhase = z.infer<typeof ApplyPhaseSchema>;\nexport type SchemaYaml = z.infer<typeof SchemaYamlSchema>;\n\n// Per-change metadata schema\n// Note: schema field is validated at parse time against available schemas\n// using a lazy import to avoid circular dependencies\nexport const ChangeMetadataSchema = z.object({\n  // Required: which workflow schema this change uses\n  schema: z.string().min(1, { message: 'schema is required' }),\n\n  // Optional: creation timestamp (ISO date string)\n  created: z\n    .string()\n    .regex(/^\\d{4}-\\d{2}-\\d{2}$/, {\n      message: 'created must be YYYY-MM-DD format',\n    })\n    .optional(),\n});\n\nexport type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;\n\n// Runtime state types (not Zod - internal only)\n\n// Slice 1: Simple completion tracking via filesystem\nexport type CompletedSet = Set<string>;\n\n// Return type for blocked query\nexport interface BlockedArtifacts {\n  [artifactId: string]: string[];\n}\n\n",
        "tokens": 604
      },
      {
        "path": "src/core/config-schema.ts",
        "content": "import { z } from 'zod';\n\n/**\n * Zod schema for global OpenSpec configuration.\n * Uses passthrough() to preserve unknown fields for forward compatibility.\n */\nexport const GlobalConfigSchema = z\n  .object({\n    featureFlags: z\n      .record(z.string(), z.boolean())\n      .optional()\n      .default({}),\n  })\n  .passthrough();\n\nexport type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;\n\n/**\n * Default configuration values.\n */\nexport const DEFAULT_CONFIG: GlobalConfigType = {\n  featureFlags: {},\n};\n\nconst KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG));\n\n/**\n * Validate a config key path for CLI set operations.\n * Unknown top-level keys are rejected unless explicitly allowed by the caller.\n */\nexport function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } {\n  const rawKeys = path.split('.');\n\n  if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) {\n    return { valid: false, reason: 'Key path must not be empty' };\n  }\n\n  const rootKey = rawKeys[0];\n  if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) {\n    return { valid: false, reason: `Unknown top-level key \"${rootKey}\"` };\n  }\n\n  if (rootKey === 'featureFlags') {\n    if (rawKeys.length > 2) {\n      return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' };\n    }\n    return { valid: true };\n  }\n\n  if (rawKeys.length > 1) {\n    return { valid: false, reason: `\"${rootKey}\" does not support nested keys` };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Get a nested value from an object using dot notation.\n *\n * @param obj - The object to access\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @returns The value at the path, or undefined if not found\n */\nexport function getNestedValue(obj: Record<string, unknown>, path: string): unknown {\n  const keys = path.split('.');\n  let current: unknown = obj;\n\n  for (const key of keys) {\n    if (current === null || current === undefined) {\n      return undefined;\n    }\n    if (typeof current !== 'object') {\n      return undefined;\n    }\n    current = (current as Record<string, unknown>)[key];\n  }\n\n  return current;\n}\n\n/**\n * Set a nested value in an object using dot notation.\n * Creates intermediate objects as needed.\n *\n * @param obj - The object to modify (mutated in place)\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @param value - The value to set\n */\nexport function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {\n  const keys = path.split('.');\n  let current: Record<string, unknown> = obj;\n\n  for (let i = 0; i < keys.length - 1; i++) {\n    const key = keys[i];\n    if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {\n      current[key] = {};\n    }\n    current = current[key] as Record<string, unknown>;\n  }\n\n  const lastKey = keys[keys.length - 1];\n  current[lastKey] = value;\n}\n\n/**\n * Delete a nested value from an object using dot notation.\n *\n * @param obj - The object to modify (mutated in place)\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @returns true if the key existed and was deleted, false otherwise\n */\nexport function deleteNestedValue(obj: Record<string, unknown>, path: string): boolean {\n  const keys = path.split('.');\n  let current: Record<string, unknown> = obj;\n\n  for (let i = 0; i < keys.length - 1; i++) {\n    const key = keys[i];\n    if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {\n      return false;\n    }\n    current = current[key] as Record<string, unknown>;\n  }\n\n  const lastKey = keys[keys.length - 1];\n  if (lastKey in current) {\n    delete current[lastKey];\n    return true;\n  }\n  return false;\n}\n\n/**\n * Coerce a string value to its appropriate type.\n * - \"true\" / \"false\" -> boolean\n * - Numeric strings -> number\n * - Everything else -> string\n *\n * @param value - The string value to coerce\n * @param forceString - If true, always return the value as a string\n * @returns The coerced value\n */\nexport function coerceValue(value: string, forceString: boolean = false): string | number | boolean {\n  if (forceString) {\n    return value;\n  }\n\n  // Boolean coercion\n  if (value === 'true') {\n    return true;\n  }\n  if (value === 'false') {\n    return false;\n  }\n\n  // Number coercion - must be a valid finite number\n  const num = Number(value);\n  if (!isNaN(num) && isFinite(num) && value.trim() !== '') {\n    return num;\n  }\n\n  return value;\n}\n\n/**\n * Format a value for YAML-like display.\n *\n * @param value - The value to format\n * @param indent - Current indentation level\n * @returns Formatted string\n */\nexport function formatValueYaml(value: unknown, indent: number = 0): string {\n  const indentStr = '  '.repeat(indent);\n\n  if (value === null || value === undefined) {\n    return 'null';\n  }\n\n  if (typeof value === 'boolean' || typeof value === 'number') {\n    return String(value);\n  }\n\n  if (typeof value === 'string') {\n    return value;\n  }\n\n  if (Array.isArray(value)) {\n    if (value.length === 0) {\n      return '[]';\n    }\n    return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\\n');\n  }\n\n  if (typeof value === 'object') {\n    const entries = Object.entries(value as Record<string, unknown>);\n    if (entries.length === 0) {\n      return '{}';\n    }\n    return entries\n      .map(([key, val]) => {\n        const formattedVal = formatValueYaml(val, indent + 1);\n        if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) {\n          return `${indentStr}${key}:\\n${formattedVal}`;\n        }\n        return `${indentStr}${key}: ${formattedVal}`;\n      })\n      .join('\\n');\n  }\n\n  return String(value);\n}\n\n/**\n * Validate a configuration object against the schema.\n *\n * @param config - The configuration to validate\n * @returns Validation result with success status and optional error message\n */\nexport function validateConfig(config: unknown): { success: boolean; error?: string } {\n  try {\n    GlobalConfigSchema.parse(config);\n    return { success: true };\n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      const zodError = error as z.ZodError;\n      const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);\n      return { success: false, error: messages.join('; ') };\n    }\n    return { success: false, error: 'Unknown validation error' };\n  }\n}\n",
        "tokens": 1606
      },
      {
        "path": "src/core/schemas/change.schema.ts",
        "content": "import { z } from 'zod';\nimport { RequirementSchema } from './base.schema.js';\nimport { \n  MIN_WHY_SECTION_LENGTH,\n  MAX_WHY_SECTION_LENGTH,\n  MAX_DELTAS_PER_CHANGE,\n  VALIDATION_MESSAGES \n} from '../validation/constants.js';\n\nexport const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']);\n\nexport const DeltaSchema = z.object({\n  spec: z.string().min(1, VALIDATION_MESSAGES.DELTA_SPEC_EMPTY),\n  operation: DeltaOperationType,\n  description: z.string().min(1, VALIDATION_MESSAGES.DELTA_DESCRIPTION_EMPTY),\n  requirement: RequirementSchema.optional(),\n  requirements: z.array(RequirementSchema).optional(),\n  rename: z.object({\n    from: z.string(),\n    to: z.string(),\n  }).optional(),\n});\n\nexport const ChangeSchema = z.object({\n  name: z.string().min(1, VALIDATION_MESSAGES.CHANGE_NAME_EMPTY),\n  why: z.string()\n    .min(MIN_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_SHORT)\n    .max(MAX_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_LONG),\n  whatChanges: z.string().min(1, VALIDATION_MESSAGES.CHANGE_WHAT_EMPTY),\n  deltas: z.array(DeltaSchema)\n    .min(1, VALIDATION_MESSAGES.CHANGE_NO_DELTAS)\n    .max(MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS),\n  metadata: z.object({\n    version: z.string().default('1.0.0'),\n    format: z.literal('openspec-change'),\n    sourcePath: z.string().optional(),\n  }).optional(),\n});\n\nexport type DeltaOperation = z.infer<typeof DeltaOperationType>;\nexport type Delta = z.infer<typeof DeltaSchema>;\nexport type Change = z.infer<typeof ChangeSchema>;",
        "tokens": 388
      },
      {
        "path": "src/core/schemas/base.schema.ts",
        "content": "import { z } from 'zod';\nimport { VALIDATION_MESSAGES } from '../validation/constants.js';\n\nexport const ScenarioSchema = z.object({\n  rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),\n});\n\nexport const RequirementSchema = z.object({\n  text: z.string()\n    .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY)\n    .refine(\n      (text) => text.includes('SHALL') || text.includes('MUST'),\n      VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL\n    ),\n  scenarios: z.array(ScenarioSchema)\n    .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS),\n});\n\nexport type Scenario = z.infer<typeof ScenarioSchema>;\nexport type Requirement = z.infer<typeof RequirementSchema>;",
        "tokens": 167
      },
      {
        "path": "src/core/validation/types.ts",
        "content": "export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';\n\nexport interface ValidationIssue {\n  level: ValidationLevel;\n  path: string;\n  message: string;\n  line?: number;\n  column?: number;\n}\n\nexport interface ValidationReport {\n  valid: boolean;\n  issues: ValidationIssue[];\n  summary: {\n    errors: number;\n    warnings: number;\n    info: number;\n  };\n}",
        "tokens": 90
      },
      {
        "path": "src/core/artifact-graph/schema.ts",
        "content": "import * as fs from 'node:fs';\nimport { parse as parseYaml } from 'yaml';\nimport { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';\n\nexport class SchemaValidationError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'SchemaValidationError';\n  }\n}\n\n/**\n * Loads and validates an artifact schema from a YAML file.\n */\nexport function loadSchema(filePath: string): SchemaYaml {\n  const content = fs.readFileSync(filePath, 'utf-8');\n  return parseSchema(content);\n}\n\n/**\n * Parses and validates an artifact schema from YAML content.\n */\nexport function parseSchema(yamlContent: string): SchemaYaml {\n  const parsed = parseYaml(yamlContent);\n\n  // Validate with Zod\n  const result = SchemaYamlSchema.safeParse(parsed);\n  if (!result.success) {\n    const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');\n    throw new SchemaValidationError(`Invalid schema: ${errors}`);\n  }\n\n  const schema = result.data;\n\n  // Check for duplicate artifact IDs\n  validateNoDuplicateIds(schema.artifacts);\n\n  // Check that all requires references are valid\n  validateRequiresReferences(schema.artifacts);\n\n  // Check for cycles\n  validateNoCycles(schema.artifacts);\n\n  return schema;\n}\n\n/**\n * Validates that there are no duplicate artifact IDs.\n */\nfunction validateNoDuplicateIds(artifacts: Artifact[]): void {\n  const seen = new Set<string>();\n  for (const artifact of artifacts) {\n    if (seen.has(artifact.id)) {\n      throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);\n    }\n    seen.add(artifact.id);\n  }\n}\n\n/**\n * Validates that all `requires` references point to valid artifact IDs.\n */\nfunction validateRequiresReferences(artifacts: Artifact[]): void {\n  const validIds = new Set(artifacts.map(a => a.id));\n\n  for (const artifact of artifacts) {\n    for (const req of artifact.requires) {\n      if (!validIds.has(req)) {\n        throw new SchemaValidationError(\n          `Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`\n        );\n      }\n    }\n  }\n}\n\n/**\n * Validates that there are no cyclic dependencies.\n * Uses DFS to detect cycles and reports the full cycle path.\n */\nfunction validateNoCycles(artifacts: Artifact[]): void {\n  const artifactMap = new Map(artifacts.map(a => [a.id, a]));\n  const visited = new Set<string>();\n  const inStack = new Set<string>();\n  const parent = new Map<string, string>();\n\n  function dfs(id: string): string | null {\n    visited.add(id);\n    inStack.add(id);\n\n    const artifact = artifactMap.get(id);\n    if (!artifact) return null;\n\n    for (const dep of artifact.requires) {\n      if (!visited.has(dep)) {\n        parent.set(dep, id);\n        const cycle = dfs(dep);\n        if (cycle) return cycle;\n      } else if (inStack.has(dep)) {\n        // Found a cycle - reconstruct the path\n        const cyclePath = [dep];\n        let current = id;\n        while (current !== dep) {\n          cyclePath.unshift(current);\n          current = parent.get(current)!;\n        }\n        cyclePath.unshift(dep);\n        return cyclePath.join(' → ');\n      }\n    }\n\n    inStack.delete(id);\n    return null;\n  }\n\n  for (const artifact of artifacts) {\n    if (!visited.has(artifact.id)) {\n      const cycle = dfs(artifact.id);\n      if (cycle) {\n        throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);\n      }\n    }\n  }\n}\n",
        "tokens": 854
      },
      {
        "path": "src/core/global-config.ts",
        "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\n// Constants\nexport const GLOBAL_CONFIG_DIR_NAME = 'openspec';\nexport const GLOBAL_CONFIG_FILE_NAME = 'config.json';\nexport const GLOBAL_DATA_DIR_NAME = 'openspec';\n\n// TypeScript interfaces\nexport interface GlobalConfig {\n  featureFlags?: Record<string, boolean>;\n}\n\nconst DEFAULT_CONFIG: GlobalConfig = {\n  featureFlags: {}\n};\n\n/**\n * Gets the global configuration directory path following XDG Base Directory Specification.\n *\n * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set\n * - Unix/macOS fallback: ~/.config/openspec/\n * - Windows fallback: %APPDATA%/openspec/\n */\nexport function getGlobalConfigDir(): string {\n  // XDG_CONFIG_HOME takes precedence on all platforms when explicitly set\n  const xdgConfigHome = process.env.XDG_CONFIG_HOME;\n  if (xdgConfigHome) {\n    return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);\n  }\n\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    // Windows: use %APPDATA%\n    const appData = process.env.APPDATA;\n    if (appData) {\n      return path.join(appData, GLOBAL_CONFIG_DIR_NAME);\n    }\n    // Fallback for Windows if APPDATA is not set\n    return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);\n  }\n\n  // Unix/macOS fallback: ~/.config\n  return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);\n}\n\n/**\n * Gets the global data directory path following XDG Base Directory Specification.\n * Used for user data like schema overrides.\n *\n * - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set\n * - Unix/macOS fallback: ~/.local/share/openspec/\n * - Windows fallback: %LOCALAPPDATA%/openspec/\n */\nexport function getGlobalDataDir(): string {\n  // XDG_DATA_HOME takes precedence on all platforms when explicitly set\n  const xdgDataHome = process.env.XDG_DATA_HOME;\n  if (xdgDataHome) {\n    return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);\n  }\n\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    // Windows: use %LOCALAPPDATA%\n    const localAppData = process.env.LOCALAPPDATA;\n    if (localAppData) {\n      return path.join(localAppData, GLOBAL_DATA_DIR_NAME);\n    }\n    // Fallback for Windows if LOCALAPPDATA is not set\n    return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);\n  }\n\n  // Unix/macOS fallback: ~/.local/share\n  return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);\n}\n\n/**\n * Gets the path to the global config file.\n */\nexport function getGlobalConfigPath(): string {\n  return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);\n}\n\n/**\n * Loads the global configuration from disk.\n * Returns default configuration if file doesn't exist or is invalid.\n * Merges loaded config with defaults to ensure new fields are available.\n */\nexport function getGlobalConfig(): GlobalConfig {\n  const configPath = getGlobalConfigPath();\n\n  try {\n    if (!fs.existsSync(configPath)) {\n      return { ...DEFAULT_CONFIG };\n    }\n\n    const content = fs.readFileSync(configPath, 'utf-8');\n    const parsed = JSON.parse(content);\n\n    // Merge with defaults (loaded values take precedence)\n    return {\n      ...DEFAULT_CONFIG,\n      ...parsed,\n      // Deep merge featureFlags\n      featureFlags: {\n        ...DEFAULT_CONFIG.featureFlags,\n        ...(parsed.featureFlags || {})\n      }\n    };\n  } catch (error) {\n    // Log warning for parse errors, but not for missing files\n    if (error instanceof SyntaxError) {\n      console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);\n    }\n    return { ...DEFAULT_CONFIG };\n  }\n}\n\n/**\n * Saves the global configuration to disk.\n * Creates the config directory if it doesn't exist.\n */\nexport function saveGlobalConfig(config: GlobalConfig): void {\n  const configDir = getGlobalConfigDir();\n  const configPath = getGlobalConfigPath();\n\n  // Create directory if it doesn't exist\n  if (!fs.existsSync(configDir)) {\n    fs.mkdirSync(configDir, { recursive: true });\n  }\n\n  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n', 'utf-8');\n}\n",
        "tokens": 1027
      },
      {
        "path": "src/core/project-config.ts",
        "content": "import { existsSync, readFileSync, statSync } from 'fs';\nimport path from 'path';\nimport { parse as parseYaml } from 'yaml';\nimport { z } from 'zod';\n\n/**\n * Zod schema for project configuration.\n *\n * Purpose:\n * 1. Documentation - clearly defines the config file structure\n * 2. Type safety - TypeScript infers ProjectConfig type from schema\n * 3. Runtime validation - uses safeParse() for resilient field-by-field validation\n *\n * Why Zod over manual validation:\n * - Helps understand OpenSpec's data interfaces at a glance\n * - Single source of truth for type and validation\n * - Consistent with other OpenSpec schemas\n */\nexport const ProjectConfigSchema = z.object({\n  // Required: which schema to use (e.g., \"spec-driven\", or project-local schema name)\n  schema: z\n    .string()\n    .min(1)\n    .describe('The workflow schema to use (e.g., \"spec-driven\")'),\n\n  // Optional: project context (injected into all artifact instructions)\n  // Max size: 50KB (enforced during parsing)\n  context: z\n    .string()\n    .optional()\n    .describe('Project context injected into all artifact instructions'),\n\n  // Optional: per-artifact rules (additive to schema's built-in guidance)\n  rules: z\n    .record(\n      z.string(), // artifact ID\n      z.array(z.string()) // list of rules\n    )\n    .optional()\n    .describe('Per-artifact rules, keyed by artifact ID'),\n});\n\nexport type ProjectConfig = z.infer<typeof ProjectConfigSchema>;\n\nconst MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit\n\n/**\n * Read and parse openspec/config.yaml from project root.\n * Uses resilient parsing - validates each field independently using Zod safeParse.\n * Returns null if file doesn't exist.\n * Returns partial config if some fields are invalid (with warnings).\n *\n * Performance note (Jan 2025):\n * Benchmarks showed direct file reads are fast enough without caching:\n * - Typical config (1KB): ~0.5ms per read\n * - Large config (50KB): ~1.6ms per read\n * - Missing config: ~0.01ms per read\n * Config is read 1-2 times per command (schema resolution + instruction loading),\n * adding ~1-3ms total overhead. Caching would add complexity (mtime checks,\n * invalidation logic) for negligible benefit. Direct reads also ensure config\n * changes are reflected immediately without stale cache issues.\n *\n * @param projectRoot - The root directory of the project (where `openspec/` lives)\n * @returns Parsed config or null if file doesn't exist\n */\nexport function readProjectConfig(projectRoot: string): ProjectConfig | null {\n  // Try both .yaml and .yml, prefer .yaml\n  let configPath = path.join(projectRoot, 'openspec', 'config.yaml');\n  if (!existsSync(configPath)) {\n    configPath = path.join(projectRoot, 'openspec', 'config.yml');\n    if (!existsSync(configPath)) {\n      return null; // No config is OK\n    }\n  }\n\n  try {\n    const content = readFileSync(configPath, 'utf-8');\n    const raw = parseYaml(content);\n\n    if (!raw || typeof raw !== 'object') {\n      console.warn(`openspec/config.yaml is not a valid YAML object`);\n      return null;\n    }\n\n    const config: Partial<ProjectConfig> = {};\n\n    // Parse schema field using Zod\n    const schemaField = z.string().min(1);\n    const schemaResult = schemaField.safeParse(raw.schema);\n    if (schemaResult.success) {\n      config.schema = schemaResult.data;\n    } else if (raw.schema !== undefined) {\n      console.warn(`Invalid 'schema' field in config (must be non-empty string)`);\n    }\n\n    // Parse context field with size limit\n    if (raw.context !== undefined) {\n      const contextField = z.string();\n      const contextResult = contextField.safeParse(raw.context);\n\n      if (contextResult.success) {\n        const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');\n        if (contextSize > MAX_CONTEXT_SIZE) {\n          console.warn(\n            `Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`\n          );\n          console.warn(`Ignoring context field`);\n        } else {\n          config.context = contextResult.data;\n        }\n      } else {\n        console.warn(`Invalid 'context' field in config (must be string)`);\n      }\n    }\n\n    // Parse rules field using Zod\n    if (raw.rules !== undefined) {\n      const rulesField = z.record(z.string(), z.array(z.string()));\n\n      // First check if it's an object structure (guard against null since typeof null === 'object')\n      if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {\n        const parsedRules: Record<string, string[]> = {};\n        let hasValidRules = false;\n\n        for (const [artifactId, rules] of Object.entries(raw.rules)) {\n          const rulesArrayResult = z.array(z.string()).safeParse(rules);\n\n          if (rulesArrayResult.success) {\n            // Filter out empty strings\n            const validRules = rulesArrayResult.data.filter((r) => r.length > 0);\n            if (validRules.length > 0) {\n              parsedRules[artifactId] = validRules;\n              hasValidRules = true;\n            }\n            if (validRules.length < rulesArrayResult.data.length) {\n              console.warn(\n                `Some rules for '${artifactId}' are empty strings, ignoring them`\n              );\n            }\n          } else {\n            console.warn(\n              `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`\n            );\n          }\n        }\n\n        if (hasValidRules) {\n          config.rules = parsedRules;\n        }\n      } else {\n        console.warn(`Invalid 'rules' field in config (must be object)`);\n      }\n    }\n\n    // Return partial config even if some fields failed\n    return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;\n  } catch (error) {\n    console.warn(`Failed to parse openspec/config.yaml:`, error);\n    return null;\n  }\n}\n\n/**\n * Validate artifact IDs in rules against a schema's artifacts.\n * Called during instruction loading (when schema is known).\n * Returns warnings for unknown artifact IDs.\n *\n * @param rules - The rules object from config\n * @param validArtifactIds - Set of valid artifact IDs from the schema\n * @param schemaName - Name of the schema for error messages\n * @returns Array of warning messages for unknown artifact IDs\n */\nexport function validateConfigRules(\n  rules: Record<string, string[]>,\n  validArtifactIds: Set<string>,\n  schemaName: string\n): string[] {\n  const warnings: string[] = [];\n\n  for (const artifactId of Object.keys(rules)) {\n    if (!validArtifactIds.has(artifactId)) {\n      const validIds = Array.from(validArtifactIds).sort().join(', ');\n      warnings.push(\n        `Unknown artifact ID in rules: \"${artifactId}\". ` +\n          `Valid IDs for schema \"${schemaName}\": ${validIds}`\n      );\n    }\n  }\n\n  return warnings;\n}\n\n/**\n * Suggest valid schema names when user provides invalid schema.\n * Uses fuzzy matching to find similar names.\n *\n * @param invalidSchemaName - The invalid schema name from config\n * @param availableSchemas - List of available schemas with their type (built-in or project-local)\n * @returns Error message with suggestions and available schemas\n */\nexport function suggestSchemas(\n  invalidSchemaName: string,\n  availableSchemas: { name: string; isBuiltIn: boolean }[]\n): string {\n  // Simple fuzzy match: Levenshtein distance\n  function levenshtein(a: string, b: string): number {\n    const matrix: number[][] = [];\n    for (let i = 0; i <= b.length; i++) {\n      matrix[i] = [i];\n    }\n    for (let j = 0; j <= a.length; j++) {\n      matrix[0][j] = j;\n    }\n    for (let i = 1; i <= b.length; i++) {\n      for (let j = 1; j <= a.length; j++) {\n        if (b.charAt(i - 1) === a.charAt(j - 1)) {\n          matrix[i][j] = matrix[i - 1][j - 1];\n        } else {\n          matrix[i][j] = Math.min(\n            matrix[i - 1][j - 1] + 1,\n            matrix[i][j - 1] + 1,\n            matrix[i - 1][j] + 1\n          );\n        }\n      }\n    }\n    return matrix[b.length][a.length];\n  }\n\n  // Find closest matches (distance <= 3)\n  const suggestions = availableSchemas\n    .map((s) => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))\n    .filter((s) => s.distance <= 3)\n    .sort((a, b) => a.distance - b.distance)\n    .slice(0, 3);\n\n  const builtIn = availableSchemas.filter((s) => s.isBuiltIn).map((s) => s.name);\n  const projectLocal = availableSchemas.filter((s) => !s.isBuiltIn).map((s) => s.name);\n\n  let message = `Schema '${invalidSchemaName}' not found in openspec/config.yaml\\n\\n`;\n\n  if (suggestions.length > 0) {\n    message += `Did you mean one of these?\\n`;\n    suggestions.forEach((s) => {\n      const type = s.isBuiltIn ? 'built-in' : 'project-local';\n      message += `  - ${s.name} (${type})\\n`;\n    });\n    message += '\\n';\n  }\n\n  message += `Available schemas:\\n`;\n  if (builtIn.length > 0) {\n    message += `  Built-in: ${builtIn.join(', ')}\\n`;\n  }\n  if (projectLocal.length > 0) {\n    message += `  Project-local: ${projectLocal.join(', ')}\\n`;\n  } else {\n    message += `  Project-local: (none found)\\n`;\n  }\n\n  message += `\\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;\n\n  return message;\n}\n",
        "tokens": 2294
      },
      {
        "path": "src/core/completions/completion-provider.ts",
        "content": "import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';\n\n/**\n * Cache entry for completion data\n */\ninterface CacheEntry<T> {\n  data: T;\n  timestamp: number;\n}\n\n/**\n * Provides dynamic completion suggestions for OpenSpec items (changes and specs).\n * Implements a 2-second cache to avoid excessive file system operations during\n * tab completion.\n */\nexport class CompletionProvider {\n  private readonly cacheTTL: number;\n  private changeCache: CacheEntry<string[]> | null = null;\n  private specCache: CacheEntry<string[]> | null = null;\n\n  /**\n   * Creates a new completion provider\n   *\n   * @param cacheTTLMs - Cache time-to-live in milliseconds (default: 2000ms)\n   * @param projectRoot - Project root directory (default: process.cwd())\n   */\n  constructor(\n    private readonly cacheTTLMs: number = 2000,\n    private readonly projectRoot: string = process.cwd()\n  ) {\n    this.cacheTTL = cacheTTLMs;\n  }\n\n  /**\n   * Get all active change IDs for completion\n   *\n   * @returns Array of change IDs\n   */\n  async getChangeIds(): Promise<string[]> {\n    const now = Date.now();\n\n    // Check if cache is valid\n    if (this.changeCache && now - this.changeCache.timestamp < this.cacheTTL) {\n      return this.changeCache.data;\n    }\n\n    // Fetch fresh data\n    const changeIds = await getActiveChangeIds(this.projectRoot);\n\n    // Update cache\n    this.changeCache = {\n      data: changeIds,\n      timestamp: now,\n    };\n\n    return changeIds;\n  }\n\n  /**\n   * Get all spec IDs for completion\n   *\n   * @returns Array of spec IDs\n   */\n  async getSpecIds(): Promise<string[]> {\n    const now = Date.now();\n\n    // Check if cache is valid\n    if (this.specCache && now - this.specCache.timestamp < this.cacheTTL) {\n      return this.specCache.data;\n    }\n\n    // Fetch fresh data\n    const specIds = await getSpecIds(this.projectRoot);\n\n    // Update cache\n    this.specCache = {\n      data: specIds,\n      timestamp: now,\n    };\n\n    return specIds;\n  }\n\n  /**\n   * Get both change and spec IDs for completion\n   *\n   * @returns Object with changeIds and specIds arrays\n   */\n  async getAllIds(): Promise<{ changeIds: string[]; specIds: string[] }> {\n    const [changeIds, specIds] = await Promise.all([\n      this.getChangeIds(),\n      this.getSpecIds(),\n    ]);\n\n    return { changeIds, specIds };\n  }\n\n  /**\n   * Clear all cached data\n   */\n  clearCache(): void {\n    this.changeCache = null;\n    this.specCache = null;\n  }\n\n  /**\n   * Get cache statistics for debugging\n   *\n   * @returns Cache status information\n   */\n  getCacheStats(): {\n    changeCache: { valid: boolean; age?: number };\n    specCache: { valid: boolean; age?: number };\n  } {\n    const now = Date.now();\n\n    return {\n      changeCache: {\n        valid: this.changeCache !== null && now - this.changeCache.timestamp < this.cacheTTL,\n        age: this.changeCache ? now - this.changeCache.timestamp : undefined,\n      },\n      specCache: {\n        valid: this.specCache !== null && now - this.specCache.timestamp < this.cacheTTL,\n        age: this.specCache ? now - this.specCache.timestamp : undefined,\n      },\n    };\n  }\n}\n",
        "tokens": 781
      },
      {
        "path": "src/core/config.ts",
        "content": "export const OPENSPEC_DIR_NAME = 'openspec';\n\nexport const OPENSPEC_MARKERS = {\n  start: '<!-- OPENSPEC:START -->',\n  end: '<!-- OPENSPEC:END -->'\n};\n\nexport interface OpenSpecConfig {\n  aiTools: string[];\n}\n\nexport interface AIToolOption {\n  name: string;\n  value: string;\n  available: boolean;\n  successLabel?: string;\n  skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec\n}\n\nexport const AI_TOOLS: AIToolOption[] = [\n  { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },\n  { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },\n  { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },\n  { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },\n  { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },\n  { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },\n  { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },\n  { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },\n  { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },\n  { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },\n  { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },\n  { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },\n  { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },\n  { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' },\n  { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },\n  { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },\n  { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },\n  { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },\n  { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },\n  { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },\n  { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },\n  { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' },\n  { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }\n];\n",
        "tokens": 755
      },
      {
        "path": "src/commands/workflow/schemas.ts",
        "content": "/**\n * Schemas Command\n *\n * Lists available workflow schemas with descriptions.\n */\n\nimport chalk from 'chalk';\nimport { listSchemasWithInfo } from '../../core/artifact-graph/index.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface SchemasOptions {\n  json?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Command Implementation\n// -----------------------------------------------------------------------------\n\nexport async function schemasCommand(options: SchemasOptions): Promise<void> {\n  const projectRoot = process.cwd();\n  const schemas = listSchemasWithInfo(projectRoot);\n\n  if (options.json) {\n    console.log(JSON.stringify(schemas, null, 2));\n    return;\n  }\n\n  console.log('Available schemas:');\n  console.log();\n\n  for (const schema of schemas) {\n    let sourceLabel = '';\n    if (schema.source === 'project') {\n      sourceLabel = chalk.cyan(' (project)');\n    } else if (schema.source === 'user') {\n      sourceLabel = chalk.dim(' (user override)');\n    }\n    console.log(`  ${chalk.bold(schema.name)}${sourceLabel}`);\n    console.log(`    ${schema.description}`);\n    console.log(`    Artifacts: ${schema.artifacts.join(' → ')}`);\n    console.log();\n  }\n}\n",
        "tokens": 341
      },
      {
        "path": "src/commands/feedback.ts",
        "content": "import { execSync, execFileSync } from 'child_process';\nimport { createRequire } from 'module';\nimport os from 'os';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Check if gh CLI is installed and available in PATH\n * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS\n */\nfunction isGhInstalled(): boolean {\n  try {\n    const command = process.platform === 'win32' ? 'where gh' : 'which gh';\n    execSync(command, { stdio: 'pipe' });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Check if gh CLI is authenticated\n */\nfunction isGhAuthenticated(): boolean {\n  try {\n    execSync('gh auth status', { stdio: 'pipe' });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Get OpenSpec version from package.json\n */\nfunction getVersion(): string {\n  try {\n    const { version } = require('../../package.json');\n    return version;\n  } catch {\n    return 'unknown';\n  }\n}\n\n/**\n * Get platform name\n */\nfunction getPlatform(): string {\n  return os.platform();\n}\n\n/**\n * Get current timestamp in ISO format\n */\nfunction getTimestamp(): string {\n  return new Date().toISOString();\n}\n\n/**\n * Generate metadata footer for feedback\n */\nfunction generateMetadata(): string {\n  const version = getVersion();\n  const platform = getPlatform();\n  const timestamp = getTimestamp();\n\n  return `---\nSubmitted via OpenSpec CLI\n- Version: ${version}\n- Platform: ${platform}\n- Timestamp: ${timestamp}`;\n}\n\n/**\n * Format the feedback title\n */\nfunction formatTitle(message: string): string {\n  return `Feedback: ${message}`;\n}\n\n/**\n * Format the full feedback body\n */\nfunction formatBody(bodyText?: string): string {\n  const parts: string[] = [];\n\n  if (bodyText) {\n    parts.push(bodyText);\n    parts.push(''); // Empty line before metadata\n  }\n\n  parts.push(generateMetadata());\n\n  return parts.join('\\n');\n}\n\n/**\n * Generate a pre-filled GitHub issue URL for manual submission\n */\nfunction generateManualSubmissionUrl(title: string, body: string): string {\n  const repo = 'Fission-AI/OpenSpec';\n  const encodedTitle = encodeURIComponent(title);\n  const encodedBody = encodeURIComponent(body);\n  const encodedLabels = encodeURIComponent('feedback');\n\n  return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;\n}\n\n/**\n * Display formatted feedback content for manual submission\n */\nfunction displayFormattedFeedback(title: string, body: string): void {\n  console.log('\\n--- FORMATTED FEEDBACK ---');\n  console.log(`Title: ${title}`);\n  console.log(`Labels: feedback`);\n  console.log('\\nBody:');\n  console.log(body);\n  console.log('--- END FEEDBACK ---\\n');\n}\n\n/**\n * Submit feedback via gh CLI\n * Uses execFileSync to prevent shell injection vulnerabilities\n */\nfunction submitViaGhCli(title: string, body: string): void {\n  try {\n    const result = execFileSync(\n      'gh',\n      [\n        'issue',\n        'create',\n        '--repo',\n        'Fission-AI/OpenSpec',\n        '--title',\n        title,\n        '--body',\n        body,\n        '--label',\n        'feedback',\n      ],\n      { encoding: 'utf-8', stdio: 'pipe' }\n    );\n\n    const issueUrl = result.trim();\n    console.log(`\\n✓ Feedback submitted successfully!`);\n    console.log(`Issue URL: ${issueUrl}\\n`);\n  } catch (error: any) {\n    // Display the error output from gh CLI\n    if (error.stderr) {\n      console.error(error.stderr.toString());\n    } else if (error.message) {\n      console.error(error.message);\n    }\n\n    // Exit with the same code as gh CLI\n    process.exit(error.status ?? 1);\n  }\n}\n\n/**\n * Handle fallback when gh CLI is not available or not authenticated\n */\nfunction handleFallback(title: string, body: string, reason: 'missing' | 'unauthenticated'): void {\n  if (reason === 'missing') {\n    console.log('⚠️  GitHub CLI not found. Manual submission required.');\n  } else {\n    console.log('⚠️  GitHub authentication required. Manual submission required.');\n  }\n\n  displayFormattedFeedback(title, body);\n\n  const manualUrl = generateManualSubmissionUrl(title, body);\n  console.log('Please submit your feedback manually:');\n  console.log(manualUrl);\n\n  if (reason === 'unauthenticated') {\n    console.log('\\nTo auto-submit in the future: gh auth login');\n  }\n\n  // Exit with success code (fallback is successful)\n  process.exit(0);\n}\n\n/**\n * Feedback command implementation\n */\nexport class FeedbackCommand {\n  async execute(message: string, options?: { body?: string }): Promise<void> {\n    // Format title and body once for all code paths\n    const title = formatTitle(message);\n    const body = formatBody(options?.body);\n\n    // Check if gh CLI is installed\n    if (!isGhInstalled()) {\n      handleFallback(title, body, 'missing');\n      return;\n    }\n\n    // Check if gh CLI is authenticated\n    if (!isGhAuthenticated()) {\n      handleFallback(title, body, 'unauthenticated');\n      return;\n    }\n\n    // Submit via gh CLI\n    submitViaGhCli(title, body);\n  }\n}\n",
        "tokens": 1238
      },
      {
        "path": "src/telemetry/config.ts",
        "content": "/**\n * Global configuration for telemetry state.\n * Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json\n */\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\n\nexport interface TelemetryConfig {\n  anonymousId?: string;\n  noticeSeen?: boolean;\n}\n\nexport interface GlobalConfig {\n  telemetry?: TelemetryConfig;\n  [key: string]: unknown; // Preserve other fields\n}\n\n/**\n * Get the path to the global config file.\n * Uses ~/.config/openspec/config.json on all platforms.\n */\nexport function getConfigPath(): string {\n  const configDir = path.join(os.homedir(), '.config', 'openspec');\n  return path.join(configDir, 'config.json');\n}\n\n/**\n * Read the global config file.\n * Returns an empty object if the file doesn't exist.\n */\nexport async function readConfig(): Promise<GlobalConfig> {\n  const configPath = getConfigPath();\n  try {\n    const content = await fs.readFile(configPath, 'utf-8');\n    return JSON.parse(content) as GlobalConfig;\n  } catch (error: unknown) {\n    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n      return {};\n    }\n    // If parse fails or other error, return empty config\n    return {};\n  }\n}\n\n/**\n * Write to the global config file.\n * Preserves existing fields and merges in new values.\n */\nexport async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {\n  const configPath = getConfigPath();\n  const configDir = path.dirname(configPath);\n\n  // Ensure directory exists\n  await fs.mkdir(configDir, { recursive: true });\n\n  // Read existing config and merge\n  const existing = await readConfig();\n  const merged = { ...existing, ...updates };\n\n  // Deep merge for telemetry object\n  if (updates.telemetry && existing.telemetry) {\n    merged.telemetry = { ...existing.telemetry, ...updates.telemetry };\n  }\n\n  await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\\n');\n}\n\n/**\n * Get the telemetry config section.\n */\nexport async function getTelemetryConfig(): Promise<TelemetryConfig> {\n  const config = await readConfig();\n  return config.telemetry ?? {};\n}\n\n/**\n * Update the telemetry config section.\n */\nexport async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {\n  const existing = await getTelemetryConfig();\n  await writeConfig({\n    telemetry: { ...existing, ...updates },\n  });\n}\n",
        "tokens": 588
      }
    ],
    "totalTokens": 11720
  },
  "phase3_validation": {
    "purpose": "Verification samples",
    "files": [
      {
        "path": "src/core/update.ts",
        "content": "/**\n * Update Command\n *\n * Refreshes OpenSpec skills and commands for configured tools.\n * Supports smart update detection to skip updates when already current.\n */\n\nimport path from 'path';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport { createRequire } from 'module';\nimport { FileSystemUtils } from '../utils/file-system.js';\nimport { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';\nimport {\n  generateCommands,\n  CommandAdapterRegistry,\n} from './command-generation/index.js';\nimport {\n  getConfiguredTools,\n  getAllToolVersionStatus,\n  getSkillTemplates,\n  getCommandContents,\n  generateSkillContent,\n  getToolsWithSkillsDir,\n  type ToolVersionStatus,\n} from './shared/index.js';\nimport {\n  detectLegacyArtifacts,\n  cleanupLegacyArtifacts,\n  formatCleanupSummary,\n  formatDetectionSummary,\n  getToolsFromLegacyArtifacts,\n  type LegacyDetectionResult,\n} from './legacy-cleanup.js';\nimport { isInteractive } from '../utils/interactive.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: OPENSPEC_VERSION } = require('../../package.json');\n\n/**\n * Options for the update command.\n */\nexport interface UpdateCommandOptions {\n  /** Force update even when tools are up to date */\n  force?: boolean;\n}\n\nexport class UpdateCommand {\n  private readonly force: boolean;\n\n  constructor(options: UpdateCommandOptions = {}) {\n    this.force = options.force ?? false;\n  }\n\n  async execute(projectPath: string): Promise<void> {\n    const resolvedProjectPath = path.resolve(projectPath);\n    const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);\n\n    // 1. Check openspec directory exists\n    if (!await FileSystemUtils.directoryExists(openspecPath)) {\n      throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);\n    }\n\n    // 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills\n    const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath);\n\n    // 3. Find configured tools\n    const configuredTools = getConfiguredTools(resolvedProjectPath);\n\n    if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {\n      console.log(chalk.yellow('No configured tools found.'));\n      console.log(chalk.dim('Run \"openspec init\" to set up tools.'));\n      return;\n    }\n\n    // 4. Check version status for all configured tools\n    const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION);\n\n    // 5. Smart update detection\n    const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate);\n    const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate);\n\n    if (!this.force && toolsNeedingUpdate.length === 0) {\n      // All tools are up to date\n      this.displayUpToDateMessage(toolStatuses);\n      return;\n    }\n\n    // 6. Display update plan\n    if (this.force) {\n      console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);\n    } else {\n      this.displayUpdatePlan(toolsNeedingUpdate, toolsUpToDate);\n    }\n    console.log();\n\n    // 7. Prepare templates\n    const skillTemplates = getSkillTemplates();\n    const commandContents = getCommandContents();\n\n    // 8. Update tools (all if force, otherwise only those needing update)\n    const toolsToUpdate = this.force ? configuredTools : toolsNeedingUpdate.map((s) => s.toolId);\n    const updatedTools: string[] = [];\n    const failedTools: Array<{ name: string; error: string }> = [];\n\n    for (const toolId of toolsToUpdate) {\n      const tool = AI_TOOLS.find((t) => t.value === toolId);\n      if (!tool?.skillsDir) continue;\n\n      const spinner = ora(`Updating ${tool.name}...`).start();\n\n      try {\n        const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');\n\n        // Update skill files\n        for (const { template, dirName } of skillTemplates) {\n          const skillDir = path.join(skillsDir, dirName);\n          const skillFile = path.join(skillDir, 'SKILL.md');\n\n          const skillContent = generateSkillContent(template, OPENSPEC_VERSION);\n          await FileSystemUtils.writeFile(skillFile, skillContent);\n        }\n\n        // Update commands\n        const adapter = CommandAdapterRegistry.get(tool.value);\n        if (adapter) {\n          const generatedCommands = generateCommands(commandContents, adapter);\n\n          for (const cmd of generatedCommands) {\n            const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);\n            await FileSystemUtils.writeFile(commandFile, cmd.fileContent);\n          }\n        }\n\n        spinner.succeed(`Updated ${tool.name}`);\n        updatedTools.push(tool.name);\n      } catch (error) {\n        spinner.fail(`Failed to update ${tool.name}`);\n        failedTools.push({\n          name: tool.name,\n          error: error instanceof Error ? error.message : String(error)\n        });\n      }\n    }\n\n    // 9. Summary\n    console.log();\n    if (updatedTools.length > 0) {\n      console.log(chalk.green(`✓ Updated: $",
        "tokens": 3384
      },
      {
        "path": "test/core/update.test.ts",
        "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { UpdateCommand } from '../../src/core/update.js';\nimport { FileSystemUtils } from '../../src/utils/file-system.js';\nimport { OPENSPEC_MARKERS } from '../../src/core/config.js';\nimport path from 'path';\nimport fs from 'fs/promises';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\n\ndescribe('UpdateCommand', () => {\n  let testDir: string;\n  let updateCommand: UpdateCommand;\n\n  beforeEach(async () => {\n    // Create a temporary test directory\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n\n    // Create openspec directory\n    const openspecDir = path.join(testDir, 'openspec');\n    await fs.mkdir(openspecDir, { recursive: true });\n\n    updateCommand = new UpdateCommand();\n\n    // Clear all mocks before each test\n    vi.restoreAllMocks();\n  });\n\n  afterEach(async () => {\n    // Restore all mocks after each test\n    vi.restoreAllMocks();\n\n    // Clean up test directory\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('basic validation', () => {\n    it('should throw error if openspec directory does not exist', async () => {\n      // Remove openspec directory\n      await fs.rm(path.join(testDir, 'openspec'), {\n        recursive: true,\n        force: true,\n      });\n\n      await expect(updateCommand.execute(testDir)).rejects.toThrow(\n        \"No OpenSpec directory found. Run 'openspec init' first.\"\n      );\n    });\n\n    it('should report no configured tools when none exist', async () => {\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('No configured tools found')\n      );\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('skill updates', () => {\n    it('should update skill files for configured Claude tool', async () => {\n      // Set up a configured Claude tool by creating skill directories\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      const exploreSkillDir = path.join(skillsDir, 'openspec-explore');\n      await fs.mkdir(exploreSkillDir, { recursive: true });\n\n      // Create an existing skill file\n      const oldSkillContent = `---\nname: openspec-explore (old)\ndescription: Old description\nlicense: MIT\ncompatibility: Requires openspec CLI.\nmetadata:\n  author: openspec\n  version: \"0.9\"\n---\n\nOld instructions content\n`;\n      await fs.writeFile(\n        path.join(exploreSkillDir, 'SKILL.md'),\n        oldSkillContent\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Check skill file was updated\n      const updatedSkill = await fs.readFile(\n        path.join(exploreSkillDir, 'SKILL.md'),\n        'utf-8'\n      );\n      expect(updatedSkill).toContain('name: openspec-explore');\n      expect(updatedSkill).not.toContain('Old instructions content');\n      expect(updatedSkill).toContain('license: MIT');\n\n      // Check console output\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updating 1 tool(s): claude')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should update all 9 skill files when tool is configured', async () => {\n      // Set up a configured tool with all skill directories\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      const skillNames = [\n        'openspec-explore',\n        'openspec-new-change',\n        'openspec-continue-change',\n        'openspec-apply-change',\n        'openspec-ff-change',\n        'openspec-sync-specs',\n        'openspec-archive-change',\n        'openspec-bulk-archive-change',\n        'openspec-verify-change',\n      ];\n\n      // Create at least one skill to mark tool as configured\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old content'\n      );\n\n      await updateCommand.execute(testDir);\n\n      // Verify all skill files were created/updated\n      for (const skillName of skillNames) {\n        const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n        const exists = await FileSystemUtils.fileExists(skillFile);\n        expect(exists).toBe(true);\n\n        const content = await fs.readFile(skillFile, 'utf-8');\n        expect(content).toContain('---');\n        expect(content).toContain('name:');\n        expect(content).toContain('description:');\n      }\n    });\n  });\n\n  describe('command updates', () => {\n    it('should update opsx commands for configured Claude tool', async () => {\n      // Set up a configured Claude tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explo",
        "tokens": 11044
      },
      {
        "path": "src/core/init.ts",
        "content": "/**\n * Init Command\n *\n * Sets up OpenSpec with Agent Skills and /opsx:* slash commands.\n * This is the unified setup command that replaces both the old init and experimental commands.\n */\n\nimport path from 'path';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport * as fs from 'fs';\nimport { createRequire } from 'module';\nimport { FileSystemUtils } from '../utils/file-system.js';\nimport {\n  AI_TOOLS,\n  OPENSPEC_DIR_NAME,\n  AIToolOption,\n} from './config.js';\nimport { PALETTE } from './styles/palette.js';\nimport { isInteractive } from '../utils/interactive.js';\nimport { serializeConfig } from './config-prompts.js';\nimport {\n  generateCommands,\n  CommandAdapterRegistry,\n} from './command-generation/index.js';\nimport {\n  detectLegacyArtifacts,\n  cleanupLegacyArtifacts,\n  formatCleanupSummary,\n  formatDetectionSummary,\n  type LegacyDetectionResult,\n} from './legacy-cleanup.js';\nimport {\n  SKILL_NAMES,\n  getToolsWithSkillsDir,\n  getToolSkillStatus,\n  getToolStates,\n  getSkillTemplates,\n  getCommandContents,\n  generateSkillContent,\n  type ToolSkillStatus,\n} from './shared/index.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: OPENSPEC_VERSION } = require('../../package.json');\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nconst DEFAULT_SCHEMA = 'spec-driven';\n\nconst PROGRESS_SPINNER = {\n  interval: 80,\n  frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],\n};\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\ntype InitCommandOptions = {\n  tools?: string;\n  force?: boolean;\n  interactive?: boolean;\n};\n\n// -----------------------------------------------------------------------------\n// Init Command Class\n// -----------------------------------------------------------------------------\n\nexport class InitCommand {\n  private readonly toolsArg?: string;\n  private readonly force: boolean;\n  private readonly interactiveOption?: boolean;\n\n  constructor(options: InitCommandOptions = {}) {\n    this.toolsArg = options.tools;\n    this.force = options.force ?? false;\n    this.interactiveOption = options.interactive;\n  }\n\n  async execute(targetPath: string): Promise<void> {\n    const projectPath = path.resolve(targetPath);\n    const openspecDir = OPENSPEC_DIR_NAME;\n    const openspecPath = path.join(projectPath, openspecDir);\n\n    // Validation happens silently in the background\n    const extendMode = await this.validate(projectPath, openspecPath);\n\n    // Check for legacy artifacts and handle cleanup\n    await this.handleLegacyCleanup(projectPath, extendMode);\n\n    // Show animated welcome screen (interactive mode only)\n    const canPrompt = this.canPromptInteractively();\n    if (canPrompt) {\n      const { showWelcomeScreen } = await import('../ui/welcome-screen.js');\n      await showWelcomeScreen();\n    }\n\n    // Get tool states before processing\n    const toolStates = getToolStates(projectPath);\n\n    // Get tool selection\n    const selectedToolIds = await this.getSelectedTools(toolStates, extendMode);\n\n    // Validate selected tools\n    const validatedTools = this.validateTools(selectedToolIds, toolStates);\n\n    // Create directory structure and config\n    await this.createDirectoryStructure(openspecPath, extendMode);\n\n    // Generate skills and commands for each tool\n    const results = await this.generateSkillsAndCommands(projectPath, validatedTools);\n\n    // Create config.yaml if needed\n    const configStatus = await this.createConfig(openspecPath, extendMode);\n\n    // Display success message\n    this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // VALIDATION & SETUP\n  // ═══════════════════════════════════════════════════════════\n\n  private async validate(\n    projectPath: string,\n    openspecPath: string\n  ): Promise<boolean> {\n    const extendMode = await FileSystemUtils.directoryExists(openspecPath);\n\n    // Check write permissions\n    if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {\n      throw new Error(`Insufficient permissions to write to ${projectPath}`);\n    }\n    return extendMode;\n  }\n\n  private canPromptInteractively(): boolean {\n    if (this.interactiveOption === false) return false;\n    if (this.toolsArg !== undefined) return false;\n    return isInteractive({ interactive: this.interactiveOption });\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // LEGACY CLEANUP\n  // ═══════════════════════════════════════════════════════════\n\n  private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise<void> {\n    // Detect legacy artifacts\n    const detection = await detectLegacyArtifacts(projectPath);\n\n    if (!detection.hasLegacyArtifacts) {\n      return; ",
        "tokens": 4947
      },
      {
        "path": "src/cli/index.ts",
        "content": "import { Command } from 'commander';\nimport { createRequire } from 'module';\nimport ora from 'ora';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport { AI_TOOLS } from '../core/config.js';\nimport { UpdateCommand } from '../core/update.js';\nimport { ListCommand } from '../core/list.js';\nimport { ArchiveCommand } from '../core/archive.js';\nimport { ViewCommand } from '../core/view.js';\nimport { registerSpecCommand } from '../commands/spec.js';\nimport { ChangeCommand } from '../commands/change.js';\nimport { ValidateCommand } from '../commands/validate.js';\nimport { ShowCommand } from '../commands/show.js';\nimport { CompletionCommand } from '../commands/completion.js';\nimport { FeedbackCommand } from '../commands/feedback.js';\nimport { registerConfigCommand } from '../commands/config.js';\nimport { registerSchemaCommand } from '../commands/schema.js';\nimport {\n  statusCommand,\n  instructionsCommand,\n  applyInstructionsCommand,\n  templatesCommand,\n  schemasCommand,\n  newChangeCommand,\n  DEFAULT_SCHEMA,\n  type StatusOptions,\n  type InstructionsOptions,\n  type TemplatesOptions,\n  type SchemasOptions,\n  type NewChangeOptions,\n} from '../commands/workflow/index.js';\nimport { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';\n\nconst program = new Command();\nconst require = createRequire(import.meta.url);\nconst { version } = require('../../package.json');\n\n/**\n * Get the full command path for nested commands.\n * For example: 'change show' -> 'change:show'\n */\nfunction getCommandPath(command: Command): string {\n  const names: string[] = [];\n  let current: Command | null = command;\n\n  while (current) {\n    const name = current.name();\n    // Skip the root 'openspec' command\n    if (name && name !== 'openspec') {\n      names.unshift(name);\n    }\n    current = current.parent;\n  }\n\n  return names.join(':') || 'openspec';\n}\n\nprogram\n  .name('openspec')\n  .description('AI-native system for spec-driven development')\n  .version(version);\n\n// Global options\nprogram.option('--no-color', 'Disable color output');\n\n// Apply global flags and telemetry before any command runs\n// Note: preAction receives (thisCommand, actionCommand) where:\n// - thisCommand: the command where hook was added (root program)\n// - actionCommand: the command actually being executed (subcommand)\nprogram.hook('preAction', async (thisCommand, actionCommand) => {\n  const opts = thisCommand.opts();\n  if (opts.color === false) {\n    process.env.NO_COLOR = '1';\n  }\n\n  // Show first-run telemetry notice (if not seen)\n  await maybeShowTelemetryNotice();\n\n  // Track command execution (use actionCommand to get the actual subcommand)\n  const commandPath = getCommandPath(actionCommand);\n  await trackCommand(commandPath, version);\n});\n\n// Shutdown telemetry after command completes\nprogram.hook('postAction', async () => {\n  await shutdown();\n});\n\nconst availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);\nconst toolsOptionDescription = `Configure AI tools non-interactively. Use \"all\", \"none\", or a comma-separated list of: ${availableToolIds.join(', ')}`;\n\nprogram\n  .command('init [path]')\n  .description('Initialize OpenSpec in your project')\n  .option('--tools <tools>', toolsOptionDescription)\n  .option('--force', 'Auto-cleanup legacy files without prompting')\n  .action(async (targetPath = '.', options?: { tools?: string; force?: boolean }) => {\n    try {\n      // Validate that the path is a valid directory\n      const resolvedPath = path.resolve(targetPath);\n\n      try {\n        const stats = await fs.stat(resolvedPath);\n        if (!stats.isDirectory()) {\n          throw new Error(`Path \"${targetPath}\" is not a directory`);\n        }\n      } catch (error: any) {\n        if (error.code === 'ENOENT') {\n          // Directory doesn't exist, but we can create it\n          console.log(`Directory \"${targetPath}\" doesn't exist, it will be created.`);\n        } else if (error.message && error.message.includes('not a directory')) {\n          throw error;\n        } else {\n          throw new Error(`Cannot access path \"${targetPath}\": ${error.message}`);\n        }\n      }\n\n      const { InitCommand } = await import('../core/init.js');\n      const initCommand = new InitCommand({\n        tools: options?.tools,\n        force: options?.force,\n      });\n      await initCommand.execute(targetPath);\n    } catch (error) {\n      console.log(); // Empty line for spacing\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Hidden alias: 'experimental' -> 'init' for backwards compatibility\nprogram\n  .command('experimental', { hidden: true })\n  .description('Alias for init (deprecated)')\n  .option('--tool <tool-id>', 'Target AI tool (maps to --tools)')\n  .option('--no-interactive', 'Disable interactive prompts')\n  .action(async (options?: { tool?: string; noInteractive?: boolean }) => {\n    try {\n      console.log('Note: \"openspec experimental\" is deprecated. Use \"openspe",
        "tokens": 4608
      }
    ],
    "totalTokens": 23983
  }
}