From bf32fa71b744288b7aeb461339becf158ced4aed Mon Sep 17 00:00:00 2001 From: App Boy <100626910+appboypov@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:21:23 +0100 Subject: [PATCH 1/2] feat(cli): add plx command alias and rebrand as OpenSplx (OpenSplx-#1) - Add `plx` as an alias command alongside `openspec` - Create OpenSplx pixel art logo assets (light/dark themes) - Update README with fork notice and Quick Start section - Make CLI command name dynamic based on invocation - Update completion system to support both command names - Add command-name utility for detecting invoked command --- README.md | 47 ++++++++-- assets/opensplx_pixel_dark.svg | 94 +++++++++++++++++++ assets/opensplx_pixel_light.svg | 94 +++++++++++++++++++ bin/plx.js | 3 + package.json | 3 +- scripts/postinstall.js | 10 +- src/cli/index.ts | 5 +- src/commands/completion.ts | 11 ++- .../completions/generators/zsh-generator.ts | 67 ++++++------- src/core/completions/types.ts | 3 +- src/utils/command-name.ts | 16 ++++ .../generators/zsh-generator.test.ts | 2 +- 12 files changed, 298 insertions(+), 57 deletions(-) create mode 100644 assets/opensplx_pixel_dark.svg create mode 100644 assets/opensplx_pixel_light.svg create mode 100755 bin/plx.js create mode 100644 src/utils/command-name.ts diff --git a/README.md b/README.md index 7b6c7354..ec55265d 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,52 @@

- + - - - OpenSpec logo + + + OpenSplx logo - +

Spec-driven development for AI coding assistants.

- CI - npm version + Fork of OpenSpec node version License: MIT - Conventional Commits - Discord

- OpenSpec dashboard preview + OpenSplx dashboard preview

+# OpenSplx + +> **Fork Notice:** OpenSplx is a community fork of [OpenSpec](https://github.com/Fission-AI/OpenSpec). +> It adds the `plx` command alias and extended features while maintaining full compatibility +> with the original OpenSpec workflow. + +## What's Different in OpenSplx + +| Feature | OpenSpec | OpenSplx | +|---------|----------|----------| +| Command | `openspec` | `openspec` + `plx` alias | +| Install | `npm i -g @fission-ai/openspec` | Clone & `npm link` (local) | + +### Quick Start (OpenSplx) + +```bash +git clone https://github.com/appyboypov/OpenSplx.git +cd OpenSplx +pnpm install && pnpm build +npm link +plx --version # or openspec --version +``` + +--- + +
+Original OpenSpec Documentation (click to expand) +

Follow @0xTab on X for updates ยท Join the OpenSpec Discord for help and questions.

@@ -379,3 +404,5 @@ Run `openspec update` whenever someone switches tools so your agents pick up the ## License MIT + +
diff --git a/assets/opensplx_pixel_dark.svg b/assets/opensplx_pixel_dark.svg new file mode 100644 index 00000000..40d18f7e --- /dev/null +++ b/assets/opensplx_pixel_dark.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/opensplx_pixel_light.svg b/assets/opensplx_pixel_light.svg new file mode 100644 index 00000000..9f9c273f --- /dev/null +++ b/assets/opensplx_pixel_light.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bin/plx.js b/bin/plx.js new file mode 100755 index 00000000..75996ba5 --- /dev/null +++ b/bin/plx.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import '../dist/cli/index.js'; diff --git a/package.json b/package.json index 9ad409fe..c0cc11be 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ } }, "bin": { - "openspec": "./bin/openspec.js" + "openspec": "./bin/openspec.js", + "plx": "./bin/plx.js" }, "files": [ "dist", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index bfe6e123..0c26fc7c 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -72,7 +72,7 @@ async function installCompletions(shell) { // Check if shell is supported if (!CompletionFactory.isSupported(shell)) { - console.log(`\nTip: Run 'openspec completion install' for shell completions`); + console.log(`\nTip: Run 'openspec completion install' or 'plx completion install' for shell completions`); return; } @@ -99,11 +99,11 @@ async function installCompletions(shell) { } } else { // Installation failed, show tip for manual install - console.log(`\nTip: Run 'openspec completion install' for shell completions`); + console.log(`\nTip: Run 'openspec completion install' or 'plx completion install' for shell completions`); } } catch (error) { // Fail gracefully - show tip for manual install - console.log(`\nTip: Run 'openspec completion install' for shell completions`); + console.log(`\nTip: Run 'openspec completion install' or 'plx completion install' for shell completions`); } } @@ -127,7 +127,7 @@ async function main() { // Detect shell const shell = await detectShell(); if (!shell) { - console.log(`\nTip: Run 'openspec completion install' for shell completions`); + console.log(`\nTip: Run 'openspec completion install' or 'plx completion install' for shell completions`); return; } @@ -136,7 +136,7 @@ async function main() { } catch (error) { // Fail gracefully - never break npm install // Show tip for manual install - console.log(`\nTip: Run 'openspec completion install' for shell completions`); + console.log(`\nTip: Run 'openspec completion install' or 'plx completion install' for shell completions`); } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 9ae2c239..ba867dbb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,12 +14,15 @@ import { ValidateCommand } from '../commands/validate.js'; import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; +// Import command name detection utility +import { commandName } from '../utils/command-name.js'; + const program = new Command(); const require = createRequire(import.meta.url); const { version } = require('../../package.json'); program - .name('openspec') + .name(commandName) .description('AI-native system for spec-driven development') .version(version); diff --git a/src/commands/completion.ts b/src/commands/completion.ts index 56c075ed..c4d21d6e 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -4,6 +4,7 @@ import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; import { detectShell, SupportedShell } from '../utils/shell-detection.js'; import { CompletionProvider } from '../core/completions/completion-provider.js'; import { getArchivedChangeIds } from '../utils/item-discovery.js'; +import { commandName } from '../utils/command-name.js'; interface GenerateOptions { shell?: string; @@ -58,7 +59,7 @@ export class CompletionCommand { // No shell specified and cannot auto-detect console.error('Error: Could not auto-detect shell. Please specify shell explicitly.'); - console.error(`Usage: openspec completion ${operationName} [shell]`); + console.error(`Usage: ${commandName} completion ${operationName} [shell]`); console.error(`Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`); process.exitCode = 1; return null; @@ -114,7 +115,7 @@ export class CompletionCommand { */ private async generateForShell(shell: SupportedShell): Promise { const generator = CompletionFactory.createGenerator(shell); - const script = generator.generate(COMMAND_REGISTRY); + const script = generator.generate(COMMAND_REGISTRY, commandName); console.log(script); } @@ -125,11 +126,11 @@ export class CompletionCommand { const generator = CompletionFactory.createGenerator(shell); const installer = CompletionFactory.createInstaller(shell); - const spinner = ora(`Installing ${shell} completion script...`).start(); + const spinner = ora(`Installing ${shell} completion script for ${commandName}...`).start(); try { // Generate the completion script - const script = generator.generate(COMMAND_REGISTRY); + const script = generator.generate(COMMAND_REGISTRY, commandName); // Install it const result = await installer.install(script); @@ -180,7 +181,7 @@ export class CompletionCommand { if (!skipConfirmation) { const { confirm } = await import('@inquirer/prompts'); const confirmed = await confirm({ - message: 'Remove OpenSpec configuration from ~/.zshrc?', + message: `Remove ${commandName} configuration from ~/.zshrc?`, default: false, }); diff --git a/src/core/completions/generators/zsh-generator.ts b/src/core/completions/generators/zsh-generator.ts index 765fd5b4..75f39ae4 100644 --- a/src/core/completions/generators/zsh-generator.ts +++ b/src/core/completions/generators/zsh-generator.ts @@ -1,8 +1,8 @@ import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js'; /** - * Generates Zsh completion scripts for the OpenSpec CLI. - * Follows Zsh completion system conventions using the _openspec function. + * Generates Zsh completion scripts for the OpenSpec/OpenSplx CLI. + * Follows Zsh completion system conventions. */ export class ZshGenerator implements CompletionGenerator { readonly shell = 'zsh' as const; @@ -11,20 +11,21 @@ export class ZshGenerator implements CompletionGenerator { * Generate a Zsh completion script * * @param commands - Command definitions to generate completions for + * @param commandName - The CLI command name (defaults to 'openspec') * @returns Zsh completion script as a string */ - generate(commands: CommandDefinition[]): string { + generate(commands: CommandDefinition[], commandName: string = 'openspec'): string { const script: string[] = []; // Header comment - script.push('#compdef openspec'); + script.push(`#compdef ${commandName}`); script.push(''); - script.push('# Zsh completion script for OpenSpec CLI'); + script.push(`# Zsh completion script for ${commandName} CLI`); script.push('# Auto-generated - do not edit manually'); script.push(''); // Main completion function - script.push('_openspec() {'); + script.push(`_${commandName}() {`); script.push(' local context state line'); script.push(' typeset -A opt_args'); script.push(''); @@ -48,7 +49,7 @@ export class ZshGenerator implements CompletionGenerator { // Command dispatch logic script.push(' case $state in'); script.push(' command)'); - script.push(' _describe "openspec command" commands'); + script.push(` _describe "${commandName} command" commands`); script.push(' ;;'); script.push(' args)'); script.push(' case $words[1] in'); @@ -56,7 +57,7 @@ export class ZshGenerator implements CompletionGenerator { // Generate completion for each command for (const cmd of commands) { script.push(` ${cmd.name})`); - script.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`); + script.push(` _${commandName}_${this.sanitizeFunctionName(cmd.name)}`); script.push(' ;;'); } @@ -68,15 +69,15 @@ export class ZshGenerator implements CompletionGenerator { // Generate individual command completion functions for (const cmd of commands) { - script.push(...this.generateCommandFunction(cmd)); + script.push(...this.generateCommandFunction(cmd, commandName)); script.push(''); } // Add dynamic completion helper functions - script.push(...this.generateDynamicCompletionHelpers()); + script.push(...this.generateDynamicCompletionHelpers(commandName)); // Register the completion function - script.push('compdef _openspec openspec'); + script.push(`compdef _${commandName} ${commandName}`); script.push(''); return script.join('\n'); @@ -128,44 +129,44 @@ export class ZshGenerator implements CompletionGenerator { /** * Generate dynamic completion helper functions for change and spec IDs */ - private generateDynamicCompletionHelpers(): string[] { + private generateDynamicCompletionHelpers(commandName: string): string[] { const lines: string[] = []; lines.push('# Dynamic completion helpers'); lines.push(''); // Helper function for completing change IDs - lines.push('# Use openspec __complete to get available changes'); - lines.push('_openspec_complete_changes() {'); + lines.push(`# Use ${commandName} __complete to get available changes`); + lines.push(`_${commandName}_complete_changes() {`); lines.push(' local -a changes'); lines.push(' while IFS=$\'\\t\' read -r id desc; do'); lines.push(' changes+=("$id:$desc")'); - lines.push(' done < <(openspec __complete changes 2>/dev/null)'); + lines.push(` done < <(${commandName} __complete changes 2>/dev/null)`); lines.push(' _describe "change" changes'); lines.push('}'); lines.push(''); // Helper function for completing spec IDs - lines.push('# Use openspec __complete to get available specs'); - lines.push('_openspec_complete_specs() {'); + lines.push(`# Use ${commandName} __complete to get available specs`); + lines.push(`_${commandName}_complete_specs() {`); lines.push(' local -a specs'); lines.push(' while IFS=$\'\\t\' read -r id desc; do'); lines.push(' specs+=("$id:$desc")'); - lines.push(' done < <(openspec __complete specs 2>/dev/null)'); + lines.push(` done < <(${commandName} __complete specs 2>/dev/null)`); lines.push(' _describe "spec" specs'); lines.push('}'); lines.push(''); // Helper function for completing both changes and specs lines.push('# Get both changes and specs'); - lines.push('_openspec_complete_items() {'); + lines.push(`_${commandName}_complete_items() {`); lines.push(' local -a items'); lines.push(' while IFS=$\'\\t\' read -r id desc; do'); lines.push(' items+=("$id:$desc")'); - lines.push(' done < <(openspec __complete changes 2>/dev/null)'); + lines.push(` done < <(${commandName} __complete changes 2>/dev/null)`); lines.push(' while IFS=$\'\\t\' read -r id desc; do'); lines.push(' items+=("$id:$desc")'); - lines.push(' done < <(openspec __complete specs 2>/dev/null)'); + lines.push(` done < <(${commandName} __complete specs 2>/dev/null)`); lines.push(' _describe "item" items'); lines.push('}'); lines.push(''); @@ -176,8 +177,8 @@ export class ZshGenerator implements CompletionGenerator { /** * Generate completion function for a specific command */ - private generateCommandFunction(cmd: CommandDefinition): string[] { - const funcName = `_openspec_${this.sanitizeFunctionName(cmd.name)}`; + private generateCommandFunction(cmd: CommandDefinition, commandName: string): string[] { + const funcName = `_${commandName}_${this.sanitizeFunctionName(cmd.name)}`; const lines: string[] = []; lines.push(`${funcName}() {`); @@ -216,7 +217,7 @@ export class ZshGenerator implements CompletionGenerator { for (const subcmd of cmd.subcommands) { lines.push(` ${subcmd.name})`); - lines.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}_${this.sanitizeFunctionName(subcmd.name)}`); + lines.push(` _${commandName}_${this.sanitizeFunctionName(cmd.name)}_${this.sanitizeFunctionName(subcmd.name)}`); lines.push(' ;;'); } @@ -234,7 +235,7 @@ export class ZshGenerator implements CompletionGenerator { // Add positional argument completion if (cmd.acceptsPositional) { - const positionalSpec = this.generatePositionalSpec(cmd.positionalType); + const positionalSpec = this.generatePositionalSpec(cmd.positionalType, commandName); lines.push(' ' + positionalSpec); } else { // Remove trailing backslash from last flag @@ -250,7 +251,7 @@ export class ZshGenerator implements CompletionGenerator { if (cmd.subcommands) { for (const subcmd of cmd.subcommands) { lines.push(''); - lines.push(...this.generateSubcommandFunction(cmd.name, subcmd)); + lines.push(...this.generateSubcommandFunction(cmd.name, subcmd, commandName)); } } @@ -260,8 +261,8 @@ export class ZshGenerator implements CompletionGenerator { /** * Generate completion function for a subcommand */ - private generateSubcommandFunction(parentName: string, subcmd: CommandDefinition): string[] { - const funcName = `_openspec_${this.sanitizeFunctionName(parentName)}_${this.sanitizeFunctionName(subcmd.name)}`; + private generateSubcommandFunction(parentName: string, subcmd: CommandDefinition, commandName: string): string[] { + const funcName = `_${commandName}_${this.sanitizeFunctionName(parentName)}_${this.sanitizeFunctionName(subcmd.name)}`; const lines: string[] = []; lines.push(`${funcName}() {`); @@ -274,7 +275,7 @@ export class ZshGenerator implements CompletionGenerator { // Add positional argument completion if (subcmd.acceptsPositional) { - const positionalSpec = this.generatePositionalSpec(subcmd.positionalType); + const positionalSpec = this.generatePositionalSpec(subcmd.positionalType, commandName); lines.push(' ' + positionalSpec); } else { // Remove trailing backslash from last flag @@ -326,14 +327,14 @@ export class ZshGenerator implements CompletionGenerator { /** * Generate positional argument specification */ - private generatePositionalSpec(positionalType?: string): string { + private generatePositionalSpec(positionalType?: string, commandName: string = 'openspec'): string { switch (positionalType) { case 'change-id': - return "'*: :_openspec_complete_changes'"; + return `'*: :_${commandName}_complete_changes'`; case 'spec-id': - return "'*: :_openspec_complete_specs'"; + return `'*: :_${commandName}_complete_specs'`; case 'change-or-spec-id': - return "'*: :_openspec_complete_items'"; + return `'*: :_${commandName}_complete_items'`; case 'path': return "'*:path:_files'"; case 'shell': diff --git a/src/core/completions/types.ts b/src/core/completions/types.ts index fef908b6..4d426e1b 100644 --- a/src/core/completions/types.ts +++ b/src/core/completions/types.ts @@ -84,7 +84,8 @@ export interface CompletionGenerator { * Generate the completion script content * * @param commands - Command definitions to generate completions for + * @param commandName - The CLI command name (e.g., 'openspec' or 'plx') * @returns The shell-specific completion script as a string */ - generate(commands: CommandDefinition[]): string; + generate(commands: CommandDefinition[], commandName?: string): string; } diff --git a/src/utils/command-name.ts b/src/utils/command-name.ts new file mode 100644 index 00000000..150d5ef7 --- /dev/null +++ b/src/utils/command-name.ts @@ -0,0 +1,16 @@ +import path from 'path'; + +/** + * Detect the CLI command name from the invocation path. + * Returns 'plx' if invoked via plx, otherwise defaults to 'openspec'. + */ +export function getCommandName(): string { + const scriptPath = process.argv[1] || ''; + const scriptName = path.basename(scriptPath).replace(/\.js$/, ''); + return scriptName === 'plx' ? 'plx' : 'openspec'; +} + +/** + * The detected command name for the current invocation. + */ +export const commandName = getCommandName(); diff --git a/test/core/completions/generators/zsh-generator.test.ts b/test/core/completions/generators/zsh-generator.test.ts index 74bef2ac..67682122 100644 --- a/test/core/completions/generators/zsh-generator.test.ts +++ b/test/core/completions/generators/zsh-generator.test.ts @@ -32,7 +32,7 @@ describe('ZshGenerator', () => { const script = generator.generate(commands); expect(script).toContain('#compdef openspec'); - expect(script).toContain('# Zsh completion script for OpenSpec CLI'); + expect(script).toContain('# Zsh completion script for openspec CLI'); expect(script).toContain('_openspec() {'); }); From 0e9cde1d007b9e5740ca46e159f3dc046ff9f9af Mon Sep 17 00:00:00 2001 From: appboypov Date: Sun, 21 Dec 2025 22:32:27 +0100 Subject: [PATCH 2/2] feat(cli): add external issue tracking support - Add YAML frontmatter parsing for tracked issues in proposal.md - Display issue identifiers in `openspec list` output - Include trackedIssues in `openspec show --json` output - Report tracked issues when archiving changes - Add External Issue Tracking section to AGENTS.md template - Add TrackedIssue schema and type exports --- openspec/AGENTS.md | 37 +++++++ .../add-external-issue-tracking/proposal.md | 23 ++++ .../specs/cli-archive/spec.md | 33 ++++++ .../specs/cli-list/spec.md | 27 +++++ .../specs/cli-show/spec.md | 22 ++++ .../specs/docs-agent-instructions/spec.md | 29 +++++ .../add-external-issue-tracking/tasks.md | 21 ++++ src/commands/change.ts | 46 +++++--- src/core/archive.ts | 24 ++++- src/core/list.ts | 25 ++++- src/core/parsers/change-parser.ts | 13 ++- src/core/parsers/markdown-parser.ts | 101 +++++++++++++++++- src/core/schemas/change.schema.ts | 12 ++- src/core/schemas/index.ts | 2 + src/core/templates/agents-template.ts | 37 +++++++ 15 files changed, 425 insertions(+), 27 deletions(-) create mode 100644 openspec/changes/add-external-issue-tracking/proposal.md create mode 100644 openspec/changes/add-external-issue-tracking/specs/cli-archive/spec.md create mode 100644 openspec/changes/add-external-issue-tracking/specs/cli-list/spec.md create mode 100644 openspec/changes/add-external-issue-tracking/specs/cli-show/spec.md create mode 100644 openspec/changes/add-external-issue-tracking/specs/docs-agent-instructions/spec.md create mode 100644 openspec/changes/add-external-issue-tracking/tasks.md diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md index 355969d8..e12f8912 100644 --- a/openspec/AGENTS.md +++ b/openspec/AGENTS.md @@ -86,6 +86,43 @@ After deployment, create separate PR to: - Change: `openspec show --json --deltas-only` - Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` +## External Issue Tracking + +Proposals may reference external issues (Linear, GitHub, Jira, etc.). When detected, track and update them throughout the workflow. + +### Detection + +When user input contains issue references: +1. Confirm with user: "I detected [issue]. Track and post progress updates?" +2. If confirmed, fetch issue metadata using available tools +3. Store in proposal.md frontmatter: + +```yaml +--- +tracked-issues: + - tracker: linear + id: SM-123 + url: https://linear.app/team/issue/SM-123 +--- +``` + +Only store immutable references (tracker, id, url). Fetch current details from the API when needed. + +### Updates + +Post progress to tracked issues at these points: +- **Proposal created**: Comment linking to the change +- **Section completed**: Comment with progress (batch per section, not per checkbox) +- **Archive complete**: Final summary comment + +### User Confirmation + +Before any external update, present a summary and wait for confirmation. Never auto-post. + +### Tool Usage + +Use available tools to interact with issue trackers. If no tools available for a tracker, note the issue URL for manual updates. + ## Quick Start ### CLI Commands diff --git a/openspec/changes/add-external-issue-tracking/proposal.md b/openspec/changes/add-external-issue-tracking/proposal.md new file mode 100644 index 00000000..2b17eb1f --- /dev/null +++ b/openspec/changes/add-external-issue-tracking/proposal.md @@ -0,0 +1,23 @@ +# Change: add-external-issue-tracking + +## Why + +When external issues (Linear, GitHub, Jira, etc.) are provided with a proposal, OpenSpec ignores them. The CLI doesn't display issue references, and agents have no guidance on tracking or updating external issues throughout the proposal lifecycle. + +## What Changes + +- Add YAML frontmatter support to proposal.md for storing tracked issue references +- Display issue identifiers in CLI output (`openspec list`, `openspec show`, `openspec archive`) +- Add "External Issue Tracking" section to AGENTS.md with guidance for detecting, storing, and updating external issues + +## Impact + +- Affected specs: None (new capability, no existing specs) +- Affected code: + - `src/core/parsers/markdown-parser.ts` - Add YAML frontmatter extraction + - `src/core/parsers/change-parser.ts` - Include frontmatter in parsed result + - `src/core/schemas/change.schema.ts` - Add trackedIssues type + - `src/core/list.ts` - Display issue in list output + - `src/commands/change.ts` - Include issue in show/list output + - `src/core/archive.ts` - Report tracked issues on archive + - `openspec/AGENTS.md` - Add External Issue Tracking section diff --git a/openspec/changes/add-external-issue-tracking/specs/cli-archive/spec.md b/openspec/changes/add-external-issue-tracking/specs/cli-archive/spec.md new file mode 100644 index 00000000..11c0db4b --- /dev/null +++ b/openspec/changes/add-external-issue-tracking/specs/cli-archive/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Display Output + +The command SHALL provide clear feedback about delta operations and tracked issues. + +#### Scenario: Showing delta application + +- **WHEN** applying delta changes +- **THEN** display for each spec: + - Number of requirements added + - Number of requirements modified + - Number of requirements removed + - Number of requirements renamed +- **AND** use standard output symbols (+ ~ - โ†’) as defined in openspec-conventions: + ``` + Applying changes to specs/user-auth/spec.md: + + 2 added + ~ 3 modified + - 1 removed + โ†’ 1 renamed + ``` + +#### Scenario: Showing tracked issues on archive + +- **WHEN** archiving a change with tracked issues in frontmatter +- **THEN** display the tracked issue identifiers in the success message +- **AND** format as: `Archived 'change-name' (ISSUE-ID)` + +#### Scenario: Archiving change without tracked issues + +- **WHEN** archiving a change without tracked issues +- **THEN** display the standard success message without issue reference diff --git a/openspec/changes/add-external-issue-tracking/specs/cli-list/spec.md b/openspec/changes/add-external-issue-tracking/specs/cli-list/spec.md new file mode 100644 index 00000000..56492e60 --- /dev/null +++ b/openspec/changes/add-external-issue-tracking/specs/cli-list/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: Output Format +The command SHALL display items in a clear, readable table format with mode-appropriate progress or counts, including tracked issue references when available. + +#### Scenario: Displaying change list (default) +- **WHEN** displaying the list of changes +- **THEN** show a table with columns: + - Change name (directory name) + - Tracked issue identifier (if present in frontmatter, e.g., "(SM-123)") + - Task progress (e.g., "3/5 tasks" or "โœ“ Complete") + +#### Scenario: Displaying change with tracked issue +- **WHEN** a change has `tracked-issues` in proposal.md frontmatter +- **THEN** display the first issue identifier in parentheses after the change name +- **AND** format as: `change-name (ISSUE-ID) [task progress]` + +#### Scenario: Displaying change without tracked issue +- **WHEN** a change has no `tracked-issues` in proposal.md frontmatter +- **THEN** display the change without issue identifier +- **AND** format as: `change-name [task progress]` + +#### Scenario: Displaying spec list +- **WHEN** displaying the list of specs +- **THEN** show a table with columns: + - Spec id (directory name) + - Requirement count (e.g., "requirements 12") diff --git a/openspec/changes/add-external-issue-tracking/specs/cli-show/spec.md b/openspec/changes/add-external-issue-tracking/specs/cli-show/spec.md new file mode 100644 index 00000000..d9cdf672 --- /dev/null +++ b/openspec/changes/add-external-issue-tracking/specs/cli-show/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: Output format options + +The show command SHALL support various output formats consistent with existing commands, including tracked issue metadata when available. + +#### Scenario: JSON output + +- **WHEN** executing `openspec show --json` +- **THEN** output the item in JSON format +- **AND** include parsed metadata and structure +- **AND** include `trackedIssues` array if present in frontmatter +- **AND** maintain format consistency with existing change/spec show commands + +#### Scenario: Flag scoping and delegation + +- **WHEN** showing a change or a spec via the top-level command +- **THEN** accept common flags such as `--json` +- **AND** pass through type-specific flags to the corresponding implementation + - Change-only flags: `--deltas-only` (alias `--requirements-only` deprecated) + - Spec-only flags: `--requirements`, `--no-scenarios`, `-r/--requirement` +- **AND** ignore irrelevant flags for the detected type with a warning diff --git a/openspec/changes/add-external-issue-tracking/specs/docs-agent-instructions/spec.md b/openspec/changes/add-external-issue-tracking/specs/docs-agent-instructions/spec.md new file mode 100644 index 00000000..0da13962 --- /dev/null +++ b/openspec/changes/add-external-issue-tracking/specs/docs-agent-instructions/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: External Issue Tracking Guidance + +`openspec/AGENTS.md` SHALL include guidance for detecting, storing, and updating external issue references throughout the proposal lifecycle. + +#### Scenario: Providing issue detection guidance + +- **WHEN** an agent reads the External Issue Tracking section +- **THEN** find instructions for detecting issue references in user input +- **AND** find guidance on confirming tracking with the user before storing + +#### Scenario: Providing metadata storage format + +- **WHEN** an agent needs to store tracked issue references +- **THEN** find the YAML frontmatter format for proposal.md +- **AND** understand that only immutable references (tracker, id, url) are stored + +#### Scenario: Providing update workflow guidance + +- **WHEN** an agent needs to update external issues +- **THEN** find guidance on when to post updates (proposal created, section completed, archive) +- **AND** find instruction to confirm with user before any external update + +#### Scenario: Providing tool usage guidance + +- **WHEN** an agent has access to issue tracker tools +- **THEN** find guidance on using available tools to interact with trackers +- **AND** find fallback guidance for when no tools are available diff --git a/openspec/changes/add-external-issue-tracking/tasks.md b/openspec/changes/add-external-issue-tracking/tasks.md new file mode 100644 index 00000000..b46a0c47 --- /dev/null +++ b/openspec/changes/add-external-issue-tracking/tasks.md @@ -0,0 +1,21 @@ +# Tasks: add-external-issue-tracking + +## 1. Core Parsing +- [x] 1.1 Add YAML frontmatter extraction to `markdown-parser.ts` +- [x] 1.2 Update `change-parser.ts` to extract and include frontmatter in parsed result +- [x] 1.3 Add `TrackedIssue` type and update Change schema in `change.schema.ts` + +## 2. CLI Display +- [x] 2.1 Update `list.ts` to show issue identifiers next to change names +- [x] 2.2 Update `change.ts` show command to include tracked issues in output +- [x] 2.3 Update `change.ts` list command (long format) to include issues +- [x] 2.4 Update `archive.ts` to report tracked issues when archiving + +## 3. Agent Guidance +- [x] 3.1 Add "External Issue Tracking" section to AGENTS.md + +## 4. Validation +- [x] 4.1 Run typecheck +- [x] 4.2 Run lint +- [x] 4.3 Run tests +- [x] 4.4 Manual testing with proposal containing frontmatter diff --git a/src/commands/change.ts b/src/commands/change.ts index 051b4697..b7f8c8de 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -3,7 +3,7 @@ import path from 'path'; import { JsonConverter } from '../core/converters/json-converter.js'; import { Validator } from '../core/validation/validator.js'; import { ChangeParser } from '../core/parsers/change-parser.js'; -import { Change } from '../core/schemas/index.js'; +import { Change, TrackedIssue } from '../core/schemas/index.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; @@ -12,6 +12,14 @@ const ARCHIVE_DIR = 'archive'; const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; +interface ChangeListItem { + id: string; + title: string; + deltaCount: number; + taskStatus: { total: number; completed: number }; + trackedIssues?: TrackedIssue[]; +} + export class ChangeCommand { private converter: JsonConverter; @@ -70,19 +78,20 @@ export class ChangeCommand { const title = this.extractTitle(contentForTitle, changeName); const id = parsed.name; const deltas = parsed.deltas || []; + const trackedIssues = parsed.trackedIssues; - if (options.requirementsOnly || options.deltasOnly) { - const output = { id, title, deltaCount: deltas.length, deltas }; - console.log(JSON.stringify(output, null, 2)); - } else { - const output = { - id, - title, - deltaCount: deltas.length, - deltas, - }; - console.log(JSON.stringify(output, null, 2)); + const output: Record = { + id, + title, + deltaCount: deltas.length, + deltas, + }; + + if (trackedIssues && trackedIssues.length > 0) { + output.trackedIssues = trackedIssues; } + + console.log(JSON.stringify(output, null, 2)); } else { const content = await fs.readFile(proposalPath, 'utf-8'); console.log(content); @@ -122,12 +131,18 @@ export class ChangeCommand { } } - return { + const result: ChangeListItem = { id: changeName, title: this.extractTitle(content, changeName), deltaCount: change.deltas.length, taskStatus, }; + + if (change.trackedIssues && change.trackedIssues.length > 0) { + result.trackedIssues = change.trackedIssues; + } + + return result; } catch (error) { return { id: changeName, @@ -174,7 +189,10 @@ export class ChangeCommand { const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir); const change = await parser.parseChangeWithDeltas(changeName); const deltaCountText = ` [deltas ${change.deltas.length}]`; - console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`); + const issueText = change.trackedIssues && change.trackedIssues.length > 0 + ? ` (${change.trackedIssues[0].id})` + : ''; + console.log(`${changeName}${issueText}: ${title}${deltaCountText}${taskStatusText}`); } catch { console.log(`${changeName}: (unable to read)`); } diff --git a/src/core/archive.ts b/src/core/archive.ts index c9756024..59403e9d 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -10,6 +10,8 @@ import { normalizeRequirementName, type RequirementBlock, } from './parsers/requirement-blocks.js'; +import { MarkdownParser } from './parsers/markdown-parser.js'; +import type { TrackedIssue } from './schemas/index.js'; interface SpecUpdate { source: string; @@ -251,10 +253,28 @@ export class ArchiveCommand { // Create archive directory if needed await fs.mkdir(archiveDir, { recursive: true }); + // Get tracked issues before moving the change + let trackedIssues: TrackedIssue[] = []; + try { + const proposalPath = path.join(changeDir, 'proposal.md'); + const proposalContent = await fs.readFile(proposalPath, 'utf-8'); + const parser = new MarkdownParser(proposalContent); + const frontmatter = parser.getFrontmatter(); + if (frontmatter?.trackedIssues) { + trackedIssues = frontmatter.trackedIssues; + } + } catch { + // proposal.md might not exist or be unreadable + } + // Move change to archive await fs.rename(changeDir, archivePath); - - console.log(`Change '${changeName}' archived as '${archiveName}'.`); + + // Display archive success with tracked issues if present + const issueDisplay = trackedIssues.length > 0 + ? ` (${trackedIssues.map(i => i.id).join(', ')})` + : ''; + console.log(`Change '${changeName}'${issueDisplay} archived as '${archiveName}'.`); } private async selectChange(changesDir: string): Promise { diff --git a/src/core/list.ts b/src/core/list.ts index c815540a..4c87d89c 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -4,11 +4,13 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre import { readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import type { TrackedIssue } from './schemas/index.js'; interface ChangeInfo { name: string; completedTasks: number; totalTasks: number; + trackedIssue?: string; } export class ListCommand { @@ -36,13 +38,29 @@ export class ListCommand { // Collect information about each change const changes: ChangeInfo[] = []; - + for (const changeDir of changeDirs) { const progress = await getTaskProgressForChange(changesDir, changeDir); + + // Try to get tracked issue from proposal.md frontmatter + let trackedIssue: string | undefined; + try { + const proposalPath = path.join(changesDir, changeDir, 'proposal.md'); + const proposalContent = await fs.readFile(proposalPath, 'utf-8'); + const parser = new MarkdownParser(proposalContent); + const frontmatter = parser.getFrontmatter(); + if (frontmatter?.trackedIssues && frontmatter.trackedIssues.length > 0) { + trackedIssue = frontmatter.trackedIssues[0].id; + } + } catch { + // proposal.md might not exist or be unreadable + } + changes.push({ name: changeDir, completedTasks: progress.completed, - totalTasks: progress.total + totalTasks: progress.total, + trackedIssue, }); } @@ -55,8 +73,9 @@ export class ListCommand { const nameWidth = Math.max(...changes.map(c => c.name.length)); for (const change of changes) { const paddedName = change.name.padEnd(nameWidth); + const issueDisplay = change.trackedIssue ? ` (${change.trackedIssue})` : ''; const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); - console.log(`${padding}${paddedName} ${status}`); + console.log(`${padding}${paddedName}${issueDisplay} ${status}`); } return; } diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index 0c8d1e28..fb4c8bbf 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -21,30 +21,35 @@ export class ChangeParser extends MarkdownParser { const sections = this.parseSections(); const why = this.findSection(sections, 'Why')?.content || ''; const whatChanges = this.findSection(sections, 'What Changes')?.content || ''; - + if (!why) { throw new Error('Change must have a Why section'); } - + if (!whatChanges) { throw new Error('Change must have a What Changes section'); } // Parse deltas from the What Changes section (simple format) const simpleDeltas = this.parseDeltas(whatChanges); - + // Check if there are spec files with delta format const specsDir = path.join(this.changeDir, 'specs'); const deltaDeltas = await this.parseDeltaSpecs(specsDir); - + // Combine both types of deltas, preferring delta format if available const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas; + // Get tracked issues from frontmatter + const frontmatter = this.getFrontmatter(); + const trackedIssues = frontmatter?.trackedIssues; + return { name, why: why.trim(), whatChanges: whatChanges.trim(), deltas, + ...(trackedIssues && trackedIssues.length > 0 ? { trackedIssues } : {}), metadata: { version: '1.0.0', format: 'openspec-change', diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index 8bd59d1a..14264fe1 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -1,4 +1,4 @@ -import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js'; +import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation, TrackedIssue } from '../schemas/index.js'; export interface Section { level: number; @@ -7,13 +7,26 @@ export interface Section { children: Section[]; } +export interface Frontmatter { + trackedIssues?: TrackedIssue[]; + raw?: Record; +} + +export interface FrontmatterResult { + frontmatter: Frontmatter | null; + content: string; +} + export class MarkdownParser { private lines: string[]; private currentLine: number; + private frontmatter: Frontmatter | null = null; constructor(content: string) { const normalized = MarkdownParser.normalizeContent(content); - this.lines = normalized.split('\n'); + const { frontmatter, content: bodyContent } = MarkdownParser.extractFrontmatter(normalized); + this.frontmatter = frontmatter; + this.lines = bodyContent.split('\n'); this.currentLine = 0; } @@ -21,6 +34,90 @@ export class MarkdownParser { return content.replace(/\r\n?/g, '\n'); } + static extractFrontmatter(content: string): FrontmatterResult { + const lines = content.split('\n'); + + if (lines.length === 0 || lines[0].trim() !== '---') { + return { frontmatter: null, content }; + } + + let endIndex = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + endIndex = i; + break; + } + } + + if (endIndex === -1) { + return { frontmatter: null, content }; + } + + const yamlLines = lines.slice(1, endIndex); + const remainingContent = lines.slice(endIndex + 1).join('\n'); + + const frontmatter = MarkdownParser.parseSimpleYaml(yamlLines); + + return { frontmatter, content: remainingContent }; + } + + private static parseSimpleYaml(lines: string[]): Frontmatter { + const result: Frontmatter = { raw: {} }; + const trackedIssues: TrackedIssue[] = []; + + let inTrackedIssues = false; + let currentIssue: Partial | null = null; + + for (const line of lines) { + if (line.trim() === '' || line.trim().startsWith('#')) continue; + + const topLevelMatch = line.match(/^(\w[\w-]*)\s*:/); + if (topLevelMatch && !line.startsWith(' ') && !line.startsWith('\t')) { + if (topLevelMatch[1] === 'tracked-issues') { + inTrackedIssues = true; + continue; + } else { + inTrackedIssues = false; + currentIssue = null; + } + } + + if (inTrackedIssues) { + if (line.match(/^\s+-\s+\w/)) { + if (currentIssue && currentIssue.tracker && currentIssue.id && currentIssue.url) { + trackedIssues.push(currentIssue as TrackedIssue); + } + currentIssue = {}; + const inlineMatch = line.match(/^\s+-\s+(\w+)\s*:\s*(.+)$/); + if (inlineMatch) { + const key = inlineMatch[1] as keyof TrackedIssue; + currentIssue[key] = inlineMatch[2].trim().replace(/^["']|["']$/g, ''); + } + } else if (currentIssue) { + const propMatch = line.match(/^\s+(\w+)\s*:\s*(.+)$/); + if (propMatch) { + const key = propMatch[1] as keyof TrackedIssue; + currentIssue[key] = propMatch[2].trim().replace(/^["']|["']$/g, ''); + } + } + } + } + + if (currentIssue && currentIssue.tracker && currentIssue.id && currentIssue.url) { + trackedIssues.push(currentIssue as TrackedIssue); + } + + if (trackedIssues.length > 0) { + result.trackedIssues = trackedIssues; + } + + return result; + } + + getFrontmatter(): Frontmatter | null { + return this.frontmatter; + } + parseSpec(name: string): Spec { const sections = this.parseSections(); const purpose = this.findSection(sections, 'Purpose')?.content || ''; diff --git a/src/core/schemas/change.schema.ts b/src/core/schemas/change.schema.ts index 3c933bbe..ca911069 100644 --- a/src/core/schemas/change.schema.ts +++ b/src/core/schemas/change.schema.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; import { RequirementSchema } from './base.schema.js'; -import { +import { MIN_WHY_SECTION_LENGTH, MAX_WHY_SECTION_LENGTH, MAX_DELTAS_PER_CHANGE, - VALIDATION_MESSAGES + VALIDATION_MESSAGES } from '../validation/constants.js'; export const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']); @@ -21,6 +21,12 @@ export const DeltaSchema = z.object({ }).optional(), }); +export const TrackedIssueSchema = z.object({ + tracker: z.string().min(1), + id: z.string().min(1), + url: z.string().url(), +}); + export const ChangeSchema = z.object({ name: z.string().min(1, VALIDATION_MESSAGES.CHANGE_NAME_EMPTY), why: z.string() @@ -30,6 +36,7 @@ export const ChangeSchema = z.object({ deltas: z.array(DeltaSchema) .min(1, VALIDATION_MESSAGES.CHANGE_NO_DELTAS) .max(MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS), + trackedIssues: z.array(TrackedIssueSchema).optional(), metadata: z.object({ version: z.string().default('1.0.0'), format: z.literal('openspec-change'), @@ -39,4 +46,5 @@ export const ChangeSchema = z.object({ export type DeltaOperation = z.infer; export type Delta = z.infer; +export type TrackedIssue = z.infer; export type Change = z.infer; \ No newline at end of file diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index b5416c88..d7cd6bc3 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -13,8 +13,10 @@ export { export { DeltaOperationType, DeltaSchema, + TrackedIssueSchema, ChangeSchema, type DeltaOperation, type Delta, + type TrackedIssue, type Change, } from './change.schema.js'; \ No newline at end of file diff --git a/src/core/templates/agents-template.ts b/src/core/templates/agents-template.ts index ad6dbdae..4cb9a911 100644 --- a/src/core/templates/agents-template.ts +++ b/src/core/templates/agents-template.ts @@ -86,6 +86,43 @@ After deployment, create separate PR to: - Change: \`openspec show --json --deltas-only\` - Full-text search (use ripgrep): \`rg -n "Requirement:|Scenario:" openspec/specs\` +## External Issue Tracking + +Proposals may reference external issues (Linear, GitHub, Jira, etc.). When detected, track and update them throughout the workflow. + +### Detection + +When user input contains issue references: +1. Confirm with user: "I detected [issue]. Track and post progress updates?" +2. If confirmed, fetch issue metadata using available tools +3. Store in proposal.md frontmatter: + +\`\`\`yaml +--- +tracked-issues: + - tracker: linear + id: SM-123 + url: https://linear.app/team/issue/SM-123 +--- +\`\`\` + +Only store immutable references (tracker, id, url). Fetch current details from the API when needed. + +### Updates + +Post progress to tracked issues at these points: +- **Proposal created**: Comment linking to the change +- **Section completed**: Comment with progress (batch per section, not per checkbox) +- **Archive complete**: Final summary comment + +### User Confirmation + +Before any external update, present a summary and wait for confirmation. Never auto-post. + +### Tool Usage + +Use available tools to interact with issue trackers. If no tools available for a tracker, note the issue URL for manual updates. + ## Quick Start ### CLI Commands