diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbe6923..3f10025 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,10 @@ -name: tests +name: checks -# Offline by design. BOS_OFFLINE=1 makes the shared library refuse every -# network call, so this workflow can never reach the live API or read a real -# API key. No secrets are referenced anywhere in this file — on purpose. +# BOS is a no-code, no-Python pack: skills/commands are Markdown, the studios are +# Node. CI therefore does two things only — make sure no real credential ever +# lands in the repo, and make sure the deleted Python runtime never creeps back. +# No secrets are referenced anywhere in this file, on purpose. The secret scan +# uses TruffleHog (free, no license) in filesystem mode. on: push: @@ -12,40 +14,48 @@ permissions: contents: read jobs: - test: + checks: runs-on: ubuntu-latest - env: - BOS_OFFLINE: "1" steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Secret scan (no key may enter the repo) - run: python tools/check-no-secrets.py - - - name: Lint every skill + run: | + set -euo pipefail + # Free, license-free secret scan. Filesystem mode is deterministic on + # both push and pull_request (no BASE/HEAD range to trip over). + curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \ + | sh -s -- -b /usr/local/bin + trufflehog filesystem . --results=verified,unknown --fail --no-update + + - name: No-Python guard (the pack must stay pure-MCP) run: | set -e - for d in skills/*/; do - python tools/lint-skill.py "$d" - done - - - name: Offline fixture tests (no key, no network) + # The Python data-fetch runtime was removed deliberately. Fail if any of + # it returns, or if a skill/command starts shelling out to python again. + if git ls-files '*.py' | grep -q .; then + echo "::error::A .py file is tracked. BOS is Python-free — remove it." + git ls-files '*.py' + exit 1 + fi + if grep -RInE 'python (tools/|~/\.claude|skills/)|bos-run\.py|fetch\.py|trustpager_api' \ + --include='*.md' skills commands agents knowledge templates README.md INSTALL.md; then + echo "::error::Found a reference to the removed Python runtime above." + exit 1 + fi + echo "OK — no Python runtime references." + + - name: Skill frontmatter sanity run: | set -e + # Every skill needs a SKILL.md with name + description frontmatter. + fail=0 for d in skills/*/; do - name="$(basename "$d")" - if [ -f "$d/test-fixture.json" ]; then - echo "== $name ==" - python tools/test-skill.py "$name" - fi + f="$d/SKILL.md" + if [ ! -f "$f" ]; then echo "::error::$d has no SKILL.md"; fail=1; continue; fi + head -n 1 "$f" | grep -q '^---$' || { echo "::error::$f missing frontmatter"; fail=1; } + grep -q '^name:' "$f" || { echo "::error::$f missing name:"; fail=1; } + grep -q '^description:' "$f" || { echo "::error::$f missing description:"; fail=1; } done - - - name: Unit tests - run: python -m unittest discover -s tests -v - - - name: Sequence linter self-check - run: python tools/lint-sequence.py --drafts tests/fixtures/sequence-mixed.json || test $? -eq 2 + [ "$fail" -eq 0 ] && echo "OK — every skill has valid frontmatter." + exit $fail diff --git a/.gitignore b/.gitignore index 4182caf..e489abd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,12 +11,11 @@ _staging/ *-keys.json *api-keys.json *credentials.json -bos.json -bos-journal/ -# Local sweep reports (may contain sensitive matches mid-investigation). -_scripts/sweep-report-*.txt -_scripts/*.log +# Operator working files (created in the operator's project folder, not here — +# listed for safety in case anyone runs a skill from inside this repo). +.bos-memory/ +.bos-journal.md # OS / editor / tooling .DS_Store @@ -27,15 +26,7 @@ desktop.ini *.swp *~ -# Python build artefacts (for the sweep script and any helpers) -__pycache__/ -*.pyc -*.pyo -.pytest_cache/ -venv/ -.venv/ - -# Node — in case any installer wraps in npm +# Node (the studios) node_modules/ package-lock.json yarn.lock diff --git a/INSTALL.md b/INSTALL.md index 87aeb09..6d850d5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,6 +1,6 @@ # Install Business Operating System -Total time: about 10 minutes. No coding required. +Total time: about 5 minutes. No coding required, and **no Python** — everything runs through Claude Code and your TrustPager MCP connection. --- @@ -9,57 +9,45 @@ Total time: about 10 minutes. No coding required. You need: 1. **A TrustPager workspace.** Sign up at [trustpager.com](https://trustpager.com) — you'll get one free. -2. **TrustPager already connected to Claude.** If you're on Claude in the browser, connect TrustPager from the [TrustPager AI access page](https://app.trustpager.com/auto/ai-access). Once it's connected there, Claude Code picks it up automatically. -3. **Claude Code installed.** Get it from [claude.com/claude-code](https://claude.com/claude-code) (works on Mac, Windows, and Linux). -4. **Your TrustPager API key.** Find it under your workspace settings → API. It starts with `tp_live_`. +2. **Claude Code installed.** Get it from [claude.com/claude-code](https://claude.com/claude-code) (works on Mac, Windows, and Linux). +3. **Your TrustPager API key.** Find it under your workspace settings → API. It starts with `tp_live_`. --- ## Install in 2 steps -### Step 1 — Install the Business Operating System pack +### Step 1 — Connect TrustPager, then install the pack -You have two ways to get the skills + commands. **Either way you still run the -Python setup** in 1b, because that's what stores your API key for the tools. - -**Option A — as a Claude Code plugin (recommended).** This registers every -command, skill, and subagent with Claude Code automatically. In Claude Code: +**1a — Connect your TrustPager workspace to Claude Code.** This is what gives the skills their `trustpager` tools. Add the TrustPager MCP server to Claude Code — either with the `/mcp` command, or by adding it to your `.mcp.json`: -``` -/plugin marketplace add TrustPager/Business_Operating_System -/plugin install business-operating-system@trustpager +```json +{ + "mcpServers": { + "trustpager": { + "type": "http", + "url": "https://mcp.trustpager.com//mcp", + "headers": { "Authorization": "Bearer tp_live_..." } + } + } +} ``` -Then clone the repo too (the Python tools and the installer live in it): +Replace `` and the `tp_live_...` key with yours. The exact connection details for your setup are at [docs.trustpager.com](https://docs.trustpager.com). The server **must** be named `trustpager` — the skills look for it by that name. -``` -cd ~ -git clone https://github.com/TrustPager/Business_Operating_System.git -``` +> The MCP connection holds your API key. There's nothing else to store — no key file, no setup script. -**Option B — clone only.** Clone to your home folder and point Claude Code at -the directory (or run from inside it): +**1b — Install the Business Operating System pack.** This registers every command, skill, and subagent. In Claude Code: ``` -cd ~ -git clone https://github.com/TrustPager/Business_Operating_System.git -``` - -#### 1b — Run setup (both options, same on Mac, Linux, and Windows) - -``` -cd Business_Operating_System -python tools/setup.py -python tools/check-install.py +/plugin marketplace add TrustPager/Business_Operating_System +/plugin install business-operating-system@trustpager ``` -`setup.py` writes your TrustPager API key to `~/.claude/bos.json`. If you've already connected TrustPager to Claude in the browser, it'll detect that key and offer to reuse it (no copy-paste needed). - -`check-install.py` runs 7 quick health checks and prints a green / red list. If you see "All checks passed", you're ready. +(Prefer to clone? `git clone https://github.com/TrustPager/Business_Operating_System.git` into your home folder and point Claude Code at it. Either way there's no build step.) ### Step 2 — Teach Claude your business (run `/learn-my-business`) -**Restart Claude Code** so the new commands load, then type: +**Restart Claude Code** so the new commands and MCP connection load, then type: ``` /learn-my-business @@ -67,6 +55,8 @@ python tools/check-install.py It reads your live TrustPager workspace and writes a `CLAUDE.md` into your project folder for you — your real pipeline, products, and brand — and folds in the gotchas for your line of work. That file tells Claude the shape of your business so it doesn't have to ask every time. Re-run it whenever your pipeline, products, or brand change. +It also creates a local memory store (`.bos-memory/` in your project folder) that loads automatically each session. As you work, tell Claude to remember things with `/remember` — preferences, how you like things done, context the CRM doesn't hold — and it carries them forward. (If your project folder is a git repo, you may want to add `.bos-memory/` and `.bos-journal.md` to `.gitignore` — they're your private working notes and change log.) + **Prefer to do it by hand?** Copy `templates/CLAUDE.md` into your project folder as `CLAUDE.md` and fill in the `<<< ... >>>` blanks. Industry-specific gotchas live in `knowledge/industry-notes.md` (one section per vertical: mortgage/finance, trades, insurance, consulting, allied health, manufacturing). --- @@ -85,14 +75,14 @@ You should see Claude pull up everything that needs your attention today — quo ## Troubleshooting -**"trustpager mcp not found"** -The TrustPager connector isn't connected to Claude. Connect it at [app.trustpager.com/auto/ai-access](https://app.trustpager.com/auto/ai-access), then restart Claude Code. +**"trustpager mcp not found" / the skills can't reach your data** +The `trustpager` MCP server isn't connected. Run `/mcp` in Claude Code to check it's listed and connected, re-check the URL + key in your `.mcp.json` (Step 1a), then restart Claude Code. **"Authorization: Bearer invalid"** -The API key didn't paste correctly. Generate a new one in your TrustPager workspace settings → API → Create new key. +The API key is wrong or expired. Generate a new one in your TrustPager workspace settings → API → Create new key, and update it in your MCP connection. **"command /sweep-my-day not found"** -Step 1 didn't complete. Make sure you ran `python tools/setup.py` from inside the `Business_Operating_System` folder and restart Claude Code. +The pack didn't install, or Claude Code needs a restart. Re-run the `/plugin install` step (Step 1b) and restart. **"Claude doesn't know about my products / pipeline / brand"** You skipped Step 2. Run `/learn-my-business` and it'll write your `CLAUDE.md` from your live workspace (or copy `templates/CLAUDE.md` in by hand). Claude picks it up next session. @@ -101,34 +91,27 @@ You skipped Step 2. Run `/learn-my-business` and it'll write your `CLAUDE.md` fr ## Updating -When new skills ship, pull the latest: +When new skills ship, update the plugin: ``` -cd ~/Business_Operating_System -git pull -python tools/setup.py # refreshes the skill launcher (safe to re-run; won't touch your key) -python tools/check-install.py +/plugin update business-operating-system@trustpager ``` -Claude Code reads the skills directly from this folder, so there's no plugin re-install. The `setup.py` step just makes sure the `~/.claude/bos-run.py` launcher is present and points at this folder — it's idempotent and leaves your API key alone. +(Cloned instead? `cd ~/Business_Operating_System && git pull`.) There's no build step and nothing to re-run — the skills are Markdown that Claude reads directly, and your MCP connection and `CLAUDE.md` are untouched. --- ## Uninstall -To remove BOS, just delete the folder: - ``` -rm -rf ~/Business_Operating_System +/plugin uninstall business-operating-system@trustpager ``` -Optionally clear the stored API key + cache: +(Or delete the cloned folder.) Optionally remove the `trustpager` entry from your `.mcp.json` to disconnect the workspace. -``` -python tools/config.py --clear-all -``` +Your memory store and change log live in your project folder, not in the pack — if you want to wipe them too, delete `.bos-memory/` and `.bos-journal.md` from that folder. -(Neither step touches your TrustPager workspace — your data is unaffected.) +(None of these steps touch your TrustPager workspace — your data is unaffected.) --- diff --git a/README.md b/README.md index 4521a2f..d4f5343 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ Claude: /sweep-my-day The method behind automations is in [knowledge/automation-method.md](knowledge/automation-method.md); a catalogue of ready-to-adapt automations (missed-call recovery, lead intake, review requests, renewal reminders, and more — tagged by industry) is in [knowledge/automation-recipes.md](knowledge/automation-recipes.md). +**🧠 Memory & feedback** *(gets sharper the more you use it)* +- `/remember` — tell Claude something to carry into future sessions: how you like things done, soft context the CRM doesn't hold, a recurring quirk. Kept in a local store (`./.bos-memory/`), one fact per file, that loads automatically each session. Claude also saves things proactively as it learns them — and always tells you when it does. +- `/suggest-improvement` — wanted something that doesn't exist yet? Log it. Whether it's a missing BOS skill or a TrustPager capability that isn't there, it files a request to the TrustPager team so they can build it. That's how the thing you wanted becomes a feature. + +The model behind both — what loads automatically, what's worth remembering, the rails, and how the feedback loop works — is in [knowledge/memory-and-feedback.md](knowledge/memory-and-feedback.md). + **📈 Reporting & cash flow (know your numbers, on a schedule)** - `/outstanding-invoices` — who owes you money. Pulls accounts receivable from your connected accounting integration into an aged summary (Current / 1-30 / 31-60 / 61-90 / 90+), surfaces the worst offenders, and — if you want — builds a dashboard and emails it to you (and your bookkeeper) every morning. - `/email-me-a-report` — deliver *any* report as a recurring email digest. Pick or build a dashboard, choose recipients and a cadence (e.g. 7am weekdays), and it lands in your inbox server-side with nothing open. The same mechanism behind the built-in Team Task Digest. @@ -114,8 +120,10 @@ If that's you — this is built for you. ## How to install +No coding, no Python — it's a Claude Code plugin plus a TrustPager MCP connection. + 1. Sign up for TrustPager and grab your API key from your workspace settings -2. Run the installer (see [INSTALL.md](./INSTALL.md)) +2. Connect the `trustpager` MCP server to Claude Code, then install the pack (see [INSTALL.md](./INSTALL.md)) 3. Restart Claude Code 4. Type `/sweep-my-day` and say good morning @@ -141,7 +149,7 @@ Every skill in here: - Is open source and inspectable — read the source, modify it, fork it - Only ever talks to your TrustPager workspace (never anyone else's) - Asks before doing anything destructive -- Logs what it did, so you can see the trail — every write lands in `~/.claude/bos-journal/`; read it any time with `python tools/journal.py` +- Logs what it did, so you can see the trail — every write is appended to `.bos-journal.md` in your project folder; open it any time ## Subagents diff --git a/TESTING.md b/TESTING.md index 1d33dc3..fa9f61b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,96 +1,25 @@ -# Testing — and how we keep the API key out of it +# Testing & checks -The one rule: **a real `tp_live_…` key never enters a test, a fixture, CI, or -the repo.** The key is read only at runtime, by the operator, from -`~/.claude/bos.json`. Everything in the test suite runs offline against canned -data. This document is the strategy and the guard-rails that enforce it. +BOS is a **no-code, no-Python** pack: skills and commands are Markdown that Claude reads, the studios are Node. There's no fetch layer to unit-test anymore, so "testing" here means two things — keep secrets out of the repo, and keep the pack pure-MCP. Both run in CI ([.github/workflows/test.yml](.github/workflows/test.yml)) and need no key and no network. -## The kill-switch: `BOS_OFFLINE` +## What CI checks -Set `BOS_OFFLINE=1` and the shared library -([`tools/trustpager_api.py`](tools/trustpager_api.py)) refuses every -**authenticated** call — `_request` raises **before** it even reads the API -key. (The public, unauthenticated API catalog can still load — fetching it -can't leak a key — so `_use_live` fixtures still work in CI.) A key leak is -impossible *by construction*, not just by convention. Run anything under it: +1. **Secret scan.** [gitleaks](https://github.com/gitleaks/gitleaks-action) scans tracked files for real credentials. No `tp_live_`/`tp_test_` key (or any other secret) may ever land in the repo. The only place a key belongs is the operator's own `trustpager` MCP connection, which is never committed. -``` -BOS_OFFLINE=1 python tools/test-skill.py nurture-health -BOS_OFFLINE=1 python -m unittest discover -s tests -``` +2. **No-Python guard.** The Python data-fetch runtime was removed on purpose. CI fails if any `.py` file is tracked, or if a skill/command/doc references the removed runtime (`python tools/…`, `bos-run.py`, `fetch.py`, `trustpager_api`). This is what stops the pack quietly regrowing a Python dependency. -## Three layers of test +3. **Skill frontmatter sanity.** Every `skills/*/SKILL.md` must exist and carry `name:` + `description:` frontmatter. -1. **Static lint** — `python tools/lint-skill.py skills/` checks every - skill's frontmatter and (for any `fetch.py`) that there's no hardcoded - `tp_live_` key, no hardcoded `supabase.co` URL, and that paths come from - `resolve_path()`. +## Sanity-checking a skill by hand -2. **Offline fixture tests** — `python tools/test-skill.py ` monkeypatches - `api_get` + the catalog with the skill's `test-fixture.json` and runs the - real `fetch.py` end to end. **No key, no network.** Fixtures are *input* - shape: +There's no fixture harness — a skill is just instructions. To verify one: - ```json - { - "catalog": { "resources": [ { "id": "...", "endpoints": [ {"method":"GET","path":"/..."} ] } ] }, - "responses": { "": { "data": [ ... ], "pagination": {"has_more": false} } } - } - ``` +1. **Read it.** Does Step 1 pull data via named `trustpager` MCP read tools (no `python`)? Is the digest logic spelled out as explicit rules? Are the tool names real (`list_deals`, not `list_opportunities`)? +2. **Dry-run it** against a demo workspace: trigger the skill in Claude Code with the `trustpager` MCP connected and watch the tool calls. Reads are free; any write should pause for your approval and get logged to `.bos-journal.md` (see [knowledge/safeguards.md](knowledge/safeguards.md)). +3. **Check the rails.** Anything that sends/creates/updates must draft-then-confirm, journal the write, and search-first rather than blind-retry. - (`{"_use_live": true}` fetches the public catalog instead of inlining one — - fine under `BOS_OFFLINE` since the catalog needs no key. Prefer inline - catalogs for new skills so the test has zero network dependency.) +## Before opening a PR -3. **Unit tests** — `tests/` holds pure-logic tests with no I/O: - - `test_lint_sequence.py` — the house-style linter (CTA-above-image, mixed-set - failure, em-dash, negative subjects). - - `test_safety.py` — the offline guard actually blocks GET/POST/catalog, the - guard fires before the key is read, and the journal redacts keys. - - Run: `python -m unittest discover -s tests -v`. - -## The secret scanner - -`python tools/check-no-secrets.py` scans tracked files for real credential -tokens (TrustPager / Anthropic / AWS / private-key blocks) and a stray -`bos.json`. It matches a *real* key (long token after the prefix), not the bare -`tp_live_` prefix that legitimately appears in docs — so prose is never a false -positive. **Run it before every push**; CI runs it first. - -Wire it as a pre-commit hook: - -``` -# .git/hooks/pre-commit -#!/bin/sh -python tools/check-no-secrets.py || exit 1 -``` - -## CI - -[`.github/workflows/test.yml`](.github/workflows/test.yml) runs all of the -above on every push/PR with `BOS_OFFLINE: "1"` and **no secrets referenced -anywhere** in the workflow. Order: secret scan → lint every skill → offline -fixture tests → unit tests → linter self-check. - -## The only place a real key is allowed: opt-in live smoke - -Sometimes you need to confirm a `fetch.py` works against the real API. That is -**manual, local, and never the production key**: - -- Use a **dedicated read-only key for the Demo workspace**, exported for the - one command: `TRUSTPAGER_API_KEY=tp_live_ python skills//fetch.py`. -- Never the production key. Never committed. Never in CI. Never echoed. -- Writes during a smoke go to the Demo workspace with controlled recipients - only — the same boundary the rest of the platform's testing follows. - -If you're unsure whether something is safe to run live, it isn't — make a -fixture instead. - -## Redaction (defence in depth) - -Even though a key should never appear in a write body, the journal -([`tools/journal.py`](tools/journal.py)) redacts any `tp_live_`/`tp_test_` -token from what it writes, and library error messages never print the key -(they reference the prefix only). `config.py` masks the stored key when it -shows it. +- No key anywhere (gitleaks will catch it, but check). +- No `.py` files, and no `python …` invocations in any skill, command, agent, or doc. +- New skills follow [skills/sweep-my-day/SKILL.md](skills/sweep-my-day/SKILL.md) as the gold standard. diff --git a/_scripts/sweep.py b/_scripts/sweep.py deleted file mode 100644 index 55df429..0000000 --- a/_scripts/sweep.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Business Operating System — Private Data Sweep - -Scans every text file in the repository for patterns that should never reach a -public repo: secrets, internal UUIDs, personal contact info, internal -infrastructure paths, named customer references, and FinalPiece-internal -persona names. - -Usage: - python _scripts/sweep.py # scan whole repo, exit non-zero on any match - python _scripts/sweep.py path/to/file # scan a single file or directory - python _scripts/sweep.py --staging # scan only _staging/ (work-in-progress) - python _scripts/sweep.py --quiet # report only summary, not per-line matches - -Exit codes: - 0 = clean (or only INFO-level findings) - 1 = WARN-level findings present (review needed) - 2 = FAIL-level findings present (must fix before publish) - -Add to pre-push hook to make this a hard gate on the public repo. -""" - -from __future__ import annotations - -import argparse -import re -import sys -from dataclasses import dataclass -from pathlib import Path - -# ============================================================================= -# Deny-list — every pattern has a severity: -# FAIL must not appear in published files; sweep exits 2 -# WARN almost certainly should be removed; sweep exits 1 -# INFO context-dependent; reviewer's call -# ============================================================================= - - -@dataclass(frozen=True) -class Pattern: - name: str - severity: str # 'FAIL' | 'WARN' | 'INFO' - regex: re.Pattern - replacement_hint: str # what to substitute when fixing - - -# Compile once at module load. -def _p(name: str, severity: str, pattern: str, replacement: str, flags: int = 0) -> Pattern: - return Pattern(name, severity, re.compile(pattern, flags), replacement) - - -PATTERNS: list[Pattern] = [ - # ------------------------------------------------------------------------- - # SECRETS — auto-fail. - # ------------------------------------------------------------------------- - _p("TrustPager API key", "FAIL", r"tp_live_[A-Za-z0-9_]{8,}", "(redacted — use placeholder tp_live_YOUR_KEY)"), - _p("TrustPager OAuth token", "FAIL", r"tp_oauth_[A-Za-z0-9_]{8,}", "(redacted)"), - _p("Supabase secret key", "FAIL", r"sb_secret_[A-Za-z0-9_]{8,}", "(redacted)"), - _p("Stripe live key", "FAIL", r"sk_live_[A-Za-z0-9]{8,}", "(redacted)"), - _p("Stripe test key", "FAIL", r"sk_test_[A-Za-z0-9]{8,}", "(redacted)"), - _p("Slack bot token", "FAIL", r"xoxb-[A-Za-z0-9\-]{8,}", "(redacted)"), - _p("GitHub PAT", "FAIL", r"ghp_[A-Za-z0-9]{8,}", "(redacted)"), - _p("JWT-shaped token", "WARN", r"eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}", "(redacted — verify if JWT)"), - - # ------------------------------------------------------------------------- - # KNOWN INTERNAL UUIDs — auto-fail. - # ------------------------------------------------------------------------- - # Pattern split via concatenation so this script file doesn't itself - # contain the literal company_id as a single contiguous string. The - # compiled regex still matches the live UUID anywhere it leaks into - # repo content. - _p("FinalPiece company_id", "FAIL", r"[uuid]" + "-0000-0000-0000-" + "000000000001", "{{your_company_id}}"), - _p("Demo Company company_id", "FAIL", r"[uuid]", "(omit — internal test workspace)"), - _p("Operator user_id (internal)", "FAIL", r"[uuid]", "{{your_user_id}}"), - _p("Internal persona user_id", "FAIL", r"[uuid]", "(omit)"), - - # ------------------------------------------------------------------------- - # PERSONAL CONTACT INFO — auto-fail. - # ------------------------------------------------------------------------- - _p("Personal Gmail", "FAIL", r"s\.k[a-z]+@gmail\.com", "you@yourdomain.com", re.IGNORECASE), - _p("FinalPiece work email", "FAIL", r"\b[a-z][a-z\.]+@finalpiece\.ai\b", "you@yourdomain.com", re.IGNORECASE), - _p("Test pool emails", "FAIL", r"test\d+@finalpiece\.ai", "(omit)", re.IGNORECASE), - _p("Operator surname", "FAIL", r"\b[name]\b", "(omit)", re.IGNORECASE), - _p("AU phone E.164", "FAIL", r"\0400 000 000", "+61 4XX XXX XXX"), - _p("AU phone local", "FAIL", r"\b0431\s?377\s?068\b", "0400 000 000"), - - # ------------------------------------------------------------------------- - # INTERNAL INFRASTRUCTURE — warn (some may be intentional). - # ------------------------------------------------------------------------- - _p("Windows dev path", "WARN", r"[Dd]:[\\/]Dev[\\/]", "~/your-project/"), - _p("Windows user path", "FAIL", r"C:[\\/]Users[\\/][A-Za-z0-9_\-]+[\\/]", "~/"), - _p("EVE hostname", "FAIL", r"eve\.[internal-host]\.net", "(omit — internal infra)"), - _p("Supabase project ref", "FAIL", r"[project-ref]", ""), - _p("Keys manifest filename", "FAIL", r"\.trustpager-keys\.json", "(omit)"), - _p("Internal .net subdomains", "WARN", r"\b([internal]|fulfillment|fulfilment|operations)\.trustpager\.net", "(omit — internal infra)"), - _p("Cloudflare tunnel id", "WARN", r"\b[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\b", "(verify — looks like UUID)"), - - # ------------------------------------------------------------------------- - # INTERNAL PERSONA NAMES — warn (context matters — "Adam" inside SKILL.md - # for the BOS sidekick persona is fine; "Adam wrote this" attributing the - # FinalPiece engineering assistant is not). - # ------------------------------------------------------------------------- - _p("Operator first name", "WARN", r"(? bool: - if path.is_dir(): - return False - # The sweep script itself contains the literal patterns it scans for. - # Scanning it would always self-flag. Exclude by absolute path. - if path.resolve() == SELF_PATH: - return False - if any(part in SKIP_DIRS for part in path.parts): - return False - if not include_staging and "_staging" in path.parts: - return False - if path.suffix.lower() in SKIP_EXTENSIONS: - return False - try: - if path.stat().st_size > MAX_FILE_SIZE: - return False - except OSError: - return False - return True - - -def _iter_files(root: Path, include_staging: bool): - if root.is_file(): - if _should_scan(root, include_staging=True): - yield root - return - for path in root.rglob("*"): - if _should_scan(path, include_staging): - yield path - - -# ============================================================================= -# Scanning -# ============================================================================= - - -@dataclass -class Finding: - file: Path - line: int - severity: str - pattern: str - match: str - replacement: str - context: str - - -def _scan_file(path: Path) -> list[Finding]: - findings: list[Finding] = [] - try: - text = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return findings - - for i, line in enumerate(text.splitlines(), start=1): - for pat in PATTERNS: - for m in pat.regex.finditer(line): - findings.append(Finding( - file=path, - line=i, - severity=pat.severity, - pattern=pat.name, - match=m.group(0), - replacement=pat.replacement_hint, - context=line.strip()[:140], - )) - return findings - - -def _scan_repo(root: Path, include_staging: bool) -> list[Finding]: - all_findings: list[Finding] = [] - for path in _iter_files(root, include_staging): - all_findings.extend(_scan_file(path)) - return all_findings - - -# ============================================================================= -# Reporting -# ============================================================================= - - -SEVERITY_RANK = {"FAIL": 2, "WARN": 1, "INFO": 0} -SEVERITY_BADGE = {"FAIL": "[FAIL]", "WARN": "[WARN]", "INFO": "[INFO]"} - - -def _print_report(findings: list[Finding], quiet: bool) -> int: - if not findings: - print("Sweep clean — no matches found.") - return 0 - - by_severity: dict[str, list[Finding]] = {"FAIL": [], "WARN": [], "INFO": []} - for f in findings: - by_severity[f.severity].append(f) - - worst_rank = max(SEVERITY_RANK[f.severity] for f in findings) - - if not quiet: - for severity in ("FAIL", "WARN", "INFO"): - items = by_severity[severity] - if not items: - continue - print(f"\n{SEVERITY_BADGE[severity]} {severity} ({len(items)} match{'es' if len(items) != 1 else ''}):") - for f in items: - rel = f.file.relative_to(REPO_ROOT) if f.file.is_relative_to(REPO_ROOT) else f.file - print(f" {rel}:{f.line} [{f.pattern}]") - print(f" match: {f.match!r}") - print(f" fix: {f.replacement}") - print(f" line: {f.context}") - - print("\n" + "=" * 60) - print(f"Summary: {len(by_severity['FAIL'])} FAIL, {len(by_severity['WARN'])} WARN, {len(by_severity['INFO'])} INFO") - print("=" * 60) - - if worst_rank == 2: - print("Result: BLOCKED — fix all FAIL matches before publishing.") - return 2 - if worst_rank == 1: - print("Result: WARNINGS — review before publishing.") - return 1 - return 0 - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> int: - parser = argparse.ArgumentParser(description="Sweep the repo for private data.") - parser.add_argument("path", nargs="?", default=str(REPO_ROOT), - help="File or directory to scan (default: repo root)") - parser.add_argument("--staging", action="store_true", - help="Include the _staging/ directory (work-in-progress)") - parser.add_argument("--quiet", action="store_true", - help="Print only the summary, not per-line matches") - args = parser.parse_args() - - root = Path(args.path).resolve() - if not root.exists(): - print(f"Error: path does not exist: {root}", file=sys.stderr) - return 2 - - findings = _scan_repo(root, include_staging=args.staging) - return _print_report(findings, quiet=args.quiet) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agents/nurture-architect.md b/agents/nurture-architect.md index 6bd4255..add1843 100644 --- a/agents/nurture-architect.md +++ b/agents/nurture-architect.md @@ -1,7 +1,7 @@ --- name: nurture-architect description: Heavy-lifting marketing strategist for the BOS marketing pack. Delegate the long, context-hungry stages — reading every call transcript end-to-end to build the customer-voice synthesis, authoring the brand-strategy docs, and drafting a multi-step nurture sequence in the operator's voice. It produces drafts and local artifact files for the operator to review; it NEVER writes to the live auto queue (that's the wire-nurture-sequence skill, run from the main thread after approval). -tools: Bash, Read, Grep, Glob, Write, WebFetch +tools: Read, Grep, Glob, Write, WebFetch, ToolSearch, mcp__trustpager__* model: inherit --- @@ -21,11 +21,11 @@ read [`knowledge/automation-recipes.md`](../knowledge/automation-recipes.md) One of these, named in your prompt: -1. **Customer-voice synthesis** — pull transcripts (`python - tools/dump-transcripts.py`), read EVERY file end-to-end (paginate large - ones), and write `customer-voice-synthesis.md` with the 10 sections the - method doc specifies. Quote verbatim with `[Speaker, file]` footnotes. - Filter out the host/operator's voice. +1. **Customer-voice synthesis** — pull transcripts via the `trustpager` MCP + (`list_transcripts` to enumerate, then `get_transcript` on each), read EVERY + one end-to-end, and write `customer-voice-synthesis.md` with the 10 sections + the method doc specifies. Quote verbatim with `[Speaker, transcript]` + footnotes. Filter out the host/operator's voice. 2. **Brand strategy docs** — from the synthesis, author `positioning.md`, `icp.yaml`, `voice.md`, `value-props.yaml`, `content-pillars.yaml`. Every claim anchored to a real customer quote — never invented sales copy. @@ -37,9 +37,12 @@ One of these, named in your prompt: ## How you work -- Run `python tools/dump-transcripts.py` and `python tools/dump-crm-bundle.py` - to get raw material. Read it thoroughly — the synthesis is only as good as - how completely you read. +- Gather raw material via `trustpager` MCP read calls: transcripts through + `list_transcripts` + `get_transcript`, and CRM context through reads like + `list_deals`, `get_deal`, `get_deal_activities`, `list_contacts`, + `list_customers`, `get_crm_settings`. Reads are free — pull thoroughly; the + synthesis is only as good as how completely you read. (Tool names use `deal` + for legacy reasons — say "opportunity" in anything operator-facing.) - Write artifacts as local files (`Write`) so the operator and the main thread can review and iterate. Return a tight summary of what you produced + where, plus anything that needs the operator's judgement. @@ -49,8 +52,9 @@ One of these, named in your prompt: ## Hard rules - **You DRAFT and SYNTHESIZE. You never deploy.** No writes to the live auto - queue, no `wire-nurture-sequence`, no MCP write tools, no `api_post`/ - `api_patch`. Pushing approved drafts into TrustPager is the main thread's job + queue, no `wire-nurture-sequence`, no MCP write tools (`create_*` / + `update_*` / `add_*` / `enrol_*` / `send_*`). Pushing approved drafts into + TrustPager is the main thread's job (via `wire-nurture-sequence`) after the operator approves. If asked to deploy, return: "Drafts ready — hand back to the main thread to wire them in after the operator's green light." diff --git a/agents/workspace-analyst.md b/agents/workspace-analyst.md index 74b48b6..d9004a8 100644 --- a/agents/workspace-analyst.md +++ b/agents/workspace-analyst.md @@ -1,7 +1,7 @@ --- name: workspace-analyst -description: Read-only deep-dive analyst for a TrustPager workspace. Delegate to this agent for any heavy, multi-step READ task that would otherwise flood the main conversation — full pipeline sweeps, automation health audits, nurture-sequence health, data-quality scans, "what's the state of X across the whole business". It runs the BOS fetch scripts (which fan out many API calls), digests the results, and returns a tight conclusion — not the raw dumps. It NEVER writes. -tools: Bash, Read, Grep, Glob +description: Read-only deep-dive analyst for a TrustPager workspace. Delegate to this agent for any heavy, multi-step READ task that would otherwise flood the main conversation — full pipeline sweeps, automation health audits, nurture-sequence health, data-quality scans, "what's the state of X across the whole business". It fans out many `trustpager` MCP read calls, digests the results, and returns a tight conclusion — not the raw dumps. It NEVER writes. +tools: Read, Grep, Glob, ToolSearch, mcp__trustpager__* model: inherit --- @@ -19,27 +19,33 @@ pipeline". Answer THAT question. Don't broaden the scope. ## How you work -1. **Prefer the BOS fetch scripts over chained calls.** Most analytical - questions already have a fetcher that does the multi-endpoint fan-out and - digest for you. Look first: - - `python skills/sweep-my-day/fetch.py` — daily state - - `python skills/audit-my-automations/fetch.py` — automation health - - `python skills/nurture-health/fetch.py` — auto-queue / sequence health - - `python skills/learn-my-business/fetch.py` — workspace shape - - `python tools/dump-crm-bundle.py --resources ` — raw bundles - - `python tools/audit-pipeline.py` / `tools/audit-contacts.py` / `tools/find-gaps.py` - Run `Glob` over `skills/*/fetch.py` and `tools/*.py` if you're unsure what - exists. Read the script's docstring to learn its output shape before - running it. +1. **Fan out `trustpager` MCP read calls in parallel.** Reads are free, so batch + the ones a question needs in a single round and digest the results yourself. + The usual building blocks, by question type: + - Workspace shape / "state of X": `get_company`, `get_crm_settings`, + `list_pipelines`, `list_pipeline_stages`, `list_products`. + - Pipeline / opportunity health: `list_deals` (large `limit`), `get_pipeline_summary`, + `get_pipeline_deals`, `get_deal_activities` on the deals that matter. + - Automation health: `list_automations`, `get_automation`, + `list_automation_runs` (look at recent run outcomes). + - Nurture / auto-queue health: `list_auto_queues`, `get_auto_queue`, + `list_auto_queue_enrollments`. + - Data-quality scans: `list_contacts`, `list_customers` (paginate via + `limit`/`offset`), checking for missing emails/phones, orphaned records, + unstaged deals. + - Comms / signal: `list_email_threads`, `list_sms_conversations`, + `list_phone_call_logs`, `list_transcripts` + `get_transcript`. + Tool names use `deal` for legacy reasons — always say **"opportunity"** in + your answer. If unsure a tool exists, search the surface (`ToolSearch`) + rather than guessing a name. -2. **Run the script, read its JSON from stdout.** The scripts digest raw API - responses down to the rows that matter, so you can reason over the whole - business cheaply. If a script reports per-endpoint errors on stderr, note - them and proceed with what you have — don't bail on a partial failure. +2. **Read the results, reason over the whole set.** Pull the most recent + records and filter/aggregate them yourself. If one call errors, note it in + one line and proceed with what you have — don't bail on a partial failure. -3. **If no script fits**, you may run targeted reads via the shared library - from a one-off Python snippet (`from trustpager_api import api_get, - parallel_get, paginate, resolve_path`). Keep it read-only. +3. **Need a record's full detail?** Chain a `get_*` on the specific id + (`get_deal`, `get_automation`, `get_auto_queue`, `get_contact`). Keep + everything read-only — `list_*`, `get_*`, `search_*` only. 4. **Think, then conclude.** The value you add is the synthesis: ranked findings, the one number that matters, the single biggest problem, the @@ -48,7 +54,7 @@ pipeline". Answer THAT question. Don't broaden the scope. ## Hard rules - **READ ONLY. Never write.** No `send_*`, `create_*`, `update_*`, `delete_*`, - `trigger_*`, `dispatch_*`, no POST/PATCH/DELETE, no `api_post`/`api_patch`. + `trigger_*`, `dispatch_*`, `move_*` — only `list_*` / `get_*` / `search_*`. If the task implies a write, STOP and return: "This needs a write — hand it back to the main thread to confirm with the operator." You analyse; the main thread (with the operator's yes) acts. diff --git a/brand/README.md b/brand/README.md index 39029cc..e4b928a 100644 --- a/brand/README.md +++ b/brand/README.md @@ -1,6 +1,6 @@ # Brand kit -This folder is the **single source of truth for your brand** across every BOS studio. Edit `brand.json`, drop your logo in, run one sync command — every studio you build picks it up automatically. +This folder is the **single source of truth for your brand** across every BOS studio. Edit `brand.json`, drop your logo in, and `/brand-my-workspace` propagates it — every studio you build picks it up automatically. ## Files @@ -29,11 +29,7 @@ Claude scrapes your site, picks up your colours, finds your logo, writes `brand. 1. Open `brand.json`. Swap colours under `colors:`. Plain hex codes. 2. Replace `logo.png` with yours (ideally a wide wordmark; 400-1200px wide; transparent PNG). -3. Run the sync: - ```bash - python tools/sync-brand.py - ``` - This copies `logo.png` + the favicon set into each studio's `public/` folder so dev servers serve them at `/logo.png`, `/favicon.ico`, etc. +3. Copy `logo.png` + the favicon set into each studio's `public/` folder so dev servers serve them at `/logo.png`, `/favicon.ico`, etc. The easiest way is to re-run `/brand-my-workspace` — it does the copy for you. To do it by hand, copy each asset into every `studio/*/public/` directory. 4. Restart any running studio dev servers. That's it. Every studio in `studio/` is now on your brand. diff --git a/brand/brand.json b/brand/brand.json index 58c64be..899fce6 100644 --- a/brand/brand.json +++ b/brand/brand.json @@ -2,7 +2,7 @@ "name": "TrustPager", "tagline": "The unified CRM for service businesses", - "_comment": "Edit this file (or run /brand-my-workspace) to retheme every BOS studio in one shot. After editing, run `python tools/sync-brand.py` to refresh logo + favicons in each studio's public/ folder. Studios import this JSON via src/brand.js — no other files need touching.", + "_comment": "Edit this file (or run /brand-my-workspace) to retheme every BOS studio in one shot. After editing, re-run /brand-my-workspace to copy the logo + favicons into each studio's public/ folder. Studios import this JSON via src/brand.js — no other files need touching.", "colors": { "primary": "#29c6c6", diff --git a/commands/audit-my-automations.md b/commands/audit-my-automations.md index bcc0d9b..c56645f 100644 --- a/commands/audit-my-automations.md +++ b/commands/audit-my-automations.md @@ -4,4 +4,4 @@ description: Health-check every automation — which are firing, stale, erroring Run the **audit-my-automations** skill. -Invoke the skill at `skills/audit-my-automations/SKILL.md`. Follow its instructions exactly — including running `fetch.py` first and presenting findings worst-first, never auto-applying fixes. +Invoke the skill at `skills/audit-my-automations/SKILL.md`. Follow its instructions exactly — it gathers automation state via `trustpager` MCP read tools, then presents findings worst-first, never auto-applying fixes. diff --git a/commands/audit-my-data.md b/commands/audit-my-data.md index 56ae402..06382e6 100644 --- a/commands/audit-my-data.md +++ b/commands/audit-my-data.md @@ -4,12 +4,13 @@ description: Find the mess — missing fields, bad/missing emails, likely-duplic Run the **Audit My Data** skill. -Invoke the skill at `skills/audit-my-data/SKILL.md`. Run both -`python tools/find-gaps.py --json` and `python tools/audit-contacts.py --json`, -then present a consolidated hygiene report worst-first — FIX (unowned/duplicate/ -no-value records that cost money) then WORTH-A-LOOK (missing emails, dormant -contacts). Offer the safe mechanical fixes one at a time with a yes; never merge -or delete without showing the records and naming what survives. +Invoke the skill at `skills/audit-my-data/SKILL.md`. It gathers CRM records via +`trustpager` MCP read tools (`list_contacts`, `list_customers`, `list_deals`, +`list_tasks`) and scans them for gaps, then presents a consolidated hygiene +report worst-first — FIX (unowned/duplicate/no-value records that cost money) +then WORTH-A-LOOK (missing emails, dormant contacts). Offer the safe mechanical +fixes one at a time with a yes; never merge or delete without showing the +records and naming what survives. For pipeline performance (stuck deals, stage drop-offs) point to `/weekly-review`, not this skill. diff --git a/commands/brand-my-workspace.md b/commands/brand-my-workspace.md index bd7ae1b..f6c0a6b 100644 --- a/commands/brand-my-workspace.md +++ b/commands/brand-my-workspace.md @@ -1,5 +1,5 @@ --- -description: Point at the user's website, infer brand colours + logo + name, write `brand/brand.json` + drop assets, run `tools/sync-brand.py`. Every BOS studio is rebranded in one shot. +description: Point at the user's website, infer brand colours + logo + name, write `brand/brand.json` + drop assets, then sync them into every studio's `public/`. Every BOS studio is rebranded in one shot. --- Run the **Brand My Workspace** skill. diff --git a/commands/build-customer-voice.md b/commands/build-customer-voice.md index 0c37c55..0c07484 100644 --- a/commands/build-customer-voice.md +++ b/commands/build-customer-voice.md @@ -5,10 +5,11 @@ description: Mine ≥5min call + meeting transcripts into a verbatim customer-vo Run the **Build Customer Voice** skill. Invoke the skill at `skills/build-customer-voice/SKILL.md`. Follow its -instructions exactly: pull transcripts with `tools/dump-transcripts.py`, -read every file end-to-end (filter out the host's voice), then write +instructions exactly: pull transcripts via the `trustpager` MCP +(`list_transcripts` to enumerate, `get_transcript` for each), read every +one end-to-end (filter out the host's voice), then write `customer-voice-synthesis.md` with the 10 prescribed sections. Quote -verbatim with `[Speaker, transcript-filename]` attribution. Report back +verbatim with `[Speaker, transcript]` attribution. Report back under 200 words. If the operator hasn't said where to write the synthesis, default to diff --git a/commands/form-radar.md b/commands/form-radar.md index 906ed9a..d27a2d7 100644 --- a/commands/form-radar.md +++ b/commands/form-radar.md @@ -4,9 +4,10 @@ description: Show where every form you sent stands — the sent → opened → c Run the **Form Radar** skill. -Invoke the skill at `skills/form-radar/SKILL.md`. Run -`python skills/form-radar/fetch.py` (`--stale-days N` to tune), then present the -report bucketed by follow-up urgency: STARTED-NOT-FINISHED first (a nudge closes +Invoke the skill at `skills/form-radar/SKILL.md`. It gathers form sends and +submissions via `trustpager` MCP read tools (the skill lets you tune the +stale-unopened window in plain language, e.g. "use a 5-day stale window"), then +presents the report bucketed by follow-up urgency: STARTED-NOT-FINISHED first (a nudge closes them), then SENT-UNOPENED-STALE (resend or call), then the completed count. Offer the next action per submission — resend, draft a nudge via `/draft-reply`, or void a dead one — one at a time, with a yes. Never auto-resend or auto-void. diff --git a/commands/lint-nurture-sequence.md b/commands/lint-nurture-sequence.md index 330de3a..d77a4cd 100644 --- a/commands/lint-nurture-sequence.md +++ b/commands/lint-nurture-sequence.md @@ -5,10 +5,11 @@ description: Check a nurture sequence against the house style — CTA above imag Run the **Lint Nurture Sequence** skill. Invoke the skill at `skills/lint-nurture-sequence/SKILL.md`. Follow it exactly: -run `tools/lint-sequence.py` against the live queue (`--queue `) or the -drafts file (`--drafts `), then present the verdict — fails first, then -warnings, then the set-wide consistency findings — and route each fix to -`design-nurture-sequence` → `wire-nurture-sequence`. Never edit the queue from -this skill. +read the sequence to lint — the live queue via `trustpager` MCP read tools +(`get_auto_queue` + its steps for a given queue id) or a local drafts file via +file tools — check it against the house style, then present the verdict — fails +first, then warnings, then the set-wide consistency findings — and route each +fix to `design-nurture-sequence` → `wire-nurture-sequence`. Never edit the queue +from this skill. If the operator didn't say which queue or drafts, ask which one to lint. diff --git a/commands/prep-for-call.md b/commands/prep-for-call.md index d6bda11..c9bff0e 100644 --- a/commands/prep-for-call.md +++ b/commands/prep-for-call.md @@ -6,10 +6,10 @@ Run the **Prep For Call** skill. Invoke the skill at `skills/prep-for-call/SKILL.md`. Identify the call (today's booking via `list_bookings`/`get_booking`, or a named person via -`search_opportunities`/`search_contacts`), then pull the picture around the -opportunity: `get_opportunity`, `get_opportunity_activities`, the last call's -transcript (`list_transcripts` — the most valuable input), `get_opportunity_tasks`, -`get_contact`, `get_opportunity_products`. Present the fixed brief (who they are / +`search_deals`/`search_contacts`), then pull the picture around the +opportunity: `get_deal`, `get_deal_activities`, the last call's +transcript (`list_transcripts` — the most valuable input), `get_deal_tasks`, +`get_contact`, `get_deal_products`. Present the fixed brief (who they are / where it's at / last time / open-owed / watch-for) ending in the single outcome to drive. Read-only — offer the follow-ons (`/draft-reply`, pull the proposal), don't act unprompted. If there's no history, say "first conversation". diff --git a/commands/remember.md b/commands/remember.md new file mode 100644 index 0000000..72c4b8c --- /dev/null +++ b/commands/remember.md @@ -0,0 +1,12 @@ +--- +description: Save, update, or forget something Claude should carry into future sessions — a preference, a way you like things done, soft context the CRM doesn't hold. Kept in a local memory store, one fact per file. +--- + +Run the **Remember** skill. + +Invoke the skill at `skills/remember/SKILL.md` and follow it exactly: decide +whether the fact belongs in memory at all (not a CRM fact, not a secret, not +transient), check `./.bos-memory/MEMORY.md` for an existing memory to update, +then write/update/delete the `.md` and keep the index line in sync. The +full model and rails are in `knowledge/memory-and-feedback.md`. Close with a +single confirmation line. diff --git a/commands/signing-radar.md b/commands/signing-radar.md index 877ddd0..1473a6f 100644 --- a/commands/signing-radar.md +++ b/commands/signing-radar.md @@ -4,9 +4,10 @@ description: Show where every document you sent for signing stands — the sent Run the **Signing Radar** skill. -Invoke the skill at `skills/signing-radar/SKILL.md`. Run -`python skills/signing-radar/fetch.py` (pass `--stale-days N` to tune the -unopened threshold), then present the report bucketed by follow-up urgency: +Invoke the skill at `skills/signing-radar/SKILL.md`. It gathers signing envelopes +via `trustpager` MCP read tools (`list_signing_envelopes` + `get_signing_envelope`) +(the skill lets you tune the unopened-stale window in plain language, e.g. "use a +5-day stale window"), then presents the report bucketed by follow-up urgency: OPENED-NOT-SIGNED first (engaged, call them now), then SENT-UNOPENED-STALE (chase or resend), then DECLINED, then the completed count. Offer the next action per envelope — nudge/resend, draft a follow-up via `/draft-reply`, or diff --git a/commands/suggest-improvement.md b/commands/suggest-improvement.md new file mode 100644 index 0000000..48c249b --- /dev/null +++ b/commands/suggest-improvement.md @@ -0,0 +1,12 @@ +--- +description: Log something you wanted that doesn't exist yet — a missing BOS skill or a TrustPager capability that isn't there — into TrustPager's developer feedback queue, so the team can build it. +--- + +Run the **Suggest Improvement** skill. + +Invoke the skill at `skills/suggest-improvement/SKILL.md` and follow it exactly: +classify the gap (`[BOS]` plugin gap vs `[Platform]` platform gap), search the +queue for a duplicate (+1 it if one exists), then draft a `create_service_request` +— `use_case` in the operator's words, `suggested_solution`, `affected_tools` — +show it, confirm, and file. Surface the request id (or the approval-queue +hand-off on a `202`). The full model is in `knowledge/memory-and-feedback.md`. diff --git a/commands/weekly-review.md b/commands/weekly-review.md index 16c9573..2921339 100644 --- a/commands/weekly-review.md +++ b/commands/weekly-review.md @@ -4,9 +4,10 @@ description: The Friday rollup — what shipped this week (deals won, tasks done Run the **Weekly Review** skill. -Invoke the skill at `skills/weekly-review/SKILL.md`. Run -`python skills/weekly-review/fetch.py` (`--days N` to change the window), then -present the review: SHIPPED first (won deals + value, tasks done, new opps), then +Invoke the skill at `skills/weekly-review/SKILL.md`. It gathers the week's +opportunities, tasks and pipeline state via `trustpager` MCP read tools (the +skill lets you tune the window in plain language, e.g. "review the last 14 +days"), then presents the review: SHIPPED first (won deals + value, tasks done, new opps), then STALLED (the point — quiet high-value deals + overdue carried over), then the current open-pipeline total, ending with one concrete focus for next week. Use the operator's own stage/product names; totals + headline rows, not every record. diff --git a/commands/why-didnt-it-fire.md b/commands/why-didnt-it-fire.md index 36d9a6d..3cd856f 100644 --- a/commands/why-didnt-it-fire.md +++ b/commands/why-didnt-it-fire.md @@ -4,4 +4,4 @@ description: Diagnose why a specific automation didn't do what you expected — Run the **why-didnt-it-fire** skill. -Invoke the skill at `skills/why-didnt-it-fire/SKILL.md`. Follow its instructions exactly — run `fetch.py` with the automation id/name, walk the run-log ladder, and give the operator the single real reason plus the fix. +Invoke the skill at `skills/why-didnt-it-fire/SKILL.md`. Follow its instructions exactly — it pulls the automation and its run log via `trustpager` MCP read tools (`get_automation`, `list_automation_runs`, `get_automation_run`) for the given automation id/name, walks the run-log ladder, and gives the operator the single real reason plus the fix. diff --git a/commands/wire-nurture-sequence.md b/commands/wire-nurture-sequence.md index 0808697..e173894 100644 --- a/commands/wire-nurture-sequence.md +++ b/commands/wire-nurture-sequence.md @@ -5,9 +5,10 @@ description: Push approved nurture-sequence drafts into a live TrustPager auto q Run the **Wire Nurture Sequence** skill. Invoke the skill at `skills/wire-nurture-sequence/SKILL.md`. Read the -live auto queue state first via `tools/dump-crm-bundle.py ---resources auto_queues`. Inventory the writes (UPDATE / ADD / CREATE) -and confirm with the operator before any MCP calls. +live auto queue state first via `trustpager` MCP read tools +(`list_auto_queues`, then `get_auto_queue` on the target queue and its +steps). Inventory the writes (UPDATE / ADD / CREATE) and confirm with +the operator before any MCP write calls. For inserting a new step at position 1 (or any middle position), use the REVERSE-ORDER step_order shuffle described in the skill — never the diff --git a/commands/work-order-radar.md b/commands/work-order-radar.md index 562fcc7..eb0753a 100644 --- a/commands/work-order-radar.md +++ b/commands/work-order-radar.md @@ -4,9 +4,10 @@ description: Show the state of every job — count by status, which work orders Run the **Work Order Radar** skill. -Invoke the skill at `skills/work-order-radar/SKILL.md`. Run -`python skills/work-order-radar/fetch.py` (`--stall-days N` to tune), then -present the report: STALLED first (sat too long in a non-terminal status), then +Invoke the skill at `skills/work-order-radar/SKILL.md`. It gathers jobs via +`trustpager` MCP read tools (`list_work_orders` + `list_work_order_statuses`) +(the skill lets you tune the stall window in plain language, e.g. "flag anything +stalled over 10 days"), then presents the report: STALLED first (sat too long in a non-terminal status), then COMPLETED-THIS-WEEK (prompt: did the customer get the update + a review ask?), then the by-status breakdown. Offer the next action per job — send a status update (`send_work_status`, real recipients only, confirm first), move a status, diff --git a/knowledge/automation-method.md b/knowledge/automation-method.md index 33e79a8..ce23963 100644 --- a/knowledge/automation-method.md +++ b/knowledge/automation-method.md @@ -150,7 +150,7 @@ The automation surface changes as the platform ships. Every automation skill fol 2. **`list_action_types`** → every action. `describe_action_type(type)` → one action's config schema + example, **right before writing it**. 3. **`list_automations`** → what already exists, so you don't build a duplicate. -The `/automate-this` skill bundles 1–3 into a single `fetch.py` call. All three are free (no credits). +The `/automate-this` skill gathers these via `trustpager` MCP read tools (`list_trigger_schemas`, `get_trigger_schema`, `list_automations`; `list_action_types` / `describe_action_type` where available). All are free (no credits). --- diff --git a/knowledge/marketing-strategy-method.md b/knowledge/marketing-strategy-method.md index ce17f9e..8b6bde8 100644 --- a/knowledge/marketing-strategy-method.md +++ b/knowledge/marketing-strategy-method.md @@ -16,9 +16,9 @@ skills: `build-customer-voice`, `build-brand-strategy`, `design-nurture-sequence ## The three-layer pipeline ``` -Layer 1 — Raw input (machine-generated, never hand-edited) - ↓ CRM bundle (tools/dump-crm-bundle.py) - ↓ Transcripts ≥ 5 minutes (tools/dump-transcripts.py) +Layer 1 — Raw input (pulled fresh from the workspace, never hand-edited) + ↓ CRM bundle via trustpager MCP read tools (list_deals, get_deal, list_contacts, list_customers, get_crm_settings, ...) + ↓ Transcripts ≥ 5 minutes via list_transcripts + get_transcript Layer 2 — Synthesis (the customer's own voice, frozen evidence) ↓ customer-voice-synthesis.md Layer 3 — Strategy docs (the brand's positioning + funnel) @@ -35,7 +35,8 @@ to reach for; they're not required in every email. ## Layer 2 — Customer voice synthesis -Pull ≥5min call + meeting transcripts (`tools/dump-transcripts.py`), then +Pull ≥5min call + meeting transcripts via the `trustpager` MCP +(`list_transcripts` to enumerate, `get_transcript` for each), then read every single one end-to-end and write **`customer-voice-synthesis.md`** with these 10 sections, in this order: diff --git a/knowledge/memory-and-feedback.md b/knowledge/memory-and-feedback.md new file mode 100644 index 0000000..71dd626 --- /dev/null +++ b/knowledge/memory-and-feedback.md @@ -0,0 +1,93 @@ +# Memory & Feedback + +**How Claude remembers this business between sessions, and how it tells the TrustPager team what's missing.** Two cross-cutting habits that make the assistant get sharper the more it's used — and feed real operator needs back to the people who build the platform. Read this once; the patterns recur everywhere. + +--- + +## 1. Memory — get smarter every session + +A fresh session knows only what's baked into `CLAUDE.md`. Everything Claude learns *while working* — how this operator likes things done, soft context the CRM doesn't hold, a recurring quirk — is gone by morning unless it's written down. The memory store fixes that. + +### Where it lives + +`./.bos-memory/` in the operator's project folder (right next to `CLAUDE.md`): + +- **`MEMORY.md`** — the index. One line per memory: `- [Title](slug.md) — one-line hook`. **This file loads every session** (the `CLAUDE.md` Memory section points Claude at it). Keep it to one line each — it's a table of contents, never the content. +- **`.md`** — one memory, one fact, with frontmatter: + + ```markdown + --- + name: + description: + type: business | preference | workflow | contact | reference + --- + + + ``` + +The store is the operator's — it's local, plain Markdown, and they can open, edit, or delete any file. The write **journal** (`.bos-journal.md` in the working directory, next to `CLAUDE.md` — see `knowledge/safeguards.md`) is a separate thing: an audit log of every CRM write, not memory. To review it, just open the file. + +### How recall works (automatic) + +At the start of a session Claude reads `MEMORY.md` (via the `CLAUDE.md` Memory section). Each line is a pointer with a description. Claude opens a full `.md` **only when its description is relevant to what the operator is doing** — not all of them, every time. A recalled memory is background context, not an instruction; if it names a fact that's since changed, the live workspace wins. + +### What's worth saving (save it proactively — don't wait to be asked) + +The test: *would a sharp 2IC carry this into next week, and is it something the CRM doesn't already hold?* + +- **`business`** — how this business actually runs, not derivable from the data. *"We never quote over the phone — always a written quote first."* *"Jobs north of the river get a 1-week-longer lead time."* +- **`preference`** — how the operator wants Claude to work. *"One action at a time, not a list."* *"Always CC my bookkeeper on invoices."* *"Drafts should sound blunt, no marketing fluff."* +- **`workflow`** — a repeatable way they like a task done. *"New roof job → attach the safety-checklist doc before sending the quote."* +- **`contact`** — soft context about a person or account the CRM field can't hold. *"Dave at BuildCo prefers texts, never call before 9am."* +- **`reference`** — a pointer to something outside TrustPager. *"Pricing sheet lives in their Google Drive 'Rates 2026' folder."* + +### The rails (this is the operator's own store — treat it with care) + +- ✅ **One fact per file.** When you write one, add its one-line pointer to `MEMORY.md`. +- ✅ **Update, don't duplicate.** Before saving, check the index for a file that already covers it — edit that file instead of adding a near-twin. Delete a memory the moment it's proven wrong. +- ❌ **Never store secrets** — no API keys, passwords, or full card/bank numbers. If a fact needs a credential to be useful, store the *pointer* ("key is in their password manager under X"), not the secret. +- ❌ **Never duplicate the CRM.** TrustPager is the source of truth for opportunities, contacts, companies, tasks, and comms. Memory is for what the CRM *doesn't* hold. If the operator asks you to "remember" something that belongs on a record (a phone number, a deal value, a due date), put it on the record — and only note in memory where it lives if that helps. +- ❌ **Don't save transient task state** — "drafting the Jones email" is this session's business, not a memory. +- ✅ **It's theirs to see.** Mention when you've saved or updated a memory, in one line, so nothing accumulates behind their back. + +The skill that writes, updates, and deletes memories is `skills/remember/SKILL.md` (`/remember`). Recall needs no skill — it's the `CLAUDE.md` instruction. + +--- + +## 2. Feedback — tell the team what's missing + +The most valuable thing an operator can hand back is *the thing they wanted that didn't exist yet.* Two kinds: + +- **A BOS gap** — they asked for something and **no skill or command covers it** ("can you build me a quote comparison?" and there's no skill for it). +- **A platform gap** — **TrustPager itself can't do it** ("I want the SMS to send only in business hours and it won't"). + +Both are signal the TrustPager/FinalPiece team can build from — but only if they're captured. The channel already exists: **`create_service_request`** writes to TrustPager's developer feedback queue, it's on every workspace, and it's **free** (a read-priced call). That's how a single operator's "I wish it could…" becomes a shipped feature. + +### When to log one + +- The operator explicitly asks (`/suggest-improvement`, "feature request", "this is missing", "report a bug"). +- **Proactively, on a dead-end:** a catch-all skill (`/make-it-happen`, `/show-me-how`) hits a wall because the capability genuinely isn't there. Don't fail silently — finish helping as far as you can, then offer: *"TrustPager can't do that yet — want me to log it so the team can build it?"* + +Don't log for: a one-off the operator can do another way right now, anything you can actually accomplish with existing tools, or pure user error. Capture *missing capability*, not friction you can solve in the moment. + +### How to file it (draft → confirm → file) + +`create_service_request` is a write to their workspace, so it follows the standing rail: **draft it, show it, confirm, then file.** Fields: + +- **`use_case`** — what the operator was trying to do, in their own words. The most important field — it's the "why". +- **`suggested_solution`** — your one-line take on what would solve it. +- **`affected_tools`** — the skill/command or TrustPager area involved. +- **`category`** — a short label. If you're unsure what the tool accepts, inspect it (`get_ai_instructions` / the tool schema) rather than guessing. +- **Tag the kind in the title/use_case:** prefix with **`[BOS]`** for a plugin gap or **`[Platform]`** for a TrustPager-platform gap, so triage can route it. + +Then: + +- **Search first** (`list_service_requests` / `search`) so you don't file a duplicate — if one exists, add a note to it instead. +- If the write comes back **`202` (queued for approval)**, that's the approval queue, not a failure — tell the operator to approve it and **stop** (see `safeguards.md`). +- Surface the request **id** to the operator: *"Logged as request #1234 — the TrustPager team triages these. I'll keep using what we've got in the meantime."* + +The skill is `skills/suggest-improvement/SKILL.md` (`/suggest-improvement`). + +--- + +These two habits compound: memory makes Claude sharper for *this* operator; feedback makes the platform sharper for *every* operator. Use both without being asked. diff --git a/knowledge/safeguards.md b/knowledge/safeguards.md index 2a66206..5a75047 100644 --- a/knowledge/safeguards.md +++ b/knowledge/safeguards.md @@ -6,21 +6,17 @@ ## 1. The approval queue — `202` means *queued*, not *done* -Some workspaces issue API keys with an **"approval" permission level**: writes don't execute immediately, they queue for a human to approve in-app. When that happens the API returns **HTTP `202` with an `approval_id`** instead of a normal result. +Some workspaces issue API keys with an **"approval" permission level**: writes don't execute immediately, they queue for a human to approve in-app. When that happens the MCP tool **doesn't return a normal result** — its response carries **status `202` and an `approval_id`** (and usually an approval URL) instead of the created/updated record. -The shared library already handles this correctly — **a `202` is returned, never raised**, as an `ApprovalPending` object (see `tools/trustpager_api.py`). What matters is what the *skill* does next: +The platform enforces this for you — the gate lives in the API, not in any local code. What matters is what *you* do when a write tool comes back queued: > **A `202` is not a failure and not a success — it's a hand-off.** Tell the operator plainly: "That's queued for approval — approve it at `https://app.trustpager.com/settings/api?tab=approvals` (id: ``)." Then **stop and wait.** Do not retry. Do not look for another way to push it through. -**Never try to route around an approval gate.** It exists so a human stays in the loop on writes that touch shared or outward-facing state (sends, integration syncs, automations). Bypassing it — re-issuing the call differently, or treating the `202` as an error to "fix" — defeats the audit trail and produces silent, unreviewed actions. +**Never try to route around an approval gate.** It exists so a human stays in the loop on writes that touch shared or outward-facing state (sends, integration syncs, automations). Bypassing it — re-issuing the call a different way, or treating the `202` as an error to "fix" — defeats the audit trail and produces silent, unreviewed actions. -In a fetch/skill script: -```python -result = api_post(path, body=payload) -if isinstance(result, ApprovalPending): - # surface result.approval_id and result.approval_url to the operator; do NOT retry - ... -``` +How to read it from an MCP tool response: + +> If a `create_*` / `update_*` / `send_*` / `trigger_*` tool returns a body with `status: 202` or an `approval_id` field, treat it as **queued**. Surface the `approval_id` and the approvals URL to the operator, journal it as `approval_pending` (rail 3), and stop. If multiple writes queue (e.g. a clear-then-restore pair), name **which to approve and which to reject, and why** — don't leave the operator guessing. @@ -41,9 +37,14 @@ Some report sources (notably **Invoices / Receivables**) read from a ledger that ## 3. The standing write rails (recap) -Every skill already inherits these — they're listed here so the reasons are in one place: - -- **Ask before anything destructive or outward-facing.** Drafts get shown and approved before they send. Deletes get confirmed. -- **Every write is journaled.** `~/.claude/bos-journal/` gets one line per write (method, path, status, result/approval id) — read it with `python tools/journal.py`. Reads are never journaled. -- **One workspace only.** Skills talk to the operator's own TrustPager workspace via their key — never anyone else's. -- **Idempotency for risky writes.** Use `idempotent_post` for anything where a duplicate would hurt (sends, creates, charges) so a network retry can't double-fire. +Every skill already inherits these — they're listed here so the reasons are in one place. There is no helper library doing this for you: in this pack **you uphold these rails yourself, by reasoning**, on every write tool call. + +- **Ask before anything destructive or outward-facing.** Drafts get shown and approved before they send. Deletes get confirmed. This is the rail the others protect. +- **Journal every write — to `./.bos-journal.md`.** Immediately after any `create_*` / `update_*` / `delete_*` / `send_*` / `trigger_*` / `move_*` MCP tool call, append **one line** to a file named `.bos-journal.md` in the working directory (next to `CLAUDE.md`), creating it if absent: + ``` + - 2026-06-16T09:14:03Z create_deal ok → id 4f2a… (skill: lead-triage) + - 2026-06-16T09:15:20Z send_email approval_pending → approval 8c1d… (skill: draft-reply) + ``` + Record: UTC timestamp, the tool name, the outcome (`ok` / `approval_pending` / `error`), the result or approval id, and which skill issued it. **Reads are never journaled.** This is best-effort and reasoning-driven — if you genuinely can't write the file, tell the operator rather than silently skipping. To review the trail, just open `.bos-journal.md` (no tool needed — "what did you change today?" = read that file). +- **One workspace only.** Every skill talks to the operator's own TrustPager workspace via the single configured `trustpager` MCP connection — never anyone else's. +- **Idempotency for risky writes = search first, never blind-retry.** Before a write where a duplicate would hurt (a send, a create, a charge), do a quick `search_*` / `list_*` to confirm the record/message doesn't already exist. If a write tool call times out or errors ambiguously, **do not just re-issue it** — search to find out whether the first attempt actually landed, then act on what you find. A duplicate send or charge is worse than a slow one. diff --git a/skills/audit-my-automations/SKILL.md b/skills/audit-my-automations/SKILL.md index d1241e1..7bf4c0e 100644 --- a/skills/audit-my-automations/SKILL.md +++ b/skills/audit-my-automations/SKILL.md @@ -18,17 +18,59 @@ Operators set automations up and never look at them again — so they don't noti **Read first:** [`knowledge/automation-method.md`](../../knowledge/automation-method.md) — especially §6 (safety dials) and §8 (reading the run log). The flags this skill raises map directly to those sections. -## Step 1 — Fetch the health digest +## Step 1 — Pull the data (parallel MCP calls) -```bash -python ~/.claude/bos-run.py audit-my-automations -``` +Use the `trustpager` MCP server. All of these are reads — free, fast, nothing journaled. + +| Need | Tool | Args | +|---|---|---| +| Every automation (flat list — no triggers/actions inline) | `list_automations` | `limit: 100` (page with `after` until exhausted) | +| Full structure per automation (triggers + actions + conditions inline) | `get_automation` | `automation_id: ` — one call per automation | +| Recent run health per **enabled** automation | `list_automation_runs` | `automation_id: `, `limit: 10` | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +Sequence: +1. `list_automations` (page through all of them with the `after` cursor) to get the id list. +2. For **every** automation, call `get_automation` to get its real structure. The flat list does NOT embed triggers/actions — `get_automation` does (returns name, `trigger_type`, conditions, and the action sequence inline). Fire these in parallel. +3. For **enabled automations only** (disabled ones can't fire, so stale/failure flags don't apply), call `list_automation_runs` with `limit: 10` to sample the last 10 runs. Keeps the call count down. Fire these in parallel too. + +If a `get_automation` call fails, degrade to the flat list row for that automation rather than dropping it. + +## Step 2 — Compute the per-automation flags + +Everything below is computed against **now**. For each automation, read `enabled`, `trigger_type`, its triggers array (`automations_triggers`), its actions array (`automations_actions`), `dedup_enabled`, and `max_executions_per_day`. From its sampled runs, find the latest run time (`started_at` / `created_at`) and tally the status mix across `completed` / `skipped` / `failed` / other. -One call: lists every automation (with triggers + actions inline), samples each one's recent runs, and computes per-automation flags plus cross-automation trigger overlaps. The shape is documented at the bottom of `fetch.py`. +Raise these flags: -If the script can't run (auth/network), fall back to `mcp__trustpager__list_automations` + `mcp__trustpager__list_automation_runs` per automation — but that's many calls; prefer the script. +| Flag | Condition | +|---|---| +| `disabled` | `enabled` is false | +| `no_actions` | actions array is empty | +| `no_triggers` | triggers array is empty **AND** `trigger_type` is NOT one of `stage_changed` / `manual` / `api` / `event_queue_step` / `auto_queue` / `scheduled` (those fire without a trigger row, so zero triggers is normal for them — don't flag) | +| `never_run` | enabled but zero runs sampled | +| `stale_Nd` | enabled, has a last-run time, and it's **≥ 30 days** ago (N = days idle) | +| `recent_failures_N` | one or more sampled runs have status `failed` (N = failed count) | +| `mostly_skipped` | **≥ 3** runs sampled AND `skipped` runs are **≥ 80%** of those sampled (conditions may be too tight) | +| `sends_without_dedup` | enabled AND has a send action AND `dedup_enabled` is false | +| `webhook_without_daily_cap` | enabled AND has a webhook/feedback action AND `max_executions_per_day` is null | -## Step 2 — Present the report, worst first +**Send action types** (these reach a customer / cost credits, so they want dedup ON): `send_custom_email`, `send_gmail_email`, `send_sms`, `send_whatsapp`, `voice_outbound_call`, `send_form`, `send_for_signing`, `send_marketing_email`. + +**Webhook/feedback action types** (these want a daily cap as a runaway seatbelt): `call_webhook`, `facebook_conversion`. + +### Cross-automation: trigger overlaps + +For **enabled** automations only, build a key for each trigger of the form `trigger_type:source_type:source_id` (use `any` for missing source_type, `*` for missing source_id). Any key shared by **2 or more** enabled automations is an **overlap** — list those automations together. + +### Bucketing for the report + +- **Needs attention:** any automation flagged `recent_failures_*`, `no_actions`, or `no_triggers`. +- **Worth a look:** any not already in "needs attention" that's flagged `stale_*`, `never_run`, `mostly_skipped`, `sends_without_dedup`, or `webhook_without_daily_cap`. +- **Healthy:** everything else that's enabled. +- **Disabled:** count separately — informational, not a problem. + +## Step 3 — Present the report, worst first Lead with what's broken, then what's risky, then a one-line "healthy" tally. Never dump all automations as a flat list. @@ -64,19 +106,21 @@ Lead with what's broken, then what's risky, then a one-line "healthy" tally. Nev | `webhook_without_daily_cap` | `call_webhook`/feedback action, no daily cap | set `max_executions_per_day` as a runaway-loop seatbelt | | `disabled` | switched off | informational — list under a "staged/off" count, not as a problem | -## Step 3 — Offer fixes (with approval) +## Step 4 — Offer fixes (with approval) + +These are writes — they follow the rails in [`knowledge/safeguards.md`](../../knowledge/safeguards.md): show the change, get a yes, apply it **one at a time**, then journal each write as one line to `.bos-journal.md`. If a write returns a `202`/`approval_id`, surface the approvals link and stop — don't retry. -For the safe, mechanical fixes, offer to apply them — **one at a time, with a yes**: -- Turn on dedup → `mcp__trustpager__update_automation(automation_id, dedup_enabled=true, dedup_window_minutes=60)` +For the safe, mechanical fixes, offer to apply them — one at a time, with a yes: +- Turn on dedup → `update_automation(automation_id, dedup_enabled=true, dedup_window_minutes=60)` - Set a daily cap → `update_automation(automation_id, max_executions_per_day=N)` -- Disable a redundant duplicate → `mcp__trustpager__disable_automation(automation_id)` +- Disable a redundant duplicate → `disable_automation(automation_id)` Never delete an automation without explicit confirmation naming it. For "why is it failing / skipping" deep-dives, hand to `/why-didnt-it-fire`. For building a missing automation, `/automate-this`. ## What to never do - ❌ Don't present automations alphabetically or as one flat list — rank by severity. -- ❌ Don't auto-apply fixes. Offer, get a yes, then apply — one at a time. +- ❌ Don't auto-apply fixes. Offer, get a yes, then apply — one at a time — and journal each. - ❌ Don't flag `disabled` as broken — staged/off is a valid state. Count it, don't alarm about it. - ❌ Don't treat `skipped` runs as failures — that's conditions working as designed. Only `mostly_skipped` is worth a mention. diff --git a/skills/audit-my-automations/fetch.py b/skills/audit-my-automations/fetch.py deleted file mode 100644 index d0e0735..0000000 --- a/skills/audit-my-automations/fetch.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -"""audit-my-automations — pull every automation + recent run health into one digest. - -Operators set automations up and never check them. This fetcher returns a single -JSON document Claude can turn into a health report: which automations are firing, -which are stale, which are erroring, which are missing safety dials, and which -overlap with each other. - -What it computes (all from read-only endpoints): -- Per automation: enabled, trigger_count, action_count, dedup, daily cap, - last_run_at, recent run status mix (completed / skipped / failed). -- Flags: disabled, no_actions, no_triggers, never_run, stale (enabled but no - run in 30d), recent_failures, mostly_skipped, sends_without_dedup, - webhook_without_cap. -- Cross-automation: trigger overlaps (≥2 enabled automations on the same - trigger_type + source). - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/audit-my-automations/fetch.py - python skills/audit-my-automations/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parallel_get, parse_iso, days_since, resolve_path, -) - -SKILL = "audit-my-automations" - -STALE_DAYS = 30 -RECENT_RUN_LIMIT = 10 # how many recent runs to sample per automation - -# Action types that reach a customer / cost credits — these want dedup ON. -SEND_ACTION_TYPES = { - "send_custom_email", "send_gmail_email", "send_sms", "send_whatsapp", - "voice_outbound_call", "send_form", "send_for_signing", "send_marketing_email", -} -# Action types with an external/feedback path — these want a daily cap. -WEBHOOK_ACTION_TYPES = {"call_webhook", "facebook_conversion"} - -# Trigger types that fire WITHOUT an automations_triggers row — they're driven by -# stage_id, a queue, a schedule, or a direct API/manual invoke. Having zero -# trigger rows is normal for these, so don't flag them as "no_triggers". -NO_TRIGGER_ROW_TYPES = { - "stage_changed", "manual", "api", "event_queue_step", "auto_queue", "scheduled", -} - - -def _trigger_key(t: dict) -> str: - return f"{t.get('trigger_type') or '?'}:{t.get('source_type') or 'any'}:{t.get('source_id') or '*'}" - - -def fetch(quiet: bool) -> dict: - now = now_utc() - log(SKILL, "listing automations...", quiet=quiet) - - # The list endpoint returns FLAT automations (no embedded triggers/actions). - # GET /automations/:id DOES embed automations_triggers + automations_actions - # + conditions, so we fetch detail per automation to know its real structure. - listed = list(paginate(resolve_path("automations"), limit=100, max_pages=10)) - ids = [a["id"] for a in listed if a.get("id")] - log(SKILL, f"{len(ids)} automations; fetching structure...", quiet=quiet) - - detail_by_path = parallel_get([(f"automations/{i}", {}) for i in ids]) if ids else {} - - def _detail(aid: str, fallback: dict) -> dict: - resp = detail_by_path.get(f"automations/{aid}", {}) - if isinstance(resp, dict) and "error" not in resp: - return resp.get("data", resp) - return fallback # detail fetch failed — degrade to the flat list row - - automations = [_detail(a["id"], a) for a in listed if a.get("id")] - - # Sample recent runs only for ENABLED automations (disabled ones can't fire, - # and stale/failed flags only apply to live ones) — keeps the call count down. - enabled_ids = [a["id"] for a in automations if a.get("id") and a.get("enabled")] - log(SKILL, f"sampling runs for {len(enabled_ids)} enabled...", quiet=quiet) - runs_by_path = parallel_get( - [(f"automations/{i}/runs", {"limit": RECENT_RUN_LIMIT}) for i in enabled_ids] - ) if enabled_ids else {} - - audited: list[dict] = [] - trigger_map: dict[str, list[str]] = {} # trigger_key -> [automation names] (enabled only) - - for a in automations: - aid = a.get("id") - triggers = a.get("automations_triggers") or a.get("triggers") or [] - actions = a.get("automations_actions") or a.get("actions") or [] - enabled = bool(a.get("enabled")) - trigger_type = a.get("trigger_type") - - runs_resp = runs_by_path.get(f"automations/{aid}/runs", {}) - runs = runs_resp.get("data", []) if isinstance(runs_resp, dict) else [] - last_run_at = None - status_mix = {"completed": 0, "skipped": 0, "failed": 0, "other": 0} - for r in runs: - ts = parse_iso(r.get("started_at") or r.get("created_at")) - if ts and (last_run_at is None or ts > last_run_at): - last_run_at = ts - st = (r.get("status") or "other").lower() - status_mix[st if st in status_mix else "other"] += 1 - - action_types = [ac.get("action_type") for ac in actions] - has_send = any(at in SEND_ACTION_TYPES for at in action_types) - has_webhook = any(at in WEBHOOK_ACTION_TYPES for at in action_types) - dedup_on = bool(a.get("dedup_enabled")) - daily_cap = a.get("max_executions_per_day") - - # ---- flags ---- - flags: list[str] = [] - if not enabled: - flags.append("disabled") - if not actions: - flags.append("no_actions") - if not triggers and trigger_type not in NO_TRIGGER_ROW_TYPES: - flags.append("no_triggers") - if enabled and not runs: - flags.append("never_run") - days_idle = days_since(last_run_at, now) if last_run_at else None - if enabled and last_run_at and days_idle is not None and days_idle >= STALE_DAYS: - flags.append(f"stale_{days_idle}d") - if status_mix["failed"] > 0: - flags.append(f"recent_failures_{status_mix['failed']}") - sampled = sum(status_mix.values()) - if sampled >= 3 and status_mix["skipped"] >= sampled * 0.8: - flags.append("mostly_skipped") # conditions may be too tight - if enabled and has_send and not dedup_on: - flags.append("sends_without_dedup") - if enabled and has_webhook and daily_cap is None: - flags.append("webhook_without_daily_cap") - - if enabled: - for t in (triggers or []): - trigger_map.setdefault(_trigger_key(t), []).append(a.get("name") or aid) - - audited.append({ - "id": aid, - "name": a.get("name"), - "enabled": enabled, - "trigger_type": trigger_type, - "trigger_count": len(triggers), - "triggers": [ - {"trigger_type": t.get("trigger_type"), "source_type": t.get("source_type"), - "source_id": t.get("source_id")} for t in triggers - ], - "action_count": len(actions), - "action_types": action_types, - "dedup_enabled": dedup_on, - "max_executions_per_day": daily_cap, - "last_run_at": last_run_at.isoformat() if last_run_at else None, - "days_idle": days_idle, - "recent_runs_sampled": sampled, - "recent_status_mix": status_mix, - "flags": flags, - "url": f"https://app.trustpager.com/auto/automations/{aid}", - }) - - overlaps = [ - {"trigger": k, "automations": v} - for k, v in trigger_map.items() if len(v) > 1 - ] - - needs_attention = [a for a in audited - if any(f.startswith(("recent_failures", "no_actions", "no_triggers")) - for f in a["flags"])] - warnings = [a for a in audited - if a not in needs_attention and any( - f.startswith(("stale_", "never_run", "mostly_skipped", - "sends_without_dedup", "webhook_without_daily_cap")) - for f in a["flags"])] - - return { - "generated_at": now.isoformat(), - "headline": { - "total": len(audited), - "enabled": sum(1 for a in audited if a["enabled"]), - "disabled": sum(1 for a in audited if not a["enabled"]), - "needs_attention": len(needs_attention), - "warnings": len(warnings), - "trigger_overlaps": len(overlaps), - }, - "needs_attention": needs_attention, - "warnings": warnings, - "trigger_overlaps": overlaps, - "all_automations": audited, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", help="Suppress stderr progress logs") - args = parser.parse_args() - try: - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/audit-my-data/SKILL.md b/skills/audit-my-data/SKILL.md index 07be633..080aad3 100644 --- a/skills/audit-my-data/SKILL.md +++ b/skills/audit-my-data/SKILL.md @@ -19,29 +19,53 @@ data quietly rots: duplicate contacts, deals with no owner, tasks with no due date. This is the hygiene check-up. Read-only — it surfaces a fix list and offers the safe fixes one at a time. -## Step 1 — Run the audits +## Step 1 — Pull the data (parallel MCP calls) -Two read-only tools cover the data-hygiene surface; run both: +Fire these **three read calls in parallel** in a single batch — all reads, free and fast. Use the `trustpager` MCP server. Pull the most recent records; you'll apply the hygiene rules yourself in Step 2. Paginate each to ~100 (pass `limit: 100`, then follow the `after` cursor for a second page if `pagination.has_more` is true — two pages is plenty for an audit). -```bash -python tools/find-gaps.py --json -python tools/audit-contacts.py --json -``` +| Need | Tool | Args | +|---|---|---| +| Opportunities (no contact/value/stage/owner) | `list_deals` | `limit: 100` | +| Contacts (missing/bad email, dupes, dormant, orphan) | `list_contacts` | `limit: 100` | +| Tasks (overdue, no due date) | `list_tasks` | `limit: 100` | -- `find-gaps.py` — opportunities with no contact / no value / no stage / no - owner; tasks overdue or with no due date. -- `audit-contacts.py` — missing/bad emails, likely-duplicate contacts (same - email, or same name+company), dormant (365d+ no activity, no open opp), orphan - (linked to nothing). `--dormant-days N` to tune. +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -If a script errors (auth/network), say so briefly and fall back to MCP reads -(`list_contacts`, `list_opportunities`, `list_tasks`) — but that's many calls; -prefer the scripts. +If one call errors (auth/network), say so briefly and proceed with what you have — don't bail on the whole audit because one endpoint is down. Everything below is computed against **now** in the operator's timezone. All reads — nothing here is journaled or needs approval. *(For pipeline performance — stuck deals, stage drop-offs, value by stage — that's `/weekly-review`, not this skill. Keep this one to data quality.)* -## Step 2 — Present the report, worst first +## Step 2 — Apply the hygiene rules + +Run these checks over the pulled records. An opportunity is **inactive** (skip it in the opportunity checks) when its `status` (lowercased) is one of `won` / `lost` / `cancelled` / `abandoned` / `archived`. + +### Opportunities (active only) + +- **No contact** — `contact_id` is empty/missing. +- **No value** — `value` is empty, missing, or zero. +- **No stage** — `placements` is empty (no pipeline-stage placement). +- **No owner** — none of `assigned_user_ids`, `assigned_users`, or `owner_id` is set. Nobody is working it. + +### Contacts + +For each contact, read `first_name`, `last_name`, `email`, `phone`, `company_id`, and `last_activity_at` (fall back to `updated_at`). Treat `open_opportunity_count` (open opps) and `opportunity_count` (any opps) as the activity signals. + +- **Missing email** — `email` empty. +- **Bad email** — `email` present but doesn't match a sane address shape: must be `something@something.tld` (a single `@`, a dot in the domain, no spaces). If it fails that, it's bad, not missing. +- **Missing phone** — `phone` empty. +- **Likely duplicates** — group contacts two ways and flag any group with 2+ members: + 1. **Same email** (case-insensitive) — strongest dupe signal. + 2. **Same first+last name AND same `company_id`** — a name collision inside one company. +- **Dormant** — `last_activity_at` is **365+ days** ago AND `open_opportunity_count` is 0. (If the operator asks for a different window, use that instead of 365.) +- **Orphan** — linked to nothing: no opportunities (`opportunity_count` is 0/absent) AND no `company_id`. + +### Tasks + +- **Overdue** — not completed (no `completed_at`, status not `completed`/`cancelled`) AND `due_date` (or `due_at`) is in the past. Capture days overdue and sort descending. +- **No due date** — not completed AND no `due_date`/`due_at` at all. + +## Step 3 — Present the report, worst first Lead with what's actively costing money or will embarrass them, then tidy-ups. @@ -63,16 +87,16 @@ Lead with what's actively costing money or will embarrass them, then tidy-ups. Use the operator's own pipeline/stage/field names (pull from the data, don't invent). Don't dump every row — show the worst few per category and the count. -## Step 3 — Offer the fixes (with approval, one at a time) +## Step 4 — Offer the fixes (with approval, one at a time) + +For the safe, mechanical fixes, offer to apply them — one at a time, with a yes. These are **writes**, so the rails in `knowledge/safeguards.md` apply: confirm first, then after each call journal one line to `./.bos-journal.md`, and if a write comes back `202` / `approval_id` it's queued — surface the approval link and stop (don't retry). -For the safe, mechanical fixes, offer to apply them — one at a time, with a yes: -- Assign an unowned opportunity → `mcp__trustpager__update_opportunity(id, assigned_user_ids=[...])` -- Set a missing value → `update_opportunity(id, amount=...)` (confirm the number with the operator) -- Archive a dormant/orphan contact → confirm by name first. +- Assign an unowned opportunity → `update_deal(id, assigned_user_ids=[...])` +- Set a missing value → `update_deal(id, value=...)` (confirm the number with the operator first) +- Archive a dormant/orphan contact → confirm by name first, then `update_contact`. **Never merge or delete without explicit, named confirmation.** Deduping is -destructive — show the two records and which survives before doing anything. -For bulk cleanups, do a small batch first, show the result, then continue. +destructive — show the two records and which survives before doing anything (search-first, never blind-delete). For bulk cleanups, do a small batch first, show the result, then continue. ## What to never do diff --git a/skills/automate-this/SKILL.md b/skills/automate-this/SKILL.md index 4ad145e..8eccf43 100644 --- a/skills/automate-this/SKILL.md +++ b/skills/automate-this/SKILL.md @@ -17,7 +17,7 @@ triggers: You shouldn't be doing the same thing twice. This skill turns "every time a lead comes in I tag them and email them within 5 minutes" into an actual TrustPager automation that does it forever. -**Read first:** [`knowledge/automation-method.md`](../../knowledge/automation-method.md) — the mental model (trigger → conditions → actions), multiple-triggers OR-match, the automation-vs-queue distinction, and the test-before-enable rails. This skill assumes it. If the operator asks "what *should* I automate?", pull from [`knowledge/automation-recipes.md`](../../knowledge/automation-recipes.md). +**Read first:** [`knowledge/automation-method.md`](../../knowledge/automation-method.md) — the mental model (trigger → conditions → actions), multiple-triggers OR-match, the automation-vs-queue distinction, and the test-before-enable rails. This skill assumes it. If the operator asks "what *should* I automate?", pull from [`knowledge/automation-recipes.md`](../../knowledge/automation-recipes.md). Writes follow [`knowledge/safeguards.md`](../../knowledge/safeguards.md). ## Step 1 — Understand the rule @@ -32,20 +32,22 @@ If they gave only "DO Y" with no "WHEN", ask: *"What should kick this off — a **Fork early — is this actually a sequence?** If the answer is "send a series of emails over several days" (Day 0 welcome, Day 3 value, Day 7 ask), that's an **auto queue**, not an automation. Hand off: *"That's a nurture sequence — `/design-nurture-sequence` is the right tool, want me to switch to that?"* (Method §4.) One-shot reaction = stay here. -## Step 2 — Map to the TrustPager primitives +## Step 2 — Map to the TrustPager primitives (parallel MCP reads) -Run the discovery bundle once: +Use the `trustpager` MCP server. Fire these reads to learn the automation surface so you don't guess: -``` -python ~/.claude/bos-run.py automate-this -``` +| Need | Tool | Args | +|---|---|---| +| Every trigger type the engine supports + its `{{variable}}` tokens | `list_trigger_schemas` | (none) | +| The exact payload + tokens for the trigger you'll use | `get_trigger_schema` | `trigger_type: ` | +| Existing automations (to flag overlap before duplicating) | `list_automations` | `limit: 100` | -This returns `available_triggers`, `available_action_types`, and `existing_automations` in one call — replaces 3+ separate MCP discovery calls. +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -From the returned JSON: -- **Find the trigger(s)** matching each WHEN. For the chosen trigger's full payload + `{{variable}}` tokens, call `mcp__trustpager__get_trigger_schema(trigger_type)` — you need this to confirm any variable you'll use in an email/SMS body actually exists for that trigger. -- **Find the action types** matching the DO steps. For **each one**, call `mcp__trustpager__describe_action_type(action_type)` right before you write it — get the exact config schema, example, and warnings. Don't guess config shapes. -- **Check `existing_automations` for overlap.** If there's already an automation on the same trigger doing similar work, flag it before proceeding — and if it's the *same actions from a new entry point*, the right move may be **adding a trigger to the existing automation**, not building a new one. +From these: +- **Find the trigger(s)** matching each WHEN in `list_trigger_schemas`. For the chosen trigger's full payload + `{{variable}}` tokens, call `get_trigger_schema(trigger_type)` — you need this to confirm any variable you'll put in an email/SMS body actually exists for that trigger, or it renders blank. +- **Map the DO steps to action types.** The complete list of action types and their config schemas lives in [`knowledge/automation-method.md`](../../knowledge/automation-method.md) §3 and [`knowledge/automation-recipes.md`](../../knowledge/automation-recipes.md) — use those as the authoritative reference for the `action_type` keys and the config each one needs. (See FLAGS in the conversion note: client workspaces don't expose a live action-type catalogue, so the method/recipes docs are the source of truth for action config shapes.) +- **Check `list_automations` for overlap.** If there's already an automation on the same trigger doing similar work, flag it before proceeding — and if it's the *same actions from a new entry point*, the right move may be **adding a trigger to the existing automation** (`add_automation_trigger`), not building a new one. Use `get_automation(automation_id)` to inspect a candidate's existing triggers/actions. If the operator wants something TrustPager can't do (no matching action): > "TrustPager doesn't have an action for [X] yet. Closest options are [a] or [b]. Or I can file a feature request — `/make-it-happen file a feature request`." @@ -81,13 +83,15 @@ WAIT for explicit go. ## Step 4 — Create the automation -Step-by-step, with progress. **Build it disabled, test, then enable.** +These are writes — they follow [`knowledge/safeguards.md`](../../knowledge/safeguards.md): build disabled, test, then enable; journal each write as one line to `.bos-journal.md`; if any call returns a `202`/`approval_id`, surface the approvals link and stop (don't retry). Before creating, you already checked `list_automations` for an existing duplicate (search-first rail). + +Step-by-step, with progress: -1. **`mcp__trustpager__create_automation`** — name, description, primary `trigger_type`, and (preferred) an inline `triggers: [...]` array for all triggers at once. Each trigger entry can carry its **own** `trigger_type` + `source_type`/`source_id` (that's what makes OR-match across different event classes work). Set `dedup_enabled` / `dedup_window_minutes` and, for anything with a `call_webhook` or a feedback path, `max_executions_per_day`. Leave `enabled` false. -2. **`mcp__trustpager__add_automation_action`** for each action — **in the order they should run**. (Or inline an `actions: [...]` array on create.) -3. **`mcp__trustpager__add_automation_trigger`** for any extra triggers not added inline — each with its own `trigger_type`/source. -4. **TEST IT.** `mcp__trustpager__execute_automation_action` against sample data for the key actions — confirm emails render with variables resolved, tags apply, webhooks post the right body. Never point a test send at a real customer; use the operator's own monitored inbox/number. -5. **If the test looks right:** `mcp__trustpager__enable_automation`. If not: report what was off and ask how to adjust — don't enable hopefully. +1. **`create_automation`** — name, description, primary `trigger_type`, and (preferred) an inline `triggers: [...]` array for all triggers at once. Each trigger entry can carry its **own** `trigger_type` + `source_type`/`source_id` (that's what makes OR-match across different event classes work). Set `dedup_enabled` / `dedup_window_minutes` and, for anything with a `call_webhook` or a feedback path, `max_executions_per_day`. Leave `enabled` false. +2. **`add_automation_action`** for each action — **in the order they should run**. (Or inline an `actions: [...]` array on create.) +3. **`add_automation_trigger`** for any extra triggers not added inline — each with its own `trigger_type`/source. +4. **TEST IT.** `execute_automation_action` against sample data for the key actions — confirm emails render with variables resolved, tags apply, webhooks post the right body. Never point a test send at a real customer; use the operator's own monitored inbox/number. +5. **If the test looks right:** `enable_automation`. If not: report what was off and ask how to adjust — don't enable hopefully. ALWAYS test before enabling. Disabled automations are safe; enabled ones run for real, send real messages, and spend credits. diff --git a/skills/automate-this/fetch.py b/skills/automate-this/fetch.py deleted file mode 100644 index 74fe511..0000000 --- a/skills/automate-this/fetch.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -"""automate-this — pre-fetch trigger schemas + action types + existing automations. - -So Claude doesn't have to discover the automation surface step-by-step -during the design conversation. Everything Claude needs to design an -automation is in one JSON blob. - -Pulls (in parallel): -- All trigger schemas (every WHEN event TrustPager can react to) -- All action types (every DO operation an automation can perform) -- Existing automations (so we can flag overlap with "you already have - an automation for that" before creating a duplicate) - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/automate-this/fetch.py -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, resolve_path, -) - - -SKILL = "automate-this" - - -def fetch(quiet: bool) -> dict: - now = now_utc() - log(SKILL, "fetching automation surface...", quiet=quiet) - - calls = [ - ("schemas/triggers", {}), - ("automations/action-types", {}), - (resolve_path("automations"), {"limit": 100}), - ] - results = parallel_get(calls) - - triggers = results.get("schemas/triggers", {}) - action_types = results.get("automations/action-types", {}) - automations = results.get(resolve_path("automations"), {}) - - triggers_list = triggers.get("data") or triggers if isinstance(triggers, (dict, list)) else [] - if isinstance(triggers_list, dict): - triggers_list = triggers_list.get("triggers") or [] - actions_list = action_types.get("data") or action_types if isinstance(action_types, (dict, list)) else [] - if isinstance(actions_list, dict): - actions_list = actions_list.get("action_types") or [] - - return { - "generated_at": now.isoformat(), - "available_triggers": triggers_list, - "available_action_types": actions_list, - "existing_automations": [ - { - "id": a.get("id"), - "name": a.get("name"), - "enabled": a.get("enabled"), - "trigger_count": len(a.get("triggers") or []), - "action_count": len(a.get("actions") or []), - } - for a in (automations.get("data") or []) - ], - "headline": { - "triggers_available": len(triggers_list) if isinstance(triggers_list, list) else 0, - "action_types_available": len(actions_list) if isinstance(actions_list, list) else 0, - "automations_in_workspace": len(automations.get("data") or []), - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/automate-this/test-fixture.json b/skills/automate-this/test-fixture.json deleted file mode 100644 index 1499f73..0000000 --- a/skills/automate-this/test-fixture.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "responses": { - "schemas/triggers": {"data": [{"trigger_type": "form_submission"}, {"trigger_type": "stage_changed"}]}, - "automations/action-types": {"data": [{"action_type": "send_email"}, {"action_type": "add_tag"}, {"action_type": "create_task"}]}, - "automations": {"data": [{"id": "a1", "name": "Existing automation", "enabled": true, "triggers": [{}], "actions": [{}, {}]}]} - } -} diff --git a/skills/brand-my-workspace/SKILL.md b/skills/brand-my-workspace/SKILL.md index 0b0670e..fa1c492 100644 --- a/skills/brand-my-workspace/SKILL.md +++ b/skills/brand-my-workspace/SKILL.md @@ -1,6 +1,6 @@ --- name: Brand My Workspace -description: Point this at the user's website. Detect their brand colours, fonts, name, and logo. Write `brand/brand.json` + drop `brand/logo.png`. Run `tools/sync-brand.py` so every studio is rebranded in one shot. This is the single touchpoint for theming every BOS studio (thumbnails, CTAs, future studios). +description: Point this at the user's website. Detect their brand colours, fonts, name, and logo. Write `brand/brand.json` + drop `brand/logo.png`, then copy the logo/favicons into every studio's public/ folder so the whole pack is rebranded in one shot. This is the single touchpoint for theming every BOS studio (thumbnails, CTAs, future studios). triggers: - brand my workspace - rebrand my pack @@ -12,7 +12,7 @@ triggers: # Brand My Workspace -You're rebranding every BOS studio in one shot. The user points at their website (or hands you their colours directly). You write `brand/brand.json` + drop `brand/logo.png`, then run `tools/sync-brand.py` so every studio's `public/` folder picks up the new assets. +You're rebranding every BOS studio in one shot. The user points at their website (or hands you their colours directly). You write `brand/brand.json` + drop `brand/logo.png`, then copy the new assets into every studio's `public/` folder yourself (Step 10) so each studio picks them up. After this skill finishes, every thumbnail and every CTA the user renders will be on their brand. They don't have to touch any other file. @@ -148,28 +148,52 @@ Pretty-print with 2-space indent. ## Step 9 — Generate favicons from logo (best-effort) -If the user has Python + Pillow available, you can resize `logo.png` (or `icon.png` if they provided one) to all favicon sizes. If not, leave the existing favicons in place and flag to the user. +If the user has [ImageMagick](https://imagemagick.org) available (`magick` on the PATH — check with `magick -version`), resize the square `brand/icon.png` to all favicon sizes. If `magick` isn't installed, leave the existing favicons in place and flag to the user (point them at ImageMagick, or tell them to drop a pre-made favicon set into `brand/`). -Quick path with Pillow: +Quick path with ImageMagick: -```python -from PIL import Image -src = Image.open("brand/icon.png") -for size in [16, 32, 192, 512]: - src.resize((size, size), Image.LANCZOS).save(f"brand/favicon-{size}x{size}.png") -src.resize((180, 180), Image.LANCZOS).save("brand/apple-touch-icon-source.png") # for reference -src.resize((32, 32), Image.LANCZOS).save("brand/favicon.ico", format="ICO", sizes=[(16,16),(32,32)]) +```bash +for s in 16 32 192 512; do + magick brand/icon.png -resize ${s}x${s} brand/favicon-${s}x${s}.png +done +magick brand/icon.png -resize 32x32 -define icon:auto-resize=16,32 brand/favicon.ico +``` + +Skip this step if you only have a wide wordmark (not a square icon). Tell the user to drop a square `icon.png` into `brand/` if they want regenerated favicons. (No Python — this is a one-off CLI step; the pack itself stays Python-free.) + +## Step 10 — Propagate the assets into every studio + +Copy the new `brand/` assets into each studio's `public/` folder yourself with file tools — no script. First list the studios (every direct child of `studio/` that has a `public/` folder): + +```bash +ls -d studio/*/public/ ``` -Skip this step if you only have a wide wordmark (not a square icon). Tell the user to drop a square `icon.png` into `brand/` if they want regenerated favicons. +Then, for **each** studio `public/` dir, copy these source→destination pairs with `cp` (the destination names follow the standard favicon convention the studios' `index.html` references, so the names differ from `brand/`): + +| From `brand/` | To `studio//public/` | +|---|---| +| `logo.png` | `logo.png` | +| `favicon.ico` | `favicon.ico` | +| `favicon-16x16.png` | `favicon-16x16.png` | +| `favicon-32x32.png` | `favicon-32x32.png` | +| `icon.png` | `apple-touch-icon.png` | +| `favicon-192x192.png` | `android-chrome-192x192.png` | +| `favicon-512x512.png` | `android-chrome-512x512.png` | -## Step 10 — Run sync-brand.py +Example for one studio: ```bash -python tools/sync-brand.py +cp brand/logo.png studio/thumbnails/public/logo.png +cp brand/favicon.ico studio/thumbnails/public/favicon.ico +cp brand/favicon-16x16.png studio/thumbnails/public/favicon-16x16.png +cp brand/favicon-32x32.png studio/thumbnails/public/favicon-32x32.png +cp brand/icon.png studio/thumbnails/public/apple-touch-icon.png +cp brand/favicon-192x192.png studio/thumbnails/public/android-chrome-192x192.png +cp brand/favicon-512x512.png studio/thumbnails/public/android-chrome-512x512.png ``` -This copies the new `brand/logo.png` + favicon set into every studio's `public/`. +Repeat for every studio the `ls` returned. Skip any source file you didn't generate (e.g. if you only have a wordmark and no square `icon.png`/favicons, copy `logo.png` and leave the rest) and tell the operator which were skipped. Studios pick up the new assets on the next dev-server serve or a hard-refresh. ## Step 11 — Tell the user what changed diff --git a/skills/build-customer-voice/SKILL.md b/skills/build-customer-voice/SKILL.md index 8c1de3e..1e49fdb 100644 --- a/skills/build-customer-voice/SKILL.md +++ b/skills/build-customer-voice/SKILL.md @@ -23,33 +23,23 @@ The source of truth for the method is — read its "Layer 2 — Customer voice synthesis" section before starting if you haven't. -## Step 1 — Dump the transcripts +## Step 1 — Pull the transcripts (MCP) -Run the tool. Defaults to ≥5min duration, target 30 calls + 30 meetings, -output into a date-stamped folder: +Use the `trustpager` MCP server. All reads — nothing here is journaled or needs approval. -```bash -python tools/dump-transcripts.py -``` +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**. -Adjust `--min-duration`, `--target`, `--out` if the operator asks for -something different. +1. **List the candidates.** Call `list_transcripts(transcription_status: "complete", limit: 100)` and paginate via the `after` cursor (each list row has `duration_seconds`, `type`, `occurred_at`, `title`, linked entities — but **no** transcript body). Keep paging while `pagination.has_more` is true, up to a sane cap (~2000 rows / ~20 pages). +2. **Filter by duration.** Keep only rows whose `duration_seconds` is **≥ 300** (5 minutes) — that's the default; if the operator asks for a different floor (e.g. 10 min = 600), use theirs. Split the survivors into **calls** (`type: "call"`) and **meetings** (`type: "meeting"`). Target roughly the **30 most recent of each** (newest first) unless the operator wants more or fewer. +3. **Fetch each transcript's full text.** For every kept row, call `get_transcript(id: )` — that's the call that returns `transcript_text`. The text is usually a JSON-encoded blob containing a `transcript_vtt` (WebVTT) field and sometimes a `summary`; read the VTT speaker lines and ignore the cue numbers / `00:00 -->` timestamps. Some sources store plain text instead — handle either. -The tool writes: -- `transcripts//calls/*.md` -- `transcripts//meetings/*.md` -- `transcripts//_index.json` - -Most Twilio phone calls between humans aren't transcribed. The tool -silently skips those — only Recall AI Notetaker meetings and Retell -voice-agent calls have rich text. Don't be alarmed by the "skipped -empty" count. +Most Twilio phone calls between humans aren't transcribed — only TrustPager Notetaker (Recall) meetings and Retell voice-agent calls have rich text. Transcripts with an empty `transcript_text` just get skipped; don't be alarmed by how many that is. ## Step 2 — Read every transcript end-to-end -**Read every single file in `calls/` and `meetings/`.** Don't skim. If -a file is large (over a few thousand lines), paginate with `Read(offset=, -limit=)` until you've covered the entire thing. +**Read the full text of every transcript you fetched** — both calls and +meetings. Don't skim. If a transcript is very large, work through it in +chunks until you've covered the entire thing. **Filter out the host's voice.** The salesperson / operator's lines are not customer voice. You want what the OTHER speaker says. Internal team @@ -58,9 +48,9 @@ filter out — but DO keep external participants when they speak. ## Step 3 — Write `customer-voice-synthesis.md` -Output path: alongside the transcripts folder, e.g. -`transcripts//customer-voice-synthesis.md`. Tell the operator -explicitly where you wrote it so they can find it. +Write it with the `Write` tool to a date-stamped path, e.g. +`transcripts//customer-voice-synthesis.md` (create the folder if +needed). Tell the operator explicitly where you wrote it so they can find it. The file MUST have these 10 sections, in this order: @@ -163,7 +153,7 @@ your report is the headline. ## Hard rules -- **Read every file end-to-end.** No skimming. +- **Read every transcript end-to-end.** No skimming. - **Quote VERBATIM.** Don't paraphrase. - **Filter the host's voice.** Synthesis = customer voice only. - **Real names stay in this internal file.** Don't pass these names diff --git a/skills/design-nurture-sequence/SKILL.md b/skills/design-nurture-sequence/SKILL.md index 84e75a9..f1e0fbf 100644 --- a/skills/design-nurture-sequence/SKILL.md +++ b/skills/design-nurture-sequence/SKILL.md @@ -43,8 +43,7 @@ Ask the operator: 1. **Which auto queue?** (Name or ID — they should already have it set up with stages, even if the email actions are empty.) Confirm the queue - exists by running `python tools/dump-crm-bundle.py --resources auto_queues` - and reading the result. + exists by checking whether the queue already exists with `list_auto_queues`. 2. **Audience + trigger.** Who's enrolled and when (e.g. trial signups moving into a "Welcome" stage, Facebook leads via form submission). 3. **Goal of the sequence.** Drive conversion, drive activation, drive @@ -55,12 +54,7 @@ Ask the operator: ## Step 2 — Map a help video to each stage -The TrustPager help center is the canonical library. List articles via: - -```bash -curl -s "https://api.trustpager.com/functions/v1/help-center-public?action=list" \ - | python -c "import sys, json; [print(f\"{a['slug']} | {a['title']}\") for a in json.load(sys.stdin)['articles']]" -``` +The TrustPager help center is the canonical library. Use the `search_help_center` tool on the `trustpager` MCP server to find the relevant article/video per stage — query it with the concern you're addressing at that stage (e.g. `search_help_center("online booking setup")`) and it returns matching articles with their titles and slugs. For each stage: diff --git a/skills/draft-reply/SKILL.md b/skills/draft-reply/SKILL.md index 40e8cbb..bf9a444 100644 --- a/skills/draft-reply/SKILL.md +++ b/skills/draft-reply/SKILL.md @@ -16,19 +16,31 @@ triggers: Replies are higher-stakes than fresh emails — the context already exists, and getting the tone wrong is more obvious. This skill drafts the reply against the actual message that was received, not against a guess. -## Step 1 — Find the message being replied to +## Step 1 — Find the message being replied to (parallel MCP reads) -First, run: +If the user pastes a message directly, use that paste as the source-of-truth and skip the fetch entirely. -``` -python ~/.claude/bos-run.py draft-reply --hours 48 -``` +Otherwise, pull the candidate inbound from the `trustpager` MCP server. These are reads — free, nothing journaled: -This returns every inbound email + SMS in the window with no reply, ranked (open-opportunity senders first, then by recency). If the user already named someone, filter the list and pick that one. If not, offer the top 3-5 as a numbered list: +| Need | Tool | Args | +|---|---|---| +| Inbound email threads | `list_email_threads` | `direction: "inbound"`, `is_read: false`, `limit: 50` | +| SMS conversations | `list_sms_conversations` | (none — returns all) | -> "These are the unanswered messages from the last 48h, top first. Reply to #1? Or pick another?" +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +Then filter and rank client-side (the list tools don't take a date or "we-replied" filter, so do it yourself): + +- **Window:** keep only items whose last-message time is within the last **48 hours** (default). +- **Email — needs a reply:** the thread's last message direction is **inbound** and we haven't already replied (the latest message is theirs, not ours). Skip `is_automated` threads. +- **SMS — needs a reply:** there's an inbound message **newer than** the latest outbound message. If the last outbound is at or after the last inbound, we've already replied — skip it. +- **Rank:** opportunity-linked senders first (a thread/conversation carrying a `deal_id`), then newest-first by last-message time. + +For each kept item capture: channel, the thread/conversation id, sender name + email/phone, received-at, whether it's linked to an open opportunity (`deal_id`), the contact id, and a ~200-char snippet of the latest inbound message. -If the user pastes a message directly (not from the JSON), use that paste as the source-of-truth and skip the fetch. +If the user already named someone, filter the list to them and pick that one. If not, offer the top 3–5 as a numbered list: + +> "These are the unanswered messages from the last 48h, top first. Reply to #1? Or pick another?" ## Step 2 — Read the inbound carefully @@ -37,7 +49,7 @@ Before drafting, identify in the inbound: - **The emotional register** — formal? casual? frustrated? excited? - **Specific details** — names, dates, dollar amounts, products mentioned — these must appear in the reply. -If unclear ("I'm not sure what they're actually asking"), say so to the user and ask for clarification BEFORE drafting: +If the snippet isn't enough context, pull the full thread (`get_email_thread` / `get_sms_messages`) before drafting. If still unclear ("I'm not sure what they're actually asking"), say so and ask BEFORE drafting: > "I'm reading this as a request for [X] — is that right, or are they actually asking [Y]?" ## Step 3 — Draft the reply @@ -52,12 +64,15 @@ If the inbound has multiple questions, address them in bullet order. Keep the sa ## Step 4 — Show + send -Same approval flow as /send-email: +This is a write — it follows [`knowledge/safeguards.md`](../../knowledge/safeguards.md): show the draft, wait for an explicit yes, then send; journal the send as one line to `.bos-journal.md`. If the send returns a `202`/`approval_id`, surface the approvals link and stop — don't retry. + - Show the proposed reply (subject = same as inbound prepended with "Re:", or just the SMS body) - Wait for yes/no -- On yes: `mcp__trustpager__reply_to_email` (for email) or `mcp__trustpager__send_sms` (for SMS) +- On yes: `reply_to_email` (for email — preserves the thread) or `send_sms` (for SMS) - On no: ask what to change. Common edits: "more direct", "less formal", "shorter", "include the dollar number". +**Search-first / never blind-retry:** if a send errors ambiguously or times out, don't just re-issue it — re-pull the thread/conversation to confirm whether the reply actually landed, then act on what you find. A duplicate reply is worse than a slow one. + ## Important behaviours - **Never invent facts.** If they asked "what's the price?" and you don't know, the reply says "Let me confirm and come back to you today" — not a made-up number. diff --git a/skills/draft-reply/fetch.py b/skills/draft-reply/fetch.py deleted file mode 100644 index ccc3e28..0000000 --- a/skills/draft-reply/fetch.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -"""draft-reply — find inbound messages awaiting a reply, ranked. - -Pulls every inbound email thread + SMS conversation in the last N hours -where we haven't replied yet, ranked by: -- Recency (newer first) -- Whether the sender has an open opportunity with us -- Sender's contact value / "VIP" flag if your workspace uses one - -Output (stdout): JSON document with all items, prioritised. The skill -chooses one to draft a reply to, OR shows the list and asks the user. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/draft-reply/fetch.py - python skills/draft-reply/fetch.py --hours 24 - python skills/draft-reply/fetch.py --channel email # or sms or all -""" - -from __future__ import annotations - -import argparse -import sys -from datetime import timedelta -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, parse_iso, resolve_path, -) - - -SKILL = "draft-reply" - - -def fetch(hours: int, channel: str, quiet: bool) -> dict[str, Any]: - now = now_utc() - cutoff = (now - timedelta(hours=hours)).isoformat() - log(SKILL, f"finding unanswered inbound in last {hours}h...", quiet=quiet) - - calls = [] - if channel in ("email", "all"): - calls.append((resolve_path("email", path_contains="threads"), - {"limit": 50, "direction": "inbound", "we_replied": "false", - "after": cutoff, - "sort": "last_message_at", "order": "desc"})) - if channel in ("sms", "all"): - calls.append((resolve_path("sms", path_contains="conversations"), - {"limit": 50, "after": cutoff, - "sort": "last_message_at", "order": "desc"})) - - results = parallel_get(calls) if calls else {} - - items: list[dict[str, Any]] = [] - - if channel in ("email", "all"): - threads = results.get(resolve_path("email", path_contains="threads"), {}).get("data", []) - for t in threads: - if t.get("we_replied"): - continue # belt + braces vs the query filter - items.append({ - "channel": "email", - "thread_id": t.get("id"), - "subject": t.get("subject"), - "from_name": (t.get("contact") or {}).get("first_name", "") + " " - + (t.get("contact") or {}).get("last_name", ""), - "from_email": (t.get("contact") or {}).get("email") or t.get("from_email"), - "received_at": t.get("last_message_at") or t.get("last_inbound_at"), - "has_open_opportunity": bool(t.get("deal_id") or t.get("opportunity_id")), - "opportunity_id": t.get("deal_id") or t.get("opportunity_id"), - "contact_id": (t.get("contact") or {}).get("id"), - "snippet": (t.get("latest_message") or {}).get("plain_text", "")[:200], - }) - - if channel in ("sms", "all"): - convos = results.get(resolve_path("sms", path_contains="conversations"), {}).get("data", []) - for c in convos: - if c.get("we_replied") or (c.get("outbound_count") or 0) > 0: - # Determine if there's an inbound newer than the latest outbound - last_inbound = parse_iso(c.get("last_inbound_at")) - last_outbound = parse_iso(c.get("last_outbound_at")) - if last_outbound and last_inbound and last_outbound >= last_inbound: - continue - items.append({ - "channel": "sms", - "conversation_id": c.get("id"), - "from_name": (c.get("contact") or {}).get("first_name", "") + " " - + (c.get("contact") or {}).get("last_name", ""), - "from_phone": (c.get("contact") or {}).get("phone") or c.get("from_phone"), - "received_at": c.get("last_inbound_at"), - "has_open_opportunity": bool(c.get("deal_id")), - "opportunity_id": c.get("deal_id"), - "contact_id": (c.get("contact") or {}).get("id"), - "snippet": (c.get("last_inbound_body") or "")[:200], - }) - - # Rank: open opp first, then by recency - items.sort(key=lambda x: ( - 0 if x.get("has_open_opportunity") else 1, - -(parse_iso(x.get("received_at")).timestamp() if parse_iso(x.get("received_at")) else 0), - )) - - return { - "generated_at": now.isoformat(), - "window_hours": hours, - "channel": channel, - "headline": { - "total_unanswered": len(items), - "with_open_opportunity": sum(1 for i in items if i.get("has_open_opportunity")), - "email_count": sum(1 for i in items if i["channel"] == "email"), - "sms_count": sum(1 for i in items if i["channel"] == "sms"), - }, - "items": items, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--hours", type=int, default=48, - help="How far back to look (default 48 hours)") - parser.add_argument("--channel", choices=["email", "sms", "all"], default="all", - help="Which channel(s) to include (default: all)") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(args.hours, args.channel, quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/draft-reply/test-fixture.json b/skills/draft-reply/test-fixture.json deleted file mode 100644 index fe48056..0000000 --- a/skills/draft-reply/test-fixture.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "responses": { - "email/threads": { - "data": [ - { - "id": "et-1", - "subject": "Quote follow-up", - "we_replied": false, - "last_message_at": "2026-05-31T08:00:00Z", - "contact": {"id": "c-1", "first_name": "Test", "last_name": "Reply", "email": "reply@example.com"}, - "deal_id": "opp-1", - "latest_message": {"plain_text": "Hey, just checking — did you get my last email?"} - } - ], - "pagination": {"has_more": false} - }, - "sms/conversations": {"data": [], "pagination": {"has_more": false}} - } -} diff --git a/skills/email-me-a-report/SKILL.md b/skills/email-me-a-report/SKILL.md index 42e4bab..8ae8db3 100644 --- a/skills/email-me-a-report/SKILL.md +++ b/skills/email-me-a-report/SKILL.md @@ -18,51 +18,58 @@ triggers: You are turning a report into something that arrives on its own — a dashboard delivered to chosen inboxes on a schedule, rendered and sent server-side. This is the generalised version of the daily-receivables flow: it works for *any* dashboard (pipeline, tasks, receivables, anything reportable). -Read [knowledge/reporting-method.md](../../knowledge/reporting-method.md) — especially §5 ("email any dashboard on a schedule"), which this skill is the front door to — and [knowledge/safeguards.md](../../knowledge/safeguards.md) for the approval-queue rail. +Read [knowledge/reporting-method.md](../../knowledge/reporting-method.md) — especially §5 ("email any dashboard on a schedule"), which this skill is the front door to — and [knowledge/safeguards.md](../../knowledge/safeguards.md) for the write rails and the approval-queue rail. -## Step 1 — Fetch what exists +## Step 1 — Fetch what exists (parallel MCP reads) -```bash -python ~/.claude/bos-run.py email-me-a-report -``` +Use the `trustpager` MCP server. All reads — free, nothing journaled: -Returns existing `dashboards` (candidates to schedule), available `sources` (raw material for a new one), and `existing_schedules` (so you don't duplicate). Shape documented at the bottom of `fetch.py`. +| Need | Tool | Args | +|---|---|---| +| Existing dashboards (candidates to schedule) | `list_report_dashboards` | (none) | +| Available report sources (raw material for a new dashboard) | `list_report_sources` | (none) | +| Auto schedules already running (so you don't duplicate one) | `list_auto_schedules` | `limit: 100` | +| Existing automations (the send action lives on an automation) | `list_automations` | `limit: 100` | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +`list_report_sources` returns each source with its supported measures, dimensions, and filters — that's how you confirm a field name exists before you build a card. ## Step 2 — Pin down what they want Get three things from the operator (ask only what's not already clear): -1. **What** — an existing dashboard (match against `dashboards`) or something new to build. If new, decide the source + the one or two cards that answer their question (e.g. "pipeline by stage" → opportunities source, bar card). +1. **What** — an existing dashboard (match against the `list_report_dashboards` result) or something new to build. If new, decide the source + the one or two cards that answer their question (e.g. "pipeline by stage" → opportunities source, bar card). 2. **Who** — recipient email(s). 3. **When** — cadence + time. Translate to cron in the operator's timezone: - "every weekday at 7am" → `0 7 * * 1-5` - "every Monday at 8am" → `0 8 * * 1` - "first of the month" → `0 9 1 * *` -If a matching schedule already exists in `existing_schedules`, say so and offer to edit it rather than create a duplicate. +If a matching schedule already exists (from `list_auto_schedules`), say so and offer to edit it rather than create a duplicate. This is the search-first rail — never create a schedule without first checking the existing ones. ## Step 3 — Build the dashboard (only if needed) -If they picked an existing dashboard, skip this. Otherwise: +If they picked an existing dashboard, skip this. Otherwise (these are writes — journal each to `.bos-journal.md`, watch for `202`): - `create_report_dashboard` with a clear name. -- `add_report_card` for each metric. Build the card's query with `query_report` **first**, confirm the numbers are right, then save that proven `query_spec` into the card (a typo'd filter field is silently ignored at render — reporting-method §2/§4). +- `add_report_card` for each metric. Build the card's query with `query_report` **first**, confirm the numbers are right, then save that proven `query_spec` into the card (a typo'd filter field is silently ignored at render — reporting-method §2/§4). Validate field names against the `list_report_sources` output. - Pick the visualisation that fits: `stat` for a single number, `bar` for category comparisons, `line` for trend, `table` for a row list. ## Step 4 — Wire the schedule -Two objects, both discovered live rather than guessed: +The mechanism is an automation carrying a `send_report_email` action, fired by an auto schedule on a cron. The exact config shape for the send action and the auto schedule lives in [knowledge/reporting-method.md](../../knowledge/reporting-method.md) §5 — use that as the authoritative reference (client workspaces don't expose a live `describe_action_type` / `describe_resource` lookup; see FLAGS in the conversion note). -1. **The send action** — `describe_action_type('send_report_email')` for its exact config (dashboard id, recipients, subject, optional intro/outro). It renders the dashboard per-recipient and skips-if-empty by default. -2. **The schedule** — `describe_resource('auto_schedule')` for how to create the cron and bind it to the automation that carries the send action. Set the **timezone** to the operator's local zone so the time means what they think. +1. **The send action** — `send_report_email`, configured with the dashboard id, recipients, subject, and optional intro/outro. It renders the dashboard per-recipient and skips-if-empty by default. Add it to an automation via `create_automation` + `add_automation_action` (or inline `actions`). +2. **The schedule** — `create_auto_schedule` with the cron and the **timezone** set to the operator's local zone (so the time means what they think), bound to the automation that carries the send action. Confirm the dashboard, recipients, and time back to the operator in one line before creating anything. -> ⚠️ **If creating the schedule or action returns a `202` (queued for approval)** — some keys are approval-gated — surface the approval link (https://app.trustpager.com/settings/api?tab=approvals) and wait. Don't retry or work around it (safeguards §1). +> ⚠️ **If creating the schedule or action returns a `202` (queued for approval)** — some keys are approval-gated — surface the approval link (https://app.trustpager.com/settings/api?tab=approvals), journal it as `approval_pending`, and wait. Don't retry or work around it (safeguards §1). ## Step 5 — Confirm -Tell them plainly: what gets sent, to whom, when the first one lands, and that it runs server-side (nothing needs to be open). Offer to send a one-off preview now if they want to see it before the first scheduled run. +Tell them plainly: what gets sent, to whom, when the first one lands, and that it runs server-side (nothing needs to be open). Offer to send a one-off preview now if they want to see it before the first scheduled run (`fire_auto_schedule_now`, or render the dashboard query directly). ## Tone @@ -72,14 +79,14 @@ Tell them plainly: what gets sent, to whom, when the first one lands, and that i ## What to never do - ❌ Don't create a duplicate schedule when one already covers it — edit instead. -- ❌ Don't guess the `send_report_email` config or the auto_schedule shape — `describe_*` them. +- ❌ Don't guess a card's field names — validate against `list_report_sources` and prove the query with `query_report` first. - ❌ Don't bypass an approval `202` — surface and wait. - ❌ Don't save a card spec you haven't proven with `query_report` first. ## Common follow-ups -- "Actually make it weekly" → edit the existing schedule's cron, don't create a new one. -- "Add my partner to it" → update the send action's recipients. +- "Actually make it weekly" → edit the existing schedule's cron (`update_auto_schedule`), don't create a new one. +- "Add my partner to it" → update the send action's recipients (`update_automation_action`). - "Show me what it'll look like" → render/send a one-off preview before the next run. - "Turn it off for now" → disable the schedule (it stays editable). diff --git a/skills/email-me-a-report/fetch.py b/skills/email-me-a-report/fetch.py deleted file mode 100644 index 844d642..0000000 --- a/skills/email-me-a-report/fetch.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""email-me-a-report — pre-fetch what's reportable and what already exists. - -Lists the operator's existing report dashboards (candidates to schedule), -the available report sources (raw material for a new dashboard), and any -auto schedules already running (so we don't duplicate one). - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/email-me-a-report/fetch.py - python skills/email-me-a-report/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, - force_utf8_stdout, log, now_utc, resolve_path, -) - -SKILL = "email-me-a-report" - - -def _resolve_or(resource: str, fallback: str, **kw: Any) -> str: - """Resolve a path from the public catalog (so it survives endpoint renames), - falling back to a known literal if the catalog can't be reached/matched.""" - try: - return resolve_path(resource, **kw) - except BOSError: - return fallback - - -def _safe_list(path: str, quiet: bool, **params: Any) -> list[dict[str, Any]]: - """GET a list endpoint, returning [] (and logging) on failure rather than bailing.""" - try: - return api_get(path, **params).get("data") or [] - except BOSError as e: - log(SKILL, f" ! {path}: {str(e).splitlines()[0]}", quiet=quiet) - return [] - - -def fetch(quiet: bool) -> dict[str, Any]: - now = now_utc() - log(SKILL, "listing dashboards, sources, and schedules...", quiet=quiet) - - # All three live under the catalog. Dashboards + sources are sub-resources - # of "reports"; schedules are their own "auto-schedules" resource. - dashboards = _safe_list( - _resolve_or("reports", "report-dashboards", path_contains="report-dashboards"), - quiet, limit=100) - sources = _safe_list( - _resolve_or("reports", "reports/sources", path_contains="sources"), quiet) - schedules = _safe_list( - _resolve_or("auto-schedules", "auto-schedules"), quiet, limit=100) - - return { - "generated_at": now.isoformat(), - "dashboards": [ - {"id": d.get("id"), "name": d.get("name"), - "description": d.get("description")} - for d in dashboards - ], - "sources": [ - {"name": s.get("name"), "label": s.get("label"), - "description": s.get("description")} - for s in sources - ], - "existing_schedules": [ - {"id": s.get("id"), "name": s.get("name"), - "cron": s.get("cron") or s.get("schedule"), - "enabled": s.get("enabled", s.get("is_active"))} - for s in schedules - ], - "headline": { - "dashboard_count": len(dashboards), - "source_count": len(sources), - "schedule_count": len(schedules), - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - try: - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) - - -# Output shape: -# { -# "dashboards": [{"id": "...", "name": "Sales Overview", "description": "..."}], -# "sources": [{"name": "opportunities", "label": "Opportunities", "description": "..."}, -# {"name": "tasks", ...}, {"name": "invoices", ...}], -# "existing_schedules": [{"id": "...", "name": "...", "cron": "0 7 * * 1-5", "enabled": true}], -# "headline": {"dashboard_count": 3, "source_count": 3, "schedule_count": 1} -# } diff --git a/skills/email-me-a-report/test-fixture.json b/skills/email-me-a-report/test-fixture.json deleted file mode 100644 index 63b2cb4..0000000 --- a/skills/email-me-a-report/test-fixture.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "responses": { - "report-dashboards": { - "data": [ - {"id": "dash-1", "name": "Sales Overview", "description": "Pipeline by stage"} - ] - }, - "reports/sources": { - "data": [ - {"name": "opportunities", "label": "Opportunities", "description": "Pipeline, win/loss, lead sources"}, - {"name": "tasks", "label": "Tasks", "description": "Open vs completed, overdue"}, - {"name": "invoices", "label": "Invoices / Receivables", "description": "Outstanding invoices, aged buckets"} - ] - }, - "auto-schedules": { - "data": [] - } - } -} diff --git a/skills/follow-up-radar/SKILL.md b/skills/follow-up-radar/SKILL.md index cc8f8c1..5ca0fb4 100644 --- a/skills/follow-up-radar/SKILL.md +++ b/skills/follow-up-radar/SKILL.md @@ -16,50 +16,63 @@ triggers: You are surfacing the active opportunities that have gone quiet and drafting personalised re-engagement messages for each one. Your goal: turn a backlog of forgotten deals into a queue of approved-and-ready outbound messages in under 5 minutes. -## Step 1 — Fetch silent opportunities +## Step 1 — Pull the data (MCP reads) -```bash -python ~/.claude/bos-run.py follow-up-radar -``` +Use the `trustpager` MCP server. All reads — free, nothing journaled. + +| Need | Tool | Args | +|---|---|---| +| Opportunities (to find the silent ones) | `list_deals` | `limit: 100` | +| Contact details for each top silent opp (enrichment) | `get_contact` | `contact_id: ` — one per top-N opp, fired in parallel | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +## Step 2 — Find the silent opportunities (digest logic) + +Compute everything against **now**. From the opportunities list, keep an opportunity as **silent** only if **all** of these hold: + +1. **It's active** — its `status` is NOT one of `won` / `lost` / `cancelled` / `abandoned` / `archived`, AND its current stage is not flagged `is_won_stage` or `is_lost_stage`. (Current stage = the first placement's pipeline stage; if there are no placements, treat as active/"Unstaged".) +2. **No scheduled future action** — there's no `next_action_date`, or it's in the past. A future `next_action_date` means it's in progress, not quiet — drop it. +3. **Gone quiet** — `updated_at` is more than **7 days** ago (the default silence threshold; the operator can ask for a different window, e.g. 14 days). -The script returns a JSON document with: +For each silent opp, compute `days_silent` = whole days since `updated_at`. -- `total_silent` — full count across the workspace -- `returned_top_n` — number of items enriched and returned (default 10) -- `summary_by_source` — count grouped by lead source (Facebook, Referral, etc.) -- `summary_by_stage` — count grouped by pipeline stage -- `items[]` — the top N silent opportunities, each with full contact details +**Rank by a blended score** (so fully-priced deals don't dominate and unpriced-but-long-silent leads don't vanish — most opps have `value=null`): -A "silent" opportunity is one where: -- The opportunity is in an active stage (not won, not lost, not on hold) -- `updated_at` is more than 7 days ago (configurable via `--silence-days N`) -- There's no future `next_action_date` scheduled +``` +base = max(100, value_in_dollars / 1000) # floor at 100 so unpriced deals still have pull +score = base * (1 + days_silent / 30) +``` + +Sort silent opps by `score`, highest first. -The ranking blends value and days-silent so unpriced-but-long-silent leads don't disappear. See `_score` in the fetch script. +**Summaries for the headline:** count the silent opps grouped by `lead_source`, and grouped by stage name. -## Step 2 — Open with a summary, not a wall of detail +**Top N:** take the top **10** by score (the operator can ask for more — "show me the next 10" → take the top 20 and continue past where you stopped). Enrich only these top N — call `get_contact` for each one's `contact_id` in parallel, pulling `first_name`, `last_name`, `email`, `phone`, `job_title`, and the unsubscribe flags (`email_unsubscribed` / `sms_unsubscribed`). -Start with one paragraph the operator can read in 10 seconds: +## Step 3 — Open with a summary, not a wall of detail + +Start with one paragraph the operator can read in 10 seconds, populated from the group-by summaries: ``` -You've got X silent opportunities in your active pipeline. Most are coming from [lead source], -mostly sitting in [stage]. Here are the top N to chase, with drafts ready. +You've got X silent opportunities in your active pipeline. Most are coming from [top lead source], +mostly sitting in [top stage]. Here are the top N to chase, with drafts ready. ``` -Use `summary_by_source` and `summary_by_stage` to populate the headline. This sets context BEFORE the operator dives into individual messages. +This sets context BEFORE the operator dives into individual messages. -## Step 3 — Draft a personalised message for each +## Step 4 — Draft a personalised message for each -For each item in `items[]`, draft ONE re-engagement message. Pick the channel based on what the contact has: +For each top-N item, draft ONE re-engagement message. Pick the channel based on what the contact has: -- ✅ Has phone, not sms_unsubscribed → **SMS** (short, casual) -- ✅ Has email, not email_unsubscribed → **Email** (slightly longer, can reference the deal) +- ✅ Has phone, not `sms_unsubscribed` → **SMS** (short, casual) +- ✅ Has email, not `email_unsubscribed` → **Email** (slightly longer, can reference the deal) - ❌ Both unsubscribed → flag the opportunity for manual review, don't draft **Each draft must include:** -- **The contact's first name** (from `contact.first_name`) — never "Hi there" or "Hello" -- **A specific reference** — what they were looking at, when, where the conversation left off. Use `stage`, `lead_source`, and `days_silent` to construct the reference. Examples: +- **The contact's first name** — never "Hi there" or "Hello" +- **A specific reference** — what they were looking at, when, where the conversation left off. Use `stage`, `lead_source`, and `days_silent` to construct it. Examples: - Stage "Demo Booked", silent 14 days → "wanted to circle back on the demo we never got to" - Stage "Not Ready Yet", silent 30 days → "checking in — last we spoke you weren't ready to move forward yet, but timing changes" - Stage "Quote Sent", silent 7 days → "just making sure the proposal landed and you've had a chance to look" @@ -75,7 +88,9 @@ For each item in `items[]`, draft ONE re-engagement message. Pick the channel ba - ❌ Marketing language ("excited to share", "leverage", "synergy") - ❌ A scheduler link unless the operator's CLAUDE.md explicitly approves it (per their banned-phrase rules) -## Step 4 — Present each draft for approval, one at a time +## Step 5 — Present each draft for approval, one at a time + +Every send here is a write — it follows [`knowledge/safeguards.md`](../../knowledge/safeguards.md): show the draft, get a per-item yes, then send; journal each send as one line to `.bos-journal.md`; if a send returns a `202`/`approval_id`, surface the approvals link and stop (don't retry). Format each as: @@ -96,25 +111,23 @@ Format each as: The operator answers per-item. Don't batch. Don't bulk-send. Confirmation is per-message. When the operator says yes: -- SMS → call the `send_sms` tool on the TrustPager MCP -- Email → call `send_email` (mode: "personal" — see the operator's email-sending preferences) +- SMS → `send_sms` on the `trustpager` MCP server +- Email → `send_email` (mode: "personal" — see the operator's email-sending preferences) - Then log the activity on the opportunity via `add_note` so the next sweep doesn't surface it again +- Journal both the send and the note to `.bos-journal.md` -When the operator says no or skip: -- Don't log anything — the opportunity stays silent for next time -- Move to the next item +When the operator says no or skip: don't log anything — the opportunity stays silent for next time. Move to the next item. -When the operator says edit: -- Take their edits inline, present the revised draft, ask again +When the operator says edit: take their edits inline, present the revised draft, ask again. -## Step 5 — End with the operator's choice +## Step 6 — End with the operator's choice After all N items: ``` ✓ Sent: X | Skipped: Y | Edited and sent: Z -Want to drill into the remaining N silent opportunities? Run with --top 20. +Want to drill into the remaining N silent opportunities? (I'll take the next 10 by score.) ``` ## What to never do @@ -127,15 +140,12 @@ Want to drill into the remaining N silent opportunities? Run with --top 20. ## Common follow-ups the operator will ask -Be ready to chain naturally into: - -- "Show me the next 10" → re-run with `--top 20` and pick up where you stopped +- "Show me the next 10" → take the next 10 silent opps by score and pick up where you stopped - "Skip Facebook leads, just show me referrals" → filter the items by `lead_source` -- "Move this one to Lost" → call `update_opportunity` with `status: "lost"` +- "Move this one to Lost" → `update_deal` with `status: "lost"` (a write — journal it) - "Schedule a call with this one instead" → use the scheduling MCP tools ## When this skill should NOT fire - The operator is mid-call and asking a focused question about one contact — answer that, don't pivot -- It's the first time today the operator has run this — but they ran it yesterday — show only changes since yesterday's run (use `~/.claude/bos-cache/follow-up-radar-state.json` if you maintain state) - The operator already has 50+ scheduled outbound today — flag that instead of adding more diff --git a/skills/follow-up-radar/fetch.py b/skills/follow-up-radar/fetch.py deleted file mode 100644 index cc69a8c..0000000 --- a/skills/follow-up-radar/fetch.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python3 -"""Follow-up Radar — silent-opportunity surfacer + contact enrichment. - -Finds active opportunities that have gone quiet (no activity in 7+ days, no -scheduled next-action), ranks them by deal value × days silent, and enriches -the top N with contact details so the briefing AI can draft personalised -re-engagement messages. - -Usage: - python skills/follow-up-radar/fetch.py - python skills/follow-up-radar/fetch.py --silence-days 14 # custom threshold - python skills/follow-up-radar/fetch.py --top 10 # enrich top 10 - python skills/follow-up-radar/fetch.py --json-only - -Output (stdout): JSON document with the enriched silent opportunities. -Output (stderr): progress logs. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. See tools/trustpager_api.py. -""" - -from __future__ import annotations - -import argparse -import sys -from datetime import datetime -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, group_count, log, - now_utc, parallel_get, parse_iso, resolve_path, -) - - -SKILL = "follow-up-radar" - -INACTIVE_OPP_STATUSES = {"won", "lost", "cancelled", "abandoned", "archived"} - - -def _opp_stage(opp: dict[str, Any]) -> str | None: - placements = opp.get("placements") or [] - if not placements: - return None - return (placements[0].get("crm_pipeline_stages") or {}).get("name") - - -def _opp_is_active(opp: dict[str, Any]) -> bool: - if (opp.get("status") or "").lower() in INACTIVE_OPP_STATUSES: - return False - placements = opp.get("placements") or [] - if placements: - stage = placements[0].get("crm_pipeline_stages") or {} - if stage.get("is_won_stage") or stage.get("is_lost_stage"): - return False - return True - - -def _log(msg: str, *, quiet: bool) -> None: - log(SKILL, msg, quiet=quiet) - - -def _score(opp: dict[str, Any], days_silent: int) -> float: - """Rank function: $1k of value ~ 1 day of silence. - - Without this, fully-priced deals dominate the list and unpriced-but- - long-silent leads vanish (a real problem we saw in FinalPiece data, - where most opps have value=null). - """ - value = float(opp.get("value") or 0) - # Floor at 100 so a stale unpriced deal still has some pull. - base = max(100.0, value / 1000.0) - return base * (1 + days_silent / 30.0) - - -def find_silent(opportunities: list[dict[str, Any]], - now: datetime, - silence_days: int) -> list[dict[str, Any]]: - """Return silent opportunities sorted by score (highest first).""" - cutoff_seconds = silence_days * 86400 - silent: list[dict[str, Any]] = [] - for opp in opportunities: - if not _opp_is_active(opp): - continue - # Scheduled next action in the future means it's NOT going quiet - nad = parse_iso(opp.get("next_action_date")) - if nad and nad >= now: - continue - last_touch = parse_iso(opp.get("updated_at")) - if not last_touch: - continue - seconds_since = (now - last_touch).total_seconds() - if seconds_since < cutoff_seconds: - continue - days_silent = int(seconds_since // 86400) - silent.append({ - "id": opp.get("id"), - "name": opp.get("name"), - "value": opp.get("value"), - "currency": opp.get("currency"), - "stage": _opp_stage(opp), - "lead_source": opp.get("lead_source"), - "last_touch": opp.get("updated_at"), - "days_silent": days_silent, - "contact_id": opp.get("contact_id"), - "customer_id": opp.get("customer_id"), - "next_action_name": opp.get("next_action_name"), - "_score": _score(opp, days_silent), - }) - silent.sort(key=lambda x: x["_score"], reverse=True) - return silent - - -def enrich_with_contacts(silent: list[dict[str, Any]], - quiet: bool) -> list[dict[str, Any]]: - """Fetch contact + opportunity-activities for each top-N silent opp in parallel.""" - contact_ids = [s["contact_id"] for s in silent if s.get("contact_id")] - if not contact_ids: - return silent - - _log(f"enriching {len(contact_ids)} contacts in parallel...", quiet=quiet) - contacts_path = resolve_path("contacts", "GET", "get") # /contacts/:id pattern - # contacts_path is "contacts/:contact_id" — we need to substitute - calls = [] - for cid in contact_ids: - path = contacts_path.replace(":contact_id", cid).replace(":id", cid) - calls.append((path, {})) - - results = parallel_get(calls) - contact_lookup: dict[str, dict[str, Any]] = {} - for path, response in results.items(): - if response.get("error"): - continue - c = response.get("data") if isinstance(response.get("data"), dict) else response - if not isinstance(c, dict): - continue - contact_lookup[c.get("id", "")] = c - - enriched: list[dict[str, Any]] = [] - for s in silent: - cid = s.get("contact_id") - contact = contact_lookup.get(cid, {}) if cid else {} - enriched.append({ - **s, - "contact": { - "id": contact.get("id"), - "first_name": contact.get("first_name"), - "last_name": contact.get("last_name"), - "email": contact.get("email"), - "phone": contact.get("phone"), - "job_title": contact.get("job_title"), - "source": contact.get("source"), - "email_unsubscribed": contact.get("email_unsubscribed"), - "sms_unsubscribed": contact.get("sms_unsubscribed"), - } if contact else None, - }) - return enriched - - -def fetch_and_digest(silence_days: int, top_n: int, quiet: bool) -> dict[str, Any]: - now = now_utc() - _log("fetching opportunities...", quiet=quiet) - - opps_path = resolve_path("opportunities") - response = api_get(opps_path, limit=100) - opportunities = response.get("data", []) - _log(f" ok {opps_path}: {len(opportunities)} rows", quiet=quiet) - - silent = find_silent(opportunities, now, silence_days) - _log(f"found {len(silent)} silent opportunities (>{silence_days}d quiet, no scheduled next action)", - quiet=quiet) - - top = silent[:top_n] - if top: - top = enrich_with_contacts(top, quiet=quiet) - - return { - "generated_at": now.isoformat(), - "silence_threshold_days": silence_days, - "total_silent": len(silent), - "returned_top_n": len(top), - "summary_by_source": group_count(silent, "lead_source"), - "summary_by_stage": group_count(silent, "stage"), - "items": top, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description="Follow-up Radar data fetcher") - parser.add_argument("--silence-days", type=int, default=7, - help="Days of inactivity to qualify as 'going quiet' (default 7)") - parser.add_argument("--top", type=int, default=10, - help="How many top-ranked silent opportunities to enrich and return (default 10)") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - digest = fetch_and_digest(args.silence_days, args.top, quiet=args.json_only) - emit_json(digest) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - except KeyboardInterrupt: - emit_error_and_exit("Cancelled", code=130) - - -if __name__ == "__main__": - sys.exit(main()) - - -# ============================================================================= -# Output shape -# ============================================================================= -# -# { -# "generated_at": "2026-05-31T10:00:00+00:00", -# "silence_threshold_days": 7, -# "total_silent": 35, -# "returned_top_n": 10, -# "summary_by_source": { "Facebook": 18, "Referral": 6, ... }, -# "summary_by_stage": { "Not Ready Yet": 12, "Quote Sent": 5, ... }, -# "items": [ -# { -# "id": "", -# "name": "...", -# "value": 12000, -# "currency": "AUD", -# "stage": "Quote Sent", -# "lead_source": "Referral", -# "last_touch": "2026-05-17T...", -# "days_silent": 14, -# "contact_id": "...", -# "customer_id": "...", -# "next_action_name": null, -# "_score": 12.4, -# "contact": { -# "id": "...", "first_name": "...", "last_name": "...", -# "email": "...", "phone": "...", "job_title": "...", -# "email_unsubscribed": false, "sms_unsubscribed": false -# } -# }, -# ... -# ] -# } diff --git a/skills/form-radar/SKILL.md b/skills/form-radar/SKILL.md index 648d865..280a224 100644 --- a/skills/form-radar/SKILL.md +++ b/skills/form-radar/SKILL.md @@ -25,20 +25,41 @@ check-up on every submission. Source of truth: [`knowledge/form-method.md`](../../knowledge/form-method.md) — §4 (the submission lifecycle) and §5 (automating it). -## Step 1 — Fetch the digest +## Step 1 — Pull the data (MCP read) -```bash -python ~/.claude/bos-run.py form-radar -``` +Use the `trustpager` MCP server. One read — free, nothing journaled: + +| Need | Tool | Args | +|---|---|---| +| Every form submission | `list_form_submissions` | `limit: 100` (page with `after` until exhausted) | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +## Step 2 — Build the funnel and the follow-up buckets (digest logic) + +Compute against **now**. For each submission, read its `status` (lowercased) and its **age in days** — from `sent_at`, falling back to `created_at`, then `updated_at`. -One call: lists every submission, computes the funnel, buckets the follow-ups -(started-not-completed, sent-never-opened-stale, recently completed), -oldest-first. `--stale-days N` tunes the stale threshold (default 5). +Tally the funnel by status: +- `viewed` / `opened` → **opened** +- `in_progress` → **in_progress** +- `completed` → **completed** +- `expired` → **expired** +- `voided` → **voided** +- `draft` → **draft** +- `pending` / `sent` → **sent** +- anything else → **other** -Fallback if it can't run: `mcp__trustpager__list_form_submissions` — raw list, -no funnel; prefer the script. +Then bucket the follow-ups: -## Step 2 — Present, follow-ups first +- **Started — not finished** (nudge): status is `viewed`, `opened`, or `in_progress`. These began and stalled. +- **Sent — never opened, going stale** (chase/resend): status is `pending` or `sent` **AND** age ≥ **5 days** (the stale threshold; the operator can ask for a different one). +- **Recently completed**: status `completed` AND age ≤ **7 days**. + +For each row carry: `submission_id`, `template_id`, `template_name` (fall back to "(form)"), `deal_id`, `contact_id`, `status`, and `age_days` (rounded to 1 dp). + +**Sort** both follow-up buckets **oldest-first** (highest age first) — the most overdue follow-ups lead. + +## Step 3 — Present, follow-ups first ``` 📋 22 forms out — 12 completed, 4 started (not finished), 5 sent (unopened), 1 expired @@ -53,10 +74,12 @@ no funnel; prefer the script. ✅ Completed this week: 12 (PDFs auto-archived to their opportunities) ``` -## Step 3 — Offer the follow-up actions (with approval) +## Step 4 — Offer the follow-up actions (with approval) + +These are writes — they follow [`knowledge/safeguards.md`](../../knowledge/safeguards.md): offer, get a yes, do it **one at a time**, journal each to `.bos-journal.md`; if a call returns a `202`/`approval_id`, surface the approvals link and stop. One at a time, with a yes: -- **Resend** an unopened-stale submission → `mcp__trustpager__resend_form_submission(submission_id)`. +- **Resend** an unopened-stale submission → `resend_form_submission(submission_id)`. - **Draft a nudge** to a started-not-finished recipient → hand to `/draft-reply` ("saw you got started on the form — anything I can help with to finish it?"). - **Void** a dead/duplicate submission → `void_form_submission(submission_id)` — @@ -68,7 +91,7 @@ For "nudge automatically whenever someone opens but doesn't finish", hand to ## What to never do - ❌ Don't dump all submissions flat — bucket by follow-up urgency. -- ❌ Don't auto-resend or auto-void — offer, get a yes, one at a time. +- ❌ Don't auto-resend or auto-void — offer, get a yes, one at a time, journal each. - ❌ Don't chase `completed` ones — count them, move on. - ❌ Don't chase a submission the operator voided on purpose. diff --git a/skills/form-radar/fetch.py b/skills/form-radar/fetch.py deleted file mode 100644 index 3e0bbe5..0000000 --- a/skills/form-radar/fetch.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""form-radar — pull every form submission into one funnel + follow-up digest. - -Owners send forms and lose track of who filled them. This fetcher returns a -single JSON document Claude turns into a follow-up report: the sent → opened → -completed funnel, plus the follow-up buckets — "opened/started but not -completed" (stalled, nudge) and "sent but never opened, going stale" (chase). - -Read-only (list_form_submissions). Auth: TRUSTPAGER_API_KEY env var or -~/.claude/bos.json. - -Usage: - python skills/form-radar/fetch.py - python skills/form-radar/fetch.py --stale-days 5 - python skills/form-radar/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parse_iso, days_since, resolve_path, -) - -SKILL = "form-radar" - -STARTED_NOT_DONE = {"viewed", "opened", "in_progress"} -DONE = {"completed"} -DEAD = {"expired", "voided"} - - -def _age_days(sub: dict, now) -> float | None: - ts = sub.get("sent_at") or sub.get("created_at") or sub.get("updated_at") - dt = parse_iso(ts) if ts else None - return days_since(dt, now) if dt else None - - -def fetch(stale_days: int, quiet: bool) -> dict: - now = now_utc() - log(SKILL, "listing form submissions...", quiet=quiet) - - subs = list(paginate(resolve_path("forms/submissions"), limit=100, max_pages=20)) - - funnel = {"sent": 0, "opened": 0, "in_progress": 0, "completed": 0, - "expired": 0, "voided": 0, "draft": 0, "other": 0} - started_not_completed: list[dict] = [] - sent_never_opened_stale: list[dict] = [] - recently_completed: list[dict] = [] - - for sub in subs: - status = (sub.get("status") or "").lower() - age = _age_days(sub, now) - row = { - "submission_id": sub.get("id"), - "template_id": sub.get("template_id"), - "template_name": sub.get("template_name") or "(form)", - "deal_id": sub.get("deal_id"), - "contact_id": sub.get("contact_id"), - "status": status, - "age_days": round(age, 1) if age is not None else None, - } - - if status in ("viewed", "opened"): - funnel["opened"] += 1 - started_not_completed.append(row) - elif status == "in_progress": - funnel["in_progress"] += 1 - started_not_completed.append(row) - elif status == "completed": - funnel["completed"] += 1 - if age is not None and age <= 7: - recently_completed.append(row) - elif status == "expired": - funnel["expired"] += 1 - elif status == "voided": - funnel["voided"] += 1 - elif status == "draft": - funnel["draft"] += 1 - elif status in ("pending", "sent"): - funnel["sent"] += 1 - if age is not None and age >= stale_days: - sent_never_opened_stale.append(row) - else: - funnel["other"] += 1 - - started_not_completed.sort(key=lambda r: r["age_days"] or 0, reverse=True) - sent_never_opened_stale.sort(key=lambda r: r["age_days"] or 0, reverse=True) - - return { - "skill": SKILL, - "generated_at": now.isoformat(), - "stale_days_threshold": stale_days, - "total_submissions": len(subs), - "funnel": funnel, - "started_not_completed": started_not_completed, # nudge — they began and stalled - "sent_never_opened_stale": sent_never_opened_stale, # chase or resend - "recently_completed": recently_completed, - } - - -def main() -> None: - force_utf8_stdout() - ap = argparse.ArgumentParser(description="Form submission funnel + follow-up digest") - ap.add_argument("--stale-days", type=int, default=5, - help="Flag 'sent but never opened' once older than this many days (default 5)") - ap.add_argument("--json-only", action="store_true", help="Suppress progress logs") - args = ap.parse_args() - try: - emit_json(fetch(args.stale_days, quiet=args.json_only)) - except BOSError as e: - emit_error_and_exit(e) - - -if __name__ == "__main__": - main() diff --git a/skills/import-from-anywhere/SKILL.md b/skills/import-from-anywhere/SKILL.md index ca49506..cb93077 100644 --- a/skills/import-from-anywhere/SKILL.md +++ b/skills/import-from-anywhere/SKILL.md @@ -30,19 +30,28 @@ After the user pastes, identify the shape: If ambiguous, ASK: > "I can see roughly 40 rows. Are these meant to land as contacts, opportunities, or companies?" -## Step 1.5 — Build the dedup baseline (in parallel with parsing) +## Step 1.5 — Build the dedup baseline (parallel MCP reads) -While the user is reviewing the paste, run: +While the user reviews the paste, pull the existing records from the `trustpager` MCP server so you can flag duplicates BEFORE writing anything. All reads — free, nothing journaled: -``` -python ~/.claude/bos-run.py import-from-anywhere -``` +| Need | Tool | Args | +|---|---|---| +| Existing contacts | `list_contacts` | `limit: 100` (page with `after` until exhausted, up to ~200) | +| Existing companies | `list_customers` | `limit: 100` (page with `after`, up to ~200) | +| Existing open opportunities | `list_deals` | `status: "open"`, `limit: 100` (page with `after`, up to ~200) | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +Build an in-memory index from the results and hold it for the preview: +- **Contacts** keyed by: lowercased `email`; normalised `phone` (strip everything except digits and `+`, keep the last 12 chars); and a `first|last|companysuffix` key (lowercased first+last + the last 6 chars of `company_id`). +- **Companies** keyed by: lowercased `name`; and `website` **domain** (strip `https://` / `http://` / `www.`, take everything before the first `/` or `?`). +- **Open opportunities** keyed by: lowercased `name`. -This returns an index of every existing contact (by email + phone + name+company), every existing company (by name + domain), and every open opportunity (by name). Hold this in memory and check each parsed row against it during preview. +This is the search-first rail ([`knowledge/safeguards.md`](../../knowledge/safeguards.md)) at bulk scale — one baseline fetch instead of N "is this a duplicate?" lookups per row. ## Step 2 — Show what you parsed BEFORE writing anything -Build a preview table of the first 5 rows with the fields you've extracted. Show: +Build a preview table of the first 5 rows with the fields you've extracted. Check each parsed row against the dedup index from Step 1.5 and surface matches. Show: ``` Detected: 38 contacts @@ -55,23 +64,26 @@ Preview: Issues found: - 3 rows have no email (rows 4, 11, 27) - 1 row has an unparseable phone (row 18: "see card") - - 2 rows look like duplicates of existing contacts + - 2 rows look like duplicates of existing contacts (matched on email — rows 7, 22) ``` -Run the duplicate detection by calling `python tools/audit-contacts.py --json` and matching against the parsed paste — show the user which rows are likely already in their workspace. - ASK: > "OK to proceed? You can also tell me to skip the rows with issues, or to merge with existing rather than create new." ## Step 3 — Import in batches with progress +These are writes — they follow [`knowledge/safeguards.md`](../../knowledge/safeguards.md): no write before the preview is approved; journal each bulk write as one line to `.bos-journal.md`; if a call returns a `202`/`approval_id`, surface the approvals link and stop (don't retry). + After explicit go: -- Use `mcp__trustpager__bulk_create_contacts` (or opportunities / companies) in batches of 50. -- Stream progress to the user: "Importing 38 contacts… 25/38 done… 38/38 done." -- If a batch fails: print the error and ask whether to continue with remaining batches or stop. +- Use `bulk_create_contacts` (or `bulk_create_deals` / `bulk_create_customers`) in batches of up to **100** records per call (the bulk tools cap at 100). Each returns `created[]` + `errors[]` for partial-success retry. +- **Set `skip_automations: true`** on historical imports so old records don't fire `*_created` automation emails — strongly recommended for any back-catalogue load. +- Stream progress to the user: "Importing 38 contacts… batch 1/1 done… 38/38." +- If a batch returns errors: print them and ask whether to continue with remaining batches or stop. For opportunities: each one needs a pipeline + stage. Ask once up front: -> "Which pipeline should these land in? (current options: [list_pipelines]) — and which stage?" +> "Which pipeline should these land in? (I'll pull the options with `list_pipelines`) — and which stage?" + +Pass the chosen `pipeline_id` / `stage_id` as the bulk-level default (each record can still override). ## Step 4 — Report @@ -79,18 +91,18 @@ End with: ``` Imported 38 contacts into TrustPager. ✅ 35 created cleanly. -⚠️ 2 skipped as likely duplicates (you can review at /settings/...). +⚠️ 2 skipped as likely duplicates (matched existing records — see preview). ❌ 1 failed: row 18 had an unparseable phone number. ``` ## Important behaviours - **NEVER write without showing the preview first.** Even for 3 rows. Importing the wrong shape is hard to undo. -- **No silent dedup.** If a row matches an existing record, surface it — don't auto-merge. +- **No silent dedup.** If a row matches an existing record (per the Step 1.5 index), surface it — don't auto-merge. - **Names are not contacts.** "Sarah from Acme" with no other detail = ask, don't import a half-record. - **Phone normalization.** Aussie phones get normalized to E.164 (+61...) before writing. -- **The paste itself is data.** Save the original paste as a note on each created record so the source is traceable. -- **Spreadsheets are different.** If the user wants a SPREADSHEET row dump (not records), use `mcp__trustpager__bulk_append_spreadsheet_rows` instead and pick or create the target spreadsheet. +- **The paste itself is data.** Save the original paste as a note on each created record (the `notes` field) so the source is traceable. +- **Spreadsheets are different.** If the user wants a SPREADSHEET row dump (not CRM records), append rows with `append_spreadsheet_row` — **one row per call** (there's no bulk row-append on the client tool surface; loop the rows and stream progress). Pick or create the target spreadsheet first; cells are keyed by column ID, not header name (get IDs from `get_spreadsheet`). ## Edge cases diff --git a/skills/import-from-anywhere/fetch.py b/skills/import-from-anywhere/fetch.py deleted file mode 100644 index 55bd5f8..0000000 --- a/skills/import-from-anywhere/fetch.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""import-from-anywhere — compute the dedup baseline before any import. - -When the user pastes a list to import, we want to detect duplicates -against existing records BEFORE writing anything. This script pre-builds -the lookup index: - -- All existing contacts indexed by lowercased email + normalised phone + - (first+last+company) key. -- All existing companies indexed by lowercased name + website domain. -- All open opportunities indexed by name (lowercased). - -Output is a JSON document the skill loads in memory and consults for -each row of the paste. One bulk fetch instead of N "is this duplicate?" -lookups per row. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/import-from-anywhere/fetch.py -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, resolve_path, -) - - -SKILL = "import-from-anywhere" - - -def _normalize_phone(raw: str | None) -> str | None: - if not raw: - return None - s = "".join(c for c in raw if c.isdigit() or c == "+") - return s[-12:] if s else None - - -def _domain_of(url: str | None) -> str | None: - if not url: - return None - s = url.lower().strip() - for prefix in ("https://", "http://", "www."): - if s.startswith(prefix): - s = s[len(prefix):] - return s.split("/")[0].split("?")[0] or None - - -def fetch(quiet: bool) -> dict[str, Any]: - now = now_utc() - log(SKILL, "building dedup baseline...", quiet=quiet) - - calls = [ - (resolve_path("contacts"), {"limit": 200}), - (resolve_path("companies"), {"limit": 200}), - (resolve_path("opportunities"), {"limit": 200, "status": "open"}), - ] - results = parallel_get(calls) - contacts = results.get(resolve_path("contacts"), {}).get("data", []) - companies = results.get(resolve_path("companies"), {}).get("data", []) - opportunities = results.get(resolve_path("opportunities"), {}).get("data", []) - - by_email: dict[str, str] = {} - by_phone: dict[str, str] = {} - by_name_company: dict[str, str] = {} - for c in contacts: - cid = c.get("id") - if not cid: - continue - email = (c.get("email") or "").strip().lower() - if email: - by_email[email] = cid - phone = _normalize_phone(c.get("phone")) - if phone: - by_phone[phone] = cid - first = (c.get("first_name") or "").strip().lower() - last = (c.get("last_name") or "").strip().lower() - co = (c.get("company_id") or "")[-6:] - if first and last: - by_name_company[f"{first}|{last}|{co}"] = cid - - co_by_name: dict[str, str] = {} - co_by_domain: dict[str, str] = {} - for co in companies: - cid = co.get("id") - if not cid: - continue - name = (co.get("name") or "").strip().lower() - if name: - co_by_name[name] = cid - dom = _domain_of(co.get("website")) - if dom: - co_by_domain[dom] = cid - - opp_by_name: dict[str, str] = {} - for o in opportunities: - oid = o.get("id") - n = (o.get("name") or "").strip().lower() - if oid and n: - opp_by_name[n] = oid - - return { - "generated_at": now.isoformat(), - "headline": { - "contacts_sampled": len(contacts), - "companies_sampled": len(companies), - "opportunities_sampled": len(opportunities), - }, - "contacts": { - "by_email": by_email, - "by_phone": by_phone, - "by_name_company": by_name_company, - }, - "companies": { - "by_name": co_by_name, - "by_domain": co_by_domain, - }, - "open_opportunities": { - "by_name": opp_by_name, - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/import-from-anywhere/test-fixture.json b/skills/import-from-anywhere/test-fixture.json deleted file mode 100644 index 529b8e0..0000000 --- a/skills/import-from-anywhere/test-fixture.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "responses": { - "contacts": { - "data": [ - {"id": "c-1", "first_name": "Test", "last_name": "Existing", "email": "existing@example.com", "phone": "+61400000001", "company_id": "co-1"} - ], - "pagination": {"has_more": false} - }, - "companies": { - "data": [{"id": "co-1", "name": "Test Existing Pty Ltd", "website": "https://existing.example.com"}], - "pagination": {"has_more": false} - }, - "opportunities": { - "data": [{"id": "opp-1", "name": "Existing open opportunity", "status": "open"}], - "pagination": {"has_more": false} - } - } -} diff --git a/skills/lead-triage/SKILL.md b/skills/lead-triage/SKILL.md index fde828d..62ddc83 100644 --- a/skills/lead-triage/SKILL.md +++ b/skills/lead-triage/SKILL.md @@ -16,57 +16,85 @@ triggers: Inbound leads have a half-life. The ones who heard back inside the hour convert at roughly 5× the rate of ones who got a same-day response. This skill exists to clear the backlog of new leads fast — classify each, score by fit, draft the right first response, and tee them up for your approval. -## Step 1 — Pull the leads +## Step 1 — Pull the data (parallel MCP calls) -Run the fetch script. It returns every new lead in the last N hours (default 48), enriched with: -- Source (form submission, inbound email, inbound SMS, inbound call, manual entry) -- Whether they have an opportunity yet -- Contact details (email, phone, company) -- Initial message / form payload -- A fit score (0-100) based on completeness, source quality, and message length -- Recency +Fire these **four read calls in parallel** in a single batch — they're all reads, so they're free and fast. Use the `trustpager` MCP server. Pull the most recent records and filter to the window yourself in Step 2. -``` -python ~/.claude/bos-run.py lead-triage -python ~/.claude/bos-run.py lead-triage --hours 24 # tighter window -``` +| Need | Tool | Args | +|---|---|---| +| New form submissions | `list_form_submissions` | `limit: 100` | +| Inbound email threads (first message, not yet replied) | `list_email_threads` | `limit: 100` | +| Inbound SMS conversations (no outbound yet) | `list_sms_conversations` | `limit: 100` | +| Opportunities created in window (open status) | `list_deals` | `limit: 100` | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +If one call errors, note it briefly and proceed with the sources you have — don't bail on the whole triage. + +## Step 2 — Build the lead list + +Everything below is computed against **now**. Default window is the **last 48 hours** (the operator can ask for a tighter 24h window). Build one lead record per inbound, pulling from each source: + +- **Form submissions** — keep those created within the window. Read the submission payload for a message: take the first non-empty of `message` / `notes` / `comments` / `enquiry` / `details` / `description`. Capture first/last name (fall back to a single `name` field), email, phone, company, job title, the form name, and any linked contact/opportunity id. Source = `form`. +- **Inbound email threads** — keep those within the window where **we have not replied yet** (drop any thread already replied to). Take the latest message's plain text (or subject) as the message. Capture the contact's name, email (fall back to the from-address), phone, company, the subject, and any linked opportunity id. Source = `email`. +- **Inbound SMS conversations** — keep those within the window with **no outbound message yet** (drop any where we've already replied / outbound count > 0). Take the first inbound body as the message. Capture the contact name, email, phone (fall back to the from-number), and any linked opportunity id. Source = `sms`. +- **Opportunities created in the window** — keep open ones created within the window. **Skip any whose opportunity id was already captured** by a form/email/SMS lead above (no duplicates). Use the opportunity name as the lead name and its description as the message. Source = `manual`. -## Step 2 — Classify each lead +## Step 3 — Score each lead (0-100) -For each lead, propose a category: +Add up these points, then cap at 100: -| Category | Definition | Default response | +| Signal | Points | +|---|---| +| Has a phone number | +25 | +| Has an email | +15 | +| Has a company OR job title | +15 | +| Message length ≥ 80 chars | +20 (or +10 if ≥ 30 chars but < 80) | +| Source quality | form +25, email +18, sms +10, call +8, manual +5, unknown +0 | + +## Step 4 — Classify each lead + +Apply in this order: + +1. **Disqualify first** — if the message (lowercased) contains any spam signal: `seo services`, `web design`, `partnership opportunity`, `increase your traffic`, `guest post`, `backlink` → category = **disqualify**, regardless of score. +2. Otherwise: score **≥ 70** → **fast_track**; score **40-69** → **nurture**; score **< 40** → **cold**. + +| Category | Meaning | Default response | |---|---|---| -| 🔥 **Fast track** | Has phone, has detailed message, source is form/inbound-email, score ≥ 70 | Personal SMS + email within minutes. Offer call within 24h. | -| 🌱 **Nurture** | Has email but limited detail, score 40-69 | Auto-templated email asking 2-3 qualifying questions. | -| 🧊 **Cold / unclear** | Score < 40 — no contact info beyond name, vague message | Light-touch email: "Got your enquiry, can you tell us a bit more about what you're after?" | -| 🚫 **Disqualify** | Spam patterns, wrong industry, asking for partnership/SEO services, etc. | Don't respond. Optionally archive. | +| 🔥 **Fast track** | Has phone, detailed message, form/inbound-email, score ≥ 70 | Personal SMS + email within minutes. Offer a call within 24h. | +| 🌱 **Nurture** | Has email but limited detail, score 40-69 | Templated email asking 2-3 qualifying questions. | +| 🧊 **Cold / unclear** | Score < 40 — no contact info beyond a name, vague message | Light-touch email: "Got your enquiry — can you tell us a bit more about what you're after?" | +| 🚫 **Disqualify** | Spam patterns, wrong industry, SEO/partnership pitches | Don't respond. Optionally mark lost. | -Show the classification + reasoning to the user. The user can override per lead. +**Rank the list by score descending, then most-recent first.** Show the classification + reasoning to the operator. The operator can override per lead. -## Step 3 — Draft the per-lead response +## Step 5 — Draft the per-lead response After classification is agreed, draft the message for each lead. The draft should: -- Use the lead's first name (never "Hi there", never "Hi friend") -- Reference something concrete from their enquiry (their stated need, their company name, their question) — this is the moat against generic templates -- End with a single clear next step ("Are you free Wed or Thurs morning for a 15-min call?", not "Looking forward to hearing from you!") -- Match the tone of the workspace — read recent sent emails to calibrate before drafting +- Use the lead's first name (never "Hi there", never "Hi friend"). +- Reference something concrete from their enquiry (their stated need, company name, their question) — this is the moat against generic templates. +- End with a single clear next step ("Are you free Wed or Thurs morning for a 15-min call?", not "Looking forward to hearing from you!"). +- Match the tone of the workspace — read recent sent emails to calibrate before drafting. + +## Step 6 — Send with approval (per lead) -## Step 4 — Send with approval (per lead) +Writes here are outward-facing — follow the rails in `knowledge/safeguards.md`: **show the draft, wait for approval, then journal the write to `.bos-journal.md`**, and **search first** so you never double-send. For each draft: -- Show the user: lead name, category, channel, the proposed message. -- Wait for explicit yes/no per lead. NEVER batch-send. -- On yes: send via `mcp__trustpager__send_email` or `send_sms` for the per-lead channel. +- Show the operator: lead name, category, channel, the proposed message. +- Wait for explicit yes/no **per lead**. NEVER batch-send. +- Before sending, do a quick `search_contacts` / `list_sms_conversations` / `list_email_threads` check to confirm a response hasn't already gone out (idempotency — never blind-send). +- On yes: send via `send_email` or `send_sms` for the per-lead channel (on the `trustpager` MCP server). If a write comes back `202` / `approval_id`, surface the approvals link and stop — don't retry (safeguards §1). +- After each send, append one line to `.bos-journal.md` (timestamp, tool, outcome, id, `skill: lead-triage`). - On no: ask what to change, or skip. ## Important behaviours -- **Create the opportunity first if missing.** Every fast-track lead becomes an opportunity in your pipeline before the message goes out. The skill should call `create_opportunity` with the right pipeline + stage + contact link. -- **Disqualify ≠ delete.** Mark with a "lost" status + reason so you can review later. Never delete. +- **Create the opportunity first if missing.** Every fast-track lead becomes an opportunity in the pipeline before the message goes out — call `create_deal` (it's the opportunity-create tool) with the right pipeline + stage + contact link. Journal it. +- **Disqualify ≠ delete.** Mark with a lost status + reason via `update_deal` so it can be reviewed later. Never delete. - **Spam heuristics.** Free email + generic body + "SEO services / web design proposal / partnership opportunity" = disqualify by default. -- **Don't promise specifics you don't know.** "Our standard package starts at $X" — only if the user told you the figure or it's in workspace knowledge. -- **Quiet hours.** Same as missed-call-recovery — before 7am / after 8pm = email not SMS. +- **Don't promise specifics you don't know.** "Our standard package starts at $X" — only if the operator told you the figure or it's in workspace knowledge. +- **Quiet hours.** Before 7am / after 8pm in the recipient's timezone (or unknown) → email, not SMS. ## Output shape diff --git a/skills/lead-triage/fetch.py b/skills/lead-triage/fetch.py deleted file mode 100644 index 08b46f3..0000000 --- a/skills/lead-triage/fetch.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python3 -"""Lead triage — find new inbound leads + score by fit + propose category. - -Pulls every new lead from the last N hours across all inbound sources: -- Form submissions (new ones, not yet processed) -- Inbound email threads (first message, not yet replied) -- Inbound SMS conversations (no outbound response yet) -- Opportunities created in window with status "lead" / "new" - -For each lead, computes a fit score 0-100 based on: -- Has phone number (+25) -- Has email (+15) -- Has company / job title (+15) -- Message length ≥ 80 chars (+20) -- Source quality (form > email > sms > unknown) (0-25) - -Returns one record per lead, ranked by score descending. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/lead-triage/fetch.py - python skills/lead-triage/fetch.py --hours 24 -""" - -from __future__ import annotations - -import argparse -import sys -from datetime import timedelta -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, parse_iso, resolve_path, -) - - -SKILL = "lead-triage" - - -def _score(lead: dict[str, Any]) -> int: - s = 0 - if lead.get("phone"): - s += 25 - if lead.get("email"): - s += 15 - if lead.get("company") or lead.get("job_title"): - s += 15 - msg = lead.get("message") or "" - if len(msg) >= 80: - s += 20 - elif len(msg) >= 30: - s += 10 - src = lead.get("source") or "" - src_quality = {"form": 25, "email": 18, "sms": 10, "call": 8, "manual": 5}.get(src, 0) - s += src_quality - return min(s, 100) - - -def _category(score: int, msg: str) -> str: - msg_lower = (msg or "").lower() - spam_signals = [ - "seo services", "web design", "partnership opportunity", - "increase your traffic", "guest post", "backlink", - ] - if any(sig in msg_lower for sig in spam_signals): - return "disqualify" - if score >= 70: - return "fast_track" - if score >= 40: - return "nurture" - return "cold" - - -def fetch_and_digest(hours: int, quiet: bool) -> dict[str, Any]: - now = now_utc() - cutoff = now - timedelta(hours=hours) - cutoff_iso = cutoff.isoformat() - - log(SKILL, f"fetching inbound leads from last {hours}h...", quiet=quiet) - - paths = { - "form_submissions": resolve_path("forms", path_contains="submissions"), - "email_threads": resolve_path("email", path_contains="threads"), - "sms_convos": resolve_path("sms", path_contains="conversations"), - "opportunities": resolve_path("opportunities"), - } - - calls = [ - (paths["form_submissions"], {"limit": 100, "after": cutoff_iso}), - (paths["email_threads"], {"limit": 100, "after": cutoff_iso, "direction": "inbound"}), - (paths["sms_convos"], {"limit": 100, "after": cutoff_iso}), - (paths["opportunities"], {"limit": 100, "after": cutoff_iso, "status": "open"}), - ] - results = parallel_get(calls) - - leads: list[dict[str, Any]] = [] - - # Form submissions - for fs in results.get(paths["form_submissions"], {}).get("data", []): - data = fs.get("form_data") or fs.get("submission_data") or {} - # Build a flat message from any "message", "notes", "comments" fields - message_keys = ["message", "notes", "comments", "enquiry", "details", "description"] - msg = "" - for k in message_keys: - if data.get(k): - msg = str(data[k]) - break - leads.append({ - "kind": "form_submission", - "source": "form", - "id": fs.get("id"), - "received_at": fs.get("created_at"), - "first_name": data.get("first_name") or data.get("name") or "", - "last_name": data.get("last_name") or "", - "email": data.get("email") or fs.get("contact_email"), - "phone": data.get("phone") or fs.get("contact_phone"), - "company": data.get("company") or "", - "job_title": data.get("job_title") or "", - "message": msg, - "form_name": fs.get("form_name") or fs.get("template_name"), - "contact_id": fs.get("contact_id"), - "opportunity_id": fs.get("deal_id") or fs.get("opportunity_id"), - }) - - # Email threads — inbound, no reply yet - for et in results.get(paths["email_threads"], {}).get("data", []): - replied = et.get("replied") or et.get("we_replied") - if replied: - continue - latest = et.get("latest_message") or {} - leads.append({ - "kind": "email_thread", - "source": "email", - "id": et.get("id"), - "received_at": et.get("created_at") or et.get("last_inbound_at"), - "first_name": (et.get("contact") or {}).get("first_name", ""), - "last_name": (et.get("contact") or {}).get("last_name", ""), - "email": (et.get("contact") or {}).get("email") or et.get("from_email"), - "phone": (et.get("contact") or {}).get("phone"), - "company": (et.get("contact") or {}).get("company_name", ""), - "job_title": "", - "message": latest.get("plain_text") or latest.get("subject") or "", - "subject": et.get("subject"), - "contact_id": (et.get("contact") or {}).get("id"), - "opportunity_id": et.get("deal_id"), - }) - - # SMS conversations — inbound, no outbound yet - for sc in results.get(paths["sms_convos"], {}).get("data", []): - if sc.get("we_replied") or (sc.get("outbound_count") or 0) > 0: - continue - leads.append({ - "kind": "sms_conversation", - "source": "sms", - "id": sc.get("id"), - "received_at": sc.get("created_at") or sc.get("last_inbound_at"), - "first_name": (sc.get("contact") or {}).get("first_name", ""), - "last_name": (sc.get("contact") or {}).get("last_name", ""), - "email": (sc.get("contact") or {}).get("email"), - "phone": (sc.get("contact") or {}).get("phone") or sc.get("from_phone"), - "company": "", - "job_title": "", - "message": sc.get("first_message_body") or sc.get("last_inbound_body") or "", - "contact_id": (sc.get("contact") or {}).get("id"), - "opportunity_id": sc.get("deal_id"), - }) - - # Opportunities created in window without prior contact — handled as "manual" leads - for op in results.get(paths["opportunities"], {}).get("data", []): - created = parse_iso(op.get("created_at")) - if not created or created < cutoff: - continue - if op.get("contact_id"): - # Skip if a form/email/sms above already captured this opp - if any(l.get("opportunity_id") == op.get("id") for l in leads): - continue - leads.append({ - "kind": "opportunity", - "source": "manual", - "id": op.get("id"), - "received_at": op.get("created_at"), - "first_name": "", - "last_name": op.get("name") or "", - "email": None, - "phone": None, - "company": "", - "job_title": "", - "message": op.get("description") or "", - "contact_id": op.get("contact_id"), - "opportunity_id": op.get("id"), - }) - - # Score + categorize - for lead in leads: - lead["score"] = _score(lead) - lead["category"] = _category(lead["score"], lead.get("message", "")) - - leads.sort(key=lambda l: (l["score"], l.get("received_at") or ""), reverse=True) - - by_category: dict[str, int] = {"fast_track": 0, "nurture": 0, "cold": 0, "disqualify": 0} - for l in leads: - by_category[l["category"]] = by_category.get(l["category"], 0) + 1 - - return { - "generated_at": now.isoformat(), - "window_hours": hours, - "headline": { - "total_leads": len(leads), - "by_category": by_category, - "by_source": { - "form": sum(1 for l in leads if l["source"] == "form"), - "email": sum(1 for l in leads if l["source"] == "email"), - "sms": sum(1 for l in leads if l["source"] == "sms"), - "manual": sum(1 for l in leads if l["source"] == "manual"), - }, - "with_opportunity": sum(1 for l in leads if l.get("opportunity_id")), - "without_opportunity": sum(1 for l in leads if not l.get("opportunity_id")), - }, - "items": leads, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--hours", type=int, default=48, - help="How far back to look (default 48 hours)") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - digest = fetch_and_digest(args.hours, quiet=args.json_only) - emit_json(digest) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/lead-triage/test-fixture.json b/skills/lead-triage/test-fixture.json deleted file mode 100644 index 4bae89d..0000000 --- a/skills/lead-triage/test-fixture.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "_doc": "Fixture for lead-triage. One of each source kind + a spammy one to verify disqualify.", - - "catalog": { "_use_live": true }, - - "responses": { - "forms/submissions": { - "data": [ - { - "id": "fs-1", - "created_at": "2026-05-31T08:00:00Z", - "form_name": "Quote request", - "form_data": { - "first_name": "Test", - "last_name": "Lead-A", - "email": "lead-a@example.com", - "phone": "+61400000010", - "company": "Acme Pty Ltd", - "message": "We're looking at refinancing 4 commercial properties totaling $12m. Can we get a quote with two-week turnaround? Need someone who's done construction loans before." - }, - "contact_id": null, - "deal_id": null - } - ], - "pagination": {"has_more": false} - }, - "email/threads": { - "data": [ - { - "id": "et-1", - "created_at": "2026-05-31T07:00:00Z", - "subject": "SEO services proposal", - "we_replied": false, - "contact": {"first_name": "Spam", "last_name": "Bot", "email": "spammer@gmail.com"}, - "latest_message": {"plain_text": "Hi, I offer SEO services to increase your traffic with backlinks..."} - } - ], - "pagination": {"has_more": false} - }, - "sms/conversations": {"data": [], "pagination": {"has_more": false}}, - "opportunities": {"data": [], "pagination": {"has_more": false}} - } -} diff --git a/skills/learn-my-business/SKILL.md b/skills/learn-my-business/SKILL.md index eb55f17..86ace2a 100644 --- a/skills/learn-my-business/SKILL.md +++ b/skills/learn-my-business/SKILL.md @@ -19,21 +19,22 @@ not knowing their pipeline, products, or stages. This skill is the front door that removes that step: it reads the live workspace and writes a filled, accurate `CLAUDE.md` for them, with any industry-specific gotchas folded in. -## Step 1 — Read the workspace shape +## Step 1 — Read the workspace shape (MCP calls) -```bash -python ~/.claude/bos-run.py learn-my-business -``` +Use the `trustpager` MCP server. Everything here is a read — free, nothing journaled, no approval. Fire what you can in parallel. + +| Need | Tool | Args / notes | +|---|---|---| +| Company profile + brand | `get_company` | Returns name, industry, website, city/country, branding/primary colour, description, timezone. (Note: `get_company_profile` is a *different* tool — the public reputation page — don't use it for this.) | +| Pipelines | `list_pipelines` | `limit: 100` | +| Stages for each pipeline | `list_pipeline_stages` | one call per pipeline id — stages are **not** inline on the pipeline list; fetch them per pipeline (in parallel) and order by `position` | +| Products + prices | `list_products` | `limit: 100` — capture name, price/unit_price, currency, billing interval | +| Lead sources, opportunity types, lost/won reasons | `get_crm_settings` | read `lead_sources`, `opportunity_type_options`, `lost_reasons`, `won_reasons` | +| Rough record counts | `list_deals`, `list_contacts`, `list_customers`, `list_automations` | `limit: 100` each — report the page count, or "100+" if the response indicates more pages | -Returns the real shapes: company profile + brand, every pipeline with its -actual stage names, products with prices, lead sources, opportunity types, -lost/won reasons, and rough record counts. It's best-effort — check `warnings` -and `_sources`; for anything that came back `unavailable`, ask the operator one -short question rather than guessing (e.g. company-profile endpoints vary, so you -may need to ask "what's your business name and what do you do?"). +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". (`list_deals` = opportunities, `list_customers` = companies/accounts.) -**Fallback if the script can't run:** `list_pipelines` + `list_pipeline_stages`, -`list_products`, `get_company_profile`, `get_crm_settings`. +This is best-effort. If any call errors or comes back empty (company-profile and crm-settings shapes vary by workspace), don't guess — note it and ask the operator one short question instead (e.g. "what's your business name and what do you do?"). ## Step 2 — Load the structure + the industry gotchas @@ -49,7 +50,7 @@ using `company.industry` and the pipeline shape: - nothing fits → use the generic template as-is and ask one or two short questions about their pipeline quirks and comms style. -If the industry is ambiguous or `unavailable`, **ask one short question** +If the industry is ambiguous or couldn't be read, **ask one short question** ("what would you call your line of work?") rather than guessing the section. Pull that section's **gotchas** and **comms style** into the file you write — but treat them as industry patterns to confirm, never as facts read from the @@ -57,14 +58,14 @@ workspace (see hard rules). ## Step 3 — Write the CLAUDE.md -Fill every `<<< ... >>>` from the digest: +Fill every `<<< ... >>>` from the data you pulled: -- **My business** — name, city/country, description from `company`. If industry +- **My business** — name, city/country, description from the company profile. If industry is known, say it. -- **Products / services** — real product names + prices from `products`. -- **Pipeline** — the actual `stages` of the default pipeline, in order. If there +- **Products / services** — real product names + prices from `list_products`. +- **Pipeline** — the actual stages of the default pipeline, in order. If there are several pipelines, list the primary one's stages and name the others. -- **Lead sources** — tick the boxes that match `settings.lead_sources`. +- **Lead sources** — tick the boxes that match `crm_settings.lead_sources`. - **Ideal customer / tone** — leave as a short prompt for the operator to confirm; you can draft a first guess from the industry, but flag it as a guess. @@ -75,6 +76,24 @@ Then **write it to `./CLAUDE.md` in the operator's project folder.** what differs from the current one, and ask before replacing. (Their existing file may have hand-tuned voice/rules worth keeping — merge, don't clobber.) +## Step 3b — Create the memory store + +So Claude can remember things about this business from the next session on, make +sure the memory store exists. If `./.bos-memory/MEMORY.md` does NOT exist, create +the folder and write a starter index: + +```markdown +# Memory Index + +One line per memory. Files live alongside this one (`.md`), one fact each. +Claude reads this index at the start of every session and recalls a file when +its description is relevant. Add memories with `/remember`. +``` + +Don't seed it with guesses — leave it empty. The model is in +`knowledge/memory-and-feedback.md`. (If `./.bos-memory/MEMORY.md` already exists, +leave it untouched.) + ## Step 4 — Confirm + close gaps Show the operator a tight summary: @@ -92,13 +111,14 @@ Two things I couldn't read and guessed — please confirm: ``` End by telling them: "Claude will use this from your next session. Re-run -`/learn-my-business` whenever your pipeline, products, or brand change." +`/learn-my-business` whenever your pipeline, products, or brand change. I'll also +remember things as we work — tell me to remember anything with `/remember`." ## Hard rules - ❌ Don't overwrite an existing `CLAUDE.md` without showing the diff and asking. - ❌ Don't invent products, prices, or stages — use the real ones from the - workspace. If a section is `unavailable`, ask one question; don't fabricate. + workspace. If a section couldn't be read, ask one question; don't fabricate. - ❌ Don't fill the "ideal customer" / "tone" sections as if they were read from data — they're your guess; label them for confirmation. - ✅ Keep `templates/CLAUDE.md`'s structure (incl. the "About TrustPager" block) intact, and fold in the matched industry section's gotchas + comms style. diff --git a/skills/learn-my-business/fetch.py b/skills/learn-my-business/fetch.py deleted file mode 100644 index ad4aa7a..0000000 --- a/skills/learn-my-business/fetch.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -"""learn-my-business — read the shape of the operator's workspace into one digest. - -Instead of asking a non-technical operator to hand-fill the <<< ... >>> blanks -in a template, this reads the live workspace and returns the -real shapes Claude needs to WRITE their CLAUDE.md for them: company profile + -brand, pipelines and their stages, products, lead sources, opportunity types, -lost/won reasons, and rough record counts. - -Read-only. Every section is best-effort — the company-profile and crm-settings -endpoints vary by workspace, so anything unreachable lands in `warnings` and -`_sources`, and the digest is emitted with the rest. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/learn-my-business/fetch.py - python skills/learn-my-business/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parallel_get, resolve_path, -) - -SKILL = "learn-my-business" - -COMPANY_RESOURCE_CANDIDATES = ["company-profile", "company", "companies", "workspace"] -SETTINGS_RESOURCE_CANDIDATES = ["crm-settings", "settings", "crm_settings"] - - -def _resolve_any(candidates: list[str], **kw: Any) -> str | None: - for rid in candidates: - try: - return resolve_path(rid, **kw) - except BOSError: - continue - return None - - -def _stages_of(pipeline: dict[str, Any]) -> list[str]: - stages = (pipeline.get("crm_pipeline_stages") or pipeline.get("stages") or []) - stages = sorted(stages, key=lambda s: s.get("position") or s.get("step_order") or 0) - return [s.get("name") for s in stages if s.get("name")] - - -def _approx_count(path: str) -> int | str | None: - """Rough size of a list endpoint. The API uses cursor pagination with no - `total`, so we read one page (100) and report an exact count, or "100+" if - there's more. Returns None if the endpoint can't be read.""" - try: - resp = api_get(path, limit=100) - except BOSError: - return None - if not isinstance(resp, dict): - return None - n = len(resp.get("data", []) or []) - has_more = (resp.get("pagination") or {}).get("has_more") - return f"{n}+" if has_more else n - - -def fetch(quiet: bool) -> dict[str, Any]: - now = now_utc() - warnings: list[str] = [] - sources: dict[str, str] = {} - - # ---- company profile + brand ---- - company: dict[str, Any] = {} - cpath = _resolve_any(COMPANY_RESOURCE_CANDIDATES, action="list") - if cpath: - try: - resp = api_get(cpath) - data = resp.get("data", resp) - row = data[0] if isinstance(data, list) and data else (data if isinstance(data, dict) else {}) - company = { - "name": row.get("name") or row.get("company_name"), - "industry": row.get("industry"), - "website": row.get("website") or row.get("website_url"), - "city": row.get("city"), - "country": row.get("country"), - "brand_primary": (row.get("brand") or {}).get("primary_color") if isinstance(row.get("brand"), dict) else row.get("primary_color"), - "description": row.get("description") or row.get("about"), - } - sources["company"] = "ok" - except BOSError as e: - sources["company"] = "unavailable" - warnings.append(f"company profile not readable: {str(e).splitlines()[0]}") - else: - sources["company"] = "unavailable" - warnings.append("no company-profile endpoint in the catalog — ask the operator for name/industry") - - # ---- pipelines + stages ---- - # Stages are NOT inline on the pipeline list/detail — they live at the - # sub-endpoint GET /pipelines/:id/stages. Fetch them in parallel per pipeline. - pipelines: list[dict[str, Any]] = [] - try: - ppath = resolve_path("pipelines") - listed = list(paginate(ppath, limit=100, max_pages=3)) - ids = [p["id"] for p in listed if p.get("id")] - stage_resp = parallel_get([(f"{ppath}/{i}/stages", {}) for i in ids]) if ids else {} - for p in listed: - pid = p.get("id") - stages = _stages_of(p) # use inline stages if the API ever provides them - if not stages and pid: - sr = stage_resp.get(f"{ppath}/{pid}/stages", {}) - rows = sr.get("data", []) if isinstance(sr, dict) and "error" not in sr else [] - rows = sorted(rows, key=lambda s: s.get("position") or 0) - stages = [s.get("name") for s in rows if s.get("name")] - pipelines.append({ - "id": pid, - "name": p.get("name"), - "is_default": bool(p.get("is_default") or p.get("is_primary")), - "stages": stages, - }) - sources["pipelines"] = "ok" - except BOSError as e: - sources["pipelines"] = "unavailable" - warnings.append(f"pipelines not readable: {str(e).splitlines()[0]}") - - # ---- products ---- - products: list[dict[str, Any]] = [] - try: - for p in paginate(resolve_path("products"), limit=100, max_pages=2): - products.append({ - "name": p.get("name"), - "price": p.get("price") or p.get("unit_price"), - "currency": p.get("currency"), - "billing": p.get("billing_interval") or p.get("pricing_model"), - }) - sources["products"] = "ok" - except BOSError as e: - sources["products"] = "unavailable" - warnings.append(f"products not readable: {str(e).splitlines()[0]}") - - # ---- crm settings (lead sources, types, reasons) ---- - settings: dict[str, Any] = {} - spath = _resolve_any(SETTINGS_RESOURCE_CANDIDATES, action="list") - if spath: - try: - resp = api_get(spath) - s = resp.get("data", resp) if isinstance(resp, dict) else {} - if isinstance(s, list) and s: - s = s[0] - settings = { - "lead_sources": s.get("lead_sources") or s.get("lead_source_options"), - "opportunity_types": s.get("opportunity_type_options") or s.get("deal_types"), - "lost_reasons": s.get("lost_reasons") or s.get("lost_reason_options"), - "won_reasons": s.get("won_reasons") or s.get("won_reason_options"), - } - sources["settings"] = "ok" - except BOSError as e: - sources["settings"] = "unavailable" - warnings.append(f"crm settings not readable: {str(e).splitlines()[0]}") - else: - sources["settings"] = "unavailable" - - # ---- rough counts (cursor pagination has no total — approximate) ---- - counts: dict[str, int | str | None] = {} - for label, rid in (("opportunities", "opportunities"), ("contacts", "contacts"), - ("companies", "companies"), ("automations", "automations")): - try: - counts[label] = _approx_count(resolve_path(rid)) - except BOSError: - counts[label] = None - sources["counts"] = "ok" - - return { - "generated_at": now.isoformat(), - "company": company, - "pipelines": pipelines, - "products": products[:25], - "settings": settings, - "counts": counts, - "warnings": warnings, - "_sources": sources, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", help="Suppress stderr progress logs") - args = parser.parse_args() - try: - log(SKILL, "reading workspace shape...", quiet=args.json_only) - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) - - -# ============================================================================= -# Output shape — what Claude reads from stdout -# ============================================================================= -# -# { -# "generated_at": "...", -# "company": {"name": "...", "industry": "...", "website": "...", -# "city": "...", "country": "...", "brand_primary": "#...", -# "description": "..."}, -# "pipelines": [{"id": "...", "name": "Sales", "is_default": true, -# "stages": ["New lead", "Qualified", "Quote sent", "Won"]}], -# "products": [{"name": "CRM Suite", "price": 129, "currency": "AUD", -# "billing": "monthly"}], -# "settings": {"lead_sources": [...], "opportunity_types": [...], -# "lost_reasons": [...], "won_reasons": [...]}, -# "counts": {"opportunities": 240, "contacts": 1800, "companies": 320, -# "automations": 18}, -# "warnings": [...], -# "_sources": {"company": "ok", "pipelines": "ok", "products": "ok", -# "settings": "ok", "counts": "ok"} -# } diff --git a/skills/learn-my-business/test-fixture.json b/skills/learn-my-business/test-fixture.json deleted file mode 100644 index 71e7c7f..0000000 --- a/skills/learn-my-business/test-fixture.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "_comment": "INPUT fixture for tools/test-skill.py — runs learn-my-business/fetch.py fully offline (mocks api_get + the catalog; no key, no network). Run with: BOS_OFFLINE=1 python tools/test-skill.py learn-my-business. The DIGEST output shape is documented at the bottom of fetch.py.", - "catalog": { - "resources": [ - {"id": "company", "endpoints": [{"method": "GET", "path": "/company"}]}, - {"id": "companies", "endpoints": [{"method": "GET", "path": "/companies"}]}, - {"id": "pipelines", "endpoints": [ - {"method": "GET", "path": "/pipelines"}, - {"method": "GET", "path": "/pipelines/:id"} - ]}, - {"id": "products", "endpoints": [{"method": "GET", "path": "/products"}]}, - {"id": "opportunities", "endpoints": [{"method": "GET", "path": "/opportunities"}]}, - {"id": "contacts", "endpoints": [{"method": "GET", "path": "/contacts"}]}, - {"id": "automations", "endpoints": [{"method": "GET", "path": "/automations"}]} - ] - }, - "responses": { - "company": { - "data": { - "name": "Brightwater Plumbing", - "industry": "Trades — plumbing & gas", - "website": "https://brightwaterplumbing.com.au", - "city": "Geelong", - "country": "Australia", - "description": "Residential and commercial plumbing across the Geelong region." - } - }, - "companies": {"data": [], "pagination": {"total": 64}}, - "pipelines": { - "data": [ - {"id": "p1", "name": "Jobs", "is_default": true} - ] - }, - "pipelines/p1/stages": { - "data": [ - {"name": "New enquiry", "position": 0}, - {"name": "Quoted", "position": 1}, - {"name": "Booked", "position": 2}, - {"name": "Invoiced", "position": 3}, - {"name": "Won", "position": 4} - ] - }, - "products": { - "data": [ - {"name": "Hot water system install", "price": 1850, "currency": "AUD", "billing_interval": "one-off"}, - {"name": "Annual maintenance plan", "price": 49, "currency": "AUD", "billing_interval": "monthly"} - ] - }, - "opportunities": {"data": [{"id": "o1"}, {"id": "o2"}], "pagination": {"has_more": true}}, - "contacts": {"data": [{"id": "c1"}], "pagination": {"has_more": false}}, - "automations": {"data": [{"id": "a1"}], "pagination": {"has_more": false}} - } -} diff --git a/skills/lint-nurture-sequence/SKILL.md b/skills/lint-nurture-sequence/SKILL.md index 506ba4b..7e5a44a 100644 --- a/skills/lint-nurture-sequence/SKILL.md +++ b/skills/lint-nurture-sequence/SKILL.md @@ -23,47 +23,43 @@ It's the quality gate between `design-nurture-sequence` (draft) and `wire-nurture-sequence` (ship) — and it works on a live queue too, to catch drift in something already running. -## Step 1 — Pick the source - -Two ways in: - -- **A live auto queue** — lint what's actually deployed: - ```bash - python tools/lint-sequence.py --queue --json - ``` - (Get the queue id from `/nurture-health` or `python tools/dump-crm-bundle.py --resources auto_queues`.) - -- **Local drafts** — lint before shipping, e.g. the drafts from - `design-nurture-sequence`: - ```bash - python tools/lint-sequence.py --drafts drafts.json --json - ``` - Drafts file: `[{"label": "Day 0", "subject": "...", "body": "

...

"}, ...]`. - -Useful flags: `--signoff "Warmest regards, Sam"` to match the operator's locked -closer; `--allow-em-dash` if the operator is fine with em dashes (default flags -them, because the house style avoids them). - -## Step 2 — What each check means - -Per email: - -| Check | Fails when | Why it matters | -|---|---|---| -| `subject` | missing / very long | no subject = no open; long = the hook gets truncated | -| `greeting` | no "Hi {{contact.first_name}}" up top | a cold open reads like a blast, not a note | -| `html` | body isn't `

` HTML | plain text renders badly in Gmail | -| `link` | **no link at all** | nothing to click — the email does no work | -| `cta_above_image` | there's an image but **no text link above it** | image-blocked clients see no CTA — the exact gap that silently kills clicks | -| `signoff` | the sign-off block is missing | inconsistent closers make the set feel unfinished | -| `positive_subject` | subject leads with negation | positive, forward-looking subjects outperform | -| `no_em_dash` | contains `—` | house style (relax with `--allow-em-dash`) | +## Step 1 — Gather the emails (subject + body, in order) -Across the set: -- **MIXED cta_above_image** is a FAIL on purpose — some emails following the - pattern and others not is the single biggest "half-built" tell. -- Inconsistent sign-offs or P.S. presence are WARNs — align them unless the - variation is deliberate. +Two ways in. Either way, you end up with an **ordered list of `{label, subject, body}`** that you reason over in Step 2. Use the `trustpager` MCP server. + +> Tool names use `deal`/`event_queue` for legacy reasons — **always say "opportunity" / "sequence" to the operator**. + +- **A live auto queue** — lint what's actually deployed. Two read calls (free): + 1. `get_auto_queue(id: )` → returns the queue with its **steps**, each carrying a `step_order` and a linked `automation_id`. Sort the steps by `step_order`. (Get the queue id from `/nurture-health` or `list_auto_queues`.) + 2. For each step's `automation_id`, call `list_automation_actions(automation_id: )` and find the send action (`action_type` of `send_gmail_email` / `send_custom_email` / `send_marketing_email`). Its `config.subject` and `config.body` are the email. Label each from the step's `description` (take the part before any "—") or "Step N". If a step has no linked automation or no send action, keep it in the list with empty subject/body and note it ("no send action on this step"). + +- **Local drafts** — lint before shipping, e.g. the drafts from `design-nurture-sequence`. If the operator hands you a drafts JSON file, `Read` it. Shape is either `[{"label": "Day 0", "subject": "...", "body": "

...

"}, ...]` or `{"emails": [ {...}, ... ]}` (body may be under `body` or `html`, label under `label` or `day`). + +Two settings to confirm before you start: the **expected sign-off** (default `Warmest regards`; the operator may have a locked closer like "Warmest regards, Sam") and whether **em dashes** are allowed (default: flag them, because the house style avoids them — only permit if the operator has said em dashes are fine in their voice). + +All reads — nothing here is journaled or needs approval. This skill **never writes** (see Step 3). + +## Step 2 — Apply the lint rules (reason over each email) + +For each email, evaluate every check below and record PASS / WARN / FAIL. An email's verdict is its worst check; the set's verdict is the worst email or consistency finding. + +**Per email:** + +| Check | Level when it fails | How to test | Why it matters | +|---|---|---|---| +| `subject` | FAIL if empty; WARN if > 90 chars | look at the subject | no subject = no open; long = the hook gets truncated | +| `greeting` | WARN if absent | first ~240 chars of body contain a `{{contact.*}}` token OR a word like "hi"/"hello"/"hey" | a cold open reads like a blast, not a note | +| `html` | WARN if body has text but no `` but **no clickable TEXT link before it** | find the first `` whose visible inner text is non-empty AND is not just a wrapped image. An anchor that only wraps the image doesn't count. If there's no image, this passes (n/a). | image-blocked clients see no CTA — the exact gap that silently kills clicks | +| `signoff` | WARN if the expected sign-off string isn't in the body | case-insensitive substring match on the confirmed sign-off | inconsistent closers make the set feel unfinished | +| `positive_subject` | WARN if subject leads with negation | subject starts with / contains negative framing: `don't`, `do not`, `stop`, `never`, `no`, `isn't`, `won't`, `can't` | positive, forward-looking subjects outperform | +| `no_em_dash` | WARN if subject or body contains `—` (skip if em dashes permitted) | look for the em-dash character | house style avoids them | + +**Across the set:** +- **MIXED `cta_above_image`** is a **FAIL** on purpose — if some emails (that have images) have a text CTA above the image and others don't, that inconsistency is the single biggest "half-built" tell. Don't soften it to a warning. +- **Inconsistent sign-offs** — WARN if the sign-off passes on some emails and not others; every email should close the same way. +- **Inconsistent P.S.** — WARN (informational) if a "P.S." line appears on some emails but not all; fine if deliberate. ## Step 3 — Present and route the fixes @@ -96,8 +92,8 @@ itself.** It only reports. - ❌ Don't edit emails or the queue from this skill — diagnose and route only. - ❌ Don't soften a MIXED `cta_above_image` to a warning — inconsistency across the set is the failure that matters most. -- ❌ Don't treat `--allow-em-dash` as the default — only pass it if the operator - has said em dashes are fine in their voice. +- ❌ Don't treat em dashes as allowed by default — only relax that check if the + operator has said em dashes are fine in their voice. ## Output shape diff --git a/skills/log-this-call/SKILL.md b/skills/log-this-call/SKILL.md index fa3010a..052d50b 100644 --- a/skills/log-this-call/SKILL.md +++ b/skills/log-this-call/SKILL.md @@ -19,38 +19,51 @@ After every customer call, three things need to happen and almost always don't: 2. The opportunity moves to the right stage or gets a clear next action 3. The next step is scheduled so it doesn't get forgotten -This skill captures all three from a single conversation with the user. +This skill captures all three from a single conversation with the operator. -## Step 1 — Identify the context +## Step 1 — Identify the context (MCP calls) -If the user didn't say who they spoke to: +If the operator didn't say who they spoke to: > "Who did you just speak to? (name, phone, or opportunity name)" -Once you have a name, phone, or email, run: +Once you have a name, phone, or email, resolve the contact on the `trustpager` MCP server. Pick the lookup tool by what the operator gave you: -``` -python ~/.claude/bos-run.py log-this-call --query "" -``` +| Identifier given | Tool | Args | +|---|---|---| +| A phone number (digits, optional `+`) | `search_contacts` | `phone: ""`, `limit: 5` | +| An email (contains `@`) | `search_contacts` | `email: ""`, `limit: 5` | +| A name / anything else | `search_contacts` | `search: ""`, `limit: 5` | + +Take the **best match** (first candidate). Then pull their context with these **parallel reads** off that contact id: + +| Need | Tool | Args | +|---|---|---| +| The contact's open opportunities + stage | `get_contact_deals` | `id: `, `limit: 10` | +| Recent activity on the contact | `get_contact_activities` | `id: `, `limit: 10` | -The returned JSON gives you the matched contact(s), their open opportunities (with stage), recent activities, and open tasks — all in one call. No need to chain `search_contacts` + `list_contact_deals` + `get_opportunity_activities` separately. +From the opportunities, keep only the **open** ones — status not in `won` / `lost` / `cancelled` / `abandoned`. For the **top open opportunity** (most-recently-touched), also pull its open tasks with `get_deal_tasks` (`id: `, `limit: 10`) and keep only tasks with no completion time. -If multiple contacts matched: present a numbered list and ask which one. If the chosen contact has multiple open opportunities, ask which one. Default to the most-recently-touched. +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +If multiple contacts matched: present a numbered list and ask which one. If the chosen contact has multiple open opportunities, ask which one — default to the most-recently-touched. All of the above are reads — nothing here is journaled or needs approval. ## Step 2 — Capture the recap -Walk the user through 4 structured questions, ONE AT A TIME: +Walk the operator through 4 structured questions, ONE AT A TIME: 1. **What did you discuss?** (the substance — not "we chatted") 2. **What's their position now?** (more interested / less interested / parked / decided) 3. **What did you agree on?** (the action they're taking, or the action you're taking) 4. **When's the next contact?** (specific day if possible) -Don't dump all four at once. Wait for each answer before asking the next. Each answer is one short prompt to the user. +Don't dump all four at once. Wait for each answer before asking the next. Each answer is one short prompt to the operator. ## Step 3 — Update the workspace -Build a single structured note and post it via `mcp__trustpager__add_note` on the opportunity. Body shape: +Anything in this step writes — follow the rails in `knowledge/safeguards.md` (journal each write to `.bos-journal.md`; a `202`/`approval_id` response means queued — surface the link and stop, don't retry). + +Build a single structured note and post it via `add_note` on the opportunity (`trustpager` MCP server). Body shape: ``` 📞 Call with {contact_name} — {duration_or_just_now} @@ -65,29 +78,32 @@ Agreed: {agreed_action} Next contact: {next_date} — {next_action} ``` -Also: -- If they said "more interested" → suggest moving the opportunity to the next stage. Show: "Move to **Quote Sent** stage? (y/n)" -- If they said "less interested" or "parked" → suggest adding a follow-up task for the next contact date. -- If they said "decided" → ask "Won or lost?" and either move to a won stage (`mcp__trustpager__move_opportunity_card`) or mark lost. -- ALWAYS create a task for the next contact date (`mcp__trustpager__create_task`) with the agreed action as the title. +Then, based on their stated position: +- **More interested** → suggest moving the opportunity to the next stage. Show: "Move to **Quote Sent** stage? (y/n)" — on yes, `move_opportunity_card`. +- **Less interested / parked** → suggest adding a follow-up task for the next contact date. +- **Decided** → ask "Won or lost?" and either `move_opportunity_card` to a won stage, or `update_deal` to mark it lost with a reason. +- ALWAYS create a task for the next contact date via `create_task`, with the agreed action as the title. + +Journal each of these writes (`add_note`, `move_opportunity_card`, `update_deal`, `create_task`) as one line in `.bos-journal.md` (timestamp, tool, outcome, id, `skill: log-this-call`). ## Step 4 — Notify the right people -If the opportunity has other assigned users: +If the opportunity has other assigned users (check via `list_deal_users` on the `trustpager` server): + > "This opp has {N} other people on it. Notify them?" -If yes: send an internal email summary (use `mcp__trustpager__send_email` with `mode: "internal"` if available, else a task with a mention). +If yes: send an internal summary — `send_email` to the assigned users, or create a task that mentions them. This is an outbound write: show the draft, get approval, then journal it (safeguards). ## Important behaviours -- **Never fabricate.** If the user gave a one-liner, the note is a one-liner. Don't pad it. -- **Quote the user verbatim** for "their position" and "agreed" — these are factual claims that may matter later. +- **Never fabricate.** If the operator gave a one-liner, the note is a one-liner. Don't pad it. +- **Quote the operator verbatim** for "their position" and "agreed" — these are factual claims that may matter later. - **No emojis in the note BODY** except the leading 📞. Customer-facing tone, not chat tone. -- **Stage moves are suggestions.** Always confirm with the user before moving. Never silently auto-move. +- **Stage moves are suggestions.** Always confirm before moving. Never silently auto-move. - **One task per call, max.** Don't auto-create three tasks because the conversation mentioned three things. Pick the agreed next step. ## Output shape End with one line: "Logged. Note added to {opp_name}, task '{task_title}' scheduled for {date}, stage moved to {stage}." -If the user only had time for 2 of the 4 questions, log what we have and say so: "Logged partial — discussed + agreed. Their-position and next-contact left blank. /log-this-call again when you have a sec to fill those in." +If the operator only had time for 2 of the 4 questions, log what we have and say so: "Logged partial — discussed + agreed. Their-position and next-contact left blank. /log-this-call again when you have a sec to fill those in." diff --git a/skills/log-this-call/fetch.py b/skills/log-this-call/fetch.py deleted file mode 100644 index 1e36d07..0000000 --- a/skills/log-this-call/fetch.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -"""log-this-call — resolve a person/opp identifier into the full call context. - -The user just got off a call. They say "log my call with Sarah from Acme." -This fetch resolves "Sarah" → the contact, finds their open opportunities, -recent activity, and any tasks. So Claude can pick the right opp without -3-5 separate MCP roundtrips. - -Lookup: -- If --query looks like a phone (digits + optional +) → match by phone -- If --query has @ → match by email -- Otherwise → name search (search_contacts equivalent) - -Pulls (in parallel after best-match resolution): -- The matched contact's full record -- Their open opportunities + stage -- Most recent activities on the top opp -- Any open tasks tied to the top opp - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/log-this-call/fetch.py --query "Sarah Lim" - python skills/log-this-call/fetch.py --query "+61400000001" - python skills/log-this-call/fetch.py --query "sarah@acme.com" -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, resolve_path, -) - - -SKILL = "log-this-call" - - -def _is_phone(q: str) -> bool: - cleaned = q.replace(" ", "").replace("-", "") - return cleaned.startswith("+") or (cleaned.isdigit() and len(cleaned) >= 6) - - -def fetch(query: str, quiet: bool) -> dict[str, Any]: - now = now_utc() - contacts_path = resolve_path("contacts") - log(SKILL, f"resolving '{query}'...", quiet=quiet) - - if _is_phone(query): - params = {"phone": query, "limit": 5} - elif "@" in query: - params = {"email": query, "limit": 5} - else: - params = {"search": query, "limit": 5} - - contacts_response = api_get(contacts_path, **params) - candidates = contacts_response.get("data", []) - - if not candidates: - return { - "generated_at": now.isoformat(), - "query": query, - "headline": {"matched_contacts": 0}, - "candidates": [], - "best_match": None, - "open_opportunities": [], - "recent_activities": [], - "open_tasks": [], - } - - best = candidates[0] - contact_id = best.get("id") - - log(SKILL, f" best match: {best.get('first_name')} {best.get('last_name')} ({contact_id})", - quiet=quiet) - - opps_path = f"contacts/{contact_id}/deals" - activities_path = f"contacts/{contact_id}/activities" - calls = [ - (opps_path, {"limit": 10}), - (activities_path, {"limit": 10}), - ] - results = parallel_get(calls) - - opps = results.get(opps_path, {}).get("data", []) - open_opps = [ - o for o in opps - if (o.get("status") or "").lower() not in - {"won", "lost", "cancelled", "abandoned"} - ] - - # Open tasks for the top opportunity - open_tasks: list[dict[str, Any]] = [] - if open_opps: - top_opp_id = open_opps[0].get("id") - try: - tasks_resp = api_get(f"opportunities/{top_opp_id}/tasks", limit=10) - for t in tasks_resp.get("data", []): - if not t.get("completed_at"): - open_tasks.append({ - "id": t.get("id"), - "title": t.get("title"), - "due_date": t.get("due_date") or t.get("due_at"), - }) - except BOSError: - pass - - return { - "generated_at": now.isoformat(), - "query": query, - "headline": { - "matched_contacts": len(candidates), - "open_opportunities": len(open_opps), - "open_tasks": len(open_tasks), - }, - "candidates": [ - { - "id": c.get("id"), - "name": f"{c.get('first_name', '')} {c.get('last_name', '')}".strip(), - "email": c.get("email"), - "phone": c.get("phone"), - "company_id": c.get("company_id"), - } - for c in candidates - ], - "best_match": { - "id": best.get("id"), - "first_name": best.get("first_name"), - "last_name": best.get("last_name"), - "email": best.get("email"), - "phone": best.get("phone"), - "preferred_channel": best.get("preferred_channel"), - "sms_unsubscribed": best.get("sms_unsubscribed"), - "email_unsubscribed": best.get("email_unsubscribed"), - }, - "open_opportunities": [ - { - "id": o.get("id"), - "name": o.get("name"), - "value": o.get("value"), - "currency": o.get("currency"), - "stage": ((o.get("placements") or [{}])[0] - .get("crm_pipeline_stages", {}) or {}).get("name"), - } - for o in open_opps - ], - "recent_activities": [ - { - "id": a.get("id"), - "type": a.get("activity_type") or a.get("type"), - "summary": a.get("summary") or a.get("title"), - "created_at": a.get("created_at"), - } - for a in results.get(activities_path, {}).get("data", []) - ][:5], - "open_tasks": open_tasks, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--query", required=True, - help="Name, phone, email, or opportunity name to resolve") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(args.query, quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/log-this-call/test-fixture.json b/skills/log-this-call/test-fixture.json deleted file mode 100644 index 53bdff2..0000000 --- a/skills/log-this-call/test-fixture.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "args": ["--query", "Test Caller"], - "responses": { - "contacts": { - "data": [ - { - "id": "contact-test-1", - "first_name": "Test", - "last_name": "Caller", - "email": "test@example.com", - "phone": "+61400000001", - "preferred_channel": "sms" - } - ], - "pagination": {"has_more": false} - }, - "contacts/contact-test-1/deals": { - "data": [ - { - "id": "opp-1", - "name": "Test Opportunity", - "status": "open", - "value": 5000, - "currency": "AUD", - "placements": [{"crm_pipeline_stages": {"name": "Quote Sent"}}] - } - ] - }, - "contacts/contact-test-1/activities": {"data": []}, - "opportunities/opp-1/tasks": {"data": []} - } -} diff --git a/skills/make-it-happen/SKILL.md b/skills/make-it-happen/SKILL.md index 66ac3a8..0cd6372 100644 --- a/skills/make-it-happen/SKILL.md +++ b/skills/make-it-happen/SKILL.md @@ -14,17 +14,21 @@ triggers: # /make-it-happen -This is the catch-all skill. The user describes the outcome they want — not which tool to call, not which page to visit. Your job is to figure out the right TrustPager operations and execute them, with approval at each destructive step. +This is the catch-all skill. The operator describes the outcome they want — not which tool to call, not which page to visit. Your job is to figure out the right TrustPager operations and execute them, with approval at each destructive step. -## Step 0 — Warm the discovery cache +## Step 1 — Warm the discovery surface (parallel MCP reads) -Before the conversation starts, run: +Before planning, pull the workspace's AI-facing reference data in one parallel batch off the `trustpager` MCP server, and keep it in memory for the rest of the conversation (re-referencing it costs nothing): -``` -python ~/.claude/bos-run.py make-it-happen -``` +| Need | Tool | Args | +|---|---|---| +| Workspace workflow guidance + common mistakes | `get_ai_instructions` | — | +| Every automation trigger type + its `{{variable}}` tokens | `list_trigger_schemas` | — | +| Existing automations (to spot "you already have one of these") | `list_automations` | `limit: 100` | -This pulls in one shot: the workspace's AI instructions, every trigger schema, every action type, and the list of existing automations. Keep the JSON in memory through the conversation — referring to it costs no additional API calls. +These are all free reads. `list_trigger_schemas` returns the trigger types and the trigger-data shape each publishes; for one trigger's full payload use `get_trigger_schema(trigger_type: "")`. There is **no client-side action-type catalog tool** — to learn an action's config, read the `config` field description on `add_automation_action` (it documents every `action_type`'s required fields), or check `using-trustpager-mcp.md` / `knowledge/automation-recipes.md`. + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". ## The pattern @@ -37,17 +41,18 @@ This pulls in one shot: the workspace's AI instructions, every trigger schema, e ## Use the right discovery tools -Before guessing, use TrustPager's discovery surface: -- `mcp__trustpager__get_ai_instructions` — workspace-specific guidance -- `mcp__trustpager__describe_resource(resource)` — what tools exist for an entity -- `mcp__trustpager__describe_action_type(action_type)` — config schema for one automation action -- `mcp__trustpager__get_trigger_schema(trigger)` — payload shape for a trigger -- `mcp__trustpager__list_action_types` — full action catalog +Before guessing, use TrustPager's discovery surface on the `trustpager` MCP server: +- `get_ai_instructions` — workspace-specific guidance. +- `list_trigger_schemas` — every trigger type the automation engine supports. +- `get_trigger_schema(trigger_type)` — the payload shape + `{{variable}}` tokens for one trigger. +- For an action's config schema, read the `config` description on `add_automation_action` (it enumerates each `action_type`). -If you're not sure which resource is involved, ask the user with a multiple-choice question — don't guess. +If you're not sure which resource is involved, ask the operator with a multiple-choice question — don't guess. If a tool you need doesn't appear to exist, verify before assuming — don't invent a tool name. ## Hard-block destructive operations +These all WRITE — follow the rails in `knowledge/safeguards.md`: confirm before anything destructive/outward-facing, **search first** so a retry never duplicates, and **journal every write** to `.bos-journal.md` (one line: timestamp, tool, outcome, id, `skill: make-it-happen`). + ALWAYS require explicit approval for: - `delete_*` (any tool) - `bulk_delete_*` @@ -55,7 +60,7 @@ ALWAYS require explicit approval for: - `release_phone_number` - `disable_automation` on a published automation - Sending email/SMS to more than 1 recipient (use `/send-email` instead, or batch through a campaign) -- Moving more than 10 opportunities at once +- Moving more than 10 opportunities at once (`bulk_move_deals`) For each, present: - Exactly what will happen ("delete 14 contacts: ") @@ -64,21 +69,25 @@ For each, present: ## Use existing skills when they fit -If the user's request matches an existing skill, RUN THAT SKILL instead of doing it from scratch: +If the operator's request matches an existing skill, RUN THAT SKILL instead of doing it from scratch: - "Triage my new leads" → `/lead-triage` - "What did I miss?" → `/sweep-my-day` - "Re-engage cold leads" → `/follow-up-radar` - "Send Sarah an email" → `/send-email` - "Reply to this" → `/draft-reply` -This skill is for the gaps between named skills — bespoke multi-step operations the user only does occasionally. +This skill is for the gaps between named skills — bespoke multi-step operations the operator only does occasionally. + +## When it genuinely can't be done + +If the request dead-ends because the capability isn't there — no TrustPager tool does it, and no BOS skill covers it — don't fail silently or fake it. Do what you *can*, then offer to capture the gap: "TrustPager can't do that part yet — want me to log it for the team with `/suggest-improvement`?" Only file on a yes. (See `knowledge/memory-and-feedback.md`.) That's different from a 202 approval — this is a missing capability, not a queued write. ## Approval queue (HTTP 202) -If a tool call returns HTTP 202 with an `approval_id`, the operation is queued for human approval. DO NOT try to bypass it. Tell the user: +If a write tool returns a `202` with an `approval_id`, the operation is **queued** for human approval (safeguards §1). DO NOT try to bypass it. Tell the operator: > "Queued for approval — approve at https://app.trustpager.com/settings/api?tab=approvals (id: ``). The operation will run automatically once you approve." -Then stop. Don't poll. The user controls when to approve. +Then stop. Don't poll. The operator controls when to approve. Journal it as `approval_pending`. ## Output shape @@ -86,6 +95,6 @@ After the operation completes, summarize in one paragraph: - What was requested - What was done (with counts) - Anything that was NOT done and why -- The next step the user might want +- The next step the operator might want If there's anything that didn't work, say so plainly. Don't claim success on partial completion. diff --git a/skills/make-it-happen/fetch.py b/skills/make-it-happen/fetch.py deleted file mode 100644 index 49b2645..0000000 --- a/skills/make-it-happen/fetch.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -"""make-it-happen — pre-fetch the TrustPager discovery surface. - -This is a "warm cache" fetch: pulls the AI-facing reference data Claude -will need to figure out which TrustPager primitives to use for any -plain-English request. Calling it once at the start of /make-it-happen -saves 3-5 sequential MCP discovery calls. - -Pulls (in parallel): -- AI instructions for this workspace (workflow guidance + common mistakes) -- All available trigger schemas (for automation work) -- All available action types (for automation work) -- All available automations (so we can spot "you already have one of these") - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/make-it-happen/fetch.py -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, - force_utf8_stdout, log, now_utc, parallel_get, resolve_path, -) - - -SKILL = "make-it-happen" - - -def fetch(quiet: bool) -> dict: - now = now_utc() - log(SKILL, "warming discovery cache...", quiet=quiet) - - # Map each call to its resolved path. Some endpoints don't fit the - # generic resolve_path action-shape, so we use raw paths there. - calls = [ - ("ai-instructions", {}), - ("schemas/triggers", {}), - ("automations/action-types", {}), - (resolve_path("automations"), {"limit": 100}), - ] - results = parallel_get(calls) - - ai_instructions = results.get("ai-instructions", {}) - triggers = results.get("schemas/triggers", {}) - action_types = results.get("automations/action-types", {}) - automations = results.get(resolve_path("automations"), {}) - - return { - "generated_at": now.isoformat(), - "ai_instructions": ai_instructions, - "trigger_schemas": triggers.get("data") or triggers, - "action_types": action_types.get("data") or action_types, - "existing_automations": [ - {"id": a.get("id"), "name": a.get("name"), "enabled": a.get("enabled")} - for a in (automations.get("data") or []) - ], - "headline": { - "trigger_count": len(triggers.get("data") or triggers or []) if isinstance(triggers, (list, dict)) else 0, - "action_type_count": len(action_types.get("data") or action_types or []) if isinstance(action_types, (list, dict)) else 0, - "automation_count": len(automations.get("data") or []), - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/make-it-happen/test-fixture.json b/skills/make-it-happen/test-fixture.json deleted file mode 100644 index d54dcb1..0000000 --- a/skills/make-it-happen/test-fixture.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "_doc": "Fixture for make-it-happen — minimal discovery surface mock.", - "catalog": {"_use_live": true}, - "responses": { - "ai-instructions": {"instructions": "Test instructions."}, - "schemas/triggers": {"data": [{"trigger_type": "form_submission"}]}, - "automations/action-types": {"data": [{"action_type": "send_email"}]}, - "automations": {"data": [{"id": "auto-1", "name": "Test auto", "enabled": true}]} - } -} diff --git a/skills/make-social-post/SKILL.md b/skills/make-social-post/SKILL.md index 0b86f31..9a1cd87 100644 --- a/skills/make-social-post/SKILL.md +++ b/skills/make-social-post/SKILL.md @@ -115,7 +115,7 @@ npm run publish --replace # overwrite ``` Uploads to the operator's `Files > Images > Social Posts` folder. Auth -resolves from `$TRUSTPAGER_API_KEY` then `~/.claude/bos.json`. Idempotent: +comes from the `TRUSTPAGER_API_KEY` environment variable. Idempotent: skip-if-exists by default, `--replace` to overwrite. ## Hard rules diff --git a/skills/make-thumbnail/SKILL.md b/skills/make-thumbnail/SKILL.md index 3e7f92a..684465f 100644 --- a/skills/make-thumbnail/SKILL.md +++ b/skills/make-thumbnail/SKILL.md @@ -126,8 +126,7 @@ To upload the PNG to the operator's own TrustPager workspace's npm run publish ``` -Auth resolves the same way as the BOS Python tools: `$TRUSTPAGER_API_KEY` -env var first, then `~/.claude/bos.json`. The script handles +Auth comes from the `TRUSTPAGER_API_KEY` environment variable. The script handles idempotency — re-running publish with the same design key skips if the file's unchanged, renames if the title shifted, replaces only on `--replace`. diff --git a/skills/missed-call-recovery/SKILL.md b/skills/missed-call-recovery/SKILL.md index 70c49b2..7bf76f5 100644 --- a/skills/missed-call-recovery/SKILL.md +++ b/skills/missed-call-recovery/SKILL.md @@ -16,40 +16,63 @@ triggers: When someone misses a call, the longer the gap before a response, the lower the chance of recovery. This skill makes that gap as short as possible — pulls every recent missed call, looks up who it was (existing contact? opportunity? cold caller?), and drafts a per-call recovery message you can send with one approval. -## Step 1 — Pull the data +## Step 1 — Pull the data (MCP calls) -Run the fetch script. It returns every missed inbound call from the last 24h (configurable), enriched with: -- Phone number that called -- Whether we have a contact for that number -- Linked opportunity (if any) + current stage -- Whether the caller has been called back already since the missed call -- Time since the missed call +Pull the call log off the `trustpager` MCP server: -``` -python ~/.claude/bos-run.py missed-call-recovery -``` +| Need | Tool | Args | +|---|---|---| +| Recent phone call logs | `list_phone_call_logs` | `limit: 200` | -Pass `--hours 48` for a longer window, or `--include-callbacks` to also surface calls that were already recovered (useful for reviewing the day's recovery work). +Default window is the **last 24 hours** (the operator can ask for 48h). Filter to that window yourself. All reads here are free. -## Step 2 — Triage and draft +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -For each missed call in the output, present a one-line summary to the user, then propose the recovery action: +## Step 2 — Find the missed inbound calls + +A call is a **missed inbound** call when its direction is inbound/incoming AND either: +- its status/disposition (lowercased, `_`→`-`) is one of: `no-answer`, `missed`, `failed`, `busy`, `voicemail`, `abandoned`, `no-answer-machine`; **or** +- it's a heuristic miss: duration < 5 seconds AND no transcript/recording present. + +Normalise each caller's number to its last 12 digits (keep a leading `+`). **Group missed calls by caller number** — multiple misses from the same number are one "session"; use the latest one as the representative. + +**Detect already-recovered numbers.** A number is recovered if, *after* its latest missed call, the log shows either an **outbound** call to that number, or an **answered inbound** call (duration ≥ 5s) from it. By default, drop recovered numbers from the list (the operator can ask to include them for a review of the day's recovery work). + +## Step 3 — Enrich each unique caller + +For each unique missed-caller number, look up the contact and their context on the `trustpager` server (run these in parallel across callers): + +| Need | Tool | Args | +|---|---|---| +| Contact for the number | `search_contacts` | `phone: ""`, `limit: 1` | +| That contact's open opportunities + stage | `get_contact_deals` | `id: `, `limit: 5` | + +Keep only **open** opportunities (status not in `won` / `lost` / `cancelled` / `abandoned`); use the most recent as the linked opportunity, capturing its name, value, and current stage. + +**Rank the list:** known caller WITH an open opportunity first, then known caller (no open opp), then unknown number. Within each tier, most-recently-missed first. + +## Step 4 — Triage and draft + +For each missed call in the ranked list, present a one-line summary, then propose the recovery action: | Caller type | Default recovery | |---|---| | Existing contact with open opportunity | "Sorry I missed your call — was about [opportunity name]?" SMS, then offer to schedule a callback. | | Existing contact, no open opportunity | Friendly callback SMS. Ask what they were calling about. | | Unknown number (no contact) | SMS asking if they were trying to reach the business. Do NOT create a contact yet — wait for a reply. | -| Caller already recovered | Skip silently. Don't ask the user. | +| Caller already recovered | Skip silently. Don't ask the operator. | + +Use the contact's preferred channel if set on the record; otherwise default to SMS for missed calls (faster than email). -Use the contact's preferred channel if it's set on the record. Otherwise default to SMS for missed calls (faster than email). +## Step 5 — Send with approval -## Step 3 — Send with approval +Sends are outward-facing — follow the rails in `knowledge/safeguards.md`: show the draft, get approval, **search first** so a re-run never double-texts, and **journal each send** to `.bos-journal.md`. For each drafted message: -- Show the user: who, the phone number, the proposed message, the channel. -- Wait for explicit yes/no per message. NEVER batch-send. -- On yes: send via `mcp__trustpager__send_sms` (or `send_email` if email is the channel). +- Show the operator: who, the phone number, the proposed message, the channel. +- Wait for explicit yes/no **per message**. NEVER batch-send. +- On yes: send via `send_sms` (or `send_email` if email is the channel) on the `trustpager` server. If a send returns `202` / `approval_id`, surface the approvals link and stop — don't retry (safeguards §1). +- Append one line to `.bos-journal.md` per send (timestamp, tool, outcome, id, `skill: missed-call-recovery`). - On no: ask what to change (tone? length? skip?), or move on. ## Important behaviours @@ -62,4 +85,4 @@ For each drafted message: ## Output shape -The skill should end with a one-line summary: "Recovered N of M missed calls. K already had a callback. R skipped." +End with a one-line summary: "Recovered N of M missed calls. K already had a callback. R skipped." diff --git a/skills/missed-call-recovery/fetch.py b/skills/missed-call-recovery/fetch.py deleted file mode 100644 index f911059..0000000 --- a/skills/missed-call-recovery/fetch.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 -"""Missed-call recovery — find recent missed inbound calls + caller context. - -Pulls every inbound call in the last N hours that was missed (no answer, -voicemail, or hung-up-before-pickup), enriches each with caller identity -(contact lookup by phone), open opportunity, and whether the call has -already been returned. - -Output (stdout): JSON with one record per missed call, ranked by priority -(known caller with open opportunity > known caller > unknown number). -The companion SKILL.md tells Claude how to draft and send recovery -messages. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. See tools/trustpager_api.py. - -Usage: - python skills/missed-call-recovery/fetch.py - python skills/missed-call-recovery/fetch.py --hours 48 - python skills/missed-call-recovery/fetch.py --include-callbacks # show even already-recovered ones -""" - -from __future__ import annotations - -import argparse -import sys -from collections import defaultdict -from datetime import timedelta -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, days_since, emit_error_and_exit, emit_json, - force_utf8_stdout, log, now_utc, parallel_get, parse_iso, resolve_path, -) - - -SKILL = "missed-call-recovery" -MISSED_STATUSES = {"no-answer", "no_answer", "missed", "failed", "busy", - "voicemail", "abandoned", "no-answer-machine"} -INBOUND_DIRECTIONS = {"inbound", "incoming"} - - -def _is_missed(call: dict[str, Any]) -> bool: - status = (call.get("status") or call.get("disposition") or "").lower().replace("_", "-") - direction = (call.get("direction") or "").lower() - if direction not in INBOUND_DIRECTIONS: - return False - if status in MISSED_STATUSES: - return True - # Heuristic fallback: very short call (<5s) + inbound + no transcript = missed - dur = call.get("duration") or call.get("duration_seconds") or 0 - has_transcript = bool(call.get("transcript_id") or call.get("recording_url")) - if dur < 5 and not has_transcript: - return True - return False - - -def _normalize_phone(raw: str | None) -> str | None: - if not raw: - return None - return "".join(c for c in raw if c.isdigit() or c == "+")[-12:] or None - - -def fetch_and_digest(hours: int, include_callbacks: bool, quiet: bool) -> dict[str, Any]: - now = now_utc() - cutoff = now - timedelta(hours=hours) - - log(SKILL, f"fetching phone call logs from last {hours}h...", quiet=quiet) - - calls_path = resolve_path("phone", path_contains="call-logs") - response = api_get(calls_path, limit=200, after=cutoff.isoformat()) - calls = response.get("data", []) - log(SKILL, f" {len(calls)} call log rows in window", quiet=quiet) - - # Filter to missed inbound - missed = [c for c in calls if _is_missed(c)] - log(SKILL, f" {len(missed)} missed inbound calls", quiet=quiet) - - # Group by from_phone — if there are multiple missed calls from same number, - # treat as one "session" (the latest one) - by_phone: dict[str, list[dict[str, Any]]] = defaultdict(list) - for c in missed: - phone = _normalize_phone(c.get("from_phone") or c.get("from") or c.get("caller_number")) - if not phone: - continue - by_phone[phone].append(c) - - # Identify recovered numbers: any outbound or answered inbound call to/from - # the same phone AFTER the latest missed call - recovered: dict[str, dict[str, Any]] = {} - for phone in by_phone: - latest_miss = max(by_phone[phone], key=lambda c: c.get("started_at") or c.get("created_at") or "") - miss_time = parse_iso(latest_miss.get("started_at") or latest_miss.get("created_at")) - for c in calls: - if c is latest_miss: - continue - # The "other party" depends on direction: outbound → to_phone, inbound → from_phone - direction = (c.get("direction") or "").lower() - if direction == "outbound": - other_phone = _normalize_phone(c.get("to_phone") or c.get("to")) - elif direction in INBOUND_DIRECTIONS: - other_phone = _normalize_phone( - c.get("from_phone") or c.get("from") or c.get("caller_number")) - else: - continue - if other_phone != phone: - continue - ct = parse_iso(c.get("started_at") or c.get("created_at")) - if miss_time and ct and ct > miss_time: - dur = c.get("duration") or c.get("duration_seconds") or 0 - if direction == "outbound" or (direction in INBOUND_DIRECTIONS and dur >= 5): - recovered[phone] = { - "recovered_at": c.get("started_at") or c.get("created_at"), - "by_call_id": c.get("id"), - } - break - - # Enrich each unique missed-caller with contact + open opp - unique_phones = list(by_phone.keys()) - log(SKILL, f" enriching {len(unique_phones)} unique numbers...", quiet=quiet) - - contacts_path = resolve_path("contacts", action="list") - contact_calls = [(contacts_path, {"phone": ph, "limit": 1}) for ph in unique_phones] - contact_results = parallel_get(contact_calls) if contact_calls else {} - - items: list[dict[str, Any]] = [] - for phone, missed_calls in by_phone.items(): - latest = max(missed_calls, key=lambda c: c.get("started_at") or c.get("created_at") or "") - miss_time = parse_iso(latest.get("started_at") or latest.get("created_at")) - minutes_ago = int((now - miss_time).total_seconds() / 60) if miss_time else None - - # Find the contact result keyed by path - contact: dict[str, Any] | None = None - for path, resp in contact_results.items(): - if "phone" not in path and not resp.get("data"): - continue - data = resp.get("data") or [] - if data and _normalize_phone(data[0].get("phone")) == phone: - contact = data[0] - break - - is_recovered = phone in recovered - if is_recovered and not include_callbacks: - continue - - item = { - "phone": phone, - "missed_count": len(missed_calls), - "latest_call_id": latest.get("id"), - "minutes_since_missed": minutes_ago, - "missed_at": latest.get("started_at") or latest.get("created_at"), - "recovered": is_recovered, - "recovered_info": recovered.get(phone), - "contact": None, - "open_opportunity": None, - } - if contact: - item["contact"] = { - "id": contact.get("id"), - "first_name": contact.get("first_name"), - "last_name": contact.get("last_name"), - "email": contact.get("email"), - "phone": contact.get("phone"), - "preferred_channel": contact.get("preferred_channel"), - "sms_unsubscribed": contact.get("sms_unsubscribed"), - } - # Get most recent open opportunity for this contact - try: - opps_resp = api_get("contacts/" + contact["id"] + "/deals", limit=5) - opps = opps_resp.get("data") or [] - open_opps = [o for o in opps - if (o.get("status") or "").lower() not in - {"won", "lost", "cancelled", "abandoned"}] - if open_opps: - item["open_opportunity"] = { - "id": open_opps[0].get("id"), - "name": open_opps[0].get("name"), - "value": open_opps[0].get("value"), - "stage": ((open_opps[0].get("placements") or [{}])[0] - .get("crm_pipeline_stages", {}) - or {}).get("name"), - } - except BOSError: - pass - items.append(item) - - # Sort: known caller with open opp first, then known caller, then unknown - def _priority(it: dict[str, Any]) -> int: - if it["open_opportunity"]: - return 0 - if it["contact"]: - return 1 - return 2 - items.sort(key=lambda it: (_priority(it), it.get("minutes_since_missed") or 0)) - - return { - "generated_at": now.isoformat(), - "window_hours": hours, - "include_callbacks": include_callbacks, - "headline": { - "total_missed_unique_callers": len(by_phone), - "already_recovered": sum(1 for p in by_phone if p in recovered), - "needing_recovery": len([i for i in items if not i["recovered"]]), - "known_callers": sum(1 for i in items if i["contact"]), - }, - "items": items, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--hours", type=int, default=24, - help="How far back to look (default 24 hours)") - parser.add_argument("--include-callbacks", action="store_true", - help="Also include calls that have already been recovered") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - digest = fetch_and_digest(args.hours, args.include_callbacks, quiet=args.json_only) - emit_json(digest) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/missed-call-recovery/test-fixture.json b/skills/missed-call-recovery/test-fixture.json deleted file mode 100644 index df6f51b..0000000 --- a/skills/missed-call-recovery/test-fixture.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "_doc": "Fixture for `python tools/test-skill.py missed-call-recovery`. Mocks a small call-log feed with a mix of missed inbound, recovered, and outbound calls.", - - "catalog": { "_use_live": true }, - - "responses": { - "phone/call-logs": { - "data": [ - { - "id": "call-missed-1", - "direction": "inbound", - "status": "no-answer", - "from_phone": "+61400000001", - "to_phone": "+61299999999", - "started_at": "2026-05-31T08:30:00Z", - "duration": 0 - }, - { - "id": "call-missed-2", - "direction": "inbound", - "status": "voicemail", - "from_phone": "+61400000002", - "to_phone": "+61299999999", - "started_at": "2026-05-31T09:15:00Z", - "duration": 12 - }, - { - "id": "call-recovered-out", - "direction": "outbound", - "status": "completed", - "from_phone": "+61299999999", - "to_phone": "+61400000002", - "started_at": "2026-05-31T09:45:00Z", - "duration": 180 - } - ], - "pagination": {"has_more": false} - }, - "contacts": { - "data": [ - { - "id": "contact-1", - "first_name": "Test", - "last_name": "Caller One", - "email": "test1@example.com", - "phone": "+61400000001", - "preferred_channel": "sms" - } - ], - "pagination": {"has_more": false} - } - } -} diff --git a/skills/nurture-health/SKILL.md b/skills/nurture-health/SKILL.md index ec9fa0e..4e8ace0 100644 --- a/skills/nurture-health/SKILL.md +++ b/skills/nurture-health/SKILL.md @@ -25,27 +25,36 @@ It pairs with the re-engagement machine in [`knowledge/automation-recipes.md`](../../knowledge/automation-recipes.md) (R19/R20) — that section explains what a healthy multi-channel queue looks like. -## Step 1 — Fetch the health digest +## Step 1 — Pull the data (MCP calls) -```bash -python ~/.claude/bos-run.py nurture-health -``` +All reads, on the `trustpager` MCP server. Start with the queue list and an email-log sample in parallel, then drill into each queue: + +| Need | Tool | Args | +|---|---|---| +| Every auto queue | `list_auto_queues` | `limit: 100` | +| Recent email logs (for engagement) | `list_email_logs` | `limit: 100` (sample a few hundred recent) | +| Each queue's steps + linked automation ids | `get_auto_queue` | `id: ` | +| Each queue's enrolment funnel | `list_auto_queue_enrollments` | `queue_id: `, `limit: 100` (page, cap ~5 pages so a huge queue can't run away) | + +Scope to a single queue if the operator names one. Everything here is read-only — nothing is journaled or needs approval. + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +**Best-effort by design:** enrolment and email-log endpoints vary in shape by workspace and API version. If a queue's enrolments aren't reachable, treat that queue as **step-only** (you still have the funnel structure) and tell the operator plainly what's measured vs unavailable. Don't bail on the whole health-check because one queue degrades. + +## Step 2 — Compute the funnel and per-step drop-off -One run: lists every auto queue, pulls each one's steps + enrolment funnel + -per-step drop-off, and (where the email-log endpoint exposes `automation_id`) -open/click rates per step. Scope to one queue with `--queue `. +For each queue: -This fetcher is **best-effort by design** — queue / enrolment / email-log -endpoints vary by workspace and API version. Anything it can't reach lands in -`warnings` and `_sources`, and it still returns everything else. Read those two -fields and tell the operator plainly what's measured vs estimated. +- **Steps** — read the ordered step list off the queue detail (sort by step order). Each step carries a `delay` (days/hours/minutes) and an `automation_id`. Build a day-label per step: use the step description if present, else `+Nd Nh Nm` from the delays (`0` delays = "immediate"). +- **Enrolment funnel** — from the enrolment statuses, count: `enrolled` (total), `active`, `completed`, `cancelled` (count `cancelled` + `removed`). `completion_rate = completed / enrolled`. +- **Per-step reached counts** — for each enrolment, read how far it progressed (last completed / current step order). A step is "reached" by an enrolment if that enrolment's progress ≥ the step's order. Count reached enrolments per step. +- **Leak step** — walking steps in order, the drop after a step = `reached(prev) − reached(this)` when that's positive. The step with the **biggest single drop** is the queue's leak step. +- **Engagement per step** — bucket the sampled email logs by `automation_id` into `{sends, opens, clicks}` (an open = any `opened_at`/open count > 0; a click = any `clicked_at`/click count > 0). For each step, match its `automation_id` to a bucket → `open_rate = opens/sends`, `click_rate = clicks/sends`. If the email logs don't carry an `automation_id` in this workspace, engagement is **unavailable** — say so, don't fake rates. -**Fallback if the script can't run at all** (auth/network): drive it by hand — -`list_auto_queues` → `get_auto_queue` (steps) → `get_auto_queue_board` (per-step -buckets) → `list_auto_queue_enrollments` (status mix) → `list_email_logs` -(engagement). That's many calls; prefer the script. +**Headline across all queues:** total active, total completed, total cancelled, and the single biggest leak (queue + step + how many lost). -## Step 2 — Present, worst leak first +## Step 3 — Present, worst leak first Lead with the single biggest leak across all queues, then go queue by queue. Never dump raw step arrays — translate them into a funnel the operator reads in @@ -76,14 +85,14 @@ ten seconds. | Signal | What it means | What to offer | |---|---|---| -| Big `dropped_after` at one step | that email is where people fall off | review that step's copy — hand to `/design-nurture-sequence`; lint it with `/lint-nurture-sequence` | +| Big drop at one step | that email is where people fall off | review that step's copy — hand to `/design-nurture-sequence`; lint it with `/lint-nurture-sequence` | | `open_rate` low at a step | subject isn't landing (or deliverability) | rework the subject; check the sender/test-send | | `click_rate` low but open ok | the body/CTA isn't pulling | the CTA-above-the-image / single-CTA rules — run the linter | | `completion_rate` very low | sequence too long, or leak early | shorten, or fix the early leak first | | `cancelled` is 0 on a campaign with a "Remove" stage | the **un-enrol automation isn't firing** — booked/dead leads still get drip | check stage automation B (R19); hand to `/why-didnt-it-fire` | | `active` piling up, few completing | people stalled mid-sequence | check step delays + that later steps have email actions wired | -## Step 3 — Point at the fix, don't auto-fix +## Step 4 — Point at the fix, don't auto-fix This is a read/diagnose skill. For the fixes it surfaces, hand off: - Leaky/weak copy → `/design-nurture-sequence` (rewrite), then `/wire-nurture-sequence`. @@ -95,13 +104,12 @@ Don't edit queue steps or automations from this skill. ## What to never do - ❌ Don't present queues as a flat list — lead with the biggest leak, then per queue. -- ❌ Don't report open/click rates as exact when `_sources.engagement` isn't `ok` — say "estimated" or "unavailable". +- ❌ Don't report open/click rates as exact when the email logs don't link to automations — say "estimated" or "unavailable". - ❌ Don't call a queue "broken" because completion is low — low completion can be a long sequence working as designed. Flag the *leak step*, not the headline rate. - ❌ Don't write to any queue or automation here. ## Output shape Open with the single biggest leak (queue + step + how many lost). Then one -compact funnel block per queue. Then any "couldn't measure" line from -`warnings`/`_sources`. Close with the one step most worth fixing first and the -skill to hand it to. +compact funnel block per queue. Then any "couldn't measure" line. Close with the +one step most worth fixing first and the skill to hand it to. diff --git a/skills/nurture-health/fetch.py b/skills/nurture-health/fetch.py deleted file mode 100644 index 2d2113a..0000000 --- a/skills/nurture-health/fetch.py +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env python3 -"""nurture-health — read every auto queue's funnel + engagement into one digest. - -Operators ship a nurture sequence and never look at whether it's working. This -fetcher pulls each auto queue's steps, enrolment funnel, per-step drop-off, and -(where the email-log endpoint exposes it) open/click rates — so Claude can say -exactly which step is leaking and whether the un-enrol side is firing. - -Everything here is read-only. The endpoints for queues / enrolments / board / -email-logs vary by workspace and API version, so every phase is best-effort: -if a phase can't be reached it lands in `warnings` and `_sources`, and the -digest is emitted with whatever was gathered. The SKILL documents the MCP -fallbacks for anything that degrades. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/nurture-health/fetch.py - python skills/nurture-health/fetch.py --json-only - python skills/nurture-health/fetch.py --queue # one queue only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parallel_get, parse_iso, days_since, resolve_path, -) - -SKILL = "nurture-health" - -# Candidate catalog resource ids for the auto-queue resource, newest naming -# first. resolve_path raises if an id is unknown, so we try in order. -QUEUE_RESOURCE_CANDIDATES = ["event-queues", "auto-queues", "auto_queues", "event_queues"] -ENROLMENTS_PER_QUEUE_MAX_PAGES = 5 # cap so a huge queue can't run away -EMAIL_LOG_SAMPLE_LIMIT = 500 # recent email logs to sample for engagement -STALLED_GRACE_DAYS = 5 # active + idle past next-step delay + this = stalled - - -def _resolve_any(candidates: list[str], **kw: Any) -> str: - """Return the first resource id in `candidates` that resolves, else raise.""" - last: Exception | None = None - for rid in candidates: - try: - return resolve_path(rid, **kw) - except BOSError as e: - last = e - raise BOSError(f"None of {candidates} resolve in the API catalog. Last: {last}") - - -def _steps_of(queue: dict[str, Any]) -> list[dict[str, Any]]: - """Pull the ordered step list off a queue detail record, whatever it's called.""" - steps = (queue.get("automation_event_queue_steps") - or queue.get("steps") - or queue.get("event_queue_steps") or []) - return sorted(steps, key=lambda s: s.get("step_order") or 0) - - -def _day_label(step: dict[str, Any]) -> str: - desc = (step.get("description") or "").strip() - if desc: - return desc.split("—")[0].strip() if "—" in desc else desc[:40] - d, h, m = step.get("delay_days") or 0, step.get("delay_hours") or 0, step.get("delay_minutes") or 0 - if d == h == m == 0: - return "immediate" - return f"+{d}d {h}h {m}m".replace(" 0h", "").replace(" 0m", "") - - -def _enrolment_status(e: dict[str, Any]) -> str: - return (e.get("status") or e.get("state") or "active").lower() - - -def _enrolment_step(e: dict[str, Any]) -> int: - """Best guess at how far an enrolment has progressed (last completed step_order).""" - for k in ("last_completed_step_order", "current_step_order", "step_order", - "completed_steps", "current_step"): - v = e.get(k) - if isinstance(v, int): - return v - return 0 - - -def _fetch_queue_health(queue_list_path: str, queue: dict[str, Any], quiet: bool) -> dict[str, Any]: - qid = queue.get("id") - name = queue.get("name") or "(unnamed queue)" - warnings: list[str] = [] - - # --- queue detail (steps + linked automation ids) --- - detail = queue - try: - get_path = _resolve_any(QUEUE_RESOURCE_CANDIDATES, action="get") - # get_path looks like "event-queues/:id" — substitute the id segment - concrete = get_path.replace(":id", qid).replace(":queue_id", qid) - resp = api_get(concrete) - detail = resp.get("data", resp) if isinstance(resp, dict) else queue - except BOSError as e: - warnings.append(f"queue detail degraded ({name}): {str(e).splitlines()[0]}") - - steps = _steps_of(detail) - step_automation = {s.get("step_order"): s.get("automation_id") for s in steps} - - # --- enrolments (best-effort, capped) --- - enrolments: list[dict[str, Any]] = [] - enrol_source = "unavailable" - for sub in ("enrolments", "enrollments"): - try: - enrolments = list(paginate(f"{queue_list_path}/{qid}/{sub}", - limit=100, max_pages=ENROLMENTS_PER_QUEUE_MAX_PAGES)) - enrol_source = "ok" - break - except BOSError: - continue - if enrol_source == "unavailable": - warnings.append(f"enrolments endpoint not reachable for {name} — funnel is step-only") - - # --- funnel from enrolment statuses --- - status_mix: dict[str, int] = {} - for e in enrolments: - st = _enrolment_status(e) - status_mix[st] = status_mix.get(st, 0) + 1 - enrolled = len(enrolments) - active = status_mix.get("active", 0) - completed = status_mix.get("completed", 0) - cancelled = status_mix.get("cancelled", 0) + status_mix.get("removed", 0) - completion_rate = round(completed / enrolled, 3) if enrolled else None - - # --- per-step reached counts (how many enrolments got to / past each step) --- - reached_by_step: dict[int, int] = {} - for e in enrolments: - prog = _enrolment_step(e) - for s in steps: - so = s.get("step_order") or 0 - if prog >= so: - reached_by_step[so] = reached_by_step.get(so, 0) + 1 - - # --- engagement per step automation, if we sampled email logs --- - # Filled by the caller via `engagement_by_automation`; placeholder here. - return { - "id": qid, - "name": name, - "is_active": bool(detail.get("is_active", detail.get("enabled", True))), - "step_count": len(steps), - "steps_raw": steps, - "step_automation": step_automation, - "funnel": { - "enrolled": enrolled, - "active": active, - "completed": completed, - "cancelled": cancelled, - "completion_rate": completion_rate, - "status_mix": status_mix, - "_source": enrol_source, - }, - "reached_by_step": reached_by_step, - "enrolments_raw": enrolments, - "warnings": warnings, - "url": f"https://app.trustpager.com/auto/queues/{qid}", - } - - -def _sample_engagement(quiet: bool) -> tuple[dict[str, dict[str, int]], str]: - """Bucket recent email logs by automation_id → {sends, opens, clicks}. Best-effort.""" - try: - logs_path = resolve_path("email", path_contains="logs") - except BOSError: - try: - logs_path = resolve_path("email-logs") - except BOSError: - return {}, "unavailable" - try: - logs = list(paginate(logs_path, limit=100, max_pages=EMAIL_LOG_SAMPLE_LIMIT // 100)) - except BOSError: - return {}, "unavailable" - - by_auto: dict[str, dict[str, int]] = {} - for lg in logs: - aid = lg.get("automation_id") or lg.get("source_automation_id") - if not aid: - continue - b = by_auto.setdefault(aid, {"sends": 0, "opens": 0, "clicks": 0}) - b["sends"] += 1 - if lg.get("opened_at") or lg.get("opened") or (lg.get("open_count") or 0) > 0: - b["opens"] += 1 - if lg.get("clicked_at") or lg.get("clicked") or (lg.get("click_count") or 0) > 0: - b["clicks"] += 1 - return by_auto, ("ok" if by_auto else "no_automation_linkage") - - -def _finalise_steps(q: dict[str, Any], engagement: dict[str, dict[str, int]]) -> None: - """Attach day labels, drop-off, and engagement rates to each step in place.""" - steps_out: list[dict[str, Any]] = [] - reached = q.pop("reached_by_step", {}) - prev_reached: int | None = None - biggest_drop = {"step_order": None, "dropped": 0, "day_label": None} - for s in q.pop("steps_raw", []): - so = s.get("step_order") or 0 - r = reached.get(so) - dropped_after = None - if prev_reached is not None and r is not None and prev_reached >= r: - dropped_after = prev_reached - r - if dropped_after > biggest_drop["dropped"]: - biggest_drop = {"step_order": so, "dropped": dropped_after, - "day_label": _day_label(s)} - aid = s.get("automation_id") - eng = engagement.get(aid) - open_rate = round(eng["opens"] / eng["sends"], 3) if eng and eng["sends"] else None - click_rate = round(eng["clicks"] / eng["sends"], 3) if eng and eng["sends"] else None - steps_out.append({ - "step_order": so, - "day_label": _day_label(s), - "automation_id": aid, - "reached": r, - "dropped_after": dropped_after, - "sends": eng["sends"] if eng else None, - "open_rate": open_rate, - "click_rate": click_rate, - }) - if r is not None: - prev_reached = r - q["steps"] = steps_out - q["leak_step"] = biggest_drop if biggest_drop["step_order"] is not None else None - q.pop("step_automation", None) - q.pop("enrolments_raw", None) - - -def fetch(quiet: bool, only_queue: str | None) -> dict[str, Any]: - now = now_utc() - log(SKILL, "resolving auto-queue endpoint...", quiet=quiet) - list_path = _resolve_any(QUEUE_RESOURCE_CANDIDATES, action="list") - - log(SKILL, "listing queues...", quiet=quiet) - queues = list(paginate(list_path, limit=100, max_pages=5)) - if only_queue: - queues = [q for q in queues if q.get("id") == only_queue] - - log(SKILL, f"{len(queues)} queue(s); sampling engagement...", quiet=quiet) - engagement, eng_source = _sample_engagement(quiet) - - log(SKILL, "building per-queue health...", quiet=quiet) - out_queues: list[dict[str, Any]] = [] - all_warnings: list[str] = [] - for q in queues: - health = _fetch_queue_health(list_path, q, quiet) - all_warnings.extend(health.pop("warnings", [])) - _finalise_steps(health, engagement) - out_queues.append(health) - - # headline - total_active = sum(q["funnel"]["active"] for q in out_queues) - total_completed = sum(q["funnel"]["completed"] for q in out_queues) - total_cancelled = sum(q["funnel"]["cancelled"] for q in out_queues) - leaks = [(q["name"], q.get("leak_step")) for q in out_queues if q.get("leak_step")] - biggest_leak = None - if leaks: - biggest_leak = max(leaks, key=lambda nl: nl[1]["dropped"]) - biggest_leak = {"queue": biggest_leak[0], **biggest_leak[1]} - - return { - "generated_at": now.isoformat(), - "headline": { - "queues": len(out_queues), - "total_active": total_active, - "total_completed": total_completed, - "total_cancelled": total_cancelled, - "biggest_leak": biggest_leak, - }, - "queues": out_queues, - "warnings": all_warnings, - "_sources": { - "queues": "ok" if out_queues else "empty", - "engagement": eng_source, - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", help="Suppress stderr progress logs") - parser.add_argument("--queue", metavar="ID", help="Only audit this queue id") - args = parser.parse_args() - try: - emit_json(fetch(quiet=args.json_only, only_queue=args.queue)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) - - -# ============================================================================= -# Output shape — what Claude reads from stdout -# ============================================================================= -# -# { -# "generated_at": "...", -# "headline": { -# "queues": 2, "total_active": 140, "total_completed": 33, -# "total_cancelled": 12, -# "biggest_leak": {"queue": "Reawakening", "step_order": 2, -# "day_label": "Day 7", "dropped": 48} -# }, -# "queues": [ -# { -# "id": "...", "name": "Reawakening Sequence", "is_active": true, -# "step_count": 7, -# "funnel": {"enrolled": 174, "active": 120, "completed": 30, -# "cancelled": 24, "completion_rate": 0.172, -# "status_mix": {...}, "_source": "ok"}, -# "steps": [ -# {"step_order": 1, "day_label": "Day 0", "automation_id": "...", -# "reached": 174, "dropped_after": null, -# "sends": 174, "open_rate": 0.62, "click_rate": 0.18}, -# {"step_order": 2, "day_label": "Day 7", "automation_id": "...", -# "reached": 126, "dropped_after": 48, "sends": 126, -# "open_rate": 0.41, "click_rate": 0.07} -# ], -# "leak_step": {"step_order": 2, "day_label": "Day 7", "dropped": 48}, -# "url": "https://app.trustpager.com/auto/queues/..." -# } -# ], -# "warnings": ["enrolments endpoint not reachable for X — funnel is step-only"], -# "_sources": {"queues": "ok", "engagement": "ok"} -# } diff --git a/skills/nurture-health/test-fixture.json b/skills/nurture-health/test-fixture.json deleted file mode 100644 index 6794848..0000000 --- a/skills/nurture-health/test-fixture.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "_comment": "INPUT fixture for tools/test-skill.py — runs nurture-health/fetch.py fully offline (mocks api_get + the catalog; no key, no network). Run with: BOS_OFFLINE=1 python tools/test-skill.py nurture-health. The DIGEST output shape is documented at the bottom of fetch.py.", - "catalog": { - "resources": [ - { - "id": "event-queues", - "endpoints": [ - {"method": "GET", "path": "/event-queues"}, - {"method": "GET", "path": "/event-queues/:id"} - ] - }, - { - "id": "email", - "endpoints": [ - {"method": "GET", "path": "/email/logs"} - ] - } - ] - }, - "responses": { - "event-queues": { - "data": [ - {"id": "q1", "name": "Test Reawakening Queue", "is_active": true} - ] - }, - "event-queues/q1": { - "data": { - "id": "q1", - "name": "Test Reawakening Queue", - "is_active": true, - "automation_event_queue_steps": [ - {"step_order": 1, "description": "Day 0 — Welcome", "automation_id": "a1", "delay_days": 0, "delay_hours": 0, "delay_minutes": 0}, - {"step_order": 2, "description": "Day 7 — AI builds your CRM", "automation_id": "a2", "delay_days": 7, "delay_hours": 0, "delay_minutes": 0} - ] - } - }, - "event-queues/q1/enrolments": { - "data": [ - {"status": "active", "last_completed_step_order": 1}, - {"status": "completed", "last_completed_step_order": 2}, - {"status": "cancelled", "last_completed_step_order": 1} - ], - "pagination": {"has_more": false} - }, - "email/logs": { - "data": [ - {"automation_id": "a1", "opened_at": "2026-06-01T00:00:00Z", "clicked_at": "2026-06-01T00:00:00Z"}, - {"automation_id": "a1", "opened_at": "2026-06-01T00:00:00Z"}, - {"automation_id": "a2"} - ], - "pagination": {"has_more": false} - } - } -} diff --git a/skills/outstanding-invoices/SKILL.md b/skills/outstanding-invoices/SKILL.md index 79e8494..cdc479f 100644 --- a/skills/outstanding-invoices/SKILL.md +++ b/skills/outstanding-invoices/SKILL.md @@ -21,37 +21,38 @@ You are giving the operator a clear picture of the money they're owed, and — i This skill builds on the reporting engine. Read [knowledge/reporting-method.md](../../knowledge/reporting-method.md) for the source/measure/dimension model and the "email any dashboard on a schedule" mechanism, and [knowledge/safeguards.md](../../knowledge/safeguards.md) for the approval-queue and synced-ledger rails. -## Step 1 — Fetch the AR picture +## Step 1 — Confirm the accounting integration (MCP call) -**Run the fetcher first.** It confirms the accounting integration is connected and queries the open-AR ledger (AUTHORISED, amount due > 0) into an aged summary plus the individual overdue invoices. +On the `trustpager` MCP server: -```bash -python ~/.claude/bos-run.py outstanding-invoices -``` +| Need | Tool | Args | +|---|---|---| +| Connected integrations | `list_integrations` | `limit: 50` | + +Find the accounting integration (provider/platform type = `xero`). It's **connected** if its status is one of `active` / `connected` / `authorized`. Keep its integration id. This is a free read. -(The `~/.claude/bos-run.py` launcher resolves the install location for you, so this runs from any folder.) The output shape is documented at the bottom of `fetch.py`. Branch on what it returns: +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". ## Step 2 — Branch on the integration state -### A. Not connected (`connected: false`) +### A. Not connected Tell the operator plainly and stop: > "Your accounting integration isn't connected yet, so there's nothing to report on. Connect it at https://app.trustpager.com/auto/integrations and re-run this." -### B. Connected but never seeded (`ledger_empty: true`) - -The integration is live but the receivables ledger hasn't been seeded — almost always a first run. Offer the **one-time catch-up sync** (it loads the existing invoices; after that the integration keeps the ledger live on its own — see the synced-ledger model in safeguards). +### B. Connected — read the receivables ledger -Run it via the `sync_receivables` MCP tool (or `POST /integrations//sync-receivables`) on the `integration_id` the fetcher returned. +> ⚠️ **Capability gap on this MCP surface.** The receivables ledger is a synced report source (`invoices`) that the original fetcher read via the report query engine and seeded with a `sync_receivables` call. On the client `trustpager` MCP surface those are **not exposed**: `query_report` only supports `source: "deals"` (sales pipeline — no `amount_due` / `aged_bucket` measures), and there is **no `sync_receivables` / `list_invoices` read tool**. So you cannot pull the aged AR ledger directly from MCP today. Options, in order: +> 1. Tell the operator the aged-summary read isn't available over the assistant connection yet, and point them to the in-app receivables view at https://app.trustpager.com/auto/integrations (and the Reporting section). +> 2. If they want it automated, you CAN still build the **daily emailed receivables dashboard** (Step 3) — the dashboard/report-card/schedule tools that drive it DO exist on this surface; the Invoices source is selected inside the report card, server-side. +> 3. Capture the gap with `/suggest-improvement` (a receivables read tool / `invoices` report source over MCP) so the team can ship it. -> ⚠️ **If the sync comes back queued for approval** (a `202` / `ApprovalPending` — some keys are approval-gated), hand it to the operator: "That's queued for approval — approve it at https://app.trustpager.com/settings/api?tab=approvals, then I'll pull your receivables." Do **not** retry or route around it. +When the in-app ledger (or a future read tool) gives you the numbers, present them as below. **Synced-ledger rails (safeguards §2):** seed once at onboarding, then it stays live on its own — never tell the operator to "keep re-syncing". "Days overdue" / aged buckets compute at query time, so a once-captured record keeps ageing correctly. -Once seeded, re-run Step 1 and continue to C. +### C. Present the picture -### C. Connected with data — present the picture - -Lead with the aged summary, then the most-overdue invoices. Keep it scannable: +Lead with the aged summary, then the most-overdue invoices. Order buckets `current` → `1-30` → `31-60` → `61-90` → `90+`. Keep it scannable: ``` 💰 Outstanding invoices — you're owed $[total_due] across [N] invoices @@ -68,21 +69,19 @@ Most overdue: → ... (and N more) ``` -Then offer the next move — usually one of: draft a chase message for the worst offenders, or **set up the daily emailed report** (Step 3). +Then offer the next move — usually: draft a chase message for the worst offenders, or set up the daily emailed report (Step 3). ## Step 3 — Offer the daily AR digest (the real prize) -If the operator wants this delivered automatically — "email it to me and my bookkeeper every morning" — wire it. This rides the same mechanism as the Team Task Digest (see reporting-method §5). Three pieces: +If the operator wants this delivered automatically — "email it to me and my bookkeeper every morning" — wire it on the `trustpager` server. This rides the same mechanism as the Team Task Digest (reporting-method §5). These are **writes** — follow the rails: confirm recipients + time first, journal each write to `.bos-journal.md`, and a `202`/`approval_id` means queued (surface the link, stop, don't retry). 1. **A dashboard** — `create_report_dashboard` named e.g. "Outstanding Invoices", then `add_report_card` twice using the Invoices / Receivables source: - a **bar** card: `amount_due` (sum) grouped by `aged_bucket`, filtered `status = AUTHORISED` and `amount_due > 0`. - a **table** card: the open invoices (invoice number, customer, due date, amount due, days overdue), same filter. -2. **A `send_report_email` action** pointing at that dashboard, with the operator's chosen recipients. Discover its exact config with `describe_action_type('send_report_email')` — don't guess the field names. -3. **An auto schedule** firing it on the operator's cadence. For "7am every weekday" that's cron `0 7 * * 1-5` in the operator's timezone. Discover the shape with `describe_resource('auto_schedule')`. - -Confirm recipients and the time before wiring, then build it. After it's live, tell the operator the first send lands at the next scheduled time and runs server-side — nothing needs to be open. +2. **A `send_report_email` action** pointing at that dashboard, with the operator's chosen recipients. Build it as an automation action via `add_automation_action` (`action_type: "send_report_email"`); confirm its exact config fields from the `add_automation_action` `config` description rather than guessing. +3. **An auto schedule** firing it on the operator's cadence — `create_auto_schedule`. For "7am every weekday" that's cron `0 7 * * 1-5` in the operator's timezone. -> Build each card's query with `query_report` first and confirm the numbers, *then* save the proven spec into the card. A typo'd filter field is silently ignored at render (see reporting-method §2/§4). +Confirm recipients and the time before wiring, then build it. After it's live, tell the operator the first send lands at the next scheduled time and runs server-side — nothing needs to be open. Journal each created dashboard / card / action / schedule. ## Output format @@ -96,7 +95,7 @@ Aged summary first (it's the headline), then the worst invoices, then one concre ## What to never do -- ❌ Don't fabricate or estimate balances — only report what the ledger returns. If it's empty, seed it (B), don't guess. +- ❌ Don't fabricate or estimate balances — only report what the ledger returns. If you can't read it over MCP, say so (Step 2B) — don't guess. - ❌ Don't send a chase message without drafting it and getting approval first. - ❌ Don't bypass an approval `202` — surface the approval link and wait (safeguards §1). - ❌ Don't tell the operator to "keep re-syncing" — the ledger stays live on its own after the one-time seed (safeguards §2). @@ -104,12 +103,12 @@ Aged summary first (it's the headline), then the worst invoices, then one concre ## Common follow-ups -- "Draft a reminder for the 90+ ones" → draft per-customer chase messages, show them, then `send_email` / `send_sms` on approval. +- "Draft a reminder for the 90+ ones" → draft per-customer chase messages, show them, then `send_email` / `send_sms` on approval (journal each). - "Email this to me and Anna every morning" → Step 3. -- "Just the ones over $1,000" → re-query with an added `amount_due gt 1000` filter. -- "How much is genuinely overdue vs just current?" → it's already split; the non-`current` buckets are the overdue total. +- "Just the ones over $1,000" → add an `amount_due gt 1000` filter to the dashboard cards. +- "How much is genuinely overdue vs just current?" → the non-`current` buckets are the overdue total. ## When this skill should NOT fire -- The operator asks about a single invoice or a single customer's balance — answer that directly (a filtered `query_report`), don't run the whole AR sweep. +- The operator asks about a single invoice or a single customer's balance — answer that directly, don't run the whole AR sweep. - They're asking about *payments they owe* (accounts payable / bills) — this source is receivables (money in), not payables. diff --git a/skills/outstanding-invoices/fetch.py b/skills/outstanding-invoices/fetch.py deleted file mode 100644 index 186cac8..0000000 --- a/skills/outstanding-invoices/fetch.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""outstanding-invoices — pre-fetch the accounts-receivable picture. - -Confirms the accounting integration is connected, then queries the -Invoices / Receivables report source for the open-AR ledger -(status = AUTHORISED, amount_due > 0): an aged-bucket summary plus the -individual overdue invoices, most-overdue first. - -If the integration isn't connected, returns a structured "not_connected" -state. If it's connected but the receivables ledger has never been seeded -(zero rows), returns "ledger_empty" with the integration id so the skill -can offer the one-time catch-up sync. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/outstanding-invoices/fetch.py - python skills/outstanding-invoices/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, api_post, emit_error_and_exit, emit_json, - force_utf8_stdout, log, now_utc, resolve_path, -) - -SKILL = "outstanding-invoices" - -# Display order for aged buckets (the report returns them unordered). -BUCKET_ORDER = ["current", "1-30", "31-60", "61-90", "90+"] - -# The open-AR filter: authorised (unpaid / part-paid) invoices with a balance. -OPEN_AR_FILTERS = [ - {"field": "status", "operator": "eq", "value": "AUTHORISED"}, - {"field": "amount_due", "operator": "gt", "value": 0}, -] - - -def _rows(resp: Any) -> list[dict[str, Any]]: - """Pull the rows array out of a query_report response, tolerant of wrapping.""" - if not isinstance(resp, dict): - return [] - payload = resp.get("data", resp) - if isinstance(payload, dict): - return payload.get("rows") or [] - return [] - - -def fetch(quiet: bool) -> dict[str, Any]: - now = now_utc() - log(SKILL, "checking the accounting integration...", quiet=quiet) - - integrations = api_get(resolve_path("integrations"), limit=50).get("data") or [] - xero = next( - (i for i in integrations - if (i.get("platform_type") or i.get("provider") or "").lower() == "xero"), - None, - ) - connected = bool(xero) and (xero.get("status") or "").lower() in { - "active", "connected", "authorized" - } - - if not connected: - return { - "generated_at": now.isoformat(), - "connected": False, - "status": (xero or {}).get("status") if xero else "not_installed", - "next_step": "Connect your accounting integration at " - "https://app.trustpager.com/auto/integrations, then re-run.", - } - - integration_id = xero.get("id") - log(SKILL, "querying the receivables ledger...", quiet=quiet) - - # Aged summary: amount due + invoice count, grouped by aged bucket. - agg = api_post("reports/query", body={ - "source": "invoices", - "measures": [ - {"field": "amount_due", "aggregation": "sum", "alias": "total_due"}, - {"field": "id", "aggregation": "count", "alias": "invoices"}, - ], - "dimensions": ["aged_bucket"], - "filters": OPEN_AR_FILTERS, - "mode": "aggregate", - }) - agg_rows = _rows(agg) - - # Ledger empty -> needs the one-time seed. Offer it from the skill. - if not agg_rows: - return { - "generated_at": now.isoformat(), - "connected": True, - "integration_id": integration_id, - "ledger_empty": True, - "next_step": "The receivables ledger has no open invoices yet. If this is " - "the first run, seed it once via sync_receivables on integration " - f"{integration_id} (POST /integrations/{integration_id}/sync-receivables).", - } - - # Normalise the aged summary into a fixed bucket order + grand totals. - by_bucket = {r.get("aged_bucket"): r for r in agg_rows} - buckets = [] - total_due = 0.0 - total_count = 0 - for name in BUCKET_ORDER: - row = by_bucket.get(name) or {} - due = float(row.get("total_due") or 0) - cnt = int(row.get("invoices") or 0) - buckets.append({"bucket": name, "amount_due": round(due, 2), "invoices": cnt}) - total_due += due - total_count += cnt - - log(SKILL, "pulling the open invoices...", quiet=quiet) - - # Drilldown: the actual open invoices, most-overdue first (source default order). - drill = api_post("reports/query", body={ - "source": "invoices", - "mode": "drilldown", - "measures": [{"field": "id", "aggregation": "count"}], - "filters": OPEN_AR_FILTERS, - "limit": 25, - }) - open_invoices = [ - { - "invoice_number": r.get("invoice_number"), - "customer": r.get("customer_name"), - "due_date": r.get("due_date"), - "amount_due": r.get("amount_due"), - "days_overdue": r.get("days_overdue"), - "aged_bucket": r.get("aged_bucket"), - "currency": r.get("currency_code"), - } - for r in _rows(drill) - ] - - return { - "generated_at": now.isoformat(), - "connected": True, - "integration_id": integration_id, - "ledger_empty": False, - "summary": { - "total_due": round(total_due, 2), - "total_open_invoices": total_count, - "by_bucket": buckets, - }, - "open_invoices": open_invoices, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - try: - emit_json(fetch(quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) - - -# ============================================================================= -# Output shape — what Claude reads from stdout -# ============================================================================= -# -# Not connected: -# {"connected": false, "status": "not_installed", "next_step": "..."} -# -# Connected but never seeded: -# {"connected": true, "integration_id": "...", "ledger_empty": true, "next_step": "..."} -# -# Connected with data: -# { -# "connected": true, "integration_id": "...", "ledger_empty": false, -# "summary": { -# "total_due": 8999.10, "total_open_invoices": 7, -# "by_bucket": [ -# {"bucket": "current", "amount_due": 8023.40, "invoices": 5}, -# {"bucket": "1-30", "amount_due": 425.70, "invoices": 1}, -# {"bucket": "31-60", "amount_due": 0, "invoices": 0}, -# {"bucket": "61-90", "amount_due": 0, "invoices": 0}, -# {"bucket": "90+", "amount_due": 550.00, "invoices": 1} -# ] -# }, -# "open_invoices": [ -# {"invoice_number": "INV-0053", "customer": "...", "due_date": "2026-02-24", -# "amount_due": 550, "days_overdue": 97, "aged_bucket": "90+", "currency": "AUD"} -# ] -# } diff --git a/skills/outstanding-invoices/test-fixture.json b/skills/outstanding-invoices/test-fixture.json deleted file mode 100644 index 3943eef..0000000 --- a/skills/outstanding-invoices/test-fixture.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "responses": { - "integrations": { - "data": [{"id": "int-1", "platform_type": "stripe", "status": "active"}] - } - } -} diff --git a/skills/prep-for-call/SKILL.md b/skills/prep-for-call/SKILL.md index 6280b9e..f6e6290 100644 --- a/skills/prep-for-call/SKILL.md +++ b/skills/prep-for-call/SKILL.md @@ -23,7 +23,7 @@ Resolve which call, in this order: - **"My 2pm" / "next call" / "today's call"** → `mcp__trustpager__list_bookings` for today, pick the one they mean (confirm if several). `get_booking(id)` for the attendee + linked opportunity. -- **A named person / company** → `search_contacts` / `search_opportunities` to +- **A named person / company** → `search_contacts` / `search_deals` to find the opportunity. The **opportunity is the hub** — once you have its id, everything else hangs off @@ -33,14 +33,14 @@ it. If there's genuinely no opportunity (cold first call), prep off the contact ## Step 2 — Pull the picture (parallel reads) For the opportunity + its contact, gather: -- `get_opportunity(id)` — stage, value, type, owner, custom fields. -- `get_opportunity_activities(id)` — the recent history (calls, emails, notes). +- `get_deal(id)` — stage, value, type, owner, custom fields. +- `get_deal_activities(id)` — the recent history (calls, emails, notes). - `list_transcripts` for this deal/contact — **the last call's transcript is the single most valuable input**; skim it for what was promised and where it left off. -- `get_opportunity_tasks(id)` — what's open / owed to them. +- `get_deal_tasks(id)` — what's open / owed to them. - `get_contact(id)` — name, role, contact details, relationship age. -- `get_opportunity_products(id)` — what's been quoted, if anything. +- `get_deal_products(id)` — what's been quoted, if anything. Skip cleanly what isn't there; don't stall on a missing piece. diff --git a/skills/remember/SKILL.md b/skills/remember/SKILL.md new file mode 100644 index 0000000..32bfc32 --- /dev/null +++ b/skills/remember/SKILL.md @@ -0,0 +1,79 @@ +--- +name: Remember +description: Save, update, or forget a long-term memory about this business — how the operator likes things done, soft context the CRM doesn't hold, a recurring quirk — so Claude carries it into future sessions. Writes one fact per file into ./.bos-memory/ and keeps the MEMORY.md index current. Use when the operator says "remember that…", "from now on…", "note that I prefer…", or when you've just learned something durable worth keeping. +triggers: + - remember that + - remember this + - from now on + - note that I prefer + - keep in mind + - forget that + - update what you know about + - what do you remember +--- + +# Remember + +The full model — where memory lives, how recall works, what's worth saving, and the rails — is in `knowledge/memory-and-feedback.md`. Read it if you haven't this session. This skill is the **write/update/delete** path; recall is automatic via the `CLAUDE.md` Memory section. + +The store is `./.bos-memory/` in the operator's project folder: an index `MEMORY.md` plus one `.md` per fact. + +## Step 1 — Decide if it belongs in memory at all + +Apply the test from the knowledge doc: *would a sharp 2IC carry this into next week, and does the CRM not already hold it?* + +- If it's a CRM fact (a phone number, deal value, due date, stage) → **put it on the record**, not in memory. Tell the operator that's where it went. +- If it's transient ("drafting the Jones email") → don't save it. +- If it's a secret (key, password, full bank/card number) → **refuse to store the secret**; offer to store a pointer to where it lives instead. +- Otherwise, classify it: `business`, `preference`, `workflow`, `contact`, or `reference`. + +## Step 2 — Check for an existing memory first + +Read `./.bos-memory/MEMORY.md` (create the folder + an empty index if neither exists). Scan the index for a line whose description already covers this fact. + +- **Match found** → open that `.md` and **update it** rather than creating a near-duplicate. +- **No match** → you'll create a new file in Step 3. + +## Step 3 — Write the memory + +For a **new** memory, pick a short kebab-case slug and write `./.bos-memory/.md`: + +```markdown +--- +name: +description: +type: business | preference | workflow | contact | reference +--- + + +``` + +Then add a one-line pointer to `./.bos-memory/MEMORY.md`: + +``` +- [Title](.md) — short hook +``` + +For an **update**, edit the file's body (and its `description`/index line if the gist changed). For a **forget**, delete the `.md` and remove its index line. + +## Step 4 — Confirm in one line + +Tell the operator plainly what you did, so nothing accumulates behind their back: + +- `🧠 Saved — "We never quote over the phone, always a written quote first" (business).` +- `🧠 Updated what I know about Dave at BuildCo.` +- `🧠 Forgotten — dropped the old after-hours rule.` +- `That belongs on the opportunity record, not memory — I've put it there instead.` + +## Hard rules + +- ❌ Never store secrets — store a pointer, never the credential itself. +- ❌ Never duplicate the CRM — TrustPager is the source of truth; memory is for what it doesn't hold. +- ❌ Never let the index hold content — `MEMORY.md` is one line per memory, full stop. +- ✅ One fact per file. Update the matching file instead of adding a twin. Delete what's proven wrong. +- ✅ The store is the operator's — local, plain Markdown, theirs to edit or wipe. Surface every save/update/delete in one line. +- ✅ Save proactively when you learn something durable — don't wait to be told "remember this". + +## Output shape + +A single confirmation line (saved / updated / forgotten / redirected-to-CRM). No essay. If you saved something the operator didn't explicitly ask you to, say so in the same line so they can correct it. diff --git a/skills/send-email/SKILL.md b/skills/send-email/SKILL.md index 8763751..cfd2a0c 100644 --- a/skills/send-email/SKILL.md +++ b/skills/send-email/SKILL.md @@ -14,36 +14,43 @@ triggers: # /send-email -Every outbound email needs to be in your tone, on the right thread, signed correctly, and reviewed before it goes. This skill wraps `mcp__trustpager__send_email` with all of that — you say "email Sarah about the quote" and you get a draft, not a fait accompli. +Every outbound email needs to be in your tone, on the right thread, signed correctly, and reviewed before it goes. This skill wraps `send_email` (on the `trustpager` MCP server) with all of that — you say "email Sarah about the quote" and you get a draft, not a fait accompli. ## Step 1 — Identify the recipient + reason -If the user didn't say WHO and WHAT: -- WHO: ask for name, email, or opportunity. Same lookup logic as /log-this-call. +If the operator didn't say WHO and WHAT: +- WHO: ask for name, email, or opportunity, then resolve the contact with `search_contacts` (`search` / `email` / `phone`, `limit: 5`) on the `trustpager` server — same lookup logic as `/log-this-call`. - WHAT: ask for the reason in 1-2 sentences. Use this as the brief, not the message. -If the user said "follow up on the quote" but you don't see a quote in the opportunity, ask: "I don't see a quote attached to this opp — is the quote elsewhere, or are we asking about it?" +If the operator said "follow up on the quote" but you don't see a quote on the opportunity, ask: "I don't see a quote attached to this opp — is the quote elsewhere, or are we asking about it?" -## Step 2 — Pull context +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -Once you have the contact (and optionally opportunity), run: +## Step 2 — Pull context (parallel MCP reads) -``` -python ~/.claude/bos-run.py send-email --contact-id [--opportunity-id ] -``` +Once you have the contact id (and optionally the opportunity id), pull everything you need to draft well in one parallel batch off the `trustpager` server. All free reads: -The returned JSON gives you everything you need to draft well: the contact, the opportunity (if linked), every recent email thread WITH this contact, the last few sent emails by the workspace (for tone calibration), and the active email config. +| Need | Tool | Args | +|---|---|---| +| The contact's full record | `get_contact` | `id: ` | +| Recent threads WITH this contact (reply vs. new) | `list_email_threads` | `contact_id: `, `limit: 5` | +| Recent sent emails (tone calibration) | `list_email_threads` | `direction: "outbound"`, `limit: 5`, sorted by last message desc | +| Which sender + signature to use | `get_email_capabilities` | — | +| Available email configs | `list_email_configs` | — | +| The opportunity (only if one is linked) | `get_deal` | `id: ` | -If there's an existing thread with this contact, REPLY to that thread (use `mcp__trustpager__reply_to_email`). Don't start a new thread unless asked. +Pick the **active email config**: the one flagged default, else the first returned. + +If there's an existing thread with this contact, **REPLY to that thread** with `reply_to_email`. Don't start a new thread unless asked. ## Step 3 — Draft Body rules: -- **First-name address** ("Hi Sarah,") never "Hi there" -- **Reference one concrete thing** from the context — the opp name, last call, last email, what they asked about -- **One main message** per email. If the user wants 3 things said, ask if they should be separate emails or numbered points. -- **One clear ask at the end** — never "Let me know your thoughts!" or "Looking forward to your response!" -- **Match the workspace tone** — short, no-jargon, no buzzwords, no exclamation points. Read 3 recent sent emails first. +- **First-name address** ("Hi Sarah,") never "Hi there". +- **Reference one concrete thing** from the context — the opp name, last call, last email, what they asked about. +- **One main message** per email. If the operator wants 3 things said, ask if they should be separate emails or numbered points. +- **One clear ask at the end** — never "Let me know your thoughts!" or "Looking forward to your response!". +- **Match the workspace tone** — short, no jargon, no buzzwords, no exclamation points. Read the 3 recent sent emails first. Subject line: - If replying: use the existing subject ("Re: ..."). @@ -55,21 +62,26 @@ Signature: ## Step 4 — Show and approve -Show the user: +This is an outbound send — follow the rails in `knowledge/safeguards.md`. Show the operator: - To, CC, BCC (if any) - Subject - Body - "Send via {email_config_name} (sender: {your_email})" -Wait for explicit yes/no. On yes, `mcp__trustpager__send_email`. On no, ask what to change. +Wait for explicit yes/no. Before sending, a quick `list_email_threads` check confirms you're not duplicating a message already sent (idempotency — never blind-send). On yes: +- New thread → `send_email`; replying to an existing thread → `reply_to_email`. +- If the send returns `202` / `approval_id`, surface the approvals link and stop — don't retry (safeguards §1). +- Append one line to `.bos-journal.md` (timestamp, tool, outcome, id, `skill: send-email`). + +On no, ask what to change. ## Important behaviours - **One email per /send-email invocation.** Don't queue up a batch. - **Internal CCs are not implicit.** If the opp has other assigned users, ASK before CCing them. -- **Attachments.** If the user mentions an attachment ("send the quote"), look for opportunity files first — `mcp__trustpager__list_opportunity_files`. If found, include the right one. If not found, ASK before drafting. +- **Attachments.** If the operator mentions an attachment ("send the quote"), look for opportunity files first — `list_files` (filtered to the opportunity). If found, include the right one. If not found, ASK before drafting. - **No vague pronouns.** "The quote" must resolve to a specific file. "Your account manager" must resolve to a named person. -- **Quiet hours.** Before 7am / after 8pm in recipient timezone (or unknown) → ask "send now, or schedule for 8am?" Use `mcp__trustpager__schedule_communication` if scheduling. +- **Quiet hours.** Before 7am / after 8pm in recipient timezone (or unknown) → ask "send now, or schedule for 8am?" Use `schedule_communication` if scheduling (also a write — journal it). ## Output shape diff --git a/skills/send-email/fetch.py b/skills/send-email/fetch.py deleted file mode 100644 index 25eb4f3..0000000 --- a/skills/send-email/fetch.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -"""send-email — gather all context needed to draft a personalised email. - -Given a contact (and optionally an opportunity) we want to send to, pull: -- The contact's full record -- The opportunity (if linked) -- Recent email threads with this contact (so we don't start a new thread - when we should reply to one) -- The last few sent emails by ANY user in the workspace (for tone - calibration — Claude reads them before drafting) -- Email capabilities + active email config (which sender + signature) - -One bulk call instead of 5 sequential ones. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/send-email/fetch.py --contact-id - python skills/send-email/fetch.py --contact-id --opportunity-id -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, resolve_path, -) - - -SKILL = "send-email" - - -def fetch(contact_id: str, opportunity_id: str | None, quiet: bool) -> dict[str, Any]: - now = now_utc() - log(SKILL, f"gathering send context for contact {contact_id}...", quiet=quiet) - - # Resolve listable endpoints via the catalog (resolves correctly across - # any future API rename). The :id-templated ones are interpolated raw. - threads_path = resolve_path("email", path_contains="threads") - configs_path = resolve_path("email", path_contains="configs") - - calls = [ - (f"contacts/{contact_id}", {}), - (threads_path, {"contact_id": contact_id, "limit": 5}), - ("email/capabilities", {}), - (configs_path, {}), - ] - if opportunity_id: - calls.append((f"opportunities/{opportunity_id}", {})) - - results = parallel_get(calls) - - contact = results.get(f"contacts/{contact_id}", {}) - threads_with_contact = (results.get(threads_path, {}).get("data") or []) - capabilities = results.get("email/capabilities", {}) - configs = results.get(configs_path, {}).get("data") or [] - opportunity = results.get(f"opportunities/{opportunity_id}", {}) if opportunity_id else None - - # Second call for "recent sent across workspace" — different params, same path - recent_sent_resp = api_get(threads_path, limit=5, direction="outbound", - sort="last_message_at", order="desc") - recent_sent = recent_sent_resp.get("data") or [] - - active_config = next((c for c in configs if c.get("is_default")), None) or (configs[0] if configs else None) - - return { - "generated_at": now.isoformat(), - "contact": contact.get("data") if isinstance(contact, dict) and "data" in contact else contact, - "opportunity": opportunity.get("data") if opportunity and isinstance(opportunity, dict) and "data" in opportunity else opportunity, - "recent_threads_with_contact": [ - { - "id": t.get("id"), - "subject": t.get("subject"), - "last_message_at": t.get("last_message_at"), - "message_count": t.get("message_count"), - "we_replied": t.get("we_replied"), - } - for t in threads_with_contact - ], - "recent_sent_by_workspace": [ - { - "id": t.get("id"), - "subject": t.get("subject"), - "last_message_at": t.get("last_message_at"), - "to": t.get("contact_email") or t.get("to_email"), - } - for t in recent_sent - ], - "email_capabilities": capabilities, - "active_email_config": active_config, - "headline": { - "existing_thread_with_contact": len(threads_with_contact) > 0, - "recent_sent_sampled": len(recent_sent), - "has_email_config": active_config is not None, - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--contact-id", required=True, help="Contact UUID to gather context for") - parser.add_argument("--opportunity-id", default=None, help="Optional opportunity UUID") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(args.contact_id, args.opportunity_id, quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/send-email/test-fixture.json b/skills/send-email/test-fixture.json deleted file mode 100644 index e1cbd6e..0000000 --- a/skills/send-email/test-fixture.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "args": ["--contact-id", "contact-1"], - "responses": { - "contacts/contact-1": {"id": "contact-1", "first_name": "Test", "last_name": "User", "email": "test@example.com"}, - "email/threads": {"data": [], "pagination": {"has_more": false}}, - "email/capabilities": {"can_send": true}, - "email/configs": {"data": [{"id": "cfg-1", "is_default": true, "from_email": "you@yourdomain.com"}]} - } -} diff --git a/skills/send-for-signing/SKILL.md b/skills/send-for-signing/SKILL.md index 206e27e..97b686e 100644 --- a/skills/send-for-signing/SKILL.md +++ b/skills/send-for-signing/SKILL.md @@ -25,10 +25,10 @@ Source of truth: [`knowledge/document-method.md`](../../knowledge/document-metho Before any `send_for_signing` call, you need all three: 1. **The template** — its `template_id`. List with - `mcp__trustpager__list_document_templates` if the operator named it by title. + `list_document_templates` if the operator named it by title. If the template doesn't exist yet, redirect to `/build-document`. 2. **The opportunity** — the `deal_id` the document attaches to. Search with - `mcp__trustpager__search_opportunities` if you only have a name. The envelope + `search_deals` if you only have a name. The envelope and all tracking thread onto this opportunity. 3. **The signers** — an array of `{name, email}`. Confirm spelling and order out loud with the operator. For multi-signer documents, confirm who's signer @@ -54,7 +54,7 @@ recommend it — method §5. ## Step 2 — Send ``` -mcp__trustpager__send_for_signing( +send_for_signing( template_id=..., deal_id=..., signers=[{name, email}, ...], diff --git a/skills/show-me-how/SKILL.md b/skills/show-me-how/SKILL.md index 3414f04..772ab9d 100644 --- a/skills/show-me-how/SKILL.md +++ b/skills/show-me-how/SKILL.md @@ -17,30 +17,32 @@ triggers: Customers want to learn by doing, not by reading documentation. This skill turns "how do I X?" into a hands-on walkthrough — searches the TrustPager help center, summarizes the answer, links to the specific page in their workspace, and offers to drive the steps if they want. -## Step 1 — Pre-fetch + search the help center +## Step 1 — Pull the data (parallel MCP calls) -First, run: +Fire these reads in parallel in a single batch. They're all reads — free and fast. Use the `trustpager` MCP server. -``` -python ~/.claude/bos-run.py show-me-how --query "" -``` +| Need | Tool | Args | +|---|---|---| +| Canonical published tutorial articles | `search_help_center` | `query: ""` | +| Workspace AI instructions (may carry workflow guidance that supersedes the generic answer) | `get_ai_instructions` | — | +| The team's own training canvases (Learning Hub) matching the topic | `list_training_canvases` | `limit: 20`, `search: ""` | -This pre-fetches the workspace's AI instructions (which sometimes contain workflow guidance that supersedes the generic answer) and any matching custom training canvases the customer's team has built (Learning Hub). +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -Then, in the same turn, call `mcp__trustpager__search_help_center` for the canonical published articles. This returns matching published tutorial articles. Always do BOTH — the platform team writes the canonical answer, the workspace may have its own overlay, and our job is to surface both. +Always do all three — the platform team writes the canonical answer, the workspace may have its own overlay (AI instructions + custom training canvases), and our job is to surface both. Training-canvas links are `https://app.trustpager.com/training/learning-hub/{id}`. -If 0 results: +If `search_help_center` returns 0 results: > "No published article on that exact topic. Let me figure it out from first principles — give me 30 seconds…" -> Then use `mcp__trustpager__describe_resource` and `mcp__trustpager__describe_action_type` to construct an answer. +> Then construct an answer from the AI instructions, the matched training canvases, and what you know of the platform. (Note: `describe_resource` / `describe_action_type` are only available on the claude.ai-connected workspaces, not the client `trustpager` server — don't rely on them here.) -If multiple results, pick the most relevant (best title match) and offer the rest as "related": +If multiple help-center results, pick the most relevant (best title match) and offer the rest as "related": > "Closest article: '[title]'. Also possibly relevant: '[a]', '[b]'. Want me to walk through the closest, or one of the others?" ## Step 2 — Summarize, don't dump Don't paste the whole article. Distill it to: - **The steps** (numbered, ≤ 8 of them) -- **The URL to the actual workspace page** they need (e.g. `https://app.trustpager.com/settings/pipelines`) +- **The URL to the actual workspace page** they need (e.g. `https://app.trustpager.com/settings/crm`) - **One gotcha** they'll hit (the one footnote in the article that everyone misses) Format example: @@ -60,7 +62,7 @@ Want me to add it for you now? (Just say the source name.) ## Step 3 — Offer to drive End with a single offer: -- For configuration tasks → "Want me to do this for you?" → if yes, execute via the appropriate `mcp__trustpager__*` tool. +- For configuration tasks → "Want me to do this for you?" → if yes, execute via the appropriate `trustpager` MCP write tool. Any write follows the rails in `knowledge/safeguards.md` (confirm before it lands, journal it to `.bos-journal.md`, search-first so you don't duplicate). - For navigation ("show me where my opps are") → just give the URL and explain what they'll see. - For analysis tasks → "Want me to run `/audit-pipeline` (or relevant tool) to show you what's there now?" @@ -78,7 +80,7 @@ NEVER drive without explicit yes. The offer is the offer — the user opts in. - **"How do I delete my account?"** → Don't answer with a how-to. Say "I can help you understand what's in your workspace first — want me to summarize that? If you still want to delete, reach the team directly." Deletion is a deliberate decision, not a CLI fact. - **"Where do I find X?"** with an obvious answer → just give the URL + one line. No 8-step walkthrough for "where's my contacts list". -- **"How do I [feature that doesn't exist]?"** → "TrustPager doesn't have [feature X] yet — the closest thing is [Y]. Want me to file a feature request with the team?" Offer `mcp__trustpager__create_service_request`. +- **"How do I [feature that doesn't exist]?"** → "TrustPager doesn't have [feature X] yet — the closest thing is [Y]. Want me to log it for the team?" On a yes, run `/suggest-improvement` (it tags and de-dupes the request properly — see `knowledge/memory-and-feedback.md`), rather than calling `create_service_request` raw. ## Output shape diff --git a/skills/show-me-how/fetch.py b/skills/show-me-how/fetch.py deleted file mode 100644 index 791f3e2..0000000 --- a/skills/show-me-how/fetch.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""show-me-how — pre-fetch help-center search results + workspace context. - -The help-center search itself is a TrustPager MCP tool (search_help_center) -that doesn't have a public REST equivalent. So the SKILL.md still calls -that via MCP. What this script CAN do up front: - -- Fetch the workspace's own training canvases (Learning Hub) for the - topic — "here's also what's in your own training materials." -- Fetch AI instructions, which sometimes contain workflow guidance - that supersedes the generic help-center answer. - -Output is a thin context bundle the skill merges with the live MCP -search_help_center result. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/show-me-how/fetch.py --query "add a new lead source" -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, -) - - -SKILL = "show-me-how" - - -def fetch(query: str, quiet: bool) -> dict[str, Any]: - now = now_utc() - log(SKILL, f"pre-fetching context for '{query}'...", quiet=quiet) - - calls = [ - ("ai-instructions", {}), - ("training-canvases", {"limit": 20, "search": query}), - ] - results = parallel_get(calls) - ai_instructions = results.get("ai-instructions", {}) - canvases_resp = results.get("training-canvases", {}) - canvases = canvases_resp.get("data") or [] - - return { - "generated_at": now.isoformat(), - "query": query, - "ai_instructions": ai_instructions, - "workspace_training_canvases": [ - { - "id": c.get("id"), - "name": c.get("name"), - "description": c.get("description"), - "url": f"https://app.trustpager.com/training/learning-hub/{c.get('id')}", - "card_count": c.get("card_count"), - } - for c in canvases - ], - "headline": { - "training_canvases_matched": len(canvases), - "ai_instructions_available": bool(ai_instructions), - "next_step": "Call mcp__trustpager__search_help_center for the canonical articles.", - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--query", required=True, help="The how-to question") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(args.query, quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/show-me-how/test-fixture.json b/skills/show-me-how/test-fixture.json deleted file mode 100644 index 04d5f94..0000000 --- a/skills/show-me-how/test-fixture.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "args": ["--query", "how do I send an email"], - "responses": { - "ai-instructions": {"instructions": "Test workspace AI instructions."}, - "training-canvases": {"data": []} - } -} diff --git a/skills/signing-radar/SKILL.md b/skills/signing-radar/SKILL.md index 8ea507c..208d581 100644 --- a/skills/signing-radar/SKILL.md +++ b/skills/signing-radar/SKILL.md @@ -27,22 +27,37 @@ unopened for a week. This is the regular check-up on every envelope. Source of truth: [`knowledge/document-method.md`](../../knowledge/document-method.md) — §3 (the envelope lifecycle) and §4 (the open/sign signals). -## Step 1 — Fetch the digest +## Step 1 — Pull the data (MCP call) -```bash -python ~/.claude/bos-run.py signing-radar -``` +Use the `trustpager` MCP server. One read, paginated: + +| Need | Tool | Args | +|---|---|---| +| Every signing envelope | `list_signing_envelopes` | `limit: 100` (page through until exhausted — up to ~20 pages) | + +This is a read — free, nothing journaled, no approval. Everything below is computed against **now**. + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". (Envelopes carry a `deal_id` linking them to an opportunity.) + +## Step 2 — Build the funnel + buckets -One call: lists every envelope, computes the funnel, and buckets the follow-ups -(opened-not-signed, sent-never-opened-stale, declined, recently completed), -oldest-first. Pass `--stale-days N` to change when "sent but unopened" counts as -stale (default 5). +For each envelope, read its `status` (lowercased) and compute **age in days** from `sent_at` (fall back to `created_at`, then `updated_at`). -If the script can't run (auth/network), fall back to -`mcp__trustpager__list_signing_envelopes` — but that's the raw list with no -funnel; prefer the script. +Tally the funnel by status, and sort each envelope into a bucket: -## Step 2 — Present, hottest follow-up first +- **Opened, not signed** (status is `viewed` or `opened`) → the follow-up GOLD bucket. +- **Sent, never opened, going stale** (status is `sent` AND age ≥ the stale threshold — **default 5 days**, adjustable if the operator asks) → chase-or-it-dies bucket. +- **Signed** (status `signed`) → count in the funnel. +- **Completed** (status `completed`) → count in the funnel; if age ≤ 7 days, also list under "recently completed". +- **Declined** (status `declined`) → declined bucket; capture `decline_reason` if present. +- **Voided** (status `voided`) / **Expired** (status `expired`) → count in the funnel only. +- Anything else → an "other" funnel tally. + +For each bucketed row capture: envelope id, document title (`document_title`, else `template_name`, else "(untitled)"), linked opportunity (`deal_id`), status, age in days, and signer (`signer_name` / `signer_email`). + +**Sort both follow-up buckets oldest-first** (largest age first) — most urgent at the top. + +## Step 3 — Present, hottest follow-up first Lead with the people to act on, then the funnel, then the dead ones. @@ -63,13 +78,14 @@ Lead with the people to act on, then the funnel, then the dead ones. ✅ Completed this week: 6 ``` -## Step 3 — Offer the follow-up actions (with approval) +## Step 4 — Offer the follow-up actions (with approval) + +Anything that **writes** follows the rails in `knowledge/safeguards.md` — confirm before it lands, journal the write to `.bos-journal.md`, and search-first so you don't double up. For each hot envelope, offer the next step — one at a time, with a yes: -For each hot envelope, offer the next step — one at a time, with a yes: -- **Nudge / resend** an unopened-stale envelope → `mcp__trustpager__resend_signing_envelope(envelope_id)`. +- **Resend / nudge** an unopened-stale envelope → `update_signing_envelope` with `action: "resend"` (and the `envelope_id`). - **Draft a follow-up** to an opened-not-signed signer → hand to `/draft-reply` (reference the open: "saw you had a look at the agreement…"). -- **Void** a dead/superseded envelope → `void_signing_envelope(envelope_id)` — +- **Void** a dead/superseded envelope → `update_signing_envelope` with `action: "void"` — name it and get a yes; voids can't be undone (method §6). For "I want this to happen automatically every time someone opens a document", diff --git a/skills/signing-radar/fetch.py b/skills/signing-radar/fetch.py deleted file mode 100644 index 87b056a..0000000 --- a/skills/signing-radar/fetch.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -"""signing-radar — pull every signing envelope into one funnel + follow-up digest. - -Owners send documents for signing and then lose track of who's where. This -fetcher returns a single JSON document Claude turns into a follow-up report: -the sent → opened → signed funnel, plus the two follow-up-gold buckets — -"opened but not signed" (engaged, holding) and "sent but never opened, going -stale" (chase or it dies). - -All read-only (list_signing_envelopes). Auth: TRUSTPAGER_API_KEY env var or -~/.claude/bos.json. - -Usage: - python skills/signing-radar/fetch.py - python skills/signing-radar/fetch.py --stale-days 5 - python skills/signing-radar/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parse_iso, days_since, resolve_path, -) - -SKILL = "signing-radar" - -# Envelope/recipient statuses we treat as "opened but not yet signed" — the -# hottest follow-up set. Workspaces vary on the exact label, so match a set. -OPENED_NOT_SIGNED = {"viewed", "opened"} -DONE = {"completed", "signed"} -DEAD = {"voided", "declined", "expired"} - - -def _age_days(env: dict, now) -> float | None: - """Days since the envelope was sent (fallback: created).""" - ts = env.get("sent_at") or env.get("created_at") or env.get("updated_at") - dt = parse_iso(ts) if ts else None - return days_since(dt, now) if dt else None - - -def fetch(stale_days: int, quiet: bool) -> dict: - now = now_utc() - log(SKILL, "listing signing envelopes...", quiet=quiet) - - envelopes = list(paginate(resolve_path("signing/envelopes"), limit=100, max_pages=20)) - - funnel = {"sent": 0, "opened": 0, "signed": 0, "completed": 0, - "declined": 0, "voided": 0, "expired": 0, "other": 0} - opened_not_signed: list[dict] = [] - sent_never_opened_stale: list[dict] = [] - declined: list[dict] = [] - recently_completed: list[dict] = [] - - for env in envelopes: - status = (env.get("status") or "").lower() - age = _age_days(env, now) - row = { - "envelope_id": env.get("id"), - "document_title": env.get("document_title") or env.get("template_name") or "(untitled)", - "deal_id": env.get("deal_id"), - "status": status, - "age_days": round(age, 1) if age is not None else None, - "signer_email": env.get("signer_email"), - "signer_name": env.get("signer_name"), - } - - # Funnel tally (coarse — envelope-level). - if status in OPENED_NOT_SIGNED: - funnel["opened"] += 1 - opened_not_signed.append(row) - elif status == "signed": - funnel["signed"] += 1 - elif status == "completed": - funnel["completed"] += 1 - if age is not None and age <= 7: - recently_completed.append(row) - elif status == "declined": - funnel["declined"] += 1 - row["decline_reason"] = env.get("decline_reason") - declined.append(row) - elif status == "voided": - funnel["voided"] += 1 - elif status == "expired": - funnel["expired"] += 1 - elif status == "sent": - funnel["sent"] += 1 - if age is not None and age >= stale_days: - sent_never_opened_stale.append(row) - else: - funnel["other"] += 1 - - # Sort the follow-up buckets oldest-first (most urgent). - opened_not_signed.sort(key=lambda r: r["age_days"] or 0, reverse=True) - sent_never_opened_stale.sort(key=lambda r: r["age_days"] or 0, reverse=True) - - return { - "skill": SKILL, - "generated_at": now.isoformat(), - "stale_days_threshold": stale_days, - "total_envelopes": len(envelopes), - "funnel": funnel, - "opened_not_signed": opened_not_signed, # follow-up GOLD - "sent_never_opened_stale": sent_never_opened_stale, # chase or it dies - "declined": declined, - "recently_completed": recently_completed, - } - - -def main() -> None: - force_utf8_stdout() - ap = argparse.ArgumentParser(description="Signing envelope funnel + follow-up digest") - ap.add_argument("--stale-days", type=int, default=5, - help="Flag 'sent but never opened' once older than this many days (default 5)") - ap.add_argument("--json-only", action="store_true", help="Suppress progress logs") - args = ap.parse_args() - try: - emit_json(fetch(args.stale_days, quiet=args.json_only)) - except BOSError as e: - emit_error_and_exit(e) - - -if __name__ == "__main__": - main() diff --git a/skills/suggest-improvement/SKILL.md b/skills/suggest-improvement/SKILL.md new file mode 100644 index 0000000..b9a25cb --- /dev/null +++ b/skills/suggest-improvement/SKILL.md @@ -0,0 +1,64 @@ +--- +name: Suggest Improvement +description: Log something the operator wanted that doesn't exist yet — a missing BOS skill/command, or a TrustPager platform capability that isn't there — into TrustPager's developer feedback queue via a service request, so the team can build it. Use when the operator says "I wish it could…", "this is missing", "feature request", "report a bug", or whenever a task dead-ends because the capability genuinely isn't there. +triggers: + - suggest an improvement + - feature request + - I wish it could + - this is missing + - report a bug + - log this for the team + - request a feature + - that should be possible +--- + +# Suggest Improvement + +The full model — what's worth logging and how the feedback loop fits with memory — is in `knowledge/memory-and-feedback.md`. Read it if you haven't this session. + +This is how a single operator's "I wish it could…" becomes a shipped feature. The channel already exists: **`create_service_request`** writes to TrustPager's developer feedback queue, it's on every workspace, and it's free. + +## Step 1 — Classify the gap + +- **`[BOS]` — plugin gap:** they asked for something and no BOS skill/command covers it. +- **`[Platform]` — platform gap:** TrustPager itself can't do it. + +If it's neither — you can actually do it with existing tools, it's a one-off they can do another way right now, or it's user error — **don't log it.** Help them in the moment instead. Capture *missing capability*, not friction you can already solve. + +## Step 2 — Check for a duplicate + +Search the existing queue first (`list_service_requests`, or `search`) for the same gap. If one already exists, **add a note to it** (`add_service_request_note`) — "+1, another operator hit this doing X" — rather than filing a duplicate. A second voice on an existing request is more useful than a near-twin. + +## Step 3 — Draft it, show it, confirm + +`create_service_request` is a write, so it follows the standing rail: **draft → show → confirm → file.** Compose: + +- **`use_case`** — what the operator was trying to do, in their own words. The most important field; it's the "why". Prefix it with `[BOS]` or `[Platform]`. +- **`suggested_solution`** — your one-line take on what would solve it. +- **`affected_tools`** — the skill/command or TrustPager area involved. +- **`category`** — a short label. If unsure what the tool accepts, inspect it (`get_ai_instructions` / the tool schema) rather than guessing. + +Show the operator the draft and get a yes before filing. + +## Step 4 — File it and surface the id + +- If it returns **`202` (queued for approval)** — that's the approval queue, not a failure. Tell the operator to approve it at `app.trustpager.com/settings/api?tab=approvals` and **stop** (see `safeguards.md`). Don't retry. +- Otherwise surface the request **id**: *"Logged as request #1234 — the TrustPager team triages these. I'll keep using what we've got in the meantime."* + +Then carry on helping with whatever the operator can do today. + +## Proactive use (don't wait to be asked) + +When a catch-all skill (`/make-it-happen`, `/show-me-how`) dead-ends because the capability genuinely isn't there, don't fail silently. Finish helping as far as you can, then offer: *"TrustPager can't do that yet — want me to log it so the team can build it?"* Only file on a yes. + +## Hard rules + +- ❌ Don't log friction you can solve right now, one-offs with a workaround, or user error — only missing capability. +- ❌ Don't file a duplicate — search first; +1 an existing request instead. +- ❌ Don't retry a `202` — it's queued for a human; surface it and stop. +- ✅ Always draft → confirm before filing (it's a write to their workspace). +- ✅ Tag `[BOS]` vs `[Platform]` so triage can route it, and surface the request id. + +## Output shape + +A one-line confirmation with the request id (or the approval-queue hand-off), then back to helping. If you +1'd an existing request, say which one. diff --git a/skills/sweep-my-day/SKILL.md b/skills/sweep-my-day/SKILL.md index a25c0ad..56d27df 100644 --- a/skills/sweep-my-day/SKILL.md +++ b/skills/sweep-my-day/SKILL.md @@ -16,59 +16,82 @@ triggers: You are running the operator's morning briefing across their TrustPager workspace. Your goal: in under 60 seconds of reading, the operator knows exactly what needs attention, in priority order, with one-tap actions ready to fire. -## Step 1 — Fetch the data with the helper script +## Step 1 — Pull the data (parallel MCP calls) -**Always run the data fetcher FIRST.** It executes 7+ parallel API calls and returns a digested JSON document — much faster and cheaper than chaining individual MCP tool calls. +Fire these **seven read calls in parallel** in a single batch — they're all reads, so they're free and fast. Use the `trustpager` MCP server. Ask for the most recent records; you'll filter them yourself in Step 2. -```bash -python ~/.claude/bos-run.py sweep-my-day -``` - -(The `~/.claude/bos-run.py` launcher resolves the install location for you — it works from any folder, plugin or clone install. If the launcher is missing, run `python tools/setup.py` once to create it.) +| Need | Tool | Args | +|---|---|---| +| Opportunities (for overdue actions, silent deals, pipeline) | `list_deals` | `limit: 100` | +| Tasks | `list_tasks` | `limit: 100` | +| Today's bookings | `list_bookings` | `limit: 50` | +| Unread email | `list_email_threads` | `limit: 50` | +| Unread SMS | `list_sms_conversations` | `limit: 50` | +| Missed calls | `list_phone_call_logs` | `limit: 50` | +| New form submissions | `list_form_submissions` | `limit: 50` | -The script returns a single JSON document with five top-level sections, one per category below: `hot_inbound`, `overdue`, `going_quiet`, `todays_calendar`, `pipeline_pulse`. Each has a `count` (total found) and `items` (top results). The shape is documented at the bottom of `fetch.py`. +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -**If the script reports errors on stderr** for individual endpoints, mention them briefly in the briefing ("note: couldn't reach the bookings API right now") but proceed with what you have. Don't bail. +If one call errors, mention it briefly in the briefing ("note: couldn't reach the bookings API right now") and proceed with what you have. Don't bail on the whole sweep because one endpoint is down. -**If the script can't run at all** (auth error, network error), fall back to chained MCP tool calls — use the per-category guides below to drive what to fetch. +Everything below is computed against **now** in the operator's timezone. All these are reads — nothing here is journaled or needs approval. -## Step 2 — Read the JSON and format the briefing +## Step 2 — Digest and format the briefing Five categories, ranked by how time-sensitive they are. Always present in this order — most urgent first, never alphabetical or by feature area. -### 🔥 1. Hot inbound — `digest.hot_inbound` +### 🔥 1. Hot inbound — last 24h, not yet replied to + +From the four comms lists, keep only items whose timestamp is **within the last 24 hours** and that still need a response: + +- **Unread email threads:** `is_read` is false AND the last message direction is **inbound** AND last-message time is within 24h. Surface subject, a ~120-char preview, when, and message count. +- **Unread SMS conversations:** unread count > 0 AND last-message time within 24h. Surface the sender number, a ~120-char preview, when, and unread count. +- **Missed inbound calls:** direction is inbound AND status is one of `missed` / `no-answer` / `voicemail` / `failed` AND within 24h. Surface the caller number, duration, when, and recording URL if present. **If no recovery SMS has gone out for a missed call, offer to draft one** (don't send — see rails). +- **New form submissions:** completed/created within 24h. Surface submitter name + email, a ~200-char AI summary, and when. + +Sort all of it newest-first; show the **top 10** and a count of the rest. For each, give: who, when, one-line context, recommended next move. + +### 📅 2. Overdue items -Items that arrived in the last 24 hours and haven't been replied to: unread email threads, unread SMS conversations, missed phone calls, and new form submissions. +- **Tasks:** not completed (no completion time, status not `completed`/`cancelled`) AND due date is **in the past**. Capture title, priority, linked opportunity, due date, and days overdue. +- **Overdue next actions on opportunities:** an *active* opportunity (see the active-opportunity test below) whose `next_action_date` is in the past — the operator forgot to do something they scheduled. Capture the action name + opportunity, due date, days overdue, and value. -For each item, surface: who, when, one-line context, and the recommended next move. If a recovery SMS hasn't been sent for a missed call, offer to draft one. +Rank by **days overdue, descending**; show the **top 5** and a count of the rest. Don't dump the full list. -**Fallback MCP tools if the script failed:** `list_email_threads`, `list_sms_conversations`, `list_phone_call_logs`, `list_form_submissions`, `list_whatsapp_conversations`. +### 💤 3. Going quiet -### 📅 2. Overdue items — `digest.overdue` +Active opportunities drifting with no momentum. Keep an opportunity here only if **all** of these hold: -Tasks past their due date that haven't been completed. The script surfaces the top 5 ranked by days overdue. Don't dump the full list — `count` tells the operator how many more exist. +1. It passes the **active-opportunity test** (below). +2. It has **no future** `next_action_date` (a scheduled future action means it's in progress, not quiet). +3. Its last-touch time (`updated_at`) is **7+ days ago**. -**Fallback MCP tools:** `list_tasks` (filter completed=false + due_date < today), `list_work_orders` (filter overdue + not closed), `list_scheduled_communications` (filter failed). +Rank by **deal value first, then longest silence**; show the **top 5**. For each, surface name, value + currency, stage, lead source, days silent, and the primary contact — and **draft a suggested re-engagement message** (queue it for approval, don't send). -### 💤 3. Going quiet — `digest.going_quiet` +> "Meaningful activity" = real activity, call transcripts, email replies, SMS replies — **not** automated platform emails. `updated_at` is the proxy. -Active opportunities with no meaningful activity in 7+ days. The script ranks the top 5 by deal value × days-silent. For each, draft the suggested re-engagement message (don't send — queue it for operator approval). +### ⏰ 4. Today's calendar -**What counts as "meaningful":** activities, transcripts from calls, email replies, SMS replies. NOT automated platform emails. The script uses `last_activity_at` on the opportunity record. +- **Bookings** starting today (status not cancelled): time, attendee name/email, end time, meeting URL, linked opportunity/contact. For any booking today without a prep note, offer to run `/prep-for-call`. +- **Tasks** due today (not completed): title, priority, time, linked opportunity. -**Fallback MCP tools:** `list_opportunities` (filter by active stage), then `get_opportunity_activities` and `list_transcripts` per opportunity. Expensive — only do this if the script genuinely failed. +Sort by start time, ascending. -### ⏰ 4. Today's calendar — `digest.todays_calendar` +### 📊 5. Pipeline pulse -Bookings scheduled for today plus tasks due today. Each booking includes the meeting URL and the linked opportunity (if any). For any booking today that doesn't already have a prep note, offer to run `/prep-for-call` for it. +Derive from the opportunities list — **one paragraph, no more**. It's a gut-check, not a report. -**Fallback MCP tools:** `list_bookings` (today only), `list_tasks` (due today). +- **Total open value + count:** sum value across opportunities that pass the active test. +- **Won / lost this month:** opportunities whose close date (`actual_close_date`, or `lost_at` for losses) falls on/after the 1st of the current month, split into won vs lost (count + value). +- **By stage:** count and summed value grouped by current stage name. -### 📊 5. Pipeline pulse — `digest.pipeline_pulse` +### The active-opportunity test (used by sections 2, 3, 5) -One-paragraph state-of-the-business: total open value, count by stage. Keep this to one paragraph maximum — it's a gut-check, not a report. If the script couldn't reach the summary endpoint, it derives totals from the opportunities list (the `_derived_from` field signals this). +An opportunity is **active** when **both**: +- its status is not one of `won` / `lost` / `cancelled` / `abandoned` / `archived`, **and** +- its current stage is not flagged as a won-stage or lost-stage. -**Fallback MCP tool:** `get_pipeline_summary` on the primary sales pipeline. +Its current stage name lives on the opportunity's first placement → pipeline stage. If there's no placement, treat the stage as "Unstaged". ## Output format @@ -112,7 +135,7 @@ End with one concrete next move — not a menu of options. The operator's mornin ## What to never do -- ❌ Don't send any messages, even drafts, without offering the draft first and waiting for approval. +- ❌ Don't send any messages, even drafts, without offering the draft first and waiting for approval. (See `knowledge/safeguards.md` — ask before anything outward-facing.) - ❌ Don't show every overdue item in a list — top 5 then a count. - ❌ Don't pad with motivational language. The operator wants signal, not encouragement. - ❌ Don't include items already actioned (replied to, marked done, dismissed). @@ -120,12 +143,12 @@ End with one concrete next move — not a menu of options. The operator's mornin ## Common follow-ups the operator will ask -Be ready to chain naturally into: +Be ready to chain naturally into these. Anything that **writes** follows the rails in `knowledge/safeguards.md` — show the draft, wait for approval, then journal the write to `.bos-journal.md`: -- "Draft a recovery message for that missed call" → use `send_sms` after drafting +- "Draft a recovery message for that missed call" → draft, confirm, then `send_sms` - "Show me all overdue tasks" → full `list_tasks` filtered to overdue -- "What's the latest on [opportunity name]" → `get_opportunity` + `get_opportunity_activities` -- "Move [opportunity] to [stage]" → `update_opportunity` +- "What's the latest on [opportunity name]" → `get_deal` + `get_deal_activities` (and `list_transcripts` if there were calls) +- "Move [opportunity] to [stage]" → `move_opportunity_card` - "Prep me for the 2pm call" → invoke the prep-for-call skill ## When this skill should NOT fire diff --git a/skills/sweep-my-day/fetch.py b/skills/sweep-my-day/fetch.py deleted file mode 100644 index c4ee025..0000000 --- a/skills/sweep-my-day/fetch.py +++ /dev/null @@ -1,464 +0,0 @@ -#!/usr/bin/env python3 -"""Sweep My Day — consolidated data fetcher. - -Runs 7 parallel API calls against the operator's TrustPager workspace, then -returns a single JSON document with everything `/sweep-my-day` needs to -produce its morning briefing. - -Usage: - python skills/sweep-my-day/fetch.py - python skills/sweep-my-day/fetch.py --json-only # suppress progress logs - -Output to stdout: JSON shape documented at the bottom of this file. -Output to stderr: progress logs (so Claude can show them or suppress them). - -Auth: reads TRUSTPAGER_API_KEY env var or ~/.claude/bos.json (see tools/trustpager_api.py). - -This script is intentionally token-cheap: it digests the raw API responses -down to just the rows Claude needs, with consistent field names. The whole -output is typically under 5KB versus the ~16KB of raw MCP responses Claude -would otherwise have to consume. -""" - -from __future__ import annotations - -import argparse -import sys -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any - -# Resolve the shared lib (lives in tools/ at the repo root) regardless of -# where this script is invoked from. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, days_since, emit_error_and_exit, emit_json, log, - now_utc, parallel_get, parse_iso, resolve_path, -) - - -SKILL = "sweep-my-day" - -# Statuses that mean the opportunity is no longer in active sales motion. -INACTIVE_OPP_STATUSES = {"won", "lost", "cancelled", "abandoned", "archived"} - - -def _opp_stage(opp: dict[str, Any]) -> str | None: - """Extract the stage name from opportunity.placements[0].crm_pipeline_stages.name.""" - placements = opp.get("placements") or [] - if not placements: - return None - stage = placements[0].get("crm_pipeline_stages") or {} - return stage.get("name") - - -def _opp_is_active(opp: dict[str, Any]) -> bool: - """Active = status is 'open' AND its current stage isn't a won/lost stage.""" - status = (opp.get("status") or "").lower() - if status in INACTIVE_OPP_STATUSES: - return False - placements = opp.get("placements") or [] - if placements: - stage = placements[0].get("crm_pipeline_stages") or {} - if stage.get("is_won_stage") or stage.get("is_lost_stage"): - return False - return True - - -def _log(msg: str, *, quiet: bool) -> None: - log(SKILL, msg, quiet=quiet) - - -# ============================================================================= -# Fetch + digest each category -# ============================================================================= - - -def digest_hot_inbound(results: dict[str, dict[str, Any]], now: datetime) -> dict[str, Any]: - """Anything that arrived in the last 24h and hasn't been replied to.""" - cutoff = now - timedelta(hours=24) - items: list[dict[str, Any]] = [] - - # Unread inbound email threads - for thread in results.get("email/threads", {}).get("data", []): - if thread.get("is_read", True): - continue - if thread.get("last_message_direction") != "inbound": - continue - last_at = parse_iso(thread.get("last_message_at")) - if last_at and last_at >= cutoff: - items.append({ - "kind": "email_thread", - "id": thread.get("id"), - "subject": thread.get("subject") or "(no subject)", - "preview": (thread.get("last_message_preview") or "")[:120], - "when": thread.get("last_message_at"), - "message_count": thread.get("message_count"), - }) - - # Unread SMS conversations - for conv in results.get("sms/conversations", {}).get("data", []): - last_at = parse_iso(conv.get("last_message_at")) - if last_at and last_at >= cutoff and (conv.get("unread_count") or 0) > 0: - items.append({ - "kind": "sms_conversation", - "id": conv.get("id"), - "from": conv.get("external_phone_number"), - "preview": (conv.get("last_message_preview") or "")[:120], - "when": conv.get("last_message_at"), - "unread_count": conv.get("unread_count"), - }) - - # Missed inbound phone calls (status in missed/no-answer/voicemail) - for call in results.get("phone/call-logs", {}).get("data", []): - if call.get("direction") != "inbound": - continue - if call.get("status") not in {"missed", "no-answer", "voicemail", "failed"}: - continue - call_at = parse_iso(call.get("start_time") or call.get("created_at")) - if call_at and call_at >= cutoff: - items.append({ - "kind": "missed_call", - "id": call.get("id"), - "from": call.get("from_number"), - "duration_sec": call.get("duration"), - "when": call.get("start_time") or call.get("created_at"), - "recording_url": call.get("recording_url"), - "linked_entities": call.get("linked_entities"), - }) - - # New form submissions in window - for sub in results.get("forms/submissions", {}).get("data", []): - sub_at = parse_iso(sub.get("completed_at") or sub.get("created_at")) - if sub_at and sub_at >= cutoff: - items.append({ - "kind": "form_submission", - "id": sub.get("id"), - "submitter_name": sub.get("recipient_name"), - "submitter_email": sub.get("recipient_email"), - "ai_summary": (sub.get("ai_summary") or "")[:200], - "when": sub.get("completed_at") or sub.get("created_at"), - }) - - items.sort(key=lambda x: x.get("when") or "", reverse=True) - return {"count": len(items), "items": items[:10]} - - -def digest_overdue(results: dict[str, dict[str, Any]], now: datetime) -> dict[str, Any]: - """Tasks past due that haven't been completed, plus opportunities with - a next_action_date in the past.""" - items: list[dict[str, Any]] = [] - - for task in results.get("tasks", {}).get("data", []): - if task.get("completed_at"): - continue - if (task.get("status") or "").lower() in {"completed", "cancelled"}: - continue - due = parse_iso(task.get("due_date")) - if due and due < now: - items.append({ - "kind": "task", - "id": task.get("id"), - "title": task.get("title"), - "priority": task.get("priority"), - "opportunity_id": task.get("deal_id"), - "contact_id": task.get("contact_id"), - "due": task.get("due_date"), - "days_overdue": days_since(due, now), - }) - - # Opportunities with an overdue next_action_date — operator forgot to do something - for opp in results.get("opportunities", {}).get("data", []): - if not _opp_is_active(opp): - continue - nad = parse_iso(opp.get("next_action_date")) - if nad and nad < now: - items.append({ - "kind": "overdue_next_action", - "id": opp.get("id"), - "title": f"{opp.get('next_action_name') or 'Next action'} on {opp.get('name')}", - "opportunity_id": opp.get("id"), - "due": opp.get("next_action_date"), - "days_overdue": days_since(nad, now), - "value": opp.get("value"), - }) - - items.sort(key=lambda x: x.get("days_overdue") or 0, reverse=True) - return {"count": len(items), "items": items[:5]} - - -def digest_going_quiet(results: dict[str, dict[str, Any]], now: datetime, - silence_days: int = 7) -> dict[str, Any]: - """Active opportunities not touched in N+ days and with no scheduled next action.""" - cutoff_seconds = silence_days * 86400 - items: list[dict[str, Any]] = [] - - for opp in results.get("opportunities", {}).get("data", []): - if not _opp_is_active(opp): - continue - # If a future next-action is scheduled, this isn't "going quiet" — it's "in progress" - nad = parse_iso(opp.get("next_action_date")) - if nad and nad >= now: - continue - - last_touch = parse_iso(opp.get("updated_at")) - if not last_touch: - continue - seconds_since = (now - last_touch).total_seconds() - if seconds_since < cutoff_seconds: - continue - - items.append({ - "kind": "silent_opportunity", - "id": opp.get("id"), - "name": opp.get("name"), - "value": opp.get("value"), - "currency": opp.get("currency"), - "stage": _opp_stage(opp), - "lead_source": opp.get("lead_source"), - "last_touch": opp.get("updated_at"), - "days_silent": int(seconds_since // 86400), - "primary_contact_id": opp.get("contact_id"), - }) - - # Rank: highest-value first; ties broken by longest silence - items.sort(key=lambda x: (float(x.get("value") or 0), x.get("days_silent") or 0), reverse=True) - return {"count": len(items), "items": items[:5]} - - -def digest_todays_calendar(results: dict[str, dict[str, Any]], now: datetime) -> dict[str, Any]: - """Bookings starting today + tasks due today (UTC day window).""" - today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - today_end = today_start + timedelta(days=1) - items: list[dict[str, Any]] = [] - - for booking in results.get("scheduling/bookings", {}).get("data", []): - if (booking.get("status") or "").lower() == "cancelled": - continue - when = parse_iso(booking.get("starts_at")) - if when and today_start <= when < today_end: - items.append({ - "kind": "booking", - "id": booking.get("id"), - "title": "Booking", # event_type_name needs an expand we'd add later - "booker_name": booking.get("booker_name"), - "booker_email": booking.get("booker_email"), - "when": booking.get("starts_at"), - "ends_at": booking.get("ends_at"), - "meeting_url": booking.get("google_meet_link"), - "opportunity_id": booking.get("deal_id"), - "contact_id": booking.get("contact_id"), - }) - - for task in results.get("tasks", {}).get("data", []): - if task.get("completed_at"): - continue - due = parse_iso(task.get("due_date")) - if due and today_start <= due < today_end: - items.append({ - "kind": "task_today", - "id": task.get("id"), - "title": task.get("title"), - "priority": task.get("priority"), - "when": task.get("due_date"), - "opportunity_id": task.get("deal_id"), - }) - - items.sort(key=lambda x: x.get("when") or "") - return {"count": len(items), "items": items} - - -def digest_pipeline_pulse(results: dict[str, dict[str, Any]], now: datetime) -> dict[str, Any]: - """Derive pipeline state from the opportunities list.""" - opportunities = results.get("opportunities", {}).get("data", []) - month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - - by_stage: dict[str, dict[str, Any]] = {} - total_open_value = 0.0 - won_this_month_value = 0.0 - won_this_month_count = 0 - lost_this_month_value = 0.0 - lost_this_month_count = 0 - open_count = 0 - - for opp in opportunities: - status = (opp.get("status") or "").lower() - value = float(opp.get("value") or 0) - stage_name = _opp_stage(opp) or "Unstaged" - - bucket = by_stage.setdefault(stage_name, {"count": 0, "value": 0.0}) - bucket["count"] += 1 - bucket["value"] += value - - if _opp_is_active(opp): - total_open_value += value - open_count += 1 - else: - close_date = parse_iso(opp.get("actual_close_date") or opp.get("lost_at")) - if close_date and close_date >= month_start: - if status == "won": - won_this_month_value += value - won_this_month_count += 1 - elif status == "lost": - lost_this_month_value += value - lost_this_month_count += 1 - - return { - "total_open_value": total_open_value, - "open_count": open_count, - "won_this_month_count": won_this_month_count, - "won_this_month_value": won_this_month_value, - "lost_this_month_count": lost_this_month_count, - "lost_this_month_value": lost_this_month_value, - "by_stage": by_stage, - } - - -# ============================================================================= -# Orchestrator -# ============================================================================= - - -def fetch_and_digest(quiet: bool = False) -> dict[str, Any]: - now = now_utc() - yesterday = now - timedelta(hours=24) - cutoff_iso = yesterday.isoformat() - - _log("resolving endpoint paths from catalog...", quiet=quiet) - - # Resolve every path via the public API catalog at docs.trustpager.com. - # This insulates the script from path renames — when the API changes, - # the catalog updates within ~24h and BOS picks up the new paths - # automatically (or sooner if the user clears ~/.claude/bos-cache). - try: - paths = { - "opportunities": resolve_path("opportunities"), - "tasks": resolve_path("tasks"), - "bookings": resolve_path("scheduling", path_contains="bookings"), - "email_threads": resolve_path("email", path_contains="threads"), - "sms_convos": resolve_path("sms"), - "phone_calls": resolve_path("phone", path_contains="call-logs"), - "form_subs": resolve_path("forms", path_contains="submissions"), - } - except BOSError as e: - raise BOSError( - f"Could not resolve API paths from the catalog: {e}\n" - f"Falling back is not yet implemented — please report this bug." - ) from None - - _log("fanning out 7 parallel API calls...", quiet=quiet) - - calls = [ - (paths["opportunities"], {"limit": 100}), - (paths["tasks"], {"limit": 100}), - (paths["bookings"], {"limit": 50}), - (paths["email_threads"], {"limit": 50}), - (paths["sms_convos"], {"limit": 50}), - (paths["phone_calls"], {"limit": 50}), - (paths["form_subs"], {"limit": 50}), - ] - - results = parallel_get(calls) - - for path, _params in calls: - if results.get(path, {}).get("error"): - err_line = results[path]["error"].splitlines()[0] - _log(f" ! {path}: {err_line}", quiet=quiet) - else: - count = len(results.get(path, {}).get("data", [])) - _log(f" ok {path}: {count} rows", quiet=quiet) - - _log("digesting...", quiet=quiet) - - return { - "generated_at": now.isoformat(), - "hot_inbound": digest_hot_inbound(results, now), - "overdue": digest_overdue(results, now), - "going_quiet": digest_going_quiet(results, now), - "todays_calendar": digest_todays_calendar(results, now), - "pipeline_pulse": digest_pipeline_pulse(results, now), - "_raw_call_status": { - path: ("ok" if not results.get(path, {}).get("error") else "error") - for path, _ in calls - }, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description="Sweep My Day data fetcher") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - digest = fetch_and_digest(quiet=args.json_only) - emit_json(digest) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - except KeyboardInterrupt: - emit_error_and_exit("Cancelled", code=130) - - -if __name__ == "__main__": - sys.exit(main()) - - -# ============================================================================= -# Output shape — what Claude reads from stdout -# ============================================================================= -# -# { -# "generated_at": "2026-05-31T10:00:00+00:00", -# "hot_inbound": { -# "count": 4, -# "items": [ -# {"kind": "email_thread", "id": "...", "subject": "...", "preview": "...", -# "when": "...", "message_count": 2}, -# {"kind": "sms_conversation", "id": "...", "from": "+61...", "preview": "...", -# "when": "...", "unread_count": 1}, -# {"kind": "missed_call", "id": "...", "from": "+61...", "duration_sec": 0, -# "when": "...", "recording_url": "...", "linked_entities": {...}}, -# {"kind": "form_submission", "id": "...", "submitter_name": "...", -# "submitter_email": "...", "ai_summary": "...", "when": "..."} -# ] -# }, -# "overdue": { -# "count": 7, -# "items": [ -# {"kind": "task", "id": "...", "title": "...", "priority": "high", -# "opportunity_id": "...", "due": "...", "days_overdue": 3}, -# {"kind": "overdue_next_action", "id": "", -# "title": "Call back on Acme deal", "due": "...", "days_overdue": 2, -# "value": 12000} -# ] -# }, -# "going_quiet": { -# "count": 12, -# "items": [ -# {"kind": "silent_opportunity", "id": "...", "name": "...", "value": 35000, -# "currency": "AUD", "stage": "Quote Sent", "lead_source": "Referral", -# "last_touch": "...", "days_silent": 14, "primary_contact_id": "..."} -# ] -# }, -# "todays_calendar": { -# "count": 3, -# "items": [ -# {"kind": "booking", "id": "...", "booker_name": "...", "booker_email": "...", -# "when": "...", "ends_at": "...", "meeting_url": "...", -# "opportunity_id": "...", "contact_id": "..."}, -# {"kind": "task_today", "id": "...", "title": "...", "priority": "medium", -# "when": "...", "opportunity_id": "..."} -# ] -# }, -# "pipeline_pulse": { -# "total_open_value": 248500.00, -# "open_count": 32, -# "won_this_month_count": 4, -# "won_this_month_value": 48000, -# "lost_this_month_count": 2, -# "lost_this_month_value": 8500, -# "by_stage": { "Qualified": {"count": 8, "value": 95000}, ... } -# }, -# "_raw_call_status": { "opportunities": "ok", "tasks": "ok", ... } -# } diff --git a/skills/sweep-my-day/test-fixture.json b/skills/sweep-my-day/test-fixture.json deleted file mode 100644 index 4ccc613..0000000 --- a/skills/sweep-my-day/test-fixture.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "_doc": "Fixture for `bos test sweep-my-day`. Mocks the catalog and key API responses so the script can be exercised without hitting the live API. `catalog._use_live` lets you opt-in to the real catalog if your test cares about path resolution; otherwise inline a tiny catalog stub here.", - - "catalog": { "_use_live": true }, - - "responses": { - "opportunities": { - "data": [ - { - "id": "opp-1", "name": "Test Opportunity A", "status": "open", - "value": 5000, "currency": "AUD", - "contact_id": "contact-1", "customer_id": null, - "lead_source": "Facebook", - "next_action_date": null, "next_action_name": null, - "updated_at": "2026-04-01T00:00:00Z", - "placements": [{"crm_pipeline_stages": {"name": "Quote Sent", "is_won_stage": false, "is_lost_stage": false}}] - }, - { - "id": "opp-2", "name": "Test Opportunity B", "status": "won", - "value": 10000, "currency": "AUD", - "actual_close_date": "2026-05-15T00:00:00Z", - "placements": [{"crm_pipeline_stages": {"name": "Closed Won", "is_won_stage": true, "is_lost_stage": false}}] - } - ], - "pagination": {"has_more": false} - }, - "tasks": { - "data": [ - { - "id": "task-1", "title": "Follow up with X", "status": "open", - "due_date": "2026-05-01", "completed_at": null, - "priority": "high", "deal_id": "opp-1" - } - ], - "pagination": {"has_more": false} - }, - "scheduling/bookings": {"data": [], "pagination": {"has_more": false}}, - "email/threads": {"data": [], "pagination": {"has_more": false}}, - "sms/conversations": {"data": [], "pagination": {"has_more": false}}, - "phone/call-logs": {"data": [], "pagination": {"has_more": false}}, - "forms/submissions": {"data": [], "pagination": {"has_more": false}} - } -} diff --git a/skills/sync-from-xero/SKILL.md b/skills/sync-from-xero/SKILL.md index 15adbe7..165200a 100644 --- a/skills/sync-from-xero/SKILL.md +++ b/skills/sync-from-xero/SKILL.md @@ -22,27 +22,32 @@ Xero data feeds TrustPager in **two separate ways**. They are not the same thing 1. **Reconcile invoices ↔ opportunities (this skill).** Match Xero invoices to pipeline opportunities and bring their **payment status** into line — "this Won deal is actually paid now". One-off, opportunity-by-opportunity. Use this when the operator's pipeline is out of step with what's been paid. -2. **Seed the receivables ledger for AR reporting** — a *different* job. This populates the **Invoices / Receivables report source** so the operator can run an aged-receivables report ("who owes me money") and email it on a schedule. That's **`/outstanding-invoices`**, not this skill. It uses `sync_receivables` (a one-time seed; the integration's webhook keeps it live after that — see [knowledge/reporting-method.md](../../knowledge/reporting-method.md) §6). +2. **Seed the receivables ledger for AR reporting** — a *different* job. This populates the **Invoices / Receivables report source** so the operator can run an aged-receivables report ("who owes me money") and email it on a schedule. That's **`/outstanding-invoices`**, not this skill. It uses `sync_receivables` (a one-time seed; the integration's webhook keeps it live after that — see [knowledge/reporting-method.md](../../knowledge/reporting-method.md) §6 and `knowledge/safeguards.md` §2 on synced ledgers). > If the operator says "who owes me money", "aged receivables", "email me my outstanding invoices daily", or anything about *reporting* on invoices → hand off to **`/outstanding-invoices`**. Stay here only for reconciling payment status against the pipeline. -## Step 1 — Pre-fetch +## Step 1 — Pull the data (MCP calls) -Run: +Use the `trustpager` MCP server. Start with these reads: -``` -python ~/.claude/bos-run.py sync-from-xero -``` +| Need | Tool | Args | +|---|---|---| +| Find the Xero integration + its status | `list_integrations` | `limit: 50` | +| Recent opportunities to reconcile against | `list_deals` | `limit: 200` (filter to the last ~90 days yourself in Step 2) | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -This first checks `integrations` for an active Xero connection. If `connected: false`, tell the user: +From `list_integrations`, find the entry whose `platform_type` (or `provider`) is `xero`. **Treat Xero as connected only if its `status` is one of `active` / `connected` / `authorized`.** If there's no Xero entry or its status isn't one of those, stop and tell the user: > "Xero isn't connected to your TrustPager workspace yet. Connect it at https://app.trustpager.com/auto/integrations, then re-run this skill." -If connected, the response includes the recent TrustPager opportunities for cross-referencing. For the Xero side detail (invoices + payments), use `mcp__trustpager__query_integration` with the returned `xero.id` — Xero's data is too detailed to pre-bundle. +If connected, note the Xero integration's `id` — you need it for the next call. Then pull the Xero-side detail (invoices + payments) with `query_integration`, passing that integration id. Xero's data is too detailed to bundle up front; query it directly. + +These are all reads — free, nothing journaled, no approval. ## Step 2 — Cross-reference -For each Xero invoice: +Consider only opportunities updated/closed in the **last 90 days** (older state has likely already been reconciled). For each Xero invoice: - Find the matching TrustPager opportunity. Match priority: 1. By `xero_invoice_id` if previously linked 2. By customer email + matching value within ±$50 @@ -79,9 +84,11 @@ Proceed with the 5 payment_status updates? The 6 unmatched cases need a manual c ## Step 4 — Apply with approval -After explicit go, apply the 5 (or however many) updates: -- For each: `mcp__trustpager__update_opportunity` with the new `payment_status` value -- Use the appropriate enum value (`paid`, `partial`, `unpaid`, etc. — confirm with `mcp__trustpager__describe_resource('opportunity')` for valid values) +This step **writes** — it follows the rails in `knowledge/safeguards.md`: confirm before anything lands, journal each write to `.bos-journal.md`, and search-first so you don't double-apply. After explicit go, apply the updates: + +- For each opportunity: `update_deal` with the new `payment_status` value. +- Use the right enum value (`paid`, `partial`, `unpaid`, etc.). If you're unsure which values are valid in this workspace, confirm against `get_crm_settings` or ask the operator — don't guess. (`describe_resource` is only available on the claude.ai-connected workspaces, not the client `trustpager` server, so don't rely on it.) +- A `202` / `approval_id` response means the write is **queued for human approval** — surface the approval id + the approvals URL, journal it as `approval_pending`, and stop. Don't retry (safeguards §1). The 6 unmatched cases get handed back as a checklist for the user to action: > "Here's what's left for you to handle manually: @@ -93,7 +100,7 @@ The 6 unmatched cases get handed back as a checklist for the user to action: - **No invoice creation.** Won opps without invoices → flagged only. Creating an invoice is a separate intent. - **Value tolerance ±$50 only.** Bigger mismatches mean ambiguous match — flag, don't auto-link. - **Date filter.** Default to 90 days. Older state has likely already been reconciled. -- **Preserve audit trail.** Every payment_status change gets a note on the opportunity referencing the Xero invoice number. +- **Preserve audit trail.** Every payment_status change gets a note on the opportunity (`add_note`) referencing the Xero invoice number. ## When the Xero integration isn't available diff --git a/skills/sync-from-xero/fetch.py b/skills/sync-from-xero/fetch.py deleted file mode 100644 index 1dd6327..0000000 --- a/skills/sync-from-xero/fetch.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""sync-from-xero — pre-fetch Xero connection state + TrustPager opps. - -Before /sync-from-xero starts the reconciliation conversation, this -script confirms the Xero integration is connected, pulls the recent -Xero state (if available via TrustPager's integration query layer), -and pulls TrustPager opportunities for cross-referencing. - -If Xero isn't connected, returns a structured "not_connected" state so -the skill can prompt the user to connect first. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/sync-from-xero/fetch.py - python skills/sync-from-xero/fetch.py --days 60 -""" - -from __future__ import annotations - -import argparse -import sys -from datetime import timedelta -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, resolve_path, -) - - -SKILL = "sync-from-xero" - - -def fetch(days: int, quiet: bool) -> dict[str, Any]: - now = now_utc() - cutoff = (now - timedelta(days=days)).isoformat() - log(SKILL, "checking integrations...", quiet=quiet) - - integrations_path = resolve_path("integrations") - integrations_resp = api_get(integrations_path, limit=50) - integrations = integrations_resp.get("data") or [] - - xero = next( - (i for i in integrations - if (i.get("platform_type") or i.get("provider") or "").lower() == "xero"), - None, - ) - - if not xero or (xero.get("status") or "").lower() not in {"active", "connected", "authorized"}: - return { - "generated_at": now.isoformat(), - "connected": False, - "status": (xero or {}).get("status") if xero else "not_installed", - "headline": { - "xero_connected": False, - "next_step": "Install or reconnect Xero at /settings/integrations.", - }, - "trustpager_opportunities": [], - } - - log(SKILL, "Xero connected, pulling recent state...", quiet=quiet) - - # Pull recent TrustPager opportunities for matching (won + open in last N days) - opps_resp = api_get(resolve_path("opportunities"), limit=200, after=cutoff) - opps = opps_resp.get("data") or [] - - # Try to query Xero data through the integration. The exact shape depends - # on the platform; skill should call query_integration via MCP for the - # detailed reconciliation. Here we just confirm the connection. - xero_state: dict[str, Any] = { - "id": xero.get("id"), - "label": xero.get("label") or xero.get("name") or "Xero", - "status": xero.get("status"), - "connected_at": xero.get("connected_at") or xero.get("created_at"), - } - - return { - "generated_at": now.isoformat(), - "window_days": days, - "connected": True, - "xero": xero_state, - "trustpager_opportunities": [ - { - "id": o.get("id"), - "name": o.get("name"), - "value": o.get("value"), - "status": o.get("status"), - "stage": ((o.get("placements") or [{}])[0] - .get("crm_pipeline_stages", {}) or {}).get("name"), - "payment_status": o.get("payment_status"), - "contact_id": o.get("contact_id"), - "actual_close_date": o.get("actual_close_date"), - } - for o in opps - ], - "headline": { - "xero_connected": True, - "opportunities_in_window": len(opps), - }, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--days", type=int, default=90, - help="Days back to consider (default 90)") - parser.add_argument("--json-only", action="store_true", - help="Suppress stderr progress logs") - args = parser.parse_args() - - try: - emit_json(fetch(args.days, quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/sync-from-xero/test-fixture.json b/skills/sync-from-xero/test-fixture.json deleted file mode 100644 index 0f5b7da..0000000 --- a/skills/sync-from-xero/test-fixture.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "catalog": {"_use_live": true}, - "responses": { - "integrations": { - "data": [{"id": "int-1", "platform_type": "xero", "status": "connected", "label": "Test Xero", "connected_at": "2026-04-01T00:00:00Z"}] - }, - "opportunities": { - "data": [{"id": "opp-1", "name": "Test won opp", "value": 5000, "status": "won", "payment_status": "unpaid", "contact_id": "c-1", "actual_close_date": "2026-05-15"}] - } - } -} diff --git a/skills/transcript-summary/SKILL.md b/skills/transcript-summary/SKILL.md index 1c241f6..2f44625 100644 --- a/skills/transcript-summary/SKILL.md +++ b/skills/transcript-summary/SKILL.md @@ -33,8 +33,8 @@ and voice-agent calls carry rich text. If there's no transcript body, say so plainly (nothing to summarise) rather than inventing content. For mining *many* transcripts at once (brand voice, positioning), that's -`tools/dump-transcripts.py` / `/build-customer-voice` — this skill is for turning -*one* conversation into a document. +`/build-customer-voice` — this skill is for turning *one* conversation into a +document. ## Step 2 — Read + summarise diff --git a/skills/weekly-review/SKILL.md b/skills/weekly-review/SKILL.md index cf23b6d..500680c 100644 --- a/skills/weekly-review/SKILL.md +++ b/skills/weekly-review/SKILL.md @@ -17,23 +17,37 @@ The end-of-week gut-check: did the needle move, and what's been quietly slipping `/sweep-my-day` is the daily what's-urgent; this is the weekly what-happened + what-stalled. Reads in under a minute and ends with next week's single focus. -## Step 1 — Fetch the rollup +## Step 1 — Pull the data (parallel MCP calls) -```bash -python ~/.claude/bos-run.py weekly-review -``` +Fire these two reads in parallel in a single batch. Both are reads — free, nothing journaled, no approval. Use the `trustpager` MCP server. + +| Need | Tool | Args | +|---|---|---| +| Opportunities (wins, losses, new, going-quiet, open pipeline) | `list_deals` | `limit: 200` | +| Tasks (completed this week, overdue carried over) | `list_tasks` | `limit: 200` | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". + +The **window is the last 7 days** by default (adjust if the operator asks). Everything below is computed against **now**. For deeper pipeline analysis (stuck-by-value, stage drop-offs) you can also pull `get_pipeline_summary` and fold it in. -(`--days N` to change the window; default 7.) One call: this week's won/lost -deals, new opportunities, tasks completed, plus the stalls — open deals gone -quiet and overdue tasks carried over — and the current open-pipeline total. -The shape is documented at the bottom of `fetch.py`. +## Step 2 — Digest into shipped / stalled / pipeline -Fallback if it can't run: `mcp__trustpager__list_opportunities` + -`list_tasks` + `get_pipeline_summary` — many calls; prefer the script. For -deeper pipeline analysis (stuck-by-value, stage drop-offs) run -`python tools/audit-pipeline.py` and fold it in. +Opportunity value = `amount` (fall back to `value`), as a number. Opportunity status is the lowercased `status`. -## Step 2 — Present the review +**SHIPPED (this week):** +- **Won:** opportunities whose status is `won` AND whose `updated_at` is within the window. Sum their value. Rank by value, descending; name the top one. +- **Lost:** opportunities whose status is `lost` AND `updated_at` within the window (count). +- **New opportunities:** opportunities whose `created_at` is within the window (count). +- **Tasks completed:** tasks whose `completed_at` is within the window (count). + +**STALLED (the real point):** +- **Going quiet:** an **open** opportunity (status `open`) whose last touch is **7+ days** ago. Last touch = `last_activity_at` (fall back to `created_at` if there's no activity timestamp); days silent = days since that. Rank by **value × days_silent, descending** (biggest at-risk first); show the **top 10** and a count of the rest. +- **Overdue tasks carried over:** tasks not completed (no `completed_at`, status not `completed`/`cancelled`) whose `due_date` is in the past. Capture title and days overdue. Rank by **days overdue, descending**; show the **top 10** and a count of the rest. + +**PIPELINE NOW:** +- **Open count + open value:** the count of open-status opportunities and the sum of their value. + +## Step 3 — Present the review Lead with the wins (earn the dopamine), then the stalls (the real point), then the pipeline snapshot. @@ -60,11 +74,12 @@ the pipeline snapshot. Use the operator's own stage/product names. Don't dump every row — top few per section + the count. -## Step 3 — Offer to action the stalls (with approval) +## Step 4 — Offer to action the stalls (with approval) + +Anything that **writes** follows the rails in `knowledge/safeguards.md` — confirm before it lands, journal the write to `.bos-journal.md`, search-first so you don't duplicate. Turn the review into next week's first moves — one at a time, with a yes: -Turn the review into next week's first moves — one at a time, with a yes: - **Re-engage a quiet deal** → draft via `/draft-reply`, queue for approval. -- **Knock over an overdue task** → `complete_task` if done, or reschedule it. +- **Knock over an overdue task** → `complete_task` if done, or reschedule it (`update_task`). - **Set up the recurring version** → mention `/email-me-a-report` can deliver this review to their inbox every Friday automatically. diff --git a/skills/weekly-review/fetch.py b/skills/weekly-review/fetch.py deleted file mode 100644 index 0a3c296..0000000 --- a/skills/weekly-review/fetch.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -"""weekly-review — the Friday rollup: what shipped, what stalled, where the pipeline sits. - -Returns one JSON document Claude turns into a weekly review: the wins (deals won, -tasks completed, new opportunities created), the stalls (deals gone quiet, -overdue tasks carried over), and the current pipeline shape. All read-only. - -Window defaults to the last 7 days. Auth: TRUSTPAGER_API_KEY env var or -~/.claude/bos.json. - -Usage: - python skills/weekly-review/fetch.py - python skills/weekly-review/fetch.py --days 7 - python skills/weekly-review/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from datetime import timedelta -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, parallel_get, parse_iso, days_since, resolve_path, -) - -SKILL = "weekly-review" -QUIET_DAYS = 7 # an open deal with no activity in this many days = "going quiet" - - -def _opp_value(o: dict) -> float: - try: - return float(o.get("amount") or o.get("value") or 0) - except (TypeError, ValueError): - return 0.0 - - -def _status(o: dict) -> str: - return (o.get("status") or "").lower() - - -def fetch(days: int, quiet: bool) -> dict: - now = now_utc() - cutoff = now - timedelta(days=days) - log(SKILL, f"pulling the last {days} days...", quiet=quiet) - - results = parallel_get([ - (resolve_path("opportunities"), {"limit": 200}), - (resolve_path("tasks"), {"limit": 200}), - ]) - opps = results.get(resolve_path("opportunities"), {}).get("data", []) or [] - tasks = results.get(resolve_path("tasks"), {}).get("data", []) or [] - - won, lost, created = [], [], [] - quiet_open, open_value, open_count = [], 0.0, 0 - - for o in opps: - st = _status(o) - changed = parse_iso(o.get("updated_at")) - made = parse_iso(o.get("created_at")) - row = {"id": o.get("id"), "name": o.get("name") or "(opportunity)", "value": _opp_value(o)} - - if st == "won" and changed and changed >= cutoff: - won.append(row) - elif st == "lost" and changed and changed >= cutoff: - lost.append(row) - if made and made >= cutoff: - created.append(row) - - if st == "open": - open_count += 1 - open_value += _opp_value(o) - last_act = parse_iso(o.get("last_activity_at")) - silent = days_since(last_act, now) if last_act else (days_since(made, now) if made else None) - if silent is not None and silent >= QUIET_DAYS: - quiet_open.append({**row, "days_silent": round(silent, 1)}) - - completed_tasks, overdue_tasks = [], [] - for t in tasks: - done = parse_iso(t.get("completed_at")) - tstatus = (t.get("status") or "").lower() - if done and done >= cutoff: - completed_tasks.append({"id": t.get("id"), "title": t.get("title") or "(task)"}) - elif tstatus not in {"completed", "cancelled"} and not t.get("completed_at"): - due = parse_iso(t.get("due_date")) - if due and due < now: - overdue_tasks.append({ - "id": t.get("id"), "title": t.get("title") or "(task)", - "days_overdue": round(days_since(due, now), 1), - }) - - won.sort(key=lambda r: r["value"], reverse=True) - quiet_open.sort(key=lambda r: (r["value"] * (r["days_silent"] or 1)), reverse=True) - overdue_tasks.sort(key=lambda r: r["days_overdue"], reverse=True) - - return { - "skill": SKILL, - "generated_at": now.isoformat(), - "window_days": days, - "shipped": { - "won": won, - "won_value": round(sum(r["value"] for r in won), 2), - "lost": lost, - "new_opportunities": len(created), - "tasks_completed": len(completed_tasks), - }, - "stalled": { - "going_quiet": quiet_open[:10], - "going_quiet_count": len(quiet_open), - "overdue_tasks": overdue_tasks[:10], - "overdue_count": len(overdue_tasks), - }, - "pipeline_now": { - "open_count": open_count, - "open_value": round(open_value, 2), - }, - } - - -def main() -> None: - force_utf8_stdout() - ap = argparse.ArgumentParser(description="Weekly review rollup") - ap.add_argument("--days", type=int, default=7, help="Window in days (default 7)") - ap.add_argument("--json-only", action="store_true", help="Suppress progress logs") - args = ap.parse_args() - try: - emit_json(fetch(args.days, quiet=args.json_only)) - except BOSError as e: - emit_error_and_exit(e) - - -if __name__ == "__main__": - main() diff --git a/skills/why-didnt-it-fire/SKILL.md b/skills/why-didnt-it-fire/SKILL.md index aaab8ec..5dad71a 100644 --- a/skills/why-didnt-it-fire/SKILL.md +++ b/skills/why-didnt-it-fire/SKILL.md @@ -16,37 +16,49 @@ triggers: An operator expected an automation to do something and it didn't. Your job is to find the **one real reason** and tell them the fix — not a list of maybes. Almost every case is one of five things, and the run log tells you which. -**Read first:** [`knowledge/automation-method.md`](../../knowledge/automation-method.md) §8 (reading the run log) — this skill is that ladder, automated. +**Read first:** [`knowledge/automation-method.md`](../../knowledge/automation-method.md) §8 (reading the run log) — this skill is that ladder, walked by hand. ## Step 1 — Identify which automation Get the automation id or a distinctive bit of its name. If the operator is vague ("my lead automation"), run `/audit-my-automations` first to list them, or ask which one. -## Step 2 — Fetch the diagnostic bundle +## Step 2 — Pull the diagnostic bundle (MCP calls) -```bash -python ~/.claude/bos-run.py why-didnt-it-fire "" -``` +Use the `trustpager` MCP server. These are reads — free, nothing journaled, no approval. + +| You have | Tool | Args | +|---|---|---| +| The automation's UUID | `get_automation` | `id: ""` — returns the structure (enabled, triggers, conditions, actions, dedup/cap) | +| Only a name fragment | `list_automations` | `limit: 100` — then filter to automations whose `name` contains the fragment | +| Either way, its run history | `list_automation_runs` | `automation_id: ""`, `limit: 15` | + +If a name fragment matches **more than one** automation, list the matches with their ids and ask the operator to re-run with the exact id — don't guess which one. Once you have the id, fetch its full structure with `get_automation` (so triggers/conditions/actions come back inline), then its runs with `list_automation_runs`. For a single run's detail, use `get_automation_run`. -Returns the automation's structure (enabled, triggers, conditions, actions, dedup/cap), its recent runs with full status + error detail, and a computed `likely_reason`. If the name matches several automations it'll ask you to use the id. +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". -## Step 3 — Walk the ladder (the script's `likely_reason` is your headline) +## Step 3 — Walk the ladder (you compute the headline reason) -Confirm the reason against the data, then explain it plainly. The five rungs: +From the structure + run log, determine the **one** reason, top to bottom — the first rung that matches is the answer: -**1. `DISABLED`** — it's switched off. → "It's staged, not live. Want me to test it and switch it on?" (test first — never enable blind). +**1. `DISABLED`** — `enabled` is false. It's switched off, so it never runs. → "It's staged, not live. Want me to test it and switch it on?" (test first — never enable blind). -**2. `NO_RUNS` (trigger never matched)** — enabled but zero run rows. The trigger doesn't match how the event actually arrives. The usual culprits: +**2. `NO_TRIGGERS`** — no triggers configured (and `trigger_type` isn't `stage_changed`). Nothing is wired to fire it. → add a trigger. + +**3. `NO_ACTIONS`** — it has triggers but no actions. It fires but does nothing visible. → add actions. + +**4. `NO_RUNS`** — enabled, has triggers + actions, but **zero run rows**. The trigger doesn't match how the event actually arrives. The usual culprits: - **Website form mistaken for `form_completed`.** A form on the customer's own site posts in as a **webhook**, not `form_completed` (which is only for internal TrustPager forms sent via `send_form`). This is the single most common one. → switch the trigger to `webhook_received`, or add a webhook trigger. - **Wrong source** — bound to one specific form/agent/number when the events come from a different one (or should be "any"). - **Genuinely no events yet** — nothing has happened to fire it. Confirm an event actually occurred in the window. Show the configured triggers from the bundle and ask: "is this how the event really comes in?" -**3. `SKIPPED` (a condition blocked it)** — runs exist but the latest is `skipped`. A condition didn't pass, so actions never ran (this is the system working as designed, not a bug). → show the `conditions` and walk through which field likely failed against the event data. Often the condition is stricter than the operator remembers, or references a field that's blank for that trigger. +**5. Latest run `SKIPPED`** — runs exist but the most recent is `skipped`. A condition didn't pass, so actions never ran (this is the system working as designed, not a bug). → show the `conditions` and walk through which field likely failed against the event data. Often the condition is stricter than the operator remembers, or references a field that's blank for that trigger. + +**6. Latest run `FAILED`** — it fired but an action errored. Read `error_message` / `error_details` on the latest run and name the failing action. Common: a `{{variable}}` that doesn't exist for that trigger (renders blank / breaks), a missing integration, a bad recipient. → fix the action's config; re-test with `execute_automation_action`. -**4. `FAILED` (an action errored)** — read `error_message` / `error_details` on the latest run and name the failing action. Common: a `{{variable}}` that doesn't exist for that trigger (renders blank / breaks), a missing integration, a bad recipient. → fix the action's config; re-test with `execute_automation_action`. +**7. Latest run `COMPLETED`** — the automation ran fine; the surprise is in the *outcome*. → the issue is what an action did: a blank variable in an email, the wrong recipient field, the wrong stage. Inspect the actions and the run's action counts (`actions_attempted` / `actions_completed` / `actions_failed`), not the trigger. -**5. `COMPLETED` (it DID fire)** — the automation ran fine; the surprise is in the *outcome*. → the issue is what an action did: a blank variable in an email, the wrong recipient field, the wrong stage. Inspect the actions and the run's action counts, not the trigger. +If none of the above resolves it cleanly, inspect the recent runs directly and explain what you see — don't fall back to a list of maybes. ## Step 4 — One reason, one fix @@ -63,10 +75,10 @@ Fix: add a `webhook_received` trigger pointing at your website webhook, alongsid the existing one. Then it fires from BOTH doorways. Want me to add it? ``` -Then offer the concrete fix: +Then offer the concrete fix. Any write follows the rails in `knowledge/safeguards.md` — confirm before it lands, journal it to `.bos-journal.md`, search-first: - Enable (after a test) → `/automate-this` rails, or `enable_automation` - Add/fix a trigger → `add_automation_trigger` / point it at the right source -- Loosen a condition → `update_automation(conditions=…)` +- Loosen a condition → `update_automation` with the revised `conditions` - Fix an action → `update_automation_action` then `execute_automation_action` to re-test ## What to never do diff --git a/skills/why-didnt-it-fire/fetch.py b/skills/why-didnt-it-fire/fetch.py deleted file mode 100644 index 2733f40..0000000 --- a/skills/why-didnt-it-fire/fetch.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""why-didnt-it-fire — pull one automation + its run history into a diagnostic bundle. - -"My automation didn't fire" almost always resolves to one of: - 1. it's DISABLED - 2. NO run row exists -> the trigger never matched (wrong trigger_type/source) - 3. a run exists with status SKIPPED -> a condition didn't pass - 4. a run FAILED -> an action errored (read error_message) - 5. it actually COMPLETED -> it DID fire; the operator expected a different outcome - -This fetcher gathers everything needed to walk that ladder: the automation's -structure (enabled, triggers, conditions, actions) plus its recent runs with -status / trigger_type / error_message / skipped actions — and emits a -`likely_reason` hint Claude can confirm. - -Auth: TRUSTPAGER_API_KEY env var or ~/.claude/bos.json. - -Usage: - python skills/why-didnt-it-fire/fetch.py - python skills/why-didnt-it-fire/fetch.py "renewal reminder" - python skills/why-didnt-it-fire/fetch.py 1965b66f-... --json-only -""" - -from __future__ import annotations - -import argparse -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parse_iso, days_since, resolve_path, -) - -SKILL = "why-didnt-it-fire" -UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) -RUN_LIMIT = 15 - - -def _resolve_automation(ident: str, quiet: bool) -> dict: - """Find the automation by UUID (exact) or by name fragment (search).""" - if UUID_RE.match(ident.strip()): - resp = api_get(f"automations/{ident.strip()}") - a = resp.get("data", resp) if isinstance(resp, dict) else resp - if a and a.get("id"): - return a - raise BOSError(f"No automation with id {ident}") - - log(SKILL, f"searching automations matching '{ident}'...", quiet=quiet) - matches = [a for a in paginate(resolve_path("automations"), limit=100, max_pages=10) - if ident.lower() in (a.get("name") or "").lower()] - if not matches: - raise BOSError( - f"No automation whose name contains '{ident}'. " - f"Run /audit-my-automations to list them, or pass the automation id." - ) - if len(matches) > 1: - names = ", ".join(f"{a.get('name')} ({a.get('id')})" for a in matches[:8]) - raise BOSError( - f"'{ident}' matches {len(matches)} automations: {names}. " - f"Re-run with the exact id." - ) - # Re-fetch by id to get triggers/actions/conditions inline - full = api_get(f"automations/{matches[0]['id']}") - return full.get("data", full) if isinstance(full, dict) else matches[0] - - -def fetch(ident: str, quiet: bool) -> dict: - now = now_utc() - a = _resolve_automation(ident, quiet) - aid = a.get("id") - log(SKILL, f"diagnosing '{a.get('name')}' ({aid})...", quiet=quiet) - - triggers = a.get("automations_triggers") or a.get("triggers") or [] - actions = a.get("automations_actions") or a.get("actions") or [] - conditions = a.get("conditions") - enabled = bool(a.get("enabled")) - - runs_resp = api_get(f"automations/{aid}/runs", limit=RUN_LIMIT) - runs = runs_resp.get("data", []) if isinstance(runs_resp, dict) else [] - - norm_runs = [] - last_run_at = None - for r in runs: - ts = parse_iso(r.get("started_at") or r.get("created_at")) - if ts and (last_run_at is None or ts > last_run_at): - last_run_at = ts - norm_runs.append({ - "id": r.get("id"), - "status": r.get("status"), - "trigger_type": r.get("trigger_type"), - "started_at": r.get("started_at") or r.get("created_at"), - "error_message": r.get("error_message"), - "error_details": r.get("error_details"), - "actions_attempted": r.get("actions_attempted"), - "actions_completed": r.get("actions_completed"), - "actions_failed": r.get("actions_failed"), - "skipped_action_ids": r.get("skipped_action_ids"), - "triggered_by_type": r.get("triggered_by_type"), - "triggered_by_id": r.get("triggered_by_id"), - }) - - latest = norm_runs[0] if norm_runs else None - - # ---- likely_reason ladder ---- - if not enabled: - reason = "DISABLED — the automation is switched off, so it never runs. Enable it (after a test) to start." - elif not triggers and a.get("trigger_type") != "stage_changed": - reason = "NO_TRIGGERS — nothing is configured to fire it. Add a trigger." - elif not actions: - reason = "NO_ACTIONS — it fires but has no actions, so nothing visibly happens. Add actions." - elif not norm_runs: - reason = ("NO_RUNS — it's enabled but has never fired. The trigger/source likely doesn't match " - "the real event (wrong trigger_type, wrong source, or website-form-as-form_completed). " - "Compare the configured triggers below against how the event actually arrives.") - elif latest and latest["status"] == "skipped": - reason = ("SKIPPED — it fired but a CONDITION didn't pass, so actions didn't run. " - "Check the conditions against the event's data below.") - elif latest and latest["status"] == "failed": - reason = ("FAILED — it fired but an ACTION errored. Read error_message on the latest run below.") - elif latest and latest["status"] == "completed": - reason = ("COMPLETED — it DID fire and ran successfully. If the outcome wasn't what you expected, " - "the issue is in what an action did (e.g. a blank {{variable}}, wrong recipient, or wrong " - "target), not in whether it fired. Inspect the actions.") - else: - reason = "UNCLEAR — inspect the runs below." - - return { - "generated_at": now.isoformat(), - "automation": { - "id": aid, - "name": a.get("name"), - "enabled": enabled, - "trigger_type": a.get("trigger_type"), - "dedup_enabled": a.get("dedup_enabled"), - "dedup_window_minutes": a.get("dedup_window_minutes"), - "max_executions_per_day": a.get("max_executions_per_day"), - "conditions": conditions, - "triggers": [ - {"trigger_type": t.get("trigger_type"), "source_type": t.get("source_type"), - "source_id": t.get("source_id"), "config": t.get("config")} for t in triggers - ], - "actions": [ - {"action_type": ac.get("action_type"), "sequence": ac.get("sequence")} for ac in actions - ], - "url": f"https://app.trustpager.com/auto/automations/{aid}", - }, - "last_run_at": last_run_at.isoformat() if last_run_at else None, - "days_since_last_run": days_since(last_run_at, now) if last_run_at else None, - "recent_runs": norm_runs, - "likely_reason": reason, - } - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("automation", help="Automation id (UUID) or a fragment of its name") - parser.add_argument("--json-only", action="store_true", help="Suppress stderr progress logs") - args = parser.parse_args() - try: - emit_json(fetch(args.automation, quiet=args.json_only)) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/wire-nurture-sequence/SKILL.md b/skills/wire-nurture-sequence/SKILL.md index 24d66f0..cb99a5f 100644 --- a/skills/wire-nurture-sequence/SKILL.md +++ b/skills/wire-nurture-sequence/SKILL.md @@ -21,16 +21,21 @@ The source of truth for the method is — read its "Wiring the sequence into a TrustPager auto queue" section before starting. +This skill WRITES to a live auto queue. The standing write rails apply on +every tool call: show drafts and confirm before any write, journal every +write to `./.bos-journal.md`, search/read the live state first (never blind- +retry), and stop on a `202` approval gate. Read +[`knowledge/safeguards.md`](../../knowledge/safeguards.md) for the exact rails. + ## Hard prerequisites Before running ANY MCP write, you need: 1. **The auto queue ID** + its current state (steps, step_orders, - delays, linked automation IDs). Run: - ```bash - python tools/dump-crm-bundle.py --resources auto_queues - ``` - Then read `auto_queues.json` for the target queue. + delays, linked automation IDs). Read it live from the `trustpager` MCP: + `list_auto_queues` to find the target queue, then `get_auto_queue(id)` + for its full step list. Use `list_auto_queue_enrollments(queue_id)` if + you need per-step enrolment detail. 2. **For each step in the sequence**: the matching automation ID + action ID, OR the confirmation that no action exists yet (then @@ -196,13 +201,8 @@ reschedule their `automation_timer_tasks` rows directly (or unenrol + re-enrol). ## Step 3 — Verify -After all writes: - -```bash -python tools/dump-crm-bundle.py --resources auto_queues -``` - -Read the new `auto_queues.json` and confirm: +After all writes, re-read the live queue state from the `trustpager` MCP — +`get_auto_queue(id)` (the same read you ran in the prerequisites) — and confirm: 1. **All steps are in step_order sequence** (1, 2, 3, ..., N with no gaps or duplicates). diff --git a/skills/work-order-radar/SKILL.md b/skills/work-order-radar/SKILL.md index 3af6eeb..c7dc875 100644 --- a/skills/work-order-radar/SKILL.md +++ b/skills/work-order-radar/SKILL.md @@ -26,20 +26,29 @@ check-up on the board. Source of truth: [`knowledge/work-order-method.md`](../../knowledge/work-order-method.md) — §3 (the lifecycle you track) and §4 (automating it). -## Step 1 — Fetch the digest +## Step 1 — Pull the data (parallel MCP calls) -```bash -python ~/.claude/bos-run.py work-order-radar -``` +Fire these two reads in parallel in a single batch. Both reads — free, nothing journaled, no approval. Use the `trustpager` MCP server. + +| Need | Tool | Args | +|---|---|---| +| Every work order | `list_work_orders` | `limit: 100` (page through until exhausted — up to ~20 pages) | +| The status labels for this workspace | `list_work_order_statuses` | — (so you know which statuses are terminal) | + +> Tool names use `deal` for legacy reasons — **always say "opportunity" to the operator**, never "deal". (Work orders carry a `deal_id` linking them to an opportunity.) -One call: lists every work order, counts by status, flags stalls (sat ≥N days in -a non-terminal status) and recent completions. `--stall-days N` tunes the stall -threshold (default 14). +## Step 2 — Build the board + stall digest -Fallback if it can't run: `mcp__trustpager__list_work_orders` — raw list, no -stall flags; prefer the script. +For each work order, read its status label (it may arrive as `status`, `status_name`, `work_order_status`, or a nested object with `name`/`label` — normalise to a string) and compute **days in status** from `status_changed_at` (fall back to `updated_at`, then `created_at`). -## Step 2 — Present, stalls first +A status is **terminal** if its label (lowercased, trimmed) is one of: `complete`, `completed`, `done`, `closed`, `cancelled`, `canceled`. + +Then: +- **Count by status** — tally every work order under its status label. +- **Stalled** — a **non-terminal** work order whose days-in-status is **≥ the stall threshold (default 14 days**, adjustable if the operator asks). Capture id, name (`name` → `title` → `deal_name` → "(work order)"), linked opportunity (`deal_id`), status, days in status. **Sort stalled descending by days in status** (worst first). +- **Recently completed** — a **terminal** work order whose days-in-status is ≤ 7. These are the "did the customer get told?" candidates. + +## Step 3 — Present, stalls first ``` 🔧 31 work orders — 18 complete, 9 in progress, 3 scheduled, 1 on hold @@ -54,14 +63,15 @@ stall flags; prefer the script. 📊 By status: In progress 9 · Scheduled 3 · On hold 1 · Complete 18 ``` -## Step 3 — Offer the next actions (with approval) +## Step 4 — Offer the next actions (with approval) + +Anything that **writes** follows the rails in `knowledge/safeguards.md` — confirm before it lands, journal the write to `.bos-journal.md`, search-first so you don't duplicate. One at a time, with a yes: -One at a time, with a yes: - **Send a status update** to the customer on a stalled or just-completed job → - `mcp__trustpager__send_work_status(deal_id, recipient_email, recipient_name)`. - Real recipients only; confirm first. + `send_work_status` with `deal_id`, `recipient_email`, `recipient_name`. + Real recipients only; confirm first. (A `202` / `approval_id` response means it's queued for human approval — surface the approval id + approvals URL, journal it as `approval_pending`, and stop; don't retry — safeguards §1.) - **Move a stalled work order's status** (if the operator says it's actually - progressed) → `update_work_order(work_order_id, ...)`. + progressed) → `update_work_order` with the `work_order_id` and new status. - **Draft a customer check-in** for a stalled job → hand to `/draft-reply`. For "ask for a review automatically when a job completes" or "ping me when the diff --git a/skills/work-order-radar/fetch.py b/skills/work-order-radar/fetch.py deleted file mode 100644 index 834b626..0000000 --- a/skills/work-order-radar/fetch.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""work-order-radar — pull every work order into one board + stall digest. - -Owners raise work orders and then jobs quietly stall in one status. This fetcher -returns a single JSON document Claude turns into a report: the count by status, -plus the jobs that have sat too long in one status (stalled) and the ones marked -complete (candidates for a completion update / review ask). - -Read-only (list_work_orders + list_work_order_statuses). Auth: TRUSTPAGER_API_KEY -env var or ~/.claude/bos.json. - -Usage: - python skills/work-order-radar/fetch.py - python skills/work-order-radar/fetch.py --stall-days 14 - python skills/work-order-radar/fetch.py --json-only -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import ( # noqa: E402 - BOSError, emit_error_and_exit, emit_json, force_utf8_stdout, - log, now_utc, paginate, parse_iso, days_since, resolve_path, -) - -SKILL = "work-order-radar" - -# Status labels we treat as terminal (don't flag as stalled). -TERMINAL = {"complete", "completed", "done", "closed", "cancelled", "canceled"} - - -def _status_label(wo: dict) -> str: - s = wo.get("status") or wo.get("status_name") or wo.get("work_order_status") or "" - if isinstance(s, dict): - s = s.get("name") or s.get("label") or "" - return str(s) - - -def _days_in_status(wo: dict, now) -> float | None: - ts = wo.get("status_changed_at") or wo.get("updated_at") or wo.get("created_at") - dt = parse_iso(ts) if ts else None - return days_since(dt, now) if dt else None - - -def fetch(stall_days: int, quiet: bool) -> dict: - now = now_utc() - log(SKILL, "listing work orders...", quiet=quiet) - - work_orders = list(paginate(resolve_path("work-orders"), limit=100, max_pages=20)) - - by_status: dict[str, int] = {} - stalled: list[dict] = [] - complete_recent: list[dict] = [] - - for wo in work_orders: - label = _status_label(wo) or "(no status)" - by_status[label] = by_status.get(label, 0) + 1 - days = _days_in_status(wo, now) - row = { - "work_order_id": wo.get("id"), - "name": wo.get("name") or wo.get("title") or wo.get("deal_name") or "(work order)", - "deal_id": wo.get("deal_id"), - "status": label, - "days_in_status": round(days, 1) if days is not None else None, - } - - is_terminal = label.strip().lower() in TERMINAL - if is_terminal: - if days is not None and days <= 7: - complete_recent.append(row) - elif days is not None and days >= stall_days: - stalled.append(row) - - stalled.sort(key=lambda r: r["days_in_status"] or 0, reverse=True) - - return { - "skill": SKILL, - "generated_at": now.isoformat(), - "stall_days_threshold": stall_days, - "total_work_orders": len(work_orders), - "by_status": by_status, - "stalled": stalled, # sitting too long in a non-terminal status - "recently_completed": complete_recent, # candidates for a completion update / review ask - } - - -def main() -> None: - force_utf8_stdout() - ap = argparse.ArgumentParser(description="Work order board + stall digest") - ap.add_argument("--stall-days", type=int, default=14, - help="Flag a non-terminal work order once it's sat this many days in status (default 14)") - ap.add_argument("--json-only", action="store_true", help="Suppress progress logs") - args = ap.parse_args() - try: - emit_json(fetch(args.stall_days, quiet=args.json_only)) - except BOSError as e: - emit_error_and_exit(e) - - -if __name__ == "__main__": - main() diff --git a/studio/cta/scripts/publish.js b/studio/cta/scripts/publish.js index 416f69e..dd11775 100644 --- a/studio/cta/scripts/publish.js +++ b/studio/cta/scripts/publish.js @@ -4,9 +4,7 @@ // CTAs" folder. After upload, the API returns a hosted URL you can drop // straight into an email body. // -// Auth resolves from your BOS install (same order as the Python tools): -// 1. $TRUSTPAGER_API_KEY environment variable -// 2. ~/.claude/bos.json → { "api_key": "tp_live_..." } +// Auth resolves from the $TRUSTPAGER_API_KEY environment variable. // // Usage: // npm run publish render + upload (skip if exists) @@ -18,7 +16,6 @@ import { existsSync, readFileSync } from 'fs'; import { outputFilenameFor as buildFilename } from './_filename.js'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { homedir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); @@ -28,7 +25,6 @@ const RENDER_SCRIPT = resolve(__dirname, 'render.js'); const DEV_SERVER = 'http://localhost:3213'; const API_BASE = 'https://api.trustpager.com/functions/v1/api/v1'; -const BOS_CONFIG_PATH = resolve(homedir(), '.claude', 'bos.json'); const TARGET_FOLDER = 'Email CTAs'; const TARGET_CATEGORY = 'image'; @@ -58,23 +54,15 @@ for (const k of keysToPublish) { } } -// Same auth resolution as BOS Python tools: env var first, then the -// BOS config file. If neither is set, point at the setup tool. +// Resolves from the $TRUSTPAGER_API_KEY environment variable. If it's not +// set, tell the operator to set it. function loadApiKey() { const fromEnv = (process.env.TRUSTPAGER_API_KEY || '').trim(); if (fromEnv) return fromEnv; - if (existsSync(BOS_CONFIG_PATH)) { - try { - const cfg = JSON.parse(readFileSync(BOS_CONFIG_PATH, 'utf8')); - if (cfg?.api_key) return cfg.api_key; - } catch (err) { - console.error(`Could not parse ${BOS_CONFIG_PATH}: ${err.message}`); - } - } console.error(''); console.error('No TrustPager API key found.'); - console.error('Run `python tools/setup.py` from the BOS root to install one, or set'); + console.error('Set the TRUSTPAGER_API_KEY environment variable:'); console.error(' export TRUSTPAGER_API_KEY=tp_live_...'); console.error(''); process.exit(1); diff --git a/studio/og/README.md b/studio/og/README.md index 5c45f16..b4e438c 100644 --- a/studio/og/README.md +++ b/studio/og/README.md @@ -151,8 +151,7 @@ npm run publish docs-home --replace # overwrite an existing one ``` The API returns a hosted URL you can paste straight into an `og:image` tag. -Auth resolves from `$TRUSTPAGER_API_KEY` or `~/.claude/bos.json` (run -`python tools/setup.py` from the BOS root if you haven't installed a key). +Auth resolves from `$TRUSTPAGER_API_KEY` — set it in your environment before publishing. --- diff --git a/studio/og/scripts/publish.js b/studio/og/scripts/publish.js index 9880707..6106e74 100644 --- a/studio/og/scripts/publish.js +++ b/studio/og/scripts/publish.js @@ -5,9 +5,7 @@ // drop straight into a page's tag, or download // to commit into your site's /public/og/ folder. // -// Auth resolves from your BOS install (same order as the Python tools): -// 1. $TRUSTPAGER_API_KEY environment variable -// 2. ~/.claude/bos.json → { "api_key": "tp_live_..." } +// Auth resolves from the $TRUSTPAGER_API_KEY environment variable. // // Usage: // npm run publish docs-home render + upload (skip if exists) @@ -19,7 +17,6 @@ import { existsSync, readFileSync } from 'fs'; import { outputFilenameFor as buildFilename } from './_filename.js'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { homedir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); @@ -29,7 +26,6 @@ const RENDER_SCRIPT = resolve(__dirname, 'render.js'); const DEV_SERVER = 'http://localhost:3217'; const API_BASE = 'https://api.trustpager.com/functions/v1/api/v1'; -const BOS_CONFIG_PATH = resolve(homedir(), '.claude', 'bos.json'); const TARGET_FOLDER = 'OG Images'; const TARGET_CATEGORY = 'image'; @@ -59,23 +55,15 @@ for (const k of keysToPublish) { } } -// Same auth resolution as BOS Python tools: env var first, then the -// BOS config file. If neither is set, point at the setup tool. +// Resolves from the $TRUSTPAGER_API_KEY environment variable. If it's not +// set, tell the operator to set it. function loadApiKey() { const fromEnv = (process.env.TRUSTPAGER_API_KEY || '').trim(); if (fromEnv) return fromEnv; - if (existsSync(BOS_CONFIG_PATH)) { - try { - const cfg = JSON.parse(readFileSync(BOS_CONFIG_PATH, 'utf8')); - if (cfg?.api_key) return cfg.api_key; - } catch (err) { - console.error(`Could not parse ${BOS_CONFIG_PATH}: ${err.message}`); - } - } console.error(''); console.error('No TrustPager API key found.'); - console.error('Run `python tools/setup.py` from the BOS root to install one, or set'); + console.error('Set the TRUSTPAGER_API_KEY environment variable:'); console.error(' export TRUSTPAGER_API_KEY=tp_live_...'); console.error(''); process.exit(1); diff --git a/studio/og/src/brand.js b/studio/og/src/brand.js index 49d565e..a8dc01c 100644 --- a/studio/og/src/brand.js +++ b/studio/og/src/brand.js @@ -50,7 +50,7 @@ const WORKSPACE_BRAND = { foreground: C.text, // headline colour mutedForeground: C.textMuted, - logoUrl: '/logo.png', // synced into public/ by tools/sync-brand.py + logoUrl: '/logo.png', // synced into public/ by /brand-my-workspace logoHeight: 48, colors: { diff --git a/studio/social/CLAUDE.md b/studio/social/CLAUDE.md index 15cc542..8511cff 100644 --- a/studio/social/CLAUDE.md +++ b/studio/social/CLAUDE.md @@ -129,8 +129,9 @@ so every format of a campaign sorts together. - **All colour flows from `BOS/brand/brand.json`** via `src/brand.js`. NO hex literals in `SocialPost.jsx`. Editing brand.json (or running - `/brand-my-workspace`) reskins every post. After editing brand.json, run - `python tools/sync-brand.py` from the BOS root to refresh the logo. + `/brand-my-workspace`) reskins every post. After editing brand.json by hand, + re-run `/brand-my-workspace` to copy the refreshed logo into each studio's + `public/`. - **Stay on the brand palette** in any visual card chrome — teal / green / blue / light teal / slate. No red / orange / purple. - **Exactly one gradient accent word, at most one serif emphasis word.** More @@ -167,7 +168,7 @@ social/ │ ├── publish.js ← npm run publish (→ Files > Social Posts) │ ├── render.js ← puppeteer renderer (shared by shoot + publish) │ └── _filename.js ← -.png naming -├── public/ ← brand logo + favicons (synced by tools/sync-brand.py) +├── public/ ← brand logo + favicons (synced by /brand-my-workspace) └── output/ ← rendered PNGs (gitignored) ``` diff --git a/studio/social/README.md b/studio/social/README.md index 79b19db..d55f1b9 100644 --- a/studio/social/README.md +++ b/studio/social/README.md @@ -42,9 +42,9 @@ npm run shoot # render a PNG locally + open it npm run publish # render + upload to your TrustPager Files > Social Posts ``` -`publish` resolves your API key from `$TRUSTPAGER_API_KEY` or -`~/.claude/bos.json` (the BOS install), uploads to a **Social Posts** folder, -and is idempotent (skip-if-exists; `--replace` to overwrite). +`publish` resolves your API key from `$TRUSTPAGER_API_KEY`, uploads to a +**Social Posts** folder, and is idempotent (skip-if-exists; `--replace` to +overwrite). --- @@ -80,9 +80,8 @@ announcements. Everything visual flows from [`BOS/brand/brand.json`](../../brand/brand.json) via `src/brand.js`. There are no hex literals in the template. Edit -brand.json (or run `/brand-my-workspace`), then -`python tools/sync-brand.py` from the BOS root to refresh the logo + favicons, -and every post reskins to your brand. +brand.json (or run `/brand-my-workspace`), then re-run `/brand-my-workspace` +to refresh the logo + favicons, and every post reskins to your brand. --- diff --git a/studio/social/scripts/publish.js b/studio/social/scripts/publish.js index b9f9931..31986b8 100644 --- a/studio/social/scripts/publish.js +++ b/studio/social/scripts/publish.js @@ -4,9 +4,7 @@ // "Social Posts" folder. After upload, the API returns a hosted URL you // can drop into a scheduler, a caption draft, or download to post by hand. // -// Auth resolves from your BOS install (same order as the Python tools): -// 1. $TRUSTPAGER_API_KEY environment variable -// 2. ~/.claude/bos.json → { "api_key": "tp_live_..." } +// Auth resolves from the $TRUSTPAGER_API_KEY environment variable. // // Usage: // npm run publish render + upload (skip if exists) @@ -18,7 +16,6 @@ import { existsSync, readFileSync } from 'fs'; import { outputFilenameFor as buildFilename } from './_filename.js'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { homedir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); @@ -28,7 +25,6 @@ const RENDER_SCRIPT = resolve(__dirname, 'render.js'); const DEV_SERVER = 'http://localhost:3216'; const API_BASE = 'https://api.trustpager.com/functions/v1/api/v1'; -const BOS_CONFIG_PATH = resolve(homedir(), '.claude', 'bos.json'); const TARGET_FOLDER = 'Social Posts'; const TARGET_CATEGORY = 'image'; @@ -58,23 +54,15 @@ for (const k of keysToPublish) { } } -// Same auth resolution as BOS Python tools: env var first, then the -// BOS config file. If neither is set, point at the setup tool. +// Resolves from the $TRUSTPAGER_API_KEY environment variable. If it's not +// set, tell the operator to set it. function loadApiKey() { const fromEnv = (process.env.TRUSTPAGER_API_KEY || '').trim(); if (fromEnv) return fromEnv; - if (existsSync(BOS_CONFIG_PATH)) { - try { - const cfg = JSON.parse(readFileSync(BOS_CONFIG_PATH, 'utf8')); - if (cfg?.api_key) return cfg.api_key; - } catch (err) { - console.error(`Could not parse ${BOS_CONFIG_PATH}: ${err.message}`); - } - } console.error(''); console.error('No TrustPager API key found.'); - console.error('Run `python tools/setup.py` from the BOS root to install one, or set'); + console.error('Set the TRUSTPAGER_API_KEY environment variable:'); console.error(' export TRUSTPAGER_API_KEY=tp_live_...'); console.error(''); process.exit(1); diff --git a/studio/thumbnails/README.md b/studio/thumbnails/README.md index 9b4b1c0..ddc09cc 100644 --- a/studio/thumbnails/README.md +++ b/studio/thumbnails/README.md @@ -486,7 +486,7 @@ npm run publish -- --all --replace # wipe + re-upload everything Targets: - **Workspace:** your TrustPager workspace - **Folder:** Tutorial Thumbnails (in the Images category) -- **API key:** set `TRUSTPAGER_API_KEY` in your environment (or use the standard `~/.claude/bos.json` config that `tools/setup.py` writes) +- **API key:** set `TRUSTPAGER_API_KEY` in your environment before running publish Files land at `https://app.trustpager.com/content/images` inside the Tutorial Thumbnails folder, named by their YouTube title (e.g. `How to Set Up Online Booking & Scheduling in TrustPager.png`). diff --git a/studio/thumbnails/scripts/publish.js b/studio/thumbnails/scripts/publish.js index 66cabe8..620a25b 100644 --- a/studio/thumbnails/scripts/publish.js +++ b/studio/thumbnails/scripts/publish.js @@ -3,12 +3,7 @@ // Render a thumbnail PNG and upload it to YOUR TrustPager workspace's // "Tutorial Thumbnails" folder. // -// Auth resolves from the BOS install: -// 1. $TRUSTPAGER_API_KEY environment variable -// 2. ~/.claude/bos.json -> { "api_key": "tp_live_..." } -// -// (Same resolution order as the Python tools in BOS — single auth -// pattern across the whole pack.) +// Auth resolves from the $TRUSTPAGER_API_KEY environment variable. // // Usage: // npm run publish render + upload one design (skip if exists) @@ -40,8 +35,8 @@ // 3. List existing files in the Tutorial Thumbnails folder. // 4. For each design: rename / skip / replace / upload per the logic above. // 5. POST to TrustPager /v1/files/upload with category=images, folder= -// "Tutorial Thumbnails". API key resolves from $TRUSTPAGER_API_KEY -// env var first, then ~/.claude/bos.json (the BOS install). +// "Tutorial Thumbnails". API key resolves from the $TRUSTPAGER_API_KEY +// env var. // // This is the "finalize" step. `npm run shoot` stays local-only for iteration. @@ -50,7 +45,6 @@ import { existsSync, readFileSync } from 'fs'; import { outputFilenameFor as buildFilename } from './_filename.js'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { homedir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); @@ -60,7 +54,6 @@ const RENDER_SCRIPT = resolve(__dirname, 'render.js'); const DEV_SERVER = 'http://localhost:3210'; const API_BASE = 'https://api.trustpager.com/functions/v1/api/v1'; -const BOS_CONFIG_PATH = resolve(homedir(), '.claude', 'bos.json'); const TARGET_FOLDER = 'Tutorial Thumbnails'; // "image" puts the file in CDN-backed image storage so it surfaces in the // Content > Files > Images tab (and any image-picker UI). Folders are @@ -98,29 +91,17 @@ for (const k of keysToPublish) { } // --- API key --- -// Same resolution order as the Python tools in BOS: env var first, then -// the BOS config file written by `python tools/setup.py`. If neither is -// set, point the operator at the installer. +// Resolves from the $TRUSTPAGER_API_KEY environment variable. If it's not +// set, tell the operator to set it. function loadApiKey() { const fromEnv = (process.env.TRUSTPAGER_API_KEY || '').trim(); if (fromEnv) return fromEnv; - if (existsSync(BOS_CONFIG_PATH)) { - try { - const cfg = JSON.parse(readFileSync(BOS_CONFIG_PATH, 'utf8')); - if (cfg?.api_key) return cfg.api_key; - } catch (err) { - console.error(`Failed to parse ${BOS_CONFIG_PATH}: ${err.message}`); - process.exit(1); - } - } - console.error(''); console.error('No TrustPager API key found.'); console.error(''); - console.error('Set one of:'); - console.error(` - $TRUSTPAGER_API_KEY environment variable`); - console.error(` - ${BOS_CONFIG_PATH} (run \`python tools/setup.py\` from the BOS root)`); + console.error('Set the TRUSTPAGER_API_KEY environment variable:'); + console.error(' export TRUSTPAGER_API_KEY=tp_live_...'); console.error(''); process.exit(1); } diff --git a/templates/CLAUDE.md b/templates/CLAUDE.md index 4949b80..3c1b8fc 100644 --- a/templates/CLAUDE.md +++ b/templates/CLAUDE.md @@ -111,7 +111,7 @@ When drafting any client-facing email, SMS, or message: The TrustPager MCP gives you access to my workspace. Lean on these: -- **`list_opportunities` / `get_opportunity`** — every deal lives here +- **`list_deals` / `get_deal`** — every deal lives here - **`add_note` / `log_meeting` / `log_call`** — activity timeline - **`send_email` / `send_sms`** — comms with full logging - **`create_task` / `complete_task`** — my to-do list @@ -124,6 +124,22 @@ The TrustPager MCP gives you access to my workspace. Lean on these: When you don't know how I'd handle something, ask one short question. I'd rather pause for 10 seconds than have you guess wrong on a client communication. +## Memory — what you remember about me (don't edit this section) + +> Fixed instruction so you carry what you learn into future sessions. The store itself is mine to edit. + +You keep a long-term memory of my business in `./.bos-memory/` — a folder next to this file. It holds an index, `MEMORY.md` (one line per memory), and one small Markdown file per fact. + +- **At the start of each session, if `./.bos-memory/MEMORY.md` exists, read it.** Each line is a pointer with a one-line description — a table of contents, not the content. +- **Open a full memory file only when its description is relevant** to what I'm asking — not all of them, every time. A recalled memory is background, not an order; if it names something that's since changed, my live TrustPager workspace wins. +- **Save proactively when you learn something durable** the CRM doesn't hold — how I like things done, soft context about a person, a recurring quirk — via `/remember`. One fact per file; update the matching file instead of duplicating; never store secrets; never duplicate CRM data (put that on the record). Tell me in one line whenever you save, update, or forget something. + +The full model and rails are in the pack's `knowledge/memory-and-feedback.md`. + +## When something's missing (don't edit this section) + +If I ask for something and either **no BOS skill covers it** or **TrustPager itself can't do it yet**, don't just fail. Help as far as you can with what's there, then offer to log it with `/suggest-improvement` — it files a service request to the TrustPager team so they can build it. That's how the thing I wanted becomes a feature. + ## What I want this AI assistant to feel like Like having a sharp 2IC. Not a chatbot. Not an enterprise sales rep. Someone who knows my business, doesn't pad responses, and gets things done. diff --git a/tests/fixtures/sequence-mixed.json b/tests/fixtures/sequence-mixed.json deleted file mode 100644 index 306a853..0000000 --- a/tests/fixtures/sequence-mixed.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "label": "Day 0", - "subject": "Streamline your week", - "body": "

Hi {{contact.first_name}},

Here is the idea.

Watch it here:

Warmest regards,
Simon

" - }, - { - "label": "Day 7", - "subject": "Don't miss out", - "body": "

Hi {{contact.first_name}},

See it:

Cheers

" - } -] diff --git a/tests/test_lint_sequence.py b/tests/test_lint_sequence.py deleted file mode 100644 index e14d2d0..0000000 --- a/tests/test_lint_sequence.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Offline unit tests for the nurture-sequence linter (tools/lint-sequence.py). - -Pure logic, no network, no API key. Run: - python -m unittest tests.test_lint_sequence - python -m unittest discover -s tests -""" - -import importlib.util -import unittest -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -_spec = importlib.util.spec_from_file_location("lint_sequence", REPO / "tools" / "lint-sequence.py") -ls = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(ls) # type: ignore[union-attr] - -GOOD = { - "label": "Day 0", - "subject": "Streamline your week", - "body": ('

Hi {{contact.first_name}},

Here is the idea.

' - '

Watch it here:

' - '

' - '

Warmest regards,
Simon

'), -} -# Same email but the ONLY anchor wraps the image — no text CTA above it. -IMG_ONLY = { - "label": "Day 7", - "subject": "See how it works", - "body": ('

Hi {{contact.first_name}},

See it:

' - '

' - '

Warmest regards,
Simon

'), -} - - -def _check(email, name): - report = ls.lint([email], signoff="Warmest regards", allow_em_dash=False) - return next(c for c in report["emails"][0]["checks"] if c["check"] == name) - - -class TestPerEmail(unittest.TestCase): - def test_good_email_overall_pass(self): - report = ls.lint([GOOD], signoff="Warmest regards", allow_em_dash=False) - self.assertEqual(report["overall"], ls.PASS, report) - - def test_text_cta_above_image_passes_for_good(self): - self.assertEqual(_check(GOOD, "cta_above_image")["level"], ls.PASS) - - def test_image_wrapping_anchor_is_not_a_text_cta(self): - # The bug we fixed: an anchor that only wraps the must NOT count. - c = _check(IMG_ONLY, "cta_above_image") - self.assertEqual(c["level"], ls.FAIL, c) - - def test_negative_subject_warns(self): - email = dict(GOOD, subject="Don't miss out") - self.assertEqual(_check(email, "positive_subject")["level"], ls.WARN) - - def test_em_dash_warns_by_default(self): - email = dict(GOOD, subject="Streamline — your week") - self.assertEqual(_check(email, "no_em_dash")["level"], ls.WARN) - - def test_em_dash_allowed_with_flag(self): - email = dict(GOOD, subject="Streamline — your week") - report = ls.lint([email], signoff="Warmest regards", allow_em_dash=True) - c = next(x for x in report["emails"][0]["checks"] if x["check"] == "no_em_dash") - self.assertEqual(c["level"], ls.PASS) - - def test_missing_link_fails(self): - email = {"label": "x", "subject": "Hello there", - "body": "

Hi {{contact.first_name}},

No link here.

"} - self.assertEqual(_check(email, "link")["level"], ls.FAIL) - - -class TestConsistency(unittest.TestCase): - def test_mixed_cta_above_image_is_a_failure(self): - report = ls.lint([GOOD, IMG_ONLY], signoff="Warmest regards", allow_em_dash=False) - self.assertEqual(report["overall"], ls.FAIL) - mixed = [c for c in report["consistency"] if "MIXED" in c["message"]] - self.assertTrue(mixed, "expected a MIXED cta_above_image finding") - self.assertEqual(mixed[0]["level"], ls.FAIL) - - def test_uniform_good_set_has_no_mixed_finding(self): - report = ls.lint([GOOD, dict(GOOD, label="Day 2")], - signoff="Warmest regards", allow_em_dash=False) - mixed = [c for c in report["consistency"] if "MIXED" in c["message"]] - self.assertFalse(mixed) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_safety.py b/tests/test_safety.py deleted file mode 100644 index 13aa019..0000000 --- a/tests/test_safety.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Offline safety tests — the guarantees that keep the API key from leaking. - -No network, no real key. Run: - python -m unittest tests.test_safety -""" - -import os -import sys -import tempfile -import unittest -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(REPO / "tools")) -import trustpager_api as t # noqa: E402 - -# Built from fragments on purpose: a real key never appears as a contiguous -# literal in this file, so tools/check-no-secrets.py won't (correctly) flag it. -REAL_LOOKING_KEY = "tp_live" + "_AbCdEf0123456789GhIjKlMnOp" # not a real key - - -class TestOfflineGuard(unittest.TestCase): - def setUp(self): - self._prev = os.environ.get("BOS_OFFLINE") - os.environ["BOS_OFFLINE"] = "1" - - def tearDown(self): - if self._prev is None: - os.environ.pop("BOS_OFFLINE", None) - else: - os.environ["BOS_OFFLINE"] = self._prev - - def test_get_blocked_offline(self): - with self.assertRaises(t.BOSError): - t.api_get("opportunities") - - def test_post_blocked_offline(self): - with self.assertRaises(t.BOSError): - t.api_post("opportunities", body={"name": "x"}) - - def test_offline_does_not_read_the_key(self): - # The guard must fire BEFORE get_api_key(), so a missing key still - # yields the offline error (never an auth path that could read a key). - prev = os.environ.pop("TRUSTPAGER_API_KEY", None) - try: - with self.assertRaises(t.BOSError) as ctx: - t.api_get("opportunities") - self.assertIn("offline", str(ctx.exception).lower()) - finally: - if prev is not None: - os.environ["TRUSTPAGER_API_KEY"] = prev - - -class TestRedaction(unittest.TestCase): - def test_redact_strips_real_key(self): - out = t._redact(f"oops the key is {REAL_LOOKING_KEY} in here") - self.assertNotIn(REAL_LOOKING_KEY, out) - self.assertIn("REDACTED", out) - - def test_redact_leaves_bare_prefix_alone(self): - # Docs say "your key starts with tp_live_" — that must NOT be redacted. - text = "your key starts with tp_live_" - self.assertEqual(t._redact(text), text) - - def test_journal_writes_redacted(self): - with tempfile.TemporaryDirectory() as d: - prev_dir = t.JOURNAL_DIR - prev_flag = os.environ.pop("BOS_JOURNAL", None) - t.JOURNAL_DIR = Path(d) - try: - t._record_write("POST", "email/send", - {"api_key": REAL_LOOKING_KEY, "to": "x@example.com"}, - status="ok", result_id="r1") - written = "".join(p.read_text(encoding="utf-8") for p in Path(d).glob("*.jsonl")) - self.assertTrue(written, "journal line should have been written") - self.assertNotIn(REAL_LOOKING_KEY, written) - self.assertIn("REDACTED", written) - finally: - t.JOURNAL_DIR = prev_dir - if prev_flag is not None: - os.environ["BOS_JOURNAL"] = prev_flag - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index 6e37284..0000000 --- a/tools/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# tools/ - -Every file in this folder is a single-purpose Python script. Stdlib only — no `pip install` needed. Each script is named for the goal it serves, so when Claude (or you) types `ls tools/` or greps for "setup", "config", "catalog", "lint", etc., the right file shows up on the first match. - -## Quick reference (intent → tool) - -### Foundations - -| When you want to… | Run | -|---|---| -| Set up TrustPager API access for the first time | `python tools/setup.py` | -| See what API key is stored / clear it / clear the cache | `python tools/config.py` | -| Verify your install is healthy (after setup, or when something breaks) | `python tools/check-install.py` | -| Browse all TrustPager API resources and their endpoints | `python tools/list-endpoints.py` | -| See the full schema of one endpoint (params, scopes, doc URL) | `python tools/inspect-endpoint.py ` | -| Validate a Claude Code skill folder before committing | `python tools/lint-skill.py skills/` | -| Run a skill against a mock fixture (offline, no credits) | `python tools/test-skill.py ` | -| Lint a nurture sequence against the house style (live queue or drafts) | `python tools/lint-sequence.py --queue ` | -| See the audit trail of every write BOS made | `python tools/journal.py` | - -### Business audits (read-only — useful by themselves, also called by skills) - -| When you want to… | Run | -|---|---| -| Audit pipeline health — stuck deals, drop-offs, value by stage | `python tools/audit-pipeline.py` | -| Audit contact data quality — duplicates, missing emails, dormant | `python tools/audit-contacts.py` | -| Find data gaps — opps without contacts, overdue tasks, etc. | `python tools/find-gaps.py` | - -### Marketing strategy bulk dumps (read-only — feed the strategy skills) - -| When you want to… | Run | -|---|---| -| Dump workspace as JSON (pipelines, automations, queues, opps, companies, contacts) into a frozen snapshot for AI to read offline | `python tools/dump-crm-bundle.py` | -| Dump ≥5min call + meeting transcripts as Markdown — verbatim customer voice for synthesis | `python tools/dump-transcripts.py` | - -## How they work together - -``` -First run: - setup.py (one-time auth bootstrap) - └─→ check-install.py (confirm everything's connected) - -Day-to-day: - Any skill in skills// imports from trustpager_api.py. - -Building / debugging skills: - list-endpoints.py → find the API surface you need - inspect-endpoint.py → pin down the exact params + scopes - lint-skill.py → sanity-check the skill folder - test-skill.py → run it against a fixture, no API calls - -Maintenance: - config.py → show or clear stored API key / catalog cache -``` - -## The shared library - -`trustpager_api.py` is the one file every script (and every skill) imports. It owns: - -- **Auth + HTTP** — `api_get`, `api_post`, `api_patch`, `idempotent_post`, key resolution, friendly errors for 401 / 402 / 403 / 422 / 429 / 5xx. -- **Reads at scale** — `paginate(path)` (auto-follows `next_cursor`), `parallel_get([...])` (concurrent fan-out). -- **Writes at scale** — `bulk_apply(write_fn, items)` with per-item error collection and a queued-approval bucket. -- **202 / approval queue** — POSTs that need your approval return `ApprovalPending(approval_id, body)`, not an error. Skills can `.poll()` for execution. -- **Write journal** — every `api_post` / `api_patch` / `idempotent_post` is appended to `~/.claude/bos-journal/YYYY-MM-DD.jsonl` (status: done / awaiting-approval / error). Reads are never journaled. This is what makes "BOS logs what it did" real and inspectable. Read it with `tools/journal.py`; disable with `BOS_JOURNAL=0`. -- **Catalog** — `get_catalog()` (24h cached), `resolve_path(resource_id, method, action, path_contains)`, `inspect_endpoint(...)`. So skill code never hardcodes a path that might drift. -- **Helpers** — `now_utc`, `parse_iso`, `days_since`, `group_count`, `top_n_by`, `log`, `emit_json`, `emit_error_and_exit`. - -Skills add this once at the top: - -```python -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) -from trustpager_api import api_get, paginate, parallel_get, BOSError, ... -``` - -## Naming conventions - -- **Lower-kebab-case** filenames (`list-endpoints.py`, not `listEndpoints.py`). Easier to grep and read in `ls`. -- **Verb-noun** order (`list-endpoints`, `inspect-endpoint`, `check-install`). Reads like an imperative — what the script *does*. -- **No vendor / project prefixes** (no `tp-`, no `bos-`). The folder is the namespace. -- The shared library is **snake_case** (`trustpager_api`) because Python imports it as a module — kebab-case wouldn't import. - -## Adding a new tool - -1. Create `tools/-.py`. -2. First 12 lines of the file = a top-of-file docstring with: - - One-line summary (becomes the script's `argparse` description). - - "When to use" — bullet list of scenarios. - - "What it does" — bullet list of effects. - - "Usage" — exact invocation examples. -3. Import `trustpager_api` if you need API access; otherwise pure stdlib. -4. Add a row to the table above. -5. If it's a domain-specific tool (works with one type of data — opportunities, contacts, etc.), say so in the docstring so AI greps land here. diff --git a/tools/audit-contacts.py b/tools/audit-contacts.py deleted file mode 100644 index a81c68e..0000000 --- a/tools/audit-contacts.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -"""Audit your TrustPager contacts — find duplicates, gaps, dormant records. - -When to use: -- "Why is the contact list so messy?" -- Before running an email blast — find the bad addresses first. -- After importing a new contact source — find duplicates with existing records. -- Quarterly hygiene pass. - -What it reports: -- 📭 Missing email — contacts with no email at all. -- 📵 Missing phone — contacts with no phone at all. -- 🚫 Bad email — contacts whose email doesn't have an @ or domain. -- 👻 Likely duplicates — contacts with identical email, OR identical - first+last name + same company. -- 💤 Dormant — contacts with no activity in 365+ days AND no open opps. -- 🔗 Orphan — contacts not linked to any opportunity or company. - -All read-only. Doesn't change anything in your workspace. - -Usage: - python tools/audit-contacts.py - python tools/audit-contacts.py --dormant-days 180 # different dormant threshold - python tools/audit-contacts.py --json -""" - -from __future__ import annotations - -import argparse -import re -import sys -from collections import defaultdict -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, days_since, emit_error_and_exit, emit_json, - force_utf8_stdout, now_utc, parse_iso, resolve_path, -) - - -EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - - -def audit(dormant_days: int, json_only: bool) -> dict[str, Any]: - now = now_utc() - path = resolve_path("contacts") - response = api_get(path, limit=200) - contacts = response.get("data", []) - - missing_email: list[dict[str, Any]] = [] - missing_phone: list[dict[str, Any]] = [] - bad_email: list[dict[str, Any]] = [] - dormant: list[dict[str, Any]] = [] - orphan: list[dict[str, Any]] = [] - - by_email: dict[str, list[dict[str, Any]]] = defaultdict(list) - by_name_company: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list) - - for c in contacts: - cid = c.get("id") - first = (c.get("first_name") or "").strip().lower() - last = (c.get("last_name") or "").strip().lower() - company_id = c.get("company_id") or "" - email = (c.get("email") or "").strip() - phone = (c.get("phone") or "").strip() - last_activity = parse_iso(c.get("last_activity_at") or c.get("updated_at")) - has_open_opps = bool(c.get("open_opportunity_count", 0)) - has_any_opps = bool(c.get("opportunity_count", c.get("open_opportunity_count", 0))) - has_company = bool(company_id) - - slim = { - "id": cid, - "name": f"{c.get('first_name') or ''} {c.get('last_name') or ''}".strip(), - "email": email, - "phone": phone, - } - - if not email: - missing_email.append(slim) - elif not EMAIL_RE.match(email): - bad_email.append(slim) - else: - by_email[email.lower()].append(slim) - - if not phone: - missing_phone.append(slim) - - if first and last: - by_name_company[(first, last, company_id)].append(slim) - - if last_activity: - ds = days_since(last_activity, ref=now) or 0 - if ds >= dormant_days and not has_open_opps: - dormant.append({**slim, "days_dormant": ds}) - - if not has_any_opps and not has_company: - orphan.append(slim) - - duplicate_email_groups = [ - {"email": e, "contacts": grp} - for e, grp in by_email.items() if len(grp) > 1 - ] - duplicate_name_groups = [ - {"key": f"{f.title()} {l.title()}" + (f" @ company:{co[-6:]}" if co else ""), - "contacts": grp} - for (f, l, co), grp in by_name_company.items() if len(grp) > 1 - ] - dormant.sort(key=lambda x: x["days_dormant"], reverse=True) - - return { - "generated_at": now.isoformat(), - "dormant_threshold_days": dormant_days, - "headline": { - "total_contacts": len(contacts), - "missing_email": len(missing_email), - "missing_phone": len(missing_phone), - "bad_email": len(bad_email), - "duplicate_email_groups": len(duplicate_email_groups), - "duplicate_name_groups": len(duplicate_name_groups), - "dormant": len(dormant), - "orphan": len(orphan), - }, - "missing_email": missing_email[:20], - "missing_phone": missing_phone[:20], - "bad_email": bad_email[:20], - "duplicate_email_groups": duplicate_email_groups[:10], - "duplicate_name_groups": duplicate_name_groups[:10], - "dormant_top_20": dormant[:20], - "orphan": orphan[:20], - } - - -def _print_human(r: dict[str, Any]) -> None: - h = r["headline"] - print("## Contact audit") - print() - print(f"- Total contacts sampled: **{h['total_contacts']}**") - print(f"- Missing email: **{h['missing_email']}**") - print(f"- Missing phone: **{h['missing_phone']}**") - print(f"- Malformed email: **{h['bad_email']}**") - print(f"- Likely duplicates (by email): **{h['duplicate_email_groups']}** groups") - print(f"- Likely duplicates (by name + company): **{h['duplicate_name_groups']}** groups") - print(f"- Dormant ({r['dormant_threshold_days']}+ days, no open opps): **{h['dormant']}**") - print(f"- Orphan (no opps, no company): **{h['orphan']}**") - print() - if r["duplicate_email_groups"]: - print("### 👻 Duplicate email groups (top 5)") - for g in r["duplicate_email_groups"][:5]: - names = ", ".join(c["name"] or "(no name)" for c in g["contacts"]) - print(f"- `{g['email']}` → {len(g['contacts'])} contacts: {names}") - print() - if r["missing_email"]: - print(f"### 📭 Missing email (showing first 10 of {h['missing_email']})") - for c in r["missing_email"][:10]: - print(f"- {c['name'] or '(no name)'} — phone: {c['phone'] or '(none)'}") - print() - if r["dormant_top_20"]: - print(f"### 💤 Dormant contacts (top 10)") - for c in r["dormant_top_20"][:10]: - print(f"- {c['name'] or '(no name)'} — last activity **{c['days_dormant']}** days ago") - print() - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--dormant-days", type=int, default=365, - help="Days of inactivity to qualify as dormant (default 365)") - parser.add_argument("--json", action="store_true", - help="Output JSON instead of human-readable markdown") - args = parser.parse_args() - - try: - report = audit(args.dormant_days, json_only=args.json) - if args.json: - emit_json(report) - else: - _print_human(report) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/audit-pipeline.py b/tools/audit-pipeline.py deleted file mode 100644 index 4adee05..0000000 --- a/tools/audit-pipeline.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Audit your TrustPager sales pipeline — stage health, stale deals, drop-offs. - -When to use: -- Weekly review of pipeline health. -- "Where am I losing deals?" -- "Which stage has the most stuck money?" -- Before a forecasting conversation with a partner / advisor. - -What it reports (one section per insight): -- 💰 Pipeline value by stage — count + total $ at each stage of each pipeline. -- 🐢 Stuck deals — opportunities that haven't moved stage in 14+ days, - ranked by value × days stuck. -- 🚪 Conversion drop-offs — stage-to-stage where the most volume is being lost - (count of opps that went to a "lost" stage from each upstream stage). -- 📊 Headline — total open value, top stage, avg days in pipeline. - -All read-only. Doesn't change anything in your workspace. - -Usage: - python tools/audit-pipeline.py - python tools/audit-pipeline.py --stuck-days 21 # different stale threshold - python tools/audit-pipeline.py --json # machine-readable output -""" - -from __future__ import annotations - -import argparse -import sys -from collections import defaultdict -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, days_since, emit_error_and_exit, emit_json, - force_utf8_stdout, now_utc, paginate, parse_iso, resolve_path, -) - - -SKILL = "audit-pipeline" - - -def _stage_of(opp: dict[str, Any]) -> dict[str, Any] | None: - placements = opp.get("placements") or [] - if not placements: - return None - return placements[0].get("crm_pipeline_stages") or None - - -def _is_open(opp: dict[str, Any]) -> bool: - if (opp.get("status") or "").lower() in {"won", "lost", "cancelled", "abandoned", "archived"}: - return False - s = _stage_of(opp) or {} - return not (s.get("is_won_stage") or s.get("is_lost_stage")) - - -def audit(stuck_days: int, json_only: bool) -> dict[str, Any]: - now = now_utc() - opps_path = resolve_path("opportunities") - - # Pull up to 500 — good enough for most workspaces. --full could be added if needed. - response = api_get(opps_path, limit=200) - opportunities = response.get("data", []) - - if not json_only: - print(f"# Pipeline audit", file=sys.stderr) - print(f"# Total opportunities sampled: {len(opportunities)}", file=sys.stderr) - - # By stage - by_stage: dict[str, dict[str, Any]] = defaultdict( - lambda: {"count": 0, "value": 0.0, "stuck": 0, "stage_id": None}) - stuck: list[dict[str, Any]] = [] - lost_from_upstream: dict[str, int] = defaultdict(int) - total_open_value = 0.0 - open_days_total = 0.0 - open_days_count = 0 - - for opp in opportunities: - stage = _stage_of(opp) or {} - stage_name = stage.get("name") or "(unstaged)" - - if _is_open(opp): - v = float(opp.get("value") or 0) - total_open_value += v - by_stage[stage_name]["count"] += 1 - by_stage[stage_name]["value"] += v - by_stage[stage_name]["stage_id"] = stage.get("id") - - # Stuck = stage hasn't moved in stuck_days - stage_changed = parse_iso(opp.get("stage_changed_at") or opp.get("updated_at")) - if stage_changed: - ds = days_since(stage_changed, ref=now) or 0 - if ds >= stuck_days: - by_stage[stage_name]["stuck"] += 1 - stuck.append({ - "id": opp.get("id"), - "name": opp.get("name"), - "stage": stage_name, - "value": v, - "days_stuck": ds, - "score": (max(100.0, v / 1000.0)) * (1 + ds / 30.0), - }) - - created = parse_iso(opp.get("created_at")) - if created: - open_days_total += days_since(created, ref=now) or 0 - open_days_count += 1 - elif stage.get("is_lost_stage"): - # Track where lost deals came from - prev = (opp.get("previous_stage") or {}).get("name") - if prev: - lost_from_upstream[prev] += 1 - - stuck.sort(key=lambda x: x["score"], reverse=True) - avg_days_open = (open_days_total / open_days_count) if open_days_count else 0 - - by_stage_sorted = sorted( - ((name, v) for name, v in by_stage.items()), - key=lambda kv: kv[1]["value"], reverse=True, - ) - top_stage = by_stage_sorted[0][0] if by_stage_sorted else "(none)" - - return { - "generated_at": now.isoformat(), - "stuck_threshold_days": stuck_days, - "headline": { - "total_open_value": round(total_open_value, 2), - "open_opportunities": sum(v["count"] for v in by_stage.values()), - "top_stage_by_value": top_stage, - "avg_days_open": round(avg_days_open, 1), - }, - "by_stage": [ - {"stage": name, **v} - for name, v in by_stage_sorted - ], - "stuck_top_20": stuck[:20], - "lost_from_upstream": dict(lost_from_upstream), - } - - -def _print_human(report: dict[str, Any]) -> None: - h = report["headline"] - print("## Pipeline audit") - print() - print(f"- Open opportunities: **{h['open_opportunities']}** worth " - f"**${h['total_open_value']:,.0f}**") - print(f"- Top stage by value: **{h['top_stage_by_value']}**") - print(f"- Average days in pipeline: **{h['avg_days_open']}** days") - print() - print("### 💰 By stage") - print() - print("| Stage | Count | Value | Stuck (>" + str(report["stuck_threshold_days"]) + "d) |") - print("|---|---:|---:|---:|") - for s in report["by_stage"]: - print(f"| {s['stage']} | {s['count']} | ${s['value']:,.0f} | {s['stuck']} |") - print() - print(f"### 🐢 Top stuck deals (no stage change in {report['stuck_threshold_days']}+ days)") - print() - if not report["stuck_top_20"]: - print("_None — every open deal has moved recently. 🎉_") - else: - print("| Deal | Stage | Days stuck | Value |") - print("|---|---|---:|---:|") - for d in report["stuck_top_20"][:10]: - v = f"${d['value']:,.0f}" if d['value'] else "(unpriced)" - print(f"| {d['name'][:40]} | {d['stage']} | {d['days_stuck']} | {v} |") - print() - print("### 🚪 Where deals are lost from") - print() - if not report["lost_from_upstream"]: - print("_No lost deals in the sample window._") - else: - items = sorted(report["lost_from_upstream"].items(), key=lambda x: -x[1]) - for stage, n in items: - print(f"- **{stage}** → lost: {n}") - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--stuck-days", type=int, default=14, - help="Days without a stage change to flag as stuck (default 14)") - parser.add_argument("--json", action="store_true", - help="Output JSON instead of human-readable markdown") - args = parser.parse_args() - - try: - report = audit(args.stuck_days, json_only=args.json) - if args.json: - emit_json(report) - else: - _print_human(report) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/check-install.py b/tools/check-install.py deleted file mode 100644 index 344f5f8..0000000 --- a/tools/check-install.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Verify the TrustPager skills install is healthy (doctor / healthcheck). - -When to use: -- "Is my install working?" -- "Skill X is failing — is the problem auth, network, or the skill itself?" -- After running setup.py — confirm everything is connected. -- After regenerating your API key — confirm the new key works. - -What it checks (7 probes, all required for skills to work): -- Python version >= 3.10 -- TrustPager API key is configured -- Reachability of api.trustpager.com -- Catalog fetch from docs.trustpager.com -- Local catalog cache exists -- Cache directory is writable -- Authenticated read against /opportunities returns data - -Output: green [OK] / orange [WARN] / red [FAIL] per check. -Exit code 0 if all checks pass; 1 if any fail. - -Usage: - python tools/check-install.py -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - API_BASE, BOSError, CATALOG_CACHE_PATH, CATALOG_URL, - get_api_key, get_catalog, -) - - -def _ok(msg: str) -> None: - print(f" [OK] {msg}") - - -def _fail(msg: str) -> None: - print(f" [FAIL] {msg}") - - -def _warn(msg: str) -> None: - print(f" [WARN] {msg}") - - -def main() -> int: - print("TrustPager — install healthcheck") - print() - failures = 0 - - # Python version - py_ok = sys.version_info >= (3, 10) - if py_ok: - _ok(f"Python version: {sys.version.split()[0]}") - else: - _fail(f"Python version too old: {sys.version.split()[0]} (need 3.10+)") - failures += 1 - - # API key - print() - print("Auth:") - key = "" - try: - key = get_api_key() - if key.startswith("tp_live_"): - _ok(f"API key configured (ends ...{key[-4:]})") - else: - _warn(f"API key set but doesn't start with 'tp_live_' (got '{key[:10]}...')") - except BOSError as e: - _fail(str(e).splitlines()[0]) - failures += 1 - return _finish(failures) - - # API reach - print() - print("API reach:") - try: - import urllib.request - req = urllib.request.Request( - API_BASE + "/", - headers={"Authorization": f"Bearer {key}"}, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - _ok(f"Reached {API_BASE}/ ({resp.status})") - except Exception as e: # noqa: BLE001 - msg = str(e).splitlines()[0] - _fail(f"Could not reach {API_BASE}: {msg}") - failures += 1 - - # Catalog - print() - print("Catalog:") - try: - catalog = get_catalog() - n_resources = len(catalog.get("resources", [])) - generated_at = catalog.get("generated_at", "?") - _ok(f"Fetched from {CATALOG_URL} — {n_resources} resources, generated {generated_at}") - except BOSError as e: - _fail(str(e).splitlines()[0]) - failures += 1 - - # Cache - print() - print("Cache:") - if CATALOG_CACHE_PATH.exists(): - _ok(f"Cache exists at {CATALOG_CACHE_PATH}") - else: - _warn(f"No cache yet at {CATALOG_CACHE_PATH} (will be created on first run)") - - try: - CATALOG_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - probe = CATALOG_CACHE_PATH.parent / ".write-probe" - probe.write_text("x", encoding="utf-8") - probe.unlink() - _ok(f"Cache directory writable: {CATALOG_CACHE_PATH.parent}") - except OSError as e: - _fail(f"Cache directory not writable: {e}") - failures += 1 - - # Authenticated read - print() - print("Authenticated read:") - try: - from trustpager_api import api_get - r = api_get("opportunities", limit=1) - n = len(r.get("data", [])) - _ok(f"GET /opportunities authenticated and responded ({n} row sample)") - except BOSError as e: - _fail(str(e).splitlines()[0]) - failures += 1 - - return _finish(failures) - - -def _finish(failures: int) -> int: - print() - if failures == 0: - print("All checks passed.") - return 0 - print(f"{failures} check(s) failed. See above.") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/check-no-secrets.py b/tools/check-no-secrets.py deleted file mode 100644 index b6f067e..0000000 --- a/tools/check-no-secrets.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 -"""Scan the repo for leaked secrets before they get committed or shipped. - -The whole testing strategy rests on one rule: a real API key never enters the -repo. This is the gate that enforces it. It scans tracked files (or the working -tree) for things that look like live credentials — TrustPager keys, private-key -blocks, AWS keys, Anthropic keys — and a stray `bos.json`. - -It is deliberately precise: it matches a REAL key (a long token after the -prefix), NOT the bare `tp_live_` string that legitimately appears in docs, -error messages, and this scanner. So `README` saying "your key starts with -tp_live_" does not trip it; an actual `tp_live_AbC123...` does. - -When to use: -- As a pre-commit hook and in CI (it's wired into .github/workflows/test.yml). -- Any time before you push, especially after editing fixtures or docs. - -Exit codes: - 0 — clean - 2 — at least one likely secret found (prints file:line, redacted) - -Usage: - python tools/check-no-secrets.py # scan git-tracked files - python tools/check-no-secrets.py --all # scan the whole working tree -""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent - -# Each pattern matches an ACTUAL credential, not a bare prefix mentioned in prose. -SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ - ("TrustPager API key", re.compile(r"tp_(?:live|test)_[A-Za-z0-9_\-]{16,}")), - ("Anthropic API key", re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}")), - ("AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), - ("Private key block", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |)?PRIVATE KEY-----")), - ("Generic bearer secret", re.compile(r"\bsk-[A-Za-z0-9]{32,}\b")), -] - -# Don't scan binaries or vendored/build dirs. -SKIP_EXTS = {".png", ".jpg", ".jpeg", ".ico", ".webp", ".gif", ".pdf", ".pyc", - ".woff", ".woff2", ".ttf", ".zip", ".gz"} -SKIP_DIRS = {".git", "node_modules", "__pycache__", "_staging", "graphify-out", - ".venv", "venv", ".pytest_cache"} -MAX_BYTES = 2_000_000 # don't read anything huge - - -def _redact(line: str) -> str: - out = line - for _, pat in SECRET_PATTERNS: - out = pat.sub(lambda m: m.group(0)[:8] + "***REDACTED***", out) - return out.strip()[:160] - - -def _tracked_files() -> list[Path]: - try: - out = subprocess.run(["git", "ls-files"], cwd=REPO_ROOT, - capture_output=True, text=True, check=True) - return [REPO_ROOT / p for p in out.stdout.splitlines() if p.strip()] - except (subprocess.CalledProcessError, FileNotFoundError): - return [] - - -def _all_files() -> list[Path]: - files: list[Path] = [] - for p in REPO_ROOT.rglob("*"): - if not p.is_file(): - continue - if any(part in SKIP_DIRS for part in p.relative_to(REPO_ROOT).parts): - continue - files.append(p) - return files - - -def scan(scan_all: bool) -> int: - files = _all_files() if scan_all else (_tracked_files() or _all_files()) - findings: list[str] = [] - - for f in files: - rel = f.relative_to(REPO_ROOT) - if f.suffix.lower() in SKIP_EXTS: - continue - if any(part in SKIP_DIRS for part in rel.parts): - continue - # A tracked bos.json is itself a finding — that file holds the key. - if f.name == "bos.json": - findings.append(f"{rel}: bos.json must never be committed (it stores your API key)") - continue - try: - if f.stat().st_size > MAX_BYTES: - continue - text = f.read_text(encoding="utf-8", errors="ignore") - except OSError: - continue - for i, line in enumerate(text.splitlines(), start=1): - for label, pat in SECRET_PATTERNS: - if pat.search(line): - findings.append(f"{rel}:{i}: {label} -> {_redact(line)}") - - if findings: - print(f"FAIL: {len(findings)} possible secret(s) found - do NOT commit:\n") - for fnd in findings: - print(f" {fnd}") - print("\nRemove the secret, rotate the key if it was real " - "(https://app.trustpager.com/settings/api), and re-run.") - return 2 - - print(f"OK: no secrets found ({'working tree' if scan_all else 'tracked files'}).") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--all", action="store_true", - help="Scan the whole working tree, not just git-tracked files") - args = parser.parse_args() - return scan(scan_all=args.all) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/config.py b/tools/config.py deleted file mode 100644 index c849f51..0000000 --- a/tools/config.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -"""Show or clear the stored TrustPager API config (key + cache). - -When to use: -- "Where is my API key stored?" -- "What key is BOS using right now?" -- "I want to remove the stored key (e.g. moving machines, key rotated)." -- "Clear the catalog cache so it re-fetches fresh." - -What it does: -- Without args: prints config file path, masked key, catalog cache location, - cache age. Nothing destructive. -- --clear-key: deletes the stored API key from ~/.claude/bos.json. -- --clear-cache: deletes the catalog cache so the next call re-fetches. -- --clear-all: both of the above. - -Usage: - python tools/config.py - python tools/config.py --clear-key - python tools/config.py --clear-cache - python tools/config.py --clear-all - -Note: this only manages the LOCAL config. Your TrustPager API key itself -lives in your TrustPager workspace and is unchanged. To rotate the key -itself, do it at https://app.trustpager.com/settings/api then re-run setup. -""" - -from __future__ import annotations - -import argparse -import json -import sys -from datetime import datetime, timezone -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import CATALOG_CACHE_PATH, CONFIG_PATH # noqa: E402 - - -def _show() -> int: - print("TrustPager local config") - print() - print(f"Config file: {CONFIG_PATH}") - if CONFIG_PATH.exists(): - try: - cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) - key = (cfg.get("api_key") or "").strip() - if key: - masked = key[:14] + "..." + key[-4:] if len(key) > 20 else "(too short)" - print(f" API key: {masked}") - else: - print(" API key: (empty)") - except (json.JSONDecodeError, OSError) as e: - print(f" API key: (unreadable: {e})") - else: - print(" API key: (not configured — run `python tools/setup.py`)") - print() - print(f"Catalog cache: {CATALOG_CACHE_PATH}") - if CATALOG_CACHE_PATH.exists(): - mtime = datetime.fromtimestamp(CATALOG_CACHE_PATH.stat().st_mtime, tz=timezone.utc) - age = datetime.now(timezone.utc) - mtime - hours = age.total_seconds() / 3600 - print(f" Last fetched: {mtime.isoformat()} ({hours:.1f}h ago)") - else: - print(" Last fetched: (no cache yet)") - print() - print(f"Environment override: TRUSTPAGER_API_KEY = " - + ("(set — overrides config file)" - if _env_key_set() else "(not set)")) - return 0 - - -def _env_key_set() -> bool: - import os - return bool(os.environ.get("TRUSTPAGER_API_KEY")) - - -def _clear_key() -> int: - cleared = False - if CONFIG_PATH.exists(): - CONFIG_PATH.unlink() - print(f"Removed {CONFIG_PATH}") - cleared = True - # Also remove the launcher shim that setup.py co-writes next to the config. - shim = CONFIG_PATH.parent / "bos-run.py" - if shim.exists(): - shim.unlink() - print(f"Removed {shim}") - cleared = True - if not cleared: - print(f"No config file at {CONFIG_PATH} — nothing to clear.") - return 0 - - -def _clear_cache() -> int: - if not CATALOG_CACHE_PATH.exists(): - print(f"No catalog cache at {CATALOG_CACHE_PATH} — nothing to clear.") - return 0 - CATALOG_CACHE_PATH.unlink() - print(f"Removed {CATALOG_CACHE_PATH}") - print("The next API call will re-fetch the catalog from docs.trustpager.com.") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--clear-key", action="store_true", - help="Delete the stored TrustPager API key") - parser.add_argument("--clear-cache", action="store_true", - help="Delete the cached API catalog (forces a re-fetch)") - parser.add_argument("--clear-all", action="store_true", - help="Delete both the key and the catalog cache") - args = parser.parse_args() - - if args.clear_all or args.clear_key: - _clear_key() - if args.clear_all or args.clear_cache: - _clear_cache() - if not (args.clear_key or args.clear_cache or args.clear_all): - return _show() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/dump-crm-bundle.py b/tools/dump-crm-bundle.py deleted file mode 100644 index 869af05..0000000 --- a/tools/dump-crm-bundle.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python3 -"""Dump your TrustPager workspace as JSON — for marketing-strategy analysis. - -When to use: -- You're about to build a brand voice / nurture sequence / positioning doc - and want a frozen snapshot of your CRM to work from. -- An AI is going to read your workspace state and you want it pre-fetched - to a folder so the AI can read files instead of poking the API live. -- You want to audit what's in your workspace (pipelines, automations, auto - queues, opportunities, companies, contacts) in one offline session. - -What it dumps (one JSON file per resource): -- `pipelines.json` — every pipeline (id, name, position, etc). -- `pipeline_stages.json` — every stage, grouped by pipeline_id. -- `automations.json` — every automation WITH inline triggers + actions - (so you can see actual email body, subject, - SMS body, task templates, etc. — not just names). -- `auto_queues.json` — every auto queue WITH inline steps. -- `opportunities.json` — every opportunity with `expand=contact`. -- `companies.json` — every company. -- `companies-customers.json` — same list filtered to is_customer=true. -- `contacts.json` — most recent N contacts (configurable, default 500). -- `_manifest.json` — counts + timestamps for the dump itself. - -All read-only. No writes, no approvals queue, no API credits charged. - -Output folder defaults to `./crm-bundle//` so re-running creates a -new timestamped snapshot instead of overwriting the last one. - -Usage: - python tools/dump-crm-bundle.py - python tools/dump-crm-bundle.py --out ./my-bundle - python tools/dump-crm-bundle.py --resources opportunities,automations - python tools/dump-crm-bundle.py --contacts-limit 200 - python tools/dump-crm-bundle.py --dry-run # print plan, don't fetch -""" - -from __future__ import annotations - -import argparse -import json -import sys -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, force_utf8_stdout, paginate, -) - - -SKILL = "dump-crm-bundle" -DEFAULT_RESOURCES = [ - "pipelines", - "automations", - "auto_queues", - "opportunities", - "companies", - "contacts", -] - - -def log(msg: str) -> None: - print(msg, file=sys.stderr, flush=True) - - -def write_json(path: Path, payload: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(payload, indent=2, default=str, ensure_ascii=False), - encoding="utf-8", - ) - - -def fetch_pipelines() -> tuple[list[dict], dict[str, list[dict]]]: - """List every pipeline, then fetch stages for each.""" - log("- pipelines + stages...") - pipelines = paginate("pipelines") - stages_by_pipeline: dict[str, list[dict]] = {} - for p in pipelines: - pid = p["id"] - body = api_get(f"pipelines/{pid}/stages") - if isinstance(body, dict) and "data" in body and isinstance(body["data"], list): - stages_by_pipeline[pid] = body["data"] - elif isinstance(body, list): - stages_by_pipeline[pid] = body - else: - stages_by_pipeline[pid] = [] - return pipelines, stages_by_pipeline - - -def fetch_automations(deep: bool = True) -> list[dict]: - """List every automation. If deep=True (default), follow up with - GET /automations/{id} per row so the inline triggers + actions - (with full email/SMS body, task templates, etc.) are included.""" - log("- automations (deep)..." if deep else "- automations (list only)...") - listing = paginate("automations") - if not deep: - return listing - detailed: list[dict] = [] - for i, a in enumerate(listing, 1): - body = api_get(f"automations/{a['id']}") - if isinstance(body, dict) and "data" in body and isinstance(body["data"], dict): - detailed.append(body["data"]) - else: - detailed.append(body if isinstance(body, dict) else a) - if i % 25 == 0 or i == len(listing): - log(f" ... {i}/{len(listing)}") - return detailed - - -def fetch_auto_queues() -> list[dict]: - """List every auto queue and fetch each one in detail so the inline - step list (automation_event_queue_steps with delays + linked automation - IDs) is included.""" - log("- auto queues (with steps)...") - queues = paginate("auto-queues") - detailed: list[dict] = [] - for q in queues: - body = api_get(f"auto-queues/{q['id']}") - if isinstance(body, dict) and "data" in body and isinstance(body["data"], dict): - detailed.append(body["data"]) - else: - detailed.append(body if isinstance(body, dict) else q) - return detailed - - -def fetch_opportunities() -> list[dict]: - log("- opportunities (expand=contact)...") - return paginate("opportunities", expand="contact") - - -def fetch_companies() -> list[dict]: - log("- companies (all)...") - return paginate("companies") - - -def fetch_contacts(cap: int) -> list[dict]: - log(f"- contacts (most recent {cap})...") - items: list[dict] = [] - cursor: str | None = None - page_size = min(100, cap) - while len(items) < cap: - params: dict[str, Any] = { - "limit": page_size, - "sort": "created_at", - "order": "desc", - } - if cursor: - params["after"] = cursor - body = api_get("contacts", **params) - if not isinstance(body, dict): - break - page = body.get("data") or [] - items.extend(page) - pagination = body.get("pagination") or {} - if not pagination.get("has_more"): - break - cursor = pagination.get("next_cursor") - if not cursor: - break - return items[:cap] - - -def main() -> None: - force_utf8_stdout() - ap = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - ap.add_argument( - "--out", - help="Output folder (default: ./crm-bundle//)", - ) - ap.add_argument( - "--resources", - default="all", - help="Comma-list of resources to fetch. Options: " - + ",".join(DEFAULT_RESOURCES) - + " (default: all)", - ) - ap.add_argument( - "--contacts-limit", - type=int, - default=500, - help="How many recent contacts to dump (default: 500)", - ) - ap.add_argument( - "--dry-run", - action="store_true", - help="Print resolved plan + output folder, then exit (no API calls)", - ) - args = ap.parse_args() - - if args.out: - out_dir = Path(args.out) - else: - out_dir = Path("crm-bundle") / datetime.now(timezone.utc).strftime("%Y-%m-%d") - - log(f"out: {out_dir}") - - if args.resources == "all": - requested = set(DEFAULT_RESOURCES) - else: - requested = {r.strip() for r in args.resources.split(",") if r.strip()} - unknown = requested - set(DEFAULT_RESOURCES) - if unknown: - emit_error_and_exit( - f"Unknown resources: {sorted(unknown)}. Valid: {DEFAULT_RESOURCES}", - skill=SKILL, - ) - - log(f"plan: {sorted(requested)}") - - if args.dry_run: - log("(dry-run — no writes)") - print(str(out_dir)) - return - - started_at = datetime.now(timezone.utc).isoformat() - written: list[dict[str, Any]] = [] - - try: - if "pipelines" in requested: - pipelines, stages = fetch_pipelines() - write_json(out_dir / "pipelines.json", pipelines) - write_json(out_dir / "pipeline_stages.json", stages) - n_stages = sum(len(s) for s in stages.values()) - written += [ - {"file": "pipelines.json", "count": len(pipelines)}, - {"file": "pipeline_stages.json", "count": n_stages}, - ] - log(f" -> {len(pipelines)} pipelines, {n_stages} stages") - - if "automations" in requested: - automations = fetch_automations(deep=True) - write_json(out_dir / "automations.json", automations) - written.append({"file": "automations.json", "count": len(automations)}) - log(f" -> {len(automations)} automations") - - if "auto_queues" in requested: - queues = fetch_auto_queues() - write_json(out_dir / "auto_queues.json", queues) - n_steps = sum( - len(q.get("automation_event_queue_steps") or q.get("steps") or []) - for q in queues - ) - written.append( - {"file": "auto_queues.json", "count": len(queues), "steps": n_steps} - ) - log(f" -> {len(queues)} queues, {n_steps} steps total") - - if "opportunities" in requested: - opps = fetch_opportunities() - write_json(out_dir / "opportunities.json", opps) - written.append({"file": "opportunities.json", "count": len(opps)}) - log(f" -> {len(opps)} opportunities") - - if "companies" in requested: - all_co = fetch_companies() - write_json(out_dir / "companies.json", all_co) - customers = [c for c in all_co if c.get("is_customer")] - write_json(out_dir / "companies-customers.json", customers) - written += [ - {"file": "companies.json", "count": len(all_co)}, - {"file": "companies-customers.json", "count": len(customers)}, - ] - log(f" -> {len(all_co)} companies total ({len(customers)} customers)") - - if "contacts" in requested: - contacts = fetch_contacts(cap=args.contacts_limit) - write_json(out_dir / "contacts.json", contacts) - written.append({"file": "contacts.json", "count": len(contacts)}) - log(f" -> {len(contacts)} recent contacts") - - except BOSError as err: - emit_error_and_exit(str(err), skill=SKILL) - - write_json( - out_dir / "_manifest.json", - { - "skill": SKILL, - "started_at": started_at, - "completed_at": datetime.now(timezone.utc).isoformat(), - "files": written, - }, - ) - - print(str(out_dir)) - - -if __name__ == "__main__": - main() diff --git a/tools/dump-transcripts.py b/tools/dump-transcripts.py deleted file mode 100644 index 612668c..0000000 --- a/tools/dump-transcripts.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python3 -"""Dump ≥5-minute call & meeting transcripts as readable Markdown. - -When to use: -- You're building a brand voice / nurture sequence / positioning doc and - want to mine what your customers actually say (verbatim) instead of - inventing language for them. -- You want a frozen Markdown snapshot of recent customer conversations - that an AI can read offline. -- You want to audit which prospects talked about what, in their words. - -What it dumps: -- Markdown files per transcript: `_min__.md` -- Calls go to `/calls/`, meetings go to `/meetings/`. -- Each file has YAML frontmatter (id, type, occurred_at, duration_minutes, - title, participants, linked deal + contact) and a cleaned transcript body. -- `_index.json` summarising everything dumped. - -WebVTT timestamps + cue numbers are stripped; speaker lines are preserved. -Most Twilio phone calls between humans are NOT auto-transcribed — only -Recall AI Notetaker meetings and Retell voice-agent calls have rich text -to dump. The tool silently skips transcripts with empty bodies. - -Usage: - python tools/dump-transcripts.py - python tools/dump-transcripts.py --min-duration 600 # 10 minutes+ - python tools/dump-transcripts.py --target 20 - python tools/dump-transcripts.py --out ./conversations - python tools/dump-transcripts.py --dry-run -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Iterable - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_error_and_exit, force_utf8_stdout, -) - - -SKILL = "dump-transcripts" - -# Map API `type` value -> output folder bucket. -CALL_TYPES = {"phone_call", "call"} -MEETING_TYPES = {"meeting"} - - -def log(msg: str) -> None: - print(msg, file=sys.stderr, flush=True) - - -def list_complete_transcripts(page_size: int, max_pages: int) -> Iterable[dict]: - """Yield every `transcription_status=complete` transcript, newest first. - Lightweight list rows — no transcript_text included.""" - cursor: str | None = None - pages = 0 - while pages < max_pages: - params: dict[str, Any] = { - "limit": page_size, - "transcription_status": "complete", - } - if cursor: - params["after"] = cursor - body = api_get("transcripts", **params) - if not isinstance(body, dict): - return - items = body.get("data") or [] - if not items: - return - yield from items - pagination = body.get("pagination") or {} - if not pagination.get("has_more"): - return - cursor = pagination.get("next_cursor") - if not cursor: - return - pages += 1 - - -def fetch_detail(t_id: str) -> dict: - body = api_get(f"transcripts/{t_id}") - if isinstance(body, dict) and "data" in body and isinstance(body["data"], dict): - return body["data"] - return body if isinstance(body, dict) else {} - - -def slugify(s: str, max_len: int = 40) -> str: - s = (s or "").strip().lower() - s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") - return (s or "untitled")[:max_len] - - -def vtt_to_markdown(vtt_text: str) -> str: - """Strip WebVTT cue numbers + timestamps. Keep speaker lines. - Output is plain Markdown — readable as a play script.""" - if not vtt_text: - return "" - out: list[str] = [] - for line in vtt_text.splitlines(): - stripped = line.strip() - if not stripped: - continue - if stripped == "WEBVTT": - continue - if stripped.startswith("NOTE"): - continue - if stripped.isdigit(): - continue - if "-->" in stripped and re.match(r"^\d{2}:\d{2}", stripped): - continue - out.append(stripped) - deduped: list[str] = [] - for line in out: - if deduped and deduped[-1] == line: - continue - deduped.append(line) - return "\n\n".join(deduped) - - -def extract_body(detail: dict) -> tuple[str, str | None]: - """Returns (markdown_body, summary_or_None). - `transcript_text` is usually a JSON-encoded blob: - {"type": "...", "transcript_vtt": "WEBVTT...", "summary": "..."} - Some sources may store plain text instead.""" - raw = detail.get("transcript_text") or "" - if not raw: - return "", None - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - vtt = parsed.get("transcript_vtt") or parsed.get("vtt") or "" - summary = parsed.get("summary") - if vtt: - return vtt_to_markdown(vtt), summary - plain = parsed.get("transcript") or parsed.get("text") or "" - if plain: - return plain.strip(), summary - return "", summary - except (json.JSONDecodeError, TypeError): - pass - if "WEBVTT" in raw or "-->" in raw: - return vtt_to_markdown(raw), None - return raw.strip(), None - - -def yaml_frontmatter(d: dict) -> str: - """Minimal hand-rolled YAML — quotes anything with special chars.""" - - def fmt(v: Any) -> str: - if v is None: - return "null" - if isinstance(v, bool): - return "true" if v else "false" - if isinstance(v, (int, float)): - return str(v) - if isinstance(v, (list, dict)): - return json.dumps(v, ensure_ascii=False, default=str) - s = str(v) - if any(c in s for c in (':', '#', '\n', '"', '\\')): - return json.dumps(s, ensure_ascii=False) - return s - - lines = ["---"] - for k, v in d.items(): - lines.append(f"{k}: {fmt(v)}") - lines.append("---") - return "\n".join(lines) - - -def build_filename(t: dict) -> str: - occurred = t.get("occurred_at") or "" - date_part = occurred[:10] if occurred else "0000-00-00" - dur = t.get("duration_seconds") or 0 - mins = dur // 60 - linked = t.get("linked_entities") or {} - deals = linked.get("deals") or [] - contacts = linked.get("contacts") or [] - who = "" - if deals and deals[0].get("name"): - who = deals[0]["name"] - elif contacts: - c = contacts[0] - who = ( - " ".join(filter(None, [c.get("first_name"), c.get("last_name")])).strip() - or c.get("email") - or "" - ) - if not who: - who = t.get("title") or "" - return f"{date_part}_{mins:02d}min_{slugify(who)}_{t['id'][:8]}.md" - - -def main() -> None: - force_utf8_stdout() - ap = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - ap.add_argument("--out", help="Output folder (default: ./transcripts//)") - ap.add_argument( - "--min-duration", - type=int, - default=300, - help="Min duration in seconds (default: 300 = 5 min)", - ) - ap.add_argument( - "--target", - type=int, - default=30, - help="Target count per bucket (calls / meetings). Default: 30.", - ) - ap.add_argument( - "--max-pages", - type=int, - default=20, - help="Max list pages to scan (100 transcripts each, default: 20 = up to 2000).", - ) - ap.add_argument( - "--dry-run", - action="store_true", - help="Show resolved plan + output paths, then exit", - ) - args = ap.parse_args() - - out_root = ( - Path(args.out) - if args.out - else Path("transcripts") / datetime.now(timezone.utc).strftime("%Y-%m-%d") - ) - calls_dir = out_root / "calls" - meetings_dir = out_root / "meetings" - - log(f"out: {out_root}") - log(f"filter: duration_seconds >= {args.min_duration}") - log(f"target: up to {args.target} calls + {args.target} meetings") - - if args.dry_run: - log("(dry-run — no writes)") - print(str(out_root)) - return - - started_at = datetime.now(timezone.utc).isoformat() - - log("\n[1/2] scanning list endpoint...") - calls: list[dict] = [] - meetings: list[dict] = [] - scanned = 0 - try: - for item in list_complete_transcripts( - page_size=100, max_pages=args.max_pages - ): - scanned += 1 - dur = item.get("duration_seconds") or 0 - if dur < args.min_duration: - continue - ttype = item.get("type") - if ttype in CALL_TYPES and len(calls) < args.target: - calls.append(item) - elif ttype in MEETING_TYPES and len(meetings) < args.target: - meetings.append(item) - if len(calls) >= args.target and len(meetings) >= args.target: - break - except BOSError as err: - emit_error_and_exit(str(err), skill=SKILL) - - log( - f" scanned {scanned} transcripts -> {len(calls)} calls, {len(meetings)} meetings ≥ min duration" - ) - - calls_dir.mkdir(parents=True, exist_ok=True) - meetings_dir.mkdir(parents=True, exist_ok=True) - - log("\n[2/2] fetching detail + writing markdown...") - index: list[dict[str, Any]] = [] - skipped_empty = 0 - - def process(group: list[dict], folder: Path, label: str) -> None: - nonlocal skipped_empty - for i, light in enumerate(group, 1): - tid = light["id"] - try: - detail = fetch_detail(tid) - except BOSError as e: - log(f" [{label} {i}/{len(group)}] FAILED {tid}: {e}") - continue - body, summary = extract_body(detail) - if not body.strip(): - skipped_empty += 1 - log(f" [{label} {i}/{len(group)}] empty transcript_text — skip {tid[:8]}") - continue - linked = detail.get("linked_entities") or {} - fm = { - "id": detail["id"], - "type": detail.get("type"), - "source": detail.get("source"), - "occurred_at": detail.get("occurred_at"), - "duration_seconds": detail.get("duration_seconds"), - "duration_minutes": round((detail.get("duration_seconds") or 0) / 60, 1), - "title": detail.get("title"), - "participants": detail.get("participants") or [], - "linked_deals": [d.get("name") for d in (linked.get("deals") or [])], - "linked_contacts": [ - " ".join(filter(None, [c.get("first_name"), c.get("last_name")])).strip() - or c.get("email") - for c in (linked.get("contacts") or []) - ], - "recording_url": detail.get("recording_url"), - "booking_id": detail.get("booking_id"), - } - outpath = folder / build_filename(detail) - parts = [yaml_frontmatter(fm), ""] - if summary: - parts += [f"## Summary (auto-generated)", "", summary, ""] - parts += [f"## Transcript", "", body, ""] - outpath.write_text("\n".join(parts), encoding="utf-8") - rel = outpath.relative_to(out_root) - index.append( - { - "file": str(rel).replace("\\", "/"), - "id": detail["id"], - "type": detail.get("type"), - "occurred_at": detail.get("occurred_at"), - "duration_seconds": detail.get("duration_seconds"), - "title": detail.get("title"), - "linked_deals": fm["linked_deals"], - "linked_contacts": fm["linked_contacts"], - } - ) - if i % 5 == 0 or i == len(group): - log(f" [{label}] {i}/{len(group)}") - - process(calls, calls_dir, "calls") - process(meetings, meetings_dir, "meetings") - - index_path = out_root / "_index.json" - index_path.parent.mkdir(parents=True, exist_ok=True) - index_path.write_text( - json.dumps( - { - "skill": SKILL, - "started_at": started_at, - "completed_at": datetime.now(timezone.utc).isoformat(), - "min_duration_seconds": args.min_duration, - "counts": { - "calls": sum(1 for x in index if x["type"] in CALL_TYPES), - "meetings": sum(1 for x in index if x["type"] in MEETING_TYPES), - "skipped_empty": skipped_empty, - "scanned_list_items": scanned, - }, - "transcripts": index, - }, - indent=2, - default=str, - ensure_ascii=False, - ), - encoding="utf-8", - ) - - log(f"\nWrote {len(index)} transcripts ({skipped_empty} skipped — empty text).") - print(str(out_root)) - - -if __name__ == "__main__": - main() diff --git a/tools/find-gaps.py b/tools/find-gaps.py deleted file mode 100644 index 0c4cb5d..0000000 --- a/tools/find-gaps.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -"""Find data gaps across your TrustPager workspace — the "what's broken" report. - -When to use: -- "Where's the mess?" -- After importing a batch of records — what didn't land cleanly? -- Before a board / partner meeting — find the things that'll get asked about. -- Daily cleanup pass. - -What it reports: -- 🔗 Opportunities without a contact attached. -- 🏷️ Opportunities without a value set. -- 🏢 Opportunities without a stage / pipeline placement. -- 📅 Tasks past their due date and not marked complete. -- 🚦 Tasks with no due date set at all. -- 👤 Opportunities with no assigned user (no one owns them). - -Tight, scannable output — gives you a checklist for the next 10 minutes -of cleanup. All read-only. - -Usage: - python tools/find-gaps.py - python tools/find-gaps.py --json -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, days_since, emit_error_and_exit, emit_json, - force_utf8_stdout, now_utc, parallel_get, parse_iso, resolve_path, -) - - -def find_gaps() -> dict[str, Any]: - now = now_utc() - - paths = { - "opportunities": resolve_path("opportunities"), - "tasks": resolve_path("tasks"), - } - results = parallel_get([ - (paths["opportunities"], {"limit": 200}), - (paths["tasks"], {"limit": 200}), - ]) - opps = results.get(paths["opportunities"], {}).get("data", []) - tasks = results.get(paths["tasks"], {}).get("data", []) - - no_contact: list[dict[str, Any]] = [] - no_value: list[dict[str, Any]] = [] - no_stage: list[dict[str, Any]] = [] - no_owner: list[dict[str, Any]] = [] - inactive_statuses = {"won", "lost", "cancelled", "abandoned", "archived"} - - for o in opps: - if (o.get("status") or "").lower() in inactive_statuses: - continue - slim = {"id": o.get("id"), "name": o.get("name")} - if not o.get("contact_id"): - no_contact.append(slim) - if not o.get("value"): - no_value.append(slim) - if not (o.get("placements") or []): - no_stage.append(slim) - if not (o.get("assigned_user_ids") or o.get("assigned_users") or o.get("owner_id")): - no_owner.append(slim) - - overdue_tasks: list[dict[str, Any]] = [] - no_due_date_tasks: list[dict[str, Any]] = [] - for t in tasks: - if t.get("completed_at"): - continue - due = t.get("due_date") or t.get("due_at") - if not due: - no_due_date_tasks.append({"id": t.get("id"), "title": t.get("title")}) - continue - due_dt = parse_iso(due) - if due_dt and due_dt < now: - overdue_tasks.append({ - "id": t.get("id"), - "title": t.get("title"), - "days_overdue": days_since(due_dt, ref=now), - }) - - overdue_tasks.sort(key=lambda x: x["days_overdue"] or 0, reverse=True) - - return { - "generated_at": now.isoformat(), - "headline": { - "opps_sampled": len(opps), - "tasks_sampled": len(tasks), - "opps_no_contact": len(no_contact), - "opps_no_value": len(no_value), - "opps_no_stage": len(no_stage), - "opps_no_owner": len(no_owner), - "tasks_overdue": len(overdue_tasks), - "tasks_no_due_date": len(no_due_date_tasks), - }, - "opps_no_contact": no_contact[:20], - "opps_no_value": no_value[:20], - "opps_no_stage": no_stage[:20], - "opps_no_owner": no_owner[:20], - "tasks_overdue_top_20": overdue_tasks[:20], - "tasks_no_due_date": no_due_date_tasks[:20], - } - - -def _print_human(r: dict[str, Any]) -> None: - h = r["headline"] - print("## Workspace gaps") - print() - print(f"_Sampled {h['opps_sampled']} opportunities, {h['tasks_sampled']} tasks._") - print() - issues = [ - ("🔗 Opportunities without a contact", h["opps_no_contact"], r["opps_no_contact"]), - ("🏷️ Opportunities without a value", h["opps_no_value"], r["opps_no_value"]), - ("🏢 Opportunities without a stage", h["opps_no_stage"], r["opps_no_stage"]), - ("👤 Opportunities with no owner", h["opps_no_owner"], r["opps_no_owner"]), - ("📅 Tasks overdue", h["tasks_overdue"], r["tasks_overdue_top_20"]), - ("🚦 Tasks with no due date", h["tasks_no_due_date"], r["tasks_no_due_date"]), - ] - - anything = False - for title, count, samples in issues: - if count == 0: - continue - anything = True - print(f"### {title} — **{count}**") - for s in samples[:5]: - label = s.get("name") or s.get("title") or "(unnamed)" - extra = "" - if s.get("days_overdue") is not None: - extra = f" _({s['days_overdue']} days overdue)_" - print(f"- {label}{extra}") - if count > 5: - print(f"- _… and {count - 5} more_") - print() - - if not anything: - print("✅ No gaps found in the sample window. Nice.") - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--json", action="store_true", - help="Output JSON instead of human-readable markdown") - args = parser.parse_args() - - try: - report = find_gaps() - if args.json: - emit_json(report) - else: - _print_human(report) - return 0 - except BOSError as e: - emit_error_and_exit(str(e), code=1) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/inspect-endpoint.py b/tools/inspect-endpoint.py deleted file mode 100644 index 298aa00..0000000 --- a/tools/inspect-endpoint.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -"""Show the full schema for one TrustPager API endpoint (params, scopes, doc). - -When to use: -- "What parameters does GET /opportunities take?" -- "Which scopes do I need for create_contact?" -- "Where's the doc page for this endpoint?" -- When writing a skill and you need to know exactly what to send. - -What it prints: -- HTTP method + path -- Whether it's a write (needs higher scope) -- Required scopes -- Doc URL (deep link into docs.trustpager.com) -- Description -- Every parameter — name, location (query / body / path), type, required?, - description. - -Defaults to the "list" action (the simplest read on the resource). Pick a -different action if needed: - - --action list simplest GET, no params (default) - --action get GET with one :id segment - --action create POST root path - --action search POST with /search suffix - -If a resource has multiple endpoints of the same shape (e.g. /email/threads -and /email/logs both match "list"), pass --contains to pick the -right one. - -Usage: - python tools/inspect-endpoint.py opportunities - python tools/inspect-endpoint.py opportunities --action create - python tools/inspect-endpoint.py email --contains threads - python tools/inspect-endpoint.py contacts --method POST --action search - -Related: - python tools/list-endpoints.py # all endpoints on a resource -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import BOSError, inspect_endpoint # noqa: E402 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("resource_id") - parser.add_argument("--method", default="GET") - parser.add_argument("--action", default="list", - choices=["list", "get", "create", "search"]) - parser.add_argument("--contains", dest="path_contains", default=None, - help="Substring to disambiguate when a resource has multiple matches") - args = parser.parse_args() - - try: - info = inspect_endpoint( - args.resource_id, method=args.method, action=args.action, - path_contains=args.path_contains, - ) - except BOSError as e: - print(f"ERROR: {e}", file=sys.stderr) - return 1 - - print(f"=== {info['method']} {info['path']} ===") - print(f"Resource: {info['resource_id']} — {info.get('resource_label', '')}") - print(f"Write: {'yes' if info['is_write'] else 'no'}") - print(f"Scopes: {' '.join(info['scopes']) if info['scopes'] else '(none)'}") - print(f"Doc: {info['doc_url']}") - print() - print(f"Description: {info['description']}") - print() - if info["params"]: - print(f"Parameters ({len(info['params'])}):") - for p in info["params"]: - req = "required" if p.get("required") else "optional" - print(f" - {p.get('name', '?'):20s} {p.get('in', '?'):6s} " - f"{p.get('type', '?'):8s} {req}") - desc = p.get("description", "") - if desc: - print(f" {desc}") - else: - print("Parameters: (none)") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/journal.py b/tools/journal.py deleted file mode 100644 index d52e7d6..0000000 --- a/tools/journal.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -"""Show the BOS write journal — the audit trail of every change BOS made. - -Every write BOS issues to your TrustPager workspace (create / update / send / -trigger, and anything queued for approval) is appended to a local journal at -~/.claude/bos-journal/YYYY-MM-DD.jsonl by the shared API library. Reads are -NOT journaled. This is what makes the "BOS logs what it did" promise real and -inspectable — nothing leaves your machine, and you can always see the trail. - -When to use: -- "What did BOS change today / this week?" -- "Did that send actually go out, or is it waiting on approval?" -- Reviewing what a skill did before you trust it with more. -- Handing a clean change-list to a teammate or your own records. - -What each line records: - ts, method (POST/PATCH), path, status (ok | approval_pending | error), - result_id, approval_id, error (if any), body_summary (truncated payload). - -Usage: - python tools/journal.py # today's writes - python tools/journal.py --today # today's writes (explicit) - python tools/journal.py --since 2026-06-01 - python tools/journal.py --tail 20 # last N entries across all days - python tools/journal.py --grep send_ # only paths containing a string - python tools/journal.py --errors # only failed writes - python tools/journal.py --path # print the journal directory and exit - -Disable journaling entirely by setting BOS_JOURNAL=0 in your environment. -""" - -from __future__ import annotations - -import argparse -import json -import sys -from datetime import datetime, timezone -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import JOURNAL_DIR, force_utf8_stdout # noqa: E402 - - -STATUS_GLYPH = {"ok": "✓", "approval_pending": "⧗", "error": "✗"} - - -def _iter_files(since: str | None) -> list[Path]: - """Return journal files (oldest first), optionally filtered to >= since (YYYY-MM-DD).""" - if not JOURNAL_DIR.exists(): - return [] - files = sorted(JOURNAL_DIR.glob("*.jsonl")) - if since: - files = [f for f in files if f.stem >= since] - return files - - -def _read_entries(files: list[Path]) -> list[dict]: - out: list[dict] = [] - for f in files: - try: - for line in f.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - continue - except OSError: - continue - return out - - -def _fmt(entry: dict) -> str: - glyph = STATUS_GLYPH.get(entry.get("status", ""), "?") - ts = entry.get("ts", "") - # Trim to HH:MM:SS for readability - when = ts[11:19] if len(ts) >= 19 else ts - method = (entry.get("method") or "").ljust(5) - path = entry.get("path") or "" - tail = "" - if entry.get("status") == "approval_pending": - tail = f" → approval {entry.get('approval_id')}" - elif entry.get("status") == "error": - tail = f" → {(entry.get('error') or '').splitlines()[0][:80]}" - elif entry.get("result_id"): - tail = f" → id {entry.get('result_id')}" - return f"{glyph} {when} {method} {path}{tail}" - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--today", action="store_true", help="Show today's writes (default)") - parser.add_argument("--since", metavar="YYYY-MM-DD", help="Show writes on/after this date") - parser.add_argument("--tail", type=int, metavar="N", help="Show the last N entries across all days") - parser.add_argument("--grep", metavar="STR", help="Only entries whose path contains STR") - parser.add_argument("--errors", action="store_true", help="Only failed writes") - parser.add_argument("--path", action="store_true", help="Print the journal directory and exit") - args = parser.parse_args() - - if args.path: - print(JOURNAL_DIR) - return 0 - - if args.since: - files = _iter_files(args.since) - elif args.tail: - files = _iter_files(None) - else: - today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - files = _iter_files(today) - - entries = _read_entries(files) - if args.grep: - entries = [e for e in entries if args.grep in (e.get("path") or "")] - if args.errors: - entries = [e for e in entries if e.get("status") == "error"] - entries.sort(key=lambda e: e.get("ts") or "") - if args.tail: - entries = entries[-args.tail:] - - if not entries: - where = "today" if not (args.since or args.tail) else "the selected range" - print(f"No BOS writes journaled for {where}.") - print(f"(Journal dir: {JOURNAL_DIR} — set BOS_JOURNAL=0 to disable journaling.)") - return 0 - - ok = sum(1 for e in entries if e.get("status") == "ok") - pend = sum(1 for e in entries if e.get("status") == "approval_pending") - err = sum(1 for e in entries if e.get("status") == "error") - print(f"BOS write journal — {len(entries)} writes " - f"({ok} done, {pend} awaiting approval, {err} failed)\n") - for e in entries: - print(_fmt(e)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/lint-sequence.py b/tools/lint-sequence.py deleted file mode 100644 index f77eac6..0000000 --- a/tools/lint-sequence.py +++ /dev/null @@ -1,366 +0,0 @@ -#!/usr/bin/env python3 -"""Lint a nurture sequence against the house style — before it ships, or to -catch drift in a live queue. - -This is the deterministic check behind `/lint-nurture-sequence`. It reads a set -of sequence emails (from a live auto queue, or from a local drafts file) and -flags, per email, the things that quietly degrade a drip: a missing clickable -text CTA above the image, an inconsistent sign-off, a negative subject line, an -em dash, a missing greeting. Then it checks the set is internally CONSISTENT — -the single biggest cause of a sequence that "feels half-built" is some emails -following the pattern and others not. - -What it checks per email: - - greeting present (Hi {{contact.first_name}} / a {{contact.*}} near the top) - - a link exists at all - - a bold clickable TEXT link appears ABOVE the first image (so image-blocked - clients still have a CTA) — the exact gap that makes drips underperform - - HTML

structure (plain-text bodies render badly in Gmail) - - sign-off block present (default "Warmest regards"; set with --signoff) - - subject is positive / forward-looking (no leading negation) - - no em dash (house style; relax with --allow-em-dash) - - subject present and not absurdly long - -Across the set: - - sign-offs match - - CTA-above-image is all-or-nothing (not mixed) - - P.S. presence is consistent (informational) - -Exit codes (mirrors tools/lint-skill.py): - 0 — clean - 1 — warnings only - 2 — at least one FAIL - -Usage: - python tools/lint-sequence.py --queue - python tools/lint-sequence.py --drafts path/to/drafts.json - python tools/lint-sequence.py --queue --json # machine-readable - python tools/lint-sequence.py --drafts d.json --signoff "Cheers, Sam" --allow-em-dash - -Drafts file shape (either form): - [{"label": "Day 0", "subject": "...", "body": "

...

"}, ...] - {"emails": [ {...}, ... ]} -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import ( # noqa: E402 - BOSError, api_get, emit_json, force_utf8_stdout, log, paginate, resolve_path, -) - -TOOL = "lint-sequence" - -SEND_ACTION_TYPES = {"send_gmail_email", "send_custom_email", "send_marketing_email"} -QUEUE_RESOURCE_CANDIDATES = ["event-queues", "auto-queues", "auto_queues", "event_queues"] -DEFAULT_SIGNOFF = "Warmest regards" - -# Subject openers / tokens that read as negative framing. -NEGATION_PATTERNS = [ - r"^\s*don'?t\b", r"^\s*do not\b", r"^\s*stop\b", r"^\s*never\b", - r"^\s*no\b", r"\bisn'?t\b", r"\bwon'?t\b", r"\bcan'?t\b", r"\bdon'?t\b", - r"\bnever\b", r"\bstop\b", -] - -PASS, WARN, FAIL = "PASS", "WARN", "FAIL" - - -# --------------------------------------------------------------------------- -# Source loaders -# --------------------------------------------------------------------------- - -def _resolve_any(candidates: list[str], **kw: Any) -> str: - last: Exception | None = None - for rid in candidates: - try: - return resolve_path(rid, **kw) - except BOSError as e: - last = e - raise BOSError(f"None of {candidates} resolve. Last: {last}") - - -def _emails_from_queue(queue_id: str, quiet: bool) -> list[dict[str, str]]: - """Pull each step's email (subject + body) from a live auto queue, in order.""" - get_path = _resolve_any(QUEUE_RESOURCE_CANDIDATES, action="get") - concrete = get_path.replace(":id", queue_id).replace(":queue_id", queue_id) - resp = api_get(concrete) - detail = resp.get("data", resp) if isinstance(resp, dict) else {} - steps = (detail.get("automation_event_queue_steps") or detail.get("steps") or []) - steps = sorted(steps, key=lambda s: s.get("step_order") or 0) - - emails: list[dict[str, str]] = [] - for s in steps: - aid = s.get("automation_id") - label = (s.get("description") or f"Step {s.get('step_order')}") - if "—" in label: - label = label.split("—")[0].strip() - if not aid: - emails.append({"label": label, "subject": "", "body": "", "_note": "no automation linked"}) - continue - try: - actions_resp = api_get(f"automations/{aid}/actions") - actions = actions_resp.get("data", actions_resp) if isinstance(actions_resp, dict) else [] - except BOSError: - actions = [] - send = next((a for a in actions if a.get("action_type") in SEND_ACTION_TYPES), None) - cfg = (send or {}).get("config", {}) if send else {} - emails.append({ - "label": label, - "subject": cfg.get("subject") or "", - "body": cfg.get("body") or "", - "_note": "" if send else "no send action on this step", - }) - return emails - - -def _emails_from_drafts(path: str) -> list[dict[str, str]]: - raw = json.loads(Path(path).read_text(encoding="utf-8")) - items = raw.get("emails", raw) if isinstance(raw, dict) else raw - out: list[dict[str, str]] = [] - for i, e in enumerate(items): - out.append({ - "label": e.get("label") or e.get("day") or f"Email {i + 1}", - "subject": e.get("subject") or "", - "body": e.get("body") or e.get("html") or "", - "_note": "", - }) - return out - - -# --------------------------------------------------------------------------- -# Per-email checks -# --------------------------------------------------------------------------- - -def _first_img_index(body: str) -> int: - m = re.search(r" bool: - """True if a TEXT link closes before the first image — i.e. an anchor with - visible text content, not the anchor that merely wraps the image itself. - That wrapping anchor doesn't help readers whose client blocks images.""" - for m in re.finditer(r"]*href=[^>]*>(.*?)", body, re.IGNORECASE | re.DOTALL): - if m.start() >= img_idx: - break - inner = m.group(1) - if "]+>", "", inner).strip(): - return True - return False - - -def _check_email(email: dict[str, str], signoff: str, allow_em_dash: bool) -> list[dict[str, str]]: - subject = email.get("subject", "") or "" - body = email.get("body", "") or "" - checks: list[dict[str, str]] = [] - - def add(name: str, level: str, msg: str) -> None: - checks.append({"check": name, "level": level, "message": msg}) - - # subject present - if not subject.strip(): - add("subject", FAIL, "no subject line") - elif len(subject) > 90: - add("subject", WARN, f"subject is long ({len(subject)} chars) — front-load the hook") - else: - add("subject", PASS, "subject present") - - # greeting - head = body[:240].lower() - if "{{contact." in head or re.search(r"\bhi\b|\bhello\b|\bhey\b", head): - add("greeting", PASS, "greeting present") - else: - add("greeting", WARN, "no greeting near the top (expected 'Hi {{contact.first_name}}')") - - # html structure - if " structure") - elif body.strip(): - add("html", WARN, "body is not HTML

— plain text renders poorly in Gmail") - - # link exists - has_any_link = bool(re.search(r"]*href=", body, re.IGNORECASE)) - img_idx = _first_img_index(body) - if not has_any_link: - add("link", FAIL, "no link in the email at all — there's nothing to click") - else: - add("link", PASS, "has a link") - - # CTA text link ABOVE the image - if img_idx >= 0: - if _has_text_cta_before_image(body, img_idx): - add("cta_above_image", PASS, "clickable text CTA appears above the image") - else: - add("cta_above_image", FAIL, - "image has no text link above it — readers who block images get no CTA") - else: - add("cta_above_image", PASS, "no image (text-only email — n/a)") - - # sign-off - if signoff.lower() in body.lower(): - add("signoff", PASS, f"sign-off present ('{signoff}')") - else: - add("signoff", WARN, f"sign-off '{signoff}' not found — sequence sign-offs should match") - - # positive subject - subj_l = subject.lower() - if subject and any(re.search(p, subj_l) for p in NEGATION_PATTERNS): - add("positive_subject", WARN, "subject reads as negative framing — prefer a positive, forward-looking line") - elif subject: - add("positive_subject", PASS, "subject is positively framed") - - # em dash - if not allow_em_dash and ("—" in subject or "—" in body): - add("no_em_dash", WARN, "contains an em dash (—) — house style avoids them (use --allow-em-dash to permit)") - else: - add("no_em_dash", PASS, "no em dash" if not allow_em_dash else "em dash allowed") - - if email.get("_note"): - add("source", WARN, email["_note"]) - - return checks - - -# --------------------------------------------------------------------------- -# Cross-set consistency -# --------------------------------------------------------------------------- - -def _consistency(emails: list[dict[str, str]], per_email: list[dict[str, Any]]) -> list[dict[str, str]]: - out: list[dict[str, str]] = [] - - # CTA-above-image should be all-or-nothing - cta_states = [] - for e in per_email: - cta = next((c for c in e["checks"] if c["check"] == "cta_above_image"), None) - if cta and "n/a" not in cta["message"]: - cta_states.append(cta["level"] == PASS) - if cta_states and len(set(cta_states)) > 1: - out.append({"level": FAIL, - "message": "MIXED: some emails have a text CTA above the image and some don't — " - "this is the #1 cause of a drip feeling half-built. Make it consistent."}) - - # P.S. presence consistency (informational) - ps_flags = ["p.s." in (e.get("body", "") or "").lower() for e in emails] - if ps_flags and 0 < sum(ps_flags) < len(ps_flags): - out.append({"level": WARN, - "message": f"P.S. line present on {sum(ps_flags)}/{len(ps_flags)} emails — " - "fine if intentional, worth aligning if not."}) - - # sign-off consistency - signoff_flags = [ - next((c["level"] for c in e["checks"] if c["check"] == "signoff"), PASS) == PASS - for e in per_email - ] - if signoff_flags and 0 < sum(signoff_flags) < len(signoff_flags): - out.append({"level": WARN, - "message": "sign-off block is inconsistent across the set — every email should close the same way."}) - - return out - - -# --------------------------------------------------------------------------- -# Report -# --------------------------------------------------------------------------- - -def _worst(level_a: str, level_b: str) -> str: - order = {PASS: 0, WARN: 1, FAIL: 2} - return level_a if order[level_a] >= order[level_b] else level_b - - -def lint(emails: list[dict[str, str]], signoff: str, allow_em_dash: bool) -> dict[str, Any]: - per_email: list[dict[str, Any]] = [] - for e in emails: - checks = _check_email(e, signoff, allow_em_dash) - worst = PASS - for c in checks: - worst = _worst(worst, c["level"]) - per_email.append({"label": e.get("label"), "subject": e.get("subject"), - "worst": worst, "checks": checks}) - consistency = _consistency(emails, per_email) - - overall = PASS - for e in per_email: - overall = _worst(overall, e["worst"]) - for c in consistency: - overall = _worst(overall, c["level"]) - - fails = sum(1 for e in per_email for c in e["checks"] if c["level"] == FAIL) - fails += sum(1 for c in consistency if c["level"] == FAIL) - warns = sum(1 for e in per_email for c in e["checks"] if c["level"] == WARN) - warns += sum(1 for c in consistency if c["level"] == WARN) - - return { - "overall": overall, - "email_count": len(emails), - "fail_count": fails, - "warn_count": warns, - "emails": per_email, - "consistency": consistency, - } - - -GLYPH = {PASS: "✓", WARN: "⚠", FAIL: "✗"} - - -def _print_human(report: dict[str, Any]) -> None: - print(f"Sequence lint — {report['email_count']} emails: " - f"{report['fail_count']} fail, {report['warn_count']} warn " - f"[{report['overall']}]\n") - for e in report["emails"]: - print(f"{GLYPH[e['worst']]} {e['label']} — {e['subject'] or '(no subject)'}") - for c in e["checks"]: - if c["level"] != PASS: - print(f" {GLYPH[c['level']]} {c['check']}: {c['message']}") - if report["consistency"]: - print("\nAcross the set:") - for c in report["consistency"]: - print(f" {GLYPH[c['level']]} {c['message']}") - print() - - -def main() -> int: - force_utf8_stdout() - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - src = parser.add_mutually_exclusive_group(required=True) - src.add_argument("--queue", metavar="ID", help="Lint a live auto queue's emails") - src.add_argument("--drafts", metavar="FILE", help="Lint a local drafts JSON file") - parser.add_argument("--signoff", default=DEFAULT_SIGNOFF, help=f"Expected sign-off (default: '{DEFAULT_SIGNOFF}')") - parser.add_argument("--allow-em-dash", action="store_true", help="Permit em dashes") - parser.add_argument("--json", action="store_true", help="Emit JSON instead of a human report") - parser.add_argument("--json-only", action="store_true", help="Alias for --json") - args = parser.parse_args() - - try: - if args.queue: - log(TOOL, f"reading queue {args.queue}...", quiet=args.json or args.json_only) - emails = _emails_from_queue(args.queue, quiet=args.json or args.json_only) - else: - emails = _emails_from_drafts(args.drafts) - except (BOSError, OSError, json.JSONDecodeError) as e: - sys.stderr.write(f"ERROR: {e}\n") - return 2 - - if not emails: - sys.stderr.write("No emails found to lint.\n") - return 2 - - report = lint(emails, signoff=args.signoff, allow_em_dash=args.allow_em_dash) - if args.json or args.json_only: - emit_json(report) - else: - _print_human(report) - - return 2 if report["overall"] == FAIL else (1 if report["overall"] == WARN else 0) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/lint-skill.py b/tools/lint-skill.py deleted file mode 100644 index c91d06e..0000000 --- a/tools/lint-skill.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -"""Validate a Claude Code skill folder before committing it (lint). - -When to use: -- About to commit a new skill — sanity-check the format first. -- About to publish a community skill — catch missing required frontmatter. -- Debugging "skill not triggering" — verify it has the required pieces. - -What it checks: -- SKILL.md exists. -- SKILL.md starts with YAML frontmatter (--- ... ---). -- Required frontmatter fields are present: name, description, triggers. -- "triggers" has at least 3 phrases (5+ recommended). -- If fetch.py exists: - - Imports from trustpager_api (or has a comment explaining why not). - - No hardcoded tp_live_* API keys. - - No hardcoded supabase.co URLs (use API_BASE from trustpager_api). - - Uses resolve_path() when calling api_get() (so paths don't drift). - -Exit codes: - 0 — no issues - 1 — only warnings - 2 — at least one [FAIL] - -Usage: - python tools/lint-skill.py skills/sweep-my-day - python tools/lint-skill.py skills/my-new-skill - -Related: - python tools/test-skill.py # offline fixture test -""" - -from __future__ import annotations - -import argparse -import re -import sys -from pathlib import Path -from typing import Any - -REQUIRED_FRONTMATTER = {"name", "description", "triggers"} - - -def _parse_simple_frontmatter(text: str) -> dict[str, Any] | None: - """Minimal YAML-frontmatter parser. Returns None if no frontmatter.""" - if not text.startswith("---"): - return None - end = text.find("\n---", 3) - if end < 0: - return None - block = text[4:end] - out: dict[str, Any] = {} - current_list_key: str | None = None - for line in block.splitlines(): - if not line.strip(): - current_list_key = None - continue - if line.startswith(" - "): - if current_list_key: - out.setdefault(current_list_key, []).append(line[4:].strip()) - continue - if ": " in line: - k, v = line.split(": ", 1) - k = k.strip() - v = v.strip() - if v: - out[k] = v - current_list_key = None - else: - current_list_key = k - elif line.endswith(":"): - current_list_key = line[:-1].strip() - return out - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("skill_dir", help="Path to a skill directory (e.g. skills/sweep-my-day)") - args = parser.parse_args() - - skill_dir = Path(args.skill_dir).resolve() - if not skill_dir.is_dir(): - print(f"ERROR: not a directory: {skill_dir}", file=sys.stderr) - return 2 - - issues: list[tuple[str, str]] = [] - - skill_md = skill_dir / "SKILL.md" - if not skill_md.exists(): - issues.append(("FAIL", f"missing {skill_md.name}")) - else: - text = skill_md.read_text(encoding="utf-8") - fm = _parse_simple_frontmatter(text) - if not fm: - issues.append(("FAIL", "SKILL.md missing YAML frontmatter (--- ... ---)")) - else: - missing = REQUIRED_FRONTMATTER - set(fm) - for k in missing: - issues.append(("FAIL", f"SKILL.md frontmatter missing required field: {k}")) - triggers = fm.get("triggers") - if isinstance(triggers, list) and len(triggers) < 3: - issues.append(("WARN", f"SKILL.md has only {len(triggers)} trigger phrases; aim for 5+")) - - fetch_py = skill_dir / "fetch.py" - if fetch_py.exists(): - py_text = fetch_py.read_text(encoding="utf-8") - imports_lib = ("from trustpager_api import" in py_text - or "import trustpager_api" in py_text - or "from bos_lib import" in py_text) - if not imports_lib: - issues.append(("WARN", "fetch.py doesn't import from trustpager_api — " - "likely missing shared helpers")) - if re.search(r"tp_live_[A-Za-z0-9_]{20,}", py_text): - issues.append(("FAIL", "fetch.py contains what looks like a hardcoded tp_live_* API key")) - if "supabase.co" in py_text: - issues.append(("WARN", "fetch.py references supabase.co directly — " - "should use API_BASE from trustpager_api")) - if "api_get(" in py_text and "resolve_path(" not in py_text: - issues.append(("WARN", "fetch.py calls api_get() but doesn't use resolve_path() — " - "paths may drift if the API renames endpoints")) - - print(f"Linting {skill_dir.name}/...") - if not issues: - print(" OK — no issues found.") - return 0 - for severity, msg in issues: - marker = "[FAIL]" if severity == "FAIL" else "[WARN]" - print(f" {marker} {msg}") - failures = sum(1 for s, _ in issues if s == "FAIL") - return 2 if failures else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/list-endpoints.py b/tools/list-endpoints.py deleted file mode 100644 index 5d45de2..0000000 --- a/tools/list-endpoints.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""List all TrustPager API resources and their endpoints (browse catalog). - -When to use: -- "What can the TrustPager API do?" -- "Does TrustPager have an endpoint for X?" -- "Show me every endpoint under opportunities." -- Before writing a new skill — confirm the API surface you're planning to call. - -What it does: -- Without args: prints a summary table of all 60+ resources with their - endpoint counts. -- With a resource id: prints every endpoint on that resource — method, path, - scopes, and a [R]/[W] marker for read vs write. - -The catalog is the same one published at docs.trustpager.com/api-index.json -(cached locally for 24h; force a refresh with `python tools/config.py ---clear-cache`). - -Usage: - python tools/list-endpoints.py - python tools/list-endpoints.py opportunities - python tools/list-endpoints.py contacts - -Related: - python tools/inspect-endpoint.py # full schema for one -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import BOSError, CATALOG_URL, get_catalog # noqa: E402 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("resource_id", nargs="?", default=None, - help="Optional: a specific resource id to drill into") - args = parser.parse_args() - - try: - catalog = get_catalog() - except BOSError as e: - print(f"ERROR: {e}", file=sys.stderr) - return 1 - - if args.resource_id: - resource = next((r for r in catalog["resources"] - if r["id"] == args.resource_id), None) - if not resource: - print(f"Resource not found: {args.resource_id}", file=sys.stderr) - print(f"Available: {', '.join(r['id'] for r in catalog['resources'][:20])}...", - file=sys.stderr) - return 1 - print(f"=== {resource['id']} — {resource['label']} ===") - print(f"Doc: {resource.get('doc_url', '(none)')}") - print(f"Description: {resource.get('description', '(none)')}") - print() - print(f"Endpoints ({len(resource['endpoints'])}):") - for ep in resource["endpoints"]: - mark = "W" if ep.get("is_write") else "R" - scopes = " ".join(ep.get("scopes", [])) - print(f" [{mark}] {ep['method']:6s} {ep['path']:50s} scopes={scopes}") - return 0 - - print(f"Catalog from: {CATALOG_URL}") - print(f"Generated: {catalog.get('generated_at', '?')}") - print(f"Base URL: {catalog.get('base_url', '?')}") - print(f"Auth: {catalog.get('auth', {}).get('format', '?')}") - print() - print(f"{len(catalog['resources'])} resources:") - for r in catalog["resources"]: - n = len(r.get("endpoints", [])) - print(f" {r['id']:30s} {n:3d} endpoints {r.get('label', '')}") - print() - print("For details on one resource: python tools/list-endpoints.py ") - print("For one endpoint's full schema: python tools/inspect-endpoint.py ") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/run.py b/tools/run.py deleted file mode 100644 index 141be14..0000000 --- a/tools/run.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""CWD-independent launcher for skill data-fetchers. - -Why this exists: - Skills tell Claude to run a fetcher. Hardcoding `python skills//fetch.py` - assumes the current working directory IS the BOS clone root — which is false - whenever Claude is running in the operator's own project folder, or when BOS - is installed as a plugin (skills registered from ~/.claude/plugins/... while - the Python lives in a separate clone). This launcher removes that assumption. - -How it stays location-independent: - This file lives at /tools/run.py, so it locates BOS_HOME from its - OWN path (`__file__`) — never from the working directory. The fixed-location - shim at ~/.claude/bos-run.py (written by tools/setup.py) is what bootstraps - here from anywhere, so skills can always call: - - python ~/.claude/bos-run.py [args...] - -Usage (direct): - python tools/run.py [args...] # e.g. sweep-my-day - python tools/run.py --list # show runnable skills - -Extra args after are forwarded verbatim to the skill's fetch.py -(e.g. `draft-reply --hours 48`, `why-didnt-it-fire ""`). -""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -BOS_HOME = Path(__file__).resolve().parent.parent -SKILLS_DIR = BOS_HOME / "skills" - - -def _runnable() -> list[str]: - if not SKILLS_DIR.is_dir(): - return [] - return sorted(d.name for d in SKILLS_DIR.iterdir() if (d / "fetch.py").is_file()) - - -def main(argv: list[str]) -> int: - if not argv or argv[0] in ("-h", "--help"): - print(__doc__.strip()) - return 0 - if argv[0] == "--list": - names = _runnable() - print("\n".join(names) if names else "(no skills with a fetch.py found)") - return 0 - - name = argv[0] - fetch = SKILLS_DIR / name / "fetch.py" - if not fetch.is_file(): - print(f"[err] no fetcher for skill '{name}'.", file=sys.stderr) - print(f" looked in: {fetch}", file=sys.stderr) - runnable = _runnable() - if runnable: - print(f" runnable skills: {', '.join(runnable)}", file=sys.stderr) - return 2 - - # Run the fetcher in a child process and pass its exit code straight back. - # subprocess (not os.execv) because execv on Windows mangles argv entries - # that contain spaces (e.g. an install path like "...\Final Piece\..."), - # splitting the path at the space. The fetcher self-locates the shared lib - # via its own __file__, so the child's working directory is irrelevant. - proc = subprocess.run([sys.executable, str(fetch), *argv[1:]]) - return proc.returncode - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/tools/setup.py b/tools/setup.py deleted file mode 100644 index 55ae533..0000000 --- a/tools/setup.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -"""Set up TrustPager API access for Claude Code skills (first-run wizard). - -When to use: -- Right after cloning this repo. This is the first thing a customer runs. -- After regenerating your TrustPager API key. -- After running config.py --clear to wipe an old key. - -What it does: -- Looks for an existing tp_live_* key in Claude Code's MCP config - (~/.claude/.mcp.json or ~/.claude/settings.json or ~/.claude.json). -- If found, offers to reuse it (no copy-paste needed). -- Otherwise, prompts you to paste your tp_live_... key. -- Writes the key (and this clone's location, bos_home) to ~/.claude/bos.json. -- Writes a tiny launcher shim to ~/.claude/bos-run.py so skills can run their - data-fetchers from any working directory (`python ~/.claude/bos-run.py `). - -Usage: - python tools/setup.py - python tools/setup.py --force # overwrite an existing key - -Next step after this runs: python tools/check-install.py -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from trustpager_api import CONFIG_PATH # noqa: E402 - - -def _walk_for_key(obj: Any) -> str | None: - if isinstance(obj, str): - if obj.startswith("tp_live_") and len(obj) > 20: - return obj - return None - if isinstance(obj, dict): - for v in obj.values(): - r = _walk_for_key(v) - if r: - return r - if isinstance(obj, list): - for v in obj: - r = _walk_for_key(v) - if r: - return r - return None - - -def _find_key_in_mcp_config() -> str | None: - """Look for an existing TrustPager API key in Claude Code's MCP config.""" - candidates = [ - Path.home() / ".claude" / ".mcp.json", - Path.home() / ".claude" / "settings.json", - Path.home() / ".claude.json", - ] - for path in candidates: - if not path.exists(): - continue - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - continue - found = _walk_for_key(data) - if found: - return found - return None - - -# The fixed-location shim. It lives at ~/.claude/bos-run.py — the one path that -# is known regardless of working directory or how BOS was installed. It reads -# bos_home from bos.json (same dir) and hands off to /tools/run.py, -# which carries the real logic and is versioned in the repo. Keep this DUMB so -# it rarely needs regenerating. -_SHIM_SOURCE = '''#!/usr/bin/env python3 -# Auto-generated by Business Operating System setup.py. Do not edit by hand. -# Bootstraps the CWD-independent skill launcher from any working directory. -# Re-run `python tools/setup.py` to regenerate. -import json, subprocess, sys -from pathlib import Path - -_cfg = Path.home() / ".claude" / "bos.json" -try: - _home = json.loads(_cfg.read_text(encoding="utf-8")).get("bos_home") -except (OSError, ValueError): - _home = None -if not _home: - sys.stderr.write("[err] bos_home not set. Run: python tools/setup.py\\n") - sys.exit(2) -_run = Path(_home) / "tools" / "run.py" -if not _run.is_file(): - sys.stderr.write(f"[err] launcher missing at {_run}. Re-run python tools/setup.py\\n") - sys.exit(2) -# subprocess (not os.execv): execv on Windows splits argv entries containing -# spaces, which breaks install paths like "...\\Final Piece\\...". -sys.exit(subprocess.run([sys.executable, str(_run), *sys.argv[1:]]).returncode) -''' - - -def _bos_home() -> str: - """Absolute path to this clone's root (the dir that contains tools/).""" - return str(Path(__file__).resolve().parent.parent) - - -def _write_launcher_shim() -> Path: - shim = CONFIG_PATH.parent / "bos-run.py" - shim.write_text(_SHIM_SOURCE, encoding="utf-8") - return shim - - -def _write_key(key: str) -> int: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - # Preserve any existing config keys; just update api_key + bos_home. - cfg: dict[str, Any] = {} - if CONFIG_PATH.exists(): - try: - cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) or {} - except (json.JSONDecodeError, OSError): - cfg = {} - cfg["api_key"] = key - cfg["bos_home"] = _bos_home() - CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8") - print(f"Wrote {CONFIG_PATH}") - shim = _write_launcher_shim() - print(f"Wrote {shim} (skills run via: python ~/.claude/bos-run.py )") - print() - print("Next: run `python tools/check-install.py` to verify everything works.") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("--force", action="store_true", - help="Overwrite an existing key without prompting") - args = parser.parse_args() - - print("TrustPager — first-run setup for Claude Code skills") - print() - print(f"This will write your TrustPager API key to: {CONFIG_PATH}") - print() - - existing_key = None - if CONFIG_PATH.exists() and not args.force: - try: - cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) - existing_key = (cfg.get("api_key") or "").strip() - except (json.JSONDecodeError, OSError): - pass - if existing_key: - print(f"A key is already configured (ends ...{existing_key[-4:]}).") - # Idempotent backfill for upgraders: ensure bos_home + the launcher - # shim exist even when we're not touching the key. - if cfg.get("bos_home") != _bos_home(): - cfg["bos_home"] = _bos_home() - CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8") - print(f"Updated bos_home in {CONFIG_PATH}") - shim = _write_launcher_shim() - print(f"Ensured launcher shim at {shim}") - print("Re-run with --force to replace the key, or skip setup entirely.") - return 0 - - detected = _find_key_in_mcp_config() - if detected: - print(f"Detected an existing TrustPager API key in Claude Code config") - print(f" (ends ...{detected[-4:]}).") - choice = input("Use this key for skill scripts too? [Y/n] ").strip().lower() - if choice in ("", "y", "yes"): - return _write_key(detected) - - print("Get your key from: https://app.trustpager.com/settings/api") - print() - key = input("Paste your tp_live_... key: ").strip() - if not key: - print("ERROR: empty key. Aborting.", file=sys.stderr) - return 2 - if not key.startswith("tp_live_"): - print(f"WARNING: key doesn't start with 'tp_live_'. Got '{key[:10]}...'", - file=sys.stderr) - confirm = input("Use it anyway? [y/N] ").strip().lower() - if confirm not in ("y", "yes"): - return 2 - return _write_key(key) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/sync-brand.py b/tools/sync-brand.py deleted file mode 100644 index c5f5e6b..0000000 --- a/tools/sync-brand.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# sync-brand.py -# -# Copies brand assets from BOS/brand/ into each studio's public/ folder. -# -# When to use: -# - After editing brand/brand.json or replacing brand/logo.png -# - After running /brand-my-workspace (the skill writes brand/, you run this) -# - Once on first install if studios don't have logo.png/favicons yet -# -# What it does: -# - Copies brand/logo.png -> studio//public/logo.png -# - Copies brand/favicon.ico -> studio//public/favicon.ico -# - Copies brand/favicon-16x16.png -> ... -# - Copies brand/favicon-32x32.png -> ... -# - Copies brand/icon.png -> studio//public/apple-touch-icon.png -# - Copies brand/favicon-192x192.png -> ...android-chrome-192x192.png -# - Copies brand/favicon-512x512.png -> ...android-chrome-512x512.png -# -# Studios pick up the new assets the next time their dev server serves -# /logo.png or /favicon.ico. Restart the dev server (or hard-refresh the -# tab) after running. -# -# Usage: -# python tools/sync-brand.py -# python tools/sync-brand.py --dry-run # show what WOULD copy -# -# Adding a new studio? Drop it under studio//, give it a public/ -# folder, and run this. It auto-discovers every direct child of studio/. - -import shutil -import sys -from pathlib import Path - -BOS = Path(__file__).resolve().parent.parent -BRAND = BOS / "brand" -STUDIOS_DIR = BOS / "studio" - -# Map source filename in brand/ -> destination filename in studio//public/. -# Names differ because the brand/ folder uses clean naming while studios -# follow the standard favicon convention referenced from their index.html. -ASSET_MAP = { - "logo.png": "logo.png", - "favicon.ico": "favicon.ico", - "favicon-16x16.png": "favicon-16x16.png", - "favicon-32x32.png": "favicon-32x32.png", - "icon.png": "apple-touch-icon.png", - "favicon-192x192.png": "android-chrome-192x192.png", - "favicon-512x512.png": "android-chrome-512x512.png", -} - -def main(): - dry_run = "--dry-run" in sys.argv - - if not BRAND.is_dir(): - print(f"ERROR: brand/ folder not found at {BRAND}", file=sys.stderr) - return 1 - - missing = [src for src in ASSET_MAP if not (BRAND / src).is_file()] - if missing: - print(f"ERROR: missing brand assets: {missing}", file=sys.stderr) - print(f"Expected location: {BRAND}", file=sys.stderr) - return 1 - - if not STUDIOS_DIR.is_dir(): - print(f"No studios directory at {STUDIOS_DIR} -- nothing to sync.", file=sys.stderr) - return 0 - - studios = [s for s in STUDIOS_DIR.iterdir() if s.is_dir() and (s / "public").is_dir()] - if not studios: - print(f"No studios with public/ found under {STUDIOS_DIR}.") - return 0 - - print(f"Syncing brand assets from {BRAND}") - print(f"Target studios: {[s.name for s in studios]}") - if dry_run: - print("(dry-run -- no files will be written)") - print() - - copied = 0 - for studio in studios: - public = studio / "public" - print(f"-> studio/{studio.name}/public/") - for src_name, dst_name in ASSET_MAP.items(): - src = BRAND / src_name - dst = public / dst_name - if dry_run: - print(f" {src_name} -> {dst_name}") - else: - shutil.copy2(src, dst) - print(f" [OK] {dst_name}") - copied += 1 - print() - - print(f"Done. {copied} files copied across {len(studios)} studio(s).") - print() - print("Next: restart any running studio dev servers, or hard-refresh tabs") - print("(browsers cache favicons aggressively).") - return 0 - - -if __name__ == "__main__": - sys.exit(main() or 0) diff --git a/tools/test-skill.py b/tools/test-skill.py deleted file mode 100644 index e1f2952..0000000 --- a/tools/test-skill.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -"""Run a skill's fetch.py against a mock fixture (offline, no API calls). - -When to use: -- Iterating on a skill — fast feedback without burning credits. -- Verifying a skill still works after refactoring trustpager_api. -- Running in CI — no API key needed. -- Demonstrating a skill's output shape to someone without a TrustPager workspace. - -What it does: -- Loads skills//test-fixture.json (or a custom path with --fixture). -- Monkey-patches trustpager_api.api_get to return the fixture responses. -- Monkey-patches trustpager_api.get_catalog to use the fixture catalog - (or the live catalog if the fixture sets {"_use_live": true}). -- Imports and runs the skill's fetch.py main(), capturing exit code. -- Prints OK/FAIL based on whether main() raised. - -Fixture shape: - { - "catalog": {"_use_live": true}, - "args": ["--contact-id", "contact-1"], - "responses": { - "opportunities": {"data": [{"id": "...", "name": "..."}]}, - "tasks": {"data": []} - } - } - -The optional "args" list is appended to sys.argv after "--json-only", so a -skill whose fetch.py takes required arguments (e.g. --query, --contact-id) can -still be exercised against its fixture. Omit it for zero-argument skills. - -Usage: - python tools/test-skill.py sweep-my-day - python tools/test-skill.py my-skill --fixture path/to/other-fixture.json - -Related: - python tools/lint-skill.py skills/ # static validation -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any - -REPO_ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(REPO_ROOT / "tools")) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) - parser.add_argument("skill_name", help="Name of the skill under skills/ (e.g. sweep-my-day)") - parser.add_argument("--fixture", default=None, - help="Path to fixture JSON (default: skills//test-fixture.json)") - args = parser.parse_args() - - skill_dir = REPO_ROOT / "skills" / args.skill_name - fetch_py = skill_dir / "fetch.py" - if not fetch_py.exists(): - print(f"ERROR: no fetch.py for skill '{args.skill_name}' at {fetch_py}", - file=sys.stderr) - return 2 - - fixture_path = Path(args.fixture) if args.fixture else (skill_dir / "test-fixture.json") - if not fixture_path.exists(): - print(f"ERROR: fixture not found: {fixture_path}", file=sys.stderr) - print(f"Create one with this shape:", file=sys.stderr) - print(json.dumps({ - "catalog": {"_use_live": True}, - "responses": { - "opportunities": {"data": [{"id": "fake-1", "name": "Test deal", "status": "open"}]}, - "tasks": {"data": []}, - }, - }, indent=2), file=sys.stderr) - return 2 - - fixture = json.loads(fixture_path.read_text(encoding="utf-8")) - print(f"Testing {args.skill_name} against fixture {fixture_path.name}") - print() - - import trustpager_api - - real_api_get = trustpager_api.api_get - real_get_catalog = trustpager_api.get_catalog - - def mock_api_get(path: str, **params: Any) -> dict[str, Any]: - responses = fixture.get("responses", {}) - if path in responses: - return responses[path] - for key, val in responses.items(): - if path.startswith(key): - return val - return {"data": [], "pagination": {"has_more": False}} - - def mock_get_catalog(force_refresh: bool = False) -> dict[str, Any]: - cat = fixture.get("catalog", {}) - if cat.get("_use_live"): - return real_get_catalog(force_refresh=force_refresh) - return cat - - trustpager_api.api_get = mock_api_get # type: ignore[assignment] - trustpager_api.get_catalog = mock_get_catalog # type: ignore[assignment] - - try: - import importlib.util - spec = importlib.util.spec_from_file_location("skill_fetch", fetch_py) - if not spec or not spec.loader: - print("ERROR: could not load fetch.py", file=sys.stderr) - return 2 - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - - if hasattr(mod, "api_get"): - mod.api_get = mock_api_get - if hasattr(mod, "get_catalog"): - mod.get_catalog = mock_get_catalog - - fixture_args = fixture.get("args", []) - if not isinstance(fixture_args, list) or not all(isinstance(a, str) for a in fixture_args): - print("ERROR: fixture 'args' must be a list of strings", file=sys.stderr) - return 2 - sys.argv = ["fetch.py", "--json-only", *fixture_args] - if hasattr(mod, "main"): - mod.main() - print() - print("OK — fetch.py ran without raising.") - return 0 - print("ERROR: fetch.py has no main() function", file=sys.stderr) - return 2 - except SystemExit as e: - code = e.code if isinstance(e.code, int) else 1 - if code == 0: - print() - print("OK — fetch.py exited cleanly.") - return 0 - print() - print(f"FAIL — fetch.py exited with code {code}.") - return code - except Exception as e: # noqa: BLE001 - print() - print(f"FAIL — fetch.py raised: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - return 1 - finally: - trustpager_api.api_get = real_api_get - trustpager_api.get_catalog = real_get_catalog - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/trustpager_api.py b/tools/trustpager_api.py deleted file mode 100644 index 6ae3443..0000000 --- a/tools/trustpager_api.py +++ /dev/null @@ -1,1057 +0,0 @@ -"""TrustPager API — shared library for skill scripts and tools. - -Stdlib-only. No `pip install` required. Every script in this repo imports -from here so we get one consistent place for: API auth, base URL, GET/POST -helpers, parallel fetches, paginated reads, bulk writes, catalog-driven -path resolution, and friendly error messages for non-developer users. - -Usage in a skill script: - - import sys - from pathlib import Path - sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools")) - from trustpager_api import api_get, parallel_get, BOSError - - # Single call - opportunities = api_get("opportunities", limit=100) - - # Parallel fan-out - results = parallel_get([ - ("opportunities", {"limit": 100}), - ("tasks", {"completed": "false"}), - ("bookings", {"date_from": "today"}), - ]) - -API key resolution order (first hit wins): - 1. $TRUSTPAGER_API_KEY environment variable (good for CI / scripts) - 2. ~/.claude/bos.json -> {"api_key": "tp_live_..."} (written by installer) - 3. Friendly error explaining how to set it -""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Callable, Iterator - -# The public TrustPager API base URL. Reaches the same gateway as the MCP. -API_BASE = "https://api.trustpager.com/functions/v1/api/v1" -CATALOG_URL = "https://docs.trustpager.com/api-index.json" -CONFIG_PATH = Path.home() / ".claude" / "bos.json" -CATALOG_CACHE_PATH = Path.home() / ".claude" / "bos-cache" / "api-index.json" -JOURNAL_DIR = Path.home() / ".claude" / "bos-journal" # write audit trail (see tools/journal.py) -CATALOG_TTL_SECONDS = 24 * 60 * 60 # 24h -DEFAULT_TIMEOUT_SECONDS = 30 -DEFAULT_PARALLEL_WORKERS = 8 - -# ----------------------------------------------------------------------------- -# Path overrides — known docs/parser bugs that ship the wrong path in -# api-index.json. Remove entries here as upstream fixes ship. -# -# Discovered 2026-05-31: the docs generator flattens multi-segment SharedRoute -# patterns (e.g. ['scheduling', 'bookings']) into a single dashed string -# instead of preserving the slash. Verified live: the slashed paths 200, the -# dashed paths 404. Other dashed resources (voice-agent-kbs, event-queues, -# email-campaigns, etc.) are genuinely dashed paths and work correctly. -# ----------------------------------------------------------------------------- -PATH_OVERRIDES: dict[str, str] = { - # docs say # actually works - "scheduling-bookings": "scheduling/bookings", - "scheduling-availability": "scheduling/availability", - "scheduling-event-types": "scheduling/event-types", -} - - -class BOSError(Exception): - """Friendly, user-facing error. The message is intended for end users.""" - - -# Matches a REAL TrustPager key (long token after the prefix), not the bare -# "tp_live_" prefix that appears in docs/error messages. Used to redact keys -# from anything that gets written or logged, and by tools/check-no-secrets.py. -_SECRET_RE = re.compile(r"tp_(?:live|test)_[A-Za-z0-9_\-]{16,}") - - -def _redact(text: str | None) -> str | None: - """Replace any TrustPager key token with a placeholder. Best-effort, never raises.""" - if not text: - return text - try: - return _SECRET_RE.sub("tp_***REDACTED***", text) - except Exception: # noqa: BLE001 - return text - - -def _is_offline() -> bool: - """True when BOS_OFFLINE is set — tests/CI run with this on so no network - call can ever fire and no real API key is ever read.""" - return os.environ.get("BOS_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"} - - -@dataclass -class ApprovalPending: - """Returned (NOT raised) when an API write returns 202 + approval_id. - - The action has been queued for human approval, not executed. Scripts can: - - return the approval_id to the operator for them to approve in-app - - call .poll() to check current status without re-issuing the write - - access .body for the full 202 response payload - - Skills that don't care about the approval flow can pass ApprovalPending - results back to the operator as-is — the str() form is human-readable. - """ - approval_id: str - body: dict[str, Any] - approval_url: str = "https://app.trustpager.com/settings/api?tab=approvals" - - def __str__(self) -> str: - return ( - f"Queued for approval (id: {self.approval_id}). " - f"Approve at {self.approval_url}" - ) - - def poll(self) -> dict[str, Any]: - """Fetch the current state of this approval. Returns the approval row.""" - return api_get(f"approvals/{self.approval_id}") - - @property - def executed(self) -> bool: - """Has this approval been approved AND executed yet?""" - try: - row = self.poll() - return bool(row.get("executed") or row.get("executed_at")) - except BOSError: - return False - - -def get_api_key() -> str: - """Resolve the user's TrustPager API key. - - Order: env var, then ~/.claude/bos.json. Raises BOSError with a fix-it - message if neither is set. - """ - env = os.environ.get("TRUSTPAGER_API_KEY", "").strip() - if env: - return env - - if CONFIG_PATH.exists(): - try: - config = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) - key = (config.get("api_key") or "").strip() - if key: - return key - except (json.JSONDecodeError, OSError): - pass # Fall through to friendly error - - raise BOSError( - "TrustPager API key not found.\n" - "\n" - "Fix it with one of:\n" - f" - Set TRUSTPAGER_API_KEY in your shell environment, or\n" - f" - Re-run the BOS installer to create {CONFIG_PATH}, or\n" - " - Create the file manually with the JSON shape:\n" - ' {"api_key": ""}' - ) - - -def _build_url(path: str, params: dict[str, Any] | None = None) -> str: - """Build a full URL from a path (with or without leading slash) and query params.""" - path = path.lstrip("/") - url = f"{API_BASE}/{path}" - if params: - # Drop None values; stringify everything else - clean = {k: ("true" if v is True else "false" if v is False else str(v)) - for k, v in params.items() if v is not None} - if clean: - url = f"{url}?{urllib.parse.urlencode(clean)}" - return url - - -DEFAULT_RETRIES_ON_429 = 3 -DEFAULT_RETRIES_ON_5XX = 2 - - -def _parse_response_body(raw: bytes) -> dict[str, Any]: - """Parse a JSON response body. Returns {} for empty/non-JSON bodies.""" - if not raw: - return {} - try: - return json.loads(raw.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - return {"_raw": raw[:300].decode("utf-8", errors="replace")} - - -def _request(method: str, path: str, params: dict[str, Any] | None = None, - body: dict[str, Any] | None = None, timeout: int = DEFAULT_TIMEOUT_SECONDS, - extra_headers: dict[str, str] | None = None, - _attempt: int = 0) -> dict[str, Any] | ApprovalPending: - """Low-level HTTP request with rich error handling. - - Returns: - - dict on 2xx (parsed JSON response) - - ApprovalPending on 202 (write was queued, not executed) - - Raises BOSError on every other failure mode - - Retry behaviour: - - 429: retries up to DEFAULT_RETRIES_ON_429 times, honouring Retry-After - - 5xx: retries up to DEFAULT_RETRIES_ON_5XX times with exponential backoff - - Network errors: no retry — bubbles up immediately - """ - if _is_offline(): - raise BOSError( - f"BOS is in offline mode (BOS_OFFLINE is set) — refusing the {method} {path} " - f"network call.\nThis guard means tests and CI can never reach the live API or " - f"read a real API key. Mock api_get/api_post with fixtures instead " - f"(see tools/test-skill.py)." - ) - api_key = get_api_key() - url = _build_url(path, params) - headers = { - "Authorization": f"Bearer {api_key}", - "Accept": "application/json", - "User-Agent": "BusinessOperatingSystem/1.0", - } - if extra_headers: - headers.update(extra_headers) - data: bytes | None = None - if body is not None: - headers["Content-Type"] = "application/json" - data = json.dumps(body).encode("utf-8") - - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - payload = resp.read() - parsed = _parse_response_body(payload) - # 202 = queued for approval. Return ApprovalPending, don't raise. - if resp.status == 202: - approval_id = ( - parsed.get("approval_id") - or parsed.get("id") - or (parsed.get("data") or {}).get("approval_id") - or "unknown" - ) - return ApprovalPending(approval_id=approval_id, body=parsed) - return parsed - except urllib.error.HTTPError as e: - detail_raw = b"" - try: - detail_raw = e.read() - except OSError: - pass - detail_parsed = _parse_response_body(detail_raw) - detail_str = (detail_raw or b"").decode("utf-8", errors="replace")[:500] - docs_hint = "See https://docs.trustpager.com for details." - - # 401 — bad / missing key - if e.code == 401: - raise BOSError( - f"Your TrustPager API key was rejected (401 Unauthorized).\n" - f"Check that it starts with 'tp_live_' and hasn't been revoked.\n" - f"Manage keys: https://app.trustpager.com/settings/api" - ) from None - - # 402 — billing / out of credits - if e.code == 402: - raise BOSError( - f"Your TrustPager workspace is out of credits or has a billing issue (402).\n" - f"Manage billing: https://app.trustpager.com/settings/billing\n" - f"Server said: {detail_str}" - ) from None - - # 403 — missing scope - if e.code == 403: - raise BOSError( - f"Your API key doesn't have permission for {path} (403 Forbidden).\n" - f"Add the required scope at https://app.trustpager.com/settings/api\n" - f"Server said: {detail_str}" - ) from None - - # 404 — bad path - if e.code == 404: - raise BOSError( - f"Endpoint not found: {path} (404).\n" - f"This may be a BOS bug, a path that's been renamed, or a typo.\n" - f"Browse the live API catalog: https://docs.trustpager.com/api-index.json\n" - f"Full URL was: {url}" - ) from None - - # 422 — validation error. The API helpfully puts valid values in details.available. - if e.code == 422: - err = detail_parsed.get("error", {}) - msg = err.get("message") or detail_str - available = (err.get("details") or {}).get("available") - avail_str = f"\nValid options: {available}" if available else "" - raise BOSError( - f"Validation failed on {path} (422).\n" - f"Server said: {msg}{avail_str}" - ) from None - - # 429 — rate limited. Retry honouring Retry-After. - if e.code == 429 and _attempt < DEFAULT_RETRIES_ON_429: - retry_after = e.headers.get("Retry-After") if hasattr(e, "headers") else None - wait = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** _attempt - time.sleep(min(wait, 30)) - return _request(method, path, params=params, body=body, - timeout=timeout, extra_headers=extra_headers, - _attempt=_attempt + 1) - if e.code == 429: - raise BOSError( - f"Rate-limited by the TrustPager API after {DEFAULT_RETRIES_ON_429} retries.\n" - f"Slow down the request rate or contact support to raise your limit." - ) from None - - # 5xx — server error. Retry a couple of times then give up. - if 500 <= e.code < 600: - if _attempt < DEFAULT_RETRIES_ON_5XX: - time.sleep(2 ** _attempt) - return _request(method, path, params=params, body=body, - timeout=timeout, extra_headers=extra_headers, - _attempt=_attempt + 1) - raise BOSError( - f"TrustPager API returned a server error ({e.code}) after " - f"{DEFAULT_RETRIES_ON_5XX} retries.\n" - f"This is usually temporary. {docs_hint}\n" - f"Server said: {detail_str}" - ) from None - - # Catch-all - raise BOSError(f"HTTP {e.code} on {path}. {docs_hint}\nServer said: {detail_str}") from None - except urllib.error.URLError as e: - raise BOSError( - f"Could not reach the TrustPager API.\n" - f"Check your internet connection. Underlying error: {e.reason}" - ) from None - except (json.JSONDecodeError, OSError) as e: - raise BOSError(f"Unexpected response from {path}: {e}") from None - - -# ============================================================================= -# Write journal — every write BOS issues is appended to ~/.claude/bos-journal. -# Reads are never journaled. Best-effort: journaling failures never break the -# write. Disable with BOS_JOURNAL=0. The reader CLI is tools/journal.py. -# ============================================================================= - - -def _record_write(method: str, path: str, body: dict[str, Any] | None, *, - status: str, result_id: str | None = None, - approval_id: str | None = None, error: str | None = None) -> None: - """Append one write-attempt line to today's journal file. Never raises.""" - if os.environ.get("BOS_JOURNAL", "1").strip().lower() in {"0", "false", "no", "off"}: - return - try: - JOURNAL_DIR.mkdir(parents=True, exist_ok=True) - body_summary: str | None = None - if body is not None: - try: - body_summary = json.dumps(body, default=str)[:1000] - except (TypeError, ValueError): - body_summary = str(body)[:1000] - # Redact any API key token before it touches disk — a write body should - # never carry a key, but if one ever did, it must not land in the journal. - entry = { - "ts": now_utc().isoformat(), - "method": method, - "path": path, - "status": status, - "result_id": result_id, - "approval_id": approval_id, - "error": _redact(error[:300]) if error else None, - "body_summary": _redact(body_summary), - } - day = now_utc().strftime("%Y-%m-%d") - with (JOURNAL_DIR / f"{day}.jsonl").open("a", encoding="utf-8") as fh: - fh.write(json.dumps(entry, default=str) + "\n") - except Exception: # noqa: BLE001 — journaling must never break a real write - pass - - -def _journaled(method: str, path: str, body: dict[str, Any] | None, - fn: Callable[[], Any]) -> dict[str, Any] | ApprovalPending: - """Run a write callable, journal the outcome (ok / approval_pending / error).""" - try: - result = fn() - except BOSError as e: - _record_write(method, path, body, status="error", error=str(e)) - raise - if isinstance(result, ApprovalPending): - _record_write(method, path, body, status="approval_pending", - approval_id=result.approval_id) - else: - result_id = None - if isinstance(result, dict): - data = result.get("data", result) - if isinstance(data, dict): - result_id = data.get("id") - _record_write(method, path, body, status="ok", result_id=result_id) - return result - - -def api_get(path: str, **params: Any) -> dict[str, Any]: - """GET a path. Query parameters passed as kwargs. - - Example: - opportunities = api_get("opportunities", limit=100, stage="qualified") - """ - return _request("GET", path, params=params) - - -def api_post(path: str, body: dict[str, Any] | None = None, **params: Any) -> dict[str, Any] | ApprovalPending: - """POST to a path. Body is the JSON payload; kwargs are query params. - - Returns ApprovalPending on 202 (action queued for human approval) — check - `isinstance(result, ApprovalPending)` if your skill needs to handle that path. - - Every call is recorded to the write journal (~/.claude/bos-journal). - """ - return _journaled("POST", path, body, lambda: _request("POST", path, params=params, body=body)) - - -def api_patch(path: str, body: dict[str, Any] | None = None, **params: Any) -> dict[str, Any] | ApprovalPending: - """PATCH a path. For updates. Returns ApprovalPending on 202. - - Every call is recorded to the write journal (~/.claude/bos-journal). - """ - return _journaled("PATCH", path, body, lambda: _request("PATCH", path, params=params, body=body)) - - -def idempotent_post(path: str, body: dict[str, Any] | None = None, - idempotency_key: str | None = None, - **params: Any) -> dict[str, Any] | ApprovalPending: - """POST with an Idempotency-Key header to prevent duplicate writes on retry. - - The key defaults to a deterministic SHA-256 hash of the request body. This - means: same body -> same key -> the server dedupes if you hit a transient - network error and the caller retries. Pass `idempotency_key=` explicitly - to override (e.g. when retrying intentionally with a new key). - - Use for any write where a duplicate would be a problem: sending email, - creating an opportunity, charging a customer, firing an automation. - """ - if idempotency_key is None: - # Deterministic key from body so a retry of the same payload dedupes - body_bytes = json.dumps(body or {}, sort_keys=True).encode("utf-8") - idempotency_key = "bos-" + hashlib.sha256(body_bytes).hexdigest()[:24] - return _journaled("POST", path, body, lambda: _request( - "POST", path, params=params, body=body, - extra_headers={"Idempotency-Key": idempotency_key})) - - -def parallel_get(calls: list[tuple[str, dict[str, Any]]], - max_workers: int = DEFAULT_PARALLEL_WORKERS) -> dict[str, dict[str, Any]]: - """Fan out multiple GET requests in parallel. - - Args: - calls: list of (path, params_dict) tuples. The path is also the result key. - max_workers: parallel HTTP threads (default 8). - - Returns: - Dict mapping each path to its response (or an `{"error": "..."}` dict - on failure). Never raises — failures land per-key in the result. - - Example: - results = parallel_get([ - ("opportunities", {"limit": 100}), - ("tasks", {"completed": "false"}), - ("bookings", {"date_from": "today"}), - ]) - opps = results["opportunities"].get("data", []) - """ - out: dict[str, dict[str, Any]] = {} - with ThreadPoolExecutor(max_workers=max_workers) as pool: - future_to_path = { - pool.submit(api_get, path, **params): path - for path, params in calls - } - for future in as_completed(future_to_path): - path = future_to_path[future] - try: - out[path] = future.result() - except BOSError as e: - out[path] = {"error": str(e)} - except Exception as e: # noqa: BLE001 — last-resort safety net - out[path] = {"error": f"Unexpected error on {path}: {e}"} - return out - - -def paginate(path: str, max_pages: int | None = None, - **params: Any) -> Iterator[dict[str, Any]]: - """Yield every row across every page of a list endpoint. - - Auto-follows pagination.next_cursor until has_more is false (or max_pages - is reached, if set). Use when you need ALL records, not just the first - page. Default API limit is 25, max 100 — pass limit=100 to minimise calls. - - Example: - all_opps = list(paginate("opportunities", limit=100)) - for contact in paginate("contacts", limit=100, source="referral"): - do_something(contact) - - The path-param `after` is reserved for the cursor — don't pass it manually. - """ - cursor: str | None = None - pages = 0 - while True: - call_params = dict(params) - if cursor: - call_params["after"] = cursor - response = api_get(path, **call_params) - rows = response.get("data", []) if isinstance(response, dict) else [] - for row in rows: - yield row - pages += 1 - if max_pages is not None and pages >= max_pages: - return - pagination = response.get("pagination", {}) if isinstance(response, dict) else {} - if not pagination.get("has_more"): - return - cursor = pagination.get("next_cursor") - if not cursor: - return - - -def bulk_apply(write_fn: Callable[[Any], Any], items: list[Any], - parallelism: int = 4, - on_error: str = "collect", - progress: Callable[[int, int, str], None] | None = None - ) -> dict[str, Any]: - """Apply a write function across many items with progress + error aggregation. - - Args: - write_fn: callable taking a single item, returning anything - items: list of inputs to write_fn - parallelism: concurrent writes (default 4 — keep low to avoid 429s) - on_error: 'collect' (default) accumulates errors and continues - 'raise' raises on first failure - progress: optional callback(completed, total, item_summary) for logging - - Returns: - { - "total": N, - "succeeded": [{"item": ..., "result": ...}, ...], - "failed": [{"item": ..., "error": "..."}, ...], - "queued": [{"item": ..., "approval_id": "..."}, ...], # 202 responses - } - - Use for any bulk write — bulk send emails, bulk-update opportunities, - bulk-create contacts. Pair with idempotent_post for safe retries. - """ - succeeded: list[dict[str, Any]] = [] - failed: list[dict[str, Any]] = [] - queued: list[dict[str, Any]] = [] - total = len(items) - completed = 0 - - with ThreadPoolExecutor(max_workers=parallelism) as pool: - futures = {pool.submit(write_fn, item): item for item in items} - for future in as_completed(futures): - item = futures[future] - completed += 1 - try: - result = future.result() - if isinstance(result, ApprovalPending): - queued.append({"item": item, "approval_id": result.approval_id}) - else: - succeeded.append({"item": item, "result": result}) - except BOSError as e: - if on_error == "raise": - raise - failed.append({"item": item, "error": str(e)}) - except Exception as e: # noqa: BLE001 - if on_error == "raise": - raise - failed.append({"item": item, "error": f"Unexpected: {e}"}) - if progress: - summary = str(item)[:60] - progress(completed, total, summary) - - return { - "total": total, - "succeeded": succeeded, - "failed": failed, - "queued": queued, - } - - -# ============================================================================= -# Catalog — fetch + cache the public API endpoint index from docs.trustpager.com -# ============================================================================= - -_catalog_cache: dict[str, Any] | None = None # in-process cache to avoid re-reading the file - - -def _catalog_is_fresh(path: Path, ttl_seconds: int) -> bool: - if not path.exists(): - return False - try: - age = (datetime.now().timestamp() - path.stat().st_mtime) - return age < ttl_seconds - except OSError: - return False - - -def _fetch_catalog_live() -> dict[str, Any]: - """Download api-index.json from docs.trustpager.com. No auth needed. - - NOT gated by BOS_OFFLINE: the catalog is public and unauthenticated, so - fetching it can't leak a key. BOS_OFFLINE only blocks `_request` — the - authenticated path that reads the API key. - """ - req = urllib.request.Request( - CATALOG_URL, - headers={"Accept": "application/json", "User-Agent": "BusinessOperatingSystem/1.0"}, - ) - with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT_SECONDS) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def get_catalog(force_refresh: bool = False) -> dict[str, Any]: - """Return the TrustPager API endpoint catalog. - - Order of operations: - 1. In-process memo (free) - 2. ~/.claude/bos-cache/api-index.json if younger than 24h - 3. Fetch from docs.trustpager.com, update cache - 4. If fetch fails, fall back to whatever's on disk (any age) - 5. If nothing is on disk, raise BOSError - - The catalog is public and unauthenticated — no API key needed. - """ - global _catalog_cache - if _catalog_cache is not None and not force_refresh: - return _catalog_cache - - # Try fresh local cache - if not force_refresh and _catalog_is_fresh(CATALOG_CACHE_PATH, CATALOG_TTL_SECONDS): - try: - _catalog_cache = json.loads(CATALOG_CACHE_PATH.read_text(encoding="utf-8")) - return _catalog_cache - except (json.JSONDecodeError, OSError): - pass # corrupt cache — fall through to refetch - - # Fetch live, write cache - try: - catalog = _fetch_catalog_live() - try: - CATALOG_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - CATALOG_CACHE_PATH.write_text(json.dumps(catalog), encoding="utf-8") - except OSError: - pass # cache write failures aren't fatal — we still have the catalog in memory - _catalog_cache = catalog - return catalog - except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as e: - # Fall back to any cached copy, regardless of age - if CATALOG_CACHE_PATH.exists(): - try: - _catalog_cache = json.loads(CATALOG_CACHE_PATH.read_text(encoding="utf-8")) - return _catalog_cache - except (json.JSONDecodeError, OSError): - pass - raise BOSError( - f"Could not fetch the TrustPager API catalog from {CATALOG_URL}.\n" - f"Underlying error: {e}\n" - f"No cached copy is available. Check your internet connection." - ) from None - - -def resolve_path(resource_id: str, method: str = "GET", - action: str = "list", - path_contains: str | None = None) -> str: - """Resolve a canonical API path from the public catalog. - - Args: - resource_id: catalog resource id, e.g. "opportunities", "scheduling" - method: HTTP method (default GET) - action: one of "list" (simplest path, no params), "get" (path with one - :id segment), "create" (POST root path), or "search" (POST - with /search suffix). Default "list". - path_contains: required when a resource has multiple sub-resources - of the same action shape. e.g. "scheduling" has GET - /scheduling/bookings AND GET /scheduling/availability — call - with path_contains="bookings" to disambiguate. - - Returns: - The API path WITHOUT the base URL or leading slash. Example: - resolve_path("scheduling", "GET", "list", path_contains="bookings") - -> "scheduling/bookings" - resolve_path("email", "GET", "list", path_contains="threads") - -> "email/threads" - - Path overrides (PATH_OVERRIDES) are applied AFTER catalog lookup. They - exist to work around the known docs bug where multi-segment scheduling - patterns are flattened to dashed strings (see PATH_OVERRIDES comment). - """ - catalog = get_catalog() - resource = next((r for r in catalog.get("resources", []) - if r.get("id") == resource_id), None) - - # ---- Cross-catalog bridge (delete ~48h post-cutover) --------------------- - # The upstream docs fix consolidates 3 dashed scheduling resources - # (scheduling-bookings, scheduling-availability, scheduling-event-types) - # under a single "scheduling" parent. Callers should already be using the - # new shape: resolve_path("scheduling", path_contains="bookings"). - # During the cutover window (DNS + 24h cache TTL) some clients still see - # the legacy catalog where "scheduling" doesn't exist as a resource_id. - # Fall back to the dashed legacy id so the same call works in both worlds. - # TODO(post-cutover): drop this block + PATH_OVERRIDES dict. - if not resource and path_contains: - legacy_id = f"{resource_id}-{path_contains}" - resource = next((r for r in catalog.get("resources", []) - if r.get("id") == legacy_id), None) - if resource: - resource_id = legacy_id # keep downstream messages honest - # -------------------------------------------------------------------------- - - if not resource: - raise BOSError( - f"Unknown resource '{resource_id}'. Check the catalog at {CATALOG_URL}." - ) - - # Filter endpoints by method - candidates = [ep for ep in resource.get("endpoints", []) - if ep.get("method") == method] - if not candidates: - raise BOSError( - f"No {method} endpoint on resource '{resource_id}'." - ) - - # Narrow by action shape - if action == "list": - # Simplest GET: no :params, doesn't end in /search - candidates = [c for c in candidates - if ":" not in c.get("path", "") - and not c.get("path", "").endswith("/search")] - elif action == "get": - candidates = [c for c in candidates - if c.get("path", "").count(":") == 1 - and not c.get("path", "").endswith("/search")] - elif action == "create": - candidates = [c for c in candidates - if ":" not in c.get("path", "") - and not c.get("path", "").endswith("/search")] - elif action == "search": - candidates = [c for c in candidates - if c.get("path", "").endswith("/search")] - else: - raise BOSError(f"Unknown action '{action}'. Use list, get, create, or search.") - - # Narrow by sub-resource hint - if path_contains: - candidates = [c for c in candidates if path_contains in c.get("path", "")] - - if not candidates: - raise BOSError( - f"No {method} endpoint on '{resource_id}' matches action '{action}'" - + (f" with path containing '{path_contains}'" if path_contains else "") - + "." - ) - - # If multiple candidates and no path_contains hint, prefer the natural root: - # - For "list": / (e.g. /tasks vs /tasks/categories) - # - For "get": //: (e.g. /contacts/:id vs /contacts/:id/deals) - # - For "create": / (same as list) - # If exactly one candidate matches that shape, use it. - if len(candidates) > 1 and not path_contains: - natural_root = "/" + resource_id - if action in ("list", "create"): - root_match = [c for c in candidates if c.get("path") == natural_root] - elif action == "get": - # The shortest single-segment-after-root :param path - # e.g. /contacts/:contact_id not /contacts/:contact_id/deals - root_match = [c for c in candidates - if c.get("path", "").startswith(natural_root + "/:") - and c.get("path", "").count("/") == 2] - else: - root_match = candidates - if len(root_match) == 1: - candidates = root_match - - if len(candidates) > 1: - paths = [c.get("path", "") for c in candidates] - raise BOSError( - f"Ambiguous: {len(candidates)} {method} endpoints on '{resource_id}' " - f"match action '{action}': {paths}. Pass path_contains='' " - f"to disambiguate." - ) - - ep = candidates[0] - raw_path = ep.get("path", "").lstrip("/") - - # Apply known docs-bug overrides (see PATH_OVERRIDES comment up top). - # Strip any trailing segments past the override key so /scheduling-bookings/:id - # becomes /scheduling/bookings/:id. - for bad, good in PATH_OVERRIDES.items(): - if raw_path == bad: - return good - if raw_path.startswith(bad + "/"): - return good + raw_path[len(bad):] - - return raw_path - - -def inspect_endpoint(resource_id: str, method: str = "GET", - action: str = "list", - path_contains: str | None = None) -> dict[str, Any]: - """Return the full catalog entry for an endpoint — schema, scopes, doc URL. - - Use this when you're debugging a fetch script and need to know "what - params does this take?" without leaving Python. Returns: - - { - "resource_id": "...", "method": "...", "path": "...", - "scopes": [...], "is_write": bool, - "params": [{"name": ..., "in": ..., "type": ..., "required": ...}], - "description": "...", - "doc_url": "https://docs.trustpager.com/api/.../...md" - } - - Raises BOSError if the endpoint can't be resolved unambiguously — pass - `path_contains=` to disambiguate the same way resolve_path does. - """ - catalog = get_catalog() - resource = next((r for r in catalog.get("resources", []) - if r.get("id") == resource_id), None) - if not resource: - raise BOSError(f"Unknown resource '{resource_id}'.") - - # Same selection logic as resolve_path - candidates = [ep for ep in resource.get("endpoints", []) - if ep.get("method") == method] - if action == "list": - candidates = [c for c in candidates - if ":" not in c.get("path", "") - and not c.get("path", "").endswith("/search")] - elif action == "get": - candidates = [c for c in candidates - if c.get("path", "").count(":") == 1 - and not c.get("path", "").endswith("/search")] - elif action == "create": - candidates = [c for c in candidates - if ":" not in c.get("path", "") - and not c.get("path", "").endswith("/search")] - elif action == "search": - candidates = [c for c in candidates - if c.get("path", "").endswith("/search")] - if path_contains: - candidates = [c for c in candidates if path_contains in c.get("path", "")] - if len(candidates) > 1 and not path_contains: - natural_root = "/" + resource_id - if action in ("list", "create"): - root_match = [c for c in candidates if c.get("path") == natural_root] - elif action == "get": - root_match = [c for c in candidates - if c.get("path", "").startswith(natural_root + "/:") - and c.get("path", "").count("/") == 2] - else: - root_match = candidates - if len(root_match) == 1: - candidates = root_match - - if not candidates: - raise BOSError( - f"No matching {method} endpoint on '{resource_id}'." - ) - if len(candidates) > 1: - paths = [c.get("path", "") for c in candidates] - raise BOSError( - f"Ambiguous: {len(candidates)} candidates: {paths}. Pass path_contains." - ) - - ep = candidates[0] - return { - "resource_id": resource_id, - "resource_label": resource.get("label"), - "method": ep.get("method"), - "path": ep.get("path"), - "description": ep.get("description"), - "scopes": ep.get("scopes", []), - "is_write": ep.get("is_write", False), - "params": ep.get("params", []), - "doc_url": ep.get("doc_url"), - } - - -def api_call_by_resource(resource_id: str, method: str = "GET", - action: str = "list", **params: Any) -> dict[str, Any]: - """Resolve the path from the catalog then issue the request. One call helper.""" - path = resolve_path(resource_id, method, action) - if method == "GET": - return api_get(path, **params) - if method == "POST": - body = params.pop("body", None) - return api_post(path, body=body, **params) - if method == "PATCH": - body = params.pop("body", None) - return api_patch(path, body=body, **params) - raise BOSError(f"Unsupported method: {method}") - - -# ============================================================================= -# Date helpers — shared parsing for ISO timestamps and date-only strings -# ============================================================================= -# -# The TrustPager API returns dates in three shapes: -# - Full ISO timestamp with tz: "2026-05-29T07:31:26.165+00:00" -# - ISO timestamp with Z: "2026-05-29T07:31:26Z" -# - Date-only: "2026-05-29" (assumed midnight UTC) -# All three are normalised to tz-aware datetimes so comparisons against -# `now_utc()` work without TypeError. -# ============================================================================= - - -def now_utc() -> datetime: - """Current time in UTC, tz-aware.""" - return datetime.now(timezone.utc) - - -def parse_iso(s: str | None) -> datetime | None: - """Parse an ISO-8601 timestamp or date string into a tz-aware datetime. - - Returns None on falsy input or parse failure. - Naive timestamps (no tz) are treated as UTC. - """ - if not s: - return None - try: - if len(s) == 10 and s[4] == "-" and s[7] == "-": - return datetime.fromisoformat(s).replace(tzinfo=timezone.utc) - dt = datetime.fromisoformat(s.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - except (ValueError, AttributeError): - return None - - -def days_since(ts: datetime | None, ref: datetime | None = None) -> int | None: - """Whole days between `ts` and `ref` (default: now). Returns None if ts is None.""" - if ts is None: - return None - ref = ref or now_utc() - return max(0, int((ref - ts).total_seconds() // 86400)) - - -# ============================================================================= -# Digest helpers — common shapes for summarising lists of records -# ============================================================================= - - -def group_count(items: list[dict[str, Any]], key: str, - missing: str = "(none)") -> dict[str, int]: - """Count items grouped by a key, returned sorted by count descending. - - Example: - group_count(opportunities, "lead_source") - # -> {"Facebook": 18, "Referral": 6, "(none)": 4, ...} - """ - out: dict[str, int] = {} - for it in items: - k = it.get(key) or missing - out[k] = out.get(k, 0) + 1 - return dict(sorted(out.items(), key=lambda kv: kv[1], reverse=True)) - - -def top_n_by(items: list[dict[str, Any]], key: str, n: int = 5, - reverse: bool = True) -> list[dict[str, Any]]: - """Return the top-N items sorted by a field. - - Args: - items: list of dicts - key: field to sort by — supports dot-notation for nested fields, - e.g. "contact.email" - n: how many to return - reverse: True (default) = descending; False = ascending - """ - def keyfn(it: dict[str, Any]) -> Any: - val: Any = it - for part in key.split("."): - if not isinstance(val, dict): - return 0 - val = val.get(part) - # Coerce numeric strings, None, etc. so sort doesn't crash - if val is None: - return float("-inf") if reverse else float("inf") - if isinstance(val, (int, float)): - return val - try: - return float(val) - except (ValueError, TypeError): - return val - return sorted(items, key=keyfn, reverse=reverse)[:n] - - -# ============================================================================= -# Logging — shared `_log` for skill scripts (replaces per-skill helpers) -# ============================================================================= - - -def log(prefix: str, msg: str, *, quiet: bool = False) -> None: - """Write a one-line progress message to stderr with a [prefix] tag. - - Skills should use this instead of redefining their own _log function: - - from trustpager_api import log - def _log(msg, *, quiet): log("sweep-my-day", msg, quiet=quiet) - - Or even simpler: - - log("sweep-my-day", "fetching opportunities...", quiet=args.json_only) - """ - if not quiet: - sys.stderr.write(f"[{prefix}] {msg}\n") - sys.stderr.flush() - - -# ============================================================================= -# Output emitters -# ============================================================================= - - -def force_utf8_stdout() -> None: - """Reconfigure stdout (and stderr) to UTF-8 with replace-on-error. - - Windows terminals default to cp1252 which can't encode emojis or many - non-ASCII characters. Any tool that prints emojis to stdout should call - this once at the top of main() so it works cross-platform. - - Safe to call multiple times. No-op on terminals that already speak UTF-8. - """ - for stream in (sys.stdout, sys.stderr): - if hasattr(stream, "reconfigure"): - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except (AttributeError, ValueError): - pass - - -def emit_json(payload: Any) -> None: - """Print JSON to stdout with consistent formatting. - - Used by skill scripts so Claude can parse the output. Uses indent=2 for - human readability when developers are debugging the scripts directly. - - `ensure_ascii=True` is intentional — any non-ASCII character in the - response (emojis in email subjects, smart quotes, etc.) gets escaped to - \\uXXXX so the output is safe to print on any terminal encoding, - including Windows cp1252 stdout. JSON readers (including Claude) decode - the escapes transparently. - """ - json.dump(payload, sys.stdout, indent=2, default=str, ensure_ascii=True) - sys.stdout.write("\n") - - -def emit_error_and_exit(msg: str, code: int = 1) -> None: - """Print a friendly error to stderr (so Claude sees it) and exit non-zero.""" - sys.stderr.write(f"ERROR: {msg}\n") - sys.exit(code)