diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 0000000..7b819b6
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1,138 @@
+# Global Rules
+
+# Auto-generated from ~/.cursor/rules/ (alwaysApply: true files only).
+# Do not edit manually. Re-generate via convention-sync.
+
+---
+
+## answer-questions-first
+
+# Answer Questions Before Acting
+
+Before using any code editing tools, scan the user's message for `?` characters and determine if it's a question.
+
+- **Ignore** `?` inside code, URLs or query parameters (e.g. `?param=x`, `?key=value` , `const x = ifTrue ? 'yes' : 'no'`)
+- **Treat all other `?`** as question statements, if they appear to be questions.
+
+If questions are detected:
+
+1. Read `~/.cursor/skills/q/SKILL.md` and follow its workflow to answer every question.
+2. **Workflow context**: If a skill was invoked earlier in this conversation, note which one. When a question or critique references agent behavior from that execution, load the skill definition before answering and evaluate whether the skill should have governed that behavior. If it should have but didn't, that's a workflow gap — treat it as the primary concern per `fix-workflow-first.mdc`.
+3. Do **not** edit files, create files, or run mutating commands until the user responds.
+4. Only proceed with implementation after the user permits it in a follow-up message.
+
+---
+
+## load-standards-by-filetype
+
+Load language-specific coding standards before editing or investigating lint/type errors in files, without redundant reads.
+
+
+Before using any code editing tool on a file OR investigating lint/type errors in that file type, check if the matching standards rule is already present in `cursor_rules_context`. Only read the rule file if it is NOT already in context.
+If the rule is not in context, read it using the Read tool and follow its contents BEFORE making the edit or investigating the error.
+
+
+
+
+| File glob | Standards file |
+|---|----|
+| `**/*.ts`,`**/*.tsx` | `~/.cursor/rules/typescript-standards.mdc` |
+
+
+
+---
+
+## no-format-lint
+
+# No Manual Formatting or Lint Fixing
+
+- Do NOT run `yarn lint`, `yarn fix`, `yarn verify`, or any lint/format shell commands unless explicitly asked.
+- Do NOT manually fix formatting issues (whitespace, quotes, semicolons, trailing commas, line length). The `lint-commit.sh` script runs `eslint --fix` (including Prettier) before each commit.
+- Only use `ReadLints` to check for logical or type errors, not formatting. If the only lint errors are formatting-related, ignore them.
+- Focus tokens on correctness and logic, not style.
+
+---
+
+## workflow-halt-on-error
+
+
+
+All workflow-related skill definitions (`*.md` / `SKILL.md`) and workflow companion scripts (`*.sh`) are sourced from `~/.cursor/`. When executing skills, prefer explicit `~/.cursor/...` paths and do not assume repo-local workflow files unless the skill explicitly points to one.
+
+When a skill mentions a script path, resolve it under `~/.cursor/skills//scripts/` unless the skill explicitly specifies an absolute path elsewhere. Do not assume repo-relative `scripts/` paths without verifying the skill directory contents.
+
+When ANY shell command fails (non-zero exit code) while executing an active skill workflow, a delegated subskill from that workflow, or a companion-script step required by that workflow (except where explicitly allowed by `auto-fix-verification-failures` or `companion-script-nonzero-contracts`):
+1. **STOP** — do not retry, work around, substitute, or continue the workflow.
+2. **Report** — show the user the exact command, exit code, and error output.
+3. **Diagnose** — classify the failure: missing tool (`command not found`), wrong path, permissions, or logic error.
+4. **Evaluate workflow** — if the failure reveals a gap in a skill definition, follow the fix-workflow-first rules below.
+5. **Wait** — do not resume until the user responds.
+
+
+When a workflow gap is discovered in an active skill definition:
+1. **Stop immediately** — do not continue the current task or apply any workaround.
+2. **Identify the root cause** in the skill (`.cursor/skills/*/SKILL.md`) definition.
+3. **Propose the fix** to the user and wait for approval before proceeding.
+4. **Fix the skill** using `/author` after approval.
+5. **Resume the original task** only after the skill is updated.
+
+Fixing the skill takes **absolute priority** over all other actions — including workarounds, continuing the original task, or applying temporary fixes. Do NOT apply workarounds or manual fixes before proposing the skill update. The correct sequence is: identify gap → propose fix → get approval → apply fix → then resume original task. This applies to all workflow issues — missed steps, incorrect output, wrong tool usage, shell failures, formatting problems, etc. The skill is the source of truth; patching around it creates drift.
+
+
+These workflow halt rules are for skill-driven execution, especially hands-off/orchestrated skills and their dependencies. They do not automatically apply to ad hoc exploration, incidental verification, or low-risk authoring work unless that command is part of an active skill contract.
+
+Exception to `halt-on-error`: For verification/code-quality failures where diagnostics are explicit and local, continue automatically with bounded remediation.
+
+Allowed auto-fix scope:
+- TypeScript/compiler failures (`tsc`) with clear file/line diagnostics
+- Lint failures (`eslint`) with clear file/line diagnostics
+- Test failures (`jest`/`yarn test`) when stack traces or assertion output identify failing test files
+- `verify-repo.sh` code-step failures that resolve to one of the above
+
+Required behavior:
+1. Briefly log rationale: failure type, affected files, and why scope is unambiguous.
+2. Apply the minimal fix in the failing repo.
+3. Re-run the failing verification step.
+4. Limit to 2 remediation attempts; if still failing or scope expands, fall back to `halt-on-error`.
+
+Never auto-fix:
+- Missing tools/auth (`command not found`, `PROMPT_GH_AUTH`)
+- Wrong path/permissions
+- Companion script contract/usage failures
+- Unexpected exit codes from orchestrator scripts
+- Any failure requiring destructive operations or workflow bypasses
+
+
+Respect documented companion script exit-code contracts. Non-zero does NOT always mean fatal.
+
+For `~/.cursor/skills/im/scripts/lint-warnings.sh`:
+- `0` = no remaining lint findings after auto-fix
+- `1` = remaining lint findings after auto-fix (expected actionable state)
+- `2` = execution error (fatal)
+
+Required behavior:
+1. If exit `1`, continue workflow by fixing the remaining lint findings before implementation.
+2. If the script auto-fixes pre-existing lint issues, commit those changes in a separate lint-fix commit immediately before feature commits, even if no findings remain.
+3. If exit `2`, apply `halt-on-error`.
+
+
+Do NOT silently substitute an alternative tool or approach when a command fails. If `rg` is not found, do not fall back to `grep`. If a script exits non-zero, do not manually replicate what the script does. The failure is the signal — report it.
+
+
+
+
+
+Scan the user's message for `/word` tokens. A token is a **command invocation** when ALL of:
+- `/word` is preceded by whitespace, a newline, or is at the start of the message
+- `word` contains only lowercase letters and hyphens (e.g., `/im`, `/pr-create`, `/author`)
+- `/word` is NOT inside a file path, URL, or code block
+
+When detected:
+1. Read `~/.cursor/skills//SKILL.md` and follow it immediately.
+2. If the file does not exist, inform the user: "Skill `/` not found in `~/.cursor/skills/`."
+
+**Ignore `/`** in: file paths (`/Users/...`, `~/...`), URLs (`https://...`), mid-word (`and/or`), backticks/code blocks.
+
+
+
+
diff --git a/.claude/skills b/.claude/skills
new file mode 120000
index 0000000..8574c4f
--- /dev/null
+++ b/.claude/skills
@@ -0,0 +1 @@
+../.cursor/skills
\ No newline at end of file
diff --git a/.cursor/.syncignore b/.cursor/.syncignore
new file mode 100644
index 0000000..10363f6
--- /dev/null
+++ b/.cursor/.syncignore
@@ -0,0 +1,6 @@
+# Files to exclude from convention-sync (one glob per line)
+# Patterns match against relative paths like: commands/foo.sh, rules/bar.mdc
+
+# WIP commands
+commands/hudl.md
+commands/github-pr-hudl.sh
diff --git a/.cursor/commands/github-pr-hudl.sh b/.cursor/commands/github-pr-hudl.sh
new file mode 100755
index 0000000..2ec3db7
--- /dev/null
+++ b/.cursor/commands/github-pr-hudl.sh
@@ -0,0 +1,389 @@
+#!/usr/bin/env bash
+# github-pr-hudl.sh — Fetch comprehensive GitHub PR activity for a given day.
+# Detects multiple action categories for HUDL standup generation.
+#
+# Categories:
+# - created: PRs created by user on target date
+# - committed: PRs where user pushed commits on target date
+# - addressed: PRs with commits after receiving review comments
+# - reviewed: PRs by others that user reviewed on target date
+# - commented: PRs where user posted comments on target date
+# - approved: PRs that have approval (for Goals Today)
+# - blocked: PRs blocked by CI or changes requested (for Handoffs)
+# - open_prs: All open PRs for debug section
+#
+# Usage:
+# github-pr-hudl.sh [--date YYYY-MM-DD]
+#
+# Requires: gh CLI authenticated, ASANA_TOKEN for cross-referencing
+#
+# Output: JSON with date, username, day_label, and category arrays
+set -euo pipefail
+
+TARGET_DATE=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --date) TARGET_DATE="$2"; shift 2 ;;
+ *) echo "Unknown: $1" >&2; exit 1 ;;
+ esac
+done
+
+if ! command -v gh &>/dev/null; then
+ echo "Error: gh CLI not installed" >&2; exit 1
+fi
+if ! gh auth status &>/dev/null 2>&1; then
+ echo "PROMPT_GH_AUTH" >&2; exit 2
+fi
+
+USERNAME=$(gh api user --jq '.login')
+ASANA_TOKEN="${ASANA_TOKEN:-}"
+
+export TARGET_DATE USERNAME ASANA_TOKEN
+
+python3 - << 'PYEOF'
+import json, os, re, subprocess, sys, urllib.request, urllib.error
+from datetime import date, timedelta
+
+USERNAME = os.environ["USERNAME"]
+TARGET_DATE_STR = os.environ.get("TARGET_DATE", "")
+ASANA_TOKEN = os.environ.get("ASANA_TOKEN", "")
+
+today = date.today()
+
+if TARGET_DATE_STR:
+ # Explicit date: use single day
+ target_start = date.fromisoformat(TARGET_DATE_STR)
+ target_end = target_start
+ day_label = target_start.strftime("%A")
+else:
+ # Default: from last workday until now
+ if today.weekday() == 0: # Monday
+ target_start = today - timedelta(days=3) # Friday
+ target_end = today
+ day_label = "since Friday"
+ else:
+ target_start = today - timedelta(days=1) # Yesterday
+ target_end = today
+ day_label = "since yesterday"
+
+TARGET_START_STR = target_start.isoformat()
+TARGET_END_STR = target_end.isoformat()
+
+
+def gh_graphql(query, variables):
+ args = ["gh", "api", "graphql", "-f", f"query={query}"]
+ for k, v in variables.items():
+ args.extend(["-f", f"{k}={v}"])
+ result = subprocess.run(args, capture_output=True, text=True)
+ if result.returncode != 0:
+ print(f"GH_ERROR: {result.stderr[:300]}", file=sys.stderr)
+ return {"data": {"search": {"nodes": []}}}
+ parsed = json.loads(result.stdout)
+ if "errors" in parsed:
+ print(f"GQL_ERROR: {json.dumps(parsed['errors'][:2])}", file=sys.stderr)
+ return parsed
+
+
+def extract_asana_gid(body):
+ if not body:
+ return None
+ m = re.search(r'asana\.com/\S*/(\d{10,})', body)
+ return m.group(1) if m else None
+
+
+def fetch_asana_status(gid):
+ """Fetch Asana task status via API."""
+ if not ASANA_TOKEN or not gid:
+ return None
+ try:
+ req = urllib.request.Request(
+ f"https://app.asana.com/api/1.0/tasks/{gid}?opt_fields=custom_fields.gid,custom_fields.display_value",
+ headers={"Authorization": f"Bearer {ASANA_TOKEN}"}
+ )
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ data = json.loads(resp.read())
+ for f in data.get("data", {}).get("custom_fields", []):
+ if f.get("gid") == "1190660107346181": # Status field
+ return f.get("display_value")
+ except Exception as e:
+ print(f"ASANA_ERROR: {e}", file=sys.stderr)
+ return None
+
+
+# --- Main GraphQL query for user's activity ---
+QUERY_USER_PRS = """
+query($search: String!) {
+ search(query: $search, type: ISSUE, first: 100) {
+ nodes {
+ ... on PullRequest {
+ number
+ title
+ url
+ body
+ state
+ createdAt
+ repository { nameWithOwner }
+ reviews(last: 50) {
+ nodes {
+ author { login }
+ state
+ submittedAt
+ }
+ }
+ commits(last: 50) {
+ nodes {
+ commit {
+ committedDate
+ author { user { login } }
+ }
+ }
+ }
+ comments(last: 50) {
+ nodes {
+ author { login }
+ createdAt
+ }
+ }
+ reviewThreads(first: 50) {
+ nodes {
+ comments(first: 10) {
+ nodes {
+ author { login }
+ createdAt
+ }
+ }
+ }
+ }
+ reviewDecision
+ statusCheckRollup {
+ state
+ }
+ }
+ }
+ }
+}
+"""
+
+# Search 1: User's own PRs (open or recently updated)
+search_authored = f"is:pr author:{USERNAME} updated:>={TARGET_START_STR} sort:updated"
+authored_raw = gh_graphql(QUERY_USER_PRS, {"search": search_authored})
+
+# Search 2: PRs reviewed by user
+search_reviewed = f"is:pr reviewed-by:{USERNAME} -author:{USERNAME} updated:>={TARGET_START_STR} sort:updated"
+reviewed_raw = gh_graphql(QUERY_USER_PRS, {"search": search_reviewed})
+
+# Search 3: PRs where user commented
+search_commented = f"is:pr commenter:{USERNAME} -author:{USERNAME} updated:>={TARGET_START_STR} sort:updated"
+commented_raw = gh_graphql(QUERY_USER_PRS, {"search": search_commented})
+
+search_count = 0
+for raw in [authored_raw, reviewed_raw, commented_raw]:
+ search_count += len(raw.get("data", {}).get("search", {}).get("nodes", []))
+
+print(f"Searched {search_count} PR candidates", file=sys.stderr)
+
+# --- Process authored PRs ---
+created = []
+committed = []
+addressed = []
+approved = []
+blocked = []
+open_prs = []
+
+seen_prs = set()
+
+for node in authored_raw.get("data", {}).get("search", {}).get("nodes", []):
+ if not node or "number" not in node:
+ continue
+
+ pr_key = f"{node['repository']['nameWithOwner']}#{node['number']}"
+ if pr_key in seen_prs:
+ continue
+ seen_prs.add(pr_key)
+
+ asana_gid = extract_asana_gid(node.get("body"))
+ asana_status = fetch_asana_status(asana_gid) if asana_gid else None
+
+ pr_entry = {
+ "pr_number": node["number"],
+ "pr_title": node["title"],
+ "pr_url": node["url"],
+ "repo": node["repository"]["nameWithOwner"],
+ "asana_gid": asana_gid,
+ "asana_status": asana_status,
+ }
+
+ # Check if created within target window
+ created_at = (node.get("createdAt") or "")[:10]
+ if TARGET_START_STR <= created_at <= TARGET_END_STR:
+ created.append(pr_entry)
+
+ # Check for human reviews before target window
+ has_prior_review = False
+ for r in (node.get("reviews") or {}).get("nodes", []):
+ if not r or not r.get("author"):
+ continue
+ reviewer = r["author"].get("login", "")
+ if reviewer == USERNAME or "[bot]" in reviewer:
+ continue
+ submitted = (r.get("submittedAt") or "")[:10]
+ if submitted < TARGET_START_STR and r.get("state") in ("CHANGES_REQUESTED", "COMMENTED"):
+ has_prior_review = True
+ break
+
+ # Check for commits within target window
+ commits_in_window = []
+ for c in (node.get("commits") or {}).get("nodes", []):
+ commit = (c or {}).get("commit", {})
+ committed_date = (commit.get("committedDate") or "")[:10]
+ commit_user = ((commit.get("author") or {}).get("user") or {}).get("login", "")
+ if TARGET_START_STR <= committed_date <= TARGET_END_STR and commit_user == USERNAME:
+ commits_in_window.append(commit)
+
+ if commits_in_window:
+ entry_with_count = {**pr_entry, "commit_count": len(commits_in_window)}
+ # Only count as addressed/committed if PR wasn't created in window
+ if not (TARGET_START_STR <= created_at <= TARGET_END_STR):
+ if has_prior_review:
+ addressed.append(entry_with_count)
+ else:
+ committed.append(entry_with_count)
+
+ # Track open PRs for debug and blocked/approved analysis
+ if node.get("state") == "OPEN":
+ review_decision = node.get("reviewDecision")
+ ci_state = (node.get("statusCheckRollup") or {}).get("state")
+
+ # Determine status summary
+ status_parts = []
+ if review_decision:
+ status_parts.append(review_decision.lower().replace("_", " "))
+ if ci_state:
+ status_parts.append(f"CI: {ci_state.lower()}")
+ if asana_status:
+ status_parts.append(f"Asana: {asana_status}")
+
+ open_prs.append({
+ **pr_entry,
+ "review_decision": review_decision,
+ "ci_state": ci_state,
+ "status_summary": ", ".join(status_parts) if status_parts else "open"
+ })
+
+ # Check if approved (GitHub approved OR Asana Publish Needed)
+ if review_decision == "APPROVED" or asana_status == "Publish Needed":
+ approved.append(pr_entry)
+
+ # Check if blocked
+ if ci_state == "FAILURE":
+ blocked.append({**pr_entry, "block_reason": "ci_failure", "detail": "CI failing"})
+ elif review_decision == "CHANGES_REQUESTED":
+ # Find who requested changes
+ changers = []
+ for r in (node.get("reviews") or {}).get("nodes", []):
+ if r and r.get("state") == "CHANGES_REQUESTED":
+ author = (r.get("author") or {}).get("login", "")
+ if author and author not in changers:
+ changers.append(author)
+ blocked.append({
+ **pr_entry,
+ "block_reason": "changes_requested",
+ "detail": ", ".join(changers) if changers else "reviewer"
+ })
+
+# --- Process reviewed PRs ---
+reviewed = []
+for node in reviewed_raw.get("data", {}).get("search", {}).get("nodes", []):
+ if not node or "number" not in node:
+ continue
+
+ pr_key = f"{node['repository']['nameWithOwner']}#{node['number']}"
+ if pr_key in seen_prs:
+ continue
+ seen_prs.add(pr_key)
+
+ # Find user's review within target window
+ review_state = None
+ for r in (node.get("reviews") or {}).get("nodes", []):
+ if not r or not r.get("author"):
+ continue
+ if r["author"].get("login") != USERNAME:
+ continue
+ submitted = (r.get("submittedAt") or "")[:10]
+ if TARGET_START_STR <= submitted <= TARGET_END_STR:
+ review_state = r.get("state", "COMMENTED")
+ break
+
+ if review_state:
+ reviewed.append({
+ "pr_number": node["number"],
+ "pr_title": node["title"],
+ "pr_url": node["url"],
+ "repo": node["repository"]["nameWithOwner"],
+ "asana_gid": extract_asana_gid(node.get("body")),
+ "review_state": review_state,
+ })
+
+# --- Process commented PRs ---
+commented_list = []
+for node in commented_raw.get("data", {}).get("search", {}).get("nodes", []):
+ if not node or "number" not in node:
+ continue
+
+ pr_key = f"{node['repository']['nameWithOwner']}#{node['number']}"
+ if pr_key in seen_prs:
+ continue
+ seen_prs.add(pr_key)
+
+ # Check for comments by user on target date
+ has_comment = False
+
+ # Issue comments
+ for c in (node.get("comments") or {}).get("nodes", []):
+ if not c:
+ continue
+ author = (c.get("author") or {}).get("login", "")
+ created = (c.get("createdAt") or "")[:10]
+ if author == USERNAME and TARGET_START_STR <= created <= TARGET_END_STR:
+ has_comment = True
+ break
+
+ # Review thread comments
+ if not has_comment:
+ for thread in (node.get("reviewThreads") or {}).get("nodes", []):
+ for c in (thread.get("comments") or {}).get("nodes", []):
+ if not c:
+ continue
+ author = (c.get("author") or {}).get("login", "")
+ created = (c.get("createdAt") or "")[:10]
+ if author == USERNAME and TARGET_START_STR <= created <= TARGET_END_STR:
+ has_comment = True
+ break
+ if has_comment:
+ break
+
+ if has_comment:
+ commented_list.append({
+ "pr_number": node["number"],
+ "pr_title": node["title"],
+ "pr_url": node["url"],
+ "repo": node["repository"]["nameWithOwner"],
+ "asana_gid": extract_asana_gid(node.get("body")),
+ })
+
+print(json.dumps({
+ "date_start": TARGET_START_STR,
+ "date_end": TARGET_END_STR,
+ "day_label": day_label,
+ "username": USERNAME,
+ "search_count": search_count,
+ "created": created,
+ "committed": committed,
+ "addressed": addressed,
+ "reviewed": reviewed,
+ "commented": commented_list,
+ "approved": approved,
+ "blocked": blocked,
+ "open_prs": open_prs,
+}, indent=2))
+PYEOF
diff --git a/.cursor/commands/hudl.md b/.cursor/commands/hudl.md
new file mode 100644
index 0000000..adf630a
--- /dev/null
+++ b/.cursor/commands/hudl.md
@@ -0,0 +1,229 @@
+Generate a daily HUDL document from GitHub PR activity, upload to a single persistent private gist.
+
+
+PR names are the clickable link: `[{title}]({url})`. Never add a separate URL.
+All HUDL files go into ONE gist with description "HUDL Notes". Create on first run, add files on subsequent runs. Never overwrite — append a suffix (`-1`, `-2`, etc.) if the filename exists.
+Delete the local file after successful gist upload.
+Set `block_until_ms: 120000` for the companion script.
+PRs with Asana GIDs in body should have their Asana status fetched to determine true workflow status.
+
+
+
+Run the companion script:
+
+```bash
+~/.cursor/commands/github-pr-hudl.sh
+```
+
+If the user supplies a specific date, pass `--date YYYY-MM-DD`.
+
+Capture stdout (JSON) and stderr (diagnostics) separately.
+
+
+
+The JSON output has these fields:
+- `date_start`, `date_end`: The time window (e.g., Friday to Monday for Monday HUDL)
+- `day_label`: Display label (e.g., "since Friday" or "since yesterday")
+
+And these arrays:
+- `created`: PRs created within window
+- `committed`: PRs where user pushed commits within window
+- `addressed`: PRs with commits after receiving review comments
+- `reviewed`: PRs by others that user reviewed
+- `commented`: PRs where user posted comments
+- `approved`: PRs that have approval (for Goals Today)
+- `blocked`: PRs blocked by CI failure or changes requested (for Handoffs)
+- `open_prs`: All open PRs for debug section
+
+Each entry has: `pr_number`, `pr_title`, `pr_url`, `repo`, `asana_gid` (nullable), `asana_status` (nullable), plus action-specific fields.
+
+
+
+Build the markdown file with EXACTLY the structure below. Every heading, bullet, and blank line matters.
+
+
+Line 1 of the file. Use `date_end` from the JSON for the header date.
+
+```
+# HUDL Notes — {full_weekday_name} {full_month_name} {day}, {year}
+```
+
+Example: `# HUDL Notes — Monday February 17, 2026`
+
+
+
+```
+## Accomplishments {day_label}
+```
+
+Use `day_label` from the JSON (either `"yesterday"` or `"Friday"`).
+
+Categorize each PR into exactly ONE subsection based on its PRIMARY action. Determine the primary action using this priority (highest first):
+
+1. `created` → goes in **PR'd**
+2. `addressed` → goes in **Addressed PR Comments**
+3. `reviewed` → goes in **Reviewed PRs**
+4. `committed` or `commented` → goes in **General**
+
+A PR appears in only ONE subsection — the highest-priority one that matches.
+
+**Subsection: PR'd** — include only if at least one PR qualifies.
+
+```
+### PR'd
+
+- [{pr_title}]({pr_url}) ({repo})
+```
+
+One bullet per PR. No action text — the heading says it.
+
+**Subsection: Addressed PR Comments** — include only if at least one PR qualifies.
+
+```
+### Addressed PR Comments
+
+- [{pr_title}]({pr_url}) ({repo})
+```
+
+**Subsection: Reviewed PRs** — include only if at least one PR qualifies.
+
+```
+### Reviewed PRs
+
+- [{pr_title}]({pr_url}) ({repo}) — approved
+```
+
+Append the review verdict in lowercase after ` — `. Map `review_state`:
+- `APPROVED` → `approved`
+- `CHANGES_REQUESTED` → `changes requested`
+- `COMMENTED` → `commented`
+
+**Subsection: General** — include only if at least one PR qualifies.
+
+```
+### General
+
+- [{pr_title}]({pr_url}) ({repo}) — Committed: 3 commits
+```
+
+Format each action type:
+- `committed` → `Committed: {commit_count} commits`
+- `commented` → `Commented`
+
+If a PR has multiple actions in General, join with `; `.
+
+**Omit any subsection that would have zero bullets.**
+
+
+
+```
+## Goals Today
+```
+
+List PRs from the `approved` array (PRs that are approved and ready to merge/publish):
+
+```
+- Publish [{pr_title}]({pr_url})
+```
+
+After all approved items (or immediately if there are none), add one blank bullet for the user to fill in:
+
+```
+-
+```
+
+
+
+```
+## Handoffs
+```
+
+Group entries from the `blocked` array by block reason.
+
+**CI Failures** — if any PR has `block_reason=ci_failure`:
+
+```
+### Blocked by CI
+
+- [{pr_title}]({pr_url}) — CI failing
+```
+
+**Changes Requested** — if any PR has `block_reason=changes_requested`:
+
+```
+### Changes Requested
+
+- [{pr_title}]({pr_url}) — {reviewer} requested changes
+```
+
+If the blocked array is completely empty, write:
+
+```
+None
+```
+
+
+
+Add a horizontal rule, then a collapsed details block.
+
+```
+---
+
+Debug: {N} open PRs
+
+```
+
+Where `{N}` is the length of the `open_prs` array.
+
+For each entry in `open_prs`, write:
+
+```
+- [{pr_title}]({pr_url}) — {status_summary}
+```
+
+Where `status_summary` includes: review state, CI status, Asana status (if present).
+
+End with search stats and close the details tag:
+
+```
+
+*Searched {search_count} PRs*
+
+
+```
+
+`search_count` comes from the JSON.
+
+
+
+
+1. Write the markdown to `hudl-{date}.md` in the current working directory.
+2. Upload to gist using this exact bash logic:
+
+```bash
+GIST_ID=$(gh gist list --limit 100 --filter "HUDL Notes" | head -1 | awk '{print $1}')
+FILENAME="hudl-{date}.md"
+
+if [ -n "$GIST_ID" ]; then
+ FILES=$(gh gist view "$GIST_ID" --files)
+ N=1
+ BASE="hudl-{date}"
+ while echo "$FILES" | grep -q "$FILENAME"; do
+ N=$((N + 1))
+ FILENAME="${BASE}-${N}.md"
+ done
+ [ "$FILENAME" != "hudl-{date}.md" ] && mv "hudl-{date}.md" "$FILENAME"
+ gh gist edit "$GIST_ID" --add "$FILENAME"
+else
+ gh gist create --desc "HUDL Notes" "$FILENAME"
+ GIST_ID=$(gh gist list --limit 1 --filter "HUDL Notes" | awk '{print $1}')
+fi
+
+rm "$FILENAME"
+```
+
+3. Present a brief summary to the user:
+ - Number of accomplishment items
+ - Number of handoffs
+ - Gist URL: `https://gist.github.com/{username}/{GIST_ID}`
+
diff --git a/.cursor/rules/act-autonomously.mdc b/.cursor/rules/act-autonomously.mdc
new file mode 100644
index 0000000..bb3544d
--- /dev/null
+++ b/.cursor/rules/act-autonomously.mdc
@@ -0,0 +1,15 @@
+---
+description: Act autonomously — run commands and investigate yourself first; only ask what you genuinely cannot determine
+alwaysApply: true
+---
+
+# Act Autonomously — Run It and Investigate Yourself First
+
+Default to autonomous execution. Do not seek permission, confirmation, or direction for anything you can do or determine yourself.
+
+- **Run commands yourself.** If a shell command, query, file read, build, or tool call would answer a question or advance the task, just run it. Never ask the user to run something for you, and never ask "may I run X?" for actions you are capable of performing — this applies especially to read-only investigation and reversible changes.
+- **Investigate before asking.** Exhaust your own means first: read the code/config, grep, run tests, inspect state, trace behavior, check logs. Derive answers from evidence instead of asking the user to explain how something works or where something lives.
+- **Only ask what you genuinely cannot figure out.** Reserve questions for the truly undeterminable: the user's private intent or preference, secrets/credentials you don't possess, an irreversible action with no defensible default, or access you lack. When you must ask, do it only after completing all the homework, and present your findings plus a recommended default.
+- **Don't stall on confirmation.** Pick the defensible default and proceed, noting the choice, rather than pausing to ask about figure-out-able or low-risk matters.
+
+This does NOT override explicit safety rules: still honor `workflow-halt-on-error` (stop and report on skill/script failures), still pause for genuinely destructive operations or user-only credential gates, and still answer the user's own questions first per `answer-questions-first`.
diff --git a/.cursor/rules/after_each_chat.mdc b/.cursor/rules/after_each_chat.mdc
new file mode 100644
index 0000000..79b7294
--- /dev/null
+++ b/.cursor/rules/after_each_chat.mdc
@@ -0,0 +1,24 @@
+---
+description: Send full chat response to Telegram at end of each turn
+alwaysApply: false
+---
+
+Send the full response of every chat turn to Telegram via chunked messages.
+
+
+Execute at the end of every chat turn without exception.
+Send the complete response content, not an abbreviated summary.
+Do not ask for confirmation or print extra explanation.
+Send in a single Shell command. Do not write intermediate files, call date, or use multiple steps.
+The script takes NO arguments. Pipe text via stdin. Do NOT pass file paths, workspace names, or any arguments.
+
+
+
+Pipe your full response to the send script via a heredoc in a single Shell command:
+```bash
+python3 ~/.cursor-autopilot/telegram-send.py << 'ENDOFMSG'
+
+ENDOFMSG
+```
+The script reads stdin, splits the message into chunks that fit Telegram's 4096-character limit, and sends each sequentially. Credentials are resolved automatically. Do NOT write intermediate files, call date, or pass any arguments.
+
diff --git a/.cursor/rules/answer-questions-first.mdc b/.cursor/rules/answer-questions-first.mdc
new file mode 100644
index 0000000..0cde491
--- /dev/null
+++ b/.cursor/rules/answer-questions-first.mdc
@@ -0,0 +1,18 @@
+---
+description: Detect questions in prompts and answer them before making changes
+alwaysApply: true
+---
+
+# Answer Questions Before Acting
+
+Before using any code editing tools, scan the user's message for `?` characters and determine if it's a question.
+
+- **Ignore** `?` inside code, URLs or query parameters (e.g. `?param=x`, `?key=value` , `const x = ifTrue ? 'yes' : 'no'`)
+- **Treat all other `?`** as question statements, if they appear to be questions.
+
+If questions are detected:
+
+1. Read `~/.cursor/skills/q/SKILL.md` and follow its workflow to answer every question.
+2. **Workflow context**: If a skill was invoked earlier in this conversation, note which one. When a question or critique references agent behavior from that execution, load the skill definition before answering and evaluate whether the skill should have governed that behavior. If it should have but didn't, that's a workflow gap — treat it as the primary concern per `fix-workflow-first.mdc`.
+3. Do **not** edit files, create files, or run mutating commands until the user responds.
+4. Only proceed with implementation after the user permits it in a follow-up message.
diff --git a/.cursor/rules/eslint-warnings.mdc b/.cursor/rules/eslint-warnings.mdc
new file mode 100644
index 0000000..bb30cc9
--- /dev/null
+++ b/.cursor/rules/eslint-warnings.mdc
@@ -0,0 +1,10 @@
+---
+description: Guidance for addressing ESLint warnings in the codebase
+globs: ["**/*.ts", "**/*.tsx"]
+alwaysApply: false
+---
+
+# ESLint Warning Fixes
+
+- Skip deprecation warnings (`@typescript-eslint/no-deprecated`) unless explicitly asked to address them.
+- After addressing warnings, run `yarn update-eslint-warnings` to update the baseline.
diff --git a/.cursor/rules/image/typescript-standards/1770928879881.png b/.cursor/rules/image/typescript-standards/1770928879881.png
new file mode 100644
index 0000000..13a56c4
Binary files /dev/null and b/.cursor/rules/image/typescript-standards/1770928879881.png differ
diff --git a/.cursor/rules/image/typescript-standards/1770928886532.png b/.cursor/rules/image/typescript-standards/1770928886532.png
new file mode 100644
index 0000000..58a2409
Binary files /dev/null and b/.cursor/rules/image/typescript-standards/1770928886532.png differ
diff --git a/.cursor/rules/load-standards-by-filetype.mdc b/.cursor/rules/load-standards-by-filetype.mdc
new file mode 100644
index 0000000..d272c04
--- /dev/null
+++ b/.cursor/rules/load-standards-by-filetype.mdc
@@ -0,0 +1,19 @@
+---
+description:
+alwaysApply: true
+---
+
+Load language-specific coding standards before editing or investigating lint/type errors in files, without redundant reads.
+
+
+Before using any code editing tool on a file OR investigating lint/type errors in that file type, check if the matching standards rule is already present in `cursor_rules_context`. Only read the rule file if it is NOT already in context.
+If the rule is not in context, read it using the Read tool and follow its contents BEFORE making the edit or investigating the error.
+
+
+
+
+| File glob | Standards file |
+|---|----|
+| `**/*.ts`,`**/*.tsx` | `~/.cursor/rules/typescript-standards.mdc` |
+
+
diff --git a/.cursor/rules/no-format-lint.mdc b/.cursor/rules/no-format-lint.mdc
new file mode 100644
index 0000000..76cd248
--- /dev/null
+++ b/.cursor/rules/no-format-lint.mdc
@@ -0,0 +1,11 @@
+---
+description: Prevent agent from spending tokens on formatting and lint fixing
+alwaysApply: true
+---
+
+# No Manual Formatting or Lint Fixing
+
+- Do NOT run `yarn lint`, `yarn fix`, `yarn verify`, or any lint/format shell commands unless explicitly asked.
+- Do NOT manually fix formatting issues (whitespace, quotes, semicolons, trailing commas, line length). The `lint-commit.sh` script runs `eslint --fix` (including Prettier) before each commit.
+- Only use `ReadLints` to check for logical or type errors, not formatting. If the only lint errors are formatting-related, ignore them.
+- Focus tokens on correctness and logic, not style.
diff --git a/.cursor/rules/review-standards.mdc b/.cursor/rules/review-standards.mdc
new file mode 100644
index 0000000..855cfcc
--- /dev/null
+++ b/.cursor/rules/review-standards.mdc
@@ -0,0 +1,199 @@
+---
+description: Review-specific coding conventions for Edge codebase PR reviews. Load alongside typescript-standards.mdc during code review.
+globs: []
+alwaysApply: false
+---
+
+Provide project-specific review patterns to detect in PR code — anti-patterns and conventions that go beyond the editing standards in typescript-standards.mdc.
+
+
+
+Don't use shorthand `.catch(showError)` — it loses the calling file from stack traces.
+❌ `doSomething().catch(showError)`
+✅ `doSomething().catch((error: unknown) => showError(error))`
+
+
+Don't double down on `@ts-expect-error` when trivial fixes exist. Use `?? []`, `?? {}`, or explicit type annotations instead of suppressing type errors.
+
+Use `!== undefined` when `null` has semantic meaning (like "delete this field"). `!= null` treats both the same.
+❌ `const changed = value != null` (when null means "delete")
+✅ `const changed = value !== undefined`
+
+
+Always `await` async operations for proper spinners, double-click prevention, and race condition avoidance.
+❌ `wallet.saveTxMetadata(params).catch(showError)`
+✅ `await wallet.saveTxMetadata(params)`
+
+
+When the whole function is async and the caller handles errors, don't add a separate `.catch()`.
+❌ `const handle = async () => { await op().catch(err => showError(err)) }`
+✅ `const handle = async () => { await op() }`
+
+
+When `tokenId` is a non-null string, any dereference using it must succeed or throw. Never fall back to `null` — it silently changes the intended asset from "this specific token" to "native currency."
+
+When a global error handler (e.g., `withExtendedTouchable`) already catches and displays errors, don't add local `.catch(showError)` — it causes errors to display twice. Only add explicit handling when you need specific error types, cleanup, or there's no global handler.
+
+User cancellations (closing modals, pressing back) should exit silently, not show a generic error.
+❌ `try { await modal() } catch (error) { showError(error) }`
+✅ `if (error instanceof UserCancelledError) return; showError(error)`
+
+
+Catch blocks should not always throw the same generic error. Only throw specific messages for expected errors (e.g., API 400); re-throw the original for unexpected ones so users see accurate messages.
+
+Verify arrays have elements before indexing. `vin.addresses[0]` is `undefined` when the array is empty — check before passing to functions that can't handle undefined.
+
+Don't compare tokenIds with currency codes — they are different identifier types that will never match. Use `request.fromTokenId` when checking against a list of tokenIds, not `request.fromCurrencyCode`.
+
+Use optional chaining on lookup tables with dynamic keys.
+❌ `TABLE[pluginId].includes(tokenId)` (TypeError if key missing)
+✅ `TABLE[pluginId]?.includes(tokenId) ?? false`
+
+
+If a validation applies to all code paths, perform it once at function entry rather than repeating in each branch.
+
+
+
+
+
+Prefer `useHandler` (from `hooks/useHandler`) over `useCallback` for event handlers and async functions. Provides better TypeScript inference and handles async more gracefully.
+
+If two `useEffect` hooks update related state from related dependencies, combine them into one effect to avoid redundant renders.
+
+Extract complex display logic to helper functions with early returns instead of nested ternaries or inline conditional chains.
+
+Use `StyleSheet.compose(baseStyle, customStyle)` for style composition. Handles null automatically — no manual array handling needed.
+
+iOS number-pad keyboards don't support certain `returnKeyType` values ("Can't find keyplane" warning). Conditionally set: `returnKeyType={Platform.OS === 'ios' ? undefined : 'done'}`
+
+When replacing one component with another, ensure all props (color, size, style) are carried over. Check the original component's props before replacing — missing visual props change appearance.
+
+When switching icon libraries, wrap replacement icons in a `View` with the original margin/padding styles if the new component doesn't accept the same style props.
+
+Wrap navigation calls (push, pop, replace) after complex gestures (slider completion, swipe) in `InteractionManager.runAfterInteractions()`. Navigating while the gesture system is active causes crashes on physical devices.
+
+Disable interactive elements during async operations to prevent double-taps and race conditions. Use a `pending` state and pass it to the component for visual feedback.
+
+
+
+
+
+Don't track Redux state locally with `useState(reduxValue)` — it becomes stale when Redux updates. Read from `useSelector` directly.
+
+Module-level cached state that doesn't reset on logout/login leaks data between users. Export a clear function and call it on logout. This is a recurring bug pattern. The clear function must reset **all** module-level singletons — including lazily-created connection/provider/resolver objects (e.g. an ethers provider, an SDK client), not just user-data maps — since those accumulate internal state that outlives a session.
+
+Local account settings belong in Redux, not separate module-level caches. Redux is the right place for globally-available account information.
+
+Use `account.dataStore.setItem/getItem` instead of `account.localDisklet` directly. Disklet filenames are stored in plaintext, leaking information the server shouldn't see. DataStore encrypts filenames.
+
+When changing storage formats, always include migration code: read old format, convert, write new format, delete old. Users have existing data on disk.
+
+When updating nested state objects in storage, merge with existing state to avoid overwriting concurrent updates from other parts of the app.
+❌ `notifState: newNotifState` (overwrites sibling keys)
+✅ `notifState: { ...settings.notifState, ...newNotifState }`
+
+
+
+
+
+
+Always use `makePeriodicTask` instead of `setInterval`, especially for async work. Provides proper start/stop lifecycle and handles overlapping invocations.
+
+Background services go in `components/services/` as React components. Component-based mounting ensures clean lifecycle tied to login/logout. Avoid excessive background work — trigger only when needed.
+
+Use a `runOnce` helper or `pending` flag to prevent duplicate parallel calls when functions can be triggered multiple times (button presses, retries).
+
+When implementing cancellable polling, check the cancel flag after every `await`, not just at loop start. The flag can change during any async gap.
+
+In `setTimeout`/interval callbacks, read state fresh inside the callback. Closures capture stale values — especially problematic for callbacks that fire much later.
+
+Track `setTimeout` IDs in services/engines with a `Set` and clear them all in the shutdown method. Stale timeouts fire on cleared/deallocated state.
+
+When async event handlers operate on shared resources (files, git repos, databases), serialize operations per resource using a pending-operation map or queue. Fire-and-forget `.catch()` patterns cause race conditions on rapid events.
+
+
+
+
+
+All network responses and disk reads must be cleaned with the cleaners library before use. Access cleaned values, not raw data.
+
+Derive types from cleaners with `ReturnType`. Don't duplicate type definitions alongside cleaner definitions.
+
+`asOptional` accepts both `undefined` AND `null` despite the name. To preserve the null/undefined distinction, use `asOptional(asEither(asNull, asString), null)` with a default.
+
+New fields added to cleaners for persisted data MUST use `asOptional` unless migration code is included. Existing data on disk won't have the new field — non-optional fields cause load failures.
+
+Remove or comment out unused fields in cleaners. Dead cleaner fields add noise and can mislead.
+
+
+
+
+
+Don't leave dead or unused code "just in case." Git history preserves it. This includes unused variables, unreachable branches, and commented-out blocks.
+
+Don't declare variables just to pass them to a function — inline the parameters. Exception: typed constants for functions with untyped/`any` parameters, where the constant provides compile-time checking.
+
+Before creating a new utility, check for existing helpers: `getTokenId`/`getTokenIdForced` instead of `getWalletTokenId`, `getExchangeDenom` instead of custom multiplier lookups.
+
+Use existing mock data from `src/util/fake/` or consolidate new mocks there. Duplicated half-baked mock data breaks on core changes.
+
+Never commit hardcoded sandbox URLs or debug flags. Use environment configuration (`envConfig.*`, `__DEV__`).
+
+Don't use local file paths (`file:../my-package`) in package.json dependencies. Breaks builds for other developers and CI.
+
+No unguarded `console.log` in production code. Guard with `ENV.DEBUG_VERBOSE_LOGGING` or remove entirely.
+
+Use a single validation function for both real-time and submit-time checks. Duplicated validation with different thresholds lets users submit invalid forms.
+
+Use local synchronous helpers (`div` from biggystring + `getExchangeDenom`) for amount conversions instead of async wallet API calls that cross an expensive bridge. Always specify decimal precision to avoid integer truncation: `div(native, multiplier, 18)` not `div(native, multiplier)`.
+
+Use established libraries (e.g., `rfc4648` for base64) instead of hand-rolling standard algorithms. Hand-rolled implementations miss edge cases and add maintenance burden.
+
+When a value appears in multiple configuration locations, ensure they match. Extract shared constants to prevent silent drift.
+
+Delete style properties from `StyleSheet.create` that aren't referenced by any component. Unused styles add noise.
+
+
+
+
+
+Search the localization file (`en_US.json`) before adding new keys. Don't create duplicates of existing strings.
+
+String keys describe semantic meaning, not UI location.
+❌ `signup_screen_get_started`
+✅ `get_started_button`
+
+
+Prompts describe the action, not the gesture. Doesn't translate well across platforms.
+❌ `"Tap to select a country"`
+✅ `"Select a country"`
+
+
+Error messages and user-facing strings are localized in the GUI layer, not in API/plugin code. API layers throw structured errors (e.g., `NetworkError('CONNECTION_FAILED')`) that the GUI translates for display.
+
+
+
+
+
+Document constraints that aren't obvious from the code: `// EVM-only: assumes EVM contract address format`
+
+Remove comments when the context they describe has changed. Stale comments mislead more than missing comments.
+
+Good comments explain reasoning, not mechanics.
+❌ `// Loop through items and filter by status`
+✅ `// Only active items can be edited; archived items are read-only`
+
+
+
+
+
+
+Place all dependencies in `devDependencies` except cleaner packages (which may be exported as types to NPM consumers).
+
+Server and client configuration in separate files (`serverConfig.json`, `clientConfig.json`), both validated with cleaners via `cleaner-config`. Prevents accidentally exposing server secrets to clients.
+
+Server processes use PM2 with `pm2.json` at repo root. API processes in cluster mode (`"instances": "max"`); engine processes as single instances to avoid duplicate background work.
+
+When a server repo has both backend and frontend, the `build` script must build both. Use `npm-run-all -p build.*` to run in parallel.
+
+
diff --git a/.cursor/rules/typescript-standards.mdc b/.cursor/rules/typescript-standards.mdc
new file mode 100644
index 0000000..a0b7816
--- /dev/null
+++ b/.cursor/rules/typescript-standards.mdc
@@ -0,0 +1,279 @@
+---
+description: TypeScript/React coding standards for error handling, types, and patterns
+globs: ["**/*.ts","**/*.tsx"]
+alwaysApply: false
+---
+
+Enforce TypeScript and React coding standards in all `.ts`/`.tsx` file edits.
+
+
+
+NEVER use hard-coded user-facing strings. All display text MUST come from localized string resources (`lstrings.*`). This includes error messages, labels, placeholders, and any text visible to users.
+❌ `setError('Something went wrong')`
+❌ `Loading...`
+✅ `setError(lstrings.generic_error)`
+✅ `{lstrings.loading}`
+
+
+Localized strings with placeholders MUST use numbered suffixes (`_1s`, `_2s`, etc.) and positional `sprintf` args (`%1$s`, `%2$s`).
+❌ `warning_message: 'Amount %s exceeds limit of %s'`
+✅ `warning_message_2s: 'Amount %1$s exceeds limit of %2$s'`
+❌ `sprintf(lstrings.warning_header, 'this item')`
+✅ `sprintf(lstrings.warning_header_1s, itemName)`
+
+
+NEVER use `any` types. Define an interface, type, or cleaner. If truly unavoidable, add a comment explaining why.
+
+NEVER use optional chaining results directly in conditions.
+❌ `if (obj?.prop)` → ✅ `if (obj?.prop != null)`
+❌ `if (obj?.arr?.length > 0)` → ✅ `if (obj?.arr != null && obj.arr.length > 0)`
+
+
+NEVER use empty rejection handlers that silently swallow errors.
+❌ `.catch(() => {})`
+✅ `.catch((err: unknown) => { showError(err) })`
+Exception: Empty handlers are acceptable ONLY when the rejection is an expected user action (e.g., user cancelled a modal) AND there's nothing to clean up.
+
+
+Catch blocks MUST use `(error: unknown) => {...}` format.
+
+Do not use the `void` operator to silence Promise returns. Create a non-async handler wrapping the async call with explicit error handling.
+❌ `onSwipe={() => { void doAsync() }}`
+✅ `const onSwipe = useHandler(() => { doAsync().catch((err: unknown) => { showError(err) }) })`
+
+
+Do not use inline styles in JSX. Use `getStyles`/`cacheStyles` (static) and memoized (derived) for style definitions.
+
+JSX event handler props MUST NOT use inline arrow functions. Create named handlers.
+
+
+
+
+
+Use `??` instead of `||` for default values. `??` only treats `null`/`undefined` as missing; `||` treats all falsy values as missing.
+❌ `config.timeout || 5000` → ✅ `config.timeout ?? 5000` (preserves `0`)
+❌ `user.name || 'Anonymous'` → ✅ `user.name ?? 'Anonymous'` (preserves `''`)
+
+
+Prefer flat boolean expressions over nested if/return in filter/predicate functions.
+❌ `if (x != null) { if (f(x).match(y)) { return true } }; return otherResult`
+✅ `return (x != null && f(x).match(y)) || otherResult`
+
+
+Do not add branches that return the same value as the final return.
+❌ `if (node.type === 'TSNullKeyword') { return false }; return false`
+✅ `return false`
+
+
+When a handler only forwards to another function with no additional logic, pass the function directly.
+❌ `const handleComplete = useHandler(() => { onComplete?.() })`
+✅ `onPress: onComplete`
+
+
+Extract reusable helpers for common boilerplate patterns (e.g., "run at most once in parallel").
+
+Avoid calling expensive transformation functions (like `normalizeForSearch`, `toLowerCase`) inside loops when the input doesn't change per iteration. Pre-compute outside the loop.
+❌ `items.filter(item => searchTerms.every(term => normalize(item.name).includes(term)))`
+✅ `items.filter(item => { const n = normalize(item.name); return searchTerms.every(term => n.includes(term)) })`
+
+
+Use `asJSON` cleaner instead of manual `JSON.parse`.
+❌ `const data = asMyCleaner(JSON.parse(text))`
+✅ `const data = asJSON(asMyCleaner)(text)`
+
+
+Use TanStack Query (`useQuery`) for async data fetching instead of `useEffect`/`useState` patterns.
+❌ `const [data, setData] = useState(null); useEffect(() => { fetchData().then(setData) }, [])`
+✅ `const { data } = useQuery({ queryKey: ['myData', deps], queryFn: fetchData, enabled: deps != null })`
+
+
+Use specific Redux selectors to avoid unnecessary re-renders.
+❌ `const { countryCode } = useSelector(state => state.ui.settings)`
+✅ `const countryCode = useSelector(state => state.ui.settings.countryCode)`
+
+
+Keep `useSelector` callbacks simple — only access state, never derive. Derivation logic belongs in `useMemo` (or inline) after all referenced variables are declared. Selector callbacks run on every store update and can reference hoisted-but-uninitialized variables, causing silent bugs.
+❌ `const result = useSelector(state => { const x = expensiveFn(someVar, state.foo); return x })`
+✅ `const foo = useSelector(state => state.foo)` then `const result = useMemo(() => expensiveFn(someVar, foo), [someVar, foo])`
+
+
+Use `React.FC` for component exports. Use `React.ReactElement` for non-component render functions.
+❌ `const Component = (props: Props): React.JSX.Element => {`
+✅ `const Component: React.FC = props => {`
+
+
+Use descriptive variable names that clearly indicate their purpose. Avoid single/few-letter variables except in trivial cases (loop counters, mathematical formulas).
+❌ `const s = asMaybePrivateNetworkingSetting(cfg.userSettings)`
+❌ `const ds = asMaybePrivateNetworkingSetting(cfg.currencyInfo.defaultSettings)`
+❌ `return (s ?? ds)?.networkPrivacy === 'nym'`
+✅ `const userSettings = asMaybePrivateNetworkingSetting(currencyConfig.userSettings)`
+✅ `return userSettings?.networkPrivacy === 'nym'`
+
+
+Always include cleanup functions in `useEffect` hooks that create timers, intervals, subscriptions, or other side effects.
+
+Code comments and READMEs document the current state of the code, not the history of changes.
+
+Use `biggystring` for all numeric calculations involving crypto amounts, fiat values, or exchange rates. Native JS floating-point math loses precision. Values from `convertCurrency`, `convertNativeToExchange`, and similar helpers are already biggystring-compatible strings.
+❌ `const impact = (parseFloat(from) - parseFloat(to)) / parseFloat(from)`
+✅ `const impact = div(sub(from, to), from, 8)`
+
+
+When deriving arrays or objects from props/state (e.g. `Object.values()`, `Object.keys()`, `.filter()`, `.map()`), wrap in `React.useMemo` if the result is used in a dependency array or passed as a prop. Bare derivations create new references every render.
+❌ `const wallets = Object.values(currencyWallets)` (used in effect deps)
+✅ `const wallets = React.useMemo(() => Object.values(currencyWallets), [currencyWallets])`
+
+
+When guarding against re-fetching with nullable map lookups, check for the success payload specifically — not just entry existence. Storing error results as non-null entries permanently blocks retry if the guard only checks `== null`.
+❌ `if (resultMap[id] == null) fetchData(id)` (error entries block retry)
+✅ `if (resultMap[id]?.data == null) fetchData(id)` (only skip when data is present)
+Exception: Auto-load effects where infinite retry on persistent failure is undesirable — keep `== null` there and allow retry only via explicit user action.
+
+
+Never build React list keys solely from optional fields — when those fields are absent, every such row collapses to the same key (e.g. `"undefinedundefined"`) and list updates render incorrectly. Include the array index or a required unique field in the key.
+❌ `key={String(item.timestamp) + String(item.dollarValue)}` (both optional)
+✅ `key={`${String(item.date)}-${index}`}` (required field + index)
+
+
+Guard HTTP query params before `new Date(...)`: treat missing, empty, and unparseable values as unset. `new Date('')` and `new Date('garbage')` yield Invalid Date, whose `valueOf()` is `NaN` — it silently corrupts range queries (e.g. CouchDB view keys) instead of throwing.
+❌ `typeof startDate === 'string' ? new Date(startDate) : defaultDate` (empty string passes)
+✅ Use a helper: `if (typeof value !== 'string' || value === '') return fallback; const d = new Date(value); return isNaN(d.valueOf()) ? fallback : d`
+
+
+Component files (`.tsx`) and utility files (`.ts`) follow a consistent section ordering.
+
+**File-level ordering:**
+1. Imports
+2. Types / Interfaces — exported types first, then internal `Props`
+3. Constants
+4. Main component (`export const Scene: React.FC`)
+5. Sub-components (internal, non-exported)
+6. Styles (`getStyles` / `cacheStyles`)
+7. Helpers / utility functions — pure functions at the very end of the file
+
+**Component body ordering:**
+1. Props destructuring
+2. Theme / styles (`useTheme`, `getStyles`)
+3. State (`useState`)
+4. Refs (`useRef`)
+5. Selectors (`useSelector`, `useWatch`)
+6. Derived values / `useMemo`
+7. Handlers (`useHandler`)
+8. Effects (`useEffect`, `useBackEvent`)
+9. Return JSX
+
+
+
+
+
+
+`@typescript-eslint/strict-boolean-expressions` on `any`-typed value.
+Cause: Variable is `any` because it comes from an untyped method or third-party code.
+Fix: Type-annotate the variable to remove `any`. Do NOT use explicit comparisons — they don't help when the value itself is `any`.
+❌ `if (!result.ok)` where `result` is `any`
+✅ `const results: Array> = await wallet.split(items)`
+Known untyped methods: `EdgeCurrencyWallet.split()` returns `Array>`.
+
+
+Strict boolean on nullable/optional values.
+Cause: Using truthy check on value that could be `null`, `undefined`, `0`, or `''`.
+Fix: Use explicit nullish comparison (`!= null`, `!== ''`, `> 0`).
+❌ `if (value)` where `value` is `string | undefined`
+✅ `if (value != null && value !== '')`
+❌ `if (array.length)` → ✅ `if (array.length > 0)`
+
+
+Type-only imports MUST use `import type` at the top level, not inline `type` keyword within a value import.
+❌ `import { type Foo, type Bar } from 'module'` (when importing ONLY types)
+✅ `import type { Foo, Bar } from 'module'`
+OK: `import { someValue, type Foo } from 'module'` (mixed value + type import)
+
+
+Imports are auto-sorted by `simple-import-sort/imports`. When adding new imports, place them roughly in alphabetical order — the formatter will fix the exact order. If the pre-commit hook fails with "Run autofix to sort these imports!", the imports just need reordering.
+
+
+Floating promises must have `.catch()` handlers.
+✅ `.catch((err: unknown) => { showError(err) })` — standard for unexpected errors
+✅ `.catch(() => {})` — ONLY for expected rejections (user cancelled modal, expected race condition)
+The `(err: unknown)` typing is required by `@typescript-eslint/use-unknown-in-catch-callback-variable`.
+
+
+Catch callbacks must type error as `unknown`.
+❌ `.catch(err => ...)` or `.catch((err: any) => ...)`
+✅ `.catch((err: unknown) => { showError(err) })`
+For try/catch blocks, use `catch (e: unknown)` and narrow with type guards or assertions.
+
+
+Functions must have explicit return types.
+Fix: Add return type annotation. Common types:
+- `void` for side-effect-only functions
+- `React.ReactElement` or `React.ReactElement | null` for render helpers
+- `Promise` for async functions with no return
+- Specific type for functions that return values
+❌ `function foo() { return 1 }`
+✅ `function foo(): number { return 1 }`
+
+
+Using deprecated API.
+Fix: Check the deprecation message for the replacement API. Common replacements:
+- `NavigationBase` → Read `/fix-eslint` skill `navigation-base` pattern for category-based fix guidance
+- `uniqueIdentifier` → `EdgeSpendInfo.memos`
+- `memo` → `EdgeSpendInfo.memos`
+- `networkFee` / `parentNetworkFee` → `networkFees`
+- `currencyCode` → `tokenId`
+If no clear replacement exists, flag to user for guidance.
+
+
+Event handler props must follow naming convention.
+Fix: Rename handler to match the prop pattern.
+- Props starting with `on` expect handlers starting with `handle`
+❌ `onPress={openModal}` → ✅ `onPress={handleOpenModal}`
+❌ `onChange={updateValue}` → ✅ `onChange={handleUpdateValue}`
+
+
+Components should use `React.FC` pattern.
+❌ `const Component = (props: Props): React.ReactElement => {`
+✅ `export const Component: React.FC = props => {`
+
+
+Generic components cannot use `React.FC` because it does not support type parameters.
+If the generic is not essential (type param only used internally, can be collapsed into a concrete type), remove the generic and convert to `React.FC`.
+If the generic is essential (callers rely on type inference, e.g. ` ...>`), keep the function declaration form with an explicit return type. The warning is accepted.
+✅ `export function MyComponent(props: Props): React.ReactElement {`
+❌ Converting an essential generic to `React.FC` — this loses type safety for callers.
+
+
+Avoid `styled()` wrapper components.
+Fix: Convert to regular component using `useTheme()` and `cacheStyles()`.
+❌ `const StyledView = styled(View)(theme => ({ ... }))`
+✅ Create a regular component:
+```tsx
+const MyView: React.FC = props => {
+ const theme = useTheme()
+ const styles = getStyles(theme)
+ return {props.children}
+}
+const getStyles = cacheStyles((theme: Theme) => ({
+ container: { ... }
+}))
+```
+Note: This is an architectural change. If the file has many `styled()` usages, flag to user rather than refactoring inline.
+
+
+When catching unknown errors that need property inspection, use `cleaners` instead of type assertions.
+❌ `const err = e as { code?: string; message?: string }`
+✅ Define a cleaner and use `asMaybe`:
+```ts
+const asFooError = asObject({
+ code: asValue(FOO_CODE),
+ message: asOptional(asString, '')
+})
+const fooError = asMaybe(asFooError)(e)
+if (fooError != null) { ... }
+```
+For generic error message extraction:
+❌ `err.message ?? ''` (unsafe on `unknown`)
+✅ `e instanceof Error ? e.message : String(e)`
+
+
+
diff --git a/.cursor/rules/workflow-halt-on-error.mdc b/.cursor/rules/workflow-halt-on-error.mdc
new file mode 100644
index 0000000..d5424d3
--- /dev/null
+++ b/.cursor/rules/workflow-halt-on-error.mdc
@@ -0,0 +1,84 @@
+---
+description: Halt on workflow errors and detect slash-command invocations in user messages
+alwaysApply: true
+---
+
+
+
+All workflow-related skill definitions (`*.md` / `SKILL.md`) and workflow companion scripts (`*.sh`) are sourced from `~/.cursor/`. When executing skills, prefer explicit `~/.cursor/...` paths and do not assume repo-local workflow files unless the skill explicitly points to one.
+
+When a skill mentions a script path, resolve it under `~/.cursor/skills//scripts/` unless the skill explicitly specifies an absolute path elsewhere. Do not assume repo-relative `scripts/` paths without verifying the skill directory contents.
+
+When ANY shell command fails (non-zero exit code) while executing an active skill workflow, a delegated subskill from that workflow, or a companion-script step required by that workflow (except where explicitly allowed by `auto-fix-verification-failures` or `companion-script-nonzero-contracts`):
+1. **STOP** — do not retry, work around, substitute, or continue the workflow.
+2. **Report** — show the user the exact command, exit code, and error output.
+3. **Diagnose** — classify the failure: missing tool (`command not found`), wrong path, permissions, or logic error.
+4. **Evaluate workflow** — if the failure reveals a gap in a skill definition, follow the fix-workflow-first rules below.
+5. **Wait** — do not resume until the user responds.
+
+
+When a workflow gap is discovered in an active skill definition:
+1. **Stop immediately** — do not continue the current task or apply any workaround.
+2. **Identify the root cause** in the skill (`.cursor/skills/*/SKILL.md`) definition.
+3. **Propose the fix** to the user and wait for approval before proceeding.
+4. **Fix the skill** using `/author` after approval.
+5. **Resume the original task** only after the skill is updated.
+
+Fixing the skill takes **absolute priority** over all other actions — including workarounds, continuing the original task, or applying temporary fixes. Do NOT apply workarounds or manual fixes before proposing the skill update. The correct sequence is: identify gap → propose fix → get approval → apply fix → then resume original task. This applies to all workflow issues — missed steps, incorrect output, wrong tool usage, shell failures, formatting problems, etc. The skill is the source of truth; patching around it creates drift.
+
+
+These workflow halt rules are for skill-driven execution, especially hands-off/orchestrated skills and their dependencies. They do not automatically apply to ad hoc exploration, incidental verification, or low-risk authoring work unless that command is part of an active skill contract.
+
+Exception to `halt-on-error`: For verification/code-quality failures where diagnostics are explicit and local, continue automatically with bounded remediation.
+
+Allowed auto-fix scope:
+- TypeScript/compiler failures (`tsc`) with clear file/line diagnostics
+- Lint failures (`eslint`) with clear file/line diagnostics
+- Test failures (`jest`/`yarn test`) when stack traces or assertion output identify failing test files
+- `verify-repo.sh` code-step failures that resolve to one of the above
+
+Required behavior:
+1. Briefly log rationale: failure type, affected files, and why scope is unambiguous.
+2. Apply the minimal fix in the failing repo.
+3. Re-run the failing verification step.
+4. Limit to 2 remediation attempts; if still failing or scope expands, fall back to `halt-on-error`.
+
+Never auto-fix:
+- Missing tools/auth (`command not found`, `PROMPT_GH_AUTH`)
+- Wrong path/permissions
+- Companion script contract/usage failures
+- Unexpected exit codes from orchestrator scripts
+- Any failure requiring destructive operations or workflow bypasses
+
+
+Respect documented companion script exit-code contracts. Non-zero does NOT always mean fatal.
+
+For `~/.cursor/skills/im/scripts/lint-warnings.sh`:
+- `0` = no remaining lint findings after auto-fix
+- `1` = remaining lint findings after auto-fix (expected actionable state)
+- `2` = execution error (fatal)
+
+Required behavior:
+1. If exit `1`, continue workflow by fixing the remaining lint findings before implementation.
+2. If the script auto-fixes pre-existing lint issues, commit those changes in a separate lint-fix commit immediately before feature commits, even if no findings remain.
+3. If exit `2`, apply `halt-on-error`.
+
+
+Do NOT silently substitute an alternative tool or approach when a command fails. If `rg` is not found, do not fall back to `grep`. If a script exits non-zero, do not manually replicate what the script does. The failure is the signal — report it.
+
+
+
+
+
+Scan the user's message for `/word` tokens. A token is a **command invocation** when ALL of:
+- `/word` is preceded by whitespace, a newline, or is at the start of the message
+- `word` contains only lowercase letters and hyphens (e.g., `/im`, `/pr-create`, `/author`)
+- `/word` is NOT inside a file path, URL, or code block
+
+When detected:
+1. Read `~/.cursor/skills//SKILL.md` and follow it immediately.
+2. If the file does not exist, inform the user: "Skill `/` not found in `~/.cursor/skills/`."
+
+**Ignore `/`** in: file paths (`/Users/...`, `~/...`), URLs (`https://...`), mid-word (`and/or`), backticks/code blocks.
+
+
diff --git a/.cursor/rules/writing-style.mdc b/.cursor/rules/writing-style.mdc
new file mode 100644
index 0000000..3afcb06
--- /dev/null
+++ b/.cursor/rules/writing-style.mdc
@@ -0,0 +1,10 @@
+---
+description: Prose style rules for all written output
+alwaysApply: true
+---
+
+# Writing Style
+
+- NEVER use em-dashes (`—`) in chat responses, code comments, commit messages, PR descriptions, or any other written output. Use a period, comma, colon, semicolon, or parentheses instead, whichever reads best. This is a hard rule with no exceptions.
+- The em-dash character to avoid is U+2014 (`—`). Hyphens (`-`) and en-dashes (`–`) are fine.
+- Keep chat responses tight unless instructed to give a detailed response. Lead with the answer or the action. Skip recap of what was just said. Prefer tables and short bullets over prose when comparing items. Aim for the shortest response that fully answers; if a one-liner suffices, send a one-liner.
\ No newline at end of file
diff --git a/.cursor/scripts/port-to-opencode.sh b/.cursor/scripts/port-to-opencode.sh
new file mode 100755
index 0000000..8c7599b
--- /dev/null
+++ b/.cursor/scripts/port-to-opencode.sh
@@ -0,0 +1,224 @@
+#!/usr/bin/env bash
+# port-to-opencode.sh — Convert Cursor .mdc/.md files to OpenCode-compatible JSON + MD mirrors.
+# Single self-contained script (bash + inline node). No Python dependency.
+#
+# Usage:
+# port-to-opencode.sh # Convert all rules and skills
+# port-to-opencode.sh --dry-run # Show what would be done
+# port-to-opencode.sh --validate # Validate existing JSON mirrors
+# port-to-opencode.sh file1.mdc file2.md # Convert specific files
+set -euo pipefail
+
+DRY_RUN=false
+VALIDATE=false
+FILES=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run) DRY_RUN=true; shift ;;
+ --validate) VALIDATE=true; shift ;;
+ --sync) shift ;; # accepted for compat, no-op
+ *) FILES+=("$1"); shift ;;
+ esac
+done
+
+exec node -e '
+const fs = require("fs")
+const pathMod = require("path")
+const os = require("os")
+
+const CURSOR_DIR = pathMod.join(os.homedir(), ".cursor")
+const OPENCODE_DIR = pathMod.join(os.homedir(), ".config", "opencode")
+const DRY_RUN = process.argv[1] === "true"
+const VALIDATE = process.argv[2] === "true"
+const inputFiles = process.argv.slice(3).filter(f => f)
+
+function parseYamlFrontmatter(content) {
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/)
+ if (!match) return {}
+ const fm = {}
+ for (const line of match[1].split("\n")) {
+ const idx = line.indexOf(":")
+ if (idx === -1) continue
+ const key = line.substring(0, idx).trim()
+ let value = line.substring(idx + 1).trim()
+ if (value.startsWith("[") && value.endsWith("]")) {
+ try { value = JSON.parse(value.replace(/\x27/g, "\x22")) } catch {}
+ } else if (value === "true" || value === "false") {
+ value = value === "true"
+ }
+ fm[key] = value
+ }
+ return fm
+}
+
+function extractTagContent(content, tag) {
+ const re = new RegExp("<" + tag + "[^>]*>([\\s\\S]*?)" + tag + ">")
+ const m = content.match(re)
+ return m ? m[1].trim() : ""
+}
+
+function extractGoal(content) { return extractTagContent(content, "goal") }
+
+function extractRules(content) {
+ const section = extractTagContent(content, "rules")
+ if (!section) return []
+ const rules = []
+ const re = /]*>([\s\S]*?)<\/rule>/g
+ let m
+ while ((m = re.exec(section)) !== null) {
+ let instruction = m[2].trim().replace(/\*\*/g, "").replace(/\s+/g, " ")
+ rules.push({ id: m[1], instruction })
+ }
+ return rules
+}
+
+function extractSteps(content) {
+ const steps = []
+ const re = /]*>([\s\S]*?)<\/step>/g
+ let m
+ while ((m = re.exec(content)) !== null) {
+ steps.push({ id: m[1], name: m[2], instruction: m[3].trim() })
+ }
+ return steps
+}
+
+function extractScriptRefs(content) {
+ const refs = new Set()
+ const re = /[~]?\/[\w/\-.]+\.(sh|js)/g
+ let m
+ while ((m = re.exec(content)) !== null) refs.add(m[0])
+ return [...refs].sort()
+}
+
+function convertMdcToJson(filePath) {
+ const content = fs.readFileSync(filePath, "utf8")
+ const fm = parseYamlFrontmatter(content)
+ const basename = pathMod.basename(filePath, ".mdc")
+ return {
+ id: basename, title: basename,
+ description: fm.description || extractGoal(content),
+ globs: fm.globs || [], alwaysApply: fm.alwaysApply || false,
+ goal: extractGoal(content), rules: extractRules(content),
+ steps: extractSteps(content), scripts: extractScriptRefs(content)
+ }
+}
+
+function convertCommandToJson(filePath) {
+ const content = fs.readFileSync(filePath, "utf8")
+ const basename = pathMod.basename(filePath, ".md")
+ const goal = extractGoal(content)
+ return {
+ id: basename, title: basename, description: goal, goal,
+ rules: extractRules(content), steps: extractSteps(content),
+ scripts: extractScriptRefs(content)
+ }
+}
+
+function convertSkillToJson(filePath) {
+ const content = fs.readFileSync(filePath, "utf8")
+ const fm = parseYamlFrontmatter(content)
+ const basename = pathMod.basename(pathMod.dirname(filePath))
+ return {
+ id: basename, title: fm.name || basename, name: fm.name || basename,
+ description: fm.description || extractGoal(content),
+ goal: extractGoal(content), rules: extractRules(content),
+ steps: extractSteps(content), scripts: extractScriptRefs(content)
+ }
+}
+
+function convertToMd(content) {
+ let r = content
+ r = r.replace(/([\s\S]*?)<\/goal>/g, "## Goal\n\n$1\n")
+ r = r.replace(/]*>/g, "## Rules\n\n")
+ r = r.replace(/<\/rules>/g, "")
+ r = r.replace(//g, "- **$1**: ")
+ r = r.replace(/<\/rule>/g, "")
+ r = r.replace(//g, "### Step $1: $2\n\n")
+ r = r.replace(/<\/step>/g, "")
+ r = r.replace(//g, "#### $1\n\n")
+ r = r.replace(/<\/sub-step>/g, "")
+ r = r.replace(//g, "## Edge Cases\n\n")
+ r = r.replace(/<\/edge-cases>/g, "")
+ r = r.replace(//g, "### $1\n\n")
+ r = r.replace(/<\/case>/g, "")
+ r = r.replace(//g, "## Sequence: $1\n\n")
+ r = r.replace(/<\/sequence>/g, "")
+ r = r.replace(//g, "## Scope\n\n")
+ r = r.replace(/<\/scope>/g, "")
+ r = r.replace(/]*>/g, "## Standards\n\n")
+ r = r.replace(/<\/standards>/g, "")
+ r = r.replace(//g, "- **$1**: ")
+ r = r.replace(/<\/standard>/g, "")
+ while (r.includes("\n\n\n")) r = r.replace(/\n\n\n/g, "\n\n")
+ return r
+}
+
+function processFile(filePath) {
+ let outputDir, outputBase, converter
+ if (filePath.includes("/rules/") && filePath.endsWith(".mdc")) {
+ outputDir = pathMod.join(OPENCODE_DIR, "rules")
+ outputBase = pathMod.basename(filePath, ".mdc")
+ converter = convertMdcToJson
+ } else if (filePath.includes("/skills/") && pathMod.basename(filePath) === "SKILL.md") {
+ outputDir = pathMod.join(OPENCODE_DIR, "skills", pathMod.basename(pathMod.dirname(filePath)))
+ outputBase = "SKILL"
+ converter = convertSkillToJson
+ } else {
+ return "Skipping: " + filePath + " (unknown type)"
+ }
+
+ const jsonPath = pathMod.join(outputDir, outputBase + ".json")
+ const mdPath = pathMod.join(outputDir, outputBase + ".md")
+
+ if (DRY_RUN) return "Would create: " + jsonPath + "\n Would create: " + mdPath
+
+ fs.mkdirSync(outputDir, { recursive: true })
+ const jsonData = converter(filePath)
+ const content = fs.readFileSync(filePath, "utf8")
+ fs.writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2) + "\n")
+ fs.writeFileSync(mdPath, convertToMd(content))
+ return "Converted: " + filePath + " -> " + jsonPath
+}
+
+function validateJson(jsonPath) {
+ try {
+ const data = JSON.parse(fs.readFileSync(jsonPath, "utf8"))
+ const missing = ["id", "title", "description"].filter(f => !(f in data))
+ if (missing.length) return "INVALID: " + jsonPath + " (missing: " + missing.join(", ") + ")"
+ return "VALID: " + jsonPath
+ } catch (e) {
+ return "INVALID: " + jsonPath + " (not valid JSON: " + e.message + ")"
+ }
+}
+
+function walkDir(dir, predicate) {
+ const results = []
+ try {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = pathMod.join(dir, entry.name)
+ if (entry.isDirectory()) results.push(...walkDir(full, predicate))
+ else if (predicate(full, entry.name)) results.push(full)
+ }
+ } catch {}
+ return results
+}
+
+if (VALIDATE) {
+ console.log("Validating JSON mirrors...")
+ for (const f of walkDir(OPENCODE_DIR, (fp, n) => n.endsWith(".json"))) console.log(validateJson(f))
+ process.exit(0)
+}
+
+const files = inputFiles.length > 0
+ ? inputFiles.map(f => f.startsWith("~") ? f.replace("~", os.homedir()) : f)
+ : [
+ ...walkDir(pathMod.join(CURSOR_DIR, "rules"), (fp, n) => n.endsWith(".mdc")),
+ ...walkDir(pathMod.join(CURSOR_DIR, "skills"), (fp, n) => n === "SKILL.md")
+ ]
+
+console.log("Found " + files.length + " files to process")
+for (const f of files) console.log(processFile(f))
+console.log("\nDone. Processed " + files.length + " files.")
+if (DRY_RUN) console.log("Run without --dry-run to write files.")
+' "$DRY_RUN" "$VALIDATE" ${FILES[@]+"${FILES[@]}"}
diff --git a/.cursor/scripts/pr-status-gql.sh b/.cursor/scripts/pr-status-gql.sh
new file mode 100755
index 0000000..b21c3ca
--- /dev/null
+++ b/.cursor/scripts/pr-status-gql.sh
@@ -0,0 +1,429 @@
+#!/usr/bin/env bash
+# pr-status-gql.sh — Fetch status of open PRs for a user (GraphQL API).
+# Single run, no TUI. "New" comments = posted after the PR's last commit.
+#
+# Uses a single GraphQL query per poll. Separate rate limit budget from REST.
+#
+# Usage:
+# pr-status-gql.sh --repo edge-react-gui [--owner EdgeApp] [--user Jon-edge] [--format text|json]
+# pr-status-gql.sh # All repos for user in EdgeApp org
+# pr-status-gql.sh --budget 0.5 # Reserve 50% of rate limit for other tools
+#
+# Requires: gh CLI (authenticated).
+set -euo pipefail
+
+OWNER="EdgeApp" REPO="" USER="" FORMAT="text" BUDGET="0.67"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --owner) OWNER="$2"; shift 2 ;;
+ --repo) REPO="$2"; shift 2 ;;
+ --user) USER="$2"; shift 2 ;;
+ --format) FORMAT="$2"; shift 2 ;;
+ --budget) BUDGET="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+STATE_DIR="${TMPDIR:-/tmp}/pr-watch-gql-${OWNER}-${REPO:-all}"
+mkdir -p "$STATE_DIR"
+export STATE_DIR
+
+# Build the GraphQL query based on mode (single repo vs all repos)
+PR_FIELDS='
+ number title isDraft url headRefName updatedAt
+ repository { name nameWithOwner }
+ headRefOid
+ reviewDecision
+ reviews(last: 30) {
+ nodes { author { login } state submittedAt }
+ }
+ comments(last: 100) {
+ totalCount
+ nodes { author { login } createdAt bodyText }
+ }
+ reviewThreads(first: 100) {
+ nodes {
+ isResolved
+ comments(first: 5) {
+ nodes { author { login } createdAt bodyText path line }
+ }
+ }
+ }
+ commits(last: 1) {
+ nodes {
+ commit {
+ committedDate
+ oid
+ statusCheckRollup {
+ contexts(first: 20) {
+ nodes {
+ ... on CheckRun {
+ __typename name status conclusion
+ }
+ ... on StatusContext {
+ __typename context state
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+'
+
+if [[ -n "$REPO" ]]; then
+ QUERY="
+ {
+ viewer { login }
+ repository(owner: \"${OWNER}\", name: \"${REPO}\") {
+ pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
+ nodes {
+ author { login }
+ ${PR_FIELDS}
+ }
+ }
+ }
+ rateLimit { cost remaining resetAt limit }
+ }"
+else
+ QUERY="
+ {
+ viewer {
+ login
+ pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
+ nodes {
+ ${PR_FIELDS}
+ }
+ }
+ }
+ rateLimit { cost remaining resetAt limit }
+ }"
+fi
+
+# Execute query via gh CLI
+GQL_RESULT=$(gh api graphql -f query="$QUERY" 2>&1)
+
+# Process the result with Node.js
+exec node -e '
+const fs = require("fs")
+const { OWNER, REPO, USER_ARG, FORMAT, BUDGET, STATE_DIR } = {
+ OWNER: process.argv[1],
+ REPO: process.argv[2] || "",
+ USER_ARG: process.argv[3],
+ FORMAT: process.argv[4],
+ BUDGET: parseFloat(process.argv[5]) || 0.67,
+ STATE_DIR: process.argv[6]
+}
+const gqlResult = JSON.parse(process.argv[7])
+
+if (gqlResult.errors) {
+ process.stderr.write("GraphQL errors: " + JSON.stringify(gqlResult.errors) + "\n")
+ process.exit(1)
+}
+
+const data = gqlResult.data
+
+// --- Determine user and extract raw PR nodes ---
+let user
+let rawNodes
+
+if (REPO) {
+ // Single-repo mode: repository.pullRequests, filtered by viewer login
+ user = USER_ARG || data.viewer?.login || "unknown"
+ rawNodes = (data.repository?.pullRequests?.nodes || [])
+ .filter(n => n.author?.login === user)
+} else {
+ // All-repo mode: viewer.pullRequests (already scoped to authenticated user)
+ user = data.viewer?.login || USER_ARG || "unknown"
+ rawNodes = data.viewer?.pullRequests?.nodes || []
+}
+
+// --- Rate limit ---
+const rateLimit = data.rateLimit || {}
+const rlCost = rateLimit.cost || 1
+const rlRemaining = rateLimit.remaining
+const rlLimit = rateLimit.limit
+const rlResetAt = rateLimit.resetAt
+
+// --- NEW PR tracking ---
+function loadPreviousPrNumbers() {
+ try { return JSON.parse(fs.readFileSync(`${STATE_DIR}/known-prs.json`, "utf8")) } catch { return [] }
+}
+function savePrNumbers(numbers) {
+ fs.writeFileSync(`${STATE_DIR}/known-prs.json`, JSON.stringify(numbers))
+}
+
+const previousPrNumbers = loadPreviousPrNumbers()
+const currentPrNumbers = rawNodes.map(n => n.number)
+const newPrNumbers = new Set(currentPrNumbers.filter(n => !previousPrNumbers.includes(n)))
+savePrNumbers(currentPrNumbers)
+
+// --- Transform GQL nodes to result format ---
+function checkInfo(contexts, name) {
+ const run = (contexts || []).find(c => c.__typename === "CheckRun" && c.name === name)
+ if (!run) return { status: "none", conclusion: null }
+ return { status: run.status?.toLowerCase() || "none", conclusion: run.conclusion?.toLowerCase() || null }
+}
+
+function relTime(iso) {
+ if (!iso) return "-"
+ const ms = Date.now() - new Date(iso).getTime()
+ const m = Math.floor(ms / 60000)
+ if (m < 60) return m + "m ago"
+ const h = Math.floor(m / 60)
+ if (h < 24) return h + "h ago"
+ return Math.floor(h / 24) + "d ago"
+}
+
+const results = rawNodes.map(pr => {
+ const repo = pr.repository?.name || REPO
+ const n = pr.number
+ const sha = pr.headRefOid?.substring(0, 7) || "?"
+ const lastCommitNode = pr.commits?.nodes?.[0]?.commit
+ const lastCommitDate = lastCommitNode?.committedDate || null
+ const contexts = lastCommitNode?.statusCheckRollup?.contexts?.nodes || []
+
+ // Collect review thread comments (inline review comments)
+ const reviewThreadComments = []
+ for (const thread of (pr.reviewThreads?.nodes || [])) {
+ for (const c of (thread.comments?.nodes || [])) {
+ if (c.author?.login !== user) {
+ reviewThreadComments.push({
+ user: c.author?.login,
+ body: c.bodyText?.substring(0, 120),
+ at: c.createdAt,
+ path: c.path,
+ line: c.line,
+ type: "review"
+ })
+ }
+ }
+ }
+
+ // Issue comments
+ const issueComments = (pr.comments?.nodes || [])
+ .filter(c => c.author?.login !== user)
+ .map(c => ({
+ user: c.author?.login,
+ body: c.bodyText?.substring(0, 120),
+ at: c.createdAt,
+ type: "issue"
+ }))
+
+ const allComments = [...reviewThreadComments, ...issueComments]
+ .sort((a, b) => b.at.localeCompare(a.at))
+
+ // Split into new (after last commit) and old
+ const newComments = lastCommitDate
+ ? allComments.filter(c => c.at > lastCommitDate)
+ : []
+ const oldComments = lastCommitDate
+ ? allComments.filter(c => c.at <= lastCommitDate)
+ : allComments
+
+ // Review approval status — dedupe to latest review per human user
+ const latestByUser = {}
+ for (const r of (pr.reviews?.nodes || [])) {
+ const login = r.author?.login
+ if (!login || login.endsWith("[bot]")) continue
+ if (login === user) continue
+ if (!latestByUser[login] || r.submittedAt > latestByUser[login].submittedAt) {
+ latestByUser[login] = r
+ }
+ }
+ const approvals = Object.values(latestByUser).filter(r => r.state === "APPROVED").map(r => r.author.login)
+ const changesRequested = Object.values(latestByUser).filter(r => r.state === "CHANGES_REQUESTED").map(r => r.author.login)
+ const reviewerCount = Object.keys(latestByUser).length
+
+ return {
+ number: n,
+ repo,
+ title: pr.title,
+ branch: pr.headRefName,
+ draft: pr.isDraft,
+ isNew: newPrNumbers.has(n),
+ lastCommitSha: sha,
+ lastCommitDate,
+ comments: {
+ total: allComments.length,
+ new: newComments.length,
+ old: oldComments.length,
+ newComments: newComments.map(c => ({ user: c.user, at: c.at, path: c.path, line: c.line, body: c.body })),
+ latest: allComments[0] ? { user: allComments[0].user, at: allComments[0].at } : null
+ },
+ reviews: {
+ approvals,
+ changesRequested,
+ reviewerCount
+ },
+ checks: {
+ bugbot: checkInfo(contexts, "Cursor Bugbot"),
+ ci: checkInfo(contexts, "Travis CI - Pull Request"),
+ codeql: checkInfo(contexts, "Analyze (javascript-typescript)")
+ }
+ }
+})
+
+// Calculate recommended interval
+const secsUntilReset = rlResetAt ? Math.max(1, Math.floor((new Date(rlResetAt).getTime() - Date.now()) / 1000)) : 3600
+const budgetCalls = rlRemaining != null ? Math.floor(rlRemaining * BUDGET) : 2500
+const pollsAvailable = budgetCalls > 0 ? Math.floor(budgetCalls / rlCost) : 1
+const recommendedInterval = Math.max(30, Math.ceil(secsUntilReset / pollsAvailable))
+
+const meta = {
+ backend: "graphql",
+ queryCost: rlCost,
+ rateLimitRemaining: rlRemaining,
+ rateLimitLimit: rlLimit,
+ rateLimitResetAt: rlResetAt,
+ recommendedInterval
+}
+
+if (FORMAT === "json") {
+ console.log(JSON.stringify({ user, owner: OWNER, repo: REPO || null, timestamp: new Date().toISOString(), meta, prs: results }, null, 2))
+ process.exit(0)
+}
+
+// Text output — FORCE_COLOR env var overrides TTY detection (for pr-watch subshell)
+const IS_TTY = process.env.FORCE_COLOR === "1" || process.stdout.isTTY
+const B = IS_TTY ? "\x1b[1m" : ""
+const D = IS_TTY ? "\x1b[2m" : ""
+const R = IS_TTY ? "\x1b[0m" : ""
+const GR = IS_TTY ? "\x1b[32m" : ""
+const YL = IS_TTY ? "\x1b[33m" : ""
+const RD = IS_TTY ? "\x1b[31m" : ""
+const CY = IS_TTY ? "\x1b[36m" : ""
+const MG = IS_TTY ? "\x1b[35m" : ""
+const LINE = "─".repeat(72)
+const multiRepo = !REPO
+
+function fmtCheck(label, c) {
+ if (c.status === "none") return D + label + " —" + R
+ if (c.status !== "completed") return YL + "⏳ " + label + R
+ if (c.conclusion === "success") return GR + "✅ " + label + R
+ if (c.conclusion === "neutral") return YL + "⚠️ " + label + R
+ if (c.conclusion === "failure") return RD + "❌ " + label + R
+ return label + " " + (c.conclusion || "?")
+}
+
+function fmtReview(pr) {
+ const { approvals, changesRequested, reviewerCount } = pr.reviews
+ if (changesRequested.length > 0)
+ return `${RD}❌ Changes requested${R} ${D}(${changesRequested.join(", ")})${R}`
+ if (approvals.length > 0 && approvals.length >= reviewerCount && reviewerCount > 0)
+ return `${GR}✅ Approved${R} ${D}(${approvals.join(", ")})${R}`
+ if (approvals.length > 0)
+ return `${GR}👍 ${approvals.length}/${reviewerCount} approved${R} ${D}(${approvals.join(", ")})${R}`
+ if (reviewerCount > 0)
+ return `${YL}👀 Awaiting review${R}`
+ return `${D}No reviews${R}`
+}
+
+function prState(pr) {
+ const hasApproval = pr.reviews.approvals.length > 0
+ const hasChangesRequested = pr.reviews.changesRequested.length > 0
+ const hasNew = pr.comments.new > 0
+ const bugbotOk = pr.checks.bugbot.conclusion === "success" || pr.checks.bugbot.status === "none"
+ const ciOk = pr.checks.ci.conclusion === "success" || pr.checks.ci.status === "none"
+ const ciFail = pr.checks.ci.conclusion === "failure"
+ const ciPending = pr.checks.ci.status !== "completed" && pr.checks.ci.status !== "none"
+ const bugbotPending = pr.checks.bugbot.status !== "completed" && pr.checks.bugbot.status !== "none"
+ const bugbotIssues = pr.checks.bugbot.conclusion === "neutral"
+ const checksGreen = bugbotOk && ciOk
+
+ if (ciFail || hasChangesRequested)
+ return { tier: 5, tag: `${RD}${B}BLOCKED${R}`, emoji: "🔴" }
+ if (hasNew || bugbotIssues)
+ return { tier: 4, tag: `${YL}${B}ATTENTION${R}`, emoji: "🟡" }
+ if (ciPending || bugbotPending)
+ return { tier: 3, tag: `${YL}PENDING${R}`, emoji: "⏳" }
+ if (hasApproval && checksGreen)
+ return { tier: 0, tag: `${GR}${B}READY${R}`, emoji: "🚀" }
+ if (hasApproval)
+ return { tier: 1, tag: `${GR}APPROVED${R}`, emoji: "👍" }
+ if (checksGreen)
+ return { tier: 2, tag: `${GR}CLEAR${R}`, emoji: "🟢" }
+ return { tier: 3, tag: `${D}OPEN${R}`, emoji: "⚪" }
+}
+
+function sortedPRs(list) {
+ return [...list].sort((a, b) => {
+ const ta = prState(a).tier, tb = prState(b).tier
+ if (ta !== tb) return ta - tb
+ const da = a.comments.latest?.at || a.lastCommitDate || ""
+ const db = b.comments.latest?.at || b.lastCommitDate || ""
+ return db.localeCompare(da)
+ })
+}
+
+function renderPR(pr, indent) {
+ const state = prState(pr)
+ const draft = pr.draft ? ` ${D}[draft]${R}` : ""
+ const newPrTag = pr.isNew ? ` ${MG}${B}NEW${R}` : ""
+ const title = pr.title.length > 45 ? pr.title.substring(0, 42) + "..." : pr.title
+ const newTag = pr.comments.new > 0
+ ? ` ${RD}${B}🔔 +${pr.comments.new} new${R}`
+ : ""
+ const latestInfo = pr.comments.latest
+ ? `${D}${pr.comments.latest.user} ${relTime(pr.comments.latest.at)}${R}`
+ : `${D}none${R}`
+ const pad = " ".repeat(indent)
+ const prUrl = `https://github.com/${OWNER}/${pr.repo}/pull/${pr.number}`
+
+ const lines = []
+ lines.push(`${pad}${state.emoji} ${state.tag} ${B}#${pr.number}${R}${draft}${newPrTag} ${CY}${title}${R}`)
+ lines.push(`${pad} ${D}↳${R} ${MG}${pr.branch}${R} ${D}${prUrl}${R}`)
+ lines.push(`${pad} ${fmtReview(pr)}`)
+ lines.push(`${pad} 💬 ${pr.comments.total}${newTag} ${D}latest:${R} ${latestInfo}`)
+ lines.push(`${pad} ${fmtCheck("Bugbot", pr.checks.bugbot)} ${fmtCheck("CI", pr.checks.ci)} ${fmtCheck("CodeQL", pr.checks.codeql)}`)
+ return lines
+}
+
+const scope = REPO ? `${OWNER}/${REPO}` : `${OWNER}/*`
+const out = []
+out.push(`${B}${scope}${R} ${D}— ${user} — ${results.length} open PR(s)${R}`)
+out.push(`${D}${LINE}${R}`)
+
+if (!results.length) {
+ out.push(`${D}No open PRs by ${user}${R}`)
+} else if (multiRepo) {
+ const byRepo = {}
+ for (const pr of results) {
+ if (!byRepo[pr.repo]) byRepo[pr.repo] = []
+ byRepo[pr.repo].push(pr)
+ }
+ const repoOrder = Object.keys(byRepo).sort((a, b) => {
+ const latestA = sortedPRs(byRepo[a])[0]
+ const latestB = sortedPRs(byRepo[b])[0]
+ const da = latestA.comments.latest?.at || latestA.lastCommitDate || ""
+ const db = latestB.comments.latest?.at || latestB.lastCommitDate || ""
+ return db.localeCompare(da)
+ })
+ for (const repo of repoOrder) {
+ out.push(``)
+ out.push(`${B}${repo}${R} ${D}(${byRepo[repo].length})${R}`)
+ for (const pr of sortedPRs(byRepo[repo])) {
+ out.push("")
+ out.push(...renderPR(pr, 2))
+ }
+ }
+} else {
+ for (const pr of sortedPRs(results)) {
+ out.push("")
+ out.push(...renderPR(pr, 0))
+ }
+}
+
+// Footer with rate limit info
+out.push("")
+const rlInfo = rlRemaining != null
+ ? `GQL: ${rlRemaining}/${rlLimit} remaining (cost ${rlCost})`
+ : "GQL: unknown"
+out.push(`${D}${LINE}${R}`)
+out.push(`${D}${rlInfo} | next: ${recommendedInterval}s${R}`)
+
+// Machine-readable line for pr-watch.sh to parse
+out.push(`# interval:${recommendedInterval}`)
+
+console.log(out.join("\n"))
+' "$OWNER" "$REPO" "$USER" "$FORMAT" "$BUDGET" "$STATE_DIR" "$GQL_RESULT"
diff --git a/.cursor/scripts/pr-status.sh b/.cursor/scripts/pr-status.sh
new file mode 100755
index 0000000..44519c7
--- /dev/null
+++ b/.cursor/scripts/pr-status.sh
@@ -0,0 +1,407 @@
+#!/usr/bin/env bash
+# pr-status.sh — Fetch status of open PRs for a user via gh CLI.
+# Single run, no TUI. "New" comments = posted after the PR's last commit.
+#
+# Uses gh CLI for all API access (no GITHUB_TOKEN needed).
+# Per-PR updated_at caching to skip detail fetches for unchanged PRs.
+#
+# Usage:
+# pr-status.sh --repo edge-react-gui [--owner EdgeApp] [--user Jon-edge] [--format text|json]
+# pr-status.sh # All repos for user in EdgeApp org
+# pr-status.sh --user Jon-edge # All repos for specific user in EdgeApp org
+#
+# Requires: gh CLI (authenticated), node.
+set -euo pipefail
+
+OWNER="EdgeApp" REPO="" USER="" FORMAT="text"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --owner) OWNER="$2"; shift 2 ;;
+ --repo) REPO="$2"; shift 2 ;;
+ --user) USER="$2"; shift 2 ;;
+ --format) FORMAT="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+command -v gh &>/dev/null || { echo "Error: gh CLI not found. Install: https://cli.github.com" >&2; exit 2; }
+gh auth status &>/dev/null 2>&1 || { echo "Error: gh not authenticated. Run: gh auth login" >&2; exit 2; }
+
+STATE_DIR="${TMPDIR:-/tmp}/pr-watch-${OWNER}-${REPO:-all}"
+mkdir -p "$STATE_DIR"
+export STATE_DIR
+
+exec node -e '
+const { execFile } = require("child_process")
+const fs = require("fs")
+const { OWNER, REPO, USER, FORMAT } = {
+ OWNER: process.argv[1],
+ REPO: process.argv[2] || "",
+ USER: process.argv[3],
+ FORMAT: process.argv[4]
+}
+const STATE_DIR = process.env.STATE_DIR
+
+let apiCallCount = 0
+
+function ghFetch(path, extraArgs) {
+ return new Promise((resolve) => {
+ apiCallCount++
+ const args = ["api", path]
+ if (extraArgs) args.push(...extraArgs)
+ execFile("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
+ if (err) { resolve(null); return }
+ try { resolve(JSON.parse(stdout)) } catch { resolve(null) }
+ })
+ })
+}
+
+// --- Per-PR updated_at caching ---
+function loadPrCache(number) {
+ try { return JSON.parse(fs.readFileSync(`${STATE_DIR}/pr-${number}.json`, "utf8")) } catch { return null }
+}
+
+function savePrCache(number, result, updatedAt) {
+ fs.writeFileSync(`${STATE_DIR}/pr-${number}.json`, JSON.stringify({ updatedAt, result }))
+}
+
+function loadPreviousPrNumbers() {
+ try { return JSON.parse(fs.readFileSync(`${STATE_DIR}/known-prs.json`, "utf8")) } catch { return [] }
+}
+
+function savePrNumbers(numbers) {
+ fs.writeFileSync(`${STATE_DIR}/known-prs.json`, JSON.stringify(numbers))
+}
+
+// --- Concurrency limiter ---
+async function pool(items, concurrency, fn) {
+ const results = new Array(items.length)
+ let next = 0
+ async function worker() {
+ while (next < items.length) {
+ const i = next++
+ results[i] = await fn(items[i], i)
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
+ return results
+}
+
+// --- Utilities ---
+function relTime(iso) {
+ if (!iso) return "-"
+ const ms = Date.now() - new Date(iso).getTime()
+ const m = Math.floor(ms / 60000)
+ if (m < 60) return m + "m ago"
+ const h = Math.floor(m / 60)
+ if (h < 24) return h + "h ago"
+ return Math.floor(h / 24) + "d ago"
+}
+
+function checkInfo(runs, name) {
+ const run = (runs || []).find(c => c.name === name)
+ if (!run) return { status: "none", conclusion: null }
+ return { status: run.status, conclusion: run.conclusion }
+}
+
+async function main() {
+ let user = USER
+ if (!user) {
+ const me = await ghFetch("/user")
+ user = me?.login || "unknown"
+ }
+
+ const previousPrNumbers = loadPreviousPrNumbers()
+
+ let prs
+ if (REPO) {
+ const allPRs = await ghFetch(`/repos/${OWNER}/${REPO}/pulls?state=open&per_page=30`)
+ if (!Array.isArray(allPRs)) {
+ process.stderr.write("API error fetching PRs\n")
+ process.exit(1)
+ }
+ prs = allPRs
+ .filter(p => p.user.login === user)
+ .map(p => ({ ...p, _repo: REPO }))
+ } else {
+ const q = encodeURIComponent(`type:pr state:open author:${user} org:${OWNER}`)
+ const search = await ghFetch(`/search/issues?q=${q}&per_page=50&sort=updated&order=desc`)
+ if (!search?.items) {
+ process.stderr.write("API error searching PRs\n")
+ process.exit(1)
+ }
+ prs = await pool(search.items, 4, async item => {
+ const repo = item.repository_url.split("/").pop()
+ const full = await ghFetch(`/repos/${OWNER}/${repo}/pulls/${item.number}`)
+ return { ...full, _repo: repo }
+ })
+ }
+
+ const currentPrNumbers = prs.map(p => p.number)
+ const newPrNumbers = new Set(currentPrNumbers.filter(n => !previousPrNumbers.includes(n)))
+ savePrNumbers(currentPrNumbers)
+
+ let changedPrCount = 0
+
+ const results = await pool(prs, 4, async pr => {
+ const repo = pr._repo
+ const n = pr.number
+ const sha = pr.head.sha
+ const updatedAt = pr.updated_at
+
+ const cached = loadPrCache(n)
+ if (cached && cached.updatedAt === updatedAt && !newPrNumbers.has(n)) {
+ return { ...cached.result, isNew: false }
+ }
+
+ changedPrCount++
+
+ const [inline, issue, checks, commits, reviews] = await Promise.all([
+ ghFetch(`/repos/${OWNER}/${repo}/pulls/${n}/comments?per_page=100`),
+ ghFetch(`/repos/${OWNER}/${repo}/issues/${n}/comments?per_page=100`),
+ ghFetch(`/repos/${OWNER}/${repo}/commits/${sha}/check-runs`),
+ ghFetch(`/repos/${OWNER}/${repo}/pulls/${n}/commits?per_page=100`),
+ ghFetch(`/repos/${OWNER}/${repo}/pulls/${n}/reviews?per_page=100`)
+ ])
+
+ const commitList = Array.isArray(commits) ? commits : []
+ const lastCommit = commitList.length > 0 ? commitList[commitList.length - 1] : null
+ const lastCommitDate = lastCommit?.commit?.committer?.date
+ || lastCommit?.commit?.author?.date
+ || null
+
+ const allComments = [
+ ...(Array.isArray(inline) ? inline : [])
+ .filter(c => c.user?.login !== user)
+ .map(c => ({ id: c.id, user: c.user?.login, body: c.body?.substring(0, 120), at: c.created_at, path: c.path, line: c.line, type: "review" })),
+ ...(Array.isArray(issue) ? issue : [])
+ .filter(c => c.user?.login !== user)
+ .map(c => ({ id: c.id, user: c.user?.login, body: c.body?.substring(0, 120), at: c.created_at, type: "issue" }))
+ ].sort((a, b) => b.at.localeCompare(a.at))
+
+ const newComments = lastCommitDate
+ ? allComments.filter(c => c.at > lastCommitDate)
+ : []
+ const oldComments = lastCommitDate
+ ? allComments.filter(c => c.at <= lastCommitDate)
+ : allComments
+
+ const checkRuns = checks?.check_runs || []
+
+ const reviewList = Array.isArray(reviews) ? reviews : []
+ const latestByUser = {}
+ for (const r of reviewList) {
+ const login = r.user?.login
+ if (!login || login.endsWith("[bot]")) continue
+ if (login === user) continue
+ if (!latestByUser[login] || r.submitted_at > latestByUser[login].submitted_at) {
+ latestByUser[login] = r
+ }
+ }
+ const approvals = Object.values(latestByUser).filter(r => r.state === "APPROVED").map(r => r.user.login)
+ const changesRequested = Object.values(latestByUser).filter(r => r.state === "CHANGES_REQUESTED").map(r => r.user.login)
+ const reviewerCount = Object.keys(latestByUser).length
+
+ const result = {
+ number: n,
+ repo,
+ title: pr.title,
+ branch: pr.head.ref,
+ draft: pr.draft,
+ isNew: newPrNumbers.has(n),
+ lastCommitSha: sha.substring(0, 7),
+ lastCommitDate,
+ comments: {
+ total: allComments.length,
+ new: newComments.length,
+ old: oldComments.length,
+ newComments: newComments.map(c => ({ user: c.user, at: c.at, path: c.path, line: c.line, body: c.body })),
+ latest: allComments[0] ? { user: allComments[0].user, at: allComments[0].at } : null
+ },
+ reviews: {
+ approvals,
+ changesRequested,
+ reviewerCount
+ },
+ checks: {
+ bugbot: checkInfo(checkRuns, "Cursor Bugbot"),
+ ci: checkInfo(checkRuns, "Travis CI - Pull Request"),
+ codeql: checkInfo(checkRuns, "Analyze (javascript-typescript)")
+ }
+ }
+
+ savePrCache(n, result, updatedAt)
+ return result
+ })
+
+ // Fetch rate limit info
+ const rateLimit = await ghFetch("/rate_limit")
+ const rateLimitRemaining = rateLimit?.resources?.core?.remaining ?? null
+ const rateLimitLimit = rateLimit?.resources?.core?.limit ?? null
+ const rateLimitReset = rateLimit?.resources?.core?.reset ?? null
+
+ const callsPerPoll = apiCallCount
+ const secsUntilReset = rateLimitReset ? Math.max(1, rateLimitReset - Math.floor(Date.now() / 1000)) : 3600
+ const budgetCalls = rateLimitRemaining != null ? Math.floor(rateLimitRemaining * 0.67) : 2500
+ const recommendedInterval = budgetCalls > 0 ? Math.max(30, Math.ceil(secsUntilReset / (budgetCalls / callsPerPoll))) : 300
+
+ const meta = {
+ apiCalls: apiCallCount,
+ changedPrs: changedPrCount,
+ rateLimitRemaining,
+ rateLimitLimit,
+ rateLimitReset,
+ recommendedInterval
+ }
+
+ if (FORMAT === "json") {
+ console.log(JSON.stringify({ user, owner: OWNER, repo: REPO || null, timestamp: new Date().toISOString(), meta, prs: results }, null, 2))
+ return
+ }
+
+ // Text output — FORCE_COLOR env var overrides TTY detection (for pr-watch subshell)
+ const IS_TTY = process.env.FORCE_COLOR === "1" || process.stdout.isTTY
+ const B = IS_TTY ? "\x1b[1m" : ""
+ const D = IS_TTY ? "\x1b[2m" : ""
+ const R = IS_TTY ? "\x1b[0m" : ""
+ const GR = IS_TTY ? "\x1b[32m" : ""
+ const YL = IS_TTY ? "\x1b[33m" : ""
+ const RD = IS_TTY ? "\x1b[31m" : ""
+ const CY = IS_TTY ? "\x1b[36m" : ""
+ const MG = IS_TTY ? "\x1b[35m" : ""
+ const LINE = "─".repeat(72)
+ const multiRepo = !REPO
+
+ function fmtCheck(label, c) {
+ if (c.status === "none") return D + label + " —" + R
+ if (c.status !== "completed") return YL + "⏳ " + label + R
+ if (c.conclusion === "success") return GR + "✅ " + label + R
+ if (c.conclusion === "neutral") return YL + "⚠️ " + label + R
+ if (c.conclusion === "failure") return RD + "❌ " + label + R
+ return label + " " + (c.conclusion || "?")
+ }
+
+ function fmtReview(pr) {
+ const { approvals, changesRequested, reviewerCount } = pr.reviews
+ if (changesRequested.length > 0)
+ return `${RD}❌ Changes requested${R} ${D}(${changesRequested.join(", ")})${R}`
+ if (approvals.length > 0 && approvals.length >= reviewerCount && reviewerCount > 0)
+ return `${GR}✅ Approved${R} ${D}(${approvals.join(", ")})${R}`
+ if (approvals.length > 0)
+ return `${GR}👍 ${approvals.length}/${reviewerCount} approved${R} ${D}(${approvals.join(", ")})${R}`
+ if (reviewerCount > 0)
+ return `${YL}👀 Awaiting review${R}`
+ return `${D}No reviews${R}`
+ }
+
+ function prState(pr) {
+ const hasApproval = pr.reviews.approvals.length > 0
+ const hasChangesRequested = pr.reviews.changesRequested.length > 0
+ const hasNew = pr.comments.new > 0
+ const bugbotOk = pr.checks.bugbot.conclusion === "success" || pr.checks.bugbot.status === "none"
+ const ciOk = pr.checks.ci.conclusion === "success" || pr.checks.ci.status === "none"
+ const ciFail = pr.checks.ci.conclusion === "failure"
+ const ciPending = pr.checks.ci.status !== "completed" && pr.checks.ci.status !== "none"
+ const bugbotPending = pr.checks.bugbot.status !== "completed" && pr.checks.bugbot.status !== "none"
+ const bugbotIssues = pr.checks.bugbot.conclusion === "neutral"
+ const checksGreen = bugbotOk && ciOk
+
+ if (ciFail || hasChangesRequested)
+ return { tier: 5, tag: `${RD}${B}BLOCKED${R}`, emoji: "🔴" }
+ if (hasNew || bugbotIssues)
+ return { tier: 4, tag: `${YL}${B}ATTENTION${R}`, emoji: "🟡" }
+ if (ciPending || bugbotPending)
+ return { tier: 3, tag: `${YL}PENDING${R}`, emoji: "⏳" }
+ if (hasApproval && checksGreen)
+ return { tier: 0, tag: `${GR}${B}READY${R}`, emoji: "🚀" }
+ if (hasApproval)
+ return { tier: 1, tag: `${GR}APPROVED${R}`, emoji: "👍" }
+ if (checksGreen)
+ return { tier: 2, tag: `${GR}CLEAR${R}`, emoji: "🟢" }
+ return { tier: 3, tag: `${D}OPEN${R}`, emoji: "⚪" }
+ }
+
+ function sortedPRs(list) {
+ return [...list].sort((a, b) => {
+ const ta = prState(a).tier, tb = prState(b).tier
+ if (ta !== tb) return ta - tb
+ const da = a.comments.latest?.at || a.lastCommitDate || ""
+ const db = b.comments.latest?.at || b.lastCommitDate || ""
+ return db.localeCompare(da)
+ })
+ }
+
+ function renderPR(pr, indent) {
+ const state = prState(pr)
+ const draft = pr.draft ? ` ${D}[draft]${R}` : ""
+ const newPrTag = pr.isNew ? ` ${MG}${B}NEW${R}` : ""
+ const title = pr.title.length > 45 ? pr.title.substring(0, 42) + "..." : pr.title
+ const newTag = pr.comments.new > 0
+ ? ` ${RD}${B}🔔 +${pr.comments.new} new${R}`
+ : ""
+ const latestInfo = pr.comments.latest
+ ? `${D}${pr.comments.latest.user} ${relTime(pr.comments.latest.at)}${R}`
+ : `${D}none${R}`
+ const pad = " ".repeat(indent)
+ const prUrl = `https://github.com/${OWNER}/${pr.repo}/pull/${pr.number}`
+
+ const lines = []
+ lines.push(`${pad}${state.emoji} ${state.tag} ${B}#${pr.number}${R}${draft}${newPrTag} ${CY}${title}${R}`)
+ lines.push(`${pad} ${D}↳${R} ${MG}${pr.branch}${R} ${D}${prUrl}${R}`)
+ lines.push(`${pad} ${fmtReview(pr)}`)
+ lines.push(`${pad} 💬 ${pr.comments.total}${newTag} ${D}latest:${R} ${latestInfo}`)
+ lines.push(`${pad} ${fmtCheck("Bugbot", pr.checks.bugbot)} ${fmtCheck("CI", pr.checks.ci)} ${fmtCheck("CodeQL", pr.checks.codeql)}`)
+ return lines
+ }
+
+ const scope = REPO ? `${OWNER}/${REPO}` : `${OWNER}/*`
+ const out = []
+ out.push(`${B}${scope}${R} ${D}— ${user} — ${results.length} open PR(s)${R}`)
+ out.push(`${D}${LINE}${R}`)
+
+ if (!results.length) {
+ out.push(`${D}No open PRs by ${user}${R}`)
+ } else if (multiRepo) {
+ const byRepo = {}
+ for (const pr of results) {
+ if (!byRepo[pr.repo]) byRepo[pr.repo] = []
+ byRepo[pr.repo].push(pr)
+ }
+ const repoOrder = Object.keys(byRepo).sort((a, b) => {
+ const latestA = sortedPRs(byRepo[a])[0]
+ const latestB = sortedPRs(byRepo[b])[0]
+ const da = latestA.comments.latest?.at || latestA.lastCommitDate || ""
+ const db = latestB.comments.latest?.at || latestB.lastCommitDate || ""
+ return db.localeCompare(da)
+ })
+ for (const repo of repoOrder) {
+ out.push(``)
+ out.push(`${B}${repo}${R} ${D}(${byRepo[repo].length})${R}`)
+ for (const pr of sortedPRs(byRepo[repo])) {
+ out.push("")
+ out.push(...renderPR(pr, 2))
+ }
+ }
+ } else {
+ for (const pr of sortedPRs(results)) {
+ out.push("")
+ out.push(...renderPR(pr, 0))
+ }
+ }
+
+ // Footer with rate limit info
+ out.push("")
+ const rlInfo = rateLimitRemaining != null
+ ? `API: ${rateLimitRemaining}/${rateLimitLimit} remaining`
+ : "API: unknown"
+ out.push(`${D}${LINE}${R}`)
+ out.push(`${D}${rlInfo} | ${apiCallCount} calls | next: ${recommendedInterval}s${R}`)
+
+ // Machine-readable line for pr-watch.sh to parse
+ out.push(`# interval:${recommendedInterval}`)
+
+ console.log(out.join("\n"))
+}
+
+main().catch(e => { process.stderr.write("Error: " + e.message + "\n"); process.exit(1) })
+' "$OWNER" "$REPO" "$USER" "$FORMAT"
diff --git a/.cursor/scripts/pr-watch.sh b/.cursor/scripts/pr-watch.sh
new file mode 100755
index 0000000..e257d5b
--- /dev/null
+++ b/.cursor/scripts/pr-watch.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# pr-watch.sh — TUI wrapper around pr-status scripts.
+# Redraws in-place on each poll. Ctrl+C to stop.
+#
+# Usage:
+# pr-watch.sh --repo edge-react-gui [--owner EdgeApp] [--user Jon-edge]
+# pr-watch.sh # All repos, auto interval, GQL backend
+# pr-watch.sh --backend rest # Force REST backend
+# pr-watch.sh --interval 60 # Override interval (clamped to safe minimum)
+# pr-watch.sh --budget 0.5 # Reserve 50% of rate limit budget
+# pr-watch.sh --once [...] # Single poll, no clear, no loop. For agent/script use.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ARGS=() INTERVAL="" ONCE=false BACKEND="" BUDGET=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --interval) INTERVAL="$2"; shift 2 ;;
+ --once) ONCE=true; shift ;;
+ --backend) BACKEND="$2"; shift 2 ;;
+ --budget) BUDGET="$2"; shift 2 ;;
+ *) ARGS+=("$1"); shift ;;
+ esac
+done
+
+# Inject --owner default if not already in ARGS
+if [[ ${#ARGS[@]} -eq 0 ]] || ! printf '%s\n' "${ARGS[@]}" | grep -q -- '--owner'; then
+ ARGS+=(--owner EdgeApp)
+fi
+
+# Auto-detect backend: prefer gql if gh CLI is available
+if [[ -z "$BACKEND" ]]; then
+ if command -v gh &>/dev/null && gh auth status &>/dev/null; then
+ BACKEND="gql"
+ else
+ BACKEND="rest"
+ fi
+fi
+
+# Select the status script
+if [[ "$BACKEND" == "gql" ]]; then
+ STATUS_SCRIPT="$SCRIPT_DIR/pr-status-gql.sh"
+else
+ STATUS_SCRIPT="$SCRIPT_DIR/pr-status.sh"
+fi
+
+# Pass budget through if specified
+if [[ -n "$BUDGET" ]]; then
+ ARGS+=(--budget "$BUDGET")
+fi
+
+if $ONCE; then
+ NOW=$(date '+%H:%M:%S')
+ printf '%s\n' "PR Watch — ${NOW} (${BACKEND})"
+ "$STATUS_SCRIPT" "${ARGS[@]}" --format text
+ exit $?
+fi
+
+# TUI loop
+CURRENT_INTERVAL="${INTERVAL:-60}"
+
+while true; do
+ OUTPUT=$(FORCE_COLOR=1 "$STATUS_SCRIPT" "${ARGS[@]}" --format text 2>&1) || true
+ NOW=$(date '+%H:%M:%S')
+
+ # Parse recommended interval from script output
+ RECOMMENDED=$(echo "$OUTPUT" | grep -oP '(?<=^# interval:)\d+' || echo "")
+
+ # Determine actual sleep interval
+ if [[ -n "$INTERVAL" ]]; then
+ # User-specified interval: clamp to at least the recommended minimum
+ if [[ -n "$RECOMMENDED" ]] && [[ "$INTERVAL" -lt "$RECOMMENDED" ]]; then
+ CURRENT_INTERVAL="$RECOMMENDED"
+ else
+ CURRENT_INTERVAL="$INTERVAL"
+ fi
+ elif [[ -n "$RECOMMENDED" ]]; then
+ CURRENT_INTERVAL="$RECOMMENDED"
+ fi
+
+ # Strip the machine-readable line from display output
+ DISPLAY_OUTPUT=$(echo "$OUTPUT" | grep -v '^# interval:')
+
+ printf '\033[H\033[2J'
+ printf '%s\n' "PR Watch — ${NOW} (${BACKEND}, next in ${CURRENT_INTERVAL}s, Ctrl+C to stop)"
+ printf '%s\n' "$DISPLAY_OUTPUT"
+ sleep "$CURRENT_INTERVAL"
+done
diff --git a/.cursor/scripts/push-env-key.sh b/.cursor/scripts/push-env-key.sh
new file mode 100755
index 0000000..fceb8d5
--- /dev/null
+++ b/.cursor/scripts/push-env-key.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# push-env-key.sh — Update a single key in the server's env.json and push
+#
+# Usage: push-env-key.sh [-m "commit message"]
+#
+# Examples:
+# push-env-key.sh EDGE_API_KEY abc123
+# push-env-key.sh EDGE_API_KEY abc123 -m "Rotate Edge API key"
+
+set -euo pipefail
+
+SERVER="jack"
+REMOTE_REPO="/home/jon/jenkins-files/master"
+
+KEY=""
+VALUE=""
+COMMIT_MSG=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -m) COMMIT_MSG="$2"; shift 2 ;;
+ *)
+ if [[ -z "$KEY" ]]; then KEY="$1"
+ elif [[ -z "$VALUE" ]]; then VALUE="$1"
+ else echo "Unexpected argument: $1" >&2; exit 1
+ fi
+ shift ;;
+ esac
+done
+
+if [[ -z "$KEY" || -z "$VALUE" ]]; then
+ echo "Usage: push-env-key.sh [-m \"commit message\"]" >&2
+ exit 1
+fi
+
+if [[ -z "$COMMIT_MSG" ]]; then
+ COMMIT_MSG="Update $KEY in env.json"
+fi
+
+ssh "$SERVER" bash -s -- "$KEY" "$VALUE" "$COMMIT_MSG" "$REMOTE_REPO" <<'REMOTE'
+ set -euo pipefail
+ KEY="$1"
+ VALUE="$2"
+ MSG="$3"
+ REPO="$4"
+
+ cd "$REPO"
+ git pull --ff-only
+
+ CURRENT=$(jq -r --arg k "$KEY" '.[$k] // empty' env.json)
+ if [[ "$CURRENT" == "$VALUE" ]]; then
+ echo "No change: $KEY is already set to that value."
+ exit 0
+ fi
+
+ jq --arg k "$KEY" --arg v "$VALUE" '.[$k] = $v' env.json > env.json.tmp
+ mv env.json.tmp env.json
+
+ git add env.json
+ git commit -m "$MSG"
+ git push
+ echo "Done: $KEY updated and pushed."
+REMOTE
diff --git a/.cursor/scripts/tool-sync.sh b/.cursor/scripts/tool-sync.sh
new file mode 100755
index 0000000..11201a4
--- /dev/null
+++ b/.cursor/scripts/tool-sync.sh
@@ -0,0 +1,408 @@
+#!/usr/bin/env bash
+# tool-sync.sh — Sync Cursor rules, skills, and scripts to OpenCode and Claude Code.
+# Source of truth: ~/.cursor/
+# Targets: ~/.config/opencode/, ~/.claude/
+#
+# Usage: tool-sync.sh [--dry-run] [--target opencode|claude|all]
+# --dry-run Show what would change without writing files
+# --target Sync to a specific target (default: all)
+
+set -euo pipefail
+
+CURSOR_DIR="$HOME/.cursor"
+OPENCODE_DIR="$HOME/.config/opencode"
+CLAUDE_DIR="$HOME/.claude"
+DRY_RUN=false
+TARGET="all"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run) DRY_RUN=true; shift ;;
+ --target) TARGET="$2"; shift 2 ;;
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
+ esac
+done
+
+# Counters
+created=0
+updated=0
+removed=0
+skipped=0
+
+log() { echo " $1"; }
+log_action() {
+ local action="$1" file="$2"
+ if [[ "$DRY_RUN" == true ]]; then
+ echo " [DRY-RUN] $action: $file"
+ else
+ echo " $action: $file"
+ fi
+}
+
+# ─── Helpers ──────────────────────────────────────────────────────────────────
+
+# Convert .mdc to .md: strip Cursor-specific XML tags, keep content
+mdc_to_md() {
+ local src="$1"
+ # .mdc files are already valid markdown with YAML frontmatter.
+ # Some use , , , XML tags — convert to markdown.
+ sed \
+ -e 's|^\(.*\)|## Goal\n\n\1|' \
+ -e 's|^|## Goal\n|' \
+ -e 's|^||' \
+ -e 's|^|## Rules\n|' \
+ -e 's|^||' \
+ -e 's|^\(.*\)|- **\1**: \2|' \
+ -e 's|^|- **\1**:|' \
+ -e 's|^||' \
+ -e 's|^|### Step \1: \2\n|' \
+ -e 's|^||' \
+ -e '/^$/N;/^\n$/d' \
+ "$src"
+}
+
+# Generate OpenCode JSON metadata from a .mdc rule file
+generate_rule_json() {
+ local src="$1" name="$2"
+ local description="" always_apply="false" globs="[]"
+
+ # Parse YAML frontmatter
+ local in_frontmatter=false
+ while IFS= read -r line; do
+ if [[ "$line" == "---" ]]; then
+ if [[ "$in_frontmatter" == true ]]; then break; fi
+ in_frontmatter=true
+ continue
+ fi
+ if [[ "$in_frontmatter" == true ]]; then
+ case "$line" in
+ description:*) description="${line#description: }" ;;
+ alwaysApply:*) always_apply="${line#alwaysApply: }" ;;
+ globs:*) globs="${line#globs: }" ;;
+ esac
+ fi
+ done < "$src"
+
+ jq -n \
+ --arg id "$name" \
+ --arg title "$name" \
+ --arg description "$description" \
+ --argjson globs "$globs" \
+ --argjson alwaysApply "$always_apply" \
+ '{id: $id, title: $title, description: $description, globs: $globs, alwaysApply: $alwaysApply}'
+}
+
+# Generate OpenCode JSON metadata from a command .md file
+generate_command_json() {
+ local src="$1" name="$2"
+
+ # Extract goal line (first paragraph after ## Goal)
+ local goal=""
+ goal=$(awk '/^## Goal/{getline; getline; print; exit}' "$src")
+
+ # Extract rules as JSON array
+ local rules="[]"
+ rules=$(awk '
+ /^## Rules/,/^## |^### Step/ {
+ if (/^- \*\*([^*]+)\*\*: (.+)/) {
+ match($0, /\*\*([^*]+)\*\*: (.+)/, m)
+ if (m[1] != "") {
+ printf "{\"id\":\"%s\",\"instruction\":\"%s\"}\n", m[1], m[2]
+ }
+ }
+ }
+ ' "$src" | jq -s '.' 2>/dev/null || echo "[]")
+
+ # Extract steps as JSON array
+ local steps="[]"
+ steps=$(awk '
+ /^### Step [0-9]+:/ {
+ match($0, /^### Step ([0-9]+): (.+)/, m)
+ if (m[1] != "") {
+ if (step_id != "") { printf "{\"id\":\"%s\",\"name\":\"%s\",\"instruction\":\"%s\"}\n", step_id, step_name, instruction }
+ step_id = m[1]; step_name = m[2]; instruction = ""
+ }
+ next
+ }
+ /^## / { if (step_id != "") { printf "{\"id\":\"%s\",\"name\":\"%s\",\"instruction\":\"%s\"}\n", step_id, step_name, instruction; step_id="" } next }
+ step_id != "" { gsub(/"/, "\\\""); instruction = instruction ($0 != "" ? (instruction != "" ? "\\n" : "") $0 : "") }
+ END { if (step_id != "") printf "{\"id\":\"%s\",\"name\":\"%s\",\"instruction\":\"%s\"}\n", step_id, step_name, instruction }
+ ' "$src" | jq -s '.' 2>/dev/null || echo "[]")
+
+ jq -n \
+ --arg id "$name" \
+ --arg title "$name" \
+ --arg description "$goal" \
+ --arg goal "$goal" \
+ --argjson rules "$rules" \
+ --argjson steps "$steps" \
+ '{id: $id, title: $title, description: $description, goal: $goal, rules: $rules, steps: $steps, scripts: ["sh"]}'
+}
+
+# Copy file only if changed, respecting --dry-run
+sync_file() {
+ local src="$1" dest="$2"
+ if [[ ! -f "$dest" ]]; then
+ log_action "CREATE" "$dest"
+ if [[ "$DRY_RUN" == false ]]; then
+ mkdir -p "$(dirname "$dest")"
+ cp "$src" "$dest"
+ fi
+ ((created++)) || true
+ elif ! diff -q "$src" "$dest" >/dev/null 2>&1; then
+ log_action "UPDATE" "$dest"
+ if [[ "$DRY_RUN" == false ]]; then
+ cp "$src" "$dest"
+ fi
+ ((updated++)) || true
+ else
+ ((skipped++)) || true
+ fi
+}
+
+# Write content to file only if changed
+sync_content() {
+ local content="$1" dest="$2"
+ local tmp
+ tmp=$(mktemp)
+ cat <<< "$content" > "$tmp"
+ if [[ ! -f "$dest" ]]; then
+ log_action "CREATE" "$dest"
+ if [[ "$DRY_RUN" == false ]]; then
+ mkdir -p "$(dirname "$dest")"
+ mv "$tmp" "$dest"
+ else
+ rm "$tmp"
+ fi
+ ((created++)) || true
+ elif ! diff -q "$tmp" "$dest" >/dev/null 2>&1; then
+ log_action "UPDATE" "$dest"
+ if [[ "$DRY_RUN" == false ]]; then
+ mv "$tmp" "$dest"
+ else
+ rm "$tmp"
+ fi
+ ((updated++)) || true
+ else
+ rm "$tmp"
+ ((skipped++)) || true
+ fi
+}
+
+# Create symlink, replacing if target changed
+sync_symlink() {
+ local src="$1" dest="$2"
+ if [[ -L "$dest" ]]; then
+ local current
+ current=$(readlink "$dest")
+ if [[ "$current" == "$src" ]]; then
+ ((skipped++)) || true
+ return
+ fi
+ log_action "UPDATE" "$dest -> $src"
+ if [[ "$DRY_RUN" == false ]]; then
+ ln -sf "$src" "$dest"
+ fi
+ ((updated++)) || true
+ elif [[ -f "$dest" ]]; then
+ log_action "REPLACE" "$dest (file -> symlink)"
+ if [[ "$DRY_RUN" == false ]]; then
+ rm "$dest"
+ ln -s "$src" "$dest"
+ fi
+ ((updated++)) || true
+ else
+ log_action "CREATE" "$dest -> $src"
+ if [[ "$DRY_RUN" == false ]]; then
+ mkdir -p "$(dirname "$dest")"
+ ln -s "$src" "$dest"
+ fi
+ ((created++)) || true
+ fi
+}
+
+# ─── OpenCode Sync ────────────────────────────────────────────────────────────
+
+sync_opencode() {
+ echo "━━━ Syncing to OpenCode ━━━"
+
+ # Rules: .mdc → .md + .json
+ echo " Rules:"
+ for mdc in "$CURSOR_DIR"/rules/*.mdc; do
+ [[ -f "$mdc" ]] || continue
+ local name
+ name=$(basename "$mdc" .mdc)
+
+ # Convert .mdc to .md
+ local tmp_md
+ tmp_md=$(mktemp)
+ mdc_to_md "$mdc" > "$tmp_md"
+ sync_file "$tmp_md" "$OPENCODE_DIR/rules/$name.md"
+ rm -f "$tmp_md"
+
+ # Generate .json
+ local json
+ json=$(generate_rule_json "$mdc" "$name")
+ sync_content "$json" "$OPENCODE_DIR/rules/$name.json"
+ done
+
+ # Skills: SKILL.md + scripts/ subdirs
+ echo " Skills:"
+ if [[ -d "$CURSOR_DIR/skills" ]]; then
+ # Shared scripts at skills/ top level
+ for shared in "$CURSOR_DIR"/skills/*.sh; do
+ [[ -f "$shared" ]] || continue
+ local name
+ name=$(basename "$shared")
+ sync_file "$shared" "$OPENCODE_DIR/skills/$name"
+ done
+ # Skill dirs with SKILL.md + scripts/
+ for skill_dir in "$CURSOR_DIR"/skills/*/; do
+ [[ -d "$skill_dir" ]] || continue
+ skill_dir="${skill_dir%/}" # strip trailing slash from glob
+ local name
+ name=$(basename "$skill_dir")
+ if [[ -f "$skill_dir/SKILL.md" ]]; then
+ sync_file "$skill_dir/SKILL.md" "$OPENCODE_DIR/skills/$name/SKILL.md"
+ fi
+ if [[ -d "$skill_dir/scripts" ]]; then
+ for script in "$skill_dir"/scripts/*; do
+ [[ -f "$script" ]] || continue
+ local fname
+ fname=$(basename "$script")
+ sync_file "$script" "$OPENCODE_DIR/skills/$name/scripts/$fname"
+ done
+ fi
+ done
+ fi
+
+ # Standalone scripts
+ echo " Scripts:"
+ for script in "$CURSOR_DIR"/scripts/*.sh "$CURSOR_DIR"/scripts/*.js; do
+ [[ -f "$script" ]] || continue
+ local name
+ name=$(basename "$script")
+ sync_file "$script" "$OPENCODE_DIR/scripts/$name"
+ done
+
+ # Clean up stale files in OpenCode that no longer exist in Cursor
+ echo " Cleanup:"
+ for oc_rule in "$OPENCODE_DIR"/rules/*.md; do
+ [[ -f "$oc_rule" ]] || continue
+ local name
+ name=$(basename "$oc_rule" .md)
+ if [[ ! -f "$CURSOR_DIR/rules/$name.mdc" ]]; then
+ log_action "REMOVE" "$oc_rule"
+ if [[ "$DRY_RUN" == false ]]; then
+ rm -f "$oc_rule" "$OPENCODE_DIR/rules/$name.json"
+ fi
+ ((removed++)) || true
+ fi
+ done
+
+ for oc_skill_dir in "$OPENCODE_DIR"/skills/*/; do
+ [[ -d "$oc_skill_dir" ]] || continue
+ local name
+ name=$(basename "$oc_skill_dir")
+ if [[ ! -d "$CURSOR_DIR/skills/$name" ]]; then
+ log_action "REMOVE" "$oc_skill_dir"
+ if [[ "$DRY_RUN" == false ]]; then
+ rm -rf "$oc_skill_dir"
+ fi
+ ((removed++)) || true
+ fi
+ done
+}
+
+# ─── Claude Code Sync ─────────────────────────────────────────────────────────
+
+sync_claude() {
+ echo "━━━ Syncing to Claude Code ━━━"
+
+ # Skills: symlink SKILL.md files
+ echo " Skills (symlinks):"
+ if [[ -d "$CURSOR_DIR/skills" ]]; then
+ for skill_dir in "$CURSOR_DIR"/skills/*/; do
+ [[ -d "$skill_dir" ]] || continue
+ skill_dir="${skill_dir%/}" # strip trailing slash from glob
+ local name
+ name=$(basename "$skill_dir")
+ if [[ -f "$skill_dir/SKILL.md" ]]; then
+ sync_symlink "$skill_dir/SKILL.md" "$CLAUDE_DIR/skills/$name/SKILL.md"
+ fi
+ done
+ fi
+
+ # Clean up stale symlinks
+ if [[ -d "$CLAUDE_DIR/skills" ]]; then
+ for link in "$CLAUDE_DIR"/skills/*/SKILL.md; do
+ [[ -e "$link" ]] || continue
+ if [[ -L "$link" ]]; then
+ local target
+ target=$(readlink "$link")
+ if [[ ! -f "$target" ]]; then
+ log_action "REMOVE" "$link (dead symlink)"
+ if [[ "$DRY_RUN" == false ]]; then rm "$link"; fi
+ ((removed++)) || true
+ fi
+ fi
+ done
+ fi
+
+ # CLAUDE.md: generate with @import for each rule
+ echo " CLAUDE.md:"
+ local dest="$CLAUDE_DIR/CLAUDE.md"
+ local tmp
+ tmp=$(mktemp)
+
+ {
+ echo "# Rules"
+ echo ""
+ echo "# Imported from ~/.cursor/rules/ — do not edit manually."
+ echo "# Re-generate with: ~/.cursor/scripts/tool-sync.sh"
+ echo ""
+ for mdc in "$CURSOR_DIR"/rules/*.mdc; do
+ [[ -f "$mdc" ]] || continue
+ echo "@$mdc"
+ done
+ } > "$tmp"
+
+ if [[ ! -f "$dest" ]]; then
+ log_action "CREATE" "$dest"
+ if [[ "$DRY_RUN" == false ]]; then
+ mv "$tmp" "$dest"
+ else
+ rm "$tmp"
+ fi
+ ((created++)) || true
+ elif ! diff -q "$tmp" "$dest" >/dev/null 2>&1; then
+ log_action "UPDATE" "$dest"
+ if [[ "$DRY_RUN" == false ]]; then
+ mv "$tmp" "$dest"
+ else
+ rm "$tmp"
+ fi
+ ((updated++)) || true
+ else
+ rm "$tmp"
+ ((skipped++)) || true
+ fi
+}
+
+# ─── Main ─────────────────────────────────────────────────────────────────────
+
+echo "tool-sync: Cursor → ${TARGET}"
+if [[ "$DRY_RUN" == true ]]; then
+ echo "(dry run — no files will be modified)"
+fi
+echo ""
+
+case "$TARGET" in
+ opencode) sync_opencode ;;
+ claude) sync_claude ;;
+ all) sync_opencode; echo ""; sync_claude ;;
+ *) echo "Unknown target: $TARGET" >&2; exit 1 ;;
+esac
+
+echo ""
+echo "Done: $created created, $updated updated, $removed removed, $skipped unchanged"
diff --git a/.cursor/skills/asana-get-context.sh b/.cursor/skills/asana-get-context.sh
new file mode 100755
index 0000000..87f3792
--- /dev/null
+++ b/.cursor/skills/asana-get-context.sh
@@ -0,0 +1,232 @@
+#!/usr/bin/env bash
+# asana-get-context.sh
+# Fetch concise context from an Asana task for implementation or PR creation.
+#
+# Usage:
+# asana-get-context.sh
+# asana-get-context.sh --task-url
+# asana-get-context.sh --task
+#
+# Accepts a raw task GID or a full Asana URL. URL formats supported:
+# https://app.asana.com/0//[/f]
+# https://app.asana.com/1//task/[/f]
+#
+# Requires env var: ASANA_TOKEN
+#
+# Output (compact, agent-friendly):
+# TASK_NAME:
+# TASK_DESCRIPTION:
+# PRIORITY:
+# STATUS:
+# IMPLEMENTOR:
+# REVIEWER:
+# COMMENTS: (most recent 5, one per block)
+# ATTACHMENTS: files
+# DOWNLOADED: files to
+# UNPACKED: -> ( files) [if ZIPs present]
+# PDF_TEXT: (from , chars) [if PDF has text]
+# PDF_PAGES: ( pages from ) [if PDF is image-based]
+set -euo pipefail
+
+# Parse arguments: accept positional, --task, or --task-url
+RAW_INPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --task-url|--task)
+ RAW_INPUT="${2:-}"
+ shift 2
+ ;;
+ -*)
+ echo "Unknown flag: $1" >&2
+ exit 1
+ ;;
+ *)
+ RAW_INPUT="$1"
+ shift
+ ;;
+ esac
+done
+
+if [[ -z "$RAW_INPUT" ]]; then
+ echo "Usage: asana-get-context.sh " >&2
+ exit 1
+fi
+
+# Extract task GID: accept a raw numeric GID or any Asana URL containing one.
+# Strips trailing path segments (/f, /subtask/…) and query strings.
+if [[ "$RAW_INPUT" =~ /task/([0-9]+) ]]; then
+ TASK_GID="${BASH_REMATCH[1]}"
+elif [[ "$RAW_INPUT" =~ /([0-9]+)(/f)?([?#].*)?$ ]]; then
+ TASK_GID="${BASH_REMATCH[1]}"
+elif [[ "$RAW_INPUT" =~ ^[0-9]+$ ]]; then
+ TASK_GID="$RAW_INPUT"
+else
+ echo "Error: could not extract task GID from: $RAW_INPUT" >&2
+ exit 1
+fi
+if [[ -z "${ASANA_TOKEN:-}" ]]; then
+ echo "Error: ASANA_TOKEN not set" >&2
+ exit 1
+fi
+
+API="https://app.asana.com/api/1.0"
+AUTH="Authorization: Bearer $ASANA_TOKEN"
+
+# Fetch task + custom fields
+curl -s "$API/tasks/$TASK_GID?opt_fields=name,notes,custom_fields.gid,custom_fields.name,custom_fields.display_value" \
+ -H "$AUTH" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)['data']
+
+print(f\"TASK_NAME: {data['name']}\")
+
+notes = (data.get('notes') or '').strip()
+if len(notes) > 500:
+ notes = notes[:500] + '...'
+print(f\"TASK_DESCRIPTION: {notes or '(empty)'}\")
+
+FIELDS = {
+ '795866930204488': 'PRIORITY',
+ '1190660107346181': 'STATUS',
+ '1203334386796983': 'IMPLEMENTOR',
+ '1203334388004673': 'REVIEWER',
+}
+for f in data.get('custom_fields', []):
+ label = FIELDS.get(f['gid'])
+ if label:
+ val = f.get('display_value') or '(not set)'
+ print(f'{label}: {val}')
+"
+
+# Fetch project memberships — look for version project (e.g. "4.44.0")
+curl -s "$API/tasks/$TASK_GID?opt_fields=memberships.project.name" \
+ -H "$AUTH" | python3 -c "
+import sys, json, re
+data = json.load(sys.stdin)['data']
+for m in data.get('memberships', []):
+ name = m.get('project', {}).get('name', '')
+ if re.match(r'^\d+\.\d+\.\d+$', name):
+ print(f'VERSION_PROJECT: {name}')
+ break
+else:
+ print('VERSION_PROJECT: (not set)')
+"
+
+# Fetch recent comments (last 5)
+curl -s "$API/tasks/$TASK_GID/stories?opt_fields=resource_subtype,text,created_by.name,created_at&limit=100" \
+ -H "$AUTH" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)['data']
+comments = [s for s in data if s.get('resource_subtype') == 'comment_added'][-5:]
+if not comments:
+ print('COMMENTS: (none)')
+else:
+ print('COMMENTS:')
+ for c in comments:
+ author = c.get('created_by', {}).get('name', 'unknown')
+ text = (c.get('text') or '').strip().replace('\n', ' ')
+ if len(text) > 200:
+ text = text[:200] + '...'
+ date = c.get('created_at', '')[:10]
+ print(f' [{date}] {author}: {text}')
+"
+
+# Fetch attachments — download all supported types, then post-process
+DOWNLOAD_DIR="/tmp/asana-task-$TASK_GID"
+
+# Phase 1: Download all supported attachments
+curl -s "$API/tasks/$TASK_GID/attachments?opt_fields=name,resource_subtype,download_url" \
+ -H "$AUTH" | python3 -c "
+import sys, json, os, urllib.request
+
+data = json.load(sys.stdin)['data']
+if not data:
+ print('ATTACHMENTS: (none)')
+ sys.exit(0)
+
+DOWNLOAD_EXTS = {
+ '.md', '.txt', '.json', '.csv', '.log', '.yaml', '.yml',
+ '.pdf',
+ '.zip',
+ '.png', '.jpg', '.jpeg', '.gif', '.webp',
+}
+download_dir = '$DOWNLOAD_DIR'
+downloaded = []
+
+print(f'ATTACHMENTS: {len(data)} files')
+for a in data:
+ name = a.get('name', 'unnamed')
+ url = a.get('download_url')
+ ext = os.path.splitext(name)[1].lower()
+ if ext in DOWNLOAD_EXTS and url:
+ os.makedirs(download_dir, exist_ok=True)
+ dest = os.path.join(download_dir, name)
+ try:
+ urllib.request.urlretrieve(url, dest)
+ downloaded.append(dest)
+ print(f' - {name} (downloaded)')
+ except Exception as e:
+ print(f' - {name} (download failed: {e})')
+ else:
+ print(f' - {name}')
+
+if downloaded:
+ print(f'DOWNLOADED: {len(downloaded)} files to {download_dir}')
+ for d in downloaded:
+ print(f' {d}')
+"
+
+# Phase 2: Unpack ZIP archives (may produce more files to process)
+shopt -s nullglob
+for zip_file in "$DOWNLOAD_DIR"/*.zip; do
+ subdir="$DOWNLOAD_DIR/$(basename "$zip_file" .zip)"
+ if unzip -o -q "$zip_file" -d "$subdir" 2>/dev/null; then
+ file_count=$(find "$subdir" -type f 2>/dev/null | wc -l | tr -d ' ')
+ echo "UNPACKED: $(basename "$zip_file") -> $subdir ($file_count files)"
+ rm "$zip_file"
+ else
+ echo "UNPACK_FAILED: $(basename "$zip_file")"
+ fi
+done
+shopt -u nullglob
+
+# Phase 3: Process PDFs (text extraction first, image fallback)
+process_pdf() {
+ local pdf="$1"
+ local base="${pdf%.pdf}"
+ local fname
+ fname="$(basename "$pdf")"
+
+ if command -v pdftotext &>/dev/null; then
+ local text
+ text=$(pdftotext "$pdf" - 2>/dev/null || true)
+ local char_count
+ char_count=$(printf '%s' "$text" | tr -d '[:space:]' | wc -c | tr -d ' ')
+ if [[ "$char_count" -gt 100 ]]; then
+ printf '%s' "$text" > "${base}.txt"
+ echo "PDF_TEXT: ${base}.txt (from $fname, ${char_count} chars)"
+ return
+ fi
+ fi
+
+ if command -v pdftoppm &>/dev/null; then
+ local pages_dir="${base}_pages"
+ mkdir -p "$pages_dir"
+ pdftoppm -png -r 150 "$pdf" "$pages_dir/page" 2>/dev/null
+ local page_count
+ page_count=$(find "$pages_dir" -name 'page-*.png' 2>/dev/null | wc -l | tr -d ' ')
+ if [[ "$page_count" -gt 0 ]]; then
+ echo "PDF_PAGES: $pages_dir ($page_count pages from $fname)"
+ else
+ echo "PDF_CONVERT_FAILED: $fname"
+ fi
+ else
+ echo "PDF_SKIPPED: $fname (install poppler-utils for text/image extraction)"
+ fi
+}
+
+if [[ -d "$DOWNLOAD_DIR" ]]; then
+ while IFS= read -r pdf; do
+ process_pdf "$pdf"
+ done < <(find "$DOWNLOAD_DIR" -name '*.pdf' -type f 2>/dev/null)
+fi
diff --git a/.cursor/skills/asana-plan/SKILL.md b/.cursor/skills/asana-plan/SKILL.md
new file mode 100644
index 0000000..2f8a519
--- /dev/null
+++ b/.cursor/skills/asana-plan/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: asana-plan
+description: Create an implementation plan from either an Asana task URL or ad-hoc text/file requirements, then wait for user confirmation before implementation.
+compatibility: Requires jq. ASANA_TOKEN for Asana context when task URLs are provided.
+metadata:
+ author: j0ntz
+---
+
+Produce a plan document via Cursor planning flow from Asana or text requirements, and hand off approved context to implementation skills.
+
+
+If input is an Asana task URL, read and follow `~/.cursor/skills/task-review/SKILL.md` steps 1-3 before planning.
+Do not start implementation while in this skill. End by asking for confirmation.
+Use Cursor's plan tool to output the plan document to the normal planning location.
+
+
+
+Accept two input forms:
+
+1. **Asana URL mode**: Task URL is provided
+2. **Text/file mode**: Ad-hoc text requirement or file reference is provided
+
+If input is ambiguous, ask the user to clarify which mode applies.
+
+
+
+
+Read `~/.cursor/skills/task-review/SKILL.md` and run its steps 1-3 to fetch and summarize task context.
+
+
+
+Read the provided description and any referenced file(s), then summarize scope, target areas, and assumptions.
+
+
+
+
+Create a concise actionable implementation plan using Cursor's plan flow. Include:
+
+- Summary
+- Goal / Definition of Done
+- Likely relevant files
+- Findings so far
+- Numbered implementation steps
+- Constraints
+
+
+
+Return:
+
+1. Plan file path
+2. Short execution summary (what will be changed)
+
+Then ask for confirmation before implementation:
+
+> Does this match your understanding? Any adjustments before I start?
+
+
+
+`/im` consumes this output and starts only after user confirmation. `/im` should not re-run a second independent confirmation flow for the same plan.
+
diff --git a/.cursor/skills/asana-task-update/SKILL.md b/.cursor/skills/asana-task-update/SKILL.md
new file mode 100644
index 0000000..a436ddf
--- /dev/null
+++ b/.cursor/skills/asana-task-update/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: asana-task-update
+description: Update Asana tasks via one reusable workflow (attach PRs, assign/unassign, set status, and update task fields). Use when any skill needs to modify Asana task state.
+compatibility: Requires jq. ASANA_TOKEN for Asana API updates. ASANA_GITHUB_SECRET is OPTIONAL — only used by `--attach-pr`. When unset or when the Asana ↔ GitHub widget integration is disabled at the workspace level, `--attach-pr` warns and skips gracefully (exit 0) rather than failing.
+metadata:
+ author: j0ntz
+---
+
+Perform Asana task mutations through one shared command and one shared script, so all callers use the same field mappings and prompts.
+
+
+Use `~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh` for all Asana task mutations. Do not call raw Asana APIs directly from skills that can delegate here.
+Every operation requires `--task `.
+`--attach-pr` uses the Asana ↔ GitHub widget integration. If `ASANA_GITHUB_SECRET` is unset, or if the integration endpoint returns 401/403/404 (integration disabled at the workspace level), the script warns once and skips the widget call with exit 0 — it does NOT fail the workflow. The PR body's Asana link (injected by `/pr-create`) remains the canonical Asana ↔ PR link consumed by downstream skills. Other operations (`--set-status`, `--set-board-state`, `--assign`, etc.) require `ASANA_TOKEN`.
+If the script exits code 2 with `PROMPT_REVIEWER` or `PROMPT_IMPLEMENTOR`, ask the user and re-run with explicit `--reviewer` or `--implementor`. Hands-off callers may instead pass `--skip-assign-if-missing` to convert missing-reviewer assignment into a non-blocking skip.
+Asana updates can take time. Use `block_until_ms: 120000` for script calls.
+
+
+
+```bash
+# Attach only
+~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \
+ --task \
+ --attach-pr --pr-url --pr-title "" --pr-number
+
+# Attach + assign reviewer + set review-needed status + estimate review hours
+~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \
+ --task \
+ --attach-pr --pr-url --pr-title "" --pr-number \
+ --assign --set-status "Review Needed" --auto-est-review-hrs
+
+# Hands-off attach + best-effort assign (skip if reviewer missing)
+~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \
+ --task \
+ --attach-pr --pr-url --pr-title "" --pr-number \
+ --assign --skip-assign-if-missing --set-status "Review Needed" --auto-est-review-hrs
+
+# Post-merge: set Board State to QA Verification and unassign
+~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \
+ --task \
+ --set-board-state "QA Verification" --unassign
+
+# Attach a run-report markdown file to the task
+~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \
+ --task \
+ --attach-file /tmp/agent-run-report.md --attach-name agent-run-report.md
+```
+
+
+
+Determine which updates are needed by the caller and build one command with all flags:
+
+- `--attach-pr --pr-url --pr-title --pr-number`
+- `--attach-file [--attach-name ]` (upload a local file, e.g. a run-report `.md`, as a native task attachment; distinct from `--attach-pr`)
+- `--assign` or `--assign `
+- `--skip-assign-if-missing`
+- `--unassign`
+- `--set-status "Review Needed|Publish Needed|Verification Needed"` (legacy Status field)
+- `--set-board-state "Incoming Requests|Refinement|Ready to Pull|In Progress|PR Review|QA Verification|Blocked|Done|Icebox"` (new Board State 🤖 field)
+- `--set-reviewer `
+- `--set-implementor `
+- `--set-priority `
+- `--set-planned `
+- `--auto-est-review-hrs`
+
+
+
+Run `asana-task-update.sh` with the built flags. Prefer one call with combined operations over multiple calls.
+
+
+
+If exit code is 2:
+
+- `PROMPT_REVIEWER`: ask who to assign, then re-run with `--reviewer ` and `--assign`
+- `PROMPT_IMPLEMENTOR`: ask who to set as implementor, then re-run with `--implementor `
+
+If the caller used `--skip-assign-if-missing`, do not ask about `PROMPT_REVIEWER` because the script will not emit it for missing-reviewer cases.
+
+
+
+Summarize one line per action from script output (attach result, assignment, status change, field updates).
+
+
+
+1. Jon Tzeng — `1200972350160586`
+2. William Swanson — `10128869002320`
+3. Paul Puey — `9976421903322`
+4. Sam Holmes — `1198904591136142`
+5. Matthew Piche — `522823585857811`
+
+
+
+- `0`: success
+- `1`: error
+- `2`: needs user input (`PROMPT_REVIEWER`, `PROMPT_IMPLEMENTOR`)
+
diff --git a/.cursor/skills/asana-task-update/scripts/asana-task-update.sh b/.cursor/skills/asana-task-update/scripts/asana-task-update.sh
new file mode 100755
index 0000000..dd8bd86
--- /dev/null
+++ b/.cursor/skills/asana-task-update/scripts/asana-task-update.sh
@@ -0,0 +1,334 @@
+#!/usr/bin/env bash
+# asana-task-update.sh
+# Unified Asana task mutation script.
+#
+# Exit codes:
+# 0 = success
+# 1 = error
+# 2 = needs user input (PROMPT_REVIEWER, PROMPT_IMPLEMENTOR)
+set -euo pipefail
+
+TASK_GID=""
+DO_ATTACH=false
+PR_URL=""
+PR_TITLE=""
+PR_NUMBER=""
+
+DO_ATTACH_FILE=false
+ATTACH_FILE_PATH=""
+ATTACH_FILE_NAME=""
+
+DO_ASSIGN=false
+ASSIGN_GID=""
+SKIP_ASSIGN_IF_MISSING=false
+DO_UNASSIGN=false
+
+SET_STATUS=""
+SET_BOARD_STATE=""
+SET_REVIEWER_GID=""
+SET_IMPLEMENTOR_GID=""
+SET_PRIORITY_GID=""
+SET_PLANNED_GID=""
+AUTO_EST_REVIEW=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --task) TASK_GID="$2"; shift 2 ;;
+ --attach-pr) DO_ATTACH=true; shift ;;
+ --pr-url) PR_URL="$2"; shift 2 ;;
+ --pr-title) PR_TITLE="$2"; shift 2 ;;
+ --pr-number) PR_NUMBER="$2"; shift 2 ;;
+ --attach-file) DO_ATTACH_FILE=true; ATTACH_FILE_PATH="$2"; shift 2 ;;
+ --attach-name) ATTACH_FILE_NAME="$2"; shift 2 ;;
+ --assign)
+ DO_ASSIGN=true
+ if [[ $# -ge 2 && "${2:0:2}" != "--" ]]; then
+ ASSIGN_GID="$2"
+ shift 2
+ else
+ shift
+ fi
+ ;;
+ --skip-assign-if-missing) SKIP_ASSIGN_IF_MISSING=true; shift ;;
+ --unassign) DO_UNASSIGN=true; shift ;;
+ --set-status) SET_STATUS="$2"; shift 2 ;;
+ --set-board-state) SET_BOARD_STATE="$2"; shift 2 ;;
+ --set-reviewer|--reviewer) SET_REVIEWER_GID="$2"; shift 2 ;;
+ --set-implementor|--implementor) SET_IMPLEMENTOR_GID="$2"; shift 2 ;;
+ --set-priority) SET_PRIORITY_GID="$2"; shift 2 ;;
+ --set-planned) SET_PLANNED_GID="$2"; shift 2 ;;
+ --auto-est-review-hrs) AUTO_EST_REVIEW=true; shift ;;
+ *) echo "Unknown flag: $1" >&2; exit 1 ;;
+ esac
+done
+
+if [[ -z "$TASK_GID" ]]; then
+ echo "Error: --task is required" >&2
+ exit 1
+fi
+
+if ! $DO_ATTACH && ! $DO_ATTACH_FILE && ! $DO_ASSIGN && ! $DO_UNASSIGN && [[ -z "$SET_STATUS" ]] && [[ -z "$SET_BOARD_STATE" ]] && [[ -z "$SET_REVIEWER_GID" ]] && [[ -z "$SET_IMPLEMENTOR_GID" ]] && [[ -z "$SET_PRIORITY_GID" ]] && [[ -z "$SET_PLANNED_GID" ]] && ! $AUTO_EST_REVIEW; then
+ echo "Error: No operations specified" >&2
+ exit 1
+fi
+
+if [[ -z "${ASANA_TOKEN:-}" ]]; then
+ echo "Error: ASANA_TOKEN not set" >&2
+ exit 1
+fi
+
+# --attach-pr is OPTIONAL on a workspace where the Asana ↔ GitHub widget
+# integration is disabled. If the secret is missing, skip the widget call with
+# a warning rather than failing — the canonical Asana ↔ PR link lives in the PR
+# body (injected by /pr-create) and downstream skills do not need the widget.
+if $DO_ATTACH && [[ -z "${ASANA_GITHUB_SECRET:-}" ]]; then
+ echo ">> PR attach: skipped (ASANA_GITHUB_SECRET not set; widget integration not configured)" >&2
+ DO_ATTACH=false
+fi
+
+ASANA_API="https://app.asana.com/api/1.0"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Airbitz.co workspace field GIDs
+STATUS_FIELD="1190660107346181"
+REVIEW_NEEDED_OPTION="1190660107348334"
+PUBLISH_NEEDED_OPTION="1191304757575656"
+VERIFICATION_NEEDED_OPTION="1190660107348340"
+BOARD_STATE_FIELD="1213992584300456"
+REVIEWER_FIELD="1203334388004673"
+IMPLEMENTOR_FIELD="1203334386796983"
+SPENT_DEV_HRS_FIELD="1202996660964169"
+EST_REVIEW_HRS_FIELD="1203002792997295"
+
+status_to_gid() {
+ case "$1" in
+ "Review Needed") echo "$REVIEW_NEEDED_OPTION" ;;
+ "Publish Needed") echo "$PUBLISH_NEEDED_OPTION" ;;
+ "Verification Needed") echo "$VERIFICATION_NEEDED_OPTION" ;;
+ *) echo "$1" ;;
+ esac
+}
+
+board_state_to_gid() {
+ case "$1" in
+ "Incoming Requests") echo "1214109511460876" ;;
+ "Refinement") echo "1214109511571763" ;;
+ "Ready to Pull") echo "1213992584300457" ;;
+ "In Progress") echo "1213992584300458" ;;
+ "PR Review") echo "1214074445437890" ;;
+ "QA Verification") echo "1213992584300459" ;;
+ "Blocked") echo "1213992584300460" ;;
+ "Done") echo "1213992584300461" ;;
+ "Icebox") echo "1214109610541444" ;;
+ *) echo "$1" ;;
+ esac
+}
+
+TASK_FIELDS=""
+load_task_fields() {
+ if [[ -n "$TASK_FIELDS" ]]; then
+ return 0
+ fi
+ TASK_FIELDS=$(curl -sf "$ASANA_API/tasks/$TASK_GID?opt_fields=name,assignee.name,custom_fields.gid,custom_fields.name,custom_fields.people_value.gid,custom_fields.people_value.name,custom_fields.number_value,custom_fields.enum_value.gid,custom_fields.enum_value.name" \
+ -H "Authorization: Bearer $ASANA_TOKEN")
+}
+
+read_people_field() {
+ local field_gid="$1"
+ echo "$TASK_FIELDS" | jq -r --arg gid "$field_gid" '
+ .data.custom_fields[]
+ | select(.gid == $gid)
+ | (.people_value[0].gid // "")
+ ' | head -n 1
+}
+
+if $DO_ATTACH; then
+ if [[ -z "$PR_URL" || -z "$PR_TITLE" || -z "$PR_NUMBER" ]]; then
+ echo "Error: --attach-pr requires --pr-url, --pr-title, and --pr-number" >&2
+ exit 1
+ fi
+
+ ATTACH_BODY_FILE=$(mktemp)
+ ATTACH_HTTP_CODE=$(curl -sS -o "$ATTACH_BODY_FILE" -w "%{http_code}" \
+ -X POST "https://github.integrations.asana.plus/custom/v1/actions/widget" \
+ -H "Authorization: Bearer $ASANA_GITHUB_SECRET" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"allowedProjects\": [],
+ \"blockedProjects\": [],
+ \"pullRequestDescription\": \"https://app.asana.com/0/0/$TASK_GID\",
+ \"pullRequestName\": $(jq -Rn --arg v "$PR_TITLE" '$v'),
+ \"pullRequestNumber\": $PR_NUMBER,
+ \"pullRequestURL\": \"$PR_URL\"
+ }" 2>/dev/null || echo "000")
+
+ if [[ "$ATTACH_HTTP_CODE" =~ ^(401|403|404)$ ]]; then
+ # Asana ↔ GitHub widget integration is disabled at the workspace level
+ # (or the secret is invalid). Skip gracefully — the PR body's Asana link
+ # is the canonical link and downstream skills do not need the widget.
+ echo ">> PR attach: skipped (integration returned $ATTACH_HTTP_CODE; widget integration disabled or secret invalid)" >&2
+ elif [[ "$ATTACH_HTTP_CODE" =~ ^2[0-9][0-9]$ ]]; then
+ ATTACH_STATUS=$(python3 -c "import sys,json; r=json.load(sys.stdin); print(r[0].get('result','unknown'))" <"$ATTACH_BODY_FILE" 2>/dev/null || echo "ok (unparseable)")
+ echo ">> PR attach: $ATTACH_STATUS"
+ else
+ echo ">> PR attach: failed (HTTP $ATTACH_HTTP_CODE): $(cat "$ATTACH_BODY_FILE")" >&2
+ fi
+ rm -f "$ATTACH_BODY_FILE"
+fi
+
+# Upload a local file (e.g. a run report markdown) as a native Asana attachment.
+# This is a real file upload to the task, distinct from --attach-pr (the GitHub widget).
+if $DO_ATTACH_FILE; then
+ if [[ ! -f "$ATTACH_FILE_PATH" ]]; then
+ echo "Error: --attach-file path not found: $ATTACH_FILE_PATH" >&2
+ exit 1
+ fi
+ FORM_SPEC="file=@${ATTACH_FILE_PATH};type=text/markdown"
+ [[ -n "$ATTACH_FILE_NAME" ]] && FORM_SPEC="${FORM_SPEC};filename=${ATTACH_FILE_NAME}"
+ if FILE_ATTACH_OUT=$(curl -sf -X POST "$ASANA_API/tasks/$TASK_GID/attachments" \
+ -H "Authorization: Bearer $ASANA_TOKEN" \
+ -F "$FORM_SPEC" 2>/dev/null); then
+ echo ">> File attach: $(echo "$FILE_ATTACH_OUT" | jq -r '.data.name // "attachment"')"
+ else
+ echo ">> File attach: FAILED ($ATTACH_FILE_PATH)" >&2
+ exit 1
+ fi
+fi
+
+if $DO_ASSIGN || [[ -n "$SET_REVIEWER_GID" ]] || [[ -n "$SET_IMPLEMENTOR_GID" ]] || $AUTO_EST_REVIEW || [[ -n "$SET_PRIORITY_GID" ]] || [[ -n "$SET_PLANNED_GID" ]]; then
+ load_task_fields
+fi
+
+if $DO_ASSIGN; then
+ if [[ -z "$ASSIGN_GID" ]]; then
+ ASSIGN_GID="${SET_REVIEWER_GID:-$(read_people_field "$REVIEWER_FIELD")}"
+ fi
+ if [[ -z "$ASSIGN_GID" ]]; then
+ if $SKIP_ASSIGN_IF_MISSING; then
+ echo ">> Assignee: skipped (no reviewer provided or found on task)"
+ DO_ASSIGN=false
+ else
+ echo ">> PROMPT_REVIEWER"
+ exit 2
+ fi
+ fi
+
+ if $DO_ASSIGN; then
+ if [[ -z "$SET_REVIEWER_GID" ]]; then
+ SET_REVIEWER_GID="$ASSIGN_GID"
+ fi
+
+ if [[ -z "$SET_IMPLEMENTOR_GID" ]]; then
+ SET_IMPLEMENTOR_GID="$(read_people_field "$IMPLEMENTOR_FIELD")"
+ fi
+ if [[ -z "$SET_IMPLEMENTOR_GID" ]]; then
+ SET_IMPLEMENTOR_GID="$("$SCRIPT_DIR/../../asana-whoami.sh" 2>/dev/null || true)"
+ if [[ -n "$SET_IMPLEMENTOR_GID" ]]; then
+ echo ">> Implementor: auto-resolved to current user ($SET_IMPLEMENTOR_GID)"
+ fi
+ fi
+ if [[ -z "$SET_IMPLEMENTOR_GID" ]]; then
+ echo ">> PROMPT_IMPLEMENTOR"
+ exit 2
+ fi
+ fi
+fi
+
+CUSTOM_FIELDS_PATCH='{}'
+
+if [[ -n "$SET_STATUS" ]]; then
+ STATUS_GID="$(status_to_gid "$SET_STATUS")"
+ CUSTOM_FIELDS_PATCH=$(echo "$CUSTOM_FIELDS_PATCH" | jq --arg k "$STATUS_FIELD" --arg v "$STATUS_GID" '. + {($k): $v}')
+fi
+if [[ -n "$SET_BOARD_STATE" ]]; then
+ BOARD_STATE_GID="$(board_state_to_gid "$SET_BOARD_STATE")"
+ CUSTOM_FIELDS_PATCH=$(echo "$CUSTOM_FIELDS_PATCH" | jq --arg k "$BOARD_STATE_FIELD" --arg v "$BOARD_STATE_GID" '. + {($k): $v}')
+fi
+if [[ -n "$SET_REVIEWER_GID" ]]; then
+ CUSTOM_FIELDS_PATCH=$(echo "$CUSTOM_FIELDS_PATCH" | jq --arg k "$REVIEWER_FIELD" --arg v "$SET_REVIEWER_GID" '. + {($k): [$v]}')
+fi
+if [[ -n "$SET_IMPLEMENTOR_GID" ]]; then
+ CUSTOM_FIELDS_PATCH=$(echo "$CUSTOM_FIELDS_PATCH" | jq --arg k "$IMPLEMENTOR_FIELD" --arg v "$SET_IMPLEMENTOR_GID" '. + {($k): [$v]}')
+fi
+if [[ -n "$SET_PRIORITY_GID" ]]; then
+ PRIORITY_FIELD_GID=$(echo "$TASK_FIELDS" | jq -r '.data.custom_fields[] | select(.name == "Priority") | .gid' | head -n 1)
+ if [[ -n "$PRIORITY_FIELD_GID" ]]; then
+ CUSTOM_FIELDS_PATCH=$(echo "$CUSTOM_FIELDS_PATCH" | jq --arg k "$PRIORITY_FIELD_GID" --arg v "$SET_PRIORITY_GID" '. + {($k): $v}')
+ fi
+fi
+if [[ -n "$SET_PLANNED_GID" ]]; then
+ PLANNED_FIELD_GID=$(echo "$TASK_FIELDS" | jq -r '.data.custom_fields[] | select(.name == "Planned") | .gid' | head -n 1)
+ if [[ -n "$PLANNED_FIELD_GID" ]]; then
+ CUSTOM_FIELDS_PATCH=$(echo "$CUSTOM_FIELDS_PATCH" | jq --arg k "$PLANNED_FIELD_GID" --arg v "$SET_PLANNED_GID" '. + {($k): $v}')
+ fi
+fi
+
+UPDATE_BODY='{"data":{}}'
+HAS_UPDATE=false
+
+if [[ "$CUSTOM_FIELDS_PATCH" != "{}" ]]; then
+ UPDATE_BODY=$(echo "$UPDATE_BODY" | jq --argjson cf "$CUSTOM_FIELDS_PATCH" '.data.custom_fields = $cf')
+ HAS_UPDATE=true
+fi
+
+if $DO_UNASSIGN; then
+ UPDATE_BODY=$(echo "$UPDATE_BODY" | jq '.data.assignee = null')
+ HAS_UPDATE=true
+elif $DO_ASSIGN; then
+ UPDATE_BODY=$(echo "$UPDATE_BODY" | jq --arg a "$ASSIGN_GID" '.data.assignee = $a')
+ HAS_UPDATE=true
+fi
+
+if $HAS_UPDATE; then
+ curl -sf -X PUT "$ASANA_API/tasks/$TASK_GID" \
+ -H "Authorization: Bearer $ASANA_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$UPDATE_BODY" > /dev/null
+ echo ">> Task fields: updated"
+fi
+
+if $DO_ASSIGN; then
+ echo ">> Assigned to reviewer: $ASSIGN_GID"
+fi
+if $DO_UNASSIGN; then
+ echo ">> Assignee: unset"
+fi
+if [[ -n "$SET_STATUS" ]]; then
+ echo ">> Status: $SET_STATUS"
+fi
+if [[ -n "$SET_BOARD_STATE" ]]; then
+ echo ">> Board State: $SET_BOARD_STATE"
+fi
+if [[ -n "$SET_REVIEWER_GID" ]]; then
+ echo ">> Reviewer field: set"
+fi
+if [[ -n "$SET_IMPLEMENTOR_GID" ]]; then
+ echo ">> Implementor field: set"
+fi
+if [[ -n "$SET_PRIORITY_GID" ]]; then
+ echo ">> Priority field: set"
+fi
+if [[ -n "$SET_PLANNED_GID" ]]; then
+ echo ">> Planned field: set"
+fi
+
+if $AUTO_EST_REVIEW; then
+ load_task_fields
+ EST_REVIEW=$(echo "$TASK_FIELDS" | jq -r --arg gid "$EST_REVIEW_HRS_FIELD" '.data.custom_fields[] | select(.gid == $gid) | (.number_value // empty)' | head -n 1)
+ if [[ -n "$EST_REVIEW" ]]; then
+ echo ">> Est. Review Hrs: already set ($EST_REVIEW)"
+ else
+ SPENT_DEV=$(echo "$TASK_FIELDS" | jq -r --arg gid "$SPENT_DEV_HRS_FIELD" '.data.custom_fields[] | select(.gid == $gid) | (.number_value // empty)' | head -n 1)
+ if [[ -z "$SPENT_DEV" ]]; then
+ echo ">> Est. Review Hrs: skipped (no Spent Dev Hrs)"
+ else
+ EST_VAL=$(python3 -c "v=float('$SPENT_DEV'); x=round(v*0.1,1); print(x if x >= 0.1 else 0.1)")
+ REVIEW_PATCH=$(jq -n --arg f "$EST_REVIEW_HRS_FIELD" --argjson v "$EST_VAL" '{data:{custom_fields:{($f):$v}}}')
+ curl -sf -X PUT "$ASANA_API/tasks/$TASK_GID" \
+ -H "Authorization: Bearer $ASANA_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$REVIEW_PATCH" > /dev/null
+ echo ">> Est. Review Hrs: set to $EST_VAL (10% of Spent Dev Hrs)"
+ fi
+ fi
+fi
diff --git a/.cursor/skills/asana-whoami.sh b/.cursor/skills/asana-whoami.sh
new file mode 100755
index 0000000..62b73ff
--- /dev/null
+++ b/.cursor/skills/asana-whoami.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# asana-whoami.sh
+# Resolve the current Asana user's GID from $ASANA_TOKEN.
+# Caches the result in /tmp for the duration of the session.
+#
+# Usage:
+# asana-whoami.sh # prints GID
+# asana-whoami.sh --name # prints "GID NAME"
+#
+# Requires env var: ASANA_TOKEN
+#
+# Output:
+# (default)
+# (with --name)
+set -euo pipefail
+
+SHOW_NAME=false
+if [[ "${1:-}" == "--name" ]]; then
+ SHOW_NAME=true
+fi
+
+if [[ -z "${ASANA_TOKEN:-}" ]]; then
+ echo "Error: ASANA_TOKEN not set" >&2
+ exit 1
+fi
+
+CACHE_FILE="/tmp/asana-whoami-$(echo "$ASANA_TOKEN" | shasum -a 256 | cut -c1-16).json"
+
+if [[ -f "$CACHE_FILE" ]]; then
+ cached=$(cat "$CACHE_FILE")
+else
+ cached=$(curl -s "https://app.asana.com/api/1.0/users/me?opt_fields=gid,name" \
+ -H "Authorization: Bearer $ASANA_TOKEN")
+ echo "$cached" > "$CACHE_FILE"
+fi
+
+if [[ "$SHOW_NAME" == "true" ]]; then
+ echo "$cached" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)['data']
+print(f\"{d['gid']} {d['name']}\")
+"
+else
+ echo "$cached" | python3 -c "
+import sys, json
+print(json.load(sys.stdin)['data']['gid'])
+"
+fi
diff --git a/.cursor/skills/author/SKILL.md b/.cursor/skills/author/SKILL.md
new file mode 100644
index 0000000..2b6ae0f
--- /dev/null
+++ b/.cursor/skills/author/SKILL.md
@@ -0,0 +1,145 @@
+---
+name: author
+description: Create, edit, revise, or debug Cursor skills (~/.cursor/skills/*/SKILL.md). Use when the user wants to make a new skill, update an existing skill, fix a skill, or asks about .cursor/skills/ files. Also use when the user says "new command", "create command", "create skill", "edit command", "new skill", "update skill", "update command", or references SKILL.md. NOT for general markdown editing (READMEs, CHANGELOGs, docs, AGENTS.md).
+---
+
+Write or revise Cursor commands and skills with maximum agent compliance.
+
+
+Skills (`~/.cursor/skills/*/SKILL.md`): The standard unit. Can be invoked explicitly via `/skill-name` or agent-triggered based on task matching against the description. Companion scripts live in `/scripts/`. Shared scripts live at `~/.cursor/skills/` top-level.
+
+
+
+Be prescriptive, not descriptive. Commands tell the agent what to DO, not what things ARE.
+Examples must be brief and hypothetical. Never use real data from conversations. Keep examples to 3-5 lines max.
+DRY across commands. If two commands share logic, extract it into a shared file and have both reference it.
+Order of operations matters. The agent reads top-to-bottom. Put context-setting steps before action steps.
+Hard rules at the top. Non-negotiable constraints go right after the Goal so they're read before any steps.
+Escape hatches over assumptions. When ambiguity exists, tell the agent to ask — don't let it guess.
+Offload all deterministic logic to companion scripts. If an operation has a known, repeatable sequence of steps (API calls, git commands, file parsing, linting, data fetching), it belongs in a `.sh` script — not inline in the `.md` as shell blocks the agent must reason about. The `.md` file should only handle semantic decisions, user interaction, and interpreting script output. This eliminates context bloat and prevents the agent from re-deriving logic it doesn't need to understand.
+Minimize round-trips. When a step requires multiple independent pieces of information (e.g., git status + git log + git diff), instruct the agent to gather them all in parallel tool calls within a single message/script — not sequentially. Group independent reads, searches, and shell commands together. Only sequence calls when one depends on the output of another.
+Don't duplicate in semantic rules what companion scripts already automate. If a script handles linting, formatting, localization, or other post-processing, the command should reference the script — not also instruct the agent to perform those steps. Duplication risks the agent running a step twice or conflicting with the script's output.
+For GitHub API operations in companion scripts, use `gh api` and `gh api graphql` over raw `curl` + `$GITHUB_TOKEN`. `gh` handles authentication, pagination (`--paginate`), and API versioning automatically. Use GraphQL (`gh api graphql -f query="..."`) to fetch only required fields in a single request, reducing API calls and context size. Fall back to REST (`gh api repos/...`) only when GraphQL doesn't expose the needed data (e.g., file patches).
+When companion scripts need capabilities beyond bash (JSON manipulation, complex regex, structured data processing, async I/O), embed Node.js inline via `exec node -e '...'` rather than depending on Python. Node is already a required dependency for other scripts; adding Python creates an unnecessary second runtime dependency. This keeps scripts as single `.sh` files while unlocking full-featured processing. Avoid single quotes inside the inline node code (bash single-quoted string boundary); use `\x27` in regex to match literal single quotes.
+Companion scripts must minimize context consumption. Return structured, filtered summaries — never raw API responses or full file contents. When a script processes large inputs (logs, exports, API payloads), extract only the fields the command needs and discard the rest. Commands should instruct the agent to use targeted reads (grep, line ranges) over full file reads for large files. Every token of script output that the agent reads costs context — design outputs to be as compact as possible while remaining parseable.
+
+
+
+Use XML tags to structure commands and skills. XML outperforms markdown for LLM instruction-following:
+
+- Anthropic, OpenAI, and Google all recommend XML tags for structuring prompts.
+- Claude is specifically tuned to attend to XML tag boundaries.
+- Empirical tests show up to 40% performance variance based on prompt format alone, with XML consistently outperforming markdown.
+
+Source: https://docs.claude.com/en/docs/use-xml-tags
+
+
+- Use semantic tag names that describe their content (e.g., ``, ``, ``).
+- Use attributes for metadata: `id`, `name`, `description`.
+- Nest tags for hierarchy: `...`.
+- Be consistent — use the same tag names throughout a command.
+- Markdown is still fine for inline formatting within XML tags (bold, code, lists).
+
+
+
+```xml
+One sentence. What does this command accomplish?
+
+
+...
+...
+
+
+
+Instructions for this step.
+
+
+
+Instructions for this step.
+
+
+
+How to handle it.
+
+```
+
+
+
+
+
+Give exact shell commands to copy-paste, not descriptions of what to run. Smaller models copy verbatim; they struggle to construct commands from prose. Include placeholders like `` only where the agent must substitute a value.
+
+Pass multi-line content (PR bodies, commit messages, JSON payloads) via temp files, not shell arguments. Write content using the Write tool, then pass `--body-file /tmp/foo.md` to the script. This avoids shell escaping failures that smaller models cannot debug.
+
+When the command produces formatted output (markdown, JSON, reports), show the exact template line-by-line with placeholders. Include blank lines and heading levels explicitly. Example: show `## Accomplishments {day_label}` not "add a heading for accomplishments."
+
+Spell out parallel tool calls: "Run both scripts **in parallel** (two Shell tool calls in one message)." Smaller models default to sequential unless explicitly told otherwise.
+
+When the agent must categorize or choose between options, use a numbered priority list — not prose. Example: "1. If X → do A. 2. If Y → do B. 3. Otherwise → do C." Smaller models follow numbered sequences reliably; they lose track of nested if/else prose.
+
+Duplicate critical rules from cross-referenced files as top-level `` tags. Smaller models skip "Read file X now" instructions despite explicit language. One-liner guardrails (e.g., `commit-script`, `changelog-required`) catch the failure mode where the cross-read is skipped entirely.
+
+Every action needs an explicit instruction. Never rely on "follow best practices" or "use appropriate patterns." If the agent should run `git push -u origin HEAD`, write that exact command — don't say "push the branch."
+
+Where possible, design steps so each step is ONE tool call. Smaller models lose track of multi-tool steps. If a step requires multiple calls, break it into sub-steps with explicit sequencing ("After step 2a completes, run step 2b").
+
+
+
+When revising an existing command, **every item below is mandatory** — not a suggestion. Older commands may predate current best practices; touching a command is an opportunity to bring it up to spec.
+
+1. Read the full file before making changes
+2. Check for duplicated logic across other commands — consolidate if found
+3. **Check behavioral dependencies**: Search for other commands, skills, and rules that perform similar operations or share domain overlap with the one being edited. If command A has a step that is a lightweight version of command B's core behavior (e.g., `/pr-land` addressing comments vs `/pr-address`), verify that A's step is consistent with B's rules — missing rules in A are likely bugs.
+ - Extract domain-specific verbs and nouns from the step being edited (e.g., a step about handling PR comments yields: `comment`, `reply`, `resolve`, `address`, `fixup`, `thread`)
+ - Search each term across commands, skills, and rules:
+ ```bash
+ rg -l "" ~/.cursor/skills/*/SKILL.md ~/.cursor/rules/*.mdc
+ ```
+ - Read any hits that share domain overlap and check for consistency
+ - If overlap is found, evaluate whether to consolidate per the `dry` principle: can A reference B's rules or a shared file instead of reimplementing? Propose consolidation to the user when the shared logic is non-trivial.
+4. **Check dependent callers before any script/command change**: Before adding, updating, renaming, or removing any command, skill, script, step ID, flag, or output contract, search for direct callers/references and update them in the same change.
+ - Search by skill name, script filename, flag names, and any removed/renamed identifiers:
+ ```bash
+ rg -n "" ~/.cursor/skills ~/.cursor/rules
+ ```
+ - Do not add/update/remove script behavior until caller impacts are audited and required updates are planned.
+ - Do not delete or rename a referenced target until all callers are updated.
+ - In the final response, list which callers were updated.
+5. Verify step ordering matches the agent's decision flow
+6. Ensure examples are brief and generic (no real repo names, PR numbers, or user data)
+7. Check that escape hatches exist for ambiguous cases
+8. Confirm companion scripts match the `.md` expectations
+9. Convert markdown-structured commands to XML format (this is the most commonly skipped item — `##` headers and bullet lists must become ``, ``, `` tags)
+10. Apply all current authoring principles (rules-first, scripts-over-reasoning, batch-tool-calls, etc.) even if the original command predates them
+11. If the command may run on smaller/faster models, apply `` — especially `file-over-args`, `inline-guardrails`, and `verbatim-bash`
+
+
+
+After any authoring change (skills/scripts/rules), ask:
+
+> Run `/convention-sync` to sync files and update PR conventions/description?
+
+When `.cursor/rules/*.mdc` files changed, run:
+
+```bash
+~/.cursor/skills/convention-sync/scripts/generate-claude-md.sh
+```
+
+This keeps `~/.claude/CLAUDE.md` aligned with always-apply rules via the existing convention-sync flow.
+
+
+
+Skill-specific scripts go in `/scripts/`. Shared scripts go in `~/.cursor/skills/` top-level. Conventions:
+
+- `set -euo pipefail` at the top
+- Parse args with a `while/case` loop
+- Output structured, one-line-per-action summaries the agent can parse
+- Exit code 0 = success, 1 = error, 2 = needs user input
+- **Naming**: Name scripts by what they DO, not which command they serve. Scripts will likely be reused by multiple commands. Prefer descriptive, domain-scoped names over command-coupled names:
+ - `lint-commit.sh` — good (describes the operation)
+ - `asana-task-update.sh` — good (describes the operation)
+ - `github-pr-comments.sh` — good (describes the domain + operation)
+ - `pr-address.sh` — bad (coupled to the `/pr-address` command name)
+- Before creating a new script, check if an existing script already covers the operation. Extend it with a new subcommand rather than creating a duplicate.
+- **GitHub API**: Default to `gh api` and `gh api graphql` — never raw `curl`. See `gh-cli-over-curl` principle.
+
diff --git a/.cursor/skills/bugbot/SKILL.md b/.cursor/skills/bugbot/SKILL.md
new file mode 100644
index 0000000..4b7bc10
--- /dev/null
+++ b/.cursor/skills/bugbot/SKILL.md
@@ -0,0 +1,346 @@
+---
+name: bugbot
+description: Address Cursor Bugbot PR review findings until the PR is actually clean. Runs one scan cycle (check bugbot's check-run status on HEAD, classify each unresolved bugbot thread, fix valid ones with fixup commits, push, reply+resolve) and — on Claude Code — self-schedules a 5-minute recurring cycle that stops automatically when bugbot reports the PR clean. On Cursor/Codex the recurring schedule is set up once via Automations and the skill's cycle runs identically on each fire. Only handles `cursor[bot]` feedback — leaves human and other-bot threads for /pr-address. Use when the user says "address bugbot", "handle bugbot comments", or pastes a PR URL and asks about bugbot status.
+compatibility: Requires git, gh. Composes with pr-address, lint-commit.sh, git-branch-ops.sh. Self-schedules on Claude Code via CronList/CronCreate/CronDelete tools when available.
+metadata:
+ author: j0ntz
+---
+
+Get a PR to bugbot-clean state end-to-end: run one scan cycle now, self-arm a recurring schedule when bugbot hasn't yet signed off, and self-disarm when it has.
+
+
+Do NOT call `gh` directly. Use `~/.cursor/skills/bugbot/scripts/bugbot.sh` for bugbot check-run queries, `~/.cursor/skills/pr-address/scripts/pr-address.sh` for all thread operations (fetch, fetch-thread, reply, resolve-thread, ensure-branch), and `~/.cursor/skills/pr-finalize-fixups.sh` for the post-fixup autosquash decision (SHARED with /pr-address — policy lives there, not here).
+If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or other workarounds.
+Only process threads whose first comment's author login is `cursor[bot]` (the literal `[bot]` suffix is required). Skip human threads, other-bot threads, and reviewer threads — those belong to `/pr-address`.
+`check-run.conclusion: neutral` does NOT mean the PR is clean. `neutral` means bugbot posted findings that are non-blocking. ALWAYS combine check-run `status == "completed"` with "0 unresolved `cursor[bot]` threads" before declaring clean.
+When the companion scripts query bot comments, they already pass `--paginate` — PRs with >30 bot comments miss newest without it. Do not implement your own comment queries; delegate.
+ALWAYS reply explaining how a thread was addressed (fix SHA for valid, invalidity class for invalid) BEFORE calling `resolve-thread`. No silent resolutions.
+Fixups MUST use `~/.cursor/skills/lint-commit.sh --no-reorder -m "fixup! {target-headline}" [files...]`. Do not run `git commit` directly and do not manually run eslint — the commit script handles it.
+Immediately after every successful `lint-commit.sh` call, run `~/.cursor/skills/slot-fixup.sh` to slot the new fixup next to its target's group. Keeps the "every fixup sits next to its target" invariant continuously. If `slot-fixup.sh` exits non-zero (rebase conflict), report and STOP — do not continue the cycle. The cron will retry on the next fire once resolved.
+Before each fixup, run `git log --oneline -- ` to find the commit that introduced the behavior being fixed and use its exact headline (not a generic one). The fixup must target a real commit on the branch so the later autosquash resolves correctly.
+Do NOT post a top-level PR summary comment. Reply inline on each thread only. The scheduler consumes per-cycle status from stdout; extra body comments add noise on recurring runs.
+When `CronList`, `CronCreate`, and `CronDelete` tools are available (Claude Code), the skill MUST manage its own recurring schedule per Step 5: arm a 5-minute cron on any non-clean outcome if one isn't already armed; delete any matching cron on clean/skipped. On tools without those APIs (Cursor/Codex), skip Step 5 — the user configures their tool's Automation manually per ``.
+Never arm a second cron for a `(owner, repo, pr)` tuple that already has one. Always `CronList` first and match by the prompt substring; only `CronCreate` if no existing cron matches.
+Set `block_until_ms: 60000` when invoking `bugbot.sh` or `pr-address.sh` — GitHub API calls can take up to 30s and bugbot's `--paginate` query may take longer on busy PRs.
+If any other instruction conflicts with this file, **this file wins** for `bugbot`.
+
+
+
+Accepts either form:
+- `owner/repo#pr` (e.g. `EdgeApp/edge-reports-server#207`)
+- Discrete flags: `--owner --repo --pr `
+
+Required. Parse and assign to ``, ``, `` for the steps below.
+
+
+
+Before any other work, ensure the PR's branch is checked out and up to date. Delegate to pr-address:
+
+```bash
+~/.cursor/skills/pr-address/scripts/pr-address.sh ensure-branch \
+ --owner --repo --pr
+```
+
+Output includes `BRANCH_READY`, `STASHED`, and (if switched) `PREVIOUS_BRANCH`. If `STASHED=true`, inform the user that changes were stashed on the previous branch. If the output contains `WORKTREE_PATH=`, the PR branch lives in another git worktree — `cd ""` first and run ALL subsequent git, commit, and companion-script operations there, leaving the main checkout untouched. If the target dir has no `node_modules`, the script installs deps (`npm ci`/`yarn install`) — this can take several minutes, so invoke with `block_until_ms: 600000`.
+
+
+
+Resolve the full 40-char SHA for the PR's head branch:
+
+```bash
+HEAD_SHA=$(git rev-parse origin/)
+HEAD_SHORT=${HEAD_SHA:0:10}
+```
+
+If you don't already know ``, derive it from pr-address's ensure-branch output or:
+
+```bash
+BRANCH=$(gh pr view --repo / --json headRefName --jq '.headRefName')
+```
+
+
+
+Get bugbot's authoritative state on the current HEAD:
+
+```bash
+~/.cursor/skills/bugbot/scripts/bugbot.sh check-run-status \
+ --owner --repo --sha "$HEAD_SHA"
+```
+
+Returns compact JSON: `{"status":"","conclusion":"","sha":""}`.
+
+- `status` ∈ { `queued`, `in_progress`, `completed`, `none` }
+- `conclusion` ∈ { `success`, `neutral`, `failure`, `skipped`, `null` }
+- `status: "none"` means no `Cursor Bugbot` check-run exists for this SHA (scan not yet triggered).
+
+If the script exits 2 with `PROMPT_GH_AUTH`, prompt the user: "`gh` CLI is not authenticated. Please run: `gh auth login`". Then STOP.
+
+
+
+Pick the FIRST matching row. Set an internal `OUTCOME` variable to one of `waiting` | `no-check-run` | `skipped` | `clean` | `findings`. Then run Step 4 if `OUTCOME == findings`, and ALWAYS run Step 5 last to manage the recurring schedule.
+
+1. **`status == "queued"` OR `status == "in_progress"`** → `OUTCOME = waiting`.
+ Status line: `waiting for bugbot to finish scanning `.
+ Do NOT fetch threads, commit, push, reply, or resolve anything.
+
+2. **`status == "none"`** → `OUTCOME = no-check-run`.
+ Status line: `no bugbot check-run on yet`.
+ Do NOT act. (Bugbot may start scanning shortly.)
+
+3. **`status == "completed"` AND `conclusion == "skipped"`** → `OUTCOME = skipped`.
+ Status line: `bugbot skipped `.
+ Treat as clean for this SHA — bugbot explicitly declined to scan (often because the diff only changed docs/config).
+
+4. **`status == "completed"` (any other conclusion) AND 0 unresolved `cursor[bot]` threads** → `OUTCOME = clean`.
+ Verify unresolved count with `pr-address.sh fetch` (see Step 4a).
+ Status line: `bugbot clean on `.
+
+5. **`status == "completed"` AND >0 unresolved `cursor[bot]` threads** → `OUTCOME = findings`.
+ Proceed to Step 4 to address them.
+
+**Critical**: Row 4 MUST combine the `completed` status with a live thread-count check. `conclusion: neutral` alone can mean "posted findings, non-blocking" — declaring clean on conclusion-only would silently skip real issues.
+
+
+
+
+
+```bash
+~/.cursor/skills/pr-address/scripts/pr-address.sh fetch \
+ --owner --repo --pr
+```
+
+The JSON output includes a `threads` array. Filter to threads whose first comment's author is `cursor[bot]` — that filter is the bot-only scope this skill owns. For each such thread, continue below.
+
+
+
+For each cursor[bot] thread, fetch the full body:
+
+```bash
+~/.cursor/skills/pr-address/scripts/pr-address.sh fetch-thread \
+ --owner --repo --pr \
+ --thread-id ""
+```
+
+Inside the `...` markers is the finding. Classify it by running through the `` block (below) in order. The DEFAULT is "valid" — invalidity requires a cited heuristic match.
+
+
+
+Before applying any new fixups, ask the shared finalize helper whether existing fixup commits on the branch are stale relative to the latest human review:
+
+```bash
+~/.cursor/skills/pr-finalize-fixups.sh squash-stale --owner --repo --pr
+```
+
+The script returns either `{"action":"autosquash",...}` (existing fixups were squashed and force-pushed) or `{"action":"noop",...}` (nothing to squash). Identical call site as `/pr-address` Step 1.5 — policy lives in the script so the two skills never drift.
+
+If the script exits non-zero (conflict mid-rebase), report and STOP. The cron will retry on the next fire once the user resolves the conflict.
+
+Skip this sub-step if Step 4b classified zero threads as valid (no fixups will be made anyway).
+
+
+
+For each thread classified valid, in order:
+
+1. Read the affected file and apply the fix via Edit/Write.
+2. Locate the fixup target:
+ ```bash
+ git log --oneline --
+ ```
+ Pick the commit that introduced the behavior being fixed. Use its exact headline.
+3. Typecheck first if the repo has one. Use `~/.cursor/skills/pm.sh run build.types` or `~/.cursor/skills/pm.sh run tsc` (auto-detects npm vs yarn), falling back to bare `tsc`. Skip if unavailable.
+4. Commit as a fixup:
+ ```bash
+ ~/.cursor/skills/lint-commit.sh --no-reorder -m "fixup! "
+ ```
+5. **Immediately slot the new fixup next to its target's group** (per `slot-after-each-fixup` rule):
+ ```bash
+ ~/.cursor/skills/slot-fixup.sh
+ ```
+ If `slot-fixup.sh` reports a conflict, STOP — do not continue the cycle. The cron will retry once resolved.
+6. Capture the slotted fixup SHA: `git rev-parse --short HEAD` may not be the new fixup anymore (it's been moved earlier in history). Use `git log --grep="^fixup! $" --format=%h -1` to find the most recent fixup with that headline; that's the one we just made. Record a `{threadId, commentId, fixupSha}` entry so Step 4e can reply with the correct SHA per thread.
+
+Do NOT push inside this loop — Step 4d pushes once after all fixups land.
+
+
+
+After every valid thread has been committed and slotted:
+
+```bash
+~/.cursor/skills/git-branch-ops.sh push --force-with-lease
+```
+
+Force-with-lease is required because per-fixup slotting (Step 4c.5) rewrote tip. The push makes all fixup SHAs visible to GitHub so Step 4e's reply bodies render as commit links. Skip this sub-step if Step 4c produced zero fixups (all threads were invalid).
+
+
+
+For each processed thread, post one reply then resolve. Replies and resolves for independent threads are safe to parallelize (multiple Bash tool calls in one message).
+
+**Ownership gate (check `isOwner` from Step 4a `fetch` output):** if `isOwner: false` (`currentUser !== prAuthor` — not our PR), post the reply but do NOT call `resolve-thread`. Leave threads unresolved for the owner; we never mutate the PR state of a PR we don't own (this pairs with the finalize guard's `preserve` mode). Only resolve when `isOwner: true`.
+
+Valid threads — reply body cites the fixup SHA from Step 4c's record:
+```bash
+~/.cursor/skills/pr-address/scripts/pr-address.sh reply \
+ --owner --repo --pr \
+ --comment-id \
+ --body "Valid — fixed in . ."
+```
+
+Invalid threads — reply body cites the matched heuristic:
+```bash
+~/.cursor/skills/pr-address/scripts/pr-address.sh reply \
+ --owner --repo --pr \
+ --comment-id \
+ --body ". ."
+```
+
+Then resolve:
+```bash
+~/.cursor/skills/pr-address/scripts/pr-address.sh resolve-thread --thread-id ""
+```
+
+
+
+Delegate to the shared finalize helper. Identical call site as `/pr-address` Step 4 — policy lives in the script so the two skills never drift:
+
+```bash
+~/.cursor/skills/pr-finalize-fixups.sh --owner --repo --pr
+```
+
+Output is one line of JSON:
+- `{"action": "autosquash", "mode": "autosquash", "newHead": ""}` — history rewritten, force-pushed. Use `newHead` in the Step 4g status line.
+- `{"action": "push", "mode": "preserve", "newHead": ""}` — fixups preserved for the active reviewer; force-pushed. Use `newHead` in the Step 4g status line.
+
+**Ownership guard:** if you are not the PR author (`currentUser !== prAuthor`), the helper forces `preserve` mode and squash-stale is a noop — bugbot never rewrites the history of a PR it doesn't own.
+
+If the script exits non-zero, the autosquash hit a conflict. Do NOT emit a status line or run Step 5 — report the error and STOP so the user can resolve manually. An armed cron (from a previous cycle) will keep firing; the next cycle with a clean tree will retry.
+
+Skip this sub-step entirely if Step 4c produced zero fixups.
+
+
+
+Set the final status line based on what happened in 4c–4f:
+
+- `bugbot addressed thread(s) on ; autosquashed to ` — fixups pushed and squashed (autosquash mode).
+- `bugbot addressed thread(s) on ; new HEAD ` — fixups pushed, autosquash deferred (preserve mode — active reviewer).
+- `bugbot addressed thread(s) on ; no fixups` — all threads were invalid.
+
+The new HEAD needs a fresh bugbot scan. Step 5 keeps the cron armed so the next cycle handles it.
+
+
+
+
+This step runs AFTER every other step, on every outcome. Its job: arm a 5-minute recurring cycle on non-clean outcomes and tear it down on clean outcomes, so interactive `/bugbot` invocations Just Work without the user composing with `/loop`.
+
+**If `CronList`, `CronCreate`, and `CronDelete` tools are NOT available** (Cursor, Codex, agent harnesses without Claude Code scheduling): skip this step entirely. Emit the status line from Step 3/4f and exit. The user's Cursor/Codex Automation (configured per ``) keeps firing until they disable it when they see the clean status.
+
+**If those tools ARE available** (Claude Code):
+
+1. Build the cron prompt string: `/bugbot /#` (matching exactly what the user invoked). This string is the unique key for finding/removing this PR's cron.
+
+2. Query existing crons:
+ ```
+ CronList()
+ ```
+ Find entries whose `prompt` contains the cron prompt string from (1). Save any matching job IDs into `EXISTING_IDS`.
+
+3. Act on `OUTCOME`:
+
+ - `OUTCOME == clean` OR `OUTCOME == skipped`:
+ For each id in `EXISTING_IDS`: `CronDelete(id)`.
+ Append ` · monitor stopped` to the status line if any were deleted, or ` · no monitor was armed` if not.
+
+ - `OUTCOME == waiting` OR `OUTCOME == no-check-run` OR `OUTCOME == findings`:
+ If `EXISTING_IDS` is empty:
+ ```
+ CronCreate(cron: "*/5 * * * *", prompt: "", recurring: true)
+ ```
+ Append ` · monitoring every 5m (job )` to the status line.
+
+ If `EXISTING_IDS` is non-empty: do NOT CronCreate. Append ` · continuing monitor (job )` to the status line.
+
+4. Emit the final status line as the last stdout line of the cycle.
+
+**Why this design**:
+- Interactive `/bugbot owner/repo#N` invocation arms a monitor and returns.
+- Subsequent cron fires find the existing cron and skip re-arming.
+- Clean cycle deletes the cron cleanly; user sees `bugbot clean on · monitor stopped`.
+- No piling-up of crons; no orphan schedules on clean.
+- Matching by prompt-substring (not job id) means the skill can tear down crons even when the current invocation came from the cron itself.
+
+
+
+
+
+The bot's own description contains language like "Actually the code looks correct on closer inspection", "appears consistent", "Upon closer inspection, this appears consistent", or "This is not the main issue though". The bot's own analysis has concluded no real bug — cite the exact sentence in your reply.
+
+
+
+The flagged code:
+1. Has a source-code comment documenting the author's intent (e.g. `// Only EVM-style addresses are contracts`), AND
+2. Was NOT introduced by any fixup in this session (`git log -- ` shows the hunk pre-dates the current branch work).
+
+Reply citing the author comment and the commit that introduced it.
+
+
+
+A reply on this same thread, or on a sibling thread about the same concern, has already documented the position (e.g. "keeping per-tx async for backfill-script reuse", "throw-on-unknown is intentional to force mapping updates"). Reference the earlier reply's thread ID or comment ID in the new reply so the reviewer can trace the rationale.
+
+
+
+Same file and same concern as a `cursor[bot]` thread resolved earlier in this or an immediately prior cycle. Cite the resolved thread's ID and the fixup SHA (if any) that addressed it.
+
+
+
+The finding asserts an API shape or data-model behavior that contradicts what earlier commits on the branch demonstrate (e.g. "baseCurrency on Moonpay sell is fiat" when the original sell implementation shows it is crypto). Verify by reading the commit that introduced the handling and cite that commit in the reply. Do NOT accept a finding that rewrites author intent without evidence.
+
+
+
+
+
+
+
+Just invoke the skill — it arms its own schedule on non-clean outcomes and tears it down on clean outcomes (see Step 5).
+
+```
+/bugbot /#
+```
+
+First cycle runs immediately. If bugbot hasn't finished / has findings, a session-scoped cron is armed automatically (`*/5 * * * *`). Each cron fire is another `/bugbot` cycle. The cycle that reaches clean deletes its own cron and reports `bugbot clean on · monitor stopped`.
+
+Manual cadence override: `/loop 15m /bugbot ...` still works — Step 5 skips arming a new cron when it finds the existing `/loop`-created one, so the two don't fight.
+
+
+
+For survive-across-sessions monitoring (e.g. you want bugbot polling overnight while you're logged off):
+
+```
+/schedule every 5 minutes: /bugbot /#
+```
+
+Each fire re-runs the skill. Step 5's self-teardown logic uses `CronDelete` which only covers session-scoped crons — for `/schedule`-created ones, cancel from `/schedule list` when you see the clean status.
+
+
+
+In the Automations panel, create a recurring Automation:
+- Schedule: cron `*/5 * * * *`
+- Prompt: `/bugbot /#`
+
+The skill's Step 5 is a no-op in Cursor (no CronList API to the agent), so the Automation keeps firing until you disable it. Each cycle's status line tells you when it's safe to disable.
+
+
+
+Ask Codex: "Create a standalone automation that runs every 5 minutes with prompt `/bugbot /#`." Same caveat as Cursor — the skill doesn't self-teardown; disable the automation when the clean status appears.
+
+
+
+
+
+Rely on `pr-address.sh ensure-branch` — it stashes automatically and reports `STASHED=true`. Surface that to the user so they know where their changes are.
+Same handling as `neutral` with threads — bugbot just marked the findings blocking-severity rather than informational. Proceed through Step 4.
+After a push, the previous HEAD's check-run no longer matters — always query the LATEST HEAD SHA. The script's `sort_by(.started_at) | last` logic handles cases where bugbot posts multiple runs on the same SHA.
+Skip. This skill is scoped to bugbot. For mixed human/bot reviews, run `/pr-address` separately.
+Step 2 returns `status: "none"`. Step 3 row 2 applies — report and wait. Bugbot has up to ~1 minute before it enqueues a scan.
+Prompt the user to install/authenticate `gh`, STOP. Do not fall back to curl or manual API calls.
+On Claude Code with `CronList`, `CronCreate`, `CronDelete` deferred, load them via `ToolSearch` with `query: "select:CronCreate,CronList,CronDelete"` before running Step 5. If loading fails, fall back to the Cursor/Codex path: emit the status line without scheduling and let the caller manage the Automation manually.
+`ensure-branch` will fail. If Step 5 has already armed a cron, CronDelete it before exiting. Report the error and STOP — the scheduler no longer has anything useful to do.
+
diff --git a/.cursor/skills/bugbot/scripts/bugbot.sh b/.cursor/skills/bugbot/scripts/bugbot.sh
new file mode 100755
index 0000000..516efdf
--- /dev/null
+++ b/.cursor/skills/bugbot/scripts/bugbot.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# bugbot.sh
+# Companion script for bugbot.md
+# Handles Cursor Bugbot check-run status queries. All PR thread operations
+# (fetch, reply, resolve) are delegated to pr-address.sh; this script exists
+# only to encapsulate the bugbot-specific check-run interpretation.
+#
+# Subcommands:
+# check-run-status --owner --repo --sha
+# Returns compact JSON: {"status":"...","conclusion":"...","sha":""}
+# status values: queued | in_progress | completed | none
+# conclusion values: success | neutral | failure | skipped | null
+# "none" status means no Cursor Bugbot check-run exists for the SHA
+# (e.g. scan not yet triggered).
+#
+# Exit codes: 0 = success, 1 = error, 2 = needs user input (e.g. gh not authenticated)
+set -euo pipefail
+
+CMD="${1:-}"
+shift || true
+
+OWNER="" REPO="" SHA=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --owner) OWNER="$2"; shift 2 ;;
+ --repo) REPO="$2"; shift 2 ;;
+ --sha) SHA="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+require_gh() {
+ if ! command -v gh &>/dev/null; then
+ echo "PROMPT_GH_INSTALL" >&2; exit 2
+ fi
+ if ! gh auth status &>/dev/null 2>&1; then
+ echo "PROMPT_GH_AUTH" >&2; exit 2
+ fi
+}
+
+case "$CMD" in
+ check-run-status)
+ require_gh
+ if [[ -z "$OWNER" || -z "$REPO" || -z "$SHA" ]]; then
+ echo "Error: --owner, --repo, --sha required" >&2; exit 1
+ fi
+
+ SHORT_SHA="${SHA:0:10}"
+
+ # Pull ALL Cursor Bugbot check-runs for the SHA, then pick the most-recent
+ # by started_at. The API can return multiple entries (e.g. retries, rerun);
+ # we want the latest so we don't declare clean based on a stale success.
+ gh api "repos/$OWNER/$REPO/commits/$SHA/check-runs" --paginate \
+ --jq '[.check_runs[]? | select(.name == "Cursor Bugbot")]' \
+ | SHORT="$SHORT_SHA" node -e "
+ const fs = require('fs')
+ const runs = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'))
+ const short = process.env.SHORT
+ let out
+ if (!Array.isArray(runs) || runs.length === 0) {
+ out = {status: 'none', conclusion: null, sha: short}
+ } else {
+ // Most recent first — started_at is ISO-8601 so lexicographic sort works.
+ runs.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
+ const latest = runs[0]
+ out = {
+ status: latest.status || null,
+ conclusion: latest.conclusion || null,
+ sha: short
+ }
+ }
+ process.stdout.write(JSON.stringify(out) + '\n')
+ "
+ ;;
+ ""|help|--help|-h)
+ cat >&2 < [flags]
+
+Subcommands:
+ check-run-status --owner --repo --sha
+ Returns compact JSON describing the Cursor Bugbot
+ check-run for the given commit SHA.
+USAGE
+ [[ -z "$CMD" ]] && exit 1 || exit 0
+ ;;
+ *)
+ echo "Unknown subcommand: $CMD" >&2
+ echo "Run 'bugbot.sh help' for usage." >&2
+ exit 1
+ ;;
+esac
diff --git a/.cursor/skills/build-and-test/SKILL.md b/.cursor/skills/build-and-test/SKILL.md
new file mode 100644
index 0000000..422fb6d
--- /dev/null
+++ b/.cursor/skills/build-and-test/SKILL.md
@@ -0,0 +1,135 @@
+---
+name: build-and-test
+description: Run build and test verification for the active repo. Detects edge-react-gui and runs a real iOS UI test via maestro (Buy $500 quote with proof screenshot); detects Node/TypeScript repos and runs `tsc --noEmit` + smoke checks; falls back to a placeholder ack for unknown repo shapes. Use during the Testing phase of /one-shot.
+metadata:
+ author: j0ntz
+---
+
+
+Verify the active repo builds cleanly before /one-shot marks a task complete. Returns a clear PASS/FAIL signal the caller can include in the Asana summary or use to gate the watch loop.
+
+
+
+Inspect the current working directory to decide what to run:
+1. If `package.json` `name` is `edge-react-gui` → iOS UI test (maestro) path (step 0). Check this first.
+2. Else if `package.json` exists and a `tsconfig.json` exists → Node + TypeScript path (step 1).
+3. Else if `package.json` exists with a `test` script but no tsconfig → Node path (step 2).
+4. If `Cargo.toml` exists → not implemented yet, fall through to placeholder.
+5. Otherwise → placeholder mode (step 3).
+On FAIL, surface the exact command, exit code, and last 30 lines of output. Do not try to fix anything inside this skill — the caller decides whether to amend or block.
+This skill does NOT edit source code, commit, push, or change Asana state. It runs verification commands and reports results only.
+Deterministic operations (sim selection, RN build, capture loop) MUST run via the companion scripts under `~/.cursor/skills/build-and-test/scripts/`. Do not inline their logic as raw bash blocks in this SKILL.md or in agent reasoning.
+edge-react-gui uses npm (package-lock.json present, no yarn.lock). All scripts in this skill use npm. Never invoke `yarn` against edge-react-gui.
+
+
+
+
+A real on-simulator UI test that logs into the pre-provisioned test account, navigates to the Buy tab, requests a $500 quote, and captures a proof screenshot. PASS requires the screenshot to actually render the resolved quote.
+
+**Parallel-session env contract:** when the agent-watcher spawns this session as one of several parallel slots, it exports `$AGENT_SIM_UDID` (the slot's cloned simulator) and `$AGENT_METRO_PORT` (the slot's Metro port) into the shell. The scripts below honor them automatically — `select-ios-sim.sh --accept-udid "$AGENT_SIM_UDID"` skips name/runtime resolution and trusts the clone, and `ios-rn-build.sh` falls back to `$AGENT_SIM_UDID` / `$AGENT_METRO_PORT` when `--udid` / `--port` are not passed (forwarding a non-8081 port to `react-native run-ios`). On a manual run with neither var set, behavior is unchanged: resolve the iOS 18 sim by name and use Metro 8081.
+
+### 0a. Prerequisites (check, install if missing)
+
+- `xcrun -version` → Xcode CLT
+- `maestro --version` → install with `curl -Ls "https://get.maestro.mobile.dev" | bash`, then add `$HOME/.maestro/bin` to PATH. maestro needs JDK 11+; Temurin 17 works.
+
+### 0b. Resolve + boot the simulator
+
+There can be multiple "iPhone 16 Pro Max" devices across runtimes. **Only the iOS 18 device holds the test account** (`edge-rjqa3`, PIN `1111`, region California/USA, BTC wallet). The iOS 26.x device does NOT.
+
+```bash
+UDID=$(~/.cursor/skills/build-and-test/scripts/select-ios-sim.sh \
+ --runtime "iOS 18" --device "iPhone 16 Pro Max" --boot)
+```
+
+If the script exits 2 (ambiguous), narrow `--runtime` (e.g. `"iOS 18.6"`).
+
+### 0c. Build + install + launch the app
+
+```bash
+~/.cursor/skills/build-and-test/scripts/ios-rn-build.sh \
+ --udid "$UDID" --bundle-id co.edgesecure.app
+```
+
+Skips the full RN build when the app is already installed (cached path: seconds; cold build: 30–60 min). Pass `--force-rebuild` to always rebuild.
+
+### 0d. Run the maestro capture
+
+```bash
+~/.cursor/skills/build-and-test/scripts/capture-buy-quote.sh
+```
+
+Drives `maestro/buy-quote-input.yaml` (login → Buy → $500), then captures via an external simctl screenshot burst — keeping the last frame taken while the app was alive. Retries up to 5 cycles. Writes `/tmp/agent-mvp-buy-quote-screenshot.png` on success.
+
+### 0e. PASS / FAIL contract
+
+On capture-buy-quote.sh exit 0, the screenshot must visibly show **USD 500**, a non-empty **Amount BTC**, and the **`1 BTC = USD`** line. Emit:
+
+```
+build-and-test: PASS (iOS maestro — Buy $500 quote)
+screenshot: /tmp/agent-mvp-buy-quote-screenshot.png
+```
+
+On exit nonzero, emit FAIL with the last 30 lines of the script's output:
+
+```
+build-and-test: FAIL — Buy $500 quote not captured
+
+```
+
+Return success exit only on PASS.
+
+### 0f. Critical gotchas baked into the flow (do not "fix" them)
+
+Edge's RN keypad drops digits tapped too fast → wrong PIN → exponential lockout (465s → 914s → …). Each PIN digit tap in `buy-quote-input.yaml` uses `waitToSettleTimeoutMs`. Never speed it up. If a run logs "Invalid PIN: Account locked for N seconds", wait — do NOT tap.
+On this debug build, `hideKeyboard` reliably triggers an RN Fabric text-measure SIGABRT. The flow leaves the keyboard up. Do not add `hideKeyboard` steps.
+`assertVisible`/`extendedWaitUntil` traverse the a11y hierarchy on a poll loop, provoking the same Fabric crash on the Buy scene. The flow stops polling once the amount is entered; the capture script uses external simctl screenshots (no hierarchy traversal).
+
+
+
+
+Run, in order:
+
+```bash
+[ -d node_modules ] || npm install --no-audit --no-fund
+npx tsc --noEmit
+```
+
+Emit PASS:
+```
+build-and-test: PASS (tsc --noEmit clean)
+```
+
+Or FAIL with the last 30 lines of failing output:
+```
+build-and-test: FAIL — exit
+
+```
+
+
+
+```bash
+[ -d node_modules ] || npm install --no-audit --no-fund
+npm test
+```
+
+Same PASS/FAIL contract as step 1.
+
+
+
+Emit exactly:
+```
+build-and-test: placeholder mode — no commands executed (repo shape not auto-detected).
+```
+Return success.
+
+
+
+Re-run `select-ios-sim.sh` with a more specific `--runtime` (e.g. `"iOS 18.6"`). If still ambiguous, surface the list to the caller and set `blocked = Yes` on the Asana task with the candidate UDIDs and ask which to use.
+Run `xcrun simctl shutdown all && xcrun simctl erase ` is destructive — do NOT run it. Set `blocked = Yes` with the boot error.
+Re-run step 0b. If it fails twice, set `blocked = Yes`.
+Acceptable in --yolo. Watch loop should NOT timeout the iteration during a known cold-build window — that's handled by /one-shot's `iOS prep budget` policy.
+Emit FAIL with the maestro tail. Do NOT set `blocked = Yes` unless the failure mode is clearly a true-blocker (e.g. simulator died entirely, app uninstalled). A normal capture exhaustion is a real test FAIL the caller (watch loop) should react to.
+Set `blocked = Yes` with the install error and a note about JDK requirement.
+Set `blocked = Yes` — the test relies on `edge-rjqa3` with PIN 1111. Re-provisioning is a human step.
+
diff --git a/.cursor/skills/build-and-test/maestro/buy-quote-input.yaml b/.cursor/skills/build-and-test/maestro/buy-quote-input.yaml
new file mode 100644
index 0000000..52ea17e
--- /dev/null
+++ b/.cursor/skills/build-and-test/maestro/buy-quote-input.yaml
@@ -0,0 +1,51 @@
+# Interaction-only companion to buy-quote.yaml: PIN login -> Buy tab -> enter $500.
+# Captures nothing — capture-buy-quote.sh grabs the proof screenshot externally
+# (see that script for why an external burst beats an in-flow screenshot on this build).
+appId: ${APP_ID}
+env:
+ APP_ID: co.edgesecure.app
+ PIN_DIGIT: "1"
+ BUY_AMOUNT: "500"
+---
+- launchApp
+- extendedWaitUntil:
+ visible: "Exit PIN"
+ timeout: 40000
+- assertVisible: "1"
+- assertVisible: "0"
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 900
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 900
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 900
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 1500
+- extendedWaitUntil:
+ visible: "Buy"
+ timeout: 30000
+- runFlow:
+ when:
+ visible: "Security is Our Priority"
+ commands:
+ - tapOn: "Cancel"
+- runFlow:
+ when:
+ visible: "How Did You Discover Edge?"
+ commands:
+ - tapOn: "Dismiss"
+- runFlow:
+ when:
+ visible: "Claim Your Web3 Handle"
+ commands:
+ - tapOn: "Not Now"
+- tapOn: "Buy"
+- extendedWaitUntil:
+ visible: "Amount USD"
+ timeout: 15000
+- tapOn: "Amount USD"
+- inputText: ${BUY_AMOUNT}
diff --git a/.cursor/skills/build-and-test/maestro/buy-quote.yaml b/.cursor/skills/build-and-test/maestro/buy-quote.yaml
new file mode 100644
index 0000000..f7b823d
--- /dev/null
+++ b/.cursor/skills/build-and-test/maestro/buy-quote.yaml
@@ -0,0 +1,84 @@
+# Maestro flow: Edge iOS "Buy" $500 quote — end-to-end proof screenshot.
+#
+# Target: the iPhone 16 Pro Max / iOS 18 simulator that holds the pre-provisioned
+# test account "edge-rjqa3" (PIN 1111, region California/USA, BTC wallet). The Edge
+# app must already be installed (build + install steps are in build-and-test/SKILL.md).
+#
+# Run: maestro test ~/.cursor/skills/build-and-test/maestro/buy-quote.yaml
+#
+# Notes / gotchas baked into this flow:
+# * PIN digit taps are spaced with waitToSettleTimeoutMs. Edge's RN keypad drops
+# digits when tapped too fast, which produces a WRONG PIN and an exponential
+# account lockout (465s -> 914s -> ...). Never tap the keypad faster than this.
+# * This debug build has an intermittent RN Fabric text-measure crash on the Buy
+# (Ramp) scene (RCTTextLayoutManager / folly EvictingCacheMap SIGABRT). The
+# scene renders healthy for several seconds first, so we enter the amount and
+# screenshot promptly. If it crashes before the quote, just re-run.
+appId: ${APP_ID}
+env:
+ APP_ID: co.edgesecure.app
+ PIN_DIGIT: "1" # test account PIN is 1111 -> tap "1" four times
+ BUY_AMOUNT: "500"
+ SCREENSHOT_PATH: /tmp/agent-mvp-buy-quote-screenshot
+---
+- launchApp
+
+# --- PIN login (account already provisioned on this simulator) ---
+- extendedWaitUntil:
+ visible: "Exit PIN"
+ timeout: 40000
+# make sure the whole keypad is rendered & interactive before tapping
+- assertVisible: "1"
+- assertVisible: "0"
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 900
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 900
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 900
+- tapOn:
+ text: ${PIN_DIGIT}
+ waitToSettleTimeoutMs: 1500
+
+# --- wait for the main tab bar, then dismiss optional post-login modals ---
+- extendedWaitUntil:
+ visible: "Buy"
+ timeout: 30000
+- runFlow:
+ when: { visible: "Security is Our Priority" }
+ commands: [{ tapOn: "Cancel" }]
+- runFlow:
+ when: { visible: "How Did You Discover Edge?" }
+ commands: [{ tapOn: "Dismiss" }]
+- runFlow:
+ when: { visible: "Claim Your Web3 Handle" }
+ commands: [{ tapOn: "Not Now" }]
+
+# --- navigate to the Buy (Buy Crypto) scene ---
+- tapOn: "Buy"
+- extendedWaitUntil:
+ visible: "Amount USD"
+ timeout: 15000
+
+# --- enter the $500 fiat amount ---
+# NOTE: do NOT call `hideKeyboard` here — on this debug build it forces a re-layout
+# that reliably triggers the RN Fabric text-measure SIGABRT. The USD + converted-BTC
+# fields and the exchange-rate line are all above the keyboard, so leave it up.
+- tapOn: "Amount USD"
+- inputText: ${BUY_AMOUNT}
+
+# --- let the live quote resolve, then capture ---
+# IMPORTANT: do NOT use extendedWaitUntil / assertVisible here. Those traverse the
+# accessibility hierarchy on a poll loop, which forces RN text re-measurement and
+# reliably triggers the Fabric text-cache SIGABRT on this debug build. waitForAnimationToEnd
+# only diffs screenshots (no hierarchy traversal), so the scene survives long enough for the
+# quote ("Amount BTC" value + "1 BTC = USD") to render before we grab the screenshot.
+- waitForAnimationToEnd:
+ timeout: 8000
+- takeScreenshot: ${SCREENSHOT_PATH}
+# NOTE: this single in-flow screenshot is best-effort — it races the intermittent
+# crash. For a GUARANTEED resolved-quote capture, run capture-buy-quote.sh, which
+# drives buy-quote-input.yaml and grabs the screenshot via an external simctl burst.
diff --git a/.cursor/skills/build-and-test/scripts/capture-buy-quote.sh b/.cursor/skills/build-and-test/scripts/capture-buy-quote.sh
new file mode 100755
index 0000000..e3de2ea
--- /dev/null
+++ b/.cursor/skills/build-and-test/scripts/capture-buy-quote.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# capture-buy-quote.sh — Reliably capture the Edge iOS "Buy quote" proof screenshot.
+#
+# Why a wrapper instead of a plain in-flow maestro `takeScreenshot`?
+#
+# The Buy (Ramp) scene in this debug build has an INTERMITTENT React Native
+# Fabric text-measure crash (RCTTextLayoutManager / folly::EvictingCacheMap
+# SIGABRT). Two things make a single in-flow screenshot unreliable:
+# 1. maestro's assertVisible / extendedWaitUntil traverse the accessibility
+# hierarchy on a poll loop, which forces text re-measurement and
+# *provokes* the crash.
+# 2. The quote takes ~6s to resolve, but the crash can fire any time on the
+# scene, so a fixed-delay single shot is either too early (still loading)
+# or too late (already crashed → springboard).
+#
+# This wrapper drives the interaction with maestro (the input flow), which does
+# no polling after entering the amount, then captures with an EXTERNAL simctl
+# screenshot burst (pixel-only, no hierarchy traversal), keeping the LAST frame
+# taken while the app was still alive — i.e. the resolved quote, just before any
+# crash. Retries the whole cycle until it lands a frame from late enough to
+# show the quote.
+#
+# Usage:
+# capture-buy-quote.sh [--out ] [--flow ] \
+# [--bundle-id ] [--quote-secs N] [--window-secs N] [--cycles N]
+#
+# Defaults:
+# --out /tmp/agent-mvp-buy-quote-screenshot.png
+# --flow /../maestro/buy-quote-input.yaml
+# --bundle-id co.edgesecure.app
+# --quote-secs 7 (require a live frame from at least this late post-input)
+# --window-secs 14 (stop bursting after this long; app survived → static frame)
+# --cycles 5 (retry the whole login→Buy→input cycle this many times)
+#
+# Exit codes:
+# 0 = captured a post-quote-resolution frame
+# 1 = exhausted retries without capturing a usable frame
+
+set -euo pipefail
+
+export PATH="$HOME/.maestro/bin:$PATH"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUT="/tmp/agent-mvp-buy-quote-screenshot.png"
+FLOW="$SCRIPT_DIR/../maestro/buy-quote-input.yaml"
+BUNDLE_ID="co.edgesecure.app"
+QUOTE_SECS=7
+WINDOW_SECS=14
+CYCLES=5
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --out) OUT="$2"; shift 2 ;;
+ --flow) FLOW="$2"; shift 2 ;;
+ --bundle-id) BUNDLE_ID="$2"; shift 2 ;;
+ --quote-secs) QUOTE_SECS="$2"; shift 2 ;;
+ --window-secs) WINDOW_SECS="$2"; shift 2 ;;
+ --cycles) CYCLES="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+command -v maestro >/dev/null 2>&1 || { echo "maestro not found in PATH" >&2; exit 1; }
+command -v xcrun >/dev/null 2>&1 || { echo "xcrun not found (need Xcode CLT)" >&2; exit 1; }
+[[ -f "$FLOW" ]] || { echo "Maestro flow not found: $FLOW" >&2; exit 1; }
+
+TMP="$(mktemp -d /tmp/buyquote-cap.XXXXXX)"
+trap 'rm -rf "$TMP"' EXIT
+
+alive() { xcrun simctl spawn booted launchctl list 2>/dev/null | grep -qi "${BUNDLE_ID#*.}"; }
+
+for ((cycle = 1; cycle <= CYCLES; cycle++)); do
+ echo "[capture] cycle $cycle/$CYCLES: maestro $FLOW ..."
+ maestro test "$FLOW" >"$TMP/maestro.log" 2>&1 || true
+ best=""; best_t=0; SECONDS=0
+ while [[ "$SECONDS" -lt "$WINDOW_SECS" ]]; do
+ alive || break
+ if xcrun simctl io booted screenshot "$TMP/cap-${SECONDS}-$RANDOM.png" >/dev/null 2>&1; then
+ best="$(ls -t "$TMP"/cap-*.png 2>/dev/null | head -1)"; best_t=$SECONDS
+ fi
+ done
+ echo "[capture] last live frame at t=${best_t}s"
+ if [[ -n "$best" && "$best_t" -ge "$QUOTE_SECS" ]]; then
+ cp "$best" "$OUT"
+ echo "[capture] PASS — $OUT (live frame at t=${best_t}s; quote resolved before crash)"
+ exit 0
+ fi
+ echo "[capture] crashed before the quote resolved (last frame t=${best_t}s); retrying ..."
+done
+
+echo "[capture] FAIL after $CYCLES cycles — last maestro output:"
+tail -30 "$TMP/maestro.log" >&2
+exit 1
diff --git a/.cursor/skills/build-and-test/scripts/ios-rn-build.sh b/.cursor/skills/build-and-test/scripts/ios-rn-build.sh
new file mode 100755
index 0000000..a4df6d1
--- /dev/null
+++ b/.cursor/skills/build-and-test/scripts/ios-rn-build.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# ios-rn-build.sh — Build + install + launch a React-Native iOS app on a sim.
+#
+# Detects whether the app is already installed and skips the full RN build path
+# (which can take 30-60 min on cold cache). Pass --force-rebuild to always
+# rebuild from scratch.
+#
+# Usage:
+# ios-rn-build.sh --udid --bundle-id [--port ] [--force-rebuild]
+#
+# Env fallbacks (used when the flag is NOT passed): watcher-spawned sessions get
+# these exported automatically, so the build targets the slot's sim + Metro port
+# without any extra plumbing:
+# --udid ← $AGENT_SIM_UDID
+# --port ← $AGENT_METRO_PORT (else 8081)
+# When the resolved port differs from 8081, it is passed to `react-native run-ios`
+# so the app connects to this slot's Metro instance, not the default one.
+#
+# Assumes npm (not yarn). edge-react-gui migrated to npm; this script does NOT
+# fall back to yarn. If a future repo uses yarn, add a detection branch or use
+# the top-level install-deps.sh which auto-detects.
+#
+# Exit codes:
+# 0 = installed/launched successfully
+# 1 = build, install, or launch failed
+# 2 = simulator not booted (run select-ios-sim.sh --boot first)
+
+set -euo pipefail
+
+UDID=""
+BUNDLE_ID=""
+PORT=""
+FORCE=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --udid) UDID="$2"; shift 2 ;;
+ --bundle-id) BUNDLE_ID="$2"; shift 2 ;;
+ --port) PORT="$2"; shift 2 ;;
+ --force-rebuild) FORCE=true; shift ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+# Fall back to the watcher-provided env when flags are omitted.
+UDID="${UDID:-${AGENT_SIM_UDID:-}}"
+PORT="${PORT:-${AGENT_METRO_PORT:-8081}}"
+
+[[ -n "$UDID" && -n "$BUNDLE_ID" ]] || {
+ echo "Usage: ios-rn-build.sh --udid --bundle-id [--port ] [--force-rebuild]" >&2
+ echo " (--udid may instead come from \$AGENT_SIM_UDID)" >&2
+ exit 1
+}
+
+# Confirm sim is booted
+if ! xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1; then
+ echo ">> ios-rn-build: simulator $UDID is not booted; run select-ios-sim.sh --boot first" >&2
+ exit 2
+fi
+
+# Already installed?
+if ! $FORCE && xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" >/dev/null 2>&1; then
+ echo ">> ios-rn-build: $BUNDLE_ID already installed on $UDID; launching only" >&2
+ xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null
+ echo ">> ios-rn-build: PASS (cached install, launched)"
+ exit 0
+fi
+
+# Full build path
+echo ">> ios-rn-build: npm install" >&2
+npm install --no-audit --no-fund
+
+echo ">> ios-rn-build: npm run prepare" >&2
+npm run prepare
+
+echo ">> ios-rn-build: npm run prepare.ios" >&2
+npm run prepare.ios
+
+RUN_ARGS=(--udid "$UDID")
+if [[ "$PORT" != "8081" ]]; then
+ RUN_ARGS+=(--port "$PORT")
+ echo ">> ios-rn-build: using non-default Metro port $PORT" >&2
+fi
+echo ">> ios-rn-build: npx react-native run-ios ${RUN_ARGS[*]} (cold build: 30-60 min)" >&2
+npx react-native run-ios "${RUN_ARGS[@]}"
+
+echo ">> ios-rn-build: PASS (fresh build, installed, launched)"
diff --git a/.cursor/skills/build-and-test/scripts/select-ios-sim.sh b/.cursor/skills/build-and-test/scripts/select-ios-sim.sh
new file mode 100755
index 0000000..7c81ea6
--- /dev/null
+++ b/.cursor/skills/build-and-test/scripts/select-ios-sim.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+# select-ios-sim.sh — Resolve an iOS simulator UDID by runtime + device name.
+#
+# Usage:
+# select-ios-sim.sh --runtime --device [--boot]
+# select-ios-sim.sh --accept-udid [--boot]
+#
+# --runtime: matches the runtime header in `xcrun simctl list devices`.
+# Examples: "iOS 18", "iOS 18.6", "iOS 26".
+# Use "iOS 18" (broad) when you want any 18.x device that matches the device name.
+# --device: exact device name as it appears in the list (e.g. "iPhone 16 Pro Max").
+# --accept-udid: caller already has a UDID (e.g. a per-slot sim clone) — skip
+# runtime/device resolution entirely, just confirm the UDID exists
+# (and boots, with --boot) and echo it back. Mutually exclusive with
+# --runtime/--device. Watcher-spawned sessions pass $AGENT_SIM_UDID here.
+# --boot: boot the resolved sim and open Simulator.app.
+#
+# Prints the UDID on stdout, status messages on stderr.
+#
+# Exit codes:
+# 0 = success (UDID printed on stdout)
+# 1 = error (no match, simctl failed)
+# 2 = ambiguous (multiple matches; caller must pass a tighter --runtime/--device)
+
+set -euo pipefail
+
+RUNTIME=""
+DEVICE=""
+ACCEPT_UDID=""
+BOOT=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --runtime) RUNTIME="$2"; shift 2 ;;
+ --device) DEVICE="$2"; shift 2 ;;
+ --accept-udid) ACCEPT_UDID="$2"; shift 2 ;;
+ --boot) BOOT=true; shift ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+# --accept-udid short-circuit: trust a caller-supplied UDID, just verify + (boot).
+if [[ -n "$ACCEPT_UDID" ]]; then
+ if ! xcrun simctl list devices 2>/dev/null | grep -q "$ACCEPT_UDID"; then
+ echo "select-ios-sim: --accept-udid $ACCEPT_UDID not found in simctl device list" >&2
+ exit 1
+ fi
+ echo ">> select-ios-sim: accepting caller UDID $ACCEPT_UDID" >&2
+ if $BOOT; then
+ xcrun simctl boot "$ACCEPT_UDID" 2>/dev/null || true # no-op if already booted
+ open -a Simulator
+ echo ">> select-ios-sim: booted + opened Simulator.app" >&2
+ fi
+ echo "$ACCEPT_UDID"
+ exit 0
+fi
+
+[[ -n "$RUNTIME" && -n "$DEVICE" ]] || {
+ echo "Usage: select-ios-sim.sh --runtime --device [--boot]" >&2
+ echo " or: select-ios-sim.sh --accept-udid [--boot]" >&2
+ exit 1
+}
+
+# xcrun simctl list devices groups by runtime: "-- iOS 18.6 --" ... "-- iOS 26.3 --"
+# Slice the block for the requested runtime, grep the device, extract UDIDs.
+UDIDS=$(xcrun simctl list devices 2>/dev/null \
+ | sed -n "/^-- $RUNTIME/,/^-- /p" \
+ | grep -F "$DEVICE" \
+ | grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' || true)
+
+count=$(echo "$UDIDS" | grep -c . || true)
+
+if [[ "$count" -eq 0 ]]; then
+ echo "No simulator matching runtime=$RUNTIME device=$DEVICE" >&2
+ echo "(hint: 'xcrun simctl list devices' to see what's available)" >&2
+ exit 1
+elif [[ "$count" -gt 1 ]]; then
+ echo "Multiple matches for runtime=$RUNTIME device=$DEVICE:" >&2
+ echo "$UDIDS" | sed 's/^/ /' >&2
+ echo "(narrow --runtime — e.g. 'iOS 18.6' instead of 'iOS 18')" >&2
+ exit 2
+fi
+
+UDID="$UDIDS"
+echo ">> select-ios-sim: $DEVICE / $RUNTIME → $UDID" >&2
+
+if $BOOT; then
+ xcrun simctl boot "$UDID" 2>/dev/null || true # no-op if already booted
+ open -a Simulator
+ echo ">> select-ios-sim: booted + opened Simulator.app" >&2
+fi
+
+echo "$UDID"
diff --git a/.cursor/skills/changelog/SKILL.md b/.cursor/skills/changelog/SKILL.md
new file mode 100644
index 0000000..9a48ec3
--- /dev/null
+++ b/.cursor/skills/changelog/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: changelog
+description: Update CHANGELOG.md(s) with new entries describing changes made in the repo(s). Use when the user wants to update changelogs.
+metadata:
+ author: j0ntz
+---
+
+# changelog
+
+Update the CHANGELOG.md(s) with at most a few new entries describing the changes made in the repo(s). Documented changes should ONLY describe the final state of all the current changes, not the journey, and follow the existing patterns (being sure to parse only a hundred lines to minimize context) for length and formatting, including no word wrapping.
\ No newline at end of file
diff --git a/.cursor/skills/chat-audit/SKILL.md b/.cursor/skills/chat-audit/SKILL.md
new file mode 100644
index 0000000..89411e3
--- /dev/null
+++ b/.cursor/skills/chat-audit/SKILL.md
@@ -0,0 +1,102 @@
+---
+name: chat-audit
+description: Analyze a Cursor chat export to identify inefficiencies, rule violations, and wasted tool calls. Use when the user wants to audit a chat session.
+compatibility: Requires node.
+metadata:
+ author: j0ntz
+---
+
+Analyze current chat or provided Cursor chat export to identify inefficiencies, rule violations, and wasted tool calls against the invoked command's workflow.
+
+
+Use `scripts/cursor-chat-extract.js` to parse the export. Do NOT parse the raw JSON inline — it is deeply nested and will consume excessive context.
+Default to `--tools-only` mode. Only omit the flag if the user asks for full assistant message analysis.
+Do NOT read the export JSON file directly. All data comes from the script output.
+Keep the final report under 50 lines. Use a numbered list for findings, not verbose paragraphs.
+
+
+
+If no chat export file is provided, assume the user is asking for a chat audit of the current chat session.
+
+If chat export file is provided, run the companion script on the user-provided export file:
+
+```bash
+scripts/cursor-chat-extract.js --tools-only
+```
+
+Parse the JSON output. Note the `invokedCommand`, `stats`, and `sequence` fields.
+
+If `invokedCommand` is null, check the first user message for a command reference and ask the user which command was intended.
+
+
+
+If `invokedCommand` is identified, read the command file:
+
+```bash
+Read ~/.cursor/skills//SKILL.md
+```
+
+Extract the command's:
+- **Rules** (the `` tags)
+- **Steps** (the `` tags — just names and key instructions, not full content)
+- **Companion scripts** referenced (filenames only)
+
+
+
+Walk through the `sequence` array and check each tool call against the command's prescribed workflow:
+
+
+For each rule in the command, check if the tool sequence violates it:
+- `commit-script`: Did the agent use raw `git add` + `git commit` instead of `lint-commit.sh`?
+- `use-companion-script`: Did the agent call `gh`, `curl`, or API tools directly instead of the prescribed script?
+- `no-script-bypass`: Did the agent fall back to raw tools after a script error?
+- Cross-reference rules: Did the agent read files referenced with "Read ... now (do NOT skip)"?
+
+
+
+Flag calls that consumed context without contributing to the workflow:
+- **Errors followed by retries** — the error was avoidable (e.g., reading a directory as a file)
+- **Redundant reads** — same information gathered multiple times (e.g., `git status` called twice)
+- **Unnecessary exploration** — reading code files when the user said the change was already done
+- **Sleep-based polling** — `sleep N && tail` instead of using `block_until_ms`
+- **Sequential calls that could be parallel** — independent operations run one at a time
+
+
+
+For each step in the command, check if the tool sequence includes the corresponding action:
+- Missing verification step
+- Missing CHANGELOG entry
+- Missing Asana linking
+- Skipped cross-file reads (e.g., never read `im.md` when step 3 requires it)
+
+
+
+
+Output a structured report:
+
+```
+## Chat Audit: /
+
+**Stats:** N tool calls (M errors, K cancelled) across L user messages
+
+### Rule Violations
+1. [rule-id] Description of what happened
+
+### Wasted Tool Calls
+1. [#N] tool_name — why it was wasteful
+
+### Skipped Steps
+1. [step N] What was skipped
+
+### Recommendations
+1. Specific change to the command file that would prevent this
+```
+
+If the user hasn't asked for command file changes, stop here. If they ask, apply the recommendations using the `/author` skill.
+
+
+
+Ask the user which command was being executed, or analyze without a reference command (just flag errors and wasted calls).
+The conversation may span multiple turns. The first user message typically invokes the command; subsequent ones are follow-ups. Analyze the full sequence but weight findings toward the initial command execution.
+If no `/command` was invoked, still analyze for general inefficiencies (redundant reads, errors, unnecessary exploration) but skip the rule/step compliance checks.
+
diff --git a/.cursor/skills/chat-audit/scripts/cursor-chat-extract.js b/.cursor/skills/chat-audit/scripts/cursor-chat-extract.js
new file mode 100755
index 0000000..6908d20
--- /dev/null
+++ b/.cursor/skills/chat-audit/scripts/cursor-chat-extract.js
@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+// cursor-chat-extract.js — Extract structured conversation data from Cursor chat export JSON.
+// Usage: ./cursor-chat-extract.js [--tools-only]
+// Output: Compact JSON summary of messages and tool calls for agent analysis.
+
+const fs = require("fs");
+const path = require("path");
+
+const file = process.argv[2];
+const toolsOnly = process.argv.includes("--tools-only");
+
+if (!file) {
+ console.error("Usage: cursor-chat-extract.js [--tools-only]");
+ process.exit(1);
+}
+
+let data;
+try {
+ data = JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
+} catch (e) {
+ console.error(`Failed to parse ${file}: ${e.message}`);
+ process.exit(1);
+}
+
+const composerId = Object.keys(data.bubbles || {})[0];
+if (!composerId) {
+ console.error("No conversation found in export.");
+ process.exit(1);
+}
+
+const entries = data.bubbles[composerId] || [];
+
+function extractText(val) {
+ if (val.text && typeof val.text === "string") return val.text;
+ if (!val.richText) return "";
+ try {
+ const rt = JSON.parse(val.richText);
+ return walkLexical(rt.root);
+ } catch {
+ return "";
+ }
+}
+
+function walkLexical(node) {
+ let out = "";
+ if (node.text) out += node.text;
+ if (node.children) for (const c of node.children) out += walkLexical(c);
+ return out;
+}
+
+function parseToolData(raw) {
+ if (!raw) return null;
+ const d = typeof raw === "string" ? JSON.parse(raw) : raw;
+ if (!d.name) return null;
+
+ const result = { name: d.name, status: d.status || "unknown" };
+
+ try {
+ const params = JSON.parse(d.params || "{}");
+ if (params.command) {
+ result.arg = params.command.length > 150
+ ? params.command.substring(0, 150) + "..."
+ : params.command;
+ } else if (params.targetFile) {
+ result.arg = params.targetFile;
+ } else if (params.globPattern) {
+ result.arg = `glob: ${params.globPattern}`;
+ } else if (params.pattern) {
+ result.arg = `pattern: ${params.pattern}`;
+ } else if (params.query) {
+ result.arg = `query: ${params.query.substring(0, 100)}`;
+ }
+ } catch {
+ // Ignore parse failures
+ }
+
+ return result;
+}
+
+function truncate(text, max) {
+ if (!text || text.length <= max) return text;
+ return text.substring(0, max) + "...";
+}
+
+const messages = [];
+let totalTools = 0;
+let errors = 0;
+let cancellations = 0;
+
+for (const entry of entries) {
+ let val;
+ try {
+ val = JSON.parse(entry.value);
+ } catch {
+ continue;
+ }
+
+ const type = val.type === 1 ? "user" : "assistant";
+ const text = extractText(val);
+
+ const tool = parseToolData(val.toolFormerData);
+ if (tool) {
+ totalTools++;
+ if (tool.status === "error") errors++;
+ if (tool.status === "cancelled") cancellations++;
+ messages.push({ type: "tool", ...tool });
+ continue;
+ }
+
+ if (!text.trim()) continue;
+
+ if (type === "user") {
+ messages.push({ type: "user", text: text.trim() });
+ } else if (!toolsOnly) {
+ messages.push({
+ type: "assistant",
+ text: truncate(text.trim(), 200),
+ });
+ }
+}
+
+// Detect invoked command from first user message
+let invokedCommand = null;
+const firstUser = messages.find((m) => m.type === "user");
+if (firstUser) {
+ const match = firstUser.text.match(/^\/([\w-]+)/);
+ if (match) invokedCommand = match[1];
+}
+
+const output = {
+ invokedCommand,
+ stats: {
+ messages: messages.filter((m) => m.type === "user").length,
+ assistantTurns: messages.filter((m) => m.type === "assistant").length,
+ toolCalls: totalTools,
+ errors,
+ cancellations,
+ },
+ sequence: messages,
+};
+
+console.log(JSON.stringify(output, null, 2));
diff --git a/.cursor/skills/cheese/SKILL.md b/.cursor/skills/cheese/SKILL.md
new file mode 100644
index 0000000..a1fcdf2
--- /dev/null
+++ b/.cursor/skills/cheese/SKILL.md
@@ -0,0 +1,83 @@
+---
+name: cheese
+description: Push a "cheese build" — hard-reset a test-* branch to the current edge-react-gui feature branch and force-push to trigger a Jenkins test build. Optionally pins unreleased dep repos (accb, exch, core, etc.) as prebuilt tarballs when the GUI work depends on unmerged dep changes. Use when the user asks for a "cheese build", "test build", or names a branch like test-feta / test-.
+compatibility: Requires jq, yarn. Must be run from within an edge-react-gui checkout.
+metadata:
+ author: j0ntz
+---
+
+Produce a cheese build by hard-resetting a test-* branch to a source ref and force-pushing it, optionally pinning unreleased dep repos as prebuilt tarballs so the build server can install without running each dep's prepare script.
+
+
+Target branch MUST match `test-*`. For any other branch name, stop and ask the user to confirm it is scratch space safe to force-push.
+Require a clean working tree in edge-react-gui (no staged, unstaged, or untracked files) before starting. Do NOT auto-stash — tell the user to commit or stash first.
+When pinning an unreleased dep, use a prebuilt tarball (`npm pack` or `yarn pack`, auto-detected from the dep repo's lockfile), never a git URL. Git URLs make the build server run the dep's `prepare` script, which fails on native toolchain deps (bs-platform needs python; ed25519 fails to build against current Node v8 ABI).
+Run the full workflow via `~/.cursor/skills/cheese/scripts/cheese-build.sh`. Do not inline git / pack / package-manager operations in chat.
+The script pushes with `--force-with-lease` via `~/.cursor/skills/git-branch-ops.sh`. Never use plain `--force`.
+
+
+
+
+| Alias | Repo |
+|---|---|
+| accb | edge-currency-accountbased |
+| exch | edge-exchange-plugins |
+| core | edge-core-js |
+| monero | edge-currency-monero |
+| plugins | edge-currency-plugins |
+| login-ui | edge-login-ui-rn |
+| info | edge-info-server |
+
+
+
+
+From the user message, determine:
+
+1. **Cheese branch** — default `test-feta`. Use the user's explicit name if given (e.g. `test-gouda`).
+2. **Source ref** — default: current HEAD of `edge-react-gui`. Use an explicit ref if the user names one.
+3. **Deps to pin** — from any aliases or paths the user mentions. None is valid (GUI-only cheese build).
+
+Resolve each alias to `$HOME/git/`. If an alias doesn't map, ask the user for the absolute path.
+
+
+
+Show the user a one-block summary:
+
+```
+Cheese branch: test-
+From: ()
+Deps to pin: (none) | , , ...
+```
+
+Proceed directly unless any of:
+- Cheese branch doesn't match `test-*` → confirm
+- Pinning ≥ 3 deps → confirm
+- User input was ambiguous → ask
+
+Otherwise go straight to step 3.
+
+
+
+Invoke with resolved absolute paths:
+
+```bash
+~/.cursor/skills/cheese/scripts/cheese-build.sh \
+ --branch \
+ --from \
+ [--pin ]...
+```
+
+The script handles: clean-tree check, checkout + hard reset, per-dep `install + prepare + pack` via `~/.cursor/skills/pm.sh` (auto-detects npm vs yarn from each repo's lockfile), tarball copy + `package.json` rewrite, GUI `install` to refresh the active lockfile, `lint-commit.sh` for the pin commit, and `git-branch-ops.sh push --force-with-lease`.
+
+
+
+Print the remote branch URL and final SHA from the script output. Jenkins picks up the push automatically — no further action needed.
+
+
+
+Ask the user which feature branch to reset against; cheese branches can't self-reset.
+If a pin target is on its default branch, the published version is enough. Warn; proceed only if the user confirms.
+The script verifies each tarball contains `package/lib/` before committing. If missing, the script aborts — run `~/.cursor/skills/pm.sh run prepare` manually in the dep repo and retry.
+Script exits with code 2 and tells the user to commit or stash first. Never auto-stash — their WIP is their responsibility.
+Script exits if the dep's npm name isn't in `edge-react-gui/package.json` under `dependencies`. Common cause: dep renamed or devDependency — resolve manually.
+
diff --git a/.cursor/skills/cheese/scripts/cheese-build.sh b/.cursor/skills/cheese/scripts/cheese-build.sh
new file mode 100755
index 0000000..ba5232c
--- /dev/null
+++ b/.cursor/skills/cheese/scripts/cheese-build.sh
@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+# cheese-build.sh
+# Hard-reset a test-* branch to a source ref and force-push to trigger a
+# Jenkins test build. Optionally pin unreleased dep repos as prebuilt
+# tarballs so the build server doesn't run each dep's prepare script.
+#
+# Usage:
+# cheese-build.sh --branch [--from ] [--pin ]...
+#
+# Options:
+# --branch Target cheese branch (e.g. test-feta). Required.
+# --from Source ref to reset to. Default: current HEAD.
+# --pin PATH Absolute path to a dep repo checkout. Repeatable.
+# Runs install + prepare + pack in the dep (npm or yarn,
+# auto-detected via ~/.cursor/skills/pm.sh), copies the
+# resulting tarball into the GUI root with a timestamp
+# suffix, and rewrites package.json to point at it.
+#
+# Must be run from inside an edge-react-gui checkout with a clean tree.
+#
+# Exit codes:
+# 0 success
+# 1 runtime error
+# 2 invalid input / precondition not met
+
+set -euo pipefail
+
+BRANCH=""
+FROM=""
+PINS=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --branch) BRANCH="$2"; shift 2 ;;
+ --from) FROM="$2"; shift 2 ;;
+ --pin) PINS+=("$2"); shift 2 ;;
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$BRANCH" ]] || { echo "--branch required" >&2; exit 2; }
+
+# Must be inside edge-react-gui checkout
+GUI_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
+ echo "not in a git repo" >&2; exit 2;
+}
+GUI_NAME="$(jq -r '.name // empty' "$GUI_ROOT/package.json" 2>/dev/null)"
+[[ "$GUI_NAME" == "edge-react-gui" ]] || {
+ echo "must run from within edge-react-gui (found: $GUI_NAME)" >&2; exit 2;
+}
+cd "$GUI_ROOT"
+
+# Require clean working tree — cheese builds can't stash safely
+if ! git diff --quiet || ! git diff --cached --quiet; then
+ echo "working tree has uncommitted changes — commit or stash first" >&2
+ exit 2
+fi
+if [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
+ echo "working tree has untracked files — clean or add to .gitignore first" >&2
+ exit 2
+fi
+
+# Resolve source ref (default current HEAD)
+if [[ -z "$FROM" ]]; then
+ FROM="$(git rev-parse --abbrev-ref HEAD)"
+fi
+if [[ "$FROM" == "$BRANCH" ]]; then
+ echo "--from must differ from --branch ($BRANCH)" >&2; exit 2
+fi
+FROM_SHA="$(git rev-parse "$FROM")" || {
+ echo "cannot resolve ref: $FROM" >&2; exit 2;
+}
+
+echo ">> cheese build: reset $BRANCH -> $FROM ($(git rev-parse --short "$FROM_SHA"))"
+
+# Checkout cheese branch (create if needed)
+git fetch origin "$BRANCH" --quiet 2>/dev/null || true
+if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
+ git checkout "$BRANCH" >/dev/null
+elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
+ git checkout -b "$BRANCH" "origin/$BRANCH" >/dev/null
+else
+ echo ">> creating new local branch: $BRANCH"
+ git checkout -b "$BRANCH" >/dev/null
+fi
+
+git reset --hard "$FROM_SHA" >/dev/null
+echo ">> reset complete"
+
+# --- Pin deps (optional) ---
+TARBALL_FILES=()
+DEP_REFS=()
+
+if [[ ${#PINS[@]} -gt 0 ]]; then
+ STAMP="$(date +%Y%m%dT%H%M)"
+
+ for DEP_ROOT in "${PINS[@]}"; do
+ [[ -d "$DEP_ROOT" ]] || { echo "dep repo not found: $DEP_ROOT" >&2; exit 2; }
+ [[ -f "$DEP_ROOT/package.json" ]] || {
+ echo "no package.json in $DEP_ROOT" >&2; exit 2;
+ }
+
+ DEP_NAME="$(jq -r .name "$DEP_ROOT/package.json")"
+ DEP_VERSION="$(jq -r .version "$DEP_ROOT/package.json")"
+ DEP_SHA="$(git -C "$DEP_ROOT" rev-parse HEAD)"
+ DEP_REFS+=("$DEP_NAME @ $DEP_SHA")
+
+ echo ">> packing $DEP_NAME@$DEP_VERSION ($DEP_SHA)"
+
+ # Build lib/ fresh before packing
+ if [[ -x "$HOME/.cursor/skills/install-deps.sh" ]]; then
+ (cd "$DEP_ROOT" && "$HOME/.cursor/skills/install-deps.sh")
+ else
+ (cd "$DEP_ROOT" && "$HOME/.cursor/skills/pm.sh" install && "$HOME/.cursor/skills/pm.sh" run prepare)
+ fi
+
+ # Pack using whichever package manager the dep repo uses. pm.sh prints
+ # the resulting tarball filename on stdout (npm and yarn use different
+ # naming conventions; pm.sh normalizes the capture).
+ PACK_NAME="$(cd "$DEP_ROOT" && "$HOME/.cursor/skills/pm.sh" pack)"
+ SRC_TGZ="$DEP_ROOT/$PACK_NAME"
+ [[ -f "$SRC_TGZ" ]] || {
+ echo "pack did not produce $SRC_TGZ" >&2; exit 1;
+ }
+
+ # Verify tarball contains lib/ — build server will fail without it
+ if ! tar -tzf "$SRC_TGZ" | grep -q '^package/lib/'; then
+ echo "tarball missing package/lib/ — run 'prepare' in $DEP_ROOT and retry" >&2
+ rm -f "$SRC_TGZ"
+ exit 1
+ fi
+
+ DST_NAME="${DEP_NAME}-${DEP_VERSION}-${STAMP}.tgz"
+ cp "$SRC_TGZ" "$GUI_ROOT/$DST_NAME"
+ rm -f "$SRC_TGZ"
+ TARBALL_FILES+=("$DST_NAME")
+
+ # Rewrite package.json (preserve formatting approximately)
+ node -e '
+ const fs = require("fs");
+ const path = process.argv[1];
+ const name = process.argv[2];
+ const target = "./" + process.argv[3];
+ const raw = fs.readFileSync(path, "utf8");
+ const pkg = JSON.parse(raw);
+ const deps = pkg.dependencies || {};
+ if (!(name in deps)) {
+ console.error("not in gui dependencies: " + name);
+ process.exit(1);
+ }
+ deps[name] = target;
+ const trailing = raw.endsWith("\n") ? "\n" : "";
+ fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + trailing);
+ ' "$GUI_ROOT/package.json" "$DEP_NAME" "$DST_NAME"
+
+ echo ">> pinned $DEP_NAME -> ./$DST_NAME"
+ done
+
+ # Refresh the GUI's lockfile via whichever PM it uses.
+ GUI_LOCKFILE="$("$HOME/.cursor/skills/pm.sh" lockfile)"
+ echo ">> install (refresh $GUI_LOCKFILE)"
+ "$HOME/.cursor/skills/pm.sh" install
+
+ # Commit via lint-commit.sh (handles --no-verify, staging, etc.)
+ MSG_BODY="$(
+ echo "Pin dependencies for cheese build"
+ echo ""
+ for ref in "${DEP_REFS[@]}"; do echo "- $ref"; done
+ )"
+
+ "$HOME/.cursor/skills/lint-commit.sh" -m "$MSG_BODY" \
+ package.json \
+ "$GUI_LOCKFILE" \
+ "${TARBALL_FILES[@]}"
+fi
+
+# --- Push ---
+echo ">> force-push-with-lease -> origin/$BRANCH"
+"$HOME/.cursor/skills/git-branch-ops.sh" push --force-with-lease --branch "$BRANCH"
+
+FINAL_SHA="$(git rev-parse HEAD)"
+REMOTE_URL="$(
+ git remote get-url origin \
+ | sed -E 's|git@github.com:(.*)\.git$|https://github.com/\1|' \
+ | sed -E 's|\.git$||'
+)"
+echo ">> DONE: ${REMOTE_URL}/tree/${BRANCH} (${FINAL_SHA})"
diff --git a/.cursor/skills/convention-sync/SKILL.md b/.cursor/skills/convention-sync/SKILL.md
new file mode 100644
index 0000000..8f0085e
--- /dev/null
+++ b/.cursor/skills/convention-sync/SKILL.md
@@ -0,0 +1,105 @@
+---
+name: convention-sync
+description: Sync cursor files between ~/.cursor/ and the edge-dev-agents repo, commit, push, and update PR description. Use when the user wants to sync conventions.
+compatibility: Requires git, gh.
+metadata:
+ author: j0ntz
+---
+
+Sync the canonical home setup (`~/.cursor/` skills/rules/scripts + the agent orchestration system + shared Claude memories) into the `edge-dev-agents` repo, commit, push, and update the PR description from the synced repo root README. Also maintains cross-tool compatibility: symlinks `~/.claude/skills` → `~/.cursor/skills` and generates `~/.claude/CLAUDE.md` from always-apply rules. The repo is the distribution copy a second machine bootstraps from.
+
+
+`~/.cursor/` is the canonical source. Edits happen locally; the repo is the distribution copy. Default direction is `user-to-repo`. Use `--repo-to-user` only for onboarding or pulling changes authored by others.
+Beyond `~/.cursor`, the script also mirrors portable "extra trees" into the repo so a second Mac can be reproduced: the orchestration system (`~/.config/agent-watcher` → repo `agent-watcher/`), shared Claude memories (`~/.claude/memory-shared` → repo `memory-shared/`), and the memory link helper (`~/.claude/link-shared-memory.sh` → repo `bin/`). Secrets and machine-local state are EXCLUDED by hardcoded rsync excludes in the script (`credentials.json`, `*.log`, `*.state`, `pool.json`, `slots.json`, `watchdog-state.json`, `oom-repro/forensics`, `oom-repro/logs`); `credentials.example.json` is committed as a fill-in template. These appear in the JSON under `extra`/`extraTotal`. NEVER hand-add secret/state files to the repo. A fresh machine reproduces everything by cloning the repo and running `./bootstrap.sh` (installs the trees into home, seeds credentials from the example, links skills + shared memory). Auto-memory (`~/.claude/projects//memory/`) is machine-local per Anthropic docs and is intentionally NOT synced.
+The script auto-fetches origin and HARD-BLOCKS (exit non-zero) a `--stage`/`--commit` (user-to-repo) on any of: (a) `originAhead > 0` — remote has commits you lack; pull first. (b) Wrong branch — HEAD is the repo default branch, or doesn't match the open sync PR's head branch; this prevents pushing the sync onto `main` and bypassing the PR (override: `--force-branch`). (c) Blocking `warnings` of kind `deletion`, `stale-local`, or `re-adding-deleted` — the sync would delete or revert canonical files, in `~/.cursor` OR the portable extra trees (override: `--force`). Warnings are NO LONGER advisory. When blocked by (c), the right fix is almost always `--repo-to-user --stage` to de-stale this machine first, THEN re-run user-to-repo to push. The dry-run summary computes all of these by content hash (not mtime), so timestamp churn no longer inflates the diff. Always surface `warnings` in the summary.
+Use `~/.cursor/skills/convention-sync/scripts/convention-sync.sh` for diffing and syncing. Do NOT manually diff or copy files.
+Always run without `--stage` first to show the summary. Only stage/commit after user confirms.
+If the script fails, report the error and STOP.
+`~/.cursor/README.md` is the canonical local documentation source. The sync script mirrors it to `/README.md`, and PR descriptions must be updated from that synced repo root README.
+Every run ensures `~/.claude/skills` symlinks to `~/.cursor/skills` and regenerates `~/.claude/CLAUDE.md` from `alwaysApply: true` rules. This enables OpenCode and Claude Code to discover skills and rules without separate config.
+For user-to-repo sync, target the `edge-dev-agents` checkout. Do NOT assume the current repo is correct just because it contains a `.cursor/` folder. Let the companion script resolve and validate the repo path.
+
+
+
+Use the companion script's default repo resolution first. It targets the `edge-dev-agents` checkout and fails if the resolved or provided repo is not actually `edge-dev-agents`.
+
+Run the sync script in dry-run mode:
+
+```bash
+~/.cursor/skills/convention-sync/scripts/convention-sync.sh
+```
+
+Parse the JSON output and extract `repoDir`. Then check for an open PR:
+
+```bash
+cd && gh pr view --json number,url --jq '{number: .number, url: .url}' 2>/dev/null || echo '{}'
+```
+
+Use the resolved repo path from the script for subsequent git and PR commands. If BOTH `total` and `extraTotal` are 0, report "Everything is in sync" and stop.
+
+
+
+Show the user a concise summary including PR update status, origin lag, and any cross-machine warnings:
+
+```
+Sync summary (user → repo):
+ New: file1, file2
+ Modified: file3, file4
+ Deleted: file5
+ Ignored: file6, file7 (via .syncignore)
+ Extra (orch + memories): agent-watcher/…, memory-shared/…, bin/… (from `extra`; only if extraTotal > 0)
+
+⚠️ origin/ is N commit(s) ahead — pull before staging. (only if originAhead > 0)
+⚠️ Possible overwrites of upstream work: (only if warnings array non-empty)
+ - file3 (stale-local) — last upstream commit:
+ - file8 (deletion) — last upstream commit:
+
+PR #N: Will update description from repo `README.md` (or "No open PR")
+
+Commit and push? [y/N]
+```
+
+If `ignored` is empty, omit the Ignored line. If `originAhead` is 0, omit that warning. If `warnings` is empty, omit that block.
+
+**Warning kinds:**
+- `stale-local`: a modified file's most-recent upstream commit timestamp is newer than the local file's mtime — your local was likely written from an older copy.
+- `deletion`: you'd be deleting a path that exists in the repo. Always confirm.
+- `re-adding-deleted`: a "new" file locally that was deleted upstream after your local was last written.
+
+If `originAhead > 0`, advise the user to `cd && git pull --rebase` before re-running. Do NOT proceed to step 3 — the script will refuse to stage anyway.
+
+If the user provided a commit message in their prompt, still surface warnings; only skip the y/N confirmation when there are no warnings.
+
+
+
+Run the script with `--commit`:
+
+```bash
+~/.cursor/skills/convention-sync/scripts/convention-sync.sh --commit -m ""
+```
+
+Then push:
+
+```bash
+cd && git push origin HEAD
+```
+
+If an open PR exists, update the PR description from the synced repo root README:
+
+```bash
+cd && gh pr edit --body-file README.md
+```
+
+
+
+If the user says "pull from repo" or "update my local", run with `--repo-to-user --stage`. This restores BOTH `~/.cursor` AND the portable extra trees (agent-watcher, memory-shared, bin) from the repo, and never deletes home-local state/secret files. No git operations needed. This is also the de-stale step to run before a user-to-repo sync that was blocked by `deletion`/`stale-local` warnings.
+Do not sync into that repo. Fall back to `~/git/edge-dev-agents` or ask for the correct repo path.
+Reuse the `repoDir` value from the script's JSON output for the PR query, commit run, push, and PR edit steps.
+To permanently exclude files, add glob patterns to `.syncignore` (one per line, `#` comments). The script reads `.syncignore` from the REPO (`/.cursor/.syncignore`) as the canonical source so every machine honors the same excludes, falling back to `~/.cursor/.syncignore` only if the repo lacks one. The script skips matching entries and reports them in the `ignored` array. To exclude ad-hoc, remove files from staging with `git reset HEAD .cursor/` before committing.
+During migration, the dry-run may report deletion of `.cursor/README.md` in the repo copy. That is expected: the repo should keep only the root `README.md`.
+If `~/.cursor/README.md` doesn't exist, skip PR description update and warn the user.
+The script auto-fetches and detects this. Surface the count to the user, instruct them to `cd && git pull --rebase`, then re-run convention-sync. Do not attempt --stage/--commit before pulling — the script will exit non-zero.
+The script refuses `--stage`/`--commit` when HEAD is the repo default branch or doesn't match the open sync PR's head branch — this stops a fresh clone (which sits on `main`) from pushing the sync onto `main` and bypassing the PR. Checkout the sync branch (`cd && git checkout `) and re-run. Override with `--force-branch` ONLY if intentionally committing to a different branch.
+The script HARD-BLOCKS staging on these — the sync would delete or revert canonical files because this machine is stale/incomplete (covers `~/.cursor` AND the extra trees). Default action: run `--repo-to-user --stage` to pull the canonical state down first, then re-run user-to-repo to push your genuine additions. Only after the user reviews the specific files and explicitly intends to overwrite upstream should you re-run with `--force`. Never pass `--force` reflexively.
+If `git fetch origin` fails the script proceeds with `originAhead=0`. The cross-machine safety check is best-effort; on a flaky network the user should re-run when connectivity is back if cross-machine sync matters.
+
diff --git a/.cursor/skills/convention-sync/scripts/convention-sync.sh b/.cursor/skills/convention-sync/scripts/convention-sync.sh
new file mode 100755
index 0000000..bad36c7
--- /dev/null
+++ b/.cursor/skills/convention-sync/scripts/convention-sync.sh
@@ -0,0 +1,656 @@
+#!/usr/bin/env bash
+# convention-sync.sh — Sync ~/.cursor/ files with the edge-dev-agents repo.
+# Usage: ./convention-sync.sh [repo-dir] [--stage] [--commit -m "message"] [--repo-to-user]
+# Compares ~/.cursor/{README.md,skills,rules,scripts} against the distribution
+# copy in and outputs a structured JSON summary of new, modified,
+# and deleted files.
+# With --stage: copies changed files and stages them in git (or copies to user dir with --repo-to-user).
+# With --commit: stages + commits (requires -m). Only valid for user-to-repo direction.
+#
+# Sync model: ~/.cursor/ is canonical. Default direction (user-to-repo) copies local
+# files into the repo. --repo-to-user is for onboarding or pulling others' changes.
+# No bidirectional conflict detection — the chosen direction overwrites the other side.
+
+set -euo pipefail
+
+REPO_DIR=""
+DO_STAGE=false
+DO_COMMIT=false
+COMMIT_MSG=""
+DIRECTION="user-to-repo"
+FORCE_WARN=false # --force: override blocking deletion/stale-local warnings
+FORCE_BRANCH=false # --force-branch: override the sync-branch safety check
+
+resolve_default_repo_dir() {
+ local cwd remote_url default_repo
+
+ cwd="$(pwd)"
+ if [[ "$(basename "$cwd")" == "edge-dev-agents" ]]; then
+ printf '%s\n' "$cwd"
+ return 0
+ fi
+
+ if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ remote_url="$(git -C "$cwd" remote get-url origin 2>/dev/null || true)"
+ if [[ "$remote_url" == *"edge-dev-agents"* ]]; then
+ printf '%s\n' "$cwd"
+ return 0
+ fi
+ fi
+
+ default_repo="$HOME/git/edge-dev-agents"
+ if [[ -d "$default_repo/.git" || -f "$default_repo/.git" ]]; then
+ printf '%s\n' "$default_repo"
+ return 0
+ fi
+
+ return 1
+}
+
+validate_repo_dir() {
+ local repo_dir remote_url
+ repo_dir="$1"
+
+ if [[ ! -d "$repo_dir/.cursor" ]]; then
+ echo "ERROR: Repo directory must contain .cursor/: $repo_dir" >&2
+ return 1
+ fi
+
+ if [[ "$(basename "$repo_dir")" == "edge-dev-agents" ]]; then
+ return 0
+ fi
+
+ if git -C "$repo_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ remote_url="$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)"
+ if [[ "$remote_url" == *"edge-dev-agents"* ]]; then
+ return 0
+ fi
+ fi
+
+ echo "ERROR: Repo directory does not appear to be the edge-dev-agents checkout: $repo_dir" >&2
+ return 1
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --stage) DO_STAGE=true; shift ;;
+ --commit) DO_COMMIT=true; DO_STAGE=true; shift ;;
+ -m) COMMIT_MSG="$2"; shift 2 ;;
+ --repo-to-user) DIRECTION="repo-to-user"; shift ;;
+ --force) FORCE_WARN=true; shift ;;
+ --force-branch) FORCE_BRANCH=true; shift ;;
+ *) REPO_DIR="$1"; shift ;;
+ esac
+done
+
+if [[ -z "$REPO_DIR" ]]; then
+ if ! REPO_DIR="$(resolve_default_repo_dir)"; then
+ echo "ERROR: Could not resolve the edge-dev-agents repo. Run with an explicit repo path." >&2
+ echo "Usage: convention-sync.sh [repo-dir] [--stage] [--commit -m \"message\"]" >&2
+ exit 1
+ fi
+fi
+
+if ! validate_repo_dir "$REPO_DIR"; then
+ exit 1
+fi
+
+if [[ "$DO_COMMIT" == true && -z "$COMMIT_MSG" ]]; then
+ echo "ERROR: --commit requires -m \"message\"" >&2
+ exit 1
+fi
+
+USER_DIR="$HOME/.cursor"
+REPO_CURSOR="$REPO_DIR/.cursor"
+DIRS="skills rules scripts"
+# .syncignore is canonical in the repo (#4) so a fresh machine inherits the same
+# excludes; fall back to ~/.cursor only if the repo doesn't carry one.
+if [[ -f "$REPO_CURSOR/.syncignore" ]]; then SYNCIGNORE="$REPO_CURSOR/.syncignore"; else SYNCIGNORE="$USER_DIR/.syncignore"; fi
+USER_README="$USER_DIR/README.md"
+REPO_ROOT_README="$REPO_DIR/README.md"
+LEGACY_REPO_README="$REPO_CURSOR/README.md"
+
+# --- Extra portable trees (beyond ~/.cursor) ----------------------------------
+# Home is canonical; these are mirrored into the repo so a second machine can be
+# bootstrapped from it. Secrets and machine-local state are excluded so only
+# committable code/config is mirrored. Format: "SRC_ABS|REPO_SUBDIR|csv-excludes"
+# Excludes are rsync patterns (matched against the path relative to SRC).
+EXTRA_TREES=(
+ "$HOME/.config/agent-watcher|agent-watcher|credentials.json,*.log,*.state,pool.json,slots.json,watchdog-state.json,oom-repro/forensics,oom-repro/logs,.DS_Store,.git"
+ "$HOME/.claude/memory-shared|memory-shared|.DS_Store,.git"
+)
+# Single committable files (home canonical) → repo relpath. Format: "SRC_FILE|REPO_RELPATH"
+EXTRA_FILES=(
+ "$HOME/.claude/link-shared-memory.sh|bin/link-shared-memory.sh"
+)
+extra_json="[]"
+
+# Pull-before-push gate (user-to-repo only).
+# Fetches origin and detects whether the remote branch has commits we don't.
+# Dry-run includes the count for visibility; --stage/--commit aborts if > 0.
+ORIGIN_AHEAD=0
+ORIGIN_BRANCH=""
+if [[ "$DIRECTION" == "user-to-repo" ]]; then
+ if git -C "$REPO_DIR" fetch origin --quiet 2>/dev/null; then
+ current_branch="$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+ if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then
+ if git -C "$REPO_DIR" rev-parse --verify --quiet "origin/$current_branch" >/dev/null 2>&1; then
+ ORIGIN_AHEAD=$(git -C "$REPO_DIR" rev-list --count "HEAD..origin/$current_branch" 2>/dev/null || echo 0)
+ ORIGIN_BRANCH="origin/$current_branch"
+ fi
+ fi
+ fi
+fi
+
+if [[ "$DO_STAGE" == "true" && "$ORIGIN_AHEAD" -gt 0 ]]; then
+ echo "ERROR: $ORIGIN_BRANCH is $ORIGIN_AHEAD commit(s) ahead of local HEAD." >&2
+ echo "Pull first to integrate remote changes, then re-run convention-sync:" >&2
+ echo " cd $REPO_DIR && git pull --rebase" >&2
+ exit 1
+fi
+
+# Branch safety (#1, user-to-repo + stage). The top hazard is a fresh clone sitting
+# on the default branch, where `git push origin HEAD` would bypass the sync PR and
+# push straight to main. Refuse the default branch; if an open sync PR exists,
+# require its head branch. Override with --force-branch.
+if [[ "$DO_STAGE" == "true" && "$DIRECTION" == "user-to-repo" && "$FORCE_BRANCH" != "true" ]]; then
+ cur_branch="$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+ def_branch="$(git -C "$REPO_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')"
+ [[ -z "$def_branch" ]] && def_branch="main"
+ sync_branch="$(cd "$REPO_DIR" && gh pr list --state open --json headRefName --jq '.[0].headRefName' 2>/dev/null || true)"
+ if [[ "$cur_branch" == "$def_branch" ]]; then
+ echo "ERROR: refusing to sync onto the default branch '$cur_branch'." >&2
+ echo "convention-sync targets a PR branch, not '$def_branch'." >&2
+ [[ -n "$sync_branch" ]] && echo " cd $REPO_DIR && git checkout $sync_branch" >&2
+ echo "(override with --force-branch only if you truly mean to commit to '$def_branch')." >&2
+ exit 1
+ fi
+ if [[ -n "$sync_branch" && "$cur_branch" != "$sync_branch" ]]; then
+ echo "ERROR: on branch '$cur_branch' but the open sync PR targets '$sync_branch'." >&2
+ echo " cd $REPO_DIR && git checkout $sync_branch (or pass --force-branch)" >&2
+ exit 1
+ fi
+fi
+
+# Load ignore patterns from .syncignore (one glob per line, # comments, blank lines skipped)
+ignore_patterns=()
+if [[ -f "$SYNCIGNORE" ]]; then
+ while IFS= read -r line; do
+ line="${line%%#*}" # strip comments
+ line="${line%"${line##*[![:space:]]}"}" # strip trailing whitespace
+ [[ -z "$line" ]] && continue
+ ignore_patterns+=("$line")
+ done < "$SYNCIGNORE"
+fi
+
+is_ignored() {
+ local entry="$1"
+ for pattern in "${ignore_patterns[@]+"${ignore_patterns[@]}"}"; do
+ # shellcheck disable=SC2254
+ if [[ "$entry" == $pattern ]]; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+new_json="[]"
+mod_json="[]"
+del_json="[]"
+ignored_json="[]"
+warnings_json="[]"
+
+repo_path_for() {
+ # Translate a sync entry (e.g. "skills/foo.sh" or "README.md") into the
+ # path used inside the repo so git log can look up history.
+ local entry="$1"
+ if [[ "$entry" == "README.md" ]]; then
+ printf '%s\n' "README.md"
+ else
+ printf '%s\n' ".cursor/$entry"
+ fi
+}
+
+local_path_for() {
+ local entry="$1"
+ if [[ "$entry" == "README.md" ]]; then
+ printf '%s\n' "$USER_DIR/README.md"
+ else
+ printf '%s\n' "$USER_DIR/$entry"
+ fi
+}
+
+home_path_for_extra() {
+ # Map a repo-relative extra path (e.g. "agent-watcher/rc-watchdog.js") back to
+ # its canonical home location via the EXTRA_TREES / EXTRA_FILES mappings (#5).
+ local rp="$1" tree src dest pair sfile rel
+ for tree in "${EXTRA_TREES[@]+"${EXTRA_TREES[@]}"}"; do
+ IFS='|' read -r src dest _ <<< "$tree"
+ if [[ "$rp" == "$dest/"* ]]; then printf '%s\n' "$src/${rp#"$dest"/}"; return 0; fi
+ done
+ for pair in "${EXTRA_FILES[@]+"${EXTRA_FILES[@]}"}"; do
+ IFS='|' read -r sfile rel <<< "$pair"
+ if [[ "$rp" == "$rel" ]]; then printf '%s\n' "$sfile"; return 0; fi
+ done
+ return 1
+}
+
+file_mtime() {
+ local f="$1"
+ stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || true
+}
+
+last_commit_ts() {
+ git -C "$REPO_DIR" log -1 --format=%ct -- "$1" 2>/dev/null || true
+}
+
+last_commit_short() {
+ git -C "$REPO_DIR" log -1 --format='%h %s' -- "$1" 2>/dev/null || true
+}
+
+add_warning() {
+ warnings_json=$(echo "$warnings_json" | jq \
+ --arg f "$1" --arg k "$2" --arg c "$3" \
+ '. + [{file: $f, kind: $k, lastCommit: $c}]')
+}
+
+compare_readme() {
+ local source_readme="$1"
+ local target_readme="$2"
+
+ if is_ignored "README.md"; then
+ ignored_json=$(echo "$ignored_json" | jq '. + ["README.md"]')
+ return
+ fi
+
+ if [[ -f "$source_readme" ]]; then
+ if [[ ! -f "$target_readme" ]]; then
+ new_json=$(echo "$new_json" | jq '. + ["README.md"]')
+ elif ! diff -q "$source_readme" "$target_readme" >/dev/null 2>&1; then
+ mod_json=$(echo "$mod_json" | jq '. + ["README.md"]')
+ fi
+ elif [[ -f "$target_readme" ]]; then
+ del_json=$(echo "$del_json" | jq '. + ["README.md"]')
+ fi
+}
+
+compare_dirs() {
+ local source_base="$1"
+ local target_base="$2"
+ local source_path target_path rel entry
+
+ for dir in $DIRS; do
+ source_path="$source_base/$dir"
+ target_path="$target_base/$dir"
+
+ if [[ -d "$source_path" ]]; then
+ while IFS= read -r rel; do
+ [[ -z "$rel" ]] && continue
+ entry="$dir/$rel"
+ if is_ignored "$entry"; then
+ ignored_json=$(echo "$ignored_json" | jq --arg f "$entry" '. + [$f]')
+ continue
+ fi
+ if [[ ! -f "$target_path/$rel" ]]; then
+ new_json=$(echo "$new_json" | jq --arg f "$entry" '. + [$f]')
+ elif ! diff -q "$source_path/$rel" "$target_path/$rel" >/dev/null 2>&1; then
+ mod_json=$(echo "$mod_json" | jq --arg f "$entry" '. + [$f]')
+ fi
+ done < <(cd "$source_path" && find . -type f ! -name '.DS_Store' | sed 's|^\./||')
+ fi
+
+ if [[ -d "$target_path" ]]; then
+ while IFS= read -r rel; do
+ [[ -z "$rel" ]] && continue
+ entry="$dir/$rel"
+ is_ignored "$entry" && continue
+ if [[ ! -f "$source_path/$rel" ]]; then
+ del_json=$(echo "$del_json" | jq --arg f "$entry" '. + [$f]')
+ fi
+ done < <(cd "$target_path" && find . -type f ! -name '.DS_Store' | sed 's|^\./||')
+ fi
+ done
+}
+
+# Process the extra portable trees + files (user-to-repo only). In "dryrun" mode
+# it only populates extra_json for the summary; in "stage" mode it rsyncs/copies
+# into the repo (honoring excludes) and git-adds, then records the actually-staged
+# paths. extra_json is reset each call so a dryrun then stage doesn't double-count.
+process_extra() {
+ local mode="$1" tree src dest excludes destpath pair sfile rel rp pat line
+ local exargs expats
+ extra_json="[]"
+ for tree in "${EXTRA_TREES[@]+"${EXTRA_TREES[@]}"}"; do
+ IFS='|' read -r src dest excludes <<< "$tree"
+ [[ -d "$src" ]] || continue
+ exargs=(); expats=()
+ IFS=',' read -ra expats <<< "$excludes" # split without glob-expanding patterns
+ for pat in "${expats[@]+"${expats[@]}"}"; do [[ -n "$pat" ]] && exargs+=( "--exclude=$pat" ); done
+ destpath="$REPO_DIR/$dest"
+ if [[ "$mode" == "stage" ]]; then
+ mkdir -p "$destpath"
+ # rsync stdout → /dev/null so the script's stdout stays pure JSON.
+ rsync -rlptc --delete "${exargs[@]}" "$src/" "$destpath/" >/dev/null
+ # Defensive: guarantee excluded files never land in the repo regardless of
+ # rsync-implementation exclude quirks (openrsync and rsync honor some bare
+ # filename patterns differently — this is why slots.json once slipped through).
+ for pat in "${expats[@]+"${expats[@]}"}"; do
+ [[ -z "$pat" ]] && continue
+ if [[ "$pat" == */* ]]; then rm -rf "${destpath:?}/$pat"
+ else find "$destpath" -name "$pat" -exec rm -rf {} + 2>/dev/null || true; fi
+ done
+ git -C "$REPO_DIR" add -A "$dest" >/dev/null 2>&1 || true
+ while IFS= read -r line; do
+ [[ -z "$line" ]] && continue
+ extra_json=$(echo "$extra_json" | jq --arg f "$line" '. + [$f]')
+ done < <(git -C "$REPO_DIR" diff --cached --name-only -- "$dest" 2>/dev/null)
+ else
+ while IFS= read -r line; do
+ [[ -z "$line" || "$line" == */ ]] && continue
+ case "$line" in
+ "sending "*|"sent "*|"total "*|"created "*|"building "*|"delta"*|"Transfer "*|"transferred "*|"deleting "*|"deleting"|"."|"./") continue ;;
+ esac
+ extra_json=$(echo "$extra_json" | jq --arg f "$dest/$line" '. + [$f]')
+ done < <(rsync -rlptc -n -v --delete "${exargs[@]}" "$src/" "$destpath/" 2>/dev/null)
+ fi
+ done
+ for pair in "${EXTRA_FILES[@]+"${EXTRA_FILES[@]}"}"; do
+ IFS='|' read -r sfile rel <<< "$pair"
+ [[ -f "$sfile" ]] || continue
+ rp="$REPO_DIR/$rel"
+ if [[ "$mode" == "stage" ]]; then
+ mkdir -p "$(dirname "$rp")"
+ cp "$sfile" "$rp"
+ git -C "$REPO_DIR" add "$rel" >/dev/null 2>&1 || true
+ if ! git -C "$REPO_DIR" diff --cached --quiet -- "$rel" 2>/dev/null; then
+ extra_json=$(echo "$extra_json" | jq --arg f "$rel" '. + [$f]')
+ fi
+ else
+ if [[ ! -f "$rp" ]] || ! diff -q "$sfile" "$rp" >/dev/null 2>&1; then
+ extra_json=$(echo "$extra_json" | jq --arg f "$rel" '. + [$f]')
+ fi
+ fi
+ done
+}
+
+# Reverse of process_extra (#5): pull the portable trees repo → home for
+# --repo-to-user, so de-staling a second machine restores extra-tree files (e.g.
+# agent-watcher scripts) — not just ~/.cursor. NO --delete: home-local state/secret
+# files (credentials.json, pool.json, …) are excluded from the repo and must never
+# be removed from home.
+process_extra_reverse() {
+ local mode="$1" tree src dest excludes destpath pair sfile rel rp pat line
+ local exargs expats
+ extra_json="[]"
+ for tree in "${EXTRA_TREES[@]+"${EXTRA_TREES[@]}"}"; do
+ IFS='|' read -r src dest excludes <<< "$tree"
+ destpath="$REPO_DIR/$dest"
+ [[ -d "$destpath" ]] || continue
+ exargs=(); expats=()
+ IFS=',' read -ra expats <<< "$excludes"
+ for pat in "${expats[@]+"${expats[@]}"}"; do [[ -n "$pat" ]] && exargs+=( "--exclude=$pat" ); done
+ if [[ "$mode" == "stage" ]]; then
+ mkdir -p "$src"
+ rsync -rlptc "${exargs[@]}" "$destpath/" "$src/" >/dev/null
+ else
+ while IFS= read -r line; do
+ [[ -z "$line" || "$line" == */ ]] && continue
+ case "$line" in
+ "sending "*|"sent "*|"total "*|"created "*|"building "*|"delta"*|"Transfer "*|"transferred "*|"deleting "*|"deleting"|"."|"./") continue ;;
+ esac
+ extra_json=$(echo "$extra_json" | jq --arg f "$dest/$line" '. + [$f]')
+ done < <(rsync -rlptc -n -v "${exargs[@]}" "$destpath/" "$src/" 2>/dev/null)
+ fi
+ done
+ for pair in "${EXTRA_FILES[@]+"${EXTRA_FILES[@]}"}"; do
+ IFS='|' read -r sfile rel <<< "$pair"
+ rp="$REPO_DIR/$rel"
+ [[ -f "$rp" ]] || continue
+ if [[ "$mode" == "stage" ]]; then
+ mkdir -p "$(dirname "$sfile")"; cp "$rp" "$sfile"
+ else
+ if [[ ! -f "$sfile" ]] || ! diff -q "$rp" "$sfile" >/dev/null 2>&1; then
+ extra_json=$(echo "$extra_json" | jq --arg f "$rel" '. + [$f]')
+ fi
+ fi
+ done
+}
+
+extra_deletion_warnings() {
+ # Flag repo extra-tree files MISSING from home (#5): a user→repo sync would
+ # --delete them. Mirrors compare_dirs' deletion protection for the portable
+ # trees, so a stale/incomplete machine can't silently remove another machine's
+ # extra-tree work. Honors each tree's excludes.
+ local tree src dest excludes destpath rel pat skip expats
+ for tree in "${EXTRA_TREES[@]+"${EXTRA_TREES[@]}"}"; do
+ IFS='|' read -r src dest excludes <<< "$tree"
+ destpath="$REPO_DIR/$dest"
+ [[ -d "$destpath" ]] || continue
+ expats=(); IFS=',' read -ra expats <<< "$excludes"
+ while IFS= read -r rel; do
+ [[ -z "$rel" ]] && continue
+ skip=false
+ for pat in "${expats[@]+"${expats[@]}"}"; do
+ [[ -z "$pat" ]] && continue
+ # shellcheck disable=SC2053
+ if [[ "$rel" == $pat || "$(basename "$rel")" == $pat || "$rel" == $pat/* ]]; then skip=true; break; fi
+ done
+ $skip && continue
+ [[ -e "$src/$rel" ]] && continue
+ add_warning "$dest/$rel" "deletion" "$(last_commit_short "$dest/$rel")"
+ done < <(cd "$destpath" && find . -type f ! -name '.DS_Store' | sed 's|^\./||')
+ done
+}
+
+extra_total=0
+if [[ "$DIRECTION" == "user-to-repo" ]]; then
+ compare_readme "$USER_README" "$REPO_ROOT_README"
+ compare_dirs "$USER_DIR" "$REPO_CURSOR"
+
+ if [[ -f "$LEGACY_REPO_README" ]] && ! is_ignored ".cursor/README.md"; then
+ del_json=$(echo "$del_json" | jq '. + [".cursor/README.md"]')
+ fi
+
+ process_extra "dryrun"
+ extra_total=$(echo "$extra_json" | jq 'length')
+
+ # Extra-tree staleness warnings (#5): give the portable trees the same
+ # protection as ~/.cursor. For each differing extra file, if the repo's last
+ # commit is newer than the local copy, flag stale-local so the safety gate
+ # above catches it before it can clobber another machine's work.
+ while IFS= read -r entry; do
+ [[ -z "$entry" ]] && continue
+ home_p="$(home_path_for_extra "$entry")" || continue
+ commit_ts="$(last_commit_ts "$entry")"
+ [[ -z "$commit_ts" ]] && continue
+ home_mtime="$(file_mtime "$home_p")"
+ [[ -z "$home_mtime" ]] && continue
+ if [[ "$commit_ts" -gt "$home_mtime" ]]; then
+ add_warning "$entry" "stale-local" "$(last_commit_short "$entry")"
+ fi
+ done < <(echo "$extra_json" | jq -r '.[]')
+
+ extra_deletion_warnings # #5: flag repo extra files home would --delete
+else
+ compare_readme "$REPO_ROOT_README" "$USER_README"
+ compare_dirs "$REPO_CURSOR" "$USER_DIR"
+
+ process_extra_reverse "dryrun" # #5: reverse-sync the portable trees too
+ extra_total=$(echo "$extra_json" | jq 'length')
+fi
+
+total=$(echo "$new_json $mod_json $del_json" | jq -s '.[0] + .[1] + .[2] | length')
+
+# Compute upstream-divergence warnings (user-to-repo only).
+# Compares each affected path's most-recent commit timestamp to the local
+# file's mtime. If the upstream commit is newer, the local copy is likely
+# stale and overwriting would clobber another machine's work.
+if [[ "$DIRECTION" == "user-to-repo" ]]; then
+ while IFS= read -r entry; do
+ [[ -z "$entry" ]] && continue
+ repo_p="$(repo_path_for "$entry")"
+ local_p="$(local_path_for "$entry")"
+ commit_ts="$(last_commit_ts "$repo_p")"
+ [[ -z "$commit_ts" ]] && continue
+ local_mtime="$(file_mtime "$local_p")"
+ [[ -z "$local_mtime" ]] && continue
+ if [[ "$commit_ts" -gt "$local_mtime" ]]; then
+ add_warning "$entry" "stale-local" "$(last_commit_short "$repo_p")"
+ fi
+ done < <(echo "$mod_json" | jq -r '.[]')
+
+ # New files: warn if path has prior history (re-adding something previously
+ # deleted upstream after our local was last written).
+ while IFS= read -r entry; do
+ [[ -z "$entry" ]] && continue
+ repo_p="$(repo_path_for "$entry")"
+ local_p="$(local_path_for "$entry")"
+ commit_ts="$(last_commit_ts "$repo_p")"
+ [[ -z "$commit_ts" ]] && continue
+ local_mtime="$(file_mtime "$local_p")"
+ [[ -z "$local_mtime" ]] && continue
+ if [[ "$commit_ts" -gt "$local_mtime" ]]; then
+ add_warning "$entry" "re-adding-deleted" "$(last_commit_short "$repo_p")"
+ fi
+ done < <(echo "$new_json" | jq -r '.[]')
+
+ # Deletions: always warn — no local mtime to compare against.
+ while IFS= read -r entry; do
+ [[ -z "$entry" ]] && continue
+ repo_p="$(repo_path_for "$entry")"
+ last_c="$(last_commit_short "$repo_p")"
+ [[ -z "$last_c" ]] && continue
+ add_warning "$entry" "deletion" "$last_c"
+ done < <(echo "$del_json" | jq -r '.[]')
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Ensure ~/.claude/skills symlink points to ~/.cursor/skills
+CLAUDE_SKILLS="$HOME/.claude/skills"
+if [[ -L "$CLAUDE_SKILLS" ]]; then
+ link_target="$(readlink "$CLAUDE_SKILLS")"
+ if [[ "$link_target" != "$USER_DIR/skills" ]]; then
+ rm "$CLAUDE_SKILLS"
+ ln -s "$USER_DIR/skills" "$CLAUDE_SKILLS"
+ fi
+elif [[ ! -e "$CLAUDE_SKILLS" ]]; then
+ mkdir -p "$(dirname "$CLAUDE_SKILLS")"
+ ln -s "$USER_DIR/skills" "$CLAUDE_SKILLS"
+fi
+
+# Regenerate ~/.claude/CLAUDE.md from alwaysApply rules
+if [[ -x "$SCRIPT_DIR/generate-claude-md.sh" ]]; then
+ "$SCRIPT_DIR/generate-claude-md.sh" >/dev/null
+fi
+
+# Safety gate (#2/#3/#6): refuse a staging run that would DELETE or overwrite
+# canonical files with stale local copies. These warnings used to be advisory —
+# that was the exact hole that let a stale/incomplete machine clobber another
+# machine's work. Block by default; override with --force.
+if [[ "$DO_STAGE" == "true" && "$DIRECTION" == "user-to-repo" && "$FORCE_WARN" != "true" ]]; then
+ blocking=$(echo "$warnings_json" | jq '[.[] | select(.kind=="deletion" or .kind=="stale-local" or .kind=="re-adding-deleted")] | length')
+ if [[ "$blocking" -gt 0 ]]; then
+ echo "ERROR: $blocking blocking warning(s) — this sync would delete or revert canonical files:" >&2
+ echo "$warnings_json" | jq -r '.[] | select(.kind=="deletion" or .kind=="stale-local" or .kind=="re-adding-deleted") | " [\(.kind)] \(.file) (\(.lastCommit))"' >&2
+ outgoing=$(echo "$new_json" | jq 'length')
+ if [[ "$outgoing" -gt 0 ]]; then
+ echo "Bidirectional divergence: also $outgoing local-only addition(s) to push." >&2
+ echo "Fix order: 'convention-sync --repo-to-user --stage' (de-stale this machine), then re-run to push." >&2
+ else
+ echo "This machine is stale — run 'convention-sync --repo-to-user --stage' to update it instead of overwriting upstream." >&2
+ fi
+ echo "To overwrite upstream anyway: re-run with --force." >&2
+ exit 1
+ fi
+fi
+
+if [[ "$DO_STAGE" == true ]] && (( total + extra_total > 0 )); then
+ all_copy=$(echo "$new_json $mod_json" | jq -sr '.[0] + .[1] | .[]')
+ all_del=$(echo "$del_json" | jq -r '.[]')
+
+ if [[ "$DIRECTION" == "user-to-repo" ]]; then
+ while IFS= read -r f; do
+ [[ -z "$f" ]] && continue
+ if [[ "$f" == "README.md" ]]; then
+ cp "$USER_DIR/$f" "$REPO_DIR/$f"
+ else
+ mkdir -p "$(dirname "$REPO_CURSOR/$f")"
+ cp "$USER_DIR/$f" "$REPO_CURSOR/$f"
+ fi
+ done <<< "$all_copy"
+
+ while IFS= read -r f; do
+ [[ -z "$f" ]] && continue
+ if [[ "$f" == "README.md" ]]; then
+ rm -f "$REPO_DIR/$f"
+ elif [[ "$f" == ".cursor/README.md" ]]; then
+ rm -f "$LEGACY_REPO_README"
+ else
+ rm -f "$REPO_CURSOR/$f"
+ fi
+ done <<< "$all_del"
+
+ cd "$REPO_DIR"
+ while IFS= read -r f; do
+ [[ -z "$f" ]] && continue
+ if [[ "$f" == "README.md" ]]; then
+ git add "$f"
+ else
+ git add ".cursor/$f"
+ fi
+ done <<< "$all_copy"
+
+ while IFS= read -r f; do
+ [[ -z "$f" ]] && continue
+ if [[ "$f" == "README.md" ]]; then
+ git rm -f --quiet "$f" 2>/dev/null || true
+ elif [[ "$f" == ".cursor/README.md" ]]; then
+ git rm -f --quiet "$f" 2>/dev/null || true
+ else
+ git rm -f --quiet ".cursor/$f" 2>/dev/null || true
+ fi
+ done <<< "$all_del"
+
+ process_extra "stage"
+ extra_total=$(echo "$extra_json" | jq 'length')
+
+ if [[ "$DO_COMMIT" == true ]]; then
+ git commit -m "$COMMIT_MSG" >&2 # keep stdout pure JSON
+ fi
+ else
+ while IFS= read -r f; do
+ [[ -z "$f" ]] && continue
+ if [[ "$f" == "README.md" ]]; then
+ cp "$REPO_DIR/$f" "$USER_DIR/$f"
+ else
+ mkdir -p "$(dirname "$USER_DIR/$f")"
+ cp "$REPO_CURSOR/$f" "$USER_DIR/$f"
+ fi
+ done <<< "$all_copy"
+
+ while IFS= read -r f; do
+ [[ -z "$f" ]] && continue
+ rm -f "$USER_DIR/$f"
+ done <<< "$all_del"
+
+ process_extra_reverse "stage" # #5: restore portable trees to home
+ extra_total=$(echo "$extra_json" | jq 'length')
+ fi
+fi
+
+jq -n \
+ --arg repoDir "$REPO_DIR" \
+ --argjson new "$new_json" \
+ --argjson modified "$mod_json" \
+ --argjson deleted "$del_json" \
+ --argjson ignored "$ignored_json" \
+ --argjson warnings "$warnings_json" \
+ --argjson total "$total" \
+ --argjson extra "$extra_json" \
+ --argjson extraTotal "${extra_total:-0}" \
+ --argjson originAhead "$ORIGIN_AHEAD" \
+ --arg originBranch "$ORIGIN_BRANCH" \
+ --arg staged "$DO_STAGE" \
+ --arg committed "$DO_COMMIT" \
+ '{repoDir: $repoDir, originBranch: $originBranch, originAhead: $originAhead, total: $total, new: $new, modified: $modified, deleted: $deleted, ignored: $ignored, warnings: $warnings, extra: $extra, extraTotal: $extraTotal, staged: ($staged == "true"), committed: ($committed == "true")}'
diff --git a/.cursor/skills/convention-sync/scripts/generate-claude-md.sh b/.cursor/skills/convention-sync/scripts/generate-claude-md.sh
new file mode 100755
index 0000000..3f793b8
--- /dev/null
+++ b/.cursor/skills/convention-sync/scripts/generate-claude-md.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# generate-claude-md.sh — Generate ~/.claude/CLAUDE.md from alwaysApply .mdc rules.
+# Usage: ./generate-claude-md.sh [--dry-run]
+#
+# Reads all .mdc files in ~/.cursor/rules/ that have alwaysApply: true,
+# strips YAML frontmatter, and concatenates them into ~/.claude/CLAUDE.md.
+
+set -euo pipefail
+
+RULES_DIR="$HOME/.cursor/rules"
+OUTPUT="$HOME/.claude/CLAUDE.md"
+DRY_RUN=false
+
+[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
+
+if [[ ! -d "$RULES_DIR" ]]; then
+ echo "ERROR: $RULES_DIR does not exist" >&2
+ exit 1
+fi
+
+mkdir -p "$(dirname "$OUTPUT")"
+
+collected=()
+skipped=()
+
+for mdc in "$RULES_DIR"/*.mdc; do
+ [[ -f "$mdc" ]] || continue
+ basename="$(basename "$mdc")"
+
+ if head -20 "$mdc" | grep -q '^alwaysApply: true'; then
+ collected+=("$basename")
+ else
+ skipped+=("$basename")
+ fi
+done
+
+if [[ ${#collected[@]} -eq 0 ]]; then
+ echo '{"collected":[],"skipped":[],"output":"","dry_run":true}'
+ exit 0
+fi
+
+content="# Global Rules\n\n"
+content+="# Auto-generated from ~/.cursor/rules/ (alwaysApply: true files only).\n"
+content+="# Do not edit manually. Re-generate via convention-sync.\n\n"
+
+for basename in "${collected[@]}"; do
+ mdc="$RULES_DIR/$basename"
+ name="${basename%.mdc}"
+
+ # Strip YAML frontmatter (everything between first --- and second ---)
+ body=$(awk '
+ BEGIN { in_front=0; past_front=0 }
+ /^---$/ {
+ if (!past_front) {
+ if (in_front) { past_front=1; next }
+ else { in_front=1; next }
+ }
+ }
+ past_front { print }
+ ' "$mdc")
+
+ # Trim leading blank lines
+ body=$(echo "$body" | sed '/./,$!d')
+
+ content+="---\n\n"
+ content+="## $name\n\n"
+ content+="$body\n\n"
+done
+
+if [[ "$DRY_RUN" == true ]]; then
+ echo -e "$content" > /dev/null
+else
+ echo -e "$content" > "$OUTPUT"
+fi
+
+# Output JSON summary
+collected_json=$(printf '%s\n' "${collected[@]}" | jq -R . | jq -s .)
+skipped_json=$(printf '%s\n' "${skipped[@]}" | jq -R . | jq -s .)
+
+jq -n \
+ --argjson collected "$collected_json" \
+ --argjson skipped "$skipped_json" \
+ --arg output "$OUTPUT" \
+ --arg dry_run "$DRY_RUN" \
+ '{collected: $collected, skipped: $skipped, output: $output, dry_run: ($dry_run == "true")}'
diff --git a/.cursor/skills/debugger/SKILL.md b/.cursor/skills/debugger/SKILL.md
new file mode 100644
index 0000000..e7d667e
--- /dev/null
+++ b/.cursor/skills/debugger/SKILL.md
@@ -0,0 +1,127 @@
+---
+name: debugger
+description: Set a breakpoint at a file:line in a React Native / Hermes app running under Metro, then capture the call stack, local variables, and arbitrary expressions at that point. Use when an agent needs to inspect runtime state — values of variables, what code path is taken, why a check evaluates to false, etc. — in a real running RN app. NOT for static code analysis; use grep/read for that.
+metadata:
+ author: j0ntz
+---
+
+Attach to a running React Native / Hermes JS VM via Metro's Chrome DevTools Protocol (CDP) inspector, set a precise file:line breakpoint, and capture runtime state when it fires.
+
+
+Always run `~/.cursor/skills/debugger/scripts/check-metro.sh` FIRST. If it exits 1 (Metro not reachable) or 2 (no Hermes target), surface the script's stderr verbatim and STOP. Do not try to start Metro yourself unless the user explicitly asks — Metro startup involves the project's own dev server and is the user's call.
+All CDP interaction MUST go through `~/.cursor/skills/debugger/scripts/cdp-attach.js`. Do NOT open raw WebSockets, do NOT shell out to `chrome-devtools-frontend`, do NOT use any other CDP harness inline.
+User-facing line numbers are 1-based (matches what editors and humans use). `cdp-attach.js` handles the CDP 0-based conversion internally. Always pass the line as you would see it in an editor.
+This skill does NOT edit source code, install npm packages into the target repo, commit, push, or change Asana state. It reads runtime state through CDP and reports it.
+Each `cdp-attach.js` invocation sets ONE breakpoint and reports ONE pause. For multi-breakpoint investigations, run multiple invocations (composable, predictable). Do NOT try to multiplex breakpoints in a single call — the report becomes ambiguous.
+`cdp-attach.js` writes a structured JSON report to stdout and status to stderr. When parsing for the caller (e.g. /one-shot), read stdout; show stderr only on failure or when diagnostic info is helpful.
+
+
+
+
+```bash
+~/.cursor/skills/debugger/scripts/check-metro.sh
+```
+
+Expected: exit 0 with `>> check-metro: ready (Metro on :8081, N Hermes target(s))`.
+
+Both `check-metro.sh` (`--port`) and `cdp-attach.js` (`--metro`) default to `$AGENT_METRO_PORT` when it's set (watcher-spawned parallel slots), else 8081 — so in a slot you can omit the port flags and still hit the right Metro. An explicit flag always overrides.
+
+If exit 1 (Metro down) or 2 (no Hermes target): report the error from stderr to the user/caller and stop. The fix is on their side (start Metro, launch the app on a sim/device).
+
+
+
+
+
+Decide:
+
+- **File and line to break on.** Use the source file's name (or a unique substring) plus the editor line number. Example: `src/plugins/ramps/rampConstraints.ts:51`.
+- **Trigger mode** (one of):
+ - **Passive wait**: someone else (a user driving the app, a maestro flow, a CI script) will cause the code path to be hit. The script waits up to `--timeout-ms`.
+ - **Active trigger**: pass `--trigger ''` — a JS snippet evaluated in the live VM that invokes the code path. Use when the agent knows enough about the app's runtime to call into it directly.
+- **What to report** (defaults to `stack,locals`):
+ - `stack` — top 10 call frames
+ - `locals` — local-scope variables of the top frame
+ - `evaluate:` — evaluate an arbitrary expression in the paused top frame (repeatable). Use to see derived values, dotted paths into objects, etc.
+- **Conditional** (optional): `--condition ''` — only fire when the expression is truthy in the breakpoint's scope. Filters out unrelated calls (e.g. only break when `paymentType === "ach"`).
+
+
+
+
+
+Passive wait, default report (stack + locals):
+
+```bash
+node ~/.cursor/skills/debugger/scripts/cdp-attach.js \
+ --break-at rampConstraints.ts:51 \
+ --timeout-ms 30000
+```
+
+Conditional, with extra expressions evaluated on hit:
+
+```bash
+node ~/.cursor/skills/debugger/scripts/cdp-attach.js \
+ --break-at rampConstraints.ts:51 \
+ --condition 'params.paymentType === "ach"' \
+ --report 'stack,locals,evaluate:params.paymentType,evaluate:params.regionCode.countryCode' \
+ --timeout-ms 30000
+```
+
+Active trigger (force the breakpoint to fire by evaluating an app function in the VM):
+
+```bash
+node ~/.cursor/skills/debugger/scripts/cdp-attach.js \
+ --break-at rampConstraints.ts:51 \
+ --trigger 'require("./src/plugins/ramps/infinite/infiniteRampPlugin").default.checkSupport({ countryCode: "FR" })' \
+ --report 'stack,locals'
+```
+
+(The exact `--trigger` form depends on the app's module layout and what's reachable from the VM. Active triggers can be brittle — prefer passive wait + maestro to drive the app naturally when possible.)
+
+
+
+
+
+`cdp-attach.js` writes a JSON envelope to stdout. Shape:
+
+```json
+{
+ "breakpoint": { "pattern": "rampConstraints.ts", "line": 51, "column": null, "resolved": 1 },
+ "paused": {
+ "reason": "other",
+ "callStack": [
+ { "function": "supportsBuyACH", "url": "file:///.../rampConstraints.ts", "line": 51, "column": 4 }
+ ],
+ "locals": { "params": "
+
+
+The source file URL Hermes reports may not match the pattern. Try a less specific pattern (e.g. just `rampConstraints` instead of `rampConstraints.ts`). If still 0, the source may be in a bundle without source maps — set the breakpoint by bundle URL instead (rare; usually means the dev build needs `--reset-cache`).
+The breakpoint location was never executed within the budget. Either: (a) drive the app to the relevant scene first (maestro, manual user, etc.) then re-run with a fresh timeout; (b) widen `--timeout-ms`; (c) drop or loosen `--condition`; (d) verify the line you picked is actually executed (it might be inside an unused branch).
+The `--trigger` expression failed — usually because the function it tried to call is not reachable from the global scope at that moment (RN modules are lazily loaded). Fall back to passive wait + drive the app via maestro.
+`cdp-attach.js` picks the first match for `--target-regex` (default `React Native|Hermes|Bridgeless`). If multiple devices/simulators are connected, narrow with a more specific `--target-regex` (e.g. the device name).
+Scripts seen by the debugger reset. Re-run `check-metro.sh` and re-run the cdp-attach invocation.
+Some RN debug builds are flaky (e.g. text-measure crashes on certain scenes). Resume happens automatically at the end of the report, but if you want to observe more state, capture everything in ONE invocation via `--report stack,locals,evaluate:...` — don't try to leave the VM paused for chained inspection.
+
+
+
+The script speaks Chrome DevTools Protocol (CDP) directly via WebSocket — the same protocol Chrome DevTools and React Native DevTools use. Reference: https://chromedevtools.github.io/devtools-protocol/
+
+Key CDP methods used:
+- `Debugger.enable`, `Debugger.setBreakpointsActive`
+- `Debugger.setBreakpointByUrl` — the canonical "break at file:line"
+- `Debugger.paused` (event) — fired when execution stops
+- `Runtime.getProperties` — read local-scope variables
+- `Debugger.evaluateOnCallFrame` — evaluate expressions in the paused frame
+- `Debugger.resume`
+
+Metro exposes the CDP target list at `http://localhost:8081/json/list`. Each Hermes-backed app shows up as a target with a `webSocketDebuggerUrl` we connect to.
+
diff --git a/.cursor/skills/debugger/scripts/cdp-attach.js b/.cursor/skills/debugger/scripts/cdp-attach.js
new file mode 100755
index 0000000..5c88ec7
--- /dev/null
+++ b/.cursor/skills/debugger/scripts/cdp-attach.js
@@ -0,0 +1,319 @@
+#!/usr/bin/env node
+// cdp-attach.js — Attach to a Hermes JS VM via Metro inspector (CDP), set a
+// breakpoint at file:line, optionally trigger it, and report call stack /
+// locals / arbitrary evaluated expressions on pause.
+//
+// Uses Node's built-in global WebSocket (Node 22+) — no npm deps.
+//
+// USAGE:
+// node cdp-attach.js \
+// --break-at :[:
] \
+// [--condition ''] \
+// [--trigger ''] \
+// [--report stack,locals,evaluate:] \
+// [--metro localhost:8081] \
+// [--target-regex 'React Native|Hermes|Bridgeless'] \
+// [--timeout-ms 8000]
+//
+// --break-at: pattern is matched as a case-insensitive urlRegex against the
+// source file URL Hermes reports. Substring is fine —
+// e.g. `rampConstraints.ts:51` becomes the regex `.*rampConstraints\.ts.*`.
+// Line is 1-based (matches editor convention).
+//
+// --condition: optional JS expression evaluated in the breakpoint's scope.
+// The breakpoint only fires when this returns truthy.
+// Example: `paymentType === "ach"` to only break on ACH calls.
+//
+// --trigger: optional JS evaluated in the live VM AFTER the breakpoint is
+// set. Use to force-hit the breakpoint deterministically (call the
+// function that contains it). If omitted, the script WAITS
+// passively until the breakpoint fires (e.g. from human/automation
+// driving the app) or --timeout-ms elapses.
+//
+// --report: comma-separated list of what to include in the pause report.
+// Supports:
+// stack — top 10 call frames (default)
+// locals — local-scope variables of the top frame (default)
+// evaluate: — Debugger.evaluateOnCallFrame on top frame
+// Can repeat `evaluate:`. Example: --report stack,locals,evaluate:foo,evaluate:bar
+//
+// OUTPUT: structured JSON on stdout (the pause report or an error envelope).
+// Status/log lines on stderr.
+//
+// EXIT CODES:
+// 0 = breakpoint hit, report emitted
+// 1 = error (Metro unreachable, target not found, breakpoint unresolved, etc.)
+// 2 = no breakpoint hit within --timeout-ms (passive-wait mode timed out)
+
+'use strict'
+
+const http = require('node:http')
+
+// ─── Arg parsing ─────────────────────────────────────────────────────────────
+
+const args = process.argv.slice(2)
+const opts = {
+ breakAt: null,
+ condition: null,
+ trigger: null,
+ report: 'stack,locals',
+ // Default Metro endpoint follows the slot's port when the watcher set it,
+ // else the RN default 8081. Explicit --metro always wins.
+ metro: `localhost:${process.env.AGENT_METRO_PORT || '8081'}`,
+ targetRegex: 'React Native|Hermes|Bridgeless',
+ timeoutMs: 8000,
+}
+
+for (let i = 0; i < args.length; i++) {
+ const a = args[i]
+ const next = () => args[++i]
+ switch (a) {
+ case '--break-at': opts.breakAt = next(); break
+ case '--condition': opts.condition = next(); break
+ case '--trigger': opts.trigger = next(); break
+ case '--report': opts.report = next(); break
+ case '--metro': opts.metro = next(); break
+ case '--target-regex': opts.targetRegex = next(); break
+ case '--timeout-ms': opts.timeoutMs = parseInt(next(), 10); break
+ case '--help':
+ case '-h':
+ process.stdout.write(require('node:fs').readFileSync(__filename, 'utf8').split('\n').slice(0, 50).join('\n') + '\n')
+ process.exit(0)
+ default:
+ console.error(`Unknown arg: ${a}`)
+ process.exit(1)
+ }
+}
+
+if (!opts.breakAt) {
+ console.error('Missing required --break-at :[:
]')
+ process.exit(1)
+}
+
+// Parse pattern:line[:col]
+const bpMatch = opts.breakAt.match(/^(.+?):(\d+)(?::(\d+))?$/)
+if (!bpMatch) {
+ console.error(`--break-at must be :[:
] — got: ${opts.breakAt}`)
+ process.exit(1)
+}
+const bpPattern = bpMatch[1]
+const bpLine = parseInt(bpMatch[2], 10) - 1 // CDP is 0-based; user input is 1-based
+const bpColumn = bpMatch[3] != null ? parseInt(bpMatch[3], 10) : undefined
+
+// Build CDP urlRegex: escape regex metachars in the user's substring, wrap with .*
+const escapedPattern = bpPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+const urlRegex = `.*${escapedPattern}.*`
+
+// Parse --report into structured form
+const reportItems = opts.report.split(',').map((s) => s.trim()).filter(Boolean)
+const wantStack = reportItems.includes('stack')
+const wantLocals = reportItems.includes('locals')
+const evaluateExprs = reportItems
+ .filter((s) => s.startsWith('evaluate:'))
+ .map((s) => s.slice('evaluate:'.length))
+
+// ─── HTTP target discovery ───────────────────────────────────────────────────
+
+function httpGetJson(url) {
+ return new Promise((resolve, reject) => {
+ const req = http.get(url, (res) => {
+ let body = ''
+ res.on('data', (d) => (body += d))
+ res.on('end', () => {
+ try { resolve(JSON.parse(body)) } catch (e) { reject(e) }
+ })
+ })
+ req.on('error', reject)
+ req.setTimeout(3000, () => req.destroy(new Error('timeout')))
+ })
+}
+
+// ─── Main ────────────────────────────────────────────────────────────────────
+
+async function main() {
+ // Discover targets
+ let targets
+ try {
+ targets = await httpGetJson(`http://${opts.metro}/json/list`)
+ } catch (e) {
+ console.error(`Failed to fetch targets from http://${opts.metro}/json/list: ${e.message}`)
+ process.exit(1)
+ }
+
+ const re = new RegExp(opts.targetRegex, 'i')
+ const target = targets.find((t) => re.test((t.description || '') + (t.title || '')))
+ if (!target) {
+ console.error(`No target matching /${opts.targetRegex}/i. Available targets:`)
+ targets.forEach((t) => console.error(` - ${t.title || '?'} | ${t.description || '?'}`))
+ process.exit(1)
+ }
+ console.error(`>> cdp-attach: target = ${target.title} | ${target.description}`)
+
+ // Connect via the built-in WebSocket (Node 22+)
+ if (typeof WebSocket === 'undefined') {
+ console.error('Node WebSocket global not available. Use Node 22+.')
+ process.exit(1)
+ }
+ const ws = new WebSocket(target.webSocketDebuggerUrl)
+ await new Promise((resolve, reject) => {
+ ws.addEventListener('open', () => resolve(), { once: true })
+ ws.addEventListener('error', (e) => reject(new Error(e.message || 'ws error')), { once: true })
+ })
+ console.error('>> cdp-attach: WS connected')
+
+ // Tiny CDP RPC helper
+ let nextId = 0
+ const pending = new Map()
+ let pausedParams = null
+ const eventListeners = new Map() // method → array of resolvers (waitFor)
+
+ ws.addEventListener('message', (event) => {
+ const msg = JSON.parse(event.data)
+ if (msg.id != null && pending.has(msg.id)) {
+ const { resolve } = pending.get(msg.id)
+ pending.delete(msg.id)
+ resolve(msg.error ? { __error: msg.error } : (msg.result || {}))
+ return
+ }
+ if (msg.method === 'Debugger.paused') {
+ pausedParams = msg.params
+ }
+ const listeners = eventListeners.get(msg.method)
+ if (listeners && listeners.length > 0) {
+ const resolvers = listeners.splice(0)
+ for (const r of resolvers) r(msg.params)
+ }
+ })
+
+ function send(method, params = {}) {
+ return new Promise((resolve) => {
+ const id = ++nextId
+ pending.set(id, { resolve })
+ ws.send(JSON.stringify({ id, method, params }))
+ })
+ }
+
+ function waitForEvent(method, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const to = setTimeout(() => reject(new Error(`timeout waiting for ${method}`)), timeoutMs)
+ const list = eventListeners.get(method) || []
+ list.push((params) => { clearTimeout(to); resolve(params) })
+ eventListeners.set(method, list)
+ })
+ }
+
+ await send('Runtime.enable')
+ await send('Debugger.enable', { maxScriptsCacheSize: 10_000_000 })
+ await send('Debugger.setBreakpointsActive', { active: true })
+
+ // Set the breakpoint
+ const bpResult = await send('Debugger.setBreakpointByUrl', {
+ urlRegex,
+ lineNumber: bpLine,
+ ...(bpColumn != null ? { columnNumber: bpColumn } : {}),
+ ...(opts.condition ? { condition: opts.condition } : {}),
+ })
+
+ if (bpResult.__error) {
+ console.error(`setBreakpointByUrl failed: ${bpResult.__error.message}`)
+ ws.close()
+ process.exit(1)
+ }
+
+ const resolvedCount = (bpResult.locations || []).length
+ console.error(`>> cdp-attach: breakpoint set (id=${bpResult.breakpointId}, resolved=${resolvedCount} locations)`)
+ if (resolvedCount === 0) {
+ console.error(` pattern: /${urlRegex}/ line=${bpLine + 1}${bpColumn != null ? ` col=${bpColumn}` : ''}`)
+ console.error(' warning: 0 locations resolved. Breakpoint will fire if the source URL appears later.')
+ }
+
+ // Wait for pause
+ const pausePromise = waitForEvent('Debugger.paused', opts.timeoutMs)
+
+ // Optionally trigger
+ if (opts.trigger) {
+ console.error(`>> cdp-attach: triggering with --trigger expression`)
+ send('Runtime.evaluate', { expression: opts.trigger, includeCommandLineAPI: false }).catch(() => {})
+ } else {
+ console.error(`>> cdp-attach: waiting passively for breakpoint to fire (timeout ${opts.timeoutMs}ms)`)
+ }
+
+ let pause
+ try {
+ pause = await pausePromise
+ } catch (e) {
+ console.error(`>> cdp-attach: ${e.message}`)
+ const envelope = { error: 'timeout', breakpoint: { pattern: bpPattern, line: bpLine + 1, resolved: resolvedCount } }
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n')
+ ws.close()
+ process.exit(2)
+ }
+
+ // Build the report
+ const report = {
+ breakpoint: {
+ pattern: bpPattern,
+ line: bpLine + 1,
+ column: bpColumn,
+ resolved: resolvedCount,
+ },
+ paused: {
+ reason: pause.reason,
+ },
+ }
+
+ if (wantStack) {
+ report.paused.callStack = pause.callFrames.slice(0, 10).map((f) => ({
+ function: f.functionName || '(anon)',
+ url: f.url || `(scriptId:${f.location.scriptId})`,
+ line: f.location.lineNumber + 1,
+ column: f.location.columnNumber,
+ }))
+ }
+
+ const topFrame = pause.callFrames[0]
+
+ if (wantLocals && topFrame) {
+ const localScope = (topFrame.scopeChain || []).find((s) => s.type === 'local')
+ if (localScope) {
+ const props = await send('Runtime.getProperties', {
+ objectId: localScope.object.objectId,
+ ownProperties: true,
+ })
+ report.paused.locals = {}
+ for (const p of props.result || []) {
+ const v = p.value
+ report.paused.locals[p.name] = v
+ ? (v.value !== undefined ? v.value : (v.description || `<${v.type}>`))
+ : ''
+ }
+ } else {
+ report.paused.locals = ''
+ }
+ }
+
+ if (evaluateExprs.length > 0 && topFrame) {
+ report.paused.evaluated = {}
+ for (const expr of evaluateExprs) {
+ const r = await send('Debugger.evaluateOnCallFrame', {
+ callFrameId: topFrame.callFrameId,
+ expression: expr,
+ })
+ report.paused.evaluated[expr] = r.__error
+ ? ``
+ : (r.result ? (r.result.value !== undefined ? r.result.value : r.result.description) : '')
+ }
+ }
+
+ // Resume so the app keeps running
+ await send('Debugger.resume')
+
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
+ ws.close()
+ process.exit(0)
+}
+
+main().catch((e) => {
+ console.error(`cdp-attach failed: ${e.message}`)
+ process.exit(1)
+})
diff --git a/.cursor/skills/debugger/scripts/check-metro.sh b/.cursor/skills/debugger/scripts/check-metro.sh
new file mode 100755
index 0000000..4158c9e
--- /dev/null
+++ b/.cursor/skills/debugger/scripts/check-metro.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# check-metro.sh — Preflight for Hermes debugging via Metro inspector.
+#
+# Verifies Metro is running on the given port and exposes at least one Hermes
+# JS target. Use BEFORE invoking cdp-attach.js to fail fast with an actionable
+# error.
+#
+# Usage:
+# check-metro.sh [--port 8081]
+#
+# Port default follows $AGENT_METRO_PORT when set (watcher-spawned slots), else
+# 8081. An explicit --port always wins.
+#
+# Exit codes:
+# 0 = ready (Metro alive, ≥1 Hermes target)
+# 1 = Metro not reachable on the requested port
+# 2 = Metro alive but no Hermes target (app not running in Hermes mode, or
+# not connected to Metro)
+
+set -euo pipefail
+
+PORT="${AGENT_METRO_PORT:-8081}"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --port) PORT="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+if ! curl -fsS -m 3 "http://localhost:$PORT/status" >/dev/null 2>&1; then
+ echo "Metro not reachable on localhost:$PORT" >&2
+ echo " start Metro in the project: npx react-native start --reset-cache" >&2
+ exit 1
+fi
+
+LIST=$(curl -fsS -m 3 "http://localhost:$PORT/json/list" 2>/dev/null || echo '[]')
+HERMES_COUNT=$(echo "$LIST" | jq '[.[] | select((.description // "") + (.title // "") | test("React Native|Hermes|Bridgeless"; "i"))] | length' 2>/dev/null || echo 0)
+
+if [[ "$HERMES_COUNT" -eq 0 ]]; then
+ echo "Metro alive on :$PORT but no Hermes JS target found" >&2
+ echo " is the app actually running on a sim/device?" >&2
+ echo " is Hermes enabled in the build? (RN typically default-on now)" >&2
+ echo " current targets:" >&2
+ echo "$LIST" | jq -r '.[] | " - \(.title // "(no title)") | \(.description // "(no desc)")"' >&2
+ exit 2
+fi
+
+echo ">> check-metro: ready (Metro on :$PORT, $HERMES_COUNT Hermes target(s))"
+exit 0
diff --git a/.cursor/skills/dep-pr/SKILL.md b/.cursor/skills/dep-pr/SKILL.md
new file mode 100644
index 0000000..10f02fb
--- /dev/null
+++ b/.cursor/skills/dep-pr/SKILL.md
@@ -0,0 +1,103 @@
+---
+name: dep-pr
+description: Create a dependent Asana task in another repo and run the full PR workflow for it. Use when the user needs cross-repo dependent task creation.
+compatibility: Requires git, gh, node, jq. ASANA_TOKEN for Asana integration.
+metadata:
+ author: j0ntz
+---
+
+Create a dependent Asana task in another repo and run the full PR workflow for it — automating cross-repo task creation, dependency linking, implementation, and PR creation.
+
+
+A parent Asana task URL is always required. It provides context, project placement, and dependency linking.
+Always check if a dependent task already exists before creating one. The script handles this — respect the `CREATED: false` output.
+Asana scripts can take up to 90s. Always set `block_until_ms: 120000`.
+Do NOT begin implementation until the dependent task is created and linked.
+The dependent task MUST be created in the same project(s) as the parent task, including release-version project tags (for example `4.46.0`). The script handles this automatically by copying all parent project memberships.
+The dependent task is automatically assigned to the current user (resolved via `asana-whoami.sh`). Do NOT hardcode a user GID — omit `--assignee` to let the script auto-resolve.
+
+
+
+The Edge repos have a layered dependency structure:
+
+```
+core (lowest — types, APIs, runtime)
+ ↑
+accb / exch (middle — currency and exchange plugins, depend on core)
+ ↑
+gui (highest — UI, depends on all others)
+```
+
+**Dependency direction rule**: When creating a dependent task for a repo at a **lower or equal** level, the new task **blocks** the parent task. This is the standard case — e.g., an `accb:` task blocks the `gui:` parent because the plugin change must land first.
+
+If the target repo is at a **higher** level than the parent (e.g., creating a `gui:` task from an `accb:` parent), this is unusual. Ask the user to confirm before proceeding — the dependency direction may need to be reversed (parent blocks the new task instead).
+
+| Level | Repos |
+|-------|-------|
+| 3 (highest) | `gui` |
+| 2 | `accb`, `exch` |
+| 1 (lowest) | `core` |
+
+
+
+
+
+| Prefix | Repository | Directory | Branch from |
+|--------|-----------|-----------|-------------|
+| `gui` | `edge-react-gui` | `~/git/edge-react-gui` | `develop` |
+| `exch` | `edge-exchange-plugins` | `~/git/edge-exchange-plugins` | `master` |
+| `accb` | `edge-currency-accountbased` | `~/git/edge-currency-accountbased` | `master` |
+| `core` | `edge-core-js` | `~/git/edge-core-js` | `master` |
+
+
+
+
+The user provides a parent Asana task URL and a target repo (as a prefix or full name).
+
+1. **Extract the parent task GID** from the URL.
+2. **Fetch parent task context** using `asana-get-context.sh` to understand what work is needed.
+3. **Determine the target repo** from the user's input. If not specified, ask.
+4. **Validate dependency direction** using the hierarchy table. If the target is at a higher level than the parent, warn and ask for confirmation.
+
+
+
+Derive the dependent task name from the parent: `: `.
+
+If the parent task name already has a prefix (e.g. `gui: Some feature`), strip it and replace with the target prefix. If no prefix, prepend the target prefix.
+
+```bash
+~/.cursor/skills/dep-pr/scripts/asana-create-dep-task.sh \
+ --parent \
+ --name ": " \
+ --notes ""
+```
+
+The script:
+- Checks if a matching dependency already exists (by name) — if so, outputs `CREATED: false` and the existing GID
+- Creates the task in all parent project memberships (including release-version tags)
+- Copies priority, status, and `Planned` from the parent
+- Assigns to the current user (auto-resolved via `asana-whoami.sh`)
+- Sets the new task as a blocking dependency of the parent
+
+If `CREATED: false`, report the existing task to the user and continue with the existing GID.
+
+
+
+Delegate to the `pr-create.md` workflow using the **new** (or existing) task URL:
+
+1. `cd` to the target repo directory (see repo-map).
+2. **Read `~/.cursor/skills/pr-create/SKILL.md` now** (use the Read tool — do NOT skip this). Then follow its steps 1-6 (push, verify, build PR description, create PR, optional Asana updates, report).
+
+The Asana task context from step 1 provides the implementation requirements. The agent already has full context from the parent task.
+
+
+
+Display both the new Asana task and the PR as clickable links. Note the dependency relationship.
+
+
+
+The script detects this. Report: "Found existing dependent task: [link]. Continuing with PR workflow." Then proceed to step 3.
+The script falls back to the first available project. Warn the user if the placement looks wrong.
+Step 3 delegates to `pr-create.md` which handles branch state assessment.
+Ask: "Creating a [gui] task from a [core] parent is unusual — the dependency direction would be reversed. Confirm? (yes/no)"
+
diff --git a/.cursor/skills/dep-pr/scripts/asana-create-dep-task.sh b/.cursor/skills/dep-pr/scripts/asana-create-dep-task.sh
new file mode 100755
index 0000000..968627c
--- /dev/null
+++ b/.cursor/skills/dep-pr/scripts/asana-create-dep-task.sh
@@ -0,0 +1,245 @@
+#!/usr/bin/env bash
+# asana-create-dep-task.sh
+# Create a dependent Asana task that blocks a parent task.
+# Checks for existing dependencies first to avoid duplicates.
+#
+# Usage:
+# asana-create-dep-task.sh --parent --name "task name" [--notes "description"] [--assignee ]
+#
+# If --assignee is omitted, the task is assigned to the current user
+# (resolved via asana-whoami.sh).
+#
+# Requires env var: ASANA_TOKEN
+#
+# Output:
+# TASK_GID:
+# TASK_URL:
+# CREATED: true|false (false if task already existed)
+# ASSIGNED_TO:
+# FIELDS_SET: priority=, status=, planned=, reviewer=, implementor=
+# DEPENDENCY_SET: blocks
+#
+# Exit codes: 0 = success, 1 = error
+set -euo pipefail
+
+PARENT_GID=""
+TASK_NAME=""
+TASK_NOTES=""
+ASSIGNEE_GID=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --parent) PARENT_GID="$2"; shift 2 ;;
+ --name) TASK_NAME="$2"; shift 2 ;;
+ --notes) TASK_NOTES="$2"; shift 2 ;;
+ --assignee) ASSIGNEE_GID="$2"; shift 2 ;;
+ *) echo "Unknown flag: $1" >&2; exit 1 ;;
+ esac
+done
+
+if [[ -z "$PARENT_GID" || -z "$TASK_NAME" ]]; then
+ echo "Usage: asana-create-dep-task.sh --parent --name [--notes ] [--assignee ]" >&2
+ exit 1
+fi
+
+if [[ -z "${ASANA_TOKEN:-}" ]]; then
+ echo "Error: ASANA_TOKEN not set" >&2
+ exit 1
+fi
+
+API="https://app.asana.com/api/1.0"
+AUTH="Authorization: Bearer $ASANA_TOKEN"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Auto-resolve current user GID (used for assignee and implementor)
+CURRENT_USER_GID=$("$SCRIPT_DIR/../../asana-whoami.sh" 2>/dev/null || true)
+
+# Auto-resolve assignee to current user if not provided
+if [[ -z "$ASSIGNEE_GID" ]]; then
+ ASSIGNEE_GID="$CURRENT_USER_GID"
+fi
+
+# Phase 1: Check if a dependency with a matching name already exists
+existing=$(curl -s "$API/tasks/$PARENT_GID/dependencies?opt_fields=name&limit=100" \
+ -H "$AUTH" | python3 -c "
+import sys, json
+data = json.load(sys.stdin).get('data', [])
+target = '''$TASK_NAME'''
+for dep in data:
+ if dep.get('name', '').strip().lower() == target.strip().lower():
+ print(dep['gid'])
+ sys.exit(0)
+print('')
+")
+
+if [[ -n "$existing" ]]; then
+ echo "TASK_GID: $existing"
+ echo "TASK_URL: https://app.asana.com/0/0/$existing"
+ echo "CREATED: false"
+ exit 0
+fi
+
+# Phase 2: Get parent task's project and custom fields to copy
+parent_info=$(curl -s "$API/tasks/$PARENT_GID?opt_fields=workspace.gid,memberships.project.gid,memberships.project.name,custom_fields.gid,custom_fields.enum_value.gid,custom_fields.enum_value.name,custom_fields.people_value.gid,custom_fields.people_value.name" \
+ -H "$AUTH")
+
+read -r WORKSPACE_GID PROJECT_GIDS PRIORITY_INFO STATUS_INFO PLANNED_INFO REVIEWER_INFO < <(echo "$parent_info" | python3 -c "
+import sys, json, re
+data = json.load(sys.stdin)['data']
+ws = data.get('workspace', {}).get('gid', '')
+
+# Collect all parent projects (including release-version projects like 4.46.0)
+projects = []
+for m in data.get('memberships', []):
+ p = m.get('project', {})
+ gid = p.get('gid', '')
+ if gid:
+ projects.append(gid)
+if not projects and data.get('memberships'):
+ projects.append(data['memberships'][0]['project']['gid'])
+proj_str = ','.join(projects)
+
+# Field GIDs (stable known fields)
+ENUM_FIELDS = {
+ '795866930204488': 'priority',
+ '1190660107346181': 'status',
+}
+PEOPLE_FIELDS = {
+ '1203334388004673': 'reviewer',
+}
+
+enum_results = {}
+people_results = {}
+
+for f in data.get('custom_fields', []):
+ fgid = f['gid']
+ if fgid in ENUM_FIELDS and f.get('enum_value'):
+ label = ENUM_FIELDS[fgid]
+ enum_results[label] = (fgid, f['enum_value']['gid'], f['enum_value'].get('name', ''))
+ # "Planned" is workspace-specific, so detect by field name:
+ if f.get('name') == 'Planned' and f.get('enum_value'):
+ enum_results['planned'] = (
+ fgid,
+ f['enum_value']['gid'],
+ f['enum_value'].get('name', '')
+ )
+ if fgid in PEOPLE_FIELDS:
+ label = PEOPLE_FIELDS[fgid]
+ pv = f.get('people_value', [])
+ if pv:
+ people_results[label] = (fgid, pv[0]['gid'], pv[0].get('name', ''))
+
+def fmt_enum(key):
+ if key in enum_results:
+ return ':'.join(enum_results[key])
+ return '::'
+
+def fmt_people(key):
+ if key in people_results:
+ return ':'.join(people_results[key])
+ return '::'
+
+print(f\"{ws} {proj_str} {fmt_enum('priority')} {fmt_enum('status')} {fmt_enum('planned')} {fmt_people('reviewer')}\")
+")
+
+PRIORITY_FIELD=$(echo "$PRIORITY_INFO" | cut -d: -f1)
+PRIORITY_ENUM=$(echo "$PRIORITY_INFO" | cut -d: -f2)
+PRIORITY_NAME=$(echo "$PRIORITY_INFO" | cut -d: -f3)
+STATUS_FIELD=$(echo "$STATUS_INFO" | cut -d: -f1)
+STATUS_ENUM=$(echo "$STATUS_INFO" | cut -d: -f2)
+STATUS_NAME=$(echo "$STATUS_INFO" | cut -d: -f3)
+PLANNED_FIELD=$(echo "$PLANNED_INFO" | cut -d: -f1)
+PLANNED_ENUM=$(echo "$PLANNED_INFO" | cut -d: -f2)
+PLANNED_NAME=$(echo "$PLANNED_INFO" | cut -d: -f3)
+REVIEWER_FIELD=$(echo "$REVIEWER_INFO" | cut -d: -f1)
+REVIEWER_GID=$(echo "$REVIEWER_INFO" | cut -d: -f2)
+REVIEWER_NAME=$(echo "$REVIEWER_INFO" | cut -d: -f3)
+
+# Auto-resolve implementor to current user
+IMPLEMENTOR_FIELD="1203334386796983"
+IMPLEMENTOR_GID="$CURRENT_USER_GID"
+IMPLEMENTOR_NAME="current user"
+
+# Phase 3: Create the task
+NOTES_JSON=$(python3 -c "import json; print(json.dumps('''$TASK_NOTES'''))")
+
+# Build projects list from comma-separated GIDs
+IFS=',' read -ra PROJECT_ARR <<< "$PROJECT_GIDS"
+
+new_task=$(curl -s "$API/tasks" \
+ -H "$AUTH" \
+ -H "Content-Type: application/json" \
+ -d "$(python3 -c "
+import json
+projects = '''$PROJECT_GIDS'''.split(',')
+assignee = '''$ASSIGNEE_GID''' or None
+data = {
+ 'data': {
+ 'name': '''$TASK_NAME''',
+ 'notes': $NOTES_JSON,
+ 'projects': [p for p in projects if p],
+ 'workspace': '$WORKSPACE_GID'
+ }
+}
+if assignee:
+ data['data']['assignee'] = assignee
+print(json.dumps(data))
+")")
+
+NEW_GID=$(echo "$new_task" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+if 'errors' in data:
+ print('ERROR: ' + json.dumps(data['errors']), file=sys.stderr)
+ sys.exit(1)
+print(data['data']['gid'])
+")
+
+if [[ -z "$NEW_GID" || "$NEW_GID" == "ERROR"* ]]; then
+ echo "Error creating task" >&2
+ exit 1
+fi
+
+# Phase 3b: Set copied fields via shared updater script
+UPDATE_CMD=("$SCRIPT_DIR/../../asana-task-update/scripts/asana-task-update.sh" "--task" "$NEW_GID")
+if [[ -n "$PRIORITY_ENUM" ]]; then
+ UPDATE_CMD+=("--set-priority" "$PRIORITY_ENUM")
+fi
+if [[ -n "$STATUS_ENUM" ]]; then
+ UPDATE_CMD+=("--set-status" "$STATUS_ENUM")
+fi
+if [[ -n "$PLANNED_ENUM" ]]; then
+ UPDATE_CMD+=("--set-planned" "$PLANNED_ENUM")
+fi
+if [[ -n "$REVIEWER_GID" ]]; then
+ UPDATE_CMD+=("--set-reviewer" "$REVIEWER_GID")
+fi
+if [[ -n "$IMPLEMENTOR_GID" ]]; then
+ UPDATE_CMD+=("--set-implementor" "$IMPLEMENTOR_GID")
+fi
+if [[ ${#UPDATE_CMD[@]} -gt 3 ]]; then
+ "${UPDATE_CMD[@]}" > /dev/null
+fi
+
+FIRST_PROJECT=$(echo "$PROJECT_GIDS" | cut -d, -f1)
+echo "TASK_GID: $NEW_GID"
+echo "TASK_URL: https://app.asana.com/0/$FIRST_PROJECT/$NEW_GID"
+echo "CREATED: true"
+[[ -n "$ASSIGNEE_GID" ]] && echo "ASSIGNED_TO: $ASSIGNEE_GID"
+
+# Phase 4: Set as blocking dependency
+curl -s -X POST "$API/tasks/$PARENT_GID/addDependencies" \
+ -H "$AUTH" \
+ -H "Content-Type: application/json" \
+ -d "{\"data\": {\"dependencies\": [\"$NEW_GID\"]}}" > /dev/null
+
+echo "DEPENDENCY_SET: $NEW_GID blocks $PARENT_GID"
+
+fields_msg=""
+[[ -n "$PRIORITY_NAME" ]] && fields_msg="priority=$PRIORITY_NAME"
+[[ -n "$STATUS_NAME" ]] && fields_msg="${fields_msg:+$fields_msg, }status=$STATUS_NAME"
+[[ -n "$PLANNED_NAME" ]] && fields_msg="${fields_msg:+$fields_msg, }planned=$PLANNED_NAME"
+[[ -n "$REVIEWER_NAME" ]] && fields_msg="${fields_msg:+$fields_msg, }reviewer=$REVIEWER_NAME"
+[[ -n "$IMPLEMENTOR_GID" ]] && fields_msg="${fields_msg:+$fields_msg, }implementor=$IMPLEMENTOR_NAME"
+[[ -n "$fields_msg" ]] && echo "FIELDS_SET: $fields_msg"
diff --git a/.cursor/skills/fix-eslint/SKILL.md b/.cursor/skills/fix-eslint/SKILL.md
new file mode 100644
index 0000000..8904d9d
--- /dev/null
+++ b/.cursor/skills/fix-eslint/SKILL.md
@@ -0,0 +1,108 @@
+---
+name: fix-eslint
+description: Fix ESLint warnings by applying documented patterns. Use when addressing @typescript-eslint/no-deprecated warnings for NavigationBase, RouteProp, or other deprecated types in edge-react-gui.
+---
+
+Resolve ESLint `@typescript-eslint/no-deprecated` warnings by replacing deprecated type references with their non-deprecated equivalents.
+
+
+Run `npx tsc --noEmit` after every type change to verify no new type errors are introduced.
+Do not suppress deprecation warnings with `eslint-disable` comments. Fix the underlying type reference.
+Exception: `NavigationBase` deprecation in shared cross-navigator code (Categories C, D, F below) is accepted — not suppressed, genuinely not fixable without a broader v7 navigation migration. When the fix scope is too broad, add a TODO comment documenting the required migration pattern and accept the warning.
+Only modify files with deprecation warnings. Do not refactor downstream declarations unless required for the fix to compile.
+
+
+
+
+
+`NavigationBase` is a flat navigation type hack in `routerTypes.tsx` that unions all navigator param lists (`RootParamList & DrawerParamList & EdgeAppStackParamList & ...`) to pretend the app is flat. It is deprecated because it tracks **react-navigation v7 breaking changes**:
+
+1. `navigate()` no longer crosses nested navigator boundaries at runtime.
+2. `navigate()` no longer goes back to an existing screen to update params — use `popTo()` or `navigate(screen, params, { pop: true })` instead.
+
+v7 provides `navigateDeprecated()` and `navigationInChildEnabled` as temporary bridges, both removed in v8. **Do NOT create non-deprecated aliases** (like `AppNavigation`) — this hides a real migration requirement.
+
+Fix `NavigationBase` deprecation by identifying which category the usage falls into:
+
+**Category A — Pass-through props** (component accepts `NavigationBase` only to forward it to children or actions):
+- Fix: Remove the `navigation` prop. Callers already have navigation in scope. If the child needs navigation, it should use `useNavigation()` or accept specific callbacks.
+```typescript
+// Before — CancellableProcessingScene accepts navigation to forward to onError
+interface Props { navigation: NavigationBase; onError: (nav: NavigationBase, err: unknown) => void }
+
+// After — remove navigation prop, callers handle navigation in callbacks
+interface Props { onError: (err: unknown) => Promise }
+```
+
+**Category B — Direct navigation in non-scene components** (component accepts `NavigationBase`, calls `navigate()`/`push()` directly):
+- Fix: Replace `navigation: NavigationBase` prop with `useNavigation()` hook typed to the navigator context the component lives in. Or replace with specific navigation callbacks from the parent scene.
+```typescript
+// Before — BalanceCard accepts NavigationBase, calls navigate directly
+interface Props { navigation: NavigationBase }
+const BalanceCard: React.FC = props => {
+ props.navigation.push('send2', { walletId, tokenId })
+}
+
+// After (option 1) — useNavigation hook
+const BalanceCard: React.FC = props => {
+ const navigation = useNavigation['navigation']>()
+ navigation.push('send2', { walletId, tokenId })
+}
+
+// After (option 2) — navigation callbacks
+interface Props { onSend: (walletId: string, tokenId: EdgeTokenId) => void }
+```
+- If the fix would cascade to many callers or require determining the correct navigator context across multiple usages, add a `// TODO: Replace NavigationBase with useNavigation() or callbacks. Requires v7 navigation migration.` comment and move on.
+
+**Category C — Shared action/thunk functions** (functions in `src/actions/` accept `NavigationBase`):
+- Fix: Invert control. Replace the `navigation: NavigationBase` parameter with a callback for the navigation action the function needs.
+```typescript
+// Before — function navigates internally
+function activateWalletTokens(navigation: NavigationBase, wallet, tokenIds): ThunkAction> {
+ // ... calls navigation.navigate('editToken', ...) internally
+}
+
+// After — caller provides the navigate action
+function activateWalletTokens(wallet, tokenIds, onNavigate: (route: string, params: object) => void): ThunkAction> {
+ // ... calls onNavigate('editToken', ...) instead
+}
+```
+- Simpler alternative for single-navigate functions: Return the target route + params instead of navigating; let the caller dispatch.
+- If the function has many navigate calls to different screens or the refactoring would touch many callers, add a `// TODO: Remove NavigationBase dependency. Requires inversion of navigation control for v7 migration.` comment and move on.
+
+**Category D — Shared modal components** (modals accept `NavigationBase`, navigate after user interaction):
+- Fix: Modal returns a result via Airship bridge resolve; caller handles navigation based on the result. Or modal accepts navigation callbacks.
+- If the modal's navigation logic is complex (multiple paths), add a comment and move on.
+
+**Category E — Scene component casts** (`navigation as NavigationBase`):
+- These casts exist because the scene passes navigation to a Category A-D consumer.
+- Fix: No direct fix needed — casts disappear automatically when the consumer is migrated.
+- If the scene has its own `NavigationBase` usage unrelated to shared code, apply Category B fix.
+
+**Category F — Service components** (non-scene services: `DeepLinkingManager`, `AccountCallbackManager`, etc.):
+- These are the broadest migration cases. Always add: `// TODO: Remove NavigationBase dependency. Requires broader v7 navigation migration for service-level navigation.`
+- Do not attempt to fix these incrementally — they are cross-cutting and require dedicated migration work.
+
+
+
+Replace deprecated `RouteProp<'routeName'>` with the scene-specific route type.
+
+```typescript
+// Before
+import type { RouteProp } from '../../types/routerTypes'
+const route = useRoute>()
+
+// After
+import type { WalletsTabSceneProps } from '../../types/routerTypes'
+const route = useRoute['route']>()
+```
+
+Choose the scene props type that matches the navigator the component lives in:
+- `WalletsTabSceneProps` for walletList, walletDetails, transactionList, transactionDetails
+- `EdgeAppSceneProps` for routes in EdgeAppStackParamList
+- `SwapTabSceneProps` for swap routes
+- `BuySellTabSceneProps` for buy/sell routes
+- `RootSceneProps` for login, home, etc.
+
+
+
diff --git a/.cursor/skills/git-branch-ops.sh b/.cursor/skills/git-branch-ops.sh
new file mode 100755
index 0000000..1228f05
--- /dev/null
+++ b/.cursor/skills/git-branch-ops.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# git-branch-ops.sh
+# Shared deterministic git branch operations used by Cursor skills.
+#
+# Usage:
+# git-branch-ops.sh autosquash [--base | --merge-base-with ]
+# git-branch-ops.sh push [--remote ] [--branch ] [--force-with-lease]
+#
+# Exit codes:
+# 0 - success
+# 1 - error
+set -euo pipefail
+
+CMD="${1:-}"
+shift || true
+
+BASE=""
+MERGE_BASE_WITH=""
+REMOTE="origin"
+BRANCH=""
+FORCE_WITH_LEASE="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --base)
+ BASE="$2"
+ shift 2
+ ;;
+ --merge-base-with)
+ MERGE_BASE_WITH="$2"
+ shift 2
+ ;;
+ --remote)
+ REMOTE="$2"
+ shift 2
+ ;;
+ --branch)
+ BRANCH="$2"
+ shift 2
+ ;;
+ --force-with-lease)
+ FORCE_WITH_LEASE="true"
+ shift
+ ;;
+ *)
+ echo "Unknown arg: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+resolve_default_upstream() {
+ local upstream
+ upstream="$(
+ git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null \
+ || echo "origin/$(git remote show origin 2>/dev/null | sed -n '/HEAD branch/s/.*: //p')" \
+ || echo "origin/master"
+ )"
+ if [[ -z "$upstream" || "$upstream" == "origin/" ]]; then
+ echo "origin/master"
+ else
+ echo "$upstream"
+ fi
+}
+
+run_autosquash() {
+ if [[ -n "$BASE" && -n "$MERGE_BASE_WITH" ]]; then
+ echo "Error: Use either --base or --merge-base-with, not both" >&2
+ exit 1
+ fi
+
+ if [[ -z "$BASE" ]]; then
+ if [[ -z "$MERGE_BASE_WITH" ]]; then
+ MERGE_BASE_WITH="$(resolve_default_upstream)"
+ fi
+
+ BASE="$(git merge-base "$MERGE_BASE_WITH" HEAD 2>/dev/null || true)"
+ if [[ -z "$BASE" ]]; then
+ echo "Error: Could not determine merge-base with '$MERGE_BASE_WITH'" >&2
+ exit 1
+ fi
+ fi
+
+ rm -f "$(git rev-parse --git-path index.lock)"
+ GIT_EDITOR=true GIT_SEQUENCE_EDITOR=: git rebase -i "$BASE" --autosquash
+ echo ">> Autosquash complete (base: $BASE)"
+}
+
+run_push() {
+ if [[ -z "$BRANCH" ]]; then
+ BRANCH="$(git branch --show-current)"
+ fi
+ if [[ -z "$BRANCH" ]]; then
+ echo "Error: Could not determine current branch" >&2
+ exit 1
+ fi
+
+ if [[ "$FORCE_WITH_LEASE" == "true" ]]; then
+ git push --force-with-lease "$REMOTE" "$BRANCH"
+ echo ">> Push complete ($REMOTE/$BRANCH, mode: force-with-lease)"
+ else
+ git push "$REMOTE" "$BRANCH"
+ echo ">> Push complete ($REMOTE/$BRANCH, mode: plain)"
+ fi
+}
+
+case "$CMD" in
+ autosquash)
+ run_autosquash
+ ;;
+ push)
+ run_push
+ ;;
+ *)
+ echo "Usage: git-branch-ops.sh {autosquash|push} [args]" >&2
+ exit 1
+ ;;
+esac
diff --git a/.cursor/skills/im/SKILL.md b/.cursor/skills/im/SKILL.md
new file mode 100644
index 0000000..05fa032
--- /dev/null
+++ b/.cursor/skills/im/SKILL.md
@@ -0,0 +1,166 @@
+---
+name: im
+description: Implement an Asana task or ad-hoc feature/fix with clean, structured commits. Use when the user wants to implement a task, build a feature, or fix a bug in an Edge repository.
+compatibility: Requires git, gh, node, jq. ASANA_TOKEN for Asana integration.
+metadata:
+ author: j0ntz
+---
+
+Implement an Asana task or ad-hoc feature/fix with clean, well-structured commits.
+
+
+Before writing ANY code, read `.cursor/rules/typescript-standards.mdc` and follow all rules and standards in it throughout the implementation.
+Do NOT begin implementation until the user confirms the `/asana-plan` output (Step 0).
+Before the first edit to any `.ts` / `.tsx` file, run `~/.cursor/skills/im/scripts/lint-warnings.sh ` to auto-fix auto-fixable lint issues, then load any remaining lint findings and matching fix patterns into context. If the script changes files or leaves findings, handle those in a separate lint-fix commit IMMEDIATELY BEFORE the commit with actual changes. This applies to every `.ts` / `.tsx` file you touch, including ones discovered mid-implementation — not just the files you planned upfront. Do **not** run this script for non-TypeScript files such as `CHANGELOG.md`.
+Do not manually fix formatting. `lint-commit.sh` runs `eslint --fix` (which includes Prettier) before committing. If you see a formatting lint after editing, do NOT make another edit to fix it.
+Always commit using `~/.cursor/skills/lint-commit.sh -m "message" [files...]` or `--fixup ` for fixup commits.
+When committing with scoped file arguments, treat `src/locales/strings`, `eslint.config.mjs`, and snapshot files as expected auto-generated companion files in the same commit. If `lint-commit.sh` reports additional non-generated files outside the intended scope, evaluate whether the commit plan is wrong before continuing.
+The final commit history must read as a clean, straight-line progression — as if every decision was made correctly up front. Never preserve the "squiggly path" of development (adding then removing code, temporary scaffolding, exploratory commits). If you introduce something in commit A and remove it in commit B, restructure so the final history never contains it. Plan commits proactively to avoid this; when it happens anyway, restructure the branch before finishing.
+If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or other workarounds.
+`asana-get-context.sh` can take up to 90s and `install-deps.sh` can exceed 10s on repo prepare steps. Always use at least a 120000ms timeout for these scripts to avoid false failures from client-side time limits.
+
+
+
+Always delegate planning to `~/.cursor/skills/asana-plan/SKILL.md` first:
+
+- If user provided an Asana URL, run `/asana-plan` in Asana mode.
+- If user provided ad-hoc text or file references, run `/asana-plan` in text/file mode.
+
+`/asana-plan` returns a plan file path + short execution summary and waits for user confirmation. Start implementation only after that confirmation.
+
+### Regression analysis
+
+If the task describes a regression (e.g. "broke in version X", "stopped working after update"):
+
+1. **Identify the breaking commit** using `git log`, `git bisect`, or version tag comparison. Don't take the reported version from the task at face value — verify by examining the actual commit history.
+2. **Review the original change's full intent.** Find the associated PR and any linked tasks/discussions. The regression-causing commit likely had legitimate goals (performance, refactoring, new features). Understand ALL of its intended effects, not just the one that broke.
+3. **Ensure the fix preserves the original intent.** The fix must not undo the beneficial changes introduced by the regression commit. If the fix conflicts with the original intent, flag this to the user with tradeoffs before proceeding.
+
+
+
+After Step 0 determines the target repo (or if no Asana task, use the current repo):
+
+1. **Stash any uncommitted changes** (including untracked files) before switching branches: `git stash -u`
+2. Determine the correct branch state:
+ - **Wrong repo**: `cd` to the correct workspace repo directory.
+ - **On an unrelated feature branch**: Switch to the base branch (see "Branch from" column in `task-review.md`), then create a new feature branch.
+ - **On the base branch**: Create a new feature branch.
+ - **On the correct feature branch**: Continue.
+3. **Branch naming**: `$GIT_BRANCH_PREFIX/` or `$GIT_BRANCH_PREFIX/fix/` for bug fixes. Use kebab-case. Example: `/some-feature` or `/fix/some-bug`
+4. **Assume a new branch is needed** unless the current branch clearly matches the task. Do NOT ask for confirmation — the existing branch has its own committed work and is unaffected.
+5. **Install dependencies**: After creating or switching to the feature branch, run `~/.cursor/skills/install-deps.sh` with a timeout of at least 120000ms to ensure dependencies match the base branch state without false timeout failures.
+
+If the task spans multiple repos, note the additional repos but implement in the primary repo first.
+
+
+
+**Before writing ANY code**, run `lint-warnings.sh` on every planned `.ts` / `.tsx` file you plan to modify:
+
+```bash
+~/.cursor/skills/im/scripts/lint-warnings.sh ...
+```
+
+This script only accepts existing `.ts` / `.tsx` files.
+
+This script:
+
+1. Runs `eslint --fix`
+2. Detects files that will be "graduated" from the warning suppression list on commit, promoting their suppressed-rule warnings to errors in the output
+3. Shows any remaining findings grouped by rule (with graduation promotions already applied)
+4. Outputs matching fix patterns from `~/.cursor/rules/typescript-standards.mdc`
+5. Flags unmatched rules that need new patterns added
+
+If the script auto-fixes files or remaining findings exist:
+
+1. Fix all reported **errors** first — these include graduation-promoted warnings that will block `lint-commit.sh` after the file is removed from the suppression list
+2. Fix remaining **warnings** using the matched patterns in the output
+3. For **unmatched rules**: After fixing, add a new `` to `typescript-standards.mdc` so future occurrences have guidance
+4. Commit the pre-existing lint changes separately:
+ ```bash
+ ~/.cursor/skills/lint-commit.sh -m "Fix lint warnings in " ...
+ ```
+
+**Architectural vs mechanical fixes**: If a pattern notes "architectural change" (e.g., `styled()` refactoring), flag to user rather than fixing inline — these changes have broader impact and may warrant separate discussion.
+
+`lint-commit.sh` treats passed file arguments as the primary commit scope and only stages those files plus generated companion files (`src/locales/strings`, `eslint.config.mjs`, snapshots). It does not stage unrelated dirty files in the working tree.
+
+This ensures the subsequent feature commit introduces zero pre-existing lint findings for lintable TypeScript files. This is the initial pass — if you discover additional `.ts` / `.tsx` files to modify during Step 3, the same check applies (see Step 3).
+
+
+
+1. **Lint-check newly discovered TypeScript files**: If you need to modify a newly discovered `.ts` / `.tsx` file not covered in Step 2, run `~/.cursor/skills/im/scripts/lint-warnings.sh ` before editing it. If the script auto-fixes the file or leaves remaining pre-existing findings, commit those changes as a `--fixup` to the lint-fix commit from Step 2 (use `git log --oneline` to find the hash). If no lint-fix commit exists yet, create one. For non-TypeScript files such as `CHANGELOG.md`, skip this script and continue with the normal implementation flow.
+2. Break up the feature into multiple commits if necessary. Commit messages should be a concise title without tags like "feat" and a short body.
+3. Open relevant ts/tsx files before writing code.
+4. Commit using `lint-commit.sh`:
+ ```bash
+ ~/.cursor/skills/lint-commit.sh -m "commit message" [files...]
+ ```
+ You can optionally pass specific files to scope the commit.
+5. **Fixup commits**: When a change logically amends an earlier commit on the branch (e.g. fixing a typo from commit A, adding a missed import for commit B, adjusting behavior introduced in a prior commit), use a fixup commit instead of a standalone commit:
+ ```bash
+ ~/.cursor/skills/lint-commit.sh --fixup [files...]
+ ```
+ This marks the commit for automatic squashing into the target commit. Use `git log --oneline` to find the target hash.
+6. Include a `CHANGELOG.md` entry in the **last feature commit** (not a separate commit) using format: `- type: description`
+ - Types: `added`, `changed`, `fixed`
+ - Example: `- added: New short feature description`
+ - Entries are grouped by type in order: all `added`, then all `changed`, then all `fixed`
+ - CHANGELOG.md must ONLY appear in the last commit — never in intermediate feature commits
+ - Avoid reading more than 50 lines of the file
+ - **Which section** (see CHANGELOG placement rules below)
+
+
+
+The following apply only when working in the `edge-react-gui` repo:
+
+- New string literals should be added to `en_US.ts` in the SAME commit that uses them, not in a separate commit. The `lint-commit.sh` script runs the `localize` script automatically (via npm or yarn, auto-detected) when `en_US.ts` is in the changeset.
+- **Editing `en_US.ts`**: Use grep to find exact insertion points rather than reading the file in chunks. The file is ~2500 lines; reading it piecemeal wastes context. Example:
+ ```bash
+ rg -n "nearby_string_key" src/locales/en_US.ts
+ ```
+ Then use StrReplace with minimal context — only enough surrounding lines to make the match unique. Do NOT reformat existing lines in the replacement.
+
+### CHANGELOG placement (edge-react-gui)
+
+`edge-react-gui` has two active CHANGELOG sections: `## Unreleased (develop)` and `## X.Y.Z (staging)`. Which section to target depends on the Asana task's version project:
+
+1. **Read the staging version** from CHANGELOG: grep for `^## [0-9].*staging` to get the version (e.g. `4.43.0`).
+2. **Read the task's version project** from the `VERSION_PROJECT` field in the Asana context output (e.g. `4.44.0`).
+3. **Compare**:
+ - If `VERSION_PROJECT` matches the staging version → add entry under the `## X.Y.Z (staging)` heading.
+ - If `VERSION_PROJECT` does NOT match (or is not set) → add entry under `## Unreleased (develop)`.
+4. If no Asana context was fetched, default to `## Unreleased`.
+
+Other repos only have `## Unreleased` — no staging distinction.
+
+
+
+**Always run this step** — do not skip it and do not ask for permission. Review the branch history against the `clean-history` rule and automatically fix any issues found.
+
+1. **Check for an open PR**: Run `gh pr view --json url,reviews 2>/dev/null || echo '{}'` to determine if a PR exists and whether it has human review comments. Treat `{}` as the normal "no PR exists" case, not a failure.
+2. **If a PR exists with human review comments**, skip cleanup — rewriting history would lose review context. Note the pending cleanup in the retrospective.
+3. **Otherwise (no PR, or PR with no human reviews)**, always perform ALL applicable cleanup automatically:
+ - **Fixup commits exist**: Autosquash with `~/.cursor/skills/git-branch-ops.sh autosquash --base `. Do this immediately — never leave fixup commits unsquashed.
+ - **Reorder commits**: Use the companion script to reorder commits to the desired order. Hashes are oldest-to-newest:
+ ```bash
+ ~/.cursor/skills/im/scripts/reorder-commits.sh ...
+ ```
+ The script handles index lock cleanup, awk-based reordering, and verifies the tree is unchanged afterward.
+ - **Structural issues** (add-then-remove cycles, misplaced changes, commits that should be squashed, CHANGELOG in intermediate commits): Use `reorder-commits.sh` for reordering. For squash/drop operations, use `rm -f .git/index.lock && GIT_SEQUENCE_EDITOR="..." git rebase -i ` with an awk or sed script. Verify the final tree matches the pre-restructure state with `git diff`.
+
+
+
+Run full verification to catch issues that per-commit checks (`lint-commit.sh`) may have missed (e.g. transitive snapshot breakage, type errors across files):
+
+```bash
+~/.cursor/skills/verify-repo.sh . --base
+```
+
+Where `` is `origin/develop` for `edge-react-gui` or `origin/master` for other repos. Set `block_until_ms: 120000`.
+
+If verification fails, fix the issue with a fixup commit targeting the responsible commit, then re-run history cleanup (step 4) and verification.
+
+
+
+When finished, evaluate the context and propose potential improvements to this process — mistakes or errors in the tool calls, ways to improve excessive context bloat, etc.
+
diff --git a/.cursor/skills/im/scripts/lint-warnings.sh b/.cursor/skills/im/scripts/lint-warnings.sh
new file mode 100755
index 0000000..6bab0af
--- /dev/null
+++ b/.cursor/skills/im/scripts/lint-warnings.sh
@@ -0,0 +1,264 @@
+#!/usr/bin/env bash
+# lint-warnings.sh
+# Run eslint --fix on files and match any remaining findings to documented fix
+# patterns. Detects files that will be "graduated" from the ESLint warning
+# suppression list when committed, promoting their suppressed-rule warnings to
+# errors so they can be fixed before commit.
+#
+# Usage:
+# lint-warnings.sh [file2] ...
+#
+# Output:
+# 1. Summary of auto-fixes applied (if any)
+# 2. Graduation warnings (files that will be promoted to error severity)
+# 3. Summary of remaining findings per rule/severity
+# 4. Matched patterns from typescript-standards.mdc (full XML blocks)
+# 5. Unmatched rules (need new patterns added)
+#
+# Exit codes:
+# 0 - No remaining lint findings after auto-fix
+# 1 - Remaining lint findings after auto-fix
+# 2 - Error (missing files, eslint runtime/config failure, etc.)
+set -euo pipefail
+
+# Bump node heap for large repos (edge-currency-accountbased etc. OOM at the
+# default ~4GB). Append rather than overwrite so an outer NODE_OPTIONS wins.
+export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=8192"
+
+PATTERNS_FILE="$HOME/.cursor/rules/typescript-standards.mdc"
+
+if [[ $# -eq 0 ]]; then
+ echo "Usage: lint-warnings.sh [file2] ..." >&2
+ exit 2
+fi
+
+# Filter to existing .ts/.tsx files
+FILES=()
+for f in "$@"; do
+ if [[ ("$f" == *.ts || "$f" == *.tsx) && -f "$f" ]]; then
+ FILES+=("$f")
+ fi
+done
+
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ echo "No .ts/.tsx files found" >&2
+ exit 2
+fi
+
+# Run eslint with --fix, then classify any remaining lint findings.
+TMP_JSON="$(mktemp)"
+TMP_ERR="$(mktemp)"
+trap 'rm -f "$TMP_JSON" "$TMP_ERR"' EXIT
+
+set +e
+./node_modules/.bin/eslint --fix --format json "${FILES[@]}" >"$TMP_JSON" 2>"$TMP_ERR"
+ESLINT_EXIT=$?
+set -e
+
+node -e '
+const fs = require("fs");
+const path = require("path");
+
+const patternsFile = process.argv[1];
+const jsonFile = process.argv[2];
+const errFile = process.argv[3];
+const eslintExit = Number(process.argv[4]);
+
+let input = "";
+let stderrText = "";
+try {
+ input = fs.readFileSync(jsonFile, "utf8");
+} catch (error) {
+ console.error("Failed to read eslint JSON output");
+ process.exit(2);
+}
+
+try {
+ stderrText = fs.readFileSync(errFile, "utf8").trim();
+} catch (error) {
+ stderrText = "";
+}
+
+if (input.trim() === "") {
+ if (stderrText !== "") console.error(stderrText);
+ console.error("ESLint produced no JSON output");
+ process.exit(2);
+}
+
+let results;
+try {
+ results = JSON.parse(input);
+} catch (error) {
+ if (stderrText !== "") console.error(stderrText);
+ console.error("Failed to parse eslint output");
+ process.exit(2);
+}
+
+if (!Array.isArray(results)) {
+ console.error("Unexpected eslint JSON format");
+ process.exit(2);
+}
+
+// --- Graduation detection ---
+// Parse eslint.config.mjs to find files in the warning-suppression list.
+// These files currently have certain rules at "warn" severity, but committing
+// them removes them from the list (via update-eslint-warnings), promoting
+// those rules to "error". We detect this ahead of time so the agent can fix
+// them in a lint-fix commit before the feature commit.
+const GRADUATED_RULES = new Set([
+ "@typescript-eslint/ban-ts-comment",
+ "@typescript-eslint/explicit-function-return-type",
+ "@typescript-eslint/strict-boolean-expressions",
+ "@typescript-eslint/use-unknown-in-catch-callback-variable"
+]);
+
+const suppressedFiles = new Set();
+try {
+ const configPath = path.join(process.cwd(), "eslint.config.mjs");
+ const configContent = fs.readFileSync(configPath, "utf8");
+ // Extract file paths from the suppression block (single-quoted strings)
+ for (const m of configContent.matchAll(/^\s+\x27([^\x27]+)\x27,?\s*$/gm)) {
+ suppressedFiles.add(m[1]);
+ }
+} catch (error) {
+ // No eslint.config.mjs or parse failure — skip graduation detection
+}
+
+const findingsBySeverity = new Map([
+ [2, new Map()],
+ [1, new Map()]
+]);
+let totalErrors = 0;
+let totalWarnings = 0;
+let graduatedCount = 0;
+let autoFixedFiles = 0;
+
+for (const file of results) {
+ if (file != null && typeof file.output === "string") autoFixedFiles += 1;
+
+ const rel = path.relative(process.cwd(), file.filePath);
+ const willGraduate = suppressedFiles.has(rel);
+
+ for (const message of file.messages) {
+ if (message.severity !== 1 && message.severity !== 2) continue;
+
+ const rule = message.ruleId || "unknown";
+
+ // Promote suppressed-rule warnings to errors for files that will graduate
+ let effectiveSeverity = message.severity;
+ if (willGraduate && message.severity === 1 && GRADUATED_RULES.has(rule)) {
+ effectiveSeverity = 2;
+ graduatedCount += 1;
+ }
+
+ const findingsForSeverity = findingsBySeverity.get(effectiveSeverity);
+ if (!findingsForSeverity.has(rule)) {
+ findingsForSeverity.set(rule, []);
+ }
+ findingsForSeverity.get(rule).push({
+ file: rel,
+ line: message.line,
+ message: message.message
+ });
+
+ if (effectiveSeverity === 2) totalErrors += 1;
+ else totalWarnings += 1;
+ }
+}
+
+if (eslintExit > 1 && totalErrors === 0 && totalWarnings === 0) {
+ if (stderrText !== "") console.error(stderrText);
+ console.error("ESLint failed before reporting lint findings");
+ process.exit(2);
+}
+
+if (autoFixedFiles > 0) {
+ console.log(`>> Auto-fixed ${autoFixedFiles} file(s)`);
+}
+
+if (graduatedCount > 0) {
+ console.log(`>> ${graduatedCount} warning(s) promoted to errors (graduation: file will be removed from suppression list on commit)`);
+}
+
+if (totalErrors === 0 && totalWarnings === 0) {
+ console.log(">> No remaining lint findings");
+ process.exit(0);
+}
+
+let patternsContent = "";
+try {
+ patternsContent = fs.readFileSync(patternsFile, "utf8");
+} catch (error) {
+ console.error("Warning: Could not read patterns file:", patternsFile);
+}
+
+const patternRegex = /([\s\S]*?)<\/pattern>/g;
+const patterns = new Map();
+let match;
+while ((match = patternRegex.exec(patternsContent)) !== null) {
+ const [fullMatch, id, rule] = match;
+ if (!patterns.has(rule)) {
+ patterns.set(rule, []);
+ }
+ patterns.get(rule).push({ id, fullMatch });
+}
+
+if (totalErrors > 0) {
+ console.log(`>> ${totalErrors} remaining error(s)`);
+}
+if (totalWarnings > 0) {
+ console.log(`>> ${totalWarnings} remaining warning(s)`);
+}
+
+const printFindings = (heading, findingsByRule) => {
+ if (findingsByRule.size === 0) return;
+
+ console.log(`\n=== ${heading} ===`);
+ for (const [rule, instances] of [...findingsByRule.entries()].sort((left, right) => right[1].length - left[1].length)) {
+ console.log(`\n${rule} (${instances.length}x):`);
+ for (const inst of instances.slice(0, 3)) {
+ console.log(` ${inst.file}:${inst.line} - ${inst.message}`);
+ }
+ if (instances.length > 3) {
+ console.log(` ... and ${instances.length - 3} more`);
+ }
+ }
+};
+
+printFindings("Remaining Errors by Rule", findingsBySeverity.get(2));
+printFindings("Remaining Warnings by Rule", findingsBySeverity.get(1));
+
+const matchedRules = [];
+const unmatchedRules = [];
+const seenRules = new Set();
+for (const findingsByRule of findingsBySeverity.values()) {
+ for (const rule of findingsByRule.keys()) {
+ if (seenRules.has(rule)) continue;
+ seenRules.add(rule);
+ if (patterns.has(rule)) {
+ matchedRules.push(rule);
+ } else {
+ unmatchedRules.push(rule);
+ }
+ }
+}
+
+if (matchedRules.length > 0) {
+ console.log("\n\n=== Matched Fix Patterns ===");
+ for (const rule of matchedRules) {
+ for (const pattern of patterns.get(rule)) {
+ console.log(`\n${pattern.fullMatch}`);
+ }
+ }
+}
+
+if (unmatchedRules.length > 0) {
+ console.log("\n\n=== Unmatched Rules (need patterns added) ===");
+ for (const rule of unmatchedRules) {
+ console.log(`- ${rule}`);
+ }
+ console.log("\nAfter fixing these, add patterns to ~/.cursor/rules/typescript-standards.mdc");
+}
+
+process.exit(1);
+' -- "$PATTERNS_FILE" "$TMP_JSON" "$TMP_ERR" "$ESLINT_EXIT"
diff --git a/.cursor/skills/im/scripts/reorder-commits.sh b/.cursor/skills/im/scripts/reorder-commits.sh
new file mode 100755
index 0000000..700c285
--- /dev/null
+++ b/.cursor/skills/im/scripts/reorder-commits.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# reorder-commits.sh
+# Reorder commits on a branch to a specified order using non-interactive rebase.
+#
+# Usage:
+# reorder-commits.sh ...
+#
+# Arguments:
+# base-branch The branch/ref to rebase onto (e.g., origin/develop)
+# hash1..N Commit hashes in desired order (oldest to newest)
+#
+# The script verifies all hashes exist in base..HEAD, writes an awk-based
+# GIT_SEQUENCE_EDITOR to reorder the pick lines, and runs git rebase -i.
+# It verifies the tree is unchanged after rebase.
+#
+# Exit codes:
+# 0 - Reorder successful
+# 1 - Reorder failed (conflict, missing commits, tree mismatch)
+set -euo pipefail
+
+if [[ $# -lt 3 ]]; then
+ echo "Usage: reorder-commits.sh ..." >&2
+ exit 1
+fi
+
+BASE="$1"
+shift
+DESIRED_ORDER=("$@")
+
+# Remove stale index locks
+rm -f .git/index.lock
+
+# Get short hashes for matching rebase todo lines
+BRANCH_COMMITS=$(git log --reverse --format='%h' "$BASE..HEAD")
+BRANCH_COUNT=$(echo "$BRANCH_COMMITS" | wc -l | tr -d ' ')
+DESIRED_COUNT=${#DESIRED_ORDER[@]}
+
+if [[ "$BRANCH_COUNT" -ne "$DESIRED_COUNT" ]]; then
+ echo "Error: Branch has $BRANCH_COUNT commits but $DESIRED_COUNT hashes were provided" >&2
+ echo "Branch commits: $BRANCH_COMMITS" >&2
+ exit 1
+fi
+
+# Resolve desired hashes to short hashes and verify they're on the branch
+DESIRED_SHORT=()
+for hash in "${DESIRED_ORDER[@]}"; do
+ short=$(git rev-parse --short "$hash" 2>/dev/null) || {
+ echo "Error: Cannot resolve hash '$hash'" >&2
+ exit 1
+ }
+ if ! echo "$BRANCH_COMMITS" | grep -q "^${short}$"; then
+ echo "Error: Commit $short is not in $BASE..HEAD" >&2
+ exit 1
+ fi
+ DESIRED_SHORT+=("$short")
+done
+
+# Capture pre-rebase tree for verification
+PRE_TREE=$(git rev-parse HEAD^{tree})
+
+# Build awk script that reorders pick lines to match desired order
+# The awk program collects all pick lines, then outputs them in the order
+# specified by the DESIRED env var (space-separated short hashes)
+EDITOR_SCRIPT=$(mktemp)
+trap 'rm -f "$EDITOR_SCRIPT"' EXIT
+
+cat > "$EDITOR_SCRIPT" << 'AWKSCRIPT'
+#!/usr/bin/env bash
+exec awk -v desired="$DESIRED" '
+BEGIN {
+ n = split(desired, order, " ")
+}
+/^pick / {
+ hash = $2
+ lines[hash] = $0
+ next
+}
+/^$/ || /^#/ { next }
+END {
+ for (i = 1; i <= n; i++) {
+ for (h in lines) {
+ if (index(h, order[i]) == 1 || index(order[i], h) == 1) {
+ print lines[h]
+ break
+ }
+ }
+ }
+}
+' "$1" > "$1.tmp" && mv "$1.tmp" "$1"
+AWKSCRIPT
+chmod +x "$EDITOR_SCRIPT"
+
+export DESIRED="${DESIRED_SHORT[*]}"
+if GIT_SEQUENCE_EDITOR="$EDITOR_SCRIPT" git rebase -i "$BASE" 2>/dev/null; then
+ POST_TREE=$(git rev-parse HEAD^{tree})
+ if [[ "$PRE_TREE" == "$POST_TREE" ]]; then
+ echo ">> Commits reordered successfully"
+ git log --oneline "$BASE..HEAD"
+ else
+ echo "Error: Tree changed after reorder (pre: $PRE_TREE, post: $POST_TREE)" >&2
+ echo "This indicates content was lost or modified during rebase." >&2
+ exit 1
+ fi
+else
+ git rebase --abort 2>/dev/null || true
+ echo "Error: Rebase failed (likely conflict). Aborted." >&2
+ exit 1
+fi
diff --git a/.cursor/skills/install-deps.sh b/.cursor/skills/install-deps.sh
new file mode 100755
index 0000000..ad24746
--- /dev/null
+++ b/.cursor/skills/install-deps.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# install-deps.sh — Install dependencies and run prepare script.
+# Usage: install-deps.sh [repo-dir]
+#
+# Detects npm vs yarn from lockfile:
+# - package-lock.json present → npm
+# - yarn.lock present → yarn
+# - both → prefer npm (recently-migrated repos may keep yarn.lock until cleanup)
+# - neither → default to npm
+#
+# Runs ` install` and ` run prepare` (if prepare script exists).
+# Use after: branch creation, rebase onto upstream, checkout.
+#
+# Exit codes:
+# 0 = Success (or no package.json — skipped)
+# 1 = Install or prepare failed
+
+repo_dir="${1:-.}"
+
+if [ ! -f "$repo_dir/package.json" ]; then
+ echo "⏭ No package.json — skipping dependency install" >&2
+ exit 0
+fi
+
+# Detect package manager
+if [ -f "$repo_dir/package-lock.json" ]; then
+ PM="npm"
+elif [ -f "$repo_dir/yarn.lock" ]; then
+ PM="yarn"
+else
+ PM="npm"
+fi
+
+echo "Installing dependencies (using $PM)..." >&2
+
+if [ "$PM" = "npm" ]; then
+ (cd "$repo_dir" && npm install --no-audit --no-fund)
+else
+ (cd "$repo_dir" && yarn install)
+fi
+
+if (cd "$repo_dir" && node -e "process.exit(require('./package.json').scripts?.prepare ? 0 : 1)" 2>/dev/null); then
+ echo "Running prepare (using $PM)..." >&2
+ if [ "$PM" = "npm" ]; then
+ (cd "$repo_dir" && npm run prepare)
+ else
+ (cd "$repo_dir" && yarn prepare)
+ fi
+fi
+
+echo "✓ Dependencies installed and prepared (via $PM)" >&2
diff --git a/.cursor/skills/lint-commit.sh b/.cursor/skills/lint-commit.sh
new file mode 100755
index 0000000..60abb8a
--- /dev/null
+++ b/.cursor/skills/lint-commit.sh
@@ -0,0 +1,362 @@
+#!/usr/bin/env bash
+# lint-commit.sh
+# Lint-fix, verify, localize (if needed), and commit in one atomic step.
+#
+# Usage:
+# lint-commit.sh -m "commit message" [file ...]
+# lint-commit.sh --fixup [file ...]
+# lint-commit.sh -m "fixup! Original commit" [file ...] # Auto-reorders
+#
+# Options:
+# -m "msg" Commit message (mutually exclusive with --fixup)
+# --fixup Create a fixup commit targeting
+# --reorder After fixup commit, autosquash from merge-base with upstream (default: true)
+# --no-reorder Skip the autosquash follow-up
+#
+# If files are given, they are the primary scope for linting/committing.
+# The script may also auto-include generated companion files like:
+# - src/locales/strings
+# - eslint.config.mjs
+# - __snapshots__/*.snap
+# Any additional non-generated files are reported before commit.
+# If no files are given, all staged + unstaged + untracked changes are used.
+# The script will:
+# 1. Run eslint --fix on .ts/.tsx files
+# 2. Run eslint --quiet to verify no remaining errors (exits 1 if any)
+# 2b. Check for new warnings on changed lines (exits 1 if any)
+# 3. Run the localize script via the repo's package manager (npm if
+# package-lock.json exists, else yarn if yarn.lock exists, else npm)
+# 4. git add -A && git commit --no-verify
+# 5. Run jest --findRelatedTests -u on committed .ts/.tsx files
+# 6. If snapshots changed, amend the commit to include them
+# 7. If commit is a fixup (--fixup or -m "fixup! ..."), autosquash via shared helper
+set -euo pipefail
+
+# Bump node heap for large repos (default ~4GB OOMs on big codebases).
+# Append rather than overwrite so an outer NODE_OPTIONS wins.
+export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=8192"
+
+# UNSAFE yarn workaround. The Socket CLI's `yarn` wrapper is broken in this agent
+# environment: `~/.agent-shims/yarn` execs `socket yarn`, but socket re-resolves
+# `yarn` via PATH, re-finds the same shim, and recurses until it dies (npm/npx
+# wrappers work because socket locates their real binaries). Strip the shim dir
+# from PATH so `yarn` resolves to the real binary. Tradeoff: bypasses Socket's
+# supply-chain scanning for yarn. npm keeps the working socket wrapper.
+#
+# Also default NPM_TOKEN to empty: the hardened ~/.npmrc references
+# `${NPM_TOKEN}` for registry auth, and yarn v1 aborts at startup if the var is
+# undefined (npm tolerates it). Local scripts like `localize` need no registry
+# auth, so an empty token is harmless; a real exported NPM_TOKEN still wins.
+run_yarn() {
+ NPM_TOKEN="${NPM_TOKEN:-}" \
+ PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '/\.agent-shims$' | paste -sd ':' -)" \
+ yarn "$@"
+}
+
+MESSAGE=""
+FIXUP=""
+REORDER="true" # Default to reordering fixups
+FILES=()
+PRIMARY_SCOPE_DECLARED="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -m)
+ MESSAGE="$2"
+ shift 2
+ ;;
+ --fixup)
+ FIXUP="$2"
+ shift 2
+ ;;
+ --reorder)
+ REORDER="true"
+ shift
+ ;;
+ --no-reorder)
+ REORDER="false"
+ shift
+ ;;
+ *)
+ FILES+=("$1")
+ shift
+ ;;
+ esac
+done
+
+if [[ ${#FILES[@]} -gt 0 ]]; then
+ PRIMARY_SCOPE_DECLARED="true"
+fi
+
+if [[ -z "$MESSAGE" && -z "$FIXUP" ]]; then
+ echo "Error: -m \"commit message\" or --fixup is required" >&2
+ exit 1
+fi
+if [[ -n "$MESSAGE" && -n "$FIXUP" ]]; then
+ echo "Error: -m and --fixup are mutually exclusive" >&2
+ exit 1
+fi
+
+# If no files specified, collect all changed/untracked files
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ while IFS= read -r f; do
+ [[ -n "$f" ]] && FILES+=("$f")
+ done < <(git diff --name-only HEAD 2>/dev/null; git diff --name-only --cached 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null)
+
+ # Deduplicate (compatible with macOS Bash 3.2 — no mapfile)
+ if [[ ${#FILES[@]} -gt 0 ]]; then
+ DEDUPED=()
+ while IFS= read -r f; do
+ [[ -n "$f" ]] && DEDUPED+=("$f")
+ done < <(printf '%s\n' "${FILES[@]}" | sort -u)
+ FILES=("${DEDUPED[@]}")
+ fi
+fi
+
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ echo "Error: No changed files found" >&2
+ exit 1
+fi
+
+# Filter to lintable files (.ts/.tsx) that exist on disk
+LINT_FILES=()
+for f in "${FILES[@]}"; do
+ if [[ ("$f" == *.ts || "$f" == *.tsx) && -f "$f" ]]; then
+ LINT_FILES+=("$f")
+ fi
+done
+
+# Step 1: eslint --fix
+if [[ ${#LINT_FILES[@]} -gt 0 ]]; then
+ echo ">> eslint --fix (${#LINT_FILES[@]} files)"
+ ./node_modules/.bin/eslint --fix "${LINT_FILES[@]}" || true
+
+ # Step 2: eslint --quiet (must pass)
+ echo ">> eslint --quiet (verify)"
+ if ! ./node_modules/.bin/eslint --quiet "${LINT_FILES[@]}"; then
+ echo "Error: Lint errors remain after --fix. Aborting commit." >&2
+ exit 1
+ fi
+ echo ">> Lint clean"
+
+ # Step 2b: Detect new warnings introduced on changed lines.
+ # Runs eslint (with warnings) and cross-references against git diff to
+ # only flag warnings on lines the developer actually touched.
+ NEW_WARN=$(node -e '
+const { execSync } = require("child_process")
+const path = require("path")
+
+const files = process.argv.slice(1)
+const cmd = "./node_modules/.bin/eslint --format json " + files.map(f => JSON.stringify(f)).join(" ")
+
+let results
+try {
+ results = JSON.parse(execSync(cmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }))
+} catch (e) {
+ if (e.stdout) try { results = JSON.parse(e.stdout) } catch { process.exit(0) }
+ else process.exit(0)
+}
+
+const cwd = process.cwd()
+const out = []
+
+for (const r of results) {
+ const rel = path.relative(cwd, r.filePath)
+ const warns = r.messages.filter(m => m.severity === 1)
+ if (warns.length === 0) continue
+
+ // Determine which lines were changed in this file
+ let changed
+ try {
+ execSync("git cat-file -e HEAD:" + JSON.stringify(rel), { stdio: "pipe" })
+ const diff = execSync("git diff -U0 HEAD -- " + JSON.stringify(rel), { encoding: "utf8" })
+ changed = new Set()
+ for (const m of diff.matchAll(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/g)) {
+ const start = +m[1]
+ const count = m[2] != null ? +m[2] : 1
+ for (let i = start; i < start + count; i++) changed.add(i)
+ }
+ } catch {
+ changed = null // New file — all lines count as changed
+ }
+
+ for (const w of warns) {
+ if (changed == null || changed.has(w.line)) {
+ out.push(rel + ":" + w.line + ":" + w.column + " warning " + w.message + " " + w.ruleId)
+ }
+ }
+}
+
+if (out.length > 0) console.log(out.join("\n"))
+' -- "${LINT_FILES[@]}" 2>/dev/null || true)
+
+ if [[ -n "$NEW_WARN" ]]; then
+ echo ">> New warnings on changed lines:" >&2
+ echo "$NEW_WARN" >&2
+ echo "Error: Fix new warnings before committing." >&2
+ exit 1
+ fi
+fi
+
+# Step 3: run the project's localize script (if defined), using the repo's
+# package manager. Auto-detects npm vs yarn so the script works across repos
+# that have migrated between the two without manual updates.
+if node -e "process.exit(require('./package.json').scripts?.localize ? 0 : 1)" 2>/dev/null; then
+ if [[ -f package-lock.json ]]; then
+ echo ">> npm run localize"
+ npm run --silent localize
+ elif [[ -f yarn.lock ]]; then
+ echo ">> yarn localize"
+ run_yarn localize
+ else
+ echo ">> npm run localize (no lockfile detected, defaulting to npm)"
+ npm run --silent localize
+ fi
+fi
+
+# Step 4: Stage files and report effective commit scope
+if [[ "$PRIMARY_SCOPE_DECLARED" == "true" ]]; then
+ echo ">> git add (scoped) && git commit"
+ git add -- "${FILES[@]}"
+ # Stage generated companion files if they have changes
+ for companion in eslint.config.mjs; do
+ if [[ -f "$companion" ]] && ! git diff --quiet -- "$companion" 2>/dev/null; then
+ git add -- "$companion"
+ fi
+ done
+ # Stage locales/strings if the localize script changed them (already
+ # git-added by localize in some repos, but ensure they're staged)
+ if git diff --quiet --cached -- src/locales/strings 2>/dev/null; then
+ git diff --quiet -- src/locales/strings 2>/dev/null || git add -- src/locales/strings/ 2>/dev/null || true
+ fi
+else
+ echo ">> git add -A && git commit"
+ git add -A
+fi
+
+# Graduate files from eslint warning-override list if the repo has the script
+if node -e "process.exit(require('./package.json').scripts?.['update-eslint-warnings'] ? 0 : 1)" 2>/dev/null; then
+ echo ">> update-eslint-warnings"
+ npm run --silent update-eslint-warnings
+
+ # Safety net: update-eslint-warnings (or any repo-side script) may have
+ # auto-staged config changes that introduce errors — e.g., naively
+ # graduating a file off a warning-override list when the file still has
+ # demoted rule violations. Re-validate; if eslint now fails, restore
+ # eslint.config.mjs so the bad config can't ride into a commit.
+ if [[ ${#LINT_FILES[@]} -gt 0 ]] && ! ./node_modules/.bin/eslint --quiet "${LINT_FILES[@]}" 2>/dev/null; then
+ echo "Error: post-graduation lint failed. Restoring eslint.config.mjs and aborting." >&2
+ git checkout HEAD -- eslint.config.mjs 2>/dev/null || true
+ git reset HEAD -- eslint.config.mjs 2>/dev/null || true
+ exit 1
+ fi
+fi
+
+if [[ "$PRIMARY_SCOPE_DECLARED" == "true" ]]; then
+ echo ">> commit scope report"
+ node -e '
+const { execSync } = require("child_process")
+
+const requested = [...new Set(process.argv.slice(1))].sort()
+const staged = execSync("git diff --cached --name-only --diff-filter=ACMRD", {
+ encoding: "utf8"
+})
+ .split("\n")
+ .map(line => line.trim())
+ .filter(Boolean)
+ .sort()
+
+const requestedSet = new Set(requested)
+const isGeneratedCompanion = file => {
+ return (
+ file === "eslint.config.mjs" ||
+ file === "src/locales/strings" ||
+ /(^|\/)__snapshots__\/.*\.snap$/.test(file)
+ )
+}
+
+const requestedStaged = []
+const generatedStaged = []
+const extraStaged = []
+for (const file of staged) {
+ if (requestedSet.has(file)) {
+ requestedStaged.push(file)
+ } else if (isGeneratedCompanion(file)) {
+ generatedStaged.push(file)
+ } else {
+ extraStaged.push(file)
+ }
+}
+
+const missingRequested = requested.filter(file => !staged.includes(file))
+
+const printGroup = (title, files) => {
+ if (files.length === 0) return
+ console.log(title)
+ for (const file of files) console.log("- " + file)
+}
+
+printGroup("Primary scope staged:", requestedStaged)
+printGroup("Auto-generated companion files staged:", generatedStaged)
+printGroup("Additional non-generated files staged:", extraStaged)
+printGroup("Requested files not staged:", missingRequested)
+
+if (extraStaged.length > 0) {
+ console.log("Proceeding with additional non-generated files by default.")
+}
+' -- "${FILES[@]}"
+fi
+
+if [[ -n "$FIXUP" ]]; then
+ git commit --no-verify --fixup "$FIXUP"
+else
+ git commit --no-verify -m "$MESSAGE"
+fi
+
+# Step 5: Update snapshots for related tests (Jest only)
+if [[ ${#LINT_FILES[@]} -gt 0 && -x ./node_modules/.bin/jest ]]; then
+ echo ">> jest --findRelatedTests -u (${#LINT_FILES[@]} files)"
+ ./node_modules/.bin/jest --findRelatedTests "${LINT_FILES[@]}" -u 2>&1 || true
+
+ # Step 6: If snapshots changed, amend the commit
+ SNAP_CHANGES=$(git diff --name-only -- '**/__snapshots__/**' 2>/dev/null || true)
+ if [[ -n "$SNAP_CHANGES" ]]; then
+ echo ">> Snapshots updated, amending commit:"
+ echo "$SNAP_CHANGES"
+ if [[ "$PRIMARY_SCOPE_DECLARED" == "true" ]]; then
+ echo ">> Auto-generated companion files staged:"
+ echo "$SNAP_CHANGES"
+ fi
+ git add -- $SNAP_CHANGES
+ git commit --amend --no-edit --no-verify
+ else
+ echo ">> No snapshot changes"
+ fi
+fi
+
+# Step 7: Autosquash fixup commits when requested
+# Detects fixup commits by --fixup flag or "fixup! " prefix in message
+IS_FIXUP="false"
+if [[ -n "$FIXUP" ]]; then
+ IS_FIXUP="true"
+elif [[ "$MESSAGE" == fixup!* ]]; then
+ IS_FIXUP="true"
+fi
+
+if [[ "$IS_FIXUP" == "true" && "$REORDER" == "true" ]]; then
+ echo ">> Autosquashing fixup commit..."
+
+ DEFAULT_UPSTREAM=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null \
+ || echo "origin/$(git remote show origin 2>/dev/null | sed -n '/HEAD branch/s/.*: //p')" \
+ || echo "origin/master")
+
+ if ~/.cursor/skills/git-branch-ops.sh autosquash --merge-base-with "$DEFAULT_UPSTREAM" 2>/dev/null; then
+ echo ">> Fixup autosquashed successfully"
+ else
+ git rebase --abort 2>/dev/null || true
+ echo ">> Warning: Could not autosquash fixup (conflict). Fixup remains at HEAD." >&2
+ echo ">> Run '~/.cursor/skills/git-branch-ops.sh autosquash --merge-base-with $DEFAULT_UPSTREAM' manually." >&2
+ fi
+fi
+
+echo ">> Done"
diff --git a/.cursor/skills/no-slop/SKILL.md b/.cursor/skills/no-slop/SKILL.md
new file mode 100644
index 0000000..1bf4749
--- /dev/null
+++ b/.cursor/skills/no-slop/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: no-slop
+description: Avoid AI writing patterns when producing prose. Use when writing articles, blog posts, documentation, emails, proposals, reports, README content, PR descriptions, release notes, or any long-form text. Activates for drafting, composing, or writing tasks.
+---
+
+# no-slop — Anti-AI-Detection Rules
+
+When writing any prose (articles, docs, emails, reports, descriptions, proposals), follow every rule below. These patterns are documented tells of AI-generated text. Violating even a few destroys credibility.
+
+## 1. Banned vocabulary
+
+Do NOT use any word or phrase listed in [banned-vocabulary.md](banned-vocabulary.md). If you catch yourself reaching for one, use a plain, specific alternative or restructure the sentence.
+
+## 2. Use simple copulas
+
+Use "is," "are," "was," "has," "had." Do not substitute with:
+- "serves as," "stands as," "represents," "marks"
+- "boasts," "features," "offers"
+- "ventured into" instead of "tried" or "ran for"
+
+Bad: "The library serves as a foundational component in the ecosystem."
+Good: "The library is the base of the stack."
+
+## 3. No promotional tone
+
+Write like a journalist or engineer, not a marketer. Never hype. State facts and let them speak.
+
+Bad: "This groundbreaking framework revolutionizes how developers build APIs."
+Good: "This framework generates API clients from OpenAPI specs."
+
+## 4. No vague attributions
+
+Never write "experts say," "industry reports suggest," "observers note," "some critics argue," or "modern researchers believe." Either name the source or drop the claim.
+
+## 5. No structural formulas
+
+- **No rule of three**: Do not use three-adjective or three-phrase lists as a rhetorical device. Two or four is fine. Three in a row signals AI.
+- **No "not just X, but Y"**: Drop the "not only... but also" and "it's not just... it's" constructions entirely.
+- **No "challenges and future prospects"**: Never end a piece with a section about challenges faced and future outlook. If challenges matter, weave them into the body.
+
+## 6. No present-participle chains
+
+Do not string together "-ing" words as filler commentary: "highlighting," "emphasizing," "contributing to," "reflecting," "showcasing," "cultivating." These add no information. Replace with concrete verbs or cut entirely.
+
+Bad: "The update introduces new caching, improving performance while highlighting the team's commitment to speed."
+Good: "The update adds caching. Page loads dropped from 3s to 800ms."
+
+## 7. No elegant variation
+
+Do not swap synonyms for the same thing across sentences to avoid repetition. If you're talking about a "server," call it a "server" every time. Do not alternate between "the server," "the machine," "the node," "the instance" for style.
+
+## 8. No overstating significance
+
+Do not call things pivotal, transformative, revolutionary, or groundbreaking. Do not say something "marks a turning point" or "leaves an indelible mark." If it's important, show why with evidence — don't announce it.
+
+## 9. Em dash discipline
+
+Use em dashes sparingly — maximum one per paragraph, and only when parentheses or a comma won't work. AI text is riddled with em dashes.
+
+## 10. No collaborative language
+
+Never write "let's explore," "let us delve into," "we will examine," "as we can see." Write directly. The reader is reading, not exploring with you.
+
+## 11. No knowledge-cutoff disclaimers
+
+Never apologize for gaps, say "as of my last update," or speculate about missing information. Either state the fact or don't.
+
+## 12. Formatting restraint
+
+- Do not bold excessively. Bold a term once at most when introducing it.
+- Do not use emoji unless the user explicitly asks.
+- Do not use title case in headings beyond the first word and proper nouns (sentence case).
+- Do not create "key takeaways" sections.
+
+## 13. Write like a human
+
+- Vary sentence length naturally. Mix short and long.
+- Start some sentences with "But," "And," "So," or "Or."
+- Use contractions (don't, isn't, can't) in informal contexts.
+- Be specific over general. Numbers over adjectives. Evidence over claims.
+- It's OK to be blunt, dry, or even terse. Humans are.
+
+## Examples
+
+For concrete before/after examples showing these rules applied, see [examples/bad-examples.md](examples/bad-examples.md) and [examples/good-examples.md](examples/good-examples.md).
diff --git a/.cursor/skills/no-slop/banned-vocabulary.md b/.cursor/skills/no-slop/banned-vocabulary.md
new file mode 100644
index 0000000..23fc500
--- /dev/null
+++ b/.cursor/skills/no-slop/banned-vocabulary.md
@@ -0,0 +1,87 @@
+# Banned Vocabulary
+
+These words and phrases are statistically overrepresented in AI-generated text. Do not use them. Plain alternatives are listed where helpful.
+
+## High-Frequency AI Words
+
+| Banned | Use Instead |
+|---|---|
+| additionally | also, and, (or just start the next sentence) |
+| delve / delve into | look at, examine, dig into |
+| tapestry | (drop it — almost never needed) |
+| pivotal | important, key (sparingly) |
+| vibrant | (be specific: busy, loud, colorful, active) |
+| meticulous / meticulously | careful, thorough |
+| landscape (metaphorical) | field, area, market, space |
+| testament (to) | proof, evidence, sign |
+| underscore | show, prove, reinforce |
+| intricate / intricacies | complex, complicated, details |
+| interplay | interaction, relationship |
+| garner | get, earn, attract |
+| bolster / bolstered | support, strengthen, back |
+| foster / fostering | encourage, support, build |
+| showcase / showcasing | show, display, demonstrate |
+| emphasize / emphasizing | stress, point out |
+| enduring | lasting, long-running |
+| crucial | important, critical, necessary |
+| enhance / enhancing | improve, boost |
+| highlighting | (cut it — rewrite without) |
+| renowned | well-known, famous |
+| groundbreaking | new, novel, first |
+| profound | deep, major, significant |
+| comprehensive | full, complete, thorough |
+| multifaceted | complex, varied |
+| leverage (verb) | use |
+| utilize | use |
+| facilitate | help, enable, allow |
+| encompasses | includes, covers |
+| spearhead | lead, start |
+| harness | use |
+| elevate | raise, improve |
+| streamline | simplify, speed up |
+| robust | strong, solid, reliable |
+| seamless / seamlessly | smooth, easy |
+| holistic | complete, full, whole |
+| synergy | (drop it) |
+| paradigm | model, approach, pattern |
+| ecosystem (metaphorical) | system, community, market |
+
+## Banned Phrases
+
+- "marks a pivotal moment"
+- "represents a significant shift"
+- "indelible mark"
+- "deeply rooted"
+- "rich history"
+- "natural beauty"
+- "nestled in"
+- "boasts a"
+- "serves as a"
+- "stands as a"
+- "not just X, but Y" / "not only X, but also Y"
+- "it's not... it's..."
+- "despite its [positive], [subject] faces challenges"
+- "let's explore"
+- "let us delve into"
+- "in today's [landscape/world/era]"
+- "at the heart of"
+- "it is worth noting"
+- "a testament to"
+- "paving the way"
+- "plays a crucial role"
+- "in an era where"
+- "the intersection of"
+- "a beacon of"
+- "sends a strong message"
+- "it remains to be seen"
+
+## Conditional Bans
+
+These are fine in technical/code contexts but banned in prose:
+
+| Word | OK in | Banned in |
+|---|---|---|
+| key | variable names, API keys | "key factor," "key player" |
+| landscape | actual geography | "the AI landscape" |
+| robust | engineering specs | "a robust approach to leadership" |
+| seamless | UX descriptions with data | "a seamless experience" (vague) |
diff --git a/.cursor/skills/no-slop/examples/bad-examples.md b/.cursor/skills/no-slop/examples/bad-examples.md
new file mode 100644
index 0000000..642c412
--- /dev/null
+++ b/.cursor/skills/no-slop/examples/bad-examples.md
@@ -0,0 +1,75 @@
+# Bad Examples — AI Writing Patterns
+
+Each example below contains multiple AI tells. The bracketed annotations explain what's wrong.
+
+---
+
+## Example 1: Project Description
+
+> React Query is a groundbreaking library that serves as a pivotal tool in the modern frontend landscape. It seamlessly handles data fetching, caching, and synchronization, showcasing a meticulous approach to state management. The library boasts a vibrant community and has garnered significant adoption, underscoring its enduring value in the ecosystem. Not only does it simplify complex data flows, but it also fosters a more robust development experience.
+
+**What's wrong:**
+- "groundbreaking" — promotional, overstating significance
+- "serves as a pivotal tool" — copula avoidance + banned word
+- "modern frontend landscape" — banned metaphorical use of "landscape"
+- "seamlessly" — banned word
+- "showcasing a meticulous approach" — present-participle chain + banned words
+- "boasts a vibrant community" — copula avoidance + banned words
+- "garnered" — banned word
+- "underscoring its enduring value" — present-participle chain + banned words
+- "ecosystem" — banned metaphorical use
+- "Not only... but it also" — banned structural formula
+- "fosters a more robust" — banned words
+
+---
+
+## Example 2: Blog Post Intro
+
+> In today's rapidly evolving technological landscape, artificial intelligence stands as a testament to human ingenuity. Let's delve into the intricate interplay between machine learning and natural language processing, highlighting how these groundbreaking technologies are paving the way for a more comprehensive understanding of human communication.
+
+**What's wrong:**
+- "In today's rapidly evolving technological landscape" — banned phrase
+- "stands as a testament to" — copula avoidance + banned phrase
+- "Let's delve into" — collaborative language + banned phrase
+- "intricate interplay" — banned words
+- "highlighting" — present-participle filler
+- "groundbreaking" — banned word
+- "paving the way" — banned phrase
+- "comprehensive understanding" — banned word
+
+---
+
+## Example 3: Email Draft
+
+> I wanted to reach out regarding the Q3 infrastructure initiative. The proposed migration represents a significant shift in our approach, and it's crucial that we leverage the full potential of our cloud ecosystem. Despite its numerous advantages, the project faces challenges related to legacy system compatibility. It remains to be seen how we'll navigate these intricacies, but I'm confident this will elevate our platform to new heights.
+
+**What's wrong:**
+- "represents a significant shift" — banned phrase
+- "crucial" — banned word
+- "leverage" — banned word
+- "ecosystem" — banned metaphorical use
+- "Despite its... faces challenges" — banned structural formula
+- "It remains to be seen" — banned phrase
+- "intricacies" — banned word
+- "elevate our platform to new heights" — promotional + banned word
+
+---
+
+## Example 4: Documentation
+
+> This module plays a crucial role in facilitating seamless communication between microservices. It encompasses a robust set of utilities, including message serialization, retry logic, and circuit breaking. The holistic design of the system underscores a meticulous commitment to reliability, fostering an environment where services can interact with minimal friction. Additionally, it utilizes advanced patterns to streamline error handling.
+
+**What's wrong:**
+- "plays a crucial role" — banned phrase
+- "facilitating" — banned word
+- "seamless" — banned word
+- "encompasses" — banned word
+- "robust" — banned word (prose context)
+- Rule of three: "serialization, retry logic, and circuit breaking"
+- "holistic" — banned word
+- "underscores" — banned word
+- "meticulous commitment" — banned word
+- "fostering an environment" — banned word + vague
+- "Additionally" — banned sentence starter
+- "utilizes" — banned word (use "uses")
+- "streamline" — banned word
diff --git a/.cursor/skills/no-slop/examples/good-examples.md b/.cursor/skills/no-slop/examples/good-examples.md
new file mode 100644
index 0000000..932a112
--- /dev/null
+++ b/.cursor/skills/no-slop/examples/good-examples.md
@@ -0,0 +1,73 @@
+# Good Examples — Human-Sounding Rewrites
+
+Each example below is a rewrite of the corresponding bad example from [bad-examples.md](bad-examples.md).
+
+---
+
+## Example 1: Project Description
+
+> React Query handles data fetching, caching, and background sync for React apps. You describe what data you need, and it handles refetching, deduplication, and cache invalidation. The community is large — over 40k GitHub stars — and most major React codebases have adopted it.
+
+**Why this works:**
+- Opens with what it does, not how important it is
+- "handles" and "is" instead of "serves as" or "boasts"
+- Specific number (40k stars) instead of "vibrant community"
+- No promotional adjectives
+- No "not only... but also"
+
+---
+
+## Example 2: Blog Post Intro
+
+> Machine learning and NLP have converged over the past five years, mostly because transformer architectures turned out to work well for both. This post covers how that happened and what it means if you're building products that process text.
+
+**Why this works:**
+- No "in today's landscape" opener
+- No "let's delve into"
+- States the timeframe ("past five years") instead of vague "rapidly evolving"
+- Says what the post will cover, directly
+- Conversational but not chummy
+
+---
+
+## Example 3: Email Draft
+
+> Quick note about the Q3 infrastructure migration. We're moving the main API cluster to the new cloud provider. The main risk is compatibility with the legacy auth system — it uses a session format the new platform doesn't support natively. I've outlined two workarounds in the attached doc. Can we discuss Thursday?
+
+**Why this works:**
+- Gets to the point immediately
+- Names the specific risk instead of "faces challenges"
+- No "represents a significant shift" or "crucial"
+- Uses "uses" instead of "leverages" or "utilizes"
+- Ends with a concrete action, not "it remains to be seen"
+- Contractions ("we're," "doesn't," "I've") sound natural
+
+---
+
+## Example 4: Documentation
+
+> This module handles communication between microservices. It serializes messages, retries failed calls with exponential backoff, and trips a circuit breaker after five consecutive failures. Errors are caught at the transport layer and returned as typed results — callers don't need try/catch blocks.
+
+**Why this works:**
+- "handles" instead of "plays a crucial role in facilitating"
+- Lists what it actually does with specifics (exponential backoff, five failures)
+- "is" and "are" as copulas
+- No "additionally," no "holistic," no "robust"
+- One em dash, used purposefully
+- Technical detail instead of vague claims about "reliability"
+
+---
+
+## Example 5: PR Description (bonus)
+
+**Bad:**
+> This PR represents a significant enhancement to our authentication system. It leverages modern cryptographic patterns to foster a more robust security posture, showcasing our commitment to safeguarding user data. The changes encompass token validation, session management, and rate limiting, providing a comprehensive solution that elevates our platform's security to new heights.
+
+**Good:**
+> Replaces the JWT validation logic with Ed25519 signatures. Adds per-user rate limiting (100 req/min) and moves session tokens from cookies to HttpOnly + SameSite=Strict. The old HMAC-SHA256 tokens are still accepted for 30 days during migration.
+
+**Why this works:**
+- Says exactly what changed
+- Includes specific numbers and technical details
+- No promotional language about "elevating" or "commitment"
+- Migration plan is stated as a fact, not a "challenge"
diff --git a/.cursor/skills/obsidian/SKILL.md b/.cursor/skills/obsidian/SKILL.md
new file mode 100644
index 0000000..df0c889
--- /dev/null
+++ b/.cursor/skills/obsidian/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: obsidian
+description: Create, append to, read, and organize Markdown notes in the user's Obsidian vault at ~/Documents/ob-vault. Use when the user mentions "obsidian" or "obs", or says "make a note" (e.g. "make a note of this", "write this to obsidian", "capture this in obs", "add this to my obsidian"). Handles note creation, appending to existing notes, folders, frontmatter, and wikilinks.
+compatibility: Requires the Obsidian vault at ~/Documents/ob-vault (contains .obsidian/). No other deps.
+metadata:
+ author: j0ntz
+---
+
+Capture and organize Markdown notes in the user's Obsidian vault (`~/Documents/ob-vault`) using Obsidian-native conventions, without clobbering existing notes.
+
+
+The vault is `~/Documents/ob-vault` (confirmed Obsidian vault — has `.obsidian/`). Write only inside it. Create subfolders as needed with `mkdir -p`.
+NEVER overwrite an existing note's content blindly. Before writing, check whether a matching note exists: if it clearly matches the topic, APPEND (read it first, then add a new dated section); otherwise create a NEW note. If a destructive overwrite seems intended, confirm with the user first.
+Use Obsidian-friendly Markdown: `#` headings, bullets, fenced code. Use wikilinks `[[Note Name]]` (or `[[Folder/Note]]`) when cross-referencing other vault notes, not bare paths. Use `#tag` or frontmatter tags, not ad-hoc conventions.
+Give reference notes light YAML frontmatter: `tags` (array), `created` (YYYY-MM-DD), optional `status`. Keep it minimal. Daily notes (root `YYYY-MM-DD.md`) follow the existing plain style — don't force frontmatter on them.
+If the user references a specific intended note or folder to write into and it cannot be found in the vault, STOP and ask — do NOT create a divergent folder/note, guess an alternate, or silently write elsewhere. (Creating a new folder/note is fine ONLY when the user explicitly asks to start/create one.)
+If the user gives NO target and it's ambiguous where the note belongs, pick a sensible location, state where you put it, and offer to move it — don't silently guess into an obscure path. (This applies only when no specific target was named; a named-but-missing target falls under `stop-if-target-missing`.)
+
+
+
+Decide where the content goes:
+1. User named a target folder/file:
+ - It exists → use it.
+ - User explicitly said to start/create it → `mkdir -p` and create.
+ - Named as if it exists but NOT found → **STOP and ask** (per `stop-if-target-missing`). Do not guess or create a divergent one.
+2. Content clearly matches an existing note (search the vault) → plan to APPEND.
+3. No target given → new note. Choose folder per ``; default to a sensible topic folder or vault root, and say where it landed.
+
+Find candidates when unsure:
+```bash
+ls ~/Documents/ob-vault; find ~/Documents/ob-vault -name '*.md' ! -path '*/.obsidian/*'
+```
+
+
+
+- **New note**: write clean Markdown with light frontmatter (`tags`, `created`, optional `status`) + a top-level `#` title.
+- **Append**: Read the existing note first, then add a new `##` section (date-stamped if it's a running log). Preserve everything already there.
+
+Report the exact path written and whether it was create vs append.
+
+
+
+
+Until this section is filled in: put Claude/agent-related notes under `Claude/`, otherwise choose the closest existing folder (or vault root), and tell the user where it landed so they can refile.
+
+
+
+If `~/Documents/ob-vault` doesn't exist, STOP and tell the user — do not create a vault or write elsewhere.
+If unsure whether to append to an existing note or create a new one, prefer creating a new note and mention the existing related note as a `[[wikilink]]`, then ask if they'd rather merge.
+Don't write secrets/tokens into notes. If the content contains credentials, flag it and ask before writing.
+
diff --git a/.cursor/skills/one-shot/SKILL.md b/.cursor/skills/one-shot/SKILL.md
new file mode 100644
index 0000000..0e031a3
--- /dev/null
+++ b/.cursor/skills/one-shot/SKILL.md
@@ -0,0 +1,114 @@
+---
+name: one-shot
+description: End-to-end flow for a task: plan/context, implementation, PR creation, and Asana PR attach in one command.
+compatibility: Requires git, gh, node, jq. ASANA_TOKEN for Asana integration. ASANA_GITHUB_SECRET is OPTIONAL — only needed when the Asana ↔ GitHub widget integration is enabled at the workspace level. Workflow does not depend on it; the Asana link in the PR body is the canonical link.
+metadata:
+ author: j0ntz
+---
+
+Run the full task-to-PR workflow in one command by orchestrating `/asana-plan`, `/im`, and `/pr-create`.
+
+
+Do not re-implement logic already defined in `/asana-plan`, `/im`, or `/pr-create`. Delegate to those skills.
+By default, do NOT pass `--asana-attach` to `/pr-create`. The Asana ↔ GitHub widget integration is not assumed to be enabled, and the Asana link is already embedded in the PR body by `pr-create` whenever a task GID is available. Only pass `--asana-attach` when the caller explicitly opts in via `--asana-attach`. Do NOT pass `--asana-assign` — reviewer assignment is out of scope for this workflow (see `pr-create`'s `no-reviewer-assignment` rule).
+When a task GID is available (from Asana URL input or explicit `--asana-task` flag), always pass `--asana-task ` to `/pr-create` so it injects the Asana link into the PR body. This is the canonical Asana ↔ PR link consumed by `pr-land`, standup, and other downstream skills.
+If any delegated skill or companion script fails, report and stop. Do not bypass with manual alternatives.
+Do not draft alternate PR markdown formats inside this workflow. `/pr-create` owns PR body generation and template compliance.
+If the user message is literally `` (and nothing else), respond with a single word `pong` and continue normal operations. Do NOT treat it as input to any pending question, do NOT advance or change plans, do NOT bind to any prior prompt. This is a watchdog-injected wake message used to revive a dead Remote Control bridge; it carries no user intent.
+If you receive a `/one-shot …` invocation for a task you are ALREADY running in THIS session (same task GID, or its worktree/branch is already provisioned and `agent_status` is past `Pending`), treat it as a wake/continue nudge — NOT a fresh start. Do NOT restart from Planning, do NOT re-run phases already completed, do NOT re-create the plan/branch/PR. Resume from the current phase. A re-fired initial prompt is a scheduler/wake artifact (e.g. a `ScheduleWakeup` that carried the original prompt verbatim), not a request to start over. Prevention lives in `never-self-respawn` — don't schedule such wakes in the first place.
+When a task GID is available (from URL or `--asana-task`) AND that task has an `agent_status` custom field, update `agent_status` at each step boundary via `~/.config/agent-watcher/update-status.sh `. Status names: `Planning` (step 2), `Developing` (step 3), `Reviewing` (step 4), `Testing` (step 5, stays through step 6 watch loop), `Complete` (step 7 only — set ONLY when the watch loop reports all-green). If the task has no `agent_status` field (ordinary non-agent task), silently skip the updates — do not fail.
+When the `--yolo` flag is passed, run hands-off: do NOT pause for user confirmation of `/asana-plan` output, do NOT ask clarifying questions on uncertain choices, pick a defensible default and proceed. Record each deferred decision (question, chosen default, reversibility) under a "Deferred Decisions" section in the final report. Soft uncertainty (naming choices, code-style options, whether to add tests) is always deferrable.
+In `--yolo` mode, run ALL phases (Planning → Developing → Reviewing → Testing → Complete) within ONE agent turn. Invoke each delegated skill (`/asana-plan`, `/im`, `/pr-create`, `/build-and-test`) by reading its SKILL.md from `~/.cursor/skills//SKILL.md` and executing its logic inline, OR via the Skill tool — but do NOT end your turn between phases or write phase-completion messages that imply a hand-off back to the user. The only acceptable mid-task turn end is when (a) `agent_status` reaches `Complete` and the final report has been delivered, or (b) `blocked = Yes` is being set due to a true-blocker condition.
+Even in `--yolo`, STILL pause and set `blocked = Yes` on Asana when any of these apply: (a) destructive op with no recovery path (force push outside a PR branch, git history rewrite on shared branch, file deletion outside scratch/build dirs); (b) user-only credential needed (2FA, password, OAuth re-auth, signing key passphrase); (c) no defensible default exists (genuine ambiguity that could flip task outcome wholesale); (d) risk of overwriting unstaged user work (dirty working tree on a non-agent-created branch). When `blocked = Yes` is set, capture the reason in the run report (Summary + the relevant section: orchestration/testing/task-drafting/etc.) and attach it per `report-as-attachment` with `outcome: blocked`. Do NOT write the full reason as an Asana comment.
+In `--yolo` mode, NEVER merge the PR, tag a release, deploy, publish a package, or perform any other "land/ship" action. The agent's terminal action is reaching `agent_status = Complete` after the watch loop reports all-green. Merging is the human's decision. Force-pushing to the PR's own branch (to apply review/CI fixes) is allowed and expected.
+When iterating on PR feedback (CI failures, bugbot findings, etc.) inside the watch loop, prefer `git commit --amend --no-edit` + `git push --force-with-lease` over fixup commits. The PR's history should stay a single clean commit (or the minimum set of logically distinct commits the original implementation needed). Never use `--force` without `--with-lease` — if the branch has been touched by someone else, that's a true-blocker (set `blocked = Yes`).
+The step-6 wait MUST be a single bounded, blocking call inside THIS session's own process: `timeout gh pr checks --watch --interval 30`. It blocks the current tool call until checks settle or the timeout, spawns no new process, and returns control to react. Compute ONE 30-minute deadline at the start of step 6 and derive each `timeout` from the time remaining, so total wall-clock never exceeds 30 minutes.
+In NO phase — Planning, Developing, Reviewing, Testing, or the step-6 watch — may you use `/loop`, `/schedule`, `ScheduleWakeup`, a background `claude &`, or `claude --resume`. Not to wait, not to re-check, and NOT as a "fallback in case X hangs" (e.g. a maestro or build capture). Every one of these re-invokes, resumes, or schedules another `claude`/`cli` process and can self-replicate into a fork storm. A scheduled wake also re-injects the original prompt, which restarts the whole task (see `ignore-refired-one-shot`). Any wait is a single blocking call in THIS process; the step-6 wait specifically follows `pr-watch-bounded-poll`. If a sub-operation might hang, bound it with `timeout ` — never schedule a wake to recover from it.
+Do NOT post progress, narrative, or status comments to the Asana task during the run (no per-phase updates, no "agent paused" essays). Run state is conveyed by the `agent_status`/`blocked` field transitions, nothing else. At the terminal state (Complete OR `blocked = Yes`), produce exactly ONE structured run report from the template `~/.cursor/skills/one-shot/templates/agent-run-report.md` — fill the frontmatter and every section, using `_None observed._` for empty ones (never omit a section), keep it dense — and attach it via `asana-task-update.sh --task --attach-file --attach-name agent-run-report.md`. At most ONE Asana comment is permitted for the entire run: a single line pointing to the attachment. If no task GID is available (ad-hoc text task), skip the attachment and report only in chat. The chat-facing summary is unaffected; it is Asana comment spam being eliminated, not chat output.
+Treat bugbot as a check inside the step-6 watch. `gh pr checks --watch` blocks until the `cursor[bot]` check-run completes, so it waits out bugbot latency; each fix's force-push re-triggers bugbot and the next re-entry re-blocks on the new HEAD. Green requires the `cursor[bot]` check-run present and completed-clean on the current HEAD SHA, plus no unresolved `cursor[bot]` threads. Invoke `/bugbot` only to FIX findings; `--watch` does the waiting. Do NOT arm bugbot's recurring cron; `CronDelete` it if already armed. If bugbot can't reach clean within the 30-min budget, set `blocked = Yes`.
+
+
+
+Accept one of:
+
+1. Asana task URL
+2. Text/file requirements
+
+Optional flags:
+
+- `--asana-task ` (explicit Asana GID override)
+- `--asana-attach` (opt-in to the Asana ↔ GitHub widget attach step — requires the integration to be enabled at the workspace and `ASANA_GITHUB_SECRET` to be set; off by default per `no-attach-default`)
+- `--yolo` (hands-off mode: defer soft questions to a final summary, only block on true-blockers — see `yolo-hands-off-mode` and `yolo-true-blockers` rules)
+
+**Per-task worktrees (you create them).** When the agent-watcher spawns this session as a parallel slot, the working directory is `~/git` — NOT a pre-made worktree. Once the plan (step 2) identifies the target repo(s), create a dedicated, co-located worktree for each repo this task will modify:
+
+`~/.config/agent-watcher/setup-task-workspace.sh --task-gid --repo ` → prints the worktree path.
+
+They land together under `~/git/.agent-worktrees///` on branch `agent/` off `origin/develop`, with `env.json` copied in and `node_modules` APFS-cloned, so tooling + secrets work without extra setup. `cd` into the PRIMARY repo's worktree and do all build/test/commit/push there. (Manual, non-watcher runs already sit in a normal `~/git/` checkout — skip this provisioning.)
+
+**Editing an EdgeApp gui dependency (edge-core-js, edge-currency-accountbased, edge-exchange-plugins, edge-currency-plugins, edge-login-ui-rn, …).** If the task changes `edge-react-gui` AND a dependency repo, create a co-located worktree for BOTH — because they're siblings under the same `/` dir, `updot` finds the *modified* dep. In the gui worktree, run the repo's updot to build the dep and copy it into the gui worktree's `node_modules` (currently `yarn updot && yarn prepare`; add `yarn prepare.ios` when the dep is `edge-core-js`). Leave ALL `DEBUG_*` env.json flags FALSE — those switch the app to a localhost plugin dev-server that isn't running in a headless slot; `updot` is the headless linking mechanism.
+
+
+
+Set agent_status=Planning (see `agent-status-on-pending-task`). Then run `/asana-plan` with the provided input mode:
+
+- Asana URL mode: fetch task context and create plan
+- Text/file mode: create plan from provided requirements
+
+If `--yolo` is active, do NOT wait for user confirmation — accept the plan and move to step 3 immediately. Otherwise wait for user confirmation handled by `/asana-plan`.
+
+
+
+First provision the workspace (per **Per-task worktrees** above): from the plan, create a co-located worktree for the target repo — plus any gui-dependency repos the task modifies, then `updot`-link them into the gui worktree — and `cd` into the primary repo's worktree. (Skip on manual non-watcher runs already inside a normal checkout.) Then set agent_status=Developing and run `/im` using the approved `/asana-plan` output.
+
+
+
+Set agent_status=Reviewing. Then run `/pr-create` — always pass `--asana-task ` (so the Asana link gets embedded in the PR body, per `task-gid-for-pr-body-link`), and pass `--asana-attach` ONLY if the user explicitly opted in (per `no-attach-default`). Never pass `--asana-assign`.
+
+Task GID source priority:
+
+1. explicit `--asana-task `
+2. Asana task URL from step 1
+3. chat context from prior steps
+
+
+
+Set agent_status=Testing. Run `/build-and-test` for local verification. If it fails, amend HEAD with the fix (`git commit --amend --no-edit`), `git push --force-with-lease`, and re-run `/build-and-test`. Repeat up to 2 times. If still failing after 2 attempts, set `blocked = Yes` with reason and stop — the watch loop is not entered.
+
+
+
+Wait for external green signals before marking `Complete`. Budget: 30 minutes total wall-clock. Status stays at `Testing` throughout. Do the waiting per `pr-watch-bounded-poll` and `never-self-respawn` — one blocking `gh pr checks` call, never a self-respawning loop.
+
+Compute the deadline once at the start (`now + 30 min`). Then iterate, re-entering the bounded watch with the remaining budget, until all-green or the deadline:
+
+1. **CI checks**: run `timeout gh pr checks --watch --interval 30`. When it returns —
+ - exit 0 (all pass) → CI is green
+ - non-zero (a check failed) → read the failing job's log via `gh run view --log-failed`, apply a fix, then amend + force-push per `pr-watch-loop-amend-pattern`, then re-enter the bounded watch with the remaining budget
+2. **Bugbot**: handled as part of the watch per `bugbot-in-watch`. `gh pr checks --watch` blocks until the `cursor[bot]` check-run completes on HEAD; when the watch returns, if bugbot is red or has unresolved `cursor[bot]` threads, run `/bugbot`'s scan/fix logic, amend + force-push (which re-triggers bugbot), then re-enter the watch. Never arm bugbot's cron.
+
+Exit conditions:
+- **All green** (CI checks pass + the `cursor[bot]` check-run is present and completed-clean on HEAD + no unresolved `cursor[bot]` threads): proceed to step 7.
+- **30 min wall-clock elapsed**: set `blocked = Yes` with a comment summarizing what was still red, then stop.
+- **True-blocker hit during a fix attempt**: set `blocked = Yes` per `yolo-true-blockers`, stop.
+
+Honor `yolo-stop-at-pr` strictly: never merge, never tag, never deploy. The only mutations here are force-pushes to the PR's own branch.
+
+
+
+Build the run report and attach it, THEN mark Complete. Per `report-as-attachment`, this attachment (not comments) is how the run is documented.
+
+1. Copy `~/.cursor/skills/one-shot/templates/agent-run-report.md` to a scratch path (e.g. `/tmp/agent-run-report-.md`). Fill the frontmatter (`outcome: complete`, `verified`, `verify_blockers`, repo/branch/pr, started/ended, `skills_used`) and every section. Use `_None observed._` for empty sections; keep it dense (bullets, signal over prose). Map content to sections:
+ - phases that ran (`/asana-plan`, `/im`, `/pr-create`, `/build-and-test`) + watch-loop iteration counts → **Summary**.
+ - in `--yolo`, every auto-deferred decision (question, default chosen, reversibility) → **Decisions**.
+ - build/test/debug learnings → **Dev Notes & Gotchas** (inline-tagged). Harness friction → **Orchestration**. Skill defects → **Skill Gaps**. Missing/weak task inputs → **Task-Drafting Feedback**.
+2. Attach it: `asana-task-update.sh --task --attach-file /tmp/agent-run-report-.md --attach-name agent-run-report.md`. (Optionally one pointer comment.) Skip if no task GID (ad-hoc task) and report in chat only.
+3. Set agent_status=Complete — ONLY after the watch loop reported all-green.
+4. Return a short chat summary + PR URL + phases ran.
+
+The same build-and-attach is the terminal action at ANY exit, including `blocked = Yes` (with `outcome: blocked`) — see `report-as-attachment` and `yolo-true-blockers`.
+
+
+
+Fail fast and ask for `--asana-task ` or disable the attach with `--no-asana-attach`.
+Allow workflow with `--no-asana-attach` when no task link/GID exists.
+
diff --git a/.cursor/skills/one-shot/templates/agent-run-report.md b/.cursor/skills/one-shot/templates/agent-run-report.md
new file mode 100644
index 0000000..d1b8c94
--- /dev/null
+++ b/.cursor/skills/one-shot/templates/agent-run-report.md
@@ -0,0 +1,73 @@
+---
+task_gid: ""
+task_name: ""
+repo: ""
+branch: ""
+base: origin/develop
+pr: none # PR URL, or "none"
+outcome: complete # complete | partial | blocked
+verified: not-run # pass | partial | not-run | fail
+verify_blockers: [] # any of: precondition | harness | code | task-drafting
+started: "" # ISO 8601
+ended: "" # ISO 8601
+skills_used: [] # e.g. [asana-plan, im, pr-create, build-and-test, debugger]
+---
+
+## Summary
+
+
+_None observed._
+
+## Decisions
+
+
+_None observed._
+
+## Dev Notes & Gotchas
+
+
+_None observed._
+
+## Orchestration Issues
+
+
+_None observed._
+
+## Skill Gaps
+
+
+_None observed._
+
+## Task-Drafting Feedback
+
+
+_None observed._
+
+## Follow-ups & Risks
+
+
+_None observed._
diff --git a/.cursor/skills/pm.sh b/.cursor/skills/pm.sh
new file mode 100755
index 0000000..33f0eae
--- /dev/null
+++ b/.cursor/skills/pm.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# pm.sh
+# Package-manager dispatcher that auto-detects npm vs yarn from the lockfile
+# in the current working directory, so skill scripts can stay PM-agnostic
+# while repos migrate between npm and yarn.
+#
+# Detection (in order):
+# - package-lock.json present → npm
+# - yarn.lock present → yarn
+# - neither present → npm (default for new/scratch trees)
+# - both present → npm (mid-migration repos typically leave
+# yarn.lock around until cleanup)
+#
+# Usage:
+# pm.sh install # `npm install --no-audit --no-fund` OR `yarn install --non-interactive`
+# pm.sh run