diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f9f5a5a8..55dba6bb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,6 +8,19 @@ "email": "noah@unsupervised.com" }, "plugins": [ + { + "name": "deepwork", + "description": "Framework for AI-powered multi-step workflows with quality gates", + "version": "0.8.0", + "source": "./plugins/claude", + "author": { + "name": "DeepWork" + }, + "category": "workflow", + "keywords": ["workflow", "automation", "quality-gates", "multi-step"], + "repository": "https://github.com/Unsupervisedcom/deepwork", + "license": "BSL-1.1" + }, { "name": "learning-agents", "description": "Auto-improving AI sub-agents that learn from their mistakes across sessions", diff --git a/.claude/settings.json b/.claude/settings.json index fe5ff43a..3cce7418 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -109,17 +109,8 @@ "mcp__deepwork__abort_workflow" ] }, - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "src/deepwork/hooks/check_version.sh" - } - ] - } - ] + "enabledPlugins": { + "deepwork@deepwork-plugins": true, + "learning-agents@deepwork-plugins": true } } diff --git a/.deepwork/config.yml b/.deepwork/config.yml deleted file mode 100644 index 30c250ec..00000000 --- a/.deepwork/config.yml +++ /dev/null @@ -1,4 +0,0 @@ -version: '1.0' -platforms: -- claude -- gemini diff --git a/.deepwork/doc_specs/claude_code_skill.md b/.deepwork/doc_specs/claude_code_skill.md deleted file mode 100644 index 6ba57e64..00000000 --- a/.deepwork/doc_specs/claude_code_skill.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -name: "Claude Code Skill Definition" -description: "A SKILL.md file that defines a Claude Code skill with metadata, instructions, and examples" -path_patterns: - - ".claude/skills/*/SKILL.md" - - ".claude/skills/**/SKILL.md" -target_audience: "Claude Code agents auto-selecting skills based on user intent" -frequency: "Created once per skill, updated as requirements evolve" -quality_criteria: - - name: Kebab-Case Name - description: "Skill name must be lowercase with hyphens only (e.g., `github-pr-review`, `unit-test-generator`). No underscores, spaces, or reserved words like `anthropic`, `claude`, or `general`." - - name: Third-Person Description - description: "Description must be written in third person (e.g., 'Generates...' not 'I generate...'). Under 300 characters. Must clearly state WHAT the skill does and WHEN to use it." - - name: Discoverable Keywords - description: "Description must include specific technical terms a user might say (e.g., 'PostgreSQL', 'React components', 'AWS Lambda') to enable reliable auto-selection." - - name: Progressive Disclosure - description: "Main SKILL.md file must be under 500 lines. Large reference docs or schemas must be split into separate support files loaded on demand." - - name: Input Handling - description: "Must clearly define required inputs before execution begins. Instructions must tell Claude to ask the user for missing inputs rather than guessing." - - name: Output Format - description: "Must explicitly state how output should be formatted (e.g., 'Output as a JSON block' or 'Create a markdown file at path X')." - - name: Worked Example - description: "Must include at least one Input -> Output example showing the ideal result. Few-shot examples significantly reduce hallucinations." - - name: Guardrails Section - description: "Must include a section listing what Claude should NOT do (e.g., 'Do not delete existing comments', 'Do not mock database connections')." - - name: Concrete File References - description: "Instructions must use specific file paths, not vague descriptions (e.g., 'Read src/config.json' not 'Read the config file')." - - name: Plan Before Action - description: "For complex tasks, instructions must include an analysis/planning step before implementation begins." ---- - -# Claude Code Skill Definition: [skill-name] - -A `SKILL.md` file defines a skill that Claude Code can auto-select based on user intent. Unlike slash commands (invoked explicitly via `/command`), skills are discovered and triggered automatically when Claude determines they match the user's request. - -## File Structure - -``` -.claude/ -└── skills/ - ├── database-migration/ # Skill directory (kebab-case) - │ ├── SKILL.md # Entry point (required) - │ └── schema-reference.txt # Support file (loaded on demand) - └── code-reviewer/ - └── SKILL.md -``` - -## SKILL.md Format - -### Frontmatter (Required) - -```yaml ---- -name: database-migration -description: "Generates database migration files for schema changes. Use when adding, modifying, or removing database tables or columns." ---- -``` - -### Body (Instructions) - -The markdown body instructs Claude how to execute the skill once selected. - -## Example: Complete Skill Definition - -```markdown ---- -name: github-pr-review -description: "Reviews GitHub pull requests for code quality, security issues, and adherence to project conventions. Use when the user mentions reviewing a PR, code review, or asks about changes in a pull request." ---- - -# github-pr-review - -Reviews GitHub pull requests and provides structured feedback. - -## Required Inputs - -Before proceeding, ensure you have: -1. PR number or URL (ask user if not provided) -2. Review focus areas (optional: security, performance, style) - -## Execution Steps - -### Step 1: Analyze PR Context - -Fetch the PR details and understand: -- Files changed and their purposes -- Base branch and target branch -- Related issues or requirements - -### Step 2: Review Code Changes - -For each changed file: -1. Check for security vulnerabilities -2. Verify error handling -3. Assess test coverage -4. Review naming conventions -5. Identify performance concerns - -### Step 3: Generate Review - -Create a structured review with: -- Summary of changes -- Critical issues (must fix) -- Suggestions (nice to have) -- Questions for the author - -## Example - -**Input:** "Review PR #42" - -**Output:** - -## PR #42 Review: Add user authentication - -### Summary -This PR adds JWT-based authentication to the API endpoints. - -### Critical Issues -1. **Line 45 in auth.js**: Token expiration not validated before use -2. **Line 89 in middleware.js**: Missing rate limiting on login endpoint - -### Suggestions -- Consider adding refresh token rotation -- Add integration tests for the auth flow - -### Questions -- Should failed login attempts trigger account lockout? - -## Guardrails - -- Do NOT approve or merge PRs automatically -- Do NOT modify code in the PR -- Do NOT dismiss existing reviews -- Do NOT share sensitive information found in code -``` - -## Naming Conventions - -| Bad Name | Good Name | Why? | -|----------|-----------|------| -| `github` | `github-pr-review` | "github" is too broad; be specific about the action | -| `process_data` | `csv-data-processor` | Use hyphens, not underscores; specify the data type | -| `my-tool` | `unit-test-generator` | Generic names are not discoverable; describe the output | -| `claude-helper` | `api-error-analyzer` | Avoid reserved words; focus on functionality | - -## Description Best Practices - -### Good Description -> "Generates database migration files from schema changes. Use when the user needs to add columns, create tables, or modify database structure." - -### Poor Description -> "You can use this to help with database stuff." - -### What Makes a Good Description - -1. **Third person voice**: "Generates..." not "I generate..." -2. **Specific action**: "migration files" not "database stuff" -3. **Clear trigger**: "Use when..." clause -4. **Technical keywords**: Terms users actually say - -## Subagent Delegation - -For tasks requiring extensive file reading or research, delegate to a subagent to preserve context: - -```markdown -## Step 2: Analyze Codebase - -Create a subagent to: -1. Scan the `src/` directory for circular dependencies -2. Identify unused exports -3. Return only a summary, not full file contents - -This prevents the main context from being polluted with irrelevant code. -``` - -## Testing Checklist - -Before publishing, verify: - -- [ ] **Discovery Test**: Clear history, ask a vague related question. Does the skill trigger? -- [ ] **Argument Test**: Omit a required input. Does Claude ask for it? -- [ ] **Negative Test**: Ask something outside scope. Does the skill stay silent? -- [ ] **Edge Case Test**: Empty inputs, large inputs, malformed data handled? diff --git a/.deepwork/doc_specs/job_spec.md b/.deepwork/doc_specs/job_spec.md deleted file mode 100644 index 9bbd795f..00000000 --- a/.deepwork/doc_specs/job_spec.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -name: "DeepWork Job Specification" -description: "YAML specification file that defines a multi-step workflow job for AI agents" -path_patterns: - - ".deepwork/jobs/*/job.yml" -target_audience: "AI agents executing jobs and developers defining workflows" -frequency: "Created once per job, updated as workflow evolves" -quality_criteria: - - name: Valid Identifier - description: "Job name must be lowercase with underscores, no spaces or special characters (e.g., `competitive_research`, `monthly_report`)" - - name: Semantic Version - description: "Version must follow semantic versioning format X.Y.Z (e.g., `1.0.0`, `2.1.3`)" - - name: Concise Summary - description: "Summary must be under 200 characters and clearly describe what the job accomplishes" - - name: Common Job Info - description: "common_job_info_provided_to_all_steps_at_runtime must be present and provide shared context for all steps" - - name: Complete Steps - description: "Each step must have: id (lowercase_underscores), name, description, instructions_file, outputs (at least one), and dependencies array" - - name: Valid Dependencies - description: "Dependencies must reference existing step IDs with no circular references" - - name: Input Consistency - description: "File inputs with `from_step` must reference a step that is in the dependencies array" - - name: Output Paths - description: "Outputs must be valid filenames or paths within the main repo directory structure, never in dot-directories like `.deepwork/`. Use specific, descriptive paths that lend themselves to glob patterns (e.g., `competitive_research/acme_corp/swot.md` or `operations/reports/2026-01/spending_analysis.md`). Parameterized paths like `[competitor_name]/` are encouraged for per-entity outputs. Avoid generic names (`output.md`, `analysis.md`) and transient-sounding paths (`temp/`, `draft.md`). Supporting materials for a final output should go in a peer `_dataroom` folder (e.g., `spending_analysis_dataroom/`)." - - name: Concise Instructions - description: "The content of the file, particularly the description, must not have excessively redundant information. It should be concise and to the point given that extra tokens will confuse the AI." ---- - -# DeepWork Job Specification: [job_name] - -A `job.yml` file defines a complete multi-step workflow that AI agents can execute. Each job breaks down a complex task into reviewable steps with clear inputs and outputs. - -## Required Fields - -### Top-Level Metadata - -```yaml -name: job_name # lowercase, underscores only -version: "1.0.0" # semantic versioning -summary: "Brief description" # max 200 characters -common_job_info_provided_to_all_steps_at_runtime: | - [Common context shared across all steps at runtime. - Include key terminology, constraints, and shared knowledge.] -``` - -### Steps Array - -```yaml -steps: - - id: step_id # unique, lowercase_underscores - name: "Human Readable Name" - description: "What this step accomplishes" - instructions_file: steps/step_id.md - inputs: - # User-provided inputs: - - name: param_name - description: "What the user provides" - # File inputs from previous steps: - - file: output.md - from_step: previous_step_id - outputs: - - competitive_research/competitors_list.md # descriptive path - - competitive_research/[competitor_name]/research.md # parameterized path - # With doc spec reference: - - file: competitive_research/final_report.md - doc_spec: .deepwork/doc_specs/report_type.md - dependencies: - - previous_step_id # steps that must complete first -``` - -## Optional Fields - -### Agent Delegation - -When a step should be executed by a specific agent type, use the `agent` field. This automatically sets `context: fork` in the generated skill. - -```yaml -steps: - - id: research_step - agent: general-purpose # Delegates to the general-purpose agent -``` - -Available agent types: -- `general-purpose` - Standard agent for multi-step tasks - -### Quality Hooks - -```yaml -steps: - - id: step_id - hooks: - after_agent: - # Inline prompt for quality validation: - - prompt: | - Verify the output meets criteria: - 1. [Criterion 1] - 2. [Criterion 2] - If ALL criteria are met, include `...`. - # External prompt file: - - prompt_file: hooks/quality_check.md - # Script for programmatic validation: - - script: hooks/run_tests.sh -``` - -## Validation Rules - -1. **No circular dependencies**: Step A cannot depend on Step B if Step B depends on Step A -2. **File inputs require dependencies**: If a step uses `from_step: X`, then X must be in its dependencies -3. **Unique step IDs**: No two steps can have the same id -4. **Valid file paths**: Output paths must not contain invalid characters and should be in the main repo (not dot-directories) -5. **Instructions files exist**: Each `instructions_file` path should have a corresponding file created - -## Example: Complete Job Specification - -```yaml -name: competitive_research -version: "1.0.0" -summary: "Systematic competitive analysis workflow" -common_job_info_provided_to_all_steps_at_runtime: | - A comprehensive workflow for analyzing competitors in your market segment. - Helps product teams understand the competitive landscape through systematic - identification, research, comparison, and positioning recommendations. - -steps: - - id: identify_competitors - name: "Identify Competitors" - description: "Identify 5-7 key competitors in the target market" - instructions_file: steps/identify_competitors.md - inputs: - - name: market_segment - description: "The market segment to analyze" - - name: product_category - description: "The product category" - outputs: - - competitive_research/competitors_list.md - dependencies: [] - - - id: research_competitors - name: "Research Competitors" - description: "Deep dive research on each identified competitor" - instructions_file: steps/research_competitors.md - inputs: - - file: competitive_research/competitors_list.md - from_step: identify_competitors - outputs: - - competitive_research/[competitor_name]/research.md - dependencies: - - identify_competitors - - - id: positioning_report - name: "Positioning Report" - description: "Strategic positioning recommendations" - instructions_file: steps/positioning_report.md - inputs: - - file: competitive_research/[competitor_name]/research.md - from_step: research_competitors - outputs: - - file: competitive_research/positioning_report.md - doc_spec: .deepwork/doc_specs/positioning_report.md - dependencies: - - research_competitors -``` diff --git a/.deepwork/schemas/job.schema.json b/.deepwork/schemas/job.schema.json deleted file mode 100644 index 92055d36..00000000 --- a/.deepwork/schemas/job.schema.json +++ /dev/null @@ -1,378 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://deepwork.dev/schemas/job.schema.json", - "title": "DeepWork Job Definition", - "description": "Schema for DeepWork job.yml files. Jobs are multi-step workflows executed by AI agents.", - "type": "object", - "required": [ - "name", - "version", - "summary", - "common_job_info_provided_to_all_steps_at_runtime", - "steps" - ], - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$", - "description": "Job name (lowercase letters, numbers, underscores, must start with letter). Example: 'competitive_research'" - }, - "version": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "description": "Semantic version (e.g., '1.0.0')" - }, - "summary": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Brief one-line summary of what this job accomplishes. Used in skill descriptions." - }, - "common_job_info_provided_to_all_steps_at_runtime": { - "type": "string", - "minLength": 1, - "description": "Common context and information provided to all steps at runtime. Use this for shared knowledge that every step needs (e.g., project background, key terminology, constraints, or conventions) rather than duplicating it in individual step instructions." - }, - "workflows": { - "type": "array", - "description": "Named workflows that group steps into multi-step sequences. Workflows define execution order.", - "items": { - "$ref": "#/$defs/workflow" - } - }, - "steps": { - "type": "array", - "minItems": 1, - "description": "List of steps in the job. Each step becomes a skill/command.", - "items": { - "$ref": "#/$defs/step" - } - } - }, - "$defs": { - "stepId": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$", - "description": "Step identifier (lowercase letters, numbers, underscores, must start with letter)" - }, - "workflow": { - "type": "object", - "required": [ - "name", - "summary", - "steps" - ], - "additionalProperties": false, - "description": "A named workflow grouping steps into a sequence", - "properties": { - "name": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$", - "description": "Workflow name (lowercase letters, numbers, underscores)" - }, - "summary": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Brief one-line summary of what this workflow accomplishes" - }, - "steps": { - "type": "array", - "minItems": 1, - "description": "Ordered list of step entries. Each entry is either a step ID (string) or an array of step IDs for concurrent execution.", - "items": { - "$ref": "#/$defs/workflowStepEntry" - } - } - } - }, - "workflowStepEntry": { - "oneOf": [ - { - "$ref": "#/$defs/stepId" - }, - { - "type": "array", - "minItems": 1, - "description": "Array of step IDs that can be executed concurrently", - "items": { - "$ref": "#/$defs/stepId" - } - } - ] - }, - "step": { - "type": "object", - "required": [ - "id", - "name", - "description", - "instructions_file", - "outputs", - "reviews" - ], - "additionalProperties": false, - "description": "A single Step in a job, representing one material unit of work with evaluatable outputs", - "properties": { - "id": { - "$ref": "#/$defs/stepId", - "description": "Unique step identifier within this job" - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Human-readable display name for the step" - }, - "description": { - "type": "string", - "minLength": 1, - "description": "Description of what this step does. Used in skill descriptions." - }, - "instructions_file": { - "type": "string", - "minLength": 1, - "description": "Path to instructions markdown file (relative to job directory). Example: 'steps/research.md'" - }, - "inputs": { - "type": "array", - "description": "List of inputs required by this step (user parameters or files from previous steps)", - "items": { - "$ref": "#/$defs/stepInput" - } - }, - "outputs": { - "type": "object", - "description": "Named outputs produced by this step. Keys are output identifiers, values describe type and purpose. May be empty for cleanup or validation steps.", - "additionalProperties": { - "$ref": "#/$defs/stepOutput" - } - }, - "dependencies": { - "type": "array", - "description": "List of step IDs this step depends on. Dependencies must complete before this step runs.", - "items": { - "type": "string" - }, - "default": [] - }, - "hooks": { - "$ref": "#/$defs/hooks", - "description": "Lifecycle hooks for validation and actions at different points in step execution" - }, - "stop_hooks": { - "type": "array", - "description": "DEPRECATED: Use hooks.after_agent instead. Legacy stop hooks for quality validation loops.", - "items": { - "$ref": "#/$defs/hookAction" - } - }, - "exposed": { - "type": "boolean", - "description": "If true, step is user-invocable in menus/commands. If false, step is hidden (only reachable via workflows or dependencies). Default: false", - "default": false - }, - "hidden": { - "type": "boolean", - "description": "If true, step is hidden from menus. Alias for exposed: false. Default: false", - "default": false - }, - "reviews": { - "type": "array", - "description": "Quality reviews to run when step completes. Can be empty.", - "items": { - "$ref": "#/$defs/review" - } - }, - "agent": { - "type": "string", - "minLength": 1, - "description": "Agent type for this step (e.g., 'general-purpose'). When set, the skill uses context forking and delegates to the specified agent type." - } - } - }, - "stepInput": { - "oneOf": [ - { - "$ref": "#/$defs/userParameterInput" - }, - { - "$ref": "#/$defs/fileInput" - } - ] - }, - "userParameterInput": { - "type": "object", - "required": [ - "name", - "description" - ], - "additionalProperties": false, - "description": "A user-provided parameter input that will be requested at runtime", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Parameter name (used as variable name)" - }, - "description": { - "type": "string", - "minLength": 1, - "description": "Description shown to user when requesting this input" - } - } - }, - "fileInput": { - "type": "object", - "required": [ - "file", - "from_step" - ], - "additionalProperties": false, - "description": "A file input from a previous step's output", - "properties": { - "file": { - "type": "string", - "minLength": 1, - "description": "File name to consume from the source step's outputs" - }, - "from_step": { - "type": "string", - "minLength": 1, - "description": "Step ID that produces this file. Must be in the dependencies list." - } - } - }, - "stepOutput": { - "type": "object", - "required": [ - "type", - "description", - "required" - ], - "additionalProperties": false, - "description": "Output specification with type information indicating single file or multiple files", - "properties": { - "type": { - "type": "string", - "enum": [ - "file", - "files" - ], - "description": "Whether this output is a single file ('file') or multiple files ('files')" - }, - "description": { - "type": "string", - "minLength": 1, - "description": "Description of what this output contains" - }, - "required": { - "type": "boolean", - "description": "Whether this output must be provided when calling finished_step. If false, the output is optional and can be omitted." - } - } - }, - "hooks": { - "type": "object", - "additionalProperties": false, - "description": "Lifecycle hooks triggered at different points in step execution", - "properties": { - "after_agent": { - "type": "array", - "description": "Hooks triggered after the agent finishes. Used for quality validation loops.", - "items": { - "$ref": "#/$defs/hookAction" - } - }, - "before_tool": { - "type": "array", - "description": "Hooks triggered before a tool is used. Used for pre-action checks.", - "items": { - "$ref": "#/$defs/hookAction" - } - }, - "before_prompt": { - "type": "array", - "description": "Hooks triggered when user submits a prompt. Used for input validation.", - "items": { - "$ref": "#/$defs/hookAction" - } - } - } - }, - "hookAction": { - "type": "object", - "description": "A hook action - exactly one of: prompt (inline text), prompt_file (external file), or script (shell script)", - "oneOf": [ - { - "required": [ - "prompt" - ], - "additionalProperties": false, - "properties": { - "prompt": { - "type": "string", - "minLength": 1, - "description": "Inline prompt text for validation/action" - } - } - }, - { - "required": [ - "prompt_file" - ], - "additionalProperties": false, - "properties": { - "prompt_file": { - "type": "string", - "minLength": 1, - "description": "Path to prompt file (relative to job directory)" - } - } - }, - { - "required": [ - "script" - ], - "additionalProperties": false, - "properties": { - "script": { - "type": "string", - "minLength": 1, - "description": "Path to shell script (relative to job directory)" - } - } - } - ] - }, - "review": { - "type": "object", - "required": [ - "run_each", - "quality_criteria" - ], - "additionalProperties": false, - "description": "A quality review that evaluates step outputs against criteria", - "properties": { - "run_each": { - "type": "string", - "minLength": 1, - "description": "Either 'step' to review all outputs together, or the name of a specific output to review individually" - }, - "quality_criteria": { - "type": "object", - "description": "Map of criterion name to criterion question", - "additionalProperties": { - "type": "string", - "minLength": 1 - }, - "minProperties": 1 - }, - "additional_review_guidance": { - "type": "string", - "description": "Optional guidance for the reviewer about what context to look at (e.g., 'Look at the job.yml file for context'). Replaces automatic inclusion of input file contents." - } - } - } - } -} \ No newline at end of file diff --git a/CLAUDE_PLUGINS_README.md b/CLAUDE_PLUGINS_README.md index aa1d5112..daa26dea 100644 --- a/CLAUDE_PLUGINS_README.md +++ b/CLAUDE_PLUGINS_README.md @@ -6,21 +6,53 @@ This repository includes a Claude Code plugin marketplace with reusable plugins | Plugin | Description | |--------|-------------| +| **deepwork** | Framework for AI-powered multi-step workflows with quality gates | | **learning-agents** | Auto-improving AI sub-agents that learn from their mistakes across sessions | ## Installation + +### For this repository (local development) + +Both plugins are configured via the marketplace in `.claude-plugin/marketplace.json` and enabled in `.claude/settings.json`. They load directly from their source directories, so any changes to plugin files are picked up without needing to reinstall from a remote marketplace. + +No additional setup is needed — both plugins are enabled automatically when you work in this repo. + +### For other projects (remote installation) + Run the following *in Claude*. -### Add the marketplace +#### Add the marketplace ``` /plugin marketplace add https://github.com/Unsupervisedcom/deepwork ``` -### Install the learning-agents plugin +#### Install the deepwork plugin +``` +/plugin install deepwork@deepwork-plugins +``` + +#### Install the learning-agents plugin ``` /plugin install learning-agents@deepwork-plugins ``` +## Plugin Details + +### deepwork + +The deepwork plugin provides: +- **Skill**: `/deepwork` — starts or continues DeepWork workflows using MCP tools +- **MCP server**: Runs `uvx deepwork serve` to provide workflow management tools (`get_workflows`, `start_workflow`, `finished_step`, `abort_workflow`) +- **Hook**: SessionStart hook that checks DeepWork installation and Claude Code version + +Source: `plugins/claude/` + +### learning-agents + +The learning-agents plugin provides auto-improving AI sub-agents. See [Learning Agents architecture](doc/learning_agents_architecture.md) for details. + +Source: `learning_agents/` + ## Learn More - [Learning Agents architecture](doc/learning_agents_architecture.md) diff --git a/README.md b/README.md index cbd51241..1adcac6a 100644 --- a/README.md +++ b/README.md @@ -3,43 +3,38 @@ Triage email, give feedback to your team, make tutorials/documentation, QA your product every day, do competitive research... *anything*. ## Install -```bash -brew tap unsupervisedcom/deepwork && brew install deepwork -``` -Then in your project folder (must be a Git repository): -```bash -deepwork install -claude +In Claude Code, run: +``` +/plugin marketplace add https://github.com/Unsupervisedcom/deepwork +/plugin install deepwork@deepwork-plugins ``` -> **Note:** DeepWork requires a Git repository. If your folder isn't already a repo, run `git init` first. - -Now inside claude, define your first job using the `/deepwork_jobs` command. Ex. +Then start a new session and define your first job: ``` -/deepwork_jobs Make a job for doing competitive research. It will take the URL of the competitor as an input, and should make report including a SWOT analysis for that competitor. +/deepwork Make a job for doing competitive research. It will take the URL of the competitor as an input, and should make report including a SWOT analysis for that competitor. ``` -See below for additional installation options +> **Note:** DeepWork stores job definitions in `.deepwork/jobs/` and creates work branches in Git. Your project folder should be a Git repository. If it isn't, run `git init` first. DeepWork is an open-source plugin for Claude Code (and other CLI agents). It: - teaches Claude to follow strict workflows consistently -- makes it easy for you to define them +- makes it easy for you to define them - learns and updates automatically ## Example You can make a DeepWork job that uses Claude Code to automatically run a deep competitive research workflow. To do this you: -- Run `/deepwork_jobs` in Claude Code and select `define` +- Run `/deepwork` in Claude Code - Explain your process _e.g. "Go look at my company's website and social channels to capture any new developments, look at our existing list of competitors, do broad web searches to identify any new competitiors, and then do deep research on each competitor and produce a comprehensive set of md reports including a summary report for me._ -Deepwork will ask you questions to improve the plan and make a hardened automation workflow. This usually takes ~10 minutes. +Deepwork will ask you questions to improve the plan and make a hardened automation workflow. This usually takes ~10 minutes. -When this is done, it will create a .yml file that details the plan and then will use templates to document how Claude should execute each individual step in the workflow. This usually takes 2-5 minutes. +When this is done, it will create a .yml file that details the plan and then will document how Claude should execute each individual step in the workflow. This usually takes 2-5 minutes. -After that, it will create a new skill for you in Claude, something like `/competitive_research` that you can run at any time (or automate). +After that, you can run the workflow at any time: -Running that `/competitive_research` command will get you output that looks something like this: +Running that `/deepwork competitive_research` command will get you output that looks something like this: ``` deepwork-output/competitive_research/ ├── competitors.md # Who they are, what they do @@ -76,47 +71,40 @@ Similar to how vibe coding makes easier for anyone to produce software, this is ## Quick Start -### 1. Install +### 1. Install the Plugin -```bash -brew tap unsupervisedcom/deepwork -brew install deepwork +In Claude Code: ``` - -Then in any project folder (must be a Git repository): - -```bash -deepwork install +/plugin marketplace add https://github.com/Unsupervisedcom/deepwork +/plugin install deepwork@deepwork-plugins ``` -> **Note:** If your folder isn't a Git repo yet, run `git init` first. +Start a new Claude Code session after installing. -**After install, load Claude.** Then verify you see this command: `/deepwork_jobs` +> **Note:** If your folder isn't a Git repo yet, run `git init` first. ### 2. Define Your First Workflow Start simple—something you do manually in 15-30 minutes. Here's an example: ``` -/deepwork_jobs write a tutorial for how to use a new feature we just launched +/deepwork write a tutorial for how to use a new feature we just launched ``` DeepWork asks you questions (this usually takes about 10 minutes) then writes the steps. You're creating a **reusable skill** — after you do this process you can run that skill any time you want without repeating this process. ### 3. Run It -Once the skill is created, type the name of your job (e.g. `/tutorial`) in Claude and you'll see the skill show up in your suggestions (e.g. `/tutorial_writer`). - -Hit enter to run the skill. Claude will follow the workflow step by step. - -## Some Examples of What Other People Are Building with DeepWork -======= -To start the process, just run: +Once the skill is created, invoke it via `/deepwork`: ``` -/deepwork_jobs +/deepwork tutorial_writer ``` +Claude will follow the workflow step by step. + +## Some Examples of What Other People Are Building with DeepWork + | Workflow | What It Does | Why it matters| |----------|--------------|--------------| | **Email triage** | Scan inbox, categorize, archive, and draft replies | Save time processing email | @@ -132,10 +120,10 @@ To start the process, just run: **2. Easy to define** — Describe what you want in plain English. DeepWork knows how to ask you the right questions to refine your plan. ``` -/deepwork_jobs +/deepwork ``` -**3. Learns automatically** — Run `/deepwork_jobs.learn` (or ask claude to `run the deepwork learn job`) after any job to automatically capture what worked and improve for next time. +**3. Learns automatically** — Run `/deepwork deepwork_jobs learn` after any job to automatically capture what worked and improve for next time. **4. All work happens on Git branches** — Every change can be version-controlled and tracked. You can roll-back to prior versions of the skill or keep skills in-sync and up-to-date across your team. @@ -145,11 +133,11 @@ To start the process, just run: | Platform | Status | Notes | |----------|--------|-------| -| **Claude Code** | Full Support | Recommended. Quality hooks, best DX. | -| **Gemini CLI** | Partial Support | TOML format, global hooks only | +| **Claude Code** | Full Support | Recommended. Plugin-based delivery with quality hooks. | +| **Gemini CLI** | Partial Support | TOML format skill, manual setup | | OpenCode | Planned | | | GitHub Copilot CLI | Planned | | -| Others | Planned | We are nailing Claude and Gemini first, then adding others according ot demand | +| Others | Planned | We are nailing Claude and Gemini first, then adding others according to demand | **Tip:** Use the terminal (Claude Code CLI), not the VS Code extension. The terminal has full feature support. @@ -159,7 +147,7 @@ To start the process, just run: For workflows that need to interact with websites, you can use any browser automation tool that works in Claude Code. We generally recommend [Claude in Chrome](https://www.anthropic.com/claude-in-chrome). -**⚠️ Safety note:** Browser automation is still something models can be hit-or-miss on. We recommend using a dedicated Chrome profile for automation. +**Warning:** Browser automation is still something models can be hit-or-miss on. We recommend using a dedicated Chrome profile for automation. --- @@ -167,14 +155,10 @@ For workflows that need to interact with websites, you can use any browser autom Here are some known issues that affect some early users — we're working on improving normal performance on these, but here are some known workarounds. -### Slash Commands don't appear after install - -Exit Claude completely and restart. - ### Stop hooks firing unexpectedly Occasionally, especially after updating a job or running the `deepwork_jobs learn` process after completing a task, Claude will get confused about which workflow it's running checks for. For now, if stop hooks fire when they shouldn't, you can either: -- Ask claude `do we need to address any of these stop hooks or can we ignore them for now?` +- Ask claude `do we need to address any of these stop hooks or can we ignore them for now?` - Ignore the stop hooks and keep going until the workflow steps are complete - Run the `/clear` command to start a new context window (you'll have to re-run the job after this) @@ -182,10 +166,10 @@ Occasionally, especially after updating a job or running the `deepwork_jobs lear If Claude attempts to bypass the workflow and do the task on it's own, tell it explicitly to use the skill. You can also manually run the step command: ``` -/your_job +/deepwork your_job ``` -Tip: Don't say things like "can you do X" while in **defining** a new `/deepwork_jobs` — Claude has a bias towards action and workarounds and may abandon the skill creation workflow and attempt to do your task as a one off. Instead, say something like "create a workflow that..." +Tip: Don't say things like "can you do X" while **defining** a new job — Claude has a bias towards action and workarounds and may abandon the skill creation workflow and attempt to do your task as a one off. Instead, say something like "create a workflow that..." ### If you can't solve your issues using the above and need help @@ -199,14 +183,12 @@ Send [@tylerwillis](https://x.com/tylerwillis) a message on X. ``` your-project/ ├── .deepwork/ -│ ├── config.yml # Platform configuration -│ └── jobs/ # Job definitions +│ ├── tmp/ # Session state (created lazily) +│ └── jobs/ # Job definitions │ └── job_name/ -│ ├── job.yml # Job metadata -│ └── steps/ # Step instructions -├── .claude/ # Generated Claude skills -│ └── skills/ -└── deepwork-output/ # Job outputs (gitignored) +│ ├── job.yml # Job metadata +│ └── steps/ # Step instructions +└── deepwork-output/ # Job outputs (gitignored) ``` @@ -214,12 +196,15 @@ your-project/
Alternative Installation Methods -**Prerequisites** (for non-Homebrew installs): Python 3.11+, Git +**Prerequisites** (for non-plugin installs): Python 3.11+, Git -Homebrew is recommended, but you can also use: +If you prefer to install the `deepwork` CLI directly (for running the MCP server manually): ```bash -# uv (Recommended) +# Homebrew +brew tap unsupervisedcom/deepwork && brew install deepwork + +# uv uv tool install deepwork # pipx @@ -229,11 +214,7 @@ pipx install deepwork pip install deepwork ``` -Then in your project folder (in terminal, not in Claude Code): - -```bash -deepwork install -``` +Then configure your AI agent CLI to use `deepwork serve` as an MCP server.
@@ -274,5 +255,3 @@ We're iterating fast. [Open an issue](https://github.com/Unsupervisedcom/deepwor --- Inspired by [GitHub's spec-kit](https://github.com/github/spec-kit) - -**Code Coverage**: 79.91% (as of 2026-02-13) diff --git a/claude.md b/claude.md index 1a54ee6e..cad95550 100644 --- a/claude.md +++ b/claude.md @@ -4,7 +4,7 @@ DeepWork is a framework for enabling AI agents to perform complex, multi-step work tasks across any domain. It is inspired by GitHub's spec-kit but generalized for any job type - from competitive research to ad campaign design to monthly reporting. -**Key Insight**: DeepWork is an *installation tool* that sets up job-based workflows in your project. After installation, all work is done through your chosen AI agent CLI (like Claude Code) using slash commands. The DeepWork CLI itself is only used for initial setup. +**Key Insight**: DeepWork is delivered as a **plugin** for AI agent CLIs (Claude Code, Gemini CLI, etc.). The plugin provides a skill, MCP server configuration, and hooks. The MCP server (`deepwork serve`) is the core runtime — the CLI has no install/sync commands. ## Core Concepts @@ -19,34 +19,42 @@ Jobs are complex, multi-step tasks defined once and executed many times by AI ag ### Steps Each job consists of reviewable steps with clear inputs and outputs. For example: - Competitive Research steps: `identify_competitors` → `primary_research` → `secondary_research` → `report` → `position` -- Each step becomes a slash command: `/competitive_research.identify_competitors` ## Architecture Principles 1. **Job-Agnostic**: Supports any multi-step workflow, not just software development 2. **Git-Native**: All work products are versioned for collaboration and context accumulation 3. **Step-Driven**: Jobs decomposed into reviewable steps with clear inputs/outputs -4. **Template-Based**: Job definitions are reusable and shareable via Git +4. **Plugin-Based**: Delivered as platform plugins (Claude Code plugin, Gemini extension) 5. **AI-Neutral**: Supports multiple AI platforms (Claude Code, Gemini, Copilot, etc.) 6. **Stateless Execution**: All state stored in filesystem artifacts for transparency -7. **Installation-Only CLI**: DeepWork installs skills/commands then gets out of the way +7. **MCP-Powered**: The MCP server is the core runtime — no install/sync CLI commands needed ## Project Structure ``` deepwork/ ├── src/deepwork/ -│ ├── cli/ # CLI commands (install, sync) -│ ├── core/ # Core logic (detection, generation, parsing) -│ ├── templates/ # Command templates per AI platform -│ │ ├── claude/ -│ │ ├── gemini/ -│ │ └── copilot/ -│ ├── standard_jobs/ # Built-in job definitions (auto-installed) +│ ├── cli/ # CLI commands (serve, hook) +│ ├── core/ # Core logic (parsing, jobs, doc_spec_parser) +│ ├── mcp/ # MCP server (the core runtime) +│ ├── hooks/ # Hook scripts and wrappers +│ ├── standard_jobs/ # Built-in job definitions (auto-discovered at runtime) │ │ └── deepwork_jobs/ │ ├── schemas/ # Job definition schemas │ └── utils/ # Utilities (fs, git, yaml, validation) -├── library/jobs/ # Reusable example jobs (not auto-installed) +├── platform/ # Shared platform-agnostic content +│ ├── skill-body.md # Canonical skill body (source of truth) +│ └── hooks/ # Shared hook scripts +├── plugins/ +│ ├── claude/ # Claude Code plugin +│ │ ├── .claude-plugin/plugin.json +│ │ ├── skills/deepwork/SKILL.md +│ │ ├── hooks/ # hooks.json + check_version.sh (symlink) +│ │ └── .mcp.json # MCP server config +│ └── gemini/ # Gemini CLI extension +│ └── skills/deepwork/SKILL.md +├── library/jobs/ # Reusable example jobs ├── tests/ # Test suite ├── doc/ # Documentation └── doc/architecture.md # Detailed architecture document @@ -55,8 +63,9 @@ deepwork/ ## Technology Stack - **Language**: Python 3.11+ -- **Dependencies**: Jinja2 (templates), PyYAML (config), GitPython (git ops) -- **Distribution**: uv/pipx for modern Python package management +- **Runtime Dependencies**: PyYAML, Click, jsonschema, FastMCP, Pydantic, aiofiles +- **Dev Dependencies**: Jinja2, GitPython, Rich, pytest, ruff, mypy +- **Distribution**: uv/pipx/brew for Python package management - **Testing**: pytest with pytest-mock - **Linting**: ruff - **Type Checking**: mypy @@ -74,58 +83,34 @@ uv sync # Install dependencies uv run pytest # Run tests ``` -## Running DeepWork CLI (Claude Code Web Environment) - -When running in Claude Code on the web (not local installations), the `deepwork` CLI may not be available. To run DeepWork commands: - -```bash -# Install the package in editable mode (one-time setup) -pip install -e . - -# Then run commands normally -deepwork install -``` - -**Note**: In web environments, you may also need to install dependencies like `jsonschema`, `pyyaml`, `gitpython`, `jinja2`, and `click` if they're not already available. - ## How DeepWork Works -### 1. Installation -Users install DeepWork globally, then run it in a Git project: -```bash -cd my-project/ -deepwork install --claude -``` +### 1. Plugin Installation +Users install the DeepWork plugin for their AI agent CLI: -This installs core skills into `.claude/skills/`: -- `deepwork_jobs.define` - Interactive job definition wizard -- `deepwork_jobs.implement` - Generates step files and syncs skills -- `deepwork_jobs.refine` - Refine existing job definitions - -### 2. Job Definition -Users define jobs via Claude Code: +**Claude Code:** ``` -/deepwork_jobs.define +/plugin marketplace add https://github.com/Unsupervisedcom/deepwork +/plugin install deepwork@deepwork-plugins ``` -The agent guides you through defining: -- Job name and description -- Steps with inputs/outputs -- Dependencies between steps +The plugin provides: +- `/deepwork` skill for invoking workflows +- MCP server configuration (`uvx deepwork serve`) +- SessionStart hook for version checking -This creates the `job.yml` file. Then run: +### 2. Job Definition +Users define jobs via the `/deepwork` skill: ``` -/deepwork_jobs.implement +/deepwork Make a job for doing competitive research ``` -This generates step instruction files and syncs skills to `.claude/skills/`. - -Job definitions are stored in `.deepwork/jobs/[job-name]/` and tracked in Git. +The agent uses MCP tools (`get_workflows` → `start_workflow` → `finished_step`) to guide you through defining jobs. Job definitions are stored in `.deepwork/jobs/[job-name]/` and tracked in Git. ### 3. Job Execution -Execute jobs via slash commands in Claude Code: +Execute jobs via the `/deepwork` skill: ``` -/competitive_research.identify_competitors +/deepwork competitive_research ``` Each step: @@ -140,21 +125,15 @@ Each step: - Create PR for team review - Merge to preserve work products for future context -## Target Project Structure (After Installation) +## Target Project Structure (After Plugin Install) ``` my-project/ ├── .git/ -├── .claude/ # Claude Code directory -│ └── skills/ # Skill files -│ ├── deepwork_jobs.define.md -│ ├── deepwork_jobs.implement.md -│ ├── deepwork_jobs.refine.md -│ └── [job].[step].md -└── .deepwork/ # DeepWork configuration - ├── config.yml # version, platforms[] +└── .deepwork/ # DeepWork runtime data + ├── tmp/ # Session state (created lazily) └── jobs/ - ├── deepwork_jobs/ # Built-in job + ├── deepwork_jobs/ # Built-in job (auto-discovered from package) │ ├── job.yml │ └── steps/ └── [job-name]/ @@ -163,7 +142,7 @@ my-project/ └── [step].md ``` -**Note**: Work outputs are created on dedicated Git branches (e.g., `deepwork/job_name-instance-date`), not in a separate directory. +**Note**: The plugin provides the skill and MCP config. Work outputs are created on dedicated Git branches (e.g., `deepwork/job_name-instance-date`), not in a separate directory. ## Key Files to Reference @@ -189,30 +168,21 @@ my-project/ | Type | Location | Purpose | |------|----------|---------| -| **Standard Jobs** | `src/deepwork/standard_jobs/` | Framework core, auto-installed to users | +| **Standard Jobs** | `src/deepwork/standard_jobs/` | Framework core, auto-discovered at runtime | | **Library Jobs** | `library/jobs/` | Reusable examples users can adopt | | **Bespoke Jobs** | `.deepwork/jobs/` (if not in standard_jobs) | This repo's internal workflows only | ### Editing Standard Jobs -**Standard jobs** (like `deepwork_jobs`) are bundled with DeepWork and installed to user projects. They exist in THREE locations: +**Standard jobs** (like `deepwork_jobs`) are bundled with DeepWork and discovered at runtime from the Python package. They exist in TWO locations: 1. **Source of truth**: `src/deepwork/standard_jobs/[job_name]/` - The canonical source files -2. **Installed copy**: `.deepwork/jobs/[job_name]/` - Installed by `deepwork install` -3. **Generated skills**: `.claude/skills/[job_name].[step].md` - Generated from installed jobs - -**NEVER edit files in `.deepwork/jobs/` or `.claude/skills/` for standard jobs directly!** - -Instead, follow this workflow: - -1. **Edit the source files** in `src/deepwork/standard_jobs/[job_name]/` - - `job.yml` - Job definition with steps, stop_hooks, etc. - - `steps/*.md` - Step instruction files - - `hooks/*` - Any hook scripts - -2. **Run `deepwork install`** to sync changes to `.deepwork/jobs/` and `.claude/skills/` +2. **Runtime copy**: `.deepwork/jobs/[job_name]/` - Copied at runtime by the MCP server -3. **Verify** the changes propagated correctly to all locations +**Edit the source files** in `src/deepwork/standard_jobs/[job_name]/`: +- `job.yml` - Job definition with steps, stop_hooks, etc. +- `steps/*.md` - Step instruction files +- `hooks/*` - Any hook scripts ### How to Identify Job Types diff --git a/doc/architecture.md b/doc/architecture.md index 77c4de49..f24c34d0 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -4,17 +4,17 @@ DeepWork is a framework for enabling AI agents to perform complex, multi-step work tasks across any domain. Inspired by spec-kit's approach to software development, DeepWork generalizes the pattern to support any job type—from competitive research to ad campaign design to monthly reporting. -**Key Insight**: DeepWork is an *installation tool* that sets up job-based workflows in your project. After installation, all work is done through your chosen AI agent CLI (like Claude Code, Gemini, etc.) using slash commands. The DeepWork CLI itself is only used for the initial setup. +**Key Insight**: DeepWork is delivered as a **plugin** for AI agent CLIs (Claude Code, Gemini CLI, etc.). The plugin provides a skill, MCP server configuration, and hooks. The MCP server (`deepwork serve`) is the core runtime — the CLI has no install/sync commands. ## Core Design Principles 1. **Job-Agnostic**: The framework supports any multi-step workflow, not just software development 2. **Git-Native**: All work products are versioned in Git for collaboration, review, and context accumulation 3. **Step-Driven**: Jobs are decomposed into reviewable steps with clear inputs and outputs -4. **Template-Based**: Job definitions are reusable and shareable via Git repositories +4. **Plugin-Based**: Delivered as platform plugins (Claude Code plugin, Gemini extension) 5. **AI-Neutral**: Support for multiple AI platforms (Claude Code, Gemini, Copilot, etc.) 6. **Stateless Execution**: All state is stored in filesystem artifacts, enabling resumability and transparency -7. **Installation-Only CLI**: The deepwork CLI installs skills/commands into projects, then gets out of the way +7. **MCP-Powered**: The MCP server is the core runtime — no install/sync CLI commands needed ## Architecture Overview @@ -38,239 +38,103 @@ deepwork/ # DeepWork tool repository ├── src/ │ └── deepwork/ │ ├── cli/ -│ │ ├── __init__.py -│ │ ├── main.py # CLI entry point -│ │ ├── install.py # Install command -│ │ ├── sync.py # Sync command -│ │ └── serve.py # MCP server command +│ │ ├── main.py # CLI entry point (serve + hook commands) +│ │ ├── serve.py # MCP server command +│ │ └── hook.py # Hook runner command │ ├── core/ -│ │ ├── adapters.py # Agent adapters for AI platforms -│ │ ├── detector.py # AI platform detection -│ │ ├── generator.py # Command file generation │ │ ├── parser.py # Job definition parsing -│ │ ├── doc_spec_parser.py # Doc spec parsing -│ │ └── hooks_syncer.py # Hook syncing to platforms -│ ├── mcp/ # MCP server module -│ │ ├── __init__.py +│ │ ├── jobs.py # Job discovery +│ │ └── doc_spec_parser.py # Doc spec parsing +│ ├── mcp/ # MCP server module (the core runtime) │ │ ├── server.py # FastMCP server definition │ │ ├── tools.py # MCP tool implementations │ │ ├── state.py # Workflow session state management │ │ ├── schemas.py # Pydantic models for I/O -│ │ └── quality_gate.py # Quality gate with review agent +│ │ ├── quality_gate.py # Quality gate with review agent +│ │ └── claude_cli.py # Claude CLI subprocess wrapper │ ├── hooks/ # Hook system and cross-platform wrappers -│ │ ├── __init__.py -│ │ ├── wrapper.py # Cross-platform input/output normalization -│ │ ├── claude_hook.sh # Shell wrapper for Claude Code -│ │ └── gemini_hook.sh # Shell wrapper for Gemini CLI -│ ├── templates/ # Skill templates for each platform -│ │ ├── claude/ -│ │ │ └── skill-deepwork.md.jinja # MCP entry point skill -│ │ ├── gemini/ -│ │ └── copilot/ +│ │ ├── wrapper.py # Cross-platform input/output normalization +│ │ ├── claude_hook.sh # Shell wrapper for Claude Code +│ │ └── gemini_hook.sh # Shell wrapper for Gemini CLI │ ├── standard_jobs/ # Built-in job definitions │ │ └── deepwork_jobs/ -│ │ ├── job.yml -│ │ ├── steps/ -│ │ └── templates/ -│ │ └── doc_spec.md.template │ ├── schemas/ # Definition schemas │ │ ├── job_schema.py -│ │ └── doc_spec_schema.py # Doc spec schema definition +│ │ └── doc_spec_schema.py │ └── utils/ │ ├── fs.py │ ├── git.py │ ├── validation.py │ └── yaml_utils.py -├── tests/ # DeepWork tool tests +├── platform/ # Shared platform-agnostic content +│ ├── skill-body.md # Canonical skill body (source of truth) +│ └── hooks/ +│ └── check_version.sh # Shared hook script +├── plugins/ +│ ├── claude/ # Claude Code plugin +│ │ ├── .claude-plugin/plugin.json +│ │ ├── skills/deepwork/SKILL.md +│ │ ├── hooks/ # hooks.json + check_version.sh (symlink) +│ │ └── .mcp.json # MCP server config +│ └── gemini/ # Gemini CLI extension +│ └── skills/deepwork/SKILL.md +├── library/jobs/ # Reusable example jobs +├── tests/ # Test suite ├── doc/ # Documentation ├── pyproject.toml -└── readme.md +└── README.md ``` ## DeepWork CLI Components -### 1. Installation Command (`install.py`) - -The primary installation command. When user executes `deepwork install --claude`: - -**Responsibilities**: -1. Detect if current directory is a Git repository -2. Detect if specified AI platform is available (check for `.claude/`, `.gemini/`, etc.) -3. Create `.deepwork/` directory structure in the project -4. Inject standard job definitions (deepwork_jobs) -5. Update or create configuration file -6. Run sync to generate commands for all platforms - -**Pseudocode**: -```python -def install(platform: str): - # Validate environment - if not is_git_repo(): - raise Error("Must be run in a Git repository") - - # Detect platform - platform_config = detect_platform(platform) - if not platform_config.is_available(): - raise Error(f"{platform} not found in this project") +The CLI has been simplified to two commands: `serve` and `hook`. The old `install`, `sync`, adapters, detector, and generator have been replaced by the plugin system. - # Create DeepWork structure - create_directory(".deepwork/") - create_directory(".deepwork/jobs/") +### 1. Serve Command (`serve.py`) - # Inject core job definitions - inject_deepwork_jobs(".deepwork/jobs/") +Starts the MCP server for workflow management: - # Update config (supports multiple platforms) - config = load_yaml(".deepwork/config.yml") or {} - config["version"] = "1.0.0" - config["platforms"] = config.get("platforms", []) - - if platform not in config["platforms"]: - config["platforms"].append(platform) - - write_yaml(".deepwork/config.yml", config) - - # Run sync to generate skills - sync_skills() - - print(f"✓ DeepWork installed for {platform}") - print(f" Run /deepwork_jobs.define to create your first job") +```bash +deepwork serve --path . --external-runner claude ``` -### 2. Agent Adapters (`adapters.py`) +The serve command: +- Creates `.deepwork/tmp/` lazily for session state +- Launches the FastMCP server (stdio or SSE transport) +- No config file required — works out of the box -Defines the modular adapter architecture for AI platforms. Each adapter encapsulates platform-specific configuration and behavior. +### 2. Hook Command (`hook.py`) -**Adapter Architecture**: -```python -class SkillLifecycleHook(str, Enum): - """Generic lifecycle hook events supported by DeepWork.""" - AFTER_AGENT = "after_agent" # After agent finishes (quality validation) - BEFORE_TOOL = "before_tool" # Before tool execution - BEFORE_PROMPT = "before_prompt" # When user submits a prompt - -class AgentAdapter(ABC): - """Base class for AI agent platform adapters.""" - - # Auto-registration via __init_subclass__ - _registry: ClassVar[dict[str, type[AgentAdapter]]] = {} - - # Platform configuration (subclasses define as class attributes) - name: ClassVar[str] # "claude" - display_name: ClassVar[str] # "Claude Code" - config_dir: ClassVar[str] # ".claude" - skills_dir: ClassVar[str] = "skills" - - # Mapping from generic hook names to platform-specific names - hook_name_mapping: ClassVar[dict[SkillLifecycleHook, str]] = {} - - def detect(self, project_root: Path) -> bool: - """Check if this platform is available in the project.""" - - def get_platform_hook_name(self, hook: SkillLifecycleHook) -> str | None: - """Get platform-specific event name for a generic hook.""" - - @abstractmethod - def sync_hooks(self, project_path: Path, hooks: dict) -> int: - """Sync hooks to platform settings.""" - -class ClaudeAdapter(AgentAdapter): - name = "claude" - display_name = "Claude Code" - config_dir = ".claude" - - # Claude Code uses PascalCase event names - hook_name_mapping = { - SkillLifecycleHook.AFTER_AGENT: "Stop", - SkillLifecycleHook.BEFORE_TOOL: "PreToolUse", - SkillLifecycleHook.BEFORE_PROMPT: "UserPromptSubmit", - } -``` +Runs hook scripts by name, used by platform hook wrappers: -### 3. Platform Detector (`detector.py`) - -Uses adapters to identify which AI platforms are available in the project. - -**Detection Logic**: -```python -class PlatformDetector: - def detect_platform(self, platform_name: str) -> AgentAdapter | None: - """Check if a specific platform is available.""" - adapter_class = AgentAdapter.get(platform_name) - adapter = adapter_class(self.project_root) - if adapter.detect(): - return adapter - return None - - def detect_all_platforms(self) -> list[AgentAdapter]: - """Detect all available platforms.""" - return [ - adapter_class(self.project_root) - for adapter_class in AgentAdapter.get_all().values() - if adapter_class(self.project_root).detect() - ] +```bash +deepwork hook check_version ``` -### 4. Skill Generator (`generator.py`) +### 3. Plugin System (replaces adapters/detector/generator) -Generates AI-platform-specific skill files. The generator has been simplified to focus -on generating only the MCP entry point skill (`/deepwork`), as workflow orchestration -is now handled by the MCP server rather than individual step skills. +Platform-specific delivery is now handled by plugins in `plugins/`: -This component is called by the `sync` command to regenerate the DeepWork skill: -1. Loads the platform-specific template (`skill-deepwork.md.jinja`) -2. Generates the `/deepwork` skill file that directs agents to use MCP tools -3. Writes the skill to the AI platform's skills directory +- **Claude Code**: `plugins/claude/` — installed as a Claude Code plugin via marketplace +- **Gemini CLI**: `plugins/gemini/` — skill files copied to `.gemini/skills/` -**Example Generation Flow**: -```python -class SkillGenerator: - def generate_deepwork_skill(self, adapter: AgentAdapter, - output_dir: Path) -> Path: - """Generate the global /deepwork skill for MCP entry point.""" - skills_dir = output_dir / adapter.skills_dir - skills_dir.mkdir(parents=True, exist_ok=True) - - # Load and render template - env = self._get_jinja_env(adapter) - template = env.get_template("skill-deepwork.md.jinja") - rendered = template.render() - - # Write skill file - skill_path = skills_dir / "deepwork/SKILL.md" - skill_path.parent.mkdir(parents=True, exist_ok=True) - safe_write(skill_path, rendered) - - return skill_path -``` +Each plugin contains static files (skill, hooks, MCP config) rather than generated content. The shared skill body lives in `platform/skill-body.md` as the single source of truth. --- # Part 2: Target Project Architecture -This section describes what a project looks like AFTER `deepwork install --claude` has been run. +This section describes what a project looks like after the DeepWork plugin is installed. ## Target Project Structure ``` my-project/ # User's project (target) ├── .git/ -├── .claude/ # Claude Code directory -│ ├── settings.json # Includes installed hooks -│ └── skills/ # Skill files -│ ├── deepwork_jobs.define.md # Core DeepWork skills -│ ├── deepwork_jobs.implement.md -│ ├── deepwork_jobs.refine.md -│ ├── competitive_research.identify_competitors.md -│ └── ... -├── .deepwork/ # DeepWork configuration -│ ├── config.yml # Platform config +├── .deepwork/ # DeepWork runtime data │ ├── .gitignore # Ignores tmp/ directory -│ ├── doc_specs/ # Doc specs (document specifications) -│ │ └── monthly_aws_report.md -│ ├── tmp/ # Temporary state (gitignored) +│ ├── tmp/ # Temporary session state (gitignored, created lazily) │ └── jobs/ # Job definitions -│ ├── deepwork_jobs/ # Core job for managing jobs +│ ├── deepwork_jobs/ # Core job (auto-discovered from package) │ │ ├── job.yml │ │ └── steps/ │ ├── competitive_research/ @@ -282,21 +146,11 @@ my-project/ # User's project (target) └── README.md ``` -**Note**: Work outputs are created directly in the project on dedicated Git branches (e.g., `deepwork/competitive_research-acme-2026-01-11`). The branch naming convention is `deepwork/[job_name]-[instance]-[date]`. - -## Configuration Files +**Note**: The plugin provides the `/deepwork` skill, MCP server config, and hooks. No config.yml needed. -### `.deepwork/config.yml` - -```yaml -version: 1.0.0 -platforms: - - claude -``` - -**Note**: The config supports multiple platforms. You can add additional platforms by running `deepwork install --platform gemini` etc. +**Note**: Work outputs are created directly in the project on dedicated Git branches (e.g., `deepwork/competitive_research-acme-2026-01-11`). The branch naming convention is `deepwork/[job_name]-[instance]-[date]`. -### Job Definition Example +## Job Definition Example `.deepwork/jobs/competitive_research/job.yml`: @@ -500,11 +354,9 @@ Create `competitors.md` with this structure: - [ ] No duplicate entries ``` -## Generated Command Files - -When the job is defined and `sync` is run, DeepWork generates command files. Example for Claude Code: +## Workflow Execution via MCP -`.deepwork/jobs/competitive_research` a step called `identify_competitors` will generate a skill file at `.claude/skills/competitive_research.identify_competitors.md`: +When a job is defined, the MCP server discovers it at runtime from `.deepwork/jobs/`. Steps are executed via MCP tool calls rather than individual skill files. # Part 3: Runtime Execution Model @@ -515,74 +367,38 @@ This section describes how AI agents (like Claude Code) actually execute jobs us ### User Workflow -1. **Initial Setup** (one-time): - ```bash - # In terminal - cd my-project/ - deepwork install --claude +1. **Install Plugin** (one-time): + ``` + # In Claude Code + /plugin marketplace add https://github.com/Unsupervisedcom/deepwork + /plugin install deepwork@deepwork-plugins ``` 2. **Define a Job** (once per job type): ``` # In Claude Code - User: /deepwork_jobs.define - - Claude: I'll help you define a new job. What type of work do you want to define? - - User: Competitive research - - [Interactive dialog to define all the steps] + User: /deepwork Make a competitive research workflow - Claude: ✓ Job 'competitive_research' created with 5 steps - new_job step 1/3 complete, outputs: job.yml - Continuing workflow: invoking review_job_spec... + Claude: [Calls get_workflows, finds deepwork_jobs/new_job] + [Calls start_workflow to begin the new_job workflow] + [Guides through interactive dialog to define steps] - [Claude automatically continues with review_job_spec step] - - Claude: ✓ Job spec validated against quality criteria - new_job step 2/3 complete - Continuing workflow: invoking implement... - - [Claude automatically continues with implement step] - - Claude: [Generates step instruction files] - [Runs deepwork sync] - ✓ Skills installed to .claude/skills/ - new_job workflow complete. Run /competitive_research.identify_competitors to start + ✓ Job 'competitive_research' created + new_job workflow complete. ``` 3. **Execute a Job Instance** (each time you need to do the work): ``` # In Claude Code - User: /competitive_research.identify_competitors + User: /deepwork competitive_research - Claude: Starting competitive research job... + Claude: [Calls start_workflow for competitive_research] + Starting competitive research job... Created branch: deepwork/competitive_research-acme-2026-01-11 - Please provide: - - Market segment: ? - - Product category: ? - - User: Market segment: Enterprise SaaS - Product category: Project Management - - Claude: [Performs research using web tools, analysis, etc.] - ✓ Created competitors.md - - Found 8 direct competitors and 4 indirect competitors. - Review the file and run /competitive_research.primary_research when ready. - - User: [Reviews competitors.md, maybe edits it] - /competitive_research.primary_research - - Claude: Continuing competitive research (step 2/5)... - [Reads competitors.md] - [Performs primary research on each competitor] - ✓ Created primary_research.md and competitor_profiles/ - - Next: /competitive_research.secondary_research - - [Continue through all steps...] + [Follows step instructions, creates output files] + [Calls finished_step after each step] + [Continues through all steps until workflow_complete] ``` 4. **Complete and Merge**: @@ -732,7 +548,7 @@ DeepWork includes a built-in job called `deepwork_jobs` for managing jobs. It pr **Standalone Skills** (can be run anytime): - **`/deepwork_jobs.learn`** - Analyzes conversations to improve job instructions and capture learnings -These skills are installed automatically when you run `deepwork install`. +These are auto-discovered at runtime by the MCP server from the Python package. ### The `/deepwork_jobs.define` Command @@ -1063,9 +879,9 @@ See `doc/doc-specs.md` for complete documentation. ## Technical Decisions ### Language: Python 3.11+ -- **Rationale**: Proven ecosystem for CLI tools (click, rich) -- **Alternatives**: TypeScript (more verbose), Go (less flexible for templates) -- **Dependencies**: Jinja2 (templates), PyYAML (config), GitPython (Git ops) +- **Rationale**: Proven ecosystem for CLI tools (click) and MCP servers (FastMCP) +- **Alternatives**: TypeScript (more verbose), Go (less flexible) +- **Runtime Dependencies**: PyYAML (config), Click (CLI), FastMCP (MCP server), jsonschema (validation) ### Distribution: uv/pipx - **Rationale**: Modern Python tooling, fast, isolated environments @@ -1222,16 +1038,14 @@ Pydantic models for all tool inputs and outputs: ## MCP Server Registration -When `deepwork install` runs, it registers the MCP server in platform settings: +The plugin's `.mcp.json` registers the MCP server automatically: ```json -// .claude/settings.json { "mcpServers": { "deepwork": { - "command": "deepwork", - "args": ["serve", "--path", "."], - "transport": "stdio" + "command": "uvx", + "args": ["deepwork", "serve", "--path", ".", "--external-runner", "claude"] } } } @@ -1239,7 +1053,7 @@ When `deepwork install` runs, it registers the MCP server in platform settings: ## The `/deepwork` Skill -A single skill (`.claude/skills/deepwork/SKILL.md`) instructs agents to use MCP tools: +The plugin provides a skill (`plugins/claude/skills/deepwork/SKILL.md`) that instructs agents to use MCP tools: ```markdown # DeepWork Workflow Manager diff --git a/platform/hooks/check_version.sh b/platform/hooks/check_version.sh new file mode 100755 index 00000000..21caabc1 --- /dev/null +++ b/platform/hooks/check_version.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# check_version.sh - SessionStart hook to check Claude Code version and deepwork installation +# +# This hook performs two critical checks: +# 1. Verifies that the 'deepwork' command is installed and directly invokable +# 2. Warns users if their Claude Code version is below the minimum required +# +# The deepwork check is blocking (exit 2) because hooks cannot function without it. +# The version check is informational only (exit 0) to avoid blocking sessions. +# +# Uses hookSpecificOutput.additionalContext to pass messages to Claude's context. + +# ============================================================================ +# READ STDIN INPUT +# ============================================================================ +# SessionStart hooks receive JSON input via stdin with session information. +# We need to read this to check the session source (startup, resume, clear). + +HOOK_INPUT="" +if [ ! -t 0 ]; then + HOOK_INPUT=$(cat) +fi + +# ============================================================================ +# SKIP NON-INITIAL SESSIONS +# ============================================================================ +# SessionStart hooks can be triggered for different reasons: +# - "startup": Initial session start (user ran `claude` or similar) +# - "resume": Session resumed (user ran `claude --resume`) +# - "clear": Context was cleared/compacted +# +# We only want to run the full check on initial startup. For resumed or +# compacted sessions, return immediately with empty JSON to avoid redundant +# checks and noise. + +get_session_source() { + # Extract the "source" field from the JSON input + # Returns empty string if not found or not valid JSON + if [ -n "$HOOK_INPUT" ]; then + # Use grep and sed for simple JSON parsing (avoid jq dependency) + echo "$HOOK_INPUT" | grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/' | head -1 + fi +} + +SESSION_SOURCE=$(get_session_source) + +# If source is anything other than "startup" (or empty/missing for backwards compat), +# skip this hook entirely. Empty source means older Claude Code version that doesn't +# send the source field - we treat that as an initial session to maintain backwards compat. +if [ -n "$SESSION_SOURCE" ] && [ "$SESSION_SOURCE" != "startup" ]; then + # Non-initial session (resume, clear, etc.) - skip all checks + echo '{}' + exit 0 +fi + +# ============================================================================ +# DEEPWORK INSTALLATION CHECK (BLOCKING) +# ============================================================================ +# This check runs on initial session start because if deepwork is not installed, +# nothing else will work. + +check_deepwork_installed() { + # Run 'deepwork --version' to verify the command is installed and directly invokable + if ! deepwork --version >/dev/null 2>&1; then + return 1 + fi + return 0 +} + +print_deepwork_error() { + cat >&2 << 'EOF' + +================================================================================ + *** DEEPWORK NOT INSTALLED *** +================================================================================ + + ERROR: The 'deepwork' command is not available or cannot be directly invoked. + + DeepWork must be installed such that running 'deepwork' directly works. + For example, running 'deepwork --version' should succeed. + + IMPORTANT: Do NOT use 'uv run deepwork' or similar wrappers. + The command must be directly invokable as just 'deepwork'. + + To verify: 'deepwork --version' should succeed. + + ------------------------------------------------------------------------ + | | + | Please fix your deepwork installation before proceeding. | + | | + | Installation options: | + | - pipx install deepwork | + | - pip install --user deepwork (ensure ~/.local/bin is in PATH) | + | - nix develop (if using the nix flake) | + | | + ------------------------------------------------------------------------ + +================================================================================ + +EOF +} + +output_deepwork_error_json() { + cat << 'EOF' +{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"DEEPWORK INSTALLATION ERROR: The 'deepwork' command is not installed or cannot be directly invoked. DeepWork must be installed such that it can be directly invoked (e.g., 'deepwork', NOT 'uv run deepwork'). Please fix your deepwork installation before proceeding with anything else. DO NOT CONTINUE until this is resolved."},"error":"deepwork command not found - please install deepwork so it can be directly invoked"} +EOF +} + +# Check deepwork installation FIRST (before any other checks) +if ! check_deepwork_installed; then + print_deepwork_error + output_deepwork_error_json + exit 2 # Blocking error - prevent session from continuing +fi + +# Note: We previously had a re-entry guard using DEEPWORK_VERSION_CHECK_DONE +# environment variable, but that was unreliable across session resumptions. +# Now we use the source field in the hook input JSON to detect initial sessions +# vs resumed/compacted sessions (see SKIP NON-INITIAL SESSIONS section above). + +# ============================================================================ +# MINIMUM VERSION CONFIGURATION +# ============================================================================ +MINIMUM_VERSION="2.1.14" + +# ============================================================================ +# VERSION CHECK LOGIC +# ============================================================================ + +# Get current Claude Code version +get_current_version() { + local version_output + version_output=$(claude --version 2>/dev/null) || return 1 + # Extract version number (e.g., "2.1.1" from "2.1.1 (Claude Code)") + echo "$version_output" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' | head -1 +} + +# Compare two semantic versions +# Returns 0 if version1 >= version2, 1 otherwise +version_gte() { + local version1="$1" + local version2="$2" + + # Split versions into components + local v1_major v1_minor v1_patch + local v2_major v2_minor v2_patch + + IFS='.' read -r v1_major v1_minor v1_patch <<< "$version1" + IFS='.' read -r v2_major v2_minor v2_patch <<< "$version2" + + # Default to 0 if component is missing + v1_major=${v1_major:-0} + v1_minor=${v1_minor:-0} + v1_patch=${v1_patch:-0} + v2_major=${v2_major:-0} + v2_minor=${v2_minor:-0} + v2_patch=${v2_patch:-0} + + # Compare major version + if [ "$v1_major" -gt "$v2_major" ]; then + return 0 + elif [ "$v1_major" -lt "$v2_major" ]; then + return 1 + fi + + # Compare minor version + if [ "$v1_minor" -gt "$v2_minor" ]; then + return 0 + elif [ "$v1_minor" -lt "$v2_minor" ]; then + return 1 + fi + + # Compare patch version + if [ "$v1_patch" -ge "$v2_patch" ]; then + return 0 + else + return 1 + fi +} + +# Generate warning message +get_warning_message() { + local current_version="$1" + + cat << EOF +CLAUDE CODE VERSION WARNING: Your version (${current_version}) is below the minimum recommended (${MINIMUM_VERSION}). Older versions have known bugs that may cause issues with DeepWork. RECOMMENDED: Update your version of Claude Code. TELL THE USER ABOUT THIS ISSUE LOUDLY!!! +EOF +} + +# Print visual warning to stderr for immediate user visibility +print_stderr_warning() { + local current_version="$1" + + cat >&2 << EOF + +================================================================================ + *** CLAUDE CODE VERSION WARNING *** +================================================================================ + + Your Claude Code version: ${current_version} + Minimum recommended: ${MINIMUM_VERSION} + + IMPORTANT: Versions below the minimum have known bugs that may cause + issues with DeepWork functionality. You may experience unexpected + behavior, errors, or incomplete operations. + + ------------------------------------------------------------------------ + | | + | RECOMMENDED ACTION: Update your version of Claude Code | + | | + ------------------------------------------------------------------------ + +================================================================================ + +EOF +} + +# Output JSON with additional context for Claude +output_json_with_context() { + local context="$1" + # Escape special characters for JSON + local escaped_context + escaped_context=$(echo "$context" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ') + + cat << EOF +{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"${escaped_context}"}} +EOF +} + +# ============================================================================ +# MAIN +# ============================================================================ + +main() { + local current_version + local warning_message + + # Get current version (don't exit on failure) + current_version=$(get_current_version) || current_version="" + + if [ -z "$current_version" ]; then + # Could not determine version, output empty JSON and exit + echo '{}' + exit 0 + fi + + # Check if current version is below minimum + if ! version_gte "$current_version" "$MINIMUM_VERSION"; then + # Print visual warning to stderr + print_stderr_warning "$current_version" + + # Output JSON with context for Claude + warning_message=$(get_warning_message "$current_version") + output_json_with_context "$warning_message" + else + # Version is OK, output empty JSON + echo '{}' + fi + + exit 0 +} + +main "$@" diff --git a/src/deepwork/templates/gemini/skill-deepwork.md.jinja b/platform/skill-body.md similarity index 67% rename from src/deepwork/templates/gemini/skill-deepwork.md.jinja rename to platform/skill-body.md index 8901ac20..b7220b35 100644 --- a/src/deepwork/templates/gemini/skill-deepwork.md.jinja +++ b/platform/skill-body.md @@ -1,16 +1,3 @@ -{# -Template: skill-deepwork.md.jinja -Purpose: Generates the main /deepwork skill that instructs agents to use MCP tools - -This template is used to create the entry-point skill for DeepWork. -Instead of containing step instructions, it directs agents to use the -DeepWork MCP server tools. -#} -+++ -name = "deepwork" -description = "Start or continue DeepWork workflows using MCP tools" -+++ - # DeepWork Workflow Manager Execute multi-step workflows with quality gate checkpoints. @@ -34,6 +21,24 @@ use context and the available workflows from `get_workflows` to determine the be 4. Call `finished_step` with your outputs when done 5. Handle the response: `needs_work`, `next_step`, or `workflow_complete` +## Creating New Jobs + + +You MUST create new DeepWork jobs by starting the `new_job` workflow via the DeepWork +MCP tools. Follow the guidance from the DeepWork MCP server as you go through the +workflow — it will walk you through each step. + + +To create a new job, use the MCP tools: + +1. Call `get_workflows` to confirm the `deepwork_jobs` job is available +2. Call `start_workflow` with: + - `job_name`: `"deepwork_jobs"` + - `workflow_name`: `"new_job"` + - `goal`: a description of what the new job should accomplish + - `instance_id`: a short name for the new job (e.g., `"code_review"`) +3. Follow the instructions returned by the MCP tools as you progress through the workflow + ## Intent Parsing When the user invokes `/deepwork`, parse their intent: diff --git a/plugins/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json new file mode 100644 index 00000000..3780e0d4 --- /dev/null +++ b/plugins/claude/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "deepwork", + "description": "Framework for AI-powered multi-step workflows with quality gates", + "version": "0.8.0", + "author": { + "name": "DeepWork" + }, + "repository": "https://github.com/Unsupervisedcom/deepwork" +} diff --git a/plugins/claude/.mcp.json b/plugins/claude/.mcp.json new file mode 100644 index 00000000..9453a53a --- /dev/null +++ b/plugins/claude/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "deepwork": { + "command": "uvx", + "args": ["deepwork", "serve", "--path", ".", "--external-runner", "claude"] + } + } +} diff --git a/plugins/claude/hooks/check_version.sh b/plugins/claude/hooks/check_version.sh new file mode 120000 index 00000000..10c0b847 --- /dev/null +++ b/plugins/claude/hooks/check_version.sh @@ -0,0 +1 @@ +../../../platform/hooks/check_version.sh \ No newline at end of file diff --git a/plugins/claude/hooks/hooks.json b/plugins/claude/hooks/hooks.json new file mode 100644 index 00000000..06ac549c --- /dev/null +++ b/plugins/claude/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check_version.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/deepwork/SKILL.md b/plugins/claude/skills/deepwork/SKILL.md similarity index 98% rename from .claude/skills/deepwork/SKILL.md rename to plugins/claude/skills/deepwork/SKILL.md index 02ae6d7c..2aaf10d1 100644 --- a/.claude/skills/deepwork/SKILL.md +++ b/plugins/claude/skills/deepwork/SKILL.md @@ -51,4 +51,4 @@ When the user invokes `/deepwork`, parse their intent: 2. Based on the available flows and what the user said in their request, proceed: - **Explicit workflow**: `/deepwork ` → start the `` workflow - **General request**: `/deepwork ` → infer best match from available workflows - - **No context**: `/deepwork` alone → ask user to choose from available workflows \ No newline at end of file + - **No context**: `/deepwork` alone → ask user to choose from available workflows diff --git a/src/deepwork/templates/claude/skill-deepwork.md.jinja b/plugins/gemini/skills/deepwork/SKILL.md similarity index 85% rename from src/deepwork/templates/claude/skill-deepwork.md.jinja rename to plugins/gemini/skills/deepwork/SKILL.md index 83edc7ea..651c8044 100644 --- a/src/deepwork/templates/claude/skill-deepwork.md.jinja +++ b/plugins/gemini/skills/deepwork/SKILL.md @@ -1,15 +1,7 @@ -{# -Template: skill-deepwork.md.jinja -Purpose: Generates the main /deepwork skill that instructs agents to use MCP tools - -This template is used to create the entry-point skill for DeepWork. -Instead of containing step instructions, it directs agents to use the -DeepWork MCP server tools. -#} ---- -name: deepwork -description: "Start or continue DeepWork workflows using MCP tools" ---- ++++ +name = "deepwork" +description = "Start or continue DeepWork workflows using MCP tools" ++++ # DeepWork Workflow Manager diff --git a/pyproject.toml b/pyproject.toml index ef2e5911..0e22967b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,11 +20,8 @@ classifiers = [ ] dependencies = [ - "jinja2>=3.1.0", "pyyaml>=6.0", - "gitpython>=3.1.0", "click>=8.1.0", - "rich>=13.0.0", "jsonschema>=4.17.0", "fastmcp>=2.0", "pydantic>=2.0", @@ -120,8 +117,11 @@ strict_equality = true [dependency-groups] dev = [ "fpdf2>=2.8.5", + "gitpython>=3.1.0", + "jinja2>=3.1.0", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1", + "rich>=13.0.0", ] diff --git a/src/deepwork/cli/hook.py b/src/deepwork/cli/hook.py index 3e921941..4913c1f0 100644 --- a/src/deepwork/cli/hook.py +++ b/src/deepwork/cli/hook.py @@ -14,9 +14,6 @@ import sys import click -from rich.console import Console - -console = Console() class HookError(Exception): @@ -62,8 +59,8 @@ def hook(hook_name: str) -> None: raise HookError(f"Hook module '{module_name}' does not have a main() function") except HookError as e: - console.print(f"[red]Error:[/red] {e}", style="bold red") + click.echo(f"Error: {e}", err=True) sys.exit(1) except Exception as e: - console.print(f"[red]Unexpected error running hook:[/red] {e}", style="bold red") + click.echo(f"Unexpected error running hook: {e}", err=True) sys.exit(1) diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py deleted file mode 100644 index d30a6a3a..00000000 --- a/src/deepwork/cli/install.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Install command for DeepWork CLI.""" - -import shutil -from pathlib import Path - -import click -from rich.console import Console - -from deepwork.core.adapters import AgentAdapter -from deepwork.core.detector import PlatformDetector -from deepwork.utils.fs import ensure_dir, fix_permissions -from deepwork.utils.git import is_git_repo -from deepwork.utils.yaml_utils import load_yaml, save_yaml - -console = Console() - - -class InstallError(Exception): - """Exception raised for installation errors.""" - - pass - - -def _install_schemas(schemas_dir: Path, project_path: Path) -> None: - """ - Install JSON schemas to the project's .deepwork/schemas directory. - - Args: - schemas_dir: Path to .deepwork/schemas directory - project_path: Path to project root (for relative path display) - - Raises: - InstallError: If installation fails - """ - # Find the source schemas directory - source_schemas_dir = Path(__file__).parent.parent / "schemas" - - if not source_schemas_dir.exists(): - raise InstallError( - f"Schemas directory not found at {source_schemas_dir}. " - "DeepWork installation may be corrupted." - ) - - # Copy JSON schema files - try: - for schema_file in source_schemas_dir.glob("*.json"): - target_file = schemas_dir / schema_file.name - shutil.copy(schema_file, target_file) - fix_permissions(target_file) - console.print( - f" [green]✓[/green] Installed schema {schema_file.name} ({target_file.relative_to(project_path)})" - ) - except Exception as e: - raise InstallError(f"Failed to install schemas: {e}") from e - - -def _create_deepwork_gitignore(deepwork_dir: Path) -> None: - """ - Create .gitignore file in .deepwork/ directory. - - This ensures that runtime artifacts are not committed while keeping - the tmp directory structure in version control. - - Args: - deepwork_dir: Path to .deepwork directory - """ - gitignore_path = deepwork_dir / ".gitignore" - gitignore_content = """# DeepWork runtime artifacts -# These files are generated during sessions and should not be committed -.last_work_tree -.last_head_ref - -# Temporary files (but keep the directory via .gitkeep) -tmp/* -!tmp/.gitkeep -""" - - # Always overwrite to ensure correct content - gitignore_path.write_text(gitignore_content) - - -def _create_common_info_directory(deepwork_dir: Path) -> None: - """ - Create the .deepwork/common_info directory with a .gitkeep file. - - This directory holds shared reference files that are available across - all jobs and workflow steps. - - Args: - deepwork_dir: Path to .deepwork directory - """ - common_info_dir = deepwork_dir / "common_info" - ensure_dir(common_info_dir) - - gitkeep_file = common_info_dir / ".gitkeep" - if not gitkeep_file.exists(): - gitkeep_file.write_text( - "# This file ensures the .deepwork/common_info directory exists in version control.\n" - "# Place shared reference files here that should be available across all jobs.\n" - ) - - -def _create_tmp_directory(deepwork_dir: Path) -> None: - """ - Create the .deepwork/tmp directory with a .gitkeep file. - - This ensures the tmp directory exists in version control, which is required - for file permissions to work correctly when Claude Code starts fresh. - - Args: - deepwork_dir: Path to .deepwork directory - """ - tmp_dir = deepwork_dir / "tmp" - ensure_dir(tmp_dir) - - gitkeep_file = tmp_dir / ".gitkeep" - if not gitkeep_file.exists(): - gitkeep_file.write_text( - "# This file ensures the .deepwork/tmp directory exists in version control.\n" - "# The tmp directory is used for temporary files during DeepWork operations.\n" - "# Do not delete this file.\n" - ) - - -class DynamicChoice(click.Choice): - """A Click Choice that gets its values dynamically from AgentAdapter.""" - - def __init__(self) -> None: - # Get choices at runtime from registered adapters - super().__init__(AgentAdapter.list_names(), case_sensitive=False) - - -@click.command() -@click.option( - "--platform", - "-p", - type=DynamicChoice(), - required=False, - help="AI platform to install for. If not specified, will auto-detect.", -) -@click.option( - "--path", - type=click.Path(exists=True, file_okay=False, path_type=Path), - default=".", - help="Path to project directory (default: current directory)", -) -def install(platform: str | None, path: Path) -> None: - """ - Install DeepWork in a project. - - Adds the specified AI platform to the project configuration and syncs - commands for all configured platforms. - """ - try: - _install_deepwork(platform, path) - except InstallError as e: - console.print(f"[red]Error:[/red] {e}") - raise click.Abort() from e - except Exception as e: - console.print(f"[red]Unexpected error:[/red] {e}") - raise - - -def _install_deepwork(platform_name: str | None, project_path: Path) -> None: - """ - Install DeepWork in a project. - - Args: - platform_name: Platform to install for (or None to auto-detect) - project_path: Path to project directory - - Raises: - InstallError: If installation fails - """ - console.print("\n[bold cyan]DeepWork Installation[/bold cyan]\n") - - # Step 1: Check Git repository - console.print("[yellow]→[/yellow] Checking Git repository...") - if not is_git_repo(project_path): - raise InstallError( - "Not a Git repository. DeepWork requires a Git repository.\n" - "Run 'git init' to initialize a repository." - ) - console.print(" [green]✓[/green] Git repository found") - - # Step 2: Detect or validate platform(s) - detector = PlatformDetector(project_path) - platforms_to_add: list[str] = [] - detected_adapters: list[AgentAdapter] = [] - - if platform_name: - # User specified platform - check if it's available - console.print(f"[yellow]→[/yellow] Checking for {platform_name.title()}...") - adapter = detector.detect_platform(platform_name.lower()) - - if adapter is None: - # Platform not detected - provide helpful message - adapter = detector.get_adapter(platform_name.lower()) - raise InstallError( - f"{adapter.display_name} not detected in this project.\n" - f"Expected to find '{adapter.config_dir}/' directory.\n" - f"Please ensure {adapter.display_name} is set up in this project." - ) - - console.print(f" [green]✓[/green] {adapter.display_name} detected") - platforms_to_add = [adapter.name] - detected_adapters = [adapter] - else: - # Auto-detect all available platforms - console.print("[yellow]→[/yellow] Auto-detecting AI platforms...") - available_adapters = detector.detect_all_platforms() - - if not available_adapters: - # No platforms detected - default to Claude Code - console.print(" [dim]•[/dim] No AI platform detected, defaulting to Claude Code") - - # Create .claude directory - claude_dir = project_path / ".claude" - ensure_dir(claude_dir) - console.print(f" [green]✓[/green] Created {claude_dir.relative_to(project_path)}/") - - # Get Claude adapter - claude_adapter_class = AgentAdapter.get("claude") - claude_adapter = claude_adapter_class(project_root=project_path) - platforms_to_add = [claude_adapter.name] - detected_adapters = [claude_adapter] - else: - # Add all detected platforms - for adapter in available_adapters: - console.print(f" [green]✓[/green] {adapter.display_name} detected") - platforms_to_add.append(adapter.name) - detected_adapters = available_adapters - - # Step 3: Create .deepwork/ directory structure - console.print("[yellow]→[/yellow] Creating DeepWork directory structure...") - deepwork_dir = project_path / ".deepwork" - jobs_dir = deepwork_dir / "jobs" - doc_specs_dir = deepwork_dir / "doc_specs" - schemas_dir = deepwork_dir / "schemas" - ensure_dir(deepwork_dir) - ensure_dir(jobs_dir) - ensure_dir(doc_specs_dir) - ensure_dir(schemas_dir) - console.print(f" [green]✓[/green] Created {deepwork_dir.relative_to(project_path)}/") - - # Step 3b: Install schemas - console.print("[yellow]→[/yellow] Installing schemas...") - _install_schemas(schemas_dir, project_path) - - # Note: Standard jobs (deepwork_jobs) are no longer copied into - # .deepwork/jobs/ during install. They are loaded directly from - # the package's standard_jobs directory at runtime. - - # Step 3d: Create .gitignore for temporary files - _create_deepwork_gitignore(deepwork_dir) - console.print(" [green]✓[/green] Created .deepwork/.gitignore") - - # Step 3e: Create tmp directory with .gitkeep file for version control - _create_tmp_directory(deepwork_dir) - console.print(" [green]✓[/green] Created .deepwork/tmp/.gitkeep") - - # Step 3f: Create common_info directory for shared reference files - _create_common_info_directory(deepwork_dir) - console.print(" [green]✓[/green] Created .deepwork/common_info/.gitkeep") - - # Step 4: Load or create config.yml - console.print("[yellow]→[/yellow] Updating configuration...") - config_file = deepwork_dir / "config.yml" - - if config_file.exists(): - config_data = load_yaml(config_file) - if config_data is None: - config_data = {} - else: - config_data = {} - - # Initialize config structure - if "version" not in config_data: - config_data["version"] = "0.1.0" - - if "platforms" not in config_data: - config_data["platforms"] = [] - - # Add each platform if not already present - added_platforms: list[str] = [] - for i, platform in enumerate(platforms_to_add): - adapter = detected_adapters[i] - if platform not in config_data["platforms"]: - config_data["platforms"].append(platform) - added_platforms.append(adapter.display_name) - console.print(f" [green]✓[/green] Added {adapter.display_name} to platforms") - else: - console.print(f" [dim]•[/dim] {adapter.display_name} already configured") - - save_yaml(config_file, config_data) - console.print(f" [green]✓[/green] Updated {config_file.relative_to(project_path)}") - - # Step 5: Register MCP server for each platform - console.print("[yellow]→[/yellow] Registering MCP server...") - for adapter in detected_adapters: - if adapter.register_mcp_server(project_path): - console.print(f" [green]✓[/green] Registered MCP server for {adapter.display_name}") - else: - console.print( - f" [dim]•[/dim] MCP server already registered for {adapter.display_name}" - ) - - # Step 6: Run sync to generate skills - console.print() - console.print("[yellow]→[/yellow] Running sync to generate skills...") - console.print() - - from deepwork.cli.sync import sync_skills - - try: - sync_result = sync_skills(project_path) - except Exception as e: - raise InstallError(f"Failed to sync skills: {e}") from e - - # Success or warning message - console.print() - platform_names = ", ".join(a.display_name for a in detected_adapters) - - if sync_result.has_warnings: - console.print("[bold yellow]⚠ You should repair your DeepWork install[/bold yellow]") - console.print() - console.print("[bold]To fix issues:[/bold]") - console.print(" 1. Start your agent CLI (ex. [cyan]claude[/cyan] or [cyan]gemini[/cyan])") - console.print(" 2. Run [cyan]/deepwork repair[/cyan]") - else: - console.print( - f"[bold green]✓ DeepWork installed successfully for {platform_names}![/bold green]" - ) - console.print() - console.print("[bold]Next steps:[/bold]") - console.print(" 1. Start your agent CLI (ex. [cyan]claude[/cyan] or [cyan]gemini[/cyan])") - console.print(" 2. Define your first job using the command [cyan]/deepwork_jobs[/cyan]") - console.print() diff --git a/src/deepwork/cli/main.py b/src/deepwork/cli/main.py index 66756a08..f0f23aec 100644 --- a/src/deepwork/cli/main.py +++ b/src/deepwork/cli/main.py @@ -1,9 +1,6 @@ """DeepWork CLI entry point.""" import click -from rich.console import Console - -console = Console() @click.group() @@ -15,12 +12,8 @@ def cli() -> None: # Import commands from deepwork.cli.hook import hook # noqa: E402 -from deepwork.cli.install import install # noqa: E402 from deepwork.cli.serve import serve # noqa: E402 -from deepwork.cli.sync import sync # noqa: E402 -cli.add_command(install) -cli.add_command(sync) cli.add_command(hook) cli.add_command(serve) diff --git a/src/deepwork/cli/serve.py b/src/deepwork/cli/serve.py index 91db64fa..b5c88d06 100644 --- a/src/deepwork/cli/serve.py +++ b/src/deepwork/cli/serve.py @@ -3,11 +3,6 @@ from pathlib import Path import click -from rich.console import Console - -from deepwork.utils.yaml_utils import load_yaml - -console = Console() class ServeError(Exception): @@ -16,29 +11,6 @@ class ServeError(Exception): pass -def _load_config(project_path: Path) -> dict: - """Load DeepWork config from project. - - Args: - project_path: Path to project root - - Returns: - Config dictionary - - Raises: - ServeError: If config not found or invalid - """ - config_file = project_path / ".deepwork" / "config.yml" - if not config_file.exists(): - raise ServeError(f"DeepWork not installed in {project_path}. Run 'deepwork install' first.") - - config = load_yaml(config_file) - if config is None: - config = {} - - return config - - @click.command() @click.option( "--path", @@ -106,10 +78,10 @@ def serve( try: _serve_mcp(path, not no_quality_gate, transport, port, external_runner) except ServeError as e: - console.print(f"[red]Error:[/red] {e}") + click.echo(f"Error: {e}", err=True) raise click.Abort() from e except Exception as e: - console.print(f"[red]Unexpected error:[/red] {e}") + click.echo(f"Unexpected error: {e}", err=True) raise @@ -133,8 +105,9 @@ def _serve_mcp( Raises: ServeError: If server fails to start """ - # Validate project has DeepWork installed - _load_config(project_path) + # Ensure .deepwork/tmp/ exists for session state + tmp_dir = project_path / ".deepwork" / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) # Create and run server from deepwork.mcp.server import create_server diff --git a/src/deepwork/cli/sync.py b/src/deepwork/cli/sync.py deleted file mode 100644 index 6ade4aae..00000000 --- a/src/deepwork/cli/sync.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Sync command for DeepWork CLI.""" - -import shutil -from dataclasses import dataclass, field -from pathlib import Path - -import click -from rich.console import Console -from rich.table import Table - -from deepwork.core.adapters import AgentAdapter -from deepwork.core.generator import SkillGenerator -from deepwork.core.hooks_syncer import collect_job_hooks, sync_hooks_to_platform -from deepwork.core.jobs import get_job_folders -from deepwork.core.parser import parse_job_definition -from deepwork.utils.fs import ensure_dir -from deepwork.utils.yaml_utils import load_yaml - -console = Console() - - -class SyncError(Exception): - """Exception raised for sync errors.""" - - pass - - -@dataclass -class SyncResult: - """Result of a sync operation.""" - - platforms_synced: int = 0 - skills_generated: int = 0 - hooks_synced: int = 0 - warnings: list[str] = field(default_factory=list) - - @property - def has_warnings(self) -> bool: - """Return True if there were any warnings during sync.""" - return len(self.warnings) > 0 - - -def _migrate_remove_synced_standard_jobs(deepwork_dir: Path) -> None: - """Remove standard jobs that were previously synced into .deepwork/jobs/. - - Standard jobs are now loaded directly from the package source, so the - copied ``deepwork_jobs`` folder inside ``.deepwork/jobs/`` is no longer - needed. This helper silently removes it when present to keep existing - installs tidy. - """ - synced_standard = deepwork_dir / "jobs" / "deepwork_jobs" - if synced_standard.exists(): - try: - shutil.rmtree(synced_standard) - console.print( - " [dim]•[/dim] Removed legacy .deepwork/jobs/deepwork_jobs (now loaded from package)" - ) - except OSError: - pass # best-effort cleanup - - -@click.command() -@click.option( - "--path", - type=click.Path(exists=True, file_okay=False, path_type=Path), - default=".", - help="Path to project directory (default: current directory)", -) -def sync(path: Path) -> None: - """ - Sync DeepWork skills to all configured platforms. - - Regenerates all skills for job steps and core skills based on - the current job definitions in .deepwork/jobs/. - """ - try: - sync_skills(path) - except SyncError as e: - console.print(f"[red]Error:[/red] {e}") - raise click.Abort() from e - except Exception as e: - console.print(f"[red]Unexpected error:[/red] {e}") - raise - - -def sync_skills(project_path: Path) -> SyncResult: - """ - Sync skills to all configured platforms. - - Args: - project_path: Path to project directory - - Returns: - SyncResult with statistics and any warnings - - Raises: - SyncError: If sync fails - """ - project_path = Path(project_path) - deepwork_dir = project_path / ".deepwork" - - # Load config - config_file = deepwork_dir / "config.yml" - if not config_file.exists(): - raise SyncError( - "DeepWork not initialized in this project.\n" - "Run 'deepwork install --platform ' first." - ) - - config = load_yaml(config_file) - if not config or "platforms" not in config: - raise SyncError("Invalid config.yml: missing 'platforms' field") - - platforms = config["platforms"] - if not platforms: - raise SyncError( - "No platforms configured.\n" - "Run 'deepwork install --platform ' to add a platform." - ) - - console.print("[bold cyan]Syncing DeepWork Skills[/bold cyan]\n") - - # Generate /deepwork skill FIRST for all platforms (before parsing jobs) - # This ensures the skill is available even if some jobs fail to parse - generator = SkillGenerator() - result = SyncResult() - platform_adapters: list[AgentAdapter] = [] - all_skill_paths_by_platform: dict[str, list[Path]] = {} - - console.print("[yellow]→[/yellow] Generating /deepwork skill...") - for platform_name in platforms: - try: - adapter_cls = AgentAdapter.get(platform_name) - except Exception: - warning = f"Unknown platform '{platform_name}', skipping" - console.print(f" [yellow]⚠[/yellow] {warning}") - result.warnings.append(warning) - continue - - adapter = adapter_cls(project_path) - platform_adapters.append(adapter) - - platform_dir = project_path / adapter.config_dir - skills_dir = platform_dir / adapter.skills_dir - ensure_dir(skills_dir) - - all_skill_paths: list[Path] = [] - try: - deepwork_skill_path = generator.generate_deepwork_skill(adapter, platform_dir) - all_skill_paths.append(deepwork_skill_path) - result.skills_generated += 1 - console.print(f" [green]✓[/green] {adapter.display_name}: deepwork (MCP entry point)") - except Exception as e: - warning = f"{adapter.display_name}: Failed to generate /deepwork skill: {e}" - console.print(f" [red]✗[/red] {warning}") - result.warnings.append(warning) - - all_skill_paths_by_platform[platform_name] = all_skill_paths - - # Migration: remove synced standard jobs from .deepwork/jobs/ since they - # are now loaded directly from the package's standard_jobs directory. - _migrate_remove_synced_standard_jobs(deepwork_dir) - - # Discover jobs from all configured job folders - job_folders = get_job_folders(project_path) - job_dirs: list[Path] = [] - seen_names: set[str] = set() - for folder in job_folders: - if not folder.exists() or not folder.is_dir(): - continue - for d in sorted(folder.iterdir()): - if d.is_dir() and (d / "job.yml").exists() and d.name not in seen_names: - job_dirs.append(d) - seen_names.add(d.name) - - console.print(f"\n[yellow]→[/yellow] Found {len(job_dirs)} job(s) to sync") - - # Parse all jobs - jobs = [] - failed_jobs: list[tuple[str, str]] = [] - for job_dir in job_dirs: - try: - job_def = parse_job_definition(job_dir) - jobs.append(job_def) - console.print(f" [green]✓[/green] Loaded {job_def.name} v{job_def.version}") - except Exception as e: - warning = f"Failed to load {job_dir.name}: {e}" - console.print(f" [red]✗[/red] {warning}") - failed_jobs.append((job_dir.name, str(e))) - result.warnings.append(warning) - - # Warn about failed jobs but continue (skill already installed) - if failed_jobs: - console.print() - console.print("[bold yellow]Warning: Some jobs failed to parse:[/bold yellow]") - for job_name, error in failed_jobs: - console.print(f" • {job_name}: {error}") - console.print( - "[dim]The /deepwork skill is installed. Fix the job errors and run 'deepwork sync' again.[/dim]" - ) - - # Collect hooks from jobs across all job folders - job_hooks_list: list = [] - seen_hook_jobs: set[str] = set() - for folder in job_folders: - if not folder.exists() or not folder.is_dir(): - continue - for jh in collect_job_hooks(folder): - if jh.job_name not in seen_hook_jobs: - job_hooks_list.append(jh) - seen_hook_jobs.add(jh.job_name) - if job_hooks_list: - console.print(f"\n[yellow]→[/yellow] Found {len(job_hooks_list)} job(s) with hooks") - - # Sync hooks and permissions for each platform - for adapter in platform_adapters: - console.print( - f"\n[yellow]→[/yellow] Syncing hooks and permissions to {adapter.display_name}..." - ) - - # NOTE: Job skills (meta-skills and step skills) are no longer generated. - # The MCP server now handles workflow orchestration directly. - # Only the /deepwork skill is installed as the entry point. - - # Sync hooks to platform settings - if job_hooks_list: - console.print(" [dim]•[/dim] Syncing hooks...") - try: - hooks_count = sync_hooks_to_platform(project_path, adapter, job_hooks_list) - result.hooks_synced += hooks_count - if hooks_count > 0: - console.print(f" [green]✓[/green] Synced {hooks_count} hook(s)") - except Exception as e: - warning = f"Failed to sync hooks: {e}" - console.print(f" [red]✗[/red] {warning}") - result.warnings.append(warning) - - # Sync required permissions to platform settings - console.print(" [dim]•[/dim] Syncing permissions...") - try: - perms_count = adapter.sync_permissions(project_path) - if perms_count > 0: - console.print(f" [green]✓[/green] Added {perms_count} base permission(s)") - else: - console.print(" [dim]•[/dim] Base permissions already configured") - except Exception as e: - warning = f"Failed to sync permissions: {e}" - console.print(f" [red]✗[/red] {warning}") - result.warnings.append(warning) - - # Add skill permissions for generated skills (if adapter supports it) - all_skill_paths = all_skill_paths_by_platform.get(adapter.name, []) - if all_skill_paths and hasattr(adapter, "add_skill_permissions"): - try: - skill_perms_count = adapter.add_skill_permissions(project_path, all_skill_paths) - if skill_perms_count > 0: - console.print( - f" [green]✓[/green] Added {skill_perms_count} skill permission(s)" - ) - except Exception as e: - warning = f"Failed to sync skill permissions: {e}" - console.print(f" [red]✗[/red] {warning}") - result.warnings.append(warning) - - result.platforms_synced += 1 - - # Summary - console.print() - console.print("[bold green]✓ Sync complete![/bold green]") - console.print() - - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column("Metric", style="cyan") - table.add_column("Count", style="green") - - table.add_row("Platforms synced", str(result.platforms_synced)) - table.add_row("Total skills", str(result.skills_generated)) - if result.hooks_synced > 0: - table.add_row("Hooks synced", str(result.hooks_synced)) - - console.print(table) - console.print() - - return result diff --git a/src/deepwork/core/adapters.py b/src/deepwork/core/adapters.py deleted file mode 100644 index 2cd078fd..00000000 --- a/src/deepwork/core/adapters.py +++ /dev/null @@ -1,630 +0,0 @@ -"""Agent adapters for AI coding assistants.""" - -from __future__ import annotations - -import json -from abc import ABC, abstractmethod -from enum import StrEnum -from pathlib import Path -from typing import Any, ClassVar - - -class AdapterError(Exception): - """Exception raised for adapter errors.""" - - pass - - -class SkillLifecycleHook(StrEnum): - """Generic skill lifecycle hook events supported by DeepWork. - - These represent hook points in the AI agent's skill execution lifecycle. - Each adapter maps these generic names to platform-specific event names. - The enum values are the generic names used in job.yml files. - """ - - # Triggered after the agent finishes responding (before returning to user) - # Use for quality validation loops, output verification - AFTER_AGENT = "after_agent" - - # Triggered before the agent uses a tool - # Use for tool-specific validation or pre-processing - BEFORE_TOOL = "before_tool" - - # Triggered when the user submits a new prompt - # Use for session initialization, context setup - BEFORE_PROMPT = "before_prompt" - - -# List of all supported skill lifecycle hooks -SKILL_LIFECYCLE_HOOKS_SUPPORTED: list[SkillLifecycleHook] = list(SkillLifecycleHook) - - -class AgentAdapter(ABC): - """Base class for AI agent platform adapters. - - Subclasses are automatically registered when defined, enabling dynamic - discovery of supported platforms. - """ - - # Class-level registry for auto-discovery - _registry: ClassVar[dict[str, type[AgentAdapter]]] = {} - - # Platform configuration (subclasses define as class attributes) - name: ClassVar[str] - display_name: ClassVar[str] - config_dir: ClassVar[str] - skills_dir: ClassVar[str] = "skills" - - # Mapping from generic SkillLifecycleHook to platform-specific event names. - # Subclasses should override this to provide platform-specific mappings. - hook_name_mapping: ClassVar[dict[SkillLifecycleHook, str]] = {} - - def __init__(self, project_root: Path | str | None = None): - """ - Initialize adapter with optional project root. - - Args: - project_root: Path to project root directory - """ - self.project_root = Path(project_root) if project_root else None - - def __init_subclass__(cls, **kwargs: Any) -> None: - """Auto-register subclasses.""" - super().__init_subclass__(**kwargs) - # Only register if the class has a name attribute set (not inherited default) - if "name" in cls.__dict__ and cls.name: - AgentAdapter._registry[cls.name] = cls - - @classmethod - def get_all(cls) -> dict[str, type[AgentAdapter]]: - """ - Return all registered adapter classes. - - Returns: - Dict mapping adapter names to adapter classes - """ - return cls._registry.copy() - - @classmethod - def get(cls, name: str) -> type[AgentAdapter]: - """ - Get adapter class by name. - - Args: - name: Adapter name (e.g., "claude", "gemini", "copilot") - - Returns: - Adapter class - - Raises: - AdapterError: If adapter name is not registered - """ - if name not in cls._registry: - raise AdapterError( - f"Unknown adapter '{name}'. Supported adapters: {', '.join(cls._registry.keys())}" - ) - return cls._registry[name] - - @classmethod - def list_names(cls) -> list[str]: - """ - List all registered adapter names. - - Returns: - List of adapter names - """ - return list(cls._registry.keys()) - - def get_template_dir(self, templates_root: Path) -> Path: - """ - Get the template directory for this adapter. - - Args: - templates_root: Root directory containing platform templates - - Returns: - Path to this adapter's template directory - """ - return templates_root / self.name - - def get_skills_dir(self, project_root: Path | None = None) -> Path: - """ - Get the skills directory path. - - Args: - project_root: Project root (uses instance's project_root if not provided) - - Returns: - Path to skills directory - - Raises: - AdapterError: If no project root specified - """ - root = project_root or self.project_root - if not root: - raise AdapterError("No project root specified") - return root / self.config_dir / self.skills_dir - - def detect(self, project_root: Path | None = None) -> bool: - """ - Check if this platform is available in the project. - - Args: - project_root: Project root (uses instance's project_root if not provided) - - Returns: - True if platform config directory exists - """ - root = project_root or self.project_root - if not root: - return False - config_path = root / self.config_dir - return config_path.exists() and config_path.is_dir() - - def get_platform_hook_name(self, hook: SkillLifecycleHook) -> str | None: - """ - Get the platform-specific event name for a generic hook. - - Args: - hook: Generic SkillLifecycleHook - - Returns: - Platform-specific event name, or None if not supported - """ - return self.hook_name_mapping.get(hook) - - def supports_hook(self, hook: SkillLifecycleHook) -> bool: - """ - Check if this adapter supports a specific hook. - - Args: - hook: Generic SkillLifecycleHook - - Returns: - True if the hook is supported - """ - return hook in self.hook_name_mapping - - @abstractmethod - def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]]) -> int: - """ - Sync hooks to platform settings. - - Args: - project_path: Path to project root - hooks: Dict mapping lifecycle events to hook configurations - - Returns: - Number of hooks synced - - Raises: - AdapterError: If sync fails - """ - pass - - def sync_permissions(self, project_path: Path) -> int: - """ - Sync required permissions to platform settings. - - This method adds any permissions that DeepWork requires to function - properly (e.g., access to .deepwork/tmp/ directory). - - Args: - project_path: Path to project root - - Returns: - Number of permissions added - - Raises: - AdapterError: If sync fails - """ - # Default implementation does nothing - subclasses can override - return 0 - - def register_mcp_server(self, project_path: Path) -> bool: - """ - Register the DeepWork MCP server with the platform. - - Args: - project_path: Path to project root - - Returns: - True if server was registered, False if already registered - - Raises: - AdapterError: If registration fails - """ - # Default implementation does nothing - subclasses can override - return False - - -def _hook_already_present(hooks: list[dict[str, Any]], script_path: str) -> bool: - """Check if a hook with the given script path is already in the list.""" - for hook in hooks: - hook_list = hook.get("hooks", []) - for h in hook_list: - if h.get("command") == script_path: - return True - return False - - -# ============================================================================= -# Platform Adapters -# ============================================================================= -# -# Each adapter must define hook_name_mapping to indicate which hooks it supports. -# Use an empty dict {} for platforms that don't support skill-level hooks. -# -# Hook support reviewed: -# - Claude Code: Full support (Stop, PreToolUse, UserPromptSubmit) - reviewed 2026-01-16 -# All three skill lifecycle hooks are supported in markdown frontmatter. -# See: doc/platforms/claude/hooks_system.md -# - Gemini CLI: No skill-level hooks (reviewed 2026-01-12) -# Gemini's hooks are global/project-level in settings.json, not per-skill. -# TOML skill files only support 'prompt' and 'description' fields. -# See: doc/platforms/gemini/hooks_system.md -# ============================================================================= - - -class ClaudeAdapter(AgentAdapter): - """Adapter for Claude Code.""" - - name = "claude" - display_name = "Claude Code" - config_dir = ".claude" - - # Claude Code uses PascalCase event names - hook_name_mapping: ClassVar[dict[SkillLifecycleHook, str]] = { - SkillLifecycleHook.AFTER_AGENT: "Stop", - SkillLifecycleHook.BEFORE_TOOL: "PreToolUse", - SkillLifecycleHook.BEFORE_PROMPT: "UserPromptSubmit", - } - - def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]]) -> int: - """ - Sync hooks to Claude Code settings.json. - - Args: - project_path: Path to project root - hooks: Merged hooks configuration - - Returns: - Number of hooks synced - - Raises: - AdapterError: If sync fails - """ - if not hooks: - return 0 - - settings_file = project_path / self.config_dir / "settings.json" - - # Load existing settings or create new - existing_settings: dict[str, Any] = {} - if settings_file.exists(): - try: - with open(settings_file, encoding="utf-8") as f: - existing_settings = json.load(f) - except (json.JSONDecodeError, OSError) as e: - raise AdapterError(f"Failed to read settings.json: {e}") from e - - # Merge hooks into existing settings - if "hooks" not in existing_settings: - existing_settings["hooks"] = {} - - for event, event_hooks in hooks.items(): - if event not in existing_settings["hooks"]: - existing_settings["hooks"][event] = [] - - # Add new hooks that aren't already present - for hook in event_hooks: - script_path = hook.get("hooks", [{}])[0].get("command", "") - if not _hook_already_present(existing_settings["hooks"][event], script_path): - existing_settings["hooks"][event].append(hook) - - # Write back to settings.json - try: - settings_file.parent.mkdir(parents=True, exist_ok=True) - with open(settings_file, "w", encoding="utf-8") as f: - json.dump(existing_settings, f, indent=2) - except OSError as e: - raise AdapterError(f"Failed to write settings.json: {e}") from e - - # Count total hooks - total = sum(len(hooks_list) for hooks_list in hooks.values()) - return total - - def _load_settings(self, project_path: Path) -> dict[str, Any]: - """ - Load settings.json from the project. - - Args: - project_path: Path to project root - - Returns: - Settings dictionary (empty dict if file doesn't exist) - - Raises: - AdapterError: If file exists but cannot be read - """ - settings_file = project_path / self.config_dir / "settings.json" - if settings_file.exists(): - try: - with open(settings_file, encoding="utf-8") as f: - result: dict[str, Any] = json.load(f) - return result - except (json.JSONDecodeError, OSError) as e: - raise AdapterError(f"Failed to read settings.json: {e}") from e - return {} - - def _save_settings(self, project_path: Path, settings: dict[str, Any]) -> None: - """ - Save settings.json to the project. - - Args: - project_path: Path to project root - settings: Settings dictionary to save - - Raises: - AdapterError: If file cannot be written - """ - settings_file = project_path / self.config_dir / "settings.json" - try: - settings_file.parent.mkdir(parents=True, exist_ok=True) - with open(settings_file, "w", encoding="utf-8") as f: - json.dump(settings, f, indent=2) - except OSError as e: - raise AdapterError(f"Failed to write settings.json: {e}") from e - - def add_permission( - self, project_path: Path, permission: str, settings: dict[str, Any] | None = None - ) -> bool: - """ - Add a single permission to settings.json allow list. - - Args: - project_path: Path to project root - permission: The permission string to add (e.g., "Read(./.deepwork/tmp/**)") - settings: Optional pre-loaded settings dict. If provided, modifies in-place - and does NOT save to disk (caller is responsible for saving). - If None, loads settings, adds permission, and saves. - - Returns: - True if permission was added, False if already present - - Raises: - AdapterError: If settings cannot be read/written - """ - save_after = settings is None - if settings is None: - settings = self._load_settings(project_path) - - # Ensure permissions structure exists - if "permissions" not in settings: - settings["permissions"] = {} - if "allow" not in settings["permissions"]: - settings["permissions"]["allow"] = [] - - # Add permission if not already present - allow_list = settings["permissions"]["allow"] - if permission not in allow_list: - allow_list.append(permission) - if save_after: - self._save_settings(project_path, settings) - return True - return False - - def _get_settings_template_path(self) -> Path: - """Get the path to the settings.json template for this adapter.""" - return Path(__file__).parent.parent / "templates" / self.name / "settings.json" - - def _load_required_permissions(self) -> list[str]: - """Load required permissions from the settings template file.""" - settings_template = self._get_settings_template_path() - with open(settings_template, encoding="utf-8") as f: - template_settings = json.load(f) - result: list[str] = template_settings["permissions"]["allow"] - return result - - def sync_permissions(self, project_path: Path) -> int: - """ - Sync required permissions to Claude Code settings.json. - - Loads permissions from the settings template file at - templates/claude/settings.json and merges them into the - project's .claude/settings.json. - - Args: - project_path: Path to project root - - Returns: - Number of permissions added - - Raises: - AdapterError: If sync fails - """ - required_permissions = self._load_required_permissions() - - # Load settings once, add all permissions, then save once - settings = self._load_settings(project_path) - added_count = 0 - - for permission in required_permissions: - if self.add_permission(project_path, permission, settings): - added_count += 1 - - # Save if any permissions were added - if added_count > 0: - self._save_settings(project_path, settings) - - return added_count - - def add_skill_permissions(self, project_path: Path, skill_paths: list[Path]) -> int: - """ - Add Skill permissions for generated skills to settings.json. - - This allows Claude to invoke the skills without permission prompts. - Uses the Skill(name) permission syntax. - - Note: Skill permissions are an emerging Claude Code feature and - behavior may vary between versions. - - Args: - project_path: Path to project root - skill_paths: List of paths to generated skill files - - Returns: - Number of permissions added - - Raises: - AdapterError: If sync fails - """ - if not skill_paths: - return 0 - - # Load settings once - settings = self._load_settings(project_path) - added_count = 0 - - for skill_path in skill_paths: - # Extract skill name from path - # Path format: .claude/skills/job_name/SKILL.md -> job_name - # Path format: .claude/skills/job_name.step_id/SKILL.md -> job_name.step_id - skill_name = self._extract_skill_name(skill_path) - if skill_name: - permission = f"Skill({skill_name})" - if self.add_permission(project_path, permission, settings): - added_count += 1 - - # Save if any permissions were added - if added_count > 0: - self._save_settings(project_path, settings) - - return added_count - - def _extract_skill_name(self, skill_path: Path) -> str | None: - """ - Extract skill name from a skill file path. - - Args: - skill_path: Path to skill file (e.g., .claude/skills/job_name/SKILL.md) - - Returns: - Skill name (e.g., "job_name") or None if cannot extract - """ - # Handle both absolute and relative paths - parts = skill_path.parts - - # Find 'skills' directory and get the next part - try: - skills_idx = parts.index("skills") - if skills_idx + 1 < len(parts): - # The skill name is the directory after 'skills' - # e.g., skills/job_name/SKILL.md -> job_name - return parts[skills_idx + 1] - except ValueError: - pass - - return None - - def register_mcp_server(self, project_path: Path) -> bool: - """ - Register the DeepWork MCP server in .mcp.json at project root. - - Claude Code reads MCP server configurations from .mcp.json (project scope), - not from settings.json. This method assumes the `deepwork` command is - available in the user's PATH. - - Args: - project_path: Path to project root - - Returns: - True if server was registered or updated, False if no changes needed - - Raises: - AdapterError: If registration fails - """ - mcp_file = project_path / ".mcp.json" - - # Load existing .mcp.json or create new - existing_config: dict[str, Any] = {} - if mcp_file.exists(): - try: - with open(mcp_file, encoding="utf-8") as f: - existing_config = json.load(f) - except (json.JSONDecodeError, OSError) as e: - raise AdapterError(f"Failed to read .mcp.json: {e}") from e - - # Initialize mcpServers if not present - if "mcpServers" not in existing_config: - existing_config["mcpServers"] = {} - - # Build the new MCP server config - # Assume deepwork is available in PATH - # Include --external-runner claude so quality gate reviews use Claude CLI subprocess - new_server_config = { - "command": "deepwork", - "args": ["serve", "--path", ".", "--external-runner", "claude"], - } - - # Check if already registered with same config - existing_server = existing_config["mcpServers"].get("deepwork", {}) - if ( - existing_server.get("command") == new_server_config["command"] - and existing_server.get("args") == new_server_config["args"] - ): - return False - - # Register or update the DeepWork MCP server - existing_config["mcpServers"]["deepwork"] = new_server_config - - # Write .mcp.json - try: - with open(mcp_file, "w", encoding="utf-8") as f: - json.dump(existing_config, f, indent=2) - except OSError as e: - raise AdapterError(f"Failed to write .mcp.json: {e}") from e - - return True - - -class GeminiAdapter(AgentAdapter): - """Adapter for Gemini CLI. - - Gemini CLI uses TOML format for custom skills stored in .gemini/skills/. - Skills use colon (:) for namespacing instead of dot (.). - - Note: Gemini CLI does NOT support skill-level hooks. Hooks are configured - globally in settings.json, not per-skill. Therefore, hook_name_mapping - is empty and sync_hooks returns 0. - - See: doc/platforms/gemini/hooks_system.md - """ - - name = "gemini" - display_name = "Gemini CLI" - config_dir = ".gemini" - - # Gemini CLI does NOT support skill-level hooks - # Hooks are global/project-level in settings.json, not per-skill - hook_name_mapping: ClassVar[dict[SkillLifecycleHook, str]] = {} - - def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]]) -> int: - """ - Sync hooks to Gemini CLI settings. - - Gemini CLI does not support skill-level hooks. All hooks are - configured globally in settings.json. This method is a no-op - that always returns 0. - - Args: - project_path: Path to project root - hooks: Dict mapping lifecycle events to hook configurations (ignored) - - Returns: - 0 (Gemini does not support skill-level hooks) - """ - # Gemini CLI does not support skill-level hooks - # Hooks are configured globally in settings.json, not per-skill - return 0 diff --git a/src/deepwork/core/detector.py b/src/deepwork/core/detector.py deleted file mode 100644 index 683da40b..00000000 --- a/src/deepwork/core/detector.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Platform detection for AI coding assistants.""" - -from pathlib import Path - -from deepwork.core.adapters import AdapterError, AgentAdapter - - -class DetectorError(Exception): - """Exception raised for platform detection errors.""" - - pass - - -class PlatformDetector: - """Detects available AI coding platforms using registered adapters.""" - - def __init__(self, project_root: Path | str): - """ - Initialize detector. - - Args: - project_root: Path to project root directory - """ - self.project_root = Path(project_root) - - def detect_platform(self, platform_name: str) -> AgentAdapter | None: - """ - Check if a specific platform is available. - - Args: - platform_name: Platform name ("claude", "gemini", "copilot") - - Returns: - AgentAdapter instance if platform is available, None otherwise - - Raises: - DetectorError: If platform_name is not supported - """ - try: - adapter_cls = AgentAdapter.get(platform_name) - except AdapterError as e: - raise DetectorError(str(e)) from e - - adapter = adapter_cls(self.project_root) - if adapter.detect(): - return adapter - - return None - - def detect_all_platforms(self) -> list[AgentAdapter]: - """ - Detect all available platforms. - - Returns: - List of available adapter instances - """ - available = [] - for platform_name in AgentAdapter.list_names(): - adapter = self.detect_platform(platform_name) - if adapter is not None: - available.append(adapter) - - return available - - def get_adapter(self, platform_name: str) -> AgentAdapter: - """ - Get an adapter instance for a platform (without checking availability). - - Args: - platform_name: Platform name - - Returns: - AgentAdapter instance - - Raises: - DetectorError: If platform_name is not supported - """ - try: - adapter_cls = AgentAdapter.get(platform_name) - except AdapterError as e: - raise DetectorError(str(e)) from e - - return adapter_cls(self.project_root) - - @staticmethod - def list_supported_platforms() -> list[str]: - """ - List all supported platform names. - - Returns: - List of platform names - """ - return AgentAdapter.list_names() diff --git a/src/deepwork/core/generator.py b/src/deepwork/core/generator.py deleted file mode 100644 index 58502c1a..00000000 --- a/src/deepwork/core/generator.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Skill file generator using Jinja2 templates.""" - -from pathlib import Path - -from jinja2 import Environment, FileSystemLoader, TemplateNotFound - -from deepwork.core.adapters import AgentAdapter -from deepwork.utils.fs import safe_write - - -class GeneratorError(Exception): - """Exception raised for skill generation errors.""" - - pass - - -class SkillGenerator: - """Generates skill files from job definitions.""" - - def __init__(self, templates_dir: Path | str | None = None): - """ - Initialize generator. - - Args: - templates_dir: Path to templates directory - (defaults to package templates directory) - """ - if templates_dir is None: - # Use package templates directory - templates_dir = Path(__file__).parent.parent / "templates" - - self.templates_dir = Path(templates_dir) - - if not self.templates_dir.exists(): - raise GeneratorError(f"Templates directory not found: {self.templates_dir}") - - def _get_jinja_env(self, adapter: AgentAdapter) -> Environment: - """ - Get Jinja2 environment for an adapter. - - Args: - adapter: Agent adapter - - Returns: - Jinja2 Environment - """ - platform_templates_dir = adapter.get_template_dir(self.templates_dir) - if not platform_templates_dir.exists(): - raise GeneratorError( - f"Templates for platform '{adapter.name}' not found at {platform_templates_dir}" - ) - - return Environment( - loader=FileSystemLoader(platform_templates_dir), - trim_blocks=True, - lstrip_blocks=True, - ) - - def generate_deepwork_skill( - self, - adapter: AgentAdapter, - output_dir: Path | str, - ) -> Path: - """ - Generate the global /deepwork skill that instructs agents to use MCP tools. - - This is a single skill that provides the main entry point for DeepWork, - directing agents to use the MCP server's tools for workflow management. - - Args: - adapter: Agent adapter for the target platform - output_dir: Directory to write skill file to - - Returns: - Path to generated skill file - - Raises: - GeneratorError: If generation fails - """ - output_dir = Path(output_dir) - - # Create skills subdirectory if needed - skills_dir = output_dir / adapter.skills_dir - skills_dir.mkdir(parents=True, exist_ok=True) - - # Load and render template - env = self._get_jinja_env(adapter) - template_name = "skill-deepwork.md.jinja" - - try: - template = env.get_template(template_name) - except TemplateNotFound as e: - raise GeneratorError(f"DeepWork skill template not found: {e}") from e - - try: - rendered = template.render() - except Exception as e: - raise GeneratorError(f"DeepWork skill template rendering failed: {e}") from e - - # Write skill file - # Use the adapter's convention for naming - if adapter.name == "gemini": - skill_filename = "deepwork/index.toml" - else: - skill_filename = "deepwork/SKILL.md" - - skill_path = skills_dir / skill_filename - skill_path.parent.mkdir(parents=True, exist_ok=True) - - try: - safe_write(skill_path, rendered) - except Exception as e: - raise GeneratorError(f"Failed to write DeepWork skill file: {e}") from e - - return skill_path diff --git a/src/deepwork/core/hooks_syncer.py b/src/deepwork/core/hooks_syncer.py deleted file mode 100644 index 86fb17e4..00000000 --- a/src/deepwork/core/hooks_syncer.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Hooks syncer for DeepWork - collects and syncs hooks from jobs to platform settings.""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import yaml - -from deepwork.core.adapters import AgentAdapter - - -class HooksSyncError(Exception): - """Exception raised for hooks sync errors.""" - - pass - - -@dataclass -class HookEntry: - """Represents a single hook entry for a lifecycle event.""" - - job_name: str # Job that provides this hook - job_dir: Path # Full path to job directory - script: str | None = None # Script filename (if script-based hook) - module: str | None = None # Python module (if module-based hook) - - def get_command(self, project_path: Path) -> str: - """ - Get the command to run this hook. - - Args: - project_path: Path to project root - - Returns: - Command string to execute - """ - if self.module: - # Python module - use deepwork hook CLI for portability - # Extract hook name from module path (e.g., "deepwork.hooks.my_hook" -> "my_hook") - hook_name = self.module.rsplit(".", 1)[-1] - return f"deepwork hook {hook_name}" - elif self.script: - # Script path is: .deepwork/jobs/{job_name}/hooks/{script} - script_path = self.job_dir / "hooks" / self.script - try: - return str(script_path.relative_to(project_path)) - except ValueError: - # If not relative, return the full path - return str(script_path) - else: - raise ValueError("HookEntry must have either script or module") - - -@dataclass -class HookSpec: - """Specification for a single hook (either script or module).""" - - script: str | None = None - module: str | None = None - - -@dataclass -class JobHooks: - """Hooks configuration for a job.""" - - job_name: str - job_dir: Path - hooks: dict[str, list[HookSpec]] = field(default_factory=dict) # event -> [HookSpec] - - @classmethod - def from_job_dir(cls, job_dir: Path) -> "JobHooks | None": - """ - Load hooks configuration from a job directory. - - Args: - job_dir: Path to job directory containing hooks/global_hooks.yml - - Returns: - JobHooks instance or None if no hooks defined - """ - hooks_file = job_dir / "hooks" / "global_hooks.yml" - if not hooks_file.exists(): - return None - - try: - with open(hooks_file, encoding="utf-8") as f: - data = yaml.safe_load(f) - except (yaml.YAMLError, OSError): - return None - - if not data or not isinstance(data, dict): - return None - - # Parse hooks - each key is an event, value is list of scripts or module specs - hooks: dict[str, list[HookSpec]] = {} - for event, entries in data.items(): - if not isinstance(entries, list): - entries = [entries] - - hook_specs: list[HookSpec] = [] - for entry in entries: - if isinstance(entry, str): - # Simple script filename - hook_specs.append(HookSpec(script=entry)) - elif isinstance(entry, dict) and "module" in entry: - # Python module specification - hook_specs.append(HookSpec(module=entry["module"])) - - if hook_specs: - hooks[event] = hook_specs - - if not hooks: - return None - - return cls( - job_name=job_dir.name, - job_dir=job_dir, - hooks=hooks, - ) - - -def collect_job_hooks(jobs_dir: Path) -> list[JobHooks]: - """ - Collect hooks from all jobs in the jobs directory. - - Args: - jobs_dir: Path to .deepwork/jobs directory - - Returns: - List of JobHooks for all jobs with hooks defined - """ - if not jobs_dir.exists(): - return [] - - job_hooks_list = [] - for job_dir in jobs_dir.iterdir(): - if not job_dir.is_dir(): - continue - - job_hooks = JobHooks.from_job_dir(job_dir) - if job_hooks: - job_hooks_list.append(job_hooks) - - return job_hooks_list - - -def merge_hooks_for_platform( - job_hooks_list: list[JobHooks], - project_path: Path, -) -> dict[str, list[dict[str, Any]]]: - """ - Merge hooks from multiple jobs into a single configuration. - - Args: - job_hooks_list: List of JobHooks from different jobs - project_path: Path to project root for relative path calculation - - Returns: - Dict mapping lifecycle events to hook configurations - """ - merged: dict[str, list[dict[str, Any]]] = {} - - for job_hooks in job_hooks_list: - for event, hook_specs in job_hooks.hooks.items(): - if event not in merged: - merged[event] = [] - - for spec in hook_specs: - entry = HookEntry( - job_name=job_hooks.job_name, - job_dir=job_hooks.job_dir, - script=spec.script, - module=spec.module, - ) - command = entry.get_command(project_path) - - # Create hook configuration for Claude Code format - hook_config = { - "matcher": "", # Match all - "hooks": [ - { - "type": "command", - "command": command, - } - ], - } - - # Check if this hook is already present (avoid duplicates) - if not _hook_already_present(merged[event], command): - merged[event].append(hook_config) - - # Claude Code has separate Stop and SubagentStop events. When a Stop hook - # is defined, also register it for SubagentStop so it triggers for both - # the main agent and subagents. - if "Stop" in merged: - if "SubagentStop" not in merged: - merged["SubagentStop"] = [] - for hook_config in merged["Stop"]: - command = hook_config.get("hooks", [{}])[0].get("command", "") - if not _hook_already_present(merged["SubagentStop"], command): - merged["SubagentStop"].append(hook_config) - - return merged - - -def _hook_already_present(hooks: list[dict[str, Any]], script_path: str) -> bool: - """Check if a hook with the given script path is already in the list.""" - for hook in hooks: - hook_list = hook.get("hooks", []) - for h in hook_list: - if h.get("command") == script_path: - return True - return False - - -def sync_hooks_to_platform( - project_path: Path, - adapter: AgentAdapter, - job_hooks_list: list[JobHooks], -) -> int: - """ - Sync hooks from jobs to a specific platform's settings. - - Args: - project_path: Path to project root - adapter: Agent adapter for the target platform - job_hooks_list: List of JobHooks from jobs - - Returns: - Number of hooks synced - - Raises: - HooksSyncError: If sync fails - """ - # Merge hooks from all jobs - merged_hooks = merge_hooks_for_platform(job_hooks_list, project_path) - - if not merged_hooks: - return 0 - - # Delegate to adapter's sync_hooks method - try: - return adapter.sync_hooks(project_path, merged_hooks) - except Exception as e: - raise HooksSyncError(f"Failed to sync hooks: {e}") from e diff --git a/src/deepwork/hooks/check_version.sh b/src/deepwork/hooks/check_version.sh deleted file mode 100755 index 21caabc1..00000000 --- a/src/deepwork/hooks/check_version.sh +++ /dev/null @@ -1,263 +0,0 @@ -#!/bin/bash -# check_version.sh - SessionStart hook to check Claude Code version and deepwork installation -# -# This hook performs two critical checks: -# 1. Verifies that the 'deepwork' command is installed and directly invokable -# 2. Warns users if their Claude Code version is below the minimum required -# -# The deepwork check is blocking (exit 2) because hooks cannot function without it. -# The version check is informational only (exit 0) to avoid blocking sessions. -# -# Uses hookSpecificOutput.additionalContext to pass messages to Claude's context. - -# ============================================================================ -# READ STDIN INPUT -# ============================================================================ -# SessionStart hooks receive JSON input via stdin with session information. -# We need to read this to check the session source (startup, resume, clear). - -HOOK_INPUT="" -if [ ! -t 0 ]; then - HOOK_INPUT=$(cat) -fi - -# ============================================================================ -# SKIP NON-INITIAL SESSIONS -# ============================================================================ -# SessionStart hooks can be triggered for different reasons: -# - "startup": Initial session start (user ran `claude` or similar) -# - "resume": Session resumed (user ran `claude --resume`) -# - "clear": Context was cleared/compacted -# -# We only want to run the full check on initial startup. For resumed or -# compacted sessions, return immediately with empty JSON to avoid redundant -# checks and noise. - -get_session_source() { - # Extract the "source" field from the JSON input - # Returns empty string if not found or not valid JSON - if [ -n "$HOOK_INPUT" ]; then - # Use grep and sed for simple JSON parsing (avoid jq dependency) - echo "$HOOK_INPUT" | grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/' | head -1 - fi -} - -SESSION_SOURCE=$(get_session_source) - -# If source is anything other than "startup" (or empty/missing for backwards compat), -# skip this hook entirely. Empty source means older Claude Code version that doesn't -# send the source field - we treat that as an initial session to maintain backwards compat. -if [ -n "$SESSION_SOURCE" ] && [ "$SESSION_SOURCE" != "startup" ]; then - # Non-initial session (resume, clear, etc.) - skip all checks - echo '{}' - exit 0 -fi - -# ============================================================================ -# DEEPWORK INSTALLATION CHECK (BLOCKING) -# ============================================================================ -# This check runs on initial session start because if deepwork is not installed, -# nothing else will work. - -check_deepwork_installed() { - # Run 'deepwork --version' to verify the command is installed and directly invokable - if ! deepwork --version >/dev/null 2>&1; then - return 1 - fi - return 0 -} - -print_deepwork_error() { - cat >&2 << 'EOF' - -================================================================================ - *** DEEPWORK NOT INSTALLED *** -================================================================================ - - ERROR: The 'deepwork' command is not available or cannot be directly invoked. - - DeepWork must be installed such that running 'deepwork' directly works. - For example, running 'deepwork --version' should succeed. - - IMPORTANT: Do NOT use 'uv run deepwork' or similar wrappers. - The command must be directly invokable as just 'deepwork'. - - To verify: 'deepwork --version' should succeed. - - ------------------------------------------------------------------------ - | | - | Please fix your deepwork installation before proceeding. | - | | - | Installation options: | - | - pipx install deepwork | - | - pip install --user deepwork (ensure ~/.local/bin is in PATH) | - | - nix develop (if using the nix flake) | - | | - ------------------------------------------------------------------------ - -================================================================================ - -EOF -} - -output_deepwork_error_json() { - cat << 'EOF' -{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"DEEPWORK INSTALLATION ERROR: The 'deepwork' command is not installed or cannot be directly invoked. DeepWork must be installed such that it can be directly invoked (e.g., 'deepwork', NOT 'uv run deepwork'). Please fix your deepwork installation before proceeding with anything else. DO NOT CONTINUE until this is resolved."},"error":"deepwork command not found - please install deepwork so it can be directly invoked"} -EOF -} - -# Check deepwork installation FIRST (before any other checks) -if ! check_deepwork_installed; then - print_deepwork_error - output_deepwork_error_json - exit 2 # Blocking error - prevent session from continuing -fi - -# Note: We previously had a re-entry guard using DEEPWORK_VERSION_CHECK_DONE -# environment variable, but that was unreliable across session resumptions. -# Now we use the source field in the hook input JSON to detect initial sessions -# vs resumed/compacted sessions (see SKIP NON-INITIAL SESSIONS section above). - -# ============================================================================ -# MINIMUM VERSION CONFIGURATION -# ============================================================================ -MINIMUM_VERSION="2.1.14" - -# ============================================================================ -# VERSION CHECK LOGIC -# ============================================================================ - -# Get current Claude Code version -get_current_version() { - local version_output - version_output=$(claude --version 2>/dev/null) || return 1 - # Extract version number (e.g., "2.1.1" from "2.1.1 (Claude Code)") - echo "$version_output" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' | head -1 -} - -# Compare two semantic versions -# Returns 0 if version1 >= version2, 1 otherwise -version_gte() { - local version1="$1" - local version2="$2" - - # Split versions into components - local v1_major v1_minor v1_patch - local v2_major v2_minor v2_patch - - IFS='.' read -r v1_major v1_minor v1_patch <<< "$version1" - IFS='.' read -r v2_major v2_minor v2_patch <<< "$version2" - - # Default to 0 if component is missing - v1_major=${v1_major:-0} - v1_minor=${v1_minor:-0} - v1_patch=${v1_patch:-0} - v2_major=${v2_major:-0} - v2_minor=${v2_minor:-0} - v2_patch=${v2_patch:-0} - - # Compare major version - if [ "$v1_major" -gt "$v2_major" ]; then - return 0 - elif [ "$v1_major" -lt "$v2_major" ]; then - return 1 - fi - - # Compare minor version - if [ "$v1_minor" -gt "$v2_minor" ]; then - return 0 - elif [ "$v1_minor" -lt "$v2_minor" ]; then - return 1 - fi - - # Compare patch version - if [ "$v1_patch" -ge "$v2_patch" ]; then - return 0 - else - return 1 - fi -} - -# Generate warning message -get_warning_message() { - local current_version="$1" - - cat << EOF -CLAUDE CODE VERSION WARNING: Your version (${current_version}) is below the minimum recommended (${MINIMUM_VERSION}). Older versions have known bugs that may cause issues with DeepWork. RECOMMENDED: Update your version of Claude Code. TELL THE USER ABOUT THIS ISSUE LOUDLY!!! -EOF -} - -# Print visual warning to stderr for immediate user visibility -print_stderr_warning() { - local current_version="$1" - - cat >&2 << EOF - -================================================================================ - *** CLAUDE CODE VERSION WARNING *** -================================================================================ - - Your Claude Code version: ${current_version} - Minimum recommended: ${MINIMUM_VERSION} - - IMPORTANT: Versions below the minimum have known bugs that may cause - issues with DeepWork functionality. You may experience unexpected - behavior, errors, or incomplete operations. - - ------------------------------------------------------------------------ - | | - | RECOMMENDED ACTION: Update your version of Claude Code | - | | - ------------------------------------------------------------------------ - -================================================================================ - -EOF -} - -# Output JSON with additional context for Claude -output_json_with_context() { - local context="$1" - # Escape special characters for JSON - local escaped_context - escaped_context=$(echo "$context" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ') - - cat << EOF -{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"${escaped_context}"}} -EOF -} - -# ============================================================================ -# MAIN -# ============================================================================ - -main() { - local current_version - local warning_message - - # Get current version (don't exit on failure) - current_version=$(get_current_version) || current_version="" - - if [ -z "$current_version" ]; then - # Could not determine version, output empty JSON and exit - echo '{}' - exit 0 - fi - - # Check if current version is below minimum - if ! version_gte "$current_version" "$MINIMUM_VERSION"; then - # Print visual warning to stderr - print_stderr_warning "$current_version" - - # Output JSON with context for Claude - warning_message=$(get_warning_message "$current_version") - output_json_with_context "$warning_message" - else - # Version is OK, output empty JSON - echo '{}' - fi - - exit 0 -} - -main "$@" diff --git a/src/deepwork/hooks/check_version.sh b/src/deepwork/hooks/check_version.sh new file mode 120000 index 00000000..10c0b847 --- /dev/null +++ b/src/deepwork/hooks/check_version.sh @@ -0,0 +1 @@ +../../../platform/hooks/check_version.sh \ No newline at end of file diff --git a/src/deepwork/templates/__init__.py b/src/deepwork/templates/__init__.py deleted file mode 100644 index 259c4219..00000000 --- a/src/deepwork/templates/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Skill templates for different AI platforms.""" diff --git a/src/deepwork/templates/claude/AGENTS.md b/src/deepwork/templates/claude/AGENTS.md deleted file mode 100644 index 7c4eb976..00000000 --- a/src/deepwork/templates/claude/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -# Claude Templates - Agent Notes - -Notes for AI agents working on Claude Code jinja templates. - -## Prompt-Based Stop Hooks - NOT WORKING - -**IMPORTANT: Prompt-based stop hooks (`type: prompt` for Stop/SubagentStop events) do not currently work properly in Claude Code.** - -Reference: https://github.com/anthropics/claude-code/issues/20221 - -### What This Means - -- Do NOT use `type: prompt` hooks for Stop or SubagentStop events in templates -- The quality validation loop pattern that relies on prompt hooks will not function as expected -- Instead, instruct agents to use a sub-agent (e.g., Haiku) to review their work against quality criteria - -### Workaround - -Instead of prompt-based stop hooks, templates should include explicit instructions in the command content directing the agent to: - -1. Have a sub-agent using Haiku review the work against the specified quality criteria -2. Fix any valid issues raised by the sub-agent -3. Have the sub-agent review again until all valid feedback is handled - -### Future Reversal - -If prompt-based stop hooks are fixed in Claude Code (check the issue above for updates), this guidance should be reversed and prompt hooks can be re-enabled in templates. - -## Historical Context (Prompt Hooks - When They Work) - -The following guidance applies IF prompt hooks start working again: - -When writing prompt-based hooks (e.g., Stop hooks with `type: prompt`): - -- **Do NOT include instructions on how to return responses** (e.g., "respond with JSON", "return `{"ok": true}`"). Claude Code's internal instructions already specify the expected response format for prompt hooks. -- Adding redundant response format instructions can cause conflicts or confusion with the built-in behavior. i.e. the hook will not block the agent from stopping. - -Reference: https://github.com/anthropics/claude-code/issues/11786 diff --git a/src/deepwork/templates/claude/settings.json b/src/deepwork/templates/claude/settings.json deleted file mode 100644 index 0359e5f6..00000000 --- a/src/deepwork/templates/claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "permissions": { - "allow": [ - "Read(./.deepwork/**)", - "Edit(./.deepwork/**)", - "Write(./.deepwork/**)", - "Bash(deepwork:*)", - "WebSearch", - "mcp__deepwork__get_workflows", - "mcp__deepwork__start_workflow", - "mcp__deepwork__finished_step", - "mcp__deepwork__abort_workflow" - ] - } -} diff --git a/tests/conftest.py b/tests/conftest.py index d7a81ed8..057f31e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,27 +26,6 @@ def mock_git_repo(temp_dir: Path) -> Path: return temp_dir -@pytest.fixture -def mock_claude_project(mock_git_repo: Path) -> Path: - """Create a mock project with Claude Code setup.""" - claude_dir = mock_git_repo / ".claude" - claude_dir.mkdir(exist_ok=True) - (claude_dir / "settings.json").write_text('{"version": "1.0"}') - return mock_git_repo - - -@pytest.fixture -def mock_multi_platform_project(mock_git_repo: Path) -> Path: - """Create a mock project with multiple AI platforms setup.""" - claude_dir = mock_git_repo / ".claude" - claude_dir.mkdir(exist_ok=True) - (claude_dir / "settings.json").write_text('{"version": "1.0"}') - - gemini_dir = mock_git_repo / ".gemini" - gemini_dir.mkdir(exist_ok=True) - return mock_git_repo - - @pytest.fixture def fixtures_dir() -> Path: """Return the path to the fixtures directory.""" diff --git a/tests/e2e/test_claude_code_integration.py b/tests/e2e/test_claude_code_integration.py index 76ca429f..ce1792b4 100644 --- a/tests/e2e/test_claude_code_integration.py +++ b/tests/e2e/test_claude_code_integration.py @@ -3,7 +3,7 @@ These tests validate that DeepWork MCP-based workflows work correctly. The tests can run in two modes: -1. **MCP tools mode** (default): Tests MCP skill generation and workflow tools +1. **MCP tools mode** (default): Tests MCP workflow tools directly 2. **Full e2e mode**: Actually executes workflows with Claude Code via MCP Set ANTHROPIC_API_KEY and DEEPWORK_E2E_FULL=true to run full e2e tests. @@ -17,8 +17,6 @@ import pytest -from deepwork.core.adapters import ClaudeAdapter -from deepwork.core.generator import SkillGenerator from deepwork.mcp.state import StateManager from deepwork.mcp.tools import WorkflowTools @@ -56,95 +54,137 @@ def run_full_e2e() -> bool: ) -class TestMCPSkillGeneration: - """Tests for MCP entry point skill generation.""" +class TestPluginSkillStructure: + """Tests for plugin-provided skill file structure.""" - def test_generate_deepwork_skill_in_temp_project(self) -> None: - """Test generating the /deepwork MCP skill in a realistic project structure.""" - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) - - # Set up project structure - deepwork_dir = project_dir / ".deepwork" / "jobs" - deepwork_dir.mkdir(parents=True) - - # Copy fruits job fixture (for job discovery testing) - fixtures_dir = Path(__file__).parent.parent / "fixtures" / "jobs" / "fruits" - shutil.copytree(fixtures_dir, deepwork_dir / "fruits") - - # Initialize git repo (required for some operations) - subprocess.run(["git", "init"], cwd=project_dir, capture_output=True) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=project_dir, - capture_output=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=project_dir, - capture_output=True, - ) - - # Generate MCP entry point skill - generator = SkillGenerator() - adapter = ClaudeAdapter(project_root=project_dir) - - claude_dir = project_dir / ".claude" - claude_dir.mkdir() - - skill_path = generator.generate_deepwork_skill(adapter, claude_dir) - - # Validate skill was generated - assert skill_path.exists() - expected_path = claude_dir / "skills" / "deepwork" / "SKILL.md" - assert skill_path == expected_path - - def test_deepwork_skill_structure(self) -> None: - """Test that the generated /deepwork skill has the expected structure.""" - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) - claude_dir = project_dir / ".claude" - claude_dir.mkdir(parents=True) - - generator = SkillGenerator() - adapter = ClaudeAdapter(project_root=project_dir) - skill_path = generator.generate_deepwork_skill(adapter, claude_dir) - - content = skill_path.read_text() - - # Check frontmatter - assert "---" in content - assert "name: deepwork" in content - - # Check MCP tool references - assert "get_workflows" in content - assert "start_workflow" in content - assert "finished_step" in content + def test_claude_plugin_skill_exists(self) -> None: + """Test that the Claude plugin skill file exists.""" + plugin_skill = ( + Path(__file__).parent.parent.parent + / "plugins" + / "claude" + / "skills" + / "deepwork" + / "SKILL.md" + ) + assert plugin_skill.exists(), f"Plugin skill not found at {plugin_skill}" + + def test_claude_plugin_skill_structure(self) -> None: + """Test that the Claude plugin skill has the expected structure.""" + plugin_skill = ( + Path(__file__).parent.parent.parent + / "plugins" + / "claude" + / "skills" + / "deepwork" + / "SKILL.md" + ) + content = plugin_skill.read_text() + + # Check YAML frontmatter + assert content.startswith("---") + assert "name: deepwork" in content + + # Check MCP tool references + assert "get_workflows" in content + assert "start_workflow" in content + assert "finished_step" in content + + # Check structure sections + assert "# DeepWork" in content + assert "MCP" in content + + def test_gemini_plugin_skill_exists(self) -> None: + """Test that the Gemini plugin skill file exists.""" + plugin_skill = ( + Path(__file__).parent.parent.parent + / "plugins" + / "gemini" + / "skills" + / "deepwork" + / "SKILL.md" + ) + assert plugin_skill.exists(), f"Plugin skill not found at {plugin_skill}" + + def test_gemini_plugin_skill_structure(self) -> None: + """Test that the Gemini plugin skill has TOML frontmatter.""" + plugin_skill = ( + Path(__file__).parent.parent.parent + / "plugins" + / "gemini" + / "skills" + / "deepwork" + / "SKILL.md" + ) + content = plugin_skill.read_text() + + # Check TOML frontmatter + assert content.startswith("+++") + assert 'name = "deepwork"' in content + + # Check MCP tool references (body should be same) + assert "get_workflows" in content + assert "start_workflow" in content + assert "finished_step" in content + + def test_plugin_json_exists(self) -> None: + """Test that the Claude plugin.json exists and is valid.""" + import json + + plugin_json = ( + Path(__file__).parent.parent.parent + / "plugins" + / "claude" + / ".claude-plugin" + / "plugin.json" + ) + assert plugin_json.exists() + + data = json.loads(plugin_json.read_text()) + assert data["name"] == "deepwork" + assert "version" in data + + def test_plugin_hooks_symlink(self) -> None: + """Test that the plugin hooks symlink resolves correctly.""" + hook_path = ( + Path(__file__).parent.parent.parent + / "plugins" + / "claude" + / "hooks" + / "check_version.sh" + ) + assert hook_path.exists(), f"Hook not found at {hook_path}" + assert hook_path.is_symlink(), "check_version.sh should be a symlink" - # Check structure sections - assert "# DeepWork" in content - assert "MCP" in content + # Verify the symlink target resolves + resolved = hook_path.resolve() + assert resolved.exists(), f"Symlink target does not exist: {resolved}" + assert resolved.name == "check_version.sh" - def test_deepwork_skill_mcp_instructions(self) -> None: - """Test that the /deepwork skill properly instructs use of MCP tools.""" - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) - claude_dir = project_dir / ".claude" - claude_dir.mkdir(parents=True) + def test_plugin_mcp_json_exists(self) -> None: + """Test that the plugin .mcp.json exists and is valid.""" + import json - generator = SkillGenerator() - adapter = ClaudeAdapter(project_root=project_dir) - skill_path = generator.generate_deepwork_skill(adapter, claude_dir) + mcp_json = ( + Path(__file__).parent.parent.parent / "plugins" / "claude" / ".mcp.json" + ) + assert mcp_json.exists() - content = skill_path.read_text() + data = json.loads(mcp_json.read_text()) + assert "mcpServers" in data + assert "deepwork" in data["mcpServers"] + assert data["mcpServers"]["deepwork"]["command"] == "uvx" - # Should instruct to use MCP tools, not read files - assert "MCP" in content - assert "tool" in content.lower() + def test_platform_skill_body_exists(self) -> None: + """Test that the shared platform skill body exists.""" + skill_body = ( + Path(__file__).parent.parent.parent / "platform" / "skill-body.md" + ) + assert skill_body.exists() - # Should describe the workflow execution flow - assert "start_workflow" in content - assert "finished_step" in content + content = skill_body.read_text() + assert "get_workflows" in content + assert "start_workflow" in content class TestMCPWorkflowTools: @@ -340,16 +380,36 @@ def project_with_mcp(self) -> Path: capture_output=True, ) - # Generate /deepwork skill - generator = SkillGenerator() - adapter = ClaudeAdapter(project_root=project_dir) + # Create skill and MCP config directly (no generator needed) + import json claude_dir = project_dir / ".claude" claude_dir.mkdir() - generator.generate_deepwork_skill(adapter, claude_dir) - # Register MCP server - adapter.register_mcp_server(project_dir) + skills_dir = claude_dir / "skills" / "deepwork" + skills_dir.mkdir(parents=True) + + # Copy plugin skill content + plugin_skill = ( + Path(__file__).parent.parent.parent + / "plugins" + / "claude" + / "skills" + / "deepwork" + / "SKILL.md" + ) + shutil.copy2(plugin_skill, skills_dir / "SKILL.md") + + # Write MCP config + mcp_config = { + "mcpServers": { + "deepwork": { + "command": "deepwork", + "args": ["serve", "--path", ".", "--external-runner", "claude"], + } + } + } + (project_dir / ".mcp.json").write_text(json.dumps(mcp_config, indent=2)) yield project_dir diff --git a/tests/integration/test_install_flow.py b/tests/integration/test_install_flow.py deleted file mode 100644 index 169e90ed..00000000 --- a/tests/integration/test_install_flow.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Integration tests for the install command.""" - -from pathlib import Path - -from click.testing import CliRunner - -from deepwork.cli.main import cli -from deepwork.utils.yaml_utils import load_yaml - - -class TestInstallCommand: - """Integration tests for 'deepwork install' command.""" - - def test_install_with_claude(self, mock_claude_project: Path) -> None: - """Test installing DeepWork in a Claude Code project.""" - runner = CliRunner() - - result = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert "DeepWork Installation" in result.output - assert "Git repository found" in result.output - assert "Claude Code detected" in result.output - assert "DeepWork installed successfully" in result.output - - # Verify directory structure - deepwork_dir = mock_claude_project / ".deepwork" - assert deepwork_dir.exists() - assert (deepwork_dir / "jobs").exists() - - # Verify config.yml - config_file = deepwork_dir / "config.yml" - assert config_file.exists() - config = load_yaml(config_file) - assert config is not None - assert "claude" in config["platforms"] - - # Verify MCP entry point skill was created (deepwork/SKILL.md) - claude_dir = mock_claude_project / ".claude" / "skills" - assert (claude_dir / "deepwork" / "SKILL.md").exists() - - # Verify deepwork skill content references MCP tools - deepwork_skill = (claude_dir / "deepwork" / "SKILL.md").read_text() - assert "deepwork" in deepwork_skill.lower() - - def test_install_with_auto_detect(self, mock_claude_project: Path) -> None: - """Test installing with auto-detection.""" - runner = CliRunner() - - result = runner.invoke( - cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False - ) - - assert result.exit_code == 0 - assert "Auto-detecting AI platform" in result.output - assert "Claude Code detected" in result.output - - def test_install_fails_without_git(self, temp_dir: Path) -> None: - """Test that install fails in non-Git directory.""" - runner = CliRunner() - - result = runner.invoke(cli, ["install", "--platform", "claude", "--path", str(temp_dir)]) - - assert result.exit_code != 0 - assert "Not a Git repository" in result.output - - def test_install_defaults_to_claude_when_no_platform(self, mock_git_repo: Path) -> None: - """Test that install defaults to Claude Code when no platform is detected.""" - runner = CliRunner() - - result = runner.invoke( - cli, ["install", "--path", str(mock_git_repo)], catch_exceptions=False - ) - - assert result.exit_code == 0 - assert "No AI platform detected, defaulting to Claude Code" in result.output - assert "Created .claude/" in result.output - assert "DeepWork installed successfully for Claude Code" in result.output - - # Verify .claude directory was created - claude_dir = mock_git_repo / ".claude" - assert claude_dir.exists() - - # Verify config.yml has Claude - config_file = mock_git_repo / ".deepwork" / "config.yml" - config = load_yaml(config_file) - assert config is not None - assert "claude" in config["platforms"] - - # Verify MCP entry point skill was created for Claude - skills_dir = claude_dir / "skills" - assert (skills_dir / "deepwork" / "SKILL.md").exists() - - def test_install_with_multiple_platforms_auto_detect( - self, mock_multi_platform_project: Path - ) -> None: - """Test installing with auto-detection when multiple platforms are present.""" - runner = CliRunner() - - result = runner.invoke( - cli, - ["install", "--path", str(mock_multi_platform_project)], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert "Auto-detecting AI platforms" in result.output - assert "Claude Code detected" in result.output - assert "Gemini CLI detected" in result.output - assert "DeepWork installed successfully for Claude Code, Gemini CLI" in result.output - - # Verify config.yml has both platforms - config_file = mock_multi_platform_project / ".deepwork" / "config.yml" - config = load_yaml(config_file) - assert config is not None - assert "claude" in config["platforms"] - assert "gemini" in config["platforms"] - - # Verify MCP entry point skill was created for Claude - claude_dir = mock_multi_platform_project / ".claude" / "skills" - assert (claude_dir / "deepwork" / "SKILL.md").exists() - - # Note: Gemini MCP skill template (skill-deepwork) is not yet implemented - # so we don't assert on Gemini skill existence - the install will show - # an error for Gemini skill generation but continue - - def test_install_with_specified_platform_when_missing(self, mock_git_repo: Path) -> None: - """Test that install fails when specified platform is not present.""" - runner = CliRunner() - - result = runner.invoke( - cli, ["install", "--platform", "claude", "--path", str(mock_git_repo)] - ) - - assert result.exit_code != 0 - assert "Claude Code not detected" in result.output - assert ".claude/" in result.output - - def test_install_is_idempotent(self, mock_claude_project: Path) -> None: - """Test that running install multiple times is safe.""" - runner = CliRunner() - - # First install - result1 = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result1.exit_code == 0 - - # Second install - result2 = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result2.exit_code == 0 - - # Verify files still exist and are valid - deepwork_dir = mock_claude_project / ".deepwork" - assert (deepwork_dir / "config.yml").exists() - - claude_dir = mock_claude_project / ".claude" / "skills" - # MCP entry point skill - assert (claude_dir / "deepwork" / "SKILL.md").exists() - - def test_install_shows_repair_message_when_job_fails_to_parse( - self, mock_claude_project: Path - ) -> None: - """Test that install shows repair message when there are warnings.""" - runner = CliRunner() - - # First do a normal install - result1 = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result1.exit_code == 0 - assert "DeepWork installed successfully" in result1.output - - # Create a malformed job definition - jobs_dir = mock_claude_project / ".deepwork" / "jobs" / "broken_job" - jobs_dir.mkdir(parents=True, exist_ok=True) - (jobs_dir / "job.yml").write_text("invalid: yaml: content: [") - - # Reinstall - should show repair message due to parsing warning - result2 = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result2.exit_code == 0 - assert "You should repair your DeepWork install" in result2.output - assert "/deepwork repair" in result2.output - assert "DeepWork installed successfully" not in result2.output - - -class TestCLIEntryPoint: - """Tests for CLI entry point.""" - - def test_cli_version(self) -> None: - """Test that --version works.""" - runner = CliRunner() - - result = runner.invoke(cli, ["--version"]) - - assert result.exit_code == 0 - assert "version" in result.output.lower() - - def test_cli_help(self) -> None: - """Test that --help works.""" - runner = CliRunner() - - result = runner.invoke(cli, ["--help"]) - - assert result.exit_code == 0 - assert "DeepWork" in result.output - assert "install" in result.output - - def test_install_help(self) -> None: - """Test that install --help works.""" - runner = CliRunner() - - result = runner.invoke(cli, ["install", "--help"]) - - assert result.exit_code == 0 - assert "Install DeepWork" in result.output - assert "--platform" in result.output diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py deleted file mode 100644 index 49c1e5bd..00000000 --- a/tests/unit/test_adapters.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for adapter MCP server registration.""" - -import json -from pathlib import Path - -import pytest - -from deepwork.core.adapters import ClaudeAdapter - - -@pytest.fixture -def project_root(tmp_path: Path) -> Path: - """Create a temporary project root with .claude dir.""" - claude_dir = tmp_path / ".claude" - claude_dir.mkdir() - return tmp_path - - -class TestClaudeAdapterMCPRegistration: - """Tests for ClaudeAdapter.register_mcp_server.""" - - def test_register_creates_mcp_json(self, project_root: Path) -> None: - """Test that register_mcp_server creates .mcp.json.""" - adapter = ClaudeAdapter(project_root=project_root) - result = adapter.register_mcp_server(project_root) - - assert result is True - assert (project_root / ".mcp.json").exists() - - def test_register_includes_external_runner_claude(self, project_root: Path) -> None: - """Test that .mcp.json args include --external-runner claude.""" - adapter = ClaudeAdapter(project_root=project_root) - adapter.register_mcp_server(project_root) - - config = json.loads((project_root / ".mcp.json").read_text()) - args = config["mcpServers"]["deepwork"]["args"] - - assert "--external-runner" in args - idx = args.index("--external-runner") - assert args[idx + 1] == "claude" - - def test_register_full_args(self, project_root: Path) -> None: - """Test the complete args list in .mcp.json.""" - adapter = ClaudeAdapter(project_root=project_root) - adapter.register_mcp_server(project_root) - - config = json.loads((project_root / ".mcp.json").read_text()) - args = config["mcpServers"]["deepwork"]["args"] - - assert args == ["serve", "--path", ".", "--external-runner", "claude"] - - def test_register_command_is_deepwork(self, project_root: Path) -> None: - """Test that the command in .mcp.json is 'deepwork'.""" - adapter = ClaudeAdapter(project_root=project_root) - adapter.register_mcp_server(project_root) - - config = json.loads((project_root / ".mcp.json").read_text()) - assert config["mcpServers"]["deepwork"]["command"] == "deepwork" - - def test_register_is_idempotent(self, project_root: Path) -> None: - """Test that registering twice with same config returns False.""" - adapter = ClaudeAdapter(project_root=project_root) - - assert adapter.register_mcp_server(project_root) is True - assert adapter.register_mcp_server(project_root) is False - - def test_register_updates_old_config(self, project_root: Path) -> None: - """Test that registering updates an existing .mcp.json with old args.""" - # Write an old-style config without --external-runner - old_config = { - "mcpServers": { - "deepwork": { - "command": "deepwork", - "args": ["serve", "--path", "."], - } - } - } - (project_root / ".mcp.json").write_text(json.dumps(old_config)) - - adapter = ClaudeAdapter(project_root=project_root) - result = adapter.register_mcp_server(project_root) - - # Should detect the difference and update - assert result is True - config = json.loads((project_root / ".mcp.json").read_text()) - assert config["mcpServers"]["deepwork"]["args"] == [ - "serve", - "--path", - ".", - "--external-runner", - "claude", - ] - - def test_register_preserves_other_servers(self, project_root: Path) -> None: - """Test that registering deepwork preserves other MCP servers.""" - existing_config = { - "mcpServers": { - "other_server": { - "command": "other", - "args": ["--flag"], - } - } - } - (project_root / ".mcp.json").write_text(json.dumps(existing_config)) - - adapter = ClaudeAdapter(project_root=project_root) - adapter.register_mcp_server(project_root) - - config = json.loads((project_root / ".mcp.json").read_text()) - assert "other_server" in config["mcpServers"] - assert config["mcpServers"]["other_server"]["command"] == "other" - assert "deepwork" in config["mcpServers"] diff --git a/tests/unit/test_serve_cli.py b/tests/unit/test_serve_cli.py index 651ea89d..23daf9cd 100644 --- a/tests/unit/test_serve_cli.py +++ b/tests/unit/test_serve_cli.py @@ -11,9 +11,8 @@ class TestServeExternalRunnerOption: """Tests for --external-runner CLI option on the serve command.""" @patch("deepwork.cli.serve._serve_mcp") - @patch("deepwork.cli.serve._load_config") def test_default_external_runner_is_none( - self, mock_load: MagicMock, mock_serve: MagicMock, tmp_path: str + self, mock_serve: MagicMock, tmp_path: str ) -> None: """Test that --external-runner defaults to None when not specified.""" runner = CliRunner() @@ -28,9 +27,8 @@ def test_default_external_runner_is_none( assert call_args[0][4] is None or call_args.kwargs.get("external_runner") is None @patch("deepwork.cli.serve._serve_mcp") - @patch("deepwork.cli.serve._load_config") def test_external_runner_claude( - self, mock_load: MagicMock, mock_serve: MagicMock, tmp_path: str + self, mock_serve: MagicMock, tmp_path: str ) -> None: """Test that --external-runner claude passes 'claude' through.""" runner = CliRunner()