diff --git a/.github/workflows/opencode-tools-ci.yml b/.github/workflows/opencode-tools-ci.yml new file mode 100644 index 0000000..f995cc2 --- /dev/null +++ b/.github/workflows/opencode-tools-ci.yml @@ -0,0 +1,150 @@ +name: opencode-tools-ci + +on: + push: + branches: + - main + paths: + - "packages/opencode-tools/**" + - "bootstrap/opencode/**" + - "scripts/**" + - "schemas/**" + - "dotfiles/ai-agents/opencode/tools/**" + - ".github/workflows/opencode-tools-ci.yml" + pull_request: + paths: + - "packages/opencode-tools/**" + - "bootstrap/opencode/**" + - "scripts/**" + - "schemas/**" + - "dotfiles/ai-agents/opencode/tools/**" + - ".github/workflows/opencode-tools-ci.yml" + workflow_dispatch: + +jobs: + build-tools: + name: Build opencode-tools + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: packages/opencode-tools + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: packages/opencode-tools/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type-check + run: npm run lint + + - name: Build + run: npm run build + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: opencode-tools-dist + path: packages/opencode-tools/dist/ + retention-days: 7 + + smoke-test: + name: Smoke test tools + runs-on: ubuntu-latest + needs: build-tools + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: opencode-tools-dist + path: packages/opencode-tools/dist/ + + - name: Run smoke tests + run: bash scripts/smoke-opencode-tools.sh + + doctor-check: + name: Doctor check + runs-on: ubuntu-latest + needs: build-tools + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: opencode-tools-dist + path: packages/opencode-tools/dist/ + + - name: Run doctor (JSON mode) + run: bash bootstrap/opencode/doctor.sh --json + + validate-skills: + name: Validate skills + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install dependencies + run: pip install pyyaml + + - name: Validate skills + run: bash scripts/validate-skills.sh + + validate-config-schema: + name: Validate config schema + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Validate opencode.jsonc (from dotfiles) + run: | + bash scripts/validate-opencode-config.sh \ + dotfiles/ai-agents/opencode/config.jsonc diff --git a/.gitignore b/.gitignore index 8064d8a..e14d41b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .backup/ .idea/ +.bootstrap-logs/ -**/node_modules/ \ No newline at end of file +**/node_modules/ +packages/*/dist/ \ No newline at end of file diff --git a/bootstrap/opencode/doctor.ps1 b/bootstrap/opencode/doctor.ps1 new file mode 100644 index 0000000..67868c9 --- /dev/null +++ b/bootstrap/opencode/doctor.ps1 @@ -0,0 +1,191 @@ +#Requires -Version 7 +<# +.SYNOPSIS + Doctor/health check for the OpenCode AI agent platform (Windows/PowerShell). + +.DESCRIPTION + Validates config, tools, skills, agents, and runtime environment. + +.PARAMETER Json + Emit a JSON diagnostics report to stdout (suitable for CI). +#> +[CmdletBinding()] +param( + [switch]$Json +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OpenCodeConfigDir = Join-Path $env:APPDATA 'OpenCode' +$OpenCodeConfigFile = Join-Path $OpenCodeConfigDir 'opencode.jsonc' +$ToolsSrc = Join-Path $RepoRoot 'packages\opencode-tools\src' +$DotfilesToolsDir = Join-Path $RepoRoot 'dotfiles\ai-agents\opencode\tools' +$SkillsDir = Join-Path $RepoRoot 'dotfiles\ai-agents\opencode\skills' +$AgentsDir = Join-Path $RepoRoot 'dotfiles\ai-agents\opencode\agents' +$AgentsMd = Join-Path $RepoRoot 'dotfiles\ai-agents\opencode\AGENTS.md' +$Dist = Join-Path $RepoRoot 'packages\opencode-tools\dist' + +function Write-Pass { param($Msg) Write-Host " $([char]0x2713) $Msg" -ForegroundColor Green } +function Write-Warn { param($Msg) Write-Host " ! $Msg" -ForegroundColor Yellow } +function Write-Fail { param($Msg) Write-Host " x $Msg" -ForegroundColor Red } +function Write-Section { param($Name) Write-Host "`n── $Name ──" } + +$Passed = 0; $Warned = 0; $Failed = 0 +$JsonResults = [System.Collections.Generic.List[hashtable]]::new() + +function Record { param($Name, $Status, $Message, $Detail = '') + switch ($Status) { + 'ok' { $script:Passed++; Write-Pass "${Name}: ${Message}" } + 'warn' { $script:Warned++; Write-Warn "${Name}: ${Message}" } + 'fail' { $script:Failed++; Write-Fail "${Name}: ${Message}" } + } + if ($Json) { + $JsonResults.Add(@{ name=$Name; status=$Status; message=$Message; detail=$Detail }) | Out-Null + } +} + +# Config +Write-Section "Config" + +if (Test-Path $OpenCodeConfigFile) { + Record "config-file" "ok" "opencode.jsonc exists" + try { + $raw = Get-Content $OpenCodeConfigFile -Raw + $stripped = $raw -replace '//[^\n]*','' -replace '/\*[\s\S]*?\*/','' + $null = $stripped | ConvertFrom-Json + Record "config-parse" "ok" "opencode.jsonc parses successfully" + } catch { + Record "config-parse" "fail" "opencode.jsonc has JSON parse errors" "$_" + } +} else { + Record "config-file" "warn" "opencode.jsonc not found at $OpenCodeConfigFile" "Run install.ps1 after dotfiles are linked" + Record "config-parse" "warn" "skipped (config not found)" +} + +# Tools +Write-Section "Tools" + +if (Test-Path $ToolsSrc) { + Record "tools-src-dir" "ok" "packages/opencode-tools/src exists" +} else { + Record "tools-src-dir" "fail" "packages/opencode-tools/src not found" "Expected: $ToolsSrc" +} + +foreach ($toolName in @('patch-validator','analysis-cache')) { + $srcFile = Join-Path $ToolsSrc "${toolName}.ts" + if (Test-Path $srcFile) { + Record "tool-src-$toolName" "ok" "${toolName}.ts present in package src" + } else { + Record "tool-src-$toolName" "fail" "${toolName}.ts missing from package src" "Expected: $srcFile" + } + + $dotfilesFile = Join-Path $DotfilesToolsDir "${toolName}.ts" + if (Test-Path $dotfilesFile) { + Record "tool-dotfiles-$toolName" "ok" "${toolName}.ts present in dotfiles/tools" + } else { + Record "tool-dotfiles-$toolName" "warn" "${toolName}.ts not in dotfiles/tools (run install.ps1)" "Expected: $dotfilesFile" + } + + $distFile = Join-Path $Dist "${toolName}.js" + if (Test-Path $distFile) { + Record "tool-dist-$toolName" "ok" "${toolName}.js compiled" + } else { + Record "tool-dist-$toolName" "warn" "${toolName}.js not built (run: npm run build)" "Expected: $distFile" + } +} + +$pvDist = Join-Path $Dist 'patch-validator.js' +if (Test-Path $pvDist) { + $pvPath = $pvDist.Replace('\','/') + $exitCode = & node --input-type=module ` + -e "import('file:///$pvPath').then(()=>process.exit(0)).catch(()=>process.exit(1))" ` + 2>$null; $exitCode = $LASTEXITCODE + if ($exitCode -eq 0) { + Record "tool-load-patch-validator" "ok" "patch-validator loads successfully" + } else { + Record "tool-load-patch-validator" "fail" "patch-validator failed to load from dist" "Run: npm run build" + } +} else { + Record "tool-load-patch-validator" "warn" "load test skipped (dist not built)" +} + +# Skills +Write-Section "Skills" + +if (Test-Path $SkillsDir) { + $skillFiles = Get-ChildItem -Path $SkillsDir -Recurse -Filter 'SKILL.md' + $skillCount = $skillFiles.Count + $skillErrors = 0 + foreach ($f in $skillFiles) { + $first = (Get-Content $f.FullName -TotalCount 1) + if ($first -ne '---') { $skillErrors++ } + } + if ($skillErrors -eq 0) { + Record "skills-frontmatter" "ok" "${skillCount} SKILL.md files have valid frontmatter start" + } else { + Record "skills-frontmatter" "fail" "${skillErrors}/${skillCount} SKILL.md files missing '---' frontmatter" + } +} else { + Record "skills-dir" "fail" "skills directory not found: $SkillsDir" +} + +# Agents +Write-Section "Agents" + +if (Test-Path $AgentsDir) { + $agentCount = (Get-ChildItem -Path $AgentsDir -Filter '*.md' -Recurse).Count + Record "agents-dir" "ok" "${agentCount} agent definition(s) found" +} else { + Record "agents-dir" "warn" "agents directory not found: $AgentsDir" +} +if (Test-Path $AgentsMd) { + Record "agents-md" "ok" "AGENTS.md exists" +} else { + Record "agents-md" "warn" "AGENTS.md not found" +} + +# Runtime +Write-Section "Runtime" + +if (Get-Command node -ErrorAction SilentlyContinue) { + $nv = node --version + $nm = [int](($nv -replace '^v','').Split('.')[0]) + if ($nm -ge 20) { Record "runtime-node" "ok" "node $nv" } + else { Record "runtime-node" "warn" "node $nv (recommend >=20)" } +} else { + Record "runtime-node" "fail" "node not found" +} +if (Get-Command npm -ErrorAction SilentlyContinue) { + Record "runtime-npm" "ok" "npm $(npm --version)" +} else { + Record "runtime-npm" "warn" "npm not found" +} + +# Summary +Write-Section "Summary" + +$overall = if ($Failed -gt 0) { 'fail' } elseif ($Warned -gt 0) { 'warn' } else { 'ok' } + +if ($Json) { + $report = @{ + tool = 'opencode-doctor' + version = '1.0.0' + timestamp = (Get-Date -Format 'o') + overall = $overall + passed = $Passed + warned = $Warned + failed = $Failed + checks = $JsonResults + } + $report | ConvertTo-Json -Depth 5 +} + +Write-Host " passed: $Passed" +Write-Host " warned: $Warned" +Write-Host " failed: $Failed" +Write-Host " overall: $overall" + +if ($overall -eq 'fail') { exit 1 } diff --git a/bootstrap/opencode/doctor.sh b/bootstrap/opencode/doctor.sh new file mode 100644 index 0000000..b29e7de --- /dev/null +++ b/bootstrap/opencode/doctor.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# bootstrap/opencode/doctor.sh +# +# Health/verification check for the OpenCode AI agent platform. +# +# Usage: +# bash bootstrap/opencode/doctor.sh [--json] +# +# Options: +# --json Emit a JSON diagnostics report to stdout (suitable for CI) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bootstrap/opencode/lib/paths.sh +source "${SCRIPT_DIR}/lib/paths.sh" +# shellcheck source=bootstrap/opencode/lib/checks.sh +source "${SCRIPT_DIR}/lib/checks.sh" + +JSON_MODE=false +if [[ "${1:-}" == "--json" ]]; then + JSON_MODE=true + # In JSON mode, redirect human-readable output to stderr + exec 3>&1 1>&2 +fi + +CHECKS_PASSED=0 +CHECKS_WARNED=0 +CHECKS_FAILED=0 +declare -a JSON_RESULTS=() + +# record [detail] +record() { + local name="$1" status="$2" message="$3" detail="${4:-}" + case "$status" in + ok) CHECKS_PASSED=$(( CHECKS_PASSED + 1 )); _pass "${name}: ${message}" ;; + warn) CHECKS_WARNED=$(( CHECKS_WARNED + 1 )); _warn "${name}: ${message}" ;; + fail) CHECKS_FAILED=$(( CHECKS_FAILED + 1 )); _fail "${name}: ${message}" ;; + esac + if [[ "$JSON_MODE" == "true" ]]; then + local escaped_detail + escaped_detail="$(echo -n "$detail" | sed 's/"/\\"/g')" + JSON_RESULTS+=("{\"name\":\"${name}\",\"status\":\"${status}\",\"message\":\"${message}\",\"detail\":\"${escaped_detail}\"}") + fi +} + +######################################################################## +# CONFIG CHECKS +######################################################################## +echo "" +echo "── Config ──" + +if [[ -f "${OPENCODE_CONFIG_FILE}" ]]; then + record "config-file" "ok" "opencode.jsonc exists" + if node -e " + function parseJsonc(text) { + var result = ''; var inString = false; var i = 0; + while (i < text.length) { + var ch = text[i]; + if (inString) { + if (ch === '\\\\') { result += ch + (text[i+1]||''); i += 2; continue; } + if (ch === '\"') inString = false; + result += ch; i++; + } else { + if (ch === '\"') { inString = true; result += ch; i++; } + else if (ch === '/' && text[i+1] === '/') { while (i < text.length && text[i] !== '\n') i++; } + else if (ch === '/' && text[i+1] === '*') { i+=2; while (i < text.length && !(text[i]==='*' && text[i+1]==='/')) i++; i+=2; } + else { result += ch; i++; } + } + } + return JSON.parse(result.replace(/,(\s*[}\]])/g, '\$1')); + } + var fs=require('fs'); + var raw=fs.readFileSync('${OPENCODE_CONFIG_FILE}','utf8'); + parseJsonc(raw); process.exit(0); + " 2>/dev/null; then + record "config-parse" "ok" "opencode.jsonc parses successfully" + else + record "config-parse" "fail" "opencode.jsonc has JSON parse errors" \ + "Run: node -e \"require('fs').readFileSync('${OPENCODE_CONFIG_FILE}','utf8')\"" + fi +else + record "config-file" "warn" "opencode.jsonc not found at ${OPENCODE_CONFIG_FILE}" \ + "Run install.sh after dotfiles are linked" + record "config-parse" "warn" "skipped (config not found)" +fi + +######################################################################## +# TOOLS CHECKS +######################################################################## +echo "" +echo "── Tools ──" + +TOOLS_PKG="${REPO_ROOT}/packages/opencode-tools" +TOOLS_SRC="${OPENCODE_TOOLS_SRC}" +DIST="${TOOLS_PKG}/dist" + +if [[ -d "${TOOLS_SRC}" ]]; then + record "tools-src-dir" "ok" "packages/opencode-tools/src exists" +else + record "tools-src-dir" "fail" "packages/opencode-tools/src not found" \ + "Expected: ${TOOLS_SRC}" +fi + +for tool_name in patch-validator analysis-cache; do + src_file="${TOOLS_SRC}/${tool_name}.ts" + if [[ -f "$src_file" ]]; then + record "tool-src-${tool_name}" "ok" "${tool_name}.ts present in package src" + else + record "tool-src-${tool_name}" "fail" "${tool_name}.ts missing from package src" \ + "Expected: ${src_file}" + fi + + dotfiles_file="${DOTFILES_TOOLS_DIR}/${tool_name}.ts" + if [[ -f "$dotfiles_file" ]]; then + record "tool-dotfiles-${tool_name}" "ok" "${tool_name}.ts present in dotfiles/tools" + else + record "tool-dotfiles-${tool_name}" "warn" "${tool_name}.ts not in dotfiles/tools (run install.sh)" \ + "Expected: ${dotfiles_file}" + fi + + dist_file="${DIST}/${tool_name}.js" + if [[ -f "$dist_file" ]]; then + record "tool-dist-${tool_name}" "ok" "${tool_name}.js compiled" + else + record "tool-dist-${tool_name}" "warn" "${tool_name}.js not built (run: npm run build)" \ + "Expected: ${dist_file}" + fi +done + +# Load test +if [[ -f "${DIST}/patch-validator.js" ]]; then + if node --input-type=module \ + -e "import('file://${DIST}/patch-validator.js').then(()=>process.exit(0)).catch(()=>process.exit(1))" \ + 2>/dev/null; then + record "tool-load-patch-validator" "ok" "patch-validator loads successfully" + else + record "tool-load-patch-validator" "fail" "patch-validator failed to load from dist" \ + "Run: npm run build in packages/opencode-tools" + fi +else + record "tool-load-patch-validator" "warn" "load test skipped (dist not built)" +fi + +######################################################################## +# SKILLS CHECKS +######################################################################## +echo "" +echo "── Skills ──" + +SKILLS_DIR="${DOTFILES_OPENCODE_DIR}/skills" +if [[ -d "${SKILLS_DIR}" ]]; then + skill_count=0 + skill_errors=0 + while IFS= read -r -d '' skill_file; do + skill_count=$(( skill_count + 1 )) + first_line="$(head -1 "$skill_file" 2>/dev/null || echo "")" + if [[ "$first_line" != "---" ]]; then + skill_errors=$(( skill_errors + 1 )) + fi + done < <(find "${SKILLS_DIR}" -name "SKILL.md" -print0 2>/dev/null) + if (( skill_errors == 0 )); then + record "skills-frontmatter" "ok" "${skill_count} SKILL.md files have valid frontmatter start" + else + record "skills-frontmatter" "fail" "${skill_errors}/${skill_count} SKILL.md files missing '---' frontmatter" + fi +else + record "skills-dir" "fail" "skills directory not found: ${SKILLS_DIR}" +fi + +######################################################################## +# AGENTS CHECKS +######################################################################## +echo "" +echo "── Agents ──" + +AGENTS_DIR="${DOTFILES_OPENCODE_DIR}/agents" +if [[ -d "${AGENTS_DIR}" ]]; then + agent_count="$(find "${AGENTS_DIR}" -name "*.md" 2>/dev/null | wc -l)" + record "agents-dir" "ok" "${agent_count} agent definition(s) found" +else + record "agents-dir" "warn" "agents directory not found: ${AGENTS_DIR}" +fi + +AGENTS_MD="${DOTFILES_OPENCODE_DIR}/AGENTS.md" +if [[ -f "${AGENTS_MD}" ]]; then + record "agents-md" "ok" "AGENTS.md exists" +else + record "agents-md" "warn" "AGENTS.md not found" +fi + +######################################################################## +# RUNTIME COMMANDS (node, npm) +######################################################################## +echo "" +echo "── Runtime ──" + +if command -v node &>/dev/null; then + node_ver="$(node --version)" + node_major="$(echo "$node_ver" | sed 's/v//' | cut -d. -f1)" + if (( node_major >= 20 )); then + record "runtime-node" "ok" "node ${node_ver}" + else + record "runtime-node" "warn" "node ${node_ver} (recommend >=20)" + fi +else + record "runtime-node" "fail" "node not found" +fi + +if command -v npm &>/dev/null; then + record "runtime-npm" "ok" "npm $(npm --version)" +else + record "runtime-npm" "warn" "npm not found" +fi + +######################################################################## +# SUMMARY / JSON OUTPUT +######################################################################## +echo "" +echo "── Summary ──" + +OVERALL="ok" +if (( CHECKS_FAILED > 0 )); then OVERALL="fail" +elif (( CHECKS_WARNED > 0 )); then OVERALL="warn" +fi + +if [[ "$JSON_MODE" == "true" ]]; then + joined="$(IFS=,; echo "${JSON_RESULTS[*]}")" + # Write JSON to original stdout (fd 3) + echo "{\"tool\":\"opencode-doctor\",\"version\":\"1.0.0\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"overall\":\"${OVERALL}\",\"passed\":${CHECKS_PASSED},\"warned\":${CHECKS_WARNED},\"failed\":${CHECKS_FAILED},\"checks\":[${joined}]}" | \ + node -e "process.stdout.write(JSON.stringify(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')),null,2)+'\n')" >&3 +fi + +echo " passed: ${CHECKS_PASSED}" +echo " warned: ${CHECKS_WARNED}" +echo " failed: ${CHECKS_FAILED}" +echo " overall: ${OVERALL}" +echo "" + +if [[ "$OVERALL" == "fail" ]]; then + exit 1 +fi diff --git a/bootstrap/opencode/install.ps1 b/bootstrap/opencode/install.ps1 new file mode 100644 index 0000000..cf9526f --- /dev/null +++ b/bootstrap/opencode/install.ps1 @@ -0,0 +1,176 @@ +#Requires -Version 7 +<# +.SYNOPSIS + Bootstrap installer for the OpenCode AI agent platform (Windows/PowerShell). + +.DESCRIPTION + Stages: + 1. preflight - verify required tools and paths + 2. sync - copy tool sources to dotfiles tools dir + 3. build - compile TypeScript package + 4. validate - basic JSON config validation + 5. smoke - verify at least one tool is loadable + 6. summary - print results + +.PARAMETER DryRun + Show intended actions without making changes. +#> +[CmdletBinding()] +param( + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir) + +$OpenCodeConfigDir = Join-Path $env:APPDATA 'OpenCode' +$OpenCodeConfigFile = Join-Path $OpenCodeConfigDir 'opencode.jsonc' +$ToolsSrc = Join-Path $RepoRoot 'packages\opencode-tools\src' +$DotfilesToolsDir = Join-Path $RepoRoot 'dotfiles\ai-agents\opencode\tools' +$ToolsPkg = Join-Path $RepoRoot 'packages\opencode-tools' +$Dist = Join-Path $ToolsPkg 'dist' + +function Write-Pass { param($Msg) Write-Host " $(([char]0x2713)) $Msg" -ForegroundColor Green } +function Write-Warn { param($Msg) Write-Host " ! $Msg" -ForegroundColor Yellow } +function Write-Fail { param($Msg) Write-Host " x $Msg" -ForegroundColor Red } +function Write-Stage { param($N, $Name) Write-Host "`n── Stage ${N}: ${Name} ──" } + +$StageErrors = [System.Collections.Generic.List[string]]::new() + +function Fail-Stage { param($Stage, $Msg) + $StageErrors.Add($Stage) | Out-Null + Write-Host "`nERROR: Stage failed - $Stage" + Write-Host " $Msg" +} + +# Stage 1 - Preflight +Write-Stage 1 "preflight" + +$nodeOk = $false +if (Get-Command node -ErrorAction SilentlyContinue) { + $nodeVer = (node --version) -replace '^v','' + $nodeMajor = [int]($nodeVer.Split('.')[0]) + if ($nodeMajor -ge 20) { + Write-Pass "node $(node --version)" + $nodeOk = $true + } else { + Fail-Stage "preflight" "Node.js >=20 required, found $(node --version)" + } +} else { + Fail-Stage "preflight" "node not found" +} + +if (Get-Command npm -ErrorAction SilentlyContinue) { + Write-Pass "npm $(npm --version)" +} else { + Fail-Stage "preflight" "npm required" +} + +if (Test-Path $ToolsPkg) { + Write-Pass "packages/opencode-tools found" +} else { + Fail-Stage "preflight" "packages/opencode-tools not found at $ToolsPkg" +} + +if ($StageErrors.Count -gt 0) { + Write-Host "`nPreflight failed. Resolve the issues above and re-run." + exit 1 +} + +# Stage 2 - Sync sources +Write-Stage 2 "sync tool sources" + +if ($DryRun) { + Write-Host " [dry-run] would sync: $ToolsSrc\*.ts -> $DotfilesToolsDir\" +} else { + New-Item -ItemType Directory -Force -Path $DotfilesToolsDir | Out-Null + Get-ChildItem -Path $ToolsSrc -Filter '*.ts' | ForEach-Object { + $dest = Join-Path $DotfilesToolsDir $_.Name + $src = $_.FullName + if (Test-Path $dest) { + if ((Get-FileHash $src -Algorithm SHA256).Hash -eq (Get-FileHash $dest -Algorithm SHA256).Hash) { + Write-Host " up-to-date: $($_.Name)" + return + } + # Backup before overwrite + $backupDir = Join-Path $RepoRoot ".backup\opencode\$(Get-Date -Format 'yyyyMMdd-HHmmss')" + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + Copy-Item $dest (Join-Path $backupDir $_.Name) + } + Copy-Item $src $dest -Force + Write-Host " synced: $($_.Name)" + } +} + +# Stage 3 - Build +Write-Stage 3 "build tools package" + +if ($DryRun) { + Write-Host " [dry-run] would run: npm install && npm run build (in $ToolsPkg)" +} else { + Push-Location $ToolsPkg + try { + Write-Host " installing dependencies..." + npm install --prefer-offline --silent 2>&1 | Select-Object -Last 3 + Write-Host " compiling TypeScript..." + npm run build + Write-Host " build complete: dist/ generated" + } catch { + Fail-Stage "build" "TypeScript compilation failed: $_" + } finally { + Pop-Location + } +} + +# Stage 4 - Validate config +Write-Stage 4 "validate config" + +if (Test-Path $OpenCodeConfigFile) { + try { + $raw = Get-Content $OpenCodeConfigFile -Raw + $stripped = $raw -replace '//[^\n]*','' -replace '/\*[\s\S]*?\*/','' + $null = $stripped | ConvertFrom-Json + Write-Pass "opencode.jsonc parses" + } catch { + Write-Warn "opencode.jsonc has parse errors: $_" + } +} else { + Write-Warn "opencode.jsonc not found (run install.ps1 after dotfiles are linked)" +} + +# Stage 5 - Smoke +Write-Stage 5 "smoke" + +if (Test-Path (Join-Path $Dist 'patch-validator.js')) { + $result = node --input-type=module ` + -e "import('file:///${Dist.Replace('\','/')}/patch-validator.js').then(()=>process.exit(0)).catch(()=>process.exit(1))" ` + 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Pass "patch-validator loads" + } else { + Write-Fail "patch-validator failed to load" + $StageErrors.Add("smoke: patch-validator") | Out-Null + } +} else { + Write-Warn "dist/patch-validator.js not found - skipping load test" +} + +# Stage 6 - Summary +Write-Stage 6 "summary" + +if ($StageErrors.Count -gt 0) { + Write-Host "`nInstall completed with errors:" + foreach ($e in $StageErrors) { Write-Host " x $e" -ForegroundColor Red } + Write-Host "`nRun doctor:" + Write-Host " pwsh bootstrap/opencode/doctor.ps1" + exit 1 +} + +Write-Pass "opencode-tools package built" +Write-Pass "tool sources synced to dotfiles" +Write-Host "`nNext steps:" +Write-Host " 1. Run install.ps1 to link dotfiles: pwsh ./install.ps1" +Write-Host " 2. Run doctor to verify: pwsh bootstrap/opencode/doctor.ps1" diff --git a/bootstrap/opencode/install.sh b/bootstrap/opencode/install.sh new file mode 100644 index 0000000..82068a7 --- /dev/null +++ b/bootstrap/opencode/install.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# bootstrap/opencode/install.sh +# +# Bootstrap installer for the OpenCode AI agent platform. +# +# Usage: +# bash bootstrap/opencode/install.sh [--dry-run] +# +# Stages: +# 1. preflight — verify required tools and paths +# 2. sync — copy tool sources to dotfiles tools dir +# 3. build — compile TypeScript package +# 4. deploy — link/copy artifacts to live OpenCode config +# 5. validate — basic JSON config validation +# 6. smoke — verify at least one tool is loadable +# 7. summary — print results + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bootstrap/opencode/lib/paths.sh +source "${SCRIPT_DIR}/lib/paths.sh" +# shellcheck source=bootstrap/opencode/lib/checks.sh +source "${SCRIPT_DIR}/lib/checks.sh" +# shellcheck source=bootstrap/opencode/lib/config.sh +source "${SCRIPT_DIR}/lib/config.sh" + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true + echo "[dry-run] No changes will be made." +fi + +STAGE_FAILED=false +STAGE_ERRORS=() + +stage() { + echo "" + echo "── Stage $1: $2 ──" +} + +fail_stage() { + STAGE_FAILED=true + STAGE_ERRORS+=("$1") + echo "" + echo "ERROR: Stage failed — $1" + echo " $2" +} + +######################################################################## +# STAGE 1 — PREFLIGHT +######################################################################## +stage 1 "preflight" + +check_node_version 20 || fail_stage "preflight" "Node.js >=20 required" +check_command npm || fail_stage "preflight" "npm required to build tools" +check_command jq "true" # optional — used by install.sh +check_dir_exists "${REPO_ROOT}/packages/opencode-tools" "packages/opencode-tools" \ + || fail_stage "preflight" "packages/opencode-tools not found" + +if [[ "$STAGE_FAILED" == "true" ]]; then + echo "" + echo "Preflight failed. Resolve the issues above and re-run." + exit 1 +fi + +######################################################################## +# STAGE 2 — SYNC SOURCE TO DOTFILES TOOLS DIR +######################################################################## +stage 2 "sync tool sources" + +TOOLS_SRC="${OPENCODE_TOOLS_SRC}" +TOOLS_DEST="${DOTFILES_TOOLS_DIR}" + +if [[ "$DRY_RUN" == "true" ]]; then + echo " [dry-run] would sync: ${TOOLS_SRC}/*.ts → ${TOOLS_DEST}/" +else + mkdir -p "${TOOLS_DEST}" + for ts_file in "${TOOLS_SRC}"/*.ts; do + [[ -f "$ts_file" ]] || continue + dest="${TOOLS_DEST}/$(basename "$ts_file")" + if [[ -f "$dest" ]]; then + if cmp -s "$ts_file" "$dest"; then + echo " up-to-date: $(basename "$ts_file")" + continue + fi + backup_file "$dest" + fi + cp "$ts_file" "$dest" + echo " synced: $(basename "$ts_file")" + done +fi + +######################################################################## +# STAGE 3 — BUILD TYPESCRIPT PACKAGE +######################################################################## +stage 3 "build tools package" + +TOOLS_PKG="${REPO_ROOT}/packages/opencode-tools" + +if [[ "$DRY_RUN" == "true" ]]; then + echo " [dry-run] would run: npm install && npm run build (in ${TOOLS_PKG})" +else + ( + cd "${TOOLS_PKG}" + echo " installing dependencies..." + npm install --prefer-offline --silent 2>&1 | tail -3 || true + echo " compiling TypeScript..." + npm run build + echo " build complete: dist/ generated" + ) || fail_stage "build" "TypeScript compilation failed — run 'npm run lint' in packages/opencode-tools for details" +fi + +######################################################################## +# STAGE 4 — VALIDATE CONFIG +######################################################################## +stage 4 "validate config" + +if [[ -f "${OPENCODE_CONFIG_FILE}" ]]; then + check_json_parse "${OPENCODE_CONFIG_FILE}" "opencode.jsonc" \ + || _warn "config parse failed — manual review recommended" +else + _warn "opencode.jsonc not found at ${OPENCODE_CONFIG_FILE} (run install.sh after dotfiles install)" +fi + +######################################################################## +# STAGE 5 — SMOKE TEST (verify tools loadable) +######################################################################## +stage 5 "smoke" + +DIST="${TOOLS_PKG}/dist" + +if [[ -d "$DIST" ]]; then + if [[ -f "${DIST}/patch-validator.js" ]]; then + if node --input-type=module \ + -e "import('file://${DIST}/patch-validator.js').then(() => process.exit(0)).catch(() => process.exit(1))" \ + 2>/dev/null; then + _pass "smoke: patch-validator loads" + else + _fail "smoke: patch-validator failed to load" + STAGE_ERRORS+=("smoke: patch-validator") + fi + else + _warn "smoke: dist/patch-validator.js not found (build may not have run)" + fi +else + _warn "smoke: dist/ not found — skipping load test" +fi + +######################################################################## +# STAGE 6 — SUMMARY +######################################################################## +stage 6 "summary" +echo "" + +if [[ ${#STAGE_ERRORS[@]} -gt 0 ]]; then + echo "Install completed with errors:" + for e in "${STAGE_ERRORS[@]}"; do + echo " ✗ $e" + done + echo "" + echo "Run doctor to get a full health report:" + echo " bash bootstrap/opencode/doctor.sh" + exit 1 +fi + +echo " ✓ opencode-tools package built" +echo " ✓ tool sources synced to dotfiles" +echo "" +echo "Next steps:" +echo " 1. Run install.sh to link dotfiles: bash install.sh" +echo " 2. Run doctor to verify: bash bootstrap/opencode/doctor.sh" diff --git a/bootstrap/opencode/lib/checks.sh b/bootstrap/opencode/lib/checks.sh new file mode 100644 index 0000000..5a7fc49 --- /dev/null +++ b/bootstrap/opencode/lib/checks.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# bootstrap/opencode/lib/checks.sh +# Preflight and validation check functions for the OpenCode bootstrap. + +set -euo pipefail + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; RESET='\033[0m' +else + RED=''; YELLOW=''; GREEN=''; RESET='' +fi + +_pass() { echo -e "${GREEN} ✓${RESET} $*"; } +_warn() { echo -e "${YELLOW} ⚠${RESET} $*"; } +_fail() { echo -e "${RED} ✗${RESET} $*"; } + +# check_command [optional] +# Verifies that a command is available. +check_command() { + local name="$1" + local optional="${2:-false}" + if command -v "$name" &>/dev/null; then + _pass "command available: ${name}" + return 0 + fi + if [[ "$optional" == "true" ]]; then + _warn "optional command not found: ${name}" + return 0 + fi + _fail "required command not found: ${name}" + return 1 +} + +# check_dir_writable +# Verifies that a directory exists and is writable, or can be created. +check_dir_writable() { + local dir="$1" + if [[ -d "$dir" ]] && [[ -w "$dir" ]]; then + _pass "directory writable: ${dir}" + return 0 + fi + if [[ ! -e "$dir" ]]; then + if mkdir -p "$dir" 2>/dev/null; then + _pass "directory created: ${dir}" + return 0 + fi + fi + _fail "directory not writable: ${dir}" + return 1 +} + +# check_file_exists [label] +check_file_exists() { + local path="$1" + local label="${2:-${path}}" + if [[ -f "$path" ]]; then + _pass "file exists: ${label}" + return 0 + fi + _fail "file missing: ${label}" + return 1 +} + +# check_dir_exists [label] +check_dir_exists() { + local path="$1" + local label="${2:-${path}}" + if [[ -d "$path" ]]; then + _pass "directory exists: ${label}" + return 0 + fi + _fail "directory missing: ${label}" + return 1 +} + +# check_node_version +check_node_version() { + local min="$1" + if ! command -v node &>/dev/null; then + _fail "node not found (required: >=${min})" + return 1 + fi + local version + version="$(node --version | sed 's/v//' | cut -d. -f1)" + if (( version >= min )); then + _pass "node version ok: $(node --version)" + return 0 + fi + _fail "node version too old: $(node --version) (required: >=${min})" + return 1 +} + +# check_json_parse +# Verifies a JSON/JSONC file parses without errors. +check_json_parse() { + local path="$1" + local label="${2:-${path}}" + if [[ ! -f "$path" ]]; then + _fail "file missing for JSON check: ${label}" + return 1 + fi + # String-aware JSONC comment and trailing-comma stripper + if node -e " + function parseJsonc(text) { + var result = ''; var inString = false; var i = 0; + while (i < text.length) { + var ch = text[i]; + if (inString) { + if (ch === '\\\\') { result += ch + (text[i+1]||''); i += 2; continue; } + if (ch === '\"') inString = false; + result += ch; i++; + } else { + if (ch === '\"') { inString = true; result += ch; i++; } + else if (ch === '/' && text[i+1] === '/') { while (i < text.length && text[i] !== '\n') i++; } + else if (ch === '/' && text[i+1] === '*') { i+=2; while (i < text.length && !(text[i]==='*' && text[i+1]==='/')) i++; i+=2; } + else { result += ch; i++; } + } + } + return JSON.parse(result.replace(/,(\s*[}\]])/g, '\$1')); + } + var fs = require('fs'); + var raw = fs.readFileSync('${path}', 'utf8'); + parseJsonc(raw); + " 2>/dev/null; then + _pass "JSON parses: ${label}" + return 0 + fi + _fail "JSON parse error: ${label}" + return 1 +} diff --git a/bootstrap/opencode/lib/config.sh b/bootstrap/opencode/lib/config.sh new file mode 100644 index 0000000..7348348 --- /dev/null +++ b/bootstrap/opencode/lib/config.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# bootstrap/opencode/lib/config.sh +# Config read/backup/update helpers for the OpenCode bootstrap. + +set -euo pipefail + +# backup_file +# Creates a timestamped backup of a file if it exists and is not already a symlink +# pointing into the repo. +backup_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + return 0 + fi + # Do not back up symlinks that already point into this repo + if [[ -L "$path" ]]; then + local target + target="$(readlink -f "$path" 2>/dev/null || true)" + if [[ "$target" == "${REPO_ROOT}"* ]]; then + return 0 + fi + fi + local backup_dir + backup_dir="${REPO_ROOT}/.backup/opencode/$(date +%Y%m%d-%H%M%S)" + mkdir -p "$backup_dir" + cp -a "$path" "${backup_dir}/$(basename "$path")" + echo " backed up: ${path} → ${backup_dir}/$(basename "$path")" +} + +# safe_ensure_dir +safe_ensure_dir() { + local dir="$1" + if [[ ! -d "$dir" ]]; then + mkdir -p "$dir" + echo " created: ${dir}" + fi +} diff --git a/bootstrap/opencode/lib/paths.sh b/bootstrap/opencode/lib/paths.sh new file mode 100644 index 0000000..a7ffba7 --- /dev/null +++ b/bootstrap/opencode/lib/paths.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# bootstrap/opencode/lib/paths.sh +# Platform-aware path resolution for the OpenCode bootstrap. + +set -euo pipefail + +# Resolve the repo root (two levels up from bootstrap/opencode/lib/) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +# Source package paths +# shellcheck disable=SC2034 +OPENCODE_TOOLS_SRC="${REPO_ROOT}/packages/opencode-tools/src" +# shellcheck disable=SC2034 +DOTFILES_TOOLS_DIR="${REPO_ROOT}/dotfiles/ai-agents/opencode/tools" +# shellcheck disable=SC2034 +DOTFILES_OPENCODE_DIR="${REPO_ROOT}/dotfiles/ai-agents/opencode" + +# OpenCode config locations (platform-dependent) +if [[ "${OPENCODE_CONFIG_HOME:-}" != "" ]]; then + OPENCODE_CONFIG_DIR="${OPENCODE_CONFIG_HOME}" +elif [[ "$(uname -s)" == "Darwin" ]]; then + OPENCODE_CONFIG_DIR="${HOME}/.config/opencode" +else + OPENCODE_CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/opencode" +fi + +# shellcheck disable=SC2034 +OPENCODE_CONFIG_FILE="${OPENCODE_CONFIG_DIR}/opencode.jsonc" +# shellcheck disable=SC2034 +OPENCODE_TOOLS_LIVE="${OPENCODE_CONFIG_DIR}/tools" +# shellcheck disable=SC2034 +OPENCODE_SKILLS_LIVE="${OPENCODE_CONFIG_DIR}/skills" + +# Bootstrap log dir +# shellcheck disable=SC2034 +BOOTSTRAP_LOG_DIR="${REPO_ROOT}/.bootstrap-logs" diff --git a/docs/current-failure-modes.md b/docs/current-failure-modes.md new file mode 100644 index 0000000..db6d45b --- /dev/null +++ b/docs/current-failure-modes.md @@ -0,0 +1,121 @@ +# Current Failure Modes + +**Version:** 1.0.0 +**Date:** 2026-04-04 +**Status:** Baseline issue list + +--- + +## F-001: Tool not discovered by OpenCode + +**Symptom:** OpenCode session does not show `patch-validator` or `analysis-cache` in available tools. + +**Root cause:** Tools require `~/.config/opencode/tools/` to contain the `.ts` source files. If the dotfiles installer has not been run, the directory is not linked. + +**Detection:** `bash bootstrap/opencode/doctor.sh` — check `tool-dotfiles-*` results. + +**Remediation:** +1. Run `bash install.sh` to link dotfiles to `~/.config/opencode/tools/` +2. Verify with `bash bootstrap/opencode/doctor.sh` + +--- + +## F-002: Tool discovered but not executable + +**Symptom:** Tool appears in OpenCode but fails to run with an import error. + +**Root cause:** `@opencode-ai/plugin` or `zod` is not available in the OpenCode runtime environment. + +**Detection:** `bash scripts/smoke-opencode-tools.sh` after build; or check OpenCode session logs. + +**Remediation:** +- These packages are expected to be bundled with OpenCode itself. +- If using a custom OpenCode build, ensure `@opencode-ai/plugin` is in the runtime. +- `zod` can be installed separately: `npm install -g zod` as a fallback. + +--- + +## F-003: Config drift + +**Symptom:** `opencode.jsonc` has stale values (wrong model, outdated MCP commands, etc.) + +**Root cause:** Config was manually edited at `~/.config/opencode/opencode.jsonc` without updating the repo source. + +**Detection:** `bash scripts/validate-opencode-config.sh` — fails if JSON is invalid. + +**Remediation:** +1. Compare `~/.config/opencode/opencode.jsonc` with `dotfiles/ai-agents/opencode/config.jsonc` +2. Merge intentional local changes back into `dotfiles/ai-agents/opencode/config.jsonc` +3. Re-run `bash install.sh` + +--- + +## F-004: Broken paths in dotfiles map + +**Symptom:** `install.sh --check` reports missing or foreign items. + +**Root cause:** A key in `dotfiles.map.json` points to a path that does not exist under `dotfiles/`. + +**Detection:** `bash install.sh --check` + +**Remediation:** +1. Run `bash install.sh --check` and inspect the foreign/missing items. +2. Either add the missing file or remove the stale map entry. + +--- + +## F-005: Missing runtime dependency (node, npm, jq) + +**Symptom:** Bootstrap fails at preflight stage. + +**Root cause:** Required tools not installed on the target machine. + +**Detection:** `bash bootstrap/opencode/install.sh` — fails at Stage 1 preflight. + +**Remediation:** +- **node >=20:** Install via [nodejs.org](https://nodejs.org) or `mise use node@lts` +- **npm:** Bundled with Node.js +- **jq:** `sudo apt install jq` (Linux) or `brew install jq` (macOS) + +--- + +## F-006: Unsupported platform assumption + +**Symptom:** Scripts fail on macOS with path errors (e.g., `~/.config/opencode` vs `~/Library/...`) + +**Root cause:** OpenCode uses `~/.config/opencode` on both Linux and macOS. The bootstrap scripts detect `uname -s` to set paths. The dotfiles map currently only has `linux` platform entries for OpenCode. + +**Detection:** Doctor script path checks fail on macOS. + +**Remediation:** +- Add `macos` platform entries to `dotfiles.map.json` for `ai-agents/opencode/*` +- The install scripts already handle macOS via `uname -s` detection. + +--- + +## F-007: SKILL.md missing or invalid frontmatter + +**Symptom:** OpenCode cannot auto-discover skills; `check-skills.yml` CI job fails. + +**Root cause:** A new skill was added without the required YAML frontmatter block. + +**Detection:** `bash scripts/validate-skills.sh` or `.github/workflows/check-skills.yml` + +**Remediation:** +1. Run `bash scripts/validate-skills.sh` to identify the offending file. +2. Add or fix the YAML frontmatter in the SKILL.md file. +3. Required fields: `name` (kebab-case) and `description` (>=20 chars, trigger-oriented). + +--- + +## F-008: Build artifacts committed to repo + +**Symptom:** `packages/opencode-tools/dist/` or `node_modules/` appear in git status. + +**Root cause:** `.gitignore` not set up to exclude build output. + +**Detection:** `git status` shows dist or node_modules files. + +**Remediation:** +- The root `.gitignore` excludes `**/node_modules/` and `packages/*/dist`. +- If accidentally committed, run: `git rm -r --cached packages/opencode-tools/dist packages/opencode-tools/node_modules` diff --git a/docs/opencode-baseline.md b/docs/opencode-baseline.md new file mode 100644 index 0000000..a15938b --- /dev/null +++ b/docs/opencode-baseline.md @@ -0,0 +1,116 @@ +# OpenCode Platform Baseline + +**Version:** 1.0.0 +**Date:** 2026-04-04 +**Status:** Baseline capture + +--- + +## 1. Asset Inventory + +### 1.1 Static Configuration + +| Path | Target | Platforms | +|------|--------|-----------| +| `dotfiles/ai-agents/opencode/config.jsonc` | `~/.config/opencode/opencode.jsonc` | linux | +| `dotfiles/ai-agents/opencode/AGENTS.md` | `~/.config/opencode/AGENTS.md` | linux | + +### 1.2 Agents + +| File | Purpose | +|------|---------| +| `dotfiles/ai-agents/opencode/agents/edit.prompt.md` | Code editing agent instructions | +| `dotfiles/ai-agents/opencode/agents/planner.prompt.md` | Planning/task decomposition agent | +| `dotfiles/ai-agents/opencode/agents/review.prompt.md` | Code review agent | + +### 1.3 Commands + +| Command | Description | +|---------|-------------| +| `code-review.md` | Full code review workflow | +| `openspec-eval.md` | OpenSpec evaluation workflow | +| `perf-review.md` | Performance review | +| `plan-feature.md` | Feature planning | +| `pr-govern.md` | PR governance / mutation validation | +| `regression-review.md` | Regression risk review | +| `review-file.md` | Single-file review | +| `security-review.md` | Security-focused review | + +### 1.4 Skills (23 total) + +| Skill | Domain | +|-------|--------| +| `agentsmd-expert` | documentation | +| `budget-guard` | governance | +| `budget-supervisor` | governance | +| `chaos-engineer` | engineering | +| `clean-code-master` | engineering | +| `deep-research` | research | +| `devops-engineer` | engineering | +| `doc-forge` | documentation | +| `interactive-plan` | planning | +| `kubernetes-specialist` | engineering | +| `legacy-modernizer` | engineering | +| `monorepo-navigator` | engineering | +| `mvp-watcher` | product | +| `openspec-expert` | specification | +| `perf-analyst` | engineering | +| `playwright-expert` | testing | +| `prompt-engineer` | ai | +| `readme-expert` | documentation | +| `refactor-engine` | engineering | +| `spec-miner` | specification | +| `test-forge` | testing | +| `the-fool` | reasoning | +| `threat-modeler` | security | + +### 1.5 Policies + +| Policy | Description | +|--------|-------------| +| `policies/analysis-cache.md` | Analysis artifact caching policy | + +### 1.6 Custom Tools (runtime executables) + +| Tool | Description | Import | +|------|-------------|--------| +| `tools/patch-validator.ts` | Validates unified diff patches against mutation contract | `@opencode-ai/plugin`, `zod` | +| `tools/analysis-cache.ts` | Deterministic analysis artifact cache | `@opencode-ai/plugin`, `zod`, `node:fs`, `node:path`, `node:crypto` | + +--- + +## 2. Runtime Dependency Map + +### patch-validator + +- **Runtime:** OpenCode TypeScript host (Bun-based) +- **Imports:** `@opencode-ai/plugin` (provided by OpenCode), `zod` +- **Entrypoint:** `default export` of `tool({...})` +- **Contract:** Accepts a unified diff string, returns `{ valid, percent_changed, files, violations }` +- **Environment variables:** none required +- **Governance integration:** Called by `pr-govern.md` command before any mutation approval + +### analysis-cache + +- **Runtime:** OpenCode TypeScript host (Bun-based) +- **Imports:** `@opencode-ai/plugin`, `zod`, Node.js builtins (`fs`, `path`, `crypto`) +- **Entrypoint:** `default export` of `tool({...})` +- **Contract:** Accepts `action` (lookup/store/invalidate/stats/prune) + namespace/key/artifact +- **Environment variables:** `OPENCODE_ANALYSIS_CACHE_DIR` (optional; default: `.opencode/cache/analysis-cache`) +- **Storage:** `.opencode/cache/analysis-cache/` relative to worktree + +--- + +## 3. Dotfiles Map Coverage + +All OpenCode assets are mapped in `dotfiles.map.json` for linux. No windows/macos mappings exist for OpenCode config (OpenCode is primarily a Linux/macOS tool). + +--- + +## 4. Governance + +- Global governance contract: `dotfiles/ai-agents/opencode/AGENTS.md` +- Mutation threshold: 30% per file +- Validation: `patch-validator` required before any mutation +- Caching: `analysis-cache` mandatory for expensive analyses +- Governance version: 2.0.0 diff --git a/docs/tool-runtime-inventory.md b/docs/tool-runtime-inventory.md new file mode 100644 index 0000000..b503593 --- /dev/null +++ b/docs/tool-runtime-inventory.md @@ -0,0 +1,113 @@ +# Tool Runtime Inventory + +**Version:** 1.0.0 +**Date:** 2026-04-04 + +--- + +## patch-validator + +**Source:** `packages/opencode-tools/src/patch-validator.ts` +**Deployed:** `dotfiles/ai-agents/opencode/tools/patch-validator.ts` +**Live:** `~/.config/opencode/tools/patch-validator.ts` + +### Dependencies + +| Package | Version | Source | Notes | +|---------|---------|--------|-------| +| `@opencode-ai/plugin` | peer | OpenCode host | Provides `tool()` factory | +| `zod` | ^3.x | npm | Schema validation | + +### Execution Contract + +```typescript +// Input +{ + patch: string; // Unified diff (required) + max_change_percent?: number; // Default: 30 + strict?: boolean; // Default: true + allow_new_files?: boolean; // Default: false + allow_deleted_files?: boolean; // Default: false +} + +// Output +{ + valid: boolean; + percent_changed: number; + totals?: { files, added, removed, unchanged }; + files: FileReport[]; + violations: Violation[]; +} +``` + +### Violation Types + +| Type | Severity | Description | +|------|----------|-------------| +| `invalid-format` | error | Input is not a valid unified diff | +| `new-file` | error/warn | Patch adds a new file (blocked unless `allow_new_files=true`) | +| `deleted-file` | error/warn | Patch deletes a file (blocked unless `allow_deleted_files=true`) | +| `excessive-change` | error | File change exceeds `max_change_percent` threshold | +| `full-rewrite-likely` | error | Patch appears to rewrite entire file | +| `whitespace-only` | error/warn | Only whitespace differences detected | +| `import-reorder` | error/warn | Only import ordering changes detected | + +--- + +## analysis-cache + +**Source:** `packages/opencode-tools/src/analysis-cache.ts` +**Deployed:** `dotfiles/ai-agents/opencode/tools/analysis-cache.ts` +**Live:** `~/.config/opencode/tools/analysis-cache.ts` + +### Dependencies + +| Package | Version | Source | Notes | +|---------|---------|--------|-------| +| `@opencode-ai/plugin` | peer | OpenCode host | Provides `tool()` factory | +| `zod` | ^3.x | npm | Schema validation | +| `node:fs` | built-in | Node.js/Bun | File system | +| `node:fs/promises` | built-in | Node.js/Bun | Async file system | +| `node:path` | built-in | Node.js/Bun | Path utilities | +| `node:crypto` | built-in | Node.js/Bun | SHA-256 hashing | + +### Execution Contract + +```typescript +// Actions +type CacheAction = "lookup" | "store" | "invalidate" | "stats" | "prune"; + +// Input +{ + action: CacheAction; // Required + namespace?: string; // Default: "default" + key?: string; // Required for lookup/store + key_extra?: unknown; // Additional key data for hashing + ttl_sec?: number; // TTL in seconds (0 = no expiry) + max_bytes?: number; // Max artifact size (default: 262144) + artifact?: string; // JSON string to store (required for store) + metadata?: unknown; // Arbitrary metadata to attach + key_prefix?: string; // For invalidate: prefix filter + metadata_match?: Record; // For invalidate: metadata filter + prune_expired?: boolean; // For stats: also prune expired entries +} +``` + +### Storage Layout + +``` +.opencode/cache/analysis-cache/ + / + / + .json +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENCODE_ANALYSIS_CACHE_DIR` | `.opencode/cache/analysis-cache` | Override cache directory | + +### Schema Version + +Current schema version: **2**. Changing the entry schema requires bumping `SCHEMA_VERSION`. diff --git a/dotfiles/ai-agents/codex/skills/agentsmd-expert/SKILL.md b/dotfiles/ai-agents/codex/skills/agentsmd-expert/SKILL.md index 3c3896d..b1a6f42 100644 --- a/dotfiles/ai-agents/codex/skills/agentsmd-expert/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/agentsmd-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: agentsmd-expert -description: AGENTS.md authoring and refinement for Codex agent instructions. Use when creating or updating AGENTS.md files, standardizing repository guidelines, clarifying tooling commands, or documenting local skills and constraints for agents. +description: Use when creating or updating AGENTS.md files for Codex agents. Invoke for standardizing repository guidelines, clarifying tooling commands, or documenting local skills and constraints. Not for general README or project documentation (use readme-expert instead). license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: specialist scope: implementation output-format: document - related-skills: code-documenter, prompt-engineer, readme-expert + related-skills: doc-forge, prompt-engineer, readme-expert --- # AGENTS.md Expert diff --git a/dotfiles/ai-agents/codex/skills/budget-guard/SKILL.md b/dotfiles/ai-agents/codex/skills/budget-guard/SKILL.md index 5c25639..ae54b53 100644 --- a/dotfiles/ai-agents/codex/skills/budget-guard/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/budget-guard/SKILL.md @@ -1,6 +1,8 @@ --- name: budget-guard -description: Deterministic cost-control guardrail for Codex CLI and agentic workflows. Enforces strict token/tool/attempt budgets, cost-optimized routing, state memory discipline, hash-based rescan prevention, search storm protection, CI-safe termination, and explicit build/test confirmation. +description: Use when a Codex task must run within strict cost or token limits. Invoke to enforce hard budgets, prevent search storms, or gate CI-safe termination. Do not use for session-level multi-task budget governance (use budget-supervisor instead). +metadata: + related-skills: budget-supervisor --- # Budget Guard v2.1 diff --git a/dotfiles/ai-agents/codex/skills/budget-supervisor/SKILL.md b/dotfiles/ai-agents/codex/skills/budget-supervisor/SKILL.md index 654161f..9de75ac 100644 --- a/dotfiles/ai-agents/codex/skills/budget-supervisor/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/budget-supervisor/SKILL.md @@ -1,6 +1,8 @@ --- name: budget-supervisor -description: Production-grade session-level FinOps governor for Codex CLI. Authoritatively enforces rolling token/tool/time budgets across tasks and sub-agents, allocates per-task envelopes, resolves tier conflicts, locks/unlocks escalation, detects spend drift, logs overrides, and activates/parameterizes budget-guard deterministically per task. +description: Use when governing token, tool, and time budgets across a full Codex session or multiple sub-agents. Invoke to allocate per-task envelopes, resolve tier conflicts, or audit override logs. Do not use for single-task cost control (use budget-guard instead). +metadata: + related-skills: budget-guard --- # Budget Enforcement Supervisor (BES) v2 diff --git a/dotfiles/ai-agents/codex/skills/chaos-engineer/SKILL.md b/dotfiles/ai-agents/codex/skills/chaos-engineer/SKILL.md index 66aab62..f4aa930 100644 --- a/dotfiles/ai-agents/codex/skills/chaos-engineer/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/chaos-engineer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: implementation output-format: code - related-skills: sre-engineer, devops-engineer, kubernetes-specialist + related-skills: devops-engineer, kubernetes-specialist --- # Chaos Engineer diff --git a/dotfiles/ai-agents/codex/skills/clean-code-master/SKILL.md b/dotfiles/ai-agents/codex/skills/clean-code-master/SKILL.md index 65d193f..28ab888 100644 --- a/dotfiles/ai-agents/codex/skills/clean-code-master/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/clean-code-master/SKILL.md @@ -1,6 +1,6 @@ --- name: clean-code-master -description: Deterministic, language-agnostic clean code governance engine. Audits complexity, enforces maintainability budgets, classifies technical debt, and produces measurable refactor plans with CI enforcement support. +description: Use when auditing code quality, measuring complexity, or planning technical debt reduction. Invoke for SOLID violations, naming convention enforcement, code smell detection, or maintainability budget reviews. Not for one-off formatting fixes. license: MIT metadata: version: "2.0.0" @@ -11,7 +11,7 @@ metadata: ci-enforced: true deterministic: true triggers: clean code, complexity, maintainability, refactor, technical debt, SOLID, code smells, architecture hygiene - related-skills: refactor-engine, code-reviewer, test-master, doc-forge, openspec-expert, security-reviewer + related-skills: refactor-engine, the-fool, test-forge, doc-forge, openspec-expert, threat-modeler --- # Clean Code Master v2.0 diff --git a/dotfiles/ai-agents/codex/skills/deep-research/SKILL.md b/dotfiles/ai-agents/codex/skills/deep-research/SKILL.md index 846ff4d..c5942fd 100644 --- a/dotfiles/ai-agents/codex/skills/deep-research/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/deep-research/SKILL.md @@ -1,6 +1,6 @@ --- name: deep-research -description: Multi-agent deep research orchestration workflow for parallel data collection, analysis, and report synthesis. +description: Use when a task requires broad evidence gathering, parallel research streams, or synthesis across multiple sources before writing specs or plans. Invoke for multi-domain analysis, requirements extraction, or competitive research. Not for single-file or focused code analysis. license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: expert scope: analysis output-format: document - related-skills: openspec + related-skills: openspec-expert --- # Deep Research @@ -23,7 +23,7 @@ You orchestrate parallel evidence collection, synthesize findings, and extract s - Need high-confidence requirements from mixed sources - Multi-threaded research and synthesis is required -- Preparing structured inputs for the `openspec` skill +- Preparing structured inputs for the `openspec-expert` skill ## Core Workflow @@ -31,7 +31,7 @@ You orchestrate parallel evidence collection, synthesize findings, and extract s 2. Run the research entrypoint and gather sources. 3. Synthesize notes and constraints into structured requirements. 4. Validate output contract artifacts. -5. Hand off `requirements.json` to `openspec`. +5. Hand off `requirements.json` to `openspec-expert`. ### Fast Path (Small Tasks) @@ -79,7 +79,7 @@ Primary entrypoint: `scripts/run_research.sh ""` Handoff to OpenSpec: -`.codex/skills/openspec/scripts/spec_from_input.sh .research//requirements.json` +`.codex/skills/openspec-expert/scripts/spec_from_input.sh .research//requirements.json` ## Knowledge Reference diff --git a/dotfiles/ai-agents/codex/skills/devops-engineer/SKILL.md b/dotfiles/ai-agents/codex/skills/devops-engineer/SKILL.md index 166cd06..e5658bc 100644 --- a/dotfiles/ai-agents/codex/skills/devops-engineer/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/devops-engineer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: engineer scope: implementation output-format: code - related-skills: sre-engineer, monitoring-expert, cloud-architect, kubernetes-specialist + related-skills: kubernetes-specialist --- # DevOps Engineer diff --git a/dotfiles/ai-agents/codex/skills/doc-forge/SKILL.md b/dotfiles/ai-agents/codex/skills/doc-forge/SKILL.md index f322b24..5f7a41e 100644 --- a/dotfiles/ai-agents/codex/skills/doc-forge/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/doc-forge/SKILL.md @@ -1,6 +1,8 @@ --- name: doc-forge -description: Enterprise-grade documentation and workflow analysis skill for complex codebases. Generates inline docs, structured documents, ADRs, traceability matrices, delta updates, and deterministic diagrams with evidence tagging. +description: Use when generating or updating inline code docs, architecture decision records, workflow diagrams, or traceability matrices for a complex codebase. Invoke for ADRs, Mermaid diagrams, delta doc updates, or glossary extraction. Not for README or AGENTS.md authoring. +metadata: + related-skills: readme-expert, agentsmd-expert, spec-miner --- # Doc Forge v2 diff --git a/dotfiles/ai-agents/codex/skills/interactive-plan/SKILL.md b/dotfiles/ai-agents/codex/skills/interactive-plan/SKILL.md index c94373a..4614430 100644 --- a/dotfiles/ai-agents/codex/skills/interactive-plan/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/interactive-plan/SKILL.md @@ -1,6 +1,6 @@ --- name: interactive-plan -description: Interactive production planning with decision questions, task list, clean code, and test strategy. +description: Use when a task requires upfront clarifying questions before work begins, or when an explicit task list with test strategy is needed. Invoke for ambiguous requirements, multi-step features, or planning sessions before coding starts. license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: expert scope: planning output-format: document - related-skills: test-master, architecture-designer + related-skills: test-forge --- # Interactive Plan diff --git a/dotfiles/ai-agents/codex/skills/kubernetes-specialist/SKILL.md b/dotfiles/ai-agents/codex/skills/kubernetes-specialist/SKILL.md index cf7ca1a..208ee59 100644 --- a/dotfiles/ai-agents/codex/skills/kubernetes-specialist/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/kubernetes-specialist/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: infrastructure output-format: manifests - related-skills: devops-engineer, cloud-architect, sre-engineer + related-skills: devops-engineer --- # Kubernetes Specialist diff --git a/dotfiles/ai-agents/codex/skills/legacy-modernizer/SKILL.md b/dotfiles/ai-agents/codex/skills/legacy-modernizer/SKILL.md index 4161ea6..6348aec 100644 --- a/dotfiles/ai-agents/codex/skills/legacy-modernizer/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/legacy-modernizer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: architecture output-format: code+analysis - related-skills: test-master, devops-engineer, spec-miner + related-skills: test-forge, devops-engineer, spec-miner --- # Legacy Modernizer diff --git a/dotfiles/ai-agents/codex/skills/monorepo-navigator/SKILL.md b/dotfiles/ai-agents/codex/skills/monorepo-navigator/SKILL.md index 255913d..1bef6ce 100644 --- a/dotfiles/ai-agents/codex/skills/monorepo-navigator/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/monorepo-navigator/SKILL.md @@ -1,6 +1,8 @@ --- name: monorepo-navigator -description: Enterprise-grade architectural intelligence and monorepo analysis skill. Builds deterministic module graphs, computes structural metrics, detects architectural drift, bounded contexts, cycles, ownership boundaries, blast radius, and produces CI-ready reports and diagrams with strict scope control. +description: Use when analyzing architecture, ownership boundaries, or dependency cycles across a monorepo. Invoke for module graph generation, blast-radius estimation, bounded context detection, or architectural drift reports. Not for single-package analysis. +metadata: + related-skills: refactor-engine, clean-code-master --- # Monorepo Navigator v2 diff --git a/dotfiles/ai-agents/codex/skills/mvp-watcher/SKILL.md b/dotfiles/ai-agents/codex/skills/mvp-watcher/SKILL.md index efe1432..a67980f 100644 --- a/dotfiles/ai-agents/codex/skills/mvp-watcher/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/mvp-watcher/SKILL.md @@ -1,6 +1,6 @@ --- name: mvp-watcher -description: Deterministic scope-discipline governance skill. Detects scope creep, premature abstraction, unnecessary flexibility, and non-MVP expansion. Enforces value-to-complexity ratio and cost containment. +description: Use when reviewing a plan, PR, or feature for scope creep, premature abstraction, or non-MVP complexity. Invoke before or during implementation to enforce value-to-complexity discipline and contain cost. Not for post-release retrospectives. license: MIT metadata: version: "1.2.0" diff --git a/dotfiles/ai-agents/codex/skills/openspec-expert/SKILL.md b/dotfiles/ai-agents/codex/skills/openspec-expert/SKILL.md index a126e2f..cc44221 100644 --- a/dotfiles/ai-agents/codex/skills/openspec-expert/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/openspec-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-expert -description: Enterprise-grade OpenSpec governance engine with deterministic generation, risk-tier gating, automated quality scoring, version enforcement, diff intelligence, structured artifact emission, and CI-safe validation workflows. +description: Use when creating, validating, or governing functional requirement specifications in OpenSpec format. Invoke for spec generation from inputs, risk-tier classification, quality scoring, version enforcement, diff analysis, or CI gate execution. Not for inline code documentation (use doc-forge instead). license: MIT metadata: author: https://github.com/Jeffallan @@ -12,7 +12,7 @@ metadata: risk-aware: true ci-safe: true artifact-emission: true - related-skills: deep-research, refactor-engine, threat-modeler, test-forge, analysis-cache + related-skills: deep-research, refactor-engine, threat-modeler, test-forge, perf-analyst --- # OpenSpec Expert v2.1 diff --git a/dotfiles/ai-agents/codex/skills/perf-analyst/SKILL.md b/dotfiles/ai-agents/codex/skills/perf-analyst/SKILL.md index d1a6611..398f1ef 100644 --- a/dotfiles/ai-agents/codex/skills/perf-analyst/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/perf-analyst/SKILL.md @@ -1,6 +1,8 @@ --- name: perf-analyst -description: Enterprise-grade performance engineering skill for agentic coding. Performs deterministic hotspot analysis, workload modeling, capacity estimation, tail-latency analysis, backpressure evaluation, scaling risk detection, and produces minimal safe optimization patches with measurement and rollout plans. +description: Use when diagnosing performance bottlenecks, modeling capacity, or producing optimization patches with measurement plans. Invoke for tail-latency issues, backpressure analysis, scaling risk, or workload modeling. Not for general code quality review. +metadata: + related-skills: refactor-engine, clean-code-master --- # Perf Analyst v2 diff --git a/dotfiles/ai-agents/codex/skills/playwright-expert/SKILL.md b/dotfiles/ai-agents/codex/skills/playwright-expert/SKILL.md index 434fc70..13a4fe1 100644 --- a/dotfiles/ai-agents/codex/skills/playwright-expert/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/playwright-expert/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: testing output-format: code - related-skills: test-master, devops-engineer, javascript-pro, typescript-pro, fullstack-guardian + related-skills: test-forge, devops-engineer --- # Playwright Expert diff --git a/dotfiles/ai-agents/codex/skills/prompt-engineer/SKILL.md b/dotfiles/ai-agents/codex/skills/prompt-engineer/SKILL.md index 9670075..ecf8e83 100644 --- a/dotfiles/ai-agents/codex/skills/prompt-engineer/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/prompt-engineer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: expert scope: design output-format: document - related-skills: test-master, fine-tuning-expert, rag-architect + related-skills: test-forge --- # Prompt Engineer diff --git a/dotfiles/ai-agents/codex/skills/readme-expert/SKILL.md b/dotfiles/ai-agents/codex/skills/readme-expert/SKILL.md index b8ce43b..ab3e213 100644 --- a/dotfiles/ai-agents/codex/skills/readme-expert/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/readme-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: readme-expert -description: README authoring and refinement for software projects. Use when creating or updating README.md files, improving project documentation structure, clarifying installation/usage, adding badges, or standardizing contribution and support sections. +description: Use when creating or updating README.md files for software projects. Invoke for improving documentation structure, clarifying installation or usage steps, adding badges, or standardizing contribution and support sections. Not for AGENTS.md or architecture documentation. license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: specialist scope: implementation output-format: document - related-skills: code-documenter, prompt-engineer, agentsmd-expert + related-skills: doc-forge, prompt-engineer, agentsmd-expert --- # README Expert diff --git a/dotfiles/ai-agents/codex/skills/refactor-engine/SKILL.md b/dotfiles/ai-agents/codex/skills/refactor-engine/SKILL.md index 29334a5..f33fbdc 100644 --- a/dotfiles/ai-agents/codex/skills/refactor-engine/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/refactor-engine/SKILL.md @@ -1,6 +1,8 @@ --- name: refactor-engine -description: Enterprise-grade architectural evolution and safe refactoring skill. Performs deterministic behavioral-invariant modeling, API surface protection, dependency graph validation, blast-radius estimation, semantic diff analysis, migration sequencing, and cross-skill safety orchestration. Designed for large monorepos and modular SaaS systems. +description: Use when planning or executing large-scale refactoring with behavioral-invariant preservation. Invoke for API surface protection, blast-radius estimation, migration sequencing, or architectural boundary enforcement in monorepos. Not for small cosmetic cleanups. +metadata: + related-skills: monorepo-navigator, test-forge, threat-modeler, perf-analyst --- # Refactor Engine v2 diff --git a/dotfiles/ai-agents/codex/skills/spec-miner/SKILL.md b/dotfiles/ai-agents/codex/skills/spec-miner/SKILL.md index 404cc8a..99f85d8 100644 --- a/dotfiles/ai-agents/codex/skills/spec-miner/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/spec-miner/SKILL.md @@ -11,7 +11,7 @@ metadata: role: specialist scope: review output-format: document - related-skills: fullstack-guardian, architecture-designer, code-documenter, code-reviewer, legacy-modernizer + related-skills: doc-forge, the-fool, legacy-modernizer --- # Spec Miner diff --git a/dotfiles/ai-agents/codex/skills/test-forge/SKILL.md b/dotfiles/ai-agents/codex/skills/test-forge/SKILL.md index 5b2b006..2de7398 100644 --- a/dotfiles/ai-agents/codex/skills/test-forge/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/test-forge/SKILL.md @@ -1,6 +1,8 @@ --- name: test-forge -description: Elite production-grade test generation and strategy skill for agentic coding. Produces deterministic unit/integration/contract/property-based tests, diff-driven updates, branch/path coverage modeling, mutation-sensitive assertions, failure injection, compatibility guards, and test architecture governance. CI-ready and scope-tiered. +description: Use when generating or updating tests for changed behavior, designing test architecture, or building coverage for complex flows. Invoke for unit, integration, contract, or property-based tests, diff-driven updates, or mutation-sensitive assertions. Not for E2E browser tests (use playwright-expert). +metadata: + related-skills: playwright-expert, openspec-expert --- # Test Forge v2 diff --git a/dotfiles/ai-agents/codex/skills/the-fool/SKILL.md b/dotfiles/ai-agents/codex/skills/the-fool/SKILL.md index a266401..34d8156 100644 --- a/dotfiles/ai-agents/codex/skills/the-fool/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/the-fool/SKILL.md @@ -10,7 +10,7 @@ metadata: role: expert scope: review output-format: report - related-skills: architecture-designer, code-reviewer, security-reviewer + related-skills: clean-code-master, threat-modeler --- # The Fool diff --git a/dotfiles/ai-agents/codex/skills/threat-modeler/SKILL.md b/dotfiles/ai-agents/codex/skills/threat-modeler/SKILL.md index abc47bb..95e2385 100644 --- a/dotfiles/ai-agents/codex/skills/threat-modeler/SKILL.md +++ b/dotfiles/ai-agents/codex/skills/threat-modeler/SKILL.md @@ -1,6 +1,8 @@ --- name: threat-modeler -description: Enterprise-grade security posture engineering skill for agentic coding. Performs asset-centric threat modeling, quantitative risk scoring, trust-boundary mapping, lateral movement analysis, compliance mapping, and produces actionable mitigations with verification hooks. Deterministic, evidence-tagged, and scope-tiered. +description: Use when assessing security posture, mapping trust boundaries, or producing risk-scored mitigations for a system. Invoke for threat modeling sessions, compliance mapping, lateral movement analysis, or attack surface inventory. Not for general code review. +metadata: + related-skills: refactor-engine, openspec-expert --- # Threat Modeler v2 diff --git a/dotfiles/ai-agents/opencode/skills/agentsmd-expert/SKILL.md b/dotfiles/ai-agents/opencode/skills/agentsmd-expert/SKILL.md index ecd5480..d9c37ef 100644 --- a/dotfiles/ai-agents/opencode/skills/agentsmd-expert/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/agentsmd-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: agentsmd-expert -description: AGENTS.md authoring and refinement for OpenCode agent instructions. Use when creating or updating AGENTS.md files, standardizing repository guidelines, clarifying tooling commands, or documenting local skills and constraints for agents. +description: Use when creating or updating AGENTS.md files for OpenCode agents. Invoke for standardizing repository guidelines, clarifying tooling commands, or documenting local skills and constraints. Not for general README or project documentation (use readme-expert instead). license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: specialist scope: implementation output-format: document - related-skills: code-documenter, prompt-engineer, readme-expert + related-skills: doc-forge, prompt-engineer, readme-expert --- # AGENTS.md Expert diff --git a/dotfiles/ai-agents/opencode/skills/budget-guard/SKILL.md b/dotfiles/ai-agents/opencode/skills/budget-guard/SKILL.md index a30a8d2..c2dfc99 100644 --- a/dotfiles/ai-agents/opencode/skills/budget-guard/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/budget-guard/SKILL.md @@ -1,6 +1,8 @@ --- name: budget-guard -description: Deterministic cost-control guardrail for OpenCode CLI and agentic workflows. Enforces strict token/tool/attempt budgets, cost-optimized routing, state memory discipline, hash-based rescan prevention, search storm protection, CI-safe termination, and explicit build/test confirmation. +description: Use when an OpenCode task must run within strict cost or token limits. Invoke to enforce hard budgets, prevent search storms, or gate CI-safe termination. Do not use for session-level multi-task budget governance (use budget-supervisor instead). +metadata: + related-skills: budget-supervisor --- # Budget Guard v2.1 diff --git a/dotfiles/ai-agents/opencode/skills/budget-supervisor/SKILL.md b/dotfiles/ai-agents/opencode/skills/budget-supervisor/SKILL.md index 962af2a..dfd8627 100644 --- a/dotfiles/ai-agents/opencode/skills/budget-supervisor/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/budget-supervisor/SKILL.md @@ -1,6 +1,8 @@ --- name: budget-supervisor -description: Production-grade session-level FinOps governor for OpenCode CLI. Authoritatively enforces rolling token/tool/time budgets across tasks and sub-agents, allocates per-task envelopes, resolves tier conflicts, locks/unlocks escalation, detects spend drift, logs overrides, and activates/parameterizes budget-guard deterministically per task. +description: Use when governing token, tool, and time budgets across a full OpenCode session or multiple sub-agents. Invoke to allocate per-task envelopes, resolve tier conflicts, or audit override logs. Do not use for single-task cost control (use budget-guard instead). +metadata: + related-skills: budget-guard --- # Budget Enforcement Supervisor (BES) v2 diff --git a/dotfiles/ai-agents/opencode/skills/chaos-engineer/SKILL.md b/dotfiles/ai-agents/opencode/skills/chaos-engineer/SKILL.md index 66aab62..f4aa930 100644 --- a/dotfiles/ai-agents/opencode/skills/chaos-engineer/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/chaos-engineer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: implementation output-format: code - related-skills: sre-engineer, devops-engineer, kubernetes-specialist + related-skills: devops-engineer, kubernetes-specialist --- # Chaos Engineer diff --git a/dotfiles/ai-agents/opencode/skills/clean-code-master/SKILL.md b/dotfiles/ai-agents/opencode/skills/clean-code-master/SKILL.md index 65d193f..28ab888 100644 --- a/dotfiles/ai-agents/opencode/skills/clean-code-master/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/clean-code-master/SKILL.md @@ -1,6 +1,6 @@ --- name: clean-code-master -description: Deterministic, language-agnostic clean code governance engine. Audits complexity, enforces maintainability budgets, classifies technical debt, and produces measurable refactor plans with CI enforcement support. +description: Use when auditing code quality, measuring complexity, or planning technical debt reduction. Invoke for SOLID violations, naming convention enforcement, code smell detection, or maintainability budget reviews. Not for one-off formatting fixes. license: MIT metadata: version: "2.0.0" @@ -11,7 +11,7 @@ metadata: ci-enforced: true deterministic: true triggers: clean code, complexity, maintainability, refactor, technical debt, SOLID, code smells, architecture hygiene - related-skills: refactor-engine, code-reviewer, test-master, doc-forge, openspec-expert, security-reviewer + related-skills: refactor-engine, the-fool, test-forge, doc-forge, openspec-expert, threat-modeler --- # Clean Code Master v2.0 diff --git a/dotfiles/ai-agents/opencode/skills/deep-research/SKILL.md b/dotfiles/ai-agents/opencode/skills/deep-research/SKILL.md index 06f7778..219fc1e 100644 --- a/dotfiles/ai-agents/opencode/skills/deep-research/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/deep-research/SKILL.md @@ -1,6 +1,6 @@ --- name: deep-research -description: Multi-agent deep research orchestration workflow for parallel data collection, analysis, and report synthesis. +description: Use when a task requires broad evidence gathering, parallel research streams, or synthesis across multiple sources before writing specs or plans. Invoke for multi-domain analysis, requirements extraction, or competitive research. Not for single-file or focused code analysis. license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: expert scope: analysis output-format: document - related-skills: openspec + related-skills: openspec-expert --- # Deep Research @@ -23,7 +23,7 @@ You orchestrate parallel evidence collection, synthesize findings, and extract s - Need high-confidence requirements from mixed sources - Multi-threaded research and synthesis is required -- Preparing structured inputs for the `openspec` skill +- Preparing structured inputs for the `openspec-expert` skill ## Core Workflow @@ -31,7 +31,7 @@ You orchestrate parallel evidence collection, synthesize findings, and extract s 2. Run the research entrypoint and gather sources. 3. Synthesize notes and constraints into structured requirements. 4. Validate output contract artifacts. -5. Hand off `requirements.json` to `openspec`. +5. Hand off `requirements.json` to `openspec-expert`. ### Fast Path (Small Tasks) @@ -79,7 +79,7 @@ Primary entrypoint: `scripts/run_research.sh ""` Handoff to OpenSpec: -`.opencode/skills/openspec/scripts/spec_from_input.sh .research//requirements.json` +`.opencode/skills/openspec-expert/scripts/spec_from_input.sh .research//requirements.json` ## Knowledge Reference diff --git a/dotfiles/ai-agents/opencode/skills/devops-engineer/SKILL.md b/dotfiles/ai-agents/opencode/skills/devops-engineer/SKILL.md index 166cd06..e5658bc 100644 --- a/dotfiles/ai-agents/opencode/skills/devops-engineer/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/devops-engineer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: engineer scope: implementation output-format: code - related-skills: sre-engineer, monitoring-expert, cloud-architect, kubernetes-specialist + related-skills: kubernetes-specialist --- # DevOps Engineer diff --git a/dotfiles/ai-agents/opencode/skills/doc-forge/SKILL.md b/dotfiles/ai-agents/opencode/skills/doc-forge/SKILL.md index f322b24..5f7a41e 100644 --- a/dotfiles/ai-agents/opencode/skills/doc-forge/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/doc-forge/SKILL.md @@ -1,6 +1,8 @@ --- name: doc-forge -description: Enterprise-grade documentation and workflow analysis skill for complex codebases. Generates inline docs, structured documents, ADRs, traceability matrices, delta updates, and deterministic diagrams with evidence tagging. +description: Use when generating or updating inline code docs, architecture decision records, workflow diagrams, or traceability matrices for a complex codebase. Invoke for ADRs, Mermaid diagrams, delta doc updates, or glossary extraction. Not for README or AGENTS.md authoring. +metadata: + related-skills: readme-expert, agentsmd-expert, spec-miner --- # Doc Forge v2 diff --git a/dotfiles/ai-agents/opencode/skills/interactive-plan/SKILL.md b/dotfiles/ai-agents/opencode/skills/interactive-plan/SKILL.md index c94373a..4614430 100644 --- a/dotfiles/ai-agents/opencode/skills/interactive-plan/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/interactive-plan/SKILL.md @@ -1,6 +1,6 @@ --- name: interactive-plan -description: Interactive production planning with decision questions, task list, clean code, and test strategy. +description: Use when a task requires upfront clarifying questions before work begins, or when an explicit task list with test strategy is needed. Invoke for ambiguous requirements, multi-step features, or planning sessions before coding starts. license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: expert scope: planning output-format: document - related-skills: test-master, architecture-designer + related-skills: test-forge --- # Interactive Plan diff --git a/dotfiles/ai-agents/opencode/skills/kubernetes-specialist/SKILL.md b/dotfiles/ai-agents/opencode/skills/kubernetes-specialist/SKILL.md index cf7ca1a..208ee59 100644 --- a/dotfiles/ai-agents/opencode/skills/kubernetes-specialist/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/kubernetes-specialist/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: infrastructure output-format: manifests - related-skills: devops-engineer, cloud-architect, sre-engineer + related-skills: devops-engineer --- # Kubernetes Specialist diff --git a/dotfiles/ai-agents/opencode/skills/legacy-modernizer/SKILL.md b/dotfiles/ai-agents/opencode/skills/legacy-modernizer/SKILL.md index 4161ea6..6348aec 100644 --- a/dotfiles/ai-agents/opencode/skills/legacy-modernizer/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/legacy-modernizer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: architecture output-format: code+analysis - related-skills: test-master, devops-engineer, spec-miner + related-skills: test-forge, devops-engineer, spec-miner --- # Legacy Modernizer diff --git a/dotfiles/ai-agents/opencode/skills/monorepo-navigator/SKILL.md b/dotfiles/ai-agents/opencode/skills/monorepo-navigator/SKILL.md index 255913d..1bef6ce 100644 --- a/dotfiles/ai-agents/opencode/skills/monorepo-navigator/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/monorepo-navigator/SKILL.md @@ -1,6 +1,8 @@ --- name: monorepo-navigator -description: Enterprise-grade architectural intelligence and monorepo analysis skill. Builds deterministic module graphs, computes structural metrics, detects architectural drift, bounded contexts, cycles, ownership boundaries, blast radius, and produces CI-ready reports and diagrams with strict scope control. +description: Use when analyzing architecture, ownership boundaries, or dependency cycles across a monorepo. Invoke for module graph generation, blast-radius estimation, bounded context detection, or architectural drift reports. Not for single-package analysis. +metadata: + related-skills: refactor-engine, clean-code-master --- # Monorepo Navigator v2 diff --git a/dotfiles/ai-agents/opencode/skills/mvp-watcher/SKILL.md b/dotfiles/ai-agents/opencode/skills/mvp-watcher/SKILL.md index efe1432..a67980f 100644 --- a/dotfiles/ai-agents/opencode/skills/mvp-watcher/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/mvp-watcher/SKILL.md @@ -1,6 +1,6 @@ --- name: mvp-watcher -description: Deterministic scope-discipline governance skill. Detects scope creep, premature abstraction, unnecessary flexibility, and non-MVP expansion. Enforces value-to-complexity ratio and cost containment. +description: Use when reviewing a plan, PR, or feature for scope creep, premature abstraction, or non-MVP complexity. Invoke before or during implementation to enforce value-to-complexity discipline and contain cost. Not for post-release retrospectives. license: MIT metadata: version: "1.2.0" diff --git a/dotfiles/ai-agents/opencode/skills/openspec-expert/SKILL.md b/dotfiles/ai-agents/opencode/skills/openspec-expert/SKILL.md index a126e2f..cc44221 100644 --- a/dotfiles/ai-agents/opencode/skills/openspec-expert/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/openspec-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-expert -description: Enterprise-grade OpenSpec governance engine with deterministic generation, risk-tier gating, automated quality scoring, version enforcement, diff intelligence, structured artifact emission, and CI-safe validation workflows. +description: Use when creating, validating, or governing functional requirement specifications in OpenSpec format. Invoke for spec generation from inputs, risk-tier classification, quality scoring, version enforcement, diff analysis, or CI gate execution. Not for inline code documentation (use doc-forge instead). license: MIT metadata: author: https://github.com/Jeffallan @@ -12,7 +12,7 @@ metadata: risk-aware: true ci-safe: true artifact-emission: true - related-skills: deep-research, refactor-engine, threat-modeler, test-forge, analysis-cache + related-skills: deep-research, refactor-engine, threat-modeler, test-forge, perf-analyst --- # OpenSpec Expert v2.1 diff --git a/dotfiles/ai-agents/opencode/skills/perf-analyst/SKILL.md b/dotfiles/ai-agents/opencode/skills/perf-analyst/SKILL.md index d1a6611..398f1ef 100644 --- a/dotfiles/ai-agents/opencode/skills/perf-analyst/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/perf-analyst/SKILL.md @@ -1,6 +1,8 @@ --- name: perf-analyst -description: Enterprise-grade performance engineering skill for agentic coding. Performs deterministic hotspot analysis, workload modeling, capacity estimation, tail-latency analysis, backpressure evaluation, scaling risk detection, and produces minimal safe optimization patches with measurement and rollout plans. +description: Use when diagnosing performance bottlenecks, modeling capacity, or producing optimization patches with measurement plans. Invoke for tail-latency issues, backpressure analysis, scaling risk, or workload modeling. Not for general code quality review. +metadata: + related-skills: refactor-engine, clean-code-master --- # Perf Analyst v2 diff --git a/dotfiles/ai-agents/opencode/skills/playwright-expert/SKILL.md b/dotfiles/ai-agents/opencode/skills/playwright-expert/SKILL.md index 434fc70..13a4fe1 100644 --- a/dotfiles/ai-agents/opencode/skills/playwright-expert/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/playwright-expert/SKILL.md @@ -10,7 +10,7 @@ metadata: role: specialist scope: testing output-format: code - related-skills: test-master, devops-engineer, javascript-pro, typescript-pro, fullstack-guardian + related-skills: test-forge, devops-engineer --- # Playwright Expert diff --git a/dotfiles/ai-agents/opencode/skills/prompt-engineer/SKILL.md b/dotfiles/ai-agents/opencode/skills/prompt-engineer/SKILL.md index 9670075..ecf8e83 100644 --- a/dotfiles/ai-agents/opencode/skills/prompt-engineer/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/prompt-engineer/SKILL.md @@ -10,7 +10,7 @@ metadata: role: expert scope: design output-format: document - related-skills: test-master, fine-tuning-expert, rag-architect + related-skills: test-forge --- # Prompt Engineer diff --git a/dotfiles/ai-agents/opencode/skills/readme-expert/SKILL.md b/dotfiles/ai-agents/opencode/skills/readme-expert/SKILL.md index b8ce43b..ab3e213 100644 --- a/dotfiles/ai-agents/opencode/skills/readme-expert/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/readme-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: readme-expert -description: README authoring and refinement for software projects. Use when creating or updating README.md files, improving project documentation structure, clarifying installation/usage, adding badges, or standardizing contribution and support sections. +description: Use when creating or updating README.md files for software projects. Invoke for improving documentation structure, clarifying installation or usage steps, adding badges, or standardizing contribution and support sections. Not for AGENTS.md or architecture documentation. license: MIT metadata: author: https://github.com/Jeffallan @@ -10,7 +10,7 @@ metadata: role: specialist scope: implementation output-format: document - related-skills: code-documenter, prompt-engineer, agentsmd-expert + related-skills: doc-forge, prompt-engineer, agentsmd-expert --- # README Expert diff --git a/dotfiles/ai-agents/opencode/skills/refactor-engine/SKILL.md b/dotfiles/ai-agents/opencode/skills/refactor-engine/SKILL.md index 29334a5..f33fbdc 100644 --- a/dotfiles/ai-agents/opencode/skills/refactor-engine/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/refactor-engine/SKILL.md @@ -1,6 +1,8 @@ --- name: refactor-engine -description: Enterprise-grade architectural evolution and safe refactoring skill. Performs deterministic behavioral-invariant modeling, API surface protection, dependency graph validation, blast-radius estimation, semantic diff analysis, migration sequencing, and cross-skill safety orchestration. Designed for large monorepos and modular SaaS systems. +description: Use when planning or executing large-scale refactoring with behavioral-invariant preservation. Invoke for API surface protection, blast-radius estimation, migration sequencing, or architectural boundary enforcement in monorepos. Not for small cosmetic cleanups. +metadata: + related-skills: monorepo-navigator, test-forge, threat-modeler, perf-analyst --- # Refactor Engine v2 diff --git a/dotfiles/ai-agents/opencode/skills/spec-miner/SKILL.md b/dotfiles/ai-agents/opencode/skills/spec-miner/SKILL.md index 404cc8a..99f85d8 100644 --- a/dotfiles/ai-agents/opencode/skills/spec-miner/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/spec-miner/SKILL.md @@ -11,7 +11,7 @@ metadata: role: specialist scope: review output-format: document - related-skills: fullstack-guardian, architecture-designer, code-documenter, code-reviewer, legacy-modernizer + related-skills: doc-forge, the-fool, legacy-modernizer --- # Spec Miner diff --git a/dotfiles/ai-agents/opencode/skills/test-forge/SKILL.md b/dotfiles/ai-agents/opencode/skills/test-forge/SKILL.md index b960c26..88b6421 100644 --- a/dotfiles/ai-agents/opencode/skills/test-forge/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/test-forge/SKILL.md @@ -1,6 +1,8 @@ --- name: test-forge -description: Elite production-grade test generation and strategy skill for agentic coding. Produces deterministic unit/integration/contract/property-based tests, diff-driven updates, branch/path coverage modeling, mutation-sensitive assertions, failure injection, compatibility guards, and test architecture governance. CI-ready and scope-tiered. +description: Use when generating or updating tests for changed behavior, designing test architecture, or building coverage for complex flows. Invoke for unit, integration, contract, or property-based tests, diff-driven updates, or mutation-sensitive assertions. Not for E2E browser tests (use playwright-expert). +metadata: + related-skills: playwright-expert, openspec-expert --- # Test Forge v2 diff --git a/dotfiles/ai-agents/opencode/skills/the-fool/SKILL.md b/dotfiles/ai-agents/opencode/skills/the-fool/SKILL.md index a266401..34d8156 100644 --- a/dotfiles/ai-agents/opencode/skills/the-fool/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/the-fool/SKILL.md @@ -10,7 +10,7 @@ metadata: role: expert scope: review output-format: report - related-skills: architecture-designer, code-reviewer, security-reviewer + related-skills: clean-code-master, threat-modeler --- # The Fool diff --git a/dotfiles/ai-agents/opencode/skills/threat-modeler/SKILL.md b/dotfiles/ai-agents/opencode/skills/threat-modeler/SKILL.md index abc47bb..95e2385 100644 --- a/dotfiles/ai-agents/opencode/skills/threat-modeler/SKILL.md +++ b/dotfiles/ai-agents/opencode/skills/threat-modeler/SKILL.md @@ -1,6 +1,8 @@ --- name: threat-modeler -description: Enterprise-grade security posture engineering skill for agentic coding. Performs asset-centric threat modeling, quantitative risk scoring, trust-boundary mapping, lateral movement analysis, compliance mapping, and produces actionable mitigations with verification hooks. Deterministic, evidence-tagged, and scope-tiered. +description: Use when assessing security posture, mapping trust boundaries, or producing risk-scored mitigations for a system. Invoke for threat modeling sessions, compliance mapping, lateral movement analysis, or attack surface inventory. Not for general code review. +metadata: + related-skills: refactor-engine, openspec-expert --- # Threat Modeler v2 diff --git a/packages/opencode-tools/README.md b/packages/opencode-tools/README.md new file mode 100644 index 0000000..66a7363 --- /dev/null +++ b/packages/opencode-tools/README.md @@ -0,0 +1,65 @@ +# opencode-tools + +Custom runtime tools for OpenCode agent sessions. + +## Overview + +This package is the **source of truth** for custom OpenCode tools. The TypeScript +sources here are compiled and deployed into the OpenCode tools directory as part of +the bootstrap process. + +``` +packages/opencode-tools/src/ ← author changes here +packages/opencode-tools/dist/ ← compiled output (generated, not committed) +dotfiles/ai-agents/opencode/tools/ ← deployed artifacts (synced by bootstrap) +~/.config/opencode/tools/ ← live runtime location (linked by install.sh) +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `patch-validator` | Validates unified diff patches against the minimal-mutation governance contract | +| `analysis-cache` | Deterministic structural artifact cache for expensive analysis reuse | + +## Shared Utilities + +| Module | Description | +|--------|-------------| +| `shared/logger` | Structured stderr logger | +| `shared/fs-utils` | Safe filesystem helpers (null-on-ENOENT) | +| `shared/diagnostics` | CheckResult/DiagnosticsReport types and printers | + +## Development + +```bash +# Install dependencies +npm install + +# Type-check only (no output) +npm run lint + +# Compile to dist/ +npm run build + +# Clean build artifacts +npm run clean +``` + +## Runtime Contract + +Each tool exports a default `tool({...})` object compatible with the OpenCode plugin +API. At runtime, `@opencode-ai/plugin` is provided by the OpenCode host environment. +During local development and CI, a vendor type stub satisfies the TypeScript compiler. + +## Deployment + +The bootstrap installer (`bootstrap/opencode/install.sh`) copies the `.ts` source +files from `src/` to `dotfiles/ai-agents/opencode/tools/`, which the dotfiles +installer then links to `~/.config/opencode/tools/`. + +Run the doctor to verify deployment: + +```bash +bash bootstrap/opencode/doctor.sh +``` diff --git a/packages/opencode-tools/package-lock.json b/packages/opencode-tools/package-lock.json new file mode 100644 index 0000000..aa2c6a8 --- /dev/null +++ b/packages/opencode-tools/package-lock.json @@ -0,0 +1,80 @@ +{ + "name": "opencode-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-tools", + "version": "1.0.0", + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@opencode-ai/plugin": "file:src/vendor/opencode-ai-plugin-stub", + "@types/node": "^22.0.0", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*" + }, + "peerDependenciesMeta": { + "@opencode-ai/plugin": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin": { + "resolved": "src/vendor/opencode-ai-plugin-stub", + "link": true + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "src/vendor/opencode-ai-plugin-stub": { + "name": "@opencode-ai/plugin", + "version": "0.0.0", + "dev": true + } + } +} diff --git a/packages/opencode-tools/package.json b/packages/opencode-tools/package.json new file mode 100644 index 0000000..0e2aad3 --- /dev/null +++ b/packages/opencode-tools/package.json @@ -0,0 +1,31 @@ +{ + "name": "opencode-tools", + "version": "1.0.0", + "description": "Custom runtime tools for OpenCode agent sessions", + "type": "module", + "scripts": { + "build": "tsc --project tsconfig.json", + "lint": "tsc --noEmit --project tsconfig.json", + "clean": "rm -rf dist", + "smoke": "node dist/patch-validator.js --version 2>/dev/null || node -e \"import('./dist/patch-validator.js').then(() => console.log('patch-validator: ok')).catch(e => { console.error('patch-validator: FAIL', e.message); process.exit(1); })\"" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*" + }, + "peerDependenciesMeta": { + "@opencode-ai/plugin": { + "optional": true + } + }, + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@opencode-ai/plugin": "file:src/vendor/opencode-ai-plugin-stub", + "@types/node": "^22.0.0", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/opencode-tools/src/analysis-cache.ts b/packages/opencode-tools/src/analysis-cache.ts new file mode 100644 index 0000000..c295a19 --- /dev/null +++ b/packages/opencode-tools/src/analysis-cache.ts @@ -0,0 +1,432 @@ +import { tool } from "@opencode-ai/plugin"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import { z } from "zod"; + +type CacheAction = "lookup" | "store" | "invalidate" | "stats" | "prune"; + +type CacheEntry = { + schema_version: 2; + namespace: string; + namespace_original: string; + cache_id: string; + key_hash: string; + key_preview: string; + created_at: string; + expires_at: string | null; + artifact: string; + metadata?: unknown; +}; + +type CacheMetadataRecord = Record; + +const SCHEMA_VERSION = 2; +const DEFAULT_CACHE_DIR = ".opencode/cache/analysis-cache"; +const DEFAULT_MAX_BYTES = 256 * 1024; +const DEFAULT_TTL_SEC = 0; + +function nowIso(): string { + return new Date().toISOString(); +} + +function sha256(data: string): string { + return crypto.createHash("sha256").update(data).digest("hex"); +} + +function stableStringify(value: unknown, seen = new WeakSet()): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + + const valueType = typeof value; + + if (valueType === "string") return JSON.stringify(value); + if (valueType === "number") { + if (Number.isNaN(value)) return '"[NaN]"'; + if (value === Infinity) return '"[Infinity]"'; + if (value === -Infinity) return '"[-Infinity]"'; + return JSON.stringify(value); + } + if (valueType === "boolean") return JSON.stringify(value); + if (valueType === "bigint") return `{"$bigint":"${String(value)}"}`; + if (valueType === "symbol") return `{"$symbol":${JSON.stringify(String(value))}}`; + if (valueType === "function") { + throw new Error("Cannot serialize function in stableStringify"); + } + + if (value instanceof Date) { + return `{"$date":${JSON.stringify(value.toISOString())}}`; + } + + if (Buffer.isBuffer(value)) { + return `{"$buffer":${JSON.stringify(value.toString("base64"))}}`; + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item, seen)).join(",")}]`; + } + + if (valueType === "object") { + const obj = value as Record; + + if (seen.has(obj)) { + throw new Error("Cannot serialize cyclic structure"); + } + + seen.add(obj); + try { + const keys = Object.keys(obj).sort(); + const items = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key], seen)}`); + return `{${items.join(",")}}`; + } finally { + seen.delete(obj); + } + } + + return JSON.stringify(value); +} + +function sanitizeSegment(seg: string): string { + const cleaned = seg.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80); + return cleaned.length ? cleaned : "default"; +} + +function namespaceDirName(namespace: string): string { + const sanitized = sanitizeSegment(namespace); + const suffix = sha256(namespace).slice(0, 10); + return `${sanitized}__${suffix}`; +} + +function resolveBaseDir(worktree?: string, directory?: string): string { + return worktree ?? directory ?? process.cwd(); +} + +function getCacheRoot(worktree?: string, directory?: string): string { + const baseDir = resolveBaseDir(worktree, directory); + const env = process.env.OPENCODE_ANALYSIS_CACHE_DIR?.trim(); + + if (env) { + return path.isAbsolute(env) ? env : path.resolve(baseDir, env); + } + + return path.resolve(baseDir, DEFAULT_CACHE_DIR); +} + +function entryPath(worktree: string | undefined, directory: string | undefined, namespace: string, cacheId: string): string { + const root = getCacheRoot(worktree, directory); + const ns = namespaceDirName(namespace); + const shard = cacheId.slice(0, 2); + return path.join(root, ns, shard, `${cacheId}.json`); +} + +function toRootRelativePath(filePath: string, worktree?: string, directory?: string): string { + const root = getCacheRoot(worktree, directory); + return path.relative(root, filePath); +} + +async function ensureDir(dir: string): Promise { + await fsp.mkdir(dir, { recursive: true }); +} + +function computeKeyHash(namespace: string, key: string, keyExtra: unknown): string { + const composite = `${namespace}\n${key}\n${stableStringify(keyExtra)}`; + return sha256(composite); +} + +function computeExpiresAt(ttlSec: number): string | null { + if (!ttlSec || ttlSec <= 0) return null; + return new Date(Date.now() + ttlSec * 1000).toISOString(); +} + +function isExpired(entry: CacheEntry): boolean { + if (!entry.expires_at) return false; + return Date.now() > new Date(entry.expires_at).getTime(); +} + +function metadataMatches(entryMetadata: unknown, requested: Record | undefined): boolean { + if (!requested || Object.keys(requested).length === 0) return true; + if (!entryMetadata || typeof entryMetadata !== "object" || Array.isArray(entryMetadata)) return false; + + const record = entryMetadata as CacheMetadataRecord; + return Object.entries(requested).every(([key, value]) => { + return stableStringify(record[key]) === stableStringify(value); + }); +} + +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const dir = path.dirname(filePath); + await ensureDir(dir); + + const tmp = path.join(dir, `.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}.json`); + + await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { + encoding: "utf8", + mode: 0o600, + }); + + await fsp.rename(tmp, filePath); +} + +async function safeReadJson(filePath: string): Promise { + try { + const text = await fsp.readFile(filePath, "utf8"); + return JSON.parse(text) as T; + } catch (e: unknown) { + if (typeof e === "object" && e !== null && "code" in e && (e as { code?: string }).code === "ENOENT") { + return null; + } + throw e; + } +} + +async function safeStat(filePath: string): Promise { + try { + return await fsp.stat(filePath); + } catch (e: unknown) { + if (typeof e === "object" && e !== null && "code" in e && (e as { code?: string }).code === "ENOENT") { + return null; + } + throw e; + } +} + +async function safeUnlink(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (e: unknown) { + if (typeof e === "object" && e !== null && "code" in e && (e as { code?: string }).code === "ENOENT") { + return false; + } + throw e; + } +} + +async function listFilesRecursive(dir: string): Promise { + const out: string[] = []; + + async function walk(currentDir: string): Promise { + let items: fs.Dirent[]; + + try { + items = await fsp.readdir(currentDir, { withFileTypes: true }); + } catch (e: unknown) { + if (typeof e === "object" && e !== null && "code" in e && (e as { code?: string }).code === "ENOENT") { + return; + } + throw e; + } + + for (const item of items) { + const fullPath = path.join(currentDir, item.name); + if (item.isDirectory()) { + await walk(fullPath); + } else if (item.isFile() && item.name.endsWith(".json")) { + out.push(fullPath); + } + } + } + + await walk(dir); + return out; +} + +async function collectNamespaceFiles(worktree: string | undefined, directory: string | undefined, namespace: string): Promise { + const root = getCacheRoot(worktree, directory); + const nsDir = path.join(root, namespaceDirName(namespace)); + return listFilesRecursive(nsDir); +} + +export default tool({ + description: "Deterministic structural artifact cache", + args: { + action: z.enum(["lookup", "store", "invalidate", "stats", "prune"] satisfies [CacheAction, ...CacheAction[]]), + namespace: z.string().optional(), + key: z.string().optional(), + key_extra: z.unknown().optional(), + ttl_sec: z.number().int().nonnegative().optional(), + max_bytes: z.number().int().positive().optional(), + artifact: z.string().optional(), + metadata: z.unknown().optional(), + key_prefix: z.string().optional(), + metadata_match: z.record(z.string(), z.unknown()).optional(), + prune_expired: z.boolean().optional(), + }, + + async execute(args, context) { + const namespaceOriginal = args.namespace ?? "default"; + const namespace = namespaceOriginal; + const maxBytes = args.max_bytes ?? DEFAULT_MAX_BYTES; + const ttlSec = args.ttl_sec ?? DEFAULT_TTL_SEC; + const worktree = context.worktree; + const directory = context.directory; + + if (args.action === "stats") { + const files = await collectNamespaceFiles(worktree, directory, namespace); + + let totalBytes = 0; + let count = 0; + let expired = 0; + let pruned = 0; + + for (const filePath of files) { + const stat = await safeStat(filePath); + if (!stat) continue; + + const entry = await safeReadJson(filePath); + if (!entry) continue; + + totalBytes += stat.size; + count++; + + if (isExpired(entry)) { + expired++; + if (args.prune_expired) { + if (await safeUnlink(filePath)) { + pruned++; + totalBytes -= stat.size; + count--; + } + } + } + } + + return { + ok: true, + namespace, + root: getCacheRoot(worktree, directory), + entries: count, + total_bytes: totalBytes, + expired_entries: expired, + pruned_entries: pruned, + }; + } + + if (args.action === "prune") { + const files = await collectNamespaceFiles(worktree, directory, namespace); + let pruned = 0; + + for (const filePath of files) { + const entry = await safeReadJson(filePath); + if (!entry) continue; + + if (isExpired(entry)) { + if (await safeUnlink(filePath)) { + pruned++; + } + } + } + + return { + ok: true, + namespace, + pruned_entries: pruned, + }; + } + + if (args.action === "invalidate") { + const files = await collectNamespaceFiles(worktree, directory, namespace); + const keyPrefix = args.key_prefix ?? ""; + const metadataMatch = args.metadata_match; + + let deleted = 0; + + for (const filePath of files) { + const entry = await safeReadJson(filePath); + if (!entry) continue; + + const keyMatches = !keyPrefix || entry.key_preview.startsWith(keyPrefix); + const metadataOk = metadataMatches(entry.metadata, metadataMatch); + + if (keyMatches && metadataOk) { + if (await safeUnlink(filePath)) { + deleted++; + } + } + } + + return { + ok: true, + namespace, + deleted, + criteria: { + key_prefix: keyPrefix || undefined, + metadata_match: metadataMatch, + }, + }; + } + + if (!args.key) { + throw new Error("Missing key"); + } + + const keyHash = computeKeyHash(namespace, args.key, args.key_extra ?? null); + const cacheId = keyHash; + const filePath = entryPath(worktree, directory, namespace, cacheId); + + if (args.action === "lookup") { + const entry = await safeReadJson(filePath); + + if (!entry) { + return { + ok: true, + cache_hit: false, + }; + } + + if (isExpired(entry)) { + await safeUnlink(filePath); + return { + ok: true, + cache_hit: false, + expired: true, + }; + } + + return { + ok: true, + cache_hit: true, + entry, + }; + } + + if (args.action === "store") { + if (typeof args.artifact !== "string") { + throw new Error("Missing artifact (string)"); + } + + const bytes = Buffer.byteLength(args.artifact, "utf8"); + if (bytes > maxBytes) { + throw new Error(`Artifact exceeds max_bytes: ${bytes} > ${maxBytes}`); + } + + const entry: CacheEntry = { + schema_version: SCHEMA_VERSION, + namespace: namespaceDirName(namespace), + namespace_original: namespace, + cache_id: cacheId, + key_hash: keyHash, + key_preview: args.key.slice(0, 200), + created_at: nowIso(), + expires_at: computeExpiresAt(ttlSec), + artifact: args.artifact, + metadata: args.metadata, + }; + + await atomicWriteJson(filePath, entry); + + return { + ok: true, + stored: true, + namespace, + cache_id: cacheId, + relative_path: toRootRelativePath(filePath, worktree, directory), + bytes, + expires_at: entry.expires_at, + }; + } + + throw new Error(`Unknown action: ${args.action satisfies never}`); + }, +}); diff --git a/packages/opencode-tools/src/patch-validator.ts b/packages/opencode-tools/src/patch-validator.ts new file mode 100644 index 0000000..76919ea --- /dev/null +++ b/packages/opencode-tools/src/patch-validator.ts @@ -0,0 +1,359 @@ +import { tool } from "@opencode-ai/plugin"; +import { z } from "zod"; + +type Severity = "error" | "warning"; +type FileStatus = "added" | "deleted" | "modified" | "renamed" | "unknown"; + +type Violation = { + type: string; + severity: Severity; + file?: string; + status?: FileStatus; + percent_changed?: number; + message?: string; +}; + +type FileReport = { + file: string; + status: FileStatus; + added: number; + removed: number; + unchanged: number; + percent_changed: number; + violations: Violation[]; +}; + +type ParsedFilePatch = { + raw: string; + file: string; + status: FileStatus; + added: number; + removed: number; + unchanged: number; + addedLines: string[]; + removedLines: string[]; + violations: Violation[]; +}; + +function normalizeLine(line: string): string { + return line.trim().replace(/\s+/g, " "); +} + +function isImport(line: string): boolean { + return /^(\s*)(import\s+.*(?:from\s+["'][^"']+["']|["'][^"']+["'])\s*;?|\s*using\s+.+;?\s*$)/.test(line); +} + +function isGitDiffPatch(patch: string): boolean { + return /^diff --git /m.test(patch); +} + +function isDiffMetadataLine(line: string): boolean { + return ( + line.startsWith("diff --git ") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("@@") || + line.startsWith("new file mode ") || + line.startsWith("deleted file mode ") || + line.startsWith("similarity index ") || + line.startsWith("rename from ") || + line.startsWith("rename to ") + ); +} + +function multisetEquals(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + + const counts = new Map(); + for (const item of a) { + counts.set(item, (counts.get(item) ?? 0) + 1); + } + + for (const item of b) { + const current = counts.get(item); + if (!current) return false; + if (current === 1) counts.delete(item); + else counts.set(item, current - 1); + } + + return counts.size === 0; +} + +function parseGitDiffFiles(patch: string): string[] { + return patch + .split(/^diff --git /m) + .filter(Boolean) + .map((part) => `diff --git ${part}`); +} + +function parsePlainUnifiedDiffFiles(patch: string): string[] { + const lines = patch.split("\n"); + const files: string[] = []; + let current: string[] = []; + + for (const line of lines) { + if (line.startsWith("--- ") && current.length > 0) { + files.push(current.join("\n")); + current = [line]; + continue; + } + + current.push(line); + } + + if (current.length > 0) { + files.push(current.join("\n")); + } + + return files.filter((file) => /^--- /m.test(file) && /^\+\+\+ /m.test(file)); +} + +function inferFileStatus(filePatch: string): FileStatus { + if (/^rename from /m.test(filePatch) || /^rename to /m.test(filePatch)) return "renamed"; + if (/^new file mode /m.test(filePatch)) return "added"; + if (/^deleted file mode /m.test(filePatch)) return "deleted"; + + const oldFile = filePatch.match(/^--- (.+)$/m)?.[1]; + const newFile = filePatch.match(/^\+\+\+ (.+)$/m)?.[1]; + + if (oldFile === "/dev/null") return "added"; + if (newFile === "/dev/null") return "deleted"; + if (oldFile && newFile) return "modified"; + + return "unknown"; +} + +function inferFileName(filePatch: string): string { + const renameTo = filePatch.match(/^rename to (.+)$/m)?.[1]; + if (renameTo) return renameTo.replace(/^b\//, ""); + + const newFile = filePatch.match(/^\+\+\+ (.+)$/m)?.[1]; + if (newFile && newFile !== "/dev/null") return newFile.replace(/^b\//, ""); + + const oldFile = filePatch.match(/^--- (.+)$/m)?.[1]; + if (oldFile && oldFile !== "/dev/null") return oldFile.replace(/^a\//, ""); + + return "unknown"; +} + +function computePercentChanged(added: number, removed: number, unchanged: number, status: FileStatus): number { + const totalOriginal = removed + unchanged; + + if (status === "added") { + return added > 0 ? 100 : 0; + } + + if (status === "deleted") { + return totalOriginal > 0 ? 100 : 0; + } + + if (totalOriginal === 0) { + return added + removed > 0 ? 100 : 0; + } + + return ((added + removed) / totalOriginal) * 100; +} + +function parseFilePatch(filePatch: string): ParsedFilePatch { + const lines = filePatch.split("\n"); + const addedLines: string[] = []; + const removedLines: string[] = []; + + let added = 0; + let removed = 0; + let unchanged = 0; + + for (const line of lines) { + if (line.startsWith("+") && !line.startsWith("+++")) { + added++; + addedLines.push(line.slice(1)); + continue; + } + + if (line.startsWith("-") && !line.startsWith("---")) { + removed++; + removedLines.push(line.slice(1)); + continue; + } + + if (line.startsWith(" ")) { + unchanged++; + continue; + } + + if (isDiffMetadataLine(line)) { + continue; + } + } + + return { + raw: filePatch, + file: inferFileName(filePatch), + status: inferFileStatus(filePatch), + added, + removed, + unchanged, + addedLines, + removedLines, + violations: [], + }; +} + +export default tool({ + description: "Validates unified diff against strict mutation contract", + args: { + patch: z.string().describe("Unified diff patch to validate"), + max_change_percent: z.number().positive().optional(), + strict: z.boolean().optional(), + allow_new_files: z.boolean().optional(), + allow_deleted_files: z.boolean().optional(), + }, + + async execute(args) { + const patch = args.patch; + const maxChange = args.max_change_percent ?? 30; + const strict = args.strict ?? true; + const allowNewFiles = args.allow_new_files ?? false; + const allowDeletedFiles = args.allow_deleted_files ?? false; + + if (!patch.includes("--- ") || !patch.includes("+++ ") || !patch.includes("@@")) { + return { + valid: false, + percent_changed: 0, + files: [] as FileReport[], + violations: [ + { + type: "invalid-format", + severity: "error", + message: "Not unified diff", + }, + ] satisfies Violation[], + }; + } + + const rawFiles = isGitDiffPatch(patch) ? parseGitDiffFiles(patch) : parsePlainUnifiedDiffFiles(patch); + const parsedFiles = rawFiles.map(parseFilePatch); + + const allViolations: Violation[] = []; + const fileReports: FileReport[] = []; + + let totalAdded = 0; + let totalRemoved = 0; + let totalUnchanged = 0; + + for (const parsed of parsedFiles) { + const percentChanged = computePercentChanged(parsed.added, parsed.removed, parsed.unchanged, parsed.status); + + totalAdded += parsed.added; + totalRemoved += parsed.removed; + totalUnchanged += parsed.unchanged; + + const fileViolations: Violation[] = []; + + if (parsed.status === "added" && !allowNewFiles) { + fileViolations.push({ + type: "new-file", + severity: strict ? "error" : "warning", + file: parsed.file, + status: parsed.status, + percent_changed: percentChanged, + message: "Patch adds a new file", + }); + } + + if (parsed.status === "deleted" && !allowDeletedFiles) { + fileViolations.push({ + type: "deleted-file", + severity: strict ? "error" : "warning", + file: parsed.file, + status: parsed.status, + percent_changed: percentChanged, + message: "Patch deletes a file", + }); + } + + if (parsed.status === "modified" && percentChanged > maxChange) { + fileViolations.push({ + type: "excessive-change", + severity: "error", + file: parsed.file, + status: parsed.status, + percent_changed: percentChanged, + message: `Change exceeds ${maxChange}% threshold`, + }); + } + + const hasNoContext = parsed.unchanged === 0 && parsed.added > 0 && parsed.removed > 0; + const highRewriteRatio = percentChanged >= Math.max(90, maxChange); + + if (parsed.status === "modified" && hasNoContext && highRewriteRatio) { + fileViolations.push({ + type: "full-rewrite-likely", + severity: "error", + file: parsed.file, + status: parsed.status, + percent_changed: percentChanged, + message: "Patch likely rewrites the full file or nearly all of it", + }); + } + + const normAdded = parsed.addedLines.map(normalizeLine); + const normRemoved = parsed.removedLines.map(normalizeLine); + + if (parsed.addedLines.length > 0 && parsed.addedLines.length === parsed.removedLines.length && multisetEquals(normAdded, normRemoved)) { + fileViolations.push({ + type: "whitespace-only", + severity: strict ? "error" : "warning", + file: parsed.file, + status: parsed.status, + percent_changed: percentChanged, + message: "Patch appears whitespace-only", + }); + } + + const removedImports = parsed.removedLines.filter(isImport).map(normalizeLine).sort(); + const addedImports = parsed.addedLines.filter(isImport).map(normalizeLine).sort(); + + if (removedImports.length > 0 && removedImports.length === addedImports.length && multisetEquals(removedImports, addedImports)) { + fileViolations.push({ + type: "import-reorder", + severity: strict ? "error" : "warning", + file: parsed.file, + status: parsed.status, + percent_changed: percentChanged, + message: "Patch appears to only reorder imports", + }); + } + + allViolations.push(...fileViolations); + + fileReports.push({ + file: parsed.file, + status: parsed.status, + added: parsed.added, + removed: parsed.removed, + unchanged: parsed.unchanged, + percent_changed: percentChanged, + violations: fileViolations, + }); + } + + const globalPercentChanged = computePercentChanged(totalAdded, totalRemoved, totalUnchanged, "modified"); + + const blockingViolations = allViolations.filter((violation) => violation.severity === "error"); + + return { + valid: blockingViolations.length === 0, + percent_changed: globalPercentChanged, + totals: { + files: fileReports.length, + added: totalAdded, + removed: totalRemoved, + unchanged: totalUnchanged, + }, + files: fileReports, + violations: allViolations, + }; + }, +}); diff --git a/packages/opencode-tools/src/shared/diagnostics.ts b/packages/opencode-tools/src/shared/diagnostics.ts new file mode 100644 index 0000000..ce2220c --- /dev/null +++ b/packages/opencode-tools/src/shared/diagnostics.ts @@ -0,0 +1,56 @@ +/** + * Diagnostics helpers for opencode-tools. + * + * Provides structured result types used by the doctor/verification layer. + */ + +export type CheckStatus = "ok" | "warn" | "fail"; + +export interface CheckResult { + name: string; + status: CheckStatus; + message: string; + detail?: string; +} + +export interface DiagnosticsReport { + tool: string; + version: string; + timestamp: string; + overall: CheckStatus; + checks: CheckResult[]; +} + +export function check(name: string, status: CheckStatus, message: string, detail?: string): CheckResult { + return { name, status, message, detail }; +} + +export function buildReport(tool: string, version: string, checks: CheckResult[]): DiagnosticsReport { + const overall: CheckStatus = checks.some((c) => c.status === "fail") + ? "fail" + : checks.some((c) => c.status === "warn") + ? "warn" + : "ok"; + + return { + tool, + version, + timestamp: new Date().toISOString(), + overall, + checks, + }; +} + +export function printReport(report: DiagnosticsReport): void { + const icon = report.overall === "ok" ? "✓" : report.overall === "warn" ? "⚠" : "✗"; + process.stdout.write(`\n${icon} ${report.tool} diagnostics: ${report.overall.toUpperCase()}\n\n`); + + for (const c of report.checks) { + const mark = c.status === "ok" ? " ✓" : c.status === "warn" ? " ⚠" : " ✗"; + process.stdout.write(`${mark} ${c.name}: ${c.message}\n`); + if (c.detail) { + process.stdout.write(` ${c.detail}\n`); + } + } + process.stdout.write("\n"); +} diff --git a/packages/opencode-tools/src/shared/fs-utils.ts b/packages/opencode-tools/src/shared/fs-utils.ts new file mode 100644 index 0000000..61d89e0 --- /dev/null +++ b/packages/opencode-tools/src/shared/fs-utils.ts @@ -0,0 +1,86 @@ +/** + * Safe filesystem utilities for opencode-tools. + * + * All functions return null on ENOENT rather than throwing, + * and handle errors explicitly rather than silently swallowing them. + */ + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; + +export async function safeReadText(filePath: string): Promise { + try { + return await fsp.readFile(filePath, "utf8"); + } catch (e: unknown) { + if (isNotFound(e)) return null; + throw e; + } +} + +export async function safeReadJson(filePath: string): Promise { + const text = await safeReadText(filePath); + if (text === null) return null; + return JSON.parse(text) as T; +} + +export async function safeStat(filePath: string): Promise { + try { + return await fsp.stat(filePath); + } catch (e: unknown) { + if (isNotFound(e)) return null; + throw e; + } +} + +export async function safeUnlink(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (e: unknown) { + if (isNotFound(e)) return false; + throw e; + } +} + +export async function ensureDir(dir: string): Promise { + await fsp.mkdir(dir, { recursive: true }); +} + +export async function atomicWriteJson(filePath: string, data: unknown): Promise { + const { randomBytes } = await import("node:crypto"); + const dir = path.dirname(filePath); + await ensureDir(dir); + const tmp = path.join(dir, `.tmp-${process.pid}-${randomBytes(6).toString("hex")}.json`); + await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 }); + await fsp.rename(tmp, filePath); +} + +export async function listFilesRecursive(dir: string): Promise { + const out: string[] = []; + + async function walk(currentDir: string): Promise { + let items: fs.Dirent[]; + try { + items = await fsp.readdir(currentDir, { withFileTypes: true }); + } catch (e: unknown) { + if (isNotFound(e)) return; + throw e; + } + for (const item of items) { + const fullPath = path.join(currentDir, item.name); + if (item.isDirectory()) { + await walk(fullPath); + } else if (item.isFile() && item.name.endsWith(".json")) { + out.push(fullPath); + } + } + } + + await walk(dir); + return out; +} + +function isNotFound(e: unknown): boolean { + return typeof e === "object" && e !== null && "code" in e && (e as { code?: string }).code === "ENOENT"; +} diff --git a/packages/opencode-tools/src/shared/logger.ts b/packages/opencode-tools/src/shared/logger.ts new file mode 100644 index 0000000..a49fdcf --- /dev/null +++ b/packages/opencode-tools/src/shared/logger.ts @@ -0,0 +1,37 @@ +/** + * Structured logger for opencode-tools. + * + * Emits compact, machine-readable log lines to stderr. + * Format: [LEVEL] tool= [key=value ...] + */ + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface LogFields { + [key: string]: string | number | boolean | null | undefined; +} + +function formatFields(fields: LogFields): string { + return Object.entries(fields) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(" "); +} + +function emit(level: LogLevel, tool: string, message: string, fields: LogFields = {}): void { + const parts: string[] = [`[${level.toUpperCase()}]`, `tool=${tool}`, message]; + const extra = formatFields(fields); + if (extra) parts.push(extra); + process.stderr.write(parts.join(" ") + "\n"); +} + +export function createLogger(toolName: string) { + return { + debug: (message: string, fields?: LogFields) => emit("debug", toolName, message, fields), + info: (message: string, fields?: LogFields) => emit("info", toolName, message, fields), + warn: (message: string, fields?: LogFields) => emit("warn", toolName, message, fields), + error: (message: string, fields?: LogFields) => emit("error", toolName, message, fields), + }; +} + +export type Logger = ReturnType; diff --git a/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/index.d.ts b/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/index.d.ts new file mode 100644 index 0000000..132c359 --- /dev/null +++ b/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/index.d.ts @@ -0,0 +1,17 @@ +import type { z } from "zod"; + +export interface ToolContext { + readonly worktree?: string | undefined; + readonly directory?: string | undefined; + readonly [key: string]: unknown; +} + +export type ToolArgs = Record; + +export interface ToolConfig { + description: string; + args: A; + execute(args: z.infer>, context: ToolContext): Promise; +} + +export function tool(config: ToolConfig): ToolConfig; diff --git a/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/index.js b/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/index.js new file mode 100644 index 0000000..d941d8c --- /dev/null +++ b/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/index.js @@ -0,0 +1,15 @@ +/** + * Local development/CI stub for @opencode-ai/plugin. + * + * In a real OpenCode session, this module is provided by the OpenCode host. + * This stub is used only during local builds and CI validation. + */ + +/** + * Registers a tool definition. + * @param {object} config - Tool configuration with description, args, and execute function. + * @returns {object} The tool definition as-is. + */ +export function tool(config) { + return config; +} diff --git a/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/package.json b/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/package.json new file mode 100644 index 0000000..4023b36 --- /dev/null +++ b/packages/opencode-tools/src/vendor/opencode-ai-plugin-stub/package.json @@ -0,0 +1,14 @@ +{ + "name": "@opencode-ai/plugin", + "version": "0.0.0", + "description": "Local stub for @opencode-ai/plugin used during development and CI builds", + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + } +} diff --git a/packages/opencode-tools/tsconfig.json b/packages/opencode-tools/tsconfig.json new file mode 100644 index 0000000..9ff72f5 --- /dev/null +++ b/packages/opencode-tools/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/vendor/opencode-ai-plugin-stub"] +} diff --git a/schemas/command-frontmatter.schema.json b/schemas/command-frontmatter.schema.json new file mode 100644 index 0000000..d49eecd --- /dev/null +++ b/schemas/command-frontmatter.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moeller-projects/dotfiles/schemas/command-frontmatter.schema.json", + "title": "OpenCode Command Frontmatter", + "description": "Schema for the YAML frontmatter in OpenCode command markdown files", + "type": "object", + "required": ["description"], + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + "description": "Short description of what the command does", + "minLength": 10, + "maxLength": 300 + }, + "name": { + "type": "string", + "description": "Optional override for the command name (defaults to filename without extension)" + }, + "tools": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "List of tools this command uses or requires" + }, + "skills": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "List of skills this command invokes" + } + } +} diff --git a/schemas/opencode-config.schema.json b/schemas/opencode-config.schema.json new file mode 100644 index 0000000..e19ce9f --- /dev/null +++ b/schemas/opencode-config.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moeller-projects/dotfiles/schemas/opencode-config.schema.json", + "title": "OpenCode Configuration", + "description": "Schema for opencode.jsonc configuration file", + "type": "object", + "additionalProperties": true, + "properties": { + "$schema": { + "type": "string", + "description": "JSON schema URL" + }, + "theme": { + "type": "string", + "description": "UI theme name" + }, + "autoupdate": { + "type": "boolean", + "description": "Enable automatic updates" + }, + "enabled_providers": { + "type": "array", + "items": { "type": "string" }, + "description": "List of enabled AI providers" + }, + "model": { + "type": "string", + "description": "Default model in provider/model format", + "examples": ["openai/gpt-4o", "anthropic/claude-3-5-sonnet-20241022"] + }, + "provider": { + "type": "object", + "description": "Provider-specific configuration keyed by provider name", + "additionalProperties": { + "type": "object", + "properties": { + "options": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "API key — use {env:VAR} or {file:path} references" + } + } + } + } + } + }, + "permission": { + "type": "object", + "description": "Tool permission overrides. Values: allow | ask | deny", + "additionalProperties": { + "type": "string", + "enum": ["allow", "ask", "deny"] + } + }, + "compaction": { + "type": "object", + "description": "Context compaction settings", + "properties": { + "auto": { "type": "boolean" }, + "prune": { "type": "boolean" }, + "reserved": { "type": "integer", "minimum": 0 } + } + }, + "watcher": { + "type": "object", + "description": "File watcher settings", + "properties": { + "ignore": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns to ignore" + } + } + }, + "formatter": { + "type": "object", + "description": "Code formatter settings", + "additionalProperties": { + "type": "object", + "properties": { + "disabled": { "type": "boolean" } + } + } + }, + "mcp": { + "type": "object", + "description": "MCP server definitions keyed by server name", + "additionalProperties": { + "type": "object", + "required": ["type", "command"], + "properties": { + "type": { + "type": "string", + "enum": ["local", "remote"], + "description": "Server transport type" + }, + "command": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "Command to launch the MCP server" + }, + "enabled": { + "type": "boolean", + "description": "Whether this MCP server is active" + }, + "timeout": { + "type": "integer", + "minimum": 0, + "description": "Connection timeout in milliseconds" + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Environment variables passed to the server process" + } + } + } + } + } +} diff --git a/schemas/skill-metadata.schema.json b/schemas/skill-metadata.schema.json new file mode 100644 index 0000000..998af82 --- /dev/null +++ b/schemas/skill-metadata.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moeller-projects/dotfiles/schemas/skill-metadata.schema.json", + "title": "SKILL.md YAML Frontmatter", + "description": "Schema for the YAML frontmatter block in SKILL.md files", + "type": "object", + "required": ["name", "description"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Unique kebab-case skill identifier", + "pattern": "^[a-z][a-z0-9-]*$", + "minLength": 2, + "maxLength": 60 + }, + "description": { + "type": "string", + "description": "Trigger-oriented description: when to invoke, what not to use it for, expected scope", + "minLength": 20, + "maxLength": 500 + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "examples": ["MIT", "Apache-2.0"] + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "author": { + "type": "string", + "description": "Author identifier or URL" + }, + "version": { + "type": "string", + "description": "Semantic version string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "domain": { + "type": "string", + "description": "Primary knowledge domain", + "examples": ["engineering", "documentation", "security", "testing"] + }, + "triggers": { + "type": "string", + "description": "Comma-separated trigger keywords for auto-discovery" + }, + "role": { + "type": "string", + "description": "Agent role classification", + "enum": ["specialist", "generalist", "reviewer", "planner", "executor", "supervisor"] + }, + "scope": { + "type": "string", + "description": "Scope of operation", + "enum": ["implementation", "analysis", "planning", "review", "documentation", "governance"] + }, + "output-format": { + "type": "string", + "description": "Primary output format", + "examples": ["markdown", "json", "document", "patch", "diagram"] + }, + "related-skills": { + "type": "string", + "description": "Comma-separated related skill names" + } + } + } + } +} diff --git a/scripts/smoke-opencode-tools.sh b/scripts/smoke-opencode-tools.sh new file mode 100644 index 0000000..a6bf2e5 --- /dev/null +++ b/scripts/smoke-opencode-tools.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# scripts/smoke-opencode-tools.sh +# +# Smoke test: verify that the built opencode-tools can be loaded by Node.js. +# +# Usage: +# bash scripts/smoke-opencode-tools.sh [--dist ] +# +# Requires: node >=20 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST="${REPO_ROOT}/packages/opencode-tools/dist" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dist) DIST="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +if [[ ! -d "$DIST" ]]; then + echo "FAIL: dist directory not found: ${DIST}" + echo " Run: cd packages/opencode-tools && npm run build" + exit 1 +fi + +echo "Smoke testing tools in: ${DIST}" +echo "" + +PASS=0 +FAIL=0 + +smoke_tool() { + local name="$1" + local file="${DIST}/${name}.js" + + if [[ ! -f "$file" ]]; then + echo " ✗ ${name}: file not found at ${file}" + FAIL=$(( FAIL + 1 )) + return + fi + + if node --input-type=module \ + -e "import('file://${file}').then(m => { + if (!m.default) { console.error('no default export'); process.exit(1); } + process.exit(0); + }).catch(e => { console.error(e.message); process.exit(1); })" \ + 2>/dev/null; then + echo " ✓ ${name}: loads and exports default" + PASS=$(( PASS + 1 )) + else + # Capture error for display + local err + err="$(node --input-type=module \ + -e "import('file://${file}').then(m => { + if (!m.default) { console.error('no default export'); process.exit(1); } + process.exit(0); + }).catch(e => { console.error(e.message); process.exit(1); })" 2>&1 || true)" + echo " ✗ ${name}: load failed — ${err}" + FAIL=$(( FAIL + 1 )) + fi +} + +smoke_tool "patch-validator" +smoke_tool "analysis-cache" + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" + +if (( FAIL > 0 )); then + exit 1 +fi diff --git a/scripts/validate-opencode-config.sh b/scripts/validate-opencode-config.sh new file mode 100644 index 0000000..3db21a0 --- /dev/null +++ b/scripts/validate-opencode-config.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# scripts/validate-opencode-config.sh +# +# Validates the opencode.jsonc config file against its JSON schema. +# +# Usage: +# bash scripts/validate-opencode-config.sh [--config ] +# +# Requires: node + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# REPO_ROOT may be used by callers that source this script +# shellcheck disable=SC2034 +readonly REPO_ROOT +CONFIG_FILE="${1:-}" + +# Determine config path +if [[ -z "$CONFIG_FILE" ]]; then + if [[ -n "${OPENCODE_CONFIG_HOME:-}" ]]; then + CONFIG_FILE="${OPENCODE_CONFIG_HOME}/opencode.jsonc" + elif [[ "$(uname -s)" == "Darwin" ]]; then + CONFIG_FILE="${HOME}/.config/opencode/opencode.jsonc" + else + CONFIG_FILE="${XDG_CONFIG_HOME:-${HOME}/.config}/opencode/opencode.jsonc" + fi +fi + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "SKIP: opencode.jsonc not found at ${CONFIG_FILE}" + exit 0 +fi + +echo "Validating: ${CONFIG_FILE}" + +node - <<'JS' "$CONFIG_FILE" +const fs = require('fs'); +const path = require('path'); +const file = process.argv[2]; +const raw = fs.readFileSync(file, 'utf8'); + +// String-aware JSONC parser (handles comments and trailing commas) +function parseJsonc(text) { + // Step 1: strip comments while preserving string content + let result = ''; + let inString = false; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (inString) { + if (ch === '\\') { + result += ch + (text[i + 1] || ''); + i += 2; + continue; + } + if (ch === '"') inString = false; + result += ch; + i++; + } else { + if (ch === '"') { + inString = true; + result += ch; + i++; + } else if (ch === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + } else if (ch === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + } else { + result += ch; + i++; + } + } + } + // Step 2: strip trailing commas before } or ] + result = result.replace(/,(\s*[}\]])/g, '$1'); + return JSON.parse(result); +} + +let parsed; +try { + parsed = parseJsonc(raw); +} catch (e) { + console.error(`FAIL: JSON parse error in ${file}`); + console.error(` ${e.message}`); + process.exit(1); +} + +// Basic structural checks +const errors = []; +if (parsed.model && typeof parsed.model !== 'string') { + errors.push('"model" must be a string'); +} +if (parsed.permission && typeof parsed.permission !== 'object') { + errors.push('"permission" must be an object'); +} +if (parsed.mcp) { + for (const [name, srv] of Object.entries(parsed.mcp)) { + if (!srv.type) errors.push(`mcp.${name}: missing "type"`); + if (!srv.command) errors.push(`mcp.${name}: missing "command"`); + } +} + +if (errors.length > 0) { + console.error(`FAIL: ${errors.length} validation error(s):`); + errors.forEach(e => console.error(` - ${e}`)); + process.exit(1); +} + +console.log(`OK: ${file} is valid`); +JS diff --git a/scripts/validate-skills.sh b/scripts/validate-skills.sh new file mode 100644 index 0000000..53e33bf --- /dev/null +++ b/scripts/validate-skills.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# scripts/validate-skills.sh +# +# Validates SKILL.md files across the repository: +# - YAML frontmatter is present and parseable +# - Required fields (name, description) exist +# - name matches kebab-case convention +# +# Usage: +# bash scripts/validate-skills.sh [--dir ] +# +# Requires: python3, pyyaml + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SEARCH_DIR="${REPO_ROOT}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) SEARCH_DIR="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +python3 - "$SEARCH_DIR" <<'PY' +import sys, re +from pathlib import Path + +try: + import yaml +except ImportError: + print("ERROR: pyyaml not installed. Run: pip install pyyaml") + sys.exit(1) + +search_dir = Path(sys.argv[1]) +skill_files = sorted(search_dir.glob("**/skills/**/SKILL.md")) + +if not skill_files: + print(f"No SKILL.md files found under {search_dir}") + sys.exit(0) + +errors = [] +warnings = [] +kebab_re = re.compile(r'^[a-z][a-z0-9-]*$') + +for path in skill_files: + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + + if not lines or lines[0].strip() != "---": + errors.append(f"{path}: missing YAML frontmatter start '---'") + continue + + end = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end = i + break + + if end is None: + errors.append(f"{path}: missing YAML frontmatter closing '---'") + continue + + fm_text = "\n".join(lines[1:end]) + try: + fm = yaml.safe_load(fm_text) or {} + except Exception as e: + errors.append(f"{path}: YAML parse error: {e}") + continue + + if not isinstance(fm, dict): + errors.append(f"{path}: frontmatter must be a YAML mapping") + continue + + if "name" not in fm: + errors.append(f"{path}: missing required field 'name'") + elif not kebab_re.match(str(fm["name"])): + errors.append(f"{path}: 'name' must be kebab-case, got: {fm['name']!r}") + + if "description" not in fm: + errors.append(f"{path}: missing required field 'description'") + elif len(str(fm["description"])) < 20: + warnings.append(f"{path}: 'description' is very short (<20 chars)") + + meta = fm.get("metadata", {}) + if isinstance(meta, dict): + if "version" in meta: + ver = str(meta["version"]) + if not re.match(r'^\d+\.\d+\.\d+$', ver): + errors.append(f"{path}: metadata.version must be semver (x.y.z), got: {ver!r}") + +if warnings: + print(f"\nWarnings ({len(warnings)}):") + for w in warnings: + print(f" ⚠ {w}") + +if errors: + print(f"\nErrors ({len(errors)}):") + for e in errors: + print(f" ✗ {e}") + sys.exit(1) + +print(f"OK: {len(skill_files)} SKILL.md files validated (0 errors, {len(warnings)} warnings)") +PY