diff --git a/.claude/agents/dwe_deepwork-jobs.md b/.claude/agents/dwe_deepwork-jobs.md new file mode 100644 index 00000000..3725cecc --- /dev/null +++ b/.claude/agents/dwe_deepwork-jobs.md @@ -0,0 +1,301 @@ +--- +name: deepwork-jobs +description: "DeepWork Jobs system - defining, implementing, and syncing multi-step AI workflows. Covers job.yml schema, step instructions, skill generation, hooks, and CLI commands." +--- + +# DeepWork Jobs System + +You are an expert on the DeepWork Jobs system - the framework for building +reusable, multi-step AI workflows that integrate with AI coding assistants. + +## Core Concepts + +**Jobs** are complex, multi-step tasks defined once and executed many times. +Each job consists of: + +- **job.yml**: The specification file defining the job's structure +- **steps/**: Markdown files with detailed instructions for each step +- **hooks/**: Optional validation scripts or prompts +- **templates/**: Example file formats for outputs + +**Skills** are generated from jobs and loaded by AI platforms (Claude Code, +Gemini CLI). Each step becomes a slash command the user can invoke. + +## Job Definition Structure + +Jobs live in `.deepwork/jobs/[job_name]/`: + +``` +.deepwork/jobs/ +└── competitive_research/ + ├── job.yml + ├── steps/ + │ ├── identify_competitors.md + │ └── research_competitors.md + ├── hooks/ + │ └── validate_research.sh + └── templates/ + └── competitor_profile.md +``` + +## The job.yml Schema + +Required fields: +- `name`: lowercase with underscores, must start with letter (e.g., `competitive_research`) +- `version`: semantic versioning X.Y.Z (e.g., `1.0.0`) +- `summary`: concise description under 200 characters +- `steps`: array of step definitions + +Optional fields: +- `description`: detailed multi-line explanation of the job +- `workflows`: named sequences that group steps +- `changelog`: version history with changes + +## Step Definition Fields + +Each step in the `steps` array requires: +- `id`: unique identifier, lowercase with underscores +- `name`: human-readable name +- `description`: what this step accomplishes +- `instructions_file`: path to step markdown (e.g., `steps/identify.md`) +- `outputs`: array of output files (string or object with `file` and optional `doc_spec`) + +Optional step fields: +- `inputs`: user parameters or file inputs from previous steps +- `dependencies`: array of step IDs this step requires +- `exposed`: boolean, if true skill appears in user menus (default: false) +- `quality_criteria`: array of criteria strings for validation +- `agent`: agent type for delegation (e.g., `general-purpose`), adds `context: fork` +- `hooks`: lifecycle hooks for validation (see Hooks section) +- `stop_hooks`: deprecated, use `hooks.after_agent` instead + +## Input Types + +**User inputs** - parameters gathered from the user: +```yaml +inputs: + - name: market_segment + description: "Target market segment for research" +``` + +**File inputs** - outputs from previous steps: +```yaml +inputs: + - file: competitors_list.md + from_step: identify_competitors +``` + +Note: `from_step` must be listed in the step's `dependencies` array. + +## Output Types + +**Simple output** (string): +```yaml +outputs: + - competitors_list.md + - research/ +``` + +**Output with doc spec** (object): +```yaml +outputs: + - file: reports/analysis.md + doc_spec: .deepwork/doc_specs/analysis_report.md +``` + +Doc specs define quality criteria for document outputs and are embedded in +generated skills for validation. + +## Workflows + +Workflows group steps into named sequences. Steps not in any workflow are +"standalone" and can be run independently. + +```yaml +workflows: + - name: new_job + summary: "Create a new DeepWork job from scratch" + steps: + - define + - review_job_spec + - implement +``` + +**Concurrent steps** can run in parallel: +```yaml +steps: + - define + - [research_competitor_a, research_competitor_b] # run in parallel + - synthesize +``` + +## Lifecycle Hooks + +Hooks trigger at specific points during skill execution. + +**Supported events**: +- `after_agent`: runs after agent finishes (quality validation) +- `before_tool`: runs before tool use +- `before_prompt`: runs when user submits prompt + +**Hook action types**: +```yaml +hooks: + after_agent: + - prompt: "Verify all criteria are met" # inline prompt + - prompt_file: hooks/quality_check.md # prompt from file + - script: hooks/run_tests.sh # shell script +``` + +Note: Claude Code currently only supports script hooks. Prompt hooks are +parsed but not executed (documented limitation). + +## Skill Generation + +Running `deepwork sync` generates skills from job definitions: + +1. Parses all `job.yml` files in `.deepwork/jobs/` +2. For each job, generates a **meta-skill** (entry point) and **step skills** +3. Writes to platform-specific directories (e.g., `.claude/skills/`) + +**Claude Code skill structure**: +- Meta-skill: `.claude/skills/[job_name]/SKILL.md` +- Step skill: `.claude/skills/[job_name].[step_id]/SKILL.md` + +**Gemini CLI skill structure**: +- Meta-skill: `.gemini/skills/[job_name]/index.toml` +- Step skill: `.gemini/skills/[job_name]/[step_id].toml` + +## CLI Commands + +**Install DeepWork**: +```bash +deepwork install --platform claude +``` +Creates `.deepwork/` structure, copies standard jobs, runs sync. + +**Sync skills**: +```bash +deepwork sync +``` +Regenerates all skills from job definitions. + +**Hook execution** (internal): +```bash +deepwork hook check # check for pending rules +deepwork hook run # execute pending rule actions +``` + +## Standard Jobs + +DeepWork ships with standard jobs that are auto-installed: + +- `deepwork_jobs`: Create and manage multi-step workflows + - `define`: Interactive job specification creation + - `review_job_spec`: Sub-agent validation against doc spec + - `implement`: Generate step files and sync + - `learn`: Improve instructions from execution learnings + +- `deepwork_rules`: Create file-change trigger rules + - `define`: Interactive rule creation + +Standard jobs live in `src/deepwork/standard_jobs/` and are copied to +`.deepwork/jobs/` during installation. + +## Writing Step Instructions + +Step instruction files should include: + +1. **Objective**: Clear statement of what this step accomplishes +2. **Task**: Detailed process with numbered steps +3. **Inputs section**: What to gather/read before starting +4. **Output format**: Examples of expected outputs +5. **Quality criteria**: How to verify the step is complete + +Use the phrase "ask structured questions" when gathering user input - +this triggers proper tooling for interactive prompts. + +## Template System + +Skills are generated using Jinja2 templates in `src/deepwork/templates/`: + +- `claude/skill-job-meta.md.jinja`: Meta-skill template +- `claude/skill-job-step.md.jinja`: Step skill template +- `gemini/skill-job-meta.toml.jinja`: Gemini meta-skill +- `gemini/skill-job-step.toml.jinja`: Gemini step skill + +Template variables include job context, step metadata, inputs, outputs, +hooks, quality criteria, and workflow position. + +## Platform Adapters + +The `AgentAdapter` class abstracts platform differences: + +- `ClaudeAdapter`: Claude Code with markdown skills in `.claude/skills/` +- `GeminiAdapter`: Gemini CLI with TOML skills in `.gemini/skills/` + +Adapters handle: +- Skill filename patterns +- Hook event name mapping (e.g., `after_agent` -> `Stop` for Claude) +- Settings file management +- Permission syncing + +## Parser Dataclasses + +The `parser.py` module defines the job structure: + +- `JobDefinition`: Top-level job with name, version, steps, workflows +- `Step`: Individual step with inputs, outputs, hooks, dependencies +- `StepInput`: User parameter or file input +- `OutputSpec`: Output file optionally with doc_spec reference +- `HookAction`: Hook configuration (prompt, prompt_file, or script) +- `Workflow`: Named step sequence +- `WorkflowStepEntry`: Sequential or concurrent step group + +## Validation Rules + +The parser validates: +- Dependencies reference existing steps +- No circular dependencies +- File inputs reference steps in dependencies +- Workflow steps exist +- No duplicate workflow names +- Doc spec files exist (when referenced) + +## Common Patterns + +**Creating a new job**: +1. Run `/deepwork_jobs` (or `/deepwork_jobs.define`) +2. Answer structured questions about your workflow +3. Review generated job.yml +4. Run `/deepwork_jobs.implement` to generate step files +5. Run `deepwork sync` to create skills + +**Adding a step to existing job**: +1. Edit `.deepwork/jobs/[job_name]/job.yml` +2. Add step definition with required fields +3. Create instructions file in `steps/` +4. Update workflow if applicable +5. Run `deepwork sync` + +**Debugging sync issues**: +- Check job.yml syntax with a YAML validator +- Verify step IDs match filenames +- Ensure dependencies form valid DAG +- Check instructions files exist + +--- + +## Topics + +Detailed documentation on specific subjects within this domain. + +$(deepwork topics --expert "deepwork-jobs") + +--- + +## Learnings + +Hard-fought insights from real experiences. + +$(deepwork learnings --expert "deepwork-jobs") \ No newline at end of file diff --git a/.claude/agents/dwe_experts.md b/.claude/agents/dwe_experts.md new file mode 100644 index 00000000..a86d360e --- /dev/null +++ b/.claude/agents/dwe_experts.md @@ -0,0 +1,210 @@ +--- +name: experts +description: "DeepWork experts system - creating, organizing, and evolving domain knowledge collections that auto-improve through topics and learnings." +--- + +# DeepWork Experts System + +You are an expert on the DeepWork experts system - the framework for building +auto-improving collections of domain knowledge. + +## Core Concepts + +**Experts** are structured knowledge repositories that grow smarter over time. +Each expert represents deep knowledge in a specific domain and consists of: + +- **Core expertise**: Foundational knowledge captured in expert.yml +- **Topics**: Detailed documentation on specific subjects +- **Learnings**: Hard-fought insights from real experiences + +## When to Create an Expert + +Create an expert when: +- You have recurring work in a specific domain +- Knowledge is scattered and needs consolidation +- You want to capture learnings that would otherwise be lost +- A domain has enough depth to warrant structured documentation + +Do NOT create an expert for: +- One-off tasks with no future relevance +- Domains too broad to be actionable (e.g., "Programming") +- Topics better served by external documentation + +## Expert Structure + +Experts live in `.deepwork/experts/[folder-name]/`: + +``` +.deepwork/experts/ +└── rails_activejob/ + ├── expert.yml + ├── topics/ + │ └── retry_handling.md + └── learnings/ + └── job_errors_not_going_to_sentry.md +``` + +The **expert name** derives from the folder name with spaces/underscores becoming +dashes: `rails_activejob` → `rails-activejob`. + +## Writing Good expert.yml + +The expert.yml has two key fields: + +### discovery_description +A concise description (1-3 sentences) that helps the system decide when to +invoke this expert. Be specific about the domain and capabilities. + +Good: "Ruby on Rails ActiveJob - background job processing, retries, queues, +and error handling in Rails applications." + +Bad: "Helps with Rails stuff." + +### full_expertise +The core knowledge payload (~5 pages max). Structure it as: + +1. **Identity statement**: "You are an expert on..." +2. **Core concepts**: Key ideas and mental models +3. **Common patterns**: Typical approaches and solutions +4. **Pitfalls to avoid**: Known gotchas and mistakes +5. **Decision frameworks**: How to choose between options + +Write in second person ("You should...") as this becomes agent instructions. + +## Writing Good Topics + +Topics are deep dives into specific subjects within the domain. + +### When to create a topic +- Subject needs more detail than fits in full_expertise +- You find yourself repeatedly explaining something +- A subject has enough nuance to warrant dedicated documentation + +### Topic file structure +```markdown +--- +name: Retry Handling +keywords: + - retry + - exponential backoff + - dead letter queue +last_updated: 2025-01-15 +--- + +[Detailed content here] +``` + +### Keyword guidelines +- Use topic-specific terms only +- Avoid broad domain terms (don't use "Rails" in a Rails expert's topics) +- Include synonyms and related terms users might search for +- 3-7 keywords is typical + +## Writing Good Learnings + +Learnings capture hard-fought insights from real experiences - like mini +retrospectives that prevent repeating mistakes. + +### When to create a learning +- You solved a non-obvious problem +- A debugging session revealed unexpected behavior +- You discovered something that contradicts common assumptions +- Future-you would benefit from this context + +### Learning file structure +```markdown +--- +name: Job errors not going to Sentry +last_updated: 2025-01-20 +summarized_result: | + Sentry changed their standard gem for hooking into jobs. + SolidQueue still worked but ActiveJobKubernetes did not. +--- + +## Context +What was happening and why it mattered... + +## Investigation +What you tried and what you discovered... + +## Resolution +How you fixed it and why that worked... + +## Key Takeaway +The generalizable insight for future reference... +``` + +### summarized_result guidelines +- 1-3 sentences capturing the key finding +- Should be useful even without reading the full body +- Focus on the "what" not the "how" + +## CLI Commands + +### Listing topics +```bash +deepwork topics --expert "expert-name" +``` +Returns markdown list with links, keywords, sorted by last_updated. + +### Listing learnings +```bash +deepwork learnings --expert "expert-name" +``` +Returns markdown list with links, summaries, sorted by last_updated. + +## How Experts Become Agents + +Running `deepwork sync` generates Claude agents in `.claude/agents/`: + +- Filename: `dwe_[expert-name].md` +- Agent name: `[expert-name]` +- Body: full_expertise + dynamic topic/learning lists + +The dynamic embedding ensures agents always access current topics and learnings: +``` +$(deepwork topics --expert "expert-name") +$(deepwork learnings --expert "expert-name") +``` + +## Evolution Strategy + +Experts should evolve through use: + +1. **Start minimal**: Begin with core expertise, add topics/learnings as needed +2. **Capture immediately**: Document learnings right after solving problems +3. **Refine periodically**: Review and consolidate as patterns emerge +4. **Prune actively**: Remove outdated content, merge redundant topics + +## Naming Conventions + +### Expert folders +- Use lowercase with underscores: `rails_activejob`, `social_marketing` +- Be specific enough to be useful: `react_hooks` not just `react` +- Avoid redundant words: `activejob` not `activejob_expert` + +### Topic files +- Use lowercase with underscores: `retry_handling.md` +- Name describes the subject: `queue_configuration.md` +- Filenames are organizational only - the `name` frontmatter is displayed + +### Learning files +- Use lowercase with underscores: `job_errors_not_going_to_sentry.md` +- Name captures the problem or discovery +- Can be longer/descriptive since they're not referenced programmatically + +--- + +## Topics + +Detailed documentation on specific subjects within this domain. + +$(deepwork topics --expert "experts") + +--- + +## Learnings + +Hard-fought insights from real experiences. + +$(deepwork learnings --expert "experts") \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json index cf4e3c4c..cc18d55b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,8 @@ { "permissions": { + "deny": [ + "Bash(gh pr merge:*)" + ], "allow": [ "WebFetch(domain:code.claude.com)", "WebFetch(domain:www.anthropic.com)", diff --git a/.deepwork/experts/deepwork_jobs/expert.yml b/.deepwork/experts/deepwork_jobs/expert.yml new file mode 100644 index 00000000..d84fee24 --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/expert.yml @@ -0,0 +1,285 @@ +discovery_description: | + DeepWork Jobs system - defining, implementing, and syncing multi-step AI workflows. + Covers job.yml schema, step instructions, skill generation, hooks, and CLI commands. + +full_expertise: | + # DeepWork Jobs System + + You are an expert on the DeepWork Jobs system - the framework for building + reusable, multi-step AI workflows that integrate with AI coding assistants. + + ## Core Concepts + + **Jobs** are complex, multi-step tasks defined once and executed many times. + Each job consists of: + + - **job.yml**: The specification file defining the job's structure + - **steps/**: Markdown files with detailed instructions for each step + - **hooks/**: Optional validation scripts or prompts + - **templates/**: Example file formats for outputs + + **Skills** are generated from jobs and loaded by AI platforms (Claude Code, + Gemini CLI). Each step becomes a slash command the user can invoke. + + ## Job Definition Structure + + Jobs live in `.deepwork/jobs/[job_name]/`: + + ``` + .deepwork/jobs/ + └── competitive_research/ + ├── job.yml + ├── steps/ + │ ├── identify_competitors.md + │ └── research_competitors.md + ├── hooks/ + │ └── validate_research.sh + └── templates/ + └── competitor_profile.md + ``` + + ## The job.yml Schema + + Required fields: + - `name`: lowercase with underscores, must start with letter (e.g., `competitive_research`) + - `version`: semantic versioning X.Y.Z (e.g., `1.0.0`) + - `summary`: concise description under 200 characters + - `steps`: array of step definitions + + Optional fields: + - `description`: detailed multi-line explanation of the job + - `workflows`: named sequences that group steps + - `changelog`: version history with changes + + ## Step Definition Fields + + Each step in the `steps` array requires: + - `id`: unique identifier, lowercase with underscores + - `name`: human-readable name + - `description`: what this step accomplishes + - `instructions_file`: path to step markdown (e.g., `steps/identify.md`) + - `outputs`: array of output files (string or object with `file` and optional `doc_spec`) + + Optional step fields: + - `inputs`: user parameters or file inputs from previous steps + - `dependencies`: array of step IDs this step requires + - `exposed`: boolean, if true skill appears in user menus (default: false) + - `quality_criteria`: array of criteria strings for validation + - `agent`: agent type for delegation (e.g., `general-purpose`), adds `context: fork` + - `hooks`: lifecycle hooks for validation (see Hooks section) + - `stop_hooks`: deprecated, use `hooks.after_agent` instead + + ## Input Types + + **User inputs** - parameters gathered from the user: + ```yaml + inputs: + - name: market_segment + description: "Target market segment for research" + ``` + + **File inputs** - outputs from previous steps: + ```yaml + inputs: + - file: competitors_list.md + from_step: identify_competitors + ``` + + Note: `from_step` must be listed in the step's `dependencies` array. + + ## Output Types + + **Simple output** (string): + ```yaml + outputs: + - competitors_list.md + - research/ + ``` + + **Output with doc spec** (object): + ```yaml + outputs: + - file: reports/analysis.md + doc_spec: .deepwork/doc_specs/analysis_report.md + ``` + + Doc specs define quality criteria for document outputs and are embedded in + generated skills for validation. + + ## Workflows + + Workflows group steps into named sequences. Steps not in any workflow are + "standalone" and can be run independently. + + ```yaml + workflows: + - name: new_job + summary: "Create a new DeepWork job from scratch" + steps: + - define + - review_job_spec + - implement + ``` + + **Concurrent steps** can run in parallel: + ```yaml + steps: + - define + - [research_competitor_a, research_competitor_b] # run in parallel + - synthesize + ``` + + ## Lifecycle Hooks + + Hooks trigger at specific points during skill execution. + + **Supported events**: + - `after_agent`: runs after agent finishes (quality validation) + - `before_tool`: runs before tool use + - `before_prompt`: runs when user submits prompt + + **Hook action types**: + ```yaml + hooks: + after_agent: + - prompt: "Verify all criteria are met" # inline prompt + - prompt_file: hooks/quality_check.md # prompt from file + - script: hooks/run_tests.sh # shell script + ``` + + Note: Claude Code currently only supports script hooks. Prompt hooks are + parsed but not executed (documented limitation). + + ## Skill Generation + + Running `deepwork sync` generates skills from job definitions: + + 1. Parses all `job.yml` files in `.deepwork/jobs/` + 2. For each job, generates a **meta-skill** (entry point) and **step skills** + 3. Writes to platform-specific directories (e.g., `.claude/skills/`) + + **Claude Code skill structure**: + - Meta-skill: `.claude/skills/[job_name]/SKILL.md` + - Step skill: `.claude/skills/[job_name].[step_id]/SKILL.md` + + **Gemini CLI skill structure**: + - Meta-skill: `.gemini/skills/[job_name]/index.toml` + - Step skill: `.gemini/skills/[job_name]/[step_id].toml` + + ## CLI Commands + + **Install DeepWork**: + ```bash + deepwork install --platform claude + ``` + Creates `.deepwork/` structure, copies standard jobs, runs sync. + + **Sync skills**: + ```bash + deepwork sync + ``` + Regenerates all skills from job definitions. + + **Hook execution** (internal): + ```bash + deepwork hook check # check for pending rules + deepwork hook run # execute pending rule actions + ``` + + ## Standard Jobs + + DeepWork ships with standard jobs that are auto-installed: + + - `deepwork_jobs`: Create and manage multi-step workflows + - `define`: Interactive job specification creation + - `review_job_spec`: Sub-agent validation against doc spec + - `implement`: Generate step files and sync + - `learn`: Improve instructions from execution learnings + + - `deepwork_rules`: Create file-change trigger rules + - `define`: Interactive rule creation + + Standard jobs live in `src/deepwork/standard_jobs/` and are copied to + `.deepwork/jobs/` during installation. + + ## Writing Step Instructions + + Step instruction files should include: + + 1. **Objective**: Clear statement of what this step accomplishes + 2. **Task**: Detailed process with numbered steps + 3. **Inputs section**: What to gather/read before starting + 4. **Output format**: Examples of expected outputs + 5. **Quality criteria**: How to verify the step is complete + + Use the phrase "ask structured questions" when gathering user input - + this triggers proper tooling for interactive prompts. + + ## Template System + + Skills are generated using Jinja2 templates in `src/deepwork/templates/`: + + - `claude/skill-job-meta.md.jinja`: Meta-skill template + - `claude/skill-job-step.md.jinja`: Step skill template + - `gemini/skill-job-meta.toml.jinja`: Gemini meta-skill + - `gemini/skill-job-step.toml.jinja`: Gemini step skill + + Template variables include job context, step metadata, inputs, outputs, + hooks, quality criteria, and workflow position. + + ## Platform Adapters + + The `AgentAdapter` class abstracts platform differences: + + - `ClaudeAdapter`: Claude Code with markdown skills in `.claude/skills/` + - `GeminiAdapter`: Gemini CLI with TOML skills in `.gemini/skills/` + + Adapters handle: + - Skill filename patterns + - Hook event name mapping (e.g., `after_agent` -> `Stop` for Claude) + - Settings file management + - Permission syncing + + ## Parser Dataclasses + + The `parser.py` module defines the job structure: + + - `JobDefinition`: Top-level job with name, version, steps, workflows + - `Step`: Individual step with inputs, outputs, hooks, dependencies + - `StepInput`: User parameter or file input + - `OutputSpec`: Output file optionally with doc_spec reference + - `HookAction`: Hook configuration (prompt, prompt_file, or script) + - `Workflow`: Named step sequence + - `WorkflowStepEntry`: Sequential or concurrent step group + + ## Validation Rules + + The parser validates: + - Dependencies reference existing steps + - No circular dependencies + - File inputs reference steps in dependencies + - Workflow steps exist + - No duplicate workflow names + - Doc spec files exist (when referenced) + + ## Common Patterns + + **Creating a new job**: + 1. Run `/deepwork_jobs` (or `/deepwork_jobs.define`) + 2. Answer structured questions about your workflow + 3. Review generated job.yml + 4. Run `/deepwork_jobs.implement` to generate step files + 5. Run `deepwork sync` to create skills + + **Adding a step to existing job**: + 1. Edit `.deepwork/jobs/[job_name]/job.yml` + 2. Add step definition with required fields + 3. Create instructions file in `steps/` + 4. Update workflow if applicable + 5. Run `deepwork sync` + + **Debugging sync issues**: + - Check job.yml syntax with a YAML validator + - Verify step IDs match filenames + - Ensure dependencies form valid DAG + - Check instructions files exist diff --git a/.deepwork/experts/deepwork_jobs/learnings/.gitkeep b/.deepwork/experts/deepwork_jobs/learnings/.gitkeep new file mode 100644 index 00000000..8c629330 --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/learnings/.gitkeep @@ -0,0 +1 @@ +# This file ensures the learnings directory exists in version control. diff --git a/.deepwork/experts/deepwork_jobs/learnings/prompt_hooks_not_executed.md b/.deepwork/experts/deepwork_jobs/learnings/prompt_hooks_not_executed.md new file mode 100644 index 00000000..43ae0931 --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/learnings/prompt_hooks_not_executed.md @@ -0,0 +1,50 @@ +--- +name: Prompt Hooks Not Executed in Claude Code +last_updated: 2025-01-30 +summarized_result: | + Claude Code parses but does not execute prompt-based stop hooks. + Only script/command hooks actually run. Use quality_criteria for validation. +--- + +## Context + +When implementing quality validation for job steps, developers often try +to use inline `prompt` or `prompt_file` hooks for validation. + +## Investigation + +Testing revealed that Claude Code's hook system only executes `command` type +hooks in the settings.json hooks configuration. Prompt-based hooks are parsed +by DeepWork but not rendered into the skill's hook frontmatter because they +would not be executed. + +The template code explicitly filters: +```jinja +{%- set script_hooks = event_hooks | selectattr("type", "equalto", "script") | list %} +``` + +## Resolution + +Two recommended approaches for quality validation: + +1. **Use `quality_criteria` field** (preferred): + ```yaml + quality_criteria: + - "Each competitor has description" + - "Sources are cited" + ``` + This generates instructions for sub-agent review, which works reliably. + +2. **Use script hooks** for objective validation: + ```yaml + hooks: + after_agent: + - script: hooks/run_tests.sh + ``` + Scripts actually execute and can fail the step. + +## Key Takeaway + +For subjective quality checks, use the `quality_criteria` field which triggers +sub-agent review. For objective checks (tests, linting), use script hooks. +Avoid prompt hooks until Claude Code supports them. diff --git a/.deepwork/experts/deepwork_jobs/topics/hooks_and_validation.md b/.deepwork/experts/deepwork_jobs/topics/hooks_and_validation.md new file mode 100644 index 00000000..d335dcfb --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/topics/hooks_and_validation.md @@ -0,0 +1,197 @@ +--- +name: Hooks and Validation +keywords: + - hooks + - validation + - quality + - stop_hooks + - lifecycle +last_updated: 2025-01-30 +--- + +# Hooks and Validation + +How to use lifecycle hooks for quality validation in DeepWork jobs. + +## Lifecycle Hook Events + +DeepWork supports three generic hook events: + +| Event | When it fires | Use case | +|-------|---------------|----------| +| `after_agent` | After agent finishes responding | Quality validation, output verification | +| `before_tool` | Before agent uses a tool | Pre-tool checks, validation | +| `before_prompt` | When user submits a prompt | Session setup, context loading | + +## Platform Mapping + +Hooks are mapped to platform-specific event names: + +| Generic | Claude Code | +|---------|-------------| +| `after_agent` | `Stop`, `SubagentStop` | +| `before_tool` | `PreToolUse` | +| `before_prompt` | `UserPromptSubmit` | + +Note: Gemini CLI does not support skill-level hooks (only global hooks). + +## Hook Action Types + +### Inline Prompt + +Best for simple validation criteria: + +```yaml +hooks: + after_agent: + - prompt: | + Verify the output meets these criteria: + 1. Contains at least 5 competitors + 2. Each has a description + 3. Sources are cited +``` + +**Important**: Prompt hooks are currently parsed but NOT executed by Claude Code. +This is a documented limitation. Use script hooks for actual enforcement. + +### Prompt File + +For detailed/reusable criteria: + +```yaml +hooks: + after_agent: + - prompt_file: hooks/quality_check.md +``` + +The prompt file is read and its content is embedded in the generated skill. +Same limitation applies - parsed but not executed. + +### Script Hook + +For programmatic validation (actually executed): + +```yaml +hooks: + after_agent: + - script: hooks/run_tests.sh +``` + +Scripts are shell scripts that can: +- Run test suites +- Lint output files +- Check for required content +- Validate file formats + +Exit code 0 = pass, non-zero = fail. + +## Script Hook Example + +Create `.deepwork/jobs/[job_name]/hooks/validate.sh`: + +```bash +#!/bin/bash +# Validate research output + +OUTPUT_FILE="research_notes.md" + +if [ ! -f "$OUTPUT_FILE" ]; then + echo "ERROR: $OUTPUT_FILE not found" + exit 1 +fi + +# Check minimum content +LINES=$(wc -l < "$OUTPUT_FILE") +if [ "$LINES" -lt 50 ]; then + echo "ERROR: Output has only $LINES lines, expected at least 50" + exit 1 +fi + +echo "Validation passed" +exit 0 +``` + +Make it executable: +```bash +chmod +x .deepwork/jobs/[job_name]/hooks/validate.sh +``` + +## Combining Multiple Hooks + +```yaml +hooks: + after_agent: + - script: hooks/lint.sh + - script: hooks/run_tests.sh + - prompt: "Verify documentation is complete" +``` + +Hooks run in order. Script hooks are executed; prompt hooks are for reference. + +## Deprecated: stop_hooks + +The `stop_hooks` field is deprecated but still supported: + +```yaml +# Old style (deprecated) +stop_hooks: + - script: hooks/validate.sh + +# New style (preferred) +hooks: + after_agent: + - script: hooks/validate.sh +``` + +Internally, `stop_hooks` are merged into `hooks.after_agent`. + +## Quality Criteria Alternative + +For simple validation, use declarative `quality_criteria` instead of hooks: + +```yaml +steps: + - id: research + quality_criteria: + - "**Data Coverage**: Each competitor has at least 3 data points" + - "**Source Attribution**: All facts are cited" + - "**Relevance**: All competitors are in the target market" +``` + +Quality criteria are rendered in the skill with instructions to use a +sub-agent (Haiku model) for review: + +1. Agent completes work +2. Spawns sub-agent to review against criteria +3. Fixes any issues identified +4. Repeats until sub-agent confirms all criteria pass + +This is the recommended approach for most validation needs - it's more +flexible than scripts and actually works with Claude Code. + +## When to Use Each Approach + +| Approach | When to use | +|----------|-------------| +| `quality_criteria` | Most cases - subjective quality checks | +| Script hooks | Objective checks (tests, linting, format validation) | +| Prompt hooks | Documentation only (not currently executed) | + +## Generated Skill Output + +For script hooks, the generated skill includes: + +```yaml +hooks: + Stop: + - hooks: + - type: command + command: ".deepwork/jobs/job_name/hooks/validate.sh" + SubagentStop: + - hooks: + - type: command + command: ".deepwork/jobs/job_name/hooks/validate.sh" +``` + +Both `Stop` and `SubagentStop` are registered so hooks fire for both +the main agent and any sub-agents. diff --git a/.deepwork/experts/deepwork_jobs/topics/job_yml_schema.md b/.deepwork/experts/deepwork_jobs/topics/job_yml_schema.md new file mode 100644 index 00000000..657e486e --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/topics/job_yml_schema.md @@ -0,0 +1,153 @@ +--- +name: job.yml Schema Reference +keywords: + - schema + - yaml + - validation + - fields + - structure +last_updated: 2025-01-30 +--- + +# job.yml Schema Reference + +Complete reference for the job.yml specification file. + +## Required Fields + +### name +- Type: string +- Pattern: `^[a-z][a-z0-9_]*$` +- Must start with lowercase letter, can contain lowercase letters, numbers, underscores +- Examples: `competitive_research`, `monthly_report`, `feature_dev` + +### version +- Type: string +- Pattern: `^\d+\.\d+\.\d+$` +- Semantic versioning format +- Examples: `1.0.0`, `2.1.3` + +### summary +- Type: string +- Length: 1-200 characters +- Brief one-line description of the job +- Used in skill descriptions and menus + +### steps +- Type: array +- Minimum items: 1 +- Each item is a step definition object + +## Optional Fields + +### description +- Type: string +- Multi-line detailed explanation +- Included in generated skill files for context +- Good for: problem solved, process overview, target users + +### workflows +- Type: array +- Named sequences grouping steps +- See Workflow Schema section + +### changelog +- Type: array +- Version history entries +- Each entry has `version` (string) and `changes` (string) + +## Step Schema + +### Required Step Fields + +```yaml +steps: + - id: identify_competitors # unique, lowercase_underscores + name: "Identify Competitors" # human-readable + description: "Find and list..." # what it does + instructions_file: steps/identify.md # path to instructions + outputs: # at least one output + - competitors_list.md +``` + +### Optional Step Fields + +```yaml + - id: research + # ... required fields ... + inputs: + - name: market_segment + description: "Target market" + - file: competitors_list.md + from_step: identify_competitors + dependencies: + - identify_competitors + exposed: true # show in user menus + quality_criteria: + - "All competitors have descriptions" + - "Sources are cited" + agent: general-purpose # delegate to agent type + hooks: + after_agent: + - script: hooks/validate.sh +``` + +## Output Formats + +Simple string: +```yaml +outputs: + - report.md + - data/ +``` + +With doc spec: +```yaml +outputs: + - file: reports/analysis.md + doc_spec: .deepwork/doc_specs/analysis.md +``` + +Doc spec path must match pattern: `^\.deepwork/doc_specs/[a-z][a-z0-9_-]*\.md$` + +## Workflow Schema + +```yaml +workflows: + - name: full_analysis + summary: "Complete competitive analysis workflow" + steps: + - identify + - research + - analyze + - report +``` + +Concurrent steps: +```yaml +steps: + - identify + - [research_a, research_b] # parallel execution + - synthesize +``` + +## Hook Schema + +```yaml +hooks: + after_agent: + - prompt: "Verify criteria are met" # inline + - prompt_file: hooks/check.md # from file + - script: hooks/run_tests.sh # shell script +``` + +Each hook action must have exactly one of: `prompt`, `prompt_file`, or `script`. + +## Validation Rules + +1. Step IDs must be unique within the job +2. Dependencies must reference existing step IDs +3. File inputs with `from_step` must have that step in dependencies +4. No circular dependencies allowed +5. Workflow steps must reference existing step IDs +6. No duplicate steps within a workflow diff --git a/.deepwork/experts/deepwork_jobs/topics/skill_generation.md b/.deepwork/experts/deepwork_jobs/topics/skill_generation.md new file mode 100644 index 00000000..9f210b80 --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/topics/skill_generation.md @@ -0,0 +1,155 @@ +--- +name: Skill Generation +keywords: + - sync + - templates + - jinja + - skills + - commands +last_updated: 2025-01-30 +--- + +# Skill Generation + +How DeepWork generates platform-specific skills from job definitions. + +## The Sync Process + +Running `deepwork sync`: + +1. Loads `config.yml` to get configured platforms +2. Discovers all job directories in `.deepwork/jobs/` +3. Parses each `job.yml` into `JobDefinition` dataclass +4. For each job and platform, generates skills using Jinja2 templates +5. Writes skill files to platform-specific directories +6. Syncs hooks and permissions to platform settings + +## Generated Skill Types + +### Meta-Skill (Job Entry Point) +- One per job +- Routes user intent to appropriate step +- Lists available workflows and standalone skills +- Claude: `.claude/skills/[job_name]/SKILL.md` +- Gemini: `.gemini/skills/[job_name]/index.toml` + +### Step Skills +- One per step +- Contains full instructions, inputs, outputs +- Includes workflow position and navigation +- Claude: `.claude/skills/[job_name].[step_id]/SKILL.md` +- Gemini: `.gemini/skills/[job_name]/[step_id].toml` + +## Template Variables + +Templates receive rich context including: + +**Job Context**: +- `job_name`, `job_version`, `job_summary`, `job_description` +- `total_steps`, `has_workflows`, `workflows`, `standalone_steps` + +**Step Context**: +- `step_id`, `step_name`, `step_description`, `step_number` +- `instructions_content` (full markdown from instructions file) +- `user_inputs`, `file_inputs`, `outputs`, `dependencies` +- `is_standalone`, `exposed`, `agent` + +**Workflow Context** (when step is in workflow): +- `workflow_name`, `workflow_summary` +- `workflow_step_number`, `workflow_total_steps` +- `workflow_next_step`, `workflow_prev_step` + +**Quality & Hooks**: +- `quality_criteria` (array of strings) +- `hooks` (dict by platform event name) +- `stop_hooks` (backward compat: after_agent hooks) + +## Template Location + +Templates live in `src/deepwork/templates/[platform]/`: + +``` +templates/ +├── claude/ +│ ├── skill-job-meta.md.jinja +│ ├── skill-job-step.md.jinja +│ └── settings.json +└── gemini/ + ├── skill-job-meta.toml.jinja + └── skill-job-step.toml.jinja +``` + +## Platform Differences + +**Claude Code**: +- Markdown format with YAML frontmatter +- Uses `---` delimited frontmatter for metadata +- Hook events: `Stop`, `SubagentStop`, `PreToolUse`, `UserPromptSubmit` +- Skills directory: `.claude/skills/` + +**Gemini CLI**: +- TOML format +- Uses namespace separators via directories +- No skill-level hooks (global only) +- Skills directory: `.gemini/skills/` + +## The Generator Class + +`SkillGenerator` in `core/generator.py`: + +```python +generator = SkillGenerator() + +# Generate all skills for a job +paths = generator.generate_all_skills( + job=job_definition, + adapter=claude_adapter, + output_dir=project_path, + project_root=project_path +) + +# Generate single step skill +path = generator.generate_step_skill( + job=job_def, + step=step, + adapter=adapter, + output_dir=output_dir +) +``` + +## Doc Spec Integration + +When outputs reference doc specs, the generator: + +1. Loads doc spec file using `DocSpecParser` +2. Extracts quality criteria, target audience, example document +3. Includes this context in template variables +4. Generated skill displays doc spec requirements inline + +## Skill Frontmatter + +Claude skills have YAML frontmatter: + +```yaml +--- +name: job_name.step_id +description: "Step description" +user-invocable: false # when exposed: false +context: fork # when agent is specified +agent: general-purpose +hooks: + Stop: + - hooks: + - type: command + command: ".deepwork/jobs/job_name/hooks/validate.sh" +--- +``` + +## Permissions Syncing + +After generating skills, adapters sync permissions: + +- Base permissions (read `.deepwork/tmp/**`) +- Skill invocation permissions (`Skill(job_name)`, `Skill(job_name.step_id)`) + +Permissions are added to `.claude/settings.json` in the `permissions.allow` array. diff --git a/.deepwork/experts/deepwork_jobs/topics/step_instructions.md b/.deepwork/experts/deepwork_jobs/topics/step_instructions.md new file mode 100644 index 00000000..0c45b0d9 --- /dev/null +++ b/.deepwork/experts/deepwork_jobs/topics/step_instructions.md @@ -0,0 +1,187 @@ +--- +name: Writing Step Instructions +keywords: + - instructions + - steps + - markdown + - writing + - best practices +last_updated: 2025-01-30 +--- + +# Writing Step Instructions + +Best practices for writing effective step instruction files. + +## File Location + +Step instructions live in `.deepwork/jobs/[job_name]/steps/[step_id].md`. + +The path is specified in job.yml via `instructions_file`: +```yaml +steps: + - id: identify_competitors + instructions_file: steps/identify_competitors.md +``` + +## Recommended Structure + +### 1. Objective Section + +Start with a clear objective statement: + +```markdown +## Objective + +Create a comprehensive list of competitors in the target market by +systematically researching industry players and their offerings. +``` + +### 2. Task Section + +Detailed step-by-step process: + +```markdown +## Task + +### Step 1: Understand the Market + +Ask structured questions to gather context: +- What industry or market segment? +- What product category? +- Geographic scope? + +### Step 2: Research Sources + +Search for competitors using: +1. Industry databases and reports +2. Google searches for market leaders +3. Customer review sites +... +``` + +### 3. Input Handling + +If the step has user inputs, explicitly request them: + +```markdown +## Inputs + +Before proceeding, gather the following from the user: +- **market_segment**: Target market for analysis +- **product_category**: Specific product/service category + +Use the AskUserQuestion tool to collect these inputs. +``` + +### 4. Output Format + +Show what good output looks like: + +```markdown +## Output Format + +Create `competitors_list.md` with the following structure: + +```markdown +# Competitors List + +## Market: [market_segment] + +### Competitor 1: Acme Corp +- **Website**: acme.com +- **Description**: Brief overview +- **Key Products**: Product A, Product B +``` + +### 5. Quality Criteria + +Define how to verify the step is complete: + +```markdown +## Quality Criteria + +- At least 5 competitors identified +- Each competitor has description and key products +- Sources are cited for all information +- List is relevant to the specified market +``` + +## Key Phrases + +### "Ask structured questions" + +When gathering user input, always use this phrase: + +```markdown +Ask structured questions to understand the user's requirements: +1. What is your target market? +2. Who are your main competitors? +``` + +This phrase triggers the AskUserQuestion tool which provides a better +user experience with clear options. + +### "Use the Skill tool to invoke" + +For workflow continuation: + +```markdown +## On Completion + +1. Verify outputs are created +2. Use the Skill tool to invoke `/job_name.next_step` +``` + +## Supplementary Files + +Place additional reference materials in `steps/`: + +``` +steps/ +├── identify_competitors.md +├── research_competitors.md +└── competitor_template.md # supplementary reference +``` + +Reference them using full paths: +```markdown +Use the template in `.deepwork/jobs/competitive_research/steps/competitor_template.md` +to structure each competitor profile. +``` + +## Anti-Patterns to Avoid + +### Vague Instructions +Bad: "Research the competitors" +Good: "Search each competitor's website, LinkedIn, and review sites to gather..." + +### Missing Outputs +Bad: "Create a report" +Good: "Create `research_notes.md` with sections for each competitor..." + +### Skipping Inputs +Bad: Assume inputs are available +Good: "Read `competitors_list.md` from the previous step. If it doesn't exist..." + +### Generic Quality Criteria +Bad: "Output should be good quality" +Good: "Each competitor profile includes at least 3 data points with sources" + +## Instruction Length + +- Keep instructions focused and actionable +- Aim for 1-3 pages of content +- Extract lengthy examples into separate template files +- Use bullet points over paragraphs where appropriate + +## Variables in Instructions + +Instructions can reference job-level context. The generated skill includes: +- Job name and description +- Step position in workflow +- Dependencies and next steps +- All inputs and outputs + +You don't need to repeat this metadata in instructions - it's included +automatically in the generated skill. diff --git a/.deepwork/experts/experts/expert.yml b/.deepwork/experts/experts/expert.yml new file mode 100644 index 00000000..d3e9c385 --- /dev/null +++ b/.deepwork/experts/experts/expert.yml @@ -0,0 +1,194 @@ +discovery_description: | + DeepWork experts system - creating, organizing, and evolving domain knowledge + collections that auto-improve through topics and learnings. + +full_expertise: | + # DeepWork Experts System + + You are an expert on the DeepWork experts system - the framework for building + auto-improving collections of domain knowledge. + + ## Core Concepts + + **Experts** are structured knowledge repositories that grow smarter over time. + Each expert represents deep knowledge in a specific domain and consists of: + + - **Core expertise**: Foundational knowledge captured in expert.yml + - **Topics**: Detailed documentation on specific subjects + - **Learnings**: Hard-fought insights from real experiences + + ## When to Create an Expert + + Create an expert when: + - You have recurring work in a specific domain + - Knowledge is scattered and needs consolidation + - You want to capture learnings that would otherwise be lost + - A domain has enough depth to warrant structured documentation + + Do NOT create an expert for: + - One-off tasks with no future relevance + - Domains too broad to be actionable (e.g., "Programming") + - Topics better served by external documentation + + ## Expert Structure + + Experts live in `.deepwork/experts/[folder-name]/`: + + ``` + .deepwork/experts/ + └── rails_activejob/ + ├── expert.yml + ├── topics/ + │ └── retry_handling.md + └── learnings/ + └── job_errors_not_going_to_sentry.md + ``` + + The **expert name** derives from the folder name with spaces/underscores becoming + dashes: `rails_activejob` → `rails-activejob`. + + ## Writing Good expert.yml + + The expert.yml has two key fields: + + ### discovery_description + A concise description (1-3 sentences) that helps the system decide when to + invoke this expert. Be specific about the domain and capabilities. + + Good: "Ruby on Rails ActiveJob - background job processing, retries, queues, + and error handling in Rails applications." + + Bad: "Helps with Rails stuff." + + ### full_expertise + The core knowledge payload (~5 pages max). Structure it as: + + 1. **Identity statement**: "You are an expert on..." + 2. **Core concepts**: Key ideas and mental models + 3. **Common patterns**: Typical approaches and solutions + 4. **Pitfalls to avoid**: Known gotchas and mistakes + 5. **Decision frameworks**: How to choose between options + + Write in second person ("You should...") as this becomes agent instructions. + + ## Writing Good Topics + + Topics are deep dives into specific subjects within the domain. + + ### When to create a topic + - Subject needs more detail than fits in full_expertise + - You find yourself repeatedly explaining something + - A subject has enough nuance to warrant dedicated documentation + + ### Topic file structure + ```markdown + --- + name: Retry Handling + keywords: + - retry + - exponential backoff + - dead letter queue + last_updated: 2025-01-15 + --- + + [Detailed content here] + ``` + + ### Keyword guidelines + - Use topic-specific terms only + - Avoid broad domain terms (don't use "Rails" in a Rails expert's topics) + - Include synonyms and related terms users might search for + - 3-7 keywords is typical + + ## Writing Good Learnings + + Learnings capture hard-fought insights from real experiences - like mini + retrospectives that prevent repeating mistakes. + + ### When to create a learning + - You solved a non-obvious problem + - A debugging session revealed unexpected behavior + - You discovered something that contradicts common assumptions + - Future-you would benefit from this context + + ### Learning file structure + ```markdown + --- + name: Job errors not going to Sentry + last_updated: 2025-01-20 + summarized_result: | + Sentry changed their standard gem for hooking into jobs. + SolidQueue still worked but ActiveJobKubernetes did not. + --- + + ## Context + What was happening and why it mattered... + + ## Investigation + What you tried and what you discovered... + + ## Resolution + How you fixed it and why that worked... + + ## Key Takeaway + The generalizable insight for future reference... + ``` + + ### summarized_result guidelines + - 1-3 sentences capturing the key finding + - Should be useful even without reading the full body + - Focus on the "what" not the "how" + + ## CLI Commands + + ### Listing topics + ```bash + deepwork topics --expert "expert-name" + ``` + Returns markdown list with links, keywords, sorted by last_updated. + + ### Listing learnings + ```bash + deepwork learnings --expert "expert-name" + ``` + Returns markdown list with links, summaries, sorted by last_updated. + + ## How Experts Become Agents + + Running `deepwork sync` generates Claude agents in `.claude/agents/`: + + - Filename: `dwe_[expert-name].md` + - Agent name: `[expert-name]` + - Body: full_expertise + dynamic topic/learning lists + + The dynamic embedding ensures agents always access current topics and learnings: + ``` + $(deepwork topics --expert "expert-name") + $(deepwork learnings --expert "expert-name") + ``` + + ## Evolution Strategy + + Experts should evolve through use: + + 1. **Start minimal**: Begin with core expertise, add topics/learnings as needed + 2. **Capture immediately**: Document learnings right after solving problems + 3. **Refine periodically**: Review and consolidate as patterns emerge + 4. **Prune actively**: Remove outdated content, merge redundant topics + + ## Naming Conventions + + ### Expert folders + - Use lowercase with underscores: `rails_activejob`, `social_marketing` + - Be specific enough to be useful: `react_hooks` not just `react` + - Avoid redundant words: `activejob` not `activejob_expert` + + ### Topic files + - Use lowercase with underscores: `retry_handling.md` + - Name describes the subject: `queue_configuration.md` + - Filenames are organizational only - the `name` frontmatter is displayed + + ### Learning files + - Use lowercase with underscores: `job_errors_not_going_to_sentry.md` + - Name captures the problem or discovery + - Can be longer/descriptive since they're not referenced programmatically diff --git a/.deepwork/experts/experts/learnings/.gitkeep b/.deepwork/experts/experts/learnings/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.deepwork/experts/experts/learnings/keep_experts_focused.md b/.deepwork/experts/experts/learnings/keep_experts_focused.md new file mode 100644 index 00000000..b77186a0 --- /dev/null +++ b/.deepwork/experts/experts/learnings/keep_experts_focused.md @@ -0,0 +1,33 @@ +--- +name: Keep Experts Focused +last_updated: 2025-01-30 +summarized_result: | + Broad experts like "Rails" or "JavaScript" become too large and unfocused. + Better to create specific experts like "Rails ActiveJob" or "React Hooks". +--- + +## Context + +When initially designing the experts system, we considered creating broad, +comprehensive experts that would cover entire technology stacks. + +## Investigation + +Testing showed that broad experts: +- Generated overwhelming amounts of content +- Struggled to provide specific, actionable guidance +- Made it difficult to know which expert to invoke +- Led to duplication across multiple broad experts + +## Resolution + +Adopted a principle of focused experts with clear boundaries: +- Each expert covers a specific domain or technology subset +- The discovery_description clearly indicates scope +- Topics dive deep rather than wide +- Learnings capture domain-specific insights + +## Key Takeaway + +An expert should be narrow enough that you can articulate its scope in 1-2 +sentences. If you can't, it's probably too broad. diff --git a/.deepwork/experts/experts/topics/discovery_descriptions.md b/.deepwork/experts/experts/topics/discovery_descriptions.md new file mode 100644 index 00000000..f68112b9 --- /dev/null +++ b/.deepwork/experts/experts/topics/discovery_descriptions.md @@ -0,0 +1,83 @@ +--- +name: Writing Discovery Descriptions +keywords: + - discovery + - description + - routing + - selection +last_updated: 2025-01-30 +--- + +# Writing Effective Discovery Descriptions + +The `discovery_description` determines when your expert gets invoked. It's the +"elevator pitch" that helps the system route queries to the right expert. + +## Purpose + +Discovery descriptions are used by other parts of the system to decide: +- Whether to suggest this expert for a task +- Which expert to invoke when multiple could apply +- How to present the expert to users + +## Anatomy of a Good Description + +```yaml +discovery_description: | + Ruby on Rails ActiveJob - background job processing including + queue configuration, retry strategies, error handling, and + integration with queue backends like Sidekiq and SolidQueue. +``` + +Components: +1. **Domain identifier**: "Ruby on Rails ActiveJob" +2. **Core capability**: "background job processing" +3. **Specific coverage**: "queue configuration, retry strategies..." +4. **Boundaries**: "integration with queue backends like..." + +## Guidelines + +### Be Specific +Bad: "Helps with background jobs" +Good: "Rails ActiveJob background processing - queues, retries, error handling" + +### Include Key Terms +Include terms users would search for. If someone asks about "Sidekiq retries", +the description should contain those words. + +### Set Boundaries +Indicate what's in and out of scope. "...including X, Y, Z" signals coverage. + +### Keep It Scannable +1-3 sentences max. The system needs to quickly evaluate relevance. + +### Avoid Marketing Speak +Bad: "The ultimate guide to mastering background jobs" +Good: "ActiveJob configuration, error handling, and queue backend integration" + +## Examples + +### Too Vague +```yaml +discovery_description: Helps with Rails development +``` + +### Too Narrow +```yaml +discovery_description: How to configure exponential backoff in ActiveJob +``` + +### Just Right +```yaml +discovery_description: | + Rails ActiveJob expertise - background job processing, queue + configuration, retry strategies, error handling, and integration + with Sidekiq, SolidQueue, and other queue backends. +``` + +## Testing Your Description + +Ask yourself: +1. If I had this problem, would I find this expert? +2. Does it differentiate from similar experts? +3. Can I tell what's covered in 5 seconds? diff --git a/.deepwork/experts/experts/topics/expert_design_patterns.md b/.deepwork/experts/experts/topics/expert_design_patterns.md new file mode 100644 index 00000000..3ce13a61 --- /dev/null +++ b/.deepwork/experts/experts/topics/expert_design_patterns.md @@ -0,0 +1,69 @@ +--- +name: Expert Design Patterns +keywords: + - patterns + - structure + - organization + - best practices +last_updated: 2025-01-30 +--- + +# Expert Design Patterns + +Common patterns for structuring effective experts. + +## The Layered Knowledge Pattern + +Structure expertise from general to specific: + +1. **full_expertise**: Core concepts, decision frameworks, common patterns +2. **topics/**: Deep dives into specific subjects +3. **learnings/**: Concrete experiences and edge cases + +This mirrors how humans learn - start with foundations, then specialize. + +## The Problem-Solution Pattern + +For domains centered around solving problems: + +- **full_expertise**: Problem categories, diagnostic approaches, solution frameworks +- **topics/**: Specific problem types with detailed solutions +- **learnings/**: Real debugging sessions and unexpected fixes + +Works well for: troubleshooting guides, error handling, debugging domains. + +## The Reference Pattern + +For domains with lots of factual information: + +- **full_expertise**: Overview, when to use what, quick reference +- **topics/**: Detailed reference on specific APIs, configs, options +- **learnings/**: Gotchas and undocumented behaviors + +Works well for: API documentation, configuration guides, tool references. + +## The Process Pattern + +For domains with sequential workflows: + +- **full_expertise**: Overall process, decision points, success criteria +- **topics/**: Detailed steps for each phase +- **learnings/**: Process failures and improvements + +Works well for: deployment procedures, review processes, onboarding. + +## Anti-Patterns to Avoid + +### The Kitchen Sink +Cramming everything into full_expertise. If it's over 5 pages, split into topics. + +### The Empty Shell +Creating expert.yml with minimal content and empty topics/learnings folders. +Start with meaningful content or don't create the expert yet. + +### The Stale Expert +Never updating after initial creation. Set a reminder to review quarterly. + +### The Duplicate Expert +Creating overlapping experts. Better to have one comprehensive expert than +several fragmented ones. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9c4dc4..47097cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Meta-skill template renders concurrent steps as "Background Task 1/2/3" with clear instructions - Added `get_step_entry_position_in_workflow()` and `get_concurrent_step_info()` methods to JobDefinition - Full backward compatibility: existing workflows with simple step arrays continue to work + +### Changed + +### Fixed + +### Removed + +## [0.6.0] - 2026-01-30 + +### Added +- **Experts system** for auto-improving collections of domain knowledge + - New `expert.yml` format with `discovery_description` and `full_expertise` fields + - Topics: Detailed documentation in `topics/*.md` with name, keywords, and last_updated frontmatter + - Learnings: Experience-based insights in `learnings/*.md` with summarized_result frontmatter + - Experts live in `.deepwork/experts/[folder-name]/` directory +- New CLI commands for experts: + - `deepwork topics --expert "name"` - Lists topics as markdown, sorted by most recently updated + - `deepwork learnings --expert "name"` - Lists learnings as markdown with summarized results +- Expert agent generation during `deepwork sync`: + - Generates Claude agents in `.claude/agents/dwe_[expert-name].md` + - Agent name format: `[expert-name]` + - Uses dynamic command embedding (`$(deepwork topics ...)`) for always-current content +- Standard "experts" expert shipped with DeepWork documenting the experts system itself + - Includes topics on design patterns and discovery descriptions + - Includes learning on keeping experts focused +- Expert installation during `deepwork install`: + - Standard experts from `src/deepwork/standard/experts/` copied to `.deepwork/experts/` + - Creates `.deepwork/experts/` directory during installation +- Comprehensive test suite for experts (99 new tests) - Agent delegation field for job.yml steps - New `agent` field on steps allows specifying an agent type (e.g., `agent: general-purpose`) - When `agent` is set, generated Claude Code skills automatically include `context: fork` and `agent:` in frontmatter @@ -244,7 +273,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial version. -[Unreleased]: https://github.com/Unsupervisedcom/deepwork/compare/0.5.1...HEAD +[Unreleased]: https://github.com/Unsupervisedcom/deepwork/compare/0.6.0...HEAD +[0.6.0]: https://github.com/Unsupervisedcom/deepwork/releases/tag/0.6.0 [0.5.1]: https://github.com/Unsupervisedcom/deepwork/releases/tag/0.5.1 [0.5.0]: https://github.com/Unsupervisedcom/deepwork/releases/tag/0.5.0 [0.4.2]: https://github.com/anthropics/deepwork/compare/0.4.1...0.4.2 diff --git a/README.md b/README.md index 76a659de..66c51779 100644 --- a/README.md +++ b/README.md @@ -199,12 +199,18 @@ your-project/ ├── .deepwork/ │ ├── config.yml # Platform configuration │ ├── rules/ # Automated rules +│ ├── experts/ # Domain knowledge experts +│ │ └── expert_name/ +│ │ ├── expert.yml # Expert definition +│ │ ├── topics/ # Detailed topic documentation +│ │ └── learnings/ # Experience-based insights │ └── jobs/ # Job definitions │ └── job_name/ │ ├── job.yml # Job metadata │ └── steps/ # Step instructions -├── .claude/ # Generated Claude skills -│ └── skills/ +├── .claude/ # Generated Claude skills and agents +│ ├── skills/ +│ └── agents/ # Expert agents (dwe_*.md) └── deepwork-output/ # Job outputs (gitignored) ``` diff --git a/doc/architecture.md b/doc/architecture.md index f4a2e094..fbbcaaad 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -63,7 +63,7 @@ deepwork/ # DeepWork tool repository │ │ │ └── skill-job-step.md.jinja │ │ ├── gemini/ │ │ └── copilot/ -│ ├── standard_jobs/ # Built-in job definitions +│ ├── standard_jobs/ # Built-in job definitions (legacy location) │ │ ├── deepwork_jobs/ │ │ │ ├── job.yml │ │ │ ├── steps/ @@ -77,6 +77,13 @@ deepwork/ # DeepWork tool repository │ │ ├── global_hooks.yml │ │ ├── user_prompt_submit.sh │ │ └── capture_prompt_work_tree.sh +│ ├── standard/ # Standard assets (new consolidated location) +│ │ └── experts/ # Built-in expert definitions +│ │ └── experts/ # Meta-expert for the experts system itself +│ │ ├── expert.yml +│ │ ├── topics/ +│ │ └── learnings/ +│ │ └── .gitkeep │ ├── schemas/ # Definition schemas │ │ ├── job_schema.py │ │ ├── doc_spec_schema.py # Doc spec schema definition @@ -1344,6 +1351,20 @@ Claude: Created rule "API documentation update" in .deepwork/rules/api-documenta --- +## Experts + +Experts are auto-improving collections of domain knowledge. They provide a structured mechanism for accumulating expertise and exposing it through Claude agents. + +See `doc/experts_requirements.md` for the full specification. + +**Key Concepts**: +- Experts live in `.deepwork/experts/[name]/` with `expert.yml`, `topics/`, and `learnings/` +- Standard experts ship from `src/deepwork/standard/experts/` and are installed during `deepwork install` +- `deepwork sync` will generate Claude agents in `.claude/agents/` with `dwe_` prefix +- CLI commands: `deepwork topics --expert "name"` and `deepwork learnings --expert "name"` + +--- + ## Technical Decisions ### Language: Python 3.11+ diff --git a/doc/experts_requirements.md b/doc/experts_requirements.md new file mode 100644 index 00000000..c6037590 --- /dev/null +++ b/doc/experts_requirements.md @@ -0,0 +1,165 @@ +# Experts System + +> **Status**: Implemented. See `doc/architecture.md` for integration details. + +Experts are auto-improving collections of domain knowledge. They provide a structured mechanism for accumulating expertise and exposing it throughout the system. + +## Overview + +Each expert represents deep knowledge in a specific domain (e.g., "Ruby on Rails ActiveJob", "Social Marketing"). Experts consist of: +- **Core expertise**: The foundational knowledge about the domain +- **Topics**: Detailed documentation on specific subjects within the domain +- **Learnings**: Hard-fought insights from real experiences + +## Directory Structure + +Experts live in `.deepwork/experts/[expert-folder-name]/`: + +``` +.deepwork/experts/ +└── rails_activejob/ + ├── expert.yml # Core expert definition + ├── topics/ # Detailed topic documentation + │ └── retry_handling.md + └── learnings/ # Experience-based insights + └── job_errors_not_going_to_sentry.md +``` + +### Naming Convention + +The **expert name** is derived from the folder name: +- Spaces and underscores become dashes +- Example: folder `rails_activejob` → expert name `rails-activejob` +- This name is used in CLI commands: `deepwork topics --expert "rails-activejob"` + +## File Formats + +### expert.yml + +```yaml +discovery_description: | + Short description used by other parts of the system to decide + whether to invoke this expert. Keep it concise and specific. + +full_expertise: | + Unlimited text (but generally ~5 pages max) containing the + complete current knowledge of this domain. This is the core + expertise that gets included in the generated agent. +``` + +**Note**: The expert name is not a field in this file—it's derived from the folder name. + +### topics/*.md + +Topics are frontmatter Markdown files covering specific subjects within the domain. + +```markdown +--- +name: Retry Handling +keywords: + - retry + - exponential backoff + - dead letter queue +last_updated: 2025-01-15 +--- + +Detailed documentation about retry handling in ActiveJob... +``` + +| Field | Description | +|-------|-------------| +| `name` | Human-readable name (e.g., "Retry Handling") | +| `keywords` | Topic-specific keywords only—avoid broad terms like "Rails" | +| `last_updated` | Date stamp (manually maintained) | + +Filenames are purely organizational and don't affect functionality. + +### learnings/*.md + +Learnings document complex experiences and hard-fought insights—like mini retrospectives. + +```markdown +--- +name: Job errors not going to Sentry +last_updated: 2025-01-20 +summarized_result: | + Sentry changed their standard gem for hooking into jobs. + SolidQueue still worked but ActiveJobKubernetes did not. +--- + +## Context +We noticed errors from background jobs weren't appearing in Sentry... + +## Investigation +After debugging, we discovered that Sentry's latest gem update... + +## Resolution +Updated the initialization to explicitly configure the hook... +``` + +| Field | Description | +|-------|-------------| +| `name` | Human-readable title of the learning | +| `last_updated` | Date stamp (manually maintained) | +| `summarized_result` | Brief summary of the key finding | + +## CLI Commands + +### List Topics + +```bash +deepwork topics --expert "rails-activejob" +``` + +Returns a Markdown list of topics: +- Name and relative file path as a Markdown link +- Followed by keywords +- Sorted by most-recently-updated + +### List Learnings + +```bash +deepwork learnings --expert "rails-activejob" +``` + +Returns a Markdown list of learnings: +- Name and relative file path as a Markdown link +- Followed by the summarized result +- Sorted by most-recently-updated + +## Sync Behavior + +`deepwork sync` generates Claude agents from experts (in addition to syncing jobs). + +### Generated Agent Location + +Agents are created in `.claude/agents/` with: +- **Filename**: `dwe_[expert-name].md` (e.g., `dwe_rails-activejob.md`) +- **name field**: `[expert-name]` (e.g., `rails-activejob`) +- **description**: The `discovery_description` from expert.yml + +### Agent Body Content + +The agent body combines: +1. The `full_expertise` text +2. A topics list using Claude's dynamic command embedding: + ``` + $(deepwork topics --expert "rails-activejob") + ``` +3. A learnings list using the same mechanism: + ``` + $(deepwork learnings --expert "rails-activejob") + ``` + +This ensures the agent always has access to the latest topics and learnings at runtime. + +## Standard Experts + +Standard experts ship with DeepWork and are located at `src/deepwork/standard/experts/`. + +When `deepwork install` runs, these are copied into `.deepwork/experts/` in the target project. + +## Future Considerations + +- Experts and jobs are currently independent systems +- Future versions may enable experts to reference each other and collaborate with jobs diff --git a/flake.nix b/flake.nix index d2218afb..b39b2ed3 100644 --- a/flake.nix +++ b/flake.nix @@ -68,9 +68,9 @@ uv venv .venv --quiet fi - # Sync dependencies (including dev extras like pytest, ruff, mypy) + # Sync dependencies (including dev group: pytest, ruff, mypy) # Run quietly - uv only outputs when changes are needed - uv sync --all-extras --quiet 2>/dev/null || uv sync --all-extras + uv sync --group dev --quiet 2>/dev/null || uv sync --group dev # Activate venv by setting environment variables directly # This works reliably for both interactive shells and `nix develop --command` diff --git a/pyproject.toml b/pyproject.toml index c2bc3e4a..d62ee237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deepwork" -version = "0.5.1" +version = "0.6.0" description = "Framework for enabling AI agents to perform complex, multi-step work tasks" readme = "README.md" requires-python = ">=3.11" @@ -38,6 +38,16 @@ dev = [ "types-PyYAML", ] +[dependency-groups] +dev = [ + "pytest>=7.0", + "pytest-mock>=3.10", + "pytest-cov>=4.0", + "ruff>=0.1.0", + "mypy>=1.0", + "types-PyYAML", +] + [project.scripts] deepwork = "deepwork.cli.main:cli" diff --git a/src/deepwork/cli/experts.py b/src/deepwork/cli/experts.py new file mode 100644 index 00000000..ad29510b --- /dev/null +++ b/src/deepwork/cli/experts.py @@ -0,0 +1,154 @@ +"""Expert commands for DeepWork CLI.""" + +from pathlib import Path + +import click +from rich.console import Console + +from deepwork.core.experts_parser import ( + ExpertParseError, + format_learnings_markdown, + format_topics_markdown, + parse_expert_definition, +) + +console = Console() + + +class ExpertNotFoundError(Exception): + """Exception raised when an expert is not found.""" + + pass + + +def _find_expert_dir(expert_name: str, project_path: Path) -> Path: + """ + Find the expert directory for a given expert name. + + The expert name uses dashes (e.g., "rails-activejob") but the folder + name may use underscores (e.g., "rails_activejob"). + + Args: + expert_name: Expert name (with dashes) + project_path: Project root directory + + Returns: + Path to expert directory + + Raises: + ExpertNotFoundError: If expert is not found + """ + experts_dir = project_path / ".deepwork" / "experts" + + if not experts_dir.exists(): + raise ExpertNotFoundError( + f"No experts directory found at {experts_dir}.\n" + "Run 'deepwork install' to set up the experts system." + ) + + # Convert expert name back to possible folder names + # rails-activejob -> rails_activejob, rails-activejob + possible_names = [ + expert_name.replace("-", "_"), # rails_activejob + expert_name, # rails-activejob (direct match) + ] + + for folder_name in possible_names: + expert_dir = experts_dir / folder_name + if expert_dir.exists() and (expert_dir / "expert.yml").exists(): + return expert_dir + + # List available experts for helpful error message + available_experts = [] + if experts_dir.exists(): + for subdir in experts_dir.iterdir(): + if subdir.is_dir() and (subdir / "expert.yml").exists(): + # Convert folder name to expert name (underscores to dashes) + name = subdir.name.replace("_", "-") + available_experts.append(name) + + if available_experts: + available_str = ", ".join(sorted(available_experts)) + raise ExpertNotFoundError( + f"Expert '{expert_name}' not found.\n" + f"Available experts: {available_str}" + ) + else: + raise ExpertNotFoundError( + f"Expert '{expert_name}' not found.\n" + "No experts have been defined yet." + ) + + +@click.command() +@click.option( + "--expert", + "-e", + required=True, + help="Expert name (e.g., 'rails-activejob')", +) +@click.option( + "--path", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", + help="Path to project directory (default: current directory)", +) +def topics(expert: str, path: Path) -> None: + """ + List topics for an expert. + + Returns a Markdown list of topics with names, file paths as links, + and keywords, sorted by most-recently-updated. + + Example: + deepwork topics --expert "rails-activejob" + """ + try: + expert_dir = _find_expert_dir(expert, path) + expert_def = parse_expert_definition(expert_dir) + output = format_topics_markdown(expert_def) + # Print raw output (no Rich formatting) for use in $(command) embedding + print(output) + except ExpertNotFoundError as e: + console.print(f"[red]Error:[/red] {e}") + raise click.Abort() from e + except ExpertParseError as e: + console.print(f"[red]Error parsing expert:[/red] {e}") + raise click.Abort() from e + + +@click.command() +@click.option( + "--expert", + "-e", + required=True, + help="Expert name (e.g., 'rails-activejob')", +) +@click.option( + "--path", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", + help="Path to project directory (default: current directory)", +) +def learnings(expert: str, path: Path) -> None: + """ + List learnings for an expert. + + Returns a Markdown list of learnings with names, file paths as links, + and summarized results, sorted by most-recently-updated. + + Example: + deepwork learnings --expert "rails-activejob" + """ + try: + expert_dir = _find_expert_dir(expert, path) + expert_def = parse_expert_definition(expert_dir) + output = format_learnings_markdown(expert_def) + # Print raw output (no Rich formatting) for use in $(command) embedding + print(output) + except ExpertNotFoundError as e: + console.print(f"[red]Error:[/red] {e}") + raise click.Abort() from e + except ExpertParseError as e: + console.print(f"[red]Error parsing expert:[/red] {e}") + raise click.Abort() from e diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py index 19bec4f8..73a5b97b 100644 --- a/src/deepwork/cli/install.py +++ b/src/deepwork/cli/install.py @@ -102,6 +102,61 @@ def _inject_deepwork_rules(jobs_dir: Path, project_path: Path) -> None: _inject_standard_job("deepwork_rules", jobs_dir, project_path) +def _inject_standard_experts(experts_dir: Path, project_path: Path) -> int: + """ + Inject standard expert definitions into the project. + + Copies all experts from src/deepwork/standard/experts/ to .deepwork/experts/. + + Args: + experts_dir: Path to .deepwork/experts directory + project_path: Path to project root (for relative path display) + + Returns: + Number of experts installed + + Raises: + InstallError: If injection fails + """ + # Find the standard experts directory + standard_experts_dir = Path(__file__).parent.parent / "standard" / "experts" + + if not standard_experts_dir.exists(): + # No standard experts to install - this is OK + return 0 + + installed_count = 0 + + # Iterate through each expert in the standard experts directory + for expert_source_dir in standard_experts_dir.iterdir(): + if not expert_source_dir.is_dir(): + continue + + # Check if this is a valid expert (has expert.yml) + if not (expert_source_dir / "expert.yml").exists(): + continue + + expert_name = expert_source_dir.name + target_dir = experts_dir / expert_name + + try: + if target_dir.exists(): + # Remove existing if present (for reinstall/upgrade) + shutil.rmtree(target_dir) + + shutil.copytree(expert_source_dir, target_dir) + # Fix permissions - source may have restrictive permissions + fix_permissions(target_dir) + console.print( + f" [green]✓[/green] Installed expert: {expert_name} ({target_dir.relative_to(project_path)})" + ) + installed_count += 1 + except Exception as e: + raise InstallError(f"Failed to install expert {expert_name}: {e}") from e + + return installed_count + + def _create_deepwork_gitignore(deepwork_dir: Path) -> None: """ Create .gitignore file in .deepwork/ directory. @@ -346,9 +401,11 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None: deepwork_dir = project_path / ".deepwork" jobs_dir = deepwork_dir / "jobs" doc_specs_dir = deepwork_dir / "doc_specs" + experts_dir = deepwork_dir / "experts" ensure_dir(deepwork_dir) ensure_dir(jobs_dir) ensure_dir(doc_specs_dir) + ensure_dir(experts_dir) console.print(f" [green]✓[/green] Created {deepwork_dir.relative_to(project_path)}/") # Step 3b: Inject standard jobs (core job definitions) @@ -356,6 +413,12 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None: _inject_deepwork_jobs(jobs_dir, project_path) _inject_deepwork_rules(jobs_dir, project_path) + # Step 3b-2: Inject standard experts + console.print("[yellow]→[/yellow] Installing standard experts...") + experts_count = _inject_standard_experts(experts_dir, project_path) + if experts_count == 0: + console.print(" [dim]•[/dim] No standard experts to install") + # Step 3c: Create .gitignore for temporary files _create_deepwork_gitignore(deepwork_dir) console.print(" [green]✓[/green] Created .deepwork/.gitignore") diff --git a/src/deepwork/cli/main.py b/src/deepwork/cli/main.py index b503ea9a..b5541455 100644 --- a/src/deepwork/cli/main.py +++ b/src/deepwork/cli/main.py @@ -14,6 +14,7 @@ def cli() -> None: # Import commands +from deepwork.cli.experts import learnings, topics # noqa: E402 from deepwork.cli.hook import hook # noqa: E402 from deepwork.cli.install import install # noqa: E402 from deepwork.cli.rules import rules # noqa: E402 @@ -23,6 +24,8 @@ def cli() -> None: cli.add_command(sync) cli.add_command(hook) cli.add_command(rules) +cli.add_command(topics) +cli.add_command(learnings) if __name__ == "__main__": diff --git a/src/deepwork/cli/sync.py b/src/deepwork/cli/sync.py index 03c47a30..e3795156 100644 --- a/src/deepwork/cli/sync.py +++ b/src/deepwork/cli/sync.py @@ -7,6 +7,12 @@ from rich.table import Table from deepwork.core.adapters import AgentAdapter +from deepwork.core.experts_generator import ExpertGenerator +from deepwork.core.experts_parser import ( + ExpertParseError, + discover_experts, + parse_expert_definition, +) from deepwork.core.generator import SkillGenerator from deepwork.core.hooks_syncer import collect_job_hooks, sync_hooks_to_platform from deepwork.core.parser import parse_job_definition @@ -114,9 +120,33 @@ def sync_skills(project_path: Path) -> None: if job_hooks_list: console.print(f"[yellow]→[/yellow] Found {len(job_hooks_list)} job(s) with hooks") + # Discover and parse experts + experts_dir = deepwork_dir / "experts" + expert_dirs = discover_experts(experts_dir) + console.print(f"[yellow]→[/yellow] Found {len(expert_dirs)} expert(s) to sync") + + experts = [] + failed_experts: list[tuple[str, str]] = [] + for expert_dir in expert_dirs: + try: + expert_def = parse_expert_definition(expert_dir) + experts.append(expert_def) + console.print(f" [green]✓[/green] Loaded {expert_def.name}") + except ExpertParseError as e: + console.print(f" [red]✗[/red] Failed to load {expert_dir.name}: {e}") + failed_experts.append((expert_dir.name, str(e))) + + # Warn but don't fail for expert parsing errors (experts are optional) + if failed_experts: + console.print() + console.print("[yellow]Warning: Some experts failed to parse:[/yellow]") + for expert_name, error in failed_experts: + console.print(f" • {expert_name}: {error}") + # Sync each platform generator = SkillGenerator() - stats = {"platforms": 0, "skills": 0, "hooks": 0} + expert_generator = ExpertGenerator() + stats = {"platforms": 0, "skills": 0, "hooks": 0, "agents": 0} for platform_name in platforms: try: @@ -149,6 +179,21 @@ def sync_skills(project_path: Path) -> None: except Exception as e: console.print(f" [red]✗[/red] Failed for {job.name}: {e}") + # Generate expert agents (only for Claude currently - agents live in .claude/agents/) + if experts and adapter.name == "claude": + console.print(" [dim]•[/dim] Generating expert agents...") + for expert in experts: + try: + agent_path = expert_generator.generate_expert_agent( + expert, adapter, platform_dir + ) + stats["agents"] += 1 + console.print( + f" [green]✓[/green] {expert.name} ({agent_path.name})" + ) + except Exception as e: + console.print(f" [red]✗[/red] Failed for {expert.name}: {e}") + # Sync hooks to platform settings if job_hooks_list: console.print(" [dim]•[/dim] Syncing hooks...") @@ -195,6 +240,8 @@ def sync_skills(project_path: Path) -> None: table.add_row("Platforms synced", str(stats["platforms"])) table.add_row("Total skills", str(stats["skills"])) + if stats["agents"] > 0: + table.add_row("Expert agents", str(stats["agents"])) if stats["hooks"] > 0: table.add_row("Hooks synced", str(stats["hooks"])) diff --git a/src/deepwork/core/experts_generator.py b/src/deepwork/core/experts_generator.py new file mode 100644 index 00000000..d69fac56 --- /dev/null +++ b/src/deepwork/core/experts_generator.py @@ -0,0 +1,186 @@ +"""Expert agent generator using Jinja2 templates.""" + +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, TemplateNotFound + +from deepwork.core.adapters import AgentAdapter +from deepwork.core.experts_parser import ExpertDefinition +from deepwork.utils.fs import safe_write + + +class ExpertGeneratorError(Exception): + """Exception raised for expert agent generation errors.""" + + pass + + +class ExpertGenerator: + """Generates agent files from expert definitions.""" + + # Template filename for expert agents + EXPERT_AGENT_TEMPLATE = "agent-expert.md.jinja" + + 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: + templates_dir = Path(__file__).parent.parent / "templates" + + self.templates_dir = Path(templates_dir) + + if not self.templates_dir.exists(): + raise ExpertGeneratorError(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 ExpertGeneratorError( + 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 _build_expert_context(self, expert: ExpertDefinition) -> dict: + """ + Build template context for an expert. + + Args: + expert: Expert definition + + Returns: + Template context dictionary + """ + return { + "expert_name": expert.name, + "discovery_description": expert.discovery_description, + "full_expertise": expert.full_expertise, + "topics_count": len(expert.topics), + "learnings_count": len(expert.learnings), + } + + def get_agent_filename(self, expert_name: str) -> str: + """ + Get the filename for an expert agent. + + Args: + expert_name: Name of the expert (e.g., "rails-activejob") + + Returns: + Agent filename (e.g., "dwe_rails-activejob.md") + """ + return f"dwe_{expert_name}.md" + + def get_agent_name(self, expert_name: str) -> str: + """ + Get the agent name field value for an expert. + + Args: + expert_name: Name of the expert (e.g., "rails-activejob") + + Returns: + Agent name (e.g., "rails-activejob") + """ + return expert_name + + def generate_expert_agent( + self, + expert: ExpertDefinition, + adapter: AgentAdapter, + output_dir: Path | str, + ) -> Path: + """ + Generate an agent file from an expert definition. + + Args: + expert: Expert definition + adapter: Agent adapter for the target platform + output_dir: Platform config directory (e.g., .claude/) + + Returns: + Path to generated agent file + + Raises: + ExpertGeneratorError: If generation fails + """ + output_dir = Path(output_dir) + + # Create agents subdirectory + agents_dir = output_dir / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + + # Build context + context = self._build_expert_context(expert) + + # Load and render template + env = self._get_jinja_env(adapter) + try: + template = env.get_template(self.EXPERT_AGENT_TEMPLATE) + except TemplateNotFound as e: + raise ExpertGeneratorError( + f"Expert agent template not found: {self.EXPERT_AGENT_TEMPLATE}" + ) from e + + try: + rendered = template.render(**context) + except Exception as e: + raise ExpertGeneratorError( + f"Expert agent template rendering failed for '{expert.name}': {e}" + ) from e + + # Write agent file + agent_filename = self.get_agent_filename(expert.name) + agent_path = agents_dir / agent_filename + + try: + safe_write(agent_path, rendered) + except Exception as e: + raise ExpertGeneratorError(f"Failed to write agent file: {e}") from e + + return agent_path + + def generate_all_expert_agents( + self, + experts: list[ExpertDefinition], + adapter: AgentAdapter, + output_dir: Path | str, + ) -> list[Path]: + """ + Generate agent files for all experts. + + Args: + experts: List of expert definitions + adapter: Agent adapter for the target platform + output_dir: Platform config directory (e.g., .claude/) + + Returns: + List of paths to generated agent files + + Raises: + ExpertGeneratorError: If generation fails for any expert + """ + agent_paths: list[Path] = [] + + for expert in experts: + agent_path = self.generate_expert_agent(expert, adapter, output_dir) + agent_paths.append(agent_path) + + return agent_paths diff --git a/src/deepwork/core/experts_parser.py b/src/deepwork/core/experts_parser.py new file mode 100644 index 00000000..5cb4ba8c --- /dev/null +++ b/src/deepwork/core/experts_parser.py @@ -0,0 +1,480 @@ +"""Expert definition parser.""" + +import re +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Any + +from deepwork.schemas.expert_schema import ( + EXPERT_SCHEMA, + LEARNING_FRONTMATTER_SCHEMA, + TOPIC_FRONTMATTER_SCHEMA, +) +from deepwork.utils.validation import ValidationError, validate_against_schema +from deepwork.utils.yaml_utils import YAMLError, load_yaml, load_yaml_from_string + + +class ExpertParseError(Exception): + """Exception raised for expert parsing errors.""" + + pass + + +def _folder_name_to_expert_name(folder_name: str) -> str: + """ + Convert folder name to expert name. + + Spaces and underscores become dashes. + Example: rails_activejob -> rails-activejob + + Args: + folder_name: The folder name to convert + + Returns: + The expert name + """ + return folder_name.replace("_", "-").replace(" ", "-") + + +def _parse_frontmatter_markdown(content: str, file_type: str) -> tuple[dict[str, Any], str]: + """ + Parse frontmatter from markdown content. + + Expects format: + --- + key: value + --- + markdown body + + Args: + content: Full file content + file_type: Type of file for error messages (e.g., "topic", "learning") + + Returns: + Tuple of (frontmatter dict, body content) + + Raises: + ExpertParseError: If frontmatter is missing or invalid + """ + # Match frontmatter pattern: starts with ---, ends with --- + pattern = r"^---[ \t]*\n(.*?)^---[ \t]*\n?(.*)" + match = re.match(pattern, content.strip(), re.DOTALL | re.MULTILINE) + + if not match: + raise ExpertParseError( + f"{file_type.capitalize()} file must have YAML frontmatter (content between --- markers)" + ) + + frontmatter_yaml = match.group(1) + body = match.group(2).strip() if match.group(2) else "" + + try: + frontmatter = load_yaml_from_string(frontmatter_yaml) + except YAMLError as e: + raise ExpertParseError(f"Failed to parse {file_type} frontmatter: {e}") from e + + if frontmatter is None: + frontmatter = {} + + return frontmatter, body + + +def _normalize_frontmatter_for_validation(frontmatter: dict[str, Any]) -> dict[str, Any]: + """ + Normalize frontmatter data for schema validation. + + PyYAML auto-parses dates as datetime.date objects, but our schema + expects strings. This function converts them back to strings. + + Args: + frontmatter: Parsed frontmatter data + + Returns: + Normalized frontmatter with dates as strings + """ + result = frontmatter.copy() + if "last_updated" in result and isinstance(result["last_updated"], date): + result["last_updated"] = result["last_updated"].isoformat() + return result + + +def _parse_date(date_value: str | date | None) -> date | None: + """ + Parse a date value. + + Handles both string (YYYY-MM-DD) and datetime.date objects + (which PyYAML may auto-parse). + + Args: + date_value: Date string, date object, or None + + Returns: + Parsed date or None + """ + if date_value is None: + return None + if isinstance(date_value, date): + return date_value + try: + return date.fromisoformat(date_value) + except (ValueError, TypeError): + return None + + +@dataclass +class Topic: + """Represents a topic within an expert domain.""" + + name: str + keywords: list[str] = field(default_factory=list) + last_updated: date | None = None + body: str = "" + source_file: Path | None = None + + @property + def relative_path(self) -> str | None: + """Get relative path for display (topics/filename.md).""" + if self.source_file is None: + return None + return f"topics/{self.source_file.name}" + + @classmethod + def from_dict( + cls, data: dict[str, Any], body: str = "", source_file: Path | None = None + ) -> "Topic": + """Create Topic from dictionary.""" + return cls( + name=data["name"], + keywords=data.get("keywords", []), + last_updated=_parse_date(data.get("last_updated")), + body=body, + source_file=source_file, + ) + + +@dataclass +class Learning: + """Represents a learning within an expert domain.""" + + name: str + last_updated: date | None = None + summarized_result: str | None = None + body: str = "" + source_file: Path | None = None + + @property + def relative_path(self) -> str | None: + """Get relative path for display (learnings/filename.md).""" + if self.source_file is None: + return None + return f"learnings/{self.source_file.name}" + + @classmethod + def from_dict( + cls, data: dict[str, Any], body: str = "", source_file: Path | None = None + ) -> "Learning": + """Create Learning from dictionary.""" + return cls( + name=data["name"], + last_updated=_parse_date(data.get("last_updated")), + summarized_result=data.get("summarized_result"), + body=body, + source_file=source_file, + ) + + +@dataclass +class ExpertDefinition: + """Represents a complete expert definition.""" + + name: str # Derived from folder name + discovery_description: str + full_expertise: str + expert_dir: Path + topics: list[Topic] = field(default_factory=list) + learnings: list[Learning] = field(default_factory=list) + + def get_topics_sorted(self) -> list[Topic]: + """ + Get topics sorted by most-recently-updated. + + Topics without last_updated are sorted last. + """ + return sorted( + self.topics, + key=lambda t: (t.last_updated is not None, t.last_updated), + reverse=True, + ) + + def get_learnings_sorted(self) -> list[Learning]: + """ + Get learnings sorted by most-recently-updated. + + Learnings without last_updated are sorted last. + """ + return sorted( + self.learnings, + key=lambda learning: (learning.last_updated is not None, learning.last_updated), + reverse=True, + ) + + @classmethod + def from_dict( + cls, + data: dict[str, Any], + expert_dir: Path, + topics: list[Topic] | None = None, + learnings: list[Learning] | None = None, + ) -> "ExpertDefinition": + """ + Create ExpertDefinition from dictionary. + + Args: + data: Parsed YAML data from expert.yml + expert_dir: Directory containing expert definition + topics: List of parsed topics + learnings: List of parsed learnings + + Returns: + ExpertDefinition instance + """ + name = _folder_name_to_expert_name(expert_dir.name) + return cls( + name=name, + discovery_description=data["discovery_description"].strip(), + full_expertise=data["full_expertise"].strip(), + expert_dir=expert_dir, + topics=topics or [], + learnings=learnings or [], + ) + + +def parse_topic_file(filepath: Path | str) -> Topic: + """ + Parse a topic file. + + Args: + filepath: Path to the topic file (markdown with YAML frontmatter) + + Returns: + Parsed Topic + + Raises: + ExpertParseError: If parsing fails or validation errors occur + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise ExpertParseError(f"Topic file does not exist: {filepath}") + + if not filepath.is_file(): + raise ExpertParseError(f"Topic path is not a file: {filepath}") + + try: + content = filepath.read_text(encoding="utf-8") + except Exception as e: + raise ExpertParseError(f"Failed to read topic file: {e}") from e + + frontmatter, body = _parse_frontmatter_markdown(content, "topic") + + try: + # Normalize frontmatter for validation (converts date objects to strings) + normalized = _normalize_frontmatter_for_validation(frontmatter) + validate_against_schema(normalized, TOPIC_FRONTMATTER_SCHEMA) + except ValidationError as e: + raise ExpertParseError(f"Topic validation failed in {filepath.name}: {e}") from e + + return Topic.from_dict(frontmatter, body, filepath) + + +def parse_learning_file(filepath: Path | str) -> Learning: + """ + Parse a learning file. + + Args: + filepath: Path to the learning file (markdown with YAML frontmatter) + + Returns: + Parsed Learning + + Raises: + ExpertParseError: If parsing fails or validation errors occur + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise ExpertParseError(f"Learning file does not exist: {filepath}") + + if not filepath.is_file(): + raise ExpertParseError(f"Learning path is not a file: {filepath}") + + try: + content = filepath.read_text(encoding="utf-8") + except Exception as e: + raise ExpertParseError(f"Failed to read learning file: {e}") from e + + frontmatter, body = _parse_frontmatter_markdown(content, "learning") + + try: + # Normalize frontmatter for validation (converts date objects to strings) + normalized = _normalize_frontmatter_for_validation(frontmatter) + validate_against_schema(normalized, LEARNING_FRONTMATTER_SCHEMA) + except ValidationError as e: + raise ExpertParseError(f"Learning validation failed in {filepath.name}: {e}") from e + + return Learning.from_dict(frontmatter, body, filepath) + + +def parse_expert_definition(expert_dir: Path | str) -> ExpertDefinition: + """ + Parse expert definition from directory. + + Args: + expert_dir: Directory containing expert.yml + + Returns: + Parsed ExpertDefinition + + Raises: + ExpertParseError: If parsing fails or validation errors occur + """ + expert_dir_path = Path(expert_dir) + + if not expert_dir_path.exists(): + raise ExpertParseError(f"Expert directory does not exist: {expert_dir_path}") + + if not expert_dir_path.is_dir(): + raise ExpertParseError(f"Expert path is not a directory: {expert_dir_path}") + + expert_file = expert_dir_path / "expert.yml" + if not expert_file.exists(): + raise ExpertParseError(f"expert.yml not found in {expert_dir_path}") + + # Load YAML + try: + expert_data = load_yaml(expert_file) + except YAMLError as e: + raise ExpertParseError(f"Failed to load expert.yml: {e}") from e + + if expert_data is None: + raise ExpertParseError("expert.yml is empty") + + # Validate against schema + try: + validate_against_schema(expert_data, EXPERT_SCHEMA) + except ValidationError as e: + raise ExpertParseError(f"Expert definition validation failed: {e}") from e + + # Parse topics + topics: list[Topic] = [] + topics_dir = expert_dir_path / "topics" + if topics_dir.exists() and topics_dir.is_dir(): + for topic_file in topics_dir.glob("*.md"): + try: + topic = parse_topic_file(topic_file) + topics.append(topic) + except ExpertParseError: + raise + + # Parse learnings + learnings: list[Learning] = [] + learnings_dir = expert_dir_path / "learnings" + if learnings_dir.exists() and learnings_dir.is_dir(): + for learning_file in learnings_dir.glob("*.md"): + try: + learning = parse_learning_file(learning_file) + learnings.append(learning) + except ExpertParseError: + raise + + return ExpertDefinition.from_dict(expert_data, expert_dir_path, topics, learnings) + + +def discover_experts(experts_dir: Path | str) -> list[Path]: + """ + Discover all expert directories in a given directory. + + An expert directory is one that contains an expert.yml file. + + Args: + experts_dir: Directory containing expert subdirectories + + Returns: + List of paths to expert directories + """ + experts_dir_path = Path(experts_dir) + + if not experts_dir_path.exists(): + return [] + + if not experts_dir_path.is_dir(): + return [] + + expert_dirs: list[Path] = [] + for subdir in experts_dir_path.iterdir(): + if subdir.is_dir() and (subdir / "expert.yml").exists(): + expert_dirs.append(subdir) + + return expert_dirs + + +def format_topics_markdown(expert: ExpertDefinition) -> str: + """ + Format topics as a markdown list for CLI output. + + Returns name and relative file path as a markdown link, + followed by keywords, sorted by most-recently-updated. + + Args: + expert: Expert definition + + Returns: + Markdown formatted list of topics + """ + topics = expert.get_topics_sorted() + if not topics: + return "_No topics yet._" + + lines = [] + for topic in topics: + # Format: [Topic Name](topics/filename.md) + rel_path = topic.relative_path or "topics/unknown.md" + line = f"- [{topic.name}]({rel_path})" + if topic.keywords: + line += f"\n Keywords: {', '.join(topic.keywords)}" + lines.append(line) + + return "\n".join(lines) + + +def format_learnings_markdown(expert: ExpertDefinition) -> str: + """ + Format learnings as a markdown list for CLI output. + + Returns name and relative file path as a markdown link, + followed by the summarized result, sorted by most-recently-updated. + + Args: + expert: Expert definition + + Returns: + Markdown formatted list of learnings + """ + learnings = expert.get_learnings_sorted() + if not learnings: + return "_No learnings yet._" + + lines = [] + for learning in learnings: + # Format: [Learning Name](learnings/filename.md) + rel_path = learning.relative_path or "learnings/unknown.md" + line = f"- [{learning.name}]({rel_path})" + if learning.summarized_result: + # Add indented summary + summary_lines = learning.summarized_result.strip().split("\n") + for summary_line in summary_lines: + line += f"\n {summary_line}" + lines.append(line) + + return "\n".join(lines) diff --git a/src/deepwork/schemas/expert_schema.py b/src/deepwork/schemas/expert_schema.py new file mode 100644 index 00000000..df4fd379 --- /dev/null +++ b/src/deepwork/schemas/expert_schema.py @@ -0,0 +1,76 @@ +"""JSON Schema definitions for expert definitions.""" + +from typing import Any + +# JSON Schema for expert.yml files +EXPERT_SCHEMA: dict[str, Any] = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["discovery_description", "full_expertise"], + "properties": { + "discovery_description": { + "type": "string", + "minLength": 1, + "description": "Short description used to decide whether to invoke this expert. Keep concise and specific.", + }, + "full_expertise": { + "type": "string", + "minLength": 1, + "description": "Complete current knowledge of this domain (~5 pages max). This is included in the generated agent.", + }, + }, + "additionalProperties": False, +} + +# JSON Schema for topic frontmatter +TOPIC_FRONTMATTER_SCHEMA: dict[str, Any] = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable topic name (e.g., 'Retry Handling')", + }, + "keywords": { + "type": "array", + "description": "Topic-specific keywords (avoid broad terms like the expert domain)", + "items": { + "type": "string", + "minLength": 1, + }, + }, + "last_updated": { + "type": "string", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + "description": "Date stamp in YYYY-MM-DD format", + }, + }, + "additionalProperties": False, +} + +# JSON Schema for learning frontmatter +LEARNING_FRONTMATTER_SCHEMA: dict[str, Any] = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable learning name (e.g., 'Job errors not going to Sentry')", + }, + "last_updated": { + "type": "string", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + "description": "Date stamp in YYYY-MM-DD format", + }, + "summarized_result": { + "type": "string", + "minLength": 1, + "description": "Brief summary of the key finding (1-3 sentences)", + }, + }, + "additionalProperties": False, +} diff --git a/src/deepwork/standard/experts/deepwork_jobs/expert.yml b/src/deepwork/standard/experts/deepwork_jobs/expert.yml new file mode 100644 index 00000000..d84fee24 --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/expert.yml @@ -0,0 +1,285 @@ +discovery_description: | + DeepWork Jobs system - defining, implementing, and syncing multi-step AI workflows. + Covers job.yml schema, step instructions, skill generation, hooks, and CLI commands. + +full_expertise: | + # DeepWork Jobs System + + You are an expert on the DeepWork Jobs system - the framework for building + reusable, multi-step AI workflows that integrate with AI coding assistants. + + ## Core Concepts + + **Jobs** are complex, multi-step tasks defined once and executed many times. + Each job consists of: + + - **job.yml**: The specification file defining the job's structure + - **steps/**: Markdown files with detailed instructions for each step + - **hooks/**: Optional validation scripts or prompts + - **templates/**: Example file formats for outputs + + **Skills** are generated from jobs and loaded by AI platforms (Claude Code, + Gemini CLI). Each step becomes a slash command the user can invoke. + + ## Job Definition Structure + + Jobs live in `.deepwork/jobs/[job_name]/`: + + ``` + .deepwork/jobs/ + └── competitive_research/ + ├── job.yml + ├── steps/ + │ ├── identify_competitors.md + │ └── research_competitors.md + ├── hooks/ + │ └── validate_research.sh + └── templates/ + └── competitor_profile.md + ``` + + ## The job.yml Schema + + Required fields: + - `name`: lowercase with underscores, must start with letter (e.g., `competitive_research`) + - `version`: semantic versioning X.Y.Z (e.g., `1.0.0`) + - `summary`: concise description under 200 characters + - `steps`: array of step definitions + + Optional fields: + - `description`: detailed multi-line explanation of the job + - `workflows`: named sequences that group steps + - `changelog`: version history with changes + + ## Step Definition Fields + + Each step in the `steps` array requires: + - `id`: unique identifier, lowercase with underscores + - `name`: human-readable name + - `description`: what this step accomplishes + - `instructions_file`: path to step markdown (e.g., `steps/identify.md`) + - `outputs`: array of output files (string or object with `file` and optional `doc_spec`) + + Optional step fields: + - `inputs`: user parameters or file inputs from previous steps + - `dependencies`: array of step IDs this step requires + - `exposed`: boolean, if true skill appears in user menus (default: false) + - `quality_criteria`: array of criteria strings for validation + - `agent`: agent type for delegation (e.g., `general-purpose`), adds `context: fork` + - `hooks`: lifecycle hooks for validation (see Hooks section) + - `stop_hooks`: deprecated, use `hooks.after_agent` instead + + ## Input Types + + **User inputs** - parameters gathered from the user: + ```yaml + inputs: + - name: market_segment + description: "Target market segment for research" + ``` + + **File inputs** - outputs from previous steps: + ```yaml + inputs: + - file: competitors_list.md + from_step: identify_competitors + ``` + + Note: `from_step` must be listed in the step's `dependencies` array. + + ## Output Types + + **Simple output** (string): + ```yaml + outputs: + - competitors_list.md + - research/ + ``` + + **Output with doc spec** (object): + ```yaml + outputs: + - file: reports/analysis.md + doc_spec: .deepwork/doc_specs/analysis_report.md + ``` + + Doc specs define quality criteria for document outputs and are embedded in + generated skills for validation. + + ## Workflows + + Workflows group steps into named sequences. Steps not in any workflow are + "standalone" and can be run independently. + + ```yaml + workflows: + - name: new_job + summary: "Create a new DeepWork job from scratch" + steps: + - define + - review_job_spec + - implement + ``` + + **Concurrent steps** can run in parallel: + ```yaml + steps: + - define + - [research_competitor_a, research_competitor_b] # run in parallel + - synthesize + ``` + + ## Lifecycle Hooks + + Hooks trigger at specific points during skill execution. + + **Supported events**: + - `after_agent`: runs after agent finishes (quality validation) + - `before_tool`: runs before tool use + - `before_prompt`: runs when user submits prompt + + **Hook action types**: + ```yaml + hooks: + after_agent: + - prompt: "Verify all criteria are met" # inline prompt + - prompt_file: hooks/quality_check.md # prompt from file + - script: hooks/run_tests.sh # shell script + ``` + + Note: Claude Code currently only supports script hooks. Prompt hooks are + parsed but not executed (documented limitation). + + ## Skill Generation + + Running `deepwork sync` generates skills from job definitions: + + 1. Parses all `job.yml` files in `.deepwork/jobs/` + 2. For each job, generates a **meta-skill** (entry point) and **step skills** + 3. Writes to platform-specific directories (e.g., `.claude/skills/`) + + **Claude Code skill structure**: + - Meta-skill: `.claude/skills/[job_name]/SKILL.md` + - Step skill: `.claude/skills/[job_name].[step_id]/SKILL.md` + + **Gemini CLI skill structure**: + - Meta-skill: `.gemini/skills/[job_name]/index.toml` + - Step skill: `.gemini/skills/[job_name]/[step_id].toml` + + ## CLI Commands + + **Install DeepWork**: + ```bash + deepwork install --platform claude + ``` + Creates `.deepwork/` structure, copies standard jobs, runs sync. + + **Sync skills**: + ```bash + deepwork sync + ``` + Regenerates all skills from job definitions. + + **Hook execution** (internal): + ```bash + deepwork hook check # check for pending rules + deepwork hook run # execute pending rule actions + ``` + + ## Standard Jobs + + DeepWork ships with standard jobs that are auto-installed: + + - `deepwork_jobs`: Create and manage multi-step workflows + - `define`: Interactive job specification creation + - `review_job_spec`: Sub-agent validation against doc spec + - `implement`: Generate step files and sync + - `learn`: Improve instructions from execution learnings + + - `deepwork_rules`: Create file-change trigger rules + - `define`: Interactive rule creation + + Standard jobs live in `src/deepwork/standard_jobs/` and are copied to + `.deepwork/jobs/` during installation. + + ## Writing Step Instructions + + Step instruction files should include: + + 1. **Objective**: Clear statement of what this step accomplishes + 2. **Task**: Detailed process with numbered steps + 3. **Inputs section**: What to gather/read before starting + 4. **Output format**: Examples of expected outputs + 5. **Quality criteria**: How to verify the step is complete + + Use the phrase "ask structured questions" when gathering user input - + this triggers proper tooling for interactive prompts. + + ## Template System + + Skills are generated using Jinja2 templates in `src/deepwork/templates/`: + + - `claude/skill-job-meta.md.jinja`: Meta-skill template + - `claude/skill-job-step.md.jinja`: Step skill template + - `gemini/skill-job-meta.toml.jinja`: Gemini meta-skill + - `gemini/skill-job-step.toml.jinja`: Gemini step skill + + Template variables include job context, step metadata, inputs, outputs, + hooks, quality criteria, and workflow position. + + ## Platform Adapters + + The `AgentAdapter` class abstracts platform differences: + + - `ClaudeAdapter`: Claude Code with markdown skills in `.claude/skills/` + - `GeminiAdapter`: Gemini CLI with TOML skills in `.gemini/skills/` + + Adapters handle: + - Skill filename patterns + - Hook event name mapping (e.g., `after_agent` -> `Stop` for Claude) + - Settings file management + - Permission syncing + + ## Parser Dataclasses + + The `parser.py` module defines the job structure: + + - `JobDefinition`: Top-level job with name, version, steps, workflows + - `Step`: Individual step with inputs, outputs, hooks, dependencies + - `StepInput`: User parameter or file input + - `OutputSpec`: Output file optionally with doc_spec reference + - `HookAction`: Hook configuration (prompt, prompt_file, or script) + - `Workflow`: Named step sequence + - `WorkflowStepEntry`: Sequential or concurrent step group + + ## Validation Rules + + The parser validates: + - Dependencies reference existing steps + - No circular dependencies + - File inputs reference steps in dependencies + - Workflow steps exist + - No duplicate workflow names + - Doc spec files exist (when referenced) + + ## Common Patterns + + **Creating a new job**: + 1. Run `/deepwork_jobs` (or `/deepwork_jobs.define`) + 2. Answer structured questions about your workflow + 3. Review generated job.yml + 4. Run `/deepwork_jobs.implement` to generate step files + 5. Run `deepwork sync` to create skills + + **Adding a step to existing job**: + 1. Edit `.deepwork/jobs/[job_name]/job.yml` + 2. Add step definition with required fields + 3. Create instructions file in `steps/` + 4. Update workflow if applicable + 5. Run `deepwork sync` + + **Debugging sync issues**: + - Check job.yml syntax with a YAML validator + - Verify step IDs match filenames + - Ensure dependencies form valid DAG + - Check instructions files exist diff --git a/src/deepwork/standard/experts/deepwork_jobs/learnings/.gitkeep b/src/deepwork/standard/experts/deepwork_jobs/learnings/.gitkeep new file mode 100644 index 00000000..8c629330 --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/learnings/.gitkeep @@ -0,0 +1 @@ +# This file ensures the learnings directory exists in version control. diff --git a/src/deepwork/standard/experts/deepwork_jobs/learnings/prompt_hooks_not_executed.md b/src/deepwork/standard/experts/deepwork_jobs/learnings/prompt_hooks_not_executed.md new file mode 100644 index 00000000..43ae0931 --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/learnings/prompt_hooks_not_executed.md @@ -0,0 +1,50 @@ +--- +name: Prompt Hooks Not Executed in Claude Code +last_updated: 2025-01-30 +summarized_result: | + Claude Code parses but does not execute prompt-based stop hooks. + Only script/command hooks actually run. Use quality_criteria for validation. +--- + +## Context + +When implementing quality validation for job steps, developers often try +to use inline `prompt` or `prompt_file` hooks for validation. + +## Investigation + +Testing revealed that Claude Code's hook system only executes `command` type +hooks in the settings.json hooks configuration. Prompt-based hooks are parsed +by DeepWork but not rendered into the skill's hook frontmatter because they +would not be executed. + +The template code explicitly filters: +```jinja +{%- set script_hooks = event_hooks | selectattr("type", "equalto", "script") | list %} +``` + +## Resolution + +Two recommended approaches for quality validation: + +1. **Use `quality_criteria` field** (preferred): + ```yaml + quality_criteria: + - "Each competitor has description" + - "Sources are cited" + ``` + This generates instructions for sub-agent review, which works reliably. + +2. **Use script hooks** for objective validation: + ```yaml + hooks: + after_agent: + - script: hooks/run_tests.sh + ``` + Scripts actually execute and can fail the step. + +## Key Takeaway + +For subjective quality checks, use the `quality_criteria` field which triggers +sub-agent review. For objective checks (tests, linting), use script hooks. +Avoid prompt hooks until Claude Code supports them. diff --git a/src/deepwork/standard/experts/deepwork_jobs/topics/hooks_and_validation.md b/src/deepwork/standard/experts/deepwork_jobs/topics/hooks_and_validation.md new file mode 100644 index 00000000..d335dcfb --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/topics/hooks_and_validation.md @@ -0,0 +1,197 @@ +--- +name: Hooks and Validation +keywords: + - hooks + - validation + - quality + - stop_hooks + - lifecycle +last_updated: 2025-01-30 +--- + +# Hooks and Validation + +How to use lifecycle hooks for quality validation in DeepWork jobs. + +## Lifecycle Hook Events + +DeepWork supports three generic hook events: + +| Event | When it fires | Use case | +|-------|---------------|----------| +| `after_agent` | After agent finishes responding | Quality validation, output verification | +| `before_tool` | Before agent uses a tool | Pre-tool checks, validation | +| `before_prompt` | When user submits a prompt | Session setup, context loading | + +## Platform Mapping + +Hooks are mapped to platform-specific event names: + +| Generic | Claude Code | +|---------|-------------| +| `after_agent` | `Stop`, `SubagentStop` | +| `before_tool` | `PreToolUse` | +| `before_prompt` | `UserPromptSubmit` | + +Note: Gemini CLI does not support skill-level hooks (only global hooks). + +## Hook Action Types + +### Inline Prompt + +Best for simple validation criteria: + +```yaml +hooks: + after_agent: + - prompt: | + Verify the output meets these criteria: + 1. Contains at least 5 competitors + 2. Each has a description + 3. Sources are cited +``` + +**Important**: Prompt hooks are currently parsed but NOT executed by Claude Code. +This is a documented limitation. Use script hooks for actual enforcement. + +### Prompt File + +For detailed/reusable criteria: + +```yaml +hooks: + after_agent: + - prompt_file: hooks/quality_check.md +``` + +The prompt file is read and its content is embedded in the generated skill. +Same limitation applies - parsed but not executed. + +### Script Hook + +For programmatic validation (actually executed): + +```yaml +hooks: + after_agent: + - script: hooks/run_tests.sh +``` + +Scripts are shell scripts that can: +- Run test suites +- Lint output files +- Check for required content +- Validate file formats + +Exit code 0 = pass, non-zero = fail. + +## Script Hook Example + +Create `.deepwork/jobs/[job_name]/hooks/validate.sh`: + +```bash +#!/bin/bash +# Validate research output + +OUTPUT_FILE="research_notes.md" + +if [ ! -f "$OUTPUT_FILE" ]; then + echo "ERROR: $OUTPUT_FILE not found" + exit 1 +fi + +# Check minimum content +LINES=$(wc -l < "$OUTPUT_FILE") +if [ "$LINES" -lt 50 ]; then + echo "ERROR: Output has only $LINES lines, expected at least 50" + exit 1 +fi + +echo "Validation passed" +exit 0 +``` + +Make it executable: +```bash +chmod +x .deepwork/jobs/[job_name]/hooks/validate.sh +``` + +## Combining Multiple Hooks + +```yaml +hooks: + after_agent: + - script: hooks/lint.sh + - script: hooks/run_tests.sh + - prompt: "Verify documentation is complete" +``` + +Hooks run in order. Script hooks are executed; prompt hooks are for reference. + +## Deprecated: stop_hooks + +The `stop_hooks` field is deprecated but still supported: + +```yaml +# Old style (deprecated) +stop_hooks: + - script: hooks/validate.sh + +# New style (preferred) +hooks: + after_agent: + - script: hooks/validate.sh +``` + +Internally, `stop_hooks` are merged into `hooks.after_agent`. + +## Quality Criteria Alternative + +For simple validation, use declarative `quality_criteria` instead of hooks: + +```yaml +steps: + - id: research + quality_criteria: + - "**Data Coverage**: Each competitor has at least 3 data points" + - "**Source Attribution**: All facts are cited" + - "**Relevance**: All competitors are in the target market" +``` + +Quality criteria are rendered in the skill with instructions to use a +sub-agent (Haiku model) for review: + +1. Agent completes work +2. Spawns sub-agent to review against criteria +3. Fixes any issues identified +4. Repeats until sub-agent confirms all criteria pass + +This is the recommended approach for most validation needs - it's more +flexible than scripts and actually works with Claude Code. + +## When to Use Each Approach + +| Approach | When to use | +|----------|-------------| +| `quality_criteria` | Most cases - subjective quality checks | +| Script hooks | Objective checks (tests, linting, format validation) | +| Prompt hooks | Documentation only (not currently executed) | + +## Generated Skill Output + +For script hooks, the generated skill includes: + +```yaml +hooks: + Stop: + - hooks: + - type: command + command: ".deepwork/jobs/job_name/hooks/validate.sh" + SubagentStop: + - hooks: + - type: command + command: ".deepwork/jobs/job_name/hooks/validate.sh" +``` + +Both `Stop` and `SubagentStop` are registered so hooks fire for both +the main agent and any sub-agents. diff --git a/src/deepwork/standard/experts/deepwork_jobs/topics/job_yml_schema.md b/src/deepwork/standard/experts/deepwork_jobs/topics/job_yml_schema.md new file mode 100644 index 00000000..657e486e --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/topics/job_yml_schema.md @@ -0,0 +1,153 @@ +--- +name: job.yml Schema Reference +keywords: + - schema + - yaml + - validation + - fields + - structure +last_updated: 2025-01-30 +--- + +# job.yml Schema Reference + +Complete reference for the job.yml specification file. + +## Required Fields + +### name +- Type: string +- Pattern: `^[a-z][a-z0-9_]*$` +- Must start with lowercase letter, can contain lowercase letters, numbers, underscores +- Examples: `competitive_research`, `monthly_report`, `feature_dev` + +### version +- Type: string +- Pattern: `^\d+\.\d+\.\d+$` +- Semantic versioning format +- Examples: `1.0.0`, `2.1.3` + +### summary +- Type: string +- Length: 1-200 characters +- Brief one-line description of the job +- Used in skill descriptions and menus + +### steps +- Type: array +- Minimum items: 1 +- Each item is a step definition object + +## Optional Fields + +### description +- Type: string +- Multi-line detailed explanation +- Included in generated skill files for context +- Good for: problem solved, process overview, target users + +### workflows +- Type: array +- Named sequences grouping steps +- See Workflow Schema section + +### changelog +- Type: array +- Version history entries +- Each entry has `version` (string) and `changes` (string) + +## Step Schema + +### Required Step Fields + +```yaml +steps: + - id: identify_competitors # unique, lowercase_underscores + name: "Identify Competitors" # human-readable + description: "Find and list..." # what it does + instructions_file: steps/identify.md # path to instructions + outputs: # at least one output + - competitors_list.md +``` + +### Optional Step Fields + +```yaml + - id: research + # ... required fields ... + inputs: + - name: market_segment + description: "Target market" + - file: competitors_list.md + from_step: identify_competitors + dependencies: + - identify_competitors + exposed: true # show in user menus + quality_criteria: + - "All competitors have descriptions" + - "Sources are cited" + agent: general-purpose # delegate to agent type + hooks: + after_agent: + - script: hooks/validate.sh +``` + +## Output Formats + +Simple string: +```yaml +outputs: + - report.md + - data/ +``` + +With doc spec: +```yaml +outputs: + - file: reports/analysis.md + doc_spec: .deepwork/doc_specs/analysis.md +``` + +Doc spec path must match pattern: `^\.deepwork/doc_specs/[a-z][a-z0-9_-]*\.md$` + +## Workflow Schema + +```yaml +workflows: + - name: full_analysis + summary: "Complete competitive analysis workflow" + steps: + - identify + - research + - analyze + - report +``` + +Concurrent steps: +```yaml +steps: + - identify + - [research_a, research_b] # parallel execution + - synthesize +``` + +## Hook Schema + +```yaml +hooks: + after_agent: + - prompt: "Verify criteria are met" # inline + - prompt_file: hooks/check.md # from file + - script: hooks/run_tests.sh # shell script +``` + +Each hook action must have exactly one of: `prompt`, `prompt_file`, or `script`. + +## Validation Rules + +1. Step IDs must be unique within the job +2. Dependencies must reference existing step IDs +3. File inputs with `from_step` must have that step in dependencies +4. No circular dependencies allowed +5. Workflow steps must reference existing step IDs +6. No duplicate steps within a workflow diff --git a/src/deepwork/standard/experts/deepwork_jobs/topics/skill_generation.md b/src/deepwork/standard/experts/deepwork_jobs/topics/skill_generation.md new file mode 100644 index 00000000..9f210b80 --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/topics/skill_generation.md @@ -0,0 +1,155 @@ +--- +name: Skill Generation +keywords: + - sync + - templates + - jinja + - skills + - commands +last_updated: 2025-01-30 +--- + +# Skill Generation + +How DeepWork generates platform-specific skills from job definitions. + +## The Sync Process + +Running `deepwork sync`: + +1. Loads `config.yml` to get configured platforms +2. Discovers all job directories in `.deepwork/jobs/` +3. Parses each `job.yml` into `JobDefinition` dataclass +4. For each job and platform, generates skills using Jinja2 templates +5. Writes skill files to platform-specific directories +6. Syncs hooks and permissions to platform settings + +## Generated Skill Types + +### Meta-Skill (Job Entry Point) +- One per job +- Routes user intent to appropriate step +- Lists available workflows and standalone skills +- Claude: `.claude/skills/[job_name]/SKILL.md` +- Gemini: `.gemini/skills/[job_name]/index.toml` + +### Step Skills +- One per step +- Contains full instructions, inputs, outputs +- Includes workflow position and navigation +- Claude: `.claude/skills/[job_name].[step_id]/SKILL.md` +- Gemini: `.gemini/skills/[job_name]/[step_id].toml` + +## Template Variables + +Templates receive rich context including: + +**Job Context**: +- `job_name`, `job_version`, `job_summary`, `job_description` +- `total_steps`, `has_workflows`, `workflows`, `standalone_steps` + +**Step Context**: +- `step_id`, `step_name`, `step_description`, `step_number` +- `instructions_content` (full markdown from instructions file) +- `user_inputs`, `file_inputs`, `outputs`, `dependencies` +- `is_standalone`, `exposed`, `agent` + +**Workflow Context** (when step is in workflow): +- `workflow_name`, `workflow_summary` +- `workflow_step_number`, `workflow_total_steps` +- `workflow_next_step`, `workflow_prev_step` + +**Quality & Hooks**: +- `quality_criteria` (array of strings) +- `hooks` (dict by platform event name) +- `stop_hooks` (backward compat: after_agent hooks) + +## Template Location + +Templates live in `src/deepwork/templates/[platform]/`: + +``` +templates/ +├── claude/ +│ ├── skill-job-meta.md.jinja +│ ├── skill-job-step.md.jinja +│ └── settings.json +└── gemini/ + ├── skill-job-meta.toml.jinja + └── skill-job-step.toml.jinja +``` + +## Platform Differences + +**Claude Code**: +- Markdown format with YAML frontmatter +- Uses `---` delimited frontmatter for metadata +- Hook events: `Stop`, `SubagentStop`, `PreToolUse`, `UserPromptSubmit` +- Skills directory: `.claude/skills/` + +**Gemini CLI**: +- TOML format +- Uses namespace separators via directories +- No skill-level hooks (global only) +- Skills directory: `.gemini/skills/` + +## The Generator Class + +`SkillGenerator` in `core/generator.py`: + +```python +generator = SkillGenerator() + +# Generate all skills for a job +paths = generator.generate_all_skills( + job=job_definition, + adapter=claude_adapter, + output_dir=project_path, + project_root=project_path +) + +# Generate single step skill +path = generator.generate_step_skill( + job=job_def, + step=step, + adapter=adapter, + output_dir=output_dir +) +``` + +## Doc Spec Integration + +When outputs reference doc specs, the generator: + +1. Loads doc spec file using `DocSpecParser` +2. Extracts quality criteria, target audience, example document +3. Includes this context in template variables +4. Generated skill displays doc spec requirements inline + +## Skill Frontmatter + +Claude skills have YAML frontmatter: + +```yaml +--- +name: job_name.step_id +description: "Step description" +user-invocable: false # when exposed: false +context: fork # when agent is specified +agent: general-purpose +hooks: + Stop: + - hooks: + - type: command + command: ".deepwork/jobs/job_name/hooks/validate.sh" +--- +``` + +## Permissions Syncing + +After generating skills, adapters sync permissions: + +- Base permissions (read `.deepwork/tmp/**`) +- Skill invocation permissions (`Skill(job_name)`, `Skill(job_name.step_id)`) + +Permissions are added to `.claude/settings.json` in the `permissions.allow` array. diff --git a/src/deepwork/standard/experts/deepwork_jobs/topics/step_instructions.md b/src/deepwork/standard/experts/deepwork_jobs/topics/step_instructions.md new file mode 100644 index 00000000..0c45b0d9 --- /dev/null +++ b/src/deepwork/standard/experts/deepwork_jobs/topics/step_instructions.md @@ -0,0 +1,187 @@ +--- +name: Writing Step Instructions +keywords: + - instructions + - steps + - markdown + - writing + - best practices +last_updated: 2025-01-30 +--- + +# Writing Step Instructions + +Best practices for writing effective step instruction files. + +## File Location + +Step instructions live in `.deepwork/jobs/[job_name]/steps/[step_id].md`. + +The path is specified in job.yml via `instructions_file`: +```yaml +steps: + - id: identify_competitors + instructions_file: steps/identify_competitors.md +``` + +## Recommended Structure + +### 1. Objective Section + +Start with a clear objective statement: + +```markdown +## Objective + +Create a comprehensive list of competitors in the target market by +systematically researching industry players and their offerings. +``` + +### 2. Task Section + +Detailed step-by-step process: + +```markdown +## Task + +### Step 1: Understand the Market + +Ask structured questions to gather context: +- What industry or market segment? +- What product category? +- Geographic scope? + +### Step 2: Research Sources + +Search for competitors using: +1. Industry databases and reports +2. Google searches for market leaders +3. Customer review sites +... +``` + +### 3. Input Handling + +If the step has user inputs, explicitly request them: + +```markdown +## Inputs + +Before proceeding, gather the following from the user: +- **market_segment**: Target market for analysis +- **product_category**: Specific product/service category + +Use the AskUserQuestion tool to collect these inputs. +``` + +### 4. Output Format + +Show what good output looks like: + +```markdown +## Output Format + +Create `competitors_list.md` with the following structure: + +```markdown +# Competitors List + +## Market: [market_segment] + +### Competitor 1: Acme Corp +- **Website**: acme.com +- **Description**: Brief overview +- **Key Products**: Product A, Product B +``` + +### 5. Quality Criteria + +Define how to verify the step is complete: + +```markdown +## Quality Criteria + +- At least 5 competitors identified +- Each competitor has description and key products +- Sources are cited for all information +- List is relevant to the specified market +``` + +## Key Phrases + +### "Ask structured questions" + +When gathering user input, always use this phrase: + +```markdown +Ask structured questions to understand the user's requirements: +1. What is your target market? +2. Who are your main competitors? +``` + +This phrase triggers the AskUserQuestion tool which provides a better +user experience with clear options. + +### "Use the Skill tool to invoke" + +For workflow continuation: + +```markdown +## On Completion + +1. Verify outputs are created +2. Use the Skill tool to invoke `/job_name.next_step` +``` + +## Supplementary Files + +Place additional reference materials in `steps/`: + +``` +steps/ +├── identify_competitors.md +├── research_competitors.md +└── competitor_template.md # supplementary reference +``` + +Reference them using full paths: +```markdown +Use the template in `.deepwork/jobs/competitive_research/steps/competitor_template.md` +to structure each competitor profile. +``` + +## Anti-Patterns to Avoid + +### Vague Instructions +Bad: "Research the competitors" +Good: "Search each competitor's website, LinkedIn, and review sites to gather..." + +### Missing Outputs +Bad: "Create a report" +Good: "Create `research_notes.md` with sections for each competitor..." + +### Skipping Inputs +Bad: Assume inputs are available +Good: "Read `competitors_list.md` from the previous step. If it doesn't exist..." + +### Generic Quality Criteria +Bad: "Output should be good quality" +Good: "Each competitor profile includes at least 3 data points with sources" + +## Instruction Length + +- Keep instructions focused and actionable +- Aim for 1-3 pages of content +- Extract lengthy examples into separate template files +- Use bullet points over paragraphs where appropriate + +## Variables in Instructions + +Instructions can reference job-level context. The generated skill includes: +- Job name and description +- Step position in workflow +- Dependencies and next steps +- All inputs and outputs + +You don't need to repeat this metadata in instructions - it's included +automatically in the generated skill. diff --git a/src/deepwork/standard/experts/experts/expert.yml b/src/deepwork/standard/experts/experts/expert.yml new file mode 100644 index 00000000..d3e9c385 --- /dev/null +++ b/src/deepwork/standard/experts/experts/expert.yml @@ -0,0 +1,194 @@ +discovery_description: | + DeepWork experts system - creating, organizing, and evolving domain knowledge + collections that auto-improve through topics and learnings. + +full_expertise: | + # DeepWork Experts System + + You are an expert on the DeepWork experts system - the framework for building + auto-improving collections of domain knowledge. + + ## Core Concepts + + **Experts** are structured knowledge repositories that grow smarter over time. + Each expert represents deep knowledge in a specific domain and consists of: + + - **Core expertise**: Foundational knowledge captured in expert.yml + - **Topics**: Detailed documentation on specific subjects + - **Learnings**: Hard-fought insights from real experiences + + ## When to Create an Expert + + Create an expert when: + - You have recurring work in a specific domain + - Knowledge is scattered and needs consolidation + - You want to capture learnings that would otherwise be lost + - A domain has enough depth to warrant structured documentation + + Do NOT create an expert for: + - One-off tasks with no future relevance + - Domains too broad to be actionable (e.g., "Programming") + - Topics better served by external documentation + + ## Expert Structure + + Experts live in `.deepwork/experts/[folder-name]/`: + + ``` + .deepwork/experts/ + └── rails_activejob/ + ├── expert.yml + ├── topics/ + │ └── retry_handling.md + └── learnings/ + └── job_errors_not_going_to_sentry.md + ``` + + The **expert name** derives from the folder name with spaces/underscores becoming + dashes: `rails_activejob` → `rails-activejob`. + + ## Writing Good expert.yml + + The expert.yml has two key fields: + + ### discovery_description + A concise description (1-3 sentences) that helps the system decide when to + invoke this expert. Be specific about the domain and capabilities. + + Good: "Ruby on Rails ActiveJob - background job processing, retries, queues, + and error handling in Rails applications." + + Bad: "Helps with Rails stuff." + + ### full_expertise + The core knowledge payload (~5 pages max). Structure it as: + + 1. **Identity statement**: "You are an expert on..." + 2. **Core concepts**: Key ideas and mental models + 3. **Common patterns**: Typical approaches and solutions + 4. **Pitfalls to avoid**: Known gotchas and mistakes + 5. **Decision frameworks**: How to choose between options + + Write in second person ("You should...") as this becomes agent instructions. + + ## Writing Good Topics + + Topics are deep dives into specific subjects within the domain. + + ### When to create a topic + - Subject needs more detail than fits in full_expertise + - You find yourself repeatedly explaining something + - A subject has enough nuance to warrant dedicated documentation + + ### Topic file structure + ```markdown + --- + name: Retry Handling + keywords: + - retry + - exponential backoff + - dead letter queue + last_updated: 2025-01-15 + --- + + [Detailed content here] + ``` + + ### Keyword guidelines + - Use topic-specific terms only + - Avoid broad domain terms (don't use "Rails" in a Rails expert's topics) + - Include synonyms and related terms users might search for + - 3-7 keywords is typical + + ## Writing Good Learnings + + Learnings capture hard-fought insights from real experiences - like mini + retrospectives that prevent repeating mistakes. + + ### When to create a learning + - You solved a non-obvious problem + - A debugging session revealed unexpected behavior + - You discovered something that contradicts common assumptions + - Future-you would benefit from this context + + ### Learning file structure + ```markdown + --- + name: Job errors not going to Sentry + last_updated: 2025-01-20 + summarized_result: | + Sentry changed their standard gem for hooking into jobs. + SolidQueue still worked but ActiveJobKubernetes did not. + --- + + ## Context + What was happening and why it mattered... + + ## Investigation + What you tried and what you discovered... + + ## Resolution + How you fixed it and why that worked... + + ## Key Takeaway + The generalizable insight for future reference... + ``` + + ### summarized_result guidelines + - 1-3 sentences capturing the key finding + - Should be useful even without reading the full body + - Focus on the "what" not the "how" + + ## CLI Commands + + ### Listing topics + ```bash + deepwork topics --expert "expert-name" + ``` + Returns markdown list with links, keywords, sorted by last_updated. + + ### Listing learnings + ```bash + deepwork learnings --expert "expert-name" + ``` + Returns markdown list with links, summaries, sorted by last_updated. + + ## How Experts Become Agents + + Running `deepwork sync` generates Claude agents in `.claude/agents/`: + + - Filename: `dwe_[expert-name].md` + - Agent name: `[expert-name]` + - Body: full_expertise + dynamic topic/learning lists + + The dynamic embedding ensures agents always access current topics and learnings: + ``` + $(deepwork topics --expert "expert-name") + $(deepwork learnings --expert "expert-name") + ``` + + ## Evolution Strategy + + Experts should evolve through use: + + 1. **Start minimal**: Begin with core expertise, add topics/learnings as needed + 2. **Capture immediately**: Document learnings right after solving problems + 3. **Refine periodically**: Review and consolidate as patterns emerge + 4. **Prune actively**: Remove outdated content, merge redundant topics + + ## Naming Conventions + + ### Expert folders + - Use lowercase with underscores: `rails_activejob`, `social_marketing` + - Be specific enough to be useful: `react_hooks` not just `react` + - Avoid redundant words: `activejob` not `activejob_expert` + + ### Topic files + - Use lowercase with underscores: `retry_handling.md` + - Name describes the subject: `queue_configuration.md` + - Filenames are organizational only - the `name` frontmatter is displayed + + ### Learning files + - Use lowercase with underscores: `job_errors_not_going_to_sentry.md` + - Name captures the problem or discovery + - Can be longer/descriptive since they're not referenced programmatically diff --git a/src/deepwork/standard/experts/experts/learnings/.gitkeep b/src/deepwork/standard/experts/experts/learnings/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/deepwork/standard/experts/experts/learnings/keep_experts_focused.md b/src/deepwork/standard/experts/experts/learnings/keep_experts_focused.md new file mode 100644 index 00000000..b77186a0 --- /dev/null +++ b/src/deepwork/standard/experts/experts/learnings/keep_experts_focused.md @@ -0,0 +1,33 @@ +--- +name: Keep Experts Focused +last_updated: 2025-01-30 +summarized_result: | + Broad experts like "Rails" or "JavaScript" become too large and unfocused. + Better to create specific experts like "Rails ActiveJob" or "React Hooks". +--- + +## Context + +When initially designing the experts system, we considered creating broad, +comprehensive experts that would cover entire technology stacks. + +## Investigation + +Testing showed that broad experts: +- Generated overwhelming amounts of content +- Struggled to provide specific, actionable guidance +- Made it difficult to know which expert to invoke +- Led to duplication across multiple broad experts + +## Resolution + +Adopted a principle of focused experts with clear boundaries: +- Each expert covers a specific domain or technology subset +- The discovery_description clearly indicates scope +- Topics dive deep rather than wide +- Learnings capture domain-specific insights + +## Key Takeaway + +An expert should be narrow enough that you can articulate its scope in 1-2 +sentences. If you can't, it's probably too broad. diff --git a/src/deepwork/standard/experts/experts/topics/discovery_descriptions.md b/src/deepwork/standard/experts/experts/topics/discovery_descriptions.md new file mode 100644 index 00000000..f68112b9 --- /dev/null +++ b/src/deepwork/standard/experts/experts/topics/discovery_descriptions.md @@ -0,0 +1,83 @@ +--- +name: Writing Discovery Descriptions +keywords: + - discovery + - description + - routing + - selection +last_updated: 2025-01-30 +--- + +# Writing Effective Discovery Descriptions + +The `discovery_description` determines when your expert gets invoked. It's the +"elevator pitch" that helps the system route queries to the right expert. + +## Purpose + +Discovery descriptions are used by other parts of the system to decide: +- Whether to suggest this expert for a task +- Which expert to invoke when multiple could apply +- How to present the expert to users + +## Anatomy of a Good Description + +```yaml +discovery_description: | + Ruby on Rails ActiveJob - background job processing including + queue configuration, retry strategies, error handling, and + integration with queue backends like Sidekiq and SolidQueue. +``` + +Components: +1. **Domain identifier**: "Ruby on Rails ActiveJob" +2. **Core capability**: "background job processing" +3. **Specific coverage**: "queue configuration, retry strategies..." +4. **Boundaries**: "integration with queue backends like..." + +## Guidelines + +### Be Specific +Bad: "Helps with background jobs" +Good: "Rails ActiveJob background processing - queues, retries, error handling" + +### Include Key Terms +Include terms users would search for. If someone asks about "Sidekiq retries", +the description should contain those words. + +### Set Boundaries +Indicate what's in and out of scope. "...including X, Y, Z" signals coverage. + +### Keep It Scannable +1-3 sentences max. The system needs to quickly evaluate relevance. + +### Avoid Marketing Speak +Bad: "The ultimate guide to mastering background jobs" +Good: "ActiveJob configuration, error handling, and queue backend integration" + +## Examples + +### Too Vague +```yaml +discovery_description: Helps with Rails development +``` + +### Too Narrow +```yaml +discovery_description: How to configure exponential backoff in ActiveJob +``` + +### Just Right +```yaml +discovery_description: | + Rails ActiveJob expertise - background job processing, queue + configuration, retry strategies, error handling, and integration + with Sidekiq, SolidQueue, and other queue backends. +``` + +## Testing Your Description + +Ask yourself: +1. If I had this problem, would I find this expert? +2. Does it differentiate from similar experts? +3. Can I tell what's covered in 5 seconds? diff --git a/src/deepwork/standard/experts/experts/topics/expert_design_patterns.md b/src/deepwork/standard/experts/experts/topics/expert_design_patterns.md new file mode 100644 index 00000000..3ce13a61 --- /dev/null +++ b/src/deepwork/standard/experts/experts/topics/expert_design_patterns.md @@ -0,0 +1,69 @@ +--- +name: Expert Design Patterns +keywords: + - patterns + - structure + - organization + - best practices +last_updated: 2025-01-30 +--- + +# Expert Design Patterns + +Common patterns for structuring effective experts. + +## The Layered Knowledge Pattern + +Structure expertise from general to specific: + +1. **full_expertise**: Core concepts, decision frameworks, common patterns +2. **topics/**: Deep dives into specific subjects +3. **learnings/**: Concrete experiences and edge cases + +This mirrors how humans learn - start with foundations, then specialize. + +## The Problem-Solution Pattern + +For domains centered around solving problems: + +- **full_expertise**: Problem categories, diagnostic approaches, solution frameworks +- **topics/**: Specific problem types with detailed solutions +- **learnings/**: Real debugging sessions and unexpected fixes + +Works well for: troubleshooting guides, error handling, debugging domains. + +## The Reference Pattern + +For domains with lots of factual information: + +- **full_expertise**: Overview, when to use what, quick reference +- **topics/**: Detailed reference on specific APIs, configs, options +- **learnings/**: Gotchas and undocumented behaviors + +Works well for: API documentation, configuration guides, tool references. + +## The Process Pattern + +For domains with sequential workflows: + +- **full_expertise**: Overall process, decision points, success criteria +- **topics/**: Detailed steps for each phase +- **learnings/**: Process failures and improvements + +Works well for: deployment procedures, review processes, onboarding. + +## Anti-Patterns to Avoid + +### The Kitchen Sink +Cramming everything into full_expertise. If it's over 5 pages, split into topics. + +### The Empty Shell +Creating expert.yml with minimal content and empty topics/learnings folders. +Start with meaningful content or don't create the expert yet. + +### The Stale Expert +Never updating after initial creation. Set a reminder to review quarterly. + +### The Duplicate Expert +Creating overlapping experts. Better to have one comprehensive expert than +several fragmented ones. diff --git a/src/deepwork/templates/claude/agent-expert.md.jinja b/src/deepwork/templates/claude/agent-expert.md.jinja new file mode 100644 index 00000000..bfb02c14 --- /dev/null +++ b/src/deepwork/templates/claude/agent-expert.md.jinja @@ -0,0 +1,33 @@ +{# +Template: agent-expert.md.jinja +Purpose: Generates an expert agent file for Claude Code + +Template Variables: + - expert_name: string - Expert identifier (e.g., "rails-activejob") + - discovery_description: string - Short description for expert discovery + - full_expertise: string - Complete expertise knowledge payload + - topics_count: int - Number of topics in this expert + - learnings_count: int - Number of learnings in this expert +#} +--- +name: {{ expert_name }} +description: "{{ discovery_description | replace('"', '\\"') | replace('\n', ' ') | truncate(200) }}" +--- + +{{ full_expertise }} + +--- + +## Topics + +Detailed documentation on specific subjects within this domain. + +$(deepwork topics --expert "{{ expert_name }}") + +--- + +## Learnings + +Hard-fought insights from real experiences. + +$(deepwork learnings --expert "{{ expert_name }}") diff --git a/tests/integration/test_experts_sync.py b/tests/integration/test_experts_sync.py new file mode 100644 index 00000000..bf0cb147 --- /dev/null +++ b/tests/integration/test_experts_sync.py @@ -0,0 +1,266 @@ +"""Integration tests for expert sync functionality.""" + +from pathlib import Path + +import pytest + +from deepwork.cli.sync import sync_skills +from deepwork.utils.yaml_utils import save_yaml + + +@pytest.fixture +def project_with_experts(tmp_path: Path) -> Path: + """Create a minimal project with experts for sync testing.""" + # Create .deepwork directory structure + deepwork_dir = tmp_path / ".deepwork" + deepwork_dir.mkdir() + + # Create config.yml + config = {"version": "0.1.0", "platforms": ["claude"]} + save_yaml(deepwork_dir / "config.yml", config) + + # Create jobs directory (empty - no jobs to sync) + jobs_dir = deepwork_dir / "jobs" + jobs_dir.mkdir() + + # Create experts directory with an expert + experts_dir = deepwork_dir / "experts" + experts_dir.mkdir() + + expert_dir = experts_dir / "test_expert" + expert_dir.mkdir() + + # Create expert.yml + (expert_dir / "expert.yml").write_text( + """discovery_description: | + Test expert for integration testing + +full_expertise: | + # Test Expert + + You are an expert on testing integrations. + + ## Key Concepts + + - Integration testing + - End-to-end testing +""" + ) + + # Create topics + topics_dir = expert_dir / "topics" + topics_dir.mkdir() + (topics_dir / "basics.md").write_text( + """--- +name: Testing Basics +keywords: + - basics + - fundamentals +last_updated: 2025-01-30 +--- + +Content about testing basics. +""" + ) + + # Create learnings + learnings_dir = expert_dir / "learnings" + learnings_dir.mkdir() + (learnings_dir / "discovery.md").write_text( + """--- +name: Important Discovery +last_updated: 2025-01-20 +summarized_result: | + Found that integration tests should be isolated. +--- + +Details of the discovery. +""" + ) + + # Create .claude directory + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + + return tmp_path + + +class TestExpertSync: + """Tests for expert sync functionality.""" + + def test_sync_creates_expert_agent(self, project_with_experts: Path) -> None: + """Test that sync creates expert agent files.""" + # Run sync + sync_skills(project_with_experts) + + # Check that agent was created + agent_file = project_with_experts / ".claude" / "agents" / "dwe_test-expert.md" + assert agent_file.exists(), f"Expected agent file at {agent_file}" + + def test_sync_agent_has_correct_name(self, project_with_experts: Path) -> None: + """Test that generated agent has correct name field.""" + sync_skills(project_with_experts) + + agent_file = project_with_experts / ".claude" / "agents" / "dwe_test-expert.md" + content = agent_file.read_text() + + assert "name: test-expert" in content + + def test_sync_agent_has_description(self, project_with_experts: Path) -> None: + """Test that generated agent has description from discovery_description.""" + sync_skills(project_with_experts) + + agent_file = project_with_experts / ".claude" / "agents" / "dwe_test-expert.md" + content = agent_file.read_text() + + assert "integration testing" in content.lower() + + def test_sync_agent_has_full_expertise(self, project_with_experts: Path) -> None: + """Test that generated agent includes full_expertise content.""" + sync_skills(project_with_experts) + + agent_file = project_with_experts / ".claude" / "agents" / "dwe_test-expert.md" + content = agent_file.read_text() + + assert "expert on testing integrations" in content + assert "Key Concepts" in content + + def test_sync_agent_has_dynamic_topics_command(self, project_with_experts: Path) -> None: + """Test that generated agent includes dynamic topics command embedding.""" + sync_skills(project_with_experts) + + agent_file = project_with_experts / ".claude" / "agents" / "dwe_test-expert.md" + content = agent_file.read_text() + + assert '$(deepwork topics --expert "test-expert")' in content + + def test_sync_agent_has_dynamic_learnings_command(self, project_with_experts: Path) -> None: + """Test that generated agent includes dynamic learnings command embedding.""" + sync_skills(project_with_experts) + + agent_file = project_with_experts / ".claude" / "agents" / "dwe_test-expert.md" + content = agent_file.read_text() + + assert '$(deepwork learnings --expert "test-expert")' in content + + +class TestExpertSyncMultiple: + """Tests for syncing multiple experts.""" + + def test_sync_multiple_experts(self, tmp_path: Path) -> None: + """Test syncing multiple experts creates multiple agent files.""" + # Create project structure + deepwork_dir = tmp_path / ".deepwork" + deepwork_dir.mkdir() + save_yaml(deepwork_dir / "config.yml", {"version": "0.1.0", "platforms": ["claude"]}) + (deepwork_dir / "jobs").mkdir() + + experts_dir = deepwork_dir / "experts" + experts_dir.mkdir() + + # Create multiple experts + for name in ["expert_a", "expert_b", "expert_c"]: + expert_dir = experts_dir / name + expert_dir.mkdir() + (expert_dir / "expert.yml").write_text( + f"discovery_description: Expert {name}\nfull_expertise: Content for {name}." + ) + + (tmp_path / ".claude").mkdir() + + # Run sync + sync_skills(tmp_path) + + # Check all agents were created + agents_dir = tmp_path / ".claude" / "agents" + assert (agents_dir / "dwe_expert-a.md").exists() + assert (agents_dir / "dwe_expert-b.md").exists() + assert (agents_dir / "dwe_expert-c.md").exists() + + +class TestExpertSyncNoExperts: + """Tests for syncing when no experts exist.""" + + def test_sync_no_experts_directory(self, tmp_path: Path) -> None: + """Test sync works when no experts directory exists.""" + # Create minimal project without experts + deepwork_dir = tmp_path / ".deepwork" + deepwork_dir.mkdir() + save_yaml(deepwork_dir / "config.yml", {"version": "0.1.0", "platforms": ["claude"]}) + (deepwork_dir / "jobs").mkdir() + (tmp_path / ".claude").mkdir() + + # Should not raise + sync_skills(tmp_path) + + # Agents directory may or may not exist, but should have no agent files + agents_dir = tmp_path / ".claude" / "agents" + if agents_dir.exists(): + agent_files = list(agents_dir.glob("dwe_*.md")) + assert len(agent_files) == 0 + + def test_sync_empty_experts_directory(self, tmp_path: Path) -> None: + """Test sync works when experts directory is empty.""" + # Create project with empty experts directory + deepwork_dir = tmp_path / ".deepwork" + deepwork_dir.mkdir() + save_yaml(deepwork_dir / "config.yml", {"version": "0.1.0", "platforms": ["claude"]}) + (deepwork_dir / "jobs").mkdir() + (deepwork_dir / "experts").mkdir() # Empty experts dir + (tmp_path / ".claude").mkdir() + + # Should not raise + sync_skills(tmp_path) + + +class TestExpertSyncWithJobs: + """Tests for syncing experts alongside jobs.""" + + def test_sync_experts_and_jobs_together(self, tmp_path: Path) -> None: + """Test that sync handles both experts and jobs.""" + # Create project structure + deepwork_dir = tmp_path / ".deepwork" + deepwork_dir.mkdir() + save_yaml(deepwork_dir / "config.yml", {"version": "0.1.0", "platforms": ["claude"]}) + + # Create a job + job_dir = deepwork_dir / "jobs" / "test_job" + job_dir.mkdir(parents=True) + (job_dir / "job.yml").write_text( + """name: test_job +version: 1.0.0 +summary: A test job +steps: + - id: step_one + name: Step One + description: First step + instructions_file: steps/step_one.md + outputs: + - output.md +""" + ) + steps_dir = job_dir / "steps" + steps_dir.mkdir() + (steps_dir / "step_one.md").write_text("Do the first step.") + + # Create an expert + expert_dir = deepwork_dir / "experts" / "test_expert" + expert_dir.mkdir(parents=True) + (expert_dir / "expert.yml").write_text( + "discovery_description: Test expert\nfull_expertise: Expert content." + ) + + (tmp_path / ".claude").mkdir() + + # Run sync + sync_skills(tmp_path) + + # Check both job skills and expert agents were created + skills_dir = tmp_path / ".claude" / "skills" + assert skills_dir.exists() + # Job should have created skills + assert any(skills_dir.iterdir()) + + agents_dir = tmp_path / ".claude" / "agents" + assert agents_dir.exists() + assert (agents_dir / "dwe_test-expert.md").exists() diff --git a/tests/unit/test_expert_schema.py b/tests/unit/test_expert_schema.py new file mode 100644 index 00000000..506b888c --- /dev/null +++ b/tests/unit/test_expert_schema.py @@ -0,0 +1,216 @@ +"""Tests for expert schema validation.""" + +import pytest + +from deepwork.schemas.expert_schema import ( + EXPERT_SCHEMA, + LEARNING_FRONTMATTER_SCHEMA, + TOPIC_FRONTMATTER_SCHEMA, +) +from deepwork.utils.validation import ValidationError, validate_against_schema + + +class TestExpertSchema: + """Tests for expert.yml schema validation.""" + + def test_valid_minimal_expert(self) -> None: + """Test valid minimal expert definition.""" + data = { + "discovery_description": "Test expert for unit testing", + "full_expertise": "You are an expert on testing.", + } + # Should not raise + validate_against_schema(data, EXPERT_SCHEMA) + + def test_valid_multiline_expert(self) -> None: + """Test valid expert with multiline content.""" + data = { + "discovery_description": "Test expert for unit testing\nwith multiple lines", + "full_expertise": "# Test Expert\n\nYou are an expert on testing.\n\n## Topics\n\n- Testing basics\n- Advanced testing", + } + # Should not raise + validate_against_schema(data, EXPERT_SCHEMA) + + def test_missing_discovery_description(self) -> None: + """Test that missing discovery_description fails validation.""" + data = { + "full_expertise": "You are an expert on testing.", + } + with pytest.raises(ValidationError, match="discovery_description"): + validate_against_schema(data, EXPERT_SCHEMA) + + def test_missing_full_expertise(self) -> None: + """Test that missing full_expertise fails validation.""" + data = { + "discovery_description": "Test expert", + } + with pytest.raises(ValidationError, match="full_expertise"): + validate_against_schema(data, EXPERT_SCHEMA) + + def test_empty_discovery_description(self) -> None: + """Test that empty discovery_description fails validation.""" + data = { + "discovery_description": "", + "full_expertise": "You are an expert on testing.", + } + with pytest.raises(ValidationError): + validate_against_schema(data, EXPERT_SCHEMA) + + def test_empty_full_expertise(self) -> None: + """Test that empty full_expertise fails validation.""" + data = { + "discovery_description": "Test expert", + "full_expertise": "", + } + with pytest.raises(ValidationError): + validate_against_schema(data, EXPERT_SCHEMA) + + def test_additional_properties_not_allowed(self) -> None: + """Test that additional properties are not allowed.""" + data = { + "discovery_description": "Test expert", + "full_expertise": "You are an expert on testing.", + "extra_field": "not allowed", + } + with pytest.raises(ValidationError, match="extra_field"): + validate_against_schema(data, EXPERT_SCHEMA) + + +class TestTopicFrontmatterSchema: + """Tests for topic frontmatter schema validation.""" + + def test_valid_minimal_topic(self) -> None: + """Test valid minimal topic with just name.""" + data = {"name": "Test Topic"} + # Should not raise + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_valid_full_topic(self) -> None: + """Test valid topic with all fields.""" + data = { + "name": "Test Topic", + "keywords": ["testing", "unit test", "pytest"], + "last_updated": "2025-01-30", + } + # Should not raise + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_missing_name(self) -> None: + """Test that missing name fails validation.""" + data = { + "keywords": ["testing"], + } + with pytest.raises(ValidationError, match="name"): + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_empty_name(self) -> None: + """Test that empty name fails validation.""" + data = {"name": ""} + with pytest.raises(ValidationError): + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_invalid_date_format(self) -> None: + """Test that invalid date format fails validation.""" + data = { + "name": "Test Topic", + "last_updated": "January 30, 2025", + } + with pytest.raises(ValidationError, match="last_updated"): + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_valid_date_format(self) -> None: + """Test that valid YYYY-MM-DD date format passes.""" + data = { + "name": "Test Topic", + "last_updated": "2025-01-30", + } + # Should not raise + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_empty_keywords_list(self) -> None: + """Test that empty keywords list is allowed.""" + data = { + "name": "Test Topic", + "keywords": [], + } + # Should not raise + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + def test_additional_properties_not_allowed(self) -> None: + """Test that additional properties are not allowed.""" + data = { + "name": "Test Topic", + "extra": "not allowed", + } + with pytest.raises(ValidationError, match="extra"): + validate_against_schema(data, TOPIC_FRONTMATTER_SCHEMA) + + +class TestLearningFrontmatterSchema: + """Tests for learning frontmatter schema validation.""" + + def test_valid_minimal_learning(self) -> None: + """Test valid minimal learning with just name.""" + data = {"name": "Test Learning"} + # Should not raise + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_valid_full_learning(self) -> None: + """Test valid learning with all fields.""" + data = { + "name": "Test Learning", + "last_updated": "2025-01-30", + "summarized_result": "Discovered that X causes Y under Z conditions.", + } + # Should not raise + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_missing_name(self) -> None: + """Test that missing name fails validation.""" + data = { + "summarized_result": "Some finding", + } + with pytest.raises(ValidationError, match="name"): + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_empty_name(self) -> None: + """Test that empty name fails validation.""" + data = {"name": ""} + with pytest.raises(ValidationError): + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_empty_summarized_result(self) -> None: + """Test that empty summarized_result fails validation.""" + data = { + "name": "Test Learning", + "summarized_result": "", + } + with pytest.raises(ValidationError): + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_invalid_date_format(self) -> None: + """Test that invalid date format fails validation.""" + data = { + "name": "Test Learning", + "last_updated": "30-01-2025", + } + with pytest.raises(ValidationError, match="last_updated"): + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_multiline_summarized_result(self) -> None: + """Test that multiline summarized_result is allowed.""" + data = { + "name": "Test Learning", + "summarized_result": "First line\nSecond line\nThird line", + } + # Should not raise + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) + + def test_additional_properties_not_allowed(self) -> None: + """Test that additional properties are not allowed.""" + data = { + "name": "Test Learning", + "extra": "not allowed", + } + with pytest.raises(ValidationError, match="extra"): + validate_against_schema(data, LEARNING_FRONTMATTER_SCHEMA) diff --git a/tests/unit/test_experts_cli.py b/tests/unit/test_experts_cli.py new file mode 100644 index 00000000..a4603bff --- /dev/null +++ b/tests/unit/test_experts_cli.py @@ -0,0 +1,300 @@ +"""Tests for expert CLI commands.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from deepwork.cli.experts import learnings, topics + + +@pytest.fixture +def runner() -> CliRunner: + """Create a Click test runner.""" + return CliRunner() + + +@pytest.fixture +def project_with_expert(tmp_path: Path) -> Path: + """Create a project with an expert.""" + # Create .deepwork/experts directory + experts_dir = tmp_path / ".deepwork" / "experts" + experts_dir.mkdir(parents=True) + + # Create an expert + expert_dir = experts_dir / "test_expert" + expert_dir.mkdir() + + # Create expert.yml + (expert_dir / "expert.yml").write_text( + """discovery_description: | + Test expert for CLI testing + +full_expertise: | + You are an expert on testing CLI commands. +""" + ) + + # Create topics directory with topics + topics_dir = expert_dir / "topics" + topics_dir.mkdir() + + (topics_dir / "basics.md").write_text( + """--- +name: Testing Basics +keywords: + - testing + - basics +last_updated: 2025-01-30 +--- + +Content about testing basics. +""" + ) + + (topics_dir / "advanced.md").write_text( + """--- +name: Advanced Testing +keywords: + - advanced + - mocking +last_updated: 2025-01-15 +--- + +Content about advanced testing. +""" + ) + + # Create learnings directory with learnings + learnings_dir = expert_dir / "learnings" + learnings_dir.mkdir() + + (learnings_dir / "discovery.md").write_text( + """--- +name: Important Discovery +last_updated: 2025-01-20 +summarized_result: | + Found that X causes Y under Z conditions. +--- + +Full details of the discovery. +""" + ) + + return tmp_path + + +class TestTopicsCommand: + """Tests for the topics command.""" + + def test_topics_command_help(self, runner: CliRunner) -> None: + """Test topics command shows help.""" + result = runner.invoke(topics, ["--help"]) + + assert result.exit_code == 0 + assert "List topics for an expert" in result.output + assert "--expert" in result.output + + def test_topics_command_requires_expert(self, runner: CliRunner) -> None: + """Test topics command requires --expert option.""" + result = runner.invoke(topics, []) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "required" in result.output.lower() + + def test_topics_command_lists_topics( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test topics command lists topics for an expert.""" + result = runner.invoke( + topics, ["--expert", "test-expert", "--path", str(project_with_expert)] + ) + + assert result.exit_code == 0 + assert "Testing Basics" in result.output + assert "Advanced Testing" in result.output + assert "testing" in result.output # keyword + assert "advanced" in result.output # keyword + + def test_topics_command_markdown_format( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test topics command outputs markdown format.""" + result = runner.invoke( + topics, ["--expert", "test-expert", "--path", str(project_with_expert)] + ) + + assert result.exit_code == 0 + # Should contain markdown links + assert "[Testing Basics]" in result.output + assert "(topics/" in result.output + + def test_topics_command_sorted_by_date( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test topics are sorted by most recently updated.""" + result = runner.invoke( + topics, ["--expert", "test-expert", "--path", str(project_with_expert)] + ) + + assert result.exit_code == 0 + # Testing Basics (2025-01-30) should come before Advanced Testing (2025-01-15) + basics_pos = result.output.find("Testing Basics") + advanced_pos = result.output.find("Advanced Testing") + assert basics_pos < advanced_pos + + def test_topics_command_expert_not_found( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test topics command with nonexistent expert.""" + result = runner.invoke( + topics, ["--expert", "nonexistent", "--path", str(project_with_expert)] + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_topics_command_no_experts_dir(self, runner: CliRunner, tmp_path: Path) -> None: + """Test topics command when no experts directory exists.""" + result = runner.invoke(topics, ["--expert", "test", "--path", str(tmp_path)]) + + assert result.exit_code != 0 + assert "No experts directory" in result.output or "not found" in result.output.lower() + + def test_topics_command_no_topics(self, runner: CliRunner, tmp_path: Path) -> None: + """Test topics command when expert has no topics.""" + # Create expert without topics + experts_dir = tmp_path / ".deepwork" / "experts" / "empty_expert" + experts_dir.mkdir(parents=True) + (experts_dir / "expert.yml").write_text( + "discovery_description: Empty expert\nfull_expertise: Nothing here." + ) + + result = runner.invoke( + topics, ["--expert", "empty-expert", "--path", str(tmp_path)] + ) + + assert result.exit_code == 0 + assert "No topics yet" in result.output + + +class TestLearningsCommand: + """Tests for the learnings command.""" + + def test_learnings_command_help(self, runner: CliRunner) -> None: + """Test learnings command shows help.""" + result = runner.invoke(learnings, ["--help"]) + + assert result.exit_code == 0 + assert "List learnings for an expert" in result.output + assert "--expert" in result.output + + def test_learnings_command_requires_expert(self, runner: CliRunner) -> None: + """Test learnings command requires --expert option.""" + result = runner.invoke(learnings, []) + + assert result.exit_code != 0 + + def test_learnings_command_lists_learnings( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test learnings command lists learnings for an expert.""" + result = runner.invoke( + learnings, ["--expert", "test-expert", "--path", str(project_with_expert)] + ) + + assert result.exit_code == 0 + assert "Important Discovery" in result.output + assert "X causes Y" in result.output + + def test_learnings_command_markdown_format( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test learnings command outputs markdown format.""" + result = runner.invoke( + learnings, ["--expert", "test-expert", "--path", str(project_with_expert)] + ) + + assert result.exit_code == 0 + # Should contain markdown links + assert "[Important Discovery]" in result.output + assert "(learnings/" in result.output + + def test_learnings_command_expert_not_found( + self, runner: CliRunner, project_with_expert: Path + ) -> None: + """Test learnings command with nonexistent expert.""" + result = runner.invoke( + learnings, ["--expert", "nonexistent", "--path", str(project_with_expert)] + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_learnings_command_no_learnings(self, runner: CliRunner, tmp_path: Path) -> None: + """Test learnings command when expert has no learnings.""" + # Create expert without learnings + experts_dir = tmp_path / ".deepwork" / "experts" / "empty_expert" + experts_dir.mkdir(parents=True) + (experts_dir / "expert.yml").write_text( + "discovery_description: Empty expert\nfull_expertise: Nothing here." + ) + + result = runner.invoke( + learnings, ["--expert", "empty-expert", "--path", str(tmp_path)] + ) + + assert result.exit_code == 0 + assert "No learnings yet" in result.output + + +class TestExpertNameResolution: + """Tests for expert name resolution (dashes vs underscores).""" + + def test_expert_with_underscores_resolved( + self, runner: CliRunner, tmp_path: Path + ) -> None: + """Test that expert-name resolves to expert_name folder.""" + # Create expert with underscore folder name + experts_dir = tmp_path / ".deepwork" / "experts" / "rails_activejob" + experts_dir.mkdir(parents=True) + (experts_dir / "expert.yml").write_text( + "discovery_description: Rails ActiveJob\nfull_expertise: Content." + ) + + # Topics directory + topics_dir = experts_dir / "topics" + topics_dir.mkdir() + (topics_dir / "test.md").write_text("---\nname: Test\n---\nContent") + + # Query with dashes + result = runner.invoke( + topics, ["--expert", "rails-activejob", "--path", str(tmp_path)] + ) + + assert result.exit_code == 0 + assert "Test" in result.output + + def test_available_experts_shown_on_error( + self, runner: CliRunner, tmp_path: Path + ) -> None: + """Test that available experts are listed when expert not found.""" + # Create some experts + experts_dir = tmp_path / ".deepwork" / "experts" + experts_dir.mkdir(parents=True) + + for name in ["expert_a", "expert_b"]: + ed = experts_dir / name + ed.mkdir() + (ed / "expert.yml").write_text( + f"discovery_description: {name}\nfull_expertise: Content." + ) + + # Query nonexistent expert + result = runner.invoke( + topics, ["--expert", "nonexistent", "--path", str(tmp_path)] + ) + + assert result.exit_code != 0 + assert "expert-a" in result.output.lower() or "expert_a" in result.output.lower() + assert "expert-b" in result.output.lower() or "expert_b" in result.output.lower() diff --git a/tests/unit/test_experts_generator.py b/tests/unit/test_experts_generator.py new file mode 100644 index 00000000..7ca76065 --- /dev/null +++ b/tests/unit/test_experts_generator.py @@ -0,0 +1,256 @@ +"""Tests for expert agent generator.""" + +from pathlib import Path + +import pytest + +from deepwork.core.adapters import ClaudeAdapter +from deepwork.core.experts_generator import ExpertGenerator, ExpertGeneratorError +from deepwork.core.experts_parser import ExpertDefinition, Learning, Topic + + +class TestExpertGenerator: + """Tests for ExpertGenerator class.""" + + def test_init_default_templates(self) -> None: + """Test initialization with default templates directory.""" + generator = ExpertGenerator() + + assert generator.templates_dir.exists() + assert (generator.templates_dir / "claude").exists() + + def test_init_custom_templates(self, tmp_path: Path) -> None: + """Test initialization with custom templates directory.""" + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + + generator = ExpertGenerator(templates_dir) + + assert generator.templates_dir == templates_dir + + def test_init_nonexistent_templates(self, tmp_path: Path) -> None: + """Test initialization with nonexistent templates fails.""" + with pytest.raises(ExpertGeneratorError, match="not found"): + ExpertGenerator(tmp_path / "nonexistent") + + def test_get_agent_filename(self) -> None: + """Test agent filename generation.""" + generator = ExpertGenerator() + + assert generator.get_agent_filename("rails-activejob") == "dwe_rails-activejob.md" + assert generator.get_agent_filename("experts") == "dwe_experts.md" + + def test_get_agent_name(self) -> None: + """Test agent name generation.""" + generator = ExpertGenerator() + + assert generator.get_agent_name("rails-activejob") == "rails-activejob" + assert generator.get_agent_name("experts") == "experts" + + +class TestGenerateExpertAgent: + """Tests for generating expert agent files.""" + + @pytest.fixture + def generator(self) -> ExpertGenerator: + """Create a generator instance.""" + return ExpertGenerator() + + @pytest.fixture + def claude_adapter(self, tmp_path: Path) -> ClaudeAdapter: + """Create a Claude adapter.""" + return ClaudeAdapter(project_root=tmp_path) + + @pytest.fixture + def sample_expert(self, tmp_path: Path) -> ExpertDefinition: + """Create a sample expert definition.""" + expert_dir = tmp_path / "rails_activejob" + expert_dir.mkdir(parents=True) + + return ExpertDefinition( + name="rails-activejob", + discovery_description="Rails ActiveJob background processing", + full_expertise="You are an expert on Rails ActiveJob.\n\n## Key Concepts\n\n- Queues\n- Retries", + expert_dir=expert_dir, + topics=[ + Topic(name="Retry Handling", keywords=["retry"], source_file=expert_dir / "topics/retry.md"), + ], + learnings=[ + Learning(name="Sentry Issue", summarized_result="Fixed it", source_file=expert_dir / "learnings/sentry.md"), + ], + ) + + def test_generate_expert_agent_creates_file( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, sample_expert: ExpertDefinition, tmp_path: Path + ) -> None: + """Test that generating an expert agent creates the file.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + agent_path = generator.generate_expert_agent(sample_expert, claude_adapter, output_dir) + + assert agent_path.exists() + assert agent_path.name == "dwe_rails-activejob.md" + assert agent_path.parent.name == "agents" + + def test_generate_expert_agent_content( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, sample_expert: ExpertDefinition, tmp_path: Path + ) -> None: + """Test the content of generated expert agent file.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + agent_path = generator.generate_expert_agent(sample_expert, claude_adapter, output_dir) + content = agent_path.read_text() + + # Check frontmatter + assert "name: rails-activejob" in content + assert "Rails ActiveJob" in content + + # Check full_expertise is included + assert "expert on Rails ActiveJob" in content + assert "Key Concepts" in content + + # Check dynamic command embedding + assert '$(deepwork topics --expert "rails-activejob")' in content + assert '$(deepwork learnings --expert "rails-activejob")' in content + + def test_generate_expert_agent_creates_agents_dir( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, sample_expert: ExpertDefinition, tmp_path: Path + ) -> None: + """Test that generating creates the agents directory if needed.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + assert not (output_dir / "agents").exists() + + generator.generate_expert_agent(sample_expert, claude_adapter, output_dir) + + assert (output_dir / "agents").exists() + + def test_generate_all_expert_agents( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, tmp_path: Path + ) -> None: + """Test generating agents for multiple experts.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + expert1 = ExpertDefinition( + name="expert-one", + discovery_description="First expert", + full_expertise="Expert one content", + expert_dir=tmp_path / "expert_one", + ) + expert2 = ExpertDefinition( + name="expert-two", + discovery_description="Second expert", + full_expertise="Expert two content", + expert_dir=tmp_path / "expert_two", + ) + + agent_paths = generator.generate_all_expert_agents([expert1, expert2], claude_adapter, output_dir) + + assert len(agent_paths) == 2 + assert all(p.exists() for p in agent_paths) + assert {p.name for p in agent_paths} == {"dwe_expert-one.md", "dwe_expert-two.md"} + + def test_generate_empty_experts_list( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, tmp_path: Path + ) -> None: + """Test generating with empty experts list.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + agent_paths = generator.generate_all_expert_agents([], claude_adapter, output_dir) + + assert agent_paths == [] + + +class TestExpertAgentTemplate: + """Tests for the expert agent template structure.""" + + @pytest.fixture + def generator(self) -> ExpertGenerator: + """Create a generator instance.""" + return ExpertGenerator() + + @pytest.fixture + def claude_adapter(self, tmp_path: Path) -> ClaudeAdapter: + """Create a Claude adapter.""" + return ClaudeAdapter(project_root=tmp_path) + + def test_template_has_yaml_frontmatter( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, tmp_path: Path + ) -> None: + """Test that generated agent has YAML frontmatter.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + expert = ExpertDefinition( + name="test-expert", + discovery_description="Test description", + full_expertise="Test expertise", + expert_dir=tmp_path / "test_expert", + ) + + agent_path = generator.generate_expert_agent(expert, claude_adapter, output_dir) + content = agent_path.read_text() + + # Check YAML frontmatter markers + assert content.startswith("---\n") + lines = content.split("\n") + # Find second --- + second_marker = None + for i, line in enumerate(lines[1:], 1): + if line == "---": + second_marker = i + break + assert second_marker is not None, "YAML frontmatter not properly closed" + + def test_template_escapes_description( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, tmp_path: Path + ) -> None: + """Test that description with quotes is properly escaped.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + expert = ExpertDefinition( + name="test-expert", + discovery_description='Description with "quotes" inside', + full_expertise="Test expertise", + expert_dir=tmp_path / "test_expert", + ) + + agent_path = generator.generate_expert_agent(expert, claude_adapter, output_dir) + content = agent_path.read_text() + + # Should escape quotes in description + assert r'\"quotes\"' in content or "quotes" in content + + def test_template_truncates_long_description( + self, generator: ExpertGenerator, claude_adapter: ClaudeAdapter, tmp_path: Path + ) -> None: + """Test that long descriptions are truncated.""" + output_dir = tmp_path / ".claude" + output_dir.mkdir() + + # Create a very long description + long_description = "A" * 500 + + expert = ExpertDefinition( + name="test-expert", + discovery_description=long_description, + full_expertise="Test expertise", + expert_dir=tmp_path / "test_expert", + ) + + agent_path = generator.generate_expert_agent(expert, claude_adapter, output_dir) + content = agent_path.read_text() + + # Extract description from frontmatter + lines = content.split("\n") + for line in lines: + if line.startswith("description:"): + # Should be truncated + assert len(line) < 500 + break diff --git a/tests/unit/test_experts_parser.py b/tests/unit/test_experts_parser.py new file mode 100644 index 00000000..a8e89c4b --- /dev/null +++ b/tests/unit/test_experts_parser.py @@ -0,0 +1,563 @@ +"""Tests for expert definition parser.""" + +from datetime import date +from pathlib import Path + +import pytest + +from deepwork.core.experts_parser import ( + ExpertDefinition, + ExpertParseError, + Learning, + Topic, + _folder_name_to_expert_name, + discover_experts, + format_learnings_markdown, + format_topics_markdown, + parse_expert_definition, + parse_learning_file, + parse_topic_file, +) + + +class TestFolderNameConversion: + """Tests for folder name to expert name conversion.""" + + def test_underscores_to_dashes(self) -> None: + """Test that underscores are converted to dashes.""" + assert _folder_name_to_expert_name("rails_activejob") == "rails-activejob" + + def test_spaces_to_dashes(self) -> None: + """Test that spaces are converted to dashes.""" + assert _folder_name_to_expert_name("rails activejob") == "rails-activejob" + + def test_mixed_conversion(self) -> None: + """Test mixed underscores and spaces.""" + assert _folder_name_to_expert_name("rails_active job") == "rails-active-job" + + def test_no_conversion_needed(self) -> None: + """Test name that doesn't need conversion.""" + assert _folder_name_to_expert_name("experts") == "experts" + + def test_already_dashes(self) -> None: + """Test name that already has dashes.""" + assert _folder_name_to_expert_name("rails-activejob") == "rails-activejob" + + +class TestTopic: + """Tests for Topic dataclass.""" + + def test_from_dict_minimal(self) -> None: + """Test creating topic from minimal dict.""" + data = {"name": "Test Topic"} + topic = Topic.from_dict(data) + + assert topic.name == "Test Topic" + assert topic.keywords == [] + assert topic.last_updated is None + assert topic.body == "" + + def test_from_dict_full(self) -> None: + """Test creating topic from full dict.""" + data = { + "name": "Retry Handling", + "keywords": ["retry", "backoff"], + "last_updated": "2025-01-30", + } + topic = Topic.from_dict(data, body="Content here", source_file=Path("topics/retry.md")) + + assert topic.name == "Retry Handling" + assert topic.keywords == ["retry", "backoff"] + assert topic.last_updated == date(2025, 1, 30) + assert topic.body == "Content here" + assert topic.source_file == Path("topics/retry.md") + + def test_relative_path(self) -> None: + """Test relative path generation.""" + topic = Topic(name="Test", source_file=Path("/some/path/topics/test.md")) + assert topic.relative_path == "topics/test.md" + + def test_relative_path_no_source(self) -> None: + """Test relative path when no source file.""" + topic = Topic(name="Test") + assert topic.relative_path is None + + +class TestLearning: + """Tests for Learning dataclass.""" + + def test_from_dict_minimal(self) -> None: + """Test creating learning from minimal dict.""" + data = {"name": "Test Learning"} + learning = Learning.from_dict(data) + + assert learning.name == "Test Learning" + assert learning.last_updated is None + assert learning.summarized_result is None + assert learning.body == "" + + def test_from_dict_full(self) -> None: + """Test creating learning from full dict.""" + data = { + "name": "Job errors not going to Sentry", + "last_updated": "2025-01-20", + "summarized_result": "Sentry changed their gem.", + } + learning = Learning.from_dict( + data, body="Full content", source_file=Path("learnings/sentry.md") + ) + + assert learning.name == "Job errors not going to Sentry" + assert learning.last_updated == date(2025, 1, 20) + assert learning.summarized_result == "Sentry changed their gem." + assert learning.body == "Full content" + assert learning.source_file == Path("learnings/sentry.md") + + def test_relative_path(self) -> None: + """Test relative path generation.""" + learning = Learning(name="Test", source_file=Path("/some/path/learnings/test.md")) + assert learning.relative_path == "learnings/test.md" + + +class TestExpertDefinition: + """Tests for ExpertDefinition dataclass.""" + + def test_from_dict(self) -> None: + """Test creating expert definition from dict.""" + data = { + "discovery_description": "Test expert", + "full_expertise": "You are an expert.", + } + expert = ExpertDefinition.from_dict(data, Path("/path/to/rails_activejob")) + + assert expert.name == "rails-activejob" + assert expert.discovery_description == "Test expert" + assert expert.full_expertise == "You are an expert." + assert expert.expert_dir == Path("/path/to/rails_activejob") + assert expert.topics == [] + assert expert.learnings == [] + + def test_from_dict_with_topics_and_learnings(self) -> None: + """Test creating expert with topics and learnings.""" + data = { + "discovery_description": "Test expert", + "full_expertise": "You are an expert.", + } + topics = [Topic(name="Topic 1"), Topic(name="Topic 2")] + learnings = [Learning(name="Learning 1")] + + expert = ExpertDefinition.from_dict( + data, Path("/path/to/test_expert"), topics=topics, learnings=learnings + ) + + assert len(expert.topics) == 2 + assert len(expert.learnings) == 1 + + def test_get_topics_sorted(self) -> None: + """Test topics are sorted by most recently updated.""" + topics = [ + Topic(name="Old", last_updated=date(2025, 1, 1)), + Topic(name="New", last_updated=date(2025, 1, 30)), + Topic(name="No Date"), + ] + expert = ExpertDefinition( + name="test", + discovery_description="Test", + full_expertise="Test", + expert_dir=Path("/test"), + topics=topics, + ) + + sorted_topics = expert.get_topics_sorted() + assert sorted_topics[0].name == "New" + assert sorted_topics[1].name == "Old" + assert sorted_topics[2].name == "No Date" + + def test_get_learnings_sorted(self) -> None: + """Test learnings are sorted by most recently updated.""" + learnings = [ + Learning(name="Old", last_updated=date(2025, 1, 1)), + Learning(name="New", last_updated=date(2025, 1, 30)), + Learning(name="No Date"), + ] + expert = ExpertDefinition( + name="test", + discovery_description="Test", + full_expertise="Test", + expert_dir=Path("/test"), + learnings=learnings, + ) + + sorted_learnings = expert.get_learnings_sorted() + assert sorted_learnings[0].name == "New" + assert sorted_learnings[1].name == "Old" + assert sorted_learnings[2].name == "No Date" + + +class TestParseTopicFile: + """Tests for parsing topic files.""" + + def test_parse_valid_topic(self, tmp_path: Path) -> None: + """Test parsing a valid topic file.""" + topic_file = tmp_path / "test_topic.md" + topic_file.write_text( + """--- +name: Test Topic +keywords: + - testing + - unit test +last_updated: 2025-01-30 +--- + +This is the topic content. +""" + ) + + topic = parse_topic_file(topic_file) + + assert topic.name == "Test Topic" + assert topic.keywords == ["testing", "unit test"] + assert topic.last_updated == date(2025, 1, 30) + assert "topic content" in topic.body + assert topic.source_file == topic_file + + def test_parse_minimal_topic(self, tmp_path: Path) -> None: + """Test parsing a minimal topic file.""" + topic_file = tmp_path / "minimal.md" + topic_file.write_text( + """--- +name: Minimal Topic +--- + +Content here. +""" + ) + + topic = parse_topic_file(topic_file) + + assert topic.name == "Minimal Topic" + assert topic.keywords == [] + assert topic.last_updated is None + + def test_parse_topic_missing_frontmatter(self, tmp_path: Path) -> None: + """Test parsing topic without frontmatter fails.""" + topic_file = tmp_path / "no_frontmatter.md" + topic_file.write_text("Just content without frontmatter") + + with pytest.raises(ExpertParseError, match="frontmatter"): + parse_topic_file(topic_file) + + def test_parse_topic_missing_name(self, tmp_path: Path) -> None: + """Test parsing topic without name fails.""" + topic_file = tmp_path / "no_name.md" + topic_file.write_text( + """--- +keywords: + - test +--- + +Content +""" + ) + + with pytest.raises(ExpertParseError, match="name"): + parse_topic_file(topic_file) + + def test_parse_topic_nonexistent(self, tmp_path: Path) -> None: + """Test parsing nonexistent topic file fails.""" + with pytest.raises(ExpertParseError, match="does not exist"): + parse_topic_file(tmp_path / "nonexistent.md") + + +class TestParseLearningFile: + """Tests for parsing learning files.""" + + def test_parse_valid_learning(self, tmp_path: Path) -> None: + """Test parsing a valid learning file.""" + learning_file = tmp_path / "test_learning.md" + learning_file.write_text( + """--- +name: Test Learning +last_updated: 2025-01-30 +summarized_result: | + Discovered that X causes Y. +--- + +## Context +Full learning content here. +""" + ) + + learning = parse_learning_file(learning_file) + + assert learning.name == "Test Learning" + assert learning.last_updated == date(2025, 1, 30) + assert "Discovered that X causes Y" in learning.summarized_result + assert "Full learning content" in learning.body + + def test_parse_minimal_learning(self, tmp_path: Path) -> None: + """Test parsing a minimal learning file.""" + learning_file = tmp_path / "minimal.md" + learning_file.write_text( + """--- +name: Minimal Learning +--- + +Content here. +""" + ) + + learning = parse_learning_file(learning_file) + + assert learning.name == "Minimal Learning" + assert learning.last_updated is None + assert learning.summarized_result is None + + def test_parse_learning_missing_frontmatter(self, tmp_path: Path) -> None: + """Test parsing learning without frontmatter fails.""" + learning_file = tmp_path / "no_frontmatter.md" + learning_file.write_text("Just content") + + with pytest.raises(ExpertParseError, match="frontmatter"): + parse_learning_file(learning_file) + + +class TestParseExpertDefinition: + """Tests for parsing expert definitions.""" + + def test_parse_valid_expert(self, tmp_path: Path) -> None: + """Test parsing a valid expert definition.""" + expert_dir = tmp_path / "test_expert" + expert_dir.mkdir() + + # Create expert.yml + (expert_dir / "expert.yml").write_text( + """discovery_description: | + Test expert for unit testing + +full_expertise: | + You are an expert on testing. +""" + ) + + # Create topics directory with a topic + topics_dir = expert_dir / "topics" + topics_dir.mkdir() + (topics_dir / "basics.md").write_text( + """--- +name: Testing Basics +keywords: + - basics +last_updated: 2025-01-30 +--- + +Content +""" + ) + + # Create learnings directory with a learning + learnings_dir = expert_dir / "learnings" + learnings_dir.mkdir() + (learnings_dir / "discovery.md").write_text( + """--- +name: Important Discovery +last_updated: 2025-01-20 +summarized_result: Found something important. +--- + +Details +""" + ) + + expert = parse_expert_definition(expert_dir) + + assert expert.name == "test-expert" + assert "unit testing" in expert.discovery_description + assert "expert on testing" in expert.full_expertise + assert len(expert.topics) == 1 + assert expert.topics[0].name == "Testing Basics" + assert len(expert.learnings) == 1 + assert expert.learnings[0].name == "Important Discovery" + + def test_parse_expert_no_topics_or_learnings(self, tmp_path: Path) -> None: + """Test parsing expert without topics or learnings directories.""" + expert_dir = tmp_path / "minimal_expert" + expert_dir.mkdir() + + (expert_dir / "expert.yml").write_text( + """discovery_description: Minimal expert +full_expertise: Minimal expertise. +""" + ) + + expert = parse_expert_definition(expert_dir) + + assert expert.name == "minimal-expert" + assert expert.topics == [] + assert expert.learnings == [] + + def test_parse_expert_missing_expert_yml(self, tmp_path: Path) -> None: + """Test parsing expert without expert.yml fails.""" + expert_dir = tmp_path / "no_yml" + expert_dir.mkdir() + + with pytest.raises(ExpertParseError, match="expert.yml not found"): + parse_expert_definition(expert_dir) + + def test_parse_expert_nonexistent_dir(self, tmp_path: Path) -> None: + """Test parsing nonexistent directory fails.""" + with pytest.raises(ExpertParseError, match="does not exist"): + parse_expert_definition(tmp_path / "nonexistent") + + def test_parse_expert_invalid_yml(self, tmp_path: Path) -> None: + """Test parsing expert with invalid YAML fails.""" + expert_dir = tmp_path / "invalid" + expert_dir.mkdir() + (expert_dir / "expert.yml").write_text("discovery_description: only one field") + + with pytest.raises(ExpertParseError, match="full_expertise"): + parse_expert_definition(expert_dir) + + +class TestDiscoverExperts: + """Tests for discovering expert directories.""" + + def test_discover_experts_multiple(self, tmp_path: Path) -> None: + """Test discovering multiple experts.""" + experts_dir = tmp_path / "experts" + experts_dir.mkdir() + + # Create two expert directories + for name in ["expert_a", "expert_b"]: + expert_dir = experts_dir / name + expert_dir.mkdir() + (expert_dir / "expert.yml").write_text( + f"discovery_description: {name}\nfull_expertise: Content" + ) + + expert_dirs = discover_experts(experts_dir) + + assert len(expert_dirs) == 2 + names = {d.name for d in expert_dirs} + assert names == {"expert_a", "expert_b"} + + def test_discover_experts_empty(self, tmp_path: Path) -> None: + """Test discovering experts in empty directory.""" + experts_dir = tmp_path / "experts" + experts_dir.mkdir() + + expert_dirs = discover_experts(experts_dir) + + assert expert_dirs == [] + + def test_discover_experts_nonexistent(self, tmp_path: Path) -> None: + """Test discovering experts in nonexistent directory.""" + expert_dirs = discover_experts(tmp_path / "nonexistent") + + assert expert_dirs == [] + + def test_discover_experts_ignores_non_expert_dirs(self, tmp_path: Path) -> None: + """Test that directories without expert.yml are ignored.""" + experts_dir = tmp_path / "experts" + experts_dir.mkdir() + + # Valid expert + valid_dir = experts_dir / "valid_expert" + valid_dir.mkdir() + (valid_dir / "expert.yml").write_text( + "discovery_description: Valid\nfull_expertise: Content" + ) + + # Invalid - no expert.yml + invalid_dir = experts_dir / "not_an_expert" + invalid_dir.mkdir() + (invalid_dir / "readme.md").write_text("Not an expert") + + expert_dirs = discover_experts(experts_dir) + + assert len(expert_dirs) == 1 + assert expert_dirs[0].name == "valid_expert" + + +class TestFormatTopicsMarkdown: + """Tests for formatting topics as markdown.""" + + def test_format_topics_empty(self) -> None: + """Test formatting empty topics list.""" + expert = ExpertDefinition( + name="test", + discovery_description="Test", + full_expertise="Test", + expert_dir=Path("/test"), + topics=[], + ) + + result = format_topics_markdown(expert) + + assert result == "_No topics yet._" + + def test_format_topics_with_content(self) -> None: + """Test formatting topics with content.""" + topics = [ + Topic( + name="First Topic", + keywords=["key1", "key2"], + source_file=Path("/test/topics/first.md"), + ), + Topic(name="Second Topic", source_file=Path("/test/topics/second.md")), + ] + expert = ExpertDefinition( + name="test", + discovery_description="Test", + full_expertise="Test", + expert_dir=Path("/test"), + topics=topics, + ) + + result = format_topics_markdown(expert) + + assert "[First Topic](topics/first.md)" in result + assert "[Second Topic](topics/second.md)" in result + assert "Keywords: key1, key2" in result + + +class TestFormatLearningsMarkdown: + """Tests for formatting learnings as markdown.""" + + def test_format_learnings_empty(self) -> None: + """Test formatting empty learnings list.""" + expert = ExpertDefinition( + name="test", + discovery_description="Test", + full_expertise="Test", + expert_dir=Path("/test"), + learnings=[], + ) + + result = format_learnings_markdown(expert) + + assert result == "_No learnings yet._" + + def test_format_learnings_with_content(self) -> None: + """Test formatting learnings with content.""" + learnings = [ + Learning( + name="First Learning", + summarized_result="Found something important.", + source_file=Path("/test/learnings/first.md"), + ), + Learning(name="Second Learning", source_file=Path("/test/learnings/second.md")), + ] + expert = ExpertDefinition( + name="test", + discovery_description="Test", + full_expertise="Test", + expert_dir=Path("/test"), + learnings=learnings, + ) + + result = format_learnings_markdown(expert) + + assert "[First Learning](learnings/first.md)" in result + assert "[Second Learning](learnings/second.md)" in result + assert "Found something important" in result diff --git a/uv.lock b/uv.lock index 5c61745e..433ce43a 100644 --- a/uv.lock +++ b/uv.lock @@ -126,7 +126,7 @@ toml = [ [[package]] name = "deepwork" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -147,6 +147,16 @@ dev = [ { name = "types-pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, @@ -164,6 +174,16 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.0" }, + { name = "pytest", specifier = ">=7.0" }, + { name = "pytest-cov", specifier = ">=4.0" }, + { name = "pytest-mock", specifier = ">=3.10" }, + { name = "ruff", specifier = ">=0.1.0" }, + { name = "types-pyyaml" }, +] + [[package]] name = "gitdb" version = "4.0.12"