diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index eee4332..96c9e76 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "workflows", - "version": "0.1.0", + "version": "0.2.0", "description": "Autonomous workflow orchestration for the full SDLC pipeline - from research to PR submission", "author": { "name": "Ladislav Martincik", diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index 217a4fa..c7c4dc6 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -5,6 +5,7 @@ on: branches: [main, master] pull_request: branches: [main, master] + workflow_dispatch: jobs: validate: @@ -25,6 +26,12 @@ jobs: - name: Validate plugin (versions, schema, skills, docs) run: bun run validate + - name: Type check + run: bun run typecheck + + - name: Run tests + run: bun test + - name: Validate shell scripts syntax run: | for script in scripts/*.sh; do diff --git a/.gitignore b/.gitignore index aafcb34..02588cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ .DS_Store *.log +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aac367..1ab0a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-01-25 + +### Added +- TypeScript runner infrastructure with XState state machine +- Phase commands for fresh-context execution: + - `/phase-setup` - Create worktree, initialize progress + - `/phase-plan` - Split research into implementation plans + - `/phase-impl` - Execute single implementation plan + - `/phase-submit` - Create and push PR + - `/phase-verify-ci` - Check CI status + - `/phase-fix-ci` - Fix CI failures + - `/phase-resolve-comments` - Handle reviewer feedback +- CI resolution loop with retry limits (max 5 attempts) +- Comment resolution loop with retry limits (max 10 attempts) +- CLI entry point: `bun run src/cli.ts run ` +- Unit tests for runner components (51 tests) + +### Changed +- `/workflows:build` now spawns TypeScript runner as subprocess +- Workflow continues through CI and comment resolution until PR is merge-ready + ## [0.1.0] - 2026-01-25 ### Added diff --git a/README.md b/README.md index 6ad4f99..f624318 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,30 @@ Address PR review comments autonomously. **Arguments:** - `pr-number` - GitHub PR number to resolve comments for +## Internal Phase Commands + +The TypeScript runner executes phases as separate Claude CLI subprocesses. These commands are not meant for direct user invocation but are exposed for debugging and advanced usage. + +- **/phase-setup** - Create worktree, initialize progress file +- **/phase-plan** - Split research into implementation plans +- **/phase-impl** - Execute a single implementation plan +- **/phase-submit** - Create and push the PR +- **/phase-verify-ci** - Check CI status for the PR +- **/phase-fix-ci** - Analyze and fix CI failures +- **/phase-resolve-comments** - Process and resolve PR review comments + +Each phase emits XML-style signals that the runner parses to track state transitions. + ## Workflow Phases -The build command executes four phases: +The build command executes the following phases: 1. **Setup** - Create isolated worktree, initialize progress tracking 2. **Planning** - Generate implementation plans from research 3. **Implementation** - Execute each plan sequentially 4. **Submission** - Create and push PR +5. **CI Resolution** - Monitor and fix CI failures (loops until green) +6. **Comment Resolution** - Address reviewer feedback (loops until resolved) ## Progress Tracking @@ -141,18 +157,39 @@ bun run validate ``` workflows-plugin/ ├── .claude-plugin/ -│ └── plugin.json # Plugin manifest +│ └── plugin.json # Plugin manifest +├── src/ # TypeScript runner infrastructure +│ ├── cli.ts # CLI entry point +│ ├── index.ts # Library exports +│ ├── types.ts # Type definitions +│ ├── adapters/ +│ │ └── claude-cli-adapter.ts +│ ├── runner/ +│ │ ├── workflow-runner.ts # Main orchestration loop +│ │ ├── signal-parser.ts # Parse XML signals +│ │ ├── progress-writer.ts # Progress file I/O +│ │ └── phase-mapper.ts # Map phases to commands +│ └── workflows/ +│ └── main.workflow.ts # XState machine ├── commands/ -│ └── build.md # Main workflow command +│ ├── build.md # Main workflow command +│ ├── phase-setup.md # Setup phase +│ ├── phase-plan.md # Planning phase +│ ├── phase-impl.md # Implementation phase +│ ├── phase-submit.md # PR submission phase +│ ├── phase-verify-ci.md # CI verification phase +│ ├── phase-fix-ci.md # CI fix phase +│ └── phase-resolve-comments.md ├── templates/ │ └── progress.txt.template ├── scripts/ -│ ├── workflow-ralph.sh # Main orchestrator script -│ ├── ci-ralph.sh # CI resolution loop -│ ├── comments-ralph.sh # Comment resolution loop +│ ├── workflow-ralph.sh # Shell-based orchestrator +│ ├── ci-ralph.sh +│ ├── comments-ralph.sh │ ├── validate-plugin.ts │ └── validate-versions.ts ├── package.json +├── tsconfig.json ├── README.md └── CHANGELOG.md ``` diff --git a/bun.lock b/bun.lock index 43d2944..43e36e5 100644 --- a/bun.lock +++ b/bun.lock @@ -3,8 +3,12 @@ "workspaces": { "": { "name": "workflows-plugin", + "dependencies": { + "xstate": "^5.18.0", + }, "devDependencies": { "@types/bun": "latest", + "typescript": "^5.0.0", "zod": "^3.23.8", }, }, @@ -16,8 +20,12 @@ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "xstate": ["xstate@5.25.1", "", {}, "sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], } } diff --git a/commands/build.md b/commands/build.md index 7f9ff5d..9cab730 100644 --- a/commands/build.md +++ b/commands/build.md @@ -1,303 +1,129 @@ # Build Workflow -Orchestrate the full SDLC pipeline from research file to merged PR. +Autonomous workflow orchestration from research file to merge-ready PR. ## Arguments - `$ARGUMENTS` - Path to research file (e.g., `research/my-feature.md`) -- `--continue` - Resume from last checkpoint (reads existing progress file) +- `--continue` - Resume from last checkpoint -## Progress File +## Overview -The workflow maintains state in `.workflow-progress.txt` at the worktree root. This enables: -- Resumption after interruptions -- External script coordination via phase signals -- Progress visibility +This command spawns the TypeScript workflow runner which handles all phases autonomously: -### Progress File Schema +1. **Setup** - Create worktree, initialize progress +2. **Planning** - Split research into implementation plans +3. **Implementation** - Execute each plan sequentially +4. **Submission** - Create and push PR +5. **CI Resolution** - Monitor and fix CI failures +6. **Comment Resolution** - Address reviewer feedback -``` -# Workflow Progress -# Generated: {ISO timestamp} -# Research: {research_file} -# Worktree: {worktree_path} -# Branch: {branch_name} - -## Status -current_phase: SETUP|PLANNING|IMPLEMENTATION|SUBMISSION|CI_RESOLUTION|COMMENT_RESOLUTION|COMPLETE -iteration: {number} -started_at: {ISO timestamp} -last_update: {ISO timestamp} - -## Plans -total: {number} -completed: {number} -- [x] plans/workflow-1-xxx.md (issue: #N) -- [ ] plans/workflow-2-xxx.md (issue: #N) <- CURRENT - -## PR -number: {number or null} -url: {url or null} -ci_status: pending|passing|failing -ci_attempts: {number} - -## Comments -total: {number} -resolved: {number} -pending: {number} - -## Signals -{list of emitted signals with timestamps} -``` - -## Phase Signals - -Emit signals for external script coordination: -- `SETUP_COMPLETE` - Worktree created, dependencies installed -- `PLANNING_COMPLETE` - Plans generated -- `IMPLEMENTATION_COMPLETE` - All plans implemented -- `SUBMISSION_COMPLETE` - PR created and pushed -- `WORKFLOW_COMPLETE` - Entire workflow finished - -## Workflow Phases - -### Phase 1: Setup - -**Goal**: Create isolated workspace and initialize progress tracking. - -1. **Validate input** - - Check `$ARGUMENTS` is provided (unless `--continue`) - - Verify research file exists - - Extract feature name from research file for branch naming - -2. **Create worktree** - - Invoke `/primitives:worktree {branch-name}` - - Wait for worktree setup to complete - - Capture worktree path - -3. **Initialize progress file** - - Create `.workflow-progress.txt` in worktree - - Set `current_phase: SETUP` - - Set `started_at` to current timestamp - -4. **Emit signal** - ``` - SETUP_COMPLETE - ``` - -5. **Update progress** - - Set `current_phase: PLANNING` - - Update `last_update` - -### Phase 2: Planning - -**Goal**: Generate implementation plans from research. - -1. **Invoke plan-split skill** - - Run `/workflows:plan-split {research-file}` - - This skill handles: - - Analyzing research via plan-splitter agent - - Scoring tasks by complexity - - Splitting into multiple plans (complexity ≤ 5) - - Creating GitHub issues for each plan - - Capture generated plan paths and issue numbers - - If skill fails or no plans generated, stop with error - -2. **Validate plan generation** - - Verify at least one plan was created - - Verify each plan has a GitHub issue number - - If validation fails, emit error signal: `ERROR:PLANNING:validation_failed` - -3. **Update progress file** - - Set `total` to number of plans - - List all plans with issue numbers - - Mark first plan as CURRENT - -4. **Emit signal** - ``` - PLANNING_COMPLETE - ``` - -5. **Update progress** - - Set `current_phase: IMPLEMENTATION` - - Update `last_update` - -### Phase 3: Implementation - -**Goal**: Execute each plan sequentially. - -For each plan in the plans list: - -1. **Check if already completed** - - If `[x]` in progress file, skip - - If `[ ]`, proceed - -2. **Implement plan** - - Run `/sdlc:implement {plan-path}` - - Wait for implementation to complete - -3. **Update progress** - - Mark plan as `[x]` completed - - Increment `completed` count - - Move CURRENT marker to next plan - - Update `last_update` - -4. **Emit signal** (after all plans done) - ``` - IMPLEMENTATION_COMPLETE - ``` +Each phase runs in a **separate Claude CLI subprocess** with fresh context. -5. **Update progress** - - Set `current_phase: SUBMISSION` - - Update `last_update` - -### Phase 4: Submission +## Implementation -**Goal**: Create and push PR. +### Spawn TypeScript Runner -1. **Create PR** - - Run `/github:create-pr` - - Capture PR number and URL +The workflow runner is implemented in TypeScript with XState for state management. -2. **Update progress file** - - Set `number` and `url` in PR section - - Set `ci_status: pending` +```bash +# Get plugin directory (where this command lives) +PLUGIN_DIR="$(dirname "$(dirname "$0")")" -3. **Emit signal** - ``` - SUBMISSION_COMPLETE - ``` +# Run the CLI +bun run --cwd "$PLUGIN_DIR" src/cli.ts run "$ARGUMENTS" +``` -4. **Report status** - ``` - PR created: {url} +### For Resume Mode - Next steps (handled by external scripts or future commands): - - Monitor CI status - - Address any CI failures - - Respond to review comments +```bash +bun run --cwd "$PLUGIN_DIR" src/cli.ts resume +``` - To continue after manual review: - - /workflows:build --continue - ``` +## What the Runner Does -5. **Update progress** - - Set `current_phase: COMPLETE` (for now, CI/comments are separate plans) - - Update `last_update` +The TypeScript runner (`src/runner/workflow-runner.ts`): -6. **Emit final signal** - ``` - WORKFLOW_COMPLETE - ``` +1. Creates an XState actor with the workflow state machine +2. Sends `START` event with research file path +3. Enters main loop: + - Get current phase from XState snapshot + - Map phase to slash command (e.g., `setup` → `/workflows:phase-setup`) + - Spawn fresh Claude CLI subprocess with command + - Parse output for XML signals (`SIGNAL`) + - Send event to XState actor + - Update progress file + - Repeat until terminal state -## Resume Logic (`--continue`) +4. On completion: + - Emit `COMPLETE` with PR URL + - Or `FAILED` with error -When `--continue` flag is provided: +## Progress Tracking -1. **Find progress file** - - Look for `.workflow-progress.txt` in current directory or `.worktrees/*/` - - If not found, error: "No workflow in progress. Start with: /workflows:build " +State is persisted to `.workflow-progress.txt` for: +- Visibility into current progress +- Resume capability after interruptions +- External script coordination -2. **Read current state** - - Parse progress file - - Determine `current_phase` +## Phase Commands -3. **Resume from checkpoint** - - SETUP: Re-run setup (idempotent) - - PLANNING: Continue with planning - - IMPLEMENTATION: Continue from first uncompleted plan - - SUBMISSION: Re-attempt PR creation - - COMPLETE: Report "Workflow already complete" +Each phase is a separate command for fresh context: +- `/workflows:phase-setup` - Create worktree +- `/workflows:phase-plan` - Generate plans +- `/workflows:phase-impl` - Execute single plan +- `/workflows:phase-submit` - Create PR +- `/workflows:phase-verify-ci` - Check CI status +- `/workflows:phase-fix-ci` - Fix CI failures +- `/workflows:phase-resolve-comments` - Handle review comments -4. **Continue execution** - - Pick up from the current phase - - Follow normal workflow from that point +## Output -## Error Handling +### Success -### Research File Not Found -``` -Error: Research file not found: {path} -Please provide a valid research file path. ``` +COMPLETE +PR: https://github.com/org/repo/pull/123 -### Worktree Creation Fails +Phases completed: +- Setup: ✓ +- Planning: ✓ (3 plans) +- Implementation: ✓ +- Submission: ✓ +- CI Resolution: ✓ +- Comment Resolution: ✓ + +Time elapsed: 45m 23s ``` -Error: Failed to create worktree. -Check git status and try again. + +### Failure + ``` +FAILED +CI failed after 5 attempts -### Command Invocation Fails -- Log the error -- Update progress file with error state -- Emit error signal: `ERROR:{phase}:{message}` -- Stop and report +Final phase: ci_resolution +Last signal: CI_FAILED -### Progress File Corrupt -- Attempt recovery by re-reading -- If unrecoverable, offer to start fresh +See .workflow-progress.txt for details. +``` ## Example Usage ### Start New Workflow + ``` /workflows:build research/my-feature.md ``` -### Resume After Interruption +### Resume Interrupted Workflow + ``` /workflows:build --continue ``` -## Notes - -- This command orchestrates other commands; it doesn't implement logic directly -- Each phase is designed to be resumable -- Progress file is human-readable for debugging -- Signals use XML-style tags for easy parsing by external scripts -- CI resolution and comment resolution are out of scope (Plan 2 & 3) +### With Verbose Output -## Implementation - -### Step 1: Parse Arguments - -Check if `$ARGUMENTS` contains: -- `--continue` flag → enter resume mode -- Research file path → enter new workflow mode -- Empty → error with usage instructions - -### Step 2: Execute Appropriate Mode - -**New Workflow Mode:** -1. Validate research file exists -2. Derive branch name from research file (e.g., `research/auth-system.md` → `feat/auth-system`) -3. Execute Phase 1-4 sequentially -4. Update progress file after each phase -5. Emit signals at phase transitions - -**Resume Mode:** -1. Find and parse progress file -2. Determine current phase -3. Resume execution from that phase -4. Continue through remaining phases - -### Step 3: Report Completion - -At end of workflow: ``` -Workflow Complete - -Research: {research_file} -Branch: {branch_name} -PR: {pr_url} - -Phases completed: - [x] Setup - [x] Planning ({n} plans) - [x] Implementation - [x] Submission - -Time elapsed: {duration} - -Next: Monitor CI and address review comments +/workflows:build research/my-feature.md --verbose ``` diff --git a/commands/phase-fix-ci.md b/commands/phase-fix-ci.md new file mode 100644 index 0000000..30e2db3 --- /dev/null +++ b/commands/phase-fix-ci.md @@ -0,0 +1,104 @@ +# Phase: Fix CI + +Analyze and fix CI failures. + +## Arguments + +`$ARGUMENTS` - PR number + +## Steps + +### 1. Get Failure Details + +Fetch the CI failure logs: + +```bash +gh pr checks $ARGUMENTS --json name,state,conclusion,link +``` + +For failed checks, get the run logs: + +```bash +gh run view {run_id} --log-failed +``` + +### 2. Analyze Failures + +Parse the failure logs to identify: +- Test failures: file, test name, expected vs actual +- Build errors: file, line, error message +- Lint errors: file, line, rule violated +- Type errors: file, line, type mismatch + +### 3. Invoke CI Fix Skill + +Use the specialized CI fix agent: + +``` +/github:fix-ci +``` + +This agent will: +- Analyze each failure type +- Apply appropriate fixes +- Run local verification +- Commit fixes with descriptive message + +### 4. Push Fixes + +After fixes are applied and verified locally: + +```bash +git push +``` + +### 5. Emit Signal + +``` +CI_FIX_PUSHED +``` + +## Error Handling + +If fix attempt fails: +``` +FAILED +CI fix failed: {reason} +``` + +If same error persists after fix: +- Log the pattern +- May indicate deeper issue +- Continue to next attempt (runner handles retries) + +## Output Format + +On success: +``` +CI Fix Applied + +Failures analyzed: +1. test: auth/login.test.ts - Fixed assertion +2. lint: routes.ts - Fixed unused import + +Changes: +- Fixed test assertion in auth/login.test.ts +- Removed unused import in routes.ts + +Committed: "fix: resolve CI failures in auth module" +Pushed to remote. + +CI_FIX_PUSHED +``` + +On failure: +``` +CI Fix Failed + +Unable to resolve: +- build: Missing dependency 'some-package' + (Requires manual intervention - package not in registry) + +FAILED +CI fix requires manual intervention: missing dependency +``` diff --git a/commands/phase-impl.md b/commands/phase-impl.md new file mode 100644 index 0000000..7cc681f --- /dev/null +++ b/commands/phase-impl.md @@ -0,0 +1,104 @@ +# Phase: Implementation + +Execute a single implementation plan. + +## Arguments + +`$ARGUMENTS` - Path to the plan file (e.g., `plans/workflow-1-setup-auth.md`) + +## Steps + +### 1. Read Plan File + +Read the plan file to understand: +- Tasks to implement +- Dependencies +- Acceptance criteria +- GitHub issue number + +### 2. Execute Implementation + +Invoke the sdlc implement skill: + +``` +/sdlc:implement $ARGUMENTS +``` + +This will: +- Execute the plan in TDD mode (if configured) +- Create commits for each significant change +- Run tests to verify implementation +- Link commits to the GitHub issue + +### 3. Verify Implementation + +After implementation: +- Check all tests pass +- Verify no linting errors +- Confirm changes match plan requirements + +### 4. Update Progress File + +Mark the plan as completed: + +``` +- [x] $ARGUMENTS (issue: #N) +``` + +Increment the `completed` count. + +### 5. Emit Signal + +For individual plan completion: +``` +PLAN_N_COMPLETE +``` + +After ALL plans are done: +``` +IMPLEMENTATION_COMPLETE +``` + +## Decision Logic + +- If this is the last plan → emit `IMPLEMENTATION_COMPLETE` +- If more plans remain → emit `PLAN_N_COMPLETE` where N is the plan number + +Check the progress file to determine: +``` +completed: X +total: Y +``` + +If X == Y after this plan, this is the last one. + +## Error Handling + +If implementation fails: +``` +FAILED +Implementation failed: {reason} +``` + +## Output Format + +On success (not last plan): +``` +Plan implemented successfully: $ARGUMENTS + +Changes: +- Created src/auth/login.ts +- Added tests in tests/auth/login.test.ts +- Updated routes in src/routes.ts + +PLAN_1_COMPLETE +``` + +On success (last plan): +``` +Plan implemented successfully: $ARGUMENTS + +All plans complete! +PLAN_3_COMPLETE +IMPLEMENTATION_COMPLETE +``` diff --git a/commands/phase-plan.md b/commands/phase-plan.md new file mode 100644 index 0000000..a9e4d07 --- /dev/null +++ b/commands/phase-plan.md @@ -0,0 +1,85 @@ +# Phase: Planning + +Generate implementation plans from research document. + +## Arguments + +`$ARGUMENTS` - Path to research file + +## Steps + +### 1. Read Research File + +Read the research document to understand the full scope of work. + +### 2. Invoke Plan-Split Skill + +Use the plan-splitter agent to analyze and split the research: + +``` +/workflows:plan-split $ARGUMENTS +``` + +This skill will: +- Analyze the research document +- Score each task by complexity (using the standard formula) +- Group tasks into plans (each ≤ 5 complexity) +- Generate plan files in `plans/` directory +- Create GitHub issues for each plan + +### 3. Validate Plans Generated + +After the skill completes: +- Count the number of plan files created +- Verify each plan has a GitHub issue number +- Update progress file with plan list + +### 4. Update Progress File + +Add the generated plans to the progress file: + +``` +## Plans +total: {count} +completed: 0 +- [ ] plans/workflow-1-{slug}.md (issue: #N) +- [ ] plans/workflow-2-{slug}.md (issue: #N) <- CURRENT +... +``` + +### 5. Emit Signal + +``` +PLANNING_COMPLETE +plans_count: {number} +``` + +## Error Handling + +If plan generation fails: +``` +FAILED +Planning failed: {reason} +``` + +If no plans generated (empty research): +``` +FAILED +No plans generated from research file +``` + +## Output Format + +On success: +``` +Planning complete. +Generated {N} implementation plans. + +Plans: +1. plans/workflow-1-setup-auth.md (issue: #42) +2. plans/workflow-2-implement-login.md (issue: #43) +3. plans/workflow-3-add-tests.md (issue: #44) + +PLANNING_COMPLETE +plans_count: 3 +``` diff --git a/commands/phase-resolve-comments.md b/commands/phase-resolve-comments.md new file mode 100644 index 0000000..276e70f --- /dev/null +++ b/commands/phase-resolve-comments.md @@ -0,0 +1,121 @@ +# Phase: Resolve PR Comments + +Check for and resolve all PR review comments. + +## Arguments + +`$ARGUMENTS` - PR number + +## Steps + +### 1. Fetch All Comments + +Get all comment types from the PR: + +```bash +# Review comments (inline) +gh api repos/{owner}/{repo}/pulls/{pr}/comments + +# Review summaries +gh api repos/{owner}/{repo}/pulls/{pr}/reviews + +# Issue comments (general) +gh api repos/{owner}/{repo}/issues/{pr}/comments +``` + +### 2. Filter Actionable Comments + +Exclude: +- Bot comments (author is bot) +- Own comments (author is PR author) +- Already addressed comments (resolved/outdated) + +### 3. Categorize Comments + +For each comment, determine: +- **actionable-clear**: Has specific code change request +- **actionable-unclear**: Needs clarification +- **not-actionable**: Question, praise, or FYI + +### 4. Process Each Comment + +Invoke the comment resolver skill: + +``` +/workflows:resolve-comments $ARGUMENTS +``` + +This will: +- Apply code fixes for clear actionable comments +- Post clarifying questions for unclear comments +- Post acknowledgment for informational comments + +### 5. Check for New Comments + +After processing, check if reviewers added new comments: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/comments --jq 'map(select(.updated_at > "{last_check}"))' +``` + +### 6. Determine Completion + +Comments are resolved when: +- All actionable comments have been addressed +- No new comments from reviewers in last check +- OR maximum iterations reached + +### 7. Emit Signal + +All resolved: +``` +COMMENTS_RESOLVED +``` + +Still pending (new comments or unresolved): +``` +COMMENTS_PENDING +pending_count: {number} +``` + +## Output Format + +On all resolved: +``` +Comment Resolution Complete + +Processed: +- 3 comments fixed with code changes +- 1 comment replied with explanation +- 2 comments acknowledged + +No pending comments. + +COMMENTS_RESOLVED +``` + +On pending: +``` +Comment Resolution In Progress + +Processed this iteration: +- Fixed: 2 comments +- Replied: 1 comment + +Still pending: +- 2 new comments from @reviewer since last check + +COMMENTS_PENDING +pending_count: 2 +``` + +On failure: +``` +Comment Resolution Failed + +Unable to process: +- Comment #42: Requires architectural decision + +FAILED +Comment requires manual decision: architectural change requested +``` diff --git a/commands/phase-setup.md b/commands/phase-setup.md new file mode 100644 index 0000000..b3aa68b --- /dev/null +++ b/commands/phase-setup.md @@ -0,0 +1,116 @@ +# Phase: Setup + +Create isolated workspace and initialize progress tracking. + +## Arguments + +`$ARGUMENTS` - Path to research file (e.g., `research/my-feature.md`) + +## Steps + +### 1. Validate Research File + +Check that the research file exists and is readable: + +``` +ls $ARGUMENTS +``` + +If not found, emit error and stop: +``` +ERROR:SETUP:file_not_found +Research file not found: $ARGUMENTS +``` + +### 2. Extract Feature Name + +Parse the research file path to derive a branch name: +- `research/auth-system.md` → `feat/auth-system` +- `research/fix-login-bug.md` → `feat/fix-login-bug` + +### 3. Create Worktree + +Invoke the worktree skill to create an isolated workspace: + +``` +/primitives:worktree {branch-name} +``` + +Capture the worktree path from the output. + +### 4. Copy Research File + +Copy the research file to the worktree so it's available for subsequent phases: + +```bash +cp $ARGUMENTS {worktree_path}/ +``` + +### 5. Initialize Progress File + +Create `.workflow-progress.txt` in the worktree with initial state: + +``` +# Workflow Progress +# Generated: {ISO timestamp} +# Research: $ARGUMENTS +# Worktree: {worktree_path} +# Branch: {branch} + +## Status +current_phase: SETUP +iteration: 1 +started_at: {ISO timestamp} +last_update: {ISO timestamp} + +## Plans +total: 0 +completed: 0 + +## PR +number: null +url: null +ci_status: null +ci_attempts: 0 + +## Comments +total: 0 +resolved: 0 +pending: 0 + +## Signals +``` + +### 6. Emit Signal + +Output the completion signal with metadata: + +``` +SETUP_COMPLETE +worktree_path: {path} +branch: {branch} +``` + +## Error Handling + +If any step fails: +1. Log the error +2. Emit error signal: `ERROR:SETUP:{reason}` +3. Clean up any partial state + +## Output Format + +On success: +``` +Setup complete. +SETUP_COMPLETE +worktree_path: /path/to/.worktrees/feat-my-feature +branch: feat/my-feature +``` + +On failure: +``` +Setup failed: {reason} +FAILED +{reason} +``` diff --git a/commands/phase-submit.md b/commands/phase-submit.md new file mode 100644 index 0000000..accd0a6 --- /dev/null +++ b/commands/phase-submit.md @@ -0,0 +1,102 @@ +# Phase: Submit PR + +Create and push the pull request. + +## Arguments + +None - reads context from progress file. + +## Steps + +### 1. Read Progress File + +Parse `.workflow-progress.txt` to get: +- Research file (for PR description context) +- Plans list (for PR body) +- Branch name + +### 2. Verify Ready to Submit + +Check that: +- All plans are marked complete +- No uncommitted changes +- Branch is ahead of main + +```bash +git status +git log main..HEAD --oneline +``` + +### 3. Create Pull Request + +Invoke the GitHub create-pr skill: + +``` +/github:create-pr +``` + +This will: +- Generate PR title from branch/commits +- Create comprehensive PR body with: + - Summary of changes + - List of implemented plans + - Test plan + - Links to issues +- Push to remote and create PR + +### 4. Capture PR Info + +Extract from the output: +- PR number +- PR URL + +### 5. Update Progress File + +``` +## PR +number: {pr_number} +url: {pr_url} +ci_status: pending +ci_attempts: 0 +``` + +### 6. Emit Signal + +``` +PR_CREATED +pr_number: {number} +pr_url: {url} +``` + +## Error Handling + +If PR creation fails: +``` +FAILED +PR creation failed: {reason} +``` + +If already has PR (resuming): +- Read existing PR number from progress +- Skip creation, emit signal anyway + +## Output Format + +On success: +``` +Pull request created successfully! + +PR #123: feat: implement authentication system +URL: https://github.com/org/repo/pull/123 + +Plans included: +- #42: Setup auth infrastructure +- #43: Implement login flow +- #44: Add test coverage + +Waiting for CI... + +PR_CREATED +pr_number: 123 +pr_url: https://github.com/org/repo/pull/123 +``` diff --git a/commands/phase-verify-ci.md b/commands/phase-verify-ci.md new file mode 100644 index 0000000..3eb1774 --- /dev/null +++ b/commands/phase-verify-ci.md @@ -0,0 +1,99 @@ +# Phase: Verify CI + +Check CI status for the pull request. + +## Arguments + +`$ARGUMENTS` - PR number + +## Steps + +### 1. Poll CI Status + +Use GitHub CLI to check PR checks: + +```bash +gh pr checks $ARGUMENTS --json name,state,conclusion +``` + +### 2. Evaluate Status + +Parse the check results: +- If ALL checks have `conclusion: success` → CI passed +- If ANY check has `conclusion: failure` → CI failed +- If ANY check has `state: pending` → wait and re-poll + +### 3. Wait for Pending (if needed) + +If checks are still running: +- Wait 30 seconds +- Re-poll (up to 10 times, 5 minutes total) +- If still pending after 5 minutes, report pending state + +### 4. Collect Failure Details + +If CI failed, gather: +- Which check(s) failed +- Failure logs/summary +- Relevant error messages + +```bash +gh pr checks $ARGUMENTS --json name,state,conclusion,link +``` + +### 5. Emit Signal + +On success: +``` +CI_PASSED +``` + +On failure: +``` +CI_FAILED +ci_failure_reason: {check_name}: {summary} +``` + +## Output Format + +On CI passing: +``` +CI Status: All checks passed ✓ + +Checks: +- build: success +- test: success +- lint: success + +CI_PASSED +``` + +On CI failing: +``` +CI Status: Failed + +Checks: +- build: success +- test: failure ← 3 tests failed +- lint: success + +Failed Check Details: +- test: src/auth/login.test.ts - expected 200, got 401 + +CI_FAILED +ci_failure_reason: test: 3 tests failed in auth module +``` + +On CI pending (timeout): +``` +CI Status: Still pending after 5 minutes + +Checks: +- build: success +- test: pending +- lint: pending + +Will retry in next iteration. +CI_FAILED +ci_failure_reason: Checks still pending after timeout +``` diff --git a/package.json b/package.json index b91a5ef..084f3a1 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,24 @@ { "name": "workflows-plugin", - "version": "0.1.0", + "version": "0.2.0", "description": "Autonomous workflow orchestration for the full SDLC pipeline", "private": true, "type": "module", "scripts": { "validate": "bun run scripts/validate-plugin.ts", - "validate:versions": "bun run scripts/validate-versions.ts" + "validate:versions": "bun run scripts/validate-versions.ts", + "cli": "bun run src/cli.ts", + "build": "bun build src/index.ts --outdir dist --target bun", + "typecheck": "tsc --noEmit", + "test": "bun test", + "test:watch": "bun test --watch" + }, + "dependencies": { + "xstate": "^5.18.0" }, "devDependencies": { "@types/bun": "latest", + "typescript": "^5.0.0", "zod": "^3.23.8" } } diff --git a/src/adapters/claude-cli-adapter.ts b/src/adapters/claude-cli-adapter.ts new file mode 100644 index 0000000..e5f601f --- /dev/null +++ b/src/adapters/claude-cli-adapter.ts @@ -0,0 +1,95 @@ +/** + * Adapter for spawning Claude CLI subprocesses + */ + +import { spawn } from 'node:child_process'; +import type { ClaudeRunOptions, ClaudeRunResult } from '../types'; + +const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes + +export class ClaudeCLIAdapter { + private claudePath: string; + + constructor(claudePath: string = 'claude') { + this.claudePath = claudePath; + } + + /** + * Run a prompt through Claude CLI as a subprocess + * Each invocation starts with fresh context + */ + async runPrompt(options: ClaudeRunOptions): Promise { + const { prompt, workingDirectory, timeout = DEFAULT_TIMEOUT } = options; + + return new Promise((resolve, reject) => { + const args = ['-p', prompt, '--output-format', 'text']; + + const child = spawn(this.claudePath, args, { + cwd: workingDirectory ?? process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + // Disable interactive features + CI: 'true', + }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + const timeoutId = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`Claude CLI timed out after ${timeout}ms`)); + }, timeout); + + child.on('close', (code) => { + clearTimeout(timeoutId); + resolve({ + content: stdout, + exitCode: code ?? 0, + }); + }); + + child.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + }); + } + + /** + * Run a slash command through Claude CLI + */ + async runCommand( + command: string, + options: Omit = {} + ): Promise { + return this.runPrompt({ + ...options, + prompt: command, + }); + } + + /** + * Check if Claude CLI is available + */ + async isAvailable(): Promise { + try { + const result = await this.runPrompt({ + prompt: 'echo "test"', + timeout: 5000, + }); + return result.exitCode === 0; + } catch { + return false; + } + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..5f2c7ff --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env bun +/** + * CLI entry point for the workflow runner + * + * Usage: + * bun run src/cli.ts run + * bun run src/cli.ts resume + */ + +import { WorkflowRunner } from './runner/workflow-runner'; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + + if (!command) { + printUsage(); + process.exit(1); + } + + const runner = new WorkflowRunner({ + verbose: args.includes('--verbose') || args.includes('-v'), + }); + + switch (command) { + case 'run': { + const researchFile = args.find((a) => !a.startsWith('-')); + if (!researchFile) { + console.error('Error: Research file path required'); + console.error('Usage: cli.ts run '); + process.exit(1); + } + + const result = await runner.run(researchFile); + outputResult(result); + process.exit(result.success ? 0 : 1); + break; + } + + case 'resume': { + try { + const result = await runner.resume(); + outputResult(result); + process.exit(result.success ? 0 : 1); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error(`Error: ${message}`); + process.exit(1); + } + break; + } + + case 'help': + case '--help': + case '-h': + printUsage(); + break; + + default: + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); + } +} + +function printUsage(): void { + console.log(` +Workflow Runner CLI + +Commands: + run Start a new workflow from research file + resume Resume an existing workflow from progress file + help Show this help message + +Options: + --verbose, -v Enable verbose output + +Examples: + bun run src/cli.ts run research/my-feature.md + bun run src/cli.ts run research/auth-system.md --verbose + bun run src/cli.ts resume +`); +} + +interface WorkflowResult { + success: boolean; + context: { + prUrl: string | null; + error: string | null; + signals: Array<{ signal: string; timestamp: string }>; + }; + finalPhase: string; +} + +function outputResult(result: WorkflowResult): void { + console.log(''); + console.log('═'.repeat(50)); + + if (result.success) { + console.log('COMPLETE'); + console.log(`final_phase: ${result.finalPhase}`); + if (result.context.prUrl) { + console.log(`pr_url: ${result.context.prUrl}`); + } + } else { + console.log('FAILED'); + console.log(`final_phase: ${result.finalPhase}`); + if (result.context.error) { + console.log(`${result.context.error}`); + } + } + + console.log('═'.repeat(50)); + + // Summary of signals + console.log('\nSignal History:'); + for (const signal of result.context.signals) { + console.log(` ${signal.timestamp}: ${signal.signal}`); + } +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f51f346 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,42 @@ +/** + * workflows-plugin - TypeScript runner infrastructure + * + * Main exports for programmatic usage + */ + +// Core types +export type { + WorkflowPhase, + WorkflowSignal, + WorkflowContext, + WorkflowEvent, + WorkflowResult, + PlanInfo, + SignalRecord, + ClaudeRunOptions, + ClaudeRunResult, + ProgressFileData, +} from './types'; + +// Runner +export { WorkflowRunner } from './runner/workflow-runner'; +export { parseSignals, parseAllSignals, extractSignalData } from './runner/signal-parser'; +export { ProgressWriter } from './runner/progress-writer'; +export { + mapPhaseToCommand, + formatCommand, + getPhaseName, + isTerminalPhase, + isSuccessPhase, +} from './runner/phase-mapper'; + +// Workflow machine +export { + workflowMachine, + getCurrentPhase, + isTerminal, + isSuccess, +} from './workflows/main.workflow'; + +// Adapters +export { ClaudeCLIAdapter } from './adapters/claude-cli-adapter'; diff --git a/src/runner/phase-mapper.test.ts b/src/runner/phase-mapper.test.ts new file mode 100644 index 0000000..d25a63d --- /dev/null +++ b/src/runner/phase-mapper.test.ts @@ -0,0 +1,210 @@ +/** + * Unit tests for phase-mapper.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { + mapPhaseToCommand, + formatCommand, + getPhaseName, + isTerminalPhase, + isSuccessPhase, +} from './phase-mapper'; +import type { WorkflowContext } from '../types'; + +describe('mapPhaseToCommand', () => { + const createContext = (overrides: Partial = {}): WorkflowContext => ({ + researchFile: 'research/test.md', + worktreePath: '/path/to/worktree', + branch: 'feat/test', + plans: [], + currentPlanIndex: 0, + prNumber: null, + prUrl: null, + ciAttempts: 0, + commentAttempts: 0, + error: null, + startedAt: '2024-01-01T00:00:00.000Z', + lastUpdate: '2024-01-01T00:00:00.000Z', + signals: [], + ...overrides, + }); + + test('maps setup phase to phase-setup command', () => { + const context = createContext(); + const result = mapPhaseToCommand('setup', context); + expect(result).toEqual({ + command: '/workflows:phase-setup', + args: ['research/test.md'], + }); + }); + + test('maps planning phase to phase-plan command', () => { + const context = createContext(); + const result = mapPhaseToCommand('planning', context); + expect(result).toEqual({ + command: '/workflows:phase-plan', + args: ['research/test.md'], + }); + }); + + test('maps implementing phase to phase-impl command with current plan', () => { + const context = createContext({ + plans: [ + { path: 'plans/workflow-1.md', issueNumber: 42, completed: false }, + { path: 'plans/workflow-2.md', issueNumber: 43, completed: false }, + ], + currentPlanIndex: 0, + }); + const result = mapPhaseToCommand('implementing', context); + expect(result).toEqual({ + command: '/workflows:phase-impl', + args: ['plans/workflow-1.md'], + }); + }); + + test('maps implementing phase with second plan', () => { + const context = createContext({ + plans: [ + { path: 'plans/workflow-1.md', issueNumber: 42, completed: true }, + { path: 'plans/workflow-2.md', issueNumber: 43, completed: false }, + ], + currentPlanIndex: 1, + }); + const result = mapPhaseToCommand('implementing', context); + expect(result).toEqual({ + command: '/workflows:phase-impl', + args: ['plans/workflow-2.md'], + }); + }); + + test('returns null for implementing with no plans', () => { + const context = createContext({ plans: [], currentPlanIndex: 0 }); + const result = mapPhaseToCommand('implementing', context); + expect(result).toBeNull(); + }); + + test('maps submitting phase to phase-submit command', () => { + const context = createContext(); + const result = mapPhaseToCommand('submitting', context); + expect(result).toEqual({ + command: '/workflows:phase-submit', + args: [], + }); + }); + + test('maps ci_resolution phase to phase-verify-ci command', () => { + const context = createContext({ prNumber: 123 }); + const result = mapPhaseToCommand('ci_resolution', context); + expect(result).toEqual({ + command: '/workflows:phase-verify-ci', + args: ['123'], + }); + }); + + test('maps ci_fixing phase to phase-fix-ci command', () => { + const context = createContext({ prNumber: 456 }); + const result = mapPhaseToCommand('ci_fixing', context); + expect(result).toEqual({ + command: '/workflows:phase-fix-ci', + args: ['456'], + }); + }); + + test('maps comment_resolution phase to phase-resolve-comments command', () => { + const context = createContext({ prNumber: 789 }); + const result = mapPhaseToCommand('comment_resolution', context); + expect(result).toEqual({ + command: '/workflows:phase-resolve-comments', + args: ['789'], + }); + }); + + test('returns null for idle phase', () => { + const context = createContext(); + const result = mapPhaseToCommand('idle', context); + expect(result).toBeNull(); + }); + + test('returns null for completed phase', () => { + const context = createContext(); + const result = mapPhaseToCommand('completed', context); + expect(result).toBeNull(); + }); + + test('returns null for failed phase', () => { + const context = createContext(); + const result = mapPhaseToCommand('failed', context); + expect(result).toBeNull(); + }); +}); + +describe('formatCommand', () => { + test('formats command with no args', () => { + const result = formatCommand({ command: '/workflows:phase-submit', args: [] }); + expect(result).toBe('/workflows:phase-submit'); + }); + + test('formats command with single arg', () => { + const result = formatCommand({ + command: '/workflows:phase-setup', + args: ['research/test.md'], + }); + expect(result).toBe('/workflows:phase-setup research/test.md'); + }); + + test('formats command with multiple args', () => { + const result = formatCommand({ + command: '/some:command', + args: ['arg1', 'arg2', 'arg3'], + }); + expect(result).toBe('/some:command arg1 arg2 arg3'); + }); +}); + +describe('getPhaseName', () => { + test('returns human-readable names', () => { + expect(getPhaseName('idle')).toBe('Idle'); + expect(getPhaseName('setup')).toBe('Setup'); + expect(getPhaseName('planning')).toBe('Planning'); + expect(getPhaseName('implementing')).toBe('Implementing'); + expect(getPhaseName('submitting')).toBe('Submitting PR'); + expect(getPhaseName('ci_resolution')).toBe('Verifying CI'); + expect(getPhaseName('ci_fixing')).toBe('Fixing CI'); + expect(getPhaseName('comment_resolution')).toBe('Resolving Comments'); + expect(getPhaseName('completed')).toBe('Completed'); + expect(getPhaseName('failed')).toBe('Failed'); + }); +}); + +describe('isTerminalPhase', () => { + test('returns true for completed', () => { + expect(isTerminalPhase('completed')).toBe(true); + }); + + test('returns true for failed', () => { + expect(isTerminalPhase('failed')).toBe(true); + }); + + test('returns false for other phases', () => { + expect(isTerminalPhase('idle')).toBe(false); + expect(isTerminalPhase('setup')).toBe(false); + expect(isTerminalPhase('implementing')).toBe(false); + expect(isTerminalPhase('ci_resolution')).toBe(false); + }); +}); + +describe('isSuccessPhase', () => { + test('returns true for completed', () => { + expect(isSuccessPhase('completed')).toBe(true); + }); + + test('returns false for failed', () => { + expect(isSuccessPhase('failed')).toBe(false); + }); + + test('returns false for other phases', () => { + expect(isSuccessPhase('idle')).toBe(false); + expect(isSuccessPhase('implementing')).toBe(false); + }); +}); diff --git a/src/runner/phase-mapper.ts b/src/runner/phase-mapper.ts new file mode 100644 index 0000000..a01b5db --- /dev/null +++ b/src/runner/phase-mapper.ts @@ -0,0 +1,127 @@ +/** + * Map XState phases to slash commands + */ + +import type { WorkflowPhase, WorkflowContext } from '../types'; + +interface PhaseCommand { + command: string; + args: string[]; +} + +/** + * Map current workflow phase to the appropriate slash command + */ +export function mapPhaseToCommand( + phase: WorkflowPhase, + context: WorkflowContext +): PhaseCommand | null { + switch (phase) { + case 'setup': + return { + command: '/workflows:phase-setup', + args: [context.researchFile], + }; + + case 'planning': + return { + command: '/workflows:phase-plan', + args: [context.researchFile], + }; + + case 'implementing': { + const currentPlan = context.plans[context.currentPlanIndex]; + if (!currentPlan) { + return null; + } + return { + command: '/workflows:phase-impl', + args: [currentPlan.path], + }; + } + + case 'submitting': + return { + command: '/workflows:phase-submit', + args: [], + }; + + case 'ci_resolution': + return { + command: '/workflows:phase-verify-ci', + args: context.prNumber ? [String(context.prNumber)] : [], + }; + + case 'ci_fixing': + return { + command: '/workflows:phase-fix-ci', + args: context.prNumber ? [String(context.prNumber)] : [], + }; + + case 'comment_resolution': + return { + command: '/workflows:phase-resolve-comments', + args: context.prNumber ? [String(context.prNumber)] : [], + }; + + case 'comment_resolving': + // Same command as resolution - it handles the fixing + return { + command: '/workflows:phase-resolve-comments', + args: context.prNumber ? [String(context.prNumber)] : [], + }; + + case 'idle': + case 'completed': + case 'failed': + return null; + + default: + return null; + } +} + +/** + * Format command with arguments for Claude CLI + */ +export function formatCommand(phaseCommand: PhaseCommand): string { + const { command, args } = phaseCommand; + if (args.length === 0) { + return command; + } + return `${command} ${args.join(' ')}`; +} + +/** + * Get human-readable phase name + */ +export function getPhaseName(phase: WorkflowPhase): string { + const names: Record = { + idle: 'Idle', + setup: 'Setup', + planning: 'Planning', + implementing: 'Implementing', + submitting: 'Submitting PR', + ci_resolution: 'Verifying CI', + ci_fixing: 'Fixing CI', + comment_resolution: 'Resolving Comments', + comment_resolving: 'Applying Comment Fixes', + completed: 'Completed', + failed: 'Failed', + }; + return names[phase] ?? phase; +} + +/** + * Check if phase is a terminal state + */ +export function isTerminalPhase(phase: WorkflowPhase): boolean { + return phase === 'completed' || phase === 'failed'; +} + +/** + * Check if phase completed successfully + */ +export function isSuccessPhase(phase: WorkflowPhase): boolean { + return phase === 'completed'; +} diff --git a/src/runner/progress-writer.test.ts b/src/runner/progress-writer.test.ts new file mode 100644 index 0000000..77dee60 --- /dev/null +++ b/src/runner/progress-writer.test.ts @@ -0,0 +1,159 @@ +/** + * Unit tests for progress-writer.ts + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { ProgressWriter } from './progress-writer'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { WorkflowContext, WorkflowPhase } from '../types'; + +describe('ProgressWriter', () => { + let testDir: string; + let writer: ProgressWriter; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'workflow-test-')); + writer = new ProgressWriter(testDir); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + const createContext = (overrides: Partial = {}): WorkflowContext => ({ + researchFile: 'research/test.md', + worktreePath: '/path/to/worktree', + branch: 'feat/test', + plans: [], + currentPlanIndex: 0, + prNumber: null, + prUrl: null, + ciAttempts: 0, + commentAttempts: 0, + error: null, + startedAt: '2024-01-01T00:00:00.000Z', + lastUpdate: '2024-01-01T00:00:00.000Z', + signals: [], + ...overrides, + }); + + test('writes progress file', async () => { + const context = createContext(); + await writer.write(context, 'setup', 1); + + const content = await readFile(join(testDir, '.workflow-progress.txt'), 'utf-8'); + expect(content).toContain('# Workflow Progress'); + expect(content).toContain('Research: research/test.md'); + expect(content).toContain('Worktree: /path/to/worktree'); + expect(content).toContain('current_phase: SETUP'); + expect(content).toContain('iteration: 1'); + }); + + test('writes plans list correctly', async () => { + const context = createContext({ + plans: [ + { path: 'plans/workflow-1.md', issueNumber: 42, completed: true }, + { path: 'plans/workflow-2.md', issueNumber: 43, completed: false }, + ], + currentPlanIndex: 1, + }); + + await writer.write(context, 'implementing', 2); + + const content = await readFile(join(testDir, '.workflow-progress.txt'), 'utf-8'); + expect(content).toContain('total: 2'); + expect(content).toContain('completed: 1'); + expect(content).toContain('[x] plans/workflow-1.md (issue: #42)'); + expect(content).toContain('[ ] plans/workflow-2.md (issue: #43) <- CURRENT'); + }); + + test('writes signals list', async () => { + const context = createContext({ + signals: [ + { signal: 'SETUP_COMPLETE', timestamp: '2024-01-01T00:01:00.000Z' }, + { signal: 'PLANNING_COMPLETE', timestamp: '2024-01-01T00:02:00.000Z' }, + ], + }); + + await writer.write(context, 'implementing', 3); + + const content = await readFile(join(testDir, '.workflow-progress.txt'), 'utf-8'); + expect(content).toContain('SETUP_COMPLETE'); + expect(content).toContain('PLANNING_COMPLETE'); + }); + + test('writes PR info when present', async () => { + const context = createContext({ + prNumber: 123, + prUrl: 'https://github.com/org/repo/pull/123', + ciAttempts: 2, + }); + + await writer.write(context, 'ci_resolution', 5); + + const content = await readFile(join(testDir, '.workflow-progress.txt'), 'utf-8'); + expect(content).toContain('number: 123'); + expect(content).toContain('url: https://github.com/org/repo/pull/123'); + expect(content).toContain('ci_attempts: 2'); + }); + + test('exists returns false when file does not exist', async () => { + const exists = await writer.exists(); + expect(exists).toBe(false); + }); + + test('exists returns true when file exists', async () => { + const context = createContext(); + await writer.write(context, 'setup', 1); + + const exists = await writer.exists(); + expect(exists).toBe(true); + }); + + test('read returns null when file does not exist', async () => { + const data = await writer.read(); + expect(data).toBeNull(); + }); + + test('read parses written progress file', async () => { + const context = createContext({ + plans: [ + { path: 'plans/workflow-1.md', issueNumber: 42, completed: true }, + { path: 'plans/workflow-2.md', issueNumber: 43, completed: false }, + ], + prNumber: 123, + prUrl: 'https://github.com/org/repo/pull/123', + }); + + await writer.write(context, 'ci_resolution', 5); + const data = await writer.read(); + + expect(data).not.toBeNull(); + expect(data!.researchFile).toBe('research/test.md'); + expect(data!.worktreePath).toBe('/path/to/worktree'); + expect(data!.branch).toBe('feat/test'); + expect(data!.currentPhase).toBe('ci_resolution'); + expect(data!.iteration).toBe(5); + expect(data!.plans.total).toBe(2); + expect(data!.plans.completed).toBe(1); + expect(data!.plans.list).toHaveLength(2); + expect(data!.pr.number).toBe(123); + expect(data!.pr.url).toBe('https://github.com/org/repo/pull/123'); + }); + + test('handles null worktree path', async () => { + const context = createContext({ + worktreePath: null, + branch: null, + }); + + await writer.write(context, 'idle', 0); + const data = await writer.read(); + + expect(data).not.toBeNull(); + expect(data!.worktreePath).toBeNull(); + expect(data!.branch).toBeNull(); + }); +}); diff --git a/src/runner/progress-writer.ts b/src/runner/progress-writer.ts new file mode 100644 index 0000000..15a8119 --- /dev/null +++ b/src/runner/progress-writer.ts @@ -0,0 +1,257 @@ +/** + * Progress file reader/writer for workflow state persistence + */ + +import { readFile, writeFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { + WorkflowContext, + WorkflowPhase, + ProgressFileData, + SignalRecord, + PlanInfo, +} from '../types'; + +const PROGRESS_FILENAME = '.workflow-progress.txt'; + +export class ProgressWriter { + private basePath: string; + + constructor(basePath: string = process.cwd()) { + this.basePath = basePath; + } + + private get filePath(): string { + return join(this.basePath, PROGRESS_FILENAME); + } + + /** + * Write current workflow state to progress file + */ + async write( + context: WorkflowContext, + phase: WorkflowPhase, + iteration: number + ): Promise { + const now = new Date().toISOString(); + + const plansCompleted = context.plans.filter((p) => p.completed).length; + const plansList = context.plans + .map((p, i) => { + const marker = p.completed ? '[x]' : '[ ]'; + const current = + !p.completed && i === context.currentPlanIndex ? ' <- CURRENT' : ''; + const issue = p.issueNumber ? ` (issue: #${p.issueNumber})` : ''; + return `- ${marker} ${p.path}${issue}${current}`; + }) + .join('\n'); + + const signalsList = context.signals + .map((s) => `- ${s.timestamp}: ${s.signal}`) + .join('\n'); + + const content = `# Workflow Progress +# Generated: ${now} +# Research: ${context.researchFile} +# Worktree: ${context.worktreePath ?? 'not created'} +# Branch: ${context.branch ?? 'not created'} + +## Status +current_phase: ${phase.toUpperCase()} +iteration: ${iteration} +started_at: ${context.startedAt} +last_update: ${now} + +## Plans +total: ${context.plans.length} +completed: ${plansCompleted} +${plansList || '(no plans yet)'} + +## PR +number: ${context.prNumber ?? 'null'} +url: ${context.prUrl ?? 'null'} +ci_status: ${getCiStatus(context, phase)} +ci_attempts: ${context.ciAttempts} + +## Comments +total: 0 +resolved: 0 +pending: 0 + +## Signals +${signalsList || '(no signals yet)'} +`; + + await writeFile(this.filePath, content, 'utf-8'); + } + + /** + * Read existing progress file and parse into context + */ + async read(): Promise { + try { + await access(this.filePath); + } catch { + return null; + } + + const content = await readFile(this.filePath, 'utf-8'); + return this.parse(content); + } + + /** + * Check if progress file exists + */ + async exists(): Promise { + try { + await access(this.filePath); + return true; + } catch { + return false; + } + } + + /** + * Parse progress file content into structured data + */ + private parse(content: string): ProgressFileData { + const lines = content.split('\n'); + + // Parse header comments + const researchFile = extractHeaderValue(lines, 'Research:') ?? ''; + const worktreePath = extractHeaderValue(lines, 'Worktree:'); + const branch = extractHeaderValue(lines, 'Branch:'); + + // Parse status section + const currentPhase = ( + extractValue(content, 'current_phase:') ?? 'idle' + ).toLowerCase() as WorkflowPhase; + const iteration = parseInt(extractValue(content, 'iteration:') ?? '0', 10); + const startedAt = extractValue(content, 'started_at:') ?? ''; + const lastUpdate = extractValue(content, 'last_update:') ?? ''; + + // Parse plans section + const plansTotal = parseInt(extractValue(content, 'total:') ?? '0', 10); + const plansCompleted = parseInt( + extractValue(content, 'completed:') ?? '0', + 10 + ); + const plansList = parsePlansList(content); + + // Parse PR section + const prNumber = parseNullableInt(extractValue(content, 'number:')); + const prUrl = parseNullableString(extractValue(content, 'url:')); + const ciStatus = parseNullableString(extractValue(content, 'ci_status:')) as + | 'pending' + | 'passing' + | 'failing' + | null; + const ciAttempts = parseInt( + extractValue(content, 'ci_attempts:') ?? '0', + 10 + ); + + // Parse signals section + const signals = parseSignalsList(content); + + return { + timestamp: new Date().toISOString(), + researchFile, + worktreePath: worktreePath === 'not created' ? null : worktreePath, + branch: branch === 'not created' ? null : branch, + currentPhase, + iteration, + startedAt, + lastUpdate, + plans: { + total: plansTotal, + completed: plansCompleted, + list: plansList, + }, + pr: { + number: prNumber, + url: prUrl, + ciStatus, + ciAttempts, + }, + comments: { + total: 0, + resolved: 0, + pending: 0, + }, + signals, + }; + } +} + +// Helper functions + +function getCiStatus( + context: WorkflowContext, + phase: WorkflowPhase +): 'pending' | 'passing' | 'failing' | 'null' { + if (!context.prNumber) return 'null'; + if (phase === 'completed') return 'passing'; + if (phase === 'ci_fixing') return 'failing'; + if (phase === 'ci_resolution') return 'pending'; + return 'pending'; +} + +function extractHeaderValue(lines: string[], prefix: string): string | null { + for (const line of lines) { + if (line.startsWith(`# ${prefix}`)) { + return line.slice(`# ${prefix}`.length).trim(); + } + } + return null; +} + +function extractValue(content: string, key: string): string | null { + const regex = new RegExp(`^${key}\\s*(.+)$`, 'm'); + const match = content.match(regex); + return match ? match[1].trim() : null; +} + +function parseNullableInt(value: string | null): number | null { + if (!value || value === 'null') return null; + const num = parseInt(value, 10); + return isNaN(num) ? null : num; +} + +function parseNullableString(value: string | null): string | null { + if (!value || value === 'null') return null; + return value; +} + +function parsePlansList(content: string): PlanInfo[] { + const plans: PlanInfo[] = []; + const planRegex = /^- \[(x| )\] (.+?)(?:\s*\(issue: #(\d+)\))?/gm; + + let match; + while ((match = planRegex.exec(content)) !== null) { + plans.push({ + path: match[2].replace(' <- CURRENT', '').trim(), + issueNumber: match[3] ? parseInt(match[3], 10) : null, + completed: match[1] === 'x', + }); + } + + return plans; +} + +function parseSignalsList(content: string): SignalRecord[] { + const signals: SignalRecord[] = []; + const signalSection = content.split('## Signals')[1]; + if (!signalSection) return signals; + + const signalRegex = /^- (.+): (\w+)/gm; + let match; + while ((match = signalRegex.exec(signalSection)) !== null) { + signals.push({ + timestamp: match[1], + signal: match[2] as SignalRecord['signal'], + }); + } + + return signals; +} diff --git a/src/runner/signal-parser.test.ts b/src/runner/signal-parser.test.ts new file mode 100644 index 0000000..c0c7ddd --- /dev/null +++ b/src/runner/signal-parser.test.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for signal-parser.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { parseSignals, parseAllSignals, extractSignalData } from './signal-parser'; + +describe('parseSignals', () => { + test('parses SETUP_COMPLETE signal', () => { + const output = 'Setup done.\nSETUP_COMPLETE\nworktree_path: /path/to/worktree'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'SETUP_COMPLETE' }); + }); + + test('parses PLANNING_COMPLETE signal', () => { + const output = 'PLANNING_COMPLETE'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'PLANNING_COMPLETE' }); + }); + + test('parses CI_PASSED signal', () => { + const output = 'All checks passed.\nCI_PASSED'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'CI_PASSED' }); + }); + + test('parses CI_FAILED signal', () => { + const output = 'CI_FAILED\nci_failure_reason: test failed'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'CI_FAILED' }); + }); + + test('parses PLAN_N_COMPLETE signal', () => { + const output = 'Plan done.\nPLAN_1_COMPLETE'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'PLAN_COMPLETE', data: { planNumber: 1 } }); + }); + + test('parses plan with double-digit number', () => { + const output = 'PLAN_12_COMPLETE'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'PLAN_COMPLETE', data: { planNumber: 12 } }); + }); + + test('parses promise FAILED signal', () => { + const output = 'FAILED\nSomething went wrong'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'FAIL', error: 'Something went wrong' }); + }); + + test('parses promise FAILED with unknown error', () => { + const output = 'FAILED'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'FAIL', error: 'Unknown error' }); + }); + + test('parses promise COMPLETE signal', () => { + const output = 'COMPLETE'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'WORKFLOW_COMPLETE' }); + }); + + test('returns null for no signal', () => { + const output = 'Just some regular output with no signals'; + const event = parseSignals(output); + expect(event).toBeNull(); + }); + + test('returns null for empty output', () => { + const event = parseSignals(''); + expect(event).toBeNull(); + }); + + test('prefers phase signal over plan signal when both present', () => { + const output = 'CI_PASSED\nPLAN_1_COMPLETE'; + const event = parseSignals(output); + expect(event).toEqual({ type: 'CI_PASSED' }); + }); +}); + +describe('parseAllSignals', () => { + test('parses multiple phase signals', () => { + const output = 'SETUP_COMPLETE\nPLANNING_COMPLETE'; + const events = parseAllSignals(output); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('SETUP_COMPLETE'); + expect(events[1].type).toBe('PLANNING_COMPLETE'); + }); + + test('parses mixed signals', () => { + const output = 'IMPLEMENTATION_COMPLETE\nPLAN_1_COMPLETE\nPLAN_2_COMPLETE'; + const events = parseAllSignals(output); + expect(events).toHaveLength(3); + expect(events[0].type).toBe('IMPLEMENTATION_COMPLETE'); + expect(events[1].type).toBe('PLAN_COMPLETE'); + expect(events[2].type).toBe('PLAN_COMPLETE'); + }); + + test('returns empty array for no signals', () => { + const events = parseAllSignals('no signals here'); + expect(events).toEqual([]); + }); +}); + +describe('extractSignalData', () => { + test('extracts worktree path and branch from SETUP_COMPLETE', () => { + const output = 'worktree_path: /path/to/worktree\nbranch: feat/my-feature'; + const data = extractSignalData(output, 'SETUP_COMPLETE'); + expect(data).toEqual({ + worktreePath: '/path/to/worktree', + branch: 'feat/my-feature', + }); + }); + + test('extracts plans count from PLANNING_COMPLETE', () => { + const output = 'plans_count: 5'; + const data = extractSignalData(output, 'PLANNING_COMPLETE'); + expect(data).toEqual({ plansCount: 5 }); + }); + + test('extracts PR info from PR_CREATED', () => { + const output = 'pr_url: https://github.com/org/repo/pull/123\npr_number: 123'; + const data = extractSignalData(output, 'PR_CREATED'); + expect(data).toEqual({ + prUrl: 'https://github.com/org/repo/pull/123', + prNumber: 123, + }); + }); + + test('extracts failure reason from CI_FAILED', () => { + const output = 'ci_failure_reason: tests failed'; + const data = extractSignalData(output, 'CI_FAILED'); + expect(data).toEqual({ failureReason: 'tests failed' }); + }); + + test('returns empty object for unknown signal', () => { + const output = 'some output'; + const data = extractSignalData(output, 'COMMENTS_RESOLVED'); + expect(data).toEqual({}); + }); +}); diff --git a/src/runner/signal-parser.ts b/src/runner/signal-parser.ts new file mode 100644 index 0000000..4c7ddca --- /dev/null +++ b/src/runner/signal-parser.ts @@ -0,0 +1,136 @@ +/** + * Parse XML-style signals from Claude CLI output + */ + +import type { WorkflowEvent, WorkflowSignal } from '../types'; + +const PHASE_SIGNALS: WorkflowSignal[] = [ + 'SETUP_COMPLETE', + 'PLANNING_COMPLETE', + 'IMPLEMENTATION_COMPLETE', + 'PR_CREATED', + 'CI_PASSED', + 'CI_FAILED', + 'CI_FIX_PUSHED', + 'COMMENTS_RESOLVED', + 'COMMENTS_PENDING', + 'COMMENT_FIX_PUSHED', + 'WORKFLOW_COMPLETE', +]; + +/** + * Parse signals from Claude CLI output. + * Looks for patterns like: + * - SETUP_COMPLETE + * - PLAN_1_COMPLETE + * - FAILED + * - message + */ +export function parseSignals(output: string): WorkflowEvent | null { + // Check for phase signals: SIGNAL_NAME + const phaseMatch = output.match(/(\w+)<\/phase>/); + if (phaseMatch) { + const signalName = phaseMatch[1] as WorkflowSignal; + if (PHASE_SIGNALS.includes(signalName)) { + return { type: signalName }; + } + } + + // Check for plan completion: PLAN_N_COMPLETE + const planMatch = output.match(/PLAN_(\d+)_COMPLETE<\/plan>/); + if (planMatch) { + const planNumber = parseInt(planMatch[1], 10); + return { + type: 'PLAN_COMPLETE', + data: { planNumber }, + }; + } + + // Check for promise failure: FAILED + if (output.includes('FAILED')) { + const errorMatch = output.match(/([^<]+)<\/error>/); + return { + type: 'FAIL', + error: errorMatch ? errorMatch[1] : 'Unknown error', + }; + } + + // Check for workflow complete promise + if (output.includes('COMPLETE')) { + return { type: 'WORKFLOW_COMPLETE' }; + } + + return null; +} + +/** + * Extract additional data from output based on signal type + */ +export function extractSignalData( + output: string, + signal: string +): Record { + const data: Record = {}; + + switch (signal) { + case 'SETUP_COMPLETE': { + const pathMatch = output.match(/worktree_path:\s*(.+)/); + const branchMatch = output.match(/branch:\s*(.+)/); + if (pathMatch) data.worktreePath = pathMatch[1].trim(); + if (branchMatch) data.branch = branchMatch[1].trim(); + break; + } + + case 'PLANNING_COMPLETE': { + const countMatch = output.match(/plans_count:\s*(\d+)/); + if (countMatch) data.plansCount = parseInt(countMatch[1], 10); + break; + } + + case 'PR_CREATED': { + const urlMatch = output.match(/pr_url:\s*(.+)/); + const numberMatch = output.match(/pr_number:\s*(\d+)/); + if (urlMatch) data.prUrl = urlMatch[1].trim(); + if (numberMatch) data.prNumber = parseInt(numberMatch[1], 10); + break; + } + + case 'CI_FAILED': { + const reasonMatch = output.match(/ci_failure_reason:\s*(.+)/); + if (reasonMatch) data.failureReason = reasonMatch[1].trim(); + break; + } + } + + return data; +} + +/** + * Find all signals in output (there may be multiple) + */ +export function parseAllSignals(output: string): WorkflowEvent[] { + const events: WorkflowEvent[] = []; + + // Find all phase signals + const phaseMatches = output.matchAll(/(\w+)<\/phase>/g); + for (const match of phaseMatches) { + const signalName = match[1] as WorkflowSignal; + if (PHASE_SIGNALS.includes(signalName)) { + events.push({ + type: signalName, + data: extractSignalData(output, signalName), + }); + } + } + + // Find all plan completions + const planMatches = output.matchAll(/PLAN_(\d+)_COMPLETE<\/plan>/g); + for (const match of planMatches) { + events.push({ + type: 'PLAN_COMPLETE', + data: { planNumber: parseInt(match[1], 10) }, + }); + } + + return events; +} diff --git a/src/runner/workflow-runner.ts b/src/runner/workflow-runner.ts new file mode 100644 index 0000000..e93f047 --- /dev/null +++ b/src/runner/workflow-runner.ts @@ -0,0 +1,221 @@ +/** + * Main orchestration loop for the workflow + * + * This runner holds the XState actor and iterates through phases, + * spawning fresh Claude CLI subprocesses for each phase. + */ + +import { createActor } from 'xstate'; +import { ClaudeCLIAdapter } from '../adapters/claude-cli-adapter'; +import { ProgressWriter } from './progress-writer'; +import { parseSignals, extractSignalData } from './signal-parser'; +import { + mapPhaseToCommand, + formatCommand, + isTerminalPhase, + isSuccessPhase, + getPhaseName, +} from './phase-mapper'; +import { + workflowMachine, + getCurrentPhase, + isTerminal, + isSuccess, +} from '../workflows/main.workflow'; +import type { + WorkflowResult, + WorkflowContext, + WorkflowPhase, + WorkflowEvent, +} from '../types'; + +const MAX_ITERATIONS = 50; +const PHASE_TIMEOUT = 15 * 60 * 1000; // 15 minutes per phase + +interface RunnerOptions { + claudePath?: string; + maxIterations?: number; + phaseTimeout?: number; + verbose?: boolean; +} + +export class WorkflowRunner { + private claude: ClaudeCLIAdapter; + private progressWriter: ProgressWriter; + private maxIterations: number; + private phaseTimeout: number; + private verbose: boolean; + + constructor(options: RunnerOptions = {}) { + this.claude = new ClaudeCLIAdapter(options.claudePath); + this.progressWriter = new ProgressWriter(); + this.maxIterations = options.maxIterations ?? MAX_ITERATIONS; + this.phaseTimeout = options.phaseTimeout ?? PHASE_TIMEOUT; + this.verbose = options.verbose ?? false; + } + + /** + * Run the full workflow from research file to completed PR + */ + async run(researchFile: string): Promise { + this.log(`Starting workflow for: ${researchFile}`); + + // Create and start the XState actor + const actor = createActor(workflowMachine); + actor.start(); + + // Send the start event + actor.send({ type: 'START', researchFile }); + + let iteration = 0; + + // Main orchestration loop + while (iteration < this.maxIterations) { + const snapshot = actor.getSnapshot(); + const stateValue = snapshot.value as string; + const context = snapshot.context; + + // Check if we've reached a terminal state + if (isTerminal(stateValue)) { + this.log(`Workflow reached terminal state: ${stateValue}`); + break; + } + + const phase = getCurrentPhase(stateValue); + this.log(`[${iteration + 1}] Phase: ${getPhaseName(phase)}`); + + // Get the command for this phase + const phaseCommand = mapPhaseToCommand(phase, context); + if (!phaseCommand) { + this.log(`No command for phase: ${phase}, skipping`); + iteration++; + continue; + } + + const command = formatCommand(phaseCommand); + this.log(`Executing: ${command}`); + + // Execute the phase command in a fresh Claude CLI subprocess + try { + const result = await this.claude.runPrompt({ + prompt: command, + workingDirectory: context.worktreePath ?? undefined, + timeout: this.phaseTimeout, + }); + + this.logVerbose(`Output: ${result.content.slice(0, 500)}...`); + + // Parse signals from the output + const event = parseSignals(result.content); + if (event) { + // Extract additional data for certain signals + if (event.type !== 'FAIL') { + const data = extractSignalData(result.content, event.type); + event.data = { ...event.data, ...data }; + } + + this.log(`Signal received: ${event.type}`); + actor.send(event as WorkflowEvent); + } else { + this.log('No signal received from phase, retrying...'); + // If no signal, we might need to retry or handle error + // For now, continue to next iteration + } + + // Update progress file + const newSnapshot = actor.getSnapshot(); + await this.progressWriter.write( + newSnapshot.context, + getCurrentPhase(newSnapshot.value as string), + iteration + 1 + ); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Unknown error'; + this.log(`Phase execution failed: ${errorMessage}`); + + actor.send({ + type: 'FAIL', + error: errorMessage, + } as WorkflowEvent); + } + + iteration++; + } + + // Get final state + const finalSnapshot = actor.getSnapshot(); + const finalState = finalSnapshot.value as string; + const finalContext = finalSnapshot.context; + + // Write final progress + await this.progressWriter.write( + finalContext, + getCurrentPhase(finalState), + iteration + ); + + const success = isSuccess(finalState); + + if (success) { + this.log('Workflow completed successfully!'); + } else { + this.log(`Workflow ended in state: ${finalState}`); + if (finalContext.error) { + this.log(`Error: ${finalContext.error}`); + } + } + + return { + success, + context: finalContext, + finalPhase: finalState as WorkflowPhase, + }; + } + + /** + * Resume a workflow from existing progress file + */ + async resume(): Promise { + const progressData = await this.progressWriter.read(); + + if (!progressData) { + throw new Error( + 'No workflow in progress. Start with: /workflows:build ' + ); + } + + this.log(`Resuming workflow from phase: ${progressData.currentPhase}`); + + // Reconstruct context from progress data + const context: WorkflowContext = { + researchFile: progressData.researchFile, + worktreePath: progressData.worktreePath, + branch: progressData.branch, + plans: progressData.plans.list, + currentPlanIndex: progressData.plans.list.findIndex((p) => !p.completed), + prNumber: progressData.pr.number, + prUrl: progressData.pr.url, + ciAttempts: progressData.pr.ciAttempts, + commentAttempts: 0, + error: null, + startedAt: progressData.startedAt, + lastUpdate: progressData.lastUpdate, + signals: progressData.signals, + }; + + // For resume, we continue from where we left off + return this.run(context.researchFile); + } + + private log(message: string): void { + const timestamp = new Date().toISOString().slice(11, 19); + console.log(`[${timestamp}] ${message}`); + } + + private logVerbose(message: string): void { + if (this.verbose) { + this.log(message); + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..26ebb00 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,111 @@ +/** + * Core types for the workflow runner + */ + +export type WorkflowPhase = + | 'idle' + | 'setup' + | 'planning' + | 'implementing' + | 'submitting' + | 'ci_resolution' + | 'ci_fixing' + | 'comment_resolution' + | 'comment_resolving' + | 'completed' + | 'failed'; + +export type WorkflowSignal = + | 'SETUP_COMPLETE' + | 'PLANNING_COMPLETE' + | 'PLAN_COMPLETE' + | 'IMPLEMENTATION_COMPLETE' + | 'PR_CREATED' + | 'CI_PASSED' + | 'CI_FAILED' + | 'CI_FIX_PUSHED' + | 'COMMENTS_RESOLVED' + | 'COMMENTS_PENDING' + | 'COMMENT_FIX_PUSHED' + | 'WORKFLOW_COMPLETE' + | 'FAILED'; + +export interface WorkflowContext { + researchFile: string; + worktreePath: string | null; + branch: string | null; + plans: PlanInfo[]; + currentPlanIndex: number; + prNumber: number | null; + prUrl: string | null; + ciAttempts: number; + commentAttempts: number; + error: string | null; + startedAt: string; + lastUpdate: string; + signals: SignalRecord[]; +} + +export interface PlanInfo { + path: string; + issueNumber: number | null; + completed: boolean; +} + +export interface SignalRecord { + signal: WorkflowSignal; + timestamp: string; + data?: Record; +} + +export interface WorkflowEvent { + type: WorkflowSignal | 'START' | 'FAIL'; + researchFile?: string; + error?: string; + data?: Record; +} + +export interface WorkflowResult { + success: boolean; + context: WorkflowContext; + finalPhase: WorkflowPhase; +} + +export interface ClaudeRunOptions { + prompt: string; + workingDirectory?: string; + timeout?: number; +} + +export interface ClaudeRunResult { + content: string; + exitCode: number; +} + +export interface ProgressFileData { + timestamp: string; + researchFile: string; + worktreePath: string | null; + branch: string | null; + currentPhase: WorkflowPhase; + iteration: number; + startedAt: string; + lastUpdate: string; + plans: { + total: number; + completed: number; + list: PlanInfo[]; + }; + pr: { + number: number | null; + url: string | null; + ciStatus: 'pending' | 'passing' | 'failing' | null; + ciAttempts: number; + }; + comments: { + total: number; + resolved: number; + pending: number; + }; + signals: SignalRecord[]; +} diff --git a/src/workflows/main.workflow.ts b/src/workflows/main.workflow.ts new file mode 100644 index 0000000..256d427 --- /dev/null +++ b/src/workflows/main.workflow.ts @@ -0,0 +1,319 @@ +/** + * XState workflow machine for the full SDLC pipeline + * + * States: idle → setup → planning → implementing → submitting → + * ci_resolution ↔ ci_fixing → comment_resolution ↔ comment_resolving → completed + */ + +import { createMachine, assign } from 'xstate'; +import type { WorkflowContext, WorkflowEvent, WorkflowPhase } from '../types'; + +// Retry limits +const MAX_CI_ATTEMPTS = 5; +const MAX_COMMENT_ATTEMPTS = 10; + +// Initial context +const initialContext: WorkflowContext = { + researchFile: '', + worktreePath: null, + branch: null, + plans: [], + currentPlanIndex: 0, + prNumber: null, + prUrl: null, + ciAttempts: 0, + commentAttempts: 0, + error: null, + startedAt: new Date().toISOString(), + lastUpdate: new Date().toISOString(), + signals: [], +}; + +// Helper to add signal to context +function addSignal( + context: WorkflowContext, + signal: string +): WorkflowContext['signals'] { + // Only add valid workflow signals, skip internal events like 'START' + if (signal === 'START') { + return context.signals; + } + return [ + ...context.signals, + { + signal: signal as WorkflowContext['signals'][0]['signal'], + timestamp: new Date().toISOString(), + }, + ]; +} + +export const workflowMachine = createMachine({ + id: 'workflow', + initial: 'idle', + context: initialContext, + + states: { + idle: { + on: { + START: { + target: 'setup', + actions: assign({ + researchFile: ({ event }) => event.researchFile ?? '', + startedAt: () => new Date().toISOString(), + lastUpdate: () => new Date().toISOString(), + signals: () => [], + }), + }, + }, + }, + + setup: { + on: { + SETUP_COMPLETE: { + target: 'planning', + actions: assign({ + worktreePath: ({ event }) => + (event.data?.worktreePath as string) ?? null, + branch: ({ event }) => (event.data?.branch as string) ?? null, + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'Setup failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + planning: { + on: { + PLANNING_COMPLETE: { + target: 'implementing', + actions: assign({ + plans: ({ event }) => { + const count = (event.data?.plansCount as number) ?? 0; + // Plans will be populated by the phase command + return Array.from({ length: count }, (_, i) => ({ + path: `plans/workflow-${i + 1}.md`, + issueNumber: null, + completed: false, + })); + }, + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'Planning failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + implementing: { + on: { + PLAN_COMPLETE: { + target: 'implementing', + actions: assign({ + plans: ({ context }) => + context.plans.map((p, i) => + i === context.currentPlanIndex ? { ...p, completed: true } : p + ), + currentPlanIndex: ({ context }) => context.currentPlanIndex + 1, + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + IMPLEMENTATION_COMPLETE: { + target: 'submitting', + actions: assign({ + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'Implementation failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + submitting: { + on: { + PR_CREATED: { + target: 'ci_resolution', + actions: assign({ + prNumber: ({ event }) => (event.data?.prNumber as number) ?? null, + prUrl: ({ event }) => (event.data?.prUrl as string) ?? null, + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'PR submission failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + ci_resolution: { + on: { + CI_PASSED: { + target: 'comment_resolution', + actions: assign({ + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + CI_FAILED: [ + { + guard: ({ context }) => context.ciAttempts < MAX_CI_ATTEMPTS, + target: 'ci_fixing', + actions: assign({ + ciAttempts: ({ context }) => context.ciAttempts + 1, + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + { + target: 'failed', + actions: assign({ + error: () => `CI failed after ${MAX_CI_ATTEMPTS} attempts`, + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + ], + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'CI resolution failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + ci_fixing: { + on: { + CI_FIX_PUSHED: { + target: 'ci_resolution', + actions: assign({ + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'CI fix failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + comment_resolution: { + on: { + COMMENTS_RESOLVED: { + target: 'completed', + actions: assign({ + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + COMMENTS_PENDING: [ + { + guard: ({ context }) => + context.commentAttempts < MAX_COMMENT_ATTEMPTS, + target: 'comment_resolving', + actions: assign({ + commentAttempts: ({ context }) => context.commentAttempts + 1, + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + { + target: 'failed', + actions: assign({ + error: () => + `Comments unresolved after ${MAX_COMMENT_ATTEMPTS} attempts`, + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + ], + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'Comment resolution failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + comment_resolving: { + on: { + COMMENT_FIX_PUSHED: { + target: 'comment_resolution', + actions: assign({ + signals: ({ context, event }) => addSignal(context, event.type), + lastUpdate: () => new Date().toISOString(), + }), + }, + FAIL: { + target: 'failed', + actions: assign({ + error: ({ event }) => event.error ?? 'Comment fix failed', + signals: ({ context, event }) => addSignal(context, event.type), + }), + }, + }, + }, + + completed: { + type: 'final', + entry: assign({ + signals: ({ context }) => addSignal(context, 'WORKFLOW_COMPLETE'), + lastUpdate: () => new Date().toISOString(), + }), + }, + + failed: { + type: 'final', + }, + }, +}); + +/** + * Get current phase from machine state + */ +export function getCurrentPhase(stateValue: string): WorkflowPhase { + return stateValue as WorkflowPhase; +} + +/** + * Check if state is terminal + */ +export function isTerminal(stateValue: string): boolean { + return stateValue === 'completed' || stateValue === 'failed'; +} + +/** + * Check if state is success + */ +export function isSuccess(stateValue: string): boolean { + return stateValue === 'completed'; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d8dd79c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "rootDir": "./src", + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "scripts"] +}