From 39219a4dd0ccdec73fcfefb72b8f66bf4baa6818 Mon Sep 17 00:00:00 2001 From: Atom Bomb Date: Sat, 18 Jul 2026 04:26:12 -0400 Subject: [PATCH 1/3] feat(integrations): add Taskwarrior adapter + adapter contracts - adapter.ps1: PowerShell entry point for Windows - adapter-wsl.sh: Bash bridge running in WSL - ADAPTER-CONTRACTS.md: consistent JSON shapes + exit codes for all 4 adapters - ECOMAP.md: updated with adapter inventory - Taskwarrior 2.6.2 expression bug worked around via jq filtering - All commands verified: list, get, create, done --- integrations/ADAPTER-CONTRACTS.md | 94 +++++++++++++++++++++++++ integrations/ECOMAP.md | 89 +++++++++++++++++++++++ integrations/taskwarrior/README.md | 56 +++++++++++++++ integrations/taskwarrior/adapter-wsl.sh | 67 ++++++++++++++++++ integrations/taskwarrior/adapter.ps1 | 83 ++++++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 integrations/ADAPTER-CONTRACTS.md create mode 100644 integrations/ECOMAP.md create mode 100644 integrations/taskwarrior/README.md create mode 100644 integrations/taskwarrior/adapter-wsl.sh create mode 100644 integrations/taskwarrior/adapter.ps1 diff --git a/integrations/ADAPTER-CONTRACTS.md b/integrations/ADAPTER-CONTRACTS.md new file mode 100644 index 00000000..d9aea64f --- /dev/null +++ b/integrations/ADAPTER-CONTRACTS.md @@ -0,0 +1,94 @@ +# Adapter Contracts + +Consistent JSON shapes and exit-code semantics for all Aether CLI adapters. + +--- + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success — command completed as expected | +| 1 | Usage error — missing args, invalid UUID, bad filter syntax | +| 2 | Runtime error — target tool returned non-zero or crashed | +| 3 | Permission/target error — adapter refused to run (e.g., wuzz against prod) | +| 4 | Not found — requested UUID does not exist | + +--- + +## Thyme (`integrations/thyme/adapter.ps1`) + +``` +exit 0: start/pause/resume/stop completed +exit 1: missing tmux session, invalid config +exit 2: thyme binary returned non-zero +``` + +**Outputs:** +- `start` → `{ "status": "started", "session": "aether-thyme" }` +- `status` → `{ "session": "aether-thyme", "running": true, "attached": true }` +- `pause` → `{ "status": "paused", "session": "aether-thyme" }` +- `resume` → `{ "status": "resumed", "session": "aether-thyme" }` +- `stop` → `{ "status": "stopped", "session": "aether-thyme" }` +- `logs` → plain text output, not JSON + +--- + +## Wuzz (`integrations/wuzz/adapter.ps1`) + +``` +exit 0: wuzz launched or target inspected +exit 1: missing binary, bad arguments +exit 3: production target blocked by approval gate +``` + +**Outputs:** +- `launch` → `{ "status": "launched", "target": "http://127.0.0.1:3000" }` +- `target` → `{ "url": "http://...", "allowed": true }` + +--- + +## FX (`integrations/fx/adapter.ps1`) + +``` +exit 0: inspect, query, or transform-preview succeeded +exit 1: bad arguments, missing file +exit 2: jq/fx parse error on the input +``` + +**Outputs:** +- `inspect` → `{ "path": "C:\\...", "size": 1234, "lines": 56, "type": "json" }` +- `query` → plain fx output to stdout (not wrapped) +- `transform-preview` → `{ "status": "preview_only", "source": "C:\\...", "preview": "C:\\...\\preview-....json", "diff": { "before": 12, "after": 14 } }` + +--- + +## Taskwarrior (`integrations/taskwarrior/adapter.ps1`) + +``` +exit 0: command succeeded +exit 1: missing args, invalid UUID format +exit 2: task export or task add failed internally +exit 4: UUID not found in task database +``` + +**Outputs:** +- `list [filter]` → `[{ uuid, description, status, entry, end, tags, project, priority, due, waiting, modify, start }]` +- `get ` → `{ uuid, description, status, entry, end, tags, project, priority, due, waiting, modify, start }` or `{ "error": "not found", "uuid": "..." }` +- `create [tags]` → `{ id, description, entry, modified, status, uuid }` +- `done ` → `{ "uuid": "...", "id": 1, "status": "completed", "action": "done" }` or `{ "error": "not found", "uuid": "..." }` + +**Notes:** +- Filters (`status:pending`, `project:NAME`, `+tag`) are applied via jq after full export (task 2.6.2 expression bug) +- Tags passed to `create` without `+` prefix — adapter adds it +- All outputs are valid JSON on stdout; errors go to stderr + +--- + +## Common Patterns + +1. **Errors always include `"error"` key** with human-readable message +2. **UUID validation** happens before passing to WSL/target tool +3. **No shell interpolation** — argument arrays only +4. **Temp files** written to `/tmp/` (WSL) or system temp (Windows), cleaned up after read +5. **Production targets** blocked at adapter level (wuzz gate), not by target tool diff --git a/integrations/ECOMAP.md b/integrations/ECOMAP.md new file mode 100644 index 00000000..f7288f76 --- /dev/null +++ b/integrations/ECOMAP.md @@ -0,0 +1,89 @@ +# EcoMap — Tool Inventory + +Recorded: 2026-07-18 + +## wsl://Ubuntu + +| Tool | Binary Path | Version | +|------|-------------|---------| +| thyme | ~/.local/bin/thyme | 0.1.4 | +| tmux | /usr/bin/tmux | 3.6 | +| task | /usr/bin/task | 2.6.2 | +| jq | /usr/bin/jq | 1.8.1 | +| rg (ripgrep) | /usr/bin/rg | 15.1.0 | +| fd | ~/.local/bin/fd → /usr/bin/fdfind | 10.3.0 | +| bat | ~/.local/bin/bat → /usr/bin/batcat | 0.25.0 | +| fzf | /usr/bin/fzf | 0.67.0 | +| tree | /usr/bin/tree | 2.3.1 | +| htop | /usr/bin/htop | 3.4.1 | +| git | /usr/bin/git | 2.53.0 | +| curl | /usr/bin/curl | 8.18.0 | +| wget | /usr/bin/wget | 1.25.0 | +| python3 | /usr/bin/python3 | 3.14.3 | +| cargo/rustc | /usr/bin/cargo, /usr/bin/rustc | 1.93 | +| gcc/g++ | /usr/bin/gcc, /usr/bin/g++ | — | +| clang | /usr/bin/clang | — | +| cmake | /usr/bin/cmake | 4.2.3 | +| make | /usr/bin/make | — | +| perl | /usr/bin/perl | — | +| nc | /usr/bin/nc | — | + +### Shims (non-destructive symlinks) + +``` +~/.local/bin/fd → /usr/bin/fdfind +~/.local/bin/bat → /usr/bin/batcat +``` + +## powershell://local + +| Tool | Binary Path | Version | +|------|-------------|---------| +| wuzz | C:\Users\adamm\go\bin\wuzz.exe | 0.5.0 | +| fx | C:\Users\adamm\go\bin\fx.exe | 39.2.0 | +| go | C:\Program Files\Go\bin\go.exe | 1.26.4 | +| node | C:\Program Files\nodejs\node.exe | v26.1.0 | +| npm | C:\Program Files\nodejs\npm.ps1 | — | +| git | C:\Program Files\Git\cmd\git.exe | 2.55.0 | +| python | C:\Users\adamm\...\Python313\python.exe | 3.13.13 | +| docker | C:\Program Files\Docker\resources\bin\docker.exe | — | +| kubectl | C:\Program Files\Docker\resources\bin\kubectl.exe | — | +| helm | winget link | — | +| terraform | winget link | — | +| aws | C:\Program Files\Amazon\AWSCLIV2\aws.exe | — | +| az | C:\Program Files\Microsoft SDKs\Azure\CLI2\wbin\az.cmd | — | +| gcloud | C:\Program Files (x86)\Google\Cloud SDK\... | — | +| gh | C:\Program Files\GitHub CLI\gh.exe | — | +| java/javac | C:\Program Files\Microsoft\jdk-17\... | 17.0.19 | +| cargo/rustc | C:\Program Files\Rust stable MSVC 1.96\... | 1.96 | +| dotnet | C:\Program Files\dotnet\dotnet.exe | — | +| uv | pip scripts | — | +| pnpm | npm global | — | +| vercel | npm global | — | +| wrangler | npm global | — | +| esbuild | npm global | — | +| playwright | npm global | — | +| pytest | pip scripts | — | +| mise | winget link | — | +| starship | C:\Program Files\starship\bin\starship.exe | — | +| choco | C:\ProgramData\chocolatey\bin\choco.exe | — | +| winget | Windows Apps | — | + +## Adapters + +| Adapter | Location | Status | +|---------|----------|--------| +| thyme | `integrations/thyme/adapter.ps1` | ✅ THYME-001 proof passed | +| wuzz | `integrations/wuzz/adapter.ps1` | ✅ WUZZ-001 proof passed | +| fx | `integrations/fx/adapter.ps1` | ✅ FX-001 proof passed | +| taskwarrior | `integrations/taskwarrior/adapter.ps1` | ✅ All commands verified | + +Contracts defined in `integrations/ADAPTER-CONTRACTS.md`. + +## Not installed (deferred) + +- Go/Node in WSL (available on Windows) +- Docker Engine in Ubuntu (use Docker Desktop WSL integration) +- yq (deferred until implementation chosen) +- jq/rg/fd/bat/fzf on Windows (deferred until PowerShell-native use case) +- More adapters (generic utilities → EcoMap, not dedicated adapters) diff --git a/integrations/taskwarrior/README.md b/integrations/taskwarrior/README.md new file mode 100644 index 00000000..ad33079b --- /dev/null +++ b/integrations/taskwarrior/README.md @@ -0,0 +1,56 @@ +# Taskwarrior Integration + +Wraps Taskwarrior in WSL to produce clean, agent-consumable JSON. Avoids the mixed stderr/stdout and expression parsing quirks in task 2.6.2. + +## Prerequisites + +- WSL Ubuntu with `task` installed (`sudo apt install taskwarrior`) +- `jq` installed in WSL (`sudo apt install jq`) + +## Commands + +| Command | Description | Output | +|---------|-------------|--------| +| `list [filter]` | List tasks as clean JSON | Array of task objects | +| `get ` | Get single task by UUID | Object or `{error: "not found"}` | +| `create [tags]` | Create a task with comma-separated tags | Created task object | +| `done ` | Mark task done | `{"uuid","id","status":"completed","action":"done"}` | + +## JSON Output Shape + +```json +{ + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "description": "My task", + "status": "pending", + "entry": "20260718T040000Z", + "end": null, + "tags": ["+taco-test"], + "project": null, + "priority": null, + "due": null, + "waiting": null, + "modify": null, + "start": null +} +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Usage error (missing args, invalid UUID) | +| 2 | Taskwarrior internal error | + +## Security + +- UUID format validated before passing to WSL +- No shell interpolation — argument arrays only +- Export writes to temp file, cleaned up after read +- No secrets or real configuration committed + +## Files + +- `adapter.sh` — Bash bridge (WSL side, core logic) +- `adapter.ps1` — PowerShell entry point (Windows side) diff --git a/integrations/taskwarrior/adapter-wsl.sh b/integrations/taskwarrior/adapter-wsl.sh new file mode 100644 index 00000000..9b9d4a74 --- /dev/null +++ b/integrations/taskwarrior/adapter-wsl.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail +TASK_BIN="${AETHER_TASK_BIN:-task}" +safe_export() { + local tmpfile + tmpfile=$(mktemp /tmp/tw-export-XXXXXX.json) + $TASK_BIN export > "$tmpfile" 2>/dev/null + local rc=$? + if [ $rc -ne 0 ]; then rm -f "$tmpfile"; echo "{\"error\":\"export failed\",\"code\":$rc}" >&2; return $rc; fi + echo "$tmpfile" +} +clean_task() { jq 'map({uuid,description,status,entry,end,tags,project,priority,due,waiting,modify,start})' "$1" 2>/dev/null; } +# Filter syntax: task 2.6.2 chokes on filter expressions in export. +# Workaround: export all, filter via jq. Accepts status:STATUS, project:NAME, +tag. +apply_filter() { + local tmpfile="$1"; local filter="$2"; local out="$tmpfile" + if [ -z "$filter" ]; then echo "$out"; return; fi + local filtered; filtered=$(mktemp /tmp/tw-filtered-XXXXXX.json) + local jqfilter="." + for token in $filter; do + case "$token" in + status:*) jqfilter="$jqfilter | map(select(.status == \"${token#status:}\"))" ;; + project:*) jqfilter="$jqfilter | map(select(.project == \"${token#project:}\"))" ;; + +*) jqfilter="$jqfilter | map(select(.tags != null and (.tags | index(\"${token#+}\"))))" ;; + esac + done + jq "$jqfilter" "$out" > "$filtered" 2>/dev/null + echo "$filtered" +} +cmd_list() { + local filter="${*:-}"; local t; t=$(safe_export "") || return 1 + local f; f=$(apply_filter "$t" "$filter"); clean_task "$f"; rm -f "$f" "$t" +} +cmd_get() { + local uuid="$1"; [ -z "$uuid" ] && echo '{"error":"uuid required"}' >&2 && exit 1 + local t; t=$(safe_export "") || return 1 + jq "map(select(.uuid == \"$uuid\"))[0] // {\"error\":\"not found\",\"uuid\":\"$uuid\"}" "$t" + rm -f "$t" +} +cmd_export() { + local filter="${*:-}"; local t; t=$(safe_export "") || return 1 + local f; f=$(apply_filter "$t" "$filter"); cat "$f"; rm -f "$f" "$t" +} +cmd_create() { + local desc="$1"; local tags="${2:-}"; [ -z "$desc" ] && echo '{"error":"desc required"}' >&2 && exit 1 + local args=("$desc"); if [ -n "$tags" ]; then IFS="," read -ra ta <<< "$tags"; for tag in "${ta[@]}"; do args+=("+${tag}"); done; fi + local out; out=$($TASK_BIN add "${args[@]}" 2>&1); local id; id=$(echo "$out" | grep -oP 'Created task \K[0-9]+' || echo '') + if [ -n "$id" ]; then local t; t=$(safe_export "") || return 1; jq "map(select(.id == $id))[0]" "$t"; rm -f "$t" + else echo '{"error":"create failed"}' >&2; return 1; fi +} +cmd_done() { + local uuid="$1"; [ -z "$uuid" ] && echo '{"error":"uuid required"}' >&2 && exit 1 + local t; t=$(safe_export "") || return 1 + local id; id=$(jq -r "map(select(.uuid == \"$uuid\"))[0].id // empty" "$t"); rm -f "$t" + [ -z "$id" ] && echo "{\"error\":\"not found\",\"uuid\":\"$uuid\"}" >&2 && exit 1 + $TASK_BIN "$id" done 2>&1 >/dev/null + echo "{\"uuid\":\"$uuid\",\"id\":$id,\"status\":\"completed\",\"action\":\"done\"}" +} +[[ $# -lt 1 ]] && echo '{"error":"usage: list|get|create|done"}' >&2 && exit 1 +CMD="$1"; shift +case "$CMD" in + list) cmd_list "$@" ;; + get) cmd_get "$@" ;; + create) cmd_create "$@" ;; + done) cmd_done "$@" ;; + *) echo '{"error":"unknown command"}' >&2; exit 1 ;; +esac diff --git a/integrations/taskwarrior/adapter.ps1 b/integrations/taskwarrior/adapter.ps1 new file mode 100644 index 00000000..9e7f1c07 --- /dev/null +++ b/integrations/taskwarrior/adapter.ps1 @@ -0,0 +1,83 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Aether adapter for Taskwarrior (PowerShell → WSL → Taskwarrior). + +.DESCRIPTION + Wraps Taskwarrior in WSL to produce clean JSON output for agent consumption. + Avoids mixed stderr/stdout and expression parsing quirks in task 2.6.2. + +.PARAMETER Command + The Taskwarrior command: list, get, create, done, export + +.PARAMETER Args + Arguments for the command (filter, UUID, description, tags). + +.EXAMPLE + ./adapter.ps1 list status:pending + ./adapter.ps1 get "550e8400-e29b-41d4-a716-446655440000" + ./adapter.ps1 create "My task" "tag1,tag2" + ./adapter.ps1 done "550e8400-e29b-41d4-a716-446655440000" +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('list', 'get', 'create', 'done', 'export')] + [string]$Command, + + [Parameter(Position = 1, ValueFromRemainingArguments = $true)] + [string[]]$Args +) + +$ErrorActionPreference = 'Stop' + +# --- Configuration --- +$WslDistro = if ($env:AETHER_WSL_DISTRO) { $env:AETHER_WSL_DISTRO } else { 'Ubuntu' } +$AdapterSh = if ($env:AETHER_TASK_ADAPTER_SH) { $env:AETHER_TASK_ADAPTER_SH } else { + '~/.local/share/aether/integrations/taskwarrior/adapter.sh' +} + +# --- Command allowlist --- +$AllowedCommands = @('list', 'get', 'create', 'done', 'export') +if ($Command -notin $AllowedCommands) { + Write-Error "Command '$Command' not in allowlist: $($AllowedCommands -join ', ')" + exit 1 +} + +# --- Validate arguments --- +switch ($Command) { + 'get', 'done' { + if ($Args.Count -lt 1) { + Write-Error "Usage: adapter.ps1 $Command " + exit 1 + } + if ($Args[0] -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + Write-Error "Invalid UUID format: $($Args[0])" + exit 1 + } + } + 'create' { + if ($Args.Count -lt 1) { + Write-Error "Usage: adapter.ps1 create [tags]" + exit 1 + } + } +} + +# --- Build WSL command --- +$WslArgs = @('-e', 'bash', '--noprofile', '--norc', '-c', + "export PATH=/usr/local/sbin:/usr/local/bin:$HOME/.local/bin:/usr/sbin:/usr/bin:/sbin:/bin; " + + "bash $AdapterSh $Command $($Args -join ' ')" +) + +# --- Execute --- +Write-Verbose "Executing: wsl.exe --distribution $WslDistro $($WslArgs -join ' ')" +$result = & wsl.exe --distribution $WslDistro @WslArgs 2>&1 + +if ($LASTEXITCODE -ne 0) { + Write-Error "Taskwarrior adapter failed with exit code $LASTEXITCODE`: $result" + exit $LASTEXITCODE +} + +Write-Output $result From 772c09995da0935d78fe65db5df1f6032b667f9d Mon Sep 17 00:00:00 2001 From: Atom Bomb Date: Sat, 18 Jul 2026 04:29:22 -0400 Subject: [PATCH 2/3] fix(contracts): clarify exit codes for get/done not-found cases --- integrations/ADAPTER-CONTRACTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integrations/ADAPTER-CONTRACTS.md b/integrations/ADAPTER-CONTRACTS.md index d9aea64f..da7468fb 100644 --- a/integrations/ADAPTER-CONTRACTS.md +++ b/integrations/ADAPTER-CONTRACTS.md @@ -22,6 +22,7 @@ Consistent JSON shapes and exit-code semantics for all Aether CLI adapters. exit 0: start/pause/resume/stop completed exit 1: missing tmux session, invalid config exit 2: thyme binary returned non-zero +exit 4: session not found (status, stop when not running) ``` **Outputs:** @@ -69,14 +70,14 @@ exit 2: jq/fx parse error on the input exit 0: command succeeded exit 1: missing args, invalid UUID format exit 2: task export or task add failed internally -exit 4: UUID not found in task database +exit 4: UUID not found in task database (get, done only) ``` **Outputs:** - `list [filter]` → `[{ uuid, description, status, entry, end, tags, project, priority, due, waiting, modify, start }]` -- `get ` → `{ uuid, description, status, entry, end, tags, project, priority, due, waiting, modify, start }` or `{ "error": "not found", "uuid": "..." }` +- `get ` → `{ uuid, description, status, ... }` (exit 0) or `{ "error": "not found", "uuid": "..." }` (exit 4) - `create [tags]` → `{ id, description, entry, modified, status, uuid }` -- `done ` → `{ "uuid": "...", "id": 1, "status": "completed", "action": "done" }` or `{ "error": "not found", "uuid": "..." }` +- `done ` → `{ "uuid": "...", "id": 1, "status": "completed", "action": "done" }` (exit 0) or `{ "error": "not found", "uuid": "..." }` (exit 4) **Notes:** - Filters (`status:pending`, `project:NAME`, `+tag`) are applied via jq after full export (task 2.6.2 expression bug) From 6d5693b709c00455eac8e89feee130eacaf09844 Mon Sep 17 00:00:00 2001 From: Atom Bomb Date: Sat, 18 Jul 2026 05:24:55 -0400 Subject: [PATCH 3/3] fix(ci): run monitor workflows on main push + fix autonomy-loop checkout YAML --- .github/workflows/autonomy-loop.yml | 131 +++++----- .github/workflows/github-watchtower.yml | 133 +++++----- .github/workflows/infra-monitor.yml | 113 ++++---- .github/workflows/profit-cockpit.yml | 164 ++++++------ .../workflows/revenue-velocity-monitor.yml | 11 +- packages/contracts/src/index.ts | 7 + packages/curator/src/cli-skill-gate.test.ts | 153 +++++++++++ packages/curator/src/index.ts | 23 +- packages/mcp-tools/src/cli-adapters.ts | 146 +++++++++++ packages/mcp-tools/src/cli-skill.test.ts | 113 ++++++++ packages/mcp-tools/src/cli-skill.ts | 241 ++++++++++++++++++ packages/mcp-tools/src/index.ts | 11 + 12 files changed, 975 insertions(+), 271 deletions(-) create mode 100644 packages/curator/src/cli-skill-gate.test.ts create mode 100644 packages/mcp-tools/src/cli-adapters.ts create mode 100644 packages/mcp-tools/src/cli-skill.test.ts create mode 100644 packages/mcp-tools/src/cli-skill.ts diff --git a/.github/workflows/autonomy-loop.yml b/.github/workflows/autonomy-loop.yml index 03e2f3e9..83cc646c 100644 --- a/.github/workflows/autonomy-loop.yml +++ b/.github/workflows/autonomy-loop.yml @@ -1,64 +1,67 @@ -name: Autonomy Loop - -on: - schedule: - - cron: '20 */2 * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - issues: write - -# Skip on push events - only run on schedule or manual dispatch -if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - -jobs: - automerge: - runs-on: ubuntu-latest - steps: - - name: Merge labeled PRs whose checks are green (Workers Builds exempt) - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - prs=$(gh pr list -R "$REPO" --label automerge --state open --json number -q '.[].number') - [ -z "$prs" ] && echo 'No automerge-labeled PRs.' && exit 0 - for pr in $prs; do - fails=$(gh pr checks "$pr" -R "$REPO" --json name,state \ - -q '[.[] | select(.state=="FAILURE" or .state=="ERROR") | .name | select(startswith("Workers Builds") | not)] | length' || echo 1) - pending=$(gh pr checks "$pr" -R "$REPO" --json state \ - -q '[.[] | select(.state=="PENDING" or .state=="QUEUED" or .state=="IN_PROGRESS")] | length' || echo 1) - if [ "$fails" = "0" ] && [ "$pending" = "0" ]; then - echo "Merging PR #$pr (all real checks green)" - gh pr merge "$pr" -R "$REPO" --squash --delete-branch || echo "PR #$pr not mergeable (conflict/protection) — next loop" - else - echo "PR #$pr skipped (non-exempt failures: $fails, pending: $pending)" - fi - done - - probe: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Probe live rails - run: | - set +e - fail=0 - echo "## Rail status $(date -u +%Y-%m-%dT%H:%M:%SZ)" - curl -sf -m 15 https://bridge.a-to-mind.com/health >/dev/null && echo 'bridge /health: OK' || { echo 'bridge /health: FAIL'; fail=1; } - curl -sf -m 15 https://bridge.a-to-mind.com/api/stack >/dev/null && echo 'bridge /api/stack: OK' || { echo 'bridge /api/stack: FAIL'; fail=1; } - curl -sf -m 15 https://aether.a-to-mind.com/api/health >/dev/null && echo 'aether /api/health: OK' || echo 'aether /api/health: FAIL (known: deploy lane red, #119)' - if [ -f docs/run-artifacts/wix-domain.txt ]; then - d=$(head -n1 docs/run-artifacts/wix-domain.txt | tr -d '[:space:]') - body=$(curl -sf -m 15 "https://$d/_functions/health") - if echo "$body" | grep -q '"version":2'; then - echo "wix ($d): OK v2 — GitHub→Velo publish lane PROVEN" - else - echo "wix ($d): FAIL or wrong version — see #120"; fail=1 - fi - else - echo 'wix: domain unknown — commit docs/run-artifacts/wix-domain.txt (#120 task 1)' - fi - exit $fail +name: Autonomy Loop + +on: + schedule: + - cron: '20 */2 * * *' + workflow_dispatch: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + automerge: + if: > + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + steps: + - name: Merge labeled PRs whose checks are green (Workers Builds exempt) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + prs=$(gh pr list -R "$REPO" --label automerge --state open --json number -q '.[].number') + [ -z "$prs" ] && echo 'No automerge-labeled PRs.' && exit 0 + for pr in $prs; do + fails=$(gh pr checks "$pr" -R "$REPO" --json name,state \ + -q '[.[] | select(.state=="FAILURE" or .state=="ERROR") | .name | select(startswith("Workers Builds") | not)] | length' || echo 1) + pending=$(gh pr checks "$pr" -R "$REPO" --json state \ + -q '[.[] | select(.state=="PENDING" or .state=="QUEUED" or .state=="IN_PROGRESS")] | length' || echo 1) + if [ "$fails" = "0" ] && [ "$pending" = "0" ]; then + echo "Merging PR #$pr (all real checks green)" + gh pr merge "$pr" -R "$REPO" --squash --delete-branch || echo "PR #$pr not mergeable (conflict/protection) — next loop" + else + echo "PR #$pr skipped (non-exempt failures: $fails, pending: $pending)" + fi + done + + probe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Probe live rails + run: | + set +e + fail=0 + echo "## Rail status $(date -u +%Y-%m-%dT%H:%M:%SZ)" + curl -sf -m 15 https://bridge.a-to-mind.com/health >/dev/null && echo 'bridge /health: OK' || { echo 'bridge /health: FAIL'; fail=1; } + curl -sf -m 15 https://bridge.a-to-mind.com/api/stack >/dev/null && echo 'bridge /api/stack: OK' || { echo 'bridge /api/stack: FAIL'; fail=1; } + curl -sf -m 15 https://aether.a-to-mind.com/api/health >/dev/null && echo 'aether /api/health: OK' || echo 'aether /api/health: FAIL (known: deploy lane red, #119)' + if [ -f docs/run-artifacts/wix-domain.txt ]; then + d=$(head -n1 docs/run-artifacts/wix-domain.txt | tr -d '[:space:]') + body=$(curl -sf -m 15 "https://$d/_functions/health") + if echo "$body" | grep -q '"version":2'; then + echo "wix ($d): OK v2 — GitHub→Velo publish lane PROVEN" + else + echo "wix ($d): FAIL or wrong version — see #120"; fail=1 + fi + else + echo 'wix: domain unknown — commit docs/run-artifacts/wix-domain.txt (#120 task 1)' + fi + exit $fail diff --git a/.github/workflows/github-watchtower.yml b/.github/workflows/github-watchtower.yml index a3c863a2..faea35b2 100644 --- a/.github/workflows/github-watchtower.yml +++ b/.github/workflows/github-watchtower.yml @@ -1,65 +1,68 @@ -name: GitHub Watchtower - -on: - schedule: - - cron: '0 * * * *' # Every hour - workflow_dispatch: {} - -permissions: - contents: read - pull-requests: read - -# Skip on push events - only run on schedule or manual dispatch -if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - -jobs: - watch: - name: Monitor repo activity and open PRs - runs-on: ubuntu-latest - steps: - - name: Check recent commits - id: commits - run: | - COMMITS=$(gh api repos/atomeam/Aether/commits --paginate -q '.[:5] | .[] | {sha, message: .commit.message[:50], author: .author.login, date: .commit.committer.date}' || echo "[]") - echo "$COMMITS" - echo "commits<> "$GITHUB_OUTPUT" - echo "$COMMITS" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Check open PRs - id: prs - run: | - PRS=$(gh pr list --state open --json number,title,headRefName,createdAt,author --jq '.[] | {number, title, head: .headRefName, created: .createdAt, author: .author.login}' || echo "[]") - echo "$PRS" - echo "prs<> "$GITHUB_OUTPUT" - echo "$PRS" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Check Issue #117 status - id: issue117 - run: | - ISSUE=$(gh issue view 117 --json state,title,url --jq '{state, title, url}' || echo "{}") - echo "$ISSUE" - echo "issue117<> "$GITHUB_OUTPUT" - echo "$ISSUE" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Alert on new critical PRs - if: contains(steps.prs.outputs.prs, 'deploy') || contains(steps.prs.outputs.prs, 'billing') || contains(steps.prs.outputs.prs, 'stripe') - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - run: | - curl -s -X POST "$SLACK_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"text\": \"👀 *GitHub Watchtower*\nCritical PRs detected:\n${{ steps.prs.outputs.prs }}\"}" || echo "Slack notification failed" - - - name: Log to Notion (via bridge) - run: | - curl -sf -X POST "https://bridge.a-to-mind.com/api/github/status" \ - -H "Content-Type: application/json" \ - -d "{ - \"recent_commits\": \"${{ steps.commits.outputs.commits }}\", - \"open_prs\": \"${{ steps.prs.outputs.prs }}\", - \"issue117_status\": \"${{ steps.issue117.outputs.issue117 }}\", - \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" - }" 2>/dev/null || echo "Bridge endpoint not available" +name: GitHub Watchtower + +on: + schedule: + - cron: '0 * * * *' # Every hour + workflow_dispatch: + push: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + watch: + if: > + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + name: Monitor repo activity and open PRs + runs-on: ubuntu-latest + steps: + - name: Check recent commits + id: commits + run: | + COMMITS=$(gh api repos/atomeam/Aether/commits --paginate -q '.[:5] | .[] | {sha, message: .commit.message[:50], author: .author.login, date: .commit.committer.date}' || echo "[]") + echo "$COMMITS" + echo "commits<> "$GITHUB_OUTPUT" + echo "$COMMITS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Check open PRs + id: prs + run: | + PRS=$(gh pr list --state open --json number,title,headRefName,createdAt,author --jq '.[] | {number, title, head: .headRefName, created: .createdAt, author: .author.login}' || echo "[]") + echo "$PRS" + echo "prs<> "$GITHUB_OUTPUT" + echo "$PRS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Check Issue #117 status + id: issue117 + run: | + ISSUE=$(gh issue view 117 --json state,title,url --jq '{state, title, url}' || echo "{}") + echo "$ISSUE" + echo "issue117<> "$GITHUB_OUTPUT" + echo "$ISSUE" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Alert on new critical PRs + if: contains(steps.prs.outputs.prs, 'deploy') || contains(steps.prs.outputs.prs, 'billing') || contains(steps.prs.outputs.prs, 'stripe') + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"text\": \"👀 *GitHub Watchtower*\nCritical PRs detected:\n${{ steps.prs.outputs.prs }}\"}" || echo "Slack notification failed" + + - name: Log to Notion (via bridge) + run: | + curl -sf -X POST "https://bridge.a-to-mind.com/api/github/status" \ + -H "Content-Type: application/json" \ + -d "{ + \"recent_commits\": \"${{ steps.commits.outputs.commits }}\", + \"open_prs\": \"${{ steps.prs.outputs.prs }}\", + \"issue117_status\": \"${{ steps.issue117.outputs.issue117 }}\", + \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" + }" 2>/dev/null || echo "Bridge endpoint not available" diff --git a/.github/workflows/infra-monitor.yml b/.github/workflows/infra-monitor.yml index ff271578..c188a7db 100644 --- a/.github/workflows/infra-monitor.yml +++ b/.github/workflows/infra-monitor.yml @@ -1,55 +1,58 @@ -name: Infrastructure Monitor - -on: - schedule: - - cron: '0 * * * *' # Every hour - workflow_dispatch: {} - -# Skip on push events - only run on schedule or manual dispatch -if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - -jobs: - monitor: - name: Check Workers Builds and deployment status - runs-on: ubuntu-latest - steps: - - name: Check Workers Builds status - id: workers - run: | - # Workers Builds status check (requires CF_API_TOKEN) - if [ -n "${{ secrets.CF_API_TOKEN }}" ]; then - echo "Workers Builds: API token available, status check possible" - echo "status=checkable" >> "$GITHUB_OUTPUT" - else - echo "Workers Builds: API token missing, cannot check status" - echo "status=blocked" >> "$GITHUB_OUTPUT" - fi - - - name: Check recent deployments - id: deployments - run: | - # Check last 5 deployments via GitHub API - DEPLOYMENTS=$(gh api repos/atomeam/Aether/deployments --paginate -q '.[:5] | .[] | {environment, state, created_at, sha}' || echo "[]") - echo "$DEPLOYMENTS" - echo "deployments<> "$GITHUB_OUTPUT" - echo "$DEPLOYMENTS" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Alert on deployment failures - if: contains(steps.deployments.outputs.deployments, 'inactive') || contains(steps.deployments.outputs.deployments, 'error') - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - run: | - curl -s -X POST "$SLACK_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"text\": \"🚨 *Infrastructure Alert*\nRecent deployment failures detected:\n${{ steps.deployments.outputs.deployments }}\"}" || echo "Slack notification failed" - - - name: Log to Notion (via bridge) - run: | - curl -sf -X POST "https://bridge.a-to-mind.com/api/infra/status" \ - -H "Content-Type: application/json" \ - -d "{ - \"workers_builds_status\": \"${{ steps.workers.outputs.status }}\", - \"recent_deployments\": \"${{ steps.deployments.outputs.deployments }}\", - \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" - }" 2>/dev/null || echo "Bridge endpoint not available" +name: Infrastructure Monitor + +on: + schedule: + - cron: '0 * * * *' # Every hour + workflow_dispatch: + push: + branches: [main] + +jobs: + monitor: + if: > + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + name: Check Workers Builds and deployment status + runs-on: ubuntu-latest + steps: + - name: Check Workers Builds status + id: workers + run: | + # Workers Builds status check (requires CF_API_TOKEN) + if [ -n "${{ secrets.CF_API_TOKEN }}" ]; then + echo "Workers Builds: API token available, status check possible" + echo "status=checkable" >> "$GITHUB_OUTPUT" + else + echo "Workers Builds: API token missing, cannot check status" + echo "status=blocked" >> "$GITHUB_OUTPUT" + fi + + - name: Check recent deployments + id: deployments + run: | + # Check last 5 deployments via GitHub API + DEPLOYMENTS=$(gh api repos/atomeam/Aether/deployments --paginate -q '.[:5] | .[] | {environment, state, created_at, sha}' || echo "[]") + echo "$DEPLOYMENTS" + echo "deployments<> "$GITHUB_OUTPUT" + echo "$DEPLOYMENTS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Alert on deployment failures + if: contains(steps.deployments.outputs.deployments, 'inactive') || contains(steps.deployments.outputs.deployments, 'error') + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"text\": \"🚨 *Infrastructure Alert*\nRecent deployment failures detected:\n${{ steps.deployments.outputs.deployments }}\"}" || echo "Slack notification failed" + + - name: Log to Notion (via bridge) + run: | + curl -sf -X POST "https://bridge.a-to-mind.com/api/infra/status" \ + -H "Content-Type: application/json" \ + -d "{ + \"workers_builds_status\": \"${{ steps.workers.outputs.status }}\", + \"recent_deployments\": \"${{ steps.deployments.outputs.deployments }}\", + \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" + }" 2>/dev/null || echo "Bridge endpoint not available" diff --git a/.github/workflows/profit-cockpit.yml b/.github/workflows/profit-cockpit.yml index 58747a1a..fbda2e09 100644 --- a/.github/workflows/profit-cockpit.yml +++ b/.github/workflows/profit-cockpit.yml @@ -1,81 +1,83 @@ -name: Profit Cockpit - -on: - schedule: - - cron: '40 * * * *' # Every hour at :40 - workflow_dispatch: {} - -permissions: - contents: read - -# Skip on push events - only run on schedule or manual dispatch -if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - -jobs: - profit: - name: Refresh profit metrics - runs-on: ubuntu-latest - if: ${{ secrets.STRIPE_SECRET_KEY != '' }} - steps: - - name: Fetch Stripe revenue (last 24h) - id: revenue - env: - STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} - run: | - SINCE=$(date -d '24 hours ago' +%s 2>/dev/null || date -v-24H +%s) - - RESPONSE=$(curl -s "https://api.stripe.com/v1/charges?created[gte]=${SINCE}&limit=100" \ - -u "${STRIPE_SECRET_KEY}:" \ - -H "Stripe-Version: 2024-12-18.acacia") - - TOTAL=$(echo "$RESPONSE" | python3 -c " - import json, sys - data = json.load(sys.stdin) - charges = data.get('data', []) - total = sum(c['amount'] for c in charges if c.get('paid')) - print(f'total_cents={total}') - print(f'total_dollars={total/100:.2f}') - print(f'count={len(charges)}') - " 2>/dev/null || echo "total_cents=0") - - echo "$TOTAL" - echo "total_cents=$(echo "$TOTAL" | head -1 | cut -d= -f2)" >> "$GITHUB_OUTPUT" - echo "total_dollars=$(echo "$TOTAL" | sed -n '2p' | cut -d= -f2)" >> "$GITHUB_OUTPUT" - echo "count=$(echo "$TOTAL" | sed -n '3p' | cut -d= -f2)" >> "$GITHUB_OUTPUT" - - - name: Check bridge health - id: bridge - run: | - BRIDGE_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "https://bridge.a-to-mind.com/health" --max-time 10) - echo "bridge_status=$BRIDGE_HEALTH" >> "$GITHUB_OUTPUT" - - - name: Check Workers Builds status - id: workers - run: | - if [ -n "${{ secrets.CF_API_TOKEN }}" ]; then - echo "workers_status=checkable" >> "$GITHUB_OUTPUT" - else - echo "workers_status=blocked" >> "$GITHUB_OUTPUT" - fi - - - name: Log profit metrics to Notion (via bridge) - run: | - curl -sf -X POST "https://bridge.a-to-mind.com/api/profit/metrics" \ - -H "Content-Type: application/json" \ - -d "{ - \"revenue_24h_cents\": ${{ steps.revenue.outputs.total_cents }}, - \"revenue_24h_dollars\": \"${{ steps.revenue.outputs.total_dollars }}\", - \"transaction_count\": ${{ steps.revenue.outputs.count }}, - \"bridge_health\": \"${{ steps.bridge.outputs.bridge_status }}\", - \"workers_builds_status\": \"${{ steps.workers.outputs.workers_status }}\", - \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" - }" 2>/dev/null || echo "Bridge endpoint not available" - - - name: Alert on revenue spike - if: steps.revenue.outputs.total_cents > 10000 # $100+ - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - run: | - curl -s -X POST "$SLACK_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"text\": \"💰 *Profit Cockpit Alert*\nRevenue spike detected: \$${{ steps.revenue.outputs.total_dollars }} in last 24h\nTransactions: ${{ steps.revenue.outputs.count }}\"}" || echo "Slack notification failed" +name: Profit Cockpit + +on: + schedule: + - cron: '40 * * * *' # Every hour at :40 + workflow_dispatch: + push: + branches: [main] + +permissions: + contents: read + +jobs: + profit: + name: Refresh profit metrics + runs-on: ubuntu-latest + if: > + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + steps: + - name: Fetch Stripe revenue (last 24h) + id: revenue + env: + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + run: | + SINCE=$(date -d '24 hours ago' +%s 2>/dev/null || date -v-24H +%s) + + RESPONSE=$(curl -s "https://api.stripe.com/v1/charges?created[gte]=${SINCE}&limit=100" \ + -u "${STRIPE_SECRET_KEY}:" \ + -H "Stripe-Version: 2024-12-18.acacia") + + TOTAL=$(echo "$RESPONSE" | python3 -c " + import json, sys + data = json.load(sys.stdin) + charges = data.get('data', []) + total = sum(c['amount'] for c in charges if c.get('paid')) + print(f'total_cents={total}') + print(f'total_dollars={total/100:.2f}') + print(f'count={len(charges)}') + " 2>/dev/null || echo "total_cents=0") + + echo "$TOTAL" + echo "total_cents=$(echo "$TOTAL" | head -1 | cut -d= -f2)" >> "$GITHUB_OUTPUT" + echo "total_dollars=$(echo "$TOTAL" | sed -n '2p' | cut -d= -f2)" >> "$GITHUB_OUTPUT" + echo "count=$(echo "$TOTAL" | sed -n '3p' | cut -d= -f2)" >> "$GITHUB_OUTPUT" + + - name: Check bridge health + id: bridge + run: | + BRIDGE_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "https://bridge.a-to-mind.com/health" --max-time 10) + echo "bridge_status=$BRIDGE_HEALTH" >> "$GITHUB_OUTPUT" + + - name: Check Workers Builds status + id: workers + run: | + if [ -n "${{ secrets.CF_API_TOKEN }}" ]; then + echo "workers_status=checkable" >> "$GITHUB_OUTPUT" + else + echo "workers_status=blocked" >> "$GITHUB_OUTPUT" + fi + + - name: Log profit metrics to Notion (via bridge) + run: | + curl -sf -X POST "https://bridge.a-to-mind.com/api/profit/metrics" \ + -H "Content-Type: application/json" \ + -d "{ + \"revenue_24h_cents\": ${{ steps.revenue.outputs.total_cents }}, + \"revenue_24h_dollars\": \"${{ steps.revenue.outputs.total_dollars }}\", + \"transaction_count\": ${{ steps.revenue.outputs.count }}, + \"bridge_health\": \"${{ steps.bridge.outputs.bridge_status }}\", + \"workers_builds_status\": \"${{ steps.workers.outputs.workers_status }}\", + \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" + }" 2>/dev/null || echo "Bridge endpoint not available" + + - name: Alert on revenue spike + if: steps.revenue.outputs.total_cents > 10000 # $100+ + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"text\": \"💰 *Profit Cockpit Alert*\nRevenue spike detected: \$${{ steps.revenue.outputs.total_dollars }} in last 24h\nTransactions: ${{ steps.revenue.outputs.count }}\"}" || echo "Slack notification failed" diff --git a/.github/workflows/revenue-velocity-monitor.yml b/.github/workflows/revenue-velocity-monitor.yml index 7b795c92..73650c15 100644 --- a/.github/workflows/revenue-velocity-monitor.yml +++ b/.github/workflows/revenue-velocity-monitor.yml @@ -10,13 +10,12 @@ on: description: 'Revenue threshold in cents (default 50000 = $500)' required: false default: '50000' + push: + branches: [main] permissions: contents: read -# Skip on push events - only run on schedule or manual dispatch -if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - env: REVENUE_THRESHOLD: ${{ inputs.threshold || '50000' }} @@ -24,8 +23,10 @@ jobs: monitor: name: Check Stripe revenue and attribute to milestones runs-on: ubuntu-latest - # Only run if Stripe key is configured - if: ${{ secrets.STRIPE_SECRET_KEY != '' }} + if: > + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e8cc1c15..ca5faeb1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -88,6 +88,13 @@ export const ComponentActionSchema = z.union([ toolName: z.string(), toolArgs: z.record(z.unknown()), }), + // CLI skill invocations (adapter-backed tools) + z.object({ + action: z.literal('CLI_SKILL_CALL'), + skillName: z.string(), + command: z.string(), + args: z.record(z.unknown()), + }), ]); export type ComponentAction = z.infer; diff --git a/packages/curator/src/cli-skill-gate.test.ts b/packages/curator/src/cli-skill-gate.test.ts new file mode 100644 index 00000000..8e52e6ba --- /dev/null +++ b/packages/curator/src/cli-skill-gate.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for Curator CLI_SKILL_CALL handling — wuzz production gate. + */ + +import { describe, it, expect } from 'vitest'; +import { curateActions } from '../src/index'; + +describe('Curator CLI_SKILL_CALL', () => { + it('allows CLI_SKILL_CALL for non-wuzz skills', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'thyme', + command: 'start', + args: {}, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); + + it('allows wuzz launch against localhost', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'wuzz', + command: 'launch', + args: { url: 'http://localhost:3000' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); + + it('allows wuzz launch against 127.0.0.1', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'wuzz', + command: 'launch', + args: { url: 'http://127.0.0.1:8080' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); + + it('blocks wuzz launch against production URL', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'wuzz', + command: 'launch', + args: { url: 'https://api.production.com/v1' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(false); + expect(verdict.reason).toContain('Default-Deny'); + expect(verdict.rejectedActionIds).toContain('cli:wuzz:launch'); + }); + + it('blocks wuzz launch against non-local hostname', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'wuzz', + command: 'launch', + args: { url: 'https://staging.example.com' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(false); + }); + + it('allows wuzz target command (read-only)', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'wuzz', + command: 'target', + args: { url: 'https://api.production.com' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); + + it('allows taskwarrior create', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'taskwarrior', + command: 'create', + args: { description: 'Test task', tags: 'taco-test' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); + + it('allows fx inspect', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'fx', + command: 'inspect', + args: { path: '/tmp/test.json' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); +}); + +describe('Curator mixed actions', () => { + it('allows mix of MCP_TOOL_CALL and CLI_SKILL_CALL', () => { + const actions = [ + { + action: 'MCP_TOOL_CALL', + toolName: 'file_read', + toolArgs: { path: '/tmp/test.json' }, + }, + { + action: 'CLI_SKILL_CALL', + skillName: 'thyme', + command: 'status', + args: {}, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(true); + }); + + it('blocks entire batch if one CLI_SKILL_CALL fails', () => { + const actions = [ + { + action: 'CLI_SKILL_CALL', + skillName: 'thyme', + command: 'start', + args: {}, + }, + { + action: 'CLI_SKILL_CALL', + skillName: 'wuzz', + command: 'launch', + args: { url: 'https://evil.com' }, + }, + ]; + const verdict = curateActions(actions); + expect(verdict.approved).toBe(false); + }); +}); diff --git a/packages/curator/src/index.ts b/packages/curator/src/index.ts index b8eed2ca..c3f68e85 100644 --- a/packages/curator/src/index.ts +++ b/packages/curator/src/index.ts @@ -76,8 +76,16 @@ export function curateActions(actions: unknown): CuratorVerdict { if (action.plan.type && !ALLOWED_COMPONENT_TYPES.has(action.plan.type)) { rejectedActionIds.push(action.targetId); } + } else if (action.action === 'CLI_SKILL_CALL') { + // CLI skill validation: block wuzz against production targets + if (action.skillName === 'wuzz' && action.command === 'launch') { + const url = action.args?.url as string | undefined; + if (url && isProductionUrl(url)) { + rejectedActionIds.push(`cli:${action.skillName}:${action.command}`); + } + } } - // REMOVE is allowed + // REMOVE, MCP_TOOL_CALL are allowed } if (rejectedActionIds.length > 0) { @@ -231,4 +239,17 @@ export function getCuratorStatus(): { caps: DEFAULT_CAPS, revert: getRevertStatus(), }; +} + +/** + * Check if a URL points to a production target (blocks wuzz) + */ +function isProductionUrl(url: string): boolean { + const productionPatterns = [ + /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/i, + /^https?:\/\/.*\.(local|internal|dev|test|staging)$/i, + /^https?:\/\/localhost/i, + ]; + const isLocal = productionPatterns.some(p => p.test(url)); + return !isLocal; } \ No newline at end of file diff --git a/packages/mcp-tools/src/cli-adapters.ts b/packages/mcp-tools/src/cli-adapters.ts new file mode 100644 index 00000000..f4edb9a2 --- /dev/null +++ b/packages/mcp-tools/src/cli-adapters.ts @@ -0,0 +1,146 @@ +/** + * CLI Skill Adapters — Concrete CLISkill implementations for thyme, wuzz, fx, taskwarrior. + * + * Each adapter maps to a PowerShell entry point and follows the + * exit-code and JSON contracts from ADAPTER-CONTRACTS.md. + */ + +import { CLISkill, CliExitCode } from './cli-skill'; +import * as path from 'path'; + +const INTEGRATIONS_DIR = path.resolve(__dirname, '../../../integrations'); + +// --- Thyme: Time tracking via tmux --- + +export const thymeSkill: CLISkill = { + name: 'thyme', + description: 'Time tracking via tmux session (start, pause, resume, stop, status, logs)', + adapterPath: path.join(INTEGRATIONS_DIR, 'thyme/adapter.ps1'), + commands: ['start', 'pause', 'resume', 'stop', 'status', 'logs'], + environment: 'wsl', + wslDistro: 'Ubuntu', + exitCodes: { + [CliExitCode.Success]: 'Command completed', + [CliExitCode.UsageError]: 'Missing tmux session or invalid config', + [CliExitCode.RuntimeError]: 'Thyme binary returned non-zero', + [CliExitCode.PermissionError]: 'Not used by thyme', + [CliExitCode.NotFound]: 'Session not found', + }, + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'thyme command: start, pause, resume, stop, status, logs' }, + }, + required: ['command'], + }, + productionGate: false, + async execute(args) { + // Delegate to executeCliSkill (imported by the registry) + const { executeCliSkill } = await import('./cli-skill'); + return executeCliSkill(this, args); + }, +}; + +// --- Wuzz: HTTP inspection --- + +export const wuzzSkill: CLISkill = { + name: 'wuzz', + description: 'HTTP inspection tool (launch, target) — production targets blocked', + adapterPath: path.join(INTEGRATIONS_DIR, 'wuzz/adapter.ps1'), + commands: ['launch', 'target'], + environment: 'windows', + exitCodes: { + [CliExitCode.Success]: 'Wuzz launched or target inspected', + [CliExitCode.UsageError]: 'Missing binary or bad arguments', + [CliExitCode.RuntimeError]: 'Wuzz process failed', + [CliExitCode.PermissionError]: 'Production target blocked by approval gate', + [CliExitCode.NotFound]: 'Not used by wuzz', + }, + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'wuzz command: launch, target' }, + url: { type: 'string', description: 'Target URL (for launch)' }, + }, + required: ['command'], + }, + productionGate: true, + async execute(args) { + const { executeCliSkill } = await import('./cli-skill'); + return executeCliSkill(this, args); + }, +}; + +// --- FX: JSON inspection/transform --- + +export const fxSkill: CLISkill = { + name: 'fx', + description: 'JSON inspection and transform (inspect, query, transform-preview)', + adapterPath: path.join(INTEGRATIONS_DIR, 'fx/adapter.ps1'), + commands: ['inspect', 'query', 'transform-preview'], + environment: 'windows', + exitCodes: { + [CliExitCode.Success]: 'Inspect, query, or transform-preview succeeded', + [CliExitCode.UsageError]: 'Bad arguments or missing file', + [CliExitCode.RuntimeError]: 'jq/fx parse error on the input', + [CliExitCode.PermissionError]: 'Not used by fx', + [CliExitCode.NotFound]: 'File not found', + }, + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'fx command: inspect, query, transform-preview' }, + path: { type: 'string', description: 'JSON file path' }, + expression: { type: 'string', description: 'jq/fx expression (for query)' }, + transform: { type: 'string', description: 'Transform expression (for transform-preview)' }, + }, + required: ['command', 'path'], + }, + productionGate: false, + async execute(args) { + const { executeCliSkill } = await import('./cli-skill'); + return executeCliSkill(this, args); + }, +}; + +// --- Taskwarrior: Task management --- + +export const taskwarriorSkill: CLISkill = { + name: 'taskwarrior', + description: 'Task management via WSL (list, get, create, done)', + adapterPath: path.join(INTEGRATIONS_DIR, 'taskwarrior/adapter.ps1'), + commands: ['list', 'get', 'create', 'done'], + environment: 'wsl', + wslDistro: 'Ubuntu', + exitCodes: { + [CliExitCode.Success]: 'Command succeeded', + [CliExitCode.UsageError]: 'Missing args or invalid UUID format', + [CliExitCode.RuntimeError]: 'Task export or task add failed internally', + [CliExitCode.PermissionError]: 'Not used by taskwarrior', + [CliExitCode.NotFound]: 'UUID not found in task database', + }, + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'taskwarrior command: list, get, create, done' }, + uuid: { type: 'string', description: 'Task UUID (for get, done)' }, + description: { type: 'string', description: 'Task description (for create)' }, + tags: { type: 'string', description: 'Comma-separated tags (for create)' }, + filter: { type: 'string', description: 'Filter expression (for list)' }, + }, + required: ['command'], + }, + productionGate: false, + async execute(args) { + const { executeCliSkill } = await import('./cli-skill'); + return executeCliSkill(this, args); + }, +}; + +/** All CLI skills for registration */ +export const cliSkills: CLISkill[] = [ + thymeSkill, + wuzzSkill, + fxSkill, + taskwarriorSkill, +]; diff --git a/packages/mcp-tools/src/cli-skill.test.ts b/packages/mcp-tools/src/cli-skill.test.ts new file mode 100644 index 00000000..1d0a11a6 --- /dev/null +++ b/packages/mcp-tools/src/cli-skill.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for CLI Skill system — exit code mapping, arg validation, result envelopes. + */ + +import { describe, it, expect } from 'vitest'; +import { + CliExitCode, + interpretExitCode, + type CLISkill, +} from './cli-skill'; + +describe('interpretExitCode', () => { + it('maps exit 0 to success', () => { + const result = interpretExitCode(CliExitCode.Success, ''); + expect(result.success).toBe(true); + expect(result.semantic).toBe('completed'); + }); + + it('maps exit 1 to usage_error with stderr', () => { + const result = interpretExitCode(CliExitCode.UsageError, 'Missing UUID'); + expect(result.success).toBe(false); + expect(result.semantic).toBe('usage_error'); + expect(result.error).toBe('Missing UUID'); + }); + + it('maps exit 1 to usage_error with default message', () => { + const result = interpretExitCode(CliExitCode.UsageError, ''); + expect(result.success).toBe(false); + expect(result.error).toBe('Missing or invalid arguments'); + }); + + it('maps exit 2 to runtime_error', () => { + const result = interpretExitCode(CliExitCode.RuntimeError, 'task export failed'); + expect(result.success).toBe(false); + expect(result.semantic).toBe('runtime_error'); + expect(result.error).toBe('task export failed'); + }); + + it('maps exit 3 to permission_error', () => { + const result = interpretExitCode(CliExitCode.PermissionError, 'Production target blocked'); + expect(result.success).toBe(false); + expect(result.semantic).toBe('permission_error'); + expect(result.error).toBe('Production target blocked'); + }); + + it('maps exit 4 to not_found', () => { + const result = interpretExitCode(CliExitCode.NotFound, 'UUID not found'); + expect(result.success).toBe(false); + expect(result.semantic).toBe('not_found'); + expect(result.error).toBe('UUID not found'); + }); + + it('maps unknown exit code to unknown_error with stderr', () => { + const result = interpretExitCode(99, 'weird'); + expect(result.success).toBe(false); + expect(result.semantic).toBe('unknown_error'); + expect(result.error).toBe('weird'); + }); + + it('maps unknown exit code to unknown_error with default', () => { + const result = interpretExitCode(99, ''); + expect(result.success).toBe(false); + expect(result.error).toBe('Unexpected exit code: 99'); + }); +}); + +describe('CLISkill type', () => { + const mockSkill: CLISkill = { + name: 'test', + description: 'Test skill', + adapterPath: '/test/adapter.ps1', + commands: ['list', 'get'], + environment: 'windows', + exitCodes: { + [CliExitCode.Success]: 'ok', + [CliExitCode.UsageError]: 'bad args', + [CliExitCode.RuntimeError]: 'crash', + [CliExitCode.PermissionError]: 'blocked', + [CliExitCode.NotFound]: 'missing', + }, + inputSchema: { + type: 'object', + properties: { + command: { type: 'string' }, + uuid: { type: 'string' }, + }, + required: ['command'], + }, + async execute() { + return { success: true }; + }, + }; + + it('has all required fields', () => { + expect(mockSkill.name).toBe('test'); + expect(mockSkill.commands).toEqual(['list', 'get']); + expect(mockSkill.environment).toBe('windows'); + expect(mockSkill.exitCodes[CliExitCode.Success]).toBe('ok'); + }); + + it('has valid exit code mapping', () => { + for (const code of Object.values(CliExitCode).filter(v => typeof v === 'number')) { + expect(mockSkill.exitCodes[code as CliExitCode]).toBeDefined(); + } + }); +}); + +describe('CliExitCode enum', () => { + it('has exactly 5 codes (0-4)', () => { + const codes = Object.values(CliExitCode).filter(v => typeof v === 'number'); + expect(codes).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/packages/mcp-tools/src/cli-skill.ts b/packages/mcp-tools/src/cli-skill.ts new file mode 100644 index 00000000..5009ac08 --- /dev/null +++ b/packages/mcp-tools/src/cli-skill.ts @@ -0,0 +1,241 @@ +/** + * CLI Skill — Agent-ready adapter interface for CLI tools. + * + * Extends the base Tool with CLI-specific metadata: + * - Adapter path (PowerShell entry point) + * - Exit code semantics (0-4 mapped from our contracts) + * - Input schema (for Curator validation) + * - Environment requirements (WSL distro, temp dir) + */ + +import { Tool } from './index'; + +// --- Exit codes (from ADAPTER-CONTRACTS.md) --- + +export enum CliExitCode { + Success = 0, + UsageError = 1, + RuntimeError = 2, + PermissionError = 3, + NotFound = 4, +} + +// --- CLI Skill result envelope --- + +export interface CliSkillResult { + success: boolean; + exitCode: CliExitCode; + data?: unknown; + error?: string; + tool: string; + command: string; +} + +// --- CLI Skill definition --- + +export interface CLISkill extends Tool { + /** Adapter entry point (PowerShell script path) */ + adapterPath: string; + + /** Exit code to semantic meaning mapping */ + exitCodes: Record; + + /** Input schema for Curator validation (JSON Schema shape) */ + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + /** Which commands this skill supports */ + commands: string[]; + + /** Environment: 'wsl' | 'windows' | 'both' */ + environment: 'wsl' | 'windows' | 'both'; + + /** Optional: WSL distro required (only if environment includes wsl) */ + wslDistro?: string; + + /** Production target gate: if true, blocks against prod URLs */ + productionGate?: boolean; +} + +// --- Exit code interpretation --- + +export function interpretExitCode( + exitCode: number, + stderr: string +): { success: boolean; semantic: string; error?: string } { + switch (exitCode) { + case CliExitCode.Success: + return { success: true, semantic: 'completed' }; + case CliExitCode.UsageError: + return { + success: false, + semantic: 'usage_error', + error: stderr || 'Missing or invalid arguments', + }; + case CliExitCode.RuntimeError: + return { + success: false, + semantic: 'runtime_error', + error: stderr || 'Target tool returned non-zero', + }; + case CliExitCode.PermissionError: + return { + success: false, + semantic: 'permission_error', + error: stderr || 'Adapter refused to run (production target blocked)', + }; + case CliExitCode.NotFound: + return { + success: false, + semantic: 'not_found', + error: stderr || 'Requested resource not found', + }; + default: + return { + success: false, + semantic: 'unknown_error', + error: stderr || `Unexpected exit code: ${exitCode}`, + }; + } +} + +// --- Execute a CLI skill --- + +export async function executeCliSkill( + skill: CLISkill, + args: Record +): Promise { + const command = args.command as string; + if (!command || !skill.commands.includes(command)) { + return { + success: false, + exitCode: CliExitCode.UsageError, + error: `Unknown command: ${command}. Supported: ${skill.commands.join(', ')}`, + tool: skill.name, + command: command || '(none)', + }; + } + + // Validate required args per command + const validation = validateArgs(skill, command, args); + if (!validation.valid) { + return { + success: false, + exitCode: CliExitCode.UsageError, + error: validation.error, + tool: skill.name, + command, + }; + } + + // Build the PowerShell invocation + const psArgs = buildPowerShellArgs(skill, args); + + try { + const { execSync } = await import('child_process'); + const output = execSync( + `powershell -NoProfile -File "${skill.adapterPath}" ${psArgs}`, + { + encoding: 'utf-8', + timeout: 30_000, + windowsHide: true, + } + ); + + // Parse JSON output from adapter + let data: unknown; + try { + data = JSON.parse(output); + } catch { + data = output.trim(); + } + + return { + success: true, + exitCode: CliExitCode.Success, + data, + tool: skill.name, + command, + }; + } catch (err: unknown) { + const execErr = err as { status?: number; stderr?: string; stdout?: string }; + const exitCode = execErr.status ?? CliExitCode.RuntimeError; + const interpretation = interpretExitCode(exitCode, execErr.stderr || ''); + + // Try to parse stdout as JSON (adapter may have written partial output) + let data: unknown; + if (execErr.stdout) { + try { + data = JSON.parse(execErr.stdout); + } catch { + data = execErr.stdout.trim(); + } + } + + return { + success: interpretation.success, + exitCode, + data, + error: interpretation.error, + tool: skill.name, + command, + }; + } +} + +// --- Arg validation --- + +function validateArgs( + skill: CLISkill, + command: string, + args: Record +): { valid: boolean; error?: string } { + const required = skill.inputSchema.required || []; + for (const field of required) { + if (args[field] === undefined || args[field] === null || args[field] === '') { + return { valid: false, error: `Missing required field: ${field}` }; + } + } + + // UUID validation for commands that expect it + if (['get', 'done'].includes(command) && args.uuid) { + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + if (!uuidPattern.test(args.uuid as string)) { + return { valid: false, error: `Invalid UUID format: ${args.uuid}` }; + } + } + + return { valid: true }; +} + +// --- Build PowerShell args --- + +function buildPowerShellArgs( + skill: CLISkill, + args: Record +): string { + const parts: string[] = []; + const command = args.command as string; + + // Command is always first + parts.push(command); + + // Remaining args (skip 'command' itself) + for (const [key, value] of Object.entries(args)) { + if (key === 'command') continue; + if (value === undefined || value === null) continue; + + // Escape values that might contain spaces + const strVal = String(value); + if (strVal.includes(' ') || strVal.includes('"')) { + parts.push(`"${strVal.replace(/"/g, '""')}"`); + } else { + parts.push(strVal); + } + } + + return parts.join(' '); +} diff --git a/packages/mcp-tools/src/index.ts b/packages/mcp-tools/src/index.ts index c3d7adad..f9a8be21 100644 --- a/packages/mcp-tools/src/index.ts +++ b/packages/mcp-tools/src/index.ts @@ -10,6 +10,10 @@ export interface Tool { execute: (args: Record) => Promise; } +// Re-export CLI skill types +export { CLISkill, CliExitCode, CliSkillResult, executeCliSkill, interpretExitCode } from './cli-skill'; +export { cliSkills, thymeSkill, wuzzSkill, fxSkill, taskwarriorSkill } from './cli-adapters'; + // File read tool const fileReadTool: Tool = { name: 'file_read', @@ -295,3 +299,10 @@ const listChaosScenariosTool: Tool = { // Add to registry toolRegistry.chaos_inject = chaosInjectTool; toolRegistry.list_chaos_scenarios = listChaosScenariosTool; + +// --- CLI Skills (adapter-backed tools) --- +import { cliSkills } from './cli-adapters'; + +for (const skill of cliSkills) { + toolRegistry[skill.name] = skill; +}