mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(cli): implement loop command (#1571)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
17
.changeset/tiny-clocks-report.md
Normal file
17
.changeset/tiny-clocks-report.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
"task-master-ai": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add loop command for automated task execution with Claude Code
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
- `task-master loop` command that runs Claude Code in a Docker sandbox, executing one task per iteration based on the selected tag
|
||||||
|
- Built-in presets for different workflows:
|
||||||
|
- `default` - General task completion from the Task Master backlog
|
||||||
|
- `test-coverage` - Find uncovered code and write meaningful tests
|
||||||
|
- `linting` - Fix lint errors and type errors one by one
|
||||||
|
- `duplication` - Find duplicated code and refactor into shared utilities
|
||||||
|
- `entropy` - Find code smells and clean them up
|
||||||
|
- Progress file tracking to maintain context across iterations (inside `.taskmaster/loop-progress.txt`)
|
||||||
|
- Remember to delete this file between loops to not pollute the agent with bad context
|
||||||
|
- Automatic completion detection via `<loop-complete>` and `<loop-blocked>` markers
|
||||||
@@ -115,7 +115,7 @@ Task Master uses tiered tool loading to optimize context window usage:
|
|||||||
|------|-------|----------|
|
|------|-------|----------|
|
||||||
| `core` | 7 | Minimal daily workflow tools (default) |
|
| `core` | 7 | Minimal daily workflow tools (default) |
|
||||||
| `standard` | 14 | Common task management |
|
| `standard` | 14 | Common task management |
|
||||||
| `all` | 44+ | Full suite with research, autopilot, dependencies |
|
| `all` | 42+ | Full suite with research, autopilot, dependencies |
|
||||||
|
|
||||||
**Core tools (7):** `get_tasks`, `next_task`, `get_task`, `set_task_status`, `update_subtask`, `parse_prd`, `expand_task`
|
**Core tools (7):** `get_tasks`, `next_task`, `get_task`, `set_task_status`, `update_subtask`, `parse_prd`, `expand_task`
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"models": {
|
"models": {
|
||||||
"main": {
|
"main": {
|
||||||
"provider": "claude-code",
|
"provider": "claude-code",
|
||||||
"modelId": "haiku",
|
"modelId": "opus",
|
||||||
"maxTokens": 200000,
|
"maxTokens": 32000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
},
|
},
|
||||||
"research": {
|
"research": {
|
||||||
|
|||||||
425
.taskmaster/docs/loop-prd.md
Normal file
425
.taskmaster/docs/loop-prd.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# PRD: Task Master Loop (`tm loop`)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Task Master Loop** implements the "Ralph Wiggum" pattern (credited to Jeffrey Huntley, popularized by Matt Pocock) - a simple, loop-based approach to running coding agents that work through a task backlog one task at a time, with fresh context per iteration.
|
||||||
|
|
||||||
|
Unlike complex agent orchestration systems, Loop's power lies in its simplicity: a for loop that spawns a new agent session for each iteration, letting it pick the next task, complete it, commit, and exit. The fresh context window each iteration prevents context degradation and produces higher quality code.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Users currently run Task Master in two ways:
|
||||||
|
|
||||||
|
1. **Manual "next, next, next"** - Running `task-master next` repeatedly in the same session, which works but degrades context quality over time
|
||||||
|
2. **Autopilot TDD workflow** - A structured RED/GREEN/COMMIT cycle that's powerful but opinionated and complex for simple task completion
|
||||||
|
|
||||||
|
There's no middle ground: a simple loop that spawns fresh agent sessions, completes one task per iteration, and automatically stops when all tasks are done.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Simple loop execution** - Run Claude Code in a loop, one task per iteration, fresh context each time
|
||||||
|
2. **Automatic task selection** - Use `task-master next` or intelligent task selection to pick what to work on
|
||||||
|
3. **Clear completion criteria** - Stop when all tasks are done (or max iterations reached)
|
||||||
|
4. **Progress tracking** - Maintain a progress log across iterations for agent memory
|
||||||
|
5. **Built-in presets** - Provide ready-to-use workflow presets (test coverage, linting, duplication, entropy)
|
||||||
|
6. **Custom prompts** - Allow users to provide custom prompt files for domain-specific workflows
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Replacing the existing autopilot TDD workflow
|
||||||
|
- Complex agent orchestration or multi-agent coordination
|
||||||
|
- Real-time human-in-the-loop interaction (that's what manual `next` is for)
|
||||||
|
- Multi-agent support (v2 consideration: codex, aider, etc.)
|
||||||
|
- Programmatic enforcement of quality gates (the prompt is the source of truth - modern models follow instructions)
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### US-1: Basic Loop
|
||||||
|
|
||||||
|
As a developer, I want to run `task-master loop --iterations 10` and have it automatically work through my pending tasks, one per iteration, stopping when all tasks are done or iterations exhausted.
|
||||||
|
|
||||||
|
### US-2: Preset Workflows
|
||||||
|
|
||||||
|
As a developer, I want to use built-in presets (`--prompt test-coverage`, `--prompt linting`, etc.) for common workflows without writing custom prompts.
|
||||||
|
|
||||||
|
### US-3: Custom Prompts
|
||||||
|
|
||||||
|
As a developer, I want to provide a custom prompt file (`--prompt ./my-prompt.md`) for domain-specific workflows not covered by presets.
|
||||||
|
|
||||||
|
### US-4: Progress Persistence
|
||||||
|
|
||||||
|
As a developer, I want the loop to maintain a progress.txt file that persists learnings across iterations, so each fresh agent session has context about what was already done.
|
||||||
|
|
||||||
|
### US-5: Completion Notification
|
||||||
|
|
||||||
|
As a developer, I want the loop to notify me when complete (stdout message, optional webhook/command).
|
||||||
|
|
||||||
|
## Technical Design
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
Loop will be implemented as:
|
||||||
|
|
||||||
|
1. **CLI Command** (`apps/cli/src/commands/loop.command.ts`) - Thin presentation layer
|
||||||
|
2. **Core Logic** (`packages/tm-core/src/modules/loop/`) - Business logic for task selection, progress tracking, prompt generation, preset resolution
|
||||||
|
|
||||||
|
### Command Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage - run up to 10 iterations with default preset
|
||||||
|
task-master loop --iterations 10
|
||||||
|
|
||||||
|
# Built-in presets
|
||||||
|
task-master loop -n 10 --prompt test-coverage # Write tests for uncovered code
|
||||||
|
task-master loop -n 10 --prompt linting # Fix lint/type errors
|
||||||
|
task-master loop -n 10 --prompt duplication # Refactor duplicated code
|
||||||
|
task-master loop -n 10 --prompt entropy # Clean up code smells
|
||||||
|
|
||||||
|
# Custom prompt file (detected by path-like string or file extension)
|
||||||
|
task-master loop -n 10 --prompt .taskmaster/prompts/my-workflow.md
|
||||||
|
|
||||||
|
# With completion command (runs when done)
|
||||||
|
task-master loop -n 10 --on-complete "notify-send 'Loop complete'"
|
||||||
|
|
||||||
|
# Filter by tag
|
||||||
|
task-master loop -n 5 --tag backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
| ------ | ---- | ------- | ----------- |
|
||||||
|
| `--iterations, -n` | number | 10 | Maximum iterations before stopping |
|
||||||
|
| `--prompt, -p` | string | "default" | Preset name (`default`, `test-coverage`, `linting`, `duplication`, `entropy`) or path to custom prompt file |
|
||||||
|
| `--progress-file` | string | `.taskmaster/loop-progress.txt` | Path to progress log |
|
||||||
|
| `--sleep` | number | 5 | Seconds to sleep between iterations |
|
||||||
|
| `--on-complete` | string | - | Command to run when all tasks complete |
|
||||||
|
| `--tag` | string | - | Only work on tasks with this tag |
|
||||||
|
| `--status` | string | "pending" | Only work on tasks with this status |
|
||||||
|
|
||||||
|
### Built-in Presets
|
||||||
|
|
||||||
|
Presets are bundled markdown files that define the workflow for each iteration. The `--prompt` flag accepts either a preset name or a file path (detected by presence of `/`, `\`, or file extension).
|
||||||
|
|
||||||
|
| Preset | Description | Completion Criteria |
|
||||||
|
| ------ | ----------- | ------------------- |
|
||||||
|
| `default` | Standard task completion from Task Master backlog | All tasks done |
|
||||||
|
| `test-coverage` | Find uncovered lines, write meaningful tests | Coverage target reached |
|
||||||
|
| `linting` | Fix lint errors and type errors one by one | Zero lint errors |
|
||||||
|
| `duplication` | Hook into jscpd, refactor clones into shared utilities | No duplicates above threshold |
|
||||||
|
| `entropy` | Scan for code smells, clean them up | Entropy score below threshold |
|
||||||
|
|
||||||
|
#### Preset: `default`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Task Master Loop - Default Task Completion
|
||||||
|
|
||||||
|
You are completing tasks from a Task Master backlog. Complete ONE task per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/tasks/tasks.json - Your task backlog
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log from previous iterations
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run `task-master next` to get the highest priority available task
|
||||||
|
2. Read the task details carefully with `task-master show <id>`
|
||||||
|
3. Implement the task, focusing on the smallest possible change
|
||||||
|
4. Ensure quality:
|
||||||
|
- Run tests if they exist
|
||||||
|
- Run type check if applicable
|
||||||
|
- Verify the implementation works as expected
|
||||||
|
5. Update the task status: `task-master set-status --id=<id> --status=done`
|
||||||
|
6. Commit your work with a descriptive message referencing the task ID
|
||||||
|
7. Append a brief note to the progress file about what was done
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE task per session
|
||||||
|
- Keep changes small and focused
|
||||||
|
- Do NOT start another task after completing one
|
||||||
|
- If all tasks are complete, output: <loop-complete>ALL_TASKS_DONE</loop-complete>
|
||||||
|
- If you cannot complete the task, output: <loop-blocked>REASON</loop-blocked>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Preset: `test-coverage`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Task Master Loop - Test Coverage
|
||||||
|
|
||||||
|
Find uncovered code and write meaningful tests. ONE test per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (coverage %, what was tested)
|
||||||
|
|
||||||
|
## What Makes a Great Test
|
||||||
|
|
||||||
|
A great test covers behavior users depend on. It tests a feature that, if broken,
|
||||||
|
would frustrate or block users. It validates real workflows - not implementation details.
|
||||||
|
|
||||||
|
Do NOT write tests just to increase coverage. Use coverage as a guide to find
|
||||||
|
UNTESTED USER-FACING BEHAVIOR. If code is not worth testing (boilerplate, unreachable
|
||||||
|
branches, internal plumbing), add ignore comments instead of low-value tests.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run coverage command (`pnpm coverage`, `npm run coverage`, etc.)
|
||||||
|
2. Identify the most important USER-FACING FEATURE that lacks tests
|
||||||
|
- Prioritize: error handling users hit, CLI commands, API endpoints, file parsing
|
||||||
|
- Deprioritize: internal utilities, edge cases users won't encounter, boilerplate
|
||||||
|
3. Write ONE meaningful test that validates the feature works correctly
|
||||||
|
4. Run coverage again - it should increase as a side effect of testing real behavior
|
||||||
|
5. Commit with message: `test(<file>): <describe the user behavior being tested>`
|
||||||
|
6. Append to progress file: what you tested, new coverage %, learnings
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If coverage reaches target (or 100%), output: <loop-complete>COVERAGE_TARGET</loop-complete>
|
||||||
|
- Only write ONE test per iteration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Preset: `linting`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Task Master Loop - Linting
|
||||||
|
|
||||||
|
Fix lint errors and type errors one by one. ONE fix per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (errors fixed, remaining count)
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run lint command (`pnpm lint`, `npm run lint`, `eslint .`, etc.)
|
||||||
|
2. Run type check (`pnpm typecheck`, `tsc --noEmit`, etc.)
|
||||||
|
3. Pick ONE error to fix - prioritize:
|
||||||
|
- Type errors (breaks builds)
|
||||||
|
- Security-related lint errors
|
||||||
|
- Errors in frequently-changed files
|
||||||
|
4. Fix the error with minimal changes - don't refactor surrounding code
|
||||||
|
5. Run lint/typecheck again to verify the fix doesn't introduce new errors
|
||||||
|
6. Commit with message: `fix(<file>): <describe the lint/type error fixed>`
|
||||||
|
7. Append to progress file: error fixed, remaining error count
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If zero lint errors and zero type errors, output: <loop-complete>ZERO_ERRORS</loop-complete>
|
||||||
|
- Only fix ONE error per iteration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Preset: `duplication`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Task Master Loop - Duplication
|
||||||
|
|
||||||
|
Find duplicated code and refactor into shared utilities. ONE refactor per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (clones refactored, duplication %)
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run duplication detection (`npx jscpd .`, or similar tool)
|
||||||
|
2. Review the report and pick ONE clone to refactor - prioritize:
|
||||||
|
- Larger clones (more lines = more maintenance burden)
|
||||||
|
- Clones in frequently-changed files
|
||||||
|
- Clones with slight variations (consolidate logic)
|
||||||
|
3. Extract the duplicated code into a shared utility/function
|
||||||
|
4. Update all clone locations to use the shared utility
|
||||||
|
5. Run tests to ensure behavior is preserved
|
||||||
|
6. Commit with message: `refactor(<file>): extract <utility> to reduce duplication`
|
||||||
|
7. Append to progress file: what was refactored, new duplication %
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If duplication below threshold (e.g., <3%), output: <loop-complete>LOW_DUPLICATION</loop-complete>
|
||||||
|
- Only refactor ONE clone per iteration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Preset: `entropy`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Task Master Loop - Entropy (Code Smells)
|
||||||
|
|
||||||
|
Find code smells and clean them up. ONE cleanup per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (smells fixed, areas cleaned)
|
||||||
|
|
||||||
|
## Code Smells to Target
|
||||||
|
|
||||||
|
- Long functions (>60 lines) - extract into smaller functions
|
||||||
|
- Deep nesting (>3 levels) - use early returns, extract conditions
|
||||||
|
- Large files (>500 lines) - split into focused modules
|
||||||
|
- Magic numbers - extract into named constants
|
||||||
|
- Complex conditionals - extract into well-named functions
|
||||||
|
- God classes - split responsibilities
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Scan the codebase for code smells (use your judgment or tools like `complexity-report`)
|
||||||
|
2. Pick ONE smell to fix - prioritize:
|
||||||
|
- Smells in frequently-changed files
|
||||||
|
- Smells that hurt readability the most
|
||||||
|
- Smells in critical paths (authentication, payments, etc.)
|
||||||
|
3. Refactor with minimal changes - don't over-engineer
|
||||||
|
4. Run tests to ensure behavior is preserved
|
||||||
|
5. Commit with message: `refactor(<file>): <describe the cleanup>`
|
||||||
|
6. Append to progress file: what was cleaned, smell type
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If no significant smells remain, output: <loop-complete>LOW_ENTROPY</loop-complete>
|
||||||
|
- Only fix ONE smell per iteration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Logic (tm-core)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/tm-core/src/modules/loop/types.ts
|
||||||
|
export type LoopPreset = 'default' | 'test-coverage' | 'linting' | 'duplication' | 'entropy';
|
||||||
|
|
||||||
|
export interface LoopConfig {
|
||||||
|
iterations: number;
|
||||||
|
prompt: LoopPreset | string; // Preset name or file path
|
||||||
|
progressFile: string;
|
||||||
|
sleepSeconds: number;
|
||||||
|
onComplete?: string;
|
||||||
|
tag?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopIteration {
|
||||||
|
iteration: number;
|
||||||
|
taskId?: string;
|
||||||
|
status: 'success' | 'blocked' | 'error' | 'complete';
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopResult {
|
||||||
|
iterations: LoopIteration[];
|
||||||
|
totalIterations: number;
|
||||||
|
tasksCompleted: number;
|
||||||
|
finalStatus: 'all_complete' | 'max_iterations' | 'blocked' | 'error';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/tm-core/src/modules/loop/loop.service.ts
|
||||||
|
export class LoopService {
|
||||||
|
// Resolve preset name to prompt content, or read custom file
|
||||||
|
async resolvePrompt(prompt: LoopPreset | string): Promise<string>;
|
||||||
|
|
||||||
|
// Check if string is a preset name or file path
|
||||||
|
isPreset(prompt: string): prompt is LoopPreset;
|
||||||
|
|
||||||
|
async generatePrompt(config: LoopConfig): Promise<string>;
|
||||||
|
async appendProgress(progressFile: string, note: string): Promise<void>;
|
||||||
|
async checkAllTasksComplete(options: { tag?: string; status?: string }): Promise<boolean>;
|
||||||
|
async getClaudeCommand(prompt: string): Promise<string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Flow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pseudocode for loop.command.ts
|
||||||
|
async execute(options: LoopOptions) {
|
||||||
|
const tmCore = await createTmCore({ projectPath });
|
||||||
|
|
||||||
|
for (let i = 1; i <= options.iterations; i++) {
|
||||||
|
// Check if all tasks are done before starting (for default preset)
|
||||||
|
if (options.prompt === 'default' && await tmCore.loop.checkAllTasksComplete({ tag: options.tag })) {
|
||||||
|
console.log('All tasks complete!');
|
||||||
|
if (options.onComplete) await exec(options.onComplete);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve preset or custom prompt
|
||||||
|
const promptContent = await tmCore.loop.resolvePrompt(options.prompt);
|
||||||
|
|
||||||
|
// Generate full prompt with context
|
||||||
|
const prompt = await tmCore.loop.generatePrompt({ ...options, promptContent });
|
||||||
|
|
||||||
|
// Run Claude Code
|
||||||
|
const cmd = await tmCore.loop.getClaudeCommand(prompt);
|
||||||
|
const result = await exec(cmd); // claude -p "<prompt>"
|
||||||
|
|
||||||
|
// Check for completion markers in output
|
||||||
|
if (result.includes('<loop-complete>')) {
|
||||||
|
console.log('Loop complete!');
|
||||||
|
if (options.onComplete) await exec(options.onComplete);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep between iterations
|
||||||
|
await sleep(options.sleepSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Max iterations (${options.iterations}) reached`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Code Integration
|
||||||
|
|
||||||
|
Loop runs Claude Code via CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude -p "<prompt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The prompt includes file references using `@` syntax so Claude Code loads the progress file and relevant context into context.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
1. **Preset resolution** - Verify preset names resolve to correct bundled prompts
|
||||||
|
2. **Custom prompt loading** - Verify file paths are read correctly
|
||||||
|
3. **Prompt detection** - Test `isPreset()` correctly distinguishes presets from file paths
|
||||||
|
4. **Progress file handling** - Test append operations, file creation if missing
|
||||||
|
5. **Task completion detection** - Test parsing of completion markers (`<loop-complete>`, `<loop-blocked>`)
|
||||||
|
6. **Claude command generation** - Verify correct command string formatting
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
1. **Full loop with mock agent** - Test iteration logic with mocked Claude responses
|
||||||
|
2. **Progress persistence** - Verify progress file survives across iterations
|
||||||
|
3. **Preset workflows** - Verify each preset generates expected prompt structure
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
1. **Real agent execution** (manual/CI) - Run loop with Claude Code on a test project
|
||||||
|
2. **Completion detection** - Verify loop stops when completion marker detected
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
1. Users can run loop with a single command
|
||||||
|
2. Built-in presets provide immediate value without configuration
|
||||||
|
3. Loop correctly identifies when tasks/goals are complete
|
||||||
|
4. Progress is maintained across iterations
|
||||||
|
5. Custom prompts work for domain-specific workflows
|
||||||
|
6. Integration with existing Task Master commands (next, show, set-status)
|
||||||
|
|
||||||
|
## Future Enhancements (v2)
|
||||||
|
|
||||||
|
1. **Multi-agent support** - Add codex, opencode, and custom agent support
|
||||||
|
2. **Sandbox mode** (`--sandbox`) - Run Claude Code inside a Docker container for isolation (e.g., `docker sandbox run claude -p "<prompt>"`)
|
||||||
|
3. **Structured activity log** - Replace progress.txt with `.taskmaster/activity.jsonl` or global `~/.taskmaster/workspaces/<project>/activity.jsonl` for structured logging across iterations
|
||||||
|
4. **Parallel Loop** - Run multiple loop instances on different task tags
|
||||||
|
5. **Terminal UI integration** - Monitor loop progress from Task Master terminal UI
|
||||||
|
6. **Slack/Discord notifications** - Notify channels on completion
|
||||||
|
7. **Custom preset registration** - Allow users to register their own presets globally
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Matt Pocock's Ralph Wiggum video](https://www.youtube.com/watch?v=example)
|
||||||
|
- [Jeffrey Huntley's original Ralph pattern](https://jeffreyhuntley.com/ralph-wiggum)
|
||||||
|
- [Anthropic: Effective Harnesses for Long-Running Agents](https://www.anthropic.com/research/swe-bench-sonnet)
|
||||||
|
- [AI Hero](https://www.aihero.dev/)
|
||||||
7
.taskmaster/loop-progress.txt
Normal file
7
.taskmaster/loop-progress.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Loop Feature Progress
|
||||||
|
|
||||||
|
## 2026-01-08
|
||||||
|
- 1.1: Created loop module directory and types.ts. All types already implemented: LoopPreset, LoopConfig, LoopIteration, LoopResult, LoopCompletionMarker. index.ts barrel export also present.
|
||||||
|
- 1.2: Added types.spec.ts with compile-time type tests. LoopPreset/LoopConfig were already in types.ts from 1.1. tsc --noEmit passes. Native rollup module issue in env blocked vitest but types are correct.
|
||||||
|
- 1.3: LoopIteration and LoopResult interfaces already defined in types.ts (lines 38-63) from 1.1. Tests present in types.spec.ts (lines 69-129). tsc --noEmit passes. Marked done.
|
||||||
|
- 1.4: LoopCompletionMarker already defined in types.ts (lines 65-73) from 1.1. Tests in types.spec.ts (lines 131-149). tsc --noEmit passes. Marked done.
|
||||||
157
.taskmaster/reports/task-complexity-report_loop.json
Normal file
157
.taskmaster/reports/task-complexity-report_loop.json
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"generatedAt": "2026-01-08T18:01:42.102Z",
|
||||||
|
"tasksAnalyzed": 18,
|
||||||
|
"totalTasks": 18,
|
||||||
|
"analysisCount": 18,
|
||||||
|
"thresholdScore": 5,
|
||||||
|
"projectName": "Taskmaster",
|
||||||
|
"usedResearch": false
|
||||||
|
},
|
||||||
|
"complexityAnalysis": [
|
||||||
|
{
|
||||||
|
"taskId": 1,
|
||||||
|
"taskTitle": "Define Loop Module Types and Interfaces",
|
||||||
|
"complexityScore": 2,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a straightforward type definition task.",
|
||||||
|
"reasoning": "Very straightforward task following established patterns in workflow/types.ts and execution/types.ts. The types are already well-defined in the task details. Simply creating a single types.ts file with type/interface definitions. No business logic, no dependencies, just TypeScript type exports. The test strategy (compile-time checks) is also minimal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 2,
|
||||||
|
"taskTitle": "Create Preset Markdown Files",
|
||||||
|
"complexityScore": 4,
|
||||||
|
"recommendedSubtasks": 3,
|
||||||
|
"expansionPrompt": "Break down into: 1) Create preset directory structure and index.ts export file, 2) Write the 5 markdown preset files (default, test-coverage, linting, duplication, entropy) with proper completion markers and @ syntax, 3) Write snapshot tests to verify preset structure and required markers",
|
||||||
|
"reasoning": "Content creation task requiring 5 markdown files with specific format requirements (completion markers, @ file references, numbered steps). Each preset needs careful design to work correctly with Claude. The index.ts export file is simple. Testing is straightforward with snapshot tests. Moderate complexity due to content design requirements rather than code complexity."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 3,
|
||||||
|
"taskTitle": "Implement Preset Resolution Service",
|
||||||
|
"complexityScore": 4,
|
||||||
|
"recommendedSubtasks": 2,
|
||||||
|
"expansionPrompt": "Break down into: 1) Implement LoopPresetService with isPreset(), isFilePath(), and resolvePrompt() methods including error handling, 2) Write comprehensive unit tests for all methods including edge cases (paths with spaces, various extensions, missing files)",
|
||||||
|
"reasoning": "Service follows existing patterns from workflow/services/. Core logic is simple (string matching, file reading) with clear implementation provided. Main complexity is in edge case handling and proper error messages. Dependencies on Task 1 (types) and Task 2 (presets) must be complete first. Testing is important but straightforward."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 4,
|
||||||
|
"taskTitle": "Implement Progress File Service",
|
||||||
|
"complexityScore": 3,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a focused file I/O service with clear implementation.",
|
||||||
|
"reasoning": "Simple file I/O service using node:fs/promises. Implementation is largely provided in the task details. Methods are straightforward (initializeProgressFile, appendProgress, readProgress, exists). Error handling is simple (file not found returns empty string). No complex business logic. Testing with mock filesystem is standard practice. Very similar to existing file services in the codebase."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 5,
|
||||||
|
"taskTitle": "Implement Loop Completion Detection",
|
||||||
|
"complexityScore": 3,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a focused regex parsing service with clear implementation.",
|
||||||
|
"reasoning": "Simple regex-based parsing service. Two patterns to match (<loop-complete>, <loop-blocked>). Implementation is fully provided in task details. Edge cases (multiple markers, malformed markers, case insensitivity) are well-defined. Testing is comprehensive but straightforward with clear test cases listed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 6,
|
||||||
|
"taskTitle": "Implement Loop Prompt Generator",
|
||||||
|
"complexityScore": 3,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a straightforward string composition service.",
|
||||||
|
"reasoning": "String composition service that combines preset content with context header. Dependencies on LoopPresetService (Task 3) for resolving presets. Implementation is provided. No complex logic - just building a formatted string with iteration number, file references, and optional tag. Testing includes snapshot tests for generated prompts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 7,
|
||||||
|
"taskTitle": "Implement Loop Executor Service",
|
||||||
|
"complexityScore": 6,
|
||||||
|
"recommendedSubtasks": 4,
|
||||||
|
"expansionPrompt": "Break down into: 1) Implement basic spawn mechanism with stdio handling following claude-executor.ts patterns, 2) Implement output capture and completion detection integration, 3) Implement process lifecycle management (stop, error handling, cleanup), 4) Write unit tests with mocked spawn for all scenarios (success, completion markers, errors, timeouts)",
|
||||||
|
"reasoning": "Most complex service in the loop module. Involves child process spawning with spawn(), stdout/stderr handling, process lifecycle management, and integration with LoopCompletionService. Follows patterns from packages/tm-core/src/modules/execution/executors/claude-executor.ts. Needs proper async handling, error recovery, and cleanup. Testing with mocked spawn requires careful setup."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 8,
|
||||||
|
"taskTitle": "Implement Loop Service (Main Orchestrator)",
|
||||||
|
"complexityScore": 7,
|
||||||
|
"recommendedSubtasks": 4,
|
||||||
|
"expansionPrompt": "Break down into: 1) Implement service constructor with DI for all sub-services, 2) Implement main run() loop with iteration tracking and completion detection, 3) Implement stop() functionality and sleep mechanism, 4) Write integration tests with mocked executor covering all termination scenarios (completion marker, blocked marker, max iterations, error)",
|
||||||
|
"reasoning": "Main orchestration service coordinating all other services. Has 6 dependencies (presets, progress, completion, prompt, executor). Complex state management across iterations. Needs to handle async operations, sleep between iterations, early termination conditions, and proper cleanup. Testing requires careful mocking of multiple services. Similar complexity to WorkflowService in the codebase."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 9,
|
||||||
|
"taskTitle": "Create Loop Domain Facade",
|
||||||
|
"complexityScore": 4,
|
||||||
|
"recommendedSubtasks": 2,
|
||||||
|
"expansionPrompt": "Break down into: 1) Implement LoopDomain class with config building, preset operations, and task completion checking, 2) Create index.ts exports and write unit/integration tests for domain facade",
|
||||||
|
"reasoning": "Follows established pattern from WorkflowDomain (157 lines). Thin facade over LoopService with config building, preset exposure, and TasksDomain integration. Dependency injection for TasksDomain via setTasksDomain(). Implementation details provided. Moderate complexity due to cross-domain integration requirements."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 10,
|
||||||
|
"taskTitle": "Integrate Loop Domain into TmCore",
|
||||||
|
"complexityScore": 2,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a straightforward integration task following existing patterns.",
|
||||||
|
"reasoning": "Very simple integration task. TmCore.ts shows exact pattern to follow (import, private property, getter, initialize in initialize()). Only requires: 1 import, 1 private property, 1 getter, ~3 lines in initialize(), and export updates in index.ts. Testing verifies domain is accessible. No complex logic."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 11,
|
||||||
|
"taskTitle": "Implement Loop CLI Command",
|
||||||
|
"complexityScore": 5,
|
||||||
|
"recommendedSubtasks": 3,
|
||||||
|
"expansionPrompt": "Break down into: 1) Implement LoopCommand class extending Commander.Command with all options, 2) Implement executeLoop() with display logic and on-complete command execution, 3) Write unit tests for option parsing/validation and integration tests with mocked TmCore",
|
||||||
|
"reasoning": "CLI command following NextCommand pattern (264 lines). Multiple options to parse (-n, -p, --progress-file, --sleep, --on-complete, -t, --status, --project, --json). Needs display methods for different output formats. On-complete command execution with execAsync. Follows established CLI patterns but has more options than most commands."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 12,
|
||||||
|
"taskTitle": "Register Loop Command in CLI",
|
||||||
|
"complexityScore": 1,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a trivial registration task.",
|
||||||
|
"reasoning": "Trivial task. Add 1 import and 1 entry to the commands array in command-registry.ts (~8 lines). Add 1 export to index.ts. Verify with task-master --help. Pattern is clearly established with 15+ existing commands in the registry."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 13,
|
||||||
|
"taskTitle": "Add Loop MCP Tool",
|
||||||
|
"complexityScore": 4,
|
||||||
|
"recommendedSubtasks": 2,
|
||||||
|
"expansionPrompt": "Break down into: 1) Implement loop_start and loop_presets tools following autopilot tool patterns with Zod schemas, 2) Register tools in MCP server and write unit tests with mocked TmCore",
|
||||||
|
"reasoning": "MCP tools follow clear pattern from apps/mcp/src/tools/autopilot/start.tool.ts. Two tools (loop_start, loop_presets). Zod schema definition, withToolContext wrapper, handleApiResult pattern. Registration in tool registry. Testing with mocked TmCore follows existing patterns."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 14,
|
||||||
|
"taskTitle": "Write Unit Tests for Loop Module",
|
||||||
|
"complexityScore": 5,
|
||||||
|
"recommendedSubtasks": 5,
|
||||||
|
"expansionPrompt": "Break down into: 1) Write tests for loop-preset.service.spec.ts, 2) Write tests for loop-progress.service.spec.ts, 3) Write tests for loop-completion.service.spec.ts, 4) Write tests for loop-prompt.service.spec.ts, 5) Write tests for loop.service.spec.ts with mocked sub-services",
|
||||||
|
"reasoning": "Comprehensive test suite for 5 services. Each service needs its own spec file. Testing patterns established in packages/tm-core/src/modules/**/*.spec.ts. Requires mocking filesystem operations, external processes, and coordinating mocks for the main orchestrator test. Target >80% coverage. Natural parallelism in writing tests for each service."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 15,
|
||||||
|
"taskTitle": "Write Integration Tests for Loop CLI",
|
||||||
|
"complexityScore": 4,
|
||||||
|
"recommendedSubtasks": 2,
|
||||||
|
"expansionPrompt": "Break down into: 1) Set up test infrastructure following apps/cli/tests/integration patterns with temp directory setup and CLI execution helpers, 2) Write integration tests for help, preset listing, and basic loop execution scenarios",
|
||||||
|
"reasoning": "Integration tests follow clear pattern from apps/cli/tests/integration/commands/next.command.test.ts. Setup with temp directories, beforeEach/afterEach hooks, execSync for CLI execution. Main challenge is mocking Claude Code execution for CI. Limited scope due to external dependency on Claude."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 16,
|
||||||
|
"taskTitle": "Add Documentation for Loop Command",
|
||||||
|
"complexityScore": 3,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a focused documentation task with clear structure.",
|
||||||
|
"reasoning": "Documentation following existing mdx patterns in apps/docs/. Clear sections defined: overview, examples, options table, presets, custom prompt guide, progress file format. Low technical complexity but requires clear writing. Examples should match actual implementation. Can reference existing docs for style."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 17,
|
||||||
|
"taskTitle": "Bundle Presets with Package Distribution",
|
||||||
|
"complexityScore": 5,
|
||||||
|
"recommendedSubtasks": 3,
|
||||||
|
"expansionPrompt": "Break down into: 1) Investigate current build setup (tsdown config, package.json files) and determine best bundling approach, 2) Implement chosen approach (either copy assets or inline content), 3) Test that presets are accessible in both development and after npm pack",
|
||||||
|
"reasoning": "Build configuration task with investigation required. Current package.json shows 'files': ['src', 'README.md', 'CHANGELOG.md']. Need to either: add presets to files array and update service to handle bundled paths, OR inline preset content as string constants. Requires understanding of how the monorepo builds and distributes packages. Testing must verify both dev and production paths work."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": 18,
|
||||||
|
"taskTitle": "Add Loop Tool to MCP Tool Tiers",
|
||||||
|
"complexityScore": 2,
|
||||||
|
"recommendedSubtasks": 0,
|
||||||
|
"expansionPrompt": "No expansion needed - this is a straightforward configuration update.",
|
||||||
|
"reasoning": "Simple configuration task. Add tool names to the TOOL_TIERS constant (likely in mcp-server/src/tools/). Update CLAUDE.md documentation table. Test with MCP inspector. Pattern is clearly established for existing tools. Minimal code changes."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"currentTag": "tm-core-phase-1",
|
"currentTag": "loop",
|
||||||
"lastUpdated": "2025-11-28T02:21:32.160Z",
|
"lastUpdated": "2025-11-28T02:21:32.160Z",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"upgradePrompts": {
|
"upgradePrompts": {
|
||||||
@@ -55,5 +55,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"migrationNoticeShown": true
|
"migrationNoticeShown": true,
|
||||||
|
"lastSwitched": "2026-01-08T17:48:52.356Z",
|
||||||
|
"branchTagMapping": {}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@ import { ExportCommand, ExportTagCommand } from './commands/export.command.js';
|
|||||||
import { GenerateCommand } from './commands/generate.command.js';
|
import { GenerateCommand } from './commands/generate.command.js';
|
||||||
// Import all commands
|
// Import all commands
|
||||||
import { ListTasksCommand } from './commands/list.command.js';
|
import { ListTasksCommand } from './commands/list.command.js';
|
||||||
|
import { LoopCommand } from './commands/loop.command.js';
|
||||||
import { LoginCommand } from './commands/login.command.js';
|
import { LoginCommand } from './commands/login.command.js';
|
||||||
import { LogoutCommand } from './commands/logout.command.js';
|
import { LogoutCommand } from './commands/logout.command.js';
|
||||||
import { NextCommand } from './commands/next.command.js';
|
import { NextCommand } from './commands/next.command.js';
|
||||||
@@ -89,6 +90,12 @@ export class CommandRegistry {
|
|||||||
commandClass: AutopilotCommand as any,
|
commandClass: AutopilotCommand as any,
|
||||||
category: 'development'
|
category: 'development'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'loop',
|
||||||
|
description: 'Run Claude Code in a loop, one task per iteration',
|
||||||
|
commandClass: LoopCommand as any,
|
||||||
|
category: 'development'
|
||||||
|
},
|
||||||
|
|
||||||
// Authentication & Context Commands
|
// Authentication & Context Commands
|
||||||
{
|
{
|
||||||
|
|||||||
426
apps/cli/src/commands/loop.command.spec.ts
Normal file
426
apps/cli/src/commands/loop.command.spec.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for LoopCommand
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import {
|
||||||
|
type Mock,
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { LoopCommand } from './loop.command.js';
|
||||||
|
|
||||||
|
// Mock @tm/core
|
||||||
|
vi.mock('@tm/core', () => ({
|
||||||
|
createTmCore: vi.fn(),
|
||||||
|
PRESET_NAMES: [
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock display utilities
|
||||||
|
vi.mock('../utils/display-helpers.js', () => ({
|
||||||
|
displayCommandHeader: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/error-handler.js', () => ({
|
||||||
|
displayError: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/project-root.js', () => ({
|
||||||
|
getProjectRoot: vi.fn().mockReturnValue('/test/project')
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { LoopResult } from '@tm/core';
|
||||||
|
import { createTmCore } from '@tm/core';
|
||||||
|
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||||
|
import { displayError } from '../utils/error-handler.js';
|
||||||
|
import { getProjectRoot } from '../utils/project-root.js';
|
||||||
|
|
||||||
|
describe('LoopCommand', () => {
|
||||||
|
let loopCommand: LoopCommand;
|
||||||
|
let mockTmCore: any;
|
||||||
|
let mockLoopRun: Mock;
|
||||||
|
let consoleLogSpy: any;
|
||||||
|
let processExitSpy: any;
|
||||||
|
|
||||||
|
const createMockResult = (
|
||||||
|
overrides: Partial<LoopResult> = {}
|
||||||
|
): LoopResult => ({
|
||||||
|
iterations: [],
|
||||||
|
totalIterations: 3,
|
||||||
|
tasksCompleted: 2,
|
||||||
|
finalStatus: 'max_iterations',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Re-setup mock return values after clearAllMocks
|
||||||
|
(getProjectRoot as Mock).mockReturnValue('/test/project');
|
||||||
|
|
||||||
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
|
throw new Error('process.exit called');
|
||||||
|
});
|
||||||
|
|
||||||
|
mockLoopRun = vi.fn().mockResolvedValue(createMockResult());
|
||||||
|
mockTmCore = {
|
||||||
|
loop: {
|
||||||
|
run: mockLoopRun,
|
||||||
|
checkSandboxAuth: vi.fn().mockReturnValue(true),
|
||||||
|
runInteractiveAuth: vi.fn(),
|
||||||
|
resolveIterations: vi.fn().mockImplementation((opts) => {
|
||||||
|
// Mirror the real implementation logic for accurate testing
|
||||||
|
if (opts.userIterations !== undefined) return opts.userIterations;
|
||||||
|
if (opts.preset === 'default' && opts.pendingTaskCount > 0)
|
||||||
|
return opts.pendingTaskCount;
|
||||||
|
return 10;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
getStorageType: vi.fn().mockReturnValue('local'),
|
||||||
|
getNext: vi.fn().mockResolvedValue({ id: '1', title: 'Test Task' }),
|
||||||
|
getCount: vi.fn().mockResolvedValue(0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(createTmCore as Mock).mockResolvedValue(mockTmCore);
|
||||||
|
loopCommand = new LoopCommand();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('command registration', () => {
|
||||||
|
it('should create command with correct name', () => {
|
||||||
|
expect(loopCommand.name()).toBe('loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct description', () => {
|
||||||
|
expect(loopCommand.description()).toContain('loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register on parent program via static register()', () => {
|
||||||
|
const program = new Command();
|
||||||
|
const registered = LoopCommand.register(program);
|
||||||
|
|
||||||
|
expect(registered).toBeInstanceOf(LoopCommand);
|
||||||
|
expect(program.commands.find((c) => c.name() === 'loop')).toBe(
|
||||||
|
registered
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow custom name via static register()', () => {
|
||||||
|
const program = new Command();
|
||||||
|
const registered = LoopCommand.register(program, 'custom-loop');
|
||||||
|
|
||||||
|
expect(registered.name()).toBe('custom-loop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('option parsing', () => {
|
||||||
|
it('should have no default for iterations (determined at runtime)', () => {
|
||||||
|
const option = loopCommand.options.find((o) => o.long === '--iterations');
|
||||||
|
expect(option?.defaultValue).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default prompt of "default"', () => {
|
||||||
|
const option = loopCommand.options.find((o) => o.long === '--prompt');
|
||||||
|
expect(option?.defaultValue).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have -n as short flag for iterations', () => {
|
||||||
|
const option = loopCommand.options.find((o) => o.long === '--iterations');
|
||||||
|
expect(option?.short).toBe('-n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have -p as short flag for prompt', () => {
|
||||||
|
const option = loopCommand.options.find((o) => o.long === '--prompt');
|
||||||
|
expect(option?.short).toBe('-p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have -t as short flag for tag', () => {
|
||||||
|
const option = loopCommand.options.find((o) => o.long === '--tag');
|
||||||
|
expect(option?.short).toBe('-t');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have --progress-file option', () => {
|
||||||
|
const option = loopCommand.options.find(
|
||||||
|
(o) => o.long === '--progress-file'
|
||||||
|
);
|
||||||
|
expect(option).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have --project option', () => {
|
||||||
|
const option = loopCommand.options.find((o) => o.long === '--project');
|
||||||
|
expect(option).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateIterations', () => {
|
||||||
|
it('should throw error for invalid iterations (non-numeric)', () => {
|
||||||
|
const validateIterations = (loopCommand as any).validateIterations.bind(
|
||||||
|
loopCommand
|
||||||
|
);
|
||||||
|
expect(() => validateIterations('abc')).toThrow('Invalid iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid iterations (negative)', () => {
|
||||||
|
const validateIterations = (loopCommand as any).validateIterations.bind(
|
||||||
|
loopCommand
|
||||||
|
);
|
||||||
|
expect(() => validateIterations('-5')).toThrow('Invalid iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid iterations (zero)', () => {
|
||||||
|
const validateIterations = (loopCommand as any).validateIterations.bind(
|
||||||
|
loopCommand
|
||||||
|
);
|
||||||
|
expect(() => validateIterations('0')).toThrow('Invalid iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow valid iterations', () => {
|
||||||
|
const validateIterations = (loopCommand as any).validateIterations.bind(
|
||||||
|
loopCommand
|
||||||
|
);
|
||||||
|
expect(() => validateIterations('5')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatStatus', () => {
|
||||||
|
it('should format "all_complete" as green', () => {
|
||||||
|
const formatStatus = (loopCommand as any).formatStatus.bind(loopCommand);
|
||||||
|
const result = formatStatus('all_complete');
|
||||||
|
expect(result).toContain('All tasks complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format "max_iterations" as yellow', () => {
|
||||||
|
const formatStatus = (loopCommand as any).formatStatus.bind(loopCommand);
|
||||||
|
const result = formatStatus('max_iterations');
|
||||||
|
expect(result).toContain('Max iterations reached');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format "blocked" as red', () => {
|
||||||
|
const formatStatus = (loopCommand as any).formatStatus.bind(loopCommand);
|
||||||
|
const result = formatStatus('blocked');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format "error" as red', () => {
|
||||||
|
const formatStatus = (loopCommand as any).formatStatus.bind(loopCommand);
|
||||||
|
const result = formatStatus('error');
|
||||||
|
expect(result).toContain('Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('displayResult', () => {
|
||||||
|
it('should display loop completion summary', () => {
|
||||||
|
const displayResult = (loopCommand as any).displayResult.bind(
|
||||||
|
loopCommand
|
||||||
|
);
|
||||||
|
const mockResult: LoopResult = {
|
||||||
|
iterations: [],
|
||||||
|
totalIterations: 5,
|
||||||
|
tasksCompleted: 3,
|
||||||
|
finalStatus: 'max_iterations'
|
||||||
|
};
|
||||||
|
|
||||||
|
displayResult(mockResult);
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalled();
|
||||||
|
const allOutput = consoleLogSpy.mock.calls.flat().join(' ');
|
||||||
|
expect(allOutput).toContain('Loop Complete');
|
||||||
|
expect(allOutput).toContain('5');
|
||||||
|
expect(allOutput).toContain('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute integration', () => {
|
||||||
|
it('should call tmCore.loop.run with parsed config', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({
|
||||||
|
iterations: '5',
|
||||||
|
prompt: 'test-coverage',
|
||||||
|
tag: 'feature'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLoopRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iterations: 5,
|
||||||
|
prompt: 'test-coverage',
|
||||||
|
tag: 'feature'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display header', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({});
|
||||||
|
|
||||||
|
expect(displayCommandHeader).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call displayError on exception', async () => {
|
||||||
|
const error = new Error('Test error');
|
||||||
|
mockLoopRun.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execute({});
|
||||||
|
} catch {
|
||||||
|
// Expected - processExitSpy mock throws to simulate process.exit
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(displayError).toHaveBeenCalledWith(error, { skipExit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exit with code 1 on error', async () => {
|
||||||
|
const error = new Error('Test error');
|
||||||
|
mockLoopRun.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execute({});
|
||||||
|
} catch {
|
||||||
|
// Expected - processExitSpy mock throws to simulate process.exit
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default values when options not provided and no pending tasks', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
// Mock empty pending tasks count
|
||||||
|
mockTmCore.tasks.getCount.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({});
|
||||||
|
|
||||||
|
expect(mockLoopRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iterations: 10,
|
||||||
|
prompt: 'default'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use pending task count as iterations for default preset', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
// Mock 5 pending items (3 tasks + 2 pending subtasks)
|
||||||
|
mockTmCore.tasks.getCount.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({});
|
||||||
|
|
||||||
|
expect(mockLoopRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iterations: 5,
|
||||||
|
prompt: 'default'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use explicit iterations even for default preset', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
// Mock pending tasks (should be ignored when user provides explicit iterations)
|
||||||
|
mockTmCore.tasks.getCount.mockResolvedValue(10);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({ iterations: '3' });
|
||||||
|
|
||||||
|
expect(mockLoopRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iterations: 3
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to 10 iterations for non-default presets', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({ prompt: 'test-coverage' });
|
||||||
|
|
||||||
|
// getCount should NOT be called for non-default presets
|
||||||
|
expect(mockTmCore.tasks.getCount).not.toHaveBeenCalled();
|
||||||
|
expect(mockLoopRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iterations: 10,
|
||||||
|
prompt: 'test-coverage'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass progressFile to config when provided', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({ progressFile: '/custom/progress.txt' });
|
||||||
|
|
||||||
|
expect(mockLoopRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
progressFile: '/custom/progress.txt'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check sandbox auth before running', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({});
|
||||||
|
|
||||||
|
expect(mockTmCore.loop.checkSandboxAuth).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should run interactive auth when sandbox not ready', async () => {
|
||||||
|
mockTmCore.loop.checkSandboxAuth.mockReturnValue(false);
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({});
|
||||||
|
|
||||||
|
expect(mockTmCore.loop.runInteractiveAuth).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show next task before starting loop', async () => {
|
||||||
|
const result = createMockResult();
|
||||||
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
|
await execute({});
|
||||||
|
|
||||||
|
expect(mockTmCore.tasks.getNext).toHaveBeenCalled();
|
||||||
|
const allOutput = consoleLogSpy.mock.calls.flat().join(' ');
|
||||||
|
expect(allOutput).toContain('Next task');
|
||||||
|
expect(allOutput).toContain('Test Task');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
172
apps/cli/src/commands/loop.command.ts
Normal file
172
apps/cli/src/commands/loop.command.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Loop command - thin CLI wrapper over @tm/core's LoopDomain
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
type LoopConfig,
|
||||||
|
type LoopResult,
|
||||||
|
type TmCore,
|
||||||
|
createTmCore,
|
||||||
|
PRESET_NAMES
|
||||||
|
} from '@tm/core';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||||
|
import { displayError } from '../utils/error-handler.js';
|
||||||
|
import { getProjectRoot } from '../utils/project-root.js';
|
||||||
|
|
||||||
|
export interface LoopCommandOptions {
|
||||||
|
iterations?: string;
|
||||||
|
prompt?: string;
|
||||||
|
progressFile?: string;
|
||||||
|
tag?: string;
|
||||||
|
project?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoopCommand extends Command {
|
||||||
|
private tmCore!: TmCore;
|
||||||
|
|
||||||
|
constructor(name?: string) {
|
||||||
|
super(name || 'loop');
|
||||||
|
|
||||||
|
this.description('Run Claude Code in a loop, one task per iteration')
|
||||||
|
.option('-n, --iterations <number>', 'Maximum iterations')
|
||||||
|
.option(
|
||||||
|
'-p, --prompt <preset|path>',
|
||||||
|
`Preset name (${PRESET_NAMES.join(', ')}) or path to custom prompt file`,
|
||||||
|
'default'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--progress-file <path>',
|
||||||
|
'Path to progress log file',
|
||||||
|
'.taskmaster/progress.txt'
|
||||||
|
)
|
||||||
|
.option('-t, --tag <tag>', 'Only work on tasks with this tag')
|
||||||
|
.option(
|
||||||
|
'--project <path>',
|
||||||
|
'Project root directory (auto-detected if not provided)'
|
||||||
|
)
|
||||||
|
.action((options: LoopCommandOptions) => this.execute(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute(options: LoopCommandOptions): Promise<void> {
|
||||||
|
const prompt = options.prompt || 'default';
|
||||||
|
const progressFile = options.progressFile || '.taskmaster/progress.txt';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectRoot = path.resolve(getProjectRoot(options.project));
|
||||||
|
this.tmCore = await createTmCore({ projectPath: projectRoot });
|
||||||
|
|
||||||
|
// Get pending task count for default preset iteration resolution
|
||||||
|
const pendingTaskCount =
|
||||||
|
prompt === 'default'
|
||||||
|
? await this.tmCore.tasks.getCount('pending', options.tag)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Delegate iteration resolution logic to tm-core
|
||||||
|
const iterations = this.tmCore.loop.resolveIterations({
|
||||||
|
userIterations: options.iterations
|
||||||
|
? parseInt(options.iterations, 10)
|
||||||
|
: undefined,
|
||||||
|
preset: prompt,
|
||||||
|
pendingTaskCount
|
||||||
|
});
|
||||||
|
|
||||||
|
this.validateIterations(String(iterations));
|
||||||
|
|
||||||
|
displayCommandHeader(this.tmCore, {
|
||||||
|
tag: options.tag || 'master',
|
||||||
|
storageType: this.tmCore.tasks.getStorageType()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleSandboxAuth();
|
||||||
|
|
||||||
|
console.log(chalk.cyan('Starting Task Master Loop...'));
|
||||||
|
console.log(chalk.dim(`Preset: ${prompt}`));
|
||||||
|
console.log(chalk.dim(`Max iterations: ${iterations}`));
|
||||||
|
|
||||||
|
// Show next task only for default preset (other presets don't use Task Master tasks)
|
||||||
|
if (prompt === 'default') {
|
||||||
|
const nextTask = await this.tmCore.tasks.getNext(options.tag);
|
||||||
|
if (nextTask) {
|
||||||
|
console.log(
|
||||||
|
chalk.white(
|
||||||
|
`Next task to work on: ${chalk.white(nextTask.id)} - ${nextTask.title}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(chalk.yellow('No pending tasks found'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const config: Partial<LoopConfig> = {
|
||||||
|
iterations,
|
||||||
|
prompt,
|
||||||
|
progressFile,
|
||||||
|
tag: options.tag
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.tmCore.loop.run(config);
|
||||||
|
this.displayResult(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
displayError(error, { skipExit: true });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSandboxAuth(): void {
|
||||||
|
console.log(chalk.dim('Checking sandbox auth...'));
|
||||||
|
const isAuthed = this.tmCore.loop.checkSandboxAuth();
|
||||||
|
|
||||||
|
if (isAuthed) {
|
||||||
|
console.log(chalk.green('✓ Sandbox ready'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(
|
||||||
|
'Sandbox needs authentication. Starting interactive session...'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(chalk.dim('Please complete auth, then Ctrl+C to continue.\n'));
|
||||||
|
|
||||||
|
this.tmCore.loop.runInteractiveAuth();
|
||||||
|
console.log(chalk.green('✓ Auth complete\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateIterations(iterations: string): void {
|
||||||
|
const parsed = Number(iterations);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid iterations: ${iterations}. Must be a positive integer.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private displayResult(result: LoopResult): void {
|
||||||
|
console.log();
|
||||||
|
console.log(chalk.bold('Loop Complete'));
|
||||||
|
console.log(chalk.dim('─'.repeat(40)));
|
||||||
|
console.log(`Total iterations: ${result.totalIterations}`);
|
||||||
|
console.log(`Tasks completed: ${result.tasksCompleted}`);
|
||||||
|
console.log(`Final status: ${this.formatStatus(result.finalStatus)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatStatus(status: LoopResult['finalStatus']): string {
|
||||||
|
const statusMap: Record<LoopResult['finalStatus'], string> = {
|
||||||
|
all_complete: chalk.green('All tasks complete'),
|
||||||
|
max_iterations: chalk.yellow('Max iterations reached'),
|
||||||
|
blocked: chalk.red('Blocked'),
|
||||||
|
error: chalk.red('Error')
|
||||||
|
};
|
||||||
|
return statusMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
static register(program: Command, name?: string): LoopCommand {
|
||||||
|
const cmd = new LoopCommand(name);
|
||||||
|
program.addCommand(cmd);
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export { SetStatusCommand } from './commands/set-status.command.js';
|
|||||||
export { ExportCommand } from './commands/export.command.js';
|
export { ExportCommand } from './commands/export.command.js';
|
||||||
export { TagsCommand } from './commands/tags.command.js';
|
export { TagsCommand } from './commands/tags.command.js';
|
||||||
export { BriefsCommand } from './commands/briefs.command.js';
|
export { BriefsCommand } from './commands/briefs.command.js';
|
||||||
|
export { LoopCommand } from './commands/loop.command.js';
|
||||||
|
|
||||||
// Command Registry
|
// Command Registry
|
||||||
export {
|
export {
|
||||||
|
|||||||
261
apps/cli/tests/integration/commands/loop.command.test.ts
Normal file
261
apps/cli/tests/integration/commands/loop.command.test.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Integration tests for 'task-master loop' command
|
||||||
|
*
|
||||||
|
* Tests the loop command's CLI integration including option parsing,
|
||||||
|
* validation errors, and help text. Note: The loop command spawns
|
||||||
|
* Claude Code which is not available in test environments, so these
|
||||||
|
* tests focus on pre-execution validation and CLI structure.
|
||||||
|
*
|
||||||
|
* @integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getCliBinPath } from '../../helpers/test-utils.js';
|
||||||
|
|
||||||
|
// Capture initial working directory at module load time
|
||||||
|
const initialCwd = process.cwd();
|
||||||
|
|
||||||
|
describe('loop command', () => {
|
||||||
|
let testDir: string;
|
||||||
|
let tasksPath: string;
|
||||||
|
let binPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-loop-test-'));
|
||||||
|
process.chdir(testDir);
|
||||||
|
process.env.TASKMASTER_SKIP_AUTO_UPDATE = '1';
|
||||||
|
|
||||||
|
binPath = getCliBinPath();
|
||||||
|
|
||||||
|
execSync(`node "${binPath}" init --yes`, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
tasksPath = path.join(testDir, '.taskmaster', 'tasks', 'tasks.json');
|
||||||
|
|
||||||
|
// Use fixture to create initial tasks file with some tasks
|
||||||
|
const initialTasks = createTasksFile({
|
||||||
|
tasks: [
|
||||||
|
createTask({
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'A task for testing loop',
|
||||||
|
status: 'pending'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
fs.writeFileSync(tasksPath, JSON.stringify(initialTasks, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
// Restore to the original working directory captured at module load
|
||||||
|
process.chdir(initialCwd);
|
||||||
|
} catch {
|
||||||
|
// Fallback to home directory if initial directory no longer exists
|
||||||
|
process.chdir(os.homedir());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testDir && fs.existsSync(testDir)) {
|
||||||
|
fs.rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete process.env.TASKMASTER_SKIP_AUTO_UPDATE;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runLoop = (args = ''): { output: string; exitCode: number } => {
|
||||||
|
try {
|
||||||
|
const output = execSync(`node "${binPath}" loop ${args}`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
timeout: 5000, // Short timeout since we can't actually run claude
|
||||||
|
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
|
||||||
|
});
|
||||||
|
return { output, exitCode: 0 };
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
output: error.stderr?.toString() || error.stdout?.toString() || '',
|
||||||
|
exitCode: error.status || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runHelp = (): { output: string; exitCode: number } => {
|
||||||
|
try {
|
||||||
|
const output = execSync(`node "${binPath}" loop --help`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
|
||||||
|
});
|
||||||
|
return { output, exitCode: 0 };
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
output: error.stdout?.toString() || error.stderr?.toString() || '',
|
||||||
|
exitCode: error.status || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('command registration', () => {
|
||||||
|
it('should be registered and show in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show description in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output.toLowerCase()).toContain('claude');
|
||||||
|
expect(output.toLowerCase()).toContain('loop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('option documentation', () => {
|
||||||
|
it('should show -n/--iterations option in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('-n');
|
||||||
|
expect(output).toContain('--iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show -p/--prompt option in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('-p');
|
||||||
|
expect(output).toContain('--prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show -t/--tag option in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('-t');
|
||||||
|
expect(output).toContain('--tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show --json option in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('--json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show --progress-file option in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('--progress-file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show --project option in help', () => {
|
||||||
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(output).toContain('--project');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation errors', () => {
|
||||||
|
it('should reject invalid iterations (non-numeric)', () => {
|
||||||
|
const { output, exitCode } = runLoop('-n abc');
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(output.toLowerCase()).toContain('invalid');
|
||||||
|
expect(output.toLowerCase()).toContain('iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid iterations (negative)', () => {
|
||||||
|
const { output, exitCode } = runLoop('-n -5');
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(output.toLowerCase()).toContain('invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid iterations (zero)', () => {
|
||||||
|
const { output, exitCode } = runLoop('-n 0');
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(output.toLowerCase()).toContain('invalid');
|
||||||
|
expect(output.toLowerCase()).toContain('iterations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('option parsing', () => {
|
||||||
|
it('should accept valid iterations', () => {
|
||||||
|
// Command will fail when trying to run claude, but validation should pass
|
||||||
|
const { output } = runLoop('-n 5');
|
||||||
|
|
||||||
|
// Should NOT contain validation error for iterations
|
||||||
|
expect(output.toLowerCase()).not.toContain('invalid iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom prompt preset', () => {
|
||||||
|
const { output } = runLoop('-p test-coverage');
|
||||||
|
|
||||||
|
// Should NOT contain validation error for prompt
|
||||||
|
expect(output.toLowerCase()).not.toContain('invalid prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept tag filter', () => {
|
||||||
|
const { output } = runLoop('-t feature');
|
||||||
|
|
||||||
|
// Should NOT contain validation error for tag
|
||||||
|
expect(output.toLowerCase()).not.toContain('invalid tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept progress-file option', () => {
|
||||||
|
const { output } = runLoop('--progress-file /tmp/test-progress.txt');
|
||||||
|
|
||||||
|
// Should NOT contain validation error for progress-file
|
||||||
|
expect(output.toLowerCase()).not.toContain('invalid progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept multiple options together', () => {
|
||||||
|
const { output } = runLoop('-n 3 -p default -t test');
|
||||||
|
|
||||||
|
// Should NOT contain validation errors
|
||||||
|
expect(output.toLowerCase()).not.toContain('invalid iterations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error messages', () => {
|
||||||
|
it('should show helpful error for invalid iterations', () => {
|
||||||
|
const { output, exitCode } = runLoop('-n invalid');
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
// Should mention what's wrong and what's expected
|
||||||
|
expect(output.toLowerCase()).toContain('iterations');
|
||||||
|
expect(output.toLowerCase()).toContain('positive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('project detection', () => {
|
||||||
|
it('should work in initialized project directory', () => {
|
||||||
|
// The project is already initialized in beforeEach
|
||||||
|
// Command will fail when trying to run claude, but project detection should work
|
||||||
|
const { output } = runLoop('-n 1');
|
||||||
|
|
||||||
|
// Should NOT contain "not a task-master project" or similar
|
||||||
|
expect(output.toLowerCase()).not.toContain('not initialized');
|
||||||
|
expect(output.toLowerCase()).not.toContain('no project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept --project option for explicit path', () => {
|
||||||
|
const { output } = runLoop(`--project "${testDir}" -n 1`);
|
||||||
|
|
||||||
|
// Should NOT contain validation error for project path
|
||||||
|
expect(output.toLowerCase()).not.toContain('invalid project');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
441
apps/docs/capabilities/loop.mdx
Normal file
441
apps/docs/capabilities/loop.mdx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
---
|
||||||
|
title: Loop Command
|
||||||
|
sidebarTitle: "Loop Command"
|
||||||
|
description: "Run Claude Code in autonomous loops for batch task completion"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Loop Command
|
||||||
|
|
||||||
|
The `loop` command runs Claude Code in autonomous loops, spawning fresh agent sessions for each iteration. This approach, known as the "Ralph Wiggum pattern" (credited to Jeffrey Huntley, popularized by Matt Pocock), maintains context quality by avoiding long-running sessions that accumulate context fatigue.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Instead of a single long-running agent session that degrades over time, the loop command:
|
||||||
|
|
||||||
|
1. **Spawns fresh sessions** - Each iteration starts with a clean context
|
||||||
|
2. **Uses preset prompts** - Built-in or custom prompts guide each iteration
|
||||||
|
3. **Tracks progress** - A progress file persists notes across sessions
|
||||||
|
4. **Detects completion** - Special markers signal when work is done or blocked
|
||||||
|
|
||||||
|
This pattern is ideal for batch operations like clearing task backlogs, improving test coverage, or fixing lint errors across a codebase.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Run 10 iterations with the default task-completion preset:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Run 5 iterations with the test-coverage preset:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 5 --prompt test-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Run iterations with a custom prompt file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt ./my-custom-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter to specific task tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --tag backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Each loop iteration:
|
||||||
|
|
||||||
|
1. Generates a prompt combining context header + preset content
|
||||||
|
2. Spawns `claude -p <prompt>` as a fresh subprocess
|
||||||
|
3. Captures output and checks for completion markers
|
||||||
|
4. Appends progress notes to the progress file
|
||||||
|
5. Sleeps before the next iteration (configurable)
|
||||||
|
|
||||||
|
The loop exits early when:
|
||||||
|
- An agent outputs `<loop-complete>REASON</loop-complete>` - all work done
|
||||||
|
- An agent outputs `<loop-blocked>REASON</loop-blocked>` - cannot proceed
|
||||||
|
- Maximum iterations reached
|
||||||
|
|
||||||
|
## Progress File
|
||||||
|
|
||||||
|
Each iteration's agent has access to a shared progress file (default: `.taskmaster/loop-progress.txt`) via the `@` file reference syntax. Agents append notes about what they completed, allowing subsequent iterations to build on previous work.
|
||||||
|
|
||||||
|
Example progress file content:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Loop Progress - Started 2025-01-09 10:30:00
|
||||||
|
# Preset: test-coverage | Iterations: 10
|
||||||
|
|
||||||
|
[10:30:45] Iteration 1 (Task 5.2): Added unit tests for UserService
|
||||||
|
[10:35:22] Iteration 2 (Task 5.3): Added integration tests for auth flow
|
||||||
|
[10:40:18] Iteration 3: All pending test tasks complete
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
| ------------------------ | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `-n, --iterations` | number | `10` | Maximum number of iterations to run. |
|
||||||
|
| `-p, --prompt` | string | `default` | Preset name or path to a custom prompt file. Accepts: `default`, `test-coverage`, `linting`, `duplication`, `entropy`, or a file path like `./my-prompt.md`. |
|
||||||
|
| `--progress-file` | string | `.taskmaster/loop-progress.txt` | Path to the progress log file where iteration notes are appended. |
|
||||||
|
| `--sleep` | number | `5` | Seconds to wait between iterations. Gives the system time to settle before spawning the next agent. |
|
||||||
|
| `--on-complete` | string | - | Shell command to run when all tasks complete (status becomes `all_complete`). Example: `--on-complete 'notify-send "Done!"'` |
|
||||||
|
| `-t, --tag` | string | - | Filter to only work on tasks with this tag. |
|
||||||
|
| `--status` | string | `pending` | Filter to only work on tasks with this status. |
|
||||||
|
| `--project` | string | - | Project root directory. Auto-detected if not provided. |
|
||||||
|
| `--json` | flag | - | Output results as JSON instead of formatted text. |
|
||||||
|
|
||||||
|
### Option Details
|
||||||
|
|
||||||
|
**`--prompt` accepts two types of values:**
|
||||||
|
- **Preset names**: Use built-in presets like `default`, `test-coverage`, `linting`, `duplication`, or `entropy`
|
||||||
|
- **File paths**: Provide a path to a custom prompt file (e.g., `./my-custom-loop.md`)
|
||||||
|
|
||||||
|
**`--on-complete` runs only on success:**
|
||||||
|
The shell command only executes when `finalStatus` is `all_complete`, meaning an agent output the `<loop-complete>` marker. It does not run on `max_iterations`, `blocked`, or `error` outcomes.
|
||||||
|
|
||||||
|
## Built-in Presets
|
||||||
|
|
||||||
|
The loop command includes five built-in presets for common development workflows. Each preset guides agents to complete ONE focused task per iteration.
|
||||||
|
|
||||||
|
| Preset | Purpose | Completion Marker |
|
||||||
|
| ------ | ------- | ----------------- |
|
||||||
|
| `default` | Complete tasks from Task Master backlog | `ALL_TASKS_DONE` |
|
||||||
|
| `test-coverage` | Write meaningful tests for uncovered code | `COVERAGE_TARGET` |
|
||||||
|
| `linting` | Fix lint and type errors one by one | `ZERO_ERRORS` |
|
||||||
|
| `duplication` | Refactor duplicated code into shared utilities | `LOW_DUPLICATION` |
|
||||||
|
| `entropy` | Clean up code smells (long functions, deep nesting) | `LOW_ENTROPY` |
|
||||||
|
|
||||||
|
### default
|
||||||
|
|
||||||
|
The standard task completion workflow. Agents work through your Task Master backlog, completing one task per iteration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt default
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Runs `task-master next` to get the highest priority available task
|
||||||
|
2. Reads task details with `task-master show <id>`
|
||||||
|
3. Implements the task with the smallest possible change
|
||||||
|
4. Runs tests and type checks to verify quality
|
||||||
|
5. Marks the task complete with `task-master set-status --id=<id> --status=done`
|
||||||
|
6. Commits work with a descriptive message
|
||||||
|
|
||||||
|
**Completion criteria:** All pending tasks are complete.
|
||||||
|
|
||||||
|
### test-coverage
|
||||||
|
|
||||||
|
Improves test coverage by writing meaningful tests for user-facing behavior - not just chasing coverage numbers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt test-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Runs the coverage command (`npm run coverage`, etc.)
|
||||||
|
2. Identifies the most important user-facing feature lacking tests
|
||||||
|
3. Writes ONE meaningful test validating real behavior
|
||||||
|
4. Verifies coverage increased as a side effect
|
||||||
|
5. Commits with message: `test(<file>): <describe user behavior tested>`
|
||||||
|
|
||||||
|
**Philosophy:** Don't write tests just to increase coverage. Use coverage as a guide to find untested user-facing behavior. Prioritize error handling, CLI commands, API endpoints, and file parsing over internal utilities.
|
||||||
|
|
||||||
|
**Completion criteria:** Coverage reaches target or 100%.
|
||||||
|
|
||||||
|
### linting
|
||||||
|
|
||||||
|
Cleans up lint errors and type errors systematically, one fix per iteration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 20 --prompt linting
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Runs lint command (`npm run lint`, `eslint .`, etc.)
|
||||||
|
2. Runs type check (`tsc --noEmit`, etc.)
|
||||||
|
3. Picks ONE error to fix, prioritizing:
|
||||||
|
- Type errors (breaks builds)
|
||||||
|
- Security-related lint errors
|
||||||
|
- Errors in frequently-changed files
|
||||||
|
4. Fixes with minimal changes - no surrounding refactoring
|
||||||
|
5. Commits with message: `fix(<file>): <describe error fixed>`
|
||||||
|
|
||||||
|
**Completion criteria:** Zero lint errors and zero type errors.
|
||||||
|
|
||||||
|
### duplication
|
||||||
|
|
||||||
|
Finds duplicated code using detection tools and refactors into shared utilities.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt duplication
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Runs duplication detection (`npx jscpd .` or similar)
|
||||||
|
2. Reviews the report and picks ONE clone to refactor, prioritizing:
|
||||||
|
- Larger clones (more lines = more maintenance burden)
|
||||||
|
- Clones in frequently-changed files
|
||||||
|
- Clones with slight variations
|
||||||
|
3. Extracts duplicated code into a shared utility
|
||||||
|
4. Updates all clone locations to use the shared utility
|
||||||
|
5. Runs tests to verify behavior is preserved
|
||||||
|
|
||||||
|
**Completion criteria:** Duplication below threshold (e.g., <3%).
|
||||||
|
|
||||||
|
### entropy
|
||||||
|
|
||||||
|
Targets code smells that increase cognitive load and maintenance burden.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt entropy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code smells targeted:**
|
||||||
|
- Long functions (>60 lines) → extract into smaller functions
|
||||||
|
- Deep nesting (>3 levels) → use early returns, extract conditions
|
||||||
|
- Large files (>500 lines) → split into focused modules
|
||||||
|
- Magic numbers → extract into named constants
|
||||||
|
- Complex conditionals → extract into well-named functions
|
||||||
|
- God classes → split responsibilities
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Scans codebase for code smells (uses judgment or tools like `complexity-report`)
|
||||||
|
2. Picks ONE smell to fix, prioritizing smells in critical paths
|
||||||
|
3. Refactors with minimal changes - no over-engineering
|
||||||
|
4. Runs tests to verify behavior is preserved
|
||||||
|
|
||||||
|
**Completion criteria:** No significant code smells remain.
|
||||||
|
|
||||||
|
## Custom Prompts
|
||||||
|
|
||||||
|
You can create custom prompt files for specialized workflows that aren't covered by the built-in presets.
|
||||||
|
|
||||||
|
### Specifying a Custom Prompt
|
||||||
|
|
||||||
|
Pass the file path to the `--prompt` option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt ./my-custom-loop.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The path can be absolute or relative to the current directory.
|
||||||
|
|
||||||
|
### Required Structure
|
||||||
|
|
||||||
|
Custom prompts should follow this structure to work effectively with the loop system:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Your Loop Title
|
||||||
|
|
||||||
|
Brief description of what this loop does.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/tasks/tasks.json - Your task backlog
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log from previous iterations
|
||||||
|
- @path/to/other/file.ts - Any other files the agent needs
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Step one of the workflow
|
||||||
|
2. Step two of the workflow
|
||||||
|
3. ... more steps
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE task per session
|
||||||
|
- Keep changes small and focused
|
||||||
|
- Do NOT start another task after completing one
|
||||||
|
- If work is complete, output: <loop-complete>REASON</loop-complete>
|
||||||
|
- If blocked, output: <loop-blocked>REASON</loop-blocked>
|
||||||
|
```
|
||||||
|
|
||||||
|
### File References with @ Syntax
|
||||||
|
|
||||||
|
The `@` prefix makes files available to the agent. Claude Code will read these files when processing the prompt:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @src/index.ts - Main entry point
|
||||||
|
- @package.json - Dependencies and scripts
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress from previous iterations
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the `@` prefix, file paths are just text. With `@`, the agent can actually read and reference the file contents.
|
||||||
|
|
||||||
|
### Completion Markers
|
||||||
|
|
||||||
|
The loop watches for two special markers in agent output:
|
||||||
|
|
||||||
|
| Marker | Purpose | Example |
|
||||||
|
| ------ | ------- | ------- |
|
||||||
|
| `<loop-complete>REASON</loop-complete>` | Signal that all work is done | `<loop-complete>ALL_TESTS_PASSING</loop-complete>` |
|
||||||
|
| `<loop-blocked>REASON</loop-blocked>` | Signal that work cannot proceed | `<loop-blocked>MISSING_API_KEY</loop-blocked>` |
|
||||||
|
|
||||||
|
When either marker is detected, the loop exits early. The reason text is captured in the final result.
|
||||||
|
|
||||||
|
**Example usage in a custom prompt:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- If all migrations are complete, output: <loop-complete>MIGRATIONS_DONE</loop-complete>
|
||||||
|
- If a migration fails with an unrecoverable error, output: <loop-blocked>MIGRATION_FAILED: describe error</loop-blocked>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Be specific**: Clearly define what "done" means for your workflow
|
||||||
|
2. **One task per iteration**: Instruct agents to complete exactly one unit of work
|
||||||
|
3. **Use the progress file**: Reference `@.taskmaster/loop-progress.txt` so agents can see what previous iterations accomplished
|
||||||
|
4. **Include quality checks**: Add steps for running tests or type checks before completing
|
||||||
|
5. **Define completion criteria**: Tell agents exactly when to output `<loop-complete>`
|
||||||
|
6. **Handle edge cases**: Define when to output `<loop-blocked>` to avoid infinite loops
|
||||||
|
|
||||||
|
## Progress File
|
||||||
|
|
||||||
|
The progress file is a shared log that persists notes across loop iterations. Since each iteration spawns a fresh agent session, this file provides continuity.
|
||||||
|
|
||||||
|
### Default Location
|
||||||
|
|
||||||
|
```
|
||||||
|
.taskmaster/loop-progress.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Override with the `--progress-file` option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --progress-file ./my-progress.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Initialization**: The loop creates the progress file at the start with a header
|
||||||
|
2. **Each iteration**: Agents read the file via `@` reference and append notes about their work
|
||||||
|
3. **Context continuity**: Subsequent agents see what previous iterations accomplished
|
||||||
|
|
||||||
|
### File Format
|
||||||
|
|
||||||
|
The progress file uses a simple timestamped format:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Loop Progress - Started 2025-01-09 10:30:00
|
||||||
|
# Preset: default | Iterations: 10 | Tag: backend
|
||||||
|
|
||||||
|
[10:30:45] Iteration 1 (Task 5.2): Implemented user authentication endpoint
|
||||||
|
[10:35:22] Iteration 2 (Task 5.3): Added input validation for login form
|
||||||
|
[10:40:18] Iteration 3 (Task 5.4): Fixed edge case in token refresh logic
|
||||||
|
[10:45:01] Iteration 4: All pending backend tasks complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format breakdown:**
|
||||||
|
- **Header**: Timestamp, preset name, max iterations, optional tag filter
|
||||||
|
- **Entries**: `[HH:MM:SS] Iteration N (Task ID): Description`
|
||||||
|
- **Task ID**: Optional - included when agent worked on a specific task
|
||||||
|
|
||||||
|
### Using Progress in Custom Prompts
|
||||||
|
|
||||||
|
Always include the progress file in your `## Files Available` section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log from previous iterations
|
||||||
|
```
|
||||||
|
|
||||||
|
And instruct agents to append notes:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Process
|
||||||
|
|
||||||
|
...
|
||||||
|
7. Append a brief note to the progress file about what was done
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a chain of context that helps each iteration build on previous work.
|
||||||
|
|
||||||
|
## Real-World Examples
|
||||||
|
|
||||||
|
Here are practical scenarios for using the loop command in your development workflow.
|
||||||
|
|
||||||
|
### Weekend Automation
|
||||||
|
|
||||||
|
Run a loop overnight or over the weekend to clear your task backlog while you're away:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 50 --prompt default --on-complete 'notify-send "Loop complete!"'
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs up to 50 iterations, completing tasks one by one. When all tasks are done, you'll get a desktop notification.
|
||||||
|
|
||||||
|
### Test Coverage Sprint
|
||||||
|
|
||||||
|
Push your test coverage from 60% to 80% systematically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 20 --prompt test-coverage --on-complete 'echo "Coverage target reached!"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each iteration identifies the most important untested user-facing behavior and writes a meaningful test. The agent prioritizes error handling, API endpoints, and CLI commands over internal utilities.
|
||||||
|
|
||||||
|
### Tech Debt Day
|
||||||
|
|
||||||
|
Dedicate a day to cleaning up lint errors across your codebase:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 30 --prompt linting --sleep 3
|
||||||
|
```
|
||||||
|
|
||||||
|
With `--sleep 3`, iterations run faster for quick fixes. The agent systematically addresses type errors first (they break builds), then security-related lint errors, then others.
|
||||||
|
|
||||||
|
### Code Review Prep
|
||||||
|
|
||||||
|
Reduce code duplication before opening a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task-master loop -n 10 --prompt duplication --tag backend
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--tag backend` flag focuses the loop on backend code only. Each iteration finds and refactors one instance of duplicated code into a shared utility.
|
||||||
|
|
||||||
|
### Multi-Tag Workflow
|
||||||
|
|
||||||
|
Process different parts of your codebase in sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, handle critical backend tasks
|
||||||
|
task-master loop -n 10 --tag backend --status pending
|
||||||
|
|
||||||
|
# Then, address frontend tasks
|
||||||
|
task-master loop -n 10 --tag frontend --status pending
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Migration Script
|
||||||
|
|
||||||
|
For complex migrations, create a custom prompt and run it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create custom-migration.md with your specific steps
|
||||||
|
task-master loop -n 20 --prompt ./custom-migration.md --progress-file ./migration-progress.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a separate progress file to track migration-specific notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**Related documentation:**
|
||||||
|
- [CLI Root Commands](/capabilities/cli-root-commands) - Full command reference
|
||||||
|
- [TDD Workflow](/tdd-workflow/quickstart) - Structured test-driven development
|
||||||
|
- [Task Structure](/capabilities/task-structure) - Understanding task organization
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
The loop command is perfect for "overnight automation" - start a loop before leaving and return to a cleaned-up codebase.
|
||||||
|
</Tip>
|
||||||
@@ -246,4 +246,38 @@ description: "A comprehensive reference of all available Task Master commands"
|
|||||||
|
|
||||||
The TDD workflow enforces RED → GREEN → COMMIT cycles for each subtask. See [AI Agent Integration](/tdd-workflow/ai-agent-integration) for details.
|
The TDD workflow enforces RED → GREEN → COMMIT cycles for each subtask. See [AI Agent Integration](/tdd-workflow/ai-agent-integration) for details.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="Loop (Autonomous Batch Execution)">
|
||||||
|
```bash
|
||||||
|
# Run 10 iterations with the default preset
|
||||||
|
task-master loop -n 10
|
||||||
|
|
||||||
|
# Use a specific preset
|
||||||
|
task-master loop -n 10 --prompt test-coverage
|
||||||
|
task-master loop -n 10 --prompt linting
|
||||||
|
task-master loop -n 10 --prompt duplication
|
||||||
|
task-master loop -n 10 --prompt entropy
|
||||||
|
|
||||||
|
# Use a custom prompt file
|
||||||
|
task-master loop -n 10 --prompt ./my-workflow.md
|
||||||
|
|
||||||
|
# Filter to tasks with a specific tag
|
||||||
|
task-master loop -n 10 --tag backend
|
||||||
|
|
||||||
|
# Custom progress file location
|
||||||
|
task-master loop -n 10 --progress-file ./my-progress.txt
|
||||||
|
|
||||||
|
# Output as JSON
|
||||||
|
task-master loop -n 10 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Built-in presets:**
|
||||||
|
- `default` - Complete tasks from Task Master backlog one at a time
|
||||||
|
- `test-coverage` - Write meaningful tests for untested user-facing behavior
|
||||||
|
- `linting` - Fix lint and type errors incrementally
|
||||||
|
- `duplication` - Refactor duplicated code into shared utilities
|
||||||
|
- `entropy` - Clean up code smells (long functions, deep nesting, etc.)
|
||||||
|
|
||||||
|
Each iteration spawns a fresh Claude Code session, avoiding context fatigue. See [Loop Command](/capabilities/loop) for full documentation.
|
||||||
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"capabilities/mcp",
|
"capabilities/mcp",
|
||||||
"capabilities/cli-root-commands",
|
"capabilities/cli-root-commands",
|
||||||
"capabilities/task-structure"
|
"capabilities/task-structure",
|
||||||
|
"capabilities/loop"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const coreTools = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard tools array containing the 15 most commonly used tools
|
* Standard tools array containing the 14 most commonly used tools
|
||||||
* Includes all core tools plus frequently used additional tools
|
* Includes all core tools plus frequently used additional tools
|
||||||
*/
|
*/
|
||||||
export const standardTools = [
|
export const standardTools = [
|
||||||
|
|||||||
@@ -147,6 +147,15 @@ export type {
|
|||||||
TaskComplexityData
|
TaskComplexityData
|
||||||
} from './modules/reports/types.js';
|
} from './modules/reports/types.js';
|
||||||
|
|
||||||
|
// Loop types
|
||||||
|
export type {
|
||||||
|
LoopPreset,
|
||||||
|
LoopConfig,
|
||||||
|
LoopIteration,
|
||||||
|
LoopResult
|
||||||
|
} from './modules/loop/index.js';
|
||||||
|
export { LoopDomain, PRESET_NAMES } from './modules/loop/index.js';
|
||||||
|
|
||||||
// Prompts types
|
// Prompts types
|
||||||
export type {
|
export type {
|
||||||
PromptAction,
|
PromptAction,
|
||||||
|
|||||||
32
packages/tm-core/src/modules/loop/index.ts
Normal file
32
packages/tm-core/src/modules/loop/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Loop module exports
|
||||||
|
* Simplified API: LoopDomain (facade), LoopService (logic), Presets (content)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Domain facade - primary public API
|
||||||
|
export { LoopDomain } from './loop-domain.js';
|
||||||
|
|
||||||
|
// Service - for advanced usage
|
||||||
|
export { LoopService } from './services/loop.service.js';
|
||||||
|
export type { LoopServiceOptions } from './services/loop.service.js';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
LoopPreset,
|
||||||
|
LoopConfig,
|
||||||
|
LoopIteration,
|
||||||
|
LoopResult
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// Presets - content and helpers
|
||||||
|
export {
|
||||||
|
PRESETS,
|
||||||
|
PRESET_NAMES,
|
||||||
|
getPreset,
|
||||||
|
isPreset,
|
||||||
|
DEFAULT_PRESET,
|
||||||
|
TEST_COVERAGE_PRESET,
|
||||||
|
LINTING_PRESET,
|
||||||
|
DUPLICATION_PRESET,
|
||||||
|
ENTROPY_PRESET
|
||||||
|
} from './presets/index.js';
|
||||||
278
packages/tm-core/src/modules/loop/loop-domain.spec.ts
Normal file
278
packages/tm-core/src/modules/loop/loop-domain.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for LoopDomain
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LoopDomain } from './loop-domain.js';
|
||||||
|
import type { ConfigManager } from '../config/managers/config-manager.js';
|
||||||
|
import type { LoopConfig } from './types.js';
|
||||||
|
|
||||||
|
// Mock ConfigManager
|
||||||
|
function createMockConfigManager(projectRoot = '/test/project'): ConfigManager {
|
||||||
|
return {
|
||||||
|
getProjectRoot: vi.fn().mockReturnValue(projectRoot)
|
||||||
|
} as unknown as ConfigManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoopDomain', () => {
|
||||||
|
let mockConfigManager: ConfigManager;
|
||||||
|
let loopDomain: LoopDomain;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfigManager = createMockConfigManager();
|
||||||
|
loopDomain = new LoopDomain(mockConfigManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create instance with ConfigManager', () => {
|
||||||
|
expect(loopDomain).toBeInstanceOf(LoopDomain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store projectRoot from ConfigManager', () => {
|
||||||
|
const customManager = createMockConfigManager('/custom/root');
|
||||||
|
const domain = new LoopDomain(customManager);
|
||||||
|
// Verify by checking buildConfig output
|
||||||
|
const config = (domain as any).buildConfig({});
|
||||||
|
expect(config.progressFile).toBe('/custom/root/.taskmaster/progress.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call getProjectRoot on ConfigManager', () => {
|
||||||
|
expect(mockConfigManager.getProjectRoot).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildConfig', () => {
|
||||||
|
it('should apply default iterations of 10', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({});
|
||||||
|
expect(config.iterations).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply default prompt of "default"', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({});
|
||||||
|
expect(config.prompt).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply default sleepSeconds of 5', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({});
|
||||||
|
expect(config.sleepSeconds).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct progressFile from projectRoot', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({});
|
||||||
|
expect(config.progressFile).toBe(
|
||||||
|
'/test/project/.taskmaster/progress.txt'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect provided iterations', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({ iterations: 20 });
|
||||||
|
expect(config.iterations).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect provided prompt', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({
|
||||||
|
prompt: 'test-coverage'
|
||||||
|
});
|
||||||
|
expect(config.prompt).toBe('test-coverage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect provided sleepSeconds', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({ sleepSeconds: 10 });
|
||||||
|
expect(config.sleepSeconds).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect provided progressFile', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({
|
||||||
|
progressFile: '/custom/progress.txt'
|
||||||
|
});
|
||||||
|
expect(config.progressFile).toBe('/custom/progress.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect provided tag', () => {
|
||||||
|
const config = (loopDomain as any).buildConfig({ tag: 'my-tag' });
|
||||||
|
expect(config.tag).toBe('my-tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all options combined', () => {
|
||||||
|
const fullConfig: Partial<LoopConfig> = {
|
||||||
|
iterations: 5,
|
||||||
|
prompt: 'linting',
|
||||||
|
progressFile: '/my/progress.txt',
|
||||||
|
sleepSeconds: 2,
|
||||||
|
tag: 'feature-branch'
|
||||||
|
};
|
||||||
|
const config = (loopDomain as any).buildConfig(fullConfig);
|
||||||
|
expect(config).toEqual(fullConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPreset', () => {
|
||||||
|
it('should return true for valid preset "default"', () => {
|
||||||
|
expect(loopDomain.isPreset('default')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for valid preset "test-coverage"', () => {
|
||||||
|
expect(loopDomain.isPreset('test-coverage')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for valid preset "linting"', () => {
|
||||||
|
expect(loopDomain.isPreset('linting')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for valid preset "duplication"', () => {
|
||||||
|
expect(loopDomain.isPreset('duplication')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for valid preset "entropy"', () => {
|
||||||
|
expect(loopDomain.isPreset('entropy')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid preset', () => {
|
||||||
|
expect(loopDomain.isPreset('invalid-preset')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for file path', () => {
|
||||||
|
expect(loopDomain.isPreset('/path/to/prompt.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty string', () => {
|
||||||
|
expect(loopDomain.isPreset('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAvailablePresets', () => {
|
||||||
|
it('should return array of all preset names', () => {
|
||||||
|
const presets = loopDomain.getAvailablePresets();
|
||||||
|
expect(presets).toHaveLength(5);
|
||||||
|
expect(presets).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePrompt', () => {
|
||||||
|
it('should resolve preset name to content', async () => {
|
||||||
|
const content = await loopDomain.resolvePrompt('default');
|
||||||
|
expect(typeof content).toBe('string');
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
// Assert on structural property - all presets should contain completion marker
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve all preset names', async () => {
|
||||||
|
const presets = loopDomain.getAvailablePresets();
|
||||||
|
for (const preset of presets) {
|
||||||
|
const content = await loopDomain.resolvePrompt(preset);
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for custom path without readFile', async () => {
|
||||||
|
await expect(
|
||||||
|
loopDomain.resolvePrompt('/custom/prompt.md')
|
||||||
|
).rejects.toThrow('readFile callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use readFile for custom paths', async () => {
|
||||||
|
const mockReadFile = vi.fn().mockResolvedValue('Custom prompt content');
|
||||||
|
const content = await loopDomain.resolvePrompt(
|
||||||
|
'/custom/prompt.md',
|
||||||
|
mockReadFile
|
||||||
|
);
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('/custom/prompt.md');
|
||||||
|
expect(content).toBe('Custom prompt content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIsRunning', () => {
|
||||||
|
it('should return false when no loop is running', () => {
|
||||||
|
expect(loopDomain.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false after stop() when no loop was started', () => {
|
||||||
|
loopDomain.stop();
|
||||||
|
expect(loopDomain.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('should not throw when called without starting a loop', () => {
|
||||||
|
expect(() => loopDomain.stop()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be callable multiple times', () => {
|
||||||
|
loopDomain.stop();
|
||||||
|
loopDomain.stop();
|
||||||
|
expect(loopDomain.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveIterations', () => {
|
||||||
|
it('should return userIterations when provided', () => {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
userIterations: 25,
|
||||||
|
preset: 'default',
|
||||||
|
pendingTaskCount: 10
|
||||||
|
});
|
||||||
|
expect(result).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return pendingTaskCount for default preset when no userIterations', () => {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
preset: 'default',
|
||||||
|
pendingTaskCount: 15
|
||||||
|
});
|
||||||
|
expect(result).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 10 for default preset when pendingTaskCount is 0', () => {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
preset: 'default',
|
||||||
|
pendingTaskCount: 0
|
||||||
|
});
|
||||||
|
expect(result).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 10 for default preset when pendingTaskCount is undefined', () => {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
preset: 'default'
|
||||||
|
});
|
||||||
|
expect(result).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 10 for non-default presets regardless of pendingTaskCount', () => {
|
||||||
|
const presets = ['test-coverage', 'linting', 'duplication', 'entropy'];
|
||||||
|
for (const preset of presets) {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
preset,
|
||||||
|
pendingTaskCount: 50
|
||||||
|
});
|
||||||
|
expect(result).toBe(10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize userIterations over pendingTaskCount for default preset', () => {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
userIterations: 5,
|
||||||
|
preset: 'default',
|
||||||
|
pendingTaskCount: 100
|
||||||
|
});
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize userIterations for non-default presets', () => {
|
||||||
|
const result = loopDomain.resolveIterations({
|
||||||
|
userIterations: 30,
|
||||||
|
preset: 'linting'
|
||||||
|
});
|
||||||
|
expect(result).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
194
packages/tm-core/src/modules/loop/loop-domain.ts
Normal file
194
packages/tm-core/src/modules/loop/loop-domain.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Loop Domain Facade
|
||||||
|
* Public API for loop operations following the pattern of other domains
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { getLogger } from '../../common/logger/index.js';
|
||||||
|
import type { ConfigManager } from '../config/managers/config-manager.js';
|
||||||
|
import {
|
||||||
|
PRESET_NAMES,
|
||||||
|
isPreset as checkIsPreset,
|
||||||
|
getPreset
|
||||||
|
} from './presets/index.js';
|
||||||
|
import { LoopService } from './services/loop.service.js';
|
||||||
|
import type { LoopConfig, LoopPreset, LoopResult } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop Domain - Unified API for loop operations
|
||||||
|
* Coordinates LoopService with lazy instantiation
|
||||||
|
*/
|
||||||
|
export class LoopDomain {
|
||||||
|
private readonly logger = getLogger('LoopDomain');
|
||||||
|
private loopService: LoopService | null = null;
|
||||||
|
private readonly projectRoot: string;
|
||||||
|
|
||||||
|
constructor(configManager: ConfigManager) {
|
||||||
|
this.projectRoot = configManager.getProjectRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sandbox Auth Operations ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Docker sandbox auth is ready
|
||||||
|
* @returns true if ready, false if auth needed
|
||||||
|
*/
|
||||||
|
checkSandboxAuth(): boolean {
|
||||||
|
const service = new LoopService({ projectRoot: this.projectRoot });
|
||||||
|
return service.checkSandboxAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Docker sandbox session for user authentication
|
||||||
|
* Blocks until user completes auth
|
||||||
|
*/
|
||||||
|
runInteractiveAuth(): void {
|
||||||
|
const service = new LoopService({ projectRoot: this.projectRoot });
|
||||||
|
service.runInteractiveAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Loop Operations ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a loop with the given configuration
|
||||||
|
* Creates a new LoopService instance and runs it
|
||||||
|
* @param config - Partial loop configuration (defaults will be applied)
|
||||||
|
* @returns Promise resolving to the loop result
|
||||||
|
* @throws Error if a loop is already running
|
||||||
|
*/
|
||||||
|
async run(config: Partial<LoopConfig>): Promise<LoopResult> {
|
||||||
|
// Prevent orphaning a previous running LoopService
|
||||||
|
if (this.loopService?.isRunning) {
|
||||||
|
try {
|
||||||
|
this.loopService.stop();
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't block - stopping previous service is best-effort cleanup
|
||||||
|
this.logger.warn('Failed to stop previous loop service:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullConfig = this.buildConfig(config);
|
||||||
|
this.loopService = new LoopService({ projectRoot: this.projectRoot });
|
||||||
|
return this.loopService.run(fullConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the currently running loop
|
||||||
|
* Signals the loop to stop and nulls the service reference
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.loopService) {
|
||||||
|
this.loopService.stop();
|
||||||
|
this.loopService = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a loop is currently running
|
||||||
|
*/
|
||||||
|
getIsRunning(): boolean {
|
||||||
|
return this.loopService?.isRunning ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Preset Operations ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a string is a valid preset name
|
||||||
|
* @param prompt - The string to check
|
||||||
|
* @returns True if the prompt is a valid LoopPreset
|
||||||
|
*/
|
||||||
|
isPreset(prompt: string): prompt is LoopPreset {
|
||||||
|
return checkIsPreset(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a prompt string to its content
|
||||||
|
* For preset names, returns the inlined content
|
||||||
|
* For file paths, reads the file (requires readFile callback)
|
||||||
|
* @param prompt - Either a preset name or a file path
|
||||||
|
* @param readFile - Optional async function to read file content
|
||||||
|
* @returns Promise resolving to the prompt content string
|
||||||
|
*/
|
||||||
|
async resolvePrompt(
|
||||||
|
prompt: LoopPreset | string,
|
||||||
|
readFile?: (path: string) => Promise<string>
|
||||||
|
): Promise<string> {
|
||||||
|
if (checkIsPreset(prompt)) {
|
||||||
|
return getPreset(prompt);
|
||||||
|
}
|
||||||
|
if (!readFile) {
|
||||||
|
throw new Error(
|
||||||
|
`Custom prompt file requires readFile callback: ${prompt}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return readFile(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available preset names
|
||||||
|
* @returns Array of available preset names
|
||||||
|
*/
|
||||||
|
getAvailablePresets(): LoopPreset[] {
|
||||||
|
return [...PRESET_NAMES];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Iteration Resolution ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the number of iterations to use based on preset and task count.
|
||||||
|
* Business logic for determining iterations:
|
||||||
|
* - If user provided explicit iterations, use that
|
||||||
|
* - If preset is 'default' and pendingTaskCount > 0, use pending task count
|
||||||
|
* - Otherwise, default to 10
|
||||||
|
*
|
||||||
|
* @param options - Options for resolving iterations
|
||||||
|
* @param options.userIterations - User-provided iterations (takes priority)
|
||||||
|
* @param options.preset - The preset name being used
|
||||||
|
* @param options.pendingTaskCount - Count of pending tasks + subtasks (for default preset)
|
||||||
|
* @returns The resolved number of iterations
|
||||||
|
*/
|
||||||
|
resolveIterations(options: {
|
||||||
|
userIterations?: number;
|
||||||
|
preset: string;
|
||||||
|
pendingTaskCount?: number;
|
||||||
|
}): number {
|
||||||
|
const { userIterations, preset, pendingTaskCount } = options;
|
||||||
|
|
||||||
|
// User explicitly provided iterations - use their value
|
||||||
|
if (userIterations !== undefined) {
|
||||||
|
return userIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For default preset, use pending task count if available
|
||||||
|
if (
|
||||||
|
preset === 'default' &&
|
||||||
|
pendingTaskCount !== undefined &&
|
||||||
|
pendingTaskCount > 0
|
||||||
|
) {
|
||||||
|
return pendingTaskCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default for non-default presets or when no pending tasks
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Internal Helpers ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a complete LoopConfig from partial input
|
||||||
|
* Applies sensible defaults for any missing fields
|
||||||
|
* @param partial - Partial configuration to merge with defaults
|
||||||
|
* @returns Complete LoopConfig with all required fields
|
||||||
|
*/
|
||||||
|
private buildConfig(partial: Partial<LoopConfig>): LoopConfig {
|
||||||
|
return {
|
||||||
|
iterations: partial.iterations ?? 10,
|
||||||
|
prompt: partial.prompt ?? 'default',
|
||||||
|
progressFile:
|
||||||
|
partial.progressFile ??
|
||||||
|
path.join(this.projectRoot, '.taskmaster', 'progress.txt'),
|
||||||
|
sleepSeconds: partial.sleepSeconds ?? 5,
|
||||||
|
tag: partial.tag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`Preset Snapshots > default preset matches snapshot 1`] = `
|
||||||
|
"SETUP: If task-master command not found, run: npm i -g task-master-ai
|
||||||
|
|
||||||
|
TASK: Implement ONE task/subtask from the Task Master backlog.
|
||||||
|
|
||||||
|
PROCESS:
|
||||||
|
1. Run task-master next (or use MCP) to get the next available task/subtask.
|
||||||
|
2. Read task details with task-master show <id>.
|
||||||
|
3. Implement following codebase patterns.
|
||||||
|
4. Write tests alongside implementation.
|
||||||
|
5. Run type check (e.g., \`npm run typecheck\`, \`tsc --noEmit\`).
|
||||||
|
6. Run tests (e.g., \`npm test\`, \`npm run test\`).
|
||||||
|
7. Mark complete: task-master set-status --id=<id> --status=done
|
||||||
|
8. Commit with message: feat(<scope>): <what was implemented>
|
||||||
|
9. Append super-concise notes to progress file: task ID, what was done, any learnings.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Complete ONLY ONE task per iteration.
|
||||||
|
- Keep changes small and focused.
|
||||||
|
- Do NOT start another task after completing one.
|
||||||
|
- If all tasks are done, output <loop-complete>ALL_DONE</loop-complete>.
|
||||||
|
- If blocked, output <loop-blocked>REASON</loop-blocked>.
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Preset Snapshots > duplication preset matches snapshot 1`] = `
|
||||||
|
"# Task Master Loop - Duplication
|
||||||
|
|
||||||
|
Find duplicated code and refactor into shared utilities. ONE refactor per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (clones refactored, duplication %)
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run duplication detection (\`npx jscpd .\`, or similar tool)
|
||||||
|
2. Review the report and pick ONE clone to refactor - prioritize:
|
||||||
|
- Larger clones (more lines = more maintenance burden)
|
||||||
|
- Clones in frequently-changed files
|
||||||
|
- Clones with slight variations (consolidate logic)
|
||||||
|
3. Extract the duplicated code into a shared utility/function
|
||||||
|
4. Update all clone locations to use the shared utility
|
||||||
|
5. Run tests to ensure behavior is preserved
|
||||||
|
6. Commit with message: \`refactor(<file>): extract <utility> to reduce duplication\`
|
||||||
|
7. Append to progress file: what was refactored, new duplication %
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE refactor per session
|
||||||
|
- Keep changes focused on the specific duplication
|
||||||
|
- Do NOT start another refactor after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If duplication below threshold (e.g., <3%), output: <loop-complete>LOW_DUPLICATION</loop-complete>
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Preset Snapshots > entropy preset matches snapshot 1`] = `
|
||||||
|
"# Task Master Loop - Entropy (Code Smells)
|
||||||
|
|
||||||
|
Find code smells and clean them up. ONE cleanup per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (smells fixed, areas cleaned)
|
||||||
|
|
||||||
|
## Code Smells to Target
|
||||||
|
|
||||||
|
- Long functions (>60 lines) - extract into smaller functions
|
||||||
|
- Deep nesting (>3 levels) - use early returns, extract conditions
|
||||||
|
- Large files (>500 lines) - split into focused modules
|
||||||
|
- Magic numbers - extract into named constants
|
||||||
|
- Complex conditionals - extract into well-named functions
|
||||||
|
- God classes - split responsibilities
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Scan the codebase for code smells (use your judgment or tools like \`complexity-report\`)
|
||||||
|
2. Pick ONE smell to fix - prioritize:
|
||||||
|
- Smells in frequently-changed files
|
||||||
|
- Smells that hurt readability the most
|
||||||
|
- Smells in critical paths (authentication, payments, etc.)
|
||||||
|
3. Refactor with minimal changes - don't over-engineer
|
||||||
|
4. Run tests to ensure behavior is preserved
|
||||||
|
5. Commit with message: \`refactor(<file>): <describe the cleanup>\`
|
||||||
|
6. Append to progress file: what was cleaned, smell type
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE cleanup per session
|
||||||
|
- Keep refactoring focused and minimal
|
||||||
|
- Do NOT start another cleanup after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If no significant smells remain, output: <loop-complete>LOW_ENTROPY</loop-complete>
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Preset Snapshots > linting preset matches snapshot 1`] = `
|
||||||
|
"# Task Master Loop - Linting
|
||||||
|
|
||||||
|
Fix lint errors and type errors one by one. ONE fix per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (errors fixed, remaining count)
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run lint command (\`pnpm lint\`, \`npm run lint\`, \`eslint .\`, etc.)
|
||||||
|
2. Run type check (\`pnpm typecheck\`, \`tsc --noEmit\`, etc.)
|
||||||
|
3. Pick ONE error to fix - prioritize:
|
||||||
|
- Type errors (breaks builds)
|
||||||
|
- Security-related lint errors
|
||||||
|
- Errors in frequently-changed files
|
||||||
|
4. Fix the error with minimal changes - don't refactor surrounding code
|
||||||
|
5. Run lint/typecheck again to verify the fix doesn't introduce new errors
|
||||||
|
6. Commit with message: \`fix(<file>): <describe the lint/type error fixed>\`
|
||||||
|
7. Append to progress file: error fixed, remaining error count
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE fix per session
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Do NOT start another fix after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If zero lint errors and zero type errors, output: <loop-complete>ZERO_ERRORS</loop-complete>
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Preset Snapshots > test-coverage preset matches snapshot 1`] = `
|
||||||
|
"# Task Master Loop - Test Coverage
|
||||||
|
|
||||||
|
Find uncovered code and write meaningful tests. ONE test per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (coverage %, what was tested)
|
||||||
|
|
||||||
|
## What Makes a Great Test
|
||||||
|
|
||||||
|
A great test covers behavior users depend on. It tests a feature that, if broken,
|
||||||
|
would frustrate or block users. It validates real workflows - not implementation details.
|
||||||
|
|
||||||
|
Do NOT write tests just to increase coverage. Use coverage as a guide to find
|
||||||
|
UNTESTED USER-FACING BEHAVIOR. If code is not worth testing (boilerplate, unreachable
|
||||||
|
branches, internal plumbing), add ignore comments instead of low-value tests.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run coverage command (\`pnpm coverage\`, \`npm run coverage\`, etc.)
|
||||||
|
2. Identify the most important USER-FACING FEATURE that lacks tests
|
||||||
|
- Prioritize: error handling users hit, CLI commands, API endpoints, file parsing
|
||||||
|
- Deprioritize: internal utilities, edge cases users won't encounter, boilerplate
|
||||||
|
3. Write ONE meaningful test that validates the feature works correctly
|
||||||
|
4. Run coverage again - it should increase as a side effect of testing real behavior
|
||||||
|
5. Commit with message: \`test(<file>): <describe the user behavior being tested>\`
|
||||||
|
6. Append to progress file: what you tested, new coverage %, learnings
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE test per session
|
||||||
|
- Keep tests focused on user-facing behavior
|
||||||
|
- Do NOT start another test after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If coverage reaches target (or 100%), output: <loop-complete>COVERAGE_TARGET</loop-complete>
|
||||||
|
"
|
||||||
|
`;
|
||||||
26
packages/tm-core/src/modules/loop/presets/default.ts
Normal file
26
packages/tm-core/src/modules/loop/presets/default.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Default preset for Task Master loop - general task completion
|
||||||
|
* Matches the structure of scripts/loop.sh prompt
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PRESET = `SETUP: If task-master command not found, run: npm i -g task-master-ai
|
||||||
|
|
||||||
|
TASK: Implement ONE task/subtask from the Task Master backlog.
|
||||||
|
|
||||||
|
PROCESS:
|
||||||
|
1. Run task-master next (or use MCP) to get the next available task/subtask.
|
||||||
|
2. Read task details with task-master show <id>.
|
||||||
|
3. Implement following codebase patterns.
|
||||||
|
4. Write tests alongside implementation.
|
||||||
|
5. Run type check (e.g., \`npm run typecheck\`, \`tsc --noEmit\`).
|
||||||
|
6. Run tests (e.g., \`npm test\`, \`npm run test\`).
|
||||||
|
7. Mark complete: task-master set-status --id=<id> --status=done
|
||||||
|
8. Commit with message: feat(<scope>): <what was implemented>
|
||||||
|
9. Append super-concise notes to progress file: task ID, what was done, any learnings.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Complete ONLY ONE task per iteration.
|
||||||
|
- Keep changes small and focused.
|
||||||
|
- Do NOT start another task after completing one.
|
||||||
|
- If all tasks are done, output <loop-complete>ALL_DONE</loop-complete>.
|
||||||
|
- If blocked, output <loop-blocked>REASON</loop-blocked>.
|
||||||
|
`;
|
||||||
34
packages/tm-core/src/modules/loop/presets/duplication.ts
Normal file
34
packages/tm-core/src/modules/loop/presets/duplication.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Duplication preset for Task Master loop - code deduplication
|
||||||
|
*/
|
||||||
|
export const DUPLICATION_PRESET = `# Task Master Loop - Duplication
|
||||||
|
|
||||||
|
Find duplicated code and refactor into shared utilities. ONE refactor per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (clones refactored, duplication %)
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run duplication detection (\`npx jscpd .\`, or similar tool)
|
||||||
|
2. Review the report and pick ONE clone to refactor - prioritize:
|
||||||
|
- Larger clones (more lines = more maintenance burden)
|
||||||
|
- Clones in frequently-changed files
|
||||||
|
- Clones with slight variations (consolidate logic)
|
||||||
|
3. Extract the duplicated code into a shared utility/function
|
||||||
|
4. Update all clone locations to use the shared utility
|
||||||
|
5. Run tests to ensure behavior is preserved
|
||||||
|
6. Commit with message: \`refactor(<file>): extract <utility> to reduce duplication\`
|
||||||
|
7. Append to progress file: what was refactored, new duplication %
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE refactor per session
|
||||||
|
- Keep changes focused on the specific duplication
|
||||||
|
- Do NOT start another refactor after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If duplication below threshold (e.g., <3%), output: <loop-complete>LOW_DUPLICATION</loop-complete>
|
||||||
|
`;
|
||||||
43
packages/tm-core/src/modules/loop/presets/entropy.ts
Normal file
43
packages/tm-core/src/modules/loop/presets/entropy.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Entropy (Code Smells) preset for loop module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ENTROPY_PRESET = `# Task Master Loop - Entropy (Code Smells)
|
||||||
|
|
||||||
|
Find code smells and clean them up. ONE cleanup per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (smells fixed, areas cleaned)
|
||||||
|
|
||||||
|
## Code Smells to Target
|
||||||
|
|
||||||
|
- Long functions (>60 lines) - extract into smaller functions
|
||||||
|
- Deep nesting (>3 levels) - use early returns, extract conditions
|
||||||
|
- Large files (>500 lines) - split into focused modules
|
||||||
|
- Magic numbers - extract into named constants
|
||||||
|
- Complex conditionals - extract into well-named functions
|
||||||
|
- God classes - split responsibilities
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Scan the codebase for code smells (use your judgment or tools like \`complexity-report\`)
|
||||||
|
2. Pick ONE smell to fix - prioritize:
|
||||||
|
- Smells in frequently-changed files
|
||||||
|
- Smells that hurt readability the most
|
||||||
|
- Smells in critical paths (authentication, payments, etc.)
|
||||||
|
3. Refactor with minimal changes - don't over-engineer
|
||||||
|
4. Run tests to ensure behavior is preserved
|
||||||
|
5. Commit with message: \`refactor(<file>): <describe the cleanup>\`
|
||||||
|
6. Append to progress file: what was cleaned, smell type
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE cleanup per session
|
||||||
|
- Keep refactoring focused and minimal
|
||||||
|
- Do NOT start another cleanup after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If no significant smells remain, output: <loop-complete>LOW_ENTROPY</loop-complete>
|
||||||
|
`;
|
||||||
52
packages/tm-core/src/modules/loop/presets/index.ts
Normal file
52
packages/tm-core/src/modules/loop/presets/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Preset exports for loop module
|
||||||
|
* Simple re-exports from individual preset files with helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_PRESET } from './default.js';
|
||||||
|
import { TEST_COVERAGE_PRESET } from './test-coverage.js';
|
||||||
|
import { LINTING_PRESET } from './linting.js';
|
||||||
|
import { DUPLICATION_PRESET } from './duplication.js';
|
||||||
|
import { ENTROPY_PRESET } from './entropy.js';
|
||||||
|
import type { LoopPreset } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record of all preset names to their content
|
||||||
|
*/
|
||||||
|
export const PRESETS: Record<LoopPreset, string> = {
|
||||||
|
default: DEFAULT_PRESET,
|
||||||
|
'test-coverage': TEST_COVERAGE_PRESET,
|
||||||
|
linting: LINTING_PRESET,
|
||||||
|
duplication: DUPLICATION_PRESET,
|
||||||
|
entropy: ENTROPY_PRESET
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of all available preset names
|
||||||
|
*/
|
||||||
|
export const PRESET_NAMES = Object.keys(PRESETS) as LoopPreset[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the content of a preset by name
|
||||||
|
* @param name - The preset name
|
||||||
|
* @returns The preset content string
|
||||||
|
*/
|
||||||
|
export function getPreset(name: LoopPreset): string {
|
||||||
|
return PRESETS[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a value is a valid preset name
|
||||||
|
* @param value - The value to check
|
||||||
|
* @returns True if the value is a valid LoopPreset
|
||||||
|
*/
|
||||||
|
export function isPreset(value: string): value is LoopPreset {
|
||||||
|
return value in PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export individual presets for direct access
|
||||||
|
export { DEFAULT_PRESET } from './default.js';
|
||||||
|
export { TEST_COVERAGE_PRESET } from './test-coverage.js';
|
||||||
|
export { LINTING_PRESET } from './linting.js';
|
||||||
|
export { DUPLICATION_PRESET } from './duplication.js';
|
||||||
|
export { ENTROPY_PRESET } from './entropy.js';
|
||||||
34
packages/tm-core/src/modules/loop/presets/linting.ts
Normal file
34
packages/tm-core/src/modules/loop/presets/linting.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Linting preset for Task Master loop - fix lint and type errors
|
||||||
|
*/
|
||||||
|
export const LINTING_PRESET = `# Task Master Loop - Linting
|
||||||
|
|
||||||
|
Fix lint errors and type errors one by one. ONE fix per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (errors fixed, remaining count)
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run lint command (\`pnpm lint\`, \`npm run lint\`, \`eslint .\`, etc.)
|
||||||
|
2. Run type check (\`pnpm typecheck\`, \`tsc --noEmit\`, etc.)
|
||||||
|
3. Pick ONE error to fix - prioritize:
|
||||||
|
- Type errors (breaks builds)
|
||||||
|
- Security-related lint errors
|
||||||
|
- Errors in frequently-changed files
|
||||||
|
4. Fix the error with minimal changes - don't refactor surrounding code
|
||||||
|
5. Run lint/typecheck again to verify the fix doesn't introduce new errors
|
||||||
|
6. Commit with message: \`fix(<file>): <describe the lint/type error fixed>\`
|
||||||
|
7. Append to progress file: error fixed, remaining error count
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE fix per session
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Do NOT start another fix after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If zero lint errors and zero type errors, output: <loop-complete>ZERO_ERRORS</loop-complete>
|
||||||
|
`;
|
||||||
284
packages/tm-core/src/modules/loop/presets/presets.spec.ts
Normal file
284
packages/tm-core/src/modules/loop/presets/presets.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for preset exports and preset content structure
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
PRESETS,
|
||||||
|
PRESET_NAMES,
|
||||||
|
getPreset,
|
||||||
|
isPreset,
|
||||||
|
DEFAULT_PRESET,
|
||||||
|
TEST_COVERAGE_PRESET,
|
||||||
|
LINTING_PRESET,
|
||||||
|
DUPLICATION_PRESET,
|
||||||
|
ENTROPY_PRESET
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
describe('Preset Exports', () => {
|
||||||
|
describe('PRESET_NAMES', () => {
|
||||||
|
it('contains all 5 preset names', () => {
|
||||||
|
expect(PRESET_NAMES).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes default preset', () => {
|
||||||
|
expect(PRESET_NAMES).toContain('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes test-coverage preset', () => {
|
||||||
|
expect(PRESET_NAMES).toContain('test-coverage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes linting preset', () => {
|
||||||
|
expect(PRESET_NAMES).toContain('linting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes duplication preset', () => {
|
||||||
|
expect(PRESET_NAMES).toContain('duplication');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes entropy preset', () => {
|
||||||
|
expect(PRESET_NAMES).toContain('entropy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PRESETS record', () => {
|
||||||
|
it('has entries for all preset names', () => {
|
||||||
|
for (const name of PRESET_NAMES) {
|
||||||
|
expect(PRESETS[name]).toBeDefined();
|
||||||
|
expect(typeof PRESETS[name]).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has non-empty content for each preset', () => {
|
||||||
|
for (const name of PRESET_NAMES) {
|
||||||
|
expect(PRESETS[name].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPreset', () => {
|
||||||
|
it('returns content for default preset', () => {
|
||||||
|
const content = getPreset('default');
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(typeof content).toBe('string');
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content for test-coverage preset', () => {
|
||||||
|
const content = getPreset('test-coverage');
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content for linting preset', () => {
|
||||||
|
const content = getPreset('linting');
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content for duplication preset', () => {
|
||||||
|
const content = getPreset('duplication');
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content for entropy preset', () => {
|
||||||
|
const content = getPreset('entropy');
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns same content as PRESETS record', () => {
|
||||||
|
for (const name of PRESET_NAMES) {
|
||||||
|
expect(getPreset(name)).toBe(PRESETS[name]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPreset', () => {
|
||||||
|
it('returns true for valid preset names', () => {
|
||||||
|
expect(isPreset('default')).toBe(true);
|
||||||
|
expect(isPreset('test-coverage')).toBe(true);
|
||||||
|
expect(isPreset('linting')).toBe(true);
|
||||||
|
expect(isPreset('duplication')).toBe(true);
|
||||||
|
expect(isPreset('entropy')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for invalid preset names', () => {
|
||||||
|
expect(isPreset('invalid')).toBe(false);
|
||||||
|
expect(isPreset('custom')).toBe(false);
|
||||||
|
expect(isPreset('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for file paths', () => {
|
||||||
|
expect(isPreset('/path/to/preset.md')).toBe(false);
|
||||||
|
expect(isPreset('./custom-preset.md')).toBe(false);
|
||||||
|
expect(isPreset('presets/default.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for preset names with different casing', () => {
|
||||||
|
expect(isPreset('Default')).toBe(false);
|
||||||
|
expect(isPreset('DEFAULT')).toBe(false);
|
||||||
|
expect(isPreset('Test-Coverage')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Individual preset constants', () => {
|
||||||
|
it('exports DEFAULT_PRESET', () => {
|
||||||
|
expect(DEFAULT_PRESET).toBeDefined();
|
||||||
|
expect(typeof DEFAULT_PRESET).toBe('string');
|
||||||
|
expect(DEFAULT_PRESET.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports TEST_COVERAGE_PRESET', () => {
|
||||||
|
expect(TEST_COVERAGE_PRESET).toBeDefined();
|
||||||
|
expect(typeof TEST_COVERAGE_PRESET).toBe('string');
|
||||||
|
expect(TEST_COVERAGE_PRESET.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports LINTING_PRESET', () => {
|
||||||
|
expect(LINTING_PRESET).toBeDefined();
|
||||||
|
expect(typeof LINTING_PRESET).toBe('string');
|
||||||
|
expect(LINTING_PRESET.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports DUPLICATION_PRESET', () => {
|
||||||
|
expect(DUPLICATION_PRESET).toBeDefined();
|
||||||
|
expect(typeof DUPLICATION_PRESET).toBe('string');
|
||||||
|
expect(DUPLICATION_PRESET.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports ENTROPY_PRESET', () => {
|
||||||
|
expect(ENTROPY_PRESET).toBeDefined();
|
||||||
|
expect(typeof ENTROPY_PRESET).toBe('string');
|
||||||
|
expect(ENTROPY_PRESET.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('individual constants match PRESETS record', () => {
|
||||||
|
expect(DEFAULT_PRESET).toBe(PRESETS['default']);
|
||||||
|
expect(TEST_COVERAGE_PRESET).toBe(PRESETS['test-coverage']);
|
||||||
|
expect(LINTING_PRESET).toBe(PRESETS['linting']);
|
||||||
|
expect(DUPLICATION_PRESET).toBe(PRESETS['duplication']);
|
||||||
|
expect(ENTROPY_PRESET).toBe(PRESETS['entropy']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Snapshots', () => {
|
||||||
|
it('default preset matches snapshot', () => {
|
||||||
|
expect(DEFAULT_PRESET).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test-coverage preset matches snapshot', () => {
|
||||||
|
expect(TEST_COVERAGE_PRESET).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linting preset matches snapshot', () => {
|
||||||
|
expect(LINTING_PRESET).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('duplication preset matches snapshot', () => {
|
||||||
|
expect(DUPLICATION_PRESET).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entropy preset matches snapshot', () => {
|
||||||
|
expect(ENTROPY_PRESET).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Structure Validation', () => {
|
||||||
|
describe('all presets contain required elements', () => {
|
||||||
|
it.each(PRESET_NAMES)('%s contains <loop-complete> marker', (preset) => {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
expect(content).toMatch(/<loop-complete>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(PRESET_NAMES.filter((p) => p !== 'default'))(
|
||||||
|
'%s contains @ file reference pattern',
|
||||||
|
(preset) => {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for @ file reference pattern (e.g., @.taskmaster/ or @./)
|
||||||
|
// Note: default preset uses context header injection instead
|
||||||
|
expect(content).toMatch(/@\.taskmaster\/|@\.\//);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(PRESET_NAMES)('%s contains numbered process steps', (preset) => {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for numbered steps (e.g., "1. ", "2. ")
|
||||||
|
expect(content).toMatch(/^\d+\./m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(PRESET_NAMES)(
|
||||||
|
'%s contains Important or Completion section',
|
||||||
|
(preset) => {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for Important section (markdown or plain text) or Completion section
|
||||||
|
expect(content).toMatch(/## Important|## Completion|^IMPORTANT:/im);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default preset specific requirements', () => {
|
||||||
|
it('contains <loop-blocked> marker', () => {
|
||||||
|
expect(DEFAULT_PRESET).toMatch(/<loop-blocked>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains both loop markers', () => {
|
||||||
|
expect(DEFAULT_PRESET).toMatch(/<loop-complete>.*<\/loop-complete>/);
|
||||||
|
expect(DEFAULT_PRESET).toMatch(/<loop-blocked>.*<\/loop-blocked>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Content Consistency', () => {
|
||||||
|
it.each(PRESET_NAMES)(
|
||||||
|
'%s mentions single-task-per-iteration constraint',
|
||||||
|
(preset) => {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for variations of the single-task constraint
|
||||||
|
const hasConstraint =
|
||||||
|
content.toLowerCase().includes('one task') ||
|
||||||
|
content.toLowerCase().includes('one test') ||
|
||||||
|
content.toLowerCase().includes('one fix') ||
|
||||||
|
content.toLowerCase().includes('one refactor') ||
|
||||||
|
content.toLowerCase().includes('one cleanup') ||
|
||||||
|
content.toLowerCase().includes('only one');
|
||||||
|
expect(hasConstraint).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(PRESET_NAMES)('%s has progress file reference', (preset) => {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// All presets should reference the progress file
|
||||||
|
expect(content).toMatch(/loop-progress|progress/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('specialized presets have markdown headers', () => {
|
||||||
|
// Default preset uses plain text sections (SETUP:, TASK:, PROCESS:, IMPORTANT:)
|
||||||
|
// Other presets use markdown headers
|
||||||
|
for (const preset of PRESET_NAMES.filter((p) => p !== 'default')) {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for at least one markdown header
|
||||||
|
expect(content).toMatch(/^#+ /m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all presets have process section', () => {
|
||||||
|
for (const preset of PRESET_NAMES) {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for Process header (markdown ## or plain text PROCESS:)
|
||||||
|
expect(content).toMatch(/## Process|^PROCESS:/m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('specialized presets have files available section', () => {
|
||||||
|
// Default preset doesn't have files available section - context is injected at runtime
|
||||||
|
for (const preset of PRESET_NAMES.filter((p) => p !== 'default')) {
|
||||||
|
const content = getPreset(preset);
|
||||||
|
// Check for Files Available header
|
||||||
|
expect(content).toMatch(/## Files Available/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
41
packages/tm-core/src/modules/loop/presets/test-coverage.ts
Normal file
41
packages/tm-core/src/modules/loop/presets/test-coverage.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Test coverage preset for Task Master loop - writing meaningful tests
|
||||||
|
*/
|
||||||
|
export const TEST_COVERAGE_PRESET = `# Task Master Loop - Test Coverage
|
||||||
|
|
||||||
|
Find uncovered code and write meaningful tests. ONE test per session.
|
||||||
|
|
||||||
|
## Files Available
|
||||||
|
|
||||||
|
- @.taskmaster/loop-progress.txt - Progress log (coverage %, what was tested)
|
||||||
|
|
||||||
|
## What Makes a Great Test
|
||||||
|
|
||||||
|
A great test covers behavior users depend on. It tests a feature that, if broken,
|
||||||
|
would frustrate or block users. It validates real workflows - not implementation details.
|
||||||
|
|
||||||
|
Do NOT write tests just to increase coverage. Use coverage as a guide to find
|
||||||
|
UNTESTED USER-FACING BEHAVIOR. If code is not worth testing (boilerplate, unreachable
|
||||||
|
branches, internal plumbing), add ignore comments instead of low-value tests.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Run coverage command (\`pnpm coverage\`, \`npm run coverage\`, etc.)
|
||||||
|
2. Identify the most important USER-FACING FEATURE that lacks tests
|
||||||
|
- Prioritize: error handling users hit, CLI commands, API endpoints, file parsing
|
||||||
|
- Deprioritize: internal utilities, edge cases users won't encounter, boilerplate
|
||||||
|
3. Write ONE meaningful test that validates the feature works correctly
|
||||||
|
4. Run coverage again - it should increase as a side effect of testing real behavior
|
||||||
|
5. Commit with message: \`test(<file>): <describe the user behavior being tested>\`
|
||||||
|
6. Append to progress file: what you tested, new coverage %, learnings
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Complete ONLY ONE test per session
|
||||||
|
- Keep tests focused on user-facing behavior
|
||||||
|
- Do NOT start another test after completing one
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- If coverage reaches target (or 100%), output: <loop-complete>COVERAGE_TARGET</loop-complete>
|
||||||
|
`;
|
||||||
6
packages/tm-core/src/modules/loop/services/index.ts
Normal file
6
packages/tm-core/src/modules/loop/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Loop services barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { LoopService } from './loop.service.js';
|
||||||
|
export type { LoopServiceOptions } from './loop.service.js';
|
||||||
703
packages/tm-core/src/modules/loop/services/loop.service.spec.ts
Normal file
703
packages/tm-core/src/modules/loop/services/loop.service.spec.ts
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for simplified LoopService
|
||||||
|
* Tests the synchronous spawnSync-based implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
type MockInstance
|
||||||
|
} from 'vitest';
|
||||||
|
import { LoopService, type LoopServiceOptions } from './loop.service.js';
|
||||||
|
import * as childProcess from 'node:child_process';
|
||||||
|
import * as fsPromises from 'node:fs/promises';
|
||||||
|
|
||||||
|
// Mock child_process and fs/promises
|
||||||
|
vi.mock('node:child_process');
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
|
describe('LoopService', () => {
|
||||||
|
const defaultOptions: LoopServiceOptions = {
|
||||||
|
projectRoot: '/test/project'
|
||||||
|
};
|
||||||
|
|
||||||
|
let mockSpawnSync: MockInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
// Default fs mocks
|
||||||
|
vi.mocked(fsPromises.mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fsPromises.writeFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(fsPromises.appendFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Default spawnSync mock
|
||||||
|
mockSpawnSync = vi.mocked(childProcess.spawnSync);
|
||||||
|
|
||||||
|
// Suppress console output in tests
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create a LoopService instance with required options', () => {
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
expect(service).toBeInstanceOf(LoopService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store projectRoot from options', () => {
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
expect(service.getProjectRoot()).toBe('/test/project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize isRunning to false', () => {
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
expect(service.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('service instantiation with different project roots', () => {
|
||||||
|
it('should work with absolute path', () => {
|
||||||
|
const service = new LoopService({
|
||||||
|
projectRoot: '/absolute/path/to/project'
|
||||||
|
});
|
||||||
|
expect(service.getProjectRoot()).toBe('/absolute/path/to/project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with Windows-style path', () => {
|
||||||
|
const service = new LoopService({
|
||||||
|
projectRoot: 'C:\\Users\\test\\project'
|
||||||
|
});
|
||||||
|
expect(service.getProjectRoot()).toBe('C:\\Users\\test\\project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with empty projectRoot', () => {
|
||||||
|
const service = new LoopService({ projectRoot: '' });
|
||||||
|
expect(service.getProjectRoot()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('service instance isolation', () => {
|
||||||
|
it('should create independent instances', () => {
|
||||||
|
const service1 = new LoopService(defaultOptions);
|
||||||
|
const service2 = new LoopService(defaultOptions);
|
||||||
|
expect(service1).not.toBe(service2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain independent state between instances', () => {
|
||||||
|
const service1 = new LoopService({ projectRoot: '/project1' });
|
||||||
|
const service2 = new LoopService({ projectRoot: '/project2' });
|
||||||
|
|
||||||
|
expect(service1.getProjectRoot()).toBe('/project1');
|
||||||
|
expect(service2.getProjectRoot()).toBe('/project2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stop()', () => {
|
||||||
|
it('should set isRunning to false', () => {
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
// Access private field via any cast for testing
|
||||||
|
(service as unknown as { _isRunning: boolean })._isRunning = true;
|
||||||
|
expect(service.isRunning).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
expect(service.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be safe to call multiple times', () => {
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
service.stop();
|
||||||
|
service.stop();
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
expect(service.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkSandboxAuth()', () => {
|
||||||
|
it('should return true when output contains ok', () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: 'OK',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
const result = service.checkSandboxAuth();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockSpawnSync).toHaveBeenCalledWith(
|
||||||
|
'docker',
|
||||||
|
['sandbox', 'run', 'claude', '-p', 'Say OK'],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: '/test/project',
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when output does not contain ok', () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: 'Error: not authenticated',
|
||||||
|
stderr: '',
|
||||||
|
status: 1,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
const result = service.checkSandboxAuth();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check stderr as well as stdout', () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'OK response',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
const result = service.checkSandboxAuth();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runInteractiveAuth()', () => {
|
||||||
|
it('should spawn interactive docker session', () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new LoopService(defaultOptions);
|
||||||
|
service.runInteractiveAuth();
|
||||||
|
|
||||||
|
expect(mockSpawnSync).toHaveBeenCalledWith(
|
||||||
|
'docker',
|
||||||
|
expect.arrayContaining(['sandbox', 'run', 'claude']),
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: '/test/project',
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('run()', () => {
|
||||||
|
let service: LoopService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LoopService(defaultOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('successful iteration run', () => {
|
||||||
|
it('should run a single iteration successfully', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: 'Task completed',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.totalIterations).toBe(1);
|
||||||
|
expect(result.tasksCompleted).toBe(1);
|
||||||
|
expect(result.finalStatus).toBe('max_iterations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should run multiple iterations', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: 'Done',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 3,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.totalIterations).toBe(3);
|
||||||
|
expect(result.tasksCompleted).toBe(3);
|
||||||
|
expect(mockSpawnSync).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call spawnSync with docker sandbox run claude -p', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: 'Done',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSpawnSync).toHaveBeenCalledWith(
|
||||||
|
'docker',
|
||||||
|
expect.arrayContaining([
|
||||||
|
'sandbox',
|
||||||
|
'run',
|
||||||
|
'claude',
|
||||||
|
'-p',
|
||||||
|
expect.any(String)
|
||||||
|
]),
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: '/test/project'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completion marker detection', () => {
|
||||||
|
it('should detect loop-complete marker and exit early', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '<loop-complete>ALL_DONE</loop-complete>',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 5,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.totalIterations).toBe(1);
|
||||||
|
expect(result.finalStatus).toBe('all_complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect loop-blocked marker and exit early', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '<loop-blocked>Missing API key</loop-blocked>',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 5,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.totalIterations).toBe(1);
|
||||||
|
expect(result.finalStatus).toBe('blocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle non-zero exit code', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'Error occurred',
|
||||||
|
status: 1,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.iterations[0].status).toBe('error');
|
||||||
|
expect(result.tasksCompleted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null status as error', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: null,
|
||||||
|
signal: 'SIGTERM',
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.iterations[0].status).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progress file operations', () => {
|
||||||
|
it('should initialize progress file at start', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fsPromises.mkdir).toHaveBeenCalledWith('/test', {
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
expect(fsPromises.writeFile).toHaveBeenCalledWith(
|
||||||
|
'/test/progress.txt',
|
||||||
|
expect.stringContaining('# Task Master Loop Progress'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append final summary at end', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 2,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
||||||
|
'/test/progress.txt',
|
||||||
|
expect.stringContaining('# Loop Complete'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preset resolution', () => {
|
||||||
|
it('should resolve built-in preset names', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: 'test-coverage',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify spawn was called with prompt containing iteration info
|
||||||
|
const spawnCall = mockSpawnSync.mock.calls[0];
|
||||||
|
// Args are ['sandbox', 'run', 'claude', '-p', prompt]
|
||||||
|
const promptArg = spawnCall[1][4];
|
||||||
|
expect(promptArg).toContain('iteration 1 of 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load custom prompt from file', async () => {
|
||||||
|
vi.mocked(fsPromises.readFile).mockResolvedValue(
|
||||||
|
'Custom prompt content'
|
||||||
|
);
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: '/custom/prompt.md',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fsPromises.readFile).toHaveBeenCalledWith(
|
||||||
|
'/custom/prompt.md',
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on empty custom prompt file', async () => {
|
||||||
|
vi.mocked(fsPromises.readFile).mockResolvedValue(' ');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.run({
|
||||||
|
prompt: '/custom/empty.md',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('empty');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseCompletion (inlined)', () => {
|
||||||
|
let service: LoopService;
|
||||||
|
let parseCompletion: (
|
||||||
|
output: string,
|
||||||
|
exitCode: number
|
||||||
|
) => { status: string; message?: string };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LoopService(defaultOptions);
|
||||||
|
// Access private method
|
||||||
|
parseCompletion = (
|
||||||
|
service as unknown as {
|
||||||
|
parseCompletion: typeof parseCompletion;
|
||||||
|
}
|
||||||
|
).parseCompletion.bind(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect complete marker', () => {
|
||||||
|
const result = parseCompletion(
|
||||||
|
'<loop-complete>ALL DONE</loop-complete>',
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('complete');
|
||||||
|
expect(result.message).toBe('ALL DONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect blocked marker', () => {
|
||||||
|
const result = parseCompletion('<loop-blocked>STUCK</loop-blocked>', 0);
|
||||||
|
expect(result.status).toBe('blocked');
|
||||||
|
expect(result.message).toBe('STUCK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error on non-zero exit code', () => {
|
||||||
|
const result = parseCompletion('Some output', 1);
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.message).toBe('Exit code 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return success on zero exit code without markers', () => {
|
||||||
|
const result = parseCompletion('Regular output', 0);
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive for markers', () => {
|
||||||
|
const result = parseCompletion('<LOOP-COMPLETE>DONE</LOOP-COMPLETE>', 0);
|
||||||
|
expect(result.status).toBe('complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from reason', () => {
|
||||||
|
const result = parseCompletion(
|
||||||
|
'<loop-complete> trimmed </loop-complete>',
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(result.message).toBe('trimmed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPreset (inlined)', () => {
|
||||||
|
let service: LoopService;
|
||||||
|
let isPreset: (name: string) => boolean;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LoopService(defaultOptions);
|
||||||
|
isPreset = (
|
||||||
|
service as unknown as { isPreset: (n: string) => boolean }
|
||||||
|
).isPreset.bind(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for default preset', () => {
|
||||||
|
expect(isPreset('default')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for test-coverage preset', () => {
|
||||||
|
expect(isPreset('test-coverage')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for linting preset', () => {
|
||||||
|
expect(isPreset('linting')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for duplication preset', () => {
|
||||||
|
expect(isPreset('duplication')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for entropy preset', () => {
|
||||||
|
expect(isPreset('entropy')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for unknown preset', () => {
|
||||||
|
expect(isPreset('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for file paths', () => {
|
||||||
|
expect(isPreset('/path/to/file.md')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildContextHeader (inlined)', () => {
|
||||||
|
let service: LoopService;
|
||||||
|
let buildContextHeader: (
|
||||||
|
config: { iterations: number; progressFile: string; tag?: string },
|
||||||
|
iteration: number
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LoopService(defaultOptions);
|
||||||
|
buildContextHeader = (
|
||||||
|
service as unknown as {
|
||||||
|
buildContextHeader: typeof buildContextHeader;
|
||||||
|
}
|
||||||
|
).buildContextHeader.bind(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include iteration info', () => {
|
||||||
|
const header = buildContextHeader(
|
||||||
|
{ iterations: 5, progressFile: '/test/progress.txt' },
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(header).toContain('iteration 2 of 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include progress file reference', () => {
|
||||||
|
const header = buildContextHeader(
|
||||||
|
{ iterations: 1, progressFile: '/test/progress.txt' },
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(header).toContain('@/test/progress.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include tasks file reference', () => {
|
||||||
|
const header = buildContextHeader(
|
||||||
|
{ iterations: 1, progressFile: '/test/progress.txt' },
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(header).toContain('@.taskmaster/tasks/tasks.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include tag filter when provided', () => {
|
||||||
|
const header = buildContextHeader(
|
||||||
|
{ iterations: 1, progressFile: '/test/progress.txt', tag: 'feature-x' },
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(header).toContain('tag: feature-x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include tag when not provided', () => {
|
||||||
|
const header = buildContextHeader(
|
||||||
|
{ iterations: 1, progressFile: '/test/progress.txt' },
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(header).not.toContain('tag:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('integration: stop during run', () => {
|
||||||
|
let service: LoopService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LoopService(defaultOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isRunning to true during run', async () => {
|
||||||
|
let capturedIsRunning = false;
|
||||||
|
mockSpawnSync.mockImplementation(() => {
|
||||||
|
capturedIsRunning = service.isRunning;
|
||||||
|
return {
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedIsRunning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isRunning to false on completion', async () => {
|
||||||
|
mockSpawnSync.mockReturnValue({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
status: 0,
|
||||||
|
signal: null,
|
||||||
|
pid: 123,
|
||||||
|
output: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.run({
|
||||||
|
prompt: 'default',
|
||||||
|
iterations: 1,
|
||||||
|
sleepSeconds: 0,
|
||||||
|
progressFile: '/test/progress.txt'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
251
packages/tm-core/src/modules/loop/services/loop.service.ts
Normal file
251
packages/tm-core/src/modules/loop/services/loop.service.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Loop Service - Orchestrates running Claude Code in Docker sandbox iterations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { PRESETS, isPreset as checkIsPreset } from '../presets/index.js';
|
||||||
|
import type {
|
||||||
|
LoopConfig,
|
||||||
|
LoopIteration,
|
||||||
|
LoopPreset,
|
||||||
|
LoopResult
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
export interface LoopServiceOptions {
|
||||||
|
projectRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoopService {
|
||||||
|
private readonly projectRoot: string;
|
||||||
|
private _isRunning = false;
|
||||||
|
|
||||||
|
constructor(options: LoopServiceOptions) {
|
||||||
|
this.projectRoot = options.projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectRoot(): string {
|
||||||
|
return this.projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if Docker sandbox auth is ready */
|
||||||
|
checkSandboxAuth(): boolean {
|
||||||
|
const result = spawnSync(
|
||||||
|
'docker',
|
||||||
|
['sandbox', 'run', 'claude', '-p', 'Say OK'],
|
||||||
|
{
|
||||||
|
cwd: this.projectRoot,
|
||||||
|
timeout: 30000,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['inherit', 'pipe', 'pipe'] // stdin from terminal, capture stdout/stderr
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const output = (result.stdout || '') + (result.stderr || '');
|
||||||
|
return output.toLowerCase().includes('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run interactive Docker sandbox session for user authentication */
|
||||||
|
runInteractiveAuth(): void {
|
||||||
|
spawnSync(
|
||||||
|
'docker',
|
||||||
|
[
|
||||||
|
'sandbox',
|
||||||
|
'run',
|
||||||
|
'claude',
|
||||||
|
"You're authenticated! Press Ctrl+C to continue."
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: this.projectRoot,
|
||||||
|
stdio: 'inherit'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a loop with the given configuration */
|
||||||
|
async run(config: LoopConfig): Promise<LoopResult> {
|
||||||
|
this._isRunning = true;
|
||||||
|
const iterations: LoopIteration[] = [];
|
||||||
|
let tasksCompleted = 0;
|
||||||
|
|
||||||
|
await this.initProgressFile(config);
|
||||||
|
|
||||||
|
for (let i = 1; i <= config.iterations && this._isRunning; i++) {
|
||||||
|
// Show iteration header
|
||||||
|
console.log();
|
||||||
|
console.log(`━━━ Iteration ${i} of ${config.iterations} ━━━`);
|
||||||
|
|
||||||
|
const prompt = await this.buildPrompt(config, i);
|
||||||
|
const iteration = this.executeIteration(prompt, i);
|
||||||
|
iterations.push(iteration);
|
||||||
|
|
||||||
|
// Check for early exit conditions
|
||||||
|
if (iteration.status === 'complete') {
|
||||||
|
return this.finalize(
|
||||||
|
config,
|
||||||
|
iterations,
|
||||||
|
tasksCompleted + 1,
|
||||||
|
'all_complete'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (iteration.status === 'blocked') {
|
||||||
|
return this.finalize(config, iterations, tasksCompleted, 'blocked');
|
||||||
|
}
|
||||||
|
if (iteration.status === 'success') {
|
||||||
|
tasksCompleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep between iterations (except last)
|
||||||
|
if (i < config.iterations && config.sleepSeconds > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, config.sleepSeconds * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finalize(config, iterations, tasksCompleted, 'max_iterations');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the loop after current iteration completes */
|
||||||
|
stop(): void {
|
||||||
|
this._isRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Private Helpers ==========
|
||||||
|
|
||||||
|
private async finalize(
|
||||||
|
config: LoopConfig,
|
||||||
|
iterations: LoopIteration[],
|
||||||
|
tasksCompleted: number,
|
||||||
|
finalStatus: LoopResult['finalStatus']
|
||||||
|
): Promise<LoopResult> {
|
||||||
|
this._isRunning = false;
|
||||||
|
const result: LoopResult = {
|
||||||
|
iterations,
|
||||||
|
totalIterations: iterations.length,
|
||||||
|
tasksCompleted,
|
||||||
|
finalStatus
|
||||||
|
};
|
||||||
|
await this.appendFinalSummary(config.progressFile, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initProgressFile(config: LoopConfig): Promise<void> {
|
||||||
|
await mkdir(path.dirname(config.progressFile), { recursive: true });
|
||||||
|
const tagLine = config.tag ? `# Tag: ${config.tag}\n` : '';
|
||||||
|
await writeFile(
|
||||||
|
config.progressFile,
|
||||||
|
`# Task Master Loop Progress
|
||||||
|
# Started: ${new Date().toISOString()}
|
||||||
|
# Preset: ${config.prompt}
|
||||||
|
# Max Iterations: ${config.iterations}
|
||||||
|
${tagLine}
|
||||||
|
---
|
||||||
|
|
||||||
|
`,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendFinalSummary(
|
||||||
|
file: string,
|
||||||
|
result: LoopResult
|
||||||
|
): Promise<void> {
|
||||||
|
await appendFile(
|
||||||
|
file,
|
||||||
|
`
|
||||||
|
---
|
||||||
|
# Loop Complete: ${new Date().toISOString()}
|
||||||
|
- Total iterations: ${result.totalIterations}
|
||||||
|
- Tasks completed: ${result.tasksCompleted}
|
||||||
|
- Final status: ${result.finalStatus}
|
||||||
|
`,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPreset(name: string): name is LoopPreset {
|
||||||
|
return checkIsPreset(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePrompt(prompt: string): Promise<string> {
|
||||||
|
if (this.isPreset(prompt)) {
|
||||||
|
return PRESETS[prompt];
|
||||||
|
}
|
||||||
|
const content = await readFile(prompt, 'utf-8');
|
||||||
|
if (!content.trim()) {
|
||||||
|
throw new Error(`Custom prompt file '${prompt}' is empty`);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildContextHeader(config: LoopConfig, iteration: number): string {
|
||||||
|
const tagInfo = config.tag ? ` (tag: ${config.tag})` : '';
|
||||||
|
return `@${config.progressFile} @.taskmaster/tasks/tasks.json @CLAUDE.md
|
||||||
|
|
||||||
|
Loop iteration ${iteration} of ${config.iterations}${tagInfo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildPrompt(
|
||||||
|
config: LoopConfig,
|
||||||
|
iteration: number
|
||||||
|
): Promise<string> {
|
||||||
|
const basePrompt = await this.resolvePrompt(config.prompt);
|
||||||
|
return `${this.buildContextHeader(config, iteration)}\n\n${basePrompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCompletion(
|
||||||
|
output: string,
|
||||||
|
exitCode: number
|
||||||
|
): { status: LoopIteration['status']; message?: string } {
|
||||||
|
const completeMatch = output.match(
|
||||||
|
/<loop-complete>([^<]*)<\/loop-complete>/i
|
||||||
|
);
|
||||||
|
if (completeMatch)
|
||||||
|
return { status: 'complete', message: completeMatch[1].trim() };
|
||||||
|
|
||||||
|
const blockedMatch = output.match(/<loop-blocked>([^<]*)<\/loop-blocked>/i);
|
||||||
|
if (blockedMatch)
|
||||||
|
return { status: 'blocked', message: blockedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (exitCode !== 0)
|
||||||
|
return { status: 'error', message: `Exit code ${exitCode}` };
|
||||||
|
return { status: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeIteration(
|
||||||
|
prompt: string,
|
||||||
|
iterationNum: number
|
||||||
|
): LoopIteration {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
'docker',
|
||||||
|
['sandbox', 'run', 'claude', '-p', prompt],
|
||||||
|
{
|
||||||
|
cwd: this.projectRoot,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
maxBuffer: 50 * 1024 * 1024, // 50MB buffer
|
||||||
|
stdio: ['inherit', 'pipe', 'pipe']
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = (result.stdout || '') + (result.stderr || '');
|
||||||
|
|
||||||
|
// Print output to console (spawnSync with pipe captures but doesn't display)
|
||||||
|
if (output) console.log(output);
|
||||||
|
|
||||||
|
const { status, message } = this.parseCompletion(
|
||||||
|
output,
|
||||||
|
result.status ?? 1
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
iteration: iterationNum,
|
||||||
|
status,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
159
packages/tm-core/src/modules/loop/types.spec.ts
Normal file
159
packages/tm-core/src/modules/loop/types.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for loop module type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type {
|
||||||
|
LoopPreset,
|
||||||
|
LoopConfig,
|
||||||
|
LoopIteration,
|
||||||
|
LoopResult
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// Also verify types are exported from the barrel
|
||||||
|
import type {
|
||||||
|
LoopPreset as BarrelLoopPreset,
|
||||||
|
LoopConfig as BarrelLoopConfig,
|
||||||
|
LoopIteration as BarrelLoopIteration,
|
||||||
|
LoopResult as BarrelLoopResult
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
describe('Loop Types', () => {
|
||||||
|
describe('LoopPreset', () => {
|
||||||
|
it('accepts valid preset values', () => {
|
||||||
|
// TypeScript compile-time check - these should all be valid
|
||||||
|
const presets: LoopPreset[] = [
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
];
|
||||||
|
expect(presets).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoopConfig', () => {
|
||||||
|
it('accepts valid config with required fields', () => {
|
||||||
|
const config: LoopConfig = {
|
||||||
|
iterations: 10,
|
||||||
|
prompt: 'default',
|
||||||
|
progressFile: '/path/to/progress.txt',
|
||||||
|
sleepSeconds: 5
|
||||||
|
};
|
||||||
|
expect(config.iterations).toBe(10);
|
||||||
|
expect(config.prompt).toBe('default');
|
||||||
|
expect(config.progressFile).toBe('/path/to/progress.txt');
|
||||||
|
expect(config.sleepSeconds).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts config with all optional fields', () => {
|
||||||
|
const config: LoopConfig = {
|
||||||
|
iterations: 5,
|
||||||
|
prompt: 'test-coverage',
|
||||||
|
progressFile: '/progress.txt',
|
||||||
|
sleepSeconds: 3,
|
||||||
|
tag: 'feature-branch'
|
||||||
|
};
|
||||||
|
expect(config.tag).toBe('feature-branch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts custom prompt string', () => {
|
||||||
|
const config: LoopConfig = {
|
||||||
|
iterations: 1,
|
||||||
|
prompt: '/path/to/custom-prompt.txt',
|
||||||
|
progressFile: '/progress.txt',
|
||||||
|
sleepSeconds: 0
|
||||||
|
};
|
||||||
|
expect(config.prompt).toBe('/path/to/custom-prompt.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoopIteration', () => {
|
||||||
|
it('accepts valid iteration with minimal fields', () => {
|
||||||
|
const iteration: LoopIteration = {
|
||||||
|
iteration: 1,
|
||||||
|
status: 'success'
|
||||||
|
};
|
||||||
|
expect(iteration.iteration).toBe(1);
|
||||||
|
expect(iteration.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts iteration with all fields', () => {
|
||||||
|
const iteration: LoopIteration = {
|
||||||
|
iteration: 3,
|
||||||
|
taskId: '1.2',
|
||||||
|
status: 'blocked',
|
||||||
|
message: 'Waiting on external API',
|
||||||
|
duration: 45000
|
||||||
|
};
|
||||||
|
expect(iteration.taskId).toBe('1.2');
|
||||||
|
expect(iteration.message).toBe('Waiting on external API');
|
||||||
|
expect(iteration.duration).toBe(45000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all status values', () => {
|
||||||
|
const statuses: LoopIteration['status'][] = [
|
||||||
|
'success',
|
||||||
|
'blocked',
|
||||||
|
'error',
|
||||||
|
'complete'
|
||||||
|
];
|
||||||
|
expect(statuses).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoopResult', () => {
|
||||||
|
it('accepts valid loop result', () => {
|
||||||
|
const result: LoopResult = {
|
||||||
|
iterations: [
|
||||||
|
{ iteration: 1, status: 'success', taskId: '1' },
|
||||||
|
{ iteration: 2, status: 'success', taskId: '2' }
|
||||||
|
],
|
||||||
|
totalIterations: 2,
|
||||||
|
tasksCompleted: 2,
|
||||||
|
finalStatus: 'all_complete'
|
||||||
|
};
|
||||||
|
expect(result.iterations).toHaveLength(2);
|
||||||
|
expect(result.totalIterations).toBe(2);
|
||||||
|
expect(result.tasksCompleted).toBe(2);
|
||||||
|
expect(result.finalStatus).toBe('all_complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all final status values', () => {
|
||||||
|
const statuses: LoopResult['finalStatus'][] = [
|
||||||
|
'all_complete',
|
||||||
|
'max_iterations',
|
||||||
|
'blocked',
|
||||||
|
'error'
|
||||||
|
];
|
||||||
|
expect(statuses).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Barrel exports from index.ts', () => {
|
||||||
|
it('exports all types from barrel', () => {
|
||||||
|
// Verify types are accessible from barrel export
|
||||||
|
const preset: BarrelLoopPreset = 'default';
|
||||||
|
const config: BarrelLoopConfig = {
|
||||||
|
iterations: 5,
|
||||||
|
prompt: preset,
|
||||||
|
progressFile: '/progress.txt',
|
||||||
|
sleepSeconds: 2
|
||||||
|
};
|
||||||
|
const iteration: BarrelLoopIteration = {
|
||||||
|
iteration: 1,
|
||||||
|
status: 'success'
|
||||||
|
};
|
||||||
|
const result: BarrelLoopResult = {
|
||||||
|
iterations: [iteration],
|
||||||
|
totalIterations: 1,
|
||||||
|
tasksCompleted: 1,
|
||||||
|
finalStatus: 'all_complete'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(config.prompt).toBe('default');
|
||||||
|
expect(result.iterations).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
59
packages/tm-core/src/modules/loop/types.ts
Normal file
59
packages/tm-core/src/modules/loop/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Type definitions for the loop module
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available preset loop prompts
|
||||||
|
*/
|
||||||
|
export type LoopPreset =
|
||||||
|
| 'default'
|
||||||
|
| 'test-coverage'
|
||||||
|
| 'linting'
|
||||||
|
| 'duplication'
|
||||||
|
| 'entropy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for a loop execution
|
||||||
|
*/
|
||||||
|
export interface LoopConfig {
|
||||||
|
/** Number of iterations to run */
|
||||||
|
iterations: number;
|
||||||
|
/** Preset name or custom prompt file path */
|
||||||
|
prompt: LoopPreset | string;
|
||||||
|
/** Path to the progress file */
|
||||||
|
progressFile: string;
|
||||||
|
/** Seconds to sleep between iterations */
|
||||||
|
sleepSeconds: number;
|
||||||
|
/** Tag context to operate on (optional) */
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a single loop iteration
|
||||||
|
*/
|
||||||
|
export interface LoopIteration {
|
||||||
|
/** Iteration number (1-indexed) */
|
||||||
|
iteration: number;
|
||||||
|
/** ID of the task worked on (if any) */
|
||||||
|
taskId?: string;
|
||||||
|
/** Status of this iteration */
|
||||||
|
status: 'success' | 'blocked' | 'error' | 'complete';
|
||||||
|
/** Optional message describing the result */
|
||||||
|
message?: string;
|
||||||
|
/** Duration of this iteration in milliseconds */
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall result of a loop execution
|
||||||
|
*/
|
||||||
|
export interface LoopResult {
|
||||||
|
/** Array of iteration results */
|
||||||
|
iterations: LoopIteration[];
|
||||||
|
/** Total number of iterations executed */
|
||||||
|
totalIterations: number;
|
||||||
|
/** Number of tasks completed successfully */
|
||||||
|
tasksCompleted: number;
|
||||||
|
/** Final status of the loop */
|
||||||
|
finalStatus: 'all_complete' | 'max_iterations' | 'blocked' | 'error';
|
||||||
|
}
|
||||||
@@ -154,6 +154,40 @@ export class TasksDomain {
|
|||||||
return this.taskService.getNextTask(tag);
|
return this.taskService.getNextTask(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of tasks and their subtasks matching a status
|
||||||
|
* Useful for determining work remaining, progress tracking, or setting loop iterations
|
||||||
|
*
|
||||||
|
* @param status - Task status to count (e.g., 'pending', 'done', 'in-progress')
|
||||||
|
* @param tag - Optional tag to filter tasks
|
||||||
|
* @returns Total count of matching tasks + matching subtasks
|
||||||
|
*/
|
||||||
|
async getCount(status: TaskStatus, tag?: string): Promise<number> {
|
||||||
|
// Fetch ALL tasks to ensure we count subtasks across all parent tasks
|
||||||
|
// (a parent task may have different status than its subtasks)
|
||||||
|
const result = await this.list({ tag });
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const task of result.tasks) {
|
||||||
|
// Count the task if it matches the status
|
||||||
|
if (task.status === status) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count subtasks with matching status
|
||||||
|
if (task.subtasks && task.subtasks.length > 0) {
|
||||||
|
for (const subtask of task.subtasks) {
|
||||||
|
// For pending, also count subtasks without status (default to pending)
|
||||||
|
if (subtask.status === status || (status === 'pending' && !subtask.status)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Task Status Management ==========
|
// ========== Task Status Management ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ConfigDomain } from './modules/config/config-domain.js';
|
|||||||
import { ConfigManager } from './modules/config/managers/config-manager.js';
|
import { ConfigManager } from './modules/config/managers/config-manager.js';
|
||||||
import { GitDomain } from './modules/git/git-domain.js';
|
import { GitDomain } from './modules/git/git-domain.js';
|
||||||
import { IntegrationDomain } from './modules/integration/integration-domain.js';
|
import { IntegrationDomain } from './modules/integration/integration-domain.js';
|
||||||
|
import { LoopDomain } from './modules/loop/loop-domain.js';
|
||||||
import { TasksDomain } from './modules/tasks/tasks-domain.js';
|
import { TasksDomain } from './modules/tasks/tasks-domain.js';
|
||||||
import { WorkflowDomain } from './modules/workflow/workflow-domain.js';
|
import { WorkflowDomain } from './modules/workflow/workflow-domain.js';
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export class TmCore {
|
|||||||
private _git!: GitDomain;
|
private _git!: GitDomain;
|
||||||
private _config!: ConfigDomain;
|
private _config!: ConfigDomain;
|
||||||
private _integration!: IntegrationDomain;
|
private _integration!: IntegrationDomain;
|
||||||
|
private _loop!: LoopDomain;
|
||||||
|
|
||||||
// Public readonly getters
|
// Public readonly getters
|
||||||
get tasks(): TasksDomain {
|
get tasks(): TasksDomain {
|
||||||
@@ -107,6 +109,9 @@ export class TmCore {
|
|||||||
get integration(): IntegrationDomain {
|
get integration(): IntegrationDomain {
|
||||||
return this._integration;
|
return this._integration;
|
||||||
}
|
}
|
||||||
|
get loop(): LoopDomain {
|
||||||
|
return this._loop;
|
||||||
|
}
|
||||||
get logger(): Logger {
|
get logger(): Logger {
|
||||||
return this._logger;
|
return this._logger;
|
||||||
}
|
}
|
||||||
@@ -176,6 +181,7 @@ export class TmCore {
|
|||||||
this._git = new GitDomain(this._projectPath);
|
this._git = new GitDomain(this._projectPath);
|
||||||
this._config = new ConfigDomain(this._configManager);
|
this._config = new ConfigDomain(this._configManager);
|
||||||
this._integration = new IntegrationDomain(this._configManager);
|
this._integration = new IntegrationDomain(this._configManager);
|
||||||
|
this._loop = new LoopDomain(this._configManager);
|
||||||
|
|
||||||
// Initialize domains that need async setup
|
// Initialize domains that need async setup
|
||||||
await this._tasks.initialize();
|
await this._tasks.initialize();
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Integration tests for loop type exports from @tm/core
|
||||||
|
*
|
||||||
|
* Verifies that all loop types and the LoopDomain class are correctly
|
||||||
|
* exported from the main @tm/core index.ts entry point.
|
||||||
|
*
|
||||||
|
* @integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// Import types and class from @tm/core main entry point
|
||||||
|
import {
|
||||||
|
LoopDomain,
|
||||||
|
type LoopPreset,
|
||||||
|
type LoopConfig,
|
||||||
|
type LoopIteration,
|
||||||
|
type LoopResult
|
||||||
|
} from '../../../src/index.js';
|
||||||
|
import type { ConfigManager } from '../../../src/modules/config/managers/config-manager.js';
|
||||||
|
|
||||||
|
// Helper to create mock ConfigManager
|
||||||
|
function createMockConfigManager(projectRoot = '/test/project'): ConfigManager {
|
||||||
|
return {
|
||||||
|
getProjectRoot: () => projectRoot
|
||||||
|
} as unknown as ConfigManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Loop Exports from @tm/core', () => {
|
||||||
|
describe('LoopDomain Class Export', () => {
|
||||||
|
it('should export LoopDomain class', () => {
|
||||||
|
expect(LoopDomain).toBeDefined();
|
||||||
|
expect(typeof LoopDomain).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be constructible', () => {
|
||||||
|
const mockConfigManager = createMockConfigManager();
|
||||||
|
const domain = new LoopDomain(mockConfigManager);
|
||||||
|
expect(domain).toBeInstanceOf(LoopDomain);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loop Type Exports', () => {
|
||||||
|
it('should export LoopPreset type (compile-time verification)', () => {
|
||||||
|
// TypeScript compilation verifies these types exist
|
||||||
|
const preset: LoopPreset = 'default';
|
||||||
|
expect(preset).toBe('default');
|
||||||
|
|
||||||
|
// Verify all valid presets
|
||||||
|
const validPresets: LoopPreset[] = [
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
];
|
||||||
|
expect(validPresets).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export LoopConfig type (compile-time verification)', () => {
|
||||||
|
const config: LoopConfig = {
|
||||||
|
iterations: 10,
|
||||||
|
prompt: 'default',
|
||||||
|
sleepSeconds: 5,
|
||||||
|
progressFile: '/path/to/progress.txt'
|
||||||
|
};
|
||||||
|
expect(config.iterations).toBe(10);
|
||||||
|
expect(config.prompt).toBe('default');
|
||||||
|
expect(config.sleepSeconds).toBe(5);
|
||||||
|
expect(config.progressFile).toBe('/path/to/progress.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export LoopConfig type with optional fields', () => {
|
||||||
|
const configWithTag: LoopConfig = {
|
||||||
|
iterations: 5,
|
||||||
|
prompt: '/custom/prompt.md',
|
||||||
|
sleepSeconds: 10,
|
||||||
|
progressFile: '/progress.txt',
|
||||||
|
tag: 'feature-branch'
|
||||||
|
};
|
||||||
|
expect(configWithTag.tag).toBe('feature-branch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export LoopIteration type (compile-time verification)', () => {
|
||||||
|
const iteration: LoopIteration = {
|
||||||
|
iteration: 1,
|
||||||
|
status: 'success',
|
||||||
|
taskId: 'task-1',
|
||||||
|
message: 'Task completed successfully',
|
||||||
|
duration: 1000
|
||||||
|
};
|
||||||
|
expect(iteration.iteration).toBe(1);
|
||||||
|
expect(iteration.status).toBe('success');
|
||||||
|
expect(iteration.taskId).toBe('task-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export LoopIteration with all status values', () => {
|
||||||
|
const statuses: LoopIteration['status'][] = [
|
||||||
|
'success',
|
||||||
|
'complete',
|
||||||
|
'blocked',
|
||||||
|
'error'
|
||||||
|
];
|
||||||
|
expect(statuses).toContain('success');
|
||||||
|
expect(statuses).toContain('complete');
|
||||||
|
expect(statuses).toContain('blocked');
|
||||||
|
expect(statuses).toContain('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export LoopResult type (compile-time verification)', () => {
|
||||||
|
const result: LoopResult = {
|
||||||
|
totalIterations: 3,
|
||||||
|
tasksCompleted: 5,
|
||||||
|
finalStatus: 'all_complete',
|
||||||
|
iterations: []
|
||||||
|
};
|
||||||
|
expect(result.totalIterations).toBe(3);
|
||||||
|
expect(result.finalStatus).toBe('all_complete');
|
||||||
|
expect(result.tasksCompleted).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export LoopResult with all finalStatus values', () => {
|
||||||
|
const finalStatuses: LoopResult['finalStatus'][] = [
|
||||||
|
'all_complete',
|
||||||
|
'max_iterations',
|
||||||
|
'blocked',
|
||||||
|
'error'
|
||||||
|
];
|
||||||
|
expect(finalStatuses).toContain('all_complete');
|
||||||
|
expect(finalStatuses).toContain('max_iterations');
|
||||||
|
expect(finalStatuses).toContain('blocked');
|
||||||
|
expect(finalStatuses).toContain('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Export Usability', () => {
|
||||||
|
it('should support typical import patterns', () => {
|
||||||
|
// This test verifies that the imports at the top of this file work
|
||||||
|
// If the imports fail, this test file won't even compile
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow creating LoopDomain with valid config access', () => {
|
||||||
|
const mockConfigManager = createMockConfigManager();
|
||||||
|
const domain = new LoopDomain(mockConfigManager);
|
||||||
|
|
||||||
|
// Verify domain has expected methods
|
||||||
|
expect(typeof domain.isPreset).toBe('function');
|
||||||
|
expect(typeof domain.resolvePrompt).toBe('function');
|
||||||
|
expect(typeof domain.getAvailablePresets).toBe('function');
|
||||||
|
expect(typeof domain.getIsRunning).toBe('function');
|
||||||
|
expect(typeof domain.stop).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
218
packages/tm-core/tests/integration/loop/loop-domain.test.ts
Normal file
218
packages/tm-core/tests/integration/loop/loop-domain.test.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Integration tests for LoopDomain facade
|
||||||
|
*
|
||||||
|
* Tests the LoopDomain public API and its integration with:
|
||||||
|
* - Preset resolution (using simplified preset exports)
|
||||||
|
* - Index.ts barrel export accessibility
|
||||||
|
*
|
||||||
|
* @integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
LoopDomain,
|
||||||
|
PRESET_NAMES,
|
||||||
|
PRESETS,
|
||||||
|
getPreset,
|
||||||
|
isPreset,
|
||||||
|
type LoopPreset
|
||||||
|
} from '../../../src/modules/loop/index.js';
|
||||||
|
import type { ConfigManager } from '../../../src/modules/config/managers/config-manager.js';
|
||||||
|
|
||||||
|
// Mock ConfigManager factory
|
||||||
|
function createMockConfigManager(projectRoot = '/test/project'): ConfigManager {
|
||||||
|
return {
|
||||||
|
getProjectRoot: vi.fn().mockReturnValue(projectRoot)
|
||||||
|
} as unknown as ConfigManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoopDomain Integration', () => {
|
||||||
|
describe('Barrel Export Accessibility', () => {
|
||||||
|
it('should export LoopDomain from index.ts', () => {
|
||||||
|
expect(LoopDomain).toBeDefined();
|
||||||
|
expect(typeof LoopDomain).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be constructible with ConfigManager', () => {
|
||||||
|
const configManager = createMockConfigManager();
|
||||||
|
const domain = new LoopDomain(configManager);
|
||||||
|
expect(domain).toBeInstanceOf(LoopDomain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export preset utilities alongside LoopDomain', () => {
|
||||||
|
expect(PRESET_NAMES).toBeDefined();
|
||||||
|
expect(PRESETS).toBeDefined();
|
||||||
|
expect(getPreset).toBeDefined();
|
||||||
|
expect(isPreset).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Resolution with Real Presets', () => {
|
||||||
|
let domain: LoopDomain;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const configManager = createMockConfigManager();
|
||||||
|
domain = new LoopDomain(configManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve all presets', async () => {
|
||||||
|
const expectedPresets: LoopPreset[] = [
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const preset of expectedPresets) {
|
||||||
|
const content = await domain.resolvePrompt(preset);
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(100);
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return consistent content between isPreset and resolvePrompt', async () => {
|
||||||
|
const presets = domain.getAvailablePresets();
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
expect(domain.isPreset(preset)).toBe(true);
|
||||||
|
const content = await domain.resolvePrompt(preset);
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify non-presets', () => {
|
||||||
|
expect(domain.isPreset('/path/to/custom.md')).toBe(false);
|
||||||
|
expect(domain.isPreset('my-custom-preset')).toBe(false);
|
||||||
|
expect(domain.isPreset('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match preset content with getPreset utility', async () => {
|
||||||
|
for (const preset of PRESET_NAMES) {
|
||||||
|
const fromDomain = await domain.resolvePrompt(preset);
|
||||||
|
const fromUtility = getPreset(preset);
|
||||||
|
expect(fromDomain).toBe(fromUtility);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Config Building Integration', () => {
|
||||||
|
it('should build config with correct projectRoot in progressFile', () => {
|
||||||
|
const configManager = createMockConfigManager('/my/custom/project');
|
||||||
|
const domain = new LoopDomain(configManager);
|
||||||
|
|
||||||
|
// Access private buildConfig via run preparation
|
||||||
|
// Test indirectly by checking the domain was created with correct projectRoot
|
||||||
|
expect(domain.getAvailablePresets()).toHaveLength(5);
|
||||||
|
|
||||||
|
// Verify preset resolution still works
|
||||||
|
expect(domain.isPreset('default')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple LoopDomain instances independently', () => {
|
||||||
|
const domain1 = new LoopDomain(createMockConfigManager('/project1'));
|
||||||
|
const domain2 = new LoopDomain(createMockConfigManager('/project2'));
|
||||||
|
|
||||||
|
// Both should work independently
|
||||||
|
expect(domain1.isPreset('default')).toBe(true);
|
||||||
|
expect(domain2.isPreset('default')).toBe(true);
|
||||||
|
|
||||||
|
// Each should have its own preset values
|
||||||
|
expect(domain1.getAvailablePresets()).toEqual(
|
||||||
|
domain2.getAvailablePresets()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Run/Stop Lifecycle', () => {
|
||||||
|
let domain: LoopDomain;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
domain = new LoopDomain(createMockConfigManager());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not running initially', () => {
|
||||||
|
expect(domain.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stop when no loop is running', () => {
|
||||||
|
expect(() => domain.stop()).not.toThrow();
|
||||||
|
expect(domain.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow multiple stop calls without error', () => {
|
||||||
|
domain.stop();
|
||||||
|
domain.stop();
|
||||||
|
domain.stop();
|
||||||
|
expect(domain.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Content with Completion Markers', () => {
|
||||||
|
let domain: LoopDomain;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
domain = new LoopDomain(createMockConfigManager());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve presets with detectable completion markers', async () => {
|
||||||
|
for (const preset of domain.getAvailablePresets()) {
|
||||||
|
const content = await domain.resolvePrompt(preset);
|
||||||
|
|
||||||
|
// All presets should have a <loop-complete> marker
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
|
||||||
|
// Extract the marker from content
|
||||||
|
const match = content.match(/<loop-complete>([^<]+)<\/loop-complete>/);
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
expect(match![1].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve default preset with both complete and blocked markers', async () => {
|
||||||
|
const content = await domain.resolvePrompt('default');
|
||||||
|
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
expect(content).toContain('<loop-blocked>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom Prompt File Resolution', () => {
|
||||||
|
let domain: LoopDomain;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
domain = new LoopDomain(createMockConfigManager());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve custom file path with provided readFile callback', async () => {
|
||||||
|
const customContent =
|
||||||
|
'# My Custom Loop Prompt\n<loop-complete>CUSTOM</loop-complete>';
|
||||||
|
const mockReadFile = vi.fn().mockResolvedValue(customContent);
|
||||||
|
|
||||||
|
const content = await domain.resolvePrompt(
|
||||||
|
'/path/to/custom.md',
|
||||||
|
mockReadFile
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith('/path/to/custom.md');
|
||||||
|
expect(content).toBe(customContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for custom path without readFile callback', async () => {
|
||||||
|
await expect(domain.resolvePrompt('/path/to/custom.md')).rejects.toThrow(
|
||||||
|
'Custom prompt file requires readFile callback'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate readFile errors', async () => {
|
||||||
|
const mockReadFile = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('File not found'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
domain.resolvePrompt('/nonexistent/file.md', mockReadFile)
|
||||||
|
).rejects.toThrow('File not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Integration tests for preset accessibility
|
||||||
|
*
|
||||||
|
* Tests that preset content is accessible without filesystem dependencies,
|
||||||
|
* validating that the inlined preset approach works correctly for both
|
||||||
|
* development and bundled distribution contexts.
|
||||||
|
*
|
||||||
|
* These tests verify:
|
||||||
|
* - All 5 presets can be loaded without filesystem access
|
||||||
|
* - Preset content contains required markers for loop completion detection
|
||||||
|
* - Preset content structure is valid and usable by the loop system
|
||||||
|
*
|
||||||
|
* @integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
PRESETS,
|
||||||
|
PRESET_NAMES,
|
||||||
|
getPreset,
|
||||||
|
isPreset,
|
||||||
|
type LoopPreset
|
||||||
|
} from '../../../src/modules/loop/index.js';
|
||||||
|
|
||||||
|
describe('Preset Accessibility Integration', () => {
|
||||||
|
describe('Preset Accessibility', () => {
|
||||||
|
it('should load all 5 presets without filesystem access', () => {
|
||||||
|
// This test verifies that presets are inlined and don't require fs
|
||||||
|
expect(PRESET_NAMES).toHaveLength(5);
|
||||||
|
|
||||||
|
for (const presetName of PRESET_NAMES) {
|
||||||
|
const content = getPreset(presetName);
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(typeof content).toBe('string');
|
||||||
|
expect(content.length).toBeGreaterThan(100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all expected preset names available', () => {
|
||||||
|
const expectedPresets: LoopPreset[] = [
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const preset of expectedPresets) {
|
||||||
|
expect(isPreset(preset)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have PRESETS record with all presets', () => {
|
||||||
|
expect(Object.keys(PRESETS)).toHaveLength(5);
|
||||||
|
expect(PRESETS['default']).toBeTruthy();
|
||||||
|
expect(PRESETS['test-coverage']).toBeTruthy();
|
||||||
|
expect(PRESETS['linting']).toBeTruthy();
|
||||||
|
expect(PRESETS['duplication']).toBeTruthy();
|
||||||
|
expect(PRESETS['entropy']).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have getPreset return same content as PRESETS record', () => {
|
||||||
|
for (const presetName of PRESET_NAMES) {
|
||||||
|
expect(getPreset(presetName)).toBe(PRESETS[presetName]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Content Structure', () => {
|
||||||
|
it('all presets should contain loop-complete marker', () => {
|
||||||
|
for (const presetName of PRESET_NAMES) {
|
||||||
|
const content = getPreset(presetName);
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default preset should contain both complete and blocked markers', () => {
|
||||||
|
const content = getPreset('default');
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
expect(content).toContain('<loop-blocked>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all presets should reference progress file', () => {
|
||||||
|
for (const presetName of PRESET_NAMES) {
|
||||||
|
const content = getPreset(presetName);
|
||||||
|
// Default uses "progress file", others use "loop-progress"
|
||||||
|
expect(content).toMatch(/loop-progress|progress file/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all presets should emphasize single-task constraint', () => {
|
||||||
|
for (const presetName of PRESET_NAMES) {
|
||||||
|
const content = getPreset(presetName);
|
||||||
|
// All presets should mention completing ONE task/test/fix per session
|
||||||
|
expect(content).toMatch(/\bONE\b/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each preset should have unique completion reason', () => {
|
||||||
|
const completionReasons = new Set<string>();
|
||||||
|
|
||||||
|
for (const presetName of PRESET_NAMES) {
|
||||||
|
const content = getPreset(presetName);
|
||||||
|
// Extract the completion reason from <loop-complete>REASON</loop-complete>
|
||||||
|
const match = content.match(/<loop-complete>([^<]+)<\/loop-complete>/);
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
if (match) {
|
||||||
|
completionReasons.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All presets should have unique completion reasons
|
||||||
|
expect(completionReasons.size).toBe(PRESET_NAMES.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Completion Marker Detection', () => {
|
||||||
|
// Simple regex-based completion marker detection (inlined from deleted LoopCompletionService)
|
||||||
|
const parseOutput = (output: string) => {
|
||||||
|
const completeMatch = output.match(
|
||||||
|
/<loop-complete>([^<]*)<\/loop-complete>/i
|
||||||
|
);
|
||||||
|
const blockedMatch = output.match(
|
||||||
|
/<loop-blocked>([^<]*)<\/loop-blocked>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isComplete: !!completeMatch,
|
||||||
|
isBlocked: !!blockedMatch,
|
||||||
|
marker: completeMatch
|
||||||
|
? { type: 'complete' as const, reason: completeMatch[1] }
|
||||||
|
: blockedMatch
|
||||||
|
? { type: 'blocked' as const, reason: blockedMatch[1] }
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should detect completion markers from preset content', () => {
|
||||||
|
// Simulate agent output containing the completion marker from default preset
|
||||||
|
const agentOutput = `
|
||||||
|
I have completed all the tasks in the backlog. There are no more pending tasks to work on.
|
||||||
|
|
||||||
|
<loop-complete>ALL_TASKS_DONE</loop-complete>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parseOutput(agentOutput);
|
||||||
|
expect(result.isComplete).toBe(true);
|
||||||
|
expect(result.marker?.type).toBe('complete');
|
||||||
|
expect(result.marker?.reason).toBe('ALL_TASKS_DONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect blocked marker from default preset', () => {
|
||||||
|
const agentOutput = `
|
||||||
|
I cannot proceed because the API key is not configured.
|
||||||
|
|
||||||
|
<loop-blocked>MISSING_API_KEY</loop-blocked>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parseOutput(agentOutput);
|
||||||
|
expect(result.isBlocked).toBe(true);
|
||||||
|
expect(result.marker?.type).toBe('blocked');
|
||||||
|
expect(result.marker?.reason).toBe('MISSING_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect test-coverage preset completion marker', () => {
|
||||||
|
const agentOutput = `
|
||||||
|
Coverage has reached 95% which exceeds our target of 80%.
|
||||||
|
|
||||||
|
<loop-complete>COVERAGE_TARGET</loop-complete>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parseOutput(agentOutput);
|
||||||
|
expect(result.isComplete).toBe(true);
|
||||||
|
expect(result.marker?.reason).toBe('COVERAGE_TARGET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect linting preset completion marker', () => {
|
||||||
|
const agentOutput = `
|
||||||
|
All lint errors and type errors have been fixed.
|
||||||
|
|
||||||
|
<loop-complete>ZERO_ERRORS</loop-complete>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parseOutput(agentOutput);
|
||||||
|
expect(result.isComplete).toBe(true);
|
||||||
|
expect(result.marker?.reason).toBe('ZERO_ERRORS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect duplication preset completion marker', () => {
|
||||||
|
const agentOutput = `
|
||||||
|
Code duplication is now at 2.5%, below the 3% threshold.
|
||||||
|
|
||||||
|
<loop-complete>LOW_DUPLICATION</loop-complete>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parseOutput(agentOutput);
|
||||||
|
expect(result.isComplete).toBe(true);
|
||||||
|
expect(result.marker?.reason).toBe('LOW_DUPLICATION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect entropy preset completion marker', () => {
|
||||||
|
const agentOutput = `
|
||||||
|
No significant code smells remain in the codebase.
|
||||||
|
|
||||||
|
<loop-complete>LOW_ENTROPY</loop-complete>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parseOutput(agentOutput);
|
||||||
|
expect(result.isComplete).toBe(true);
|
||||||
|
expect(result.marker?.reason).toBe('LOW_ENTROPY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPreset Consistency', () => {
|
||||||
|
it('should return true for valid presets', () => {
|
||||||
|
const validPresets = [
|
||||||
|
'default',
|
||||||
|
'test-coverage',
|
||||||
|
'linting',
|
||||||
|
'duplication',
|
||||||
|
'entropy'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of validPresets) {
|
||||||
|
expect(isPreset(name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid presets', () => {
|
||||||
|
const invalidPresets = [
|
||||||
|
'invalid',
|
||||||
|
'custom',
|
||||||
|
'',
|
||||||
|
'DEFAULT',
|
||||||
|
'Test-Coverage'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of invalidPresets) {
|
||||||
|
expect(isPreset(name)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Integration tests for LoopDomain access via TmCore
|
||||||
|
*
|
||||||
|
* Verifies that LoopDomain is properly accessible through TmCore.loop
|
||||||
|
* and methods work correctly.
|
||||||
|
*
|
||||||
|
* @integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the logger to reduce noise in tests
|
||||||
|
vi.mock('../../../src/common/logger/index.js', () => {
|
||||||
|
const mockLogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
child: vi.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
createLogger: () => mockLogger,
|
||||||
|
getLogger: () => mockLogger
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createTmCore, TmCore, LoopDomain } from '../../../src/index.js';
|
||||||
|
|
||||||
|
describe('LoopDomain Access via TmCore', () => {
|
||||||
|
let testProjectDir: string;
|
||||||
|
let tmCore: TmCore | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create temp project directory for isolation
|
||||||
|
testProjectDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'tm-loop-access-test-')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create minimal taskmaster config structure
|
||||||
|
const taskmasterDir = path.join(testProjectDir, '.taskmaster');
|
||||||
|
const tasksDir = path.join(taskmasterDir, 'tasks');
|
||||||
|
fs.mkdirSync(tasksDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create empty tasks.json for TasksDomain initialization
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tasksDir, 'tasks.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
tasks: [],
|
||||||
|
tags: { default: { tasks: [] } },
|
||||||
|
activeTag: 'default'
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create config.json
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(taskmasterDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
models: {
|
||||||
|
main: { id: 'test-model', provider: 'test' },
|
||||||
|
research: { id: 'test-model', provider: 'test' },
|
||||||
|
fallback: { id: 'test-model', provider: 'test' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up TmCore
|
||||||
|
if (tmCore) {
|
||||||
|
await tmCore.close();
|
||||||
|
tmCore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
if (testProjectDir) {
|
||||||
|
fs.rmSync(testProjectDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TmCore.loop Accessor', () => {
|
||||||
|
it('should have loop property accessible after createTmCore()', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
expect(tmCore.loop).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return LoopDomain instance', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
expect(tmCore.loop).toBeInstanceOf(LoopDomain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have expected LoopDomain methods', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
expect(typeof tmCore.loop.isPreset).toBe('function');
|
||||||
|
expect(typeof tmCore.loop.resolvePrompt).toBe('function');
|
||||||
|
expect(typeof tmCore.loop.getAvailablePresets).toBe('function');
|
||||||
|
expect(typeof tmCore.loop.getIsRunning).toBe('function');
|
||||||
|
expect(typeof tmCore.loop.stop).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preset Resolution via TmCore', () => {
|
||||||
|
it('should resolve preset content through TmCore.loop', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
const content = await tmCore.loop.resolvePrompt('default');
|
||||||
|
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
expect(content.length).toBeGreaterThan(100);
|
||||||
|
expect(content).toContain('<loop-complete>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify valid presets', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
expect(tmCore.loop.isPreset('default')).toBe(true);
|
||||||
|
expect(tmCore.loop.isPreset('test-coverage')).toBe(true);
|
||||||
|
expect(tmCore.loop.isPreset('/some/file/path.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list all available presets', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
const presets = tmCore.loop.getAvailablePresets();
|
||||||
|
|
||||||
|
expect(presets).toContain('default');
|
||||||
|
expect(presets).toContain('test-coverage');
|
||||||
|
expect(presets).toContain('linting');
|
||||||
|
expect(presets).toContain('duplication');
|
||||||
|
expect(presets).toContain('entropy');
|
||||||
|
expect(presets).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loop Lifecycle via TmCore', () => {
|
||||||
|
it('should report not running initially', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
expect(tmCore.loop.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stop() when not running', async () => {
|
||||||
|
tmCore = await createTmCore({ projectPath: testProjectDir });
|
||||||
|
|
||||||
|
// stop() is now synchronous and should not throw
|
||||||
|
expect(() => tmCore!.loop.stop()).not.toThrow();
|
||||||
|
expect(tmCore.loop.getIsRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
scripts/loop.sh
Executable file
70
scripts/loop.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Keeping here for reference, but using the new task-master loop command instead
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Usage: $0 <iterations>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cold start check - test if sandbox auth is ready
|
||||||
|
echo "Checking sandbox auth..."
|
||||||
|
test_result=$(docker sandbox run claude -p "Say OK" 2>&1) || true
|
||||||
|
|
||||||
|
if [[ "$test_result" == *"OK"* ]]; then
|
||||||
|
echo "✓ Sandbox ready"
|
||||||
|
else
|
||||||
|
echo "Sandbox needs authentication. Starting interactive session..."
|
||||||
|
echo "Please complete auth, then ctrl+c to continue."
|
||||||
|
echo ""
|
||||||
|
docker sandbox run claude "cool you're in! you can now exit (ctrl + c) so we can continue the loop"
|
||||||
|
echo ""
|
||||||
|
echo "✓ Auth complete"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for ((i=1; i<=$1; i++)); do
|
||||||
|
echo ""
|
||||||
|
echo "━━━ Iteration $i of $1 ━━━"
|
||||||
|
|
||||||
|
result=$(docker sandbox run claude -p "@.taskmaster/tasks/tasks.json @.taskmaster/loop-progress.txt @CLAUDE.md \
|
||||||
|
SETUP: If task-master command not found, run: npm i -g task-master-ai \
|
||||||
|
\
|
||||||
|
TASK: Implement ONE task/subtask for the tm loop feature. \
|
||||||
|
\
|
||||||
|
PROCESS: \
|
||||||
|
1. Run task-master next (or use MCP) to get the next available task/subtask. \
|
||||||
|
2. Read task details with task-master show <id>. \
|
||||||
|
3. Implement following codebase patterns (CLI in apps/cli/src/commands/, core in packages/tm-core/src/modules/). \
|
||||||
|
4. Write tests alongside implementation. \
|
||||||
|
5. Run npm run turbo:typecheck to verify types. \
|
||||||
|
6. Run npm test -w <package> to verify tests pass. \
|
||||||
|
7. Mark complete: task-master set-status --id=<id> --status=done \
|
||||||
|
8. Commit with message: feat(loop): <what was implemented> (do NOT include loop-progress.txt in commit) \
|
||||||
|
9. Append super-concise notes to .taskmaster/loop-progress.txt: task ID, what was done, any learnings. \
|
||||||
|
\
|
||||||
|
IMPORTANT: \
|
||||||
|
- Complete ONLY ONE task per iteration. \
|
||||||
|
- Keep changes small and focused. \
|
||||||
|
- Do NOT start another task after completing one. \
|
||||||
|
- If all tasks are done, output <loop-complete>ALL_DONE</loop-complete>. \
|
||||||
|
- If blocked, output <loop-blocked>REASON</loop-blocked>. \
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "$result"
|
||||||
|
|
||||||
|
# Strict match - require exact format with content
|
||||||
|
if [[ "$result" =~ \<loop-complete\>ALL_DONE\</loop-complete\> ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "All tasks complete after $i iterations."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$result" =~ \<loop-blocked\>.+\</loop-blocked\> ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Blocked at iteration $i."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Max iterations ($1) reached."
|
||||||
131
tests/unit/mcp-server/tools/tool-registry.test.js
Normal file
131
tests/unit/mcp-server/tools/tool-registry.test.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* tool-registry.test.js
|
||||||
|
* Tests for tool registry - verifies tools are correctly registered in tiers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
coreTools,
|
||||||
|
getAvailableTools,
|
||||||
|
getToolCategories,
|
||||||
|
getToolCounts,
|
||||||
|
getToolRegistration,
|
||||||
|
isValidTool,
|
||||||
|
standardTools,
|
||||||
|
toolRegistry
|
||||||
|
} from '../../../../mcp-server/src/tools/tool-registry.js';
|
||||||
|
|
||||||
|
describe('tool-registry', () => {
|
||||||
|
describe('tool tier structure', () => {
|
||||||
|
it('should have exactly 7 core tools', () => {
|
||||||
|
expect(coreTools.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have exactly 14 standard tools', () => {
|
||||||
|
expect(standardTools.length).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have standardTools include all coreTools', () => {
|
||||||
|
coreTools.forEach((tool) => {
|
||||||
|
expect(standardTools).toContain(tool);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all standardTools registered in toolRegistry', () => {
|
||||||
|
standardTools.forEach((tool) => {
|
||||||
|
expect(toolRegistry[tool]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAvailableTools', () => {
|
||||||
|
it('should return all registered tool names', () => {
|
||||||
|
const tools = getAvailableTools();
|
||||||
|
expect(Array.isArray(tools)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getToolCounts', () => {
|
||||||
|
it('should return correct counts', () => {
|
||||||
|
const counts = getToolCounts();
|
||||||
|
expect(counts.core).toBe(7);
|
||||||
|
expect(counts.standard).toBe(14);
|
||||||
|
expect(counts.total).toBeGreaterThanOrEqual(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getToolCategories', () => {
|
||||||
|
it('should return categories with core tools', () => {
|
||||||
|
const categories = getToolCategories();
|
||||||
|
expect(categories.core).toContain('get_tasks');
|
||||||
|
expect(categories.core).toContain('next_task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return categories with standard tools', () => {
|
||||||
|
const categories = getToolCategories();
|
||||||
|
expect(categories.standard).toContain('get_tasks');
|
||||||
|
expect(categories.standard).toContain('add_task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return categories with all tools', () => {
|
||||||
|
const categories = getToolCategories();
|
||||||
|
expect(categories.all.length).toBeGreaterThanOrEqual(
|
||||||
|
categories.standard.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getToolRegistration', () => {
|
||||||
|
it('should return registration function for get_tasks', () => {
|
||||||
|
const registration = getToolRegistration('get_tasks');
|
||||||
|
expect(registration).toBeDefined();
|
||||||
|
expect(typeof registration).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return registration function for add_task', () => {
|
||||||
|
const registration = getToolRegistration('add_task');
|
||||||
|
expect(registration).toBeDefined();
|
||||||
|
expect(typeof registration).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unknown tool', () => {
|
||||||
|
const registration = getToolRegistration('unknown_tool');
|
||||||
|
expect(registration).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidTool', () => {
|
||||||
|
it('should return true for get_tasks', () => {
|
||||||
|
expect(isValidTool('get_tasks')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for add_task', () => {
|
||||||
|
expect(isValidTool('add_task')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for unknown tool', () => {
|
||||||
|
expect(isValidTool('unknown_tool')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TASK_MASTER_TOOLS behavior simulation', () => {
|
||||||
|
it('should allow filtering to core tools only', () => {
|
||||||
|
const coreToolSet = new Set(coreTools);
|
||||||
|
expect(coreToolSet.has('get_tasks')).toBe(true);
|
||||||
|
expect(coreToolSet.has('next_task')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow filtering to standard tools', () => {
|
||||||
|
const standardToolSet = new Set(standardTools);
|
||||||
|
expect(standardToolSet.has('get_tasks')).toBe(true);
|
||||||
|
expect(standardToolSet.has('next_task')).toBe(true);
|
||||||
|
expect(standardToolSet.has('add_task')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include all tools when using getAvailableTools', () => {
|
||||||
|
const allTools = getAvailableTools();
|
||||||
|
const allToolSet = new Set(allTools);
|
||||||
|
expect(allToolSet.has('get_tasks')).toBe(true);
|
||||||
|
expect(allToolSet.has('add_task')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user