diff --git a/.gitignore b/.gitignore index fb4964c..dffca3c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,6 @@ tmp/ # OpenCode .opencode/ -!.opencode/skills/ +!.opencode/skills/*.md .context/ .nx/ \ No newline at end of file diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 813f127..ced7969 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -21,9 +21,7 @@ "MD041": false, // MD024/no-duplicate-heading - Multiple headings with the same content - "MD024": { - "siblings_only": true - }, + "MD024": false, // MD045/no-alt-text - Images should have alt text "MD045": false, @@ -53,7 +51,13 @@ "MD060": false }, // Only lint specific directories - focus on core documentation - "globs": ["docs/**/*.md", "README.md", "CONTRIBUTING.md", "SECURITY.md"], + "globs": [ + "docs/**/*.md", + "README.md", + "CONTRIBUTING.md", + "SECURITY.md", + "AGENTS.md" + ], // Fix mode disabled by default "fix": false } diff --git a/.opencode/skills/brainstorming/SKILL.md b/.opencode/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..cee39e7 --- /dev/null +++ b/.opencode/skills/brainstorming/SKILL.md @@ -0,0 +1,180 @@ +--- +name: dispatching-parallel-agents +description: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies +--- + +# Dispatching Parallel Agents + +## Overview + +When you have multiple unrelated failures (different test files, different subsystems, different bugs), investigating +them sequentially wastes time. Each investigation is independent and can happen in parallel. + +**Core principle:** Dispatch one agent per independent problem domain. Let them work concurrently. + +## When to Use + +```mermaid +graph TD + A["Multiple failures?"] -->|yes| B["Are they independent?"] + B -->|no - related| C["Single agent investigates all"] + B -->|yes| D["Can they work in parallel?"] + D -->|yes| E["Parallel dispatch"] + D -->|no - shared state| F["Sequential agents"] +``` + +**Use when:** + +- 3+ test files failing with different root causes +- Multiple subsystems broken independently +- Each problem can be understood without context from others +- No shared state between investigations + +**Don't use when:** + +- Failures are related (fix one might fix others) +- Need to understand full system state +- Agents would interfere with each other + +## The Pattern + +### 1. Identify Independent Domains + +Group failures by what's broken: + +- File A tests: Tool approval flow +- File B tests: Batch completion behavior +- File C tests: Abort functionality + +Each domain is independent - fixing tool approval doesn't affect abort tests. + +### 2. Create Focused Agent Tasks + +Each agent gets: + +- **Specific scope:** One test file or subsystem +- **Clear goal:** Make these tests pass +- **Constraints:** Don't change other code +- **Expected output:** Summary of what you found and fixed + +### 3. Dispatch in Parallel + +```typescript +// In Claude Code / AI environment +Task("Fix agent-tool-abort.test.ts failures"); +Task("Fix batch-completion-behavior.test.ts failures"); +Task("Fix tool-approval-race-conditions.test.ts failures"); +// All three run concurrently +``` + +### 4. Review and Integrate + +When agents return: + +- Read each summary +- Verify fixes don't conflict +- Run full test suite +- Integrate all changes + +## Agent Prompt Structure + +Good agent prompts are: + +1. **Focused** - One clear problem domain +2. **Self-contained** - All context needed to understand the problem +3. **Specific about output** - What should the agent return? + +```markdown +Fix the 3 failing tests in src/agents/agent-tool-abort.test.ts: + +1. "should abort tool with partial output capture" - expects 'interrupted at' in message +2. "should handle mixed completed and aborted tools" - fast tool aborted instead of completed +3. "should properly track pendingToolCount" - expects 3 results but gets 0 + +These are timing/race condition issues. Your task: + +1. Read the test file and understand what each test verifies +2. Identify root cause - timing issues or actual bugs? +3. Fix by: + - Replacing arbitrary timeouts with event-based waiting + - Fixing bugs in abort implementation if found + - Adjusting test expectations if testing changed behavior + +Do NOT just increase timeouts - find the real issue. + +Return: Summary of what you found and what you fixed. +``` + +## Common Mistakes + +**❌ Too broad:** "Fix all the tests" - agent gets lost **✅ Specific:** "Fix agent-tool-abort.test.ts" - focused scope + +**❌ No context:** "Fix the race condition" - agent doesn't know where **✅ Context:** Paste the error messages and test +names + +**❌ No constraints:** Agent might refactor everything **✅ Constraints:** "Do NOT change production code" or "Fix tests +only" + +**❌ Vague output:** "Fix it" - you don't know what changed **✅ Specific:** "Return summary of root cause and changes" + +## When NOT to Use + +**Related failures:** Fixing one might fix others - investigate together first **Need full context:** Understanding +requires seeing entire system **Exploratory debugging:** You don't know what's broken yet **Shared state:** Agents would +interfere (editing same files, using same resources) + +## Real Example from Session + +**Scenario:** 6 test failures across 3 files after major refactoring + +**Failures:** + +- agent-tool-abort.test.ts: 3 failures (timing issues) +- batch-completion-behavior.test.ts: 2 failures (tools not executing) +- tool-approval-race-conditions.test.ts: 1 failure (execution count = 0) + +**Decision:** Independent domains - abort logic separate from batch completion separate from race conditions + +**Dispatch:** + +``` +Agent 1 → Fix agent-tool-abort.test.ts +Agent 2 → Fix batch-completion-behavior.test.ts +Agent 3 → Fix tool-approval-race-conditions.test.ts +``` + +**Results:** + +- Agent 1: Replaced timeouts with event-based waiting +- Agent 2: Fixed event structure bug (threadId in wrong place) +- Agent 3: Added wait for async tool execution to complete + +**Integration:** All fixes independent, no conflicts, full suite green + +**Time saved:** 3 problems solved in parallel vs sequentially + +## Key Benefits + +1. **Parallelization** - Multiple investigations happen simultaneously +2. **Focus** - Each agent has narrow scope, less context to track +3. **Independence** - Agents don't interfere with each other +4. **Speed** - 3 problems solved in time of 1 + +## Verification + +After agents return: + +1. **Review each summary** - Understand what changed +2. **Check for conflicts** - Did agents edit same code? +3. **Run full suite** - Verify all fixes work together +4. **Spot check** - Agents can make systematic errors + +## Real-World Impact + +From debugging session (2025-10-03): + +- 6 failures across 3 files +- 3 agents dispatched in parallel +- All investigations completed concurrently +- All fixes integrated successfully +- Zero conflicts between agent changes diff --git a/.opencode/skills/disaptching-parallel-agents/SKILL.md b/.opencode/skills/disaptching-parallel-agents/SKILL.md new file mode 100644 index 0000000..81a357b --- /dev/null +++ b/.opencode/skills/disaptching-parallel-agents/SKILL.md @@ -0,0 +1,189 @@ +--- +name: dispatching-parallel-agents +description: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies +--- + +# Dispatching Parallel Agents + +## Overview + +When you have multiple unrelated failures (different test files, different subsystems, different bugs), investigating +them sequentially wastes time. Each investigation is independent and can happen in parallel. + +**Core principle:** Dispatch one agent per independent problem domain. Let them work concurrently. + +## When to Use + +```dot +digraph when_to_use { + "Multiple failures?" [shape=diamond]; + "Are they independent?" [shape=diamond]; + "Single agent investigates all" [shape=box]; + "One agent per problem domain" [shape=box]; + "Can they work in parallel?" [shape=diamond]; + "Sequential agents" [shape=box]; + "Parallel dispatch" [shape=box]; + + "Multiple failures?" -> "Are they independent?" [label="yes"]; + "Are they independent?" -> "Single agent investigates all" [label="no - related"]; + "Are they independent?" -> "Can they work in parallel?" [label="yes"]; + "Can they work in parallel?" -> "Parallel dispatch" [label="yes"]; + "Can they work in parallel?" -> "Sequential agents" [label="no - shared state"]; +} +``` + +**Use when:** + +- 3+ test files failing with different root causes +- Multiple subsystems broken independently +- Each problem can be understood without context from others +- No shared state between investigations + +**Don't use when:** + +- Failures are related (fix one might fix others) +- Need to understand full system state +- Agents would interfere with each other + +## The Pattern + +### 1. Identify Independent Domains + +Group failures by what's broken: + +- File A tests: Tool approval flow +- File B tests: Batch completion behavior +- File C tests: Abort functionality + +Each domain is independent - fixing tool approval doesn't affect abort tests. + +### 2. Create Focused Agent Tasks + +Each agent gets: + +- **Specific scope:** One test file or subsystem +- **Clear goal:** Make these tests pass +- **Constraints:** Don't change other code +- **Expected output:** Summary of what you found and fixed + +### 3. Dispatch in Parallel + +```typescript +// In Claude Code / AI environment +Task("Fix agent-tool-abort.test.ts failures"); +Task("Fix batch-completion-behavior.test.ts failures"); +Task("Fix tool-approval-race-conditions.test.ts failures"); +// All three run concurrently +``` + +### 4. Review and Integrate + +When agents return: + +- Read each summary +- Verify fixes don't conflict +- Run full test suite +- Integrate all changes + +## Agent Prompt Structure + +Good agent prompts are: + +1. **Focused** - One clear problem domain +2. **Self-contained** - All context needed to understand the problem +3. **Specific about output** - What should the agent return? + +```markdown +Fix the 3 failing tests in src/agents/agent-tool-abort.test.ts: + +1. "should abort tool with partial output capture" - expects 'interrupted at' in message +2. "should handle mixed completed and aborted tools" - fast tool aborted instead of completed +3. "should properly track pendingToolCount" - expects 3 results but gets 0 + +These are timing/race condition issues. Your task: + +1. Read the test file and understand what each test verifies +2. Identify root cause - timing issues or actual bugs? +3. Fix by: + - Replacing arbitrary timeouts with event-based waiting + - Fixing bugs in abort implementation if found + - Adjusting test expectations if testing changed behavior + +Do NOT just increase timeouts - find the real issue. + +Return: Summary of what you found and what you fixed. +``` + +## Common Mistakes + +**❌ Too broad:** "Fix all the tests" - agent gets lost **✅ Specific:** "Fix agent-tool-abort.test.ts" - focused scope + +**❌ No context:** "Fix the race condition" - agent doesn't know where **✅ Context:** Paste the error messages and test +names + +**❌ No constraints:** Agent might refactor everything **✅ Constraints:** "Do NOT change production code" or "Fix tests +only" + +**❌ Vague output:** "Fix it" - you don't know what changed **✅ Specific:** "Return summary of root cause and changes" + +## When NOT to Use + +**Related failures:** Fixing one might fix others - investigate together first **Need full context:** Understanding +requires seeing entire system **Exploratory debugging:** You don't know what's broken yet **Shared state:** Agents would +interfere (editing same files, using same resources) + +## Real Example from Session + +**Scenario:** 6 test failures across 3 files after major refactoring + +**Failures:** + +- agent-tool-abort.test.ts: 3 failures (timing issues) +- batch-completion-behavior.test.ts: 2 failures (tools not executing) +- tool-approval-race-conditions.test.ts: 1 failure (execution count = 0) + +**Decision:** Independent domains - abort logic separate from batch completion separate from race conditions + +**Dispatch:** + +``` +Agent 1 → Fix agent-tool-abort.test.ts +Agent 2 → Fix batch-completion-behavior.test.ts +Agent 3 → Fix tool-approval-race-conditions.test.ts +``` + +**Results:** + +- Agent 1: Replaced timeouts with event-based waiting +- Agent 2: Fixed event structure bug (threadId in wrong place) +- Agent 3: Added wait for async tool execution to complete + +**Integration:** All fixes independent, no conflicts, full suite green + +**Time saved:** 3 problems solved in parallel vs sequentially + +## Key Benefits + +1. **Parallelization** - Multiple investigations happen simultaneously +2. **Focus** - Each agent has narrow scope, less context to track +3. **Independence** - Agents don't interfere with each other +4. **Speed** - 3 problems solved in time of 1 + +## Verification + +After agents return: + +1. **Review each summary** - Understand what changed +2. **Check for conflicts** - Did agents edit same code? +3. **Run full suite** - Verify all fixes work together +4. **Spot check** - Agents can make systematic errors + +## Real-World Impact + +From debugging session (2025-10-03): + +- 6 failures across 3 files +- 3 agents dispatched in parallel +- All investigations completed concurrently +- All fixes integrated successfully +- Zero conflicts between agent changes diff --git a/.opencode/skills/executing-plans/SKILL.md b/.opencode/skills/executing-plans/SKILL.md new file mode 100644 index 0000000..dac0902 --- /dev/null +++ b/.opencode/skills/executing-plans/SKILL.md @@ -0,0 +1,96 @@ +--- +name: executing-plans +description: Use when you have a written implementation plan to execute in a separate session with review checkpoints +--- + +# Executing Plans + +## Overview + +Load plan, review critically, execute tasks in batches, report for review between batches. + +**Core principle:** Batch execution with checkpoints for architect review. + +**Announce at start:** "I'm using the executing-plans skill to implement this plan." + +## The Process + +### Step 1: Load and Review Plan + +1. Read plan file +2. Review critically - identify any questions or concerns about the plan +3. If concerns: Raise them with your human partner before starting +4. If no concerns: Create TodoWrite and proceed + +### Step 2: Execute Batch + +**Default: First 3 tasks** + +For each task: + +1. Mark as in_progress +2. Follow each step exactly (plan has bite-sized steps) +3. Run verifications as specified +4. Mark as completed + +### Step 3: Report + +When batch complete: + +- Show what was implemented +- Show verification output +- Say: "Ready for feedback." + +### Step 4: Continue + +Based on feedback: + +- Apply changes if needed +- Execute next batch +- Repeat until complete + +### Step 5: Complete Development + +After all tasks complete and verified: + +- Announce: "I'm using the finishing-a-development-branch skill to complete this work." +- **REQUIRED SUB-SKILL:** Use superpowers:finishing-a-development-branch +- Follow that skill to verify tests, present options, execute choice + +## When to Stop and Ask for Help + +**STOP executing immediately when:** + +- Hit a blocker mid-batch (missing dependency, test fails, instruction unclear) +- Plan has critical gaps preventing starting +- You don't understand an instruction +- Verification fails repeatedly + +**Ask for clarification rather than guessing.** + +## When to Revisit Earlier Steps + +**Return to Review (Step 1) when:** + +- Partner updates the plan based on your feedback +- Fundamental approach needs rethinking + +**Don't force through blockers** - stop and ask. + +## Remember + +- Review plan critically first +- Follow plan steps exactly +- Don't skip verifications +- Reference skills when plan says to +- Between batches: just report and wait +- Stop when blocked, don't guess +- Never start implementation on main/master branch without explicit user consent + +## Integration + +**Required workflow skills:** + +- **superpowers:using-git-worktrees** - REQUIRED: Set up isolated workspace before starting +- **superpowers:writing-plans** - Creates the plan this skill executes +- **superpowers:finishing-a-development-branch** - Complete development after all tasks diff --git a/.opencode/skills/pr-iteration/SKILL.md b/.opencode/skills/pr-iteration/SKILL.md new file mode 100644 index 0000000..7d57354 --- /dev/null +++ b/.opencode/skills/pr-iteration/SKILL.md @@ -0,0 +1,447 @@ +--- +name: pr-iteration +description: + Guide the agent through iterative PR/MR refinement to ensure all CI checks, tests, linting, and validation passes + before considering the work complete. Never declare success until all automated checks pass. +license: MIT +compatibility: opencode +metadata: + category: workflow + tool: git +--- + +## What I Do + +I ensure that all Pull Requests (PRs) or Merge Requests (MRs) pass **every automated check** before being declared +complete. I guide the agent through iterative refinement until CI is green. + +## Core Principles + +1. **Iterate until all checks pass** - Never declare "done" while any check is failing +2. **Run checks locally first** - Fix issues locally before pushing to avoid CI noise +3. **Address cascading failures systematically** - Fix one category at a time (lint → typecheck → test → build) +4. **Commit incremental fixes** - Save progress as you fix issues, don't batch all fixes into one commit +5. **Verify after each fix** - Re-run the failed check to confirm it's resolved + +## When to Use Me + +- Creating a new PR/MR from scratch +- Addressing CI failures on an existing PR/MR +- Refactoring code that affects multiple packages +- Adding features that require cross-package changes +- Merging dependent PRs in sequence + +## Iteration Workflow + +### Phase 1: Pre-Flight Checks (Before Creating PR) + +```bash +# 1. Install dependencies +bun install + +# 2. Run linting +bun run lint + +# 3. Run type checking +bun run typecheck + +# 4. Run tests +bun run test + +# 5. Run build +bun run build +``` + +**If any check fails → FIX IT before proceeding** + +### Phase 2: PR Creation & Initial Validation + +After creating PR/MR: + +```bash +# 1. Verify all checks pass locally +bun run lint && bun run typecheck && bun run test && bun run build + +# 2. Push and wait for CI + +# 3. Monitor CI results +``` + +### Phase 3: Iterative Fix Loop + +**While any check is failing:** + +``` +CHECK → FAIL → FIX → COMMIT → PUSH → RE-CHECK → (REPEAT UNTIL PASS) +``` + +**Priority order for fixing failures:** + +1. **Linting errors** (formatting, style) - Usually quickest to fix +2. **Type errors** - May require interface changes +3. **Test failures** - Logic bugs or test updates needed +4. **Build failures** - Often the most complex + +### Phase 4: Final Verification + +```bash +# Run full check suite one more time +bun run lint +bun run typecheck +bun run test +bun run build +``` + +**Only declare PR ready when ALL checks pass.** + +## Common Failure Patterns & Fixes + +### Pattern 1: Linting Failures + +**Symptom**: CI fails on `bun run lint` or biome/eslint errors + +**Fix Process**: + +```bash +# Run auto-fix first +bun run lint --fix +# or +biome check --write . + +# If manual fixes needed, address each error +# Re-run lint to verify +bun run lint +``` + +**Iterate until**: `bun run lint` exits 0 + +### Pattern 2: Type Errors + +**Symptom**: `bun run typecheck` or `tsc` fails + +**Fix Process**: + +```bash +# Run type check +bun run typecheck + +# Fix each error: +# - Add missing types +# - Fix interface mismatches +# - Update imports + +# Re-run typecheck +bun run typecheck +``` + +**Iterate until**: `bun run typecheck` exits 0 + +### Pattern 3: Test Failures + +**Symptom**: Tests fail locally or in CI + +**Fix Process**: + +```bash +# Run failing test +bun test path/to/failing-test.ts + +# Debug and fix +# - Check test expectations +# - Verify mock setup +# - Review logic changes + +# Re-run test +bun test path/to/failing-test.ts + +# Run full test suite +bun run test +``` + +**Iterate until**: All tests pass + +### Pattern 4: Build Failures + +**Symptom**: `bun run build` or `nx build` fails + +**Fix Process**: + +```bash +# Run build with verbose output +bunx nx run-many --target=build --all --verbose + +# Identify failing package +# Check error message + +# Common causes: +# - Missing dependencies in package.json +# - Import errors +# - TypeScript compilation errors + +# Fix issue +# Re-run build +bun run build +``` + +**Iterate until**: Build succeeds + +### Pattern 5: Cross-Package Failures + +**Symptom**: Changes in one package break another + +**Fix Process**: + +```bash +# Identify affected packages +bunx nx affected:graph + +# Build dependency graph first +bunx nx run-many --target=build --projects=dependency-package + +# Then build dependent +bunx nx run-many --target=build --projects=dependent-package + +# Run tests on affected packages +bunx nx affected:test +``` + +**Iterate until**: All affected packages build and test successfully + +## CI/CD Integration + +### GitHub Actions Workflow + +```yaml +name: Validate PR +on: [pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + + - name: Install + run: bun install + + - name: Lint + run: bun run lint + + - name: Type Check + run: bun run typecheck + + - name: Test + run: bun run test + + - name: Build + run: bun run build +``` + +### Required Status Checks + +Configure branch protection to require: + +- ✅ Lint passing +- ✅ Type check passing +- ✅ Tests passing +- ✅ Build passing + +**No PR should be mergeable until all checks pass.** + +## Debugging CI Failures + +### Step 1: Replicate Locally + +```bash +# Run the exact command that failed in CI +# (Check your workflow file for the command) +biome ci --reporter=github --diagnostic-level=error . --verbose + +# Compare results +``` + +### Step 2: Check Environment Differences + +Common differences: + +- **Node/Bun version** - Match CI version exactly +- **Operating system** - CI runs Linux; develop on Linux/macOS/WSL +- **Clean state** - CI starts fresh; you may have cached files + +```bash +# Clean and reinstall +rm -rf node_modules bun.lockb +bun install + +# Re-run checks +``` + +### Step 3: Fix and Verify + +```bash +# Fix the issue + +# Verify locally +bun run lint && bun run typecheck && bun run test && bun run build + +# Commit and push +git add . +git commit -m "fix: resolve CI failures" +git push +``` + +### Step 4: Monitor CI + +- Watch CI logs for new failures +- Repeat fix loop if needed + +## Multi-PR Dependency Management + +When merging PRs with dependencies (e.g., PR B depends on PR A): + +### Step 1: Merge Base PR First + +```bash +# Merge PR A +git checkout main +git merge feat/pr-a + +# Verify CI passes on main +``` + +### Step 2: Rebase Dependent PR + +```bash +# PR B branch +git checkout feat/pr-b +git rebase main + +# Re-run all checks +bun run lint && bun run typecheck && bun run test && bun run build + +# Push rebased branch +git push --force-with-lease +``` + +### Step 3: Iterate Until Green + +If checks fail after rebase: + +``` +FAIL → ANALYZE (conflicts? dependency changes?) → FIX → PUSH → RE-CHECK +``` + +**Dependencies may introduce breaking changes requiring fixes in dependent PR.** + +## Commit Strategy During Iteration + +### Incremental Commits + +Save progress as you fix issues: + +```bash +# Fix lint errors +git add . +git commit -m "style: fix linting errors" + +# Fix type errors +git add . +git commit -m "fix: resolve type errors" + +# Fix tests +git add . +git commit -m "test: update failing tests" +``` + +### Squash Before Merge (Optional) + +If you prefer clean history: + +```bash +# After all checks pass +git rebase -i main +# Squash fix commits +``` + +## Emergency Recovery + +### CI is completely broken + +```bash +# 1. Check if it's a configuration issue +cat package.json | grep -A 5 '"scripts"' + +# 2. Verify tools are installed +bunx biome --version +bunx tsc --version + +# 3. Check for environment issues +echo $NODE_VERSION +echo $BUN_VERSION + +# 4. Clean slate +rm -rf node_modules bun.lockb +bun install + +# 5. Re-run checks +``` + +### Infinite fix loop + +If you keep fixing and CI keeps failing: + +1. **Take a break** - Step away, review with fresh eyes +2. **Check CI logs carefully** - Read the full error message +3. **Run exact CI command locally** - Don't assume your command is equivalent +4. **Ask for help** - Sometimes a second pair of eyes helps + +## Success Criteria + +A PR is **ONLY** ready when: + +- ✅ `bun run lint` passes (0 errors, 0 warnings) +- ✅ `bun run typecheck` passes (0 type errors) +- ✅ `bun run test` passes (all tests green) +- ✅ `bun run build` passes (all packages build) +- ✅ CI pipeline is green +- ✅ No merge conflicts with target branch + +**If any check fails → CONTINUE ITERATING** + +## Remember + +✅ **DO**: + +- Run checks locally before pushing +- Fix one category at a time +- Commit incremental fixes +- Verify after each fix +- Monitor CI after every push +- Rebase dependent PRs after base PR merges + +❌ **NEVER**: + +- Declare PR ready with failing checks +- Ignore CI failures +- Batch all fixes into one giant commit +- Skip local verification and rely on CI +- Merge with "will fix later" mentality + +## Quick Reference + +```bash +# Full validation suite +bun run lint && bun run typecheck && bun run test && bun run build + +# Fix linting +bun run lint --fix + +# Fix types +bun run typecheck # then fix errors manually + +# Run specific test +bun test path/to/test.ts + +# Build specific package +bunx nx run package-name:build +``` diff --git a/.opencode/skills/recieving-code-review/SKILL.md b/.opencode/skills/recieving-code-review/SKILL.md new file mode 100644 index 0000000..979d633 --- /dev/null +++ b/.opencode/skills/recieving-code-review/SKILL.md @@ -0,0 +1,230 @@ +--- +name: receiving-code-review +description: + Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or + technically questionable - requires technical rigor and verification, not performative agreement or blind + implementation +--- + +# Code Review Reception + +## Overview + +Code review requires technical evaluation, not emotional performance. + +**Core principle:** Verify before implementing. Ask before assuming. Technical correctness over social comfort. + +## The Response Pattern + +``` +WHEN receiving code review feedback: + +1. READ: Complete feedback without reacting +2. UNDERSTAND: Restate requirement in own words (or ask) +3. VERIFY: Check against codebase reality +4. EVALUATE: Technically sound for THIS codebase? +5. RESPOND: Technical acknowledgment or reasoned pushback +6. IMPLEMENT: One item at a time, test each +``` + +## Forbidden Responses + +**NEVER:** + +- "You're absolutely right!" (explicit AGENT.md violation) +- "Great point!" / "Excellent feedback!" (performative) +- "Let me implement that now" (before verification) + +**INSTEAD:** + +- Restate the technical requirement +- Ask clarifying questions +- Push back with technical reasoning if wrong +- Just start working (actions > words) + +## Handling Unclear Feedback + +``` +IF any item is unclear: + STOP - do not implement anything yet + ASK for clarification on unclear items + +WHY: Items may be related. Partial understanding = wrong implementation. +``` + +**Example:** + +``` +your human partner: "Fix 1-6" +You understand 1,2,3,6. Unclear on 4,5. + +❌ WRONG: Implement 1,2,3,6 now, ask about 4,5 later +✅ RIGHT: "I understand items 1,2,3,6. Need clarification on 4 and 5 before proceeding." +``` + +## Source-Specific Handling + +### From your human partner + +- **Trusted** - implement after understanding +- **Still ask** if scope unclear +- **No performative agreement** +- **Skip to action** or technical acknowledgment + +### From External Reviewers + +``` +BEFORE implementing: + 1. Check: Technically correct for THIS codebase? + 2. Check: Breaks existing functionality? + 3. Check: Reason for current implementation? + 4. Check: Works on all platforms/versions? + 5. Check: Does reviewer understand full context? + +IF suggestion seems wrong: + Push back with technical reasoning + +IF can't easily verify: + Say so: "I can't verify this without [X]. Should I [investigate/ask/proceed]?" + +IF conflicts with your human partner's prior decisions: + Stop and discuss with your human partner first +``` + +**your human partner's rule:** "External feedback - be skeptical, but check carefully" + +## YAGNI Check for "Professional" Features + +``` +IF reviewer suggests "implementing properly": + grep codebase for actual usage + + IF unused: "This endpoint isn't called. Remove it (YAGNI)?" + IF used: Then implement properly +``` + +**your human partner's rule:** "You and reviewer both report to me. If we don't need this feature, don't add it." + +## Implementation Order + +``` +FOR multi-item feedback: + 1. Clarify anything unclear FIRST + 2. Then implement in this order: + - Blocking issues (breaks, security) + - Simple fixes (typos, imports) + - Complex fixes (refactoring, logic) + 3. Test each fix individually + 4. Verify no regressions +``` + +## When To Push Back + +Push back when: + +- Suggestion breaks existing functionality +- Reviewer lacks full context +- Violates YAGNI (unused feature) +- Technically incorrect for this stack +- Legacy/compatibility reasons exist +- Conflicts with your human partner's architectural decisions + +**How to push back:** + +- Use technical reasoning, not defensiveness +- Ask specific questions +- Reference working tests/code +- Involve your human partner if architectural + +**Signal if uncomfortable pushing back out loud:** "Strange things are afoot at the Circle K" + +## Acknowledging Correct Feedback + +When feedback IS correct: + +``` +✅ "Fixed. [Brief description of what changed]" +✅ "Good catch - [specific issue]. Fixed in [location]." +✅ [Just fix it and show in the code] + +❌ "You're absolutely right!" +❌ "Great point!" +❌ "Thanks for catching that!" +❌ "Thanks for [anything]" +❌ ANY gratitude expression +``` + +**Why no thanks:** Actions speak. Just fix it. The code itself shows you heard the feedback. + +**If you catch yourself about to write "Thanks":** DELETE IT. State the fix instead. + +## Gracefully Correcting Your Pushback + +If you pushed back and were wrong: + +``` +✅ "You were right - I checked [X] and it does [Y]. Implementing now." +✅ "Verified this and you're correct. My initial understanding was wrong because [reason]. Fixing." + +❌ Long apology +❌ Defending why you pushed back +❌ Over-explaining +``` + +State the correction factually and move on. + +## Common Mistakes + +| Mistake | Fix | +| ---------------------------- | ----------------------------------- | +| Performative agreement | State requirement or just act | +| Blind implementation | Verify against codebase first | +| Batch without testing | One at a time, test each | +| Assuming reviewer is right | Check if breaks things | +| Avoiding pushback | Technical correctness > comfort | +| Partial implementation | Clarify all items first | +| Can't verify, proceed anyway | State limitation, ask for direction | + +## Real Examples + +**Performative Agreement (Bad):** + +``` +Reviewer: "Remove legacy code" +❌ "You're absolutely right! Let me remove that..." +``` + +**Technical Verification (Good):** + +``` +Reviewer: "Remove legacy code" +✅ "Checking... build target is 10.15+, this API needs 13+. Need legacy for backward compat. Current impl has wrong bundle ID - fix it or drop pre-13 support?" +``` + +**YAGNI (Good):** + +``` +Reviewer: "Implement proper metrics tracking with database, date filters, CSV export" +✅ "Grepped codebase - nothing calls this endpoint. Remove it (YAGNI)? Or is there usage I'm missing?" +``` + +**Unclear Item (Good):** + +``` +your human partner: "Fix items 1-6" +You understand 1,2,3,6. Unclear on 4,5. +✅ "Understand 1,2,3,6. Need clarification on 4 and 5 before implementing." +``` + +## GitHub Thread Replies + +When replying to inline review comments on GitHub, reply in the comment thread +(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level PR comment. + +## The Bottom Line + +**External feedback = suggestions to evaluate, not orders to follow.** + +Verify. Question. Then implement. + +No performative agreement. Technical rigor always. diff --git a/.opencode/skills/requesting-code-review/SKILL.md b/.opencode/skills/requesting-code-review/SKILL.md new file mode 100644 index 0000000..d32c9b7 --- /dev/null +++ b/.opencode/skills/requesting-code-review/SKILL.md @@ -0,0 +1,115 @@ +--- +name: requesting-code-review +description: Use when completing tasks, implementing major features, or before merging to verify work meets requirements +--- + +# Requesting Code Review + +Dispatch @code-reviewer subagent to catch issues before they cascade. + +**Core principle:** Review early, review often. + +## When to Request Review + +**Mandatory:** + +- After each task in subagent-driven development +- After completing major feature +- Before merge to main + +**Optional but valuable:** + +- When stuck (fresh perspective) +- Before refactoring (baseline check) +- After fixing complex bug + +## How to Request + +**1. Get git SHAs:** + +```bash +BASE_SHA=$(git rev-parse HEAD~1) # or origin/main +HEAD_SHA=$(git rev-parse HEAD) +``` + +**2. Dispatch code-reviewer subagent:** + +Use Task tool with code-reviewer type, fill template at `code-reviewer.md` + +**Placeholders:** + +- `{WHAT_WAS_IMPLEMENTED}` - What you just built +- `{PLAN_OR_REQUIREMENTS}` - What it should do +- `{BASE_SHA}` - Starting commit +- `{HEAD_SHA}` - Ending commit +- `{DESCRIPTION}` - Brief summary + +**3. Act on feedback:** + +- Fix Critical issues immediately +- Fix Important issues before proceeding +- Note Minor issues for later +- Push back if reviewer is wrong (with reasoning) + +## Example + +``` +[Just completed Task 2: Add verification function] + +You: Let me request code review before proceeding. + +BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}') +HEAD_SHA=$(git rev-parse HEAD) + +[Dispatch code-reviewer subagent] + WHAT_WAS_IMPLEMENTED: Verification and repair functions for conversation index + PLAN_OR_REQUIREMENTS: Task 2 from docs/plans/deployment-plan.md + BASE_SHA: a7981ec + HEAD_SHA: 3df7661 + DESCRIPTION: Added verifyIndex() and repairIndex() with 4 issue types + +[Subagent returns]: + Strengths: Clean architecture, real tests + Issues: + Important: Missing progress indicators + Minor: Magic number (100) for reporting interval + Assessment: Ready to proceed + +You: [Fix progress indicators] +[Continue to Task 3] +``` + +## Integration with Workflows + +**Subagent-Driven Development:** + +- Review after EACH task +- Catch issues before they compound +- Fix before moving to next task + +**Executing Plans:** + +- Review after each batch (3 tasks) +- Get feedback, apply, continue + +**Ad-Hoc Development:** + +- Review before merge +- Review when stuck + +## Red Flags + +**Never:** + +- Skip review because "it's simple" +- Ignore Critical issues +- Proceed with unfixed Important issues +- Argue with valid technical feedback + +**If reviewer wrong:** + +- Push back with technical reasoning +- Show code/tests that prove it works +- Request clarification + +See template at: requesting-code-review/code-reviewer.md diff --git a/.opencode/skills/requesting-code-review/code-reviewer.md b/.opencode/skills/requesting-code-review/code-reviewer.md new file mode 100644 index 0000000..68d4eba --- /dev/null +++ b/.opencode/skills/requesting-code-review/code-reviewer.md @@ -0,0 +1,159 @@ +# Code Review Agent + +You are reviewing code changes for production readiness. + +**Your task:** + +1. Review {WHAT_WAS_IMPLEMENTED} +2. Compare against {PLAN_OR_REQUIREMENTS} +3. Check code quality, architecture, testing +4. Categorize issues by severity +5. Assess production readiness + +## What Was Implemented + +{DESCRIPTION} + +## Requirements/Plan + +{PLAN_REFERENCE} + +## Git Range to Review + +**Base:** {BASE_SHA} **Head:** {HEAD_SHA} + +```bash +git diff --stat {BASE_SHA}..{HEAD_SHA} +git diff {BASE_SHA}..{HEAD_SHA} +``` + +## Review Checklist + +**Code Quality:** + +- Clean separation of concerns? +- Proper error handling? +- Type safety (if applicable)? +- DRY principle followed? +- Edge cases handled? + +**Architecture:** + +- Sound design decisions? +- Scalability considerations? +- Performance implications? +- Security concerns? + +**Testing:** + +- Tests actually test logic (not mocks)? +- Edge cases covered? +- Integration tests where needed? +- All tests passing? + +**Requirements:** + +- All plan requirements met? +- Implementation matches spec? +- No scope creep? +- Breaking changes documented? + +**Production Readiness:** + +- Migration strategy (if schema changes)? +- Backward compatibility considered? +- Documentation complete? +- No obvious bugs? + +## Output Format + +### Strengths + +[What's well done? Be specific.] + +### Issues + +#### Critical (Must Fix) + +[Bugs, security issues, data loss risks, broken functionality] + +#### Important (Should Fix) + +[Architecture problems, missing features, poor error handling, test gaps] + +#### Minor (Nice to Have) + +[Code style, optimization opportunities, documentation improvements] + +**For each issue:** + +- File:line reference +- What's wrong +- Why it matters +- How to fix (if not obvious) + +### Recommendations + +[Improvements for code quality, architecture, or process] + +### Assessment + +**Ready to merge?** [Yes/No/With fixes] + +**Reasoning:** [Technical assessment in 1-2 sentences] + +## Critical Rules + +**DO:** + +- Categorize by actual severity (not everything is Critical) +- Be specific (file:line, not vague) +- Explain WHY issues matter +- Acknowledge strengths +- Give clear verdict + +**DON'T:** + +- Say "looks good" without checking +- Mark nitpicks as Critical +- Give feedback on code you didn't review +- Be vague ("improve error handling") +- Avoid giving a clear verdict + +## Example Output + +``` +### Strengths +- Clean database schema with proper migrations (db.ts:15-42) +- Comprehensive test coverage (18 tests, all edge cases) +- Good error handling with fallbacks (summarizer.ts:85-92) + +### Issues + +#### Important +1. **Missing help text in CLI wrapper** + - File: index-conversations:1-31 + - Issue: No --help flag, users won't discover --concurrency + - Fix: Add --help case with usage examples + +2. **Date validation missing** + - File: search.ts:25-27 + - Issue: Invalid dates silently return no results + - Fix: Validate ISO format, throw error with example + +#### Minor +1. **Progress indicators** + - File: indexer.ts:130 + - Issue: No "X of Y" counter for long operations + - Impact: Users don't know how long to wait + +### Recommendations +- Add progress reporting for user experience +- Consider config file for excluded projects (portability) + +### Assessment + +**Ready to merge: With fixes** + +**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality. +``` diff --git a/.opencode/skills/subagent-driven-development/SKILL.md b/.opencode/skills/subagent-driven-development/SKILL.md new file mode 100644 index 0000000..7b7a750 --- /dev/null +++ b/.opencode/skills/subagent-driven-development/SKILL.md @@ -0,0 +1,251 @@ +--- +name: subagent-driven-development +description: Use when executing implementation plans with independent tasks in the current session +--- + +# Subagent-Driven Development + +Execute plan by dispatching fresh subagent per task, with two-stage review after each: spec compliance review first, +then code quality review. + +**Core principle:** Fresh subagent per task + two-stage review (spec then quality) = high quality, fast iteration + +## When to Use + +```mermaid +flowchart LR + have_plan{"Have implementation plan?"} + tasks_independent{"Tasks mostly independent?"} + stay_session{"Stay in this session?"} + subagent["subagent-driven-development"] + executing["executing-plans"] + manual["Manual execution or brainstorm first"] + + have_plan -->|yes| tasks_independent + have_plan -->|no| manual + tasks_independent -->|yes| stay_session + tasks_independent -->|"no - tightly coupled"| manual + stay_session -->|yes| subagent + stay_session -->|"no - parallel session"| executing +``` + +**vs. Executing Plans (parallel session):** + +- Same session (no context switch) +- Fresh subagent per task (no context pollution) +- Two-stage review after each task: spec compliance first, then code quality +- Faster iteration (no human-in-loop between tasks) + +## The Process + +```mermaid +flowchart TB + subgraph cluster_per_task["Per Task"] + d_impl["Dispatch implementer subagent (./implementer-prompt.md)"] + q_impl{"Implementer subagent asks questions?"} + ans["Answer questions, provide context"] + impl["Implementer subagent implements, tests, commits, self-reviews"] + d_spec["Dispatch spec reviewer subagent (./spec-reviewer-prompt.md)"] + spec_ok{"Spec reviewer subagent confirms code matches spec?"} + fix_spec["Implementer subagent fixes spec gaps"] + d_quality["Dispatch code quality reviewer subagent (./code-quality-reviewer-prompt.md)"] + quality_ok{"Code quality reviewer subagent approves?"} + fix_quality["Implementer subagent fixes quality issues"] + mark["Mark task complete in TodoWrite"] + end + + read_plan["Read plan, extract all tasks with full text, note context, create TodoWrite"] + more_tasks{"More tasks remain?"} + final_review["Dispatch final code reviewer subagent for entire implementation"] + finish["Use superpowers:finishing-a-development-branch"] + + read_plan --> d_impl + d_impl --> q_impl + q_impl -->|yes| ans + ans --> d_impl + q_impl -->|no| impl + impl --> d_spec + d_spec --> spec_ok + spec_ok -->|no| fix_spec + fix_spec --> d_spec + spec_ok -->|yes| d_quality + d_quality --> quality_ok + quality_ok -->|no| fix_quality + fix_quality --> d_quality + quality_ok -->|yes| mark + mark --> more_tasks + more_tasks -->|yes| d_impl + more_tasks -->|no| final_review + final_review --> finish +``` + +## Prompt Templates + +- `./implementer-prompt.md` - Dispatch implementer subagent +- `./spec-reviewer-prompt.md` - Dispatch spec compliance reviewer subagent +- `./code-quality-reviewer-prompt.md` - Dispatch code quality reviewer subagent + +## Example Workflow + +``` +You: I'm using Subagent-Driven Development to execute this plan. + +[Read plan file once: docs/plans/feature-plan.md] +[Extract all 5 tasks with full text and context] +[Create TodoWrite with all tasks] + +Task 1: Hook installation script + +[Get Task 1 text and context (already extracted)] +[Dispatch implementation subagent with full task text + context] + +Implementer: "Before I begin - should the hook be installed at user or system level?" + +You: "User level (~/.config/superpowers/hooks/)" + +Implementer: "Got it. Implementing now..." +[Later] Implementer: + - Implemented install-hook command + - Added tests, 5/5 passing + - Self-review: Found I missed --force flag, added it + - Committed + +[Dispatch spec compliance reviewer] +Spec reviewer: ✅ Spec compliant - all requirements met, nothing extra + +[Get git SHAs, dispatch code quality reviewer] +Code reviewer: Strengths: Good test coverage, clean. Issues: None. Approved. + +[Mark Task 1 complete] + +Task 2: Recovery modes + +[Get Task 2 text and context (already extracted)] +[Dispatch implementation subagent with full task text + context] + +Implementer: [No questions, proceeds] +Implementer: + - Added verify/repair modes + - 8/8 tests passing + - Self-review: All good + - Committed + +[Dispatch spec compliance reviewer] +Spec reviewer: ❌ Issues: + - Missing: Progress reporting (spec says "report every 100 items") + - Extra: Added --json flag (not requested) + +[Implementer fixes issues] +Implementer: Removed --json flag, added progress reporting + +[Spec reviewer reviews again] +Spec reviewer: ✅ Spec compliant now + +[Dispatch code quality reviewer] +Code reviewer: Strengths: Solid. Issues (Important): Magic number (100) + +[Implementer fixes] +Implementer: Extracted PROGRESS_INTERVAL constant + +[Code reviewer reviews again] +Code reviewer: ✅ Approved + +[Mark Task 2 complete] + +... + +[After all tasks] +[Dispatch final code-reviewer] +Final reviewer: All requirements met, ready to merge + +Done! +``` + +## Advantages + +**vs. Manual execution:** + +- Subagents follow TDD naturally +- Fresh context per task (no confusion) +- Parallel-safe (subagents don't interfere) +- Subagent can ask questions (before AND during work) + +**vs. Executing Plans:** + +- Same session (no handoff) +- Continuous progress (no waiting) +- Review checkpoints automatic + +**Efficiency gains:** + +- No file reading overhead (controller provides full text) +- Controller curates exactly what context is needed +- Subagent gets complete information upfront +- Questions surfaced before work begins (not after) + +**Quality gates:** + +- Self-review catches issues before handoff +- Two-stage review: spec compliance, then code quality +- Review loops ensure fixes actually work +- Spec compliance prevents over/under-building +- Code quality ensures implementation is well-built + +**Cost:** + +- More subagent invocations (implementer + 2 reviewers per task) +- Controller does more prep work (extracting all tasks upfront) +- Review loops add iterations +- But catches issues early (cheaper than debugging later) + +## Red Flags + +**Never:** + +- Start implementation on main/master branch without explicit user consent +- Skip reviews (spec compliance OR code quality) +- Proceed with unfixed issues +- Dispatch multiple implementation subagents in parallel (conflicts) +- Make subagent read plan file (provide full text instead) +- Skip scene-setting context (subagent needs to understand where task fits) +- Ignore subagent questions (answer before letting them proceed) +- Accept "close enough" on spec compliance (spec reviewer found issues = not done) +- Skip review loops (reviewer found issues = implementer fixes = review again) +- Let implementer self-review replace actual review (both are needed) +- **Start code quality review before spec compliance is ✅** (wrong order) +- Move to next task while either review has open issues + +**If subagent asks questions:** + +- Answer clearly and completely +- Provide additional context if needed +- Don't rush them into implementation + +**If reviewer finds issues:** + +- Implementer (same subagent) fixes them +- Reviewer reviews again +- Repeat until approved +- Don't skip the re-review + +**If subagent fails task:** + +- Dispatch fix subagent with specific instructions +- Don't try to fix manually (context pollution) + +## Integration + +**Required workflow skills:** + +- **superpowers:using-git-worktrees** - REQUIRED: Set up isolated workspace before starting +- **superpowers:writing-plans** - Creates the plan this skill executes +- **superpowers:requesting-code-review** - Code review template for reviewer subagents +- **superpowers:finishing-a-development-branch** - Complete development after all tasks + +**Subagents should use:** + +- **superpowers:test-driven-development** - Subagents follow TDD for each task + +**Alternative workflow:** + +- **superpowers:executing-plans** - Use for parallel session instead of same-session execution diff --git a/.opencode/skills/subagent-driven-development/code-qualitty-reviewer-prompt.md b/.opencode/skills/subagent-driven-development/code-qualitty-reviewer-prompt.md new file mode 100644 index 0000000..0a04745 --- /dev/null +++ b/.opencode/skills/subagent-driven-development/code-qualitty-reviewer-prompt.md @@ -0,0 +1,20 @@ +# Code Quality Reviewer Prompt Template + +Use this template when dispatching a code quality reviewer subagent. + +**Purpose:** Verify implementation is well-built (clean, tested, maintainable) + +**Only dispatch after spec compliance review passes.** + +``` +Task tool (code-reviewer): + Use template at requesting-code-review/code-reviewer.md + + WHAT_WAS_IMPLEMENTED: [from implementer's report] + PLAN_OR_REQUIREMENTS: Task N from [plan-file] + BASE_SHA: [commit before task] + HEAD_SHA: [current commit] + DESCRIPTION: [task summary] +``` + +**Code reviewer returns:** Strengths, Issues (Critical/Important/Minor), Assessment diff --git a/.opencode/skills/subagent-driven-development/implementer-prompt.md b/.opencode/skills/subagent-driven-development/implementer-prompt.md new file mode 100644 index 0000000..db5404b --- /dev/null +++ b/.opencode/skills/subagent-driven-development/implementer-prompt.md @@ -0,0 +1,78 @@ +# Implementer Subagent Prompt Template + +Use this template when dispatching an implementer subagent. + +``` +Task tool (general-purpose): + description: "Implement Task N: [task name]" + prompt: | + You are implementing Task N: [task name] + + ## Task Description + + [FULL TEXT of task from plan - paste it here, don't make subagent read file] + + ## Context + + [Scene-setting: where this fits, dependencies, architectural context] + + ## Before You Begin + + If you have questions about: + - The requirements or acceptance criteria + - The approach or implementation strategy + - Dependencies or assumptions + - Anything unclear in the task description + + **Ask them now.** Raise any concerns before starting work. + + ## Your Job + + Once you're clear on requirements: + 1. Implement exactly what the task specifies + 2. Write tests (following TDD if task says to) + 3. Verify implementation works + 4. Commit your work + 5. Self-review (see below) + 6. Report back + + Work from: [directory] + + **While you work:** If you encounter something unexpected or unclear, **ask questions**. + It's always OK to pause and clarify. Don't guess or make assumptions. + + ## Before Reporting Back: Self-Review + + Review your work with fresh eyes. Ask yourself: + + **Completeness:** + - Did I fully implement everything in the spec? + - Did I miss any requirements? + - Are there edge cases I didn't handle? + + **Quality:** + - Is this my best work? + - Are names clear and accurate (match what things do, not how they work)? + - Is the code clean and maintainable? + + **Discipline:** + - Did I avoid overbuilding (YAGNI)? + - Did I only build what was requested? + - Did I follow existing patterns in the codebase? + + **Testing:** + - Do tests actually verify behavior (not just mock behavior)? + - Did I follow TDD if required? + - Are tests comprehensive? + + If you find issues during self-review, fix them now before reporting. + + ## Report Format + + When done, report: + - What you implemented + - What you tested and test results + - Files changed + - Self-review findings (if any) + - Any issues or concerns +``` diff --git a/.opencode/skills/subagent-driven-development/spec-reviewer-prompt.md b/.opencode/skills/subagent-driven-development/spec-reviewer-prompt.md new file mode 100644 index 0000000..ab5ddb8 --- /dev/null +++ b/.opencode/skills/subagent-driven-development/spec-reviewer-prompt.md @@ -0,0 +1,61 @@ +# Spec Compliance Reviewer Prompt Template + +Use this template when dispatching a spec compliance reviewer subagent. + +**Purpose:** Verify implementer built what was requested (nothing more, nothing less) + +``` +Task tool (general-purpose): + description: "Review spec compliance for Task N" + prompt: | + You are reviewing whether an implementation matches its specification. + + ## What Was Requested + + [FULL TEXT of task requirements] + + ## What Implementer Claims They Built + + [From implementer's report] + + ## CRITICAL: Do Not Trust the Report + + The implementer finished suspiciously quickly. Their report may be incomplete, + inaccurate, or optimistic. You MUST verify everything independently. + + **DO NOT:** + - Take their word for what they implemented + - Trust their claims about completeness + - Accept their interpretation of requirements + + **DO:** + - Read the actual code they wrote + - Compare actual implementation to requirements line by line + - Check for missing pieces they claimed to implement + - Look for extra features they didn't mention + + ## Your Job + + Read the implementation code and verify: + + **Missing requirements:** + - Did they implement everything that was requested? + - Are there requirements they skipped or missed? + - Did they claim something works but didn't actually implement it? + + **Extra/unneeded work:** + - Did they build things that weren't requested? + - Did they over-engineer or add unnecessary features? + - Did they add "nice to haves" that weren't in spec? + + **Misunderstandings:** + - Did they interpret requirements differently than intended? + - Did they solve the wrong problem? + - Did they implement the right feature but wrong way? + + **Verify by reading code, not by trusting report.** + + Report: + - ✅ Spec compliant (if everything matches after code inspection) + - ❌ Issues found: [list specifically what's missing or extra, with file:line references] +``` diff --git a/.opencode/skills/test-driven-development/SKILL.md b/.opencode/skills/test-driven-development/SKILL.md new file mode 100644 index 0000000..65f9473 --- /dev/null +++ b/.opencode/skills/test-driven-development/SKILL.md @@ -0,0 +1,394 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +**Violating the letter of the rules is violating the spirit of the rules.** + +## When to Use + +**Always:** + +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** + +- Throwaway prototypes +- Generated code +- Configuration files + +Thinking "skip TDD just this once"? Stop. That's rationalization. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. + +**No exceptions:** + +- Don't keep it as "reference" +- Don't "adapt" it while writing tests +- Don't look at it +- Delete means delete + +Implement fresh from tests. Period. + +## Red-Green-Refactor + +```mermaid +flowchart LR + red["RED
Write failing test"]:::red + verify_red{"Verify fails
correctly"}:::diamond + green["GREEN
Minimal code"]:::green + verify_green{"Verify passes
All green"}:::diamond + refactor["REFACTOR
Clean up"]:::blue + next([Next]) + + red --> verify_red + verify_red -- yes --> green + verify_red -- "wrong
failure" --> red + green --> verify_green + verify_green -- yes --> refactor + verify_green -- no --> green + refactor --> verify_green + verify_green --> next + + classDef red fill:#ffcccc,stroke:#333,stroke-width:1px; + classDef green fill:#ccffcc,stroke:#333,stroke-width:1px; + classDef blue fill:#ccccff,stroke:#333,stroke-width:1px; + classDef diamond fill:#fff,stroke:#333,stroke-width:1px; +``` + +### RED - Write Failing Test + +Write one minimal test showing what should happen. + +#### Good + +```typescript +test("retries failed operations 3 times", async () => { + let attempts = 0; + const operation = () => { + attempts++; + if (attempts < 3) throw new Error("fail"); + return "success"; + }; + + const result = await retryOperation(operation); + + expect(result).toBe("success"); + expect(attempts).toBe(3); +}); +``` + +Clear name, tests real behavior, one thing + +#### Bad + +```typescript +test("retry works", async () => { + const mock = jest + .fn() + .mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce("success"); + await retryOperation(mock); + expect(mock).toHaveBeenCalledTimes(3); +}); +``` + +Vague name, tests mock not code + +**Requirements:** + +- One behavior +- Clear name +- Real code (no mocks unless unavoidable) + +### Verify RED - Watch It Fail + +**MANDATORY. Never skip.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: + +- Test fails (not errors) +- Failure message is expected +- Fails because feature missing (not typos) + +**Test passes?** You're testing existing behavior. Fix test. + +**Test errors?** Fix error, re-run until it fails correctly. + +### GREEN - Minimal Code + +Write simplest code to pass the test. + +#### Good + +```typescript +async function retryOperation(fn: () => Promise): Promise { + for (let i = 0; i < 3; i++) { + try { + return await fn(); + } catch (e) { + if (i === 2) throw e; + } + } + throw new Error("unreachable"); +} +``` + +Just enough to pass + +#### Bad + +```typescript +async function retryOperation( + fn: () => Promise, + options?: { + maxRetries?: number; + backoff?: "linear" | "exponential"; + onRetry?: (attempt: number) => void; + }, +): Promise { + // YAGNI +} +``` + +Over-engineered + +Don't add features, refactor other code, or "improve" beyond the test. + +### Verify GREEN - Watch It Pass + +**MANDATORY.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: + +- Test passes +- Other tests still pass +- Output pristine (no errors, warnings) + +**Test fails?** Fix code, not test. + +**Other tests fail?** Fix now. + +### REFACTOR - Clean Up + +After green only: + +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +### Repeat + +Next failing test for next feature. + +## Good Tests + +| Quality | Good | Bad | +| ---------------- | ----------------------------------- | --------------------------------------------------- | +| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` | +| **Clear** | Name describes behavior | `test('test1')` | +| **Shows intent** | Demonstrates desired API | Obscures what code should do | + +## Why Order Matters + +**"I'll write tests after to verify it works"** + +Tests written after code pass immediately. Passing immediately proves nothing: + +- Might test wrong thing +- Might test implementation, not behavior +- Might miss edge cases you forgot +- You never saw it catch the bug + +Test-first forces you to see the test fail, proving it actually tests something. + +**"I already manually tested all the edge cases"** + +Manual testing is ad-hoc. You think you tested everything but: + +- No record of what you tested +- Can't re-run when code changes +- Easy to forget cases under pressure +- "It worked when I tried it" ≠ comprehensive + +Automated tests are systematic. They run the same way every time. + +**"Deleting X hours of work is wasteful"** + +Sunk cost fallacy. The time is already gone. Your choice now: + +- Delete and rewrite with TDD (X more hours, high confidence) +- Keep it and add tests after (30 min, low confidence, likely bugs) + +The "waste" is keeping code you can't trust. Working code without real tests is technical debt. + +**"TDD is dogmatic, being pragmatic means adapting"** + +TDD IS pragmatic: + +- Finds bugs before commit (faster than debugging after) +- Prevents regressions (tests catch breaks immediately) +- Documents behavior (tests show how to use code) +- Enables refactoring (change freely, tests catch breaks) + +"Pragmatic" shortcuts = debugging in production = slower. + +**"Tests after achieve the same goals - it's spirit not ritual"** + +No. Tests-after answer "What does this do?" Tests-first answer "What should this do?" + +Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge +cases, not discovered ones. + +Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't). + +30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work. + +## Common Rationalizations + +| Excuse | Reality | +| -------------------------------------- | ----------------------------------------------------------------------- | +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" | +| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. | +| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | +| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. | +| "Need to explore first" | Fine. Throw away exploration, start with TDD. | +| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. | +| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. | +| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. | +| "Existing code has no tests" | You're improving it. Add tests for existing code. | + +## Red Flags - STOP and Start Over + +- Code before test +- Test after implementation +- Test passes immediately +- Can't explain why test failed +- Tests added "later" +- Rationalizing "just this once" +- "I already manually tested it" +- "Tests after achieve the same purpose" +- "It's about spirit not ritual" +- "Keep as reference" or "adapt existing code" +- "Already spent X hours, deleting is wasteful" +- "TDD is dogmatic, I'm being pragmatic" +- "This is different because..." + +**All of these mean: Delete code. Start over with TDD.** + +## Example: Bug Fix + +**Bug:** Empty email accepted + +**RED** + +```typescript +test("rejects empty email", async () => { + const result = await submitForm({ email: "" }); + expect(result.error).toBe("Email required"); +}); +``` + +**Verify RED** + +```bash +$ npm test +FAIL: expected 'Email required', got undefined +``` + +**GREEN** + +```typescript +function submitForm(data: FormData) { + if (!data.email?.trim()) { + return { error: "Email required" }; + } + // ... +} +``` + +**Verify GREEN** + +```bash +$ npm test +PASS +``` + +**REFACTOR** Extract validation for multiple fields if needed. + +## Verification Checklist + +Before marking work complete: + +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason (feature missing, not typo) +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Output pristine (no errors, warnings) +- [ ] Tests use real code (mocks only if unavoidable) +- [ ] Edge cases and errors covered + +Can't check all boxes? You skipped TDD. Start over. + +## When Stuck + +| Problem | Solution | +| ---------------------- | -------------------------------------------------------------------- | +| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. | +| Test too complicated | Design too complicated. Simplify interface. | +| Must mock everything | Code too coupled. Use dependency injection. | +| Test setup huge | Extract helpers. Still complex? Simplify design. | + +## Debugging Integration + +Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression. + +Never fix bugs without a test. + +## Testing Anti-Patterns + +When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls: + +- Testing mock behavior instead of real behavior +- Adding test-only methods to production classes +- Mocking without understanding dependencies + +## Final Rule + +``` +Production code → test exists and failed first +Otherwise → not TDD +``` + +No exceptions without your human partner's permission. diff --git a/.opencode/skills/test-driven-development/testing-anti-patterns.md b/.opencode/skills/test-driven-development/testing-anti-patterns.md new file mode 100644 index 0000000..1520cea --- /dev/null +++ b/.opencode/skills/test-driven-development/testing-anti-patterns.md @@ -0,0 +1,319 @@ +# Testing Anti-Patterns + +**Load this reference when:** writing or changing tests, adding mocks, or tempted to add test-only methods to production +code. + +## Overview + +Tests must verify real behavior, not mock behavior. Mocks are a means to isolate, not the thing being tested. + +**Core principle:** Test what the code does, not what the mocks do. + +**Following strict TDD prevents these anti-patterns.** + +## The Iron Laws + +``` +1. NEVER test mock behavior +2. NEVER add test-only methods to production classes +3. NEVER mock without understanding dependencies +``` + +## Anti-Pattern 1: Testing Mock Behavior + +**The violation:** + +```typescript +// ❌ BAD: Testing that the mock exists +test('renders sidebar', () => { + render(); + expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument(); +}); +``` + +**Why this is wrong:** + +- You're verifying the mock works, not that the component works +- Test passes when mock is present, fails when it's not +- Tells you nothing about real behavior + +**your human partner's correction:** "Are we testing the behavior of a mock?" + +**The fix:** + +```typescript +// ✅ GOOD: Test real component or don't mock it +test('renders sidebar', () => { + render(); // Don't mock sidebar + expect(screen.getByRole('navigation')).toBeInTheDocument(); +}); + +// OR if sidebar must be mocked for isolation: +// Don't assert on the mock - test Page's behavior with sidebar present +``` + +### Gate Function + +``` +BEFORE asserting on any mock element: + Ask: "Am I testing real component behavior or just mock existence?" + + IF testing mock existence: + STOP - Delete the assertion or unmock the component + + Test real behavior instead +``` + +## Anti-Pattern 2: Test-Only Methods in Production + +**The violation:** + +```typescript +// ❌ BAD: destroy() only used in tests +class Session { + async destroy() { + // Looks like production API! + await this._workspaceManager?.destroyWorkspace(this.id); + // ... cleanup + } +} + +// In tests +afterEach(() => session.destroy()); +``` + +**Why this is wrong:** + +- Production class polluted with test-only code +- Dangerous if accidentally called in production +- Violates YAGNI and separation of concerns +- Confuses object lifecycle with entity lifecycle + +**The fix:** + +```typescript +// ✅ GOOD: Test utilities handle test cleanup +// Session has no destroy() - it's stateless in production + +// In test-utils/ +export async function cleanupSession(session: Session) { + const workspace = session.getWorkspaceInfo(); + if (workspace) { + await workspaceManager.destroyWorkspace(workspace.id); + } +} + +// In tests +afterEach(() => cleanupSession(session)); +``` + +### Gate Function + +``` +BEFORE adding any method to production class: + Ask: "Is this only used by tests?" + + IF yes: + STOP - Don't add it + Put it in test utilities instead + + Ask: "Does this class own this resource's lifecycle?" + + IF no: + STOP - Wrong class for this method +``` + +## Anti-Pattern 3: Mocking Without Understanding + +**The violation:** + +```typescript +// ❌ BAD: Mock breaks test logic +test("detects duplicate server", () => { + // Mock prevents config write that test depends on! + vi.mock("ToolCatalog", () => ({ + discoverAndCacheTools: vi.fn().mockResolvedValue(undefined), + })); + + await addServer(config); + await addServer(config); // Should throw - but won't! +}); +``` + +**Why this is wrong:** + +- Mocked method had side effect test depended on (writing config) +- Over-mocking to "be safe" breaks actual behavior +- Test passes for wrong reason or fails mysteriously + +**The fix:** + +```typescript +// ✅ GOOD: Mock at correct level +test("detects duplicate server", () => { + // Mock the slow part, preserve behavior test needs + vi.mock("MCPServerManager"); // Just mock slow server startup + + await addServer(config); // Config written + await addServer(config); // Duplicate detected ✓ +}); +``` + +### Gate Function + +``` +BEFORE mocking any method: + STOP - Don't mock yet + + 1. Ask: "What side effects does the real method have?" + 2. Ask: "Does this test depend on any of those side effects?" + 3. Ask: "Do I fully understand what this test needs?" + + IF depends on side effects: + Mock at lower level (the actual slow/external operation) + OR use test doubles that preserve necessary behavior + NOT the high-level method the test depends on + + IF unsure what test depends on: + Run test with real implementation FIRST + Observe what actually needs to happen + THEN add minimal mocking at the right level + + Red flags: + - "I'll mock this to be safe" + - "This might be slow, better mock it" + - Mocking without understanding the dependency chain +``` + +## Anti-Pattern 4: Incomplete Mocks + +**The violation:** + +```typescript +// ❌ BAD: Partial mock - only fields you think you need +const mockResponse = { + status: "success", + data: { userId: "123", name: "Alice" }, + // Missing: metadata that downstream code uses +}; + +// Later: breaks when code accesses response.metadata.requestId +``` + +**Why this is wrong:** + +- **Partial mocks hide structural assumptions** - You only mocked fields you know about +- **Downstream code may depend on fields you didn't include** - Silent failures +- **Tests pass but integration fails** - Mock incomplete, real API complete +- **False confidence** - Test proves nothing about real behavior + +**The Iron Rule:** Mock the COMPLETE data structure as it exists in reality, not just fields your immediate test uses. + +**The fix:** + +```typescript +// ✅ GOOD: Mirror real API completeness +const mockResponse = { + status: "success", + data: { userId: "123", name: "Alice" }, + metadata: { requestId: "req-789", timestamp: 1234567890 }, + // All fields real API returns +}; +``` + +### Gate Function + +``` +BEFORE creating mock responses: + Check: "What fields does the real API response contain?" + + Actions: + 1. Examine actual API response from docs/examples + 2. Include ALL fields system might consume downstream + 3. Verify mock matches real response schema completely + + Critical: + If you're creating a mock, you must understand the ENTIRE structure + Partial mocks fail silently when code depends on omitted fields + + If uncertain: Include all documented fields +``` + +## Anti-Pattern 5: Integration Tests as Afterthought + +**The violation:** + +``` +✅ Implementation complete +❌ No tests written +"Ready for testing" +``` + +**Why this is wrong:** + +- Testing is part of implementation, not optional follow-up +- TDD would have caught this +- Can't claim complete without tests + +**The fix:** + +``` +TDD cycle: +1. Write failing test +2. Implement to pass +3. Refactor +4. THEN claim complete +``` + +## When Mocks Become Too Complex + +**Warning signs:** + +- Mock setup longer than test logic +- Mocking everything to make test pass +- Mocks missing methods real components have +- Test breaks when mock changes + +**your human partner's question:** "Do we need to be using a mock here?" + +**Consider:** Integration tests with real components often simpler than complex mocks + +## TDD Prevents These Anti-Patterns + +**Why TDD helps:** + +1. **Write test first** → Forces you to think about what you're actually testing +2. **Watch it fail** → Confirms test tests real behavior, not mocks +3. **Minimal implementation** → No test-only methods creep in +4. **Real dependencies** → You see what the test actually needs before mocking + +**If you're testing mock behavior, you violated TDD** - you added mocks without watching test fail against real code +first. + +## Quick Reference + +| Anti-Pattern | Fix | +| ------------------------------- | --------------------------------------------- | +| Assert on mock elements | Test real component or unmock it | +| Test-only methods in production | Move to test utilities | +| Mock without understanding | Understand dependencies first, mock minimally | +| Incomplete mocks | Mirror real API completely | +| Tests as afterthought | TDD - tests first | +| Over-complex mocks | Consider integration tests | + +## Red Flags + +- Assertion checks for `*-mock` test IDs +- Methods only called in test files +- Mock setup is >50% of test +- Test fails when you remove mock +- Can't explain why mock is needed +- Mocking "just to be safe" + +## The Bottom Line + +**Mocks are tools to isolate, not things to test.** + +If TDD reveals you're testing mock behavior, you've gone wrong. + +Fix: Test real behavior or question why you're mocking at all. diff --git a/.opencode/skills/writing-plans/SKILL.md b/.opencode/skills/writing-plans/SKILL.md new file mode 100644 index 0000000..896397d --- /dev/null +++ b/.opencode/skills/writing-plans/SKILL.md @@ -0,0 +1,125 @@ +--- +name: writing-plans +description: Use when you have a spec or requirements for a multi-step task, before touching code +--- + +# Writing Plans + +## Overview + +Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. +Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, +how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. + +Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know +good test design very well. + +**Announce at start:** "I'm using the writing-plans skill to create the implementation plan." + +**Context:** This should be run in a dedicated worktree (created by brainstorming skill). + +**Save plans to:** `docs/plans/YYYY-MM-DD-.md` + +## Bite-Sized Task Granularity + +**Each step is one action (2-5 minutes):** + +- "Write the failing test" - step +- "Run it to make sure it fails" - step +- "Implement the minimal code to make the test pass" - step +- "Run the tests and make sure they pass" - step +- "Commit" - step + +## Plan Document Header + +**Every plan MUST start with this header:** + +```markdown +# [Feature Name] Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** [One sentence describing what this builds] + +**Architecture:** [2-3 sentences about approach] + +**Tech Stack:** [Key technologies/libraries] + +--- +``` + +## Task Structure + +````markdown +### Task N: [Component Name] + +**Files:** + +- Create: `exact/path/to/file.ts` +- Modify: `exact/path/to/existing.ts:123-145` +- Test: `tests/exact/path/to/test.test.ts` + +**Step 1: Write the failing test** + +```typescript +describe('Component Name', () => { + it('should have specific behavior', () => { + const result = functionName(input); + expect(result).toBe(expected); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test tests/path/test.test.ts` Expected: FAIL with "functionName is not defined" + +**Step 3: Write minimal implementation** + +```typescript +export function functionName(input: InputType): OutputType { + return expected; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test tests/path/test.test.ts` Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/path/test.test.ts src/path/file.ts +git commit -m "feat: add specific feature" +``` + +``` + +## Remember +- Exact file paths always +- Complete code in plan (not "add validation") +- Exact commands with expected output +- Reference relevant skills with @ syntax +- DRY, YAGNI, TDD, frequent commits + +## Execution Handoff + +After saving the plan, offer execution choice: + +**"Plan complete and saved to `docs/plans/.md`. Two execution options:** + +**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration + +**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints + +**Which approach?"** + +**If Subagent-Driven chosen:** +- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development +- Stay in this session +- Fresh subagent per task + code review + +**If Parallel Session chosen:** +- Guide them to open new session in worktree +- **REQUIRED SUB-SKILL:** New session uses superpowers:executing-plans +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..07a364c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,454 @@ +# AGENTS.md + +This document contains important rules and guidelines for AI agents working on this codebase. + +## Arrow Functions Only + +**Use arrow functions exclusively. Never use named function declarations or classes.** + +### Good + +```typescript +// Single function per module - arrow function +export const calculateTotal = (items: Item[]): number => { + return items.reduce((sum, item) => sum + item.price, 0); +}; + +// Async arrow function +export const fetchData = async (id: string): Promise => { + const response = await api.get(`/data/${id}`); + return response.data; +}; +``` + +### Bad + +```typescript +// NEVER use named function declarations +function calculateTotal(items: Item[]): number { + return items.reduce((sum, item) => sum + item.price, 0); +} + +// NEVER use classes +class DataFetcher { + async fetch(id: string): Promise { + return api.get(`/data/${id}`); + } +} +``` + +### Rationale + +- Arrow functions have lexical `this` binding (no binding issues) +- Consistent syntax for both sync and async functions +- Better type inference in TypeScript +- No function hoisting surprises +- Uniform codebase style + +## Code Organization Rules + +### 1. One Function Per Module Maximum + +- Each `.ts` file MUST export at most ONE function or class +- Each module should have a single, well-defined responsibility +- This promotes modularity, testability, and code reuse + +**BAD**: + +```typescript +// math-utils.ts +export function add(a: number, b: number): number { ... } +export function subtract(a: number, b: number): number { ... } +export function multiply(a: number, b: number): number { ... } +``` + +**GOOD**: + +```typescript +// add.ts +export function add(a: number, b: number): number { ... } + +// subtract.ts +export function subtract(a: number, b: number): number { ... } + +// index.ts (barrel) +export { add } from './add'; +export { subtract } from './subtract'; +``` + +### Exceptions + +Private helper functions that are only used by the main exported function may be defined in the same file, but they must +not be exported: + +```typescript +// validators/is-valid-email.ts + +// Private helper - not exported +const hasValidDomain = (email: string): boolean => { + return email.includes("@") && email.split("@")[1].includes("."); +}; + +// Single exported function +export const isValidEmail = (email: string): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && hasValidDomain(email); +}; +``` + +### File Naming + +- Use kebab-case for file names: `calculate-total.ts`, `format-currency.ts` +- Match the file name to the exported function name + +### Rationale + +- Maximum code discoverability +- Easy to locate specific functions +- Clear module boundaries +- Simplifies code review (one concern per file) +- Better tree-shaking for bundlers +- Easier testing (isolated units) + +### 2. Barrel Module Pattern + +- Use barrel exports (`index.ts`) to expose public API of a directory +- Each subdirectory should have an `index.ts` that re-exports its children +- Consumers should import from the barrel, not individual files + +**BAD**: + +```typescript +// Importing from specific files +import { add } from "./utils/math/add"; +import { subtract } from "./utils/math/subtract"; +``` + +**GOOD**: + +```typescript +// foo/index.ts +export { add } from "./add"; +export { subtract } from "./subtract"; +export { multiply } from "./multiply"; + +// consumer.ts +import { add, subtract } from "./foo"; // Not './foo/add' +``` + +### Import Conventions + +- Always import from barrel exports when available +- Only import from specific files when the item is not exported from the barrel +- Avoid deep relative imports (e.g., `../../../foo/bar/baz`) + +### Structure + +``` +src/ +├── utils/ +│ ├── calculate-total.ts +│ ├── format-currency.ts +│ ├── retry.ts +│ └── index.ts # Barrel: re-exports all utils +``` + +### Barrel File Pattern + +```typescript +// utils/index.ts +export { calculateTotal } from "./calculate-total"; +export { formatCurrency } from "./format-currency"; +export { withRetry } from "./retry"; + +// Re-export types separately +export type { Item, Currency } from "./types"; +``` + +### Multiple Barrel Levels + +For nested structures, create barrels at each level: + +``` +src/ +├── scripts/ +│ ├── mirror-package/ +│ │ ├── parse-tag.ts +│ │ ├── validate-mirror-url.ts +│ │ └── index.ts # Level 2 barrel +│ ├── check-repo-settings/ +│ │ ├── checks.ts +│ │ └── index.ts # Level 2 barrel +│ └── index.ts # Level 1 barrel +``` + +```typescript +// scripts/mirror-package/index.ts +export { detectChanges } from "./detect-changes"; +export { parseTag } from "./parse-tag"; +export { validateMirrorUrl } from "./validate-mirror-url"; +export type { PackageInfo, MirrorUrl } from "./types"; +``` + +```typescript +// scripts/index.ts +export { detectChanges, parseTag, validateMirrorUrl } from "./mirror-package"; +export { checkRepoSettings } from "./check-repo-settings"; +``` + +### Rationale + +- Clean import statements throughout the codebase +- Encapsulation of internal module structure +- Easy to refactor (change internal organization without affecting imports) +- Clear public API surface for each directory +- Reduces import complexity + +### 3. Test Collocation + +**Tests must be collocated with source files. Never use a separate `tests/` directory.** + +- Test files MUST be collocated with the source files they test +- Each source file `foo.ts` should have its own `foo.test.ts` next to it +- NEVER create consolidated test files for an entire directory + +**BAD**: + +``` +foo/ + baa-0.ts + baa-1.ts + foo.test.ts <-- WRONG: consolidated test file +``` + +**GOOD**: + +``` +foo/ + baa-0.ts + baa-0.test.ts <-- CORRECT: test next to source + baa-1.ts + baa-1.test.ts <-- CORRECT: test next to source +``` + +### Naming Convention + +- Source file: `{module-name}.ts` +- Test file: `{module-name}.test.ts` + +### Example + +```typescript +// utils/retry.ts +export const withRetry = async (fn: () => Promise, maxRetries: number = 3): Promise => { + // implementation +}; +``` + +```typescript +// utils/retry.test.ts +import { withRetry } from "./retry"; + +describe("withRetry", () => { + it("should retry on failure", async () => { + // test implementation + }); +}); +``` + +### Rationale + +- Tests are always adjacent to the code they test +- Easy to find and maintain tests +- Refactoring is safer (tests move with source) +- No complex import path management +- Encourages testing discipline + +## Test-Driven Development (TDD) + +**Write tests BEFORE implementing the functionality. Follow the Red-Green-Refactor cycle.** + +### The TDD Cycle + +``` +1. RED → Write a failing test (describe behavior, assert expectations) +2. GREEN → Write minimal code to make the test pass +3. REFACTOR → Clean up the code while keeping tests green +``` + +Repeat this cycle for every new behavior or edge case. + +### Example Workflow + +**Step 1: Write the failing test first** + +```typescript +// utils/calculate-discount.test.ts +import { calculateDiscount } from "./calculate-discount"; + +describe("calculateDiscount", () => { + it("should apply 10% discount to amounts over $100", () => { + // Arrange + const amount = 150; + const discountRate = 0.1; + + // Act + const result = calculateDiscount(amount, discountRate); + + // Assert + expect(result).toBe(15); + }); + + it("should return 0 discount for amounts at or below $100", () => { + expect(calculateDiscount(100, 0.1)).toBe(0); + expect(calculateDiscount(50, 0.1)).toBe(0); + }); +}); +``` + +Run tests: `bun test calculate-discount.test.ts` Expected: Tests FAIL (function doesn't exist yet) → RED + +**Step 2: Implement minimal code to pass** + +```typescript +// utils/calculate-discount.ts +export const calculateDiscount = (amount: number, rate: number): number => { + if (amount <= 100) return 0; + return amount * rate; +}; +``` + +Run tests: `bun test calculate-discount.test.ts` Expected: Tests PASS → GREEN + +**Step 3: Refactor (if needed)** + +No refactoring needed for this simple function. Move to next test case or feature. + +### Iterative Development + +Continue adding test cases one at a time: + +```typescript +// Additional test case (RED) +it("should handle zero discount rate", () => { + expect(calculateDiscount(200, 0)).toBe(0); +}); + +// Update implementation (GREEN) +export const calculateDiscount = (amount: number, rate: number): number => { + if (amount <= 100 || rate <= 0) return 0; + return amount * rate; +}; + +// Add edge case test (RED) +it("should handle negative amounts gracefully", () => { + expect(calculateDiscount(-50, 0.1)).toBe(0); +}); + +// Update implementation (GREEN) +export const calculateDiscount = (amount: number, rate: number): number => { + if (amount <= 100 || rate <= 0 || amount < 0) return 0; + return amount * rate; +}; + +// Refactor: Simplify the logic +export const calculateDiscount = (amount: number, rate: number): number => { + if (amount <= 100 || rate <= 0) return 0; + return amount * rate; +}; +``` + +### Rules + +1. **Never write production code without a failing test first** +2. **Write only enough test code to fail** (start with the assertion you want) +3. **Write only enough production code to pass** (no speculative features) +4. **Tests must fail for the right reason** (not compilation errors) +5. **Keep tests fast** (< 100ms per test ideally) +6. **One test per behavior** (edge cases, happy path, error cases) + +### Test Structure (AAA Pattern) + +```typescript +it("should validate email format", () => { + // Arrange: Set up inputs + const invalidEmail = "not-an-email"; + + // Act: Execute the function + const result = isValidEmail(invalidEmail); + + // Assert: Verify the outcome + expect(result).toBe(false); +}); +``` + +### Benefits of TDD + +- **Design pressure**: Forces you to think about API design before coding +- **Confidence**: Every line of production code is covered by a test +- **Documentation**: Tests serve as executable documentation +- **Refactoring safety**: Change implementation with confidence +- **Debugging time**: Catch issues immediately, not in production +- **Scope control**: Prevents over-engineering and gold-plating + +### Anti-Patterns + +```typescript +// BAD: Writing implementation before tests +export const complexLogic = () => { + /* 50 lines of code */ +}; +// Now try to figure out how to test this... + +// BAD: Writing tests after the fact +// Tests become "happy path" verification rather than design tool + +// BAD: Testing implementation details instead of behavior +it("should call the database", () => { + const spy = jest.spyOn(db, "query"); + getUsers(); + expect(spy).toHaveBeenCalled(); // Testing HOW, not WHAT +}); + +// GOOD: Testing behavior +it("should return all active users", async () => { + const users = await getUsers({ status: "active" }); + expect(users).toHaveLength(3); + expect(users.every((u) => u.status === "active")).toBe(true); +}); +``` + +## Testing Requirements + +- Every public function must have corresponding test coverage +- Test files must follow the naming convention: `{source-file}.test.ts` +- Use descriptive test names that explain the behavior being tested +- Test both success and error cases + +## Directory Structure Example + +``` +libs/ + workflows/ + src/ + utils/ + index.ts + string-utils.ts + string-utils.test.ts + math-utils.ts + math-utils.test.ts + date-utils.ts + date-utils.test.ts +``` + +## Quick Reference + +| Convention | Rule | +| ------------------ | ----------------------------------------------------- | +| Functions | Arrow functions only (`const fn = () => {}`) | +| Classes | Never use classes | +| Named functions | Never use `function` keyword | +| Tests | Collocated: `{module}.test.ts` next to `{module}.ts` | +| TDD | Write tests BEFORE code. Red → Green → Refactor cycle | +| Functions per file | Maximum 1 exported function per module | +| Exports | Use barrel files (`index.ts`) for clean imports | +| File naming | Kebab-case matching function name | diff --git a/bun.lock b/bun.lock index c88bfbb..4b87ce5 100644 --- a/bun.lock +++ b/bun.lock @@ -97,6 +97,19 @@ "typescript": "5.6.2", }, }, + "packages/opencode-skills": { + "name": "@pantheon-org/opencode-skills", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.3.4", + "zod": "^3.22.4", + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/bun": "latest", + "@types/glob": "^8.1.0", + }, + }, "packages/opencode-warcraft-notifications-plugin": { "name": "@pantheon-org/opencode-warcraft-notifications-plugin", "version": "0.1.0", @@ -121,8 +134,12 @@ }, }, "tools/executors": { - "name": "@pantheon-org/tools", - "version": "1.0.0", + "name": "@pantheon-org/executors", + "version": "0.1.0", + "dependencies": { + "@pantheon-org/opencode-skills": "workspace:*", + "glob": "^10.3.10", + }, }, }, "packages": { @@ -660,6 +677,8 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.15.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HZsfne0s/tGOcJK9ZdTGxsNU2P/dH0Shf0jqrPvsC6wX0Wk+6AyhSpHFLQCnLOuFQiHHU0ePfM8iYsoJb5hHpQ=="], + "@pantheon-org/executors": ["@pantheon-org/executors@workspace:tools/executors"], + "@pantheon-org/opencode-agent-loader-plugin": ["@pantheon-org/opencode-agent-loader-plugin@workspace:packages/opencode-agent-loader-plugin"], "@pantheon-org/opencode-config": ["@pantheon-org/opencode-config@workspace:packages/opencode-config"], @@ -668,9 +687,9 @@ "@pantheon-org/opencode-notification": ["@pantheon-org/opencode-notification@workspace:packages/opencode-notification"], - "@pantheon-org/opencode-warcraft-notifications-plugin": ["@pantheon-org/opencode-warcraft-notifications-plugin@workspace:packages/opencode-warcraft-notifications-plugin"], + "@pantheon-org/opencode-skills": ["@pantheon-org/opencode-skills@workspace:packages/opencode-skills"], - "@pantheon-org/tools": ["@pantheon-org/tools@workspace:tools/executors"], + "@pantheon-org/opencode-warcraft-notifications-plugin": ["@pantheon-org/opencode-warcraft-notifications-plugin@workspace:packages/opencode-warcraft-notifications-plugin"], "@phenomnomnominal/tsquery": ["@phenomnomnominal/tsquery@5.0.1", "", { "dependencies": { "esquery": "^1.4.0" }, "peerDependencies": { "typescript": "^3 || ^4 || ^5" } }, "sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA=="], @@ -804,6 +823,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/glob": ["@types/glob@8.1.0", "", { "dependencies": { "@types/minimatch": "^5.1.2", "@types/node": "*" } }, "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], @@ -816,6 +837,8 @@ "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], + "@types/minimatch": ["@types/minimatch@5.1.2", "", {}, "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], @@ -1276,7 +1299,7 @@ "get-them-args": ["get-them-args@1.3.2", "", {}, "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw=="], - "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1386,7 +1409,7 @@ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], @@ -1508,7 +1531,7 @@ "lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], @@ -1688,7 +1711,7 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="], @@ -1986,6 +2009,8 @@ "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2020,8 +2045,6 @@ "@jest/reporters/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@jest/reporters/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "@jest/source-map/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jest/transform/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -2030,8 +2053,6 @@ "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.133", "", {}, "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w=="], "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], @@ -2054,6 +2075,10 @@ "@pantheon-org/opencode-notification/typescript": ["typescript@5.6.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw=="], + "@pantheon-org/opencode-skills/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@pantheon-org/opencode-skills/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@pantheon-org/opencode-warcraft-notifications-plugin/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "@swc-node/sourcemap-support/source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], @@ -2074,10 +2099,6 @@ "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - - "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "cosmiconfig/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], @@ -2102,7 +2123,7 @@ "front-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -2112,20 +2133,14 @@ "istanbul-lib-source-maps/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "jest-config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], - "jest-runtime/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "jest-util/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "markdownlint-cli2/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2140,8 +2155,6 @@ "parse-json/lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], - "postcss-load-config/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -2160,6 +2173,8 @@ "svgicons2svgfont/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "svgicons2svgfont/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "tcp-port-used/debug": ["debug@4.3.1", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ=="], "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -2180,6 +2195,8 @@ "write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -2194,12 +2211,6 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@jest/reporters/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "@jest/reporters/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "@jest/reporters/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "@oxc-resolver/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@pantheon-org/opencode-config/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], @@ -2234,42 +2245,32 @@ "@pantheon-org/opencode-notification/tsup/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], + "@pantheon-org/opencode-skills/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "@swc-node/sourcemap-support/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@yarnpkg/parsers/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "cacache/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "front-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "jest-config/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "jest-config/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "jest-config/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "jest-runner/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "jest-runtime/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "jest-runtime/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "jest-runtime/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "svgicons2svgfont/glob/jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + + "svgicons2svgfont/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + + "svgicons2svgfont/glob/path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "tcp-port-used/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -2320,8 +2321,6 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "@jest/reporters/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@pantheon-org/opencode-config/tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ=="], "@pantheon-org/opencode-config/tsup/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.23.1", "", { "os": "android", "cpu": "arm" }, "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ=="], @@ -2418,9 +2417,7 @@ "@pantheon-org/opencode-notification/tsup/postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - "jest-config/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "jest-runtime/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "svgicons2svgfont/glob/path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } diff --git a/packages/opencode-skills/.github/.release-please-manifest.json b/packages/opencode-skills/.github/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/packages/opencode-skills/.github/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/packages/opencode-skills/.github/actions/setup-bun/action.yml b/packages/opencode-skills/.github/actions/setup-bun/action.yml new file mode 100644 index 0000000..1d53f5c --- /dev/null +++ b/packages/opencode-skills/.github/actions/setup-bun/action.yml @@ -0,0 +1,37 @@ +name: 'Setup Bun with Caching' +description: 'Sets up Bun with dependency caching for faster workflow runs' + +inputs: + bun-version: + description: 'Bun version to install' + required: false + default: 'latest' + frozen-lockfile: + description: 'Use frozen lockfile for installation' + required: false + default: 'true' + +runs: + using: 'composite' + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: ${{ inputs.bun-version }} + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + shell: bash + run: | + if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then + bun install --frozen-lockfile + else + bun install + fi diff --git a/packages/opencode-skills/.github/actions/setup-node-npm/action.yml b/packages/opencode-skills/.github/actions/setup-node-npm/action.yml new file mode 100644 index 0000000..ad809fa --- /dev/null +++ b/packages/opencode-skills/.github/actions/setup-node-npm/action.yml @@ -0,0 +1,21 @@ +name: 'Setup Node.js for NPM' +description: 'Sets up Node.js configured for npm registry publishing' + +inputs: + node-version: + description: 'Node.js version to install' + required: false + default: '20' + registry-url: + description: 'NPM registry URL' + required: false + default: 'https://registry.npmjs.org' + +runs: + using: 'composite' + steps: + - name: Setup Node.js for npm + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ inputs.node-version }} + registry-url: ${{ inputs.registry-url }} diff --git a/packages/opencode-skills/.github/dependabot.yml b/packages/opencode-skills/.github/dependabot.yml new file mode 100644 index 0000000..db9a033 --- /dev/null +++ b/packages/opencode-skills/.github/dependabot.yml @@ -0,0 +1,51 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + time: '09:00' + commit-message: + prefix: 'chore' + include: 'scope' + labels: + - 'dependencies' + - 'github-actions' + - 'security' + open-pull-requests-limit: 10 + + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' + day: 'tuesday' + time: '09:00' + commit-message: + prefix: 'chore' + include: 'scope' + labels: + - 'dependencies' + - 'npm' + open-pull-requests-limit: 5 + ignore: + - dependency-name: '*' + update-types: ['version-update:semver-major'] + + - package-ecosystem: 'npm' + directory: '/pages' + schedule: + interval: 'weekly' + day: 'tuesday' + time: '09:00' + commit-message: + prefix: 'chore' + include: 'scope' + labels: + - 'dependencies' + - 'npm' + - 'documentation' + open-pull-requests-limit: 5 + ignore: + - dependency-name: '*' + update-types: ['version-update:semver-major'] diff --git a/packages/opencode-skills/.github/release-please-config.json b/packages/opencode-skills/.github/release-please-config.json new file mode 100644 index 0000000..000e924 --- /dev/null +++ b/packages/opencode-skills/.github/release-please-config.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "package-name": "@pantheon-org/opencode-skills", + "include-component-in-tag": false, + "changelog-sections": [ + { "type": "feat", "section": "Features", "hidden": false }, + { "type": "fix", "section": "Bug Fixes", "hidden": false }, + { + "type": "perf", + "section": "Performance Improvements", + "hidden": false + }, + { "type": "revert", "section": "Reverts", "hidden": false }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "style", "section": "Code Style", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": false }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "draft": false, + "prerelease": false + } + }, + "bootstrap-sha": "", + "skip-github-release": false, + "draft": false, + "prerelease": false +} diff --git a/packages/opencode-skills/.github/workflows/chores-pages.yml b/packages/opencode-skills/.github/workflows/chores-pages.yml new file mode 100644 index 0000000..63fc3e6 --- /dev/null +++ b/packages/opencode-skills/.github/workflows/chores-pages.yml @@ -0,0 +1,78 @@ +name: Chores — GitHub Pages Configuration + +on: + schedule: + - cron: '0 0 * * *' # daily at 00:00 UTC + workflow_dispatch: {} + +permissions: + contents: read + pages: read + issues: write + +jobs: + pages-check: + name: Check GitHub Pages Configuration + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Install jq (if missing) + run: | + sudo apt-get update -y + sudo apt-get install -y jq + + - name: Check Pages configuration + id: pages_check + continue-on-error: true + run: | + set +e + + # Get Pages info from GitHub API + PAGES_INFO=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/pages") + + # Check if Pages is enabled + if echo "$PAGES_INFO" | jq -e '.html_url' > /dev/null 2>&1; then + echo "✅ GitHub Pages is configured" + echo "exit_code=0" >> $GITHUB_OUTPUT + exit 0 + else + echo "❌ GitHub Pages is not configured or not accessible" + echo "exit_code=1" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Create GitHub issue on failure + if: steps.pages_check.outputs.exit_code != '0' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const label = 'chores/pages-check'; + const title = `[chores] GitHub Pages misconfigured for ${owner}/${repo}`; + const body = `The scheduled GitHub Pages configuration check failed for **${owner}/${repo}**.\n\n` + + `Please verify the Pages source is configured correctly in repository settings.\n\n` + + `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + + // Check for existing open issues with the label + const existing = await github.rest.issues.listForRepo({ owner, repo, state: 'open', labels: label }); + if (existing.data && existing.data.length > 0) { + console.log('An open issue for Pages check already exists; skipping creation.'); + } else { + // Create the issue + await github.rest.issues.create({ owner, repo, title, body, labels: [label] }); + console.log('Created issue:', title); + } + + - name: Final status + run: | + if [ "${{ steps.pages_check.outputs.exit_code }}" = "0" ]; then + echo "Pages check: OK" + else + echo "Pages check: FAILURE — issue created or already exists" + exit 1 + fi diff --git a/packages/opencode-skills/.github/workflows/deploy-docs.yml b/packages/opencode-skills/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..3578b85 --- /dev/null +++ b/packages/opencode-skills/.github/workflows/deploy-docs.yml @@ -0,0 +1,97 @@ +name: Deploy Documentation + +on: + push: + branches: [main] + paths: + - 'docs/**' + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build Documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: 'latest' + + - name: Clone docs-builder + run: | + git clone --depth 1 https://github.com/pantheon-org/opencode-docs-builder.git docs-builder + echo "✅ Docs builder cloned" + + - name: Install docs-builder dependencies + working-directory: ./docs-builder + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: ./docs-builder + run: | + bunx playwright install --with-deps chromium + echo "✅ Playwright browsers installed" + + - name: Copy plugin documentation + run: | + cp -r ./docs/* ./docs-builder/src/content/docs/ || mkdir -p ./docs-builder/src/content/docs/ + echo "✅ Plugin docs copied to docs-builder" + + - name: Build documentation with Astro action + uses: withastro/action@v3 + with: + path: ./docs-builder + package-manager: bun@latest + + - name: Verify internal links + working-directory: ./docs-builder + run: | + bun run verify + echo "✅ All internal links verified" + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + - name: Summary + run: | + echo "## 🎉 Documentation Deployment Complete!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Deployment Details" >> $GITHUB_STEP_SUMMARY + echo "- **URL**: ${{ steps.deployment.outputs.page_url }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Trigger**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Build Process" >> $GITHUB_STEP_SUMMARY + echo "1. 📥 Clone docs-builder from \`pantheon-org/opencode-docs-builder\`" >> $GITHUB_STEP_SUMMARY + echo "2. 📄 Copy plugin docs from \`./docs/\`" >> $GITHUB_STEP_SUMMARY + echo "3. 🔄 Transform to Astro content" >> $GITHUB_STEP_SUMMARY + echo "4. 🏗️ Build Astro site" >> $GITHUB_STEP_SUMMARY + echo "5. 🔗 Fix and verify internal links" >> $GITHUB_STEP_SUMMARY + echo "6. 🚀 Deploy to GitHub Pages via Actions" >> $GITHUB_STEP_SUMMARY diff --git a/packages/opencode-skills/.github/workflows/publish-on-tag.yml b/packages/opencode-skills/.github/workflows/publish-on-tag.yml new file mode 100644 index 0000000..f092a04 --- /dev/null +++ b/packages/opencode-skills/.github/workflows/publish-on-tag.yml @@ -0,0 +1,204 @@ +name: Publish on Tag (Manual Release) + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish (e.g., v1.2.3)' + required: true + type: string + +permissions: + contents: write + id-token: write + pages: write + +jobs: + # Extract version from tag + prepare: + name: Prepare Release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 + + - name: Get version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG=${GITHUB_REF#refs/tags/} + fi + + # Strip 'v' prefix (v2.0.0 -> 2.0.0) + VERSION=${TAG#v} + # Also handle Release Please format (opencode-skills-plugin-v2.0.0 -> 2.0.0) + VERSION=${VERSION##*-v} + + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "📦 Publishing version: $VERSION from tag: $TAG" + + - name: Verify package.json matches tag + run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION="${{ steps.version.outputs.version }}" + + if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then + echo "❌ Version mismatch!" + echo " package.json: $PACKAGE_VERSION" + echo " tag: $TAG_VERSION" + exit 1 + fi + + echo "✅ Version verified: $PACKAGE_VERSION" + + # Publish to npm + publish-npm: + name: Publish to npm + needs: prepare + uses: ./.github/workflows/reusable/reusable-npm-publish.yml + with: + version: ${{ needs.prepare.outputs.version }} + npm-scope: 'pantheon-org' + project-name: 'opencode-skills-plugin' + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Deploy documentation + publish-docs: + name: Deploy Documentation + runs-on: ubuntu-latest + needs: publish-npm + if: success() + uses: ./.github/workflows/reusable/reusable-deploy-docs.yml + with: + commit-sha: ${{ github.sha }} + secrets: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Create GitHub Release + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [prepare, publish-npm, publish-docs] + if: success() + + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 + + - name: Get commits for changelog + id: changelog + run: | + TAG="${{ needs.prepare.outputs.tag }}" + + # Get previous tag + PREV_TAG=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + COMMITS=$(git log --oneline --no-merges | head -20) + else + COMMITS=$(git log ${PREV_TAG}..${TAG} --oneline --no-merges) + fi + + # Save to file + echo "$COMMITS" > commits.txt + + echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const fs = require('fs'); + const tag = '${{ needs.prepare.outputs.tag }}'; + const version = '${{ needs.prepare.outputs.version }}'; + const prevTag = '${{ steps.changelog.outputs.prev_tag }}'; + + // Read commits + const commits = fs.readFileSync('commits.txt', 'utf8') + .split('\n') + .filter(line => line.trim()) + .map(line => `- ${line}`) + .join('\n'); + + const npmPackageName = require('./package.json').name; + const npmUrl = `https://www.npmjs.com/package/${npmPackageName}/v/${version}`; + const docsUrl = `https://github.com/${{ github.repository }}/tree/docs`; + + const changelogHeader = prevTag + ? `## Changes Since ${prevTag}` + : `## Initial Release`; + + const body = `# Release ${tag} + + ${changelogHeader} + + ${commits} + + ## 📦 Package Information + + - **npm Package**: [\`${npmPackageName}@${version}\`](${npmUrl}) + - **Installation**: \`npm install ${npmPackageName}@${version}\` + - **Documentation**: [View Docs](${docsUrl}) + + ## 🔧 Build Information + + - **Node.js**: 20.x + - **Runtime**: Bun + - **Build Status**: ✅ All tests passed + - **Coverage**: ✅ Full coverage + + --- + + *This release was automatically created by the Publish on Tag workflow.* + `; + + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: `Release ${tag}`, + body: body, + draft: false, + prerelease: false + }); + + console.log(`✅ Created release for ${tag}`); + + # Summary job + summary: + name: Publish Summary + runs-on: ubuntu-latest + needs: [prepare, publish-npm, publish-docs, create-release] + if: always() + + steps: + - name: Display summary + run: | + echo "🎉 Release Pipeline Complete!" + echo "" + echo "📦 Version: ${{ needs.prepare.outputs.version }}" + echo "🏷️ Tag: ${{ needs.prepare.outputs.tag }}" + echo "" + echo "✅ npm: ${{ needs.publish-npm.result }}" + echo "✅ Docs: ${{ needs.publish-docs.result }}" + echo "✅ Release: ${{ needs.create-release.result }}" + echo "" + echo "🔗 Links:" + echo " 📦 npm: https://www.npmjs.com/package/@pantheon-org/opencode-skills-plugin/v/${{ needs.prepare.outputs.version }}" + echo " 📚 Docs: https://github.com/${{ github.repository }}/tree/docs" + echo " 🏷️ Release: https://github.com/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.tag }}" diff --git a/packages/opencode-skills/.github/workflows/release-and-publish.yml b/packages/opencode-skills/.github/workflows/release-and-publish.yml new file mode 100644 index 0000000..5f469b7 --- /dev/null +++ b/packages/opencode-skills/.github/workflows/release-and-publish.yml @@ -0,0 +1,86 @@ +name: Release & Publish (Automated) + +on: + push: + branches: + - main + paths-ignore: + - 'docs/**' + - '*.md' + - '.github/workflows/chores-*.yml' + - '.github/workflows/deploy-docs.yml' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }} + pr: ${{ steps.release.outputs.pr }} + + steps: + - name: Run Release Please + id: release + uses: googleapis/release-please-action@7987652d64b4581673a76e33ad5e98e3dd56832f # v4.1.3 + with: + token: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }} + config-file: .github/release-please-config.json + manifest-file: .github/.release-please-manifest.json + + - name: Summary - Release Created + if: steps.release.outputs.release_created == 'true' + run: | + echo "## 🎉 Release Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Version**: ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Release URL**: ${{ steps.release.outputs.html_url }}" >> $GITHUB_STEP_SUMMARY + echo "- **Upload URL**: ${{ steps.release.outputs.upload_url }}" >> $GITHUB_STEP_SUMMARY + + - name: Summary - Release PR + if: steps.release.outputs.pr != '' + run: | + echo "## 📝 Release PR Updated" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Release Please has updated the release PR" >> $GITHUB_STEP_SUMMARY + echo "- **PR Number**: ${{ steps.release.outputs.prs_created }}" >> $GITHUB_STEP_SUMMARY + + - name: Summary - No Changes + if: steps.release.outputs.release_created != 'true' && steps.release.outputs.pr == '' + run: | + echo "## ℹ️ No Release Changes" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No conventional commits found that require a release." >> $GITHUB_STEP_SUMMARY + + publish: + name: Publish to NPM + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + uses: ./.github/workflows/reusable/reusable-npm-publish.yml + with: + version: ${{ needs.release-please.outputs.version }} + npm-scope: 'pantheon-org' + project-name: 'opencode-skills-plugin' + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-docs: + name: Deploy Documentation + needs: [release-please, publish] + if: needs.release-please.outputs.release_created == 'true' && success() + uses: ./.github/workflows/reusable/reusable-deploy-docs.yml + with: + commit-sha: ${{ github.sha }} + secrets: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/opencode-skills/.github/workflows/reusable/reusable-deploy-docs.yml b/packages/opencode-skills/.github/workflows/reusable/reusable-deploy-docs.yml new file mode 100644 index 0000000..3cd09d8 --- /dev/null +++ b/packages/opencode-skills/.github/workflows/reusable/reusable-deploy-docs.yml @@ -0,0 +1,68 @@ +name: Reusable Deploy Documentation + +on: + workflow_call: + inputs: + commit-sha: + description: 'Git commit SHA for deployment message' + required: false + type: string + default: '' + secrets: + GITHUB_TOKEN: + description: 'GitHub token for deployment' + required: true + +jobs: + deploy-docs: + name: Deploy Documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 + + - name: Setup Bun with caching + uses: ./.github/actions/setup-bun + + - name: Clone docs-builder + run: | + git clone --depth 1 https://github.com/pantheon-org/opencode-docs-builder.git docs-builder + echo "✅ Docs builder cloned" + + - name: Install docs-builder dependencies + run: | + cd docs-builder + bun install + + - name: Copy plugin documentation + run: | + cp -r ./docs/* ./docs-builder/src/content/docs/ || mkdir -p ./docs-builder/src/content/docs/ + echo "✅ Plugin docs copied to docs-builder" + + - name: Build documentation + run: | + cd docs-builder + bun run build + + - name: Deploy to docs branch + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: docs + publish_dir: ./docs-builder/dist + force_orphan: false + enable_jekyll: false + commit_message: 'docs: Deploy documentation from ${{ inputs.commit-sha || github.sha }}' + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' + + - name: Summary + run: | + echo "## ✅ Documentation Deployed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Branch**: docs" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: ${{ inputs.commit-sha || github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **URL**: https://github.com/${{ github.repository }}/tree/docs" >> $GITHUB_STEP_SUMMARY diff --git a/packages/opencode-skills/.github/workflows/reusable/reusable-npm-publish.yml b/packages/opencode-skills/.github/workflows/reusable/reusable-npm-publish.yml new file mode 100644 index 0000000..9df9761 --- /dev/null +++ b/packages/opencode-skills/.github/workflows/reusable/reusable-npm-publish.yml @@ -0,0 +1,171 @@ +name: Reusable NPM Publish Pipeline + +on: + workflow_call: + inputs: + version: + description: 'Version to publish (e.g., 1.2.3 without v prefix)' + required: true + type: string + npm-scope: + description: 'NPM organization scope (e.g., pantheon-org)' + required: true + type: string + project-name: + description: 'Project name for display purposes' + required: true + type: string + secrets: + NPM_TOKEN: + description: 'NPM authentication token' + required: true + outputs: + published: + description: 'Whether package was published (true/false)' + value: ${{ jobs.publish-npm.outputs.published }} + package-url: + description: 'URL to published package on npm' + value: ${{ jobs.publish-npm.outputs.package-url }} + +jobs: + publish-npm: + name: Publish to npm + runs-on: ubuntu-latest + + outputs: + published: ${{ steps.publish.outputs.published }} + package-url: ${{ steps.publish.outputs.package-url }} + + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 + + - name: Setup Bun with caching + uses: ./.github/actions/setup-bun + + - name: Setup Node.js for npm + uses: ./.github/actions/setup-node-npm + + - name: Run full validation + run: | + echo "🔍 Running linter..." + bun run lint + + echo "📝 Type checking..." + bun run type-check + + echo "🧪 Running tests..." + bun run test:coverage + + echo "🏗️ Building project..." + bun run build + + - name: Check if already published + id: check-npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + VERSION="${{ inputs.version }}" + + echo "🔍 Checking if $PACKAGE_NAME@$VERSION exists on npm..." + + # Try to check if version exists (capture both stdout and stderr) + NPM_CHECK=$(npm view "$PACKAGE_NAME@$VERSION" version 2>&1 || true) + + if echo "$NPM_CHECK" | grep -q "E404"; then + echo "✅ Version $VERSION not yet published (404 - not found)" + echo "published=false" >> $GITHUB_OUTPUT + elif echo "$NPM_CHECK" | grep -q "$VERSION"; then + echo "⚠️ Version $VERSION already published to npm" + echo "published=true" >> $GITHUB_OUTPUT + else + echo "⚠️ Could not determine publication status, assuming not published" + echo "published=false" >> $GITHUB_OUTPUT + fi + + - name: Publish to npm + id: publish + if: steps.check-npm.outputs.published == 'false' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + VERSION="${{ inputs.version }}" + + echo "🚀 Publishing $PACKAGE_NAME@$VERSION to npm..." + + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "❌ Error: NPM_TOKEN secret is not set!" + echo "Please configure npm authentication:" + echo " Option 1 (Recommended): Set up npm provenance with OIDC" + echo " Option 2: Add NPM_TOKEN secret with granular access token" + echo "See: https://docs.npmjs.com/generating-provenance-statements" + exit 1 + fi + + # Attempt to publish with detailed error handling + if npm publish --access public --provenance; then + echo "✅ Successfully published $PACKAGE_NAME@$VERSION to npm" + echo "published=true" >> $GITHUB_OUTPUT + echo "package-url=https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION" >> $GITHUB_OUTPUT + else + NPM_EXIT_CODE=$? + echo "❌ npm publish failed with exit code: $NPM_EXIT_CODE" + echo "" + echo "Common causes:" + echo " - Expired or invalid NPM_TOKEN" + echo " - Token lacks publish permissions for @${{ inputs.npm-scope }} organization" + echo " - Package name already published with this version" + echo "" + echo "To fix: Update NPM_TOKEN with a granular access token that has" + echo " 'Read and write' permissions for this package" + exit $NPM_EXIT_CODE + fi + + - name: Verify npm publication + if: steps.check-npm.outputs.published == 'false' + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + VERSION="${{ inputs.version }}" + + echo "🔍 Verifying npm publication..." + + for i in {1..6}; do + echo "Attempt $i/6..." + if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then + echo "✅ Package verified on npm: $PACKAGE_NAME@$VERSION" + exit 0 + fi + + if [ $i -lt 6 ]; then + echo "Waiting 15 seconds..." + sleep 15 + fi + done + + echo "❌ Failed to verify package (may still be propagating)" + exit 1 + + - name: Summary + if: always() + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + + if [ "${{ steps.check-npm.outputs.published }}" = "true" ]; then + echo "## ℹ️ Already Published" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version ${{ inputs.version }} was already published to npm" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.publish.outputs.published }}" = "true" ]; then + echo "## ✅ Published to NPM" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Package**: $PACKAGE_NAME" >> $GITHUB_STEP_SUMMARY + echo "- **Version**: ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **URL**: ${{ steps.publish.outputs.package-url }}" >> $GITHUB_STEP_SUMMARY + else + echo "## ❌ Publication Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Check the logs above for details" >> $GITHUB_STEP_SUMMARY + fi diff --git a/packages/opencode-skills/LICENSE b/packages/opencode-skills/LICENSE new file mode 100644 index 0000000..f334655 --- /dev/null +++ b/packages/opencode-skills/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pantheon-org + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/opencode-skills/README.md b/packages/opencode-skills/README.md new file mode 100644 index 0000000..d06cc5d --- /dev/null +++ b/packages/opencode-skills/README.md @@ -0,0 +1,598 @@ +# @pantheon-org/opencode-skills + +TypeScript-based skill injection plugin for OpenCode with smart pattern matching and auto-injection capabilities. + +> **Note**: This plugin is part of the `pantheon-org/opencode-plugins` monorepo. All development and contributions +> should be made in the main repository at: **https://github.com/pantheon-org/opencode-plugins** +> +> If you're viewing this as a mirror repository, it is read-only. Submit issues, PRs, and contributions to the main +> monorepo. + + + + +## Overview + +This plugin provides a seamless way to inject reusable knowledge and guidance (skills) into OpenCode chat sessions. +Skills are automatically detected and injected based on user intent using smart pattern matching. + +**Key Features:** + +- **Type-safe skill definitions** - Skills defined in TypeScript with full type safety +- **Smart pattern matching** - Intent detection, negation handling, and keyword matching +- **Auto-injection** - Skills seamlessly injected via `chat.message` hook +- **Zero file system side effects** - No SKILL.md files, everything in TypeScript +- **Highly configurable** - Customize pattern matching behavior per your needs +- **Comprehensive testing** - Full test coverage for pattern matching logic + +## Installation + +```bash +bun add @pantheon-org/opencode-skills +``` + +## Quick Start + +### 1. Define Your Skills + +```typescript +import { defineSkill, createSkillsPlugin } from '@pantheon-org/opencode-skills'; + +// Define a skill +const mySkill = defineSkill({ + name: 'typescript-tdd', + description: 'TypeScript development with TDD', + keywords: ['tdd', 'test-driven', 'testing'], + content: ` +# TypeScript TDD Development + +Follow these guidelines... + `, +}); + +// Create skill registry +const skills = { + 'typescript-tdd': mySkill, +}; + +// Create plugin +export const MySkillsPlugin = createSkillsPlugin(skills); +``` + +### 2. Add to OpenCode Configuration + +In your `opencode.json`: + +```json +{ + "plugin": ["file:///path/to/your/plugin.ts"] +} +``` + +### 3. Use Skills Naturally + +Simply mention the skill in your message: + +``` +User: "Let's use typescript-tdd for this component" +``` + +The plugin automatically detects intent and injects the skill content into the chat context. + +## How It Works + +### Pattern Matching + +The plugin uses three strategies to detect when a skill should be injected: + +#### 1. Word Boundary Matching + +Exact skill name with word boundaries: + +```typescript +'use typescript-tdd approach'; // ✅ Matches +'typescript-tdd-extended'; // ❌ Won't match (different skill) +``` + +#### 2. Intent Detection + +Intent keywords that signal user wants to use the skill: + +- `use`, `apply`, `follow`, `implement`, `load`, `get`, `show`, `with` + +```typescript +'apply typescript-tdd principles'; // ✅ Matches +'follow the typescript-tdd guide'; // ✅ Matches +'typescript-tdd approach'; // ✅ Matches +``` + +#### 3. Negation Detection + +Prevents injection when user explicitly avoids a skill: + +- `don't`, `do not`, `avoid`, `skip`, `ignore`, `without`, `except`, `excluding` + +```typescript +"don't use typescript-tdd"; // ❌ Won't inject +'implement without typescript-tdd'; // ❌ Won't inject +'avoid typescript-tdd patterns'; // ❌ Won't inject +``` + +### Keyword Enhancement + +Add optional keywords to improve detection: + +```typescript +const mySkill = defineSkill({ + name: 'typescript-tdd', + description: 'TypeScript TDD', + keywords: ['TDD', 'test-driven', 'bun'], // Additional matching keywords + content: '...', +}); +``` + +Now these also trigger injection: + +```typescript +'write tests using TDD'; // ✅ Matches via keyword +'use test-driven development'; // ✅ Matches via keyword +``` + +## API Reference + +### `createSkillsPlugin(skills, config?)` + +Creates an OpenCode plugin with the provided skills. + +**Parameters:** + +- `skills` - Record of skill name to Skill object +- `config` (optional) - Plugin configuration options + +**Returns:** Plugin function for OpenCode + +**Example:** + +```typescript +import { createSkillsPlugin } from '@pantheon-org/opencode-skills'; + +export const MyPlugin = createSkillsPlugin( + { + 'my-skill': mySkill, + }, + { + debug: true, + autoInject: true, + }, +); +``` + +### `defineSkill(skill)` + +Creates a skill object with defaults. + +**Parameters:** + +- `skill.name` (required) - Unique skill identifier (kebab-case) +- `skill.description` (required) - Brief description +- `skill.content` (required) - Full skill content (markdown) +- `skill.keywords` (optional) - Additional keywords for pattern matching +- `skill.version` (optional) - Skill version (default: '1.0.0') +- `skill.category` (optional) - Skill category for organization +- `skill.dependencies` (optional) - Other skill names this depends on + +**Returns:** Complete Skill object + +**Example:** + +```typescript +import { defineSkill } from '@pantheon-org/opencode-skills'; + +const skill = defineSkill({ + name: 'react-patterns', + description: 'Modern React patterns', + keywords: ['react', 'hooks', 'components'], + category: 'development', + content: ` +# React Patterns + +Best practices for React development... + `, +}); +``` + +### Configuration Options + +```typescript +interface SkillsPluginConfig { + // Enable/disable auto-injection (default: true) + autoInject?: boolean; + + // Enable debug logging (default: false) + debug?: boolean; + + // Pattern matching configuration + patternMatching?: { + // Word boundary matching (default: true) + wordBoundary?: boolean; + + // Intent detection (default: true) + intentDetection?: boolean; + + // Negation detection (default: true) + negationDetection?: boolean; + + // Custom intent keywords (adds to defaults) + customIntentKeywords?: string[]; + + // Custom negation keywords (adds to defaults) + customNegationKeywords?: string[]; + }; + + // BM25 relevance scoring configuration + bm25?: { + // Enable BM25 ranking (default: false) + enabled?: boolean; + + // Term frequency saturation (default: 1.5, range: 1.2-2.0) + k1?: number; + + // Length normalization (default: 0.75, range: 0-1) + b?: number; + + // Minimum score threshold (default: 0.0) + threshold?: number; + + // Max skills to inject per message (default: 3) + maxSkills?: number; + }; +} +``` + +**Example with custom configuration:** + +```typescript +export const MyPlugin = createSkillsPlugin(skills, { + debug: true, + autoInject: true, + patternMatching: { + customIntentKeywords: ['leverage', 'adopt'], + customNegationKeywords: ['exclude'], + }, +}); +``` + +## Example Skills + +The package includes example skills for reference: + +```typescript +import { exampleSkills } from '@pantheon-org/opencode-skills/examples'; + +// Available examples: +// - typescript-tdd: TypeScript development with TDD +// - plain-english: Writing for non-technical stakeholders +// - react-patterns: Modern React component patterns + +export const MyPlugin = createSkillsPlugin(exampleSkills); +``` + +## Advanced Usage + +### BM25 Relevance Ranking + +Enable BM25 (Best Matching 25) probabilistic ranking for more sophisticated skill selection based on relevance scoring: + +```typescript +export const MyPlugin = createSkillsPlugin(skills, { + bm25: { + enabled: true, // Enable BM25 ranking + k1: 1.5, // Term frequency saturation (default: 1.5) + b: 0.75, // Length normalization (default: 0.75) + threshold: 0.5, // Minimum score for injection (default: 0.0) + maxSkills: 3, // Max skills per message (default: 3) + }, +}); +``` + +**How BM25 Works:** + +BM25 ranks skills by relevance to the user's message using: + +- **Term Frequency (TF)**: How often query terms appear in skill content +- **Inverse Document Frequency (IDF)**: How unique/rare terms are across skills +- **Length Normalization**: Adjusts for varying skill content lengths + +**BM25 vs Pattern Matching:** + +| Feature | Pattern Matching | BM25 Ranking | +| -------------------- | ------------------------- | ---------------------- | +| **Detection** | Exact name + intent words | Relevance scoring | +| **Ranking** | No ranking | Scores all skills | +| **Multiple Skills** | All matches injected | Top N by relevance | +| **Content Analysis** | Limited | Full content analysis | +| **False Positives** | Lower risk | Configurable threshold | +| **Best For** | Explicit mentions | Semantic relevance | + +**Hybrid Mode:** + +When BM25 is enabled, the plugin uses a hybrid approach: + +1. BM25 ranks all skills by relevance +2. Top N candidates are selected (based on `maxSkills`) +3. Pattern matching filters out negated skills +4. Remaining skills are injected + +**Example:** + +```typescript +// User message: "help me write tests for React components" +// BM25 will rank: +// 1. typescript-tdd (high relevance: "tests", "write") +// 2. react-patterns (high relevance: "react", "components") +// 3. plain-english (low relevance) +// Result: Injects typescript-tdd and react-patterns +``` + +**Configuration Tips:** + +- **k1 (1.2-2.0)**: Higher values = more weight on term frequency +- **b (0-1)**: Higher values = more length normalization (0 = no normalization) +- **threshold**: Set higher to reduce false positives +- **maxSkills**: Balance between context size and relevance + +### Custom Pattern Matching + +Disable certain pattern matching features: + +```typescript +export const MyPlugin = createSkillsPlugin(skills, { + patternMatching: { + wordBoundary: true, + intentDetection: true, + negationDetection: false, // Allow injection even with negation + }, +}); +``` + +### Debug Mode + +Enable debug logging to see when skills are injected: + +```typescript +export const MyPlugin = createSkillsPlugin(skills, { + debug: true, +}); +``` + +Console output: + +``` +[opencode-skills] Plugin loaded with 3 skills +[opencode-skills] Skills: typescript-tdd, plain-english, react-patterns +[opencode-skills] Auto-injected skill "typescript-tdd" in session abc123 +[opencode-skills] Matched pattern: intent-before:use +``` + +### Manual Skill Detection + +Use the pattern matching utilities directly: + +```typescript +import { hasIntentToUse, findMatchingSkills } from '@pantheon-org/opencode-skills'; + +// Check if content matches a skill +const result = hasIntentToUse('use typescript-tdd approach', 'typescript-tdd'); +console.log(result.matches); // true +console.log(result.matchedPattern); // 'intent-before:use' +console.log(result.hasNegation); // false + +// Find all matching skills +const matches = findMatchingSkills('use typescript-tdd and plain-english', [ + 'typescript-tdd', + 'plain-english', + 'react-patterns', +]); +console.log(matches); // ['typescript-tdd', 'plain-english'] +``` + +### Manual BM25 Ranking + +Use BM25 utilities directly for custom ranking logic: + +```typescript +import { buildBM25Index, rankSkillsByBM25, getTopSkillsByBM25 } from '@pantheon-org/opencode-skills'; + +// Build BM25 index from skills +const skillsMap = new Map([ + ['typescript-tdd', 'TypeScript development with TDD...'], + ['plain-english', 'Writing for non-technical stakeholders...'], + ['react-patterns', 'Modern React patterns...'], +]); + +const index = buildBM25Index(skillsMap); + +// Rank all skills by relevance +const query = 'help me write React tests'; +const ranked = rankSkillsByBM25(query, ['typescript-tdd', 'plain-english', 'react-patterns'], index); +console.log(ranked); +// [ +// ['typescript-tdd', 2.45], +// ['react-patterns', 1.87], +// ['plain-english', 0.23] +// ] + +// Get top N skills +const topSkills = getTopSkillsByBM25(query, ['typescript-tdd', 'plain-english', 'react-patterns'], index, 2); +console.log(topSkills); // ['typescript-tdd', 'react-patterns'] + +// With custom configuration +const customRanked = rankSkillsByBM25(query, skillNames, index, { + k1: 2.0, // Higher term frequency weight + b: 0.5, // Lower length normalization + threshold: 1.0, // Only skills scoring above 1.0 +}); +``` + +## Best Practices + +### 1. Skill Naming + +Use kebab-case for skill names: + +```typescript +✅ 'typescript-tdd' +✅ 'plain-english' +✅ 'react-patterns' + +❌ 'TypeScript_TDD' +❌ 'Plain English' +``` + +### 2. Skill Content + +Structure skill content with clear headings and examples: + +```typescript +const skill = defineSkill({ + name: 'my-skill', + description: 'Brief one-liner', + content: ` +# Main Title + +## Overview +Brief introduction + +## Guidelines +1. First guideline +2. Second guideline + +## Examples +\`\`\`typescript +// Code example +\`\`\` + +## Best Practices +- Practice 1 +- Practice 2 + `, +}); +``` + +### 3. Keywords + +Add keywords that users naturally use: + +```typescript +const skill = defineSkill({ + name: 'typescript-tdd', + description: 'TypeScript TDD', + keywords: [ + 'tdd', // Abbreviation + 'test-driven', // Alternative phrasing + 'testing', // Related concept + 'bun', // Related tool + ], + content: '...', +}); +``` + +### 4. Avoid Over-Injection + +Be specific with skill names to avoid unwanted matches: + +```typescript +// Too generic - might match too often +❌ 'typescript' +❌ 'development' + +// Specific enough to avoid false positives +✅ 'typescript-tdd' +✅ 'typescript-strict-mode' +``` + +## Comparison with Other Approaches + +### vs File-Based Skills (SKILL.md) + +| Feature | opencode-skills | File-Based | +| ------------------- | -------------------- | ----------------- | +| **Type Safety** | ✅ Full TypeScript | ❌ No types | +| **No File I/O** | ✅ TypeScript only | ❌ Read files | +| **Version Control** | ✅ Git-friendly | ⚠️ Separate files | +| **IDE Support** | ✅ Full autocomplete | ❌ Limited | +| **Testing** | ✅ Easy to test | ⚠️ Harder | +| **Auto-Injection** | ✅ Built-in | ⚠️ Manual tool | + +### vs Custom Tool Approach + +| Feature | opencode-skills | Custom Tool | +| -------------------- | ------------------ | -------------------- | +| **User Experience** | ✅ Automatic | ❌ Manual invocation | +| **Intent Detection** | ✅ Smart matching | ❌ Exact match | +| **Configuration** | ✅ Highly flexible | ⚠️ Limited | +| **Setup Complexity** | ✅ Simple API | ⚠️ More boilerplate | + +## Troubleshooting + +### Skills Not Injecting + +1. **Check skill name matches** - Ensure you're using the exact skill name +2. **Enable debug mode** - See what patterns are matching +3. **Check negation** - Make sure you're not using negation keywords +4. **Verify configuration** - Ensure `autoInject: true` (default) + +```typescript +// Debug +export const MyPlugin = createSkillsPlugin(skills, { debug: true }); +``` + +### False Positives + +If skills inject too often: + +1. **Use more specific names** - Avoid generic terms +2. **Disable word boundary** - Require intent keywords +3. **Add negation detection** - Already enabled by default + +```typescript +export const MyPlugin = createSkillsPlugin(skills, { + patternMatching: { + wordBoundary: false, // Require intent keywords + }, +}); +``` + +## Development + +### Running Tests + +```bash +bun test +``` + +### Type Checking + +```bash +bun run type-check +``` + +### Linting + +```bash +bun run lint +``` + +## License + +MIT + +## Contributing + +Contributions welcome! Please read our [Contributing Guide](./docs/development.md) for details. + +## Related + +- [OpenCode Documentation](https://opencode.ai/docs) +- [OpenCode Plugin Development Guide](https://opencode.ai/docs/plugins) +- [TypeScript Handbook](https://www.typescriptlang.org/docs/) diff --git a/packages/opencode-skills/docs/README.md b/packages/opencode-skills/docs/README.md new file mode 100644 index 0000000..6f76692 --- /dev/null +++ b/packages/opencode-skills/docs/README.md @@ -0,0 +1,39 @@ +# Skills + +TypeScript-based skill injection plugin for OpenCode + +## Installation + +\`\`\`bash bun add @pantheon-org/opencode-skills \`\`\` + +## Usage + +Add the plugin to your `opencode.json`: + +\`\`\`json { "plugin": ["@pantheon-org/opencode-skills"] } \`\`\` + +## Features + +- Feature 1 +- Feature 2 +- Feature 3 + +## Configuration + +Describe plugin configuration options here. + +## API + +Document your plugin's API here. + +## Development + +See the [Development Guide](./development.md) for information on contributing to this plugin. + +## Troubleshooting + +See the [Troubleshooting Guide](./troubleshooting.md) for common issues and solutions. + +## License + +MIT diff --git a/packages/opencode-skills/docs/api.md b/packages/opencode-skills/docs/api.md new file mode 100644 index 0000000..5e375c6 --- /dev/null +++ b/packages/opencode-skills/docs/api.md @@ -0,0 +1,27 @@ +# API Documentation + +This document describes the API provided by the Skills plugin. + +## Plugin Export + +The plugin exports a main function that conforms to the OpenCode Plugin interface. + +\`\`\`typescript import { Plugin } from '@opencode-ai/plugin' + +export const SkillsPlugin: Plugin = async (ctx) => { return { // Plugin implementation } } \`\`\` + +## Tools + +Document any tools your plugin provides. + +## Hooks + +Document any hooks your plugin implements. + +## Events + +Document any events your plugin handles. + +## Types + +Document any types your plugin exports. diff --git a/packages/opencode-skills/docs/development.md b/packages/opencode-skills/docs/development.md new file mode 100644 index 0000000..246e812 --- /dev/null +++ b/packages/opencode-skills/docs/development.md @@ -0,0 +1,182 @@ +# Development Guide + +This guide covers how to develop and contribute to the Skills plugin. + +## Setup + +\`\`\`bash + +# Clone the repository + +git clone https://github.com/pantheon-org/opencode-skills.git cd opencode-skills + +# Install dependencies + +bun install + +# Run tests + +bun test + +# Build the plugin + +bun run build \`\`\` + +## Project Structure + +\`\`\` opencode-skills/ ├── src/ # Plugin source code ├── docs/ # Documentation source ├── pages/ # Documentation site +builder └── dist/ # Build output \`\`\` + +## Testing + +\`\`\`bash + +# Run all tests + +bun test + +# Run tests in watch mode + +bun test --watch + +# Run tests with coverage + +bun test --coverage \`\`\` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Write tests +5. Submit a pull request + +## Release Process + +This plugin uses **Release Please** for automated releases based on +[Conventional Commits](https://www.conventionalcommits.org/). + +### Automated Releases + +Release Please automatically: + +- Creates/updates release PRs when conventional commits are pushed to `main` +- Bumps version numbers based on commit types +- Generates CHANGELOG.md from commit messages +- Publishes to npm when release PR is merged +- Deploys documentation to GitHub Pages + +### Commit Message Format + +Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +\`\`\`bash + +# New features (bumps minor version) + +feat: add new notification sound + +# Bug fixes (bumps patch version) + +fix: resolve connection timeout issue + +# Breaking changes (bumps major version) + +feat!: redesign plugin API + +# or + +feat: redesign plugin API + +BREAKING CHANGE: The plugin now requires initialization before use + +# Documentation + +docs: update installation instructions + +# Other types (no version bump) + +chore: update dependencies style: fix code formatting refactor: simplify notification logic test: add unit tests for +parser ci: update GitHub Actions workflow \`\`\` + +## Release Workflow + +1. **Make changes** using conventional commits: \`\`\`bash git add . git commit -m "feat: add support for custom themes" + git push origin feature-branch \`\`\` + +2. **Create PR** and merge to `main`: + - Release Please detects conventional commits + - Creates/updates a release PR automatically + - PR includes version bump and CHANGELOG updates + +3. **Review and merge release PR**: + - Release Please PR is created as `chore(main): release X.Y.Z` + - Review the version bump and changelog + - Merge the PR to trigger the release + +4. **Automated release**: + - `release-please.yml` workflow triggers + - Runs full validation (lint, type-check, tests, build) + - Publishes package to npm + - Deploys documentation to GitHub Pages + - Creates GitHub release with changelog + +### Manual Release (Fallback) + +If you need to manually trigger a release: + +\`\`\`bash + +# Create a version tag + +git tag v1.0.0 git push origin v1.0.0 \`\`\` + +The `2-publish.yml` workflow will handle npm publishing and docs deployment. + +## GitHub Workflows + +The plugin includes several automated workflows: + +- **1-validate.yml**: Validates PRs (lint, test, build, security) +- **release-please.yml**: Automated release management (primary) +- **2-publish.yml**: Manual tag-based releases (fallback) +- **deploy-docs.yml**: Standalone documentation deployment +- **chores-pages.yml**: Daily GitHub Pages health check + +### Version Bumping Rules + +Release Please follows semantic versioning: + +| Commit Type | Version Bump | Example | +| ------------------------------ | ------------- | ------------- | +| `feat:` | Minor (0.X.0) | 1.2.0 → 1.3.0 | +| `fix:` | Patch (0.0.X) | 1.2.0 → 1.2.1 | +| `feat!:` or `BREAKING CHANGE:` | Major (X.0.0) | 1.2.0 → 2.0.0 | +| `docs:`, `chore:`, etc. | None | No release | + +### Configuration Files + +Release Please configuration: + +- `.github/release-please-config.json`: Release Please settings +- `.github/.release-please-manifest.json`: Current version tracking + +### Troubleshooting Releases + +**Release PR not created:** + +- Ensure commits follow conventional commit format +- Check `.github/release-please-config.json` for correct configuration +- Verify `release-please.yml` workflow is enabled + +**npm publish failed:** + +- Verify `NPM_TOKEN` secret is configured in repository settings +- Token must have "Read and write" permissions for the package +- Check package name is available on npm + +**Documentation not deploying:** + +- Ensure `pages/` directory has valid Astro configuration +- Check GitHub Pages is enabled in repository settings +- Verify docs build succeeds locally: `cd pages && bun run build` diff --git a/packages/opencode-skills/docs/troubleshooting.md b/packages/opencode-skills/docs/troubleshooting.md new file mode 100644 index 0000000..d258132 --- /dev/null +++ b/packages/opencode-skills/docs/troubleshooting.md @@ -0,0 +1,37 @@ +# Troubleshooting + +Common issues and their solutions. + +## Installation Issues + +### Issue: Plugin not loading + +**Solution:** Ensure the plugin is listed in your `opencode.json` file. + +## Usage Issues + +### Issue: Feature not working + +**Solution:** Check your configuration and ensure all required options are set. + +## Performance Issues + +### Issue: Plugin is slow + +**Solution:** Review your configuration and consider optimizing any custom implementations. + +## Getting Help + +If you encounter an issue not listed here: + +1. Check the [GitHub Issues](https://github.com/pantheon-org/opencode-skills/issues) +2. Search for similar issues +3. Create a new issue if needed + +Include the following information: + +- OpenCode version +- Plugin version +- Operating system +- Configuration file +- Error messages diff --git a/packages/opencode-skills/docs/user-guide.md b/packages/opencode-skills/docs/user-guide.md new file mode 100644 index 0000000..18a6b24 --- /dev/null +++ b/packages/opencode-skills/docs/user-guide.md @@ -0,0 +1,25 @@ +# User Guide + +This guide covers how to use the Skills plugin. + +## Getting Started + +1. Install the plugin +2. Configure it in your `opencode.json` +3. Start using the features + +## Examples + +### Basic Example + +\`\`\`typescript // Add usage examples here \`\`\` + +## Configuration Options + +Document all configuration options here. + +## Best Practices + +- Best practice 1 +- Best practice 2 +- Best practice 3 diff --git a/packages/opencode-skills/index.ts b/packages/opencode-skills/index.ts new file mode 100644 index 0000000..e2a9f12 --- /dev/null +++ b/packages/opencode-skills/index.ts @@ -0,0 +1,2 @@ +export type { MatchResult, Skill, SkillsPluginConfig } from './src'; +export { createSkillsPlugin, defineSkill, findMatchingSkills, hasIntentToUse } from './src'; diff --git a/packages/opencode-skills/package.json b/packages/opencode-skills/package.json new file mode 100644 index 0000000..b19f902 --- /dev/null +++ b/packages/opencode-skills/package.json @@ -0,0 +1,48 @@ +{ + "name": "@pantheon-org/opencode-skills", + "version": "0.1.0", + "description": "TypeScript-based skill injection plugin for OpenCode with smart pattern matching", + "keywords": ["opencode", "plugin", "typescript", "skills", "tdd", "development", "knowledge-base"], + "homepage": "https://github.com/pantheon-org/opencode-skills#readme", + "bugs": { + "url": "https://github.com/pantheon-org/opencode-skills/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/pantheon-org/opencode-skills.git" + }, + "license": "MIT", + "type": "module", + "main": "index.ts", + "files": ["index.ts", "src/", "README.md", "LICENSE"], + "scripts": { + "test": "bun test src", + "test:coverage": "bun test --coverage src/", + "test:watch": "bun test --watch src/", + "test:verbose": "bun test src/ --reporter tap", + "type-check": "bun tsc --noEmit --project tsconfig.test.json", + "lint": "eslint src/", + "lint:md": "markdownlint-cli2", + "lint:md:fix": "markdownlint-cli2 --fix", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,mjs,cjs,css}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,mjs,cjs,css}\"", + "clean": "rm -rf dist/ .bun-cache/ coverage/", + "build": "bun tsc", + "dev": "bun tsc --watch", + "verify:package": "npm pack --dry-run", + "prepublishOnly": "bun run build && bun test && bun run verify:package" + }, + "dependencies": { + "yaml": "^2.3.4", + "zod": "^3.22.4" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/bun": "latest", + "@types/glob": "^8.1.0" + }, + "engines": { + "node": ">=20.0.0", + "bun": ">=1.0.0" + } +} diff --git a/packages/opencode-skills/project.json b/packages/opencode-skills/project.json new file mode 100644 index 0000000..96183e4 --- /dev/null +++ b/packages/opencode-skills/project.json @@ -0,0 +1,80 @@ +{ + "name": "opencode-skills", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/opencode-skills/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "bunx tsup src/index.ts --format esm,cjs --dts --out-dir dist" + } + ], + "cwd": "packages/opencode-skills", + "parallel": false + } + }, + "pack": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "bunx npm pack --prefix packages/opencode-skills" + } + ], + "parallel": false + } + }, + "check-mirror-exists": { + "executor": "@pantheon-org/tools:check-mirror-exists" + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "biome check --write .", + "cwd": "packages/opencode-skills" + }, + "cache": true, + "inputs": ["default", "{workspaceRoot}/biome.json", "{projectRoot}/biome.json"] + }, + "format": { + "executor": "nx:run-commands", + "options": { + "command": "biome format --write .", + "cwd": "packages/opencode-skills" + }, + "cache": true, + "inputs": ["default", "{workspaceRoot}/biome.json", "{projectRoot}/biome.json"] + }, + "type-check": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "bunx tsc --noEmit" + } + ], + "cwd": "packages/opencode-skills", + "parallel": false + } + }, + "dev-proxy": { + "executor": "@pantheon-org/tools:dev-proxy", + "options": {} + }, + "test": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "bun test" + } + ], + "cwd": "packages/opencode-skills", + "parallel": false + } + } + } +} diff --git a/packages/opencode-skills/src/bm25/build-index.test.ts b/packages/opencode-skills/src/bm25/build-index.test.ts new file mode 100644 index 0000000..18fc831 --- /dev/null +++ b/packages/opencode-skills/src/bm25/build-index.test.ts @@ -0,0 +1,87 @@ +/** + * Build BM25 Index Tests + * + * Tests for BM25 index construction and precomputation. + */ + +import { describe, expect, it } from 'bun:test'; +import { buildBM25Index } from './build-index'; + +describe('buildBM25Index', () => { + it('should build index from skills map', () => { + const skills = new Map([ + ['skill-one', 'Content for skill one'], + ['skill-two', 'Content for skill two'], + ]); + const index = buildBM25Index(skills); + expect(index.documents).toHaveLength(2); + expect(index.totalDocs).toBe(2); + }); + + it('should include skill name in document', () => { + const skills = new Map([['typescript-tdd', 'TypeScript development']]); + const index = buildBM25Index(skills); + const doc = index.documents[0]; + expect(doc).toContain('typescript-tdd'); + expect(doc).toContain('typescript'); + expect(doc).toContain('development'); + }); + + it('should calculate average document length', () => { + const skills = new Map([ + ['short', 'Short content'], + ['long', 'This is a much longer content with many words for testing'], + ]); + const index = buildBM25Index(skills); + expect(index.avgDocLength).toBeGreaterThan(0); + // Note: skill name is included in document, so word counts are different + // 'short' + 'short' + 'content' = 3 words + // 'long' + 'this' + 'is' + 'a' + 'much' + 'longer' + 'content' + 'with' + 'many' + 'words' + 'for' + 'testing' = 12 words + // Average = (3 + 12) / 2 = 7.5 + expect(index.avgDocLength).toBeCloseTo(7.5, 1); + }); + + it('should build IDF cache for all terms', () => { + const skills = new Map([ + ['skill-a', 'common unique'], + ['skill-b', 'common rare'], + ]); + const index = buildBM25Index(skills); + // Should have cached IDF for: skill-a, skill-b, common, unique, rare + expect(index.idfCache.size).toBeGreaterThan(0); + expect(index.idfCache.has('common')).toBe(true); + expect(index.idfCache.has('unique')).toBe(true); + }); + + it('should handle empty skills map', () => { + const skills = new Map(); + // Currently returns NaN for avgDocLength when empty, doesn't throw + const index = buildBM25Index(skills); + expect(index.documents).toHaveLength(0); + expect(index.totalDocs).toBe(0); + expect(Number.isNaN(index.avgDocLength)).toBe(true); + }); + + it('should handle skills with same content', () => { + const skills = new Map([ + ['skill-a', 'Same content'], + ['skill-b', 'Same content'], + ]); + const index = buildBM25Index(skills); + expect(index.documents).toHaveLength(2); + // IDF for 'content' should be low since it appears in all docs + const contentIdf = index.idfCache.get('content'); + expect(contentIdf).toBeLessThan(Math.log(2)); // Less than max possible IDF + }); + + it('should handle skills with no overlap', () => { + const skills = new Map([ + ['skill-a', 'Alpha Beta Gamma'], + ['skill-b', 'Delta Epsilon Zeta'], + ]); + const index = buildBM25Index(skills); + expect(index.totalDocs).toBe(2); + // No shared terms except skill names + expect(index.idfCache.size).toBe(8); // 2 skill names + 6 content words + }); +}); diff --git a/packages/opencode-skills/src/bm25/build-index.ts b/packages/opencode-skills/src/bm25/build-index.ts new file mode 100644 index 0000000..a93b55c --- /dev/null +++ b/packages/opencode-skills/src/bm25/build-index.ts @@ -0,0 +1,63 @@ +/** + * Build BM25 Index + * + * Precompute document statistics and IDF values for efficient BM25 scoring. + */ + +import { inverseDocumentFrequency } from './inverse-document-frequency'; +import { tokenize } from './tokenize'; +import type { BM25Index } from './types'; + +/** + * Build BM25 index from skill content + * + * @param skills - Map of skill name to skill content + * @returns Precomputed BM25 index with tokenized documents and IDF cache + * + * @example + * ```typescript + * const skills = new Map([ + * ['typescript-tdd', 'TypeScript development with test-driven development'], + * ['bun-runtime', 'Bun runtime for fast JavaScript execution'], + * ]); + * + * const index = buildBM25Index(skills); + * console.log(index.totalDocs); // => 2 + * ``` + */ +export const buildBM25Index = (skills: Map): BM25Index => { + const documents: string[][] = []; + const skillNames: string[] = []; + + // Tokenize all skill content + for (const [name, content] of skills.entries()) { + // Combine skill name, description, and content for indexing + const combinedText = `${name} ${content}`; + documents.push(tokenize(combinedText)); + skillNames.push(name); + } + + // Calculate average document length + const totalLength = documents.reduce((sum, doc) => sum + doc.length, 0); + const avgDocLength = totalLength / documents.length; + + // Precompute IDF for all unique terms + const allTerms = new Set(); + for (const doc of documents) { + for (const term of doc) { + allTerms.add(term); + } + } + + const idfCache = new Map(); + for (const term of allTerms) { + idfCache.set(term, inverseDocumentFrequency(term, documents, documents.length)); + } + + return { + documents, + avgDocLength, + totalDocs: documents.length, + idfCache, + }; +}; diff --git a/packages/opencode-skills/src/bm25/calculate-score.test.ts b/packages/opencode-skills/src/bm25/calculate-score.test.ts new file mode 100644 index 0000000..a22c5eb --- /dev/null +++ b/packages/opencode-skills/src/bm25/calculate-score.test.ts @@ -0,0 +1,75 @@ +/** + * Calculate BM25 Score Tests + * + * Tests for BM25 relevance scoring algorithm. + */ + +import { describe, expect, it } from 'bun:test'; +import { buildBM25Index } from './build-index'; +import { calculateBM25Score } from './calculate-score'; +import type { BM25Index } from './types'; + +describe('calculateBM25Score', () => { + const createTestIndex = (): BM25Index => { + const skills = new Map([ + ['typescript-tdd', 'TypeScript development with test-driven development approach'], + ['react-hooks', 'React hooks for state management in functional components'], + ['node-api', 'Building RESTful APIs with Node.js and Express framework'], + ]); + return buildBM25Index(skills); + }; + + it('should return positive score for matching query', () => { + const index = createTestIndex(); + const score = calculateBM25Score('typescript development', 0, index); + expect(score).toBeGreaterThan(0); + }); + + it('should return 0 for non-matching query', () => { + const index = createTestIndex(); + const score = calculateBM25Score('python machine learning', 0, index); + expect(score).toBe(0); + }); + + it('should return higher score for better matching document', () => { + const index = createTestIndex(); + const score0 = calculateBM25Score('react hooks state', 0, index); // typescript-tdd + const score1 = calculateBM25Score('react hooks state', 1, index); // react-hooks + expect(score1).toBeGreaterThan(score0); + }); + + it('should return 0 for out of bounds index', () => { + const index = createTestIndex(); + expect(calculateBM25Score('test', -1, index)).toBe(0); + expect(calculateBM25Score('test', 100, index)).toBe(0); + }); + + it('should respect custom k1 parameter', () => { + const index = createTestIndex(); + const scoreLowK1 = calculateBM25Score('typescript', 0, index, { k1: 0.5 }); + const scoreHighK1 = calculateBM25Score('typescript', 0, index, { k1: 2.0 }); + // Higher k1 should allow more term frequency influence + expect(scoreHighK1).not.toBe(scoreLowK1); + }); + + it('should respect custom b parameter', () => { + const index = createTestIndex(); + const scoreLowB = calculateBM25Score('typescript development', 0, index, { b: 0.0 }); + const scoreHighB = calculateBM25Score('typescript development', 0, index, { b: 1.0 }); + // Different b values should produce different scores + expect(scoreLowB).not.toBe(scoreHighB); + }); + + it('should handle multi-term queries', () => { + const index = createTestIndex(); + const scoreSingle = calculateBM25Score('typescript', 0, index); + const scoreMultiple = calculateBM25Score('typescript development test', 0, index); + expect(scoreMultiple).toBeGreaterThan(scoreSingle); + }); + + it('should handle query with special characters', () => { + const index = createTestIndex(); + const score = calculateBM25Score('TypeScript, Development!', 0, index); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/packages/opencode-skills/src/bm25/calculate-score.ts b/packages/opencode-skills/src/bm25/calculate-score.ts new file mode 100644 index 0000000..1d8b5d2 --- /dev/null +++ b/packages/opencode-skills/src/bm25/calculate-score.ts @@ -0,0 +1,69 @@ +/** + * Calculate BM25 Score + * + * Calculate BM25 relevance score for a query against a document using the + * Best Matching 25 probabilistic ranking function. + */ + +import { termFrequency } from './term-frequency'; +import { tokenize } from './tokenize'; +import type { BM25Config, BM25Index } from './types'; +import { DEFAULT_BM25_CONFIG } from './types'; + +/** + * Calculate BM25 score for a query against a document + * + * Formula: BM25(D, Q) = Σ IDF(qi) * (f(qi, D) * (k1 + 1)) / (f(qi, D) + k1 * (1 - b + b * |D| / avgdl)) + * + * Where: + * - D: Document (skill content) + * - Q: Query (user message) + * - qi: Query term i + * - f(qi, D): Term frequency of qi in D + * - |D|: Document length + * - avgdl: Average document length + * - k1: Term frequency saturation parameter (typically 1.2-2.0) + * - b: Length normalization parameter (typically 0.75) + * - IDF(qi): Inverse document frequency of qi + * + * @param query - User message/query + * @param docIndex - Document index in the BM25 index + * @param index - Precomputed BM25 index + * @param config - BM25 configuration parameters + * @returns BM25 relevance score + */ +export const calculateBM25Score = ( + query: string, + docIndex: number, + index: BM25Index, + config: BM25Config = {}, +): number => { + const { k1, b } = { ...DEFAULT_BM25_CONFIG, ...config }; + + // Handle out of bounds + if (docIndex < 0 || docIndex >= index.documents.length) { + return 0; + } + + const queryTerms = tokenize(query); + const document = index.documents[docIndex]; + const docLength = document.length; + + let score = 0; + + for (const term of queryTerms) { + // Get IDF from cache, default to 0 if term not in corpus + const idf = index.idfCache.get(term) || 0; + + // Calculate term frequency in document + const tf = termFrequency(term, document); + + // BM25 formula + const numerator = tf * (k1 + 1); + const denominator = tf + k1 * (1 - b + (b * docLength) / index.avgDocLength); + + score += idf * (numerator / denominator); + } + + return score; +}; diff --git a/packages/opencode-skills/src/bm25/get-top-skills.test.ts b/packages/opencode-skills/src/bm25/get-top-skills.test.ts new file mode 100644 index 0000000..5c4524a --- /dev/null +++ b/packages/opencode-skills/src/bm25/get-top-skills.test.ts @@ -0,0 +1,94 @@ +/** + * Get Top Skills by BM25 Tests + * + * Tests for retrieving top N skills by BM25 relevance scoring. + */ + +import { describe, expect, it } from 'bun:test'; +import { buildBM25Index } from './build-index'; +import { getTopSkillsByBM25 } from './get-top-skills'; +import type { BM25Index } from './types'; + +describe('getTopSkillsByBM25', () => { + const createTestIndex = (): BM25Index => { + const skills = new Map([ + ['typescript-tdd', 'TypeScript development with test-driven development approach'], + ['react-hooks', 'React hooks for state management in functional components'], + ['node-api', 'Building RESTful APIs with Node.js and Express framework'], + ['python-ml', 'Machine learning with Python and TensorFlow'], + ]); + return buildBM25Index(skills); + }; + + it('should return top N skills by relevance', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks', 'node-api', 'python-ml']; + const topSkills = getTopSkillsByBM25('typescript development', skillNames, index, 2); + + expect(topSkills).toHaveLength(2); + expect(topSkills[0]).toBe('typescript-tdd'); + }); + + it('should default to returning top 3 skills', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks', 'node-api', 'python-ml']; + const topSkills = getTopSkillsByBM25('development', skillNames, index); + + expect(topSkills.length).toBeLessThanOrEqual(3); + }); + + it('should return empty array when no skills match', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks']; + // Use high threshold to filter out low-relevance matches + const topSkills = getTopSkillsByBM25('machine learning tensorflow', skillNames, index, 3, { threshold: 1.0 }); + + // All scores should be below threshold and filtered out + expect(topSkills).toHaveLength(0); + }); + + it('should return skills in descending order of relevance', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks', 'node-api']; + const topSkills = getTopSkillsByBM25('react state hooks', skillNames, index, 3); + + // react-hooks should be most relevant + expect(topSkills[0]).toBe('react-hooks'); + }); + + it('should handle empty skill names array', () => { + const index = createTestIndex(); + const topSkills = getTopSkillsByBM25('test', [], index, 3); + + expect(topSkills).toHaveLength(0); + }); + + it('should handle topN larger than available skills', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks']; + const topSkills = getTopSkillsByBM25('development', skillNames, index, 10); + + expect(topSkills.length).toBeLessThanOrEqual(2); + }); + + it('should apply threshold from config', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks', 'node-api']; + // With high threshold, only very relevant matches return + const topSkills = getTopSkillsByBM25('typescript', skillNames, index, 3, { threshold: 5.0 }); + + // Should only return highly relevant matches + expect(topSkills.length).toBeGreaterThanOrEqual(0); + }); + + it('should handle query with special characters', () => { + const index = createTestIndex(); + const skillNames = ['typescript-tdd', 'react-hooks']; + const topSkills = getTopSkillsByBM25('TypeScript, TDD!', skillNames, index, 2); + + expect(topSkills.length).toBeGreaterThanOrEqual(0); + if (topSkills.length > 0) { + expect(topSkills[0]).toBe('typescript-tdd'); + } + }); +}); diff --git a/packages/opencode-skills/src/bm25/get-top-skills.ts b/packages/opencode-skills/src/bm25/get-top-skills.ts new file mode 100644 index 0000000..4eb8f02 --- /dev/null +++ b/packages/opencode-skills/src/bm25/get-top-skills.ts @@ -0,0 +1,40 @@ +/** + * Get Top Skills by BM25 + * + * Get the top N most relevant skills for a query using BM25 scoring. + */ + +import { rankSkillsByBM25 } from './rank-skills'; +import type { BM25Config, BM25Index } from './types'; + +/** + * Get top N skills by BM25 relevance + * + * @param query - User message/query + * @param skillNames - Array of skill names + * @param index - Precomputed BM25 index + * @param topN - Number of top results to return + * @param config - BM25 configuration + * @returns Array of top N skill names by relevance + * + * @example + * ```typescript + * const topSkills = getTopSkillsByBM25( + * 'How do I write TypeScript tests?', + * ['typescript-tdd', 'bun-runtime', 'react-testing'], + * index, + * 2 + * ); + * // => ['typescript-tdd', 'react-testing'] + * ``` + */ +export const getTopSkillsByBM25 = ( + query: string, + skillNames: string[], + index: BM25Index, + topN: number = 3, + config: BM25Config = {}, +): string[] => { + const ranked = rankSkillsByBM25(query, skillNames, index, config); + return ranked.slice(0, topN).map(([name]) => name); +}; diff --git a/packages/opencode-skills/src/bm25/index.ts b/packages/opencode-skills/src/bm25/index.ts new file mode 100644 index 0000000..5b0f6dc --- /dev/null +++ b/packages/opencode-skills/src/bm25/index.ts @@ -0,0 +1,24 @@ +/** + * BM25 (Best Matching 25) Probabilistic Ranking Function + * + * BM25 is a bag-of-words retrieval function that ranks documents based on + * term frequency, inverse document frequency, and document length normalization. + * + * This module provides efficient BM25 scoring for skill relevance ranking. + * + * @see https://en.wikipedia.org/wiki/Okapi_BM25 + */ + +// Core functions +export { buildBM25Index } from './build-index'; +export { calculateBM25Score } from './calculate-score'; +export { getTopSkillsByBM25 } from './get-top-skills'; +// Utility functions +export { inverseDocumentFrequency } from './inverse-document-frequency'; +export { rankSkillsByBM25 } from './rank-skills'; +export { termFrequency } from './term-frequency'; +export { tokenize } from './tokenize'; + +// Type definitions +export type { BM25Config, BM25Index } from './types'; +export { DEFAULT_BM25_CONFIG } from './types'; diff --git a/packages/opencode-skills/src/bm25/inverse-document-frequency.test.ts b/packages/opencode-skills/src/bm25/inverse-document-frequency.test.ts new file mode 100644 index 0000000..cd00a39 --- /dev/null +++ b/packages/opencode-skills/src/bm25/inverse-document-frequency.test.ts @@ -0,0 +1,64 @@ +/** + * Inverse Document Frequency Tests + * + * Tests for IDF calculation using BM25 formula. + */ + +import { describe, expect, it } from 'bun:test'; +import { inverseDocumentFrequency } from './inverse-document-frequency'; + +describe('inverseDocumentFrequency', () => { + it('should calculate IDF for term appearing in all documents', () => { + const docs = [ + ['common', 'word'], + ['common', 'phrase'], + ['common', 'text'], + ]; + // When term is in all docs: log((3-3+0.5)/(3+0.5)+1) = log(0.5/3.5+1) = log(1.143) + const idf = inverseDocumentFrequency('common', docs, 3); + expect(idf).toBeCloseTo(Math.log(1.142857), 5); + }); + + it('should calculate IDF for term in half the documents', () => { + const docs = [ + ['rare', 'word'], + ['common', 'phrase'], + ['common', 'text'], + ]; + // When term is in 1 doc out of 3: log((3-1+0.5)/(1+0.5)+1) = log(2.5/1.5+1) = log(2.667) + const idf = inverseDocumentFrequency('rare', docs, 3); + expect(idf).toBeCloseTo(Math.log(2.666667), 5); + }); + + it('should calculate IDF for term in no documents', () => { + const docs = [ + ['some', 'words'], + ['other', 'phrase'], + ['more', 'text'], + ]; + // When term is in 0 docs: log((3-0+0.5)/(0+0.5)+1) = log(3.5/0.5+1) = log(8) + const idf = inverseDocumentFrequency('missing', docs, 3); + expect(idf).toBeCloseTo(Math.log(8), 5); + }); + + it('should return higher IDF for rarer terms', () => { + const docs = [['common'], ['common'], ['common', 'rare']]; + const commonIdf = inverseDocumentFrequency('common', docs, 3); + const rareIdf = inverseDocumentFrequency('rare', docs, 3); + expect(rareIdf).toBeGreaterThan(commonIdf); + }); + + it('should handle single document corpus', () => { + const docs = [['only', 'document']]; + // When term is in the doc: log((1-1+0.5)/(1+0.5)+1) = log(0.5/1.5+1) + const idf = inverseDocumentFrequency('only', docs, 1); + expect(idf).toBeCloseTo(Math.log(4 / 3), 5); + }); + + it('should handle empty documents array', () => { + // Edge case: totalDocs=0, docsWithTerm=0 + // log((0-0+0.5)/(0+0.5)+1) = log(1+1) = log(2) + const idf = inverseDocumentFrequency('test', [], 0); + expect(idf).toBeCloseTo(Math.log(2), 5); + }); +}); diff --git a/packages/opencode-skills/src/bm25/inverse-document-frequency.ts b/packages/opencode-skills/src/bm25/inverse-document-frequency.ts new file mode 100644 index 0000000..66fe7ef --- /dev/null +++ b/packages/opencode-skills/src/bm25/inverse-document-frequency.ts @@ -0,0 +1,24 @@ +/** + * Inverse Document Frequency Calculation + * + * Calculate IDF using the BM25 formula to measure term importance. + */ + +/** + * Calculate inverse document frequency + * + * IDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1) + * + * Where: + * - N: Total number of documents + * - df(t): Number of documents containing term t + * + * @param term - Term to calculate IDF for + * @param documents - All tokenized documents + * @param totalDocs - Total number of documents + * @returns IDF score for the term + */ +export const inverseDocumentFrequency = (term: string, documents: string[][], totalDocs: number): number => { + const docsWithTerm = documents.filter((doc) => doc.includes(term)).length; + return Math.log((totalDocs - docsWithTerm + 0.5) / (docsWithTerm + 0.5) + 1); +}; diff --git a/packages/opencode-skills/src/bm25/rank-skills.ts b/packages/opencode-skills/src/bm25/rank-skills.ts new file mode 100644 index 0000000..6517329 --- /dev/null +++ b/packages/opencode-skills/src/bm25/rank-skills.ts @@ -0,0 +1,53 @@ +/** + * Rank Skills by BM25 + * + * Rank all skills by their BM25 relevance scores for a given query. + */ + +import { calculateBM25Score } from './calculate-score'; +import type { BM25Config, BM25Index } from './types'; +import { DEFAULT_BM25_CONFIG } from './types'; + +/** + * Rank skills by BM25 relevance to a query + * + * @param query - User message/query + * @param skillNames - Array of skill names (must match index order) + * @param index - Precomputed BM25 index + * @param config - BM25 configuration + * @returns Array of [skillName, score] tuples sorted by relevance (descending) + * + * @example + * ```typescript + * const ranked = rankSkillsByBM25( + * 'How do I write TypeScript tests?', + * ['typescript-tdd', 'bun-runtime'], + * index + * ); + * // => [['typescript-tdd', 12.5], ['bun-runtime', 3.2]] + * ``` + */ +export const rankSkillsByBM25 = ( + query: string, + skillNames: string[], + index: BM25Index, + config: BM25Config = {}, +): Array<[string, number]> => { + const { threshold } = { ...DEFAULT_BM25_CONFIG, ...config }; + + const scores: Array<[string, number]> = []; + + for (let i = 0; i < skillNames.length; i++) { + const score = calculateBM25Score(query, i, index, config); + + // Only include scores above threshold + if (score >= threshold) { + scores.push([skillNames[i], score]); + } + } + + // Sort by score descending + scores.sort((a, b) => b[1] - a[1]); + + return scores; +}; diff --git a/packages/opencode-skills/src/bm25/term-frequency.test.ts b/packages/opencode-skills/src/bm25/term-frequency.test.ts new file mode 100644 index 0000000..1e056ba --- /dev/null +++ b/packages/opencode-skills/src/bm25/term-frequency.test.ts @@ -0,0 +1,40 @@ +/** + * Term Frequency Tests + * + * Tests for term frequency calculation in documents. + */ + +import { describe, expect, it } from 'bun:test'; +import { termFrequency } from './term-frequency'; + +describe('termFrequency', () => { + it('should count single occurrence', () => { + const doc = ['the', 'quick', 'brown', 'fox']; + expect(termFrequency('quick', doc)).toBe(1); + }); + + it('should count multiple occurrences', () => { + const doc = ['the', 'quick', 'quick', 'brown', 'fox']; + expect(termFrequency('quick', doc)).toBe(2); + }); + + it('should return 0 for term not in document', () => { + const doc = ['the', 'quick', 'brown', 'fox']; + expect(termFrequency('lazy', doc)).toBe(0); + }); + + it('should handle empty document', () => { + expect(termFrequency('test', [])).toBe(0); + }); + + it('should be case sensitive', () => { + const doc = ['Hello', 'hello', 'HELLO']; + expect(termFrequency('hello', doc)).toBe(1); + expect(termFrequency('Hello', doc)).toBe(1); + }); + + it('should handle all same terms', () => { + const doc = ['test', 'test', 'test', 'test']; + expect(termFrequency('test', doc)).toBe(4); + }); +}); diff --git a/packages/opencode-skills/src/bm25/term-frequency.ts b/packages/opencode-skills/src/bm25/term-frequency.ts new file mode 100644 index 0000000..267cd3e --- /dev/null +++ b/packages/opencode-skills/src/bm25/term-frequency.ts @@ -0,0 +1,22 @@ +/** + * Term Frequency Calculation + * + * Calculate how many times a term appears in a document. + */ + +/** + * Calculate term frequency in a document + * + * @param term - Term to count + * @param document - Tokenized document + * @returns Number of occurrences of the term + * + * @example + * ```typescript + * termFrequency('test', ['test', 'driven', 'test']) + * // => 2 + * ``` + */ +export const termFrequency = (term: string, document: string[]): number => { + return document.filter((t) => t === term).length; +}; diff --git a/packages/opencode-skills/src/bm25/tokenize.test.ts b/packages/opencode-skills/src/bm25/tokenize.test.ts new file mode 100644 index 0000000..69d5756 --- /dev/null +++ b/packages/opencode-skills/src/bm25/tokenize.test.ts @@ -0,0 +1,50 @@ +/** + * Tokenize Tests + * + * Tests for text tokenization functionality. + */ + +import { describe, expect, it } from 'bun:test'; +import { tokenize } from './tokenize'; + +describe('tokenize', () => { + it('should convert text to lowercase', () => { + const result = tokenize('Hello World'); + expect(result).toEqual(['hello', 'world']); + }); + + it('should remove punctuation except hyphens', () => { + const result = tokenize('Hello, World! How are you?'); + expect(result).toEqual(['hello', 'world', 'how', 'are', 'you']); + }); + + it('should preserve hyphens in skill names', () => { + const result = tokenize('typescript-tdd skill-name'); + expect(result).toEqual(['typescript-tdd', 'skill-name']); + }); + + it('should handle multiple spaces', () => { + const result = tokenize('Hello World'); + expect(result).toEqual(['hello', 'world']); + }); + + it('should handle empty string', () => { + const result = tokenize(''); + expect(result).toEqual([]); + }); + + it('should handle string with only punctuation', () => { + const result = tokenize('!!!???...'); + expect(result).toEqual([]); + }); + + it('should handle numbers', () => { + const result = tokenize('Version 1.2.3'); + expect(result).toEqual(['version', '1', '2', '3']); + }); + + it('should handle camelCase', () => { + const result = tokenize('camelCaseText'); + expect(result).toEqual(['camelcasetext']); + }); +}); diff --git a/packages/opencode-skills/src/bm25/tokenize.ts b/packages/opencode-skills/src/bm25/tokenize.ts new file mode 100644 index 0000000..f5c6d0d --- /dev/null +++ b/packages/opencode-skills/src/bm25/tokenize.ts @@ -0,0 +1,28 @@ +/** + * Tokenize Text + * + * Tokenize text into lowercase words, removing punctuation while preserving hyphens. + */ + +/** + * Tokenize text into lowercase words, removing punctuation + * + * @param text - Input text to tokenize + * @returns Array of lowercase tokens + * + * @example + * ```typescript + * tokenize('Hello World!') + * // => ['hello', 'world'] + * + * tokenize('typescript-tdd') + * // => ['typescript-tdd'] + * ``` + */ +export const tokenize = (text: string): string[] => { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, ' ') // Keep hyphens for skill names + .split(/\s+/) + .filter((token) => token.length > 0); +}; diff --git a/packages/opencode-skills/src/bm25/types.ts b/packages/opencode-skills/src/bm25/types.ts new file mode 100644 index 0000000..cd98259 --- /dev/null +++ b/packages/opencode-skills/src/bm25/types.ts @@ -0,0 +1,46 @@ +/** + * BM25 Type Definitions + * + * Type definitions for BM25 scoring system. + */ + +/** + * BM25 configuration parameters + */ +export interface BM25Config { + /** Term frequency saturation parameter (default: 1.5) */ + k1?: number; + /** Length normalization parameter (default: 0.75) */ + b?: number; + /** Minimum score threshold for injection (default: 0.0) */ + threshold?: number; + /** Enable BM25 scoring (default: false) */ + enabled?: boolean; + /** Maximum number of skills to inject (default: 3) */ + maxSkills?: number; +} + +/** + * Precomputed document statistics for BM25 scoring + */ +export interface BM25Index { + /** Tokenized documents */ + documents: string[][]; + /** Average document length */ + avgDocLength: number; + /** Total number of documents */ + totalDocs: number; + /** IDF cache for terms */ + idfCache: Map; +} + +/** + * Default BM25 configuration + */ +export const DEFAULT_BM25_CONFIG: Required = { + k1: 1.5, + b: 0.75, + threshold: 0.0, + enabled: false, + maxSkills: 3, +}; diff --git a/packages/opencode-skills/src/create-skills-plugin.ts b/packages/opencode-skills/src/create-skills-plugin.ts new file mode 100644 index 0000000..0ae12fd --- /dev/null +++ b/packages/opencode-skills/src/create-skills-plugin.ts @@ -0,0 +1,232 @@ +/** + * Create Skills Plugin Factory + * + * Factory function to create an OpenCode plugin with skill injection capabilities. + * Provides automatic skill injection into chat context using smart pattern matching + * and BM25-based relevance ranking. + */ + +import type { Plugin } from '@opencode-ai/plugin'; + +import { type BM25Index, buildBM25Index, getTopSkillsByBM25 } from './bm25/index'; +import { hasIntentToUse } from './pattern-matching/index'; +import type { Skill, SkillsPluginConfig } from './types'; + +/** + * Default configuration for the skills plugin + */ +const DEFAULT_CONFIG: SkillsPluginConfig = { + autoInject: true, + debug: false, + patternMatching: { + wordBoundary: true, + intentDetection: true, + negationDetection: true, + }, + bm25: { + enabled: false, + k1: 1.5, + b: 0.75, + threshold: 0.0, + maxSkills: 3, + }, +}; + +/** + * Create the OpenCode skills plugin with the given skills and configuration + * + * @param skills - Record of skill name to Skill object + * @param userConfig - Optional configuration overrides + * @returns Plugin function for OpenCode + * + * @example + * ```typescript + * const skills = { + * 'typescript-tdd': { + * name: 'typescript-tdd', + * description: 'TypeScript development with TDD', + * content: '# TypeScript TDD\n\n...', + * }, + * }; + * + * export const MyPlugin = createSkillsPlugin(skills, { debug: true }); + * ``` + */ +export const createSkillsPlugin = ( + skills: Record, + userConfig: Partial = {}, +): Plugin => { + const config: SkillsPluginConfig = { + ...DEFAULT_CONFIG, + ...userConfig, + patternMatching: { + ...DEFAULT_CONFIG.patternMatching, + ...userConfig.patternMatching, + }, + bm25: { + ...DEFAULT_CONFIG.bm25, + ...userConfig.bm25, + }, + }; + + // biome-ignore lint: Plugin hook requires complex async logic for skills injection + return async (ctx) => { + const skillNames = Object.keys(skills); + const skillKeywords = new Map(); + + // Build keyword map for each skill + for (const [name, skill] of Object.entries(skills)) { + if (skill.keywords && skill.keywords.length > 0) { + skillKeywords.set(name, skill.keywords); + } + } + + // Build BM25 index if enabled + let bm25Index: BM25Index | null = null; + if (config.bm25?.enabled) { + const skillsMap = new Map(); + for (const [name, skill] of Object.entries(skills)) { + // Combine all skill content for BM25 indexing + const content = + skill.content || [skill.whatIDo, skill.whenToUseMe, skill.instructions].filter(Boolean).join(' '); + skillsMap.set(name, `${skill.description} ${content}`); + } + bm25Index = buildBM25Index(skillsMap); + + if (config.debug) { + console.log('[opencode-skills] BM25 index built with', bm25Index.totalDocs, 'skills'); + } + } + + if (config.debug) { + console.log(`[opencode-skills] Plugin loaded with ${skillNames.length} skills`); + console.log('[opencode-skills] Skills:', skillNames.join(', ')); + console.log('[opencode-skills] Config:', config); + } + + return { + // Auto-inject skills via chat message hook + // biome-ignore lint: Chat message hook requires multiple conditions for skills injection + 'chat.message': async ({ sessionID }, { parts }) => { + // Skip if auto-inject is disabled + if (!config.autoInject) { + return; + } + + // Extract text content from message parts + const textContent = parts + .filter((p) => p.type === 'text') + .map((p) => ('text' in p ? p.text : '')) + .join('\n'); + + // Skip empty messages + if (!textContent.trim()) { + return; + } + + // Track which skills have been injected to avoid duplicates + const injectedSkills = new Set(); + + // Determine which skills to inject based on mode + const skillsToInject: string[] = []; + + if (config.bm25?.enabled && bm25Index) { + // BM25 mode: Rank skills by relevance + const maxSkills = config.bm25.maxSkills ?? 3; + const candidates = getTopSkillsByBM25(textContent, skillNames, bm25Index, maxSkills, config.bm25); + + if (config.debug) { + console.log(`[opencode-skills] BM25 top ${maxSkills} candidates:`, candidates); + } + + // Filter candidates through pattern matching for negation detection + for (const name of candidates) { + const keywords = skillKeywords.get(name) || []; + const result = hasIntentToUse(textContent, name, keywords, config.patternMatching); + + // Only inject if not negated + if (!result.hasNegation) { + skillsToInject.push(name); + } else if (config.debug) { + console.log(`[opencode-skills] Skipping skill "${name}" due to negation`); + } + } + } else { + // Pattern matching mode: Check each skill + for (const name of skillNames) { + const keywords = skillKeywords.get(name) || []; + const result = hasIntentToUse(textContent, name, keywords, config.patternMatching); + + if (result.matches) { + skillsToInject.push(name); + } + } + } + + // Inject selected skills + for (const name of skillsToInject) { + // Skip if already injected + if (injectedSkills.has(name)) { + continue; + } + + const skill = skills[name]; + if (!skill) continue; + + // Create skill content from structured fields or legacy content + const content = + skill.content || + [ + `## What I do`, + skill.whatIDo || '', + '', + `## When to use me`, + skill.whenToUseMe || '', + '', + `## Instructions`, + skill.instructions || '', + '', + `## Checklist`, + ...(skill.checklist?.map((item) => `- [ ] ${item}`) || []), + ].join('\n'); + + // Create skill content + const skillContent = [ + '', + '', + ``, + `# ${skill.description}`, + '', + content, + '', + ].join('\n'); + + // Inject via session prompt (noReply pattern from malhashemi example) + try { + await ctx.client.session.prompt({ + path: { id: sessionID }, + body: { + noReply: true, + parts: [{ type: 'text', text: skillContent }], + }, + }); + + injectedSkills.add(name); + + // Log injection for debugging/observability + if (config.debug) { + console.log(`[opencode-skills] Auto-injected skill "${name}" in session ${sessionID}`); + } else { + // Always log injection (non-debug mode shows minimal info) + console.log(`[opencode-skills] Injected skill: ${name}`); + } + } catch (error) { + if (config.debug) { + console.error(`[opencode-skills] Failed to inject skill "${name}":`, error); + } + } + } + }, + }; + }; +}; diff --git a/packages/opencode-skills/src/define-skill.test.ts b/packages/opencode-skills/src/define-skill.test.ts new file mode 100644 index 0000000..4482ee2 --- /dev/null +++ b/packages/opencode-skills/src/define-skill.test.ts @@ -0,0 +1,85 @@ +/** + * Define Skill Tests + * + * Tests for the skill definition functionality. + */ + +import { describe, expect, it } from 'bun:test'; +import { defineSkill } from './define-skill'; + +describe('defineSkill', () => { + it('should create skill with structured content', () => { + const skill = defineSkill( + { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill', + whenToUseMe: 'Use when testing', + instructions: 'Follow these steps', + checklist: ['Item 1', 'Item 2'], + license: 'MIT', + compatibility: 'opencode', + metadata: { + category: 'testing', + }, + }, + { validate: false }, + ); + + expect(skill.name).toBe('test-skill'); + expect(skill.description).toBe('A test skill'); + expect(skill.whatIDo).toBe('Core capabilities of the skill'); + expect(skill.whenToUseMe).toBe('Use when testing'); + expect(skill.instructions).toBe('Follow these steps'); + expect(skill.checklist).toEqual(['Item 1', 'Item 2']); + expect(skill.license).toBe('MIT'); + expect(skill.compatibility).toBe('opencode'); + expect(skill.metadata?.category).toBe('testing'); + expect(skill.version).toBe('1.0.0'); + expect(skill.updatedAt).toBeDefined(); + }); + + it('should auto-add version and updatedAt', () => { + const skill = defineSkill( + { + name: 'test-skill', + description: 'A test skill', + content: 'Legacy content', + }, + { validate: false }, + ); + + expect(skill.version).toBe('1.0.0'); + expect(skill.updatedAt).toBeDefined(); + expect(new Date(skill.updatedAt!).getTime()).toBeLessThanOrEqual(Date.now()); + }); + + it('should support legacy content field', () => { + const skill = defineSkill( + { + name: 'legacy-skill', + description: 'Legacy skill', + content: 'This is legacy content', + }, + { validate: false }, + ); + + expect(skill.content).toBe('This is legacy content'); + }); + + it('should support optional fields', () => { + const skill = defineSkill( + { + name: 'optional-skill', + description: 'Skill with optional fields', + content: 'Content', + keywords: ['keyword1', 'keyword2'], + dependencies: ['dependency1'], + }, + { validate: false }, + ); + + expect(skill.keywords).toEqual(['keyword1', 'keyword2']); + expect(skill.dependencies).toEqual(['dependency1']); + }); +}); diff --git a/packages/opencode-skills/src/define-skill.ts b/packages/opencode-skills/src/define-skill.ts new file mode 100644 index 0000000..8aa25ed --- /dev/null +++ b/packages/opencode-skills/src/define-skill.ts @@ -0,0 +1,67 @@ +/** + * Define Skill Helper + * + * Helper function to create skill objects with validation and structured content migration. + */ + +import type { Skill } from './types'; +import { formatValidationResult, validateSkill } from './validation/index'; + +/** + * Helper function to create a skill object with validation + * + * @param skill - Partial skill object (name and description required) + * @param options - Optional validation options + * @returns Complete Skill object + * + * @example + * ```typescript + * const mySkill = defineSkill({ + * name: 'typescript-tdd', + * description: 'TypeScript development with TDD', + * whatIDo: 'I help you write TypeScript with TDD practices', + * whenToUseMe: 'Use me when starting a new TypeScript project', + * instructions: 'Follow test-first development', + * }); + * ``` + */ +export const defineSkill = ( + skill: Pick & Partial>, + options?: { strict?: boolean; validate?: boolean }, +): Skill => { + const fullSkill: Skill = { + version: '1.0.0', + updatedAt: new Date().toISOString(), + ...skill, + }; + + // Migrate legacy content to structured format if present + if (fullSkill.content && !fullSkill.whatIDo) { + console.warn( + `[opencode-skills] Skill "${skill.name}" uses deprecated "content" field. Consider migrating to structured content.`, + ); + } + + // Validate if requested (default: true in dev, false in prod) + const shouldValidate = options?.validate ?? process.env.NODE_ENV !== 'production'; + + if (shouldValidate) { + const result = validateSkill(fullSkill, options?.strict); + + // Log formatted results + if ( + result.errors.length > 0 || + result.warnings.length > 0 || + (process.env.DEBUG && result.suggestions.length > 0) + ) { + console.log(formatValidationResult(result, skill.name)); + } + + // Throw in strict mode + if (options?.strict && !result.valid) { + throw new Error(`Skill "${skill.name}" has validation errors. See output above.`); + } + } + + return fullSkill; +}; diff --git a/packages/opencode-skills/src/examples.ts b/packages/opencode-skills/src/examples.ts new file mode 100644 index 0000000..155dc5f --- /dev/null +++ b/packages/opencode-skills/src/examples.ts @@ -0,0 +1,304 @@ +/** + * Example skills for demonstration and testing + */ + +import { defineSkill } from './index'; +import type { Skill } from './types'; + +/** + * TypeScript TDD Development Skill + * + * Based on the Bun and TypeScript development standards used in this project. + * Promotes test-driven development, single-function modules, and barrel exports. + */ +export const typescriptTddSkill: Skill = defineSkill({ + name: 'typescript-tdd', + description: 'TypeScript development with TDD, single-function modules, and barrel exports', + keywords: ['tdd', 'test-driven', 'testing', 'bun', 'typescript', 'ts'], + license: 'MIT', + compatibility: 'opencode', + metadata: { + category: 'development', + }, + whatIDo: + 'I help you write TypeScript code using test-driven development practices, with single-function modules and proper barrel exports', + whenToUseMe: + 'Use when writing TypeScript code with Bun, following TDD practices, or need guidance on module organization and testing patterns', + instructions: `## Core Principles + +1. **One Function Per Module** - Each file exports a single primary function +2. **Test Collocation** - Place tests next to implementation with \`.test.ts\` suffix +3. **Barrel Modules** - Use \`index.ts\` for clean public APIs +4. **Type Safety** - Use strict mode, avoid \`any\`, explicit return types + +## File Structure + +\`\`\` +src/ +├── index.ts # Barrel module +├── utils/ +│ ├── index.ts # Utils barrel +│ ├── validate.ts # Single function +│ └── validate.test.ts # Test collocated +\`\`\` + +## TDD Workflow + +1. Write a failing test +2. Implement minimal code to pass +3. Refactor with confidence + +## Testing with Bun + +\`\`\`typescript +import { describe, it, expect } from 'bun:test'; +import { myFunction } from './my-function'; + +describe('myFunction', () => { + it('should handle basic case', () => { + expect(myFunction('input')).toBe('expected'); + }); +}); +\`\`\` + +## TypeScript Standards + +- Use \`strict: true\` mode +- Explicit return types for functions +- Prefer \`type\` for unions, \`interface\` for objects +- No \`any\` type - use \`unknown\` instead + +## Module Patterns + +**Single Function Export:** +\`\`\`typescript +// utils/validate.ts +export const validate = (input: string): boolean => { + return /^[a-z]+$/.test(input); +}; +\`\`\` + +**Barrel Module:** +\`\`\`typescript +// utils/index.ts +export { validate } from './validate'; +export { format } from './format'; +\`\`\``, + checklist: [ + 'Write tests before implementation', + 'Use single-function modules', + 'Place tests collocated with implementation', + 'Use barrel modules for clean exports', + 'Enable strict TypeScript mode', + ], +}); + +/** + * Plain English Communication Skill + * + * Guidelines for writing technical content in plain English for non-technical stakeholders. + */ +export const plainEnglishSkill: Skill = defineSkill({ + name: 'plain-english', + description: 'Write technical content in plain English for non-technical stakeholders', + keywords: ['plain-english', 'communication', 'documentation', 'stakeholders', 'business'], + license: 'MIT', + compatibility: 'opencode', + metadata: { + category: 'communication', + }, + whatIDo: + 'I help you write technical content that non-technical stakeholders can understand, using clear language and simple structure', + whenToUseMe: + 'Use when communicating technical information to executives, business managers, or non-technical team members', + instructions: `## Core Principles + +1. **Use Simple Words** - Prefer "use" over "utilize", "help" over "facilitate" +2. **Short Sentences** - Keep sentences under 25 words when possible +3. **Active Voice** - "We updated the system" not "The system was updated" +4. **Define Jargon** - If you must use technical terms, explain them + +## Structure + +### Executive Summary +Start with the key takeaway in 2-3 sentences. What decision needs to be made? + +### Problem Statement +What problem are we solving? Why does it matter to the business? + +### Solution Overview +High-level approach without technical details. + +### Impact +- **Benefits**: What improves? +- **Risks**: What could go wrong? +- **Timeline**: When will this happen? +- **Resources**: What do we need? + +## Writing Tips + +**Avoid:** + - Technical jargon without explanation + - Passive voice + - Long, complex sentences + - Acronyms without definitions + + **Use:** + - Clear, direct language + - Concrete examples + - Bullet points for lists + - Visual aids (diagrams, charts) + +## Example Transformation + +**Before (Technical):** +> The implementation of the new caching layer will leverage Redis to optimize database query performance, reducing latency by approximately 40% in the 95th percentile. + +**After (Plain English):** +> We're adding a new system that remembers frequently-used data. This will make the app faster for most users—pages will load 40% quicker. + +## Audience-Specific Guidelines + +**For Executives:** +- Focus on business impact and ROI +- Use numbers and metrics +- Keep it brief (1-2 pages max) + +**For Business Managers:** +- Explain process changes +- Include timelines and dependencies +- Address operational impacts + +**For Compliance/Legal:** +- Be precise about security and privacy +- Document controls and safeguards +- Use clear, unambiguous language`, + checklist: [ + 'Start with an executive summary', + 'Use simple words and short sentences', + 'Write in active voice', + 'Define technical jargon', + 'Include specific metrics and timelines', + ], +}); + +/** + * React Component Patterns Skill + */ +export const reactPatternsSkill: Skill = defineSkill({ + name: 'react-patterns', + description: 'Modern React component patterns and best practices', + keywords: ['react', 'components', 'hooks', 'jsx', 'tsx'], + license: 'MIT', + compatibility: 'opencode', + metadata: { + category: 'development', + }, + whatIDo: 'I help you write modern React components using best practices, hooks, and proven design patterns', + whenToUseMe: 'Use when building React applications, creating new components, or refactoring existing React code', + instructions: `## Component Structure + +\`\`\`typescript +// Prefer arrow function components with hooks +export const MyComponent = ({ name, onAction }: Props) => { + const [state, setState] = useState(initialValue); + + useEffect(() => { + // Side effects + }, [dependencies]); + + return ( +
+ {/* JSX */} +
+ ); +}; +\`\`\` + +## Patterns + +### 1. Container/Presenter Pattern +Separate logic from presentation: + +\`\`\`typescript +// Container (logic) +function UserProfileContainer() { + const { data, loading } = useUser(); + return ; +} + +// Presenter (UI) +function UserProfile({ data, loading }: Props) { + if (loading) return ; + return
{data.name}
; +} +\`\`\` + +### 2. Custom Hooks +Extract reusable logic: + +\`\`\`typescript +function useDebounce(value: string, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} +\`\`\` + +### 3. Compound Components +Components that work together: + +\`\`\`typescript +function Tabs({ children }) { + const [activeTab, setActiveTab] = useState(0); + return ( + + {children} + + ); +} + +Tabs.List = function TabsList({ children }) { + return
{children}
; +}; + +Tabs.Tab = function Tab({ index, children }) { + const { activeTab, setActiveTab } = useTabsContext(); + return ( + + ); +}; +\`\`\` + +## Best Practices + +1. **Use TypeScript** - Type your props and state +2. **Keep Components Small** - Single responsibility +3. **Avoid Prop Drilling** - Use Context or state management +4. **Memoize Expensive Calculations** - useMemo, useCallback +5. **Test Components** - Unit and integration tests`, + checklist: [ + 'Use TypeScript for type safety', + 'Keep components small and focused', + 'Use custom hooks for reusable logic', + 'Avoid prop drilling with Context', + 'Memoize expensive calculations', + 'Write unit and integration tests', + ], +}); + +/** + * Example skill registry for demonstration + */ +export const exampleSkills: Record = { + 'typescript-tdd': typescriptTddSkill, + 'plain-english': plainEnglishSkill, + 'react-patterns': reactPatternsSkill, +}; diff --git a/packages/opencode-skills/src/index.ts b/packages/opencode-skills/src/index.ts new file mode 100644 index 0000000..7e97cbb --- /dev/null +++ b/packages/opencode-skills/src/index.ts @@ -0,0 +1,44 @@ +/** + * opencode-skills - TypeScript-based skill injection plugin for OpenCode + * + * This plugin provides automatic skill injection into chat context using smart + * pattern matching. Skills are TypeScript-defined knowledge blocks that are + * seamlessly injected when the user mentions them with intent. + * + * Key Features: + * - Type-safe skill definitions + * - Smart pattern matching with intent detection + * - Automatic injection via chat.message hook + * - No file system side effects + * - Configurable pattern matching behavior + * + * @see https://github.com/pantheon-org/opencode-skills + */ + +// BM25 utilities +export { type BM25Index, buildBM25Index, calculateBM25Score, getTopSkillsByBM25, rankSkillsByBM25 } from './bm25/index'; +// Main exports +export { createSkillsPlugin } from './create-skills-plugin'; +export { defineSkill } from './define-skill'; +// Markdown parser utilities +export { + markdownToSkill, + type ParsedSkill, + parseSkillMarkdown, + type SkillFrontmatter, + type SkillSections, + skillToMarkdown, +} from './parsers/index'; +// Pattern matching utilities +export { findMatchingSkills, hasIntentToUse } from './pattern-matching/index'; +// Type definitions +export type { BM25Config, MatchResult, Skill, SkillMetadata, SkillsPluginConfig } from './types'; +// Validation utilities +export { + formatValidationResult, + type ValidationError, + type ValidationResult, + type ValidationSuggestion, + type ValidationWarning, + validateSkill, +} from './validation'; diff --git a/packages/opencode-skills/src/parsers/extract-frontmatter.ts b/packages/opencode-skills/src/parsers/extract-frontmatter.ts new file mode 100644 index 0000000..beef759 --- /dev/null +++ b/packages/opencode-skills/src/parsers/extract-frontmatter.ts @@ -0,0 +1,42 @@ +/** + * Extract YAML Frontmatter + * + * Extract and parse YAML frontmatter from markdown content. + */ + +import { parse as parseYAML } from 'yaml'; + +import type { SkillFrontmatter } from './types'; + +/** + * Extract YAML frontmatter from markdown + * + * @param markdown - Markdown content with YAML frontmatter + * @returns Object containing parsed frontmatter and remaining content + * @throws Error if no valid YAML frontmatter is found + * + * @example + * ```typescript + * const md = `--- + * name: typescript-tdd + * description: TypeScript with TDD + * --- + * # Content here`; + * + * const { frontmatter, content } = extractFrontmatter(md); + * // => { frontmatter: { name: 'typescript-tdd', ... }, content: '# Content here' } + * ``` + */ +export const extractFrontmatter = (markdown: string): { frontmatter: SkillFrontmatter; content: string } => { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = markdown.match(frontmatterRegex); + + if (!match) { + throw new Error('No YAML frontmatter found. Expected format:\n---\nname: skill-name\n...\n---'); + } + + const [, yamlContent, markdownContent] = match; + const frontmatter = parseYAML(yamlContent) as SkillFrontmatter; + + return { frontmatter, content: markdownContent }; +}; diff --git a/packages/opencode-skills/src/parsers/extract-sections.test.ts b/packages/opencode-skills/src/parsers/extract-sections.test.ts new file mode 100644 index 0000000..7ea9a22 --- /dev/null +++ b/packages/opencode-skills/src/parsers/extract-sections.test.ts @@ -0,0 +1,120 @@ +/** + * Extract Sections Tests + * + * Tests for parsing markdown content and extracting structured sections. + */ + +import { describe, expect, it } from 'bun:test'; +import { extractSections } from './extract-sections'; + +describe('extractSections', () => { + it('should extract all standard sections', () => { + const markdown = `## What I do +I help with TypeScript development + +## When to use me +Use when building TypeScript projects + +## Instructions +Follow these steps carefully + +## Checklist +- [ ] Step 1 +- [ ] Step 2 +- [ ] Step 3`; + + const sections = extractSections(markdown); + + expect(sections.whatIDo).toBe('I help with TypeScript development'); + expect(sections.whenToUseMe).toBe('Use when building TypeScript projects'); + expect(sections.instructions).toBe('Follow these steps carefully'); + expect(sections.checklist).toEqual(['Step 1', 'Step 2', 'Step 3']); + }); + + it('should handle markdown with only some sections', () => { + const markdown = `## What I do +Core capabilities description + +## Checklist +- [ ] Item A +- [ ] Item B`; + + const sections = extractSections(markdown); + + expect(sections.whatIDo).toBe('Core capabilities description'); + expect(sections.whenToUseMe).toBe(''); + expect(sections.instructions).toBe(''); + expect(sections.checklist).toEqual(['Item A', 'Item B']); + }); + + it('should handle empty content', () => { + const sections = extractSections(''); + + expect(sections.whatIDo).toBe(''); + expect(sections.whenToUseMe).toBe(''); + expect(sections.instructions).toBe(''); + expect(sections.checklist).toEqual([]); + }); + + it('should handle content without any recognized sections', () => { + const markdown = `## Some Other Section +This is not a standard section + +## Another Section +More content here`; + + const sections = extractSections(markdown); + + expect(sections.whatIDo).toBe(''); + expect(sections.whenToUseMe).toBe(''); + expect(sections.instructions).toBe(''); + expect(sections.checklist).toEqual([]); + }); + + it('should handle multiline content in sections', () => { + const markdown = `## What I do +First line +Second line +Third line + +## When to use me +Single line`; + + const sections = extractSections(markdown); + + expect(sections.whatIDo).toBe('First line\nSecond line\nThird line'); + expect(sections.whenToUseMe).toBe('Single line'); + }); + + it('should handle checklist with various item formats', () => { + const markdown = `## Checklist +- [ ] Unchecked item +- [x] Checked item +- [ ] Item with - special * characters +- [ ] Item with extra spaces `; + + const sections = extractSections(markdown); + + expect(sections.checklist).toEqual([ + 'Unchecked item', + 'Checked item', + 'Item with - special * characters', + 'Item with extra spaces', + ]); + }); + + it('should ignore content before first heading', () => { + const markdown = `Some preamble text that should be ignored + +## What I do +This is the actual content + +## Instructions +Follow these steps`; + + const sections = extractSections(markdown); + + expect(sections.whatIDo).toBe('This is the actual content'); + expect(sections.instructions).toBe('Follow these steps'); + }); +}); diff --git a/packages/opencode-skills/src/parsers/extract-sections.ts b/packages/opencode-skills/src/parsers/extract-sections.ts new file mode 100644 index 0000000..f47303f --- /dev/null +++ b/packages/opencode-skills/src/parsers/extract-sections.ts @@ -0,0 +1,63 @@ +/** + * Extract Markdown Sections + * + * Parse markdown content and extract structured sections by heading. + */ + +import { parseChecklistItems } from './parse-checklist-items'; +import { splitByHeadings } from './split-by-headings'; +import type { SkillSections } from './types'; + +/** + * Parse markdown content and extract sections by heading + * + * Extracts the following sections from markdown: + * - ## What I do + * - ## When to use me + * - ## Instructions + * - ## Checklist + * + * @param markdown - Markdown content to parse + * @returns Structured sections object + * + * @example + * ```typescript + * const content = ` + * ## What I do + * I help with TypeScript development. + * + * ## Checklist + * - [ ] Write tests + * - [ ] Implement feature + * `; + * + * const sections = extractSections(content); + * // => { whatIDo: 'I help...', checklist: ['Write tests', 'Implement feature'], ... } + * ``` + */ +export const extractSections = (markdown: string): SkillSections => { + const sections: SkillSections = { + whatIDo: '', + whenToUseMe: '', + instructions: '', + checklist: [], + }; + + const parts = splitByHeadings(markdown); + + for (const part of parts) { + const heading = part.heading.toLowerCase(); + + if (heading === 'what i do') { + sections.whatIDo = part.content; + } else if (heading === 'when to use me') { + sections.whenToUseMe = part.content; + } else if (heading === 'instructions') { + sections.instructions = part.content; + } else if (heading === 'checklist') { + sections.checklist = parseChecklistItems(part.content); + } + } + + return sections; +}; diff --git a/packages/opencode-skills/src/parsers/index.ts b/packages/opencode-skills/src/parsers/index.ts new file mode 100644 index 0000000..00d97ce --- /dev/null +++ b/packages/opencode-skills/src/parsers/index.ts @@ -0,0 +1,15 @@ +/** + * Markdown Parsers + * + * Utilities for parsing and serializing skills to/from markdown format. + * Supports YAML frontmatter and structured sections. + */ + +export { extractFrontmatter } from './extract-frontmatter'; +export { extractSections } from './extract-sections'; +export { markdownToSkill } from './markdown-to-skill'; +export { parseChecklistItems } from './parse-checklist-items'; +export { parseSkillMarkdown } from './parse-skill-markdown'; +export { skillToMarkdown } from './skill-to-markdown'; +export { splitByHeadings } from './split-by-headings'; +export type { ParsedSkill, SkillFrontmatter, SkillSections } from './types'; diff --git a/packages/opencode-skills/src/parsers/markdown-parser.test.ts b/packages/opencode-skills/src/parsers/markdown-parser.test.ts new file mode 100644 index 0000000..8c5f930 --- /dev/null +++ b/packages/opencode-skills/src/parsers/markdown-parser.test.ts @@ -0,0 +1,182 @@ +/** + * Parser Module Tests + * + * Tests for the markdown parser modules. + */ + +import { describe, expect, it } from 'bun:test'; +import { markdownToSkill } from './markdown-to-skill'; +import { parseSkillMarkdown } from './parse-skill-markdown'; +import { skillToMarkdown } from './skill-to-markdown'; + +describe('parseSkillMarkdown', () => { + it('should parse skill from markdown with frontmatter', () => { + const markdown = `--- +name: test-skill +description: A test skill +version: 1.0.0 +license: MIT +--- + +## What I do +Test the parser + +## Checklist +- [ ] Item 1 +- [ ] Item 2 +`; + + const result = parseSkillMarkdown(markdown); + + expect(result.frontmatter.name).toBe('test-skill'); + expect(result.frontmatter.description).toBe('A test skill'); + expect(result.sections.whatIDo).toBe('Test the parser'); + expect(result.sections.checklist).toEqual(['Item 1', 'Item 2']); + }); + + it('should handle markdown without frontmatter', () => { + const markdown = `## What I do +Just content here`; + + expect(() => parseSkillMarkdown(markdown)).toThrow('No YAML frontmatter found'); + }); + + it('should parse all standard sections', () => { + const markdown = `--- +name: full-skill +description: Full skill test +--- + +## What I do +Core capabilities + +## When to use me +Use cases + +## Instructions +Follow these steps + +## Checklist +- [ ] Step 1 +- [ ] Step 2 +`; + + const result = parseSkillMarkdown(markdown); + + expect(result.sections.whatIDo).toBe('Core capabilities'); + expect(result.sections.whenToUseMe).toBe('Use cases'); + expect(result.sections.instructions).toBe('Follow these steps'); + expect(result.sections.checklist).toEqual(['Step 1', 'Step 2']); + }); +}); + +describe('markdownToSkill', () => { + it('should convert markdown to skill object', () => { + const markdown = `--- +name: converted-skill +description: Converted from markdown +version: 2.0.0 +license: Apache-2.0 +metadata: + category: testing +--- + +## What I do +Conversion test + +## Checklist +- [ ] Test conversion +`; + + const skill = markdownToSkill(markdown); + + expect(skill.name).toBe('converted-skill'); + expect(skill.description).toBe('Converted from markdown'); + expect(skill.version).toBe('2.0.0'); + expect(skill.license).toBe('Apache-2.0'); + expect(skill.metadata?.category).toBe('testing'); + expect(skill.whatIDo).toBe('Conversion test'); + expect(skill.checklist).toEqual(['Test conversion']); + }); + + it('should handle minimal skill markdown', () => { + const markdown = `--- +name: minimal +description: Minimal skill +--- + +## What I do +Minimal content +`; + + const skill = markdownToSkill(markdown); + + expect(skill.name).toBe('minimal'); + expect(skill.description).toBe('Minimal skill'); + expect(skill.whatIDo).toBe('Minimal content'); + expect(skill.checklist).toEqual([]); + }); +}); + +describe('skillToMarkdown', () => { + it('should convert skill to markdown', () => { + const skill = { + name: 'test-skill', + description: 'A test skill', + version: '1.0.0', + whatIDo: 'Test functionality', + whenToUseMe: 'When testing', + instructions: 'Run tests', + checklist: ['Setup', 'Execute', 'Verify'], + license: 'MIT', + compatibility: 'opencode', + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).toContain('---'); + expect(markdown).toContain('name: test-skill'); + expect(markdown).toContain('description: A test skill'); + expect(markdown).toContain('## What I do'); + expect(markdown).toContain('Test functionality'); + expect(markdown).toContain('- [ ] Setup'); + }); + + it('should handle skill with metadata', () => { + const skill = { + name: 'meta-skill', + description: 'Skill with metadata', + whatIDo: 'Testing', + metadata: { + category: 'testing', + author: 'test-author', + }, + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).toContain('metadata:'); + expect(markdown).toContain('category: testing'); + }); + + it('should produce round-trip compatible markdown', () => { + const originalSkill = { + name: 'round-trip-skill', + description: 'Round trip test', + version: '1.0.0', + whatIDo: 'Test round trip', + whenToUseMe: 'Use for testing', + instructions: 'Follow steps', + checklist: ['Step 1', 'Step 2'], + license: 'MIT', + }; + + const markdown = skillToMarkdown(originalSkill); + const parsedSkill = markdownToSkill(markdown); + + expect(parsedSkill.name).toBe(originalSkill.name); + expect(parsedSkill.description).toBe(originalSkill.description); + expect(parsedSkill.whatIDo).toBe(originalSkill.whatIDo); + expect(parsedSkill.checklist).toEqual(originalSkill.checklist); + }); +}); diff --git a/packages/opencode-skills/src/parsers/markdown-to-skill.test.ts b/packages/opencode-skills/src/parsers/markdown-to-skill.test.ts new file mode 100644 index 0000000..60fd845 --- /dev/null +++ b/packages/opencode-skills/src/parsers/markdown-to-skill.test.ts @@ -0,0 +1,125 @@ +/** + * Markdown to Skill Tests + * + * Tests for converting markdown content to Skill objects. + */ + +import { describe, expect, it } from 'bun:test'; +import { markdownToSkill } from './markdown-to-skill'; + +describe('markdownToSkill', () => { + it('should convert markdown to skill object', () => { + const markdown = `--- +name: converted-skill +description: Converted from markdown +license: Apache-2.0 +metadata: + category: testing +--- + +## What I do +Conversion test + +## Checklist +- [ ] Test conversion +`; + + const skill = markdownToSkill(markdown); + + expect(skill.name).toBe('converted-skill'); + expect(skill.description).toBe('Converted from markdown'); + expect(skill.license).toBe('Apache-2.0'); + expect(skill.metadata?.category).toBe('testing'); + expect(skill.whatIDo).toBe('Conversion test'); + expect(skill.checklist).toEqual(['Test conversion']); + }); + + it('should handle minimal skill markdown', () => { + const markdown = `--- +name: minimal +description: Minimal skill +--- + +## What I do +Minimal content +`; + + const skill = markdownToSkill(markdown); + + expect(skill.name).toBe('minimal'); + expect(skill.description).toBe('Minimal skill'); + expect(skill.whatIDo).toBe('Minimal content'); + expect(skill.checklist).toEqual([]); + }); + + it('should parse all standard sections', () => { + const markdown = `--- +name: full-skill +description: Full skill test +--- + +## What I do +Core capabilities + +## When to use me +Use cases + +## Instructions +Follow these steps + +## Checklist +- [ ] Step 1 +- [ ] Step 2 +`; + + const skill = markdownToSkill(markdown); + + expect(skill.name).toBe('full-skill'); + expect(skill.description).toBe('Full skill test'); + expect(skill.whatIDo).toBe('Core capabilities'); + expect(skill.whenToUseMe).toBe('Use cases'); + expect(skill.instructions).toBe('Follow these steps'); + expect(skill.checklist).toEqual(['Step 1', 'Step 2']); + }); + + it('should set default compatibility to opencode', () => { + const markdown = `--- +name: test-skill +description: Test description +--- + +## What I do +Test content +`; + + const skill = markdownToSkill(markdown); + + expect(skill.compatibility).toBe('opencode'); + }); + + it('should set timestamp on conversion', () => { + const markdown = `--- +name: test-skill +description: Test description +--- + +## What I do +Test content +`; + + const before = new Date().toISOString(); + const skill = markdownToSkill(markdown); + const after = new Date().toISOString(); + + expect(skill.updatedAt).toBeDefined(); + expect(skill.updatedAt! >= before).toBe(true); + expect(skill.updatedAt! <= after).toBe(true); + }); + + it('should throw error for markdown without frontmatter', () => { + const markdown = `## What I do +Just content here`; + + expect(() => markdownToSkill(markdown)).toThrow('No YAML frontmatter found'); + }); +}); diff --git a/packages/opencode-skills/src/parsers/markdown-to-skill.ts b/packages/opencode-skills/src/parsers/markdown-to-skill.ts new file mode 100644 index 0000000..8040db2 --- /dev/null +++ b/packages/opencode-skills/src/parsers/markdown-to-skill.ts @@ -0,0 +1,47 @@ +/** + * Markdown to Skill Converter + * + * Convert markdown content to Skill object. + */ + +import type { Skill } from '../types'; +import { parseSkillMarkdown } from './parse-skill-markdown'; + +/** + * Convert parsed markdown to Skill object + * + * @param markdown - Complete markdown content with YAML frontmatter + * @returns Fully structured Skill object ready for use + * + * @example + * ```typescript + * const markdown = `--- + * name: typescript-tdd + * description: TypeScript with TDD + * --- + * + * ## What I do + * I help with TypeScript development. + * `; + * + * const skill = markdownToSkill(markdown); + * // => { name: 'typescript-tdd', description: '...', whatIDo: '...', ... } + * ``` + */ +export const markdownToSkill = (markdown: string): Skill => { + const { frontmatter, sections } = parseSkillMarkdown(markdown); + + return { + name: frontmatter.name, + description: frontmatter.description, + version: frontmatter.version ?? '1.0.0', + updatedAt: new Date().toISOString(), + license: frontmatter.license, + compatibility: frontmatter.compatibility ?? 'opencode', + metadata: frontmatter.metadata, + whatIDo: sections.whatIDo, + whenToUseMe: sections.whenToUseMe, + instructions: sections.instructions, + checklist: sections.checklist, + }; +}; diff --git a/packages/opencode-skills/src/parsers/parse-checklist-items.ts b/packages/opencode-skills/src/parsers/parse-checklist-items.ts new file mode 100644 index 0000000..5f0244e --- /dev/null +++ b/packages/opencode-skills/src/parsers/parse-checklist-items.ts @@ -0,0 +1,38 @@ +/** + * Parse Checklist Items + * + * Extract checklist items from markdown content. + */ + +/** + * Parse checklist items from markdown content + * + * Extracts items in format `- [ ] Item` or `- [x] Item` + * + * @param content - Markdown content containing checklist + * @returns Array of checklist item strings + * + * @example + * ```typescript + * const content = ` + * - [ ] Write tests + * - [x] Implement feature + * - [ ] Review code + * `; + * + * const items = parseChecklistItems(content); + * // => ['Write tests', 'Implement feature', 'Review code'] + * ``` + */ +export const parseChecklistItems = (content: string): string[] => { + const checklistRegex = /^[-*]\s+\[[ x]\]\s+(.+)$/gm; + const items: string[] = []; + let match: RegExpExecArray | null = checklistRegex.exec(content); + + while (match !== null) { + items.push(match[1].trim()); + match = checklistRegex.exec(content); + } + + return items; +}; diff --git a/packages/opencode-skills/src/parsers/parse-skill-markdown.ts b/packages/opencode-skills/src/parsers/parse-skill-markdown.ts new file mode 100644 index 0000000..7a4219c --- /dev/null +++ b/packages/opencode-skills/src/parsers/parse-skill-markdown.ts @@ -0,0 +1,37 @@ +/** + * Parse Skill Markdown + * + * Main parser to convert SKILL.md file into structured ParsedSkill object. + */ + +import { extractFrontmatter } from './extract-frontmatter'; +import { extractSections } from './extract-sections'; +import type { ParsedSkill } from './types'; + +/** + * Parse SKILL.md file and convert to ParsedSkill object + * + * @param markdown - Complete markdown content with YAML frontmatter + * @returns Structured ParsedSkill with frontmatter and sections + * + * @example + * ```typescript + * const markdown = `--- + * name: typescript-tdd + * description: TypeScript with TDD + * --- + * + * ## What I do + * I help with TypeScript development. + * `; + * + * const parsed = parseSkillMarkdown(markdown); + * // => { frontmatter: { name: 'typescript-tdd', ... }, sections: { whatIDo: '...', ... } } + * ``` + */ +export const parseSkillMarkdown = (markdown: string): ParsedSkill => { + const { frontmatter, content } = extractFrontmatter(markdown); + const sections = extractSections(content); + + return { frontmatter, sections }; +}; diff --git a/packages/opencode-skills/src/parsers/skill-to-markdown.test.ts b/packages/opencode-skills/src/parsers/skill-to-markdown.test.ts new file mode 100644 index 0000000..641f3cc --- /dev/null +++ b/packages/opencode-skills/src/parsers/skill-to-markdown.test.ts @@ -0,0 +1,112 @@ +/** + * Skill to Markdown Tests + * + * Tests for converting Skill objects to markdown format. + */ + +import { describe, expect, it } from 'bun:test'; +import { markdownToSkill } from './markdown-to-skill'; +import { skillToMarkdown } from './skill-to-markdown'; + +describe('skillToMarkdown', () => { + it('should convert skill to markdown', () => { + const skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Test functionality', + whenToUseMe: 'When testing', + instructions: 'Run tests', + checklist: ['Setup', 'Execute', 'Verify'], + license: 'MIT', + compatibility: 'opencode', + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).toContain('---'); + expect(markdown).toContain('name: test-skill'); + expect(markdown).toContain('description: A test skill'); + expect(markdown).toContain('## What I do'); + expect(markdown).toContain('Test functionality'); + expect(markdown).toContain('- [ ] Setup'); + }); + + it('should handle skill with metadata', () => { + const skill = { + name: 'meta-skill', + description: 'Skill with metadata', + whatIDo: 'Testing', + metadata: { + category: 'testing', + author: 'test-author', + }, + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).toContain('metadata:'); + expect(markdown).toContain('category: testing'); + }); + + it('should produce round-trip compatible markdown', () => { + const originalSkill = { + name: 'round-trip-skill', + description: 'Round trip test', + whatIDo: 'Test round trip', + whenToUseMe: 'Use for testing', + instructions: 'Follow steps', + checklist: ['Step 1', 'Step 2'], + license: 'MIT', + }; + + const markdown = skillToMarkdown(originalSkill); + const parsedSkill = markdownToSkill(markdown); + + expect(parsedSkill.name).toBe(originalSkill.name); + expect(parsedSkill.description).toBe(originalSkill.description); + expect(parsedSkill.whatIDo).toBe(originalSkill.whatIDo); + expect(parsedSkill.checklist).toEqual(originalSkill.checklist); + }); + + it('should omit optional fields when not provided', () => { + const skill = { + name: 'minimal-skill', + description: 'Minimal skill', + whatIDo: 'Do minimal things', + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).not.toContain('license:'); + expect(markdown).not.toContain('compatibility:'); + expect(markdown).not.toContain('metadata:'); + }); + + it('should handle empty checklist', () => { + const skill = { + name: 'no-checklist-skill', + description: 'Skill without checklist', + whatIDo: 'Work without checklist', + checklist: [], + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).toContain('## Checklist'); + }); + + it('should handle skill with special characters in content', () => { + const skill = { + name: 'special-skill', + description: 'Skill with special chars: "quotes" and \'apostrophes\'', + whatIDo: 'Handle content with: colons, dashes - and asterisks *', + checklist: ['Item with "quotes"', "Item with 'apostrophes'"], + }; + + const markdown = skillToMarkdown(skill); + + expect(markdown).toContain('---'); + expect(markdown).toContain('name: special-skill'); + expect(markdown).toContain('- [ ] Item with "quotes"'); + }); +}); diff --git a/packages/opencode-skills/src/parsers/skill-to-markdown.ts b/packages/opencode-skills/src/parsers/skill-to-markdown.ts new file mode 100644 index 0000000..e6c2817 --- /dev/null +++ b/packages/opencode-skills/src/parsers/skill-to-markdown.ts @@ -0,0 +1,68 @@ +/** + * Skill to Markdown Converter + * + * Convert Skill object back to markdown format. + */ + +import { stringify as stringifyYAML } from 'yaml'; + +import type { Skill } from '../types'; + +/** + * Convert Skill object back to markdown format + * + * @param skill - Skill object to convert + * @returns Markdown string with YAML frontmatter + * + * @example + * ```typescript + * const skill = { + * name: 'typescript-tdd', + * description: 'TypeScript with TDD', + * whatIDo: 'I help with TypeScript development.', + * checklist: ['Write tests', 'Implement feature'], + * }; + * + * const markdown = skillToMarkdown(skill); + * // Returns: + * // --- + * // name: typescript-tdd + * // description: TypeScript with TDD + * // --- + * // + * // # typescript-tdd + * // + * // ## What I do + * // I help with TypeScript development. + * // ... + * ``` + */ +export const skillToMarkdown = (skill: Skill): string => { + const frontmatter = { + name: skill.name, + description: skill.description, + ...(skill.license && { license: skill.license }), + ...(skill.compatibility && { compatibility: skill.compatibility }), + ...(skill.metadata && { metadata: skill.metadata }), + }; + + const yamlFrontmatter = stringifyYAML(frontmatter); + + const content = ` +# ${skill.name} + +## What I do +${skill.whatIDo || ''} + +## When to use me +${skill.whenToUseMe || ''} + +## Instructions +${skill.instructions || ''} + +## Checklist +${skill.checklist?.map((item) => `- [ ] ${item}`).join('\n') || ''} + `.trim(); + + return `---\n${yamlFrontmatter}---\n\n${content}`; +}; diff --git a/packages/opencode-skills/src/parsers/split-by-headings.ts b/packages/opencode-skills/src/parsers/split-by-headings.ts new file mode 100644 index 0000000..d5ac84d --- /dev/null +++ b/packages/opencode-skills/src/parsers/split-by-headings.ts @@ -0,0 +1,54 @@ +/** + * Split Markdown by Headings + * + * Parse markdown and split into heading/content pairs. + */ + +interface HeadingPart { + heading: string; + content: string; +} + +/** + * Split markdown content by ## headings + * + * @param markdown - Markdown content to parse + * @returns Array of heading/content pairs + * + * @example + * ```typescript + * const content = ` + * ## What I do + * Content here + * ## Checklist + * - Item 1 + * `; + * + * const parts = splitByHeadings(content); + * // => [{ heading: 'What I do', content: 'Content here' }, ...] + * ``` + */ +export const splitByHeadings = (markdown: string): HeadingPart[] => { + const headingRegex = /^##\s+(.+?)$/gm; + const parts: HeadingPart[] = []; + + let lastIndex = 0; + let match: RegExpExecArray | null = headingRegex.exec(markdown); + + while (match !== null) { + if (lastIndex > 0) { + const previousHeading = parts[parts.length - 1]; + previousHeading.content = markdown.slice(lastIndex, match.index).trim(); + } + parts.push({ heading: match[1].trim(), content: '' }); + lastIndex = match.index + match[0].length; + match = headingRegex.exec(markdown); + } + + // Get content for last heading + if (parts.length > 0) { + parts[parts.length - 1].content = markdown.slice(lastIndex).trim(); + } + + return parts; +}; diff --git a/packages/opencode-skills/src/parsers/types.ts b/packages/opencode-skills/src/parsers/types.ts new file mode 100644 index 0000000..c297e9b --- /dev/null +++ b/packages/opencode-skills/src/parsers/types.ts @@ -0,0 +1,37 @@ +/** + * Markdown Parser Type Definitions + * + * Type definitions for skill markdown parsing. + */ + +import type { SkillMetadata } from '../types'; + +/** + * Parsed skill structure with frontmatter and sections separated + */ +export interface ParsedSkill { + frontmatter: SkillFrontmatter; + sections: SkillSections; +} + +/** + * Skill YAML frontmatter + */ +export interface SkillFrontmatter { + name: string; + description: string; + version?: string; + license?: string; + compatibility?: string; + metadata?: SkillMetadata; +} + +/** + * Skill markdown sections + */ +export interface SkillSections { + whatIDo: string; + whenToUseMe: string; + instructions: string; + checklist: string[]; +} diff --git a/packages/opencode-skills/src/pattern-matching/escape-regex.test.ts b/packages/opencode-skills/src/pattern-matching/escape-regex.test.ts new file mode 100644 index 0000000..66ddc7e --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/escape-regex.test.ts @@ -0,0 +1,77 @@ +/** + * Escape Regex Tests + * + * Tests for regex escaping functionality. + */ + +import { describe, expect, it } from 'bun:test'; +import { escapeRegex } from './escape-regex'; + +describe('escapeRegex', () => { + it('should escape dots', () => { + expect(escapeRegex('test.txt')).toBe('test\\.txt'); + expect(escapeRegex('file.name')).toBe('file\\.name'); + }); + + it('should escape asterisks', () => { + expect(escapeRegex('a*b')).toBe('a\\*b'); + expect(escapeRegex('**')).toBe('\\*\\*'); + }); + + it('should escape plus signs', () => { + expect(escapeRegex('a+b')).toBe('a\\+b'); + expect(escapeRegex('c++')).toBe('c\\+\\+'); + }); + + it('should escape question marks', () => { + expect(escapeRegex('what?')).toBe('what\\?'); + expect(escapeRegex('??')).toBe('\\?\\?'); + }); + + it('should escape caret', () => { + expect(escapeRegex('^start')).toBe('\\^start'); + expect(escapeRegex('a^2')).toBe('a\\^2'); + }); + + it('should escape dollar sign', () => { + expect(escapeRegex('$price')).toBe('\\$price'); + expect(escapeRegex('100$')).toBe('100\\$'); + }); + + it('should escape braces and brackets', () => { + expect(escapeRegex('test{1,2}')).toBe('test\\{1,2\\}'); + expect(escapeRegex('[test]')).toBe('\\[test\\]'); + expect(escapeRegex('(test)')).toBe('\\(test\\)'); + }); + + it('should escape pipe', () => { + expect(escapeRegex('a|b')).toBe('a\\|b'); + expect(escapeRegex('||')).toBe('\\|\\|'); + }); + + it('should escape backslash', () => { + expect(escapeRegex('path\\to\\file')).toBe('path\\\\to\\\\file'); + }); + + it('should handle empty string', () => { + expect(escapeRegex('')).toBe(''); + }); + + it('should handle string with no special chars', () => { + expect(escapeRegex('skill-name')).toBe('skill-name'); + expect(escapeRegex('typescript')).toBe('typescript'); + expect(escapeRegex('test123')).toBe('test123'); + }); + + it('should handle complex mixed patterns', () => { + expect(escapeRegex('(a+b)*c?')).toBe('\\(a\\+b\\)\\*c\\?'); + expect(escapeRegex('[a-z]+')).toBe('\\[a-z\\]\\+'); + expect(escapeRegex('test[1].txt')).toBe('test\\[1\\]\\.txt'); + }); + + it('should handle skill names with special chars', () => { + expect(escapeRegex('regex.utils')).toBe('regex\\.utils'); + expect(escapeRegex('file[test]')).toBe('file\\[test\\]'); + expect(escapeRegex('test+v1.0')).toBe('test\\+v1\\.0'); + }); +}); diff --git a/packages/opencode-skills/src/pattern-matching/escape-regex.ts b/packages/opencode-skills/src/pattern-matching/escape-regex.ts new file mode 100644 index 0000000..ddd2064 --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/escape-regex.ts @@ -0,0 +1,24 @@ +/** + * Escape Regex + * + * Escape special regex characters in a string for safe pattern matching. + */ + +/** + * Escape special regex characters in a string + * + * @param str - String to escape + * @returns Escaped string safe for use in RegExp + * + * @example + * ```typescript + * escapeRegex('test.txt') + * // => 'test\\.txt' + * + * escapeRegex('skill-name') + * // => 'skill-name' + * ``` + */ +export const escapeRegex = (str: string): string => { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; diff --git a/packages/opencode-skills/src/pattern-matching/find-matching-skills.test.ts b/packages/opencode-skills/src/pattern-matching/find-matching-skills.test.ts new file mode 100644 index 0000000..f4a3cfa --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/find-matching-skills.test.ts @@ -0,0 +1,104 @@ +/** + * Find Matching Skills Tests + * + * Tests for batch skill matching with edge cases. + */ + +import { describe, expect, it } from 'bun:test'; +import { findMatchingSkills } from './find-matching-skills'; + +describe('findMatchingSkills', () => { + it('should find matching skill from array', () => { + const skills = ['typescript-tdd', 'react-hooks', 'node-api']; + const result = findMatchingSkills('Use typescript-tdd', skills); + expect(result).toEqual(['typescript-tdd']); + }); + + it('should return empty array when no matches', () => { + const skills = ['typescript-tdd', 'react-hooks']; + const result = findMatchingSkills('Use python-flask', skills); + expect(result).toEqual([]); + }); + + it('should find multiple matching skills', () => { + const skills = ['typescript-tdd', 'react-hooks', 'node-api']; + const result = findMatchingSkills('Use typescript-tdd and react-hooks', skills); + expect(result).toEqual(['typescript-tdd', 'react-hooks']); + }); + + it('should handle empty skills array', () => { + const result = findMatchingSkills('Use typescript-tdd', []); + expect(result).toEqual([]); + }); + + it('should handle empty content', () => { + const skills = ['typescript-tdd', 'react-hooks']; + const result = findMatchingSkills('', skills); + expect(result).toEqual([]); + }); + + it('should use custom keywords map', () => { + const skills = ['typescript-tdd', 'react-hooks']; + const keywords = new Map([ + ['typescript-tdd', ['tdd', 'test-driven']], + ['react-hooks', ['hooks', 'react']], + ]); + const result = findMatchingSkills('Use TDD approach', skills, keywords); + expect(result).toEqual(['typescript-tdd']); + }); + + it('should handle skills without keywords in map', () => { + const skills = ['typescript-tdd', 'react-hooks']; + const keywords = new Map([['typescript-tdd', ['tdd']]]); + const result = findMatchingSkills('Use react-hooks', skills, keywords); + expect(result).toEqual(['react-hooks']); + }); + + it('should handle special characters in content', () => { + const skills = ['regex.utils', 'file[test]']; + const result = findMatchingSkills('Use regex.utils pattern', skills); + expect(result).toEqual(['regex.utils']); + }); + + it('should handle large skills array', () => { + const skills = Array.from({ length: 100 }, (_, i) => `skill-${i}`); + const result = findMatchingSkills('Use skill-50', skills); + expect(result).toEqual(['skill-50']); + }); + + it('should maintain order of matches', () => { + const skills = ['typescript-tdd', 'react-hooks', 'node-api']; + const result = findMatchingSkills('Use node-api with typescript-tdd', skills); + expect(result).toEqual(['typescript-tdd', 'node-api']); + }); + + it('should not match skills in negated phrases', () => { + const skills = ['typescript-tdd', 'react-hooks']; + // With negation detection enabled, neither skill matches due to negation + const result = findMatchingSkills("Don't use typescript-tdd or react-hooks", skills); + expect(result).toEqual([]); + }); + + it('should handle mixed case skill names', () => { + const skills = ['TypeScript-TDD', 'React-Hooks']; + const result = findMatchingSkills('Use typescript-tdd', skills); + expect(result).toEqual(['TypeScript-TDD']); + }); + + it('should handle config options', () => { + const skills = ['typescript-tdd', 'react-hooks']; + const result = findMatchingSkills('Use typescript-tdd', skills, new Map(), { + wordBoundary: true, + intentDetection: true, + negationDetection: true, + }); + expect(result).toEqual(['typescript-tdd']); + }); + + it('should handle very long content', () => { + const skills = ['typescript-tdd']; + const longContent = 'Use typescript-tdd'.repeat(1000); + const result = findMatchingSkills(longContent, skills); + expect(result).toEqual(['typescript-tdd']); + }); +}); diff --git a/packages/opencode-skills/src/pattern-matching/find-matching-skills.ts b/packages/opencode-skills/src/pattern-matching/find-matching-skills.ts new file mode 100644 index 0000000..78674ff --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/find-matching-skills.ts @@ -0,0 +1,49 @@ +/** + * Find Matching Skills + * + * Batch check multiple skills for pattern matches against content. + */ + +import type { SkillsPluginConfig } from '../types'; +import { hasIntentToUse } from './has-intent-to-use'; + +/** + * Batch check multiple skills for pattern matches + * + * @param content - The message content to analyze + * @param skillNames - Array of skill names to check + * @param skillKeywords - Map of skill names to additional keywords + * @param config - Optional configuration for pattern matching + * @returns Array of skill names that match + * + * @example + * ```typescript + * const skills = ['typescript-tdd', 'bun-runtime']; + * const keywords = new Map([ + * ['typescript-tdd', ['TDD', 'test-driven']], + * ['bun-runtime', ['bun', 'runtime']], + * ]); + * + * findMatchingSkills('Use TDD approach', skills, keywords) + * // => ['typescript-tdd'] + * ``` + */ +export const findMatchingSkills = ( + content: string, + skillNames: string[], + skillKeywords: Map = new Map(), + config?: SkillsPluginConfig['patternMatching'], +): string[] => { + const matchingSkills: string[] = []; + + for (const skillName of skillNames) { + const keywords = skillKeywords.get(skillName) || []; + const result = hasIntentToUse(content, skillName, keywords, config); + + if (result.matches) { + matchingSkills.push(skillName); + } + } + + return matchingSkills; +}; diff --git a/packages/opencode-skills/src/pattern-matching/has-intent-to-use.test.ts b/packages/opencode-skills/src/pattern-matching/has-intent-to-use.test.ts new file mode 100644 index 0000000..02cb884 --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/has-intent-to-use.test.ts @@ -0,0 +1,135 @@ +/** + * Has Intent To Use Tests + * + * Tests for intent detection with edge cases and negation. + */ + +import { describe, expect, it } from 'bun:test'; +import { hasIntentToUse } from './has-intent-to-use'; + +describe('hasIntentToUse', () => { + it('should match skill name with word boundaries', () => { + const result = hasIntentToUse('Use typescript-tdd skill', 'typescript-tdd'); + expect(result.matches).toBe(true); + expect(result.matchedPattern).toBe('word-boundary'); + }); + + it('should match skill name even within longer strings', () => { + // Implementation matches skill name regardless of context + const result = hasIntentToUse('Use my-typescript-tdd-test', 'typescript-tdd'); + expect(result.matches).toBe(true); + }); + + it('should detect skill name with intent nearby', () => { + // Implementation detects by word boundary primarily + const result = hasIntentToUse('I want to use typescript-tdd', 'typescript-tdd'); + expect(result.matches).toBe(true); + expect(result.matchedPattern).toBe('word-boundary'); + }); + + it('should detect skill name with trailing words', () => { + // Implementation detects by word boundary + const result = hasIntentToUse('typescript-tdd approach recommended', 'typescript-tdd'); + expect(result.matches).toBe(true); + expect(result.matchedPattern).toBe('word-boundary'); + }); + + it('should detect custom keywords', () => { + const result = hasIntentToUse('Use TDD approach', 'typescript-tdd', ['tdd', 'test-driven']); + expect(result.matches).toBe(true); + expect(result.matchedPattern).toBe('keyword:tdd'); + }); + + it('should handle negation before skill name', () => { + const result = hasIntentToUse("Don't use typescript-tdd", 'typescript-tdd'); + expect(result.matches).toBe(false); + expect(result.hasNegation).toBe(true); + }); + + it('should handle negation after skill name', () => { + const result = hasIntentToUse('Skip typescript-tdd for now', 'typescript-tdd'); + expect(result.matches).toBe(false); + expect(result.hasNegation).toBe(true); + }); + + it('should handle empty content', () => { + const result = hasIntentToUse('', 'typescript-tdd'); + expect(result.matches).toBe(false); + expect(result.hasNegation).toBe(false); + }); + + it('should handle whitespace-only content', () => { + const result = hasIntentToUse(' \n\t ', 'typescript-tdd'); + expect(result.matches).toBe(false); + }); + + it('should handle case insensitivity', () => { + const result = hasIntentToUse('USE TypeScript-TDD', 'typescript-tdd'); + expect(result.matches).toBe(true); + }); + + it('should handle skill names with hyphens', () => { + const result = hasIntentToUse('Use my-custom-skill', 'my-custom-skill'); + expect(result.matches).toBe(true); + }); + + it('should handle skill names with dots', () => { + const result = hasIntentToUse('Use regex.utils pattern', 'regex.utils'); + expect(result.matches).toBe(true); + }); + + it('should handle multiple skill mentions', () => { + const result = hasIntentToUse('Use typescript-tdd or typescript-tdd again', 'typescript-tdd'); + expect(result.matches).toBe(true); + }); + + it('should handle special characters in content', () => { + const result = hasIntentToUse('Use typescript-tdd (v2.0)!', 'typescript-tdd'); + expect(result.matches).toBe(true); + }); + + it('should handle unicode content', () => { + const result = hasIntentToUse('Use typescript-tdd 技能', 'typescript-tdd'); + expect(result.matches).toBe(true); + }); + + it('should handle disabled word boundary matching', () => { + const result = hasIntentToUse('typescript-tdd', 'typescript-tdd', [], { wordBoundary: false }); + expect(result.matches).toBe(false); // No patterns without word boundary + }); + + it('should handle disabled intent detection', () => { + const result = hasIntentToUse('Use typescript-tdd', 'typescript-tdd', [], { intentDetection: false }); + expect(result.matches).toBe(true); // Still matches word boundary + }); + + it('should handle disabled negation detection', () => { + const result = hasIntentToUse("Don't use typescript-tdd", 'typescript-tdd', [], { negationDetection: false }); + expect(result.matches).toBe(true); // Ignores negation + expect(result.hasNegation).toBe(false); + }); + + it('should handle custom intent keywords', () => { + // With custom keywords, still matches via word boundary + const result = hasIntentToUse('Deploy typescript-tdd', 'typescript-tdd', [], { + customIntentKeywords: ['deploy', 'ship'], + }); + expect(result.matches).toBe(true); + expect(result.matchedPattern).toBe('word-boundary'); + }); + + it('should handle custom negation keywords', () => { + const result = hasIntentToUse('Bypass typescript-tdd', 'typescript-tdd', [], { + customNegationKeywords: ['bypass', 'circumvent'], + }); + expect(result.matches).toBe(false); + expect(result.hasNegation).toBe(true); + }); + + it('should handle multiple matching patterns', () => { + const result = hasIntentToUse('Use typescript-tdd with tests', 'typescript-tdd', ['tests', 'tdd']); + expect(result.matches).toBe(true); + // Should match the first pattern found + expect(result.matchedPattern).toBeDefined(); + }); +}); diff --git a/packages/opencode-skills/src/pattern-matching/has-intent-to-use.ts b/packages/opencode-skills/src/pattern-matching/has-intent-to-use.ts new file mode 100644 index 0000000..de97a12 --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/has-intent-to-use.ts @@ -0,0 +1,133 @@ +/** + * Has Intent To Use + * + * Check if content has intent to use a skill based on pattern matching. + * Uses multiple strategies including word boundaries, intent detection, and negation detection. + */ + +import type { MatchResult, SkillsPluginConfig } from '../types'; +import { escapeRegex } from './escape-regex'; + +/** + * Default intent keywords that signal user wants to use a skill + */ +const DEFAULT_INTENT_KEYWORDS = ['use', 'apply', 'follow', 'implement', 'load', 'get', 'show', 'with']; + +/** + * Default negation keywords that signal user wants to avoid a skill + */ +const DEFAULT_NEGATION_KEYWORDS = ["don't", 'do not', 'avoid', 'skip', 'ignore', 'without', 'except', 'excluding']; + +/** + * Check if content has intent to use a skill based on pattern matching + * + * This function uses multiple strategies to detect if a user message + * indicates they want to use a particular skill: + * + * 1. Word boundary matching - exact skill name with word boundaries + * 2. Intent detection - skill name preceded/followed by intent keywords + * 3. Negation detection - rejects matches with negation keywords + * 4. Keyword matching - optional custom keywords for enhanced detection + * + * @param content - The message content to analyze + * @param skillName - The name of the skill to match + * @param keywords - Optional additional keywords to match + * @param config - Optional configuration for pattern matching + * @returns MatchResult indicating if skill should be injected + * + * @example + * ```typescript + * hasIntentToUse('Please use typescript-tdd', 'typescript-tdd') + * // => { matches: true, matchedPattern: 'intent-before:use', hasNegation: false } + * + * hasIntentToUse("Don't use typescript-tdd", 'typescript-tdd') + * // => { matches: false, hasNegation: true } + * ``` + */ +export const hasIntentToUse = ( + content: string, + skillName: string, + keywords: string[] = [], + config?: SkillsPluginConfig['patternMatching'], +): MatchResult => { + // Normalize content for matching + const normalizedContent = content.toLowerCase(); + const normalizedSkillName = skillName.toLowerCase(); + const escapedSkillName = escapeRegex(normalizedSkillName); + + // Default config values + const wordBoundary = config?.wordBoundary ?? true; + const intentDetection = config?.intentDetection ?? true; + const negationDetection = config?.negationDetection ?? true; + + const patterns: Array<{ regex: RegExp; description: string }> = []; + + // Pattern 1: Word boundary match (exact skill name) + if (wordBoundary) { + patterns.push({ + regex: new RegExp(`\\b${escapedSkillName}\\b`, 'i'), + description: 'word-boundary', + }); + } + + // Pattern 2: Intent detection patterns + if (intentDetection) { + const intentKeywords = [...DEFAULT_INTENT_KEYWORDS, ...(config?.customIntentKeywords || [])]; + + for (const keyword of intentKeywords) { + // Intent keyword before skill name: "use typescript-tdd" + patterns.push({ + regex: new RegExp(`\\b${keyword}\\b.*\\b${escapedSkillName}\\b`, 'i'), + description: `intent-before:${keyword}`, + }); + + // Intent keyword after skill name: "typescript-tdd approach" + patterns.push({ + regex: new RegExp(`\\b${escapedSkillName}\\b.{0,20}\\b${keyword}\\b`, 'i'), + description: `intent-after:${keyword}`, + }); + } + } + + // Pattern 3: Match additional keywords if provided + for (const keyword of keywords) { + const escapedKeyword = escapeRegex(keyword.toLowerCase()); + patterns.push({ + regex: new RegExp(`\\b${escapedKeyword}\\b`, 'i'), + description: `keyword:${keyword}`, + }); + } + + // Check if any pattern matches + let matchedPattern: string | undefined; + const matches = patterns.some((pattern) => { + if (pattern.regex.test(normalizedContent)) { + matchedPattern = pattern.description; + return true; + } + return false; + }); + + // If no match, return early + if (!matches) { + return { matches: false, hasNegation: false }; + } + + // Check for negation (if enabled) + let hasNegation = false; + if (negationDetection) { + const negationKeywords = [...DEFAULT_NEGATION_KEYWORDS, ...(config?.customNegationKeywords || [])]; + + hasNegation = negationKeywords.some((negWord) => { + // Check if negation appears before skill name + const negPattern = new RegExp(`\\b${escapeRegex(negWord)}\\b.{0,50}\\b${escapedSkillName}\\b`, 'i'); + return negPattern.test(normalizedContent); + }); + } + + return { + matches: matches && !hasNegation, + matchedPattern, + hasNegation, + }; +}; diff --git a/packages/opencode-skills/src/pattern-matching/index.ts b/packages/opencode-skills/src/pattern-matching/index.ts new file mode 100644 index 0000000..88a089f --- /dev/null +++ b/packages/opencode-skills/src/pattern-matching/index.ts @@ -0,0 +1,17 @@ +/** + * Smart Pattern Matching for Skill Detection + * + * This module provides intelligent pattern matching for detecting user intent + * to use specific skills in their messages. It uses multiple strategies including: + * + * - Word boundary matching for exact skill names + * - Intent detection with configurable keywords + * - Negation detection to avoid false positives + * - Custom keyword matching for aliases + * + * @see https://github.com/pantheon-org/opencode-skills + */ + +export { escapeRegex } from './escape-regex'; +export { findMatchingSkills } from './find-matching-skills'; +export { hasIntentToUse } from './has-intent-to-use'; diff --git a/packages/opencode-skills/src/types.ts b/packages/opencode-skills/src/types.ts new file mode 100644 index 0000000..862c9b7 --- /dev/null +++ b/packages/opencode-skills/src/types.ts @@ -0,0 +1,191 @@ +/** + * Skill type definitions + */ + +/** + * Metadata for a skill + */ +export interface SkillMetadata { + /** Skill category (workflow, development, documentation, testing, deployment, security) */ + category: string; + /** Additional metadata fields */ + [key: string]: string; +} + +/** + * A skill represents a reusable piece of knowledge or guidance + * that can be automatically injected into chat context when needed. + */ +export interface Skill { + /** Unique identifier for the skill (kebab-case) */ + name: string; + + /** Brief description of what the skill provides */ + description: string; + + /** Core capabilities (What I do section) - required in v2 */ + whatIDo?: string; + + /** Conditions that should trigger this skill (When to use me section) - required in v2 */ + whenToUseMe?: string; + + /** Detailed guidance (Instructions section) - required in v2 */ + instructions?: string; + + /** Verification items (Checklist section) - required in v2 */ + checklist?: string[]; + + /** License identifier (e.g., MIT) - required in v2 */ + license?: string; + + /** Compatibility identifier (e.g., opencode) - required in v2 */ + compatibility?: string; + + /** Structured metadata including category - required in v2 */ + metadata?: SkillMetadata; + + /** Optional version string for tracking updates */ + version?: string; + + /** Optional last update timestamp */ + updatedAt?: string; + + /** Optional list of skill names this skill depends on */ + dependencies?: string[]; + + /** Optional keywords for enhanced pattern matching */ + keywords?: string[]; + + /** @deprecated Use whatIDo, whenToUseMe, instructions, checklist instead */ + content?: string; + + /** @deprecated Use metadata.category instead */ + category?: string; + + /** Content sections for structured skill content */ + contentSections?: Array<{ + type: string; + content: string; + }>; + + /** Usage examples for the skill */ + examples?: Array<{ + description: string; + context?: string; + }>; +} + +/** + * BM25 ranking configuration for relevance scoring + */ +export interface BM25Config { + /** Enable BM25 relevance scoring (default: false) */ + enabled?: boolean; + + /** Term frequency saturation parameter (default: 1.5, typical range: 1.2-2.0) */ + k1?: number; + + /** Length normalization parameter (default: 0.75, typical range: 0-1) */ + b?: number; + + /** Minimum BM25 score threshold for injection (default: 0.0) */ + threshold?: number; + + /** Maximum number of skills to inject per message (default: 3) */ + maxSkills?: number; +} + +/** + * Configuration options for the skills plugin + */ +export interface SkillsPluginConfig { + /** Enable/disable auto-injection via chat hook */ + autoInject?: boolean; + + /** Enable/disable debug logging */ + debug?: boolean; + + /** Custom pattern matching configuration */ + patternMatching?: { + /** Enable word boundary matching (default: true) */ + wordBoundary?: boolean; + + /** Enable intent detection patterns (default: true) */ + intentDetection?: boolean; + + /** Enable negation detection (default: true) */ + negationDetection?: boolean; + + /** Custom intent keywords (in addition to defaults) */ + customIntentKeywords?: string[]; + + /** Custom negation keywords (in addition to defaults) */ + customNegationKeywords?: string[]; + }; + + /** BM25 relevance scoring configuration */ + bm25?: BM25Config; +} + +/** + * Result of pattern matching for a skill + */ +export interface MatchResult { + /** Whether the skill matches the content */ + matches: boolean; + + /** The specific pattern that matched (for debugging) */ + matchedPattern?: string; + + /** Whether negation was detected */ + hasNegation: boolean; +} + +// SkillDefinition is an alias for Skill for backward compatibility +export type SkillDefinition = Skill; + +// Validation Types +export type ValidationSeverity = 'error' | 'warning' | 'info'; + +export interface ValidationError { + field: string; + message: string; + code: string; +} + +export interface ValidationWarning { + field: string; + message: string; + code: string; +} + +export interface ValidationSuggestion { + field: string; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + warnings: ValidationWarning[]; + suggestions: ValidationSuggestion[]; +} + +export interface ValidationContext { + packageName?: string; + filePath?: string; + strict?: boolean; +} + +export interface SkillValidator { + validate(skill: Skill): ValidationResult; +} + +export interface SkillRegistry { + register(skill: Skill): void; + get(name: string): Skill | undefined; + has(name: string): boolean; + list(): Skill[]; + clear(): void; + size(): number; +} diff --git a/packages/opencode-skills/src/validation/format-validation-result.ts b/packages/opencode-skills/src/validation/format-validation-result.ts new file mode 100644 index 0000000..5fdf0e5 --- /dev/null +++ b/packages/opencode-skills/src/validation/format-validation-result.ts @@ -0,0 +1,41 @@ +import type { ValidationResult } from '../types.js'; + +export const formatValidationResult = (result: ValidationResult, skillName: string): string => { + const lines: string[] = []; + + lines.push(`Validation Results for "${skillName}"`); + lines.push(''); + + if (!result.valid) { + lines.push('[ERROR] Validation failed:'); + for (const error of result.errors) { + lines.push(` - ${error.field}: ${error.message}`); + } + lines.push(''); + lines.push('Skill validation failed'); + } else { + if (result.warnings.length > 0) { + lines.push('[WARNING] Issues found:'); + for (const warning of result.warnings) { + lines.push(` - ${warning.field}: ${warning.message}`); + } + lines.push(''); + } + + if (result.suggestions.length > 0) { + lines.push('[SUGGESTION] Improvements:'); + for (const suggestion of result.suggestions) { + lines.push(` - ${suggestion.field}: ${suggestion.message}`); + } + lines.push(''); + } + + if (result.warnings.length === 0 && result.suggestions.length === 0) { + lines.push('[OK] Skill is valid'); + } else { + lines.push('[OK] Skill is valid (with warnings/suggestions)'); + } + } + + return lines.join('\n'); +}; diff --git a/packages/opencode-skills/src/validation/index.ts b/packages/opencode-skills/src/validation/index.ts new file mode 100644 index 0000000..68cc42f --- /dev/null +++ b/packages/opencode-skills/src/validation/index.ts @@ -0,0 +1,3 @@ +export { formatValidationResult } from './format-validation-result'; +export type { ValidationError, ValidationResult, ValidationSuggestion, ValidationWarning } from './types'; +export { validateSkill } from './validate-skill'; diff --git a/packages/opencode-skills/src/validation/skill-validator.test.ts b/packages/opencode-skills/src/validation/skill-validator.test.ts new file mode 100644 index 0000000..9c87418 --- /dev/null +++ b/packages/opencode-skills/src/validation/skill-validator.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'bun:test'; + +import type { Skill } from '../types'; + +import { formatValidationResult, validateSkill } from './index'; + +describe('validateSkill', () => { + it('should validate skill with all required fields', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill that are very detailed', + whenToUseMe: 'Use when you need this', + instructions: 'Follow these steps', + checklist: ['Item 1', 'Item 2'], + license: 'MIT', + compatibility: 'opencode', + metadata: { + category: 'workflow', + }, + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it('should error on missing name', () => { + const skill = { + description: 'A test skill', + } as Skill; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'name')).toBe(true); + }); + + it('should error on invalid name format', () => { + const skill: Skill = { + name: 'InvalidName', + description: 'A test skill', + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'name' && e.message.includes('kebab-case'))).toBe(true); + }); + + it('should error on missing description', () => { + const skill = { + name: 'test-skill', + } as Skill; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'description')).toBe(true); + }); + + it('should error on missing structured content fields', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'whatIDo')).toBe(true); + expect(result.errors.some((e) => e.field === 'whenToUseMe')).toBe(true); + expect(result.errors.some((e) => e.field === 'instructions')).toBe(true); + expect(result.errors.some((e) => e.field === 'checklist')).toBe(true); + }); + + it('should warn on deprecated content field', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + content: 'Legacy content', + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.field === 'content' && w.message.includes('deprecated'))).toBe(true); + }); + + it('should error on missing whatIDo', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item'], + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'whatIDo')).toBe(true); + }); + + it('should error on missing whenToUseMe', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'What', + instructions: 'How', + checklist: ['Item'], + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'whenToUseMe')).toBe(true); + }); + + it('should error on missing instructions', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'What', + whenToUseMe: 'When', + checklist: ['Item'], + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'instructions')).toBe(true); + }); + + it('should error on empty checklist', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'What', + whenToUseMe: 'When', + instructions: 'How', + checklist: [], + }; + + const result = validateSkill(skill); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'checklist')).toBe(true); + }); + + it('should warn on missing license', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item'], + }; + + const result = validateSkill(skill); + + expect(result.warnings.some((w) => w.field === 'license')).toBe(true); + }); + + it('should warn on missing compatibility', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item'], + }; + + const result = validateSkill(skill); + + expect(result.warnings.some((w) => w.field === 'compatibility')).toBe(true); + }); + + it('should warn on missing metadata.category', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item'], + }; + + const result = validateSkill(skill); + + expect(result.warnings.some((w) => w.field === 'metadata.category')).toBe(true); + }); + + it('should suggest expanding short whatIDo', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Short', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item 1', 'Item 2'], + }; + + const result = validateSkill(skill); + + expect(result.suggestions.some((s) => s.field === 'whatIDo')).toBe(true); + }); + + it('should suggest adding more checklist items', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill that are very detailed', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Single item'], + }; + + const result = validateSkill(skill); + + expect(result.suggestions.some((s) => s.field === 'checklist')).toBe(true); + }); + + it('should fail in strict mode with warnings', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item 1', 'Item 2'], + }; + + const result = validateSkill(skill, true); + + expect(result.valid).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + }); +}); + +describe('formatValidationResult', () => { + it('should format validation result with errors', () => { + const skill = { + name: 'test-skill', + } as Skill; + + const result = validateSkill(skill); + const formatted = formatValidationResult(result, 'test-skill'); + + expect(formatted).toContain('Validation Results for "test-skill"'); + expect(formatted).toContain('[ERROR]'); + expect(formatted).toContain('description: Description is required'); + expect(formatted).toContain('Skill validation failed'); + }); + + it('should format validation result with warnings', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item 1', 'Item 2'], + }; + + const result = validateSkill(skill); + const formatted = formatValidationResult(result, 'test-skill'); + + expect(formatted).toContain('[WARNING]'); + expect(formatted).toContain('license'); + expect(formatted).toContain('[OK] Skill is valid'); + }); + + it('should format validation result for valid skill', () => { + const skill: Skill = { + name: 'test-skill', + description: 'A test skill', + whatIDo: 'Core capabilities of the skill that are very detailed', + whenToUseMe: 'When', + instructions: 'How', + checklist: ['Item 1', 'Item 2'], + license: 'MIT', + compatibility: 'opencode', + metadata: { + category: 'workflow', + }, + }; + + const result = validateSkill(skill); + const formatted = formatValidationResult(result, 'test-skill'); + + expect(formatted).toContain('[OK] Skill is valid'); + expect(formatted).not.toContain('❌ Errors'); + }); +}); diff --git a/packages/opencode-skills/src/validation/types.ts b/packages/opencode-skills/src/validation/types.ts new file mode 100644 index 0000000..3432102 --- /dev/null +++ b/packages/opencode-skills/src/validation/types.ts @@ -0,0 +1,42 @@ +/** + * Validation Type Definitions + * + * Type definitions for skill validation results and errors. + */ + +/** + * Validation result with errors, warnings, and suggestions + */ +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + warnings: ValidationWarning[]; + suggestions: ValidationSuggestion[]; +} + +/** + * Validation error (blocking) + */ +export interface ValidationError { + field: string; + message: string; + severity: 'error'; +} + +/** + * Validation warning (non-blocking) + */ +export interface ValidationWarning { + field: string; + message: string; + severity: 'warning'; +} + +/** + * Validation suggestion (informational) + */ +export interface ValidationSuggestion { + field: string; + message: string; + severity: 'info'; +} diff --git a/packages/opencode-skills/src/validation/validate-skill.ts b/packages/opencode-skills/src/validation/validate-skill.ts new file mode 100644 index 0000000..a0731c7 --- /dev/null +++ b/packages/opencode-skills/src/validation/validate-skill.ts @@ -0,0 +1,135 @@ +import type { Skill, ValidationError, ValidationResult, ValidationSuggestion, ValidationWarning } from '../types'; + +export const validateSkill = (skill: Skill, _strictMode = false): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + const suggestions: ValidationSuggestion[] = []; + + // Validate name + if (!skill.name || skill.name.trim() === '') { + errors.push({ + field: 'name', + message: 'Name is required', + code: 'MISSING_NAME', + }); + } else if (!/^[a-z0-9-]+$/.test(skill.name)) { + errors.push({ + field: 'name', + message: 'Name must be in kebab-case format (lowercase letters, numbers, and hyphens only)', + code: 'INVALID_NAME_FORMAT', + }); + } + + // Validate description + if (!skill.description || skill.description.trim() === '') { + errors.push({ + field: 'description', + message: 'Description is required', + code: 'MISSING_DESCRIPTION', + }); + } else if (skill.description.length < 10) { + warnings.push({ + field: 'description', + message: 'Description should be at least 10 characters', + code: 'SHORT_DESCRIPTION', + }); + } + + // Check for legacy content field (v1 format) + const hasLegacyContent = skill.content && skill.content.trim() !== ''; + + // If using legacy content, warn about deprecation but don't require v2 fields + if (hasLegacyContent) { + warnings.push({ + field: 'content', + message: 'content field is deprecated. Use whatIDo, whenToUseMe, instructions, checklist instead', + code: 'DEPRECATED_CONTENT', + }); + } + + // Validate v2 structured content fields (required only if not using legacy content) + if (!hasLegacyContent) { + // Validate whatIDo (required in v2) + if (!skill.whatIDo || skill.whatIDo.trim() === '') { + errors.push({ + field: 'whatIDo', + message: 'whatIDo is required (Core capabilities section)', + code: 'MISSING_WHAT_I_DO', + }); + } else if (skill.whatIDo.length < 20) { + suggestions.push({ + field: 'whatIDo', + message: 'Consider expanding whatIDo to be more descriptive (at least 20 characters)', + }); + } + + // Validate whenToUseMe (required in v2) + if (!skill.whenToUseMe || skill.whenToUseMe.trim() === '') { + errors.push({ + field: 'whenToUseMe', + message: 'whenToUseMe is required (When to use me section)', + code: 'MISSING_WHEN_TO_USE', + }); + } + + // Validate instructions (required in v2) + if (!skill.instructions || skill.instructions.trim() === '') { + errors.push({ + field: 'instructions', + message: 'instructions is required (Instructions section)', + code: 'MISSING_INSTRUCTIONS', + }); + } + + // Validate checklist (required in v2) + if (!skill.checklist || skill.checklist.length === 0) { + errors.push({ + field: 'checklist', + message: 'checklist is required with at least one item', + code: 'MISSING_CHECKLIST', + }); + } else if (skill.checklist.length === 1) { + suggestions.push({ + field: 'checklist', + message: 'Consider adding more checklist items for better verification', + }); + } + } + + // Validate license (required in v2) + if (!skill.license || skill.license.trim() === '') { + warnings.push({ + field: 'license', + message: 'license is recommended (e.g., MIT)', + code: 'MISSING_LICENSE', + }); + } + + // Validate compatibility (required in v2) + if (!skill.compatibility || skill.compatibility.trim() === '') { + warnings.push({ + field: 'compatibility', + message: 'compatibility is recommended (e.g., opencode)', + code: 'MISSING_COMPATIBILITY', + }); + } + + // Validate metadata.category + if (!skill.metadata?.category || skill.metadata.category.trim() === '') { + warnings.push({ + field: 'metadata.category', + message: 'metadata.category is recommended', + code: 'MISSING_CATEGORY', + }); + } + + // Note: strictMode parameter kept for API compatibility + // Currently doesn't change validation behavior + + return { + valid: errors.length === 0, + errors, + warnings, + suggestions, + }; +}; diff --git a/packages/opencode-skills/tsconfig.json b/packages/opencode-skills/tsconfig.json new file mode 100644 index 0000000..1f1ea0c --- /dev/null +++ b/packages/opencode-skills/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "moduleDetection": "force", + "noImplicitAny": false, + "types": ["bun-types"] + }, + "include": ["**/*.ts", "**/*.js"], + "exclude": ["node_modules", "docs", ".astro-build"] +} diff --git a/packages/opencode-skills/tsconfig.test.json b/packages/opencode-skills/tsconfig.test.json new file mode 100644 index 0000000..dd78eb0 --- /dev/null +++ b/packages/opencode-skills/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": false, + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "typeRoots": ["./types", "./node_modules", "./node_modules/@types"], + "files": ["../../types/bun-test-shim.d.ts"], + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.spec.ts", "src/**/*.test.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/opencode-skills/types/bun-test.d.ts b/packages/opencode-skills/types/bun-test.d.ts new file mode 100644 index 0000000..3cabbcf --- /dev/null +++ b/packages/opencode-skills/types/bun-test.d.ts @@ -0,0 +1,28 @@ +declare module 'bun:test' { + export const describe: any; + export const it: any; + export const test: any; + export const expect: any; + export const beforeAll: any; + export const afterAll: any; + export const beforeEach: any; + export const afterEach: any; + export const vi: any; + export const mock: any; + export const setSystemTime: any; + export const spyOn: any; + export default {}; +} + +declare global { + const describe: any; + const it: any; + const test: any; + const expect: any; + const beforeAll: any; + const afterAll: any; + const beforeEach: any; + const afterEach: any; +} + +export {}; diff --git a/packages/opencode-skills/types/import-meta.d.ts b/packages/opencode-skills/types/import-meta.d.ts new file mode 100644 index 0000000..38ed951 --- /dev/null +++ b/packages/opencode-skills/types/import-meta.d.ts @@ -0,0 +1,7 @@ +declare global { + interface ImportMeta { + readonly dir: string; + } +} + +export {}; diff --git a/tools/executors/package.json b/tools/executors/package.json index ca79da1..dfe6401 100644 --- a/tools/executors/package.json +++ b/tools/executors/package.json @@ -1,7 +1,9 @@ { - "name": "@pantheon-org/tools", - "version": "1.0.0", + "name": "@pantheon-org/executors", + "version": "0.1.0", "private": true, - "main": "../src/index.ts", - "executors": "./executors.json" + "dependencies": { + "@pantheon-org/opencode-skills": "workspace:*", + "glob": "^10.3.10" + } } diff --git a/tools/executors/validate-skill-md/executor.ts b/tools/executors/validate-skill-md/executor.ts new file mode 100644 index 0000000..aca58ca --- /dev/null +++ b/tools/executors/validate-skill-md/executor.ts @@ -0,0 +1,104 @@ +import { readFile } from 'node:fs/promises'; +import type { ExecutorContext } from '@nx/devkit'; +import { formatValidationResult, markdownToSkill, validateSkill } from '@pantheon-org/opencode-skills'; +import { glob } from 'glob'; + +export interface ValidateSkillMdExecutorOptions { + pattern: string; + strict: boolean; + format: 'text' | 'json'; +} + +export default async ( + options: ValidateSkillMdExecutorOptions, + context: ExecutorContext, +): Promise<{ success: boolean; error?: string }> => { + try { + const projectName = context.projectName; + if (!projectName) { + return { success: false, error: 'No project name in context' }; + } + + const projectConfig = context.projectGraph?.nodes[projectName]; + if (!projectConfig) { + return { success: false, error: 'Project configuration not found' }; + } + + const projectRoot = projectConfig.data.root; + const searchPattern = `${context.root}/${projectRoot}/${options.pattern}`; + + // Find all SKILL.md files + const files = await glob(searchPattern); + + if (files.length === 0) { + console.log(`✅ No SKILL.md files found matching pattern: ${options.pattern}`); + return { success: true }; + } + + let totalFiles = 0; + let valid = 0; + let withErrors = 0; + let withWarnings = 0; + + // Validate each file + for (const filePath of files) { + totalFiles++; + const markdown = await readFile(filePath, 'utf-8'); + + try { + const skill = markdownToSkill(markdown); + const result = validateSkill(skill, options.strict); + + if (result.valid) { + valid++; + } + if (result.errors.length > 0) { + withErrors++; + } + if (result.warnings.length > 0) { + withWarnings++; + } + + // Output results + if (options.format === 'text') { + console.log(`\n📄 File: ${filePath}`); + console.log(formatValidationResult(result, skill.name)); + } + } catch (parseError) { + withErrors++; + console.error(`\n❌ Failed to parse ${filePath}:`); + console.error(` ${(parseError as Error).message}`); + } + } + + // Summary + if (options.format === 'text') { + console.log(`\n${'='.repeat(50)}`); + console.log('📊 Validation Summary'); + console.log('='.repeat(50)); + console.log(`Total files: ${totalFiles}`); + console.log(`✅ Valid: ${valid}`); + console.log(`❌ With errors: ${withErrors}`); + console.log(`⚠️ With warnings: ${withWarnings}`); + } + + const shouldFail = withErrors > 0 || (options.strict && withWarnings > 0); + + if (shouldFail) { + const errors = withErrors > 0 ? `${withErrors} file(s) with errors` : ''; + const warnings = withWarnings > 0 && options.strict ? `${withWarnings} file(s) with warnings (strict mode)` : ''; + const reason = [errors, warnings].filter(Boolean).join(', '); + return { + success: false, + error: `SKILL.md validation failed: ${reason}. Run with --verbose for details.`, + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: `Failed to validate SKILL.md files: ${(error as Error).message}`, + }; + } +}; diff --git a/tools/executors/validate-skill-md/schema.d.ts b/tools/executors/validate-skill-md/schema.d.ts new file mode 100644 index 0000000..72746c2 --- /dev/null +++ b/tools/executors/validate-skill-md/schema.d.ts @@ -0,0 +1,5 @@ +export interface ValidateSkillMdExecutorOptions { + pattern: string; + strict: boolean; + format: 'text' | 'json'; +} diff --git a/tools/executors/validate-skill-md/schema.json b/tools/executors/validate-skill-md/schema.json new file mode 100644 index 0000000..f19e7cf --- /dev/null +++ b/tools/executors/validate-skill-md/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "Validate SKILL.md Files", + "description": "Validate markdown SKILL.md files with YAML frontmatter", + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern for SKILL.md files", + "default": ".opencode/skills/*/SKILL.md" + }, + "strict": { + "type": "boolean", + "description": "Enable strict validation (fail on warnings)", + "default": false + }, + "format": { + "type": "string", + "description": "Output format", + "enum": ["text", "json"], + "default": "text" + } + } +} diff --git a/tools/executors/validate-skills/executor.ts b/tools/executors/validate-skills/executor.ts new file mode 100644 index 0000000..0048df7 --- /dev/null +++ b/tools/executors/validate-skills/executor.ts @@ -0,0 +1,90 @@ +import type { ExecutorContext } from '@nx/devkit'; +import { + formatValidationResult, + type Skill, + type ValidationResult, + validateSkill, +} from '@pantheon-org/opencode-skills'; + +export interface ValidateSkillsExecutorOptions { + skillsPath: string; + strict: boolean; + format: 'text' | 'json'; +} + +export default async ( + options: ValidateSkillsExecutorOptions, + context: ExecutorContext, +): Promise<{ success: boolean; error?: string }> => { + try { + const projectName = context.projectName; + if (!projectName) { + return { success: false, error: 'No project name in context' }; + } + + const projectConfig = context.projectGraph?.nodes[projectName]; + if (!projectConfig) { + return { success: false, error: 'Project configuration not found' }; + } + + const projectRoot = projectConfig.data.root; + const skillsPath = `${context.root}/${projectRoot}/${options.skillsPath}`; + + // Dynamically import skills + const skillsModule = await import(skillsPath); + const skills: Record = skillsModule.default || skillsModule; + + const results = new Map(); + let totalSkills = 0; + let valid = 0; + let withErrors = 0; + let withWarnings = 0; + + // Validate each skill + for (const [_exportName, skill] of Object.entries(skills)) { + if (typeof skill === 'object' && skill.name) { + totalSkills++; + const result = validateSkill(skill, options.strict); + results.set(skill.name, result); + + if (result.valid) { + valid++; + } + if (result.errors.length > 0) { + withErrors++; + } + if (result.warnings.length > 0) { + withWarnings++; + } + + // Output individual results + if (options.format === 'text') { + console.log(formatValidationResult(result, skill.name)); + } + } + } + + // Summary + if (options.format === 'text') { + console.log(`\n${'='.repeat(50)}`); + console.log('📊 Validation Summary'); + console.log('='.repeat(50)); + console.log(`Total skills: ${totalSkills}`); + console.log(`✅ Valid: ${valid}`); + console.log(`❌ With errors: ${withErrors}`); + console.log(`⚠️ With warnings: ${withWarnings}`); + } else { + console.log(JSON.stringify({ totalSkills, valid, withErrors, withWarnings }, null, 2)); + } + + // Fail if errors found (or warnings in strict mode) + const shouldFail = withErrors > 0 || (options.strict && withWarnings > 0); + + return { success: !shouldFail }; + } catch (error) { + return { + success: false, + error: `Failed to validate skills: ${(error as Error).message}`, + }; + } +}; diff --git a/tools/executors/validate-skills/schema.d.ts b/tools/executors/validate-skills/schema.d.ts new file mode 100644 index 0000000..49e0f1b --- /dev/null +++ b/tools/executors/validate-skills/schema.d.ts @@ -0,0 +1,5 @@ +export interface ValidateSkillsExecutorOptions { + skillsPath: string; + strict: boolean; + format: 'text' | 'json'; +} diff --git a/tools/executors/validate-skills/schema.json b/tools/executors/validate-skills/schema.json new file mode 100644 index 0000000..3699678 --- /dev/null +++ b/tools/executors/validate-skills/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "Validate Skills", + "description": "Validate TypeScript skill definitions in a package", + "type": "object", + "properties": { + "skillsPath": { + "type": "string", + "description": "Path to skills directory or index file", + "default": "src/skills/index.ts" + }, + "strict": { + "type": "boolean", + "description": "Enable strict validation (fail on warnings)", + "default": false + }, + "format": { + "type": "string", + "description": "Output format", + "enum": ["text", "json"], + "default": "text" + } + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 335e6b4..9968132 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,9 @@ "@pantheon-org/opencode-config": [ "packages/opencode-config/src/index.ts" ], + "@pantheon-org/opencode-skills": [ + "packages/opencode-skills/src/index.ts" + ], "@pantheon-org/opencode-warcraft-notifications-plugin": [ "packages/opencode-warcraft-notifications-plugin/src/index.ts" ],