Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- CI scripts for publishing to Homebrew tap and AUR

### Plugins
- ticket-workflow 1.0.0: Workflow templates for repeatable multi-step processes (new plugin)
- ticket-edit 1.0.0: Open ticket in $EDITOR (extracted from core)
- ticket-ls 1.0.0: List tickets with optional filters (extracted from core); `ticket-list` symlink for alias
- ticket-query 1.0.0: Output tickets as JSON, optionally filtered with jq (extracted from core)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ Bundled plugins (ticket-extras):
ls|list [--status=X] [-a X] [-T X] List tickets
query [jq-filter] Output tickets as JSON, optionally filtered (requires jq)
migrate-beads Import tickets from .beads/issues.jsonl (requires jq)
workflow list List available workflow templates
workflow run <name> Instantiate workflow as tickets
--var key=value Set a variable (repeatable)
--dry-run Preview without creating tickets

Searches parent directories for .tickets/ (override with TICKETS_DIR env var)
Supports partial ID matching (e.g., 'tk show 5c4' matches 'nw-5c46')
Expand Down
3 changes: 3 additions & 0 deletions features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def before_scenario(context, scenario):
context.stderr = ''
context.returncode = None

# Isolate workflow tests from real user workflows
os.environ['TK_WORKFLOW_USER_DIR'] = os.path.join(context.test_dir, '.config', 'ticket', 'workflows')


def after_scenario(context, scenario):
"""Clean up temporary directories after each scenario."""
Expand Down
165 changes: 165 additions & 0 deletions features/steps/workflow_steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Step definitions for workflow plugin BDD tests."""

import re as _re
from pathlib import Path

from behave import given, use_step_matcher

use_step_matcher("re")


def create_workflow_file(context, name, content):
"""Helper to create a workflow TOML file in .tickets/workflows/."""
workflows_dir = Path(context.test_dir) / '.tickets' / 'workflows'
workflows_dir.mkdir(parents=True, exist_ok=True)
wf_path = workflows_dir / f'{name}.toml'
wf_path.write_text(content)
return wf_path


@given(r'a workflow file "(?P<name>[^"]+)" with description "(?P<desc>[^"]+)"')
def step_workflow_file_with_desc(context, name, desc):
"""Create a minimal workflow file with a description."""
content = f'''workflow = "{name}"
description = "{desc}"
version = 1

[[steps]]
id = "step1"
title = "Step 1"
'''
create_workflow_file(context, name, content)


@given(r'a workflow file "(?P<name>[^"]+)" with steps')
def step_workflow_file_with_steps(context, name):
"""Create a workflow file with steps from a table."""
lines = [f'workflow = "{name}"', f'description = "{name} workflow"', 'version = 1', '']

# Check for variables referenced in titles ({{var}})
var_names = set()
for row in context.table:
for m in _re.finditer(r'\{\{(\w+)\}\}', row['title']):
var_names.add(m.group(1))

for var_name in var_names:
lines.append(f'[vars.{var_name}]')
lines.append(f'description = "{var_name}"')
lines.append(f'required = true')
lines.append('')

for row in context.table:
lines.append('[[steps]]')
lines.append(f'id = "{row["id"]}"')
lines.append(f'title = "{row["title"]}"')
if row['needs'].strip():
needs = ', '.join(f'"{n.strip()}"' for n in row['needs'].split(','))
lines.append(f'needs = [{needs}]')
lines.append('')

create_workflow_file(context, name, '\n'.join(lines))


@given(r'a simple workflow file "(?P<name>[^"]+)" with (?P<count>\d+) steps')
def step_simple_workflow_file(context, name, count):
"""Create a simple workflow file with N steps, no variables."""
lines = [f'workflow = "{name}"', f'description = "{name} workflow"', 'version = 1', '']
for i in range(1, int(count) + 1):
lines.append('[[steps]]')
lines.append(f'id = "step{i}"')
lines.append(f'title = "Step {i}"')
if i > 1:
lines.append(f'needs = ["step{i-1}"]')
lines.append('')
create_workflow_file(context, name, '\n'.join(lines))


@given(r'a workflow file "(?P<name>[^"]+)" with required variable "(?P<var>[^"]+)"')
def step_workflow_with_required_var(context, name, var):
"""Create a workflow with a required variable."""
content = f'''workflow = "{name}"
description = "{name} workflow"
version = 1

[vars.{var}]
required = true

[[steps]]
id = "step1"
title = "Step with {{{{{var}}}}}"
'''
create_workflow_file(context, name, content)


@given(r'a workflow file "(?P<name>[^"]+)" with variable "(?P<var>[^"]+)" pattern "(?P<pattern>[^"]+)"')
def step_workflow_with_var_pattern(context, name, var, pattern):
"""Create a workflow with a pattern-validated variable."""
content = f'''workflow = "{name}"
description = "{name} workflow"
version = 1

[vars.{var}]
required = true
pattern = "{pattern}"

[[steps]]
id = "step1"
title = "Step with {{{{{var}}}}}"
'''
create_workflow_file(context, name, content)


@given(r'a workflow file "(?P<name>[^"]+)" with variable "(?P<var>[^"]+)" enum "(?P<vals>[^"]+)"')
def step_workflow_with_var_enum(context, name, var, vals):
"""Create a workflow with an enum-validated variable."""
enum_list = ', '.join(f'"{v.strip()}"' for v in vals.split(','))
content = f'''workflow = "{name}"
description = "{name} workflow"
version = 1

[vars.{var}]
required = true
enum = [{enum_list}]

[[steps]]
id = "step1"
title = "Step with {{{{{var}}}}}"
'''
create_workflow_file(context, name, content)


@given(r'a workflow file "(?P<name>[^"]+)" with multiline description and variable "(?P<var>[^"]+)"')
def step_workflow_with_multiline(context, name, var):
"""Create a workflow with a triple-quoted multiline description."""
content = f'''workflow = "{name}"
description = """
This is a multiline description for {{{{{var}}}}}.
It spans multiple lines.
"""
version = 1

[vars.{var}]
required = true

[[steps]]
id = "step1"
title = "Do something for {{{{{var}}}}}"
'''
create_workflow_file(context, name, content)


@given(r'a workflow file "(?P<name>[^"]+)" with variable "(?P<var>[^"]+)" default "(?P<default>[^"]+)" in step title "(?P<title>[^"]+)"')
def step_workflow_with_var_default(context, name, var, default, title):
"""Create a workflow with a variable that has a default value."""
content = f'''workflow = "{name}"
description = "{name} workflow"
version = 1

[vars.{var}]
default = "{default}"

[[steps]]
id = "step1"
title = "{title}"
'''
create_workflow_file(context, name, content)
100 changes: 100 additions & 0 deletions features/ticket_workflow.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
Feature: Workflow Templates
As a user
I want to define reusable workflow templates
So that I can create repeatable multi-step processes as tickets

Scenario: List workflows with none available
Given a clean tickets directory
When I run "ticket workflow list"
Then the command should succeed
And the output should contain "No workflows found"

Scenario: List workflows from project directory
Given a clean tickets directory
And a workflow file "release" with description "Standard release"
When I run "ticket workflow list"
Then the command should succeed
And the output should contain "release"
And the output should contain "Standard release"
And the output should contain "project"

Scenario: Workflow run with dry-run
Given a clean tickets directory
And a workflow file "release" with steps
| id | title | needs |
| bump | Bump version to {{version}} | |
| test | Run tests | bump |
| publish | Publish {{version}} | test |
When I run "ticket workflow run release --var version=1.0.0 --dry-run"
Then the command should succeed
And the output should contain "Dry run"
And the output should contain "Bump version to 1.0.0"
And the output should contain "Publish 1.0.0"

Scenario: Workflow run creates parent and child tickets
Given a clean tickets directory
And a simple workflow file "deploy" with 2 steps
When I run "ticket workflow run deploy"
Then the command should succeed
And the output should contain "Created parent:"
And the output should contain "Created step:"

Scenario: Workflow run creates dependencies between steps
Given a clean tickets directory
And a workflow file "release" with steps
| id | title | needs |
| build | Build project | |
| deploy | Deploy | build |
When I run "ticket workflow run release"
Then the command should succeed
And the output should contain "Created parent:"
And the output should contain "2 steps"

Scenario: Missing required variable fails
Given a clean tickets directory
And a workflow file "release" with required variable "version"
When I run "ticket workflow run release"
Then the command should fail
And the output should contain "missing required variables"
And the output should contain "version"

Scenario: Pattern validation rejects bad values
Given a clean tickets directory
And a workflow file "release" with variable "version" pattern "^[0-9]+\.[0-9]+\.[0-9]+$"
When I run "ticket workflow run release --var version=abc"
Then the command should fail
And the output should contain "does not match pattern"

Scenario: Enum validation rejects bad values
Given a clean tickets directory
And a workflow file "deploy" with variable "env" enum "staging,production"
When I run "ticket workflow run deploy --var env=dev"
Then the command should fail
And the output should contain "not in allowed values"

Scenario: Default variable values are used
Given a clean tickets directory
And a workflow file "deploy" with variable "env" default "staging" in step title "Deploy to {{env}}"
When I run "ticket workflow run deploy --dry-run"
Then the command should succeed
And the output should contain "Deploy to staging"

Scenario: Workflow not found
Given a clean tickets directory
When I run "ticket workflow run nonexistent"
Then the command should fail
And the output should contain "workflow 'nonexistent' not found"

Scenario: Multiline triple-quoted strings are parsed
Given a clean tickets directory
And a workflow file "tdd" with multiline description and variable "name"
When I run "ticket workflow run tdd --var name=auth --dry-run"
Then the command should succeed
And the output should contain "multiline description for auth"
And the output should contain "spans multiple lines"

Scenario: No subcommand shows usage
Given a clean tickets directory
When I run "ticket workflow"
Then the command should fail
And the output should contain "Usage"
2 changes: 2 additions & 0 deletions openspec/changes/workflow-plugin/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-07
62 changes: 62 additions & 0 deletions openspec/changes/workflow-plugin/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
## Context

`tk` is a single-file bash script with a plugin system. Plugins are executables named `ticket-<cmd>` in PATH. The existing plugins (ticket-query, ticket-ls, ticket-edit, ticket-migrate-beads) demonstrate the pattern: receive `TICKETS_DIR` and `TK_SCRIPT` env vars, call back to core via `$TK_SCRIPT super <cmd>`.

Beads provides formulas (TOML workflow templates) and molecules (instantiated workflows). We want the template functionality without the molecule tracking overhead.

## Goals / Non-Goals

**Goals:**
- TOML workflow definitions compatible with beads formula syntax (s/formula/workflow/)
- `tk workflow list` to discover available workflows from project and user directories
- `tk workflow run <name> --var key=value` to instantiate a workflow as tickets with dependencies
- Variable substitution in step titles and descriptions using `{{var}}` syntax

**Non-Goals:**
- Gates (human/timer/GitHub async coordination)
- Wisps (ephemeral operations)
- Molecule tracking (no persistent workflow instance state beyond the created tickets)
- Bond points / aspect composition
- Step hooks (on_complete actions)
- TOML library dependency — we parse a constrained subset with awk/sed

## Decisions

### 1. Single plugin file with subcommands

The plugin `ticket-workflow` handles `list` and `run` as subcommands via `$1` dispatch. This keeps the plugin self-contained.

Alternative: Separate `ticket-workflow-list` and `ticket-workflow-run` plugins. Rejected because `tk workflow list` naturally routes to `ticket-workflow` with `list` as an argument — the plugin system already handles this.

### 2. TOML parsing with awk/sed (no external deps)

We parse a constrained TOML subset sufficient for workflow definitions: top-level key-value pairs, `[vars.*]` sections, and `[[steps]]` array tables. This keeps the zero-dependency philosophy.

Alternative: Require `tomlq` or Python. Rejected to maintain the coreutils-only requirement.

### 3. Search path: project then user

Workflows are searched in order:
1. `.tickets/workflows/*.toml` (project-level, version controlled)
2. `~/.config/ticket/workflows/*.toml` (user-level, personal)

This mirrors the beads formula search path pattern. Project workflows take precedence.

### 4. Workflow instantiation creates a parent ticket + child tickets

`tk workflow run release --var version=1.0.0` creates:
- A parent ticket with the workflow name/description as title
- Child tickets for each step, with `--parent` set to the parent ID
- Dependencies between child tickets based on `needs` declarations

This maps directly to existing `tk create` and `tk dep` commands.

### 5. Variable validation at run time

Required variables without defaults cause an error if not provided via `--var`. Pattern and enum constraints are validated before any tickets are created. This prevents partial workflow instantiation.

## Risks / Trade-offs

- **[Constrained TOML parsing]** → Only supports the subset needed for workflows. Malformed TOML outside this subset may produce confusing errors. Mitigation: clear error messages for parse failures, document supported syntax.
- **[No molecule state]** → Once tickets are created, there's no record linking them back to the workflow template. Mitigation: the parent-child relationship and a tag (workflow name) on created tickets provides sufficient traceability.
- **[Step type field ignored]** → We parse `type` on steps but don't enforce gate/human semantics. All steps become regular tickets. Mitigation: document that `type` is informational only.
27 changes: 27 additions & 0 deletions openspec/changes/workflow-plugin/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Why

The beads workflow system (formulas/molecules) provides useful templated workflow creation, but beads requires SQLite sync and a background daemon. Since `tk` already replaces beads for issue tracking, it should also support workflow templates natively — allowing users to define repeatable multi-step processes (releases, feature development, etc.) and instantiate them as tickets with proper dependency graphs.

## What Changes

- New `ticket-workflow` plugin providing `tk workflow list` and `tk workflow run` commands
- Workflow definitions in TOML format (adapted from beads formula format, replacing "formula" with "workflow")
- Workflow search path: `.tickets/workflows/` (project-level) then `~/.config/ticket/workflows/` (user-level)
- `tk workflow run <name>` creates parent + child tickets with dependencies, supporting variable substitution via `--var key=value`
- No gates, wisps, or molecule tracking — just template instantiation into tickets

## Capabilities

### New Capabilities
- `workflow-templates`: TOML-based workflow definition format with variables, step types, and dependency declarations
- `workflow-commands`: CLI commands for listing available workflows and instantiating them as tickets

### Modified Capabilities

## Impact

- New plugin file: `plugins/ticket-workflow`
- New config directories: `.tickets/workflows/` and `~/.config/ticket/workflows/`
- Depends on core `tk create` and `tk dep` commands for ticket/dependency creation
- No changes to core script
- No new external dependencies beyond bash/sed/awk (TOML parsing done with awk/sed)
Loading