diff --git a/.gitignore b/.gitignore index dd9c908..cd21873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # Internal state — local dev context, not shipped STATE.md + +# Local-only directories (never committed) +_local/ diff --git a/scripts/soma-browser/soma-browser.sh b/scripts/soma-browser/soma-browser.sh new file mode 100755 index 0000000..09d6166 --- /dev/null +++ b/scripts/soma-browser/soma-browser.sh @@ -0,0 +1,561 @@ +#!/usr/bin/env bash +# soma-browser.sh — Browser automation via CDP bridge +# +# Uses the Somaverse bridge server's CDP API for fast, structured browser control. +# Two browser targets: +# DEFAULT (agent): Brave Beta on port 9333 (via bridge at localhost:5311) +# --personal: Personal Chrome on port 9222 (direct CDP, your logins/cookies) +# +# Usage: +# soma-browser xray [selector] # structured DOM walk — THE go-to for understanding pages +# soma-browser a11y [--roles btn,link] # accessibility tree — interactive elements +# soma-browser tabs # list open tabs +# soma-browser open # open URL in active tab +# soma-browser text [selector] # get text content +# soma-browser eval '' # run JavaScript +# soma-browser shot [path] # screenshot to file +# soma-browser styles # CSS inspection +# soma-browser perf # performance metrics +# soma-browser emulate # device emulation (mobile, tablet, desktop) +# soma-browser read [url] # extract clean markdown content +# soma-browser search # google search, return results +# soma-browser click # click an element +# soma-browser fill # fill an input +# soma-browser wait [timeout] # wait for element to appear +# soma-browser console [--errors] # read console output +# soma-browser status # connection check +# +# Tab targeting (works with most commands): +# --tab= target by tab ID +# --url= target by URL match +# --title= target by title match +# +# Setup: +# Agent browser: ./scripts/launch-browser.sh (Brave Beta, port 9333) +# Bridge server: pnpm bridge (port 5311) +# Personal: Launch Chrome with --remote-debugging-port=9222 +# +# BSL 1.1 © Curtis Mercier + +set -euo pipefail + +BRIDGE_URL="${BRIDGE_URL:-http://localhost:5311}" +CDP_DIRECT_PORT=9222 +MODE="agent" + +# ── Tab targeting ── +TAB_ID="" +TAB_URL="" +TAB_TITLE="" + +# ── Parse global flags ── +ARGS=() +for arg in "$@"; do + case "$arg" in + --personal|--chrome) MODE="personal" ;; + --tab=*) TAB_ID="${arg#--tab=}" ;; + --url=*) TAB_URL="${arg#--url=}" ;; + --title=*) TAB_TITLE="${arg#--title=}" ;; + *) ARGS+=("$arg") ;; + esac +done +set -- "${ARGS[@]+"${ARGS[@]}"}" + +CMD="${1:-status}" +shift 2>/dev/null || true + +# ── Colors ── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m' +CYAN='\033[0;36m'; DIM='\033[2m'; BOLD='\033[1m'; NC='\033[0m' + +# ── JSON builder (safe, no shell interpolation into JS) ── +# Usage: build_json key1 val1 key2 val2 ... +build_json() { + python3 -c " +import json, sys +d = {} +args = sys.argv[1:] +i = 0 +while i < len(args): + k, v = args[i], args[i+1] + # Auto-detect types + if v == 'true': d[k] = True + elif v == 'false': d[k] = False + elif v.isdigit(): d[k] = int(v) + else: + try: d[k] = float(v) + except ValueError: d[k] = v + i += 2 +print(json.dumps(d)) +" "$@" +} + +# Build an evaluate request body with safe JS expression +build_eval() { + python3 -c "import json,sys; print(json.dumps({'expression': sys.argv[1]}))" "$1" +} + +# ── API helpers ── + +# Add tab targeting to JSON body +tab_params() { + local json="$1" + if [[ -n "$TAB_ID" || -n "$TAB_URL" || -n "$TAB_TITLE" ]]; then + json=$(python3 -c " +import json, sys +d = json.loads(sys.argv[1]) +if sys.argv[2]: d['tabId'] = sys.argv[2] +if sys.argv[3]: d['url'] = sys.argv[3] +if sys.argv[4]: d['title'] = sys.argv[4] +print(json.dumps(d)) +" "$json" "$TAB_ID" "$TAB_URL" "$TAB_TITLE") + fi + echo "$json" +} + +# POST to bridge API +bridge_post() { + local endpoint="$1" + local body="$2" + body=$(tab_params "$body") + curl -s --max-time 15 -X POST "$BRIDGE_URL/api/browser/$endpoint" \ + -H "Content-Type: application/json" \ + -d "$body" +} + +# GET from bridge API +bridge_get() { + local endpoint="$1" + curl -s --max-time 10 "$BRIDGE_URL/api/browser/$endpoint" +} + +# Route to bridge (personal mode TODO) +api_post() { + if [[ "$MODE" == "personal" ]]; then + echo -e "${RED}✗ Direct CDP mode not yet implemented${NC}" >&2 + echo -e "${DIM} Use bridge mode (default) or start the bridge server${NC}" >&2 + return 1 + fi + bridge_post "$@" +} + +# Check if bridge is up +bridge_up() { + curl -s --max-time 2 "$BRIDGE_URL/api/browser/status" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('available') else 1)" 2>/dev/null +} + +# Extract result from evaluate response +print_eval_result() { + python3 -c " +import json, sys +d = json.load(sys.stdin) +if d.get('error'): + print(f'\\033[0;31m✗ {d[\"error\"]}\\033[0m', file=sys.stderr) + sys.exit(1) +r = d.get('result', '') +if isinstance(r, str): + print(r) +elif r is not None: + print(json.dumps(r, indent=2)) +" +} + +# ── Commands ── +case "$CMD" in + + status) + echo -e "${BOLD}σ soma-browser${NC}" + echo "" + if bridge_up; then + _tabs=$(bridge_get "tabs" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('tabs',[])))" 2>/dev/null || echo "?") + echo -e " agent browser: ${GREEN}✓ connected via bridge${NC} ${DIM}($_tabs tabs)${NC}" + else + echo -e " agent browser: ${YELLOW}○ bridge not running${NC} ${DIM}(pnpm bridge)${NC}" + fi + if curl -s --max-time 2 "http://localhost:$CDP_DIRECT_PORT/json/version" >/dev/null 2>&1; then + _ptabs=$(curl -s "http://localhost:$CDP_DIRECT_PORT/json" | python3 -c "import json,sys; print(len([t for t in json.load(sys.stdin) if t.get('type')=='page']))" 2>/dev/null || echo "?") + echo -e " personal: ${GREEN}✓ Chrome on :$CDP_DIRECT_PORT${NC} ${DIM}($_ptabs tabs)${NC}" + else + echo -e " personal: ${DIM}○ not running${NC}" + fi + ;; + + # ── XRay: structured DOM walk ────────────────────────────────────────── + xray|x) + SELECTOR="${1:-body}" + MAX_ELS="${2:-120}" + BODY=$(build_json selector "$SELECTOR" maxElements "$MAX_ELS" maxDepth 6) + RESULT=$(api_post "xray" "$BODY") + echo "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +if d.get('ok'): + print(d.get('rendered', '')) + print(f'\\n\\033[2m{d.get(\"elementCount\", 0)} elements\\033[0m') +else: + print(f'\\033[0;31m✗ {d.get(\"error\", \"unknown error\")}\\033[0m', file=sys.stderr) + sys.exit(1) +" + ;; + + # ── Accessibility tree ───────────────────────────────────────────────── + a11y|accessibility) + MAX_NODES=200 + ROLES="" + for arg in "$@"; do + case "$arg" in + --roles=*) ROLES="${arg#--roles=}" ;; + --max=*) MAX_NODES="${arg#--max=}" ;; + esac + done + BODY=$(python3 -c " +import json, sys +d = {'maxNodes': int(sys.argv[1])} +roles = sys.argv[2] +if roles: + d['roles'] = roles.split(',') +print(json.dumps(d)) +" "$MAX_NODES" "$ROLES") + RESULT=$(api_post "accessibility" "$BODY") + echo "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +nodes = d.get('nodes', []) +for n in nodes: + role = n.get('role', '') + name = n.get('name', '') + val = n.get('value', '') + desc = n.get('description', '') + line = f' [{role}] {name}' + if val: line += f' = {val}' + if desc: line += f' ({desc})' + print(line) +print(f'\\n\\033[2m{len(nodes)} nodes\\033[0m') +" + ;; + + # ── Tabs ─────────────────────────────────────────────────────────────── + tabs) + RESULT=$(bridge_get "tabs") + echo "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +tabs = d.get('tabs', []) +for i, t in enumerate(tabs): + title = t.get('title', '')[:50] + url = t.get('url', '')[:80] + tid = t.get('id', '')[:8] + print(f' {i+1}. [{tid}] {title}') + print(f' {url}') +print(f'\\n\\033[2m{len(tabs)} tabs\\033[0m') +" + ;; + + # ── Navigate ─────────────────────────────────────────────────────────── + open|nav|goto|navigate) + URL="${1:?Usage: soma-browser open }" + BODY=$(build_json targetUrl "$URL") + api_post "navigate" "$BODY" >/dev/null + sleep 1.5 + echo -e "${GREEN}✓${NC} Navigated to: $URL" + XBODY=$(build_json selector body maxElements 60 maxDepth 4) + api_post "xray" "$XBODY" | python3 -c " +import json, sys +d = json.load(sys.stdin) +if d.get('ok'): + print(d.get('rendered', '')) +" 2>/dev/null + ;; + + # ── Text extraction ──────────────────────────────────────────────────── + text) + SELECTOR="${1:-body}" + JS=$(python3 -c "import json,sys; s=sys.argv[1]; print('document.querySelector('+json.dumps(s)+')?.innerText?.substring(0,15000)')" "$SELECTOR") + BODY=$(build_eval "$JS") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── JavaScript evaluation ────────────────────────────────────────────── + eval|js) + JS="${1:?Usage: soma-browser eval ''}" + BODY=$(build_eval "$JS") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── Screenshot ───────────────────────────────────────────────────────── + shot|screenshot) + OUT="${1:-/tmp/soma-screenshot.png}" + FORMAT="png" + [[ "$OUT" == *.jpg || "$OUT" == *.jpeg ]] && FORMAT="jpeg" + [[ "$OUT" == *.webp ]] && FORMAT="webp" + FULLPAGE="false" + for arg in "$@"; do + [[ "$arg" == "--full" ]] && FULLPAGE="true" + done + BODY=$(build_json format "$FORMAT" quality 80 fullPage "$FULLPAGE") + RESULT=$(api_post "screenshot" "$BODY") + echo "$RESULT" | python3 -c " +import json, sys, base64 +d = json.load(sys.stdin) +data = d.get('data', '') +if data: + out = sys.argv[1] + with open(out, 'wb') as f: + f.write(base64.b64decode(data)) + w, h = d.get('width', 0), d.get('height', 0) + print(f'✓ Screenshot: {out} ({w}x{h})') +else: + print(f'✗ {d.get(\"error\", \"no data\")}', file=sys.stderr) + sys.exit(1) +" "$OUT" + ;; + + # ── Click ────────────────────────────────────────────────────────────── + click) + SEL="${1:?Usage: soma-browser click }" + JS=$(python3 -c " +import json, sys +s = sys.argv[1] +print('(()=>{const el=document.querySelector('+json.dumps(s)+');if(!el)return \"not found\";el.click();return \"clicked: \"+el.tagName+\" \"+el.textContent.trim().substring(0,40)})()') +" "$SEL") + BODY=$(build_eval "$JS") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── Fill input ───────────────────────────────────────────────────────── + fill) + SEL="${1:?Usage: soma-browser fill }" + TEXT="${2:-}" + JS=$(python3 -c " +import json, sys +s, v = sys.argv[1], sys.argv[2] +print('(()=>{const el=document.querySelector('+json.dumps(s)+');if(!el)return \"not found\";el.focus();el.value='+json.dumps(v)+';el.dispatchEvent(new Event(\"input\",{bubbles:true}));el.dispatchEvent(new Event(\"change\",{bubbles:true}));return \"filled: \"+el.tagName})()') +" "$SEL" "$TEXT") + BODY=$(build_eval "$JS") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── Wait for element ─────────────────────────────────────────────────── + wait) + SEL="${1:?Usage: soma-browser wait [timeout_ms]}" + TIMEOUT="${2:-5000}" + JS=$(python3 -c " +import json, sys +s, t = sys.argv[1], sys.argv[2] +print('new Promise((resolve)=>{const check=()=>{const el=document.querySelector('+json.dumps(s)+');if(el){resolve(\"found: \"+el.tagName+\" \"+el.textContent.trim().substring(0,40));return;}setTimeout(check,200);};setTimeout(()=>resolve(\"timeout after '+t+'ms\"),'+t+');check();})') +" "$SEL" "$TIMEOUT") + BODY=$(python3 -c "import json,sys; print(json.dumps({'expression':sys.argv[1],'awaitPromise':True,'timeout':int(sys.argv[2])+2000}))" "$JS" "$TIMEOUT") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── CSS Styles ───────────────────────────────────────────────────────── + styles) + SEL="${1:?Usage: soma-browser styles }" + BODY=$(build_json selector "$SEL") + RESULT=$(api_post "styles" "$BODY") + echo "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +matched = d.get('matched', []) +if matched: + print('Matched Rules:') + for rule in matched[:10]: + sel = rule.get('selector', '?') + props = rule.get('properties', []) + if props: + print(f' {sel}') + for p in props[:8]: + print(f' {p}') +computed = d.get('computed', {}) +if computed: + print('\\nComputed (key):') + keys = ['display','position','width','height','margin','padding','color', + 'background-color','font-size','font-family','font-weight','border', + 'flex-direction','gap','grid-template-columns','z-index','opacity'] + for k in keys: + if k in computed: + print(f' {k}: {computed[k]}') +" + ;; + + # ── Performance ──────────────────────────────────────────────────────── + perf|performance) + RESULT=$(api_post "performance" '{}') + echo "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +metrics = d.get('metrics', {}) +keys = [ + ('DomContentLoaded', 's'), ('NavigationStart', 's'), + ('Nodes', ''), ('Documents', ''), ('Frames', ''), + ('JSEventListeners', ''), ('LayoutCount', ''), + ('ScriptDuration', 's'), ('TaskDuration', 's'), + ('JSHeapUsedSize', 'MB'), ('JSHeapTotalSize', 'MB'), +] +print('Performance:') +for name, unit in keys: + val = metrics.get(name) + if val is not None: + if unit == 'MB': print(f' {name}: {val/1024/1024:.1f} MB') + elif unit == 's': print(f' {name}: {val:.2f}s') + else: print(f' {name}: {int(val)}') +" + ;; + + # ── Device Emulation ─────────────────────────────────────────────────── + emulate) + PRESET="${1:-desktop}" + case "$PRESET" in + mobile|phone) W=375; H=667; MOBILE="true"; DPR=2 ;; + tablet|ipad) W=768; H=1024; MOBILE="true"; DPR=2 ;; + desktop) W=1920; H=1080; MOBILE="false"; DPR=1 ;; + reset) W=0; H=0; MOBILE="false"; DPR=0 ;; + *x*) + W=$(echo "$PRESET" | cut -dx -f1) + H=$(echo "$PRESET" | cut -dx -f2) + MOBILE="false"; DPR=1 + ;; + *) + echo -e "${RED}Unknown preset: $PRESET${NC}" + echo -e "${DIM} Presets: mobile, tablet, desktop, reset, WxH (e.g. 1440x900)${NC}" + exit 1 + ;; + esac + if [[ "$PRESET" == "reset" ]]; then + BODY=$(build_eval 'window.innerWidth+"x"+window.innerHeight') + api_post "evaluate" "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'Reset to native: {d.get(\"result\",\"\")}')" + else + BODY=$(build_json width "$W" height "$H" deviceScaleFactor "$DPR" mobile "$MOBILE") + api_post "emulate" "$BODY" >/dev/null + echo -e "${GREEN}✓${NC} Emulating: $PRESET (${W}x${H}, DPR=$DPR, mobile=$MOBILE)" + fi + ;; + + # ── Console ──────────────────────────────────────────────────────────── + console|log) + LEVEL="all" + DURATION=3000 + for arg in "$@"; do + case "$arg" in + --errors) LEVEL="error" ;; + --warnings) LEVEL="warning" ;; + --duration=*) DURATION="${arg#--duration=}" ;; + esac + done + BODY=$(build_json level "$LEVEL" duration "$DURATION") + echo -e "${DIM}Listening for ${DURATION}ms...${NC}" + RESULT=$(api_post "console" "$BODY") + echo "$RESULT" | python3 -c " +import json, sys +d = json.load(sys.stdin) +entries = d.get('entries', []) +colors = {'error': '\\033[0;31m', 'warning': '\\033[0;33m', 'log': '', 'info': '\\033[0;36m'} +for e in entries: + level = e.get('level', 'log') + text = e.get('text', '') + c = colors.get(level, '') + print(f'{c}[{level}] {text}\\033[0m') +if not entries: + print(' (no console output)') +print(f'\\n\\033[2m{len(entries)} entries\\033[0m') +" + ;; + + # ── Read: clean content extraction ───────────────────────────────────── + read|content|markdown) + URL="${1:-}" + if [[ -n "$URL" ]]; then + NAVBODY=$(build_json targetUrl "$URL") + api_post "navigate" "$NAVBODY" >/dev/null + sleep 2 + fi + # JS stored in variable, passed via sys.argv to avoid quoting hell + READ_JS='(()=>{ + const skip = new Set(["SCRIPT","STYLE","NOSCRIPT","SVG","NAV","FOOTER","HEADER","ASIDE","IFRAME"]); + function getText(el, depth) { + if (skip.has(el.tagName)) return ""; + if (el.tagName === "IMG") return "[image: " + (el.alt || el.src?.split("/").pop() || "") + "]\n"; + let lines = []; + const tag = el.tagName; + const text = el.childNodes.length === 1 && el.childNodes[0].nodeType === 3 + ? el.childNodes[0].textContent.trim() : null; + if (tag === "H1" && text) lines.push("# " + text); + else if (tag === "H2" && text) lines.push("## " + text); + else if (tag === "H3" && text) lines.push("### " + text); + else if (tag === "H4" && text) lines.push("#### " + text); + else if (tag === "LI" && text) lines.push("- " + text); + else if (tag === "A" && text) lines.push("[" + text + "](" + el.href + ")"); + else if (tag === "PRE" || tag === "CODE") lines.push("```\n" + el.textContent.trim() + "\n```"); + else if (tag === "BLOCKQUOTE") lines.push("> " + el.textContent.trim().substring(0, 200)); + else if (text && (tag === "P" || tag === "SPAN" || tag === "DIV" || tag === "TD" || tag === "TH")) + lines.push(text); + else { + for (const child of el.children) { + const sub = getText(child, depth + 1); + if (sub) lines.push(sub); + } + } + return lines.join("\n"); + } + const main = document.querySelector("main, article, [role=main], .content, #content") || document.body; + const title = document.title; + const url = location.href; + const content = getText(main, 0).replace(/\n{3,}/g, "\n\n").trim(); + return "# " + title + "\n" + url + "\n\n" + content.substring(0, 20000); + })()' + BODY=$(build_eval "$READ_JS") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── Search ───────────────────────────────────────────────────────────── + search) + QUERY="${*:?Usage: soma-browser search }" + ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY") + NAVBODY=$(build_json targetUrl "https://www.google.com/search?q=$ENCODED") + api_post "navigate" "$NAVBODY" >/dev/null + sleep 2 + SEARCH_JS='Array.from(document.querySelectorAll("h3")).map((h,i)=>{const a=h.closest("a");return(i+1)+". "+h.textContent+" | "+(a?a.href:"")}).slice(0,10).join("\n")' + BODY=$(build_eval "$SEARCH_JS") + api_post "evaluate" "$BODY" | print_eval_result + ;; + + # ── Help ─────────────────────────────────────────────────────────────── + help|--help|-h) + echo -e "${BOLD}σ soma-browser${NC} — Browser automation via CDP" + echo "" + echo -e " ${CYAN}Inspect:${NC}" + echo " xray [selector] [max] Structured DOM walk (fast page understanding)" + echo " a11y [--roles=btn,link] Accessibility tree (interactive elements)" + echo " text [selector] Raw text content" + echo " styles CSS rules + computed styles" + echo " console [--errors] Console output (listens 3s)" + echo " perf Performance metrics" + echo "" + echo -e " ${CYAN}Navigate:${NC}" + echo " tabs List open tabs" + echo " open Navigate + xray result" + echo " search Google search results" + echo " read [url] Extract as clean markdown" + echo "" + echo -e " ${CYAN}Interact:${NC}" + echo " click Click element" + echo " fill Fill input field" + echo " eval '' Run JavaScript" + echo " wait [ms] Wait for element" + echo "" + echo -e " ${CYAN}Capture:${NC}" + echo " shot [path] [--full] Screenshot to file" + echo " emulate Device emulation (mobile/tablet/desktop)" + echo "" + echo -e " ${CYAN}Flags:${NC}" + echo " --personal Use personal Chrome (port 9222)" + echo " --tab= Target tab by ID" + echo " --url= Target tab by URL substring" + echo " --title= Target tab by title" + ;; + + *) + echo -e "${RED}Unknown: $CMD${NC} — try ${DIM}soma-browser help${NC}" + exit 1 + ;; +esac diff --git a/scripts/soma-refactor/soma-refactor.sh b/scripts/soma-refactor/soma-refactor.sh new file mode 100755 index 0000000..6e666b4 --- /dev/null +++ b/scripts/soma-refactor/soma-refactor.sh @@ -0,0 +1,993 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════════════ +# soma-refactor.sh — Smart dependency mapper, validator, and refactor helper +# +# MLR breadcrumb (s01-7631fc): +# Use `routes` before touching extensions — it shows phantom signals (documented but never wired). +# Use `verify --file` after editing — it checks imports resolve. +# Use `risk` before renaming — it scores impact across the codebase. +# Curtis corrected you THREE TIMES to use this instead of manual checks. Trust it. +# +# When to use: before any code change, map what references the thing you're +# changing. After changes, verify nothing broke. For large refactors, use +# `scan` → `plan` → execute → `verify` loop. +# +# Fixed s01-414477: string references grep now excludes node_modules, .git, dist/ +# Previously `scan` on broad terms returned 91 files (mostly deps) instead of 4 (our code). +# +# TODO: integrate scan results into test coverage check — "these 4 files reference +# the changed function, do tests/ also reference it?" Would catch the "tests pass on +# old assertions" problem automatically. +# +# Related muscles: incremental-refactor (the behavioral pattern this script mechanizes), +# task-tooling (map tools before coding), precision-edit (read before write) +# Related scripts: soma-verify.sh (ecosystem health), soma-query.sh impact (doc-level refs) +# Related protocols: quality-standards (atomic commits) +# +# Quick guide for new agents: +# `scan --target "X" --scope dir/` — find every reference to X (strings + imports) +# `refs --symbol "fn" --scope .` — find all callers of a function +# `graph dir/` — import dependency graph (circular deps flagged) +# `risk --target "X"` — how risky is changing X? (LOW/MEDIUM/HIGH) +# `duplicates --scope dir/` — find copy-pasted code blocks +# +# Subcommands: +# scan — Map all references to a target (path, symbol, string) +# refs — Find all callers/importers of a function/type +# graph — Build import dependency graph for a directory +# verify — Validate imports, exports, and type compatibility +# duplicates — Find duplicated patterns across files +# tags — Inject/list/clean REFACTOR: comment tags +# plan — Generate a refactor change plan from scan results +# risk — Score risk of changing a target +# +# Usage: +# soma-refactor.sh scan --target "memory/muscles" --scope core/ +# soma-refactor.sh refs --symbol "discoverProtocols" --scope . +# soma-refactor.sh graph core/ +# soma-refactor.sh verify --file core/protocols.ts +# soma-refactor.sh verify --imports core/ +# soma-refactor.sh duplicates --scope core/ --min-lines 5 +# soma-refactor.sh tags --list +# soma-refactor.sh tags --inject "REFACTOR: #12 — wire resolveSomaPath" core/muscles.ts:151 +# soma-refactor.sh plan --from scan-results.md +# soma-refactor.sh risk --target "discoverProtocols" +# +# Language support: TypeScript (primary), JavaScript, Bash, Markdown +# ═══════════════════════════════════════════════════════════════════════════ + +set -euo pipefail + +# ── Theme ── +source "$(dirname "$0")/soma-theme.sh" 2>/dev/null || { + SOMA_BOLD='\033[1m'; SOMA_DIM='\033[2m'; SOMA_NC='\033[0m'; SOMA_CYAN='\033[0;36m' +} +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SOMA_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +# ═══════════════════════════════════════════════════════════════════════════ +# HELPERS +# ═══════════════════════════════════════════════════════════════════════════ + +detect_language() { + local file="$1" + case "$file" in + *.ts|*.tsx) echo "typescript" ;; + *.js|*.jsx|*.mjs|*.cjs) echo "javascript" ;; + *.sh) echo "bash" ;; + *.py) echo "python" ;; + *.md) echo "markdown" ;; + *) echo "unknown" ;; + esac +} + +count_lines() { + wc -l < "$1" | tr -d ' ' +} + +# Extract exports from a TS/JS file +extract_exports() { + local file="$1" + grep -nE "^export (function|const|class|interface|type|enum|async function|default function)" "$file" 2>/dev/null | \ + sed 's/export default function/export function __default__/' | \ + sed -E 's/^([0-9]+):export (function|const|class|interface|type|enum|async function) ([a-zA-Z_][a-zA-Z0-9_]*).*/\1:\2:\3/' || true +} + +# Extract imports from a TS/JS file +extract_imports() { + local file="$1" + grep -nE "^import " "$file" 2>/dev/null | \ + sed -E 's/^([0-9]+):import \{([^}]+)\} from "([^"]+)".*/\1:named:\3:\2/' | \ + sed -E 's/^([0-9]+):import type \{([^}]+)\} from "([^"]+)".*/\1:type:\3:\2/' | \ + sed -E 's/^([0-9]+):import ([a-zA-Z_]+) from "([^"]+)".*/\1:default:\3:\2/' || true +} + +# Find all function call sites for a symbol +find_call_sites() { + local symbol="$1" + local scope="${2:-.}" + local ext="${3:-ts}" + + # Find calls (symbol followed by parenthesis) + grep -rnE "\b${symbol}\s*\(" "$scope" --include="*.$ext" 2>/dev/null | \ + grep -v "^Binary" | \ + grep -v "export function $symbol" | \ + grep -v "export async function $symbol" || true +} + +# ═══════════════════════════════════════════════════════════════════════════ +# SCAN — Map all references to a target +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_scan() { + local target="" + local scope="." + local output="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --target) target="$2"; shift 2 ;; + --scope) scope="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) target="${target:-$1}"; shift ;; + esac + done + + [[ -z "$target" ]] && { echo "Usage: soma-refactor.sh scan --target [--scope ]"; exit 1; } + + echo -e "${BOLD}═══ Scanning for: ${CYAN}$target${RESET} ${DIM}in $scope${RESET}" + echo "" + + # Category 1: String literals containing target + echo -e "${BLUE}── String References ──${RESET}" + local string_hits + # Exclude node_modules, .git, dist/ from string search + string_hits=$(grep -rnc "$target" "$scope" --include="*.ts" --include="*.js" --include="*.sh" --include="*.md" --include="*.json" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist 2>/dev/null | grep -v ":0$" | sort -t: -k2 -nr || true) + local string_count + string_count=$(echo "$string_hits" | grep -c ":" 2>/dev/null || true) + [[ -z "$string_count" ]] && string_count=0 + echo -e " ${string_count} files contain ${CYAN}\"$target\"${RESET}" + if [[ -n "$string_hits" ]]; then + echo "$string_hits" | head -20 | while IFS=: read -r file count; do + echo -e " ${DIM}$file${RESET} (${count} hits)" + done + [[ $(echo "$string_hits" | wc -l) -gt 20 ]] && echo -e " ${DIM}... and more${RESET}" + fi + echo "" + + # Category 2: Import references (TS/JS) + echo -e "${BLUE}── Import References ──${RESET}" + local import_hits + import_hits=$(grep -rn "from.*[\"'].*$target" "$scope" --include="*.ts" --include="*.js" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist 2>/dev/null || true) + local import_count=0 + [[ -n "$import_hits" ]] && import_count=$(echo "$import_hits" | wc -l | tr -d ' ') + echo -e " ${import_count} import statements" + if [[ -n "$import_hits" && "$import_count" -gt 0 ]]; then + echo "$import_hits" | head -10 | while read -r line; do + echo -e " ${DIM}$line${RESET}" + done + fi + echo "" + + # Category 3: Function calls (if target looks like a symbol) + if [[ "$target" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + echo -e "${BLUE}── Call Sites ──${RESET}" + local call_hits + call_hits=$(find_call_sites "$target" "$scope") + local call_count=0 + [[ -n "$call_hits" ]] && call_count=$(echo "$call_hits" | wc -l | tr -d ' ') + echo -e " ${call_count} call sites" + if [[ -n "$call_hits" && "$call_count" -gt 0 ]]; then + echo "$call_hits" | head -15 | while read -r line; do + echo -e " ${DIM}$line${RESET}" + done + fi + echo "" + fi + + # Category 4: Path references (if target contains /) + if [[ "$target" == *"/"* ]]; then + echo -e "${BLUE}── Path References ──${RESET}" + local path_hits + path_hits=$(grep -rn "join(.*\"$(echo "$target" | sed 's|/|", "|g')\"" "$scope" --include="*.ts" 2>/dev/null || true) + local path_count=0 + [[ -n "$path_hits" ]] && path_count=$(echo "$path_hits" | wc -l | tr -d ' ') + echo -e " ${path_count} join() path constructions" + if [[ -n "$path_hits" && "$path_count" -gt 0 ]]; then + echo "$path_hits" | head -10 | while read -r line; do + echo -e " ${DIM}$line${RESET}" + done + fi + echo "" + fi + + # Risk score + local total=$((string_count + import_count)) + local risk="LOW" + [[ $total -gt 10 ]] && risk="MEDIUM" + [[ $total -gt 30 ]] && risk="HIGH" + [[ $total -gt 100 ]] && risk="CRITICAL" + + local risk_color=$GREEN + [[ "$risk" == "MEDIUM" ]] && risk_color=$YELLOW + [[ "$risk" == "HIGH" ]] && risk_color=$RED + [[ "$risk" == "CRITICAL" ]] && risk_color=$RED + + echo -e "${BOLD}═══ Risk: ${risk_color}$risk${RESET} ${DIM}($total total references across scan)${RESET}" + + # Output to file if requested + if [[ -n "$output" ]]; then + { + echo "# Scan Results: $target" + echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Scope: $scope" + echo "Risk: $risk ($total references)" + echo "" + echo "## String References ($string_count files)" + echo "$string_hits" | head -30 + echo "" + echo "## Import References ($import_count)" + echo "$import_hits" | head -20 + } > "$output" + echo -e "${DIM}Results saved to $output${RESET}" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════ +# REFS — Find all callers/importers of a function/type +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_refs() { + local symbol="" + local scope="." + + while [[ $# -gt 0 ]]; do + case "$1" in + --symbol) symbol="$2"; shift 2 ;; + --scope) scope="$2"; shift 2 ;; + *) symbol="${symbol:-$1}"; shift ;; + esac + done + + [[ -z "$symbol" ]] && { echo "Usage: soma-refactor.sh refs --symbol [--scope ]"; exit 1; } + + echo -e "${BOLD}═══ References for: ${CYAN}$symbol${RESET}" + echo "" + + # Where is it defined? + echo -e "${BLUE}── Definition ──${RESET}" + grep -rn "export.*function $symbol\|export.*const $symbol\|export.*class $symbol\|export.*interface $symbol\|export.*type $symbol" \ + "$scope" --include="*.ts" --include="*.js" 2>/dev/null | while read -r line; do + echo -e " ${GREEN}DEF${RESET} $line" + done + echo "" + + # Who imports it? + echo -e "${BLUE}── Importers ──${RESET}" + grep -rn "import.*{[^}]*\b$symbol\b[^}]*}" "$scope" --include="*.ts" --include="*.js" 2>/dev/null | while read -r line; do + echo -e " ${CYAN}IMP${RESET} $line" + done + echo "" + + # Where is it called? + echo -e "${BLUE}── Call Sites ──${RESET}" + local calls + calls=$(find_call_sites "$symbol" "$scope") + if [[ -n "$calls" ]]; then + echo "$calls" | while read -r line; do + # Extract the arguments pattern + local args=$(echo "$line" | grep -oE "$symbol\([^)]*\)" | head -1) + echo -e " ${YELLOW}CALL${RESET} $line" + done + else + echo -e " ${DIM}(no call sites found)${RESET}" + fi + echo "" + + # Where is it referenced as a type? + echo -e "${BLUE}── Type References ──${RESET}" + grep -rn ": $symbol\b\|<$symbol\b\|$symbol\[\]\|$symbol | \|$symbol & " \ + "$scope" --include="*.ts" 2>/dev/null | \ + grep -v "import" | \ + grep -v "export" | while read -r line; do + echo -e " ${DIM}TYPE${RESET} $line" + done + + # Signature analysis + echo "" + echo -e "${BLUE}── Signature ──${RESET}" + # Find the full function signature (may span multiple lines) + local def_file=$(grep -rl "export.*function $symbol\|export.*async function $symbol" "$scope" --include="*.ts" 2>/dev/null | head -1) + if [[ -n "$def_file" ]]; then + local def_line=$(grep -n "export.*function $symbol\|export.*async function $symbol" "$def_file" 2>/dev/null | head -1 | cut -d: -f1) + if [[ -n "$def_line" ]]; then + # Show 10 lines from definition (captures multi-line signatures) + echo -e " ${DIM}$def_file:$def_line${RESET}" + sed -n "${def_line},$((def_line + 10))p" "$def_file" | head -10 | while read -r line; do + echo -e " ${DIM} $line${RESET}" + done + fi + fi +} + +# ═══════════════════════════════════════════════════════════════════════════ +# GRAPH — Build import dependency graph +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_graph() { + local scope="${1:-.}" + + echo -e "${BOLD}═══ Import Graph: ${CYAN}$scope${RESET}" + echo "" + + # Find all TS/JS files + local files + files=$(find "$scope" \( -name "*.ts" -o -name "*.js" \) ! -path "*/node_modules/*" ! -name "*.d.ts" | sort) + + # Build adjacency list + local edges=0 + local all_imports_file=$(mktemp) + + for file in $files; do + local basename_f=$(basename "$file") + local imports + imports=$(grep -E "^import.*from [\"']\./" "$file" 2>/dev/null | \ + sed -E "s/.*from [\"']([^\"']+)[\"'].*/\1/" | \ + sed 's/\.js$//' | \ + sed 's|^\./||' || true) + + if [[ -n "$imports" ]]; then + echo -e " ${CYAN}$basename_f${RESET}" + while read -r imp; do + [[ -z "$imp" ]] && continue + echo -e " → ${DIM}$imp${RESET}" + echo "$imp" >> "$all_imports_file" + edges=$((edges + 1)) + done <<< "$imports" + fi + done + + echo "" + local file_count=$(echo "$files" | wc -w) + echo -e "${DIM}$file_count files, $edges edges${RESET}" + + # Find circular dependencies + echo "" + echo -e "${BLUE}── Circular Dependency Check ──${RESET}" + local circulars=0 + for file in $files; do + local basename_f=$(basename "$file" .ts) + local imports + imports=$(grep -E "^import.*from [\"']\./" "$file" 2>/dev/null | \ + sed -E "s/.*from [\"']([^\"']+)[\"'].*/\1/" | \ + sed 's/\.js$//' | sed 's|^\./||' || true) + + while read -r imp; do + [[ -z "$imp" ]] && continue + local target_file="${scope}/${imp}.ts" + [[ ! -f "$target_file" ]] && continue + if grep -q "from.*[\"']\./${basename_f}" "$target_file" 2>/dev/null; then + echo -e " ${RED}CIRCULAR${RESET} $basename_f ⇄ $imp" + circulars=$((circulars + 1)) + fi + done <<< "$imports" + done + + [[ $circulars -eq 0 ]] && echo -e " ${GREEN}✓ No circular dependencies${RESET}" + + # Find hub files (most imported) + echo "" + echo -e "${BLUE}── Hub Files (most imported) ──${RESET}" + if [[ -s "$all_imports_file" ]]; then + sort "$all_imports_file" | uniq -c | sort -rn | head -10 | while read -r count name; do + [[ -z "$name" ]] && continue + echo -e " ${YELLOW}$count${RESET} ← $name" + done + fi + + rm -f "$all_imports_file" +} + +# ═══════════════════════════════════════════════════════════════════════════ +# VERIFY — Validate imports, exports, and compatibility +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_verify() { + local file="" + local scope="" + local mode="all" + + while [[ $# -gt 0 ]]; do + case "$1" in + --file) file="$2"; mode="file"; shift 2 ;; + --imports) scope="$2"; mode="imports"; shift 2 ;; + --exports) scope="$2"; mode="exports"; shift 2 ;; + *) scope="${scope:-$1}"; shift ;; + esac + done + + local pass=0 fail=0 warn=0 + + if [[ "$mode" == "file" || "$mode" == "all" ]]; then + # Verify single file + local target="${file:-$scope}" + [[ -z "$target" ]] && { echo "Usage: soma-refactor.sh verify --file "; exit 1; } + + echo -e "${BOLD}═══ Verifying: ${CYAN}$target${RESET}" + echo "" + + # Check: file exists + if [[ -f "$target" ]]; then + echo -e " ${GREEN}✓${RESET} File exists" + pass=$((pass + 1)) + else + echo -e " ${RED}✗${RESET} File not found" + fail=$((fail + 1)) + echo -e "\n${RED}$fail failures${RESET}" + return 1 + fi + + local lang=$(detect_language "$target") + + if [[ "$lang" == "typescript" || "$lang" == "javascript" ]]; then + # Check: all imports resolve to existing files + echo -e "\n${BLUE}── Import Resolution ──${RESET}" + local dir=$(dirname "$target") + grep -E "^import.*from [\"']\./" "$target" 2>/dev/null | \ + sed -E "s/.*from [\"']([^\"']+)[\"'].*/\1/" | while read -r imp; do + local resolved="${dir}/${imp}" + resolved="${resolved%.js}.ts" # .js → .ts for source + if [[ -f "$resolved" ]]; then + echo -e " ${GREEN}✓${RESET} $imp → $(basename "$resolved")" + pass=$((pass + 1)) + else + # Try without extension swap + if [[ -f "${dir}/${imp}" ]]; then + echo -e " ${GREEN}✓${RESET} $imp" + pass=$((pass + 1)) + else + echo -e " ${RED}✗${RESET} $imp → NOT FOUND (expected $resolved)" + fail=$((fail + 1)) + fi + fi + done + + # Check: imported symbols exist in target modules + echo -e "\n${BLUE}── Symbol Resolution ──${RESET}" + grep -E "^import \{" "$target" 2>/dev/null | while read -r line; do + local symbols=$(echo "$line" | sed -E 's/import (type )?\{([^}]+)\}.*/\2/' | tr ',' '\n' | sed 's/^ *//' | sed 's/ *$//') + local from=$(echo "$line" | sed -E "s/.*from [\"']([^\"']+)[\"'].*/\1/") + local resolved="${dir}/${from}" + resolved="${resolved%.js}.ts" + + if [[ -f "$resolved" ]]; then + echo "$symbols" | while read -r sym; do + [[ -z "$sym" ]] && continue + # Strip "as Alias" syntax + sym=$(echo "$sym" | sed 's/ as .*//') + if grep -qE "export.*(function|const|class|interface|type|enum|async function) $sym\b" "$resolved" 2>/dev/null; then + echo -e " ${GREEN}✓${RESET} $sym ← $(basename "$resolved")" + elif grep -qE "export \{.*\b$sym\b" "$resolved" 2>/dev/null; then + echo -e " ${GREEN}✓${RESET} $sym ← $(basename "$resolved") (re-export)" + else + echo -e " ${YELLOW}⚠${RESET} $sym not found in $(basename "$resolved") (may be type-only or re-exported)" + warn=$((warn + 1)) + fi + done + fi + done + + # Check: no unused imports (basic) + echo -e "\n${BLUE}── Unused Import Check ──${RESET}" + grep -E "^import \{" "$target" 2>/dev/null | while read -r line; do + local symbols=$(echo "$line" | sed -E 's/import (type )?\{([^}]+)\}.*/\2/' | tr ',' '\n' | sed 's/^ *//' | sed 's/ *$//') + echo "$symbols" | while read -r sym; do + [[ -z "$sym" ]] && continue + sym=$(echo "$sym" | sed 's/ as .*//') + # Count occurrences after the import section + local body_count=$(tail -n +2 "$target" | grep -c "\b$sym\b" 2>/dev/null || echo 0) + if [[ "$body_count" -le 1 ]]; then + echo -e " ${YELLOW}⚠${RESET} $sym may be unused (${body_count} body references)" + warn=$((warn + 1)) + fi + done + done + fi + + echo "" + echo -e "${BOLD}Results: ${GREEN}$pass pass${RESET}, ${RED}$fail fail${RESET}, ${YELLOW}$warn warn${RESET}" + fi + + if [[ "$mode" == "imports" ]]; then + local target="${scope}" + echo -e "${BOLD}═══ Import Verification: ${CYAN}$target${RESET}" + echo "" + + local files + files=$(find "$target" -name "*.ts" | grep -v node_modules | grep -v ".d.ts" | sort) + + while read -r f; do + [[ -z "$f" ]] && continue + local dir=$(dirname "$f") + local errors=0 + + grep -E "^import.*from [\"']\./" "$f" 2>/dev/null | \ + sed -E "s/.*from [\"']([^\"']+)[\"'].*/\1/" | while read -r imp; do + local resolved="${dir}/${imp}" + resolved="${resolved%.js}.ts" + if [[ ! -f "$resolved" && ! -f "${dir}/${imp}" ]]; then + echo -e " ${RED}✗${RESET} $(basename "$f"): $imp → NOT FOUND" + errors=$((errors + 1)) + fail=$((fail + 1)) + fi + done + + [[ $errors -eq 0 ]] && pass=$((pass + 1)) + done <<< "$files" + + echo "" + local total_files=$(echo "$files" | wc -l | tr -d ' ') + echo -e "${BOLD}$total_files files checked. ${GREEN}$pass clean${RESET}, ${RED}$fail broken imports${RESET}" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════ +# DUPLICATES — Find duplicated code patterns +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_duplicates() { + local scope="." + local min_lines=5 + local pattern="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --scope) scope="$2"; shift 2 ;; + --min-lines) min_lines="$2"; shift 2 ;; + --pattern) pattern="$2"; shift 2 ;; + *) scope="$1"; shift ;; + esac + done + + echo -e "${BOLD}═══ Duplicate Pattern Analysis: ${CYAN}$scope${RESET}" + echo "" + + # Strategy 1: Find identical function signatures + echo -e "${BLUE}── Duplicated Function Names ──${RESET}" + local func_names + func_names=$(grep -rhE "^(export )?(async )?function [a-zA-Z_]+" "$scope" --include="*.ts" 2>/dev/null | \ + sed -E 's/(export )?(async )?function //' | sed 's/(.*//' | sort | uniq -c | sort -rn | awk '$1 > 1' || true) + + if [[ -n "$func_names" ]]; then + echo "$func_names" | while IFS= read -r line; do + local count=$(echo "$line" | awk '{print $1}') + local name=$(echo "$line" | awk '{print $2}') + [[ -z "$name" ]] && continue + echo -e " ${YELLOW}${count}×${RESET} $name" + grep -rn "function $name" "$scope" --include="*.ts" 2>/dev/null | while read -r loc; do + echo -e " ${DIM}$loc${RESET}" + done + done + else + echo -e " ${GREEN}✓ No duplicated function names${RESET}" + fi + echo "" + + # Strategy 2: Find near-identical code blocks + echo -e "${BLUE}── Similar Patterns ──${RESET}" + + # Common refactor candidates: identical multi-line patterns + # Look for functions that start the same way + local patterns_found=0 + + # Pattern: existsSync + readdirSync + filter (discovery pattern) + local discovery_pattern + discovery_pattern=$(grep -rlE "existsSync.*readdirSync.*filter" "$scope" --include="*.ts" 2>/dev/null || true) + if [[ -n "$discovery_pattern" ]]; then + local count=$(echo "$discovery_pattern" | wc -l | tr -d ' ') + if [[ $count -gt 1 ]]; then + echo -e " ${YELLOW}Discovery pattern${RESET} (existsSync→readdirSync→filter) in ${count} files:" + echo "$discovery_pattern" | while read -r f; do + echo -e " ${DIM}$(basename "$f")${RESET}" + done + patterns_found=$((patterns_found + 1)) + fi + fi + + # Pattern: frontmatter extraction + local fm_pattern + fm_pattern=$(grep -rlE "extractFrontmatter|match.*---.*---" "$scope" --include="*.ts" 2>/dev/null || true) + if [[ -n "$fm_pattern" ]]; then + local count=$(echo "$fm_pattern" | wc -l | tr -d ' ') + if [[ $count -gt 1 ]]; then + echo -e " ${YELLOW}Frontmatter parsing${RESET} in ${count} files:" + echo "$fm_pattern" | while read -r f; do + echo -e " ${DIM}$(basename "$f")${RESET}" + done + patterns_found=$((patterns_found + 1)) + fi + fi + + # Pattern: heat bump/decay (read→parse→clamp→write) + local heat_pattern + heat_pattern=$(grep -rlE "heat.*Math\.(max|min).*writeFileSync" "$scope" --include="*.ts" 2>/dev/null || true) + if [[ -n "$heat_pattern" ]]; then + local count=$(echo "$heat_pattern" | wc -l | tr -d ' ') + if [[ $count -gt 1 ]]; then + echo -e " ${YELLOW}Heat management${RESET} (read→parse→clamp→write) in ${count} files:" + echo "$heat_pattern" | while read -r f; do + echo -e " ${DIM}$(basename "$f")${RESET}" + done + patterns_found=$((patterns_found + 1)) + fi + fi + + # Pattern: cold-start boost + local coldstart_pattern + coldstart_pattern=$(grep -rlE "COLD_START_BOOST|COLD_START_WINDOW" "$scope" --include="*.ts" 2>/dev/null || true) + if [[ -n "$coldstart_pattern" ]]; then + local count=$(echo "$coldstart_pattern" | wc -l | tr -d ' ') + if [[ $count -gt 1 ]]; then + echo -e " ${YELLOW}Cold-start boost${RESET} in ${count} files:" + echo "$coldstart_pattern" | while read -r f; do + echo -e " ${DIM}$(basename "$f")${RESET}" + done + patterns_found=$((patterns_found + 1)) + fi + fi + + # Pattern: deepMerge + local merge_pattern + merge_pattern=$(grep -rlE "function deepMerge" "$scope" --include="*.ts" 2>/dev/null || true) + if [[ -n "$merge_pattern" ]]; then + local count=$(echo "$merge_pattern" | wc -l | tr -d ' ') + if [[ $count -gt 1 ]]; then + echo -e " ${YELLOW}deepMerge${RESET} duplicated in ${count} files:" + echo "$merge_pattern" | while read -r f; do + echo -e " ${DIM}$(basename "$f")${RESET}" + done + patterns_found=$((patterns_found + 1)) + fi + fi + + [[ $patterns_found -eq 0 ]] && echo -e " ${GREEN}✓ No obvious duplicated patterns${RESET}" + + echo "" + echo -e "${BOLD}$patterns_found duplicate patterns found${RESET}" +} + +# ═══════════════════════════════════════════════════════════════════════════ +# TAGS — Inject/list/clean REFACTOR comment tags +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_tags() { + local action="list" + local scope="." + local tag_text="" + local target="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --list) action="list"; shift ;; + --inject) action="inject"; tag_text="$2"; shift 2 ;; + --clean) action="clean"; shift ;; + --scope) scope="$2"; shift 2 ;; + *) target="$1"; shift ;; + esac + done + + case "$action" in + list) + echo -e "${BOLD}═══ REFACTOR Tags ═══${RESET}" + echo "" + local tags + tags=$(grep -rnE "// REFACTOR:|# REFACTOR:|" + + sed -i '' "${line}s|$| ${comment_prefix} REFACTOR: ${tag_text}${comment_suffix}|" "$file" + echo -e "${GREEN}✓${RESET} Injected tag at $target" + else + echo -e "${RED}✗${RESET} Invalid target: $target" + fi + ;; + + clean) + echo -e "${BOLD}Cleaning resolved REFACTOR tags...${RESET}" + # Only clean tags that have been addressed (marked with ✓ or DONE) + find "$scope" \( -name "*.ts" -o -name "*.js" -o -name "*.sh" \) | while read -r file; do + if grep -q "REFACTOR:.*\(DONE\|✓\)" "$file" 2>/dev/null; then + sed -i '' '/REFACTOR:.*\(DONE\|✓\)/s/ \/\/ REFACTOR:.*//' "$file" + echo -e " ${GREEN}✓${RESET} Cleaned $(basename "$file")" + fi + done + ;; + esac +} + +# ═══════════════════════════════════════════════════════════════════════════ +# RISK — Score risk of changing a target +# ═══════════════════════════════════════════════════════════════════════════ + +cmd_risk() { + local target="" + local scope="." + + while [[ $# -gt 0 ]]; do + case "$1" in + --target) target="$2"; shift 2 ;; + --scope) scope="$2"; shift 2 ;; + *) target="${target:-$1}"; shift ;; + esac + done + + [[ -z "$target" ]] && { echo "Usage: soma-refactor.sh risk --target "; exit 1; } + + echo -e "${BOLD}═══ Risk Assessment: ${CYAN}$target${RESET}" + echo "" + + local score=0 + local factors="" + + # Factor 1: How many files reference it? + local ref_count=$(grep -rl "$target" "$scope" --include="*.ts" --include="*.js" 2>/dev/null | wc -l | tr -d ' ') + echo -e " Files referencing: ${YELLOW}$ref_count${RESET}" + score=$((score + ref_count * 2)) + + # Factor 2: Is it exported? (public API surface) + local exported=$(grep -rl "export.*$target" "$scope" --include="*.ts" 2>/dev/null | wc -l | tr -d ' ') + if [[ $exported -gt 0 ]]; then + echo -e " Exported from: ${YELLOW}$exported${RESET} files" + score=$((score + exported * 5)) + factors+="exported API, " + fi + + # Factor 3: Is it in index.ts? (barrel export = very public) + if grep -q "$target" "$scope"/index.ts 2>/dev/null; then + echo -e " ${RED}In barrel export (index.ts)${RESET}" + score=$((score + 15)) + factors+="barrel export, " + fi + + # Factor 4: Is it used in tests? + local test_refs=$(grep -rl "$target" "$scope"/../tests/ 2>/dev/null | wc -l | tr -d ' ') + if [[ $test_refs -gt 0 ]]; then + echo -e " Test references: ${YELLOW}$test_refs${RESET}" + score=$((score + test_refs * 3)) + factors+="test coverage, " + fi + + # Factor 5: Is it used in extensions? + local ext_refs=$(grep -rl "$target" "$scope"/../extensions/ 2>/dev/null | wc -l | tr -d ' ') + if [[ $ext_refs -gt 0 ]]; then + echo -e " Extension references: ${YELLOW}$ext_refs${RESET}" + score=$((score + ext_refs * 4)) + factors+="extension API, " + fi + + echo "" + + # Risk level + local risk="LOW" + local color=$GREEN + if [[ $score -gt 50 ]]; then risk="CRITICAL"; color=$RED + elif [[ $score -gt 30 ]]; then risk="HIGH"; color=$RED + elif [[ $score -gt 15 ]]; then risk="MEDIUM"; color=$YELLOW + fi + + echo -e " ${BOLD}Risk: ${color}$risk${RESET} (score: $score)" + [[ -n "$factors" ]] && echo -e " ${DIM}Factors: ${factors%, }${RESET}" + + echo "" + echo -e "${DIM}Recommendations:${RESET}" + if [[ $score -gt 30 ]]; then + echo " • Add optional params (don't break existing callers)" + echo " • Write migration tests before changing" + echo " • Update index.ts re-exports last" + elif [[ $score -gt 15 ]]; then + echo " • Keep backward compatible (accept old + new)" + echo " • Run full test suite after each file change" + else + echo " • Safe to change directly" + echo " • Run relevant tests after" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════ +# MAIN DISPATCH +# ═══════════════════════════════════════════════════════════════════════════ + +# ═══════════════════════════════════════════════════════════════ +# routes — audit router signals & capabilities vs actual usage +# Related muscles: incremental-refactor +# Related scripts: soma-code.sh +# ═══════════════════════════════════════════════════════════════ +cmd_routes() { + local ext_dir="${1:-extensions}" + local route_file="" + + # Find soma-route.ts + for candidate in "$ext_dir/soma-route.ts" "extensions/soma-route.ts" "repos/agent/extensions/soma-route.ts"; do + [[ -f "$candidate" ]] && route_file="$candidate" && break + done + + if [[ -z "$route_file" ]]; then + echo -e "${RED}Cannot find soma-route.ts in $ext_dir${RESET}" + return 1 + fi + + local search_dir + search_dir=$(dirname "$route_file") + + echo -e "${BOLD}═══ Router Audit: ${CYAN}$search_dir${RESET}" + echo "" + + # --- Signals --- + echo -e "${BLUE}── Signals ──${RESET}" + local sig_total=0 sig_phantom=0 sig_emitted_only=0 sig_listened_only=0 sig_wired=0 + + local _sigs + _sigs=$(grep "signal" "$route_file" | grep "|.*|.*|.*|" | awk -F'|' '{gsub(/^ *\** *| *$/, "", $2); if ($2 != "" && $2 !~ /type/) print $2}') + + while IFS= read -r sig; do + [[ -z "$sig" ]] && continue + local emitted listened + emitted=$(grep -rc "emit(\"$sig\"" "$search_dir"/*.ts 2>/dev/null | grep -v "soma-route.ts" | grep -v ":0$" | wc -l | tr -d ' \n' || true) + listened=$(grep -rc "\.on(\"$sig\"" "$search_dir"/*.ts 2>/dev/null | grep -v "soma-route.ts" | grep -v ":0$" | wc -l | tr -d ' \n' || true) + [[ -z "$emitted" ]] && emitted=0 + [[ -z "$listened" ]] && listened=0 + + if [[ "$emitted" -eq 0 && "$listened" -eq 0 ]]; then + echo -e " ${RED}❌${RESET} $sig ${DIM}— phantom (never wired)${RESET}" + elif [[ "$emitted" -eq 0 ]]; then + echo -e " ${YELLOW}⚠️${RESET} $sig ${DIM}— no emitter, $listened listener(s)${RESET}" + elif [[ "$listened" -eq 0 ]]; then + echo -e " ${CYAN}📡${RESET} $sig ${DIM}— $emitted emitter(s), no listeners${RESET}" + else + echo -e " ${GREEN}✅${RESET} $sig ${DIM}— $emitted emitter(s), $listened listener(s)${RESET}" + fi + done <<< "$_sigs" + + echo "" + + # --- Capabilities --- + echo -e "${BLUE}── Capabilities ──${RESET}" + local _caps + _caps=$(grep "capability" "$route_file" | grep "|.*|.*|.*|" | awk -F'|' '{gsub(/^ *\** *| *$/, "", $2); if ($2 != "" && $2 !~ /type/) print $2}') + + while IFS= read -r cap; do + [[ -z "$cap" ]] && continue + local provided consumed + provided=$(grep -rc "provide(\"$cap\"" "$search_dir"/*.ts 2>/dev/null | grep -v "soma-route.ts" | grep -v ":0$" | wc -l | tr -d ' \n' || true) + consumed=$(grep -rc "get(\"$cap\"" "$search_dir"/*.ts 2>/dev/null | grep -v "soma-route.ts" | grep -v ":0$" | wc -l | tr -d ' \n' || true) + [[ -z "$provided" ]] && provided=0 + [[ -z "$consumed" ]] && consumed=0 + + if [[ "$provided" -eq 0 && "$consumed" -eq 0 ]]; then + echo -e " ${RED}❌${RESET} $cap ${DIM}— phantom${RESET}" + elif [[ "$provided" -eq 0 ]]; then + echo -e " ${YELLOW}⚠️${RESET} $cap ${DIM}— no provider, $consumed consumer(s) (BROKEN)${RESET}" + elif [[ "$consumed" -eq 0 ]]; then + echo -e " ${CYAN}📦${RESET} $cap ${DIM}— provided, no consumers${RESET}" + else + echo -e " ${GREEN}✅${RESET} $cap ${DIM}— $provided provider(s), $consumed consumer(s)${RESET}" + fi + done <<< "$_caps" + + echo "" + + # --- Undocumented (emitted/provided but not in catalog) --- + echo -e "${BLUE}── Undocumented ──${RESET}" + local undoc=0 + + # Find emits not in the catalog + local _emitted_sigs + _emitted_sigs=$(grep -rn '\.emit("' "$search_dir"/*.ts 2>/dev/null | grep -v "soma-route.ts" | sed 's/.*emit("\([^"]*\)".*/\1/' | sort -u || true) + local undoc_found=0 + + while IFS= read -r sig; do + [[ -z "$sig" ]] && continue + if ! grep -q "$sig" "$route_file" 2>/dev/null; then + echo -e " ${YELLOW}📤${RESET} signal: $sig ${DIM}— emitted but not in catalog${RESET}" + undoc_found=1 + fi + done <<< "$_emitted_sigs" + + # Find provides not in the catalog + local _provided_caps + _provided_caps=$(grep -rn '\.provide("' "$search_dir"/*.ts 2>/dev/null | grep -v "soma-route.ts" | sed 's/.*provide("\([^"]*\)".*/\1/' | sort -u || true) + + while IFS= read -r cap; do + [[ -z "$cap" ]] && continue + if ! grep -q "$cap" "$route_file" 2>/dev/null; then + echo -e " ${YELLOW}📤${RESET} capability: $cap ${DIM}— provided but not in catalog${RESET}" + undoc_found=1 + fi + done <<< "$_provided_caps" + + [[ "$undoc_found" -eq 0 ]] && echo -e " ${GREEN}✓${RESET} all routes documented" + return 0 +} + +main() { + local cmd="${1:-help}" + shift 2>/dev/null || true + + case "$cmd" in + scan) cmd_scan "$@" ;; + refs) cmd_refs "$@" ;; + graph) cmd_graph "$@" ;; + verify) cmd_verify "$@" ;; + duplicates) cmd_duplicates "$@" ;; + tags) cmd_tags "$@" ;; + risk) cmd_risk "$@" ;; + routes) cmd_routes "$@" ;; + help|--help|-h) + echo "soma-refactor.sh — Smart refactoring toolkit" + echo "" + echo "Commands:" + echo " scan Map all references to a target (path, symbol, string)" + echo " refs Find all callers/importers of a function/type" + echo " graph Build import dependency graph for a directory" + echo " verify Validate imports, exports, and type compatibility" + echo " duplicates Find duplicated patterns across files" + echo " tags Inject/list/clean REFACTOR: comment tags" + echo " risk Score risk of changing a target" + echo " routes Audit router signals & capabilities (phantom/unused/wired)" + echo "" + echo "Examples:" + echo " soma-refactor.sh scan \"memory/muscles\" --scope core/" + echo " soma-refactor.sh refs discoverProtocols --scope core/" + echo " soma-refactor.sh graph core/" + echo " soma-refactor.sh verify --file core/protocols.ts" + echo " soma-refactor.sh verify --imports core/" + echo " soma-refactor.sh duplicates core/" + echo " soma-refactor.sh tags --list" + echo " soma-refactor.sh risk discoverProtocols --scope core/" + echo " soma-refactor.sh routes # audit all signals + capabilities" + ;; + *) + echo "Unknown command: $cmd" + echo "Run: soma-refactor.sh help" + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/soma-verify/soma-verify.sh b/scripts/soma-verify/soma-verify.sh new file mode 100755 index 0000000..b5d0fdc --- /dev/null +++ b/scripts/soma-verify/soma-verify.sh @@ -0,0 +1,1712 @@ +#!/usr/bin/env bash +# soma-verify.sh — Health checks and truth-checking for the Soma ecosystem +# +# USE WHEN: after shipping (verify nothing broke), before releases (ecosystem health), +# when docs feel stale (drift detection), periodic self-analysis. +# After ANY code change — tools verify the SYSTEM, tests verify the CODE. +# Run alongside npm test + regression suites for full coverage. +# +# Part of the dev-ship MAP Phase 1 verification stack: +# 1. npm test (unit) → 2. test-*.sh (regression) → 3. soma-verify.sh (ecosystem) +# 4. soma-hub-status.sh (drift) → 5. soma-refactor.sh scan (blast radius) +# +# TODO: add a `tests` subcommand that runs all 3 test layers in sequence +# TODO: add a `blast` subcommand that wraps soma-refactor.sh scan for common patterns +# +# Related muscles: ship-cycle (post-ship verify), self-analysis (deep check), +# context-hygiene (stale doc detection), protocol-management (protocol sync) +# Related scripts: soma-ship.sh (calls this), soma-query.sh (search/explore), +# soma-gap-check.sh (knowledge gaps), soma-verify-styles.sh (CSS), soma-verify-islands.sh (Astro) +# +# Verify claims against code, detect drift, check ecosystem health, +# and delegate analysis to sub-agents. +# Token-efficient output designed for agent consumption. +# +# Search/explore commands moved to soma-query.sh (2026-03-14): +# topic, search, related, sessions, impact, history +# +# Usage: +# soma-verify.sh doc # verify claims in a doc against code +# soma-verify.sh sync # cross-ecosystem consistency check +# soma-verify.sh streams # pro vs public stream protection +# soma-verify.sh changelog [scope] # verify changelog claims against commits +# soma-verify.sh protocols # protocol versions across all 4 sources +# soma-verify.sh website # docs sync + hub content + stale paths +# soma-verify.sh copy # marketing copy vs source of truth +# soma-verify.sh repos # multi-repo state check +# soma-verify.sh agent [files...] # delegate analysis to Haiku sub-agent +# soma-verify.sh hygiene # full workspace health sweep +# soma-verify.sh self-analysis # deep ecosystem health check +# soma-verify.sh --compact # minimal output (errors only) +# soma-verify.sh --help +# +# Designed for agent consumption. Pipe output into session for review. + +set -uo pipefail + +# ── Theme ── +source "$(dirname "$0")/soma-theme.sh" 2>/dev/null || true + +# ── Paths ──────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SOMA_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +PROJECT_ROOT="$(cd "$SOMA_DIR/.." && pwd)" + +# Repos +# Agent-stable repo — optional, for cross-source verification +AGENT_STABLE="$PROJECT_ROOT/repos/agent-stable" +[[ ! -d "$AGENT_STABLE" ]] && AGENT_STABLE="" +AGENT_DEV="$PROJECT_ROOT/repos/agent" +CLI_REPO="$PROJECT_ROOT/repos/cli" +WEBSITE_REPO="$PROJECT_ROOT/repos/website" +# Community repo — optional, enhances verification if present +COMMUNITY_REPO="$PROJECT_ROOT/repos/community" +[[ ! -d "$COMMUNITY_REPO" ]] && COMMUNITY_REPO="" +PRO_REPO="$PROJECT_ROOT/repos/soma-pro" + +COMPACT=false +[[ "${*}" == *"--compact"* ]] && COMPACT=true + +# ── Helpers ────────────────────────────────────────────────────────────── + +σ() { echo "σ $*"; } +pass() { echo " ✅ $*"; } +fail() { echo " ❌ $*"; } +warn() { echo " ⚠️ $*"; } +dim() { [[ "$COMPACT" == false ]] && echo " $*"; } +header() { echo ""; echo "━━━ $* ━━━"; } +score() { + local p=$1 t=$2 + local pct=0 + [ "$t" -gt 0 ] && pct=$(( p * 100 / t )) + echo "" + echo "SCORE: $p/$t claims verified ($pct%)" +} + +# ── DOC: Verify claims in a document ──────────────────────────────────── + +verify_doc() { + local target="$1" + [ ! -f "$target" ] && echo "File not found: $target" && exit 1 + + header "VERIFY DOC: $(basename "$target")" + + local pass_count=0 + local fail_count=0 + local total=0 + + # --- Settings values --- + # Look for patterns like: settingName: value or "settingName": value + # in code blocks (```json or ```jsonc sections) + + local settings_file="$AGENT_STABLE/core/settings.ts" + if [ -f "$settings_file" ]; then + # Extract claimed setting values from JSON-like blocks in the doc + # Pattern: word followed by colon and a number/boolean/string + local in_code_block=false + local claims="" + + while IFS= read -r line; do + # Track code blocks + if [[ "$line" =~ ^\`\`\` ]]; then + if [[ "$in_code_block" == true ]]; then + in_code_block=false + else + in_code_block=true + fi + continue + fi + + if [[ "$in_code_block" == true ]]; then + # Extract setting: value pairs (skip comments) + if [[ "$line" =~ ^[[:space:]]*\"?([a-zA-Z]+)\"?:[[:space:]]*([-0-9]+|true|false|\"[^\"]*\") ]]; then + local key="${BASH_REMATCH[1]}" + local val="${BASH_REMATCH[2]}" + # Strip quotes and trailing comma + val=$(echo "$val" | tr -d '",') + + # Skip generic words that aren't settings + case "$key" in + name|type|status|text|model|input|task|dimensions) continue ;; + esac + + # Look up in settings.ts defaults + local actual=$(grep "${key}:" "$settings_file" | grep -v "//" | grep -v "string\|number\|boolean" | grep "[0-9]\|true\|false" | head -1 | sed "s/.*${key}:[[:space:]]*//" | tr -d ',' | tr -d '[:space:]') + + if [ -n "$actual" ]; then + total=$((total + 1)) + if [ "$val" = "$actual" ]; then + [[ "$COMPACT" == false ]] && pass "$key = $val (settings.ts)" + pass_count=$((pass_count + 1)) + else + fail "$key = $val → ACTUAL: $actual (settings.ts)" + fail_count=$((fail_count + 1)) + fi + fi + fi + fi + done < "$target" + fi + + # --- File references --- + # Look for file paths like core/settings.ts, extensions/soma-boot.ts + header "FILE REFERENCES" + + local file_refs=$(grep -oE '[a-zA-Z_-]+/[a-zA-Z_-]+\.(ts|js|md|sh)' "$target" | sort -u) + for ref in $file_refs; do + total=$((total + 1)) + # Check in agent-stable + if [ -f "$AGENT_STABLE/$ref" ]; then + [[ "$COMPACT" == false ]] && pass "$ref exists" + pass_count=$((pass_count + 1)) + elif find "$AGENT_STABLE" -path "*/$ref" -print -quit 2>/dev/null | grep -q .; then + [[ "$COMPACT" == false ]] && pass "$ref exists (nested)" + pass_count=$((pass_count + 1)) + else + fail "$ref NOT FOUND in agent-stable" + fail_count=$((fail_count + 1)) + fi + done + + # --- Function references --- + # Look for functionName() patterns + header "FUNCTION REFERENCES" + + local func_refs=$(grep -oE '[a-zA-Z_]+\(\)' "$target" | sort -u | grep -v "^function\(\)") + for func in $func_refs; do + local fname="${func%()}" + total=$((total + 1)) + + local found=$(grep -rn "function ${fname}\|export.*${fname}\|const ${fname}" "$AGENT_STABLE/core/" "$AGENT_STABLE/extensions/" 2>/dev/null | head -1) + if [ -n "$found" ]; then + local loc=$(echo "$found" | cut -d: -f1-2 | sed "s|$AGENT_STABLE/||") + [[ "$COMPACT" == false ]] && pass "$func found at $loc" + pass_count=$((pass_count + 1)) + else + fail "$func NOT FOUND in codebase" + # Try fuzzy match + local close=$(grep -rn "$fname" "$AGENT_STABLE/core/" "$AGENT_STABLE/extensions/" 2>/dev/null | head -1 | cut -d: -f1-2 | sed "s|$AGENT_STABLE/||") + [ -n "$close" ] && dim "→ closest match: $close" + fail_count=$((fail_count + 1)) + fi + done + + # --- Command references --- + header "COMMAND REFERENCES" + + local cmd_refs=$(grep -oE '/[a-z][-a-z]+' "$target" | sort -u | grep -v "^//" | grep -v "^/dev" | grep -v "^/tmp" | grep -v "^/Users" | grep -v "^\./") + for cmd in $cmd_refs; do + # Skip common non-commands + case "$cmd" in + /dev|/tmp|/Users|/usr|/etc|/var|/home|/bin|/opt) continue ;; + /v1|/api|/v1beta) continue ;; + esac + total=$((total + 1)) + local cname="${cmd#/}" + local found=$(grep -rn "registerCommand.*[\"']${cname}[\"']" "$AGENT_STABLE/extensions/" 2>/dev/null | head -1) + if [ -n "$found" ]; then + local loc=$(echo "$found" | cut -d: -f1-2 | sed "s|$AGENT_STABLE/||") + [[ "$COMPACT" == false ]] && pass "$cmd registered at $loc" + pass_count=$((pass_count + 1)) + else + # Might be a subcommand or future command + [[ "$COMPACT" == false ]] && warn "$cmd not found as registered command (may be planned/subcommand)" + fi + done + + score $pass_count $total + [ $fail_count -gt 0 ] && echo "FAILURES: $fail_count" && return 1 + return 0 +} + +# ── SYNC: Cross-ecosystem consistency ──────────────────────────────────── + +verify_sync() { + header "SYNC: Protocol Drift (community ↔ bundled)" + + if [[ -z "$COMMUNITY_REPO" ]]; then + dim "Community repo not found — skipping sync checks" + return 0 + fi + + local pass_count=0 + local fail_count=0 + local total=0 + + for f in "$COMMUNITY_REPO/protocols/"*.md; do + local name=$(basename "$f") + local bundled="$AGENT_STABLE/.soma/protocols/$name" + total=$((total + 1)) + + if [ ! -f "$bundled" ]; then + warn "$name — community only (not bundled)" + continue + fi + + if diff -q "$f" "$bundled" > /dev/null 2>&1; then + [[ "$COMPACT" == false ]] && pass "$name — in sync" + pass_count=$((pass_count + 1)) + else + fail "$name — DRIFTED" + if [[ "$COMPACT" == false ]]; then + # Show what's different + local c_ver=$(grep "^version:" "$f" 2>/dev/null | awk '{print $2}') + local b_ver=$(grep "^version:" "$bundled" 2>/dev/null | awk '{print $2}') + local c_tier=$(grep "^tier:" "$f" 2>/dev/null | awk '{print $2}') + local b_tier=$(grep "^tier:" "$bundled" 2>/dev/null | awk '{print $2}') + [ "$c_ver" != "$b_ver" ] && dim "version: community=$c_ver bundled=$b_ver" + [ "$c_tier" != "$b_tier" ] && dim "tier: community=$c_tier bundled=$b_tier" + local content_diff=$(diff "$f" "$bundled" | grep "^[<>]" | grep -v "^[<>].*version:\|^[<>].*tier:\|^[<>].*author:\|^[<>].*license:\|^[<>].*scope:" | wc -l | tr -d '[:space:]') + [ "$content_diff" -gt 0 ] && dim "content lines changed: $content_diff" + fi + fail_count=$((fail_count + 1)) + fi + done + + header "SYNC: Agent ↔ CLI" + + # Check if CLI is behind agent-stable + local agent_head=$(git -C "$AGENT_STABLE" log -1 --format="%H %s" 2>/dev/null) + local cli_last_sync=$(git -C "$CLI_REPO" log -1 --format="%s" 2>/dev/null) + echo " agent-stable HEAD: $(echo "$agent_head" | cut -c1-50)" + echo " CLI last sync: $cli_last_sync" + + # Count commits since last sync + local last_sync_msg=$(git -C "$CLI_REPO" log -1 --format="%s" 2>/dev/null | sed 's/sync: //') + echo "" + + header "SYNC: Agent main ↔ dev" + + local main_head=$(git -C "$AGENT_STABLE" log -1 --format="%h %s" 2>/dev/null) + local dev_head=$(git -C "$AGENT_DEV" log -1 --format="%h %s" 2>/dev/null) + echo " main: $main_head" + echo " dev: $dev_head" + + # Commits on main not on dev + local ahead=$(git -C "$AGENT_STABLE" log --oneline "$(git -C "$AGENT_DEV" rev-parse HEAD 2>/dev/null)..HEAD" 2>/dev/null | wc -l | tr -d '[:space:]') + [ "$ahead" -gt 0 ] && warn "main is $ahead commits ahead of dev" + + score $pass_count $total +} + +# ── STREAMS: Pro vs Public protection ──────────────────────────────────── + +verify_streams() { + header "STREAMS: Pro ↔ Public Protection" + + local issues=0 + + # Check if any .soma/ files are in agent-stable (shouldn't be, except .soma/protocols and .soma/templates) + echo " Checking agent-stable for .soma/ content..." + local soma_files=$(find "$AGENT_STABLE/.soma/" -name "*.ts" -o -name "*.sh" 2>/dev/null | grep -v "node_modules") + if [ -n "$soma_files" ]; then + warn "TypeScript/shell files in agent-stable/.soma/:" + echo "$soma_files" | while read -r f; do + echo " $(echo "$f" | sed "s|$AGENT_STABLE/||")" + done + issues=$((issues + 1)) + fi + + # Check PRO extensions for imports from core/ + echo "" + echo " Checking PRO extensions for core/ imports..." + if [ -d "$PRO_REPO/extensions" ]; then + for ext in "$PRO_REPO/extensions/"*.ts; do + local bad_imports=$(grep "from.*\.\./core\|from.*agent-stable\|require.*core/" "$ext" 2>/dev/null) + if [ -n "$bad_imports" ]; then + fail "$(basename "$ext") imports from core/ (must be self-contained)" + echo "$bad_imports" | while read -r line; do + dim "$line" + done + issues=$((issues + 1)) + else + [[ "$COMPACT" == false ]] && pass "$(basename "$ext") — self-contained" + fi + done + fi + + # Check for command name conflicts + echo "" + echo " Checking command name conflicts..." + local public_cmds=$(grep -oE "registerCommand\([\"'][^\"']+[\"']" "$AGENT_STABLE/extensions/"*.ts 2>/dev/null | sed "s/.*registerCommand([\"']//" | sed "s/[\"']//") + local pro_cmds="" + if [ -d "$PRO_REPO/extensions" ]; then + pro_cmds=$(grep -oE "registerCommand\([\"'][^\"']+[\"']" "$PRO_REPO/extensions/"*.ts 2>/dev/null | sed "s/.*registerCommand([\"']//" | sed "s/[\"']//") + fi + + for cmd in $pro_cmds; do + if echo "$public_cmds" | grep -q "^${cmd}$"; then + fail "Command conflict: /$cmd registered in both public and PRO" + issues=$((issues + 1)) + fi + done + + # Check symlinks + echo "" + echo " Checking PRO symlinks..." + for link in "$SOMA_DIR/extensions/"*; do + if [ -L "$link" ]; then + local target=$(readlink "$link") + if [ -f "$link" ]; then + [[ "$COMPACT" == false ]] && pass "$(basename "$link") → $(basename "$target")" + else + fail "$(basename "$link") → BROKEN: $target" + issues=$((issues + 1)) + fi + fi + done + + echo "" + [ $issues -eq 0 ] && echo "✅ No stream issues found" || echo "⚠️ $issues issue(s) found" +} + +# ── CHANGELOG: Verify changelog claims against commits ─────────────────── + +verify_changelog() { + local scope="${1:-}" + + header "CHANGELOG: Verify claims against commits" + + local changelog="$AGENT_STABLE/CHANGELOG.md" + [ ! -f "$changelog" ] && echo "No CHANGELOG.md found" && exit 1 + + local pass_count=0 + local fail_count=0 + local total=0 + + # Extract claimed features from [Unreleased] section + local in_unreleased=false + while IFS= read -r line; do + [[ "$line" == "## [Unreleased]"* ]] && in_unreleased=true && continue + [[ "$line" == "## ["* ]] && [ "$in_unreleased" = true ] && break + + if [ "$in_unreleased" = true ] && [[ "$line" == "- "* ]]; then + # Extract the feature description + local desc=$(echo "$line" | sed 's/^- \*\*//' | sed 's/\*\*.*//') + + # Try to find a commit that matches + if [ -n "$desc" ]; then + total=$((total + 1)) + # Search for key words in commit messages + local keywords=$(echo "$desc" | tr '[:upper:]' '[:lower:]' | grep -oE '[a-z-]{4,}' | head -3 | tr '\n' '|' | sed 's/|$//') + if [ -n "$keywords" ]; then + local found=$(git -C "$AGENT_STABLE" log --oneline --all | grep -iE "$keywords" | head -1) + if [ -n "$found" ]; then + [[ "$COMPACT" == false ]] && pass "$desc" + dim "commit: $found" + pass_count=$((pass_count + 1)) + else + fail "$desc — no matching commit found" + fail_count=$((fail_count + 1)) + fi + fi + fi + fi + done < "$changelog" + + # Check for commits not in changelog + echo "" + echo " Recent feat() commits not in changelog:" + git -C "$AGENT_STABLE" log --oneline -20 | grep "^.*feat" | while read -r line; do + local msg=$(echo "$line" | sed 's/^[a-f0-9]* //') + if ! grep -q "$(echo "$msg" | grep -oE '[a-z-]{6,}' | head -1)" "$changelog" 2>/dev/null; then + warn "$line" + fi + done + + score $pass_count $total +} + +# ── (topic, search, related, sessions, impact, history moved to soma-query.sh) ── + +# ── PROTOCOLS: Cross-source protocol version check ─────────────────────── + +verify_protocols() { + header "PROTOCOLS: Version check across sources" + + local pass_count=0 + local total=0 + local issues=0 + + # Gather protocol names from community (canonical source) + local community_dir="$COMMUNITY_REPO/protocols" + local bundled_dir="$AGENT_STABLE/.soma/protocols" + local workspace_dir="$SOMA_DIR/amps/protocols" + + printf " %-25s %-10s %-10s %-10s %s\n" "PROTOCOL" "COMMUNITY" "BUNDLED" "WORKSPACE" "STATUS" + printf " %-25s %-10s %-10s %-10s %s\n" "─────────────────────────" "──────────" "──────────" "──────────" "──────" + + # All protocol names across all sources + local all_names="" + for dir in "$community_dir" "$bundled_dir" "$workspace_dir"; do + [ -d "$dir" ] || continue + for f in "$dir"/*.md; do + [ -f "$f" ] || continue + local name=$(basename "$f" .md) + [[ "$name" == "_"* || "$name" == "README" ]] && continue + all_names="$all_names $name" + done + done + all_names=$(echo "$all_names" | tr ' ' '\n' | sort -u) + + for name in $all_names; do + total=$((total + 1)) + + local cv="" bv="" wv="" + [ -f "$community_dir/$name.md" ] && cv=$(grep -m1 "^version:" "$community_dir/$name.md" 2>/dev/null | sed 's/version:\s*//') + [ -f "$bundled_dir/$name.md" ] && bv=$(grep -m1 "^version:" "$bundled_dir/$name.md" 2>/dev/null | sed 's/version:\s*//') + [ -f "$workspace_dir/$name.md" ] && wv=$(grep -m1 "^version:" "$workspace_dir/$name.md" 2>/dev/null | sed 's/version:\s*//') + + local status="✅" + local note="" + + # Check sync between community and bundled + if [ -n "$cv" ] && [ -n "$bv" ] && [ "$cv" != "$bv" ]; then + status="❌" + note="community≠bundled" + issues=$((issues + 1)) + elif [ -n "$cv" ] && [ -z "$bv" ]; then + status="ℹ️ " + note="community only" + elif [ -z "$cv" ] && [ -n "$bv" ]; then + status="ℹ️ " + note="bundled only" + elif [ -z "$cv" ] && [ -z "$bv" ] && [ -n "$wv" ]; then + status="ℹ️ " + note="workspace only" + else + pass_count=$((pass_count + 1)) + fi + + # Check workspace divergence (informational, not an error) + if [ -n "$wv" ] && [ -n "$cv" ] && [ "$wv" != "$cv" ]; then + [ -n "$note" ] && note="$note, " + note="${note}workspace diverged" + fi + + printf " %-25s %-10s %-10s %-10s %s %s\n" \ + "$name" "${cv:-—}" "${bv:-—}" "${wv:-—}" "$status" "$note" + done + + # Check for missing author/license in community + echo "" + echo " Frontmatter completeness (community):" + local fm_issues=0 + for f in "$community_dir"/*.md; do + [ -f "$f" ] || continue + local name=$(basename "$f" .md) + [[ "$name" == "_"* || "$name" == "README" ]] && continue + + local has_author=$(grep -c "^author:" "$f") + local has_license=$(grep -c "^license:" "$f") + local has_version=$(grep -c "^version:" "$f") + + if [ "$has_author" -eq 0 ] || [ "$has_license" -eq 0 ] || [ "$has_version" -eq 0 ]; then + local missing="" + [ "$has_author" -eq 0 ] && missing="author" + [ "$has_license" -eq 0 ] && missing="$missing license" + [ "$has_version" -eq 0 ] && missing="$missing version" + warn "$name — missing: $missing" + fm_issues=$((fm_issues + 1)) + fi + done + [ "$fm_issues" -eq 0 ] && pass "All community protocols have author, license, version" + + # Check CC license footers + echo "" + echo " License footers (community):" + local footer_issues=0 + for f in "$community_dir"/*.md; do + [ -f "$f" ] || continue + local name=$(basename "$f" .md) + [[ "$name" == "_"* || "$name" == "README" ]] && continue + if ! grep -q "Licensed under" "$f"; then + warn "$name — no CC license footer" + footer_issues=$((footer_issues + 1)) + fi + done + [ "$footer_issues" -eq 0 ] && pass "All community protocols have license footers" + + echo "" + [ "$issues" -eq 0 ] && echo " ✅ All protocol versions in sync" || echo " ⚠️ $issues version mismatch(es)" + score $pass_count $total +} + +# ── WEBSITE: Docs sync + content verification ──────────────────────────── + +verify_website() { + header "WEBSITE: Docs sync + content check" + + local issues=0 + local pass_count=0 + local total=0 + + # 1. Check agent-stable docs vs website docs + echo " Doc sync (agent-stable → website):" + local agent_docs="$AGENT_STABLE/docs" + local website_docs="$WEBSITE_REPO/src/content/docs" + + for doc in "$agent_docs"/*.md; do + [ -f "$doc" ] || continue + local name=$(basename "$doc") + total=$((total + 1)) + + if [ -f "$website_docs/$name" ]; then + # Compare content after stripping frontmatter + H1 from both + # (sync-docs.sh strips agent frontmatter + H1, writes Astro frontmatter) + strip_for_compare() { + awk ' + BEGIN { in_fm=0; past_fm=0; stripped_h1=0 } + /^---$/ && !past_fm { in_fm=!in_fm; if(!in_fm) past_fm=1; next } + in_fm { next } + !stripped_h1 && /^# / { stripped_h1=1; next } + !stripped_h1 && /^$/ { next } + { past_fm=1; stripped_h1=1; print } + ' "$1" + } + # Normalize: strip leading/trailing blank lines for comparison + local agent_hash=$(strip_for_compare "$doc" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' | md5 -q 2>/dev/null || strip_for_compare "$doc" | sed '/./,$!d' | md5sum | cut -d' ' -f1) + local website_hash=$(strip_for_compare "$website_docs/$name" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' | md5 -q 2>/dev/null || strip_for_compare "$website_docs/$name" | sed '/./,$!d' | md5sum | cut -d' ' -f1) + + if [ "$agent_hash" = "$website_hash" ]; then + [[ "$COMPACT" == false ]] && pass "$name — in sync" + pass_count=$((pass_count + 1)) + else + fail "$name — DRIFTED" + issues=$((issues + 1)) + fi + else + warn "$name — not on website" + fi + done + + # 2. Check hub-index.json item count vs community repo + echo "" + echo " Hub content:" + if [ -f "$COMMUNITY_REPO/hub-index.json" ]; then + local index_count=$(python3 -c "import json; print(json.load(open('$COMMUNITY_REPO/hub-index.json'))['count'])" 2>/dev/null || echo "?") + local actual_count=0 + for dir in protocols muscles skills automations; do + if [ -d "$COMMUNITY_REPO/$dir" ]; then + actual_count=$((actual_count + $(find "$COMMUNITY_REPO/$dir" -name "*.md" -not -name "README.md" | wc -l | tr -d ' '))) + fi + done + # Count template dirs + if [ -d "$COMMUNITY_REPO/templates" ]; then + actual_count=$((actual_count + $(find "$COMMUNITY_REPO/templates" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' '))) + fi + + total=$((total + 1)) + if [ "$index_count" = "$actual_count" ]; then + pass "hub-index.json: $index_count items (matches repo)" + pass_count=$((pass_count + 1)) + else + fail "hub-index.json: $index_count items, repo has $actual_count" + issues=$((issues + 1)) + fi + fi + + # 3. Check for stale paths in docs + echo "" + # AMPS structure check: docs should use amps/ paths, not legacy flat paths. + # Three generations of layout: + # OLD: .soma/memory/muscles/, .soma/memory/protocols/ + # MIDDLE: .soma/protocols/, .soma/muscles/, .soma/scripts/ (flat, no amps/) + # CURRENT: .soma/amps/protocols/, .soma/amps/muscles/, .soma/amps/scripts/, .soma/amps/automations/ + # This check catches both OLD and MIDDLE references in docs. + echo " Stale path references in docs:" + local stale_paths=0 + + # OLD layout: memory/muscles, memory/protocols, memory/automations + local stale_old=$(grep -rn "memory/muscles\|memory/protocols\|memory/automations" "$agent_docs"/ 2>/dev/null) + if [ -n "$stale_old" ]; then + echo "$stale_old" | while read -r line; do + warn "OLD layout: $(echo "$line" | sed "s|$agent_docs/||")" + done + stale_paths=$((stale_paths + $(echo "$stale_old" | wc -l | tr -d ' '))) + fi + + # MIDDLE layout: .soma/protocols/, .soma/muscles/, .soma/scripts/ without amps/ prefix + # Match patterns like: .soma/protocols/ or ├── protocols/ (in tree diagrams) + # Exclude: .soma/amps/protocols (correct), .protocol-state.json, "protocols exist in" + local stale_mid=$(grep -rn '\.soma/protocols/\|\.soma/muscles/\|\.soma/scripts/' "$agent_docs"/ 2>/dev/null | grep -v 'amps/') + if [ -n "$stale_mid" ]; then + echo "$stale_mid" | while read -r line; do + warn "FLAT layout (needs amps/): $(echo "$line" | sed "s|$agent_docs/||")" + done + stale_paths=$((stale_paths + $(echo "$stale_mid" | wc -l | tr -d ' '))) + fi + + # Tree diagram check: ├── protocols/ or └── scripts/ at ROOT level (not under amps/) + # These appear in directory tree examples that show the old flat structure. + # We check each file individually so we can use awk to detect context: + # if a protocols/muscles/scripts line appears within 5 lines of an amps/ line, + # it's correctly nested and not stale. + for doc in "$agent_docs"/*.md; do + [ -f "$doc" ] || continue + local docname=$(basename "$doc") + # Find tree lines with protocols/muscles/scripts that are NOT under amps/ + # Strategy: awk tracks whether we're "inside" an amps/ tree block + local stale_tree_hits=$(awk ' + /amps\// { amps_line = NR } + /(├──|└──|│.*├──|│.*└──).*(protocols|muscles|scripts)\// { + if (NR - amps_line <= 10 && amps_line > 0) next # nested under amps/ — OK + if (/amps\//) next # line itself mentions amps/ — OK + if (/community\//) next # community repo paths — OK + print NR": "$0 + } + ' "$doc") + if [ -n "$stale_tree_hits" ]; then + echo "$stale_tree_hits" | while read -r line; do + warn "TREE diagram (flat, needs amps/): $docname:$line" + done + stale_paths=$((stale_paths + $(echo "$stale_tree_hits" | wc -l | tr -d ' '))) + fi + done + + [ "$stale_paths" -eq 0 ] && pass "No stale layout paths in docs" + + echo "" + [ "$issues" -eq 0 ] && echo " ✅ Website content verified" || echo " ⚠️ $issues issue(s)" + score $pass_count $total +} + +# ── REPOS: Multi-repo state check ──────────────────────────────────────── + +verify_repos() { + header "REPOS: Multi-repo State" + + local repos=("agent-stable" "agent" "cli" "website" "community" "soma-pro") + + printf " %-15s %-8s %-7s %-50s\n" "REPO" "BRANCH" "DIRTY?" "HEAD COMMIT" + printf " %-15s %-8s %-7s %-50s\n" "───────────────" "────────" "───────" "──────────────────────────────────────────────────" + + for repo_name in "${repos[@]}"; do + local repo="$PROJECT_ROOT/repos/$repo_name" + [ ! -d "$repo/.git" ] && continue + + local branch=$(git -C "$repo" branch --show-current 2>/dev/null || echo "?") + local dirty="" + git -C "$repo" diff --quiet 2>/dev/null || dirty="YES" + git -C "$repo" diff --cached --quiet 2>/dev/null || dirty="STAGED" + [ -z "$dirty" ] && dirty="clean" + local head=$(git -C "$repo" log -1 --format="%h %s" 2>/dev/null | cut -c1-50) + + printf " %-15s %-8s %-7s %s\n" "$repo_name" "$branch" "$dirty" "$head" + done + + # Unpushed commits + echo "" + echo " Unpushed:" + for repo_name in "${repos[@]}"; do + local repo="$PROJECT_ROOT/repos/$repo_name" + [ ! -d "$repo/.git" ] && continue + local unpushed=$(git -C "$repo" log --oneline @{u}..HEAD 2>/dev/null | wc -l | tr -d '[:space:]') + [ "$unpushed" -gt 0 ] && warn "$repo_name: $unpushed unpushed commit(s)" + done +} + +# ── AGENT: Delegate analysis to Haiku sub-agent ───────────────────────── + +verify_agent() { + local task="$1" + shift + local files=("$@") + + header "AGENT: Delegating '$task' to Haiku" + + # Check if we can call Claude API + local api_key="${ANTHROPIC_API_KEY:-}" + if [ -z "$api_key" ]; then + # Try to read from soma secrets + local key_file="$SOMA_DIR/secrets/anthropic.key" + [ -f "$key_file" ] && api_key=$(cat "$key_file") + fi + + if [ -z "$api_key" ]; then + fail "No ANTHROPIC_API_KEY found. Set env var or create .soma/secrets/anthropic.key" + return 1 + fi + + # Build context from files + local context="" + for f in "${files[@]}"; do + if [ -f "$f" ]; then + local relpath=$(echo "$f" | sed "s|$PROJECT_ROOT/||") + context+="--- FILE: $relpath ---\n" + context+=$(head -100 "$f") + context+="\n\n" + fi + done + + # If no files specified, gather based on task + if [ ${#files[@]} -eq 0 ]; then + case "$task" in + drift) + echo " Gathering session logs + preloads for drift analysis..." + for f in "$SOMA_DIR/memory/sessions/"*.md "$SOMA_DIR/memory/preloads/"*.md; do + [ -f "$f" ] && context+="--- $(basename "$f") ---\n$(cat "$f")\n\n" + done + ;; + missed) + echo " Gathering recent commits + session logs to find missed items..." + for repo_name in agent-stable cli; do + local repo="$PROJECT_ROOT/repos/$repo_name" + context+="--- $repo_name commits (last 20) ---\n" + context+="$(git -C "$repo" log --oneline -20 2>/dev/null)\n\n" + done + for f in "$SOMA_DIR/memory/sessions/"*.md; do + [ -f "$f" ] && context+="--- $(basename "$f") ---\n$(cat "$f")\n\n" + done + ;; + patterns) + echo " Gathering session history for pattern detection..." + for f in "$SOMA_DIR/memory/sessions/"*.md "$SOMA_DIR/memory/preloads/"*.md; do + [ -f "$f" ] && context+="--- $(basename "$f") ---\n$(cat "$f")\n\n" + done + ;; + *) + echo " Available tasks: drift, missed, patterns" + echo " Or specify files: soma-verify.sh agent file1 file2 ..." + return 1 + ;; + esac + fi + + # Build the prompt based on task + local system_prompt="You are a code analysis assistant. Be concise and specific. Output structured findings." + local user_prompt="" + + case "$task" in + drift) + user_prompt="Analyze these session logs and preloads for drift — things mentioned as done or changed that might not actually be reflected in the codebase. Look for: settings values mentioned that may be wrong, features claimed as shipped but possibly incomplete, file paths referenced that may have moved. Output a bullet list of potential drift items with severity (high/medium/low)." + ;; + missed) + user_prompt="Compare these git commit logs against session logs. Find: (1) commits that aren't logged in any session, (2) session log claims that don't match any commit, (3) features mentioned in sessions but missing from changelog. Output a structured report." + ;; + patterns) + user_prompt="Analyze these session logs and preloads for recurring patterns: (1) things that keep coming up as issues, (2) workflow friction points, (3) decisions that were revisited, (4) features that were planned but never started. Suggest which patterns should become muscles or protocols." + ;; + *) + user_prompt="$task" + ;; + esac + + user_prompt+="\n\nContext:\n$context" + + echo " Calling Claude haiku..." + + local response=$(curl -s https://api.anthropic.com/v1/messages \ + -H "Content-Type: application/json" \ + -H "x-api-key: $api_key" \ + -H "anthropic-version: 2023-06-01" \ + -d "$(jq -n \ + --arg sys "$system_prompt" \ + --arg msg "$user_prompt" \ + '{ + model: "claude-3-5-haiku-latest", + max_tokens: 2000, + system: $sys, + messages: [{role: "user", content: $msg}] + }')" 2>/dev/null) + + if [ -z "$response" ]; then + fail "API call failed" + return 1 + fi + + # Extract the text content + local text=$(echo "$response" | jq -r '.content[0].text // .error.message // "No response"' 2>/dev/null) + + echo "" + echo "$text" + + # Save analysis to memory + local outfile="$SOMA_DIR/memory/sessions/agent-analysis-$(date +%Y%m%d-%H%M).md" + cat > "$outfile" << EOF +--- +type: analysis +source: haiku-agent +task: $task +created: $(date +%Y-%m-%d) +--- + +# Agent Analysis: $task + +$text +EOF + echo "" + dim "Saved to: $outfile" +} + +# ── Main ───────────────────────────────────────────────────────────────── + +usage() { + echo "" + echo -e "${SOMA_BOLD:-}σ soma-verify${SOMA_NC:-} ${SOMA_DIM:-}— health checks and truth-checking${SOMA_NC:-}" + echo "" + echo -e " ${SOMA_BOLD:-}Verification${SOMA_NC:-}" + echo -e " doc ${SOMA_DIM:-}verify claims in a doc against code${SOMA_NC:-}" + echo -e " sync ${SOMA_DIM:-}cross-ecosystem consistency${SOMA_NC:-}" + echo -e " protocols ${SOMA_DIM:-}protocol versions across all sources${SOMA_NC:-}" + echo -e " website ${SOMA_DIM:-}docs sync + hub content + stale paths${SOMA_NC:-}" + echo -e " streams ${SOMA_DIM:-}pro vs public protection check${SOMA_NC:-}" + echo -e " changelog ${SOMA_DIM:-}verify changelog against commits${SOMA_NC:-}" + echo -e " repos ${SOMA_DIM:-}multi-repo state check${SOMA_NC:-}" + echo "" + echo -e " ${SOMA_BOLD:-}Agent Delegation${SOMA_NC:-}" + echo -e " agent drift ${SOMA_DIM:-}haiku analyzes session logs for drift${SOMA_NC:-}" + echo -e " agent missed ${SOMA_DIM:-}haiku compares commits vs logs${SOMA_NC:-}" + echo -e " agent patterns ${SOMA_DIM:-}haiku finds recurring patterns${SOMA_NC:-}" + echo -e " agent [files...] ${SOMA_DIM:-}custom haiku analysis${SOMA_NC:-}" + echo "" + echo -e " ${SOMA_BOLD:-}Hygiene${SOMA_NC:-}" + echo -e " drift ${SOMA_DIM:-}_public/ ↔ working ↔ community ↔ docs ↔ website${SOMA_NC:-}" + echo -e " hygiene ${SOMA_DIM:-}full sweep: plans + scripts + muscles${SOMA_NC:-}" + echo -e " self-analysis ${SOMA_DIM:-}deep ecosystem health check${SOMA_NC:-}" + echo "" + echo -e " ${SOMA_BOLD:-}See also${SOMA_NC:-}" + echo -e " tests/test-hub.sh ${SOMA_DIM:-}regression: /hub install, fork, share, website${SOMA_NC:-}" + echo -e " tests/test-commands.sh ${SOMA_DIM:-}regression: drop-in commands, init, docs${SOMA_NC:-}" + echo -e " npm test ${SOMA_DIM:-}unit tests (12 suites, 185+ assertions)${SOMA_NC:-}" + echo "" + echo -e " ${SOMA_DIM:-}--compact errors only | --help this help${SOMA_NC:-}" + echo -e " ${SOMA_DIM:-}BSL 1.1 © Curtis Mercier — open source 2027${SOMA_NC:-}" + echo "" +} + +# ═══════════════════════════════════════════════════════════════════════════ +# HYGIENE — unified consolidation sweep +# ═══════════════════════════════════════════════════════════════════════════ + +verify_hygiene() { + echo "" + echo "━━━ HYGIENE: Full Workspace Sweep ━━━" + local issues=0 + + # 1. Plans + echo " ── Plans ──" + if command -v bash &>/dev/null && [[ -f "$SOMA_DIR/amps/scripts/soma-plans.sh" ]]; then + # Budget check + local active_plans=$(find "$SOMA_DIR/docs/plans" "$SOMA_DIR/releases" -name "*.md" \ + -not -path "*/_archive/*" -not -name "_kanban*" -not -name "README*" \ + -exec grep -l "^status: active" {} \; 2>/dev/null | wc -l | tr -d ' ') + if [[ $active_plans -le 12 ]]; then + pass "Plans: ${active_plans}/12 budget" + else + fail "Plans: ${active_plans}/12 budget (over!)" + issues=$((issues + 1)) + fi + + # Complete plans that should be archived + local complete_plans=$(find "$SOMA_DIR/docs/plans" "$SOMA_DIR/releases" -name "*.md" \ + -not -path "*/_archive/*" -not -name "_kanban*" \ + -exec grep -l "^status: complete" {} \; 2>/dev/null | wc -l | tr -d ' ') + if [[ $complete_plans -gt 0 ]]; then + warn "Plans: $complete_plans complete plan(s) should be archived" + issues=$((issues + 1)) + else + pass "Plans: no un-archived complete plans" + fi + else + warn "soma-plans.sh not found — skipping plan checks" + fi + + # 2. Scripts + echo "" + echo " ── Scripts ──" + local deprecated_scripts=0 + local orphan_scripts=0 + for f in "$SOMA_DIR/amps/scripts"/*.sh; do + [[ -f "$f" ]] || continue + local name=$(basename "$f") + + # Deprecated check + if head -5 "$f" | grep -qi "DEPRECATED" 2>/dev/null; then + deprecated_scripts=$((deprecated_scripts + 1)) + warn "Script deprecated: $name" + issues=$((issues + 1)) + fi + + # Orphan check (zero references outside itself) + local base=$(basename "$f" .sh) + local refs=$(grep -rl "$base" "$SOMA_DIR/amps/muscles/" "$SOMA_DIR/amps/scripts/" "$SOMA_DIR/identity.md" 2>/dev/null \ + | grep -v "$f" | wc -l | tr -d ' ') + if [[ $refs -eq 0 ]]; then + orphan_scripts=$((orphan_scripts + 1)) + dim "Script unreferenced: $name ($refs refs)" + fi + done + [[ $deprecated_scripts -eq 0 ]] && pass "Scripts: no deprecated scripts in active dir" + + # 3. Muscles + echo "" + echo " ── Muscles ──" + local stale_muscles=0 + local no_digest=0 + for f in "$SOMA_DIR/amps/muscles"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f" .md) + local status=$(grep -m1 "^status:" "$f" 2>/dev/null | sed 's/status: *//') + [[ "$status" == "archived" ]] && continue + + # Check for digest + if ! grep -q "" "$f" 2>/dev/null; then + no_digest=$((no_digest + 1)) + dim "Muscle missing digest: $name" + fi + + # Check for references to archived/missing scripts + if grep -q "soma-audit\|soma-restart\|soma-self-switch" "$f" 2>/dev/null; then + warn "Muscle references archived scripts: $name" + issues=$((issues + 1)) + stale_muscles=$((stale_muscles + 1)) + fi + done + [[ $stale_muscles -eq 0 ]] && pass "Muscles: no stale script references" + [[ $no_digest -eq 0 ]] && pass "Muscles: all have digests" + + # 4. Sessions & Preloads + echo "" + echo " ── Sessions & Preloads ──" + local session_dir="$SOMA_DIR/memory/sessions" + local preload_dir="$SOMA_DIR/memory/preloads" + local session_issues=0 + + if [[ -d "$session_dir" ]]; then + # Count session files + local session_count=$(find "$session_dir" -name "*.md" -not -name "scratchpad*" -not -name "agent-analysis*" | wc -l | tr -d ' ') + pass "Sessions: $session_count session log files" + + # Check for OLD daily-only format (YYYY-MM-DD.md without session number) + local daily_only=0 + local old_id_format=0 + for f in "$session_dir"/*.md; do + [[ -f "$f" ]] || continue + local fname=$(basename "$f") + [[ "$fname" == "scratchpad.md" || "$fname" == agent-analysis* ]] && continue + # Match exactly YYYY-MM-DD.md (no session suffix at all) + if [[ "$fname" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\.md$ ]]; then + daily_only=$((daily_only + 1)) + # Match old YYYY-MM-DD-XXXXXX.md (session ID, not iterating number) + elif [[ "$fname" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-f0-9]{6}\.md$ ]]; then + old_id_format=$((old_id_format + 1)) + fi + done + if [[ $daily_only -gt 0 ]]; then + warn "Sessions: $daily_only file(s) using old daily format (YYYY-MM-DD.md) — may contain overwritten data" + issues=$((issues + 1)) + session_issues=$((session_issues + 1)) + fi + if [[ $old_id_format -gt 0 ]]; then + warn "Sessions: $old_id_format file(s) using old session-ID format (YYYY-MM-DD-XXXXXX.md) — IDs can collide on restart" + issues=$((issues + 1)) + session_issues=$((session_issues + 1)) + fi + if [[ $daily_only -eq 0 && $old_id_format -eq 0 ]]; then + pass "Sessions: all use per-session naming (YYYY-MM-DD-sNN.md)" + fi + + # Check for empty session files + local empty_sessions=0 + for f in "$session_dir"/*.md; do + [[ -f "$f" ]] || continue + local fname=$(basename "$f") + [[ "$fname" == "scratchpad.md" ]] && continue + local size=$(wc -c < "$f" | tr -d ' ') + if [[ $size -lt 50 ]]; then + empty_sessions=$((empty_sessions + 1)) + dim "Empty session: $fname ($size bytes)" + fi + done + [[ $empty_sessions -eq 0 ]] && pass "Sessions: no empty files" + fi + + if [[ -d "$preload_dir" ]]; then + local preload_count=$(find "$preload_dir" -name "preload-*.md" | wc -l | tr -d ' ') + pass "Preloads: $preload_count preload files" + + # Check for preload/session ratio (more preloads than sessions = possible data loss) + if [[ -d "$session_dir" ]]; then + local today=$(date +%Y-%m-%d) + local today_preloads=$(find "$preload_dir" -name "preload-*${today}*" | wc -l | tr -d ' ') + local today_sessions=$(find "$session_dir" -name "${today}*" -not -name "scratchpad*" -not -name "agent-analysis*" | wc -l | tr -d ' ') + if [[ $today_preloads -gt 0 && $today_sessions -eq 0 ]]; then + warn "Today: $today_preloads preloads but 0 session logs — possible data loss" + issues=$((issues + 1)) + session_issues=$((session_issues + 1)) + elif [[ $today_preloads -gt $((today_sessions + 2)) ]]; then + warn "Today: $today_preloads preloads vs $today_sessions sessions — ratio suggests missing logs" + issues=$((issues + 1)) + session_issues=$((session_issues + 1)) + else + pass "Today: $today_sessions sessions, $today_preloads preloads (ratio OK)" + fi + fi + fi + [[ $session_issues -eq 0 ]] && pass "Sessions: healthy" + + # 5. Protocols + echo "" + echo " ── Protocols ──" + local no_tldr=0 + for f in "$SOMA_DIR/amps/protocols"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f" .md) + [[ "$name" == "_template" || "$name" == "README" ]] && continue + + if ! grep -q "## TL;DR" "$f" 2>/dev/null; then + local lines=$(wc -l < "$f" | tr -d ' ') + if [[ $lines -gt 20 ]]; then + no_tldr=$((no_tldr + 1)) + dim "Protocol missing TL;DR: $name ($lines lines)" + fi + fi + done + [[ $no_tldr -eq 0 ]] && pass "Protocols: all have TL;DRs" + + # 6. Stale Terms + echo "" + echo " ── Stale Terms ──" + local stale_script="$PROJECT_ROOT/repos/agent/scripts/_dev/audits/stale-terms.sh" + if [[ -f "$stale_script" ]]; then + # Run stale-terms against AMPS content (muscles, protocols, scripts headers) + local stale_output + stale_output=$(bash "$stale_script" "" "$SOMA_DIR/amps" 2>&1) + if echo "$stale_output" | grep -q "⚠"; then + local stale_count=$(echo "$stale_output" | grep -c "⚠ \"" || true) + warn "Stale terms: $stale_count deprecated term(s) in AMPS content" + echo "$stale_output" | grep "⚠\| " | head -15 | while read -r line; do dim " $line"; done + issues=$((issues + $stale_count)) + else + pass "Stale terms: no deprecated terminology in AMPS" + fi + else + dim "stale-terms.sh not found — skipping" + fi + + echo "" + if [[ $issues -eq 0 ]]; then + echo " ✅ Workspace hygiene: clean" + else + echo " ⚠️ $issues hygiene issue(s) found" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════ +# SELF-ANALYSIS — deep ecosystem health check +# ═══════════════════════════════════════════════════════════════════════════ + +verify_self_analysis() { + header "SELF-ANALYSIS: Deep Ecosystem Health" + local issues=0 + + # ── Muscle Health ── + echo " ── Muscles ──" + local muscle_dir="$SOMA_DIR/amps/muscles" + local archived_muscles=0 + local missing_fm=0 + local broken_refs=0 + + for f in "$muscle_dir"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f" .md) + [[ "$name" == "_decisions" ]] && continue + + local status=$(grep '^status:' "$f" | head -1 | sed 's/status: *//') + if [[ "$status" == "archived" || "$status" == "deprecated" ]]; then + warn "Archived muscle in active dir: $name" + archived_muscles=$((archived_muscles + 1)) + fi + + # Check required frontmatter — triggers is the canonical activation field (v0.6.2+) + # keywords and topic were merged into triggers — no longer required + if ! grep -q "^triggers:" "$f"; then + dim "$name missing frontmatter: triggers" + missing_fm=$((missing_fm + 1)) + fi + + # Check script references exist (search root + subdirectories, skip _ prefixed) + local scripts=$(grep -oh 'soma-[a-z-]*\.sh' "$f" 2>/dev/null | sort -u) + for s in $scripts; do + local found=false + # Search root level and all non-_ subdirectories (matches boot discovery) + if [[ -f "$SOMA_DIR/amps/scripts/$s" ]]; then + found=true + else + for subdir in "$SOMA_DIR/amps/scripts"/*/; do + [[ "$(basename "$subdir")" == _* ]] && continue + if [[ -f "${subdir}${s}" ]]; then + found=true + break + fi + done + fi + if [[ "$found" == "false" ]]; then + warn "$name references non-existent script: $s" + broken_refs=$((broken_refs + 1)) + fi + done + done + + [[ $archived_muscles -eq 0 ]] && pass "No archived muscles in active directory" + [[ $missing_fm -eq 0 ]] && pass "All muscles have required frontmatter" + [[ $broken_refs -eq 0 ]] && pass "All muscle script references valid" + issues=$((issues + archived_muscles + broken_refs)) + + local active_count=$(ls "$muscle_dir"/*.md 2>/dev/null | xargs grep -l '^status: active' 2>/dev/null | wc -l | tr -d ' ') + dim "$active_count active muscles" + + # ── Cross-location Duplication ── + echo " ── Cross-location Duplication ──" + local grav_soma="$HOME/Gravicity/.soma" + # Skip if the other .soma/ is archived (has ARCHIVED.md) + if [[ -d "$grav_soma" && ! -f "$grav_soma/ARCHIVED.md" ]]; then + # Muscles + local grav_muscles="$grav_soma/memory/muscles" + if [[ -d "$grav_muscles" ]]; then + local dupes=0 + for f in "$grav_muscles"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f") + if [[ -f "$muscle_dir/$name" ]]; then + if ! diff -q "$f" "$muscle_dir/$name" >/dev/null 2>&1; then + dim "Diverged muscle: $name (Gravicity vs meetsoma)" + dupes=$((dupes + 1)) + fi + fi + done + [[ $dupes -eq 0 ]] && pass "No diverged muscles between Gravicity and meetsoma" + [[ $dupes -gt 0 ]] && warn "$dupes diverged muscle(s) between Gravicity and meetsoma" + issues=$((issues + dupes)) + fi + + # Scripts + local grav_scripts="$grav_soma/scripts" + if [[ -d "$grav_scripts" ]]; then + local script_dupes=0 + for f in "$grav_scripts"/*.sh; do + [[ -f "$f" ]] || continue + local name=$(basename "$f") + if [[ -f "$SOMA_DIR/amps/scripts/$name" ]]; then + if ! diff -q "$f" "$SOMA_DIR/amps/scripts/$name" >/dev/null 2>&1; then + dim "Diverged script: $name" + script_dupes=$((script_dupes + 1)) + fi + fi + done + [[ $script_dupes -eq 0 ]] && pass "No diverged scripts between Gravicity and meetsoma" + [[ $script_dupes -gt 0 ]] && warn "$script_dupes diverged script(s) — meetsoma is canonical" + issues=$((issues + script_dupes)) + fi + + # Protocols + local grav_protocols="$grav_soma/protocols" + if [[ -d "$grav_protocols" ]]; then + local proto_dupes=0 + for f in "$grav_protocols"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f") + if [[ -f "$SOMA_DIR/amps/protocols/$name" ]]; then + if ! diff -q "$f" "$SOMA_DIR/amps/protocols/$name" >/dev/null 2>&1; then + dim "Diverged protocol: $name" + proto_dupes=$((proto_dupes + 1)) + fi + fi + done + [[ $proto_dupes -eq 0 ]] && pass "No diverged protocols" + [[ $proto_dupes -gt 0 ]] && warn "$proto_dupes diverged protocol(s) — meetsoma is canonical" + issues=$((issues + proto_dupes)) + fi + else + dim "Gravicity/.soma not found — skipping cross-location check" + fi + + # ── Muscle ↔ Muscle Linkage ── + echo " ── Muscle Linkage ──" + local orphan_muscles=0 + for f in "$muscle_dir"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f" .md) + [[ "$name" == "_decisions" ]] && continue + local status=$(grep '^status:' "$f" | head -1 | sed 's/status: *//') + [[ "$status" != "active" ]] && continue + + # Check if this muscle is referenced by any other muscle, protocol, or script + local refs + refs=$(grep -rl "$name" "$SOMA_DIR/amps/" 2>/dev/null | grep -v "$f" | wc -l | tr -d ' ') || true + refs=${refs:-0} + if [[ "$refs" -eq 0 ]]; then + # Also check identity.md + local id_ref + id_ref=$(grep -c "$name" "$SOMA_DIR/identity.md" 2>/dev/null) || true + id_ref=${id_ref:-0} + if [[ "$id_ref" -eq 0 ]]; then + dim "Orphan muscle (unreferenced): $name" + orphan_muscles=$((orphan_muscles + 1)) + fi + fi + done + [[ $orphan_muscles -eq 0 ]] && pass "All active muscles are cross-referenced" + [[ $orphan_muscles -gt 0 ]] && dim "$orphan_muscles muscle(s) not referenced by other AMPS content" + + # ── Session Naming Consistency ── + echo " ── Session Naming ──" + local sess_dir="$SOMA_DIR/memory/sessions" + local old_format=0 + local new_format=0 + for f in "$sess_dir"/*.md; do + [[ -f "$f" ]] || continue + local name=$(basename "$f") + if [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}-s[0-9]+-[a-f0-9]+\.md$ ]]; then + new_format=$((new_format + 1)) + elif [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\.md$ ]]; then + old_format=$((old_format + 1)) + fi + done + [[ $old_format -gt 0 ]] && dim "$old_format session(s) using old daily format (may have overwrites)" + dim "$new_format session(s) using new sNN-hex format" + local total_sessions=$(ls "$sess_dir"/*.md 2>/dev/null | wc -l | tr -d ' ') + dim "$total_sessions total session files" + + # ── Summary ── + echo "" + if [[ $issues -eq 0 ]]; then + pass "Self-analysis: clean" + else + warn "$issues issue(s) found — see report for details" + fi +} + +## ── COPY: Marketing copy vs source of truth ───────────────────────────── + +verify_copy() { + header "COPY: Website marketing vs source of truth" + + local issues=0 + local pass_count=0 + local total=0 + + local WEBSITE="$PROJECT_ROOT/repos/website/src" + local COMMUNITY="$PROJECT_ROOT/repos/community" + local AGENT="$PROJECT_ROOT/repos/agent" + + # ── 1. Count verification ── + echo " Hub counts (ecosystem page vs community repo):" + + # Protocol count + local page_protocols=$(grep 'stat-number' "$WEBSITE/pages/ecosystem/index.astro" | sed 's/.*stat-number">//;s/<.*//' | head -1) + local actual_protocols=$(find "$COMMUNITY/protocols" -name "*.md" -not -name "README.md" 2>/dev/null | wc -l | tr -d ' ') + total=$((total + 1)) + if [ "$page_protocols" = "$actual_protocols" ]; then + pass "Protocols: $page_protocols (matches)" + pass_count=$((pass_count + 1)) + else + fail "Protocols: page says $page_protocols, hub has $actual_protocols" + issues=$((issues + 1)) + fi + + # Muscle count + local page_muscles=$(grep 'stat-number' "$WEBSITE/pages/ecosystem/index.astro" | sed 's/.*stat-number">//;s/<.*//' | sed -n '2p') + local actual_muscles=$(find "$COMMUNITY/muscles" -name "*.md" -not -name "README.md" 2>/dev/null | wc -l | tr -d ' ') + total=$((total + 1)) + if [ "$page_muscles" = "$actual_muscles" ]; then + pass "Muscles: $page_muscles (matches)" + pass_count=$((pass_count + 1)) + else + fail "Muscles: page says $page_muscles, hub has $actual_muscles" + issues=$((issues + 1)) + fi + + # Template count + local page_templates=$(grep 'stat-number' "$WEBSITE/pages/ecosystem/index.astro" | sed 's/.*stat-number">//;s/<.*//' | sed -n '3p') + local actual_templates=$(find "$COMMUNITY/templates" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') + total=$((total + 1)) + if [ "$page_templates" = "$actual_templates" ]; then + pass "Templates: $page_templates (matches)" + pass_count=$((pass_count + 1)) + else + fail "Templates: page says $page_templates, hub has $actual_templates" + issues=$((issues + 1)) + fi + + # ── 2. Named content verification ── + echo "" + echo " Named content (do referenced items exist?):" + + # Check muscle names mentioned in ecosystem + homepage + for page in "$WEBSITE/pages/index.astro" "$WEBSITE/pages/ecosystem/index.astro"; do + local pagename=$(basename $(dirname "$page"))/$(basename "$page") + # Extract code tags that look like content names (inside layer/muscle sections) + local muscle_refs=$(grep -o '[a-z][-a-z]*' "$page" | sed 's///;s/<\/code>//' | grep -v "soma-\|\.ts\|\.md\|\.json\|/\|--\|npm\|git\|init\|install\|list\|fork\|template\|export\|vote" | sort -u) + for name in $muscle_refs; do + # Check if it exists in community or agent bundled + local found=false + [ -f "$COMMUNITY/muscles/$name.md" ] && found=true + [ -f "$COMMUNITY/protocols/$name.md" ] && found=true + [ -f "$AGENT/.soma/protocols/$name.md" ] && found=true + [ -f "$AGENT/.soma/muscles/$name.md" ] && found=true + # Known non-content names (extension names, commands, etc.) + case "$name" in + breath-cycle|frontmatter|heat-tracking|workflow|quality-standards) found=true ;; # abbreviations of real protocols + exhale|breathe|rest|soma|pin|kill|scratch|auto-breathe|auto-commit|guard-status) found=true ;; # commands + devops|writer|architect) found=true ;; # template names + logo-creator|favicon-gen|remotion|remotion-best-practices) found=true ;; # Pi skills (inherited, work in Soma) + session-start|post-commit|pre-deploy|post-ship-review|dev-session) found=true ;; # automation trigger examples (descriptive, not installable) + esac + total=$((total + 1)) + if $found; then + pass_count=$((pass_count + 1)) + else + warn "$pagename references '$name' — not found in hub or bundled" + issues=$((issues + 1)) + fi + done + done + + # ── 3. Roadmap staleness ── + echo "" + echo " Roadmap (shipped items still in 'upcoming'?):" + local roadmap_page="$WEBSITE/pages/roadmap/index.astro" + local shipped_in_upcoming=$(grep -B2 "'Shipped'" "$roadmap_page" | grep "name:" | sed "s/.*name: '//;s/'.*//") + total=$((total + 1)) + if [ -z "$shipped_in_upcoming" ]; then + pass "No shipped items in 'What's Next'" + pass_count=$((pass_count + 1)) + else + fail "Shipped items still in upcoming: $shipped_in_upcoming" + issues=$((issues + 1)) + fi + + # ── 4. Layer framing check ── + echo "" + echo " Layer framing:" + total=$((total + 1)) + local four_refs=$(grep -c "four layers\|Four layers\|four types\|Four Types" "$WEBSITE/pages/index.astro" "$WEBSITE/pages/ecosystem/index.astro" 2>/dev/null | grep -v ":0$" | wc -l | tr -d ' ') + if [ "$four_refs" -gt 0 ]; then + warn "'Four layers' language found in $four_refs page(s) — Soma has AMPS (4) + Extensions + Skills" + issues=$((issues + 1)) + else + pass "No stale 'four layers' framing" + pass_count=$((pass_count + 1)) + fi + + # ── 5. Enterprise/paywall language ── + total=$((total + 1)) + local enterprise_refs=$(grep -rn "enterprise\|free tier\|Free tier" "$WEBSITE/pages/" --include="*.astro" 2>/dev/null | wc -l | tr -d ' ') + if [ "$enterprise_refs" -gt 0 ]; then + warn "Enterprise/free-tier language found ($enterprise_refs references)" + grep -rn "enterprise\|free tier\|Free tier" "$WEBSITE/pages/" --include="*.astro" 2>/dev/null | while read -r line; do + echo " $line" + done + issues=$((issues + 1)) + else + pass "No stale enterprise/paywall language" + pass_count=$((pass_count + 1)) + fi + + echo "" + [ "$issues" -eq 0 ] && echo " ✅ Copy verified" || echo " ⚠️ $issues issue(s)" + score $pass_count $total +} + +# ═══════════════════════════════════════════════════════════════════════════ +# DRIFT: _public/ ↔ working copy ↔ community repo sync check +# ═══════════════════════════════════════════════════════════════════════════ + +# Strip runtime fields that are always different per-project (heat, loads, etc.) +# Used before diffing to separate real content drift from expected divergence. +strip_runtime() { + grep -v "^heat:\|^loads:\|^heat-default:\|^last-run:\|^runs:" "$1" +} + +# Strip hub metadata fields (tier, license, author, version, breadcrumb) +# Community is source of truth for these — not a sync concern. +strip_hub_meta() { + grep -v "^tier:\|^license:\|^author:\|^version:\|^breadcrumb:" "$1" +} + +# Content-only diff: strip runtime + hub meta, compare what remains. +# Returns 0 if same content, 1 if different. +content_diff() { + local a="$1" b="$2" + diff -q <(strip_runtime "$a" | strip_hub_meta) <(strip_runtime "$b" | strip_hub_meta) > /dev/null 2>&1 +} + +# Direction detection: which copy is newer based on updated: frontmatter date. +# Returns: "a_ahead", "b_ahead", "same", or "unknown". +drift_direction() { + local a="$1" b="$2" + local date_a date_b + date_a=$(grep "^updated:" "$a" 2>/dev/null | head -1 | sed 's/updated: *//') + date_b=$(grep "^updated:" "$b" 2>/dev/null | head -1 | sed 's/updated: *//') + if [[ -z "$date_a" || -z "$date_b" ]]; then + echo "unknown" + elif [[ "$date_a" > "$date_b" ]]; then + echo "a_ahead" + elif [[ "$date_b" > "$date_a" ]]; then + echo "b_ahead" + else + echo "same" + fi +} + +verify_drift() { + header "AMPS _public/ Drift Check" + local drifted=0 synced=0 missing=0 extra=0 + + # ── Protocols: working → _public ── + echo "" + echo " Protocols (working → _public):" + local proto_dir="$SOMA_DIR/amps/protocols" + local proto_pub="$proto_dir/_public" + if [[ -d "$proto_pub" ]]; then + for f in "$proto_pub"/*.md; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + [[ "$name" == "README.md" ]] && continue + local working="$proto_dir/$name" + if [[ -f "$working" ]]; then + if content_diff "$f" "$working"; then + ((synced++)) + else + local dir=$(drift_direction "$working" "$f") + local arrow="" + [[ "$dir" == "a_ahead" ]] && arrow=" (working ahead)" + [[ "$dir" == "b_ahead" ]] && arrow=" (_public ahead)" + fail "$name — drifted$arrow" + ((drifted++)) + fi + else + dim "$name — _public only (shipped default, no working override)" + ((extra++)) + fi + done + fi + + # ── Muscles: working → _public ── + echo "" + echo " Muscles (working → _public):" + local muscle_dir="$SOMA_DIR/amps/muscles" + local muscle_pub="$muscle_dir/_public" + if [[ -d "$muscle_pub" ]]; then + for f in "$muscle_pub"/*.md; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + [[ "$name" == "README.md" ]] && continue + local working="$muscle_dir/$name" + if [[ -f "$working" ]]; then + if content_diff "$f" "$working"; then + ((synced++)) + else + local dir=$(drift_direction "$working" "$f") + local arrow="" + [[ "$dir" == "a_ahead" ]] && arrow=" (working ahead)" + [[ "$dir" == "b_ahead" ]] && arrow=" (_public ahead)" + fail "$name — drifted$arrow" + ((drifted++)) + fi + else + dim "$name — _public only (shipped default)" + ((extra++)) + fi + done + fi + + # ── Community repo sync ── + echo "" + echo " Community repo sync:" + if [[ -d "$COMMUNITY_REPO" ]]; then + # Protocols + for f in "$proto_pub"/*.md; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + [[ "$name" == "README.md" ]] && continue + local community="$COMMUNITY_REPO/protocols/$name" + if [[ -f "$community" ]]; then + if content_diff "$f" "$community"; then + ((synced++)) + else + local dir=$(drift_direction "$f" "$community") + local arrow="" + [[ "$dir" == "a_ahead" ]] && arrow=" (_public ahead)" + [[ "$dir" == "b_ahead" ]] && arrow=" (community ahead)" + fail "protocols/$name — drifted$arrow" + ((drifted++)) + fi + else + warn "protocols/$name — not in community repo" + ((missing++)) + fi + done + # Muscles + for f in "$muscle_pub"/*.md; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + [[ "$name" == "README.md" ]] && continue + local community="$COMMUNITY_REPO/muscles/$name" + if [[ -f "$community" ]]; then + if content_diff "$f" "$community"; then + ((synced++)) + else + local dir=$(drift_direction "$f" "$community") + local arrow="" + [[ "$dir" == "a_ahead" ]] && arrow=" (_public ahead)" + [[ "$dir" == "b_ahead" ]] && arrow=" (community ahead)" + fail "muscles/$name — drifted$arrow" + ((drifted++)) + fi + fi + done + else + warn "Community repo not found at $COMMUNITY_REPO" + fi + + # ── Body _public → agent repo ── + echo "" + echo " Body files (body/_public → agent repo):" + local body_pub="$SOMA_DIR/body/_public" + local agent_body="$AGENT_DEV/body/_public" + if [[ -d "$body_pub" && -d "$agent_body" ]]; then + for f in "$body_pub"/*.md; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + local agent="$agent_body/$name" + if [[ -f "$agent" ]]; then + if ! diff -q "$f" "$agent" > /dev/null 2>&1; then + fail "body/$name — agent repo drifted" + ((drifted++)) + else + ((synced++)) + fi + else + warn "body/$name — not in agent repo" + ((missing++)) + fi + done + fi + + # ── Docs: agent → website ── + echo "" + echo " Docs (agent → website):" + local agent_docs="$AGENT_DEV/docs" + local website_docs="$WEBSITE_REPO/src/content/docs" + if [[ -d "$agent_docs" && -d "$website_docs" ]]; then + for f in "$agent_docs"/*.md; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + local site="$website_docs/$name" + if [[ -f "$site" ]]; then + # Strip Astro frontmatter from website copy for content comparison + local agent_body=$(awk 'BEGIN{fm=0;past=0} /^---$/ && !past{fm=!fm;if(!fm)past=1;next} fm{next} {past=1;print}' "$f") + local site_body=$(awk 'BEGIN{fm=0;past=0} /^---$/ && !past{fm=!fm;if(!fm)past=1;next} fm{next} {past=1;print}' "$site") + if [[ "$agent_body" != "$site_body" ]]; then + fail "docs/$name — website stale" + ((drifted++)) + else + ((synced++)) + fi + else + warn "docs/$name — not on website" + ((missing++)) + fi + done + else + dim "Docs check skipped (agent or website docs dir not found)" + fi + + # ── Scripts: agent repo → working → global ── + echo "" + echo " Scripts (agent repo → working → global):" + local agent_scripts="$AGENT_DEV/scripts" + local working_scripts="$SOMA_DIR/amps/scripts" + local global_scripts="$HOME/.soma/amps/scripts" + if [[ -d "$agent_scripts" ]]; then + for f in "$agent_scripts"/soma-*.sh; do + [[ ! -f "$f" ]] && continue + local name=$(basename "$f") + # Check working copy + local work="$working_scripts/$name" + if [[ -f "$work" ]]; then + if ! diff -q "$f" "$work" > /dev/null 2>&1; then + local lines=$(diff "$f" "$work" | grep -c '^[<>]') + fail "$name — working drifted ($lines lines)" + ((drifted++)) + else + ((synced++)) + fi + fi + # Check global copy + local glob="$global_scripts/$name" + if [[ -f "$glob" ]]; then + if ! diff -q "$f" "$glob" > /dev/null 2>&1; then + local lines=$(diff "$f" "$glob" | grep -c '^[<>]') + fail "$name — global drifted ($lines lines)" + ((drifted++)) + else + ((synced++)) + fi + fi + done + else + dim "Script check skipped (agent scripts dir not found)" + fi + + # ── Stale content check ── + echo "" + echo " Stale references:" + local stale_count=0 + # Check for identity.md refs (should be SOMA.md) + local id_refs=$(grep -rn "identity\.md" "$agent_docs"/*.md 2>/dev/null | grep -v "git-identity\|SOMA\|body/\|legacy\|fallback\|replaces" | wc -l | tr -d ' ') + if [[ "$id_refs" -gt 0 ]]; then + fail "$id_refs stale identity.md refs in docs (should be SOMA.md)" + ((stale_count++)) + fi + # Check for gendered pronouns + local pronoun_refs=$(grep -in " she \| her \| she's " "$agent_docs"/*.md 2>/dev/null | wc -l | tr -d ' ') + if [[ "$pronoun_refs" -gt 0 ]]; then + fail "$pronoun_refs gendered pronoun refs in docs" + ((stale_count++)) + fi + # Check for breadcrumb: (should be description:) + local bc_refs=$(grep -n "breadcrumb:" "$agent_docs"/*.md 2>/dev/null | wc -l | tr -d ' ') + if [[ "$bc_refs" -gt 0 ]]; then + fail "$bc_refs stale breadcrumb: refs in docs (should be description:)" + ((stale_count++)) + fi + if [[ "$stale_count" -eq 0 ]]; then + pass "No stale references" + fi + + echo "" + if [[ $drifted -eq 0 ]]; then + pass "All synced ($synced files)" + else + fail "$drifted drifted, $synced synced, $missing missing, $extra _public-only" + echo "" + echo " Fix: sync working → _public, then _public → community + agent repos" + echo " Docs: cd repos/website && bash scripts/sync-docs.sh" + fi +} + +case "${1:-}" in + doc) shift; verify_doc "$@" ;; + sync) verify_sync ;; + streams) verify_streams ;; + changelog) shift; verify_changelog "$@" ;; + protocols) verify_protocols ;; + website) verify_website ;; + copy) verify_copy ;; + repos) verify_repos ;; + agent) shift; verify_agent "$@" ;; + hygiene) verify_hygiene ;; + self-analysis) verify_self_analysis ;; + drift) verify_drift ;; + # Redirects to soma-query.sh + topic|search|related|sessions|impact|history) + echo "σ Moved to soma-query.sh. Running: soma-query.sh $*" + bash "$SCRIPT_DIR/soma-query.sh" "$@" ;; + --help|-h) usage ;; + *) usage; exit 1 ;; +esac