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 @@
-
+
-
-
-
+
+
+
-
+
Spec-driven development for AI coding assistants.
-
-
+
-
-
-
+
+# 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