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]*?)") + 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 <num> + +# Attach + assign reviewer + set review-needed status + estimate review hours +~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \ + --task <task_gid> \ + --attach-pr --pr-url <url> --pr-title "<title>" --pr-number <num> \ + --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 <task_gid> \ + --attach-pr --pr-url <url> --pr-title "<title>" --pr-number <num> \ + --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 <task_gid> \ + --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 <task_gid> \ + --attach-file /tmp/agent-run-report.md --attach-name agent-run-report.md +``` +</usage> + +<step id="1" name="Build operation flags"> +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 <path> [--attach-name <name>]` (upload a local file, e.g. a run-report `.md`, as a native task attachment; distinct from `--attach-pr`) +- `--assign` or `--assign <user_gid>` +- `--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 <user_gid>` +- `--set-implementor <user_gid>` +- `--set-priority <enum_gid>` +- `--set-planned <enum_gid>` +- `--auto-est-review-hrs` +</step> + +<step id="2" name="Run update script"> +Run `asana-task-update.sh` with the built flags. Prefer one call with combined operations over multiple calls. +</step> + +<step id="3" name="Handle prompts"> +If exit code is 2: + +- `PROMPT_REVIEWER`: ask who to assign, then re-run with `--reviewer <gid>` and `--assign` +- `PROMPT_IMPLEMENTOR`: ask who to set as implementor, then re-run with `--implementor <gid>` + +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. +</step> + +<step id="4" name="Report result"> +Summarize one line per action from script output (attach result, assignment, status change, field updates). +</step> + +<team-roster description="Asana user GIDs. Use numbered lists when prompting users."> +1. Jon Tzeng — `1200972350160586` +2. William Swanson — `10128869002320` +3. Paul Puey — `9976421903322` +4. Sam Holmes — `1198904591136142` +5. Matthew Piche — `522823585857811` +</team-roster> + +<exit-codes> +- `0`: success +- `1`: error +- `2`: needs user input (`PROMPT_REVIEWER`, `PROMPT_IMPLEMENTOR`) +</exit-codes> 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 <task_gid> 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: +# <gid> (default) +# <gid> <name> (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). +--- + +<goal>Write or revise Cursor commands and skills with maximum agent compliance.</goal> + +<commands-vs-skills> +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 `<skill>/scripts/`. Shared scripts live at `~/.cursor/skills/` top-level. +</commands-vs-skills> + +<authoring-principles> +<principle id="prescriptive">Be prescriptive, not descriptive. Commands tell the agent what to DO, not what things ARE.</principle> +<principle id="brief-examples">Examples must be brief and hypothetical. Never use real data from conversations. Keep examples to 3-5 lines max.</principle> +<principle id="dry">DRY across commands. If two commands share logic, extract it into a shared file and have both reference it.</principle> +<principle id="ordering">Order of operations matters. The agent reads top-to-bottom. Put context-setting steps before action steps.</principle> +<principle id="rules-first">Hard rules at the top. Non-negotiable constraints go right after the Goal so they're read before any steps.</principle> +<principle id="escape-hatches">Escape hatches over assumptions. When ambiguity exists, tell the agent to ask — don't let it guess.</principle> +<principle id="scripts-over-reasoning">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.</principle> +<principle id="batch-tool-calls">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.</principle> +<principle id="no-duplicate-automation">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.</principle> +<principle id="gh-cli-over-curl">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).</principle> +<principle id="node-over-python">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.</principle> +<principle id="minimize-context">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.</principle> +</authoring-principles> + +<formatting> +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 + +<rules> +- Use semantic tag names that describe their content (e.g., `<rules>`, `<step>`, `<edge-cases>`). +- Use attributes for metadata: `id`, `name`, `description`. +- Nest tags for hierarchy: `<step><sub-step>...</sub-step></step>`. +- Be consistent — use the same tag names throughout a command. +- Markdown is still fine for inline formatting within XML tags (bold, code, lists). +</rules> + +<template> +```xml +<goal>One sentence. What does this command accomplish?</goal> + +<rules description="Non-negotiable constraints."> +<rule id="constraint-1">...</rule> +<rule id="constraint-2">...</rule> +</rules> + +<step id="1" name="Step name"> +Instructions for this step. +</step> + +<step id="2" name="Step name"> +Instructions for this step. +</step> + +<edge-cases> +<case name="Case name">How to handle it.</case> +</edge-cases> +``` +</template> +</formatting> + +<small-model-conventions description="Apply these when the command will run on smaller/faster models (e.g., the user says 'for smaller models', 'optimize for lite/fast', or the command is high-frequency and must be cheap). These patterns compensate for weaker instruction-following and shorter reasoning chains."> + +<convention id="verbatim-bash">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 `<upstream-ref>` only where the agent must substitute a value.</convention> + +<convention id="file-over-args">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.</convention> + +<convention id="exact-output-templates">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."</convention> + +<convention id="explicit-parallel">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.</convention> + +<convention id="priority-ordered-decisions">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.</convention> + +<convention id="inline-guardrails">Duplicate critical rules from cross-referenced files as top-level `<rule>` 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.</convention> + +<convention id="no-implicit-steps">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."</convention> + +<convention id="single-tool-per-step">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").</convention> +</small-model-conventions> + +<revision-checklist> +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 "<term>" ~/.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 "<identifier>" ~/.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 `<goal>`, `<rules>`, `<step>` 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 `<small-model-conventions>` — especially `file-over-args`, `inline-guardrails`, and `verbatim-bash` +</revision-checklist> + +<post-authoring-actions> +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. +</post-authoring-actions> + +<companion-scripts> +Skill-specific scripts go in `<skill>/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. +</companion-scripts> 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 +--- + +<goal>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.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="use-companion-scripts">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).</rule> +<rule id="no-script-bypass">If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or other workarounds.</rule> +<rule id="cursor-bot-only">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`.</rule> +<rule id="conclusion-is-not-clean">`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.</rule> +<rule id="require-paginate">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.</rule> +<rule id="reply-before-resolve">ALWAYS reply explaining how a thread was addressed (fix SHA for valid, invalidity class for invalid) BEFORE calling `resolve-thread`. No silent resolutions.</rule> +<rule id="commit-via-script">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.</rule> +<rule id="slot-after-each-fixup">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.</rule> +<rule id="fixup-target-headline">Before each fixup, run `git log --oneline -- <changed-file>` 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.</rule> +<rule id="no-summary-comment">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.</rule> +<rule id="self-schedule-on-claude-code">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 `<scheduling>`.</rule> +<rule id="one-cron-per-pr">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.</rule> +<rule id="script-timeouts">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.</rule> +<rule id="this-file-wins">If any other instruction conflicts with this file, **this file wins** for `bugbot`.</rule> +</rules> + +<arguments> +Accepts either form: +- `owner/repo#pr` (e.g. `EdgeApp/edge-reports-server#207`) +- Discrete flags: `--owner <o> --repo <r> --pr <n>` + +Required. Parse and assign to `<OWNER>`, `<REPO>`, `<NUMBER>` for the steps below. +</arguments> + +<step id="0" name="Ensure correct branch"> +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 <OWNER> --repo <REPO> --pr <NUMBER> +``` + +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=<dir>`, the PR branch lives in another git worktree — `cd "<dir>"` 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`. +</step> + +<step id="1" name="Fetch HEAD SHA"> +Resolve the full 40-char SHA for the PR's head branch: + +```bash +HEAD_SHA=$(git rev-parse origin/<BRANCH>) +HEAD_SHORT=${HEAD_SHA:0:10} +``` + +If you don't already know `<BRANCH>`, derive it from pr-address's ensure-branch output or: + +```bash +BRANCH=$(gh pr view <NUMBER> --repo <OWNER>/<REPO> --json headRefName --jq '.headRefName') +``` +</step> + +<step id="2" name="Query bugbot check-run"> +Get bugbot's authoritative state on the current HEAD: + +```bash +~/.cursor/skills/bugbot/scripts/bugbot.sh check-run-status \ + --owner <OWNER> --repo <REPO> --sha "$HEAD_SHA" +``` + +Returns compact JSON: `{"status":"<s>","conclusion":"<c>","sha":"<short>"}`. + +- `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. +</step> + +<step id="3" name="Interpret — priority-ordered decision table"> +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 <HEAD_SHORT>`. + Do NOT fetch threads, commit, push, reply, or resolve anything. + +2. **`status == "none"`** → `OUTCOME = no-check-run`. + Status line: `no bugbot check-run on <HEAD_SHORT> yet`. + Do NOT act. (Bugbot may start scanning shortly.) + +3. **`status == "completed"` AND `conclusion == "skipped"`** → `OUTCOME = skipped`. + Status line: `bugbot skipped <HEAD_SHORT>`. + 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 <HEAD_SHORT>`. + +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. +</step> + +<step id="4" name="Address unresolved bugbot findings"> + +<sub-step id="4a" name="Fetch unresolved threads"> +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh fetch \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> +``` + +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. +</sub-step> + +<sub-step id="4b" name="Per-thread: fetch body and classify"> +For each cursor[bot] thread, fetch the full body: + +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh fetch-thread \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --thread-id "<threadId>" +``` + +Inside the `<!-- DESCRIPTION START -->...<!-- DESCRIPTION END -->` markers is the finding. Classify it by running through the `<classification-heuristics>` block (below) in order. The DEFAULT is "valid" — invalidity requires a cited heuristic match. +</sub-step> + +<sub-step id="4b1" name="Squash stale fixups (Fixups A → squash before Fixups B)"> +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 <OWNER> --repo <REPO> --pr <NUMBER> +``` + +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). +</sub-step> + +<sub-step id="4c" name="Valid: apply fixups (one at a time, slot after each)"> +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 -- <path> + ``` + 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! <target-headline>" <files...> + ``` +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! <target-headline>$" --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. +</sub-step> + +<sub-step id="4d" name="Push all fixups once"> +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). +</sub-step> + +<sub-step id="4e" name="Reply and resolve every thread (valid and 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 <OWNER> --repo <REPO> --pr <NUMBER> \ + --comment-id <numeric_id> \ + --body "Valid — fixed in <fixup_sha>. <one-sentence description of the fix and file:line>." +``` + +Invalid threads — reply body cites the matched heuristic: +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh reply \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --comment-id <numeric_id> \ + --body "<one-sentence explanation naming the heuristic: self-invalidating / pre-existing intentional / already-addressed / duplicate / wrong about the API>. <brief evidence citing code paths, author comments, or sibling threads>." +``` + +Then resolve: +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh resolve-thread --thread-id "<threadId>" +``` +</sub-step> + +<sub-step id="4f" name="Finalize: autosquash or push (mode-dependent)"> +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 <OWNER> --repo <REPO> --pr <NUMBER> +``` + +Output is one line of JSON: +- `{"action": "autosquash", "mode": "autosquash", "newHead": "<sha>"}` — history rewritten, force-pushed. Use `newHead` in the Step 4g status line. +- `{"action": "push", "mode": "preserve", "newHead": "<sha>"}` — 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. +</sub-step> + +<sub-step id="4g" name="Status line for the findings outcome"> +Set the final status line based on what happened in 4c–4f: + +- `bugbot addressed <N> thread(s) on <HEAD_SHORT>; autosquashed to <NEW_HEAD>` — fixups pushed and squashed (autosquash mode). +- `bugbot addressed <N> thread(s) on <HEAD_SHORT>; new HEAD <NEW_HEAD>` — fixups pushed, autosquash deferred (preserve mode — active reviewer). +- `bugbot addressed <N> thread(s) on <HEAD_SHORT>; 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. +</sub-step> +</step> + +<step id="5" name="Manage recurring schedule (Claude Code only)"> +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 `<scheduling>`) 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 <owner>/<repo>#<pr>` (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: "<cron prompt from step 1>", recurring: true) + ``` + Append ` · monitoring every 5m (job <new_id>)` to the status line. + + If `EXISTING_IDS` is non-empty: do NOT CronCreate. Append ` · continuing monitor (job <existing_id>)` 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 <SHA> · 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. +</step> + +<classification-heuristics description="Invalidity patterns observed in real bugbot runs. A finding is VALID by default; match one of these with cited evidence to mark it INVALID."> + +<pattern id="self-invalidating" name="Self-invalidating"> +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. +</pattern> + +<pattern id="pre-existing-intentional" name="Pre-existing intentional code"> +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 -- <file>` shows the hunk pre-dates the current branch work). + +Reply citing the author comment and the commit that introduced it. +</pattern> + +<pattern id="already-addressed" name="Already-addressed stance"> +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. +</pattern> + +<pattern id="duplicate" name="Duplicate"> +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. +</pattern> + +<pattern id="wrong-about-api" name="Wrong about the API"> +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. +</pattern> + +</classification-heuristics> + +<scheduling description="How the recurring schedule is set up per tool. On Claude Code the skill self-arms; on Cursor/Codex the user configures their Automations panel once."> + +<tool name="Claude Code (default — self-armed)"> +Just invoke the skill — it arms its own schedule on non-clean outcomes and tears it down on clean outcomes (see Step 5). + +``` +/bugbot <owner>/<repo>#<pr> +``` + +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 <SHA> · 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. +</tool> + +<tool name="Claude Code (durable cloud schedule)"> +For survive-across-sessions monitoring (e.g. you want bugbot polling overnight while you're logged off): + +``` +/schedule every 5 minutes: /bugbot <owner>/<repo>#<pr> +``` + +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. +</tool> + +<tool name="Cursor (Automations)"> +In the Automations panel, create a recurring Automation: +- Schedule: cron `*/5 * * * *` +- Prompt: `/bugbot <owner>/<repo>#<pr>` + +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. +</tool> + +<tool name="Codex (Automations)"> +Ask Codex: "Create a standalone automation that runs every 5 minutes with prompt `/bugbot <owner>/<repo>#<pr>`." Same caveat as Cursor — the skill doesn't self-teardown; disable the automation when the clean status appears. +</tool> + +</scheduling> + +<edge-cases> +<case name="Branch has uncommitted changes">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.</case> +<case name="Check-run is failure">Same handling as `neutral` with threads — bugbot just marked the findings blocking-severity rather than informational. Proceed through Step 4.</case> +<case name="Bugbot re-ran and posted on an older SHA">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.</case> +<case name="Thread from a non-cursor[bot] author">Skip. This skill is scoped to bugbot. For mixed human/bot reviews, run `/pr-address` separately.</case> +<case name="Empty PR / no check-runs ever">Step 2 returns `status: "none"`. Step 3 row 2 applies — report and wait. Bugbot has up to ~1 minute before it enqueues a scan.</case> +<case name="Script exit 2 (PROMPT_GH_AUTH / PROMPT_GH_INSTALL)">Prompt the user to install/authenticate `gh`, STOP. Do not fall back to curl or manual API calls.</case> +<case name="Cron tools are deferred / not loaded in the agent context">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.</case> +<case name="PR branch was deleted (PR merged/closed)">`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.</case> +</edge-cases> 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 <o> --repo <r> --sha <sha> +# Returns compact JSON: {"status":"...","conclusion":"...","sha":"<short>"} +# 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 <<USAGE +Usage: bugbot.sh <subcommand> [flags] + +Subcommands: + check-run-status --owner <o> --repo <r> --sha <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 +--- + +<goal> +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. +</goal> + +<rules description="Non-negotiable constraints."> +<rule id="autodetect-repo-shape">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).</rule> +<rule id="report-failures-actionable">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.</rule> +<rule id="no-mutation">This skill does NOT edit source code, commit, push, or change Asana state. It runs verification commands and reports results only.</rule> +<rule id="scripts-over-inline">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.</rule> +<rule id="npm-only-for-edge-react-gui">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.</rule> +</rules> + +<step id="0" name="iOS UI test (maestro) — edge-react-gui only"> + +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 = <rate> 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 +<last 30 lines> +``` + +Return success exit only on PASS. + +### 0f. Critical gotchas baked into the flow (do not "fix" them) + +<rule id="spaced-pin-taps">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.</rule> +<rule id="no-hideKeyboard">On this debug build, `hideKeyboard` reliably triggers an RN Fabric text-measure SIGABRT. The flow leaves the keyboard up. Do not add `hideKeyboard` steps.</rule> +<rule id="no-hierarchy-polling-on-buy">`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).</rule> + +</step> + +<step id="1" name="Node + TypeScript path"> +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 — <command> exit <code> +<last 30 lines> +``` +</step> + +<step id="2" name="Node path (no TypeScript)"> +```bash +[ -d node_modules ] || npm install --no-audit --no-fund +npm test +``` + +Same PASS/FAIL contract as step 1. +</step> + +<step id="3" name="Placeholder fallback (unknown repo shape)"> +Emit exactly: +``` +build-and-test: placeholder mode — no commands executed (repo shape not auto-detected). +``` +Return success. +</step> + +<edge-cases> +<case name="Simulator selection ambiguous (exit 2)">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.</case> +<case name="Simulator boot fails">Run `xcrun simctl shutdown all && xcrun simctl erase <UDID>` is destructive — do NOT run it. Set `blocked = Yes` with the boot error.</case> +<case name="ios-rn-build.sh exits 2 (sim not booted)">Re-run step 0b. If it fails twice, set `blocked = Yes`.</case> +<case name="Cold RN build needed and would take >30 min">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.</case> +<case name="capture-buy-quote.sh exhausts retries">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.</case> +<case name="maestro install fails">Set `blocked = Yes` with the install error and a note about JDK requirement.</case> +<case name="Repo is edge-react-gui but the test account / sim was wiped">Set `blocked = Yes` — the test relies on `edge-rjqa3` with PIN 1111. Re-provisioning is a human step.</case> +</edge-cases> 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 = <rate> 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 <amount> 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 <path>] [--flow <path-to-maestro-yaml>] \ +# [--bundle-id <id>] [--quote-secs N] [--window-secs N] [--cycles N] +# +# Defaults: +# --out /tmp/agent-mvp-buy-quote-screenshot.png +# --flow <this-script-dir>/../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 <UDID> --bundle-id <co.example.app> [--port <n>] [--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 <UDID> --bundle-id <id> [--port <n>] [--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 <runtime-substring> --device <device-name> [--boot] +# select-ios-sim.sh --accept-udid <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 <runtime> --device <device-name> [--boot]" >&2 + echo " or: select-ios-sim.sh --accept-udid <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 +--- + +<goal>Analyze current chat or provided Cursor chat export to identify inefficiencies, rule violations, and wasted tool calls against the invoked command's workflow.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="use-companion-script">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.</rule> +<rule id="tools-only-default">Default to `--tools-only` mode. Only omit the flag if the user asks for full assistant message analysis.</rule> +<rule id="no-raw-json">Do NOT read the export JSON file directly. All data comes from the script output.</rule> +<rule id="concise-output">Keep the final report under 50 lines. Use a numbered list for findings, not verbose paragraphs.</rule> +</rules> + +<step id="1" name="Extract conversation data"> +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 <export-file> --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. +</step> + +<step id="2" name="Load the invoked command"> +If `invokedCommand` is identified, read the command file: + +```bash +Read ~/.cursor/skills/<invokedCommand>/SKILL.md +``` + +Extract the command's: +- **Rules** (the `<rule>` tags) +- **Steps** (the `<step>` tags — just names and key instructions, not full content) +- **Companion scripts** referenced (filenames only) +</step> + +<step id="3" name="Analyze tool call sequence"> +Walk through the `sequence` array and check each tool call against the command's prescribed workflow: + +<sub-step name="Rule violations"> +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)"? +</sub-step> + +<sub-step name="Wasted tool calls"> +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 +</sub-step> + +<sub-step name="Skipped steps"> +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) +</sub-step> +</step> + +<step id="4" name="Generate report"> +Output a structured report: + +``` +## Chat Audit: /<command> + +**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. +</step> + +<edge-cases> +<case name="No command detected">Ask the user which command was being executed, or analyze without a reference command (just flag errors and wasted calls).</case> +<case name="Multiple user messages">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.</case> +<case name="Non-command conversation">If no `/command` was invoked, still analyze for general inefficiencies (redundant reads, errors, unnecessary exploration) but skip the rule/step compliance checks.</case> +</edge-cases> 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 <export.json> [--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 <export.json> [--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-<name>. +compatibility: Requires jq, yarn. Must be run from within an edge-react-gui checkout. +metadata: + author: j0ntz +--- + +<goal>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.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="cheese-branch-only">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.</rule> +<rule id="clean-working-tree">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.</rule> +<rule id="tarball-not-git-url">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).</rule> +<rule id="use-companion-script">Run the full workflow via `~/.cursor/skills/cheese/scripts/cheese-build.sh`. Do not inline git / pack / package-manager operations in chat.</rule> +<rule id="force-with-lease">The script pushes with `--force-with-lease` via `~/.cursor/skills/git-branch-ops.sh`. Never use plain `--force`.</rule> +</rules> + +<dep-aliases description="Short names for common Edge dep repos. Resolve to $HOME/git/<repo-name>. Aliases are case-insensitive. Explicit absolute paths are also accepted by --pin."> + +| 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 | + +</dep-aliases> + +<step id="1" name="Parse inputs"> +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/<repo>`. If an alias doesn't map, ask the user for the absolute path. +</step> + +<step id="2" name="Confirm plan"> +Show the user a one-block summary: + +``` +Cheese branch: test-<name> +From: <source-ref> (<short-sha>) +Deps to pin: (none) | <name1>, <name2>, ... +``` + +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. +</step> + +<step id="3" name="Run script"> +Invoke with resolved absolute paths: + +```bash +~/.cursor/skills/cheese/scripts/cheese-build.sh \ + --branch <cheese-branch> \ + --from <source-ref> \ + [--pin <absolute-path-to-dep-repo>]... +``` + +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`. +</step> + +<step id="4" name="Report"> +Print the remote branch URL and final SHA from the script output. Jenkins picks up the push automatically — no further action needed. +</step> + +<edge-cases> +<case name="Currently on cheese branch">Ask the user which feature branch to reset against; cheese branches can't self-reset.</case> +<case name="Dep repo on master/develop">If a pin target is on its default branch, the published version is enough. Warn; proceed only if the user confirms.</case> +<case name="Tarball missing lib/">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.</case> +<case name="Dirty working tree">Script exits with code 2 and tells the user to commit or stash first. Never auto-stash — their WIP is their responsibility.</case> +<case name="Dep name not in gui's dependencies">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.</case> +</edge-cases> 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 <name> [--from <ref>] [--pin <path>]... +# +# 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 +--- + +<goal>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.</goal> + +<rules> +<rule id="local-is-canonical">`~/.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.</rule> +<rule id="extra-trees">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/<project>/memory/`) is machine-local per Anthropic docs and is intentionally NOT synced.</rule> +<rule id="cross-machine-safety">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.</rule> +<rule id="use-companion-script">Use `~/.cursor/skills/convention-sync/scripts/convention-sync.sh` for diffing and syncing. Do NOT manually diff or copy files.</rule> +<rule id="dry-run-first">Always run without `--stage` first to show the summary. Only stage/commit after user confirms.</rule> +<rule id="no-script-bypass">If the script fails, report the error and STOP.</rule> +<rule id="readme-is-source">`~/.cursor/README.md` is the canonical local documentation source. The sync script mirrors it to `<repo>/README.md`, and PR descriptions must be updated from that synced repo root README.</rule> +<rule id="claude-compat">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.</rule> +<rule id="target-repo-resolution">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.</rule> +</rules> + +<step id="1" name="Detect changes and PR status"> +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 <repo-dir> && 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. +</step> + +<step id="2" name="Present summary"> +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/<branch> 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: <hash> <subject> + - file8 (deletion) — last upstream commit: <hash> <subject> + +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 <repo-dir> && 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. +</step> + +<step id="3" name="Stage, commit, push, update PR"> +Run the script with `--commit`: + +```bash +~/.cursor/skills/convention-sync/scripts/convention-sync.sh <repo-dir> --commit -m "<message>" +``` + +Then push: + +```bash +cd <repo-dir> && git push origin HEAD +``` + +If an open PR exists, update the PR description from the synced repo root README: + +```bash +cd <repo-dir> && gh pr edit --body-file README.md +``` +</step> + +<edge-cases> +<case name="Reverse sync (repo → user)">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.</case> +<case name="Current repo has a .cursor folder but is not edge-dev-agents">Do not sync into that repo. Fall back to `~/git/edge-dev-agents` or ask for the correct repo path.</case> +<case name="Dry-run resolved a repo path">Reuse the `repoDir` value from the script's JSON output for the PR query, commit run, push, and PR edit steps.</case> +<case name="Selective sync">To permanently exclude files, add glob patterns to `.syncignore` (one per line, `#` comments). The script reads `.syncignore` from the REPO (`<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/<file>` before committing.</case> +<case name="README migration">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`.</case> +<case name="No README">If `~/.cursor/README.md` doesn't exist, skip PR description update and warn the user.</case> +<case name="origin is ahead (originAhead > 0)">The script auto-fetches and detects this. Surface the count to the user, instruct them to `cd <repo-dir> && git pull --rebase`, then re-run convention-sync. Do not attempt --stage/--commit before pulling — the script will exit non-zero.</case> +<case name="Wrong branch (default or non-PR branch)">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 <repo-dir> && git checkout <pr-head>`) and re-run. Override with `--force-branch` ONLY if intentionally committing to a different branch.</case> +<case name="Blocking warnings (deletion / stale-local / re-adding-deleted)">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.</case> +<case name="Fetch fails (offline)">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.</case> +</edge-cases> 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 <repo-dir> 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 +--- + +<goal>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.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="preflight-required">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.</rule> +<rule id="use-script">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.</rule> +<rule id="line-numbers-are-1-based">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.</rule> +<rule id="no-mutation">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.</rule> +<rule id="one-breakpoint-per-invocation">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.</rule> +<rule id="report-is-stdout-json">`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.</rule> +</rules> + +<step id="1" name="Preflight"> + +```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). + +</step> + +<step id="2" name="Pick a breakpoint location and (optionally) a trigger"> + +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 '<js-expr>'` — 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:<expr>` — evaluate an arbitrary expression in the paused top frame (repeatable). Use to see derived values, dotted paths into objects, etc. +- **Conditional** (optional): `--condition '<js-expr>'` — only fire when the expression is truthy in the breakpoint's scope. Filters out unrelated calls (e.g. only break when `paymentType === "ach"`). + +</step> + +<step id="3" name="Run cdp-attach.js"> + +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.) + +</step> + +<step id="4" name="Parse the report and act"> + +`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": "<object>", "supported": "false" }, + "evaluated": { "params.paymentType": "ach", "params.regionCode.countryCode": "FR" } + } +} +``` + +On exit 2 (timeout, no hit), the envelope is `{ "error": "timeout", "breakpoint": {...} }` — the location may not have been on the executed code path, the user may not have driven the app to the relevant scene, or `--condition` filtered out every hit. + +Use the report to answer the original question (why this value, what code path, etc.). For follow-up breakpoints, run another invocation — do NOT try to keep one process alive across multiple pauses. + +</step> + +<edge-cases> +<case name="Breakpoint resolves to 0 locations">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`).</case> +<case name="Passive wait timed out (exit 2)">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).</case> +<case name="Active trigger threw">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.</case> +<case name="Multiple Hermes targets">`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).</case> +<case name="Metro restarted mid-investigation">Scripts seen by the debugger reset. Re-run `check-metro.sh` and re-run the cdp-attach invocation.</case> +<case name="App crashes shortly after pause">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.</case> +</edge-cases> + +<notes-on-cdp description="Background on the underlying mechanism; the agent does not need to recite this, just know where it lives."> +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. +</notes-on-cdp> 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 <pattern>:<line>[:<col>] \ +// [--condition '<js-expression>'] \ +// [--trigger '<js-expression>'] \ +// [--report stack,locals,evaluate:<expr>] \ +// [--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:<expr> — 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 <pattern>:<line>[:<col>]') + process.exit(1) +} + +// Parse pattern:line[:col] +const bpMatch = opts.breakAt.match(/^(.+?):(\d+)(?::(\d+))?$/) +if (!bpMatch) { + console.error(`--break-at must be <pattern>:<line>[:<col>] — 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}>`)) + : '<unavailable>' + } + } else { + report.paused.locals = '<no local scope reported>' + } + } + + 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 + ? `<error: ${r.__error.message}>` + : (r.result ? (r.result.value !== undefined ? r.result.value : r.result.description) : '<no result>') + } + } + + // 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 +--- + +<goal>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.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="parent-required">A parent Asana task URL is always required. It provides context, project placement, and dependency linking.</rule> +<rule id="check-existence">Always check if a dependent task already exists before creating one. The script handles this — respect the `CREATED: false` output.</rule> +<rule id="script-timeouts">Asana scripts can take up to 90s. Always set `block_until_ms: 120000`.</rule> +<rule id="no-impl-before-task">Do NOT begin implementation until the dependent task is created and linked.</rule> +<rule id="same-project">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.</rule> +<rule id="initial-assignee">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.</rule> +</rules> + +<dependency-hierarchy description="Repo dependency structure. Lower-level repos block higher-level repos."> +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` | + +</dependency-hierarchy> + +<repo-map description="Shorthand prefixes to repo directories and branch bases."> + +| 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` | + +</repo-map> + +<step id="1" name="Resolve parent task and target repo"> +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. +</step> + +<step id="2" name="Create dependent task"> +Derive the dependent task name from the parent: `<target-prefix>: <parent task name without its prefix>`. + +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 <parent_gid> \ + --name "<prefix>: <task name>" \ + --notes "<description referencing parent task>" +``` + +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. +</step> + +<step id="3" name="Implement and PR"> +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. +</step> + +<step id="4" name="Report"> +Display both the new Asana task and the PR as clickable links. Note the dependency relationship. +</step> + +<edge-cases> +<case name="Dependent task already exists">The script detects this. Report: "Found existing dependent task: [link]. Continuing with PR workflow." Then proceed to step 3.</case> +<case name="Parent task has no project">The script falls back to the first available project. Warn the user if the placement looks wrong.</case> +<case name="Target repo already has a matching branch">Step 3 delegates to `pr-create.md` which handles branch state assessment.</case> +<case name="Upward dependency (higher-level target)">Ask: "Creating a [gui] task from a [core] parent is unusual — the dependency direction would be reversed. Confirm? (yes/no)"</case> +</edge-cases> 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 <parent_gid> --name "task name" [--notes "description"] [--assignee <user_gid>] +# +# 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: <gid> +# TASK_URL: <url> +# CREATED: true|false (false if task already existed) +# ASSIGNED_TO: <user_gid> +# FIELDS_SET: priority=<val>, status=<val>, planned=<val>, reviewer=<name>, implementor=<name> +# DEPENDENCY_SET: <new_gid> blocks <parent_gid> +# +# 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 <gid> --name <name> [--notes <desc>] [--assignee <gid>]" >&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. +--- + +<goal>Resolve ESLint `@typescript-eslint/no-deprecated` warnings by replacing deprecated type references with their non-deprecated equivalents.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="tsc-after-fix">Run `npx tsc --noEmit` after every type change to verify no new type errors are introduced.</rule> +<rule id="no-suppress">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.</rule> +<rule id="scope-control">Only modify files with deprecation warnings. Do not refactor downstream declarations unless required for the fix to compile.</rule> +</rules> + +<patterns> + +<pattern id="navigation-base" rule="@typescript-eslint/no-deprecated" symbol="NavigationBase"> +`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<void> } +``` + +**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 => { + props.navigation.push('send2', { walletId, tokenId }) +} + +// After (option 1) — useNavigation hook +const BalanceCard: React.FC<Props> = props => { + const navigation = useNavigation<EdgeAppSceneProps<'home'>['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<Promise<void>> { + // ... calls navigation.navigate('editToken', ...) internally +} + +// After — caller provides the navigate action +function activateWalletTokens(wallet, tokenIds, onNavigate: (route: string, params: object) => void): ThunkAction<Promise<void>> { + // ... 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. +</pattern> + +<pattern id="route-prop" rule="@typescript-eslint/no-deprecated" symbol="RouteProp"> +Replace deprecated `RouteProp<'routeName'>` with the scene-specific route type. + +```typescript +// Before +import type { RouteProp } from '../../types/routerTypes' +const route = useRoute<RouteProp<'walletDetails'>>() + +// After +import type { WalletsTabSceneProps } from '../../types/routerTypes' +const route = useRoute<WalletsTabSceneProps<'walletDetails'>['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. +</pattern> + +</patterns> 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 <ref> | --merge-base-with <ref>] +# git-branch-ops.sh push [--remote <name>] [--branch <name>] [--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 +--- + +<goal>Implement an Asana task or ad-hoc feature/fix with clean, well-structured commits.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="read-coding-standards">Before writing ANY code, read `.cursor/rules/typescript-standards.mdc` and follow all rules and standards in it throughout the implementation.</rule> +<rule id="no-impl-before-confirm">Do NOT begin implementation until the user confirms the `/asana-plan` output (Step 0).</rule> +<rule id="lint-before-change">Before the first edit to any `.ts` / `.tsx` file, run `~/.cursor/skills/im/scripts/lint-warnings.sh <files...>` 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`.</rule> +<rule id="no-manual-formatting">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.</rule> +<rule id="commit-script">Always commit using `~/.cursor/skills/lint-commit.sh -m "message" [files...]` or `--fixup <hash>` for fixup commits.</rule> +<rule id="generated-companion-files">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.</rule> +<rule id="clean-history">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.</rule> +<rule id="no-script-bypass">If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or other workarounds.</rule> +<rule id="script-timeouts">`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.</rule> +</rules> + +<step id="0" name="Planning handoff via /asana-plan"> +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. + </step> + +<step id="1" name="Branch setup"> +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/<short-description>` or `$GIT_BRANCH_PREFIX/fix/<short-description>` for bug fixes. Use kebab-case. Example: `<prefix>/some-feature` or `<prefix>/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. +</step> + +<step id="2" name="Pre-change lint check"> +**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 <file1> <file2> ... +``` + +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 `<pattern id="..." rule="...">` 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 <ComponentName>" <file1> <file2> ... + ``` + +**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). +</step> + +<step id="3" name="Implementation"> +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 <file>` 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 <hash> [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) +</step> + +<edge-cases name="edge-react-gui only"> +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. +</edge-cases> + +<step id="4" name="History cleanup"> +**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 <base-branch>`. 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 <base-branch> <hash1> <hash2> ... + ``` + 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 <base-branch>` with an awk or sed script. Verify the final tree matches the pre-restructure state with `git diff`. + </step> + +<step id="5" name="Verification"> +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 <upstream-ref> +``` + +Where `<upstream-ref>` 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. +</step> + +<step id="6" name="Retrospective"> +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. +</step> 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 <file1> [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 <file1> [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 = /<pattern\s+id="([^"]+)"\s+rule="([^"]+)">([\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 <base-branch> <hash1> <hash2> ... +# +# 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 <base-branch> <hash1> <hash2> ..." >&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 `<pm> install` and `<pm> 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 <hash> [file ...] +# lint-commit.sh -m "fixup! Original commit" [file ...] # Auto-reorders +# +# Options: +# -m "msg" Commit message (mutually exclusive with --fixup) +# --fixup <hash> Create a fixup commit targeting <hash> +# --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 <hash> 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 +--- + +<goal>Capture and organize Markdown notes in the user's Obsidian vault (`~/Documents/ob-vault`) using Obsidian-native conventions, without clobbering existing notes.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="vault-path">The vault is `~/Documents/ob-vault` (confirmed Obsidian vault — has `.obsidian/`). Write only inside it. Create subfolders as needed with `mkdir -p`.</rule> +<rule id="no-clobber">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.</rule> +<rule id="obsidian-markdown">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.</rule> +<rule id="frontmatter">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.</rule> +<rule id="stop-if-target-missing">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.)</rule> +<rule id="confirm-location">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`.)</rule> +</rules> + +<step id="1" name="Determine target note"> +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 `<organization-conventions>`; 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/*' +``` +</step> + +<step id="2" name="Write or append"> +- **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. +</step> + +<organization-conventions description="PLACEHOLDER — to be expanded by the user. How notes should be filed/tagged."> +<!-- TODO(jon): fill in the vault's organization scheme. Until then, use sensible defaults and state choices. +Known so far: +- Root: daily notes `YYYY-MM-DD.md` (plain style, no frontmatter). +- Folders observed: Kanban/, Projects/, Claude/ (Claude-related notes). +To define later: +- Folder taxonomy (where do topic/reference notes go vs daily notes?) +- Tagging scheme (controlled vocabulary?) +- Naming conventions for note titles. +- When to append to a running note vs create a new one. +--> +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. +</organization-conventions> + +<edge-cases> +<case name="Vault missing">If `~/Documents/ob-vault` doesn't exist, STOP and tell the user — do not create a vault or write elsewhere.</case> +<case name="Ambiguous append vs new">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.</case> +<case name="Sensitive content">Don't write secrets/tokens into notes. If the content contains credentials, flag it and ask before writing.</case> +</edge-cases> 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 +--- + +<goal>Run the full task-to-PR workflow in one command by orchestrating `/asana-plan`, `/im`, and `/pr-create`.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="orchestrate-existing-skills">Do not re-implement logic already defined in `/asana-plan`, `/im`, or `/pr-create`. Delegate to those skills.</rule> +<rule id="no-attach-default">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).</rule> +<rule id="task-gid-for-pr-body-link">When a task GID is available (from Asana URL input or explicit `--asana-task` flag), always pass `--asana-task <gid>` 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.</rule> +<rule id="no-script-bypass">If any delegated skill or companion script fails, report and stop. Do not bypass with manual alternatives.</rule> +<rule id="pr-body-owned-by-pr-create">Do not draft alternate PR markdown formats inside this workflow. `/pr-create` owns PR body generation and template compliance.</rule> +<rule id="ignore-watchdog-revive-ping">If the user message is literally `<watchdog-revive-ping>` (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.</rule> +<rule id="ignore-refired-one-shot">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.</rule> +<rule id="agent-status-on-pending-task">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 <task_gid> <Status>`. 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.</rule> +<rule id="yolo-hands-off-mode">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.</rule> +<rule id="yolo-single-turn-execution">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/<name>/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.</rule> +<rule id="yolo-true-blockers">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.</rule> +<rule id="yolo-stop-at-pr">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.</rule> +<rule id="pr-watch-loop-amend-pattern">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`).</rule> +<rule id="pr-watch-bounded-poll">The step-6 wait MUST be a single bounded, blocking call inside THIS session's own process: `timeout <remaining-seconds> gh pr checks <pr-num> --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.</rule> +<rule id="never-self-respawn">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 <seconds>` — never schedule a wake to recover from it.</rule> +<rule id="report-as-attachment">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 <gid> --attach-file <path> --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.</rule> +<rule id="bugbot-in-watch">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`.</rule> +</rules> + +<step id="1" name="Collect input"> +Accept one of: + +1. Asana task URL +2. Text/file requirements + +Optional flags: + +- `--asana-task <gid>` (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 <gid> --repo <name>` → prints the worktree path. + +They land together under `~/git/.agent-worktrees/<task-gid>/<repo>/` on branch `agent/<task-gid>` 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/<repo>` 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 `<task-gid>/` 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 <dep> && 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. +</step> + +<step id="2" name="Plan/context phase"> +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`. +</step> + +<step id="3" name="Implementation phase"> +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. +</step> + +<step id="4" name="PR phase"> +Set agent_status=Reviewing. Then run `/pr-create` — always pass `--asana-task <gid>` (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 <gid>` +2. Asana task URL from step 1 +3. chat context from prior steps +</step> + +<step id="5" name="Build and test phase"> +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. +</step> + +<step id="6" name="PR watch (gate to Complete)"> +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 <remaining-seconds> gh pr checks <pr-num> --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. +</step> + +<step id="7" name="Report (attach run report, then Complete)"> +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-<gid>.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 <gid> --attach-file /tmp/agent-run-report-<gid>.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`. +</step> + +<edge-cases> +<case name="No Asana input with attach enabled">Fail fast and ask for `--asana-task <gid>` or disable the attach with `--no-asana-attach`.</case> +<case name="Ad-hoc text task">Allow workflow with `--no-asana-attach` when no task link/GID exists.</case> +</edge-cases> 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 +<!-- cat: summary --> +<!-- 3-6 lines: what was asked, what shipped, final state, links. --> +_None observed._ + +## Decisions +<!-- cat: decisions --> +<!-- Consequential / non-obvious choices only. Each: the decision, the rejected + alternative, and why. In --yolo this also captures auto-deferred decisions + (question, default chosen, reversibility). --> +_None observed._ + +## Dev Notes & Gotchas +<!-- cat: dev-notes-gotchas --> +<!-- Reusable codebase/product knowledge for the next agent on this repo. Prefix each + bullet with an inline tag for later slicing: + [build] how to build/prepare/install for this work, what was actually needed + [test] how to verify this area, preconditions, what a real test run requires + [debug] debugging method that worked (e.g. CDP/debugger usage) + [gotcha] surprising behavior, footgun, non-obvious constraint --> +_None observed._ + +## Orchestration Issues +<!-- cat: orchestration --> +<!-- Friction with the autonomous harness itself (NOT the task code): worktree / + env.json / sim / metro / ports / resume / tmux / wakeup / resource limits / + auth in the spawned env. Enough detail to reproduce or fix. --> +_None observed._ + +## Skill Gaps +<!-- cat: skill-gaps --> +<!-- Per skill: name + what was missing / ambiguous / wrong / didn't trigger when it + should have + a concrete suggested fix. Feeds /author. --> +_None observed._ + +## Task-Drafting Feedback +<!-- cat: task-drafting --> +<!-- What the task description got right or wrong. Info the agent had to guess or hunt + for (creds, paths, acceptance criteria, scope bounds, push/no-push, required + account/KYC/funds preconditions). What to include in the next task of this kind. --> +_None observed._ + +## Follow-ups & Risks +<!-- cat: follow-ups --> +<!-- Forward-looking items the task surfaced but did not (and should not) resolve: + out-of-scope fixes, tech debt, future work, risks. Write each as an ACTIONABLE + proposal a reviewer can approve at a glance — not a vague observation. Omit + low-confidence hunches. One subsection per item, in this shape: + + ### <imperative title, e.g. "Reset provider singletons on logout"> + - **What & why:** 1-2 lines — the change and the reason it matters. + - **Where:** `path/to/file.ts:line`, PR, or area. + - **Proposed action:** the concrete change + the owner skill to carry it + (e.g. /author for a skill/rule, /im for code, /pr-address for review threads). + - **Confidence:** high | medium (drop low-confidence items entirely). + + Keep to real, high-signal items. Use _None observed._ if there are none. --> +_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 <script> [args...] # `npm run --silent <script>` OR `yarn <script>` +# pm.sh pack # `npm pack --silent` OR `yarn pack --quiet`; prints tarball path +# pm.sh detect # prints "npm" or "yarn" +# pm.sh lockfile # prints "package-lock.json" or "yarn.lock" +# +# Exit codes: +# 0 = success +# 2 = usage error +# * = forwarded from the underlying npm/yarn command + +set -euo pipefail + +if [[ -f package-lock.json ]]; then + PM="npm" + LOCK="package-lock.json" +elif [[ -f yarn.lock ]]; then + PM="yarn" + LOCK="yarn.lock" +else + PM="npm" + LOCK="package-lock.json" +fi + +case "${1:-}" in + detect) + echo "$PM" + ;; + lockfile) + echo "$LOCK" + ;; + install) + shift || true + if [[ "$PM" == "npm" ]]; then + exec npm install --no-audit --no-fund "$@" + else + exec yarn install --non-interactive "$@" + fi + ;; + run) + shift + [[ $# -gt 0 ]] || { echo "pm.sh run: missing script name" >&2; exit 2; } + if [[ "$PM" == "npm" ]]; then + exec npm run --silent "$@" + else + exec yarn "$@" + fi + ;; + pack) + # Both managers create the tarball in CWD; print its filename on stdout + # so callers can pipe/capture without parsing tool-specific output. + if [[ "$PM" == "npm" ]]; then + # npm pack writes the filename to stdout (last line with --silent). + npm pack --silent | tail -n 1 + else + yarn pack --quiet >/dev/null + # yarn pack uses the {name}-v{version}.tgz convention. + node -e 'const p=require("./package.json");process.stdout.write(`${p.name}-v${p.version}.tgz`)' + echo + fi + ;; + ""|--help|-h) + sed -n '2,/^set -euo pipefail$/p' "$0" | sed 's/^# //;s/^#//;/^set -euo/d' + exit 2 + ;; + *) + echo "pm.sh: unknown subcommand '$1'" >&2 + echo "Usage: pm.sh {install|run <script>|pack|detect|lockfile}" >&2 + exit 2 + ;; +esac diff --git a/.cursor/skills/pr-address/SKILL.md b/.cursor/skills/pr-address/SKILL.md new file mode 100644 index 0000000..d6dbe37 --- /dev/null +++ b/.cursor/skills/pr-address/SKILL.md @@ -0,0 +1,233 @@ +--- +name: pr-address +description: Address PR feedback with fixup commits, resolving each comment after replying. Use when the user wants to address review comments on a pull request. +compatibility: Requires git, gh. +metadata: + author: j0ntz +--- + +<goal>Address PR feedback with fixup commits, resolving each comment after replying with how it was addressed.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="use-companion-script">Do NOT call `gh` directly. Use `~/.cursor/skills/pr-address/scripts/pr-address.sh` for all GitHub API interactions (it uses `gh` internally).</rule> +<rule id="no-script-bypass">If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or other workarounds.</rule> +<rule id="no-git-editor">All git commands that may open an editor (`rebase --continue`, `commit` without `-m`) MUST be prefixed with `GIT_EDITOR=true` to prevent blocking on `COMMIT_EDITMSG` in the IDE.</rule> +<rule id="no-gitkraken">NEVER use `git_log_or_diff:GitKraken`. Use local `git` commands directly.</rule> +<rule id="this-file-wins">If any other instruction conflicts with this file, **this file wins** for `pr-address`.</rule> +<rule id="commit-via-script">Commit fixups using `~/.cursor/skills/lint-commit.sh --no-reorder -m "fixup! {headline}" [files...]`. `--no-reorder` is required — the default reorder runs `rebase --autosquash` which squashes fixups immediately, conflicting with step 4's conditional autosquash. Do NOT manually run eslint — the commit script handles it.</rule> +<rule id="slot-after-each-fixup">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. This 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 address-pass.</rule> +<rule id="script-timeouts">GitHub API scripts can take up to 30s. Set `block_until_ms: 60000` when invoking `pr-address.sh`.</rule> +<rule id="reply-before-resolve">ALWAYS reply explaining how a comment was addressed BEFORE resolving or marking it. No silent resolutions.</rule> +<rule id="non-owner-reply-only">If you do NOT author the PR (`isOwner: false` in `fetch` output — i.e. `currentUser !== prAuthor`), you may reply to threads and push fixups, but you must NEVER resolve threads (`resolve-thread`) or post `mark-addressed` markers. Resolving/marking mutates the owner's PR state; leave every thread unresolved for the owner. This pairs with the finalize ownership guard (non-owner ⇒ `preserve` mode, never autosquash) — on a PR you don't own: push fixups + reply, never rewrite history, never resolve.</rule> +<rule id="resolution-source-of-truth">Only explicitly resolved threads (`isResolved: true`) or `<!-- addressed:... -->` markers count as resolved. Recency (commits after a comment) does NOT mean resolved.</rule> +</rules> + +<step id="0" name="Ensure correct branch"> +Before any other work, ensure the PR's branch is checked out and up to date: + +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh ensure-branch --owner <OWNER> --repo <REPO> --pr <NUMBER> +``` + +The script: +- If the PR branch is already checked out in **another git worktree** → pulls latest there and reports `WORKTREE_PATH=<dir>`, leaving the main checkout untouched (git forbids the same branch in two worktrees) +- If already on the PR branch → pulls latest +- If on a different branch → stashes uncommitted changes (if any), checks out the PR branch, pulls latest +- In every case, if the target directory has no `node_modules`, installs deps (`npm ci` or `yarn install` per the lockfile) so `lint-commit.sh`'s eslint resolves. **This can take several minutes on a cold worktree — invoke `ensure-branch` with `block_until_ms: 600000`.** + +Output includes `BRANCH_READY`, `STASHED`, and (if switched) `PREVIOUS_BRANCH`. If `STASHED=true`, inform the user that changes were stashed on the previous branch. + +<rule id="operate-in-worktree">If the output contains `WORKTREE_PATH=<dir>`, ALL subsequent git, commit, and companion-script operations for this PR MUST run inside `<dir>` — `cd "<dir>"` first (or pass `git -C "<dir>"`). Do NOT run them against the main checkout, and do NOT stash/switch the main checkout. The branch lives in that worktree.</rule> +</step> + +<step id="1" name="Fetch all unresolved feedback and PR body"> +Always fetch live from GitHub. Run both in parallel: + +```bash +# Fetch unresolved feedback +~/.cursor/skills/pr-address/scripts/pr-address.sh fetch --owner <OWNER> --repo <REPO> --pr <NUMBER> + +# Populate /tmp/pr-body.md from the live PR body (source of truth) +~/.cursor/skills/pr-address/scripts/pr-address.sh fetch-pr-body --owner <OWNER> --repo <REPO> --pr <NUMBER> +``` + +If either script exits code 2 with `PROMPT_GH_AUTH`, prompt: "`gh` CLI is not authenticated. Please run: `gh auth login`" + +The `fetch` output contains: +- **prAuthor**: The PR author's GitHub username (informational only — NOT used for filtering) +- **currentUser**: Your GitHub username (the authenticated `gh` user) +- **hasHumanReviewers**: `true` if any human (not `currentUser`, not bots) has commented — used for autosquash decision. In collab PRs, the GitHub-recorded author counts as a peer reviewer here. +- **humanReviewers**: List of human reviewer usernames (everyone except `currentUser` and bots) +- **threads**: All unresolved inline review threads (includes comments from `currentUser` for context) +- **reviewBodies**: Latest review body per human reviewer (excludes `currentUser` and bots) +- **topLevel**: Top-level comments (excludes `currentUser` and bots) + +To inspect a specific inline thread, including an already-resolved one, use: + +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh fetch-thread \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --thread-id "<PRRT_threadNodeId>" +``` + +The `fetch-pr-body` call writes the current PR body to `/tmp/pr-body.md`. This file is available for editing throughout the session. If you need to update the PR body (e.g. to revise the description after addressing feedback), edit `/tmp/pr-body.md` via the Write tool and push it back: + +```bash +gh pr edit <NUMBER> --body-file /tmp/pr-body.md +``` +</step> + +<step id="1.5" name="Squash stale fixups (Fixups A → squash before Fixups B)"> +Before applying any new fixups for this address-pass, 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 <OWNER> --repo <REPO> --pr <NUMBER> +``` + +The script returns one of: +- `{"action": "autosquash", "mode": "...", "newHead": "..."}` — existing fixups were squashed and force-pushed (clean slate for this pass). +- `{"action": "noop", "mode": "...", "reason": "..."}` — nothing to squash (no existing fixups, or fixups are still part of the current review cycle). + +Policy (single source of truth lives in `pr-finalize-fixups.sh`): squash existing fixups when (a) mode is autosquash (no active reviewer), or (b) mode is preserve AND the latest human review timestamp postdates the latest fixup commit (the reviewer has already seen those fixups in their last review and has now come back with new feedback — start fresh). **Exception:** if you are not the PR author (`currentUser !== prAuthor`), squash-stale is always a noop — we never rewrite history on a PR we don't own. + +If the script exits non-zero (conflict), report and STOP so the user can resolve manually. +</step> + +<step id="2" name="Process all unresolved feedback"> +Address every item returned by `fetch`. Group inline threads by file. If the user provided specific files, scope to those only. + +<sub-step name="Determine fixup target"> +Ask: **"Which commit introduced the behavior/code this comment is about?"** + +- List commits touching the file: `git log --oneline -- <file>` +- A specific line/function → fixup the commit that introduced it +- A missing feature/behavior → fixup the commit that should have included it +- A pattern/style issue → fixup the earliest commit where it appears +- Ambiguous → ask the user + +Get the target commit headline: +```bash +git log -1 --format='%s' <commit_sha> +``` +</sub-step> + +<sub-step name="Apply fixes"> +For each comment (one fixup at a time): + +1. Read the file +2. Apply changes — comment hunks can be narrower than intent; apply consistently within the function/file +3. Commit using `lint-commit.sh`: + ```bash + ~/.cursor/skills/lint-commit.sh --no-reorder -m "fixup! {targetHeadline}" [files...] + ``` +4. **Immediately slot the new fixup next to its target's group** (preserves the "every fixup sits next to its target" invariant continuously): + ```bash + ~/.cursor/skills/slot-fixup.sh + ``` + If `slot-fixup.sh` reports a conflict, STOP — do not continue the address-pass. The user must resolve. + +Repeat steps 1–4 for each remaining comment. Do not batch fixes across multiple comments before slotting. +</sub-step> +</step> + +<step id="3" name="Reply and resolve each comment"> +After fixing, reply to every processed comment — addressed or rejected — then resolve it. + +**Ownership gate (check `isOwner` from Step 1 `fetch` output first):** +1. `isOwner: true` (you author the PR) → reply, then resolve threads / mark-addressed as described below. +2. `isOwner: false` (`currentUser !== prAuthor`) → **reply only**. Do NOT run `resolve-thread` and do NOT run `mark-addressed`. Leave every thread unresolved for the owner. Skip the resolve/mark sub-steps entirely. + +<sub-step name="Inline threads (reply → resolve)"> +If a later fix may affect an already-addressed inline thread, inspect the thread first: + +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh fetch-thread \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --thread-id "<PRRT_threadNodeId>" +``` + +Use the returned history to decide whether the existing reply still fully reflects the latest fix. If it does not, add one new factual follow-up reply. Multiple replies in the same thread are acceptable when they capture materially new fixes. + +1. Reply to the first comment in the thread: + ```bash + ~/.cursor/skills/pr-address/scripts/pr-address.sh reply \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --comment-id <NUMERIC_ID> --body "<what was fixed>" + ``` + + If the comment ID is a GraphQL node ID, resolve to numeric first: + ```bash + ~/.cursor/skills/pr-address/scripts/pr-address.sh resolve-id \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --node-id "<PRRC_nodeId>" + ``` + +2. Then mark the thread as resolved: + ```bash + ~/.cursor/skills/pr-address/scripts/pr-address.sh resolve-thread --thread-id "<PRRT_threadNodeId>" + ``` +</sub-step> + +<sub-step name="Review bodies and top-level comments (reply → mark addressed)"> +These have no native resolution mechanism. Post a top-level comment with a machine-readable marker: + +```bash +~/.cursor/skills/pr-address/scripts/pr-address.sh mark-addressed \ + --owner <OWNER> --repo <REPO> --pr <NUMBER> \ + --type <review|comment> --target-id <NUMERIC_ID> \ + --body "<what was fixed>" +``` + +The script appends `<!-- addressed:review:ID -->` or `<!-- addressed:comment:ID -->` to the body. Subsequent `fetch` calls detect these markers and exclude already-addressed items. + +**Skip bot-only no-op items**: If a review body or top-level comment is from an automated reviewer AND contains no inline threads with actionable suggestions — only a summary or status message — do NOT post a `mark-addressed` comment. The `fetch` script classifies automated reviewers robustly via GraphQL `author.__typename === 'Bot'` (which also strips the `[bot]` suffix, so every Cursor agent — Bugbot and the Cursor Security Reviewer — appears as `cursor`), plus a `[bot]`-suffix fallback and the hard-coded `chatgpt-codex-connector` User account. Human reviewer items must always be addressed or rejected, even terse ones like "This needs work". +</sub-step> + +<sub-step name="Reply guidelines"> +- **Addressed**: State what was fixed. Factual, 1 sentence. +- **Invalid/false-positive**: Brief evidence citing code paths or logic. 1-3 sentences. +- No pleasantries. Factual tone only. +</sub-step> +</step> + +<step id="4" name="Finalize fixups (autosquash or push, mode-dependent)"> +Delegate the autosquash-vs-push decision and execution to the shared finalize helper. It calls `pr-address.sh review-mode` to derive the mode from the latest human activity, then either autosquashes + force-pushes (autosquash mode) or just force-pushes (preserve mode). Policy lives in that one script and is shared with other skills (bugbot) so behavior never drifts. + +**Ownership guard:** if you are not the PR author (`currentUser !== prAuthor`), the helper forces `preserve` mode and never autosquashes — we never rewrite the history of a PR we don't own. Fixups stay on top for the owner to squash at merge. + +```bash +~/.cursor/skills/pr-finalize-fixups.sh --owner <OWNER> --repo <REPO> --pr <NUMBER> +``` + +Output is one line of JSON: +- `{"action": "autosquash", "mode": "autosquash", "newHead": "<sha>"}` — branch history rewritten, force-pushed. +- `{"action": "push", "mode": "preserve", "newHead": "<sha>"}` — fixups left in place for the reviewer to see; force-pushed (per-fixup slotting rewrote tip). + +If the script exits non-zero, the autosquash hit a conflict mid-rebase. The working tree is in `REBASE_HEAD` state; report the error and STOP so the user can resolve manually (`git status`, fix files, `GIT_EDITOR=true git rebase --continue`, or `git rebase --abort`). +</step> + +<step id="5" name="Verification"> +Run full verification to catch issues introduced by fixup commits: + +```bash +~/.cursor/skills/verify-repo.sh . --base <upstream-ref> +``` + +Where `<upstream-ref>` 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 another fixup commit, then re-run verification. +</step> + +<step id="6" name="Post-processing"> +Propose modifications to `~/.cursor/rules/typescript-standards.mdc` to prevent similar review comments in the future. Prompt for confirmation before applying. +</step> + +<edge-cases> +<case name="No gh auth">Script exits code 2 with `PROMPT_GH_AUTH`. Prompt user to run `gh auth login` and STOP.</case> +<case name="No unresolved feedback">Report "No unresolved comments on this PR" and STOP.</case> +<case name="Reviewer is still active">Mode is `preserve` — fixups are left in place for the reviewer to verify. They get squashed automatically on the NEXT address-pass once the reviewer comes back with more feedback (Step 1.5 squash-stale handles it), or on final merge.</case> +<case name="Comment already addressed in code">If the current code already handles the feedback (e.g., from a previous fixup), still reply explaining this and resolve/mark the comment. Do not leave it unresolved. (Exception: if `isOwner: false`, reply only — never resolve/mark, per `non-owner-reply-only`.)</case> +<case name="Not the PR author (isOwner: false)">When `fetch` reports `isOwner: false` (`currentUser !== prAuthor`), reply to every processed thread explaining the fix, but never resolve threads or mark-addressed — leave them for the owner. Combined with the finalize guard, the whole pass on an unowned PR is: push fixups + reply, with no history rewrite and no resolutions.</case> +<case name="Already resolved thread needs follow-up">Fetch the thread history first. If the prior reply no longer reflects the latest fix, post one additional factual follow-up reply. Do not edit or delete prior replies in this workflow.</case> +<case name="Slot-fixup conflict">If `slot-fixup.sh` exits non-zero, the rebase has been aborted automatically (working tree is clean) but the new fixup is still at tip, not yet slotted next to its target. Report to the user and STOP. They can either resolve the conflict manually or revert the fixup and re-approach.</case> +</edge-cases> diff --git a/.cursor/skills/pr-address/scripts/pr-address.sh b/.cursor/skills/pr-address/scripts/pr-address.sh new file mode 100755 index 0000000..358fde0 --- /dev/null +++ b/.cursor/skills/pr-address/scripts/pr-address.sh @@ -0,0 +1,556 @@ +#!/usr/bin/env bash +# pr-address.sh +# Companion script for pr-address.md +# Handles deterministic operations: comment fetching, replies, thread resolution, autosquash. +# +# Subcommands: +# fetch --owner <o> --repo <r> --pr <n> Fetch all unresolved feedback via GraphQL +# fetch-thread --owner <o> --repo <r> --pr <n> --thread-id <id> +# reply --owner <o> --repo <r> --pr <n> --comment-id <id> --body <text> +# resolve-thread --thread-id <node_id> Mark inline thread as resolved (GraphQL) +# mark-addressed --owner <o> --repo <r> --pr <n> --type <review|comment> --target-id <id> --body <text> +# resolve-id --owner <o> --repo <r> --pr <n> --node-id <id> +# headline --owner <o> --repo <r> --sha <sha> +# fetch-pr-body --owner <o> --repo <r> --pr <n> Fetch current PR body → /tmp/pr-body.md +# ensure-branch --owner <o> --repo <r> --pr <n> Checkout PR branch, stash if needed, pull. +# If the branch is already bound to another worktree, +# reports WORKTREE_PATH=<dir> and leaves main checkout untouched. +# Installs node deps (npm ci / yarn install) when the +# target dir has no node_modules — may take minutes. +# review-mode --owner <o> --repo <r> --pr <n> Determine autosquash/preserve mode from latest human activity +# autosquash Rebase --autosquash from merge-base +# +# Exit codes: 0 = success, 1 = error, 2 = needs user input (e.g. gh not authenticated) +set -euo pipefail + +CMD="${1:-}" +shift || true + +OWNER="" REPO="" PR="" COMMENT_ID="" NODE_ID="" BODY="" SHA="" THREAD_ID="" TARGET_TYPE="" TARGET_ID="" +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --pr) PR="$2"; shift 2 ;; + --comment-id) COMMENT_ID="$2"; shift 2 ;; + --node-id) NODE_ID="$2"; shift 2 ;; + --body) BODY="$2"; shift 2 ;; + --sha) SHA="$2"; shift 2 ;; + --thread-id) THREAD_ID="$2"; shift 2 ;; + --type) TARGET_TYPE="$2"; shift 2 ;; + --target-id) TARGET_ID="$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 +} + +# Install node deps in <dir> when node_modules is absent. Agent worktrees are +# created without an install, so lint-commit.sh's `./node_modules/.bin/eslint` +# would be missing. Detects the package manager from the lockfile (npm vs yarn). +# Can take several minutes on a cold worktree — callers must allow for it. +ensure_deps() { + local dir="$1" + [[ -d "$dir/node_modules" ]] && return 0 + echo ">> No node_modules in $dir — installing dependencies (may take several minutes)" + # Bypass the socket-firewall shim (~/.agent-shims/{yarn,npm} → `socket <pm>`), + # which loops its banner and exits non-zero on the recursive prepare/husky + # lifecycle. Strip the shim dir from PATH so the install AND its lifecycle + # scripts run the real package managers directly. + local clean_path + clean_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '\.agent-shims' | paste -sd: - || true)" + # Global ~/.npmrc auths the public registry with `_authToken=${NPM_TOKEN}`. + # yarn 1.x aborts when that var is unset (common in agent envs). A dummy value + # satisfies the substitution; a bogus token is harmless for public-package + # installs. A real NPM_TOKEN in the env still wins. + local npm_token="${NPM_TOKEN:-public}" + if [[ -f "$dir/package-lock.json" ]]; then + ( cd "$dir" && PATH="$clean_path" NPM_TOKEN="$npm_token" npm ci ) || { echo "Error: npm ci failed in $dir" >&2; exit 1; } + elif [[ -f "$dir/yarn.lock" ]]; then + ( cd "$dir" && PATH="$clean_path" NPM_TOKEN="$npm_token" yarn install --frozen-lockfile ) || { echo "Error: yarn install failed in $dir" >&2; exit 1; } + else + echo "Error: no package-lock.json or yarn.lock in $dir — cannot install deps" >&2; exit 1 + fi + echo ">> Dependencies installed in $dir" +} + +case "$CMD" in + fetch) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" ]]; then + echo "Error: --owner, --repo, --pr required" >&2; exit 1 + fi + + gh api graphql \ + -f query='query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + author { login __typename } + headRefName + baseRefName + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 50) { + nodes { + databaseId + createdAt + author { login __typename } + path + line + body + } + } + } + } + reviews(last: 50) { + nodes { + databaseId + author { login __typename } + state + body + submittedAt + } + } + comments(last: 50) { + nodes { + databaseId + createdAt + author { login __typename } + body + } + } + } + } + }' \ + -f owner="$OWNER" -f repo="$REPO" -F number="$PR" \ + | GH_USER=$(gh api user --jq '.login') node -e " + const fs = require('fs') + const data = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8')) + const pr = data.data.repository.pullRequest + const prAuthor = pr.author?.login + const currentUser = process.env.GH_USER + + const addressedIds = new Set() + for (const c of pr.comments.nodes) { + for (const m of (c.body || '').matchAll(/<!-- addressed:(?:review|comment):(\d+) -->/g)) { + addressedIds.add(Number(m[1])) + } + } + + // GraphQL marks bot actors with __typename === 'Bot' and strips the + // '[bot]' suffix from their login (so Cursor's Bugbot AND its Security + // Reviewer both appear as 'cursor'). Collect every bot login from the + // payload so detection covers all automated reviewers (coderabbitai, + // github-actions, sonarcloud, copilot, etc.) rather than a hard-coded list. + const botLogins = new Set() + const noteAuthor = a => { if (a && a.__typename === 'Bot' && a.login) botLogins.add(a.login) } + noteAuthor(pr.author) + for (const t of pr.reviewThreads.nodes) for (const c of t.comments.nodes) noteAuthor(c.author) + for (const r of pr.reviews.nodes) noteAuthor(r.author) + for (const c of pr.comments.nodes) noteAuthor(c.author) + + // '[bot]' suffix = REST-sourced login fallback; chatgpt-codex-connector is a + // User-typed automation account (no Bot typename) so it stays hard-coded. + const isBot = u => !u || botLogins.has(u) || u.includes('[bot]') + const isAutomatedReviewer = u => isBot(u) || u === 'chatgpt-codex-connector' + + const threads = pr.reviewThreads.nodes + .filter(t => !t.isResolved) + .map(t => ({ + threadId: t.id, + path: t.comments.nodes[0]?.path, + line: t.comments.nodes[0]?.line, + comments: t.comments.nodes.map(c => ({ + id: c.databaseId, + user: c.author?.login, + body: c.body, + createdAt: c.createdAt + })) + })) + + // Check if any human (non-bot, non-automated, non-currentUser) reviewer has commented + // prAuthor CAN be an external human reviewer if they're not currentUser + const humanCommenters = new Set() + for (const t of threads) { + for (const c of t.comments) { + if (c.user && !isAutomatedReviewer(c.user) && c.user !== currentUser) { + humanCommenters.add(c.user) + } + } + } + + const latestByUser = {} + for (const r of pr.reviews.nodes) { + const user = r.author?.login + if (!user || user === currentUser || r.state === 'PENDING' || isBot(user)) continue + const prev = latestByUser[user] + if (!prev || new Date(r.submittedAt) > new Date(prev.submittedAt)) { + latestByUser[user] = r + } + if (!isAutomatedReviewer(user)) { + humanCommenters.add(user) + } + } + const reviewBodies = Object.entries(latestByUser) + .filter(([, r]) => r.body?.trim() && !addressedIds.has(r.databaseId)) + .map(([user, r]) => ({ + reviewId: r.databaseId, user, state: r.state, + body: r.body, submittedAt: r.submittedAt + })) + + const topLevel = pr.comments.nodes.filter(c => { + const user = c.author?.login + if (!user || user === currentUser || isBot(user)) return false + if ((c.body || '').includes('<!-- addressed:')) return false + if (!isAutomatedReviewer(user)) { + humanCommenters.add(user) + } + return !addressedIds.has(c.databaseId) + }).map(c => ({ + id: c.databaseId, user: c.author?.login, + body: c.body, createdAt: c.createdAt + })) + + // isOwner: do we author this PR? When false, the workflow may reply and + // push fixups but must NEVER resolve threads, mark-addressed, or rewrite + // history. Fails safe to false if prAuthor couldn't be resolved. + const isOwner = !!prAuthor && currentUser === prAuthor + + console.log(JSON.stringify({ + prAuthor, currentUser, isOwner, headRef: pr.headRefName, baseRef: pr.baseRefName, + hasHumanReviewers: humanCommenters.size > 0, + humanReviewers: Array.from(humanCommenters), + threads, reviewBodies, topLevel + }, null, 2)) + " + ;; + + fetch-thread) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" || -z "$THREAD_ID" ]]; then + echo "Error: --owner, --repo, --pr, --thread-id required" >&2; exit 1 + fi + + gh api graphql \ + -f query='query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 50) { + nodes { + databaseId + createdAt + author { login __typename } + path + line + body + } + } + } + } + } + } + }' \ + -f owner="$OWNER" -f repo="$REPO" -F number="$PR" \ + | GH_THREAD_ID="$THREAD_ID" node -e " + const fs = require('fs') + const data = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8')) + const threads = data.data.repository.pullRequest.reviewThreads.nodes + const thread = threads.find(item => item.id === process.env.GH_THREAD_ID) + if (thread == null) { + console.error('Thread not found: ' + process.env.GH_THREAD_ID) + process.exit(1) + } + + console.log(JSON.stringify({ + threadId: thread.id, + isResolved: thread.isResolved, + path: thread.comments.nodes[0]?.path ?? null, + line: thread.comments.nodes[0]?.line ?? null, + comments: thread.comments.nodes.map(comment => ({ + id: comment.databaseId, + user: comment.author?.login ?? null, + body: comment.body, + createdAt: comment.createdAt + })) + }, null, 2)) + " + ;; + + reply) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" || -z "$COMMENT_ID" || -z "$BODY" ]]; then + echo "Error: --owner, --repo, --pr, --comment-id, --body required" >&2; exit 1 + fi + RESULT=$(echo '{}' | jq --arg body "$BODY" '{body: $body}' | \ + gh api "repos/$OWNER/$REPO/pulls/$PR/comments/$COMMENT_ID/replies" \ + -X POST --input -) + ID=$(echo "$RESULT" | jq -r '.id // empty') + if [[ -n "$ID" ]]; then + echo "replied: $ID" + else + echo "Reply failed: $RESULT" >&2; exit 1 + fi + ;; + + resolve-thread) + require_gh + if [[ -z "$THREAD_ID" ]]; then + echo "Error: --thread-id required" >&2; exit 1 + fi + RESULT=$(gh api graphql \ + -f query='mutation($id: ID!) { resolveReviewThread(input: {threadId: $id}) { thread { id isResolved } } }' \ + -f id="$THREAD_ID") + RESOLVED=$(echo "$RESULT" | jq -r '.data.resolveReviewThread.thread.isResolved // empty') + if [[ "$RESOLVED" == "true" ]]; then + echo "resolved: $THREAD_ID" + else + echo "Resolve failed: $RESULT" >&2; exit 1 + fi + ;; + + mark-addressed) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" || -z "$TARGET_TYPE" || -z "$TARGET_ID" || -z "$BODY" ]]; then + echo "Error: --owner, --repo, --pr, --type, --target-id, --body required" >&2; exit 1 + fi + MARKER="<!-- addressed:${TARGET_TYPE}:${TARGET_ID} -->" + FULL_BODY="${BODY} ${MARKER}" + RESULT=$(echo '{}' | jq --arg body "$FULL_BODY" '{body: $body}' | \ + gh api "repos/$OWNER/$REPO/issues/$PR/comments" -X POST --input -) + ID=$(echo "$RESULT" | jq -r '.id // empty') + if [[ -n "$ID" ]]; then + echo "marked: $ID ($MARKER)" + else + echo "Mark failed: $RESULT" >&2; exit 1 + fi + ;; + + resolve-id) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" || -z "$NODE_ID" ]]; then + echo "Error: --owner, --repo, --pr, --node-id required" >&2; exit 1 + fi + RESULT=$(gh api "repos/$OWNER/$REPO/pulls/$PR/comments" --paginate \ + --jq ".[] | select(.node_id == \"$NODE_ID\") | .id") + if [[ -n "$RESULT" ]]; then + echo "$RESULT" + else + echo "Comment not found for node_id: $NODE_ID" >&2; exit 1 + fi + ;; + + headline) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$SHA" ]]; then + echo "Error: --owner, --repo, --sha required" >&2; exit 1 + fi + gh api "repos/$OWNER/$REPO/commits/$SHA" --jq '.commit.message | split("\n") | .[0]' + ;; + + fetch-pr-body) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" ]]; then + echo "Error: --owner, --repo, --pr required" >&2; exit 1 + fi + BODY=$(gh api "repos/$OWNER/$REPO/pulls/$PR" --jq '.body // ""') + echo "$BODY" > /tmp/pr-body.md + echo ">> Wrote PR body to /tmp/pr-body.md ($(wc -c < /tmp/pr-body.md | tr -d ' ') bytes)" + ;; + + ensure-branch) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" ]]; then + echo "Error: --owner, --repo, --pr required" >&2; exit 1 + fi + + PR_BRANCH=$(gh api "repos/$OWNER/$REPO/pulls/$PR" --jq '.head.ref') + CURRENT_BRANCH=$(git branch --show-current) + + # If the PR branch is already checked out in another worktree, operate there + # instead of stashing/switching the main checkout. git forbids the same branch + # in two worktrees, so a plain `git checkout` would fail with fatal exit 128. + THIS_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + WORKTREE_PATH=$(git worktree list --porcelain | awk -v b="refs/heads/$PR_BRANCH" ' + /^worktree /{wt=substr($0,10)} + /^branch /{if($2==b){print wt; exit}} + ') + if [[ -n "$WORKTREE_PATH" && "$WORKTREE_PATH" != "$THIS_TOPLEVEL" ]]; then + echo ">> $PR_BRANCH is checked out in worktree: $WORKTREE_PATH" + git -C "$WORKTREE_PATH" pull --ff-only 2>&1 || git -C "$WORKTREE_PATH" pull --rebase 2>&1 + ensure_deps "$WORKTREE_PATH" + echo ">> BRANCH_READY=$PR_BRANCH STASHED=false WORKTREE_PATH=$WORKTREE_PATH" + exit 0 + fi + + if [[ "$CURRENT_BRANCH" == "$PR_BRANCH" ]]; then + echo ">> Already on $PR_BRANCH — pulling latest" + git pull --ff-only 2>&1 || git pull --rebase 2>&1 + ensure_deps "$(git rev-parse --show-toplevel)" + echo ">> BRANCH_READY=$PR_BRANCH STASHED=false" + else + STASHED=false + if ! git diff --quiet HEAD 2>/dev/null || ! git diff --cached --quiet HEAD 2>/dev/null || [[ -n "$(git ls-files --others --exclude-standard)" ]]; then + echo ">> Stashing uncommitted changes on $CURRENT_BRANCH" + git stash -u + STASHED=true + fi + echo ">> Switching from $CURRENT_BRANCH to $PR_BRANCH" + git checkout "$PR_BRANCH" 2>&1 + git pull --ff-only 2>&1 || git pull --rebase 2>&1 + ensure_deps "$(git rev-parse --show-toplevel)" + echo ">> BRANCH_READY=$PR_BRANCH STASHED=$STASHED PREVIOUS_BRANCH=$CURRENT_BRANCH" + fi + ;; + + review-mode) + require_gh + if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" ]]; then + echo "Error: --owner, --repo, --pr required" >&2; exit 1 + fi + + # Pull every reviews/comments/thread record (resolved or not) so we can find + # the most-recent human activity. The "fetch" subcommand only returns + # unresolved items, which would miss the case where the human's last action + # was an inline comment that's already been resolved. + gh api graphql \ + -f query='query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + author { login __typename } + reviewThreads(first: 100) { + nodes { + comments(first: 50) { + nodes { createdAt author { login __typename } } + } + } + } + reviews(last: 100) { + nodes { author { login __typename } state submittedAt } + } + comments(last: 100) { + nodes { createdAt author { login __typename } } + } + } + } + }' \ + -f owner="$OWNER" -f repo="$REPO" -F number="$PR" \ + | GH_USER=$(gh api user --jq '.login') node -e " + const fs = require('fs') + const data = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8')) + const pr = data.data.repository.pullRequest + const prAuthor = pr.author?.login + const currentUser = process.env.GH_USER + + // Identify bots by GraphQL __typename === 'Bot' (logins lose the '[bot]' + // suffix here, so e.g. Cursor's Bugbot and Security Reviewer both show as + // 'cursor'). Collect every bot login from the payload — see the fetch + // subcommand for the full rationale. + const botLogins = new Set() + const noteAuthor = a => { if (a && a.__typename === 'Bot' && a.login) botLogins.add(a.login) } + noteAuthor(pr.author) + for (const t of pr.reviewThreads.nodes) for (const c of t.comments.nodes) noteAuthor(c.author) + for (const r of pr.reviews.nodes) noteAuthor(r.author) + for (const c of pr.comments.nodes) noteAuthor(c.author) + + const isBot = u => !u || botLogins.has(u) || u.includes('[bot]') + const isAutomated = u => isBot(u) || u === 'chatgpt-codex-connector' + // Exclude only currentUser + bots/automated. Works uniformly for solo + // PRs (currentUser == prAuthor — author/self excluded) and collab PRs + // (currentUser != prAuthor — author is a peer reviewer, included). + const isHuman = u => u && u !== currentUser && !isAutomated(u) + + const events = [] + + // Inline review comments (across all threads, resolved or not). + for (const t of pr.reviewThreads.nodes) { + for (const c of t.comments.nodes) { + if (isHuman(c.author?.login)) { + events.push({ + type: 'inline', + user: c.author.login, + timestamp: c.createdAt, + state: null + }) + } + } + } + + // Formal review submissions. + for (const r of pr.reviews.nodes) { + const user = r.author?.login + if (!isHuman(user)) continue + if (r.state === 'PENDING') continue + events.push({ + type: 'review', + user, + timestamp: r.submittedAt, + state: r.state + }) + } + + // Top-level PR comments. + for (const c of pr.comments.nodes) { + if (isHuman(c.author?.login)) { + events.push({ + type: 'topLevel', + user: c.author.login, + timestamp: c.createdAt, + state: null + }) + } + } + + events.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + const latest = events[0] || null + + // Ownership guard (HARD override): if we are not the PR author we must + // never rewrite the owner's history. Always preserve fixups so the owner + // squashes them at merge. Takes precedence over activity-based derivation; + // a missing prAuthor (couldn't resolve) also fails safe to preserve. + const isOwner = !!prAuthor && currentUser === prAuthor + + let mode + if (!isOwner) { + mode = 'preserve' + } else if (latest == null) { + mode = 'autosquash' + } else if (latest.type === 'review' && (latest.state === 'APPROVED' || latest.state === 'DISMISSED')) { + mode = 'autosquash' + } else { + mode = 'preserve' + } + + process.stdout.write(JSON.stringify({ + mode, + isOwner, + prAuthor, + currentUser, + latestHumanActivity: latest + }) + '\n') + " + ;; + + autosquash) + DEFAULT_UPSTREAM=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null \ + || echo "origin/$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')") + ~/.cursor/skills/git-branch-ops.sh autosquash --merge-base-with "$DEFAULT_UPSTREAM" + ;; + + *) + echo "Usage: pr-address.sh {fetch|fetch-thread|reply|resolve-thread|mark-addressed|resolve-id|headline|fetch-pr-body|ensure-branch|review-mode|autosquash} [args]" >&2 + exit 1 + ;; +esac diff --git a/.cursor/skills/pr-create/SKILL.md b/.cursor/skills/pr-create/SKILL.md new file mode 100644 index 0000000..37a7beb --- /dev/null +++ b/.cursor/skills/pr-create/SKILL.md @@ -0,0 +1,103 @@ +--- +name: pr-create +description: Create a pull request from the current branch, with optional Asana attach. +compatibility: Requires git, gh, node, jq. ASANA_TOKEN for Asana updates. ASANA_GITHUB_SECRET is OPTIONAL — only consumed by the `--asana-attach` widget path; the Asana link in the PR body works without it. +metadata: + author: j0ntz +--- + +<goal>Create a PR from the current branch, optionally attach it to Asana.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="use-companion-script">Do NOT call `gh` directly for PR creation. Use `~/.cursor/skills/pr-create/scripts/pr-create.sh`.</rule> +<rule id="no-script-bypass">If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or workarounds.</rule> +<rule id="gh-auth-required">If script exits code 2 with `PROMPT_GH_AUTH`, prompt user to run `gh auth login` and STOP.</rule> +<rule id="no-dirty-pr">Do NOT create a PR when there are uncommitted changes.</rule> +<rule id="no-base-push">Do NOT push to `master`/`develop` directly.</rule> +<rule id="verification-required">Run verification before creating the PR.</rule> +<rule id="no-reviewer-assignment">Do NOT auto-assign Asana reviewers, set review-needed status, or estimate review hours from this skill. Reviewer choice is a human step; callers that want those behaviors must invoke `asana-task-update` themselves.</rule> +<rule id="flag-contract">`--asana-attach` only runs when a task GID is available from chat context or explicit `--asana-task <gid>`. If no task GID is available, fail fast and skip the attach.</rule> +<rule id="script-timeouts">Asana updates can take up to 90s. Use `block_until_ms: 120000` for `asana-task-update.sh` calls.</rule> +<rule id="repo-template-required">If the repo has `.github/PULL_REQUEST_TEMPLATE.md`, the PR body must preserve that template's section headings. Do NOT substitute generic sections like `Summary` or `Test plan`.</rule> +</rules> + +<step id="1" name="Push branch"> +Push current branch if needed: + +```bash +git push -u origin HEAD +``` + +If tracking is already configured and branch is up to date, skip. +</step> + +<step id="2" name="Verification"> +Run: + +```bash +~/.cursor/skills/verify-repo.sh . --base <upstream-ref> +``` + +Use `origin/develop` for `edge-react-gui` and `origin/master` for other repos. +</step> + +<step id="3" name="Build PR description"> +Gather context in parallel: + +```bash +DEFAULT_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|origin/||' || git remote show origin 2>/dev/null | sed -n '/HEAD branch/s/.*: //p' || echo master) +git log origin/$DEFAULT_BRANCH..HEAD --format=%B--- +``` + +If `.github/PULL_REQUEST_TEMPLATE.md` exists, read it now and use it as the source of truth for the PR body structure. Fill in its existing sections and only append `### Description` if the template has no description section and branch context needs a place to live. + +If Asana context is available from chat or fetched via `--asana-task`, add it inside `### Description`. Do not invent alternate section sets such as `Summary` / `Test plan`. +</step> + +<step id="4" name="Create PR"> +Write body to `/tmp/pr-body.md`, then run: + +```bash +~/.cursor/skills/pr-create/scripts/pr-create.sh \ + --title "<title>" \ + --body-file /tmp/pr-body.md \ + [--asana-task <task_gid>] +``` + +The companion script validates body files against the repo template and rejects generic fallback sections on templated repos. Capture PR URL and number from JSON output. +</step> + +<step id="5" name="Optional Asana PR attach"> +If `--asana-attach` was not requested, skip. + +If `--asana-attach` is requested, resolve `task_gid` from: + +1. explicit `--asana-task <gid>` argument +2. chat context (previous task-review/im context) + +If no task GID is available, fail fast and report: + +> `--asana-attach` was requested but no task GID was found in flags or chat context. + +Then call: + +```bash +~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \ + --task <task_gid> \ + --attach-pr --pr-url <pr_url> --pr-title "<title>" --pr-number <number> +``` + +Do NOT pass `--assign`, `--set-status`, or `--auto-est-review-hrs` from this skill. Reviewer assignment and review-status updates are intentionally out of scope — see `no-reviewer-assignment` rule. +</step> + +<step id="6" name="Report result"> +Display PR URL as a clickable markdown link: + +`[owner/repo#123](https://github.com/owner/repo/pull/123)` +</step> + +<edge-cases> +<case name="Branch already has an open PR">Report the existing PR URL and stop.</case> +<case name="No gh auth">Prompt user to run `gh auth login` and stop.</case> +<case name="Rebase needed">Ask user before rebasing and force-pushing.</case> +</edge-cases> diff --git a/.cursor/skills/pr-create/scripts/pr-create.sh b/.cursor/skills/pr-create/scripts/pr-create.sh new file mode 100755 index 0000000..eb40ead --- /dev/null +++ b/.cursor/skills/pr-create/scripts/pr-create.sh @@ -0,0 +1,331 @@ +#!/usr/bin/env node +// pr-create.sh — Creates a PR for the current branch using gh CLI. +// Usage: ./pr-create.sh [--title "PR title"] [--body-file <path>] [--draft] +// Reads from git context: repo owner/name, current branch, default branch. +// Outputs JSON with PR URL and number on success. + +const { execSync, spawnSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +// Parse args +const args = process.argv.slice(2); +let title = null; +let bodyFile = null; +let draft = false; +let asanaTask = null; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--title" && args[i + 1]) title = args[++i]; + else if (args[i] === "--body-file" && args[i + 1]) bodyFile = args[++i]; + else if (args[i] === "--asana-task" && args[i + 1]) asanaTask = args[++i]; + else if (args[i] === "--draft") draft = true; +} + +function git(cmd) { + return execSync(`git ${cmd}`, { encoding: "utf8" }).trim(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function countOccurrences(haystack, needle) { + const matches = haystack.match(new RegExp(escapeRegExp(needle), "g")); + return matches == null ? 0 : matches.length; +} + +function hasSection(bodyText, heading) { + return new RegExp(`^${escapeRegExp(heading)}$`, "m").test(bodyText); +} + +function extractTemplateHeadings(templateBody) { + return Array.from(templateBody.matchAll(/^### .+$/gm), match => match[0]); +} + +function setChecklistValue(bodyText, label, checked) { + const pattern = new RegExp( + `^- \\[[ x]\\] ${escapeRegExp(label)}$`, + "m" + ); + return bodyText.replace(pattern, `- [${checked ? "x" : " "}] ${label}`); +} + +function appendDescriptionSection(bodyText, description) { + if (description === "") return bodyText.trimEnd(); + return `${bodyText.trimEnd()}\n\n### Description\n\n${description}`; +} + +function insertAfterHeading(bodyText, heading, insertText) { + const headingPattern = new RegExp( + `^${escapeRegExp(heading)}\\n`, + "m" + ); + const match = headingPattern.exec(bodyText); + if (match == null) return null; + + const afterHeading = match.index + match[0].length; + const rest = bodyText.slice(afterHeading).replace(/^\n*/, ""); + return ( + bodyText.slice(0, afterHeading) + + `\n${insertText}\n\n` + + rest + ); +} + +function buildDescriptionFromCommits() { + try { + const log = git(`log origin/${defaultBranch}..HEAD --format=%B---`); + const messages = log + .split("---") + .map(message => message.trim()) + .filter(Boolean); + + if (messages.length === 1) { + const parts = messages[0].split("\n").filter(Boolean); + return parts.length > 1 ? parts.slice(1).join("\n") : "none"; + } + + return "none"; + } catch { + return "none"; + } +} + +function loadRepoTemplate() { + const templatePath = path.join(process.cwd(), ".github", "PULL_REQUEST_TEMPLATE.md"); + if (!fs.existsSync(templatePath)) return null; + + return { + path: templatePath, + body: fs.readFileSync(templatePath, "utf8").replace(/\r\n/g, "\n").trim() + }; +} + +function buildBodyFromTemplate(templateBody) { + let rendered = templateBody; + + if (hasSection(rendered, "### CHANGELOG")) { + rendered = setChecklistValue(rendered, "Yes", hasChangelog); + rendered = setChecklistValue(rendered, "No", !hasChangelog); + } + + const description = buildDescriptionFromCommits(); + return hasSection(rendered, "### Description") + ? rendered + : appendDescriptionSection(rendered, description); +} + +function validateBodyForTemplate(bodyText, templateInfo) { + if (templateInfo == null) return; + + const templateHeadings = extractTemplateHeadings(templateInfo.body); + const missingHeadings = templateHeadings.filter( + heading => !hasSection(bodyText, heading) + ); + if (missingHeadings.length > 0) { + console.error( + "ERROR: PR body is missing required template headings from " + + `${templateInfo.path}: ${missingHeadings.join(", ")}` + ); + process.exit(1); + } + + const genericSections = []; + if (/^## Summary$/m.test(bodyText)) genericSections.push("## Summary"); + if (/^## Test plan$/m.test(bodyText)) genericSections.push("## Test plan"); + if (genericSections.length > 0) { + console.error( + "ERROR: PR body uses generic sections for a repo with a PR template: " + + genericSections.join(", ") + ); + process.exit(1); + } +} + +function requireGh() { + const check = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }); + if (check.status !== 0) { + console.error("PROMPT_GH_AUTH"); + process.exit(2); + } +} + +requireGh(); + +// Detect repo info from git +const remoteUrl = git("remote get-url origin"); +const normalizedRemoteUrl = remoteUrl.replace(/\/+$/, ""); +const match = normalizedRemoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/); +if (!match) { + console.error("ERROR: Could not parse owner/repo from remote:", remoteUrl); + process.exit(1); +} +const [, owner, repo] = match; + +const branch = git("rev-parse --abbrev-ref HEAD"); +if (["master", "develop", "HEAD"].includes(branch)) { + console.error( + `ERROR: Cannot create PR from '${branch}'. Switch to a feature branch.` + ); + process.exit(1); +} + +// Detect default branch +let defaultBranch; +try { + defaultBranch = git( + "symbolic-ref --quiet --short refs/remotes/origin/HEAD" + ).replace("origin/", ""); +} catch { + try { + const show = execSync("git remote show origin", { encoding: "utf8" }); + defaultBranch = + show.match(/HEAD branch:\s*(.+)/)?.[1]?.trim() || "master"; + } catch { + defaultBranch = "master"; + } +} + +let hasChangelog = false; +try { + const diff = git(`diff origin/${defaultBranch}..HEAD -- CHANGELOG.md`); + hasChangelog = + diff.includes("## Unreleased") || + /^\+- (added|changed|fixed):/m.test(diff); +} catch {} + +const templateInfo = loadRepoTemplate(); + +// Build title from commits/branch if not provided +if (!title) { + try { + const commits = git(`log origin/${defaultBranch}..HEAD --oneline`) + .split("\n") + .filter(Boolean); + if (commits.length === 1) { + title = commits[0].replace(/^[a-f0-9]+\s+/, ""); + } else { + title = branch + .replace(/^jon\//, "") + .replace(/^fix\//, "Fix: ") + .replace(/^feat\//, "") + .replace(/[-_]/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + } + } catch { + title = branch; + } +} + +// Read body from file if provided +let body = bodyFile ? fs.readFileSync(bodyFile, "utf8") : null; + +// Build body from template if not provided +if (!body) { + body = + templateInfo == null + ? `### CHANGELOG\n\n` + + `Does this branch warrant an entry to the CHANGELOG?\n\n` + + `- [${hasChangelog ? "x" : " "}] Yes\n` + + `- [${hasChangelog ? " " : "x"}] No\n\n` + + `### Dependencies\n\nnone\n\n### Description\n\n${buildDescriptionFromCommits()}` + : buildBodyFromTemplate(templateInfo.body); +} + +validateBodyForTemplate(body, templateInfo); + +// Guardrail: fail fast if the body appears to include duplicate templates. +// This prevents accidental append/concatenation from creating malformed PR descriptions. +const templateSectionCounts = { + changelog: countOccurrences(body, "### CHANGELOG"), + dependencies: countOccurrences(body, "### Dependencies"), + description: countOccurrences(body, "### Description") +}; +if ( + templateSectionCounts.changelog > 1 || + templateSectionCounts.dependencies > 1 || + templateSectionCounts.description > 1 +) { + console.error( + "ERROR: PR body contains duplicated template sections. Regenerate /tmp/pr-body.md and retry." + ); + console.error(JSON.stringify(templateSectionCounts)); + process.exit(1); +} + +// Guardrail: fail fast on duplicated PR template sections. +// This catches stale/concatenated body files before creating malformed PRs. +const sectionCounts = { + changelog: countOccurrences(body, "### CHANGELOG"), + dependencies: countOccurrences(body, "### Dependencies"), + description: countOccurrences(body, "### Description"), +}; +if ( + sectionCounts.changelog > 1 || + sectionCounts.dependencies > 1 || + sectionCounts.description > 1 +) { + console.error( + "ERROR: PR body appears to contain duplicated template sections. " + + "Regenerate the body file and retry." + ); + console.error(JSON.stringify(sectionCounts)); + process.exit(1); +} + +// Inject Asana link if provided and not already present +if (asanaTask) { + const asanaUrl = `https://app.asana.com/0/0/${asanaTask}/f`; + const asanaRegex = new RegExp(`https://app\\.asana\\.com/\\d+/\\d+/(?:task/)?${asanaTask}`, "i"); + if (!asanaRegex.test(body)) { + const link = `[Asana task](${asanaUrl})`; + body = + insertAfterHeading(body, "### Description", link) ?? + appendDescriptionSection(body, link); + } +} + +// Create PR via gh CLI — write body to a temp file to avoid arg length issues +const tmpBody = path.join(os.tmpdir(), `pr-body-${process.pid}.md`); +fs.writeFileSync(tmpBody, body, "utf8"); +const ghArgs = ["pr", "create", "--title", title, "--body-file", tmpBody]; +if (draft) ghArgs.push("--draft"); + +const result = spawnSync("gh", ghArgs, { encoding: "utf8" }); +try { fs.unlinkSync(tmpBody); } catch {} +if (bodyFile && bodyFile.startsWith(os.tmpdir())) { + try { + fs.unlinkSync(bodyFile); + } catch {} +} +if (result.status !== 0) { + console.error("ERROR:", (result.stderr || "").trim()); + process.exit(1); +} + +// gh pr create outputs the PR URL on stdout (--json not supported in older gh) +const prUrl = (result.stdout || "").trim(); +const prMatch = prUrl.match(/\/pull\/(\d+)$/); +if (!prMatch) { + console.error("ERROR: Could not parse PR URL from output:", prUrl); + process.exit(1); +} + +console.log( + JSON.stringify( + { + url: prUrl, + number: parseInt(prMatch[1], 10), + title, + base: defaultBranch, + head: branch, + draft, + owner, + repo, + }, + null, + 2 + ) +); diff --git a/.cursor/skills/pr-finalize-fixups.sh b/.cursor/skills/pr-finalize-fixups.sh new file mode 100755 index 0000000..ab2a68f --- /dev/null +++ b/.cursor/skills/pr-finalize-fixups.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# pr-finalize-fixups.sh +# Shared post-fixup finalization for pr-address, bugbot, and any future skill +# that applies fixup commits to a PR branch. +# +# POLICY (single source of truth — do not duplicate in skill .md files): +# +# Ownership guard (HARD override, checked first): if the authenticated gh user +# is NOT the PR author (currentUser != prAuthor), mode is ALWAYS preserve and +# squash-stale is a noop. We never autosquash or otherwise rewrite a PR we +# don't own — fixups are left on top for the owner to squash at merge. This +# takes precedence over the activity-based derivation below. +# +# Modes — derived from the latest human activity on the PR (formal review, +# inline comment, or top-level comment), ONLY when we own the PR. "Human" = +# anyone except the currently authenticated gh user (currentUser) and bots. +# - autosquash : no human activity yet, OR latest activity is a review +# with state APPROVED or DISMISSED (reviewer is no longer +# actively reviewing). +# - preserve : latest activity is anything else (CHANGES_REQUESTED, +# COMMENTED, inline-comment-without-formal-submit, or +# top-level PR comment). Reviewer is still looking and +# needs to see fixup commits. +# +# Subcommands: +# squash-stale Run BEFORE adding new fixups in the address-pass. Squashes +# any pre-existing fixups (Fixups A) when (a) mode is +# autosquash, or (b) mode is preserve AND the latest human +# activity timestamp is newer than the latest existing fixup +# commit timestamp (the reviewer has seen Fixups A and +# re-reviewed → start fresh on Fixups B). No-op otherwise. +# +# finalize (default subcommand) Run AFTER all new fixups are committed +# and slotted. In autosquash mode → autosquash + force-push. +# In preserve mode → just push (force-with-lease since the +# per-fixup slotting rewrote tip). +# +# Skill pre-conditions (caller's responsibility): +# - All fixup commits for this cycle are committed on HEAD and slotted next +# to their target groups (via slot-fixup.sh). +# - Reply+resolve calls referencing fixup SHAs come AFTER finalize so they +# cite stable post-rewrite SHAs. +# +# Usage: +# pr-finalize-fixups.sh [finalize] --owner <o> --repo <r> --pr <n> [--check-only] +# pr-finalize-fixups.sh squash-stale --owner <o> --repo <r> --pr <n> [--check-only] +# +# --check-only Print the decision as JSON without modifying git history. +# +# Output (stdout, one line of compact JSON): +# finalize / squash-stale shared schema: +# {"action": "autosquash" | "push" | "noop", "mode": "...", "newHead": "...", "reason": "..."} +# With --check-only the action becomes "wouldAutosquash" / "wouldPush" / "wouldNoop". +# +# Exit codes: +# 0 — done (action completed, deliberately skipped, or --check-only) +# 1 — generic error (malformed args, missing deps, rebase conflict, etc.) +# 2 — needs user input (gh not authenticated) — `PROMPT_GH_AUTH` on stderr + +set -euo pipefail + +SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PR_ADDRESS_SH="$SKILLS_DIR/pr-address/scripts/pr-address.sh" +GIT_BRANCH_OPS_SH="$SKILLS_DIR/git-branch-ops.sh" + +SUBCMD="finalize" +case "${1:-}" in + finalize|squash-stale) + SUBCMD="$1"; shift + ;; +esac + +OWNER="" REPO="" PR="" CHECK_ONLY="false" +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --pr) PR="$2"; shift 2 ;; + --check-only) CHECK_ONLY="true"; shift ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$OWNER" || -z "$REPO" || -z "$PR" ]]; then + echo "Usage: pr-finalize-fixups.sh [finalize|squash-stale] --owner <o> --repo <r> --pr <n> [--check-only]" >&2 + exit 1 +fi + +if [[ ! -x "$PR_ADDRESS_SH" ]]; then + echo "Error: pr-address.sh not found at $PR_ADDRESS_SH" >&2 + exit 1 +fi + +if [[ ! -x "$GIT_BRANCH_OPS_SH" ]]; then + echo "Error: git-branch-ops.sh not found at $GIT_BRANCH_OPS_SH" >&2 + exit 1 +fi + +emit_json() { + node -e "process.stdout.write(JSON.stringify($1) + '\n')" +} + +prefix_action() { + local action="$1" + if [[ "$CHECK_ONLY" == "true" ]]; then + case "$action" in + autosquash) echo "wouldAutosquash" ;; + push) echo "wouldPush" ;; + noop) echo "wouldNoop" ;; + *) echo "$action" ;; + esac + else + echo "$action" + fi +} + +# Determine mode + latest human activity timestamp. +MODE_JSON="$("$PR_ADDRESS_SH" review-mode --owner "$OWNER" --repo "$REPO" --pr "$PR")" +MODE=$(echo "$MODE_JSON" | node -e " + const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')) + process.stdout.write(d.mode) +") +LATEST_TS=$(echo "$MODE_JSON" | node -e " + const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')) + process.stdout.write(d.latestHumanActivity?.timestamp || '') +") +# Ownership flag from review-mode. When false (we are not the PR author), +# history must never be rewritten — review-mode already forces MODE=preserve, +# and squash-stale becomes a hard noop below. +IS_OWNER=$(echo "$MODE_JSON" | node -e " + const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')) + process.stdout.write(String(d.isOwner === true)) +") + +# Find latest existing fixup commit's timestamp on this branch (if any). +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")" +[[ -z "$DEFAULT_UPSTREAM" || "$DEFAULT_UPSTREAM" == "origin/" ]] && DEFAULT_UPSTREAM="origin/master" +MERGE_BASE="$(git merge-base "$DEFAULT_UPSTREAM" HEAD 2>/dev/null || true)" + +if [[ -n "$MERGE_BASE" ]]; then + LATEST_FIXUP_TS=$(git log "$MERGE_BASE..HEAD" --format='%cI %s' \ + | awk '/^[^ ]+ fixup! / { print $1; exit }') +else + LATEST_FIXUP_TS="" +fi + +run_autosquash_and_push() { + "$GIT_BRANCH_OPS_SH" autosquash >&2 + "$GIT_BRANCH_OPS_SH" push --force-with-lease >&2 + emit_json "{action: '$(prefix_action autosquash)', mode: '$MODE', newHead: '$(git rev-parse --short=10 HEAD)'}" +} + +run_push_only() { + # Force-with-lease because per-fixup slotting may have rewritten tip. + "$GIT_BRANCH_OPS_SH" push --force-with-lease >&2 + emit_json "{action: '$(prefix_action push)', mode: '$MODE', newHead: '$(git rev-parse --short=10 HEAD)'}" +} + +emit_noop() { + local reason="$1" + emit_json "{action: '$(prefix_action noop)', mode: '$MODE', reason: '$reason'}" +} + +if [[ "$SUBCMD" == "squash-stale" ]]; then + # Ownership guard: never squash/rewrite history on a PR we don't own, even if + # the timestamp heuristic below would otherwise fire in preserve mode. + if [[ "$IS_OWNER" != "true" ]]; then + emit_noop "not PR owner — never rewrite owner history" + exit 0 + fi + if [[ -z "$LATEST_FIXUP_TS" ]]; then + emit_noop "no existing fixups" + exit 0 + fi + + SHOULD_SQUASH="false" + if [[ "$MODE" == "autosquash" ]]; then + SHOULD_SQUASH="true" + elif [[ -n "$LATEST_TS" ]] && [[ "$LATEST_TS" > "$LATEST_FIXUP_TS" ]]; then + SHOULD_SQUASH="true" + fi + + if [[ "$SHOULD_SQUASH" != "true" ]]; then + emit_noop "existing fixups still relevant for current review cycle" + exit 0 + fi + + if [[ "$CHECK_ONLY" == "true" ]]; then + emit_json "{action: '$(prefix_action autosquash)', mode: '$MODE', reason: 'stale fixups predate latest review'}" + exit 0 + fi + + run_autosquash_and_push + exit 0 +fi + +# finalize subcommand +# The "&& IS_OWNER" is belt-and-suspenders: review-mode already forces preserve +# when we don't own the PR, so a non-owner can never reach the autosquash path. +if [[ "$MODE" == "autosquash" && "$IS_OWNER" == "true" ]]; then + if [[ "$CHECK_ONLY" == "true" ]]; then + emit_json "{action: '$(prefix_action autosquash)', mode: '$MODE', reason: 'no active reviewer'}" + exit 0 + fi + run_autosquash_and_push + exit 0 +fi + +# preserve mode +if [[ "$CHECK_ONLY" == "true" ]]; then + emit_json "{action: '$(prefix_action push)', mode: '$MODE', reason: 'reviewer still active — preserving fixups'}" + exit 0 +fi + +run_push_only diff --git a/.cursor/skills/pr-land/SKILL.md b/.cursor/skills/pr-land/SKILL.md new file mode 100644 index 0000000..fda0897 --- /dev/null +++ b/.cursor/skills/pr-land/SKILL.md @@ -0,0 +1,467 @@ +--- +name: pr-land +description: Land approved PRs by autosquashing fixups, rebasing onto the default upstream branch, and merging. Use when the user wants to merge/land pull requests. +compatibility: Requires git, gh, node, jq. ASANA_TOKEN for Asana updates. +metadata: + author: j0ntz +--- + +<goal>Land approved PRs by autosquashing fixups, rebasing onto the default upstream branch, and merging. Accepts repo names, explicit PR references, or Asana task URLs.</goal> + +<usage> +``` +/pr-land # Asana "PR Pipeline" section, incomplete tasks assigned to me +/pr-land --branch-scan # All EdgeApp repos with $GIT_BRANCH_PREFIX/* PRs (legacy) +/pr-land edge-react-gui # Specific repo (branch-prefix scan) +/pr-land edge-react-gui edge-core-js # Multiple repos +/pr-land edge-react-gui#123 # Specific PR (shorthand) +/pr-land https://github.com/EdgeApp/edge-react-gui/pull/123 # Specific PR (URL) +/pr-land https://app.asana.com/0/1234/5678 # Asana task → resolves linked PRs +/pr-land https://app.asana.com/.../task/<parent> # Parent task → walks subtasks +/pr-land edge-react-gui#123 edge-core-js # Mix: explicit PR + repo scan +``` + +Arguments are classified automatically: +- **No args** → queries the configured Asana "PR Pipeline" section (GID hardcoded in `pr-land-discover.sh`), filters to incomplete tasks assigned to the current Asana user (resolved from `ASANA_TOKEN` via `~/.cursor/skills/asana-whoami.sh`), and walks each task's attachments + subtasks for GitHub PR links. Tasks with no PR link are reported in `errors` but do not block. +- **`--branch-scan`** → legacy behavior: scans all EdgeApp repos for `$GIT_BRANCH_PREFIX/*` PRs. +- **Repo names** → branch-prefix scan, limited to the named repos. +- **PR URLs / shorthand** (`repo#N`) → fetched directly, no branch-prefix filter. +- **Asana task URLs** → resolved to linked GitHub PRs via Asana API (requires `ASANA_TOKEN`). Parent tasks are walked: each subtask's attachments are scanned for PRs; subtasks without a linked PR are skipped silently (e.g. a verification-only subtask). +</usage> + +<rules description="Non-negotiable constraints."> +<rule id="scripts-only">All GitHub API calls go through companion scripts that use `gh` CLI internally. Do NOT call `gh` or `curl` directly for GitHub operations — use the scripts.</rule> +<rule id="gh-auth">If a script exits code 2 with `PROMPT_GH_AUTH`, prompt the user to run `gh auth login`.</rule> +<rule id="code-conflicts">Code conflicts → Skip PR. Abort the rebase to leave the repo clean, continue with remaining PRs. Report all skipped PRs at the end.</rule> +<rule id="stale-prs">Stale PRs → Skip and report. Old PRs with multiple conflicts should be skipped like code conflicts. Don't block the flow.</rule> +<rule id="changelog-conflicts">CHANGELOG conflicts (any section, including staging): Agent resolves semantically, scripts verify the result.</rule> +<rule id="verification">Verification is mandatory. Built into scripts, no bypass.</rule> +<rule id="no-force-push">Do NOT force-push without explicit user confirmation.</rule> +<rule id="no-editors">Never open editors. All git operations must be non-interactive: `GIT_EDITOR=true` for commit messages, `GIT_SEQUENCE_EDITOR=:` for rebase todo lists.</rule> +<rule id="unexpected-exit">Unexpected exit codes → STOP immediately. If any script returns an exit code not documented in this file, STOP and report to user. Do NOT attempt to interpret, retry, or work around unexpected errors.</rule> +<rule id="sequential-rebase">Sequential merging requires rebase. Each subsequent PR MUST be rebased onto the updated base branch after the previous merge.</rule> +<rule id="publish-gating">Don't publish if outstanding PRs remain. Only offer to publish a repo when ALL approved PRs for that repo are merged. If any were skipped or held back, do NOT publish that repo.</rule> +<rule id="npm-otp-required">`npm publish` MUST be run with `--otp=<code>` supplied by the user. Do NOT attempt `npm publish` without an OTP. Do NOT run `npm login` — auth comes from the `_authToken` in `~/.npmrc`. If `npm whoami` fails before the first publish, STOP and report; do not try to re-authenticate.</rule> +<rule id="defer-gui">If the discovered PR set contains BOTH `edge-react-gui` PRs and at least one non-GUI PR, all GUI PRs are DEFERRED — they do NOT enter steps 3-7 (prepare/push/merge/publish/upgrade-dep). GUI PRs are processed in step 8 (new) after step 7's dep upgrades land on develop. If the batch is pure GUI or pure non-GUI, no deferral — proceed as normal.</rule> +<rule id="asana-last">Asana updates are LAST. Do NOT update Asana tasks until ALL merges, publishes, and GUI dependency upgrades are complete. Only update status for PRs that are fully landed (merged, and if non-GUI: published + GUI deps updated).</rule> +</rules> + +<scripts description="Companion scripts and their expected exit codes."> + +| Script | Purpose | +|--------|---------| +| `pr-land-discover.sh` | Discover PRs and approval status | +| `pr-land-comments.sh` | Check for recent unaddressed feedback (inline threads, review bodies, top-level comments) | +| `git-branch-ops.sh` | Shared autosquash / push helper for explicit git branch actions | +| `pr-land-prepare.sh` | Rebase + conflict detection + verification | +| `verify-repo.sh` | Verification (CHANGELOG + code; lint scoped to changed files when `--base` given) | +| `pr-land-merge.sh` | Rebase + verify + merge via GitHub API | +| `pr-land-publish.sh` | Version bump, changelog update, commit + tag (no push) | +| `staging-cherry-pick.sh` | Cherry-pick merged PR commits onto staging (see `/staging-cherry-pick` skill) | +| `asana-task-update.sh` | Update linked Asana tasks after merge | + +| Script | Exit 0 | Exit 1 | Exit 2 | Exit 3 | Exit 4 | +|--------|--------|--------|--------|--------|--------| +| `pr-land-discover.sh` | Success | Error | Auth needed | - | - | +| `pr-land-comments.sh` | Success | Error | - | - | - | +| `git-branch-ops.sh` | Success | Error | - | - | - | +| `pr-land-prepare.sh` | Ready | All failed | - | - | - | +| `verify-repo.sh` | Pass | Code fail | CHANGELOG fail | - | - | +| `pr-land-merge.sh` | Merged | Verify fail | - | - | CHANGELOG conflict | +| `staging-cherry-pick.sh` | All cherry-picked | Error | Auth needed | CHANGELOG conflict | - | +| `pr-land-publish.sh` | Ready (needs push) | Verify fail | No unreleased | - | - | +| `asana-task-update.sh` | Success | Error | Needs user input | - | - | + +**Any exit code not in this table = STOP immediately and report to user.** +</scripts> + +<step id="1" name="Discovery"> +ONE tool call: + +```bash +~/.cursor/skills/pr-land/scripts/pr-land-discover.sh [args...] +``` + +Args can be repo names, PR URLs, PR shorthand (`repo#N`), Asana task URLs (mixed freely), or `--branch-scan`. +No args = pull incomplete tasks assigned to me from the Asana "PR Pipeline" section and walk each for PR attachments + subtask PR attachments. Use `--branch-scan` for the legacy "scan all EdgeApp repos for `$GIT_BRANCH_PREFIX/*` PRs" behavior. + +Returns JSON: `{ "prs": [...], "errors": [...] }`. Each PR has `repo`, `prNumber`, `branch`, `title`, `approved`, `changesRequested`, `reviewers`. Errors include Asana resolution failures or PR fetch failures. + +<sub-step name="Split by type"> +After discovery, partition `prs` into `nonGuiPrs` (`repo !== "edge-react-gui"`) and `guiPrs` (`repo === "edge-react-gui"`). + +1. If BOTH arrays are non-empty → mixed-batch path per `defer-gui`: only `nonGuiPrs` flow through steps 3-7. Tell the user: `Deferring <N> GUI PR(s) until after non-GUI deps are published and upgraded on develop.` +2. If only one array is non-empty → no deferral; all PRs flow through steps 3-7 normally. +</sub-step> +</step> + +<step id="2" name="Comment Check and Addressing"> +```bash +echo '[{"repo":"...","prNumber":123,"branch":"<prefix>/..."}]' | ~/.cursor/skills/pr-land/scripts/pr-land-comments.sh +``` + +Returns PRs with unaddressed feedback posted after the last commit. The script checks **three sources** and includes the IDs needed to reply or mark them addressed: + +1. **Unresolved inline review threads** — threads where `isResolved: false` with comments newer than last commit +2. **Review bodies** — the latest review from each non-author/non-bot reviewer, if it has a non-empty body newer than last commit (catches feedback written in the approve/reject dialog, regardless of review state) +3. **Top-level PR comments** — non-author/non-bot comments newer than last commit + +Items previously marked with `<!-- addressed:review:ID -->` or `<!-- addressed:comment:ID -->` markers are automatically excluded. + +<sub-step name="Comment handling"> +1. AI/bot comments: Already filtered out by the script. +2. Human reviewer comments are **blocking until the user decides how to handle them**. Use the `approved` and `changesRequested` fields from discovery to determine the path: + 1. **`changesRequested: true`**: + - Treat the feedback as re-review-blocking + - If the user wants it addressed now, make the fix as a visible fixup commit, push it, reply/resolve the feedback, and **remove the PR from the merge set** so it can go back for review + - If the user does not want to address it now, leave the PR out of the merge set and report it as blocked by requested changes + 2. **`approved: true` and `changesRequested: false`**: + - Present the recent human comments to the user and ask whether to **ignore** them or **address** them before continuing + - If the user chooses **ignore**: leave the code unchanged and continue the landing workflow + - If the user chooses **address**: + 1. Read the comment and understand the requested change + 2. Make the fix as a fixup commit: `~/.cursor/skills/lint-commit.sh --fixup <hash> [files...]` + 3. Push the updated branch with `~/.cursor/skills/git-branch-ops.sh push --force-with-lease --branch <branch>`. Use `--force-with-lease` because `lint-commit.sh --fixup` may autosquash immediately. + 4. Reply on the PR item explaining what was fixed (1 sentence, factual): + - **Inline** (`type: "inline"`): Use `commentId` and `threadId` from `pr-land-comments.sh` output with `~/.cursor/skills/pr-address/scripts/pr-address.sh reply ...` followed by `resolve-thread ...` + - **Review body** (`type: "review-body"`): Use `reviewId` with `~/.cursor/skills/pr-address/scripts/pr-address.sh mark-addressed --type review ...` + - **Top-level** (`type: "top-level"`): Use `commentId` with `~/.cursor/skills/pr-address/scripts/pr-address.sh mark-addressed --type comment ...` + 5. Continue the landing workflow immediately — do **not** remove the PR from the merge set solely because an already-approved reviewer left optional comments + 3. Continue with remaining PRs that have no outstanding blocking comment decision + 4. Report ignored comments, addressed-and-continued PRs, and set-aside PRs at the end of the workflow + +**Do NOT block the rest of the flow** for PRs with comments. +</sub-step> +</step> + +<step id="3" name="Prepare Branches"> +When the `defer-gui` rule applies (mixed batch), feed only `nonGuiPrs` into `pr-land-prepare.sh`. GUI PRs enter prepare in step 8. + +ONE tool call per batch: + +```bash +echo '[{"repo":"...","branch":"<prefix>/feature"}]' | ~/.cursor/skills/pr-land/scripts/pr-land-prepare.sh +``` + +The prepare script handles: clone/checkout, autosquash fixups, rebase onto upstream, conflict detection, and verification. + +**Exit codes:** +- `0` = At least one PR ready to push (skipped PRs reported in JSON output) +- `1` = All PRs failed (verification or other errors, none ready) + +<sub-step name="On code conflict">PR is skipped and reported in the `skipped` array. Rebase is aborted to leave repo clean. Other PRs continue.</sub-step> + +<sub-step name="On CHANGELOG conflict">Agent resolves semantically (upstream entries first, then ours), then re-runs prepare.</sub-step> + +<sub-step name="On CHANGELOG placement warning"> +If any entry in `prepared[i].placementWarnings` is non-empty, the PR added CHANGELOG entries under a DATED released heading (e.g. `## 4.46.0 (2026-03-20)`) instead of `## Unreleased (develop)` or `## X.Y.Z (staging)`. This usually means the author placed the entry under the then-current released version but the PR actually targets a later unreleased version. + +Do NOT push (step 4) until the user decides. For each warning, show the user the `line`, `section`, and `text`, then ask exactly: +``` +CHANGELOG entry under released section "<section>": + <text> +(a) leave as-is (b) move to ## Unreleased (develop) (c) move to ## X.Y.Z (staging) +``` + +1. If user picks **(a)**: continue to step 4. +2. If user picks **(b)** or **(c)**: use the Edit tool to move the offending line(s) into the target section, preserving `added → changed → deprecated → fixed → removed → security` ordering within that section. Then stage and amend the top commit on the branch: + ```bash + git -C <repoDir> add CHANGELOG.md && GIT_EDITOR=true git -C <repoDir> commit --amend --no-edit + ``` + Re-run `pr-land-prepare.sh` to re-verify before pushing. Do NOT bypass precommit hooks. +</sub-step> +</step> + +<step id="4" name="Push"> +After prepare succeeds, push with `--force-with-lease`. +Use: + +```bash +~/.cursor/skills/git-branch-ops.sh push --force-with-lease --branch <branch> +``` +</step> + +<step id="5" name="Merge"> +Ask for user confirmation, then: + +```bash +echo '[{"repo":"...","prNumber":123,"branch":"<prefix>/..."}]' | ~/.cursor/skills/pr-land/scripts/pr-land-merge.sh [method] +``` + +The merge script processes PRs **sequentially** with automatic rebase-before-merge: + +1. **Check if already merged** — skip (handles re-runs after CHANGELOG resolution) +2. **Fetch + rebase onto upstream** — ALWAYS done, even for first PR +3. **Conflict handling during rebase:** + - No conflict → continue + - CHANGELOG-only (any section) → **exit 4** (agent resolves, re-runs) + - Code conflict → **skip PR**, abort rebase, continue +4. **Push `--force-with-lease`** +5. **Run local verification** (MANDATORY) +6. **Merge via GitHub API** + +**Exit codes:** +- `0` = All (non-skipped) PRs merged +- `1` = Verification failed +- `4` = CHANGELOG-only conflict (agent resolves, re-runs) + +**On exit 4:** Agent resolves semantically, pushes, re-runs merge. Script detects already-merged PRs and skips them. +</step> + +<step id="6" name="Publish"> +**Gating:** Only non-GUI repos. Only when ALL approved PRs for the repo are merged. Skip if any were skipped/held back. + +Ask for user confirmation: +``` +Merged repos ready to publish (all PRs landed): + - <repo> (<branch>) + +Repos with outstanding PRs (not ready to publish): + - <repo> (N PRs skipped) + +Publish ready repos to npm? [y/N] +``` + +If confirmed: + +```bash +echo '[{"repo":"...","branch":"master"}]' | ~/.cursor/skills/pr-land/scripts/pr-land-publish.sh +``` + +**Exit codes:** +- `0` = Version bumped, committed, tagged (check `needsPush` in JSON output) +- `1` = Verification failed +- `2` = No unreleased changes in CHANGELOG + +After script completes: + +<sub-step name="Push version commit + tag"> +1. Show version bump details to user (repo, old → new version, entries). +2. Ask user to confirm the push. +3. If confirmed, push master and tag: `cd <repoDir> && git push origin master && git push origin v<version>`. +</sub-step> + +<sub-step name="Sanity-check npm auth (once, before first publish)"> +Before publishing the first repo of the run, verify the token: +```bash +cd <repoDir> && npm whoami +``` +If it fails or prints an unexpected username: STOP and tell the user to check `~/.npmrc`. Do NOT attempt `npm login`. Do NOT prompt for credentials. +</sub-step> + +<sub-step name="Publish each repo with OTP from user"> +For each repo, in sequence: + +1. Ask the user exactly: `OTP for <repo> (npm publish)?` — wait for a 6-digit code. +2. Run: + ```bash + cd <repoDir> && npm publish --otp=<otp> + ``` +3. On success: capture the published version from output, proceed to the next repo. +4. On failure with `EOTP` / "OTP required" / any auth error: treat as a stale OTP (OTPs are single-use and ~30s-lived). Ask for a fresh OTP and retry. Retry at most **2 times**; on third failure STOP and report. +5. On any other failure (network, registry error, version conflict): STOP and report — do not retry. + +After all repos publish successfully, proceed to step 7 automatically. Do NOT ask for a second confirmation — the exit codes are the confirmation. +</sub-step> +</step> + +<step id="7" name="Update GUI Dependencies"> +**Trigger:** Only if non-`edge-react-gui` repos were published successfully in step 6 (exit 0 per repo). All non-GUI EdgeApp repos are GUI dependencies, so publishing always requires a GUI dep upgrade. Flows directly from step 6 — no additional user confirmation. + +<sub-step name="Sync develop once (before any upgrade)"> +`upgrade-dep.sh` assumes it is run on a clean `develop` synced to origin and does NOT manage the branch itself (running it N times would otherwise reset develop N times and wipe prior-package commits). Do this ONCE before the upgrade loop: + +```bash +cd <gui-repo-dir> +# Stash any uncommitted working changes so the reset is safe +if ! git diff --quiet HEAD 2>/dev/null || ! git diff --cached --quiet HEAD 2>/dev/null || [[ -n "$(git ls-files --others --exclude-standard)" ]]; then + git stash -u +fi +git checkout develop +git fetch origin develop +git reset --hard origin/develop +``` + +Stashes remain stashed — the user can restore them after the run. +</sub-step> + +<sub-step name="Upgrade each published package"> +1. Run `upgrade-dep.sh` for each published package, sequentially, on the now-clean `develop`: + ```bash + cd <gui-repo-dir> && ~/.cursor/skills/pr-land/scripts/upgrade-dep.sh <package-name> + ``` + Each invocation bumps the version in package.json, runs install + prepare + prepare.ios via the repo's package manager (npm or yarn, auto-detected), and commits package.json + lockfile. On success it prints `UPGRADE_READY ... sha=<commit_sha>`. If any run fails, STOP and report. Ask user how to proceed. + +2. After all dependency upgrades succeed, show the created `develop` commit SHA(s) to the user and ask for confirmation to land them: + ```bash + ~/.cursor/skills/git-branch-ops.sh push --branch develop + ``` + This push is required before the workflow can treat GUI dependency updates as landed. Do NOT proceed to staging cherry-pick or Asana updates until the `develop` push is confirmed complete. +</step> + +<step id="8" name="Prepare and Merge GUI PRs (deferred)"> +**Trigger:** Only runs when `guiPrs` was populated at step 1 AND step 7's dep upgrades pushed to develop successfully. Skip entirely if no GUI PRs exist. If step 7 failed or was skipped due to no non-GUI merges, also skip this step. + +At this point, `origin/develop` contains the new dep-upgrade commits from step 7, so each GUI PR will rebase cleanly onto a develop that already has its new dep versions. + +Re-run steps 3, 4, and 5 against `guiPrs`: + +1. Feed `guiPrs` into `pr-land-prepare.sh` (same invocation shape as step 3). +2. On CHANGELOG conflict: resolve semantically, `git add CHANGELOG.md && GIT_EDITOR=true git rebase --continue`, re-run prepare — same flow as step 3's CHANGELOG sub-step. +3. For each prepared GUI branch, push with `~/.cursor/skills/git-branch-ops.sh push --force-with-lease --branch <branch>` (step 4). +4. Feed `guiPrs` into `pr-land-merge.sh` (step 5). + +Do NOT re-enter steps 6 or 7 — GUI does not publish to npm and has no deps of its own to upgrade. +</step> + +<step id="9" name="Staging Cherry-Pick"> +**Trigger:** Only for `edge-react-gui` commits that target the `## X.Y.Z (staging)` CHANGELOG section (not `## Unreleased`). This includes both merged PR commits and GUI dependency upgrade commits from step 7. + +Check CHANGELOG diffs to determine which commits qualify — if the entry was added under a `(staging)` heading, it needs cherry-picking. + +**Skip** this step entirely if no commits have staging CHANGELOG entries. + +For qualifying PRs/commits, invoke the `/staging-cherry-pick` skill: + +```bash +echo '[{"repo":"edge-react-gui","prNumber":123,"mergeSha":"abc123"}]' | ~/.cursor/skills/staging-cherry-pick/scripts/staging-cherry-pick.sh +``` + +Pass the `mergeSha` from the merge step's JSON output. For dep upgrade commits, pass the commit SHA from step 7. The script cherry-picks individual (non-merge) commits onto the staging branch. + +**On exit 3 (CHANGELOG conflict):** Resolve semantically (existing staging entries first, then the new entry), then `git add CHANGELOG.md && GIT_EDITOR=true git cherry-pick --continue`. Re-run for remaining PRs. + +**On exit 1 (code conflict):** STOP and report to user. + +After cherry-picks succeed, ask user to confirm push: +```bash +git push origin staging +``` + +Then restore the previous branch. +</step> + +<step id="10" name="Update Asana Tasks"> +**Runs ONLY after ALL merges, cherry-picks, publishes, and GUI dep upgrades are complete.** + +Only update for fully landed PRs: +- GUI PRs: merged +- Non-GUI PRs: merged AND published AND GUI deps updated + +Do NOT update for: skipped PRs, addressed-but-not-re-reviewed PRs, or repos not published. + +<sub-step name="Extract Asana task GIDs"> +Pipe the PR metadata through the new helper so you only consume the Asana link once per PR: + +```bash +printf '[{"repo":"edge-react-gui","prNumber":123}]' | ~/.cursor/skills/pr-land/scripts/pr-land-extract-asana-task.sh > /tmp/asana.json +``` + +The helper outputs JSON like `{ "tasks": [{ "taskGid": "...", "label": "repo#123" }], "missing": [{ "label": "...", "reason": "..." }] }`. + +**Parent-walking:** `taskGid` is the PR's linked task's PARENT when a parent exists (the feature-level task that represents the unit of work across repos). Standalone tasks (no parent) return themselves. Only updates the parent — leave subtasks alone; they have their own state that is managed separately. Sibling subtasks of the same parent dedupe to one entry; `label` lists all contributing PRs (e.g. `"edge-react-gui#123, edge-core-js#456"`). + +Review the `missing` array, report any entries lacking an Asana link, and skip those PRs for Asana updates. +</sub-step> + +<sub-step name="Update tasks"> +For each task in `.tasks`, run: + +```bash +~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh \ + --task <task_gid> \ + --set-board-state "QA Verification" \ + --unassign +``` + +Writes to the new Board State 🤖 field. The legacy Status field is no longer updated. + +**Exit codes per call:** +- `0` = success +- `1` = error +- `2` = needs user input +</sub-step> +</step> + +<step id="11" name="End-of-Workflow Report"> +``` +=== PR Land Summary === + +Fully landed: + ✓ <repo>#<number> (<branch>) — merged, cherry-picked to staging, Asana → QA Verification + ✓ <repo>#<number> (<branch>) — merged, Asana → QA Verification + ✓ <repo>#<number> (<branch>) — merged, published v<version>, GUI deps updated, Asana → QA Verification + +Addressed but needs re-review: + ⚠ <repo>#<number> (<branch>) — fixup pushed, awaiting review + +Skipped (conflicts): + ⚠ <repo>#<number> (<branch>) — stale / code conflict in <file> + +Not published (outstanding PRs): + ⚠ <repo> — N PRs skipped, publish deferred +``` +</step> + +<conflict-handling description="Summary of conflict types and resolution."> + +| Conflict Type | Script Behavior | Agent Action | +|---|---|---| +| Code files | Skip PR, abort rebase, continue | Report to user at end | +| CHANGELOG only (prepare) | Report conflict | Resolve semantically, re-run prepare | +| CHANGELOG only (merge) | **exit 4** with instructions | Resolve semantically, push, re-run merge | + +Both prepare and merge scripts can detect CHANGELOG-only conflicts. In either case: +1. Script outputs clear resolution instructions +2. Agent resolves semantically (upstream entries first) +3. `git add CHANGELOG.md && GIT_EDITOR=true git rebase --continue` +4. Push with `~/.cursor/skills/git-branch-ops.sh push --force-with-lease --branch <branch>` +5. Re-run the script to verify and proceed +</conflict-handling> + +<changelog-resolution description="How the agent resolves CHANGELOG conflicts."> +``` +# Typical conflict: +<<<<<<< HEAD +- added: Feature from upstream +======= +- changed: Our feature +>>>>>>> our-commit + +# Resolution: Upstream first, then ours: +- added: Feature from upstream +- changed: Our feature +``` + +<sub-step name="During prepare (no push yet)"> +1. Read CHANGELOG.md with conflict markers +2. Resolve semantically using StrReplace +3. `git add CHANGELOG.md && GIT_EDITOR=true git rebase --continue` +4. Re-run `~/.cursor/skills/pr-land/scripts/pr-land-prepare.sh` +</sub-step> + +<sub-step name="During merge (already pushed, GitHub reports conflict)"> +1. `cd <repoDir>` +2. `git fetch origin && git rebase origin/master` (or `origin/develop`) +3. Read CHANGELOG.md with conflict markers +4. Resolve semantically using StrReplace +5. `git add CHANGELOG.md && GIT_EDITOR=true git rebase --continue` +6. `~/.cursor/skills/git-branch-ops.sh push --force-with-lease` +7. Re-run `~/.cursor/skills/pr-land/scripts/pr-land-merge.sh` — verification runs automatically +</sub-step> + +Verification checks: no conflict markers remaining, proper entry format (`- type: description`), no malformed entries. If verification fails after resolution, the script prompts the user. +</changelog-resolution> + +<safety-guarantees> +1. Code conflicts skip cleanly — scripts abort rebase and skip, no dirty state +2. CHANGELOG conflicts are scripted — agent resolves semantically (any section including staging), verification validates +3. Verification is mandatory — built into merge script, physically blocks merge on failure +4. Pre-merge is safe — can force-push as many times as needed +5. Sequential merging with auto-rebase — each PR rebased onto updated base +6. No bypasses — scripts enforce rules, agent cannot skip steps +7. Unexpected errors halt execution — undocumented exit codes stop immediately +8. Publish gating — repos with outstanding PRs are not published +9. Asana is last — task updates only after full pipeline completes +10. GUI deferral prevents incomplete migrations — GUI never lands before its coordinated non-GUI deps are published and upgraded on develop +</safety-guarantees> diff --git a/.cursor/skills/pr-land/scripts/edge-repo.js b/.cursor/skills/pr-land/scripts/edge-repo.js new file mode 100644 index 0000000..af85b4c --- /dev/null +++ b/.cursor/skills/pr-land/scripts/edge-repo.js @@ -0,0 +1,152 @@ +// edge-repo.js — Shared Edge repository utilities. +// Common functions for repo discovery, git operations, and conflict handling. +// Used by: pr-land-prepare.sh, pr-land-merge.sh, pr-land-publish.sh +const { spawnSync, execSync } = require("child_process"); +const { existsSync } = require("fs"); +const path = require("path"); +const os = require("os"); + +function getRepoDir(repo) { + const homeDir = os.homedir(); + const candidates = [ + path.join(homeDir, "git", repo), + path.join(homeDir, "projects", repo), + path.join(homeDir, "code", repo), + ]; + for (const dir of candidates) { + if (existsSync(path.join(dir, ".git"))) return dir; + } + return path.join(homeDir, "git", repo); +} + +function getUpstreamBranch(repo) { + return repo === "edge-react-gui" ? "origin/develop" : "origin/master"; +} + +function runGit(args, cwd, options = {}) { + const { allowFailure = false } = options; + const argArray = Array.isArray(args) ? args : args.split(" "); + const result = spawnSync("git", argArray, { + cwd, + encoding: "utf8", + env: { ...process.env, GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: ":" }, + }); + + if (result.status !== 0 && !allowFailure) { + throw new Error( + (result.stderr || result.stdout || "Unknown git error").trim() + ); + } + + return { + success: result.status === 0, + stdout: result.stdout?.trim() || "", + stderr: result.stderr?.trim() || "", + }; +} + +function parseConflictFiles(output) { + const files = []; + for (const line of output.split("\n")) { + const match = line.match(/CONFLICT.*in (.+)$/); + if (match) files.push(match[1]); + const bothMatch = line.match(/^\s+both modified:\s+(.+)$/); + if (bothMatch) files.push(bothMatch[1]); + } + return [...new Set(files)]; +} + +function isChangelogOnly(files) { + return ( + files.length > 0 && + files.every((f) => f === "CHANGELOG.md" || f.endsWith("/CHANGELOG.md")) + ); +} + +function runVerification(repoDir, baseRef, options = {}) { + const verifyScript = path.join( + os.homedir(), + ".cursor", + "skills", + "verify-repo.sh" + ); + const baseArg = baseRef != null ? ` --base "${baseRef}"` : ""; + const changelogArg = options.requireChangelog ? " --require-changelog" : ""; + const skipInstallArg = options.skipInstall ? " --skip-install" : ""; + try { + execSync( + `node "${verifyScript}" "${repoDir}"${baseArg}${changelogArg}${skipInstallArg}`, + { stdio: "inherit", encoding: "utf8" } + ); + return { success: true }; + } catch (e) { + return { success: false, exitCode: e.status }; + } +} + +// gh CLI wrapper for GitHub API calls +function ghApi(endpoint, options = {}) { + const { method, body, paginate, jq } = options; + const args = ["api", endpoint]; + if (method && method !== "GET") args.push("-X", method); + if (paginate) args.push("--paginate"); + if (jq) args.push("--jq", jq); + if (body) args.push("--input", "-"); + + const result = spawnSync("gh", args, { + encoding: "utf8", + input: body ? JSON.stringify(body) : undefined, + }); + + if (result.status !== 0) { + throw new Error( + `gh api ${endpoint} failed: ${(result.stderr || "").trim()}` + ); + } + + const out = result.stdout.trim(); + if (!out) return null; + try { + return JSON.parse(out); + } catch { + return out; + } +} + +function ghGraphql(query, variables = {}) { + const args = ["api", "graphql", "-f", `query=${query}`]; + for (const [k, v] of Object.entries(variables)) { + args.push(typeof v === "number" ? "-F" : "-f", `${k}=${v}`); + } + + const result = spawnSync("gh", args, { encoding: "utf8" }); + + if (result.status !== 0) { + throw new Error( + `gh api graphql failed: ${(result.stderr || "").trim()}` + ); + } + + const parsed = JSON.parse(result.stdout); + if (parsed.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(parsed.errors)}`); + } + return parsed.data; +} + +function installAndPrepare(repoDir) { + const script = path.join(__dirname, "..", "..", "install-deps.sh"); + execSync(`"${script}" "${repoDir}"`, { stdio: "inherit" }); +} + +module.exports = { + getRepoDir, + getUpstreamBranch, + runGit, + parseConflictFiles, + isChangelogOnly, + runVerification, + installAndPrepare, + ghApi, + ghGraphql, +}; diff --git a/.cursor/skills/pr-land/scripts/pr-land-comments.sh b/.cursor/skills/pr-land/scripts/pr-land-comments.sh new file mode 100755 index 0000000..cc10ef6 --- /dev/null +++ b/.cursor/skills/pr-land/scripts/pr-land-comments.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// pr-land-comments.sh — Landing gate: checks for recent unaddressed feedback. +// Surfaces unresolved inline threads, review bodies, and top-level comments +// posted after the last commit. Uses a single GraphQL query per PR. +// +// Skips: resolved threads, bot comments, current-user (self) comments, items with addressed markers. +// +// Usage: echo '[{"repo":"...","prNumber":123,"branch":"..."}]' | ./pr-land-comments.sh + +const { spawnSync } = require("child_process") + +function requireGh() { + const check = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }) + if (check.status !== 0) { + console.error("PROMPT_GH_AUTH") + process.exit(2) + } +} + +function ghGraphql(query, variables = {}) { + const args = ["api", "graphql", "-f", `query=${query}`] + for (const [k, v] of Object.entries(variables)) { + args.push(typeof v === "number" ? "-F" : "-f", `${k}=${v}`) + } + const result = spawnSync("gh", args, { encoding: "utf8" }) + if (result.status !== 0) { + throw new Error(`GraphQL failed: ${(result.stderr || "").trim()}`) + } + const parsed = JSON.parse(result.stdout) + if (parsed.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(parsed.errors)}`) + } + return parsed.data +} + +const QUERY = ` +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + author { __typename login } + commits(last: 1) { + nodes { commit { committedDate } } + } + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 50) { + nodes { + databaseId + createdAt + author { __typename login } + path + body + } + } + } + } + reviews(last: 50) { + nodes { + databaseId + author { __typename login } + state + body + submittedAt + } + } + comments(last: 50) { + nodes { + databaseId + createdAt + author { __typename login } + body + } + } + } + } +}` + +requireGh() + +function extractAddressedIds(comments) { + const ids = new Set() + for (const c of comments) { + for (const m of (c.body || "").matchAll( + /<!-- addressed:(?:review|comment):(\d+) -->/g + )) { + ids.add(Number(m[1])) + } + } + return ids +} + +function isBot(author) { + // GraphQL distinguishes bots via __typename === "Bot". REST callers append + // "[bot]" to logins; keep the suffix check as a defensive fallback. + if (!author) return true + if (author.__typename === "Bot") return true + const login = typeof author === "string" ? author : author.login + return !login || login.includes("[bot]") +} + +function getCurrentUser() { + const result = spawnSync("gh", ["api", "user", "--jq", ".login"], { + encoding: "utf8" + }) + if (result.status !== 0) { + throw new Error(`Failed to get current user: ${(result.stderr || "").trim()}`) + } + return result.stdout.trim() +} + +async function main() { + let input = "" + for await (const chunk of process.stdin) input += chunk + + const prs = JSON.parse(input) + const results = [] + const currentUser = getCurrentUser() + + for (const { repo, prNumber, branch } of prs) { + let data + try { + data = ghGraphql(QUERY, { owner: "EdgeApp", repo, number: prNumber }) + } catch (e) { + console.error( + `WARNING: Failed to query ${repo}#${prNumber}: ${e.message}` + ) + continue + } + + const pr = data.repository.pullRequest + const lastCommitDate = pr.commits.nodes[0] + ? new Date(pr.commits.nodes[0].commit.committedDate) + : new Date(0) + + const addressedIds = extractAddressedIds(pr.comments.nodes) + const recentComments = [] + + for (const thread of pr.reviewThreads.nodes) { + if (thread.isResolved) continue + for (const c of thread.comments.nodes) { + if (isBot(c.author)) continue + if (c.author?.login === currentUser) continue + if (new Date(c.createdAt) > lastCommitDate) { + recentComments.push({ + type: "inline", + threadId: thread.id, + commentId: c.databaseId, + user: c.author?.login, + path: c.path, + body: c.body?.slice(0, 200) + }) + } + } + } + + const latestByUser = {} + for (const r of pr.reviews.nodes) { + const user = r.author?.login + if (!user || user === currentUser || r.state === "PENDING") continue + if (isBot(r.author)) continue + const prev = latestByUser[user] + if ( + !prev || + new Date(r.submittedAt) > new Date(prev.submittedAt) + ) { + latestByUser[user] = r + } + } + for (const [user, r] of Object.entries(latestByUser)) { + if (!r.body?.trim()) continue + if (addressedIds.has(r.databaseId)) continue + if (new Date(r.submittedAt) > lastCommitDate) { + recentComments.push({ + type: "review-body", + reviewId: r.databaseId, + user, + state: r.state, + body: r.body.slice(0, 200) + }) + } + } + + for (const c of pr.comments.nodes) { + const user = c.author?.login + if (!user || user === currentUser || isBot(c.author)) continue + if ((c.body || "").includes("<!-- addressed:")) continue + if (addressedIds.has(c.databaseId)) continue + if (new Date(c.createdAt) > lastCommitDate) { + recentComments.push({ + type: "top-level", + commentId: c.databaseId, + user, + body: c.body?.slice(0, 200) + }) + } + } + + if (recentComments.length > 0) { + results.push({ repo, prNumber, branch, recentComments }) + } + } + + console.log(JSON.stringify(results, null, 2)) +} + +main().catch(e => { + console.error(e) + process.exit(1) +}) diff --git a/.cursor/skills/pr-land/scripts/pr-land-discover.sh b/.cursor/skills/pr-land/scripts/pr-land-discover.sh new file mode 100755 index 0000000..cdc6cbc --- /dev/null +++ b/.cursor/skills/pr-land/scripts/pr-land-discover.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env node +// pr-land-discover.sh — Discovers open PRs across EdgeApp repos with approval status. +// +// Accepts mixed argument types: +// Repo names: edge-react-gui edge-core-js +// PR URLs: https://github.com/EdgeApp/edge-react-gui/pull/123 +// PR shorthand: edge-react-gui#123 +// Asana tasks: https://app.asana.com/0/<project>/<taskGid> +// --branch-scan: scan all EdgeApp repos for $GIT_BRANCH_PREFIX/* PRs +// No args: Asana "PR Pipeline" section, incomplete tasks assigned to me +// +// No args (default): queries the configured Asana section, filters to incomplete +// tasks assigned to the current Asana user (resolved via asana-whoami.sh), and +// walks each task's attachments + subtasks for GitHub PR links. Tasks without a +// PR link are reported in `errors` but do not block. Requires ASANA_TOKEN. +// +// Explicit PRs (URL/shorthand) are fetched directly — no branch-prefix filter. +// Asana tasks are resolved to linked GitHub PRs via the Asana API. +// Repo names trigger a branch-prefix scan of those repos. +// --branch-scan triggers the legacy no-args behavior (all EdgeApp repos). + +const { spawnSync } = require("child_process"); +const https = require("https"); +const path = require("path"); + +const rawArgs = process.argv.slice(2); +const edgeAppRepos = [ + "edge-react-gui", + "edge-exchange-plugins", + "edge-currency-accountbased", + "edge-core-js", + "edge-login-ui-rn", + "edge-currency-plugins", +]; + +// "🔍 Review/Publish" section in the EdgeApp PR Pipeline project — the no-args queue. +// Project: https://app.asana.com/1/9976422036640/project/1213880789473005 +// Resolved via: GET /projects/1213880789473005/sections +const ASANA_PR_LAND_SECTION_GID = "1214062531915722"; + +const BRANCH_PREFIX = process.env.GIT_BRANCH_PREFIX || "jon"; + +// Parse flags out of args (the rest is classified below). +let useBranchScan = false; +const args = []; +for (const arg of rawArgs) { + if (arg === "--branch-scan") { + useBranchScan = true; + } else { + args.push(arg); + } +} + +// --- Argument classification --- + +const PR_URL_RE = /^https:\/\/github\.com\/EdgeApp\/([^/]+)\/pull\/(\d+)/; +const PR_SHORT_RE = /^([a-z][a-z0-9-]+)#(\d+)$/; +// Matches both old (app.asana.com/0/<project>/<taskGid>) and new +// (app.asana.com/1/<workspace>/project/<projectId>/task/<taskGid>) URL formats. +// Strips query params via the [^?]* fallback. +const ASANA_URL_RE = /^https:\/\/app\.asana\.com\/(?:\d+\/\d+\/(?:project\/\d+\/)?(?:task\/)?(\d+))/; + +const repoArgs = []; +const explicitPrs = []; // {repo, prNumber} +const asanaGids = []; + +for (const arg of args) { + let m; + if ((m = arg.match(PR_URL_RE))) { + explicitPrs.push({ repo: m[1], prNumber: Number(m[2]) }); + } else if ((m = arg.match(PR_SHORT_RE))) { + explicitPrs.push({ repo: m[1], prNumber: Number(m[2]) }); + } else if ((m = arg.match(ASANA_URL_RE))) { + asanaGids.push(m[1]); + } else { + repoArgs.push(arg); + } +} + +// No-args default = Asana section scan (handled in main()). +// --branch-scan with no other args = scan all EdgeApp repos for branch-prefix PRs. +// Otherwise scan only explicitly named repos. +const noArgs = args.length === 0; +const scanRepos = + useBranchScan && repoArgs.length === 0 + ? edgeAppRepos + : repoArgs; +const useAsanaSection = noArgs && !useBranchScan; + +// --- Helpers --- + +function requireGh() { + const check = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }); + if (check.status !== 0) { + console.error("PROMPT_GH_AUTH"); + process.exit(2); + } +} + +function ghGraphql(query, variables = {}) { + const gqlArgs = ["api", "graphql", "-f", `query=${query}`]; + for (const [k, v] of Object.entries(variables)) { + gqlArgs.push(typeof v === "number" ? "-F" : "-f", `${k}=${v}`); + } + const result = spawnSync("gh", gqlArgs, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`GraphQL failed: ${(result.stderr || "").trim()}`); + } + const parsed = JSON.parse(result.stdout); + if (parsed.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(parsed.errors)}`); + } + return parsed.data; +} + +function ghApi(endpoint, { paginate = false } = {}) { + const args = paginate ? ["api", "--paginate", endpoint] : ["api", endpoint]; + const result = spawnSync("gh", args, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`gh api failed: ${(result.stderr || "").trim()}`); + } + return JSON.parse(result.stdout); +} + +function asanaGet(path) { + const token = process.env.ASANA_TOKEN; + if (!token) throw new Error("ASANA_TOKEN not set"); + return new Promise((resolve, reject) => { + const req = https.get( + `https://app.asana.com/api/1.0${path}`, + { headers: { Authorization: `Bearer ${token}` } }, + (res) => { + let body = ""; + res.on("data", (d) => (body += d)); + res.on("end", () => { + if (res.statusCode !== 200) + return reject(new Error(`Asana ${res.statusCode}: ${body}`)); + resolve(JSON.parse(body).data); + }); + } + ); + req.on("error", reject); + }); +} + +function extractReviewers(reviews) { + // Per GitHub semantics, only APPROVED / CHANGES_REQUESTED / DISMISSED change + // a reviewer's effective state. COMMENTED (and PENDING) are informational and + // must not shadow a prior APPROVED — otherwise an approver who later leaves a + // comment-thread reply drops off the approver list. + const STATE_CHANGING = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); + const latestByUser = {}; + for (const r of reviews) { + const login = r.author?.login; + if (!login) continue; + if (!STATE_CHANGING.has(r.state)) continue; + if ( + !latestByUser[login] || + new Date(r.submittedAt) > new Date(latestByUser[login].submittedAt) + ) { + latestByUser[login] = r; + } + } + const reviewers = Object.values(latestByUser); + // Latest state-changing review wins for aggregate flags. A stale + // CHANGES_REQUESTED from a reviewer who hasn't engaged in months should not + // block when a later reviewer has since approved. The per-user `reviewers` + // array still surfaces everyone's latest state for visibility. + const latest = reviewers + .slice() + .sort((a, b) => new Date(b.submittedAt) - new Date(a.submittedAt))[0]; + return { + approved: latest?.state === "APPROVED", + changesRequested: latest?.state === "CHANGES_REQUESTED", + reviewers: reviewers.map((r) => ({ + user: r.author.login, + state: r.state, + })), + }; +} + +// --- Main --- + +async function main() { + requireGh(); + + const results = { prs: [], errors: [] }; + + // 0. No-args: pull task GIDs from the configured Asana section, filtered to + // incomplete tasks assigned to the current user. They flow through the + // same Asana resolution path below as if the user passed task URLs. + if (useAsanaSection) { + if (!process.env.ASANA_TOKEN) { + results.errors.push( + "No-args mode requires ASANA_TOKEN. Set it, pass repo/PR/task args, or use --branch-scan." + ); + } else { + try { + const whoami = spawnSync( + path.join(__dirname, "..", "..", "asana-whoami.sh"), + [], + { encoding: "utf8" } + ); + if (whoami.status !== 0) { + throw new Error(`asana-whoami.sh failed: ${(whoami.stderr || "").trim()}`); + } + const userGid = whoami.stdout.trim(); + + const sectionTasks = await asanaGet( + `/sections/${ASANA_PR_LAND_SECTION_GID}/tasks` + + `?opt_fields=name,assignee.gid,completed&completed_since=now&limit=100` + ); + for (const t of sectionTasks) { + if (t.completed) continue; + if (!t.assignee || t.assignee.gid !== userGid) continue; + asanaGids.push(t.gid); + } + } catch (e) { + results.errors.push(`Asana section scan: ${e.message}`); + } + } + } + + // 1. Resolve Asana tasks → explicit PRs + // GitHub integration attachments are the source of truth. + // Walk subtasks as well, since parent tasks often carry multiple subtasks each + // holding their own PR. Subtasks without PR attachments are silently skipped. + // Only fall back to scanning task notes (parent only) if nothing else found. + const ghPrRe = + /https:\/\/github\.com\/EdgeApp\/([^/]+)\/pull\/(\d+)/g; + + async function extractPrsFromAttachments(taskGid) { + const out = []; + const attachments = await asanaGet( + `/tasks/${taskGid}/attachments?opt_fields=resource_subtype,view_url` + ); + for (const att of attachments) { + if (att.resource_subtype !== "external" || !att.view_url) continue; + const m = att.view_url.match( + /^https:\/\/github\.com\/EdgeApp\/([^/]+)\/pull\/(\d+)/ + ); + if (m) { + out.push({ repo: m[1], prNumber: Number(m[2]) }); + } + } + return out; + } + + for (const gid of asanaGids) { + try { + const task = await asanaGet( + `/tasks/${gid}?opt_fields=name,notes,permalink_url` + ); + let found = false; + + // Parent task attachments + for (const pr of await extractPrsFromAttachments(gid)) { + explicitPrs.push(pr); + found = true; + } + + // Subtasks: walk each and pull PR attachments. Subtasks without PRs are + // silently skipped (e.g. a verification-only subtask). + const subtasks = await asanaGet( + `/tasks/${gid}/subtasks?opt_fields=name` + ); + for (const sub of subtasks) { + for (const pr of await extractPrsFromAttachments(sub.gid)) { + explicitPrs.push(pr); + found = true; + } + } + + // Fall back to parent task notes only if nothing else matched. + if (!found) { + let match; + while ((match = ghPrRe.exec(task.notes || "")) !== null) { + explicitPrs.push({ repo: match[1], prNumber: Number(match[2]) }); + found = true; + } + ghPrRe.lastIndex = 0; + } + + if (!found) { + results.errors.push( + `Asana task ${gid} (${task.name}): no GitHub PR link found on task or subtasks` + ); + } + } catch (e) { + results.errors.push(`Asana task ${gid}: ${e.message}`); + } + } + + // 2. Fetch explicit PRs directly (no branch-prefix filter) + for (const { repo, prNumber } of explicitPrs) { + try { + const pr = ghApi(`repos/EdgeApp/${repo}/pulls/${prNumber}`); + // Paginate: PRs with extensive review history (e.g. cursor[bot] + + // back-and-forth) can have hundreds of review records, and the user's + // APPROVED is often the LAST review. Without --paginate we'd silently + // miss approvals past the first 30. + const reviewsRaw = ghApi( + `repos/EdgeApp/${repo}/pulls/${prNumber}/reviews`, + { paginate: true } + ); + const { approved, changesRequested, reviewers } = extractReviewers( + reviewsRaw.map((r) => ({ + author: { login: r.user?.login }, + state: r.state, + submittedAt: r.submitted_at, + })) + ); + results.prs.push({ + repo, + prNumber: pr.number, + branch: pr.head.ref, + title: pr.title, + updatedAt: pr.updated_at, + approved, + changesRequested, + reviewers, + }); + } catch (e) { + results.errors.push(`${repo}#${prNumber}: ${e.message}`); + } + } + + // 3. Scan repos by branch prefix (original behavior) + if (scanRepos.length > 0) { + const repoFragments = scanRepos + .map((repo, i) => { + const alias = `repo${i}`; + return `${alias}: repository(owner: "EdgeApp", name: "${repo}") { + name + pullRequests(first: 100, states: OPEN) { + nodes { + number + title + headRefName + updatedAt + reviews(last: 30) { + nodes { + author { login } + state + submittedAt + } + } + } + } + }`; + }) + .join("\n "); + + const query = `{ ${repoFragments} }`; + + let data; + try { + data = ghGraphql(query); + } catch (e) { + console.error("ERROR:", e.message); + process.exit(1); + } + + for (const key of Object.keys(data)) { + const repoData = data[key]; + if (!repoData) continue; + const repo = repoData.name; + + for (const pr of repoData.pullRequests.nodes) { + if (!pr.headRefName.startsWith(`${BRANCH_PREFIX}/`)) continue; + + const { approved, changesRequested, reviewers } = extractReviewers( + pr.reviews.nodes + ); + + results.prs.push({ + repo, + prNumber: pr.number, + branch: pr.headRefName, + title: pr.title, + updatedAt: pr.updatedAt, + approved, + changesRequested, + reviewers, + }); + } + } + } + + // Dedupe by repo+prNumber (in case Asana/explicit overlap with scan) + const seen = new Set(); + results.prs = results.prs.filter((pr) => { + const key = `${pr.repo}#${pr.prNumber}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + results.prs.sort( + (a, b) => + a.repo.localeCompare(b.repo) || a.branch.localeCompare(b.branch) + ); + console.log(JSON.stringify(results, null, 2)); +} + +main().catch((e) => { + console.error("ERROR:", e.message); + process.exit(1); +}); diff --git a/.cursor/skills/pr-land/scripts/pr-land-extract-asana-task.sh b/.cursor/skills/pr-land/scripts/pr-land-extract-asana-task.sh new file mode 100755 index 0000000..35c53cd --- /dev/null +++ b/.cursor/skills/pr-land/scripts/pr-land-extract-asana-task.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// pr-land-extract-asana-task.sh +// Extracts Asana task GIDs from PR bodies so /pr-land can skip loading full descriptions. +// Input: JSON array of {repo, prNumber}. Output: JSON object {tasks: [...], missing: [...]}, where each entry contains label/repo info. +// +// Each PR body's Asana link is resolved to its PARENT task when one exists, so +// /pr-land updates the feature parent (the thing that represents the unit of +// work) rather than the per-repo subtask. Walks up only one level. Output +// deduplicated by taskGid so sibling subtasks collapse into a single parent +// entry. Falls back to the original GID (no walk-up) if ASANA_TOKEN is unset. +// +// The script is intentionally terse: it only emits structured JSON and does not print raw PR bodies. +const { execSync } = require("child_process"); +const https = require("https"); +const path = require("path"); + +async function readStdin() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + return input.trim(); +} + +function fetchPrBody(repo, prNumber) { + const endpoint = `repos/EdgeApp/${repo}/pulls/${prNumber}`; + const result = execSync(`gh api "${endpoint}" --jq '.body'`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return result.trim(); +} + +function buildLabel(repo, prNumber) { + return `${repo}#${prNumber}`; +} + +function asanaGetTask(gid) { + const token = process.env.ASANA_TOKEN; + if (!token) return Promise.resolve(null); + return new Promise((resolve, reject) => { + const req = https.get( + `https://app.asana.com/api/1.0/tasks/${gid}?opt_fields=parent.gid`, + { headers: { Authorization: `Bearer ${token}` } }, + (res) => { + let body = ""; + res.on("data", (d) => (body += d)); + res.on("end", () => { + if (res.statusCode !== 200) + return reject(new Error(`Asana ${res.statusCode}: ${body}`)); + try { + resolve(JSON.parse(body).data); + } catch (e) { + reject(e); + } + }); + } + ); + req.on("error", reject); + }); +} + +async function resolveToParent(gid) { + try { + const data = await asanaGetTask(gid); + if (data && data.parent && data.parent.gid) return data.parent.gid; + } catch (err) { + console.error( + `Warning: failed to resolve parent for task ${gid}: ${err.message}. Using original GID.` + ); + } + return gid; +} + +async function main() { + const input = await readStdin(); + if (!input) { + console.error("Error: no input received (expecting JSON array with repo/prNumber)"); + process.exit(2); + } + + let entries; + try { + entries = JSON.parse(input); + } catch (err) { + console.error("Error: failed to parse JSON input"); + process.exit(2); + } + + const regex = /https:\/\/app\.asana\.com\/(?:\d+\/\d+\/(?:project\/\d+\/)?(?:task\/)?(\d+))/i; + const tasks = []; + const missing = []; + + for (const { repo, prNumber } of entries) { + const label = buildLabel(repo, prNumber); + let body; + try { + body = fetchPrBody(repo, prNumber); + } catch (err) { + missing.push({ + label, + reason: `Failed to fetch PR body: ${err.message}`, + }); + continue; + } + + if (!body) { + missing.push({ + label, + reason: "PR body empty", + }); + continue; + } + + const match = body.match(regex); + if (match) { + const originalGid = match[1]; + const resolvedGid = await resolveToParent(originalGid); + tasks.push({ + taskGid: resolvedGid, + label, + }); + } else { + missing.push({ + label, + reason: "No Asana link found", + }); + } + } + + // Dedupe by taskGid: sibling subtasks collapse into one parent entry. + // Preserve first-seen order and merge labels for traceability. + const seen = new Map(); + for (const entry of tasks) { + const existing = seen.get(entry.taskGid); + if (existing) { + existing.label = `${existing.label}, ${entry.label}`; + } else { + seen.set(entry.taskGid, { taskGid: entry.taskGid, label: entry.label }); + } + } + const dedupedTasks = Array.from(seen.values()); + + console.log(JSON.stringify({ tasks: dedupedTasks, missing }, null, 2)); + process.exit(0); +} + +main().catch((err) => { + console.error(`Error: ${err.message}`); + process.exit(1); +}); diff --git a/.cursor/skills/pr-land/scripts/pr-land-merge.sh b/.cursor/skills/pr-land/scripts/pr-land-merge.sh new file mode 100755 index 0000000..b0c0500 --- /dev/null +++ b/.cursor/skills/pr-land/scripts/pr-land-merge.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env node +// pr-land-merge.sh +// Merges PRs via GitHub API with automatic rebase and MANDATORY local verification. +// Uses gh CLI for API calls and edge-repo.js for shared utilities. +// +// Usage: echo '[{"repo":"edge-react-gui","prNumber":123,"branch":"jon/feature"}]' | ./pr-land-merge.sh [method] +// Methods: merge (default), squash, rebase +// +// For each PR (sequentially): +// 1. Check if already merged (skip if so — handles re-runs) +// 2. Fetch + rebase onto latest upstream (picks up prior merges) +// 3. Push --force-with-lease +// 4. Run local verification (MANDATORY) +// 5. Merge via GitHub API +// +// SAFETY GUARANTEES: +// 1. Each PR is rebased onto latest upstream before merge (handles sequential merges) +// 2. Verification runs before EVERY merge (no bypass) +// 3. Code conflicts → Skip PR, continue with remaining +// 4. CHANGELOG-only conflicts → Agent can resolve, then re-run +// 5. Already-merged PRs are detected and skipped on re-runs +// +// Exit codes: +// 0 = All (non-skipped) PRs merged successfully +// 1 = Verification failed +// 4 = CHANGELOG-only conflict (agent can resolve semantically) + +const { spawnSync } = require("child_process"); +const path = require("path"); +const { + getRepoDir, + getUpstreamBranch, + runGit, + parseConflictFiles, + isChangelogOnly, + runVerification, + ghApi, + installAndPrepare, +} = require(path.join(__dirname, "edge-repo.js")); + +function sanitizeBranchLabel(branch) { + return branch.replace(/[^a-z0-9]/gi, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} + +// Locate a worktree (other than the canonical clone) that currently has +// `branch` checked out. Tools like agent-watcher leave such worktrees behind, +// and git refuses to `checkout` a branch already held by another worktree. +// In that case operate inside that worktree — its content is the same branch. +// Returns the worktree path, or null if none holds the branch. +function findWorktreeForBranch(repoDir, branch) { + const res = runGit(["worktree", "list", "--porcelain"], repoDir, { + allowFailure: true, + }); + if (!res.success || !res.stdout) return null; + const target = `refs/heads/${branch}`; + let currentPath = null; + for (const line of res.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length).trim(); + if (ref === target && currentPath) return currentPath; + } + } + return null; +} + +// Resolve the working directory for a branch: the canonical clone unless the +// branch is held by another worktree, in which case that worktree. +function resolveRepoDir(repo, branch) { + const canonicalDir = getRepoDir(repo); + const wtDir = findWorktreeForBranch(canonicalDir, branch); + if (wtDir && path.resolve(wtDir) !== path.resolve(canonicalDir)) { + return wtDir; + } + return canonicalDir; +} + +function describeBranchState(repoDir, branch) { + const notes = []; + const local = runGit(["rev-parse", branch], repoDir, { allowFailure: true }); + notes.push(local.success ? `Local commit (${branch}): ${local.stdout}` : `Local branch "${branch}" missing`); + + const remote = runGit(["rev-parse", `origin/${branch}`], repoDir, { allowFailure: true }); + notes.push(remote.success ? `Remote commit (origin/${branch}): ${remote.stdout}` : `Remote branch origin/${branch} missing`); + + const status = runGit(["status", "-sb"], repoDir, { allowFailure: true }); + if (status.stdout) { + notes.push(`Status: ${status.stdout.trim()}`); + } + return notes.join("\n"); +} + +function fetchBranchForPush(repoDir, branch) { + runGit(["fetch", "origin", branch], repoDir, { allowFailure: true }); +} + +// Verify gh auth +const authCheck = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }); +if (authCheck.status !== 0) { + console.error("PROMPT_GH_AUTH"); + process.exit(2); +} + +const mergeMethod = process.argv[2] || "merge"; +if (!["merge", "squash", "rebase"].includes(mergeMethod)) { + console.error("ERROR: Invalid merge method. Use: merge, squash, or rebase"); + process.exit(1); +} + +// --- Core functions --- + +/** + * Rebase a branch onto the latest upstream. + * Returns: { status, conflictFiles? } + * status: "success" | "changelog_conflict" | "code_conflict" | "error" + * + * On changelog_conflict, the rebase is LEFT IN PROGRESS for agent resolution. + * On all other failures, the rebase is aborted to leave the repo clean. + */ +function rebaseOntoUpstream(repoDir, branch, repo) { + const upstream = getUpstreamBranch(repo); + + runGit(["fetch", "origin"], repoDir); + + try { + runGit(["checkout", branch], repoDir); + } catch (e) { + return { status: "error", message: `Checkout failed: ${e.message}` }; + } + + const rebaseResult = runGit(["rebase", upstream], repoDir, { + allowFailure: true, + }); + + if (rebaseResult.success) { + return { status: "success" }; + } + + // Conflict detected — analyze + const combinedOutput = rebaseResult.stdout + "\n" + rebaseResult.stderr; + let conflictFiles = parseConflictFiles(combinedOutput); + + if (conflictFiles.length === 0) { + try { + const statusResult = runGit(["status", "--porcelain"], repoDir, { + allowFailure: true, + }); + for (const line of statusResult.stdout.split("\n")) { + if (line.startsWith("UU ") || line.startsWith("AA ")) { + conflictFiles.push(line.slice(3).trim()); + } + } + } catch {} + } + + if (conflictFiles.some((f) => !f.includes("CHANGELOG"))) { + runGit(["rebase", "--abort"], repoDir, { allowFailure: true }); + return { status: "code_conflict", conflictFiles }; + } + + if (isChangelogOnly(conflictFiles)) { + return { status: "changelog_conflict", conflictFiles }; + } + + runGit(["rebase", "--abort"], repoDir, { allowFailure: true }); + return { status: "error", message: "Unknown conflict type", conflictFiles }; +} + +function checkPRStatus(repo, prNumber) { + try { + const data = ghApi(`repos/EdgeApp/${repo}/pulls/${prNumber}`); + return { + state: data.state, + merged: data.merged || false, + mergeable: data.mergeable, + mergeable_state: data.mergeable_state, + }; + } catch (e) { + return { error: `Failed to fetch PR status: ${e.message}` }; + } +} + +function mergePR(repo, prNumber, branch) { + const commitTitle = `Merge pull request #${prNumber} from EdgeApp/${branch}`; + + try { + const data = ghApi(`repos/EdgeApp/${repo}/pulls/${prNumber}/merge`, { + method: "PUT", + body: { + merge_method: mergeMethod, + commit_title: mergeMethod === "merge" ? commitTitle : undefined, + }, + }); + return { + repo, + prNumber, + branch, + success: data?.merged || false, + merged: data?.merged || false, + message: data?.message, + sha: data?.sha, + }; + } catch (e) { + return { + repo, + prNumber, + branch, + success: false, + merged: false, + message: e.message, + }; + } +} + +// --- Main --- + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + const prs = JSON.parse(input); + const results = { + merged: [], + failed: [], + skipped: [], + pending: [], + verificationFailed: null, + changelogConflict: null, + conflict: null, + method: mergeMethod, + status: "complete", + }; + + let exitCode = 0; + + for (let i = 0; i < prs.length; i++) { + const { repo, prNumber, branch } = prs[i]; + const repoDir = resolveRepoDir(repo, branch); + + console.error( + `\n=== Merging ${repo}#${prNumber} (${branch}) [${i + 1}/${prs.length}] ===` + ); + if (path.resolve(repoDir) !== path.resolve(getRepoDir(repo))) { + console.error(`Branch held by worktree — operating in: ${repoDir}`); + } + + // CHECK: Is PR already merged? + const prStatus = checkPRStatus(repo, prNumber); + if (prStatus.merged) { + console.error("✓ Already merged — skipping"); + results.merged.push({ + repo, + prNumber, + branch, + success: true, + merged: true, + sha: "already-merged", + message: "Already merged", + }); + continue; + } + + // STEP 1: Rebase onto latest upstream + console.error("Rebasing onto latest upstream..."); + const rebaseResult = rebaseOntoUpstream(repoDir, branch, repo); + + if (rebaseResult.status === "changelog_conflict") { + console.error("\n=== CHANGELOG conflict — agent resolution needed ==="); + console.error(`Files: ${rebaseResult.conflictFiles.join(", ")}`); + console.error("\nTo resolve:"); + console.error( + ` 1. Read ${path.join(repoDir, "CHANGELOG.md")} with conflict markers` + ); + console.error( + " 2. Resolve semantically (upstream entries first, then ours)" + ); + console.error(" 3. git add CHANGELOG.md && git rebase --continue"); + console.error(" 4. git push --force-with-lease"); + console.error(" 5. Re-run merge"); + results.changelogConflict = { + repo, + prNumber, + branch, + repoDir, + conflictFiles: rebaseResult.conflictFiles, + }; + results.status = "changelog_conflict_needs_resolution"; + results.pending = prs.slice(i + 1); + exitCode = 4; + break; + } + + if (rebaseResult.status === "code_conflict") { + console.error(`⚠ Code conflict — skipping`); + console.error( + ` Conflicting files: ${rebaseResult.conflictFiles.join(", ")}` + ); + results.skipped.push({ + repo, + prNumber, + branch, + repoDir, + reason: "Code conflict", + conflictFiles: rebaseResult.conflictFiles, + }); + continue; + } + + if (rebaseResult.status !== "success") { + console.error( + `⚠ Rebase failed: ${rebaseResult.message || rebaseResult.status} — skipping` + ); + results.skipped.push({ + repo, + prNumber, + branch, + repoDir, + reason: `Rebase failed: ${rebaseResult.message || rebaseResult.status}`, + }); + continue; + } + + console.error("✓ Rebase complete"); + + // STEP 1b: Install dependencies and prepare after rebase + try { + installAndPrepare(repoDir); + } catch (e) { + console.error(`✗ Dependency install failed: ${e.message}`); + results.failed.push({ + repo, + prNumber, + branch, + success: false, + message: `Dependency install failed: ${e.message}`, + }); + continue; + } + + // STEP 2: Push rebased branch + console.error("Pushing rebased branch..."); + const pushResult = runGit( + ["push", "--force-with-lease", "origin", branch], + repoDir, + { allowFailure: true } + ); + if (!pushResult.success) { + fetchBranchForPush(repoDir, branch); + const branchState = describeBranchState(repoDir, branch); + console.error(`✗ Push failed: ${pushResult.stderr}`); + console.error(branchState); + results.failed.push({ + repo, + prNumber, + branch, + success: false, + message: `Push failed: ${pushResult.stderr}`, + }); + continue; + } + console.error("✓ Pushed"); + + // STEP 3: Run local verification (MANDATORY — no bypass) + console.error("Running local verification (MANDATORY)..."); + const verification = runVerification(repoDir, getUpstreamBranch(repo), { + skipInstall: true, + }); + + if (!verification.success) { + console.error("\n=== STOP: Verification failed ==="); + console.error( + `PR ${repo}#${prNumber} cannot be merged until verification passes.` + ); + results.verificationFailed = { + repo, + prNumber, + branch, + repoDir, + exitCode: verification.exitCode, + }; + results.status = "verification_failed"; + results.pending = prs.slice(i + 1); + exitCode = 1; + break; + } + + console.error("✓ Verification passed"); + + // STEP 4: Merge via GitHub API + console.error("Merging via GitHub API..."); + + // Brief pause to let GitHub process the push + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const mergeResult = mergePR(repo, prNumber, branch); + + if (mergeResult.success && mergeResult.merged) { + results.merged.push(mergeResult); + console.error(`✓ Merged: ${mergeResult.sha?.slice(0, 7)}`); + } else { + console.error(`✗ Merge failed: ${mergeResult.message}`); + results.failed.push(mergeResult); + } + } + + // --- Summary --- + console.error("\n=== Merge Summary ==="); + if (results.merged.length > 0) { + console.error(`Merged (${results.merged.length}):`); + for (const r of results.merged) { + const sha = + r.sha === "already-merged" ? "already merged" : r.sha?.slice(0, 7); + console.error(` ✓ ${r.repo}#${r.prNumber} (${sha})`); + } + } + if (results.skipped.length > 0) { + console.error(`\nSkipped (${results.skipped.length}):`); + for (const r of results.skipped) { + console.error(` ⚠ ${r.repo}#${r.prNumber}: ${r.reason}`); + } + } + if (results.conflict) { + console.error(`\nConflict (STOPPED):`); + console.error( + ` ✗ ${results.conflict.repo}#${results.conflict.prNumber}: ${results.conflict.reason}` + ); + } + if (results.changelogConflict) { + console.error("\nCHANGELOG conflict (agent can resolve):"); + console.error( + ` ⚠ ${results.changelogConflict.repo}#${results.changelogConflict.prNumber}` + ); + console.error( + ` Files: ${results.changelogConflict.conflictFiles.join(", ")}` + ); + } + if (results.verificationFailed) { + console.error("\nVerification failed (STOPPED):"); + console.error( + ` ✗ ${results.verificationFailed.repo}#${results.verificationFailed.prNumber}` + ); + } + if (results.failed.length > 0) { + console.error(`\nFailed (${results.failed.length}):`); + for (const r of results.failed) { + console.error(` ✗ ${r.repo}#${r.prNumber}: ${r.message}`); + } + } + if (results.pending.length > 0) { + console.error(`\nPending (${results.pending.length}):`); + for (const p of results.pending) { + console.error(` ⏸ ${p.repo}#${p.prNumber}`); + } + } + + console.log(JSON.stringify(results, null, 2)); + process.exit(exitCode); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.cursor/skills/pr-land/scripts/pr-land-prepare.sh b/.cursor/skills/pr-land/scripts/pr-land-prepare.sh new file mode 100755 index 0000000..a13ff26 --- /dev/null +++ b/.cursor/skills/pr-land/scripts/pr-land-prepare.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env node +// pr-land-prepare.sh +// Prepares a branch for merge: checkout, autosquash, rebase, verify. +// Uses edge-repo.js for shared utilities (no GitHub API calls needed). +// +// Usage: echo '[{"repo":"edge-react-gui","branch":"jon/feature"}]' | ./pr-land-prepare.sh +// +// For each branch: +// 1. Checkout + fetch +// 2. Autosquash fixup commits +// 3. Rebase onto upstream (origin/master or origin/develop for GUI) +// 4. Detect conflicts: code files = SKIP, CHANGELOG-only = report +// 5. Run full verification (CHANGELOG + code) +// +// Exit codes: +// 0 = At least one branch prepared (or has resolvable CHANGELOG conflict) +// 1 = All branches failed (verification or other errors, none ready) +// +// Output: JSON with results for each branch + +const { execSync } = require("child_process"); +const { existsSync, readFileSync } = require("fs"); +const path = require("path"); +const { + getRepoDir, + getUpstreamBranch, + runGit, + parseConflictFiles, + isChangelogOnly, + runVerification, + installAndPrepare, +} = require(path.join(__dirname, "edge-repo.js")); + +// Detect NEW CHANGELOG entries (added vs baseRef) placed under a DATED +// released heading instead of `## Unreleased` or `## X.Y.Z (staging)`. +// Pure inspection — returns an array of { line, text, section } misplaced entries. +// Does not modify the repo. Empty array means no concerns. +function checkChangelogPlacement(repoDir, baseRef) { + const changelogPath = path.join(repoDir, "CHANGELOG.md"); + if (!existsSync(changelogPath)) return []; + + let content; + try { + content = readFileSync(changelogPath, "utf8"); + } catch (e) { + return []; + } + const lines = content.split("\n"); + + // Build ordered list of section headings with kind: unreleased | staging | released + const sections = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let kind = null; + if (/^## Unreleased/i.test(line)) kind = "unreleased"; + else if (/^## .+\(staging\)/i.test(line)) kind = "staging"; + else if (/^## \d+\.\d+\.\d+/.test(line)) kind = "released"; + if (kind != null) { + sections.push({ startLine: i + 1, text: line.replace(/^##\s*/, "").trim(), kind }); + } + } + + const sectionForLine = (lineNum) => { + let last = null; + for (const s of sections) { + if (s.startLine <= lineNum) last = s; + else break; + } + return last; + }; + + // Diff-added lines on HEAD side + let diffOut; + try { + diffOut = execSync( + `git diff --unified=0 --no-color ${baseRef}...HEAD -- CHANGELOG.md`, + { cwd: repoDir, encoding: "utf8" } + ); + } catch (e) { + return []; + } + + const misplaced = []; + let headLine = 0; + for (const raw of diffOut.split("\n")) { + const h = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (h) { + headLine = parseInt(h[1], 10); + continue; + } + if (raw.startsWith("+++") || raw.startsWith("---")) continue; + if (raw.startsWith("+")) { + const text = raw.slice(1); + if (/^- (added|changed|deprecated|fixed|removed|security):/i.test(text)) { + const sect = sectionForLine(headLine); + if (sect != null && sect.kind === "released") { + misplaced.push({ line: headLine, text, section: sect.text }); + } + } + headLine++; + } else if (raw.startsWith("-")) { + // deleted line on BASE side — do not advance HEAD line counter + } else if (raw.length > 0) { + headLine++; + } + } + + return misplaced; +} + +// Locate a worktree (other than the canonical clone) that currently has +// `branch` checked out. Tools like agent-watcher leave such worktrees behind, +// and git refuses to `checkout` a branch already held by another worktree. +// In that case prepare must operate inside that worktree — its content is the +// same branch. Returns the worktree path, or null if none holds the branch. +function findWorktreeForBranch(repoDir, branch) { + const res = runGit(["worktree", "list", "--porcelain"], repoDir, { + allowFailure: true, + }); + if (!res.success || !res.stdout) return null; + const target = `refs/heads/${branch}`; + let currentPath = null; + for (const line of res.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length).trim(); + if (ref === target && currentPath) return currentPath; + } + } + return null; +} + +function describeBranchState(repoDir, branch) { + const parts = []; + const local = runGit(["rev-parse", branch], repoDir, { allowFailure: true }); + if (local.success) { + parts.push(`Local commit (${branch}): ${local.stdout}`); + } else { + parts.push(`Local branch "${branch}" missing`); + } + + const remote = runGit(["rev-parse", `origin/${branch}`], repoDir, { allowFailure: true }); + if (remote.success) { + parts.push(`Remote commit (origin/${branch}): ${remote.stdout}`); + } else { + parts.push(`Remote branch origin/${branch} missing`); + } + + const status = runGit(["status", "-sb"], repoDir, { allowFailure: true }); + if (status.stdout) { + parts.push(`Status: ${status.stdout.trim()}`); + } + return parts.join("\n"); +} + +async function prepareBranch(repo, branch) { + const canonicalDir = getRepoDir(repo); + const upstream = getUpstreamBranch(repo); + let repoDir = canonicalDir; + const result = { + repo, + branch, + repoDir, + status: "unknown", + message: "", + }; + + console.error(`\n=== Preparing ${repo}/${branch} ===`); + + // Step 1: Ensure the canonical clone exists + if (!existsSync(path.join(canonicalDir, ".git"))) { + console.error(`Cloning ${repo}...`); + try { + execSync(`git clone git@github.com:EdgeApp/${repo}.git "${canonicalDir}"`, { + stdio: "inherit", + }); + } catch (e) { + result.status = "clone_failed"; + result.message = "Failed to clone repository"; + return result; + } + } + + // If the branch is already checked out in another worktree (e.g. left behind + // by agent-watcher), git won't let the canonical clone check it out. Operate + // inside that worktree instead. + const wtDir = findWorktreeForBranch(canonicalDir, branch); + if (wtDir && path.resolve(wtDir) !== path.resolve(canonicalDir)) { + console.error(`Branch ${branch} is held by worktree: ${wtDir}`); + console.error(`Operating inside that worktree.`); + repoDir = wtDir; + result.repoDir = repoDir; + } + console.error(`Directory: ${repoDir}`); + + // Step 2: Fetch and checkout + console.error(`Fetching and checking out ${branch}...`); + try { + runGit(["fetch", "origin"], repoDir); + runGit(["fetch", "origin", branch], repoDir, { allowFailure: true }); + runGit(["checkout", branch], repoDir); + runGit(["pull", "--ff-only", "origin", branch], repoDir, { + allowFailure: true, + }); + } catch (e) { + result.status = "checkout_failed"; + result.message = e.message; + return result; + } + + // Step 3: Autosquash fixup commits + console.error("Autosquashing fixup commits..."); + try { + const baseResult = runGit(["merge-base", upstream, "HEAD"], repoDir); + const base = baseResult.stdout; + runGit(["rebase", "-i", base, "--autosquash"], repoDir); + console.error("✓ Autosquash complete"); + } catch (e) { + runGit(["rebase", "--abort"], repoDir, { allowFailure: true }); + result.status = "autosquash_failed"; + result.message = e.message; + return result; + } + + // Step 4: Rebase onto upstream + console.error(`Rebasing onto ${upstream}...`); + const rebaseResult = runGit(["rebase", upstream], repoDir, { + allowFailure: true, + }); + + if (!rebaseResult.success) { + const combinedOutput = rebaseResult.stdout + "\n" + rebaseResult.stderr; + const conflictFiles = parseConflictFiles(combinedOutput); + + console.error(`Conflict detected in: ${conflictFiles.join(", ")}`); + + if (conflictFiles.some((f) => !f.includes("CHANGELOG"))) { + console.error("\n=== Skipping: Code conflict detected ==="); + for (const f of conflictFiles) { + console.error(` - ${f}`); + } + runGit(["rebase", "--abort"], repoDir, { allowFailure: true }); + result.status = "code_conflict"; + result.message = "Code conflict — skipped"; + result.conflictFiles = conflictFiles; + return result; + } + + if (isChangelogOnly(conflictFiles)) { + console.error( + "\nCHANGELOG-only conflict. Rebase left in conflict state — resolve semantically, `git add CHANGELOG.md && GIT_EDITOR=true git rebase --continue`, then re-run prepare to verify." + ); + // Do NOT abort the rebase: the agent resolves in place and runs --continue. + result.status = "changelog_conflict"; + result.message = "CHANGELOG conflict - resolve semantically, continue rebase, then re-run"; + result.conflictFiles = conflictFiles; + return result; + } + } + + console.error("✓ Rebase complete"); + + // Step 5: Install dependencies and prepare + try { + installAndPrepare(repoDir); + } catch (e) { + result.status = "install_failed"; + result.message = `Dependency install failed: ${e.message}`; + return result; + } + + // Step 6: Run verification (lint scoped to files changed vs upstream) + console.error("\nRunning verification..."); + const verifyResult = runVerification(repoDir, upstream, { + skipInstall: true, + }); + + if (!verifyResult.success) { + console.error("Branch state:"); + console.error(describeBranchState(repoDir, branch)); + result.status = "verification_failed"; + result.message = `Verification failed (exit code ${verifyResult.exitCode})`; + return result; + } + + // Step 7: Inspect CHANGELOG placement — warn if new entries landed under a + // dated released heading instead of `## Unreleased` or staging. Non-fatal: + // the agent prompts the user to decide (leave / move to Unreleased / move to + // staging) before pushing. + const misplaced = checkChangelogPlacement(repoDir, upstream); + if (misplaced.length > 0) { + console.error( + `\n⚠ CHANGELOG placement warning: ${misplaced.length} new entry(s) under a released heading:` + ); + for (const m of misplaced) { + console.error(` line ${m.line} in "${m.section}": ${m.text}`); + } + result.placementWarnings = misplaced; + } + + result.status = "ready"; + result.message = "Branch prepared and verified successfully"; + return result; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + const branches = JSON.parse(input); + const results = { + prepared: [], + failed: [], + skipped: [], + changelogConflicts: [], + }; + + let exitCode = 0; + + for (const { repo, branch } of branches) { + const result = await prepareBranch(repo, branch); + + switch (result.status) { + case "ready": + results.prepared.push(result); + break; + case "code_conflict": + results.skipped.push(result); + break; + case "changelog_conflict": + results.changelogConflicts.push(result); + break; + default: + results.failed.push(result); + exitCode = Math.max(exitCode, 1); + } + } + + // Summary + console.error("\n=== Prepare Summary ==="); + if (results.prepared.length > 0) { + console.error(`Ready (${results.prepared.length}):`); + for (const r of results.prepared) { + const warn = r.placementWarnings?.length + ? ` ⚠ ${r.placementWarnings.length} CHANGELOG placement warning(s)` + : ""; + console.error(` ✓ ${r.repo}/${r.branch}${warn}`); + } + } + if (results.skipped.length > 0) { + console.error(`\nSkipped — code conflicts (${results.skipped.length}):`); + for (const r of results.skipped) { + console.error( + ` ⚠ ${r.repo}/${r.branch}: ${r.conflictFiles?.join(", ")}` + ); + } + } + if (results.changelogConflicts.length > 0) { + console.error( + `\nCHANGELOG conflicts (${results.changelogConflicts.length}):` + ); + for (const r of results.changelogConflicts) { + console.error( + ` ⚠ ${r.repo}/${r.branch}: Resolve semantically, then re-run` + ); + } + } + if (results.failed.length > 0) { + console.error(`\nFailed (${results.failed.length}):`); + for (const r of results.failed) { + console.error(` ✗ ${r.repo}/${r.branch}: ${r.message}`); + } + } + + if ( + results.prepared.length === 0 && + results.changelogConflicts.length === 0 && + exitCode === 0 + ) { + exitCode = 1; + } + + console.log(JSON.stringify(results, null, 2)); + process.exit(exitCode); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.cursor/skills/pr-land/scripts/pr-land-publish.sh b/.cursor/skills/pr-land/scripts/pr-land-publish.sh new file mode 100755 index 0000000..183f7ef --- /dev/null +++ b/.cursor/skills/pr-land/scripts/pr-land-publish.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env node +// pr-land-publish.sh +// Version bump, changelog update, commit, and tag for npm publishing +// Usage: echo '[{"repo":"edge-exchange-plugins","branch":"master"}]' | ./pr-land-publish.sh +// +// How it works: +// 1. Checks out the branch and fetches latest +// 2. Parses CHANGELOG.md for unreleased entries +// 3. Runs verification via ~/.cursor/skills/pm.sh (verify, else tsc + lint) +// 4. Bumps version (minor for added/changed, patch for fixed) +// 5. Updates CHANGELOG.md with version header +// 6. Commits and tags locally (does NOT push) +// 7. Returns JSON with needsPush flag +// +// The agent should: +// - Show the user the version bump details and ask for confirmation +// - If confirmed, push master + tag to origin +// - Then prompt the user to run `npm publish` in a real terminal +// +// Exit codes: +// 0 = Version bumped, committed, tagged (check needsPush in JSON) +// 1 = Verification failed +// 2 = No unreleased changes + +const { execSync } = require("child_process"); +const { existsSync, readFileSync, writeFileSync } = require("fs"); +const os = require("os"); +const path = require("path"); +const { getRepoDir, runGit: _runGit, installAndPrepare } = require(path.join(__dirname, "edge-repo.js")); + +// Thin wrapper: publish only needs the stdout string from runGit +function runGit(args, cwd) { + return _runGit(typeof args === "string" ? args.split(" ") : args, cwd).stdout; +} + +function parseChangelog(repoDir) { + const changelogPath = path.join(repoDir, "CHANGELOG.md"); + if (!existsSync(changelogPath)) { + return { entries: [], patchOnly: true, error: "No CHANGELOG.md found" }; + } + + const content = readFileSync(changelogPath, "utf8"); + const unreleasedStart = content.indexOf("## Unreleased"); + + if (unreleasedStart === -1) { + return { entries: [], patchOnly: true, error: "No ## Unreleased section" }; + } + + const nextVersionStart = content.indexOf("## ", unreleasedStart + "## Unreleased".length); + const unreleasedSection = content.substring( + unreleasedStart + "## Unreleased".length, + nextVersionStart !== -1 ? nextVersionStart : undefined + ).trim(); + + const entries = unreleasedSection.split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith("## ")); + + if (entries.length === 0) { + return { entries: [], patchOnly: true, error: "No entries in Unreleased section" }; + } + + // Validate entries and determine version bump + const allowedTags = ["- added:", "- changed:", "- deprecated:", "- removed:", "- fixed:", "- security:"]; + let patchOnly = true; + + for (const entry of entries) { + const hasValidTag = allowedTags.some(tag => entry.startsWith(tag)); + if (!hasValidTag) { + return { entries, patchOnly: true, error: `Invalid entry format: ${entry}` }; + } + + // Minor version bump for added/changed + if (entry.startsWith("- added:") || entry.startsWith("- changed:")) { + patchOnly = false; + } + } + + return { entries, patchOnly, error: null }; +} + +function bumpVersion(repoDir, patchOnly) { + const pkgPath = path.join(repoDir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const parts = pkg.version.split(".").map(Number); + + if (patchOnly) { + parts[2]++; + } else { + parts[1]++; + parts[2] = 0; + } + + const newVersion = parts.join("."); + pkg.version = newVersion; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + + return { oldVersion: pkg.version, newVersion }; +} + +function updateChangelog(repoDir, newVersion) { + const changelogPath = path.join(repoDir, "CHANGELOG.md"); + let content = readFileSync(changelogPath, "utf8"); + + const date = new Date().toISOString().split("T")[0]; + const newHeading = `## ${newVersion} (${date})`; + + content = content.replace( + "## Unreleased", + `## Unreleased\n\n${newHeading}` + ); + + writeFileSync(changelogPath, content); +} + +function checkNpmPublished(packageName, version) { + try { + const info = execSync(`npm view ${packageName}@${version} version`, { + encoding: "utf8", + stdio: "pipe" + }).trim(); + return info === version; + } catch (e) { + return false; + } +} + +async function publishRepo(repo, branch) { + const repoDir = getRepoDir(repo); + const results = { + repo, + branch, + repoDir, + success: false + }; + + console.error(`\n=== Publishing ${repo} ===`); + console.error(`Directory: ${repoDir}`); + + // 1. Ensure we're on the right branch and up to date + try { + runGit("fetch origin", repoDir); + runGit(`checkout ${branch}`, repoDir); + runGit(`reset --hard origin/${branch}`, repoDir); + } catch (e) { + results.error = `Git checkout failed: ${e.message}`; + return results; + } + + // 2. Get current package info + const pkgPath = path.join(repoDir, "package.json"); + const currentPkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const currentVersion = currentPkg.version; + const packageName = currentPkg.name; + + // 3. Check if current version is already published + const isPublished = checkNpmPublished(packageName, currentVersion); + + if (isPublished) { + // Version already published - do full version bump flow + const changelog = parseChangelog(repoDir); + if (changelog.error) { + results.error = changelog.error; + results.exitCode = 2; + return results; + } + + console.error(`\nChangelog entries (${changelog.entries.length}):`); + for (const entry of changelog.entries) { + console.error(` ${entry}`); + } + console.error(`\nVersion bump: ${changelog.patchOnly ? "PATCH" : "MINOR"}`); + + // Run verification + console.error("\nRunning verification..."); + try { + installAndPrepare(repoDir); + + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const pmScript = path.join(os.homedir(), ".cursor/skills/pm.sh"); + if (pkg.scripts?.verify) { + execSync(`"${pmScript}" run verify`, { cwd: repoDir, stdio: "inherit" }); + } else { + execSync(`"${pmScript}" run tsc && "${pmScript}" run lint`, { cwd: repoDir, stdio: "inherit" }); + } + } catch (e) { + results.error = "Verification failed"; + results.exitCode = 1; + return results; + } + console.error("✓ Verification passed"); + + // Bump version + const { newVersion } = bumpVersion(repoDir, changelog.patchOnly); + console.error(`\nVersion: ${currentVersion} → ${newVersion}`); + + // Update changelog + updateChangelog(repoDir, newVersion); + console.error("✓ Updated CHANGELOG.md"); + + // Commit and tag (do NOT push yet - agent will prompt user first) + try { + runGit("add package.json CHANGELOG.md", repoDir); + execSync(`git commit -m "v${newVersion}" --no-verify`, { cwd: repoDir, stdio: "pipe" }); + runGit(`tag v${newVersion}`, repoDir); + console.error(`✓ Committed and tagged v${newVersion}`); + } catch (e) { + results.error = `Git commit failed: ${e.message}`; + return results; + } + + results.newVersion = newVersion; + results.needsPush = true; + results.success = true; + return results; + } else { + // Current version NOT published - check if already pushed + console.error(`\nVersion ${currentVersion} not yet published to npm`); + + let alreadyPushed = false; + try { + const remoteTags = runGit(`ls-remote --tags origin v${currentVersion}`, repoDir); + alreadyPushed = remoteTags.length > 0; + } catch (e) { + // ls-remote failed, assume not pushed + } + + results.newVersion = currentVersion; + results.needsPush = !alreadyPushed; + + if (alreadyPushed) { + console.error("Tag already pushed to origin."); + } else { + console.error("Version bump exists locally but has not been pushed yet."); + } + + results.success = true; + return results; + } +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + const repos = JSON.parse(input); + const results = { + published: [], + failed: [], + skipped: [] + }; + + let exitCode = 0; + + for (const { repo, branch } of repos) { + const result = await publishRepo(repo, branch || "master"); + + if (result.success) { + results.published.push(result); + } else if (result.exitCode === 2) { + results.skipped.push(result); + } else { + results.failed.push(result); + exitCode = result.exitCode || 1; + } + } + + // Summary + console.error("\n=== Publish Summary ==="); + if (results.published.length > 0) { + console.error(`Ready (${results.published.length}):`); + for (const r of results.published) { + console.error(` ✓ ${r.repo}@${r.newVersion}${r.needsPush ? " (needs push)" : " (already pushed)"}`); + } + } + if (results.skipped.length > 0) { + console.error(`Skipped (${results.skipped.length}):`); + for (const r of results.skipped) { + console.error(` ⏭ ${r.repo}: ${r.error}`); + } + } + if (results.failed.length > 0) { + console.error(`Failed (${results.failed.length}):`); + for (const r of results.failed) { + console.error(` ✗ ${r.repo}: ${r.error}`); + } + } + + console.log(JSON.stringify(results, null, 2)); + process.exit(exitCode); +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/.cursor/skills/pr-land/scripts/upgrade-dep.sh b/.cursor/skills/pr-land/scripts/upgrade-dep.sh new file mode 100755 index 0000000..9d9affe --- /dev/null +++ b/.cursor/skills/pr-land/scripts/upgrade-dep.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# upgrade-dep.sh +# Upgrade one package on the current branch and commit the bump + lockfiles. +# +# Usage: upgrade-dep.sh <package> [version] +# +# PRECONDITION: caller has already placed us on a clean `develop` (or the +# target branch) synced to origin. This script does NOT stash, checkout, +# fetch, or reset — doing so would wipe commits from a prior `upgrade-dep.sh` +# invocation in the same `/pr-land` run. +# +# Bumps the version in package.json, runs install + prepare + prepare.ios via +# whichever package manager the repo uses (npm or yarn, auto-detected via +# ~/.cursor/skills/pm.sh), and commits package.json + lockfile with message +# "Upgrade <package>@<version>". + +usage() { + echo "Usage: upgrade-dep.sh <package> [version]" + exit 1 +} + +package="" +new_version="" + +case "$#" in + 1) + package="$1" + ;; + 2) + package="$1" + new_version="$2" + ;; + *) + usage + ;; +esac + +# Resolve latest version from npm if none provided +if [ -z "$new_version" ]; then + latest_version=$(npm view "$package" versions --json | jq -r '.[]' | sort -V | tail -n 1) + new_version="^$latest_version" +fi + +# Check if already at target version +current_version=$(jq -r ".dependencies[\"$package\"] // .devDependencies[\"$package\"]" package.json) +if [ "$current_version" = "$new_version" ]; then + echo "Error: $package is already at version $new_version" + exit 1 +fi + +# Update package.json +sed -i "" "s#\"$package\": \".*\"#\"$package\": \"$new_version\"#" package.json + +# Install and prepare +export LANG="${LANG:-en_US.UTF-8}" +export LC_ALL="${LC_ALL:-en_US.UTF-8}" +~/.cursor/skills/pm.sh install +~/.cursor/skills/pm.sh run prepare +~/.cursor/skills/pm.sh run prepare.ios + +# yarn.lock-only cleanup: yarn writes git+ prefixes for git deps; npm's +# package-lock does not. Skip the sed when there is no yarn.lock. +if [[ -f yarn.lock ]]; then + sed -i "" "s/git+//" yarn.lock +fi + +# Stage and commit (git add -A picks up whichever lockfile changed) +git add -A +git commit -m "Upgrade $package@$new_version" --no-verify +echo ">> UPGRADE_READY package=$package version=$new_version sha=$(git rev-parse HEAD) branch=$(git branch --show-current)" diff --git a/.cursor/skills/pr-review/SKILL.md b/.cursor/skills/pr-review/SKILL.md new file mode 100644 index 0000000..200fbcb --- /dev/null +++ b/.cursor/skills/pr-review/SKILL.md @@ -0,0 +1,123 @@ +--- +name: pr-review +description: Review PR changes against Edge coding conventions and post structured inline feedback to GitHub. Use when the user wants to review a pull request. +compatibility: Requires git, gh. +metadata: + author: j0ntz +--- + +<goal>Review PR changes against Edge coding conventions and post structured inline feedback to GitHub.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="standards-first">Read review standards BEFORE examining code. Load both `~/.cursor/rules/review-standards.mdc` and `~/.cursor/rules/typescript-standards.mdc` in parallel.</rule> +<rule id="use-companion-script">Use `~/.cursor/skills/pr-review/scripts/github-pr-review.sh` for all GitHub API operations. Do not use raw `curl`, `gh`, or MCP tools inline.</rule> +<rule id="no-script-bypass">If a companion script fails, report the error and STOP. Do NOT fall back to raw `gh`, `curl`, or other workarounds.</rule> +<rule id="no-duplicate-feedback">Check existing reviews from the context output. Do not repeat feedback already given by another reviewer.</rule> +<rule id="batch-reads">When reviewing changed files, batch independent Read/Grep calls in a single message.</rule> +<rule id="script-timeouts">The companion script may take up to 30s. Set `block_until_ms: 60000` when invoking it.</rule> +</rules> + +<step id="1" name="Gather PR context"> +Run the companion script to fetch PR metadata, changed files with patches, and existing reviews: + +```bash +~/.cursor/skills/pr-review/scripts/github-pr-review.sh context [--pr <number>] [--owner <owner>] [--repo <repo>] +``` + +If the user provides a PR URL or number, pass `--pr`. If they also specify a repo, pass `--owner` and `--repo`. If nothing is provided, the script auto-detects from the current branch. + +If the script exits code 2 with `PROMPT_GH_AUTH`, prompt: "`gh` CLI is not authenticated. Run `gh auth login` first." + +Save the output JSON — it contains `number`, `title`, `url`, `headRef`, `baseRef`, `headSha`, `reviews[]`, and `files[]` (with patches). +</step> + +<step id="2" name="Checkout PR branch"> +Checkout the PR branch to ensure file reads reflect the PR's code, not the current local branch: + +```bash +git fetch origin <headRef> && git checkout <headRef> +``` + +Replace `<headRef>` with the branch name from the context output (e.g., `william/fix-eth-sync`). + +If checkout fails due to uncommitted changes, prompt the user to stash or commit before proceeding. +</step> + +<step id="3" name="Load review standards"> +Read these files in parallel (skip any already present in `cursor_rules_context`): + +- `~/.cursor/rules/review-standards.mdc` +- `~/.cursor/rules/typescript-standards.mdc` +</step> + +<step id="4" name="Review changed files"> +For each changed file in the context output: + +1. Read the full file to understand surrounding context (batch reads in parallel) +2. Review the patch against all loaded standards +3. Check for: + - Convention violations from review-standards.mdc and typescript-standards.mdc + - Potential bugs or safety issues + - Performance concerns + - Unnecessary code, unnecessary JSX fragments, or missed simplifications + - Efficient memoization where necessary (memo, useHandler, useCallback) + +Categorize findings as: +- **Critical**: Must fix before merge +- **Warning**: Should address +- **Suggestion**: Consider for improvement + +Cross-reference findings against `reviews[]` from the context output. Omit any findings already raised by another reviewer. +</step> + +<step id="5" name="Submit review"> +If there are findings to report, prepare a review JSON and submit via the companion script: + +```bash +echo '<review-json>' | ~/.cursor/skills/pr-review/scripts/github-pr-review.sh submit \ + --pr <number> --owner <owner> --repo <repo> --sha <headSha> +``` + +Review JSON format: +```json +{ + "event": "COMMENT", + "body": "", + "comments": [ + { + "path": "src/file.ts", + "line": 42, + "side": "RIGHT", + "body": "Comment text" + } + ] +} +``` + +Use `"REQUEST_CHANGES"` for critical issues, `"COMMENT"` for suggestions only, `"APPROVE"` if no issues found. + +<sub-step name="Comment formatting"> +- Single line: use only `line` +- Multi-line range: use both `start_line` (first) and `line` (last) +- `side`: use `"RIGHT"` for new code (additions) +- Keep comments concise, use backtick formatting for code, bold, or italics +- 0 findings: No review needed +- 1 inline comment: Leave `body` empty (`""`) +- 2+ inline comments: Only add `body` if it provides necessary linking context +</sub-step> +</step> + +<step id="6" name="Summarize"> +After submitting (or if no findings), provide a summary in the chat response: +- Number of files reviewed +- Findings by category (critical, warning, suggestion) +- Link to the submitted review + - PR link [PR title](https://github.com/EdgeApp/<repo>/pull/5952) +</step> + +<edge-cases> +<case name="No PR found">Script exits with an error. Ask the user for a PR number or URL.</case> +<case name="No changed files">Report that the PR has no file changes.</case> +<case name="Large PR (>20 files)">Prioritize files with the most additions. Note any files skipped due to size.</case> +<case name="Server repo">If the repository name ends in `-server` or context indicates a server project, also review against the Server Conventions section in review-standards.mdc.</case> +</edge-cases> diff --git a/.cursor/skills/pr-review/scripts/github-pr-review.sh b/.cursor/skills/pr-review/scripts/github-pr-review.sh new file mode 100755 index 0000000..5bf9d88 --- /dev/null +++ b/.cursor/skills/pr-review/scripts/github-pr-review.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# github-pr-review.sh — Fetch PR review context and submit reviews via gh CLI. +# +# Subcommands: +# context [--pr <number>] [--owner <o>] [--repo <r>] Fetch PR metadata + files + existing reviews +# submit --pr <n> --owner <o> --repo <r> --sha <sha> Post review (JSON on stdin) +# +# The `context` subcommand auto-detects the PR from the current branch if --pr is omitted. +# Total API calls: 2 (gh pr view + gh api for file patches). +# +# Exit codes: 0 = success, 1 = error, 2 = needs user input (e.g. gh not authenticated) +set -euo pipefail + +CMD="${1:-}" +shift || true + +OWNER="" REPO="" PR="" SHA="" +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --pr) PR="$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 "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 +} + +case "$CMD" in + context) + require_gh + + # --- Call 1: PR metadata + reviews via gh pr view --- + VIEW_ARGS=() + [[ -n "$PR" ]] && VIEW_ARGS+=("$PR") + [[ -n "$OWNER" && -n "$REPO" ]] && VIEW_ARGS+=("--repo" "$OWNER/$REPO") + + META=$(gh pr view ${VIEW_ARGS[@]+"${VIEW_ARGS[@]}"} \ + --json number,title,url,headRefName,headRefOid,baseRefName,reviews 2>&1) || { + echo "Error: Failed to fetch PR. Output: $META" >&2 + exit 1 + } + + # Parse owner/repo/number from the PR URL + NUMBER=$(echo "$META" | jq -r '.number') + URL=$(echo "$META" | jq -r '.url') + _OWNER=$(echo "$URL" | cut -d/ -f4) + _REPO=$(echo "$URL" | cut -d/ -f5) + + # --- Call 2: Changed files with patches (REST — GraphQL doesn't expose patches) --- + FILES=$(gh api "repos/$_OWNER/$_REPO/pulls/$NUMBER/files" --paginate 2>&1) || { + echo "Error: Failed to fetch PR files. Output: $FILES" >&2 + exit 1 + } + + # Merge into single structured JSON output + jq -n \ + --argjson meta "$META" \ + --argjson files "$FILES" \ + '{ + number: $meta.number, + title: $meta.title, + url: $meta.url, + headRef: $meta.headRefName, + baseRef: $meta.baseRefName, + headSha: $meta.headRefOid, + reviews: [($meta.reviews // [])[] | {user: .author.login, state: .state, body: .body}], + files: [$files[] | {path: .filename, status: .status, additions: .additions, deletions: .deletions, patch: .patch}] + }' + ;; + + submit) + require_gh + + if [[ -z "$PR" || -z "$OWNER" || -z "$REPO" || -z "$SHA" ]]; then + echo "Error: --pr, --owner, --repo, --sha required for submit" >&2 + exit 1 + fi + + # Read review JSON from stdin: { event, body, comments: [{path, line, body, start_line?, side?}] } + # Inject commit_id from --sha and POST to reviews endpoint + jq --arg sha "$SHA" '. + {commit_id: $sha}' | \ + gh api "repos/$OWNER/$REPO/pulls/$PR/reviews" -X POST --input - | \ + jq '{id: .id, state: .state, url: .html_url}' + ;; + + *) + echo "Usage: github-pr-review.sh {context|submit} [args]" >&2 + exit 1 + ;; +esac diff --git a/.cursor/skills/q/SKILL.md b/.cursor/skills/q/SKILL.md new file mode 100644 index 0000000..162a9bd --- /dev/null +++ b/.cursor/skills/q/SKILL.md @@ -0,0 +1,88 @@ +--- +name: q +description: Answer the user's question with maximum accuracy, objectivity, and intellectual honesty. Use when the user asks a question that needs careful, evidence-based answering. +metadata: + author: j0ntz +--- + +<goal> +Answer the user's question with maximum accuracy, objectivity, and intellectual honesty. +</goal> + +<rules description="Non-negotiable constraints. Read these before anything else."> +<rule id="no-sycophancy">Do not open with "Great question!", "Certainly!", "Absolutely!", or similar. Start with substance.</rule> +<rule id="no-filler">Do not pad responses with obvious restatements of the question or generic context the user already knows.</rule> +<rule id="no-unverified-claims">For claims about APIs, libraries, project conventions, or anything that could be outdated or wrong, either verify against the codebase/docs or state that you cannot verify. Pre-training knowledge is acceptable for stable, well-established concepts (language semantics, algorithms, etc.) but not for anything version-sensitive or project-specific.</rule> +<rule id="calibrated-confidence"> + When uncertain, say so explicitly with a qualifier (e.g., "I believe…", "Based on what I can see…"). Distinguish between "I lack information" and "this is genuinely debatable." + When confident, state things directly without qualifiers. Hedging on things you know well is noise, not honesty. +</rule> +<rule id="no-code-changes">This command is for answering only. Do not edit files, create files, or run commands that mutate state.</rule> +</rules> + +<step id="1" name="Identify ambiguity"> +Check whether the question has multiple valid interpretations that would lead to **materially different answers**. If so: + +1. List the interpretations (briefly, 1 line each). +2. Ask the user which they mean. +3. **Stop and wait.** Do not answer until the user clarifies. + +If the interpretations converge on the same conclusion, proceed and note which interpretation you chose. If unambiguous, proceed directly. +</step> + +<step id="2" name="Gather evidence"> +Decide whether tool calls are needed: + +<skip-tools> + Skip evidence gathering when: + - The question is conceptual, opinion-based, or about stable well-established knowledge you can answer with high confidence (e.g., "what does Array.map do?"). + - No tool output would change or strengthen the answer. +</skip-tools> + +<use-tools> + Use read-only tools (Read, Grep, Glob, SemanticSearch, WebSearch, WebFetch) when: + - The answer depends on codebase state, project conventions, or version-specific behavior. + - The answer could plausibly be wrong or outdated without verification. + + For codebase questions: search the relevant repo(s). + For external API/library questions: search the web for current official docs and cite the source. +</use-tools> +</step> + +<step id="3" name="Answer"> +<structure> + 1. **Direct answer first.** Lead with the answer, not background. A yes/no question gets yes/no with one sentence of justification. + 2. **Evidence/reasoning second.** Show what you found and how it supports the answer. Cite files, line numbers, or URLs. Omit this section entirely if no tools were used and the reasoning is self-evident. + 3. **Caveats last.** Note limitations, unknowns, or alternative interpretations. Omit if there are none. +</structure> + +<length> + Match response length to question complexity. A simple question gets 1-3 sentences. A complex question gets structured sections. Never pad. +</length> + +<multi-part> + If the user asks multiple things at once, answer each as a numbered section with its own direct-answer-first structure. +</multi-part> +</step> + +<edge-cases> +<case name="No clear answer"> + State that explicitly. Explain what would be needed to arrive at an answer (e.g., "This depends on X, which I cannot determine from the codebase alone"). +</case> + +<case name="Question contradicts codebase reality"> + Point out the contradiction with evidence. Do not silently conform to the user's premise if it's factually wrong. +</case> + +<case name="Multiple valid answers"> + Present them as alternatives with trade-offs. Do not pick one arbitrarily. +</case> + +<case name="Sources disagree"> + When the codebase contradicts official docs, or two sources conflict, present both with attribution. State which source you trust more and why (e.g., "The codebase uses X, but current docs recommend Y — the codebase may be on an older version"). +</case> + +<case name="Implementation feasibility question"> + If the user asks "Can you implement X?" or similar, treat it as a question about feasibility — not a request to start coding. Answer with: feasibility assessment first, trade-offs and approach options second, unknowns last. +</case> +</edge-cases> diff --git a/.cursor/skills/slot-fixup.sh b/.cursor/skills/slot-fixup.sh new file mode 100755 index 0000000..af1d467 --- /dev/null +++ b/.cursor/skills/slot-fixup.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# slot-fixup.sh +# Move HEAD (a fixup! commit) to sit immediately after its target's group. +# A target's group = the target commit + any same-headline fixups already +# slotted next to it. The new fixup goes at the END of that group, preserving +# chronological order among siblings. +# +# Designed to be called after `lint-commit.sh` creates a fixup, keeping the +# "every fixup sits next to its target" invariant continuously. +# +# Usage: +# slot-fixup.sh [--base <ref>] +# +# --base <ref> Base commit/ref to rebase from. Defaults to merge-base of +# origin's default branch with HEAD. +# +# Exit codes: +# 0 — slotted cleanly (HEAD now points to the slotted fixup) +# 1 — error (HEAD not a fixup, target not found, rebase conflict, etc.) +# +# On conflict the script aborts the rebase and exits non-zero so the caller +# can surface the issue. The working tree is left clean. + +set -euo pipefail + +BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$BASE" ]]; then + 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 [[ -z "$DEFAULT_UPSTREAM" || "$DEFAULT_UPSTREAM" == "origin/" ]]; then + DEFAULT_UPSTREAM="origin/master" + fi + BASE="$(git merge-base "$DEFAULT_UPSTREAM" HEAD 2>/dev/null || true)" + if [[ -z "$BASE" ]]; then + echo "Error: could not determine merge-base with $DEFAULT_UPSTREAM" >&2 + exit 1 + fi +fi + +HEAD_MSG="$(git log -1 --format='%s')" +if [[ ! "$HEAD_MSG" =~ ^fixup!\ ]]; then + echo "Error: HEAD is not a fixup! commit (subject: $HEAD_MSG)" >&2 + exit 1 +fi + +HEADLINE="${HEAD_MSG#fixup! }" +HEAD_SHA_FULL="$(git rev-parse HEAD)" + +TARGET_FOUND="$(git log "$BASE..HEAD~1" --format='%H %s' \ + | awk -v h="$HEADLINE" '$0 ~ ("^[0-9a-f]+ " h "$") { print $1; found=1 } END { exit !found }')" + +if [[ -z "$TARGET_FOUND" ]]; then + echo "Error: target commit with subject \"$HEADLINE\" not found in $BASE..HEAD~1" >&2 + exit 1 +fi + +# If HEAD is already next to its target group, no-op. +PARENT_MSG="$(git log -1 --format='%s' HEAD~1)" +if [[ "$PARENT_MSG" == "$HEADLINE" || "$PARENT_MSG" == "fixup! $HEADLINE" ]]; then + echo ">> Already slotted next to target group ($HEADLINE) — no-op" + exit 0 +fi + +EDITOR_SCRIPT="$(mktemp -t slot-fixup-editor.XXXXXX.js)" +trap 'rm -f "$EDITOR_SCRIPT"' EXIT + +cat > "$EDITOR_SCRIPT" <<'NODEEOF' +const fs = require('fs') +const path = process.argv[2] +const headSha = process.env.SLOT_HEAD_SHA +const headline = process.env.SLOT_HEADLINE +const text = fs.readFileSync(path, 'utf8') +const lines = text.split('\n') + +const isPick = l => /^(pick|p)\s+[0-9a-f]+/.test(l) +const getSha = l => { + const m = l.match(/^[a-z]+\s+([0-9a-f]+)/) + return m ? m[1] : null +} +const getMsg = l => { + // Some git builds / `rebase.instructionFormat` settings emit the todo as + // `pick <sha> # <subject>`; strip the optional `# ` so subject matching + // works regardless of that prefix. + const m = l.match(/^[a-z]+\s+[0-9a-f]+\s+(?:#\s+)?(.+)$/) + return m ? m[1] : '' +} + +const todos = [] +const trailing = [] +let inTrailing = false +for (const line of lines) { + if (inTrailing) { trailing.push(line); continue } + if ((line.startsWith('#') || line === '') && todos.length > 0) { + inTrailing = true + trailing.push(line) + continue + } + if (isPick(line)) { + todos.push(line) + } else if (todos.length === 0) { + trailing.unshift(line) + } else { + trailing.push(line) + } +} + +const headIdx = todos.findIndex(t => { + const sha = getSha(t) + if (!sha) return false + return headSha.startsWith(sha) || sha.startsWith(headSha) +}) +if (headIdx < 0) { + console.error('ERROR: HEAD ' + headSha + ' not found in todos') + process.exit(1) +} + +const headLine = todos[headIdx] +const remaining = todos.filter((_, i) => i !== headIdx) + +const targetIdx = remaining.findIndex(t => getMsg(t) === headline) +if (targetIdx < 0) { + console.error('ERROR: target "' + headline + '" not found in todos') + process.exit(1) +} + +let slotIdx = targetIdx + 1 +while (slotIdx < remaining.length && getMsg(remaining[slotIdx]) === 'fixup! ' + headline) { + slotIdx++ +} + +const result = [...remaining.slice(0, slotIdx), headLine, ...remaining.slice(slotIdx)] +const out = result.join('\n') + '\n' + (trailing.length > 0 ? trailing.join('\n') : '') +fs.writeFileSync(path, out) +NODEEOF + +# --autostash lets slotting proceed when the working tree has unrelated +# uncommitted changes (e.g. the next fixup's edits staged for a following +# pass). It stashes tracked modifications before the rebase and restores them +# after, without touching untracked files like node_modules. +if ! SLOT_HEAD_SHA="$HEAD_SHA_FULL" SLOT_HEADLINE="$HEADLINE" \ + GIT_SEQUENCE_EDITOR="node $EDITOR_SCRIPT" \ + GIT_EDITOR=true \ + git rebase --autostash -i "$BASE" >/dev/null 2>&1; then + echo "Error: rebase failed during slotting" >&2 + if [[ -d .git/rebase-merge ]] || [[ -d .git/rebase-apply ]]; then + echo "Aborting rebase to leave working tree clean" >&2 + git rebase --abort 2>&1 | sed 's/^/ /' >&2 || true + fi + exit 1 +fi + +echo ">> Slotted fixup '$HEADLINE' next to its target group" diff --git a/.cursor/skills/staging-cherry-pick/SKILL.md b/.cursor/skills/staging-cherry-pick/SKILL.md new file mode 100644 index 0000000..c3cc154 --- /dev/null +++ b/.cursor/skills/staging-cherry-pick/SKILL.md @@ -0,0 +1,91 @@ +--- +name: staging-cherry-pick +description: Cherry-pick merged PR commits onto the staging branch. Use after pr-land merges staging-targeted PRs to develop, or standalone when commits need to land on staging. +compatibility: Requires git, gh, node. +metadata: + author: j0ntz +--- + +<goal>Cherry-pick individual commits from merged PRs onto the `staging` branch, resolving CHANGELOG conflicts semantically when they arise.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="individual-commits">Cherry-pick each commit individually — NEVER cherry-pick the merge commit itself. Extract non-merge commits via `git log --reverse <merge>^1..<merge>^2`.</rule> +<rule id="pull-first">ALWAYS pull the latest staging branch before cherry-picking.</rule> +<rule id="changelog-conflicts">CHANGELOG conflicts: Agent resolves semantically (existing staging entries first, then the new entry). Code conflicts: STOP and report.</rule> +<rule id="no-force-push">Do NOT force-push staging without explicit user confirmation.</rule> +<rule id="no-editors">Never open editors. All git operations must be non-interactive: `GIT_EDITOR=true` for commit messages.</rule> +<rule id="push-confirmation">After all cherry-picks succeed, ask user before pushing to origin/staging.</rule> +<rule id="scripts-only">Use the companion script for cherry-pick operations. Do NOT manually run git cherry-pick sequences.</rule> +<rule id="unexpected-exit">Unexpected exit codes → STOP immediately and report to user.</rule> +</rules> + +<scripts description="Companion scripts and their expected exit codes."> + +| Script | Purpose | +|--------|---------| +| `staging-cherry-pick.sh` | Cherry-pick PR commits onto staging | + +| Script | Exit 0 | Exit 1 | Exit 2 | Exit 3 | +|--------|--------|--------|--------|--------| +| `staging-cherry-pick.sh` | All cherry-picks succeeded | Error (code conflict, git failure) | Auth needed | CHANGELOG conflict (agent resolves) | + +**Any exit code not in this table = STOP immediately and report to user.** +</scripts> + +<step id="1" name="Identify Staging PRs"> +Determine which merged PRs have CHANGELOG entries in the `## X.Y.Z (staging)` section. These are the PRs that need cherry-picking. + +**When called from pr-land:** The caller provides the list of merged PRs and their merge SHAs. + +**When called standalone:** Read the CHANGELOG diff for each PR to check if entries target the staging section. +</step> + +<step id="2" name="Cherry-Pick"> +```bash +echo '[{"repo":"...","prNumber":123,"mergeSha":"abc123"}]' | ~/.cursor/skills/staging-cherry-pick/scripts/staging-cherry-pick.sh +``` + +The script handles: +1. Fetching the merge commit SHA (from input or GitHub API) +2. Extracting individual commits from the merge +3. Checking out and pulling the staging branch +4. Cherry-picking each commit in order (oldest first) +5. Detecting and classifying conflicts + +**On exit 3 (CHANGELOG conflict):** +1. Read the CHANGELOG with conflict markers +2. Resolve semantically: keep existing staging entries, add the new entry +3. `git add CHANGELOG.md && GIT_EDITOR=true git cherry-pick --continue` +4. Re-run the script for any remaining PRs +</step> + +<step id="3" name="Push"> +After all cherry-picks succeed, show the user what will be pushed: + +``` +Cherry-picked to staging: + ✓ <repo>#<number> (<N> commits) + ✓ <repo>#<number> (<N> commits) + +Push to origin/staging? [y/N] +``` + +If confirmed: +```bash +git push origin staging +``` +</step> + +<step id="4" name="Restore Branch"> +Return to the branch the user was on before cherry-picking: +```bash +git checkout <original-branch> +``` +</step> + +<edge-cases> +<case name="Empty cherry-pick">If a commit is already on staging (empty cherry-pick), the script skips it automatically.</case> +<case name="Code conflict">Script aborts the cherry-pick and reports the conflicting files. Agent STOPs and reports to user.</case> +<case name="Multiple PRs">Script processes PRs sequentially. Staging is checked out once and reused across PRs.</case> +<case name="No merge SHA provided">Script queries the GitHub API for the merge commit SHA.</case> +</edge-cases> diff --git a/.cursor/skills/staging-cherry-pick/scripts/staging-cherry-pick.sh b/.cursor/skills/staging-cherry-pick/scripts/staging-cherry-pick.sh new file mode 100755 index 0000000..4552557 --- /dev/null +++ b/.cursor/skills/staging-cherry-pick/scripts/staging-cherry-pick.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env node +// staging-cherry-pick.sh +// Cherry-picks individual commits from merged PRs onto the staging branch. +// +// Usage: echo '[{"repo":"edge-react-gui","prNumber":123,"mergeSha":"abc123"}]' | ./staging-cherry-pick.sh +// +// For each PR: +// 1. Determine merge commit SHA (from input or by querying GitHub) +// 2. Extract non-merge commits: git log <merge>^1..<merge>^2 +// 3. Pull latest staging branch +// 4. Cherry-pick each commit individually (oldest first) +// 5. Report results +// +// Exit codes: +// 0 = All cherry-picks succeeded +// 1 = Error (auth, git failure, etc.) +// 3 = Cherry-pick conflict (agent must resolve) + +const { spawnSync } = require("child_process"); +const path = require("path"); +const { getRepoDir, runGit, ghApi } = require( + path.join(__dirname, "..", "..", "pr-land", "scripts", "edge-repo.js") +); + +// Verify gh auth +const authCheck = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }); +if (authCheck.status !== 0) { + console.error("PROMPT_GH_AUTH"); + process.exit(2); +} + +function getMergeCommit(repo, prNumber) { + const data = ghApi(`repos/EdgeApp/${repo}/pulls/${prNumber}`); + if (!data.merged) { + return { error: `PR #${prNumber} is not merged` }; + } + return { sha: data.merge_commit_sha }; +} + +function getCommitsToCherry(repoDir, mergeSha) { + // Extract non-merge commits from the PR: merge^1..merge^2 + // This gives us the branch commits in chronological order + const result = runGit( + ["log", "--reverse", "--format=%H %s", `${mergeSha}^1..${mergeSha}^2`], + repoDir, + { allowFailure: true } + ); + + if (!result.success || !result.stdout) { + return []; + } + + return result.stdout.split("\n").filter(Boolean).map((line) => { + const spaceIdx = line.indexOf(" "); + return { + sha: line.slice(0, spaceIdx), + message: line.slice(spaceIdx + 1), + }; + }); +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + const prs = JSON.parse(input); + const results = { + cherryPicked: [], + skipped: [], + conflict: null, + status: "complete", + }; + + let exitCode = 0; + let stagingCheckedOut = false; + let currentRepoDir = null; + + for (let i = 0; i < prs.length; i++) { + const { repo, prNumber, mergeSha: inputMergeSha } = prs[i]; + const repoDir = getRepoDir(repo); + currentRepoDir = repoDir; + + console.error( + `\n=== Cherry-picking ${repo}#${prNumber} to staging [${i + 1}/${prs.length}] ===` + ); + + // Get merge commit SHA + let mergeSha = inputMergeSha; + if (!mergeSha) { + console.error("Fetching merge commit SHA..."); + const mergeInfo = getMergeCommit(repo, prNumber); + if (mergeInfo.error) { + console.error(`⚠ ${mergeInfo.error} — skipping`); + results.skipped.push({ repo, prNumber, reason: mergeInfo.error }); + continue; + } + mergeSha = mergeInfo.sha; + } + console.error(`Merge commit: ${mergeSha.slice(0, 10)}`); + + // Fetch latest + runGit(["fetch", "origin"], repoDir); + + // Get commits to cherry-pick + const commits = getCommitsToCherry(repoDir, mergeSha); + if (commits.length === 0) { + console.error("⚠ No commits found to cherry-pick — skipping"); + results.skipped.push({ repo, prNumber, reason: "No commits found" }); + continue; + } + + console.error( + `Found ${commits.length} commit(s):\n${commits.map((c) => ` ${c.sha.slice(0, 10)} ${c.message}`).join("\n")}` + ); + + // Checkout staging (only once per repo) + if (!stagingCheckedOut) { + console.error("Checking out staging branch..."); + const checkoutResult = runGit(["checkout", "staging"], repoDir, { + allowFailure: true, + }); + if (!checkoutResult.success) { + // Try tracking remote + const trackResult = runGit( + ["checkout", "-b", "staging", "origin/staging"], + repoDir, + { allowFailure: true } + ); + if (!trackResult.success) { + console.error(`✗ Cannot checkout staging: ${trackResult.stderr}`); + results.skipped.push({ + repo, + prNumber, + reason: "Cannot checkout staging branch", + }); + continue; + } + } + + console.error("Pulling latest staging..."); + const pullResult = runGit(["pull", "origin", "staging"], repoDir, { + allowFailure: true, + }); + if (!pullResult.success) { + // Reset to remote if pull fails (e.g. diverged) + runGit(["reset", "--hard", "origin/staging"], repoDir); + } + stagingCheckedOut = true; + } + + // Cherry-pick each commit individually + for (let j = 0; j < commits.length; j++) { + const commit = commits[j]; + console.error( + `Cherry-picking [${j + 1}/${commits.length}]: ${commit.sha.slice(0, 10)} ${commit.message}` + ); + + const cpResult = runGit(["cherry-pick", commit.sha], repoDir, { + allowFailure: true, + }); + + if (!cpResult.success) { + // Check if it's a conflict + const statusResult = runGit(["status", "--porcelain"], repoDir, { + allowFailure: true, + }); + const conflictFiles = statusResult.stdout + .split("\n") + .filter((l) => l.startsWith("UU ") || l.startsWith("AA ")) + .map((l) => l.slice(3).trim()); + + if (conflictFiles.length > 0) { + const isChangelogOnly = + conflictFiles.length > 0 && + conflictFiles.every( + (f) => f === "CHANGELOG.md" || f.endsWith("/CHANGELOG.md") + ); + + if (isChangelogOnly) { + console.error( + "\n=== CHANGELOG conflict — agent resolution needed ===" + ); + console.error(`Files: ${conflictFiles.join(", ")}`); + console.error( + `Commit: ${commit.sha.slice(0, 10)} ${commit.message}` + ); + console.error("\nTo resolve:"); + console.error( + ` 1. Read ${path.join(repoDir, "CHANGELOG.md")} with conflict markers` + ); + console.error( + " 2. Resolve semantically (upstream/staging entries first, then ours)" + ); + console.error( + " 3. git add CHANGELOG.md && git cherry-pick --continue" + ); + console.error(" 4. Re-run staging-cherry-pick for remaining PRs"); + + results.conflict = { + repo, + prNumber, + repoDir, + commit: commit.sha, + commitMessage: commit.message, + conflictFiles, + type: "changelog", + remainingCommits: commits.slice(j + 1), + remainingPRs: prs.slice(i + 1), + }; + results.status = "changelog_conflict"; + exitCode = 3; + } else { + console.error(`✗ Code conflict in: ${conflictFiles.join(", ")}`); + console.error("Aborting cherry-pick..."); + runGit(["cherry-pick", "--abort"], repoDir, { + allowFailure: true, + }); + + results.conflict = { + repo, + prNumber, + repoDir, + commit: commit.sha, + commitMessage: commit.message, + conflictFiles, + type: "code", + }; + results.status = "code_conflict"; + exitCode = 1; + } + break; + } + + // Not a conflict — some other failure + console.error(`✗ Cherry-pick failed: ${cpResult.stderr}`); + + // Check if it's an empty commit (already applied) + if ( + cpResult.stderr.includes("empty") || + cpResult.stdout.includes("empty") + ) { + console.error(" (Commit already applied — skipping)"); + runGit(["cherry-pick", "--skip"], repoDir, { allowFailure: true }); + continue; + } + + runGit(["cherry-pick", "--abort"], repoDir, { allowFailure: true }); + results.skipped.push({ + repo, + prNumber, + reason: `Cherry-pick failed: ${cpResult.stderr}`, + }); + break; + } + + console.error(` ✓ Applied`); + } + + if (exitCode !== 0) break; + + // If we got here without conflict, all commits cherry-picked + if (!results.conflict) { + results.cherryPicked.push({ + repo, + prNumber, + mergeSha, + commits: commits.map((c) => ({ + sha: c.sha.slice(0, 10), + message: c.message, + })), + }); + } + } + + // Summary + console.error("\n=== Cherry-Pick Summary ==="); + if (results.cherryPicked.length > 0) { + console.error(`Cherry-picked (${results.cherryPicked.length}):`); + for (const r of results.cherryPicked) { + console.error( + ` ✓ ${r.repo}#${r.prNumber} (${r.commits.length} commit(s))` + ); + } + } + if (results.skipped.length > 0) { + console.error(`Skipped (${results.skipped.length}):`); + for (const r of results.skipped) { + console.error(` ⚠ ${r.repo}#${r.prNumber}: ${r.reason}`); + } + } + if (results.conflict) { + console.error( + `\nConflict: ${results.conflict.repo}#${results.conflict.prNumber} (${results.conflict.type})` + ); + } + + console.log(JSON.stringify(results, null, 2)); + process.exit(exitCode); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.cursor/skills/standup/SKILL.md b/.cursor/skills/standup/SKILL.md new file mode 100644 index 0000000..da5b022 --- /dev/null +++ b/.cursor/skills/standup/SKILL.md @@ -0,0 +1,255 @@ +--- +name: standup +description: Generate a daily standup document from Asana and GitHub activity, upload to a persistent private gist. Use when the user wants to create standup notes. +compatibility: Requires gh, jq. ASANA_TOKEN for Asana integration. +metadata: + author: j0ntz +--- + +<goal>Generate a daily standup document from Asana + GitHub activity, upload to a single persistent private gist.</goal> + +<rules> +<rule id="links-as-titles">Task/PR names are the clickable link: `[{name}]({url})`. Never add a separate URL.</rule> +<rule id="no-reassign-in-accomplishments">Reassignment actions belong ONLY in the Handoffs section. Never list them under Accomplishments.</rule> +<rule id="single-gist">All standup 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.</rule> +<rule id="cleanup">Delete the local file after successful gist upload.</rule> +<rule id="script-timeout">Set `block_until_ms: 120000` for each companion script.</rule> +</rules> + +<step id="1" name="Fetch activity from both sources"> +Run both companion scripts **in parallel** (two Shell tool calls in one message): + +```bash +scripts/asana-standup.sh +``` +```bash +scripts/github-pr-activity.sh +``` + +If the user supplies a specific date, pass `--date YYYY-MM-DD` to both. + +Capture stdout (JSON) and stderr (diagnostics) separately for each. +</step> + +<step id="2" name="Merge and deduplicate"> +Parse both JSON outputs. The GitHub JSON has `addressed` and `reviewed` arrays. Each entry may have an `asana_gid` field extracted from the PR body. Use it to link GitHub activity to Asana tasks: + +- **GitHub `addressed` + matching Asana task** (same `asana_gid`): Add an action `{"type": "addressed_review_comments", "detail": ""}` to the matching Asana task's `actions` array. Do NOT create a separate entry. +- **GitHub `addressed` + no Asana match**: Create a new task-like entry with `actions: [{"type": "addressed_review_comments", "detail": ""}]`, using the PR title, URL, and repo as the project. +- **GitHub `reviewed` + matching Asana task**: Add an action `{"type": "reviewed_pr", "detail": "{review_state}"}` to the matching Asana task's `actions` array. +- **GitHub `reviewed` + no Asana match**: Create a new task-like entry with `actions: [{"type": "reviewed_pr", "detail": "{review_state}"}]`, using the PR title, URL, and repo as the project. +</step> + +<step id="3" name="Generate markdown"> +Build the markdown file with EXACTLY the structure below. Every heading, bullet, and blank line matters. + +<sub-step name="3a: Header"> +Line 1 of the file. Use the TARGET date (from the Asana JSON `date` field), not today. + +``` +# HUDL Notes — {full_weekday_name} {full_month_name} {day}, {year} +``` + +Example: `# HUDL Notes — Monday February 17, 2026` +</sub-step> + +<sub-step name="3b: Accomplishments"> +``` +## Accomplishments {day_label} +``` + +Use `day_label` from the Asana JSON (either `"yesterday"` or `"Friday"`). + +Categorize each task/PR into exactly ONE subsection based on its PRIMARY action. Determine the primary action using this priority (highest first): + +1. `prd` → goes in **PR'd** +2. `addressed_pr_comments` OR `addressed_review_comments` → goes in **Addressed PR Comments** +3. `reviewed_pr` → goes in **Reviewed PRs** +4. anything else (`commented`, `completed`, `moved`, `added to project`) → goes in **General** + +A task appears in only ONE subsection — the highest-priority one that matches any of its actions. + +**Subsection: PR'd** — include only if at least one task qualifies. + +``` +### PR'd + +- [{task_name}]({task_url}) ({project_name}) +``` + +One bullet per task. No action text — the heading says it. Append `({project})` only if non-empty. + +If the task ALSO has secondary actions (like `commented`), append them after ` — `: + +``` +- [{task_name}]({task_url}) ({project_name}) — Commented: "first 150 chars" +``` + +**Subsection: Addressed PR Comments** — include only if at least one task qualifies. + +``` +### Addressed PR Comments + +- [{task_name}]({task_url}) ({project_name}) +``` + +Same format as PR'd. Append secondary actions after ` — ` if present. + +**Subsection: Reviewed PRs** — include only if at least one task 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 task qualifies. + +``` +### General + +- [{task_name}]({task_url}) ({project_name}) — Commented: "first 150 chars" +``` + +Format each action type: +- `commented` → `Commented: "{detail}"` +- `completed` → `Completed` +- `moved` → `Moved: {detail}` +- `added to project` → `Added to {detail}` + +If a task has multiple actions in General, join with `; `: + +``` +- [{task_name}]({task_url}) ({project_name}) — Commented: "detail"; Completed +``` + +**Omit any subsection that would have zero bullets.** +</sub-step> + +<sub-step name="3c: Goals Today"> +``` +## Goals Today +``` + +Scan the Asana `tasks` array for entries where `status` equals `"Publish Needed"`. For each, write: + +``` +- Publish [{task_name}]({task_url}) +``` + +After all publish items (or immediately if there are none), add one blank bullet for the user to fill in: + +``` +- +``` +</sub-step> + +<sub-step name="3d: Handoffs"> +``` +## Handoffs +``` + +Group handoff entries by type, then by person. + +**Reassignments** — group by the `detail` field (assignee name). Write one `### {assignee_name}` heading per person, then list all tasks reassigned to them: + +``` +### William Swanson + +- [{task_name}]({task_url}) + +### Matthew Piche + +- [{task_name}]({task_url}) +``` + +**Blockers** — if any handoff has `kind=blocker`, add a Blocked subsection: + +``` +### Blocked + +- [{task_name}]({task_url}) — {detail} +``` + +If the handoffs array is completely empty, write: + +``` +None +``` +</sub-step> + +<sub-step name="3e: Debug"> +Add a horizontal rule, then a collapsed details block. + +``` +--- + +<details><summary>Debug: {N} active tasks</summary> + +``` + +Where `{N}` is the length of the `active_tasks` array from the Asana JSON. + +**Non-VN tasks**: For each entry in `active_tasks` where `status` is NOT `"Verification Needed"`, write: + +``` +- [{name}]({url}) — {status} ({role}) +``` + +**VN summary**: Count entries where `status` is `"Verification Needed"`. Group by `role` and write ONE summary line: + +``` +- {total} tasks in Verification Needed ({M} assignee, {X} implementor, {Y} reviewer) +``` + +Omit role counts that are zero. Example: `- 68 tasks in Verification Needed (5 assignee, 48 implementor, 15 reviewer)` + +End with the search stats and close the details tag: + +``` + +*Searched {candidate_count} candidates, matched {task_count}* + +</details> +``` + +`candidate_count` and `task_count` come from the Asana JSON. +</sub-step> +</step> + +<step id="4" name="Upload to gist and clean up"> +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}` +</step> diff --git a/.cursor/skills/standup/scripts/asana-standup.sh b/.cursor/skills/standup/scripts/asana-standup.sh new file mode 100755 index 0000000..6a4830a --- /dev/null +++ b/.cursor/skills/standup/scripts/asana-standup.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# asana-standup.sh — Fetch Asana tasks the user interacted with on a given day. +# Outputs structured JSON for standup document generation. +# +# Usage: +# asana-standup.sh [--date YYYY-MM-DD] +# +# If --date is omitted, defaults to yesterday (or Friday if today is Monday). +# +# Requires env var: ASANA_TOKEN +# +# Output: JSON { date, day_label, user_name, task_count, candidate_count, +# tasks: [...], handoffs: [...], active_tasks: [...] } +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +TARGET_DATE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --date) TARGET_DATE="$2"; shift 2 ;; + *) echo "Unknown: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "${ASANA_TOKEN:-}" ]]; then + echo "Error: ASANA_TOKEN not set" >&2 + exit 1 +fi + +USER_INFO=$("$SCRIPT_DIR/../../asana-whoami.sh" --name) +USER_GID=$(echo "$USER_INFO" | awk '{print $1}') +USER_NAME=$(echo "$USER_INFO" | cut -d' ' -f2-) + +CACHE_KEY=$(echo "$ASANA_TOKEN" | shasum -a 256 | cut -c1-16) +WORKSPACE_CACHE="/tmp/asana-workspace-$CACHE_KEY.txt" +if [[ -f "$WORKSPACE_CACHE" ]]; then + WORKSPACE_GID=$(cat "$WORKSPACE_CACHE") +else + WORKSPACE_GID=$(curl -s "https://app.asana.com/api/1.0/users/me?opt_fields=workspaces" \ + -H "Authorization: Bearer $ASANA_TOKEN" | \ + python3 -c "import sys,json; print(json.load(sys.stdin)['data']['workspaces'][0]['gid'])") + echo "$WORKSPACE_GID" > "$WORKSPACE_CACHE" +fi + +export ASANA_TOKEN USER_GID USER_NAME WORKSPACE_GID TARGET_DATE + +python3 - << 'PYEOF' +import json, os, re, sys, urllib.request, urllib.parse, urllib.error +from datetime import date, timedelta + +API = "https://app.asana.com/api/1.0" +TOKEN = os.environ["ASANA_TOKEN"] +USER_GID = os.environ["USER_GID"] +USER_NAME = os.environ["USER_NAME"] +WORKSPACE = os.environ["WORKSPACE_GID"] +TARGET_DATE_STR = os.environ.get("TARGET_DATE", "") + +STATUS_FIELD_GID = "1190660107346181" + + +def api_get(path, params=None): + url = f"{API}{path}" + if params: + url += "?" + urllib.parse.urlencode(params, doseq=True) + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {TOKEN}"}) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode() if e.fp else "" + print(f"API_ERROR: {e.code} {path} {body[:200]}", file=sys.stderr) + return {"data": []} + + +# --- Date calculation --- +if TARGET_DATE_STR: + target = date.fromisoformat(TARGET_DATE_STR) + day_label = target.strftime("%A") +else: + today = date.today() + if today.weekday() == 0: # Monday + target = today - timedelta(days=3) + day_label = "Friday" + else: + target = today - timedelta(days=1) + day_label = "yesterday" + TARGET_DATE_STR = target.isoformat() + +# ±1 day buffer handles modified_on drift (task modified yesterday + today +# has modified_on=today, so we need the window slightly wider than exact day). +window_start = (target - timedelta(days=1)).isoformat() +window_end = (target + timedelta(days=1)).isoformat() + +# --- Search queries --- +search_path = f"/workspaces/{WORKSPACE}/tasks/search" +opt = "name,assignee.name,memberships.project.name,custom_fields.gid,custom_fields.display_value,permalink_url" + +search_filters = [ + {"assignee.any": USER_GID}, + {"assigned_by.any": USER_GID}, +] + +tasks_by_gid = {} +for extra in search_filters: + params = { + "modified_on.after": window_start, + "modified_on.before": window_end, + "opt_fields": opt, + "limit": "100", + **extra, + } + result = api_get(search_path, params) + for t in result.get("data", []): + if t["gid"] not in tasks_by_gid: + tasks_by_gid[t["gid"]] = t + +print(f"Found {len(tasks_by_gid)} candidate tasks", file=sys.stderr) + +candidate_count = len(tasks_by_gid) + +# --- Fetch stories per task, categorize user actions --- +output_tasks = [] +handoffs = [] + +for gid, task in tasks_by_gid.items(): + stories = api_get(f"/tasks/{gid}/stories", { + "opt_fields": "resource_subtype,text,created_by.gid,created_by.name,created_at", + "limit": "100", + }) + + story_list = stories.get("data", []) + + # Pass 1: Detect status transitions to "Review Needed" (any author) + pr_action = None + for s in story_list: + created_at = s.get("created_at", "")[:10] + if created_at != TARGET_DATE_STR: + continue + if s.get("resource_subtype") == "comment_added": + continue + text_lc = ((s.get("text") or "")).lower() + if re.search(r"to\s+'?review needed", text_lc): + if re.search(r"from\s+'?changes needed", text_lc): + pr_action = {"type": "addressed_pr_comments", "detail": ""} + else: + pr_action = {"type": "prd", "detail": ""} + + # Pass 2: User's own actions (comments, moves, etc.) + user_actions = [] + for s in story_list: + created_at = s.get("created_at", "")[:10] + if created_at != TARGET_DATE_STR: + continue + if (s.get("created_by") or {}).get("gid") != USER_GID: + continue + + subtype = s.get("resource_subtype", "") + text = (s.get("text") or "").strip() + short = (text[:150] + "...") if len(text) > 150 else text + + if subtype == "comment_added": + user_actions.append({"type": "commented", "detail": short}) + elif subtype == "assigned": + m = re.search(r'assigned to (.+)$', text) + target_name = m.group(1).strip() if m else "" + if target_name.lower() == "you" or target_name == USER_NAME: + continue + if not target_name: + target_name = (task.get("assignee") or {}).get("name", "someone") + handoffs.append({ + "gid": gid, + "name": task.get("name", ""), + "url": task.get("permalink_url", f"https://app.asana.com/0/0/{gid}/f"), + "kind": "reassigned", + "detail": target_name, + }) + elif subtype == "marked_complete": + user_actions.append({"type": "completed", "detail": short}) + elif subtype == "section_changed": + user_actions.append({"type": "moved", "detail": short}) + elif subtype == "added_to_project": + user_actions.append({"type": "added to project", "detail": short}) + + if pr_action: + user_actions.append(pr_action) + + if not user_actions: + continue + + project = "" + for m in task.get("memberships", []): + p = (m.get("project") or {}).get("name", "") + if p: + project = p + break + + status = "" + for f in task.get("custom_fields", []): + if f.get("gid") == STATUS_FIELD_GID: + status = f.get("display_value") or "" + break + + output_tasks.append({ + "gid": gid, + "name": task.get("name", ""), + "url": task.get("permalink_url", f"https://app.asana.com/0/0/{gid}/f"), + "project": project, + "status": status, + "assignee": (task.get("assignee") or {}).get("name", ""), + "actions": user_actions, + }) + + if "block" in status.lower(): + handoffs.append({ + "gid": gid, + "name": task.get("name", ""), + "url": task.get("permalink_url", f"https://app.asana.com/0/0/{gid}/f"), + "kind": "blocker", + "detail": f"Status: {status}", + }) + +# --- Active tasks where user is involved (for debug) --- +ACTIVE_STATUSES = {"Started", "Review Needed", "Changes Needed", "Publish Needed", "Verification Needed"} +IMPLEMENTOR_FIELD = "1203334386796983" +REVIEWER_FIELD = "1203334388004673" + +active_window_start = (target - timedelta(days=90)).isoformat() +active_result = api_get(search_path, { + "followers.any": USER_GID, + f"custom_fields.{STATUS_FIELD_GID}.is_set": "true", + "modified_on.after": active_window_start, + "opt_fields": "name,assignee.name,assignee.gid,custom_fields.gid,custom_fields.display_value,custom_fields.people_value.gid,permalink_url", + "limit": "100", +}) + +active_tasks = [] +seen_gids = set() +for t in active_result.get("data", []): + if t["gid"] in seen_gids: + continue + seen_gids.add(t["gid"]) + status_name = "" + is_implementor = False + is_reviewer = False + for f in t.get("custom_fields", []): + fgid = f.get("gid", "") + if fgid == STATUS_FIELD_GID: + status_name = f.get("display_value") or "" + elif fgid == IMPLEMENTOR_FIELD: + for p in (f.get("people_value") or []): + if (p or {}).get("gid") == USER_GID: + is_implementor = True + elif fgid == REVIEWER_FIELD: + for p in (f.get("people_value") or []): + if (p or {}).get("gid") == USER_GID: + is_reviewer = True + if status_name not in ACTIVE_STATUSES: + continue + assignee_gid = ((t.get("assignee") or {}).get("gid", "")) + if assignee_gid != USER_GID and not is_implementor and not is_reviewer: + continue + role = "assignee" if assignee_gid == USER_GID else ("implementor" if is_implementor else "reviewer") + active_tasks.append({ + "name": t.get("name", ""), + "url": t.get("permalink_url", f"https://app.asana.com/0/0/{t['gid']}/f"), + "status": status_name, + "assignee": (t.get("assignee") or {}).get("name", ""), + "role": role, + }) + +print(json.dumps({ + "date": TARGET_DATE_STR, + "day_label": day_label, + "user_name": USER_NAME, + "task_count": len(output_tasks), + "candidate_count": candidate_count, + "tasks": output_tasks, + "handoffs": handoffs, + "active_tasks": active_tasks, +}, indent=2)) +PYEOF diff --git a/.cursor/skills/standup/scripts/github-pr-activity.sh b/.cursor/skills/standup/scripts/github-pr-activity.sh new file mode 100755 index 0000000..8cd2f15 --- /dev/null +++ b/.cursor/skills/standup/scripts/github-pr-activity.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# github-pr-activity.sh — Fetch GitHub PR activity for a given day. +# Detects two categories: +# 1. Addressed review comments: user's own PRs where human reviews existed +# and the user pushed commits on the target date +# 2. Submitted reviews: PRs authored by others that the user reviewed on +# the target date +# +# Usage: +# github-pr-activity.sh [--date YYYY-MM-DD] +# +# Requires: gh CLI authenticated +# +# Output: JSON { date, username, addressed: [...], reviewed: [...] } +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') + +export TARGET_DATE USERNAME + +python3 - << 'PYEOF' +import json, os, re, subprocess, sys +from datetime import date, timedelta + +USERNAME = os.environ["USERNAME"] +TARGET_DATE_STR = os.environ.get("TARGET_DATE", "") + +if TARGET_DATE_STR: + target = date.fromisoformat(TARGET_DATE_STR) +else: + today = date.today() + if today.weekday() == 0: + target = today - timedelta(days=3) + else: + target = today - timedelta(days=1) + TARGET_DATE_STR = target.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 + + +# --- Query 1: User's own PRs updated recently (check for addressed comments) --- +QUERY_AUTHORED = """ +query($search: String!) { + search(query: $search, type: ISSUE, first: 50) { + nodes { + ... on PullRequest { + number + title + url + body + repository { nameWithOwner } + reviews(last: 30) { + nodes { + author { login } + state + submittedAt + } + } + commits(last: 30) { + nodes { + commit { + committedDate + author { user { login } } + } + } + } + } + } + } +} +""" + +search_authored = f"is:pr author:{USERNAME} updated:>={TARGET_DATE_STR} sort:updated" +authored_raw = gh_graphql(QUERY_AUTHORED, {"search": search_authored}) + +addressed = [] +for node in authored_raw.get("data", {}).get("search", {}).get("nodes", []): + if not node or "number" not in node: + continue + + has_human_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 + if r.get("state") in ("CHANGES_REQUESTED", "COMMENTED"): + has_human_review = True + break + + if not has_human_review: + continue + + has_commit_on_date = False + for c in (node.get("commits") or {}).get("nodes", []): + commit = (c or {}).get("commit", {}) + committed = (commit.get("committedDate") or "")[:10] + commit_user = ((commit.get("author") or {}).get("user") or {}).get("login", "") + if committed == TARGET_DATE_STR and commit_user == USERNAME: + has_commit_on_date = True + break + + if has_commit_on_date: + addressed.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")), + }) + +# --- Query 2: PRs reviewed by user (not authored by user) --- +QUERY_REVIEWED = """ +query($search: String!) { + search(query: $search, type: ISSUE, first: 50) { + nodes { + ... on PullRequest { + number + title + url + body + repository { nameWithOwner } + reviews(last: 30) { + nodes { + author { login } + state + submittedAt + } + } + } + } + } +} +""" + +search_reviewed = f"is:pr reviewed-by:{USERNAME} -author:{USERNAME} updated:>={TARGET_DATE_STR} sort:updated" +reviewed_raw = gh_graphql(QUERY_REVIEWED, {"search": search_reviewed}) + +reviewed = [] +for node in reviewed_raw.get("data", {}).get("search", {}).get("nodes", []): + if not node or "number" not in node: + continue + + 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 submitted == TARGET_DATE_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, + }) + +print(json.dumps({ + "date": TARGET_DATE_STR, + "username": USERNAME, + "addressed": addressed, + "reviewed": reviewed, +}, indent=2)) +PYEOF diff --git a/.cursor/skills/task-review/SKILL.md b/.cursor/skills/task-review/SKILL.md new file mode 100644 index 0000000..d640f38 --- /dev/null +++ b/.cursor/skills/task-review/SKILL.md @@ -0,0 +1,136 @@ +--- +name: task-review +description: Fetch context from an Asana task, analyze it, present a summary, and determine the target repo. Use when the user provides an Asana task link for review. +compatibility: Requires jq. ASANA_TOKEN for Asana integration. +metadata: + author: j0ntz +--- + +<goal>Fetch context from an Asana task, analyze it, present a summary, and determine the target repo. This is the **single source of truth** for Asana task understanding — both `im.md` and `pr-create.md` delegate here.</goal> + +<rules description="Non-negotiable constraints."> +<rule id="summary-first">Present the task summary to the user BEFORE exploring any code. Code exploration happens after the user has seen the analysis.</rule> +<rule id="script-timeout">The `asana-get-context.sh` script can take up to 90s (PDF conversion is slow). Always set `block_until_ms: 120000` when invoking it.</rule> +</rules> + +<when-this-runs> +- Automatically as the first step of `im.md` when an Asana task link is provided +- Automatically as Step 1 of `pr-create.md` when an Asana task link is provided +- Can also be invoked standalone: `/task-review https://app.asana.com/1/.../task/<task_gid>` +</when-this-runs> + +<step id="1" name="Fetch task context and attachments"> +Extract the `task_gid` (the final numeric ID in the URL) and run: + +```bash +~/.cursor/skills/asana-get-context.sh <task_gid> +``` + +This fetches task metadata, comments, and **automatically downloads and processes attachments** to `/tmp/asana-task-<task_gid>/`: + +- **Text files** (`.md`, `.txt`, `.json`, `.csv`, `.log`, `.yaml`, `.yml`): Downloaded directly — read them. +- **PDFs**: Text-extracted first (`PDF_TEXT:` output). If the PDF is image-based, converted to page images (`PDF_PAGES:` output). +- **ZIPs**: Unpacked recursively (`UNPACKED:` output). Extracted files (including PDFs inside) are then processed by the same handlers. +- **Images** (`.png`, `.jpg`, `.gif`, `.webp`): Downloaded directly — read them. + +<sub-step name="Reading processed attachments"> +After the script completes, read the processed files based on the output: + +1. **`DOWNLOADED:` paths** — Read any `.txt`, `.md`, `.json`, `.csv`, `.yaml`, `.yml` files listed. +2. **`PDF_TEXT:` paths** — Read the extracted `.txt` file. This is the full text content of the PDF. +3. **`PDF_PAGES:` directories** — Read the page images (`page-01.png`, `page-02.png`, etc.) using the Read tool. For large documents (>20 pages), read the first 10 pages, then skim the rest by reading every 3rd-5th page. +4. **`UNPACKED:` directories** — List contents (`ls -R`), then read relevant files (text files, images, etc.). Skip macOS metadata (`__MACOSX/`, `.DS_Store`). +</sub-step> + +<sub-step name="No attachments case"> +If `ATTACHMENTS: (none)` appears in script output, do **not** probe `/tmp/asana-task-<task_gid>/`. Treat missing `/tmp` paths as expected in this case and continue to Step 2. +</sub-step> +</step> + +<step id="2" name="Determine target repo"> +**Resolve the target repo by examining code, not task text.** Task titles, descriptions, keywords, and attachments are noisy — the same terms (e.g. "swap", "wallet", "send", "plugin") appear across multiple repos, and text hints frequently mislead. Code is the only authoritative signal for *where* a change must land. You will need to explore the code to scope the work anyway — do it up front for repo resolution. + +<sub-step name="Resolution workflow"> +1. **Extract concrete handles from the task text.** Pull out specific file names, function names, scene names, plugin IDs, component names, config keys, URLs/hostnames, error strings, or feature identifiers. Ignore vague domain words. +2. **Grep the candidate repos** for those handles. Start broad across all four repos; narrow to the repo where the matching code actually lives: + - `edge-react-gui` — app UI, scenes, redux, navigation, plugin orchestration + - `edge-exchange-plugins` — swap/exchange plugins + - `edge-currency-accountbased` — account-based currency drivers (EVM, Cosmos, Solana, etc.) + - `edge-core-js` — EdgeAccount, login, core SDK APIs +3. **Confirm by reading the matched code.** A name collision across repos is possible; verify the matching code actually corresponds to the behavior the task describes. +4. **If code examination is inconclusive** (no grep hit, or hits span multiple repos), ASK the user before proceeding. Do not guess from text alone. + +State the resolved repo and cite the files/symbols that pinned it in the Step 3 summary. +</sub-step> + +<sub-step name="Prefix shortcut (when present)"> +If the task title starts with one of these prefixes, the prefix is a deterministic shortcut that skips grep-based resolution. Prefixes are the exception, not the norm: + +| Prefix | Repository | Branch from | +|--------|------|-------------| +| `gui:` | `edge-react-gui` | `develop` | +| `exch:` | `edge-exchange-plugins` | `master` | +| `accb:` | `edge-currency-accountbased` | `master` | +| `core:` | `edge-core-js` | `master` | + +Treat prefix absence as normal. A prefix only skips Step 2's grep work — the Step 3 summary should still cite the specific files/symbols affected (you'll need them for the implementation plan). +</sub-step> + +<sub-step name="Linked PRs short-circuit resolution"> +If the task has an attached or linked GitHub PR, the PR's repo is the authoritative target — no grep needed. +</sub-step> + +<sub-step name="Branch-from defaults"> +Always create feature branches from the "Branch from" column: `edge-react-gui` branches from `develop`; `edge-exchange-plugins`, `edge-currency-accountbased`, and `edge-core-js` branch from `master`. +</sub-step> + +<sub-step name="Cross-repo work — split into Asana subtasks"> +If grep shows the work genuinely spans more than one repo (e.g. a GUI change depending on a new core-js API), a single PR cannot cover it. Before implementing: + +1. **Stop and flag the split to the user.** Name each repo and cite the files/symbols that belong in each. +2. **Create one Asana subtask per repo under the parent**, titled with that repo's prefix (e.g. `gui: ...`, `core: ...`). Subtasks allow multiple PRs to attach to the same parent task. +3. Wait for user confirmation before creating subtasks or proceeding. +4. After the split, treat each subtask as its own task-review target — re-run Step 2 per subtask. + +Do not attempt to satisfy a multi-repo task with a single PR. +</sub-step> +</step> + +<step id="3" name="Summarize understanding"> +Present a concise summary to the user covering: + +1. **What**: One-sentence description of the task/bug in your own words (not just parroting the title) +2. **Why**: The motivation — what problem does this solve or what value does it add? +3. **Target repo**: Which repo (determined in Step 2) +4. **Scope**: What files/areas of the codebase are likely involved? Use the task description, comments, and your knowledge of the repo to estimate. +5. **Approach**: A brief proposed approach (1-3 bullets). If multiple approaches exist, list them with tradeoffs. +6. **Priority**: Note the priority level if set. + +<sub-step name="Surfacing questions"> +After the summary, list any: +- **Ambiguities**: Requirements that are unclear or could be interpreted multiple ways +- **Missing info**: Information needed that isn't in the task +- **Contradictions**: Conflicting statements between the description and comments +- **Decisions needed**: Choices that the user should weigh in on before implementation begins + +If there are no questions, say so explicitly — don't fabricate them. +</sub-step> + +<sub-step name="Using comments and attachments"> +- **Comments**: Read for updated requirements, decisions, or clarifications that may override the original description. Call out any that change scope. +- **Text attachments**: Read downloaded text files for additional context (specs, requirements, analysis). Reference relevant content in the summary. +- **PDF attachments**: Summarize key content from extracted text or page images. For brand guidelines, note colors, logos, naming, and other visual details. +- **ZIP attachments**: Note the contents and any relevant files found inside. For asset packages (logos, icons), describe the available formats and variants. +- **Image attachments**: View and describe the content. Note any UI mockups, designs, or reference screenshots. +</sub-step> +</step> + +<step id="4" name="Wait for confirmation (im.md and standalone only)"> +When invoked from `im.md` or standalone, end with a clear prompt: + +> Does this match your understanding? Any adjustments before I start? + +**Do NOT begin implementation until the user confirms.** + +When invoked from `pr-create.md`, skip this step — the task context is used for repo/branch resolution and PR enrichment, not for implementation planning. +</step> diff --git a/.cursor/skills/verify-repo.sh b/.cursor/skills/verify-repo.sh new file mode 100755 index 0000000..cdd189c --- /dev/null +++ b/.cursor/skills/verify-repo.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env node +// verify-repo.sh +// Runs full verification: CHANGELOG + code verification (prepare, tsc, lint, test) +// Usage: ./verify-repo.sh [repo-dir] [--base <upstream-ref>] [--skip-install] +// If repo-dir not provided, uses current directory +// If --base is provided, lint is scoped to files changed vs that ref +// If --skip-install is provided, skips the initial `yarn` dependency install +// +// Exit codes: +// 0 = All verification passed +// 1 = Code verification failed (prepare/tsc/lint/test) +// 2 = CHANGELOG verification failed + +const { execSync } = require("child_process"); +const { readFileSync, existsSync, writeFileSync } = require("fs"); +const path = require("path"); +const os = require("os"); + +// Bump node heap for large repos (default ~4GB OOMs on big codebases). +// Append rather than overwrite so an outer NODE_OPTIONS wins. Child processes +// (tsc, eslint, jest) inherit this via execSync. +process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --max-old-space-size=8192`.trim(); + +// Parse arguments: positional repo-dir + optional --base <ref> + optional --require-changelog +let repoDir = process.cwd(); +let baseRef = null; +let requireChangelog = false; +let skipInstall = false; +const args = process.argv.slice(2); +for (let i = 0; i < args.length; i++) { + if (args[i] === "--base" && i + 1 < args.length) { + baseRef = args[++i]; + } else if (args[i] === "--require-changelog") { + requireChangelog = true; + } else if (args[i] === "--skip-install") { + skipInstall = true; + } else if (!args[i].startsWith("--")) { + repoDir = args[i]; + } +} + +const packageJsonPath = path.join(repoDir, "package.json"); +const changelogPath = path.join(repoDir, "CHANGELOG.md"); + +function sanitizeLabel(label) { + return label.replace(/[^a-z0-9]/gi, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} + +// 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). +// Removing the shim dir from PATH lets `yarn` (and its nested lifecycle +// npx/npm calls) resolve to the real binaries. Tradeoff: this bypasses Socket's +// supply-chain scanning for yarn commands. Only applied when the repo uses +// yarn; npm runs keep the working socket wrapper. +function shimFreePath() { + return (process.env.PATH || "") + .split(path.delimiter) + .filter((p) => !p.includes(`${path.sep}.agent-shims`)) + .join(path.delimiter); +} + +function runCommandWithLog(command, label, repoDir, extraEnv = {}) { + const safeLabel = sanitizeLabel(label || command); + const logPath = path.join(os.tmpdir(), `verify-${safeLabel}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`); + try { + const output = execSync(command, { + cwd: repoDir, + encoding: "utf8", + stdio: "pipe", + env: { ...process.env, FORCE_COLOR: "1", ...extraEnv }, + }); + writeFileSync(logPath, output); + return { success: true, logPath }; + } catch (error) { + const stdout = error.stdout ? error.stdout.toString() : ""; + const stderr = error.stderr ? error.stderr.toString() : ""; + writeFileSync(logPath, stdout + stderr); + return { success: false, logPath, error }; + } +} + +// Detect repo type +const isGui = repoDir.includes("edge-react-gui"); + +console.log("=== Pre-Merge Verification ==="); +console.log(`Directory: ${repoDir}`); +console.log(""); + +// ============================================ +// CHANGELOG Verification +// ============================================ + +function verifyChangelog() { + if (!existsSync(changelogPath)) { + console.log("⏭ CHANGELOG verification - skipped (no CHANGELOG.md)"); + return { success: true, skipped: true }; + } + + console.log("▶ CHANGELOG verification..."); + + let content; + try { + content = readFileSync(changelogPath, "utf8"); + } catch (e) { + console.error(`✗ Failed to read CHANGELOG.md: ${e.message}`); + return { success: false, error: e.message }; + } + + const lines = content.split("\n"); + const errors = []; + const warnings = []; + let hasStagingSection = false; + let hasUnreleasedSection = false; + + const TYPE_ORDER = ["added", "changed", "deprecated", "fixed", "removed", "security"]; + + function entryType(line) { + const m = line.match(/^- (\w+):/i); + return m ? m[1].toLowerCase() : null; + } + + let currentSection = null; + let sectionEntries = []; + let sectionStartLine = 0; + + function validateSection() { + if (currentSection == null) return; + const isActive = currentSection === "unreleased" || currentSection === "staging"; + if (!isActive) return; + + // Empty section check removed — emptiness is validated per-PR via --require-changelog + + const seen = new Set(); + for (const { text, lineNum } of sectionEntries) { + const normalized = text.replace(/\s+/g, " ").trim(); + if (seen.has(normalized)) { + errors.push(`Line ${lineNum}: Duplicate entry in ${currentSection}: "${text.slice(0, 60)}..."`); + } + seen.add(normalized); + } + + let lastTypeIdx = -1; + for (const { text, lineNum } of sectionEntries) { + const type = entryType(text); + if (type == null) continue; + const idx = TYPE_ORDER.indexOf(type); + if (idx === -1) continue; + if (idx < lastTypeIdx) { + const expected = TYPE_ORDER[lastTypeIdx]; + errors.push(`Line ${lineNum}: "${type}" entry after "${expected}" in ${currentSection} — expected order: ${TYPE_ORDER.join(", ")}`); + } + lastTypeIdx = idx; + } + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + if (line.startsWith("<<<<<<<") || line.startsWith("=======") || + line.startsWith(">>>>>>>") || line.startsWith("|||||||")) { + errors.push(`Line ${lineNum}: Unresolved conflict marker: "${line.slice(0, 40)}..."`); + } + + if (line.match(/^## Unreleased/i)) { + validateSection(); + hasUnreleasedSection = true; + currentSection = "unreleased"; + sectionEntries = []; + sectionStartLine = lineNum; + } else if (line.match(/^## .+\(staging\)/i)) { + validateSection(); + hasStagingSection = true; + currentSection = "staging"; + sectionEntries = []; + sectionStartLine = lineNum; + } else if (line.match(/^## \d+\.\d+\.\d+/)) { + validateSection(); + currentSection = "released"; + sectionEntries = []; + sectionStartLine = lineNum; + } + + if (currentSection != null && line.startsWith("- ")) { + sectionEntries.push({ text: line, lineNum }); + const isActive = currentSection === "unreleased" || currentSection === "staging"; + if (isActive && !line.match(/^- (added|changed|fixed|deprecated|removed|security):/i)) { + warnings.push(`Line ${lineNum}: Entry may not follow "- type: description" format`); + } + } + + if (line.match(/^-\s*$/)) { + errors.push(`Line ${lineNum}: Empty list item found`); + } + if (line.match(/^--/) || line.match(/^- -/)) { + errors.push(`Line ${lineNum}: Malformed list item`); + } + } + validateSection(); + + if (!hasUnreleasedSection && !hasStagingSection) { + errors.push("No '## Unreleased' or staging section found"); + } + + if (errors.length > 0) { + console.error("✗ CHANGELOG verification - FAILED"); + for (const e of errors) { + console.error(` ${e}`); + } + return { success: false, errors }; + } + + if (warnings.length > 0) { + console.log("✓ CHANGELOG verification - passed (with warnings)"); + for (const w of warnings) { + console.log(` ⚠ ${w}`); + } + } else { + console.log("✓ CHANGELOG verification - passed"); + } + + if (hasStagingSection && isGui) { + console.log(" ℹ Note: This repo has a staging section"); + } + + return { success: true, hasStagingSection }; +} + +// ============================================ +// Code Verification +// ============================================ + +function verifyCode() { + if (!existsSync(packageJsonPath)) { + console.log("⏭ Code verification - skipped (no package.json)"); + return { success: true, skipped: true }; + } + + let pkg; + try { + pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")); + } catch (e) { + console.error(`✗ Failed to parse package.json: ${e.message}`); + return { success: false, error: e.message }; + } + + const scripts = pkg.scripts || {}; + const commands = ["prepare", "tsc", "lint", "test"]; + + // Detect package manager: package-lock.json → npm, yarn.lock → yarn, neither → npm. + // If both exist (recently-migrated repos), prefer npm. + const hasNpmLock = existsSync(path.join(repoDir, "package-lock.json")); + const hasYarnLock = existsSync(path.join(repoDir, "yarn.lock")); + const PM = hasNpmLock ? "npm" : hasYarnLock ? "yarn" : "npm"; + // Run-script forms: `npm run <cmd>` vs `yarn <cmd>`. Install: `npm install --no-audit --no-fund` vs `yarn install`. + const installCmd = PM === "npm" ? "npm install --no-audit --no-fund" : "yarn install"; + const runCmd = (cmd) => PM === "npm" ? `npm run ${cmd}` : `yarn ${cmd}`; + // For yarn, run with the socket shims stripped from PATH (see shimFreePath) + // and default NPM_TOKEN to empty so yarn v1 can expand the `${NPM_TOKEN}` + // reference in the hardened ~/.npmrc instead of aborting at startup. + const pmEnv = + PM === "yarn" + ? { PATH: shimFreePath(), NPM_TOKEN: process.env.NPM_TOKEN ?? "" } + : {}; + + console.log(""); + console.log(`Code verification (using ${PM}):`); + + if (!skipInstall) { + console.log(`▶ ${installCmd}...`); + const installResult = runCommandWithLog(installCmd, `${PM}-install`, repoDir, pmEnv); + if (!installResult.success) { + console.error(`✗ ${installCmd} - FAILED (log: ${installResult.logPath})\n`); + return { + success: false, + failedStep: installCmd, + logPath: installResult.logPath, + }; + } + console.log(`✓ ${installCmd} - passed\n`); + } else { + console.log(`⏭ ${PM} install - skipped (--skip-install)`); + } + + for (const cmd of commands) { + if (scripts[cmd] == null) { + console.log(`⏭ ${runCmd(cmd)} - skipped (not in package.json)`); + continue; + } + + // When a base ref is provided, scope lint to only files changed by the branch + if (cmd === "lint" && baseRef != null) { + let changedFiles; + try { + changedFiles = execSync( + `git diff --name-only --diff-filter=ACMR ${baseRef}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx'`, + { cwd: repoDir, encoding: "utf8" } + ).trim(); + } catch (e) { + console.error(`✗ Failed to determine changed files for lint: ${e.message}`); + return { success: false, failedStep: "lint (changed files)" }; + } + + if (changedFiles.length === 0) { + console.log(`⏭ ${runCmd("lint")} - skipped (no lintable files changed)`); + continue; + } + + const fileList = changedFiles.split("\n").map(f => `"${f}"`).join(" "); + const fileCount = changedFiles.split("\n").length; + console.log(`▶ eslint (${fileCount} changed file${fileCount === 1 ? "" : "s"} vs ${baseRef})...`); + const eslintResult = runCommandWithLog( + `npx eslint ${fileList}`, + `eslint-${fileCount}-files`, + repoDir, + pmEnv + ); + if (eslintResult.success) { + console.log(`✓ eslint (changed files) - passed\n`); + continue; + } + console.error(`✗ eslint (changed files) - FAILED (log: ${eslintResult.logPath})\n`); + return { + success: false, + failedStep: "eslint (changed files)", + logPath: eslintResult.logPath, + }; + } + + const fullCmd = runCmd(cmd); + console.log(`▶ ${fullCmd}...`); + const result = runCommandWithLog(fullCmd, `${PM}-${cmd}`, repoDir, pmEnv); + if (result.success) { + console.log(`✓ ${fullCmd} - passed\n`); + continue; + } + console.error(`✗ ${fullCmd} - FAILED (log: ${result.logPath})\n`); + return { + success: false, + failedStep: fullCmd, + logPath: result.logPath, + }; + } + + return { success: true }; +} + +// ============================================ +// Main +// ============================================ + +const changelogResult = verifyChangelog(); +if (!changelogResult.success) { + console.error("\n=== Verification FAILED (CHANGELOG) ==="); + process.exit(2); +} + +if (requireChangelog && baseRef) { + console.log("▶ CHANGELOG entry existence check..."); + try { + const diff = execSync(`git diff --name-only ${baseRef}...HEAD -- CHANGELOG.md`, { + cwd: repoDir, encoding: "utf8" + }).trim(); + if (diff.length === 0) { + console.error("✗ No CHANGELOG.md changes found but PR requires a changelog entry"); + console.error("\n=== Verification FAILED (CHANGELOG) ==="); + process.exit(2); + } + console.log("✓ CHANGELOG entry exists in diff"); + } catch (e) { + console.error(`✗ Failed to check CHANGELOG diff: ${e.message}`); + process.exit(2); + } +} + +const codeResult = verifyCode(); +if (!codeResult.success) { + console.error("\n=== Verification FAILED (Code) ==="); + console.error(`Failed step: ${codeResult.failedStep || codeResult.error}`); + process.exit(1); +} + +console.log("\n=== Verification PASSED ==="); +process.exit(0); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4c292e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +node_modules/ +*.log +.env +.env.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..d226161 --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# edge-dev-agents + +Complete agent-assisted development workflow for Edge repositories: +slash skills, companion scripts, coding standards, review standards, +and meta-tooling for maintaining the workflow itself. + +The distributable Cursor content lives under `.cursor/`. This repo is the +versioned home for those skills, rules, scripts, and docs. + +The canonical local doc lives at `~/.cursor/README.md`. During +`/convention-sync`, that file is mirrored to `edge-dev-agents/README.md`, and +the repo copy should not keep a second `.cursor/README.md`. + +## Installation + +**Fresh machine (one command):** clone this repo and run the bootstrap — it +installs everything (cursor skills/rules, the orchestration system, and shared +memories) into your home dir, seeds `credentials.json` from the example, and +links skills + shared memory: + +```bash +git clone <this-repo> ~/git/edge-dev-agents && cd ~/git/edge-dev-agents && ./bootstrap.sh +# then edit ~/.config/agent-watcher/credentials.json with your real asana_token +``` + +For incremental onboarding instead of the full bootstrap: + +**1. Set the required env var** in your `~/.zshrc`: + +```bash +export GIT_BRANCH_PREFIX=yourname # e.g. jon, paul, sam +``` + +This drives branch naming and PR discovery across the workflow. + +**2. Sync the repo copy into `~/.cursor/`:** + +This repo treats `~/.cursor/` as the canonical working copy. Use +`/convention-sync` to move local changes into `edge-dev-agents`, or run the +companion script directly when onboarding: + +```bash +~/.cursor/skills/convention-sync/scripts/convention-sync.sh \ + --repo-to-user --stage +``` + +**3. Verify prerequisites:** + +- `gh` CLI: `gh auth login` +- `jq`: `brew install jq` +- `ASANA_TOKEN` env var for Asana-backed workflows + +## Table of Contents + +- [Architecture](#architecture) +- [Orchestration & Memory](#orchestration--memory) +- [Skills](#skills-slash-skills) +- [Companion Scripts](#companion-scripts) +- [Shared Modules](#shared-modules) +- [Rules](#rules-mdc-files) +- [Design Principles](#design-principles) + +## Orchestration & Memory + +Beyond cursor skills/rules, this repo mirrors two more portable trees so a +second Mac is reproducible from a single clone + `./bootstrap.sh`: + +- **`agent-watcher/`** — the autonomous agent orchestration system (Asana + watcher daemon + worktree/iOS-sim pool helpers + watchdog). Canonical home is + `~/.config/agent-watcher` (XDG config; `~/.agents` is not an established + standard). Committed: scripts, `*.js`, `asana-config.json`, `README.md`, + `oom-repro/HANDOFF.md`+`scripts/`, and `credentials.example.json`. **Never + committed:** `credentials.json` (secret) and machine-local state + (`pool.json`, `slots.json`, `watchdog-state.json`, `*.state`, `*.log`, + `oom-repro/forensics`, `oom-repro/logs`). +- **`memory-shared/`** + **`bin/link-shared-memory.sh`** — cross-cutting Claude + memory notes that should surface regardless of working directory. Canonical + home `~/.claude/memory-shared`; `link-shared-memory.sh` symlinks them into the + per-project auto-memory dirs (`~/.claude/projects/<project>/memory/`) and + maintains a managed block in each `MEMORY.md`. Claude auto-memory itself is + machine-local (per Anthropic docs) and is intentionally NOT synced — only the + shared store is. The only officially global Claude file is `~/.claude/CLAUDE.md` + (generated here from always-apply rules). + +`/convention-sync` keeps all of the above in sync (home → repo); `bootstrap.sh` +does the reverse (repo → home) on a new machine. + +## Architecture + +```text +edge-dev-agents/ +├── README.md # Synced copy of ~/.cursor/README.md +└── .cursor/ + ├── skills/ # Slash skills (*/SKILL.md) + companion scripts + ├── scripts/ # Shared portability and dashboard scripts + ├── commands/ # Minimal command wrappers + └── rules/ # Coding and workflow standards (.mdc) +``` + +**Separation of concerns:** + +- **Skills** (`SKILL.md`) define workflows, rules, and step ordering. +- **Companion scripts** (`.sh`, `.js`) handle deterministic work like git, + GitHub, Asana, and JSON processing. +- **Rules** (`.mdc`) provide persistent guidance that gets loaded by context. +- **Repo docs** describe the system and how the distribution copy fits + together. + +All GitHub API work uses `gh` CLI. Deterministic git operations should live in +scripts, not be re-described independently across skills. + +## Skills (Slash Skills) + +### Core Implementation + +| Skill | Description | +|------|-------------| +| [`/im`](.cursor/skills/im/SKILL.md) | Implement an Asana task or ad-hoc feature/fix with clean, structured commits | +| [`/one-shot`](.cursor/skills/one-shot/SKILL.md) | Legacy-style task-to-PR flow built from planning, implementation, and PR creation | +| [`/pr-create`](.cursor/skills/pr-create/SKILL.md) | Create a PR from the current branch with repo-aligned title and body | +| [`/dep-pr`](.cursor/skills/dep-pr/SKILL.md) | Create dependent Asana tasks and downstream PR work in another repo | +| [`/changelog`](.cursor/skills/changelog/SKILL.md) | Update CHANGELOG entries using repo conventions | + +### Planning and Context + +| Skill | Description | +|------|-------------| +| [`/asana-plan`](.cursor/skills/asana-plan/SKILL.md) | Build an implementation plan from Asana or ad-hoc requirements | +| [`/task-review`](.cursor/skills/task-review/SKILL.md) | Fetch and summarize Asana task context | +| [`/q`](.cursor/skills/q/SKILL.md) | Answer questions before taking action | + +### Review and Landing + +| Skill | Description | +|------|-------------| +| [`/pr-review`](.cursor/skills/pr-review/SKILL.md) | Review a PR against coding and review standards | +| [`/pr-address`](.cursor/skills/pr-address/SKILL.md) | Address PR feedback with fixup commits, replies, and optional autosquash | +| [`/pr-land`](.cursor/skills/pr-land/SKILL.md) | Land approved PRs, including prepare, merge, publish, GUI dep updates, staging cherry-picks, and Asana updates | +| [`/staging-cherry-pick`](.cursor/skills/staging-cherry-pick/SKILL.md) | Cherry-pick landed staging-targeted commits onto the staging branch | + +### Asana and Utility + +| Skill | Description | +|------|-------------| +| [`/asana-task-update`](.cursor/skills/asana-task-update/SKILL.md) | Generic Asana mutations such as attach PR, assign, unassign, and status updates | +| [`/standup`](.cursor/skills/standup/SKILL.md) | Generate daily standup notes from Asana and GitHub activity | +| [`/chat-audit`](.cursor/skills/chat-audit/SKILL.md) | Audit Cursor chat sessions for waste, drift, and workflow gaps | +| [`/convention-sync`](.cursor/skills/convention-sync/SKILL.md) | Sync `~/.cursor/` with this repo, mirror the local README to repo root, and update PR descriptions from `README.md` | +| [`/author`](.cursor/skills/author/SKILL.md) | Create, revise, and debug skills, scripts, and rules | +| [`/fix-eslint`](.cursor/skills/fix-eslint/SKILL.md) | Apply documented fixes for recurring Edge React GUI ESLint warnings | + +## Companion Scripts + +### PR Operations + +| Script | What it does | API | +|------|-------------|-----| +| [`pr-create.sh`](.cursor/skills/pr-create/scripts/pr-create.sh) | Create a PR for the current branch with standardized body formatting | `gh pr create` | +| [`pr-address.sh`](.cursor/skills/pr-address/scripts/pr-address.sh) | Fetch unresolved feedback, reply, resolve threads, and mark items addressed | `gh api` REST + GraphQL | +| [`github-pr-review.sh`](.cursor/skills/pr-review/scripts/github-pr-review.sh) | Fetch PR context and submit reviews | `gh pr view` + `gh api` | +| [`github-pr-activity.sh`](.cursor/skills/standup/scripts/github-pr-activity.sh) | Gather recent PR activity and CI context for standups | `gh api graphql` | + +### PR Landing Pipeline (`/pr-land`) + +| Script | Phase | What it does | +|------|-------|-------------| +| [`pr-land-discover.sh`](.cursor/skills/pr-land/scripts/pr-land-discover.sh) | Discovery | Find relevant PRs and approval state | +| [`pr-land-comments.sh`](.cursor/skills/pr-land/scripts/pr-land-comments.sh) | Comment check | Detect unresolved inline, review-body, and top-level comments | +| [`git-branch-ops.sh`](.cursor/skills/git-branch-ops.sh) | Shared git ops | Run deterministic autosquash and push operations for multiple skills | +| [`pr-land-prepare.sh`](.cursor/skills/pr-land/scripts/pr-land-prepare.sh) | Prepare | Autosquash, rebase, detect conflicts, and verify | +| [`pr-land-merge.sh`](.cursor/skills/pr-land/scripts/pr-land-merge.sh) | Merge | Rebase again, verify, and merge sequentially | +| [`pr-land-publish.sh`](.cursor/skills/pr-land/scripts/pr-land-publish.sh) | Publish | Version bump, changelog update, commit, and tag | +| [`pr-land-extract-asana-task.sh`](.cursor/skills/pr-land/scripts/pr-land-extract-asana-task.sh) | Asana extraction | Pull task IDs from landed PR metadata | +| [`upgrade-dep.sh`](.cursor/skills/pr-land/scripts/upgrade-dep.sh) | GUI deps | Bump one package on the current branch, run yarn/prepare, commit lockfile updates. Caller must sync `develop` first. | +| [`staging-cherry-pick.sh`](.cursor/skills/staging-cherry-pick/scripts/staging-cherry-pick.sh) | Staging | Cherry-pick staging-qualified commits onto `staging` | +| [`verify-repo.sh`](.cursor/skills/verify-repo.sh) | Verification | Run changelog and code verification | + +### Build, Lint, and Analysis + +| Script | What it does | +|------|-------------| +| [`lint-commit.sh`](.cursor/skills/lint-commit.sh) | Run lint-assisted commits and autosquash fixups through the shared git helper | +| [`lint-warnings.sh`](.cursor/skills/im/scripts/lint-warnings.sh) | Auto-fix and summarize remaining TypeScript/ESLint warnings | +| [`install-deps.sh`](.cursor/skills/install-deps.sh) | Install dependencies and run project prepare steps | +| [`cursor-chat-extract.js`](.cursor/skills/chat-audit/scripts/cursor-chat-extract.js) | Parse Cursor chat exports into structured summaries | + +### Asana and Portability + +| Script | What it does | +|------|-------------| +| [`asana-get-context.sh`](.cursor/skills/asana-get-context.sh) | Fetch task details, comments, subtasks, and attachments | +| [`asana-task-update.sh`](.cursor/skills/asana-task-update/scripts/asana-task-update.sh) | Apply reusable Asana task mutations | +| [`asana-create-dep-task.sh`](.cursor/skills/dep-pr/scripts/asana-create-dep-task.sh) | Create dependent Asana tasks | +| [`asana-whoami.sh`](.cursor/skills/asana-whoami.sh) | Return current Asana identity | +| [`convention-sync.sh`](.cursor/skills/convention-sync/scripts/convention-sync.sh) | Sync `~/.cursor/` and `edge-dev-agents` in either direction, mirroring `~/.cursor/README.md` to repo root `README.md` | +| [`generate-claude-md.sh`](.cursor/skills/convention-sync/scripts/generate-claude-md.sh) | Regenerate `~/.claude/CLAUDE.md` from always-apply rules | +| [`tool-sync.sh`](.cursor/scripts/tool-sync.sh) | Sync Cursor assets into OpenCode and Claude-compatible formats | +| [`port-to-opencode.sh`](.cursor/scripts/port-to-opencode.sh) | Convert Cursor files into OpenCode-friendly mirrors | + +## Shared Modules + +| Module | Purpose | +|------|---------| +| [`edge-repo.js`](.cursor/skills/pr-land/scripts/edge-repo.js) | Shared repo resolution, git wrappers, conflict detection, verification, and `gh` helpers for the `pr-land` pipeline | + +## Rules (`.mdc` files) + +| Rule | Purpose | +|------|---------| +| [`workflow-halt-on-error.mdc`](.cursor/rules/workflow-halt-on-error.mdc) | Stop skill execution on script failures and fix the workflow definition first | +| [`load-standards-by-filetype.mdc`](.cursor/rules/load-standards-by-filetype.mdc) | Load language standards before editing or investigating file-specific issues | +| [`answer-questions-first.mdc`](.cursor/rules/answer-questions-first.mdc) | Answer user questions before editing or mutating state | +| [`no-format-lint.mdc`](.cursor/rules/no-format-lint.mdc) | Avoid manual formatting and formatting-only lint work | +| [`typescript-standards.mdc`](.cursor/rules/typescript-standards.mdc) | TypeScript and React editing standards | +| [`review-standards.mdc`](.cursor/rules/review-standards.mdc) | Review-specific bug patterns and conventions | +| [`eslint-warnings.mdc`](.cursor/rules/eslint-warnings.mdc) | Documented fixes for recurring ESLint warnings | +| [`after_each_chat.mdc`](.cursor/rules/after_each_chat.mdc) | Post-chat automation rule used in the local workflow | + +## Design Principles + +1. **Scripts over duplicated reasoning**. Deterministic git, API, and parsing + work belongs in shared scripts. +2. **`gh` over raw GitHub HTTP calls**. Use the authenticated CLI for GitHub + workflows. +3. **Shared helpers over drift**. Reusable mechanics like autosquash and push + should live in one script and be consumed by multiple skills. +4. **Rules before edits**. Load the relevant standards before editing code or + evaluating lint/type failures. +5. **Workflow fixes before workarounds**. If a skill is wrong, fix the skill or + script instead of patching around it in an ad-hoc way. +6. **Canonical local copy**. `~/.cursor/` is the working source of truth; + `edge-dev-agents` is the distribution and review copy. diff --git a/agent-watcher/NEW-MACHINE-SETUP.md b/agent-watcher/NEW-MACHINE-SETUP.md new file mode 100644 index 0000000..fff46f3 --- /dev/null +++ b/agent-watcher/NEW-MACHINE-SETUP.md @@ -0,0 +1,165 @@ +# New-machine setup — dedicated orchestration Mac + +One-time runbook to stand up the Asana agent-orchestration on a fresh Mac, under a +**different macOS user** than the source machine (`jontz`). The orchestration is +`$HOME`-relative, so the only user-specific things to fix are the launchd plists +(absolute paths) — Claude handles that. + +**Design goal: your part is minimal.** Claude does the transfer, installs, repo clones, +sims, plist rewrite, and validation. You only do the things a script can't: install +Claude, one toggle on the source Mac, paste secrets, paste two sudo blocks, the App +Store / Apple-ID gate, and `gh auth login`. + +## Assumptions +- New Mac is **dedicated** to the orchestration and logged in as the target user + (iOS sims + LaunchAgents need that user to be the active GUI session). +- Same GitHub user (`j0ntz`) and same secrets as the source for now. + +--- + +## YOUR STEPS (the entire manual part) + +**1. Install + log into Claude** (the one thing you named): +```bash +curl -fsSL https://claude.ai/install.sh | bash +``` + +**2. Transfer the bundle** (no SSH / Remote Login needed). On the source mac the migration +bundle is at `~/Downloads/orchestration-migration.tgz` (configs, secrets, skills, plists — +5 MB). **AirDrop** it to the new mac's Downloads, then on the new mac: +```bash +tar xzf ~/Downloads/orchestration-migration.tgz -C ~ && ~/APPLY.sh && rm ~/APPLY.sh +``` +That places `~/.config/agent-watcher` (incl. credentials + this runbook), `~/.cursor/{skills,rules}`, +`~/.claude/{CLAUDE.md,settings.json,memory-shared}`, `env.json`, and the 6 plists, and +regenerates the `~/.claude/skills` symlinks for your user. (Delete the .tgz after — it has secrets.) + +**3. Shell env + secrets — nothing to paste.** Your full `.zprofile` + `.zshrc` + `.zshenv` +(all aliases, PATH, and every secret: `ASANA_TOKEN`/`GITHUB_TOKEN`/`NPM_TOKEN`/`YOLO_*`/etc.) +came in the bundle, and `APPLY.sh` already placed them and rewrote `/Users/jontz`→`/Users/eddy`. +Just open a new terminal (or `source ~/.zprofile`). + +**4. Paste sudo block A — Homebrew** (prompts once for your password, then RETURN): +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +**5. Ensure Xcode is current** — the master sim runs iOS 18, which needs **Xcode 16+**. +Run `xcodebuild -version`; if it's older, upgrade via the App Store (Apple ID + 2FA — the +one unavoidable GUI gate). Already 16+? Skip the upgrade. Either way, paste sudo block B — +Xcode setup (copy-paste exactly as-is): +```bash +sudo xcodebuild -license accept +sudo xcode-select -s /Applications/Xcode.app/Contents/Developer +sudo xcodebuild -runFirstLaunch +``` + +**6. Log into GitHub CLI** (account `j0ntz`, follow the device-code prompts): +```bash +gh auth login +``` + +**7. Launch Claude and paste the PART C prompt.** That's it — Claude does the rest +(repos, sims, plist rewrite, validation), pausing only if a gate needs you. +```bash +claude --dangerously-skip-permissions +``` + +Everything below this line is for Claude, not you. + +--- + +## PART C — the setup prompt (paste into Claude on the new Mac) + +> You are bootstrapping a dedicated Asana agent-orchestration on this fresh Mac, logged +> in as the user (`eddy`) it will run under. Homebrew and Xcode are already installed; the +> bundle has been extracted and `APPLY.sh` has run — so config trees, the full shell env + +> all secrets (`~/.zprofile`+`~/.zshrc`, already path-fixed `/Users/jontz`→`$HOME`), the +> plists (also path-fixed), and the skill symlinks are all in place. `gh` is authed as +> `j0ntz`. Do the following, reporting each step. Pause only if you hit a credential/Apple-ID gate. +> +> 1. **Verify the transferred config** (already placed by the bundle + APPLY.sh — no SSH): +> confirm `~/.config/agent-watcher/credentials.json` (mode 600), `~/git/edge-react-gui/env.json` +> (147 keys, incl. `BREEZ_API_KEY`), `~/.cursor/skills` + `~/.cursor/rules`, the 6 +> `~/Library/LaunchAgents/com.jontz.*.plist`, and that `~/.claude/skills/one-shot/SKILL.md` +> resolves (its symlink should now point into THIS user's `~/.cursor`, not `/Users/jontz`). +> 2. **Toolchain** (no sudo — Homebrew exists): `brew install jq gh tmux watchman cocoapods`. +> Install nvm and `nvm install v24.15.0` (pin it — keeps the launchd node path identical +> except for the username). Install maestro (`curl -Ls https://get.maestro.mobile.dev | bash`). +> Install oh-my-zsh WITHOUT clobbering the placed `.zshrc` (which sources it): +> `RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"` +> Set `git config --global user.name "Jonathan Tzeng"` and `user.email jnthntzng@gmail.com`. +> Verify `jq gh tmux watchman`, `node -v` (== v24.15.0), `maestro -v`. +> 3. **iOS sims.** Install the iOS 18 runtime (`xcodebuild -downloadPlatform iOS`; if it +> needs Apple ID, tell me). Create the master sim matching +> `~/.config/agent-watcher/asana-config.json` → `.watcher.master_sim` (iPhone 16 Pro Max, +> iOS 18). Boot it once to confirm. +> 4. **Repos.** Clone every EdgeApp repo into `~/git` via `gh repo clone EdgeApp/<name>` +> (uses the gh auth, HTTPS, no SSH key needed): +> ```bash +> for r in edge-automations edge-change-server edge-conventions edge-core-js \ +> edge-currency-accountbased edge-currency-bitcoin edge-currency-monero \ +> edge-currency-plugins edge-dev-agents edge-exchange-plugins edge-info-server \ +> edge-login-server edge-login-ui-rn edge-monitor-server edge-plugin-bity \ +> edge-rates-server edge-referral-server edge-reports-server edge-swap-server \ +> edge-zignal fee-metrics react-native-piratechain react-native-zano react-native-zcash; do +> [ -d "$HOME/git/$r" ] || gh repo clone "EdgeApp/$r" "$HOME/git/$r" +> done +> ``` +> Clone `edge-react-gui` too, preserving the `env.json` the bundle already placed +> (clone needs an empty dir, so move it aside and back): +> ```bash +> mv ~/git/edge-react-gui/env.json /tmp/env.json.keep 2>/dev/null || true +> rm -rf ~/git/edge-react-gui +> gh repo clone EdgeApp/edge-react-gui ~/git/edge-react-gui +> mv /tmp/env.json.keep ~/git/edge-react-gui/env.json 2>/dev/null || true +> ``` +> Check out the test branch the Asana project uses (ask me if unsure), confirm `env.json` +> is the real 147-key file, then `npm install` so a populated `node_modules` exists for +> the per-worktree APFS clone. All repos are gh-cloned, so remotes are already HTTPS +> (required — the launchd agents have no ssh-agent). If any local-only test repo isn't on +> GitHub, tell me and I'll have you pull it from the bundle/source separately. +> 5. **launchd plists** — `APPLY.sh` already rewrote `/Users/jontz`→`$HOME` in the plists and +> in `~/.claude/settings.json`, so just VERIFY each `~/Library/LaunchAgents/com.jontz.*.plist`: +> the node path is `$HOME/.nvm/versions/node/v24.15.0/bin/node` and resolves (you pinned +> that version in step 2); `PATH` includes `$(brew --prefix)/bin` (Apple Silicon = +> `/opt/homebrew`; if this is an Intel mac, fix to `/usr/local`). `plutil -lint` each. For a +> dedicated box bootstrap `asana-watcher`, `rc-watchdog`, `runaway-guard`; +> `mem-trace`/`memory-monitor`/`config-watch` are optional — skip unless I ask. +> 6. **Fresh machine state.** `rm -f ~/.config/agent-watcher/{pool.json,pool.lock,watchdog-state.json}` +> and `rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/slots.json"`. Then +> `~/.config/agent-watcher/ensure-sim-pool.sh --size 2` to build a fresh pool. +> 7. **Load + validate.** `launchctl bootstrap gui/$(id -u) <plist>` for the chosen agents. +> Confirm `launchctl list | grep com.jontz`, run one `asana-watcher.js` tick and confirm it +> reaches "guardrail ok" + fetches tasks, and `resume-agent.sh --list` runs clean. Do NOT +> spawn a real task yet. End with a report: installed tools, pool sim UDID(s), loaded +> agents, and anything needing me. + +--- + +## PART D — first real test (run this ON the new mac, after Claude reports green) +The source Mac's watcher is off, so the new mac is the only watcher — no double-pick. +**On the new mac**, drop a Pending task in the Asana test kanban, then watch (these are +the new mac's logs/sessions): +```bash +tail -f /tmp/asana-watcher.out # tick decisions +tail -f /tmp/asana-watcher.err # provisioning (worktree/sim/env.json) +tmux ls # the spawned agent session +``` + +## PART E — later: signed commits + per-machine traceability +Same GitHub identity on both machines = commits aren't attributable by author. The +**signed-commits requirement solves this for free** if you do it per-machine: + +1. On this box, generate a dedicated **passphraseless** SSH signing key (passphraseless so + the launchd agents can sign non-interactively): + `ssh-keygen -t ed25519 -C "orchestrator-<machine>" -N "" -f ~/.ssh/commit-signing` +2. Add `~/.ssh/commit-signing.pub` to GitHub (`j0ntz`) as a **Signing key** (not an Auth key). +3. `git config --global gpg.format ssh`, `git config --global user.signingkey ~/.ssh/commit-signing.pub`, + `git config --global commit.gpgsign true`. + +Commits are then distinguishable by which machine's signing key signed them — so this +*is* the per-machine attribution, and you likely don't need a separate GitHub account. +Until signed commits are required, attribution is implicit (source watcher is off, so +anything new came from this machine). Note: auth/push stays on HTTPS + the gh token; the +signing key is only for signatures. diff --git a/agent-watcher/README.md b/agent-watcher/README.md new file mode 100644 index 0000000..6bebb00 --- /dev/null +++ b/agent-watcher/README.md @@ -0,0 +1,162 @@ +# agent-watcher + +Host-local control plane that turns **Pending** Asana tasks into autonomous +`claude --rc` sessions. As of the parallelization work it runs up to +`MAX_CONCURRENT` sessions at once — each in its own git worktree, on its own +cloned iOS simulator, on its own Metro port — behind a resource guardrail. + +> This directory is **not** git-tracked. The runtime files here *are* the +> deliverable. A snapshot is mirrored to `edge-dev-agents:jon` (`agent-watcher/`) +> purely as a paper trail; the live copy is what runs. + +## Parallel architecture + +``` +launchd ──tick──▶ asana-watcher.js ──┐ + ├─ per picked task: + │ setup-task-workspace.sh → worktree + │ clone-ios-sim.sh → sim clone + │ lib/slots.js allocate → slot + Metro port + │ spawn-test-session.sh → tmux: claude --rc +launchd ──tick──▶ rc-watchdog.js ────┘ + └─ on agent_status=Complete: + kill tmux ▸ delete-ios-sim.sh ▸ cleanup-task-workspace.sh ▸ slots release +``` + +### Slot model + +A **slot** is one parallel lane. Each slot owns: + +| resource | where it comes from | naming | +|------------|---------------------------------------------|----------------------------| +| worktree | `setup-task-workspace.sh` | `~/git/.agent-worktrees/<gid>/<repo>/` on branch `agent/<gid>` | +| iOS sim | `clone-ios-sim.sh` (clones the master) | `agent-sim-<gid>` | +| Metro port | `lib/slots.js` (`metro_base_port + slot_index`) | slot 0 → 8081, slot 1 → 8082, … | + +**Accounting is by LIVE tmux sessions, not Asana state.** The watcher enforces +the cap against the count of real `claude-asana-*` tmux sessions. `slots.json` is +*bookkeeping for teardown* (which sim/worktree to reap), not the cap itself. So a +task that is blocked-or-in-flight in Asana but whose tmux session has died does +**not** hold a slot — its lane is immediately reusable. + +### Lifecycle + +1. **Spawn** (`asana-watcher.js`): pick oldest `(MAX - active)` Pending tasks; for + each, set `Planning`, create worktree, clone sim, allocate slot, start the tmux + session. The session's wrapper bash exports `$AGENT_SIM_UDID` and + `$AGENT_METRO_PORT` so build-and-test and debugger inherit them transparently. +2. **Run**: the spawned `claude` runs `/one-shot --yolo <task-url>` in its worktree. +3. **Reap** (`rc-watchdog.js`): when Asana shows `agent_status=Complete`, kill the + tmux session, delete the cloned sim, remove the worktree + branch, drop the slot. + +## Configuration knobs (`asana-config.json` → `.watcher`) + +| key | default | meaning | +|----------------------------------|--------------------|---------| +| `max_concurrent` | `2` | Max parallel sessions. Env override `AGENT_WATCHER_MAX_CONCURRENT`. | +| `metro_base_port` | `8081` | Slot N → port `metro_base_port + N`. | +| `master_sim.device` / `.runtime` | iPhone 16 Pro Max / iOS 18 | Master sim cloned per slot (holds the test account). | +| `resource_guardrail.max_load_avg`| `12.0` | Skip the tick if 1-min load avg exceeds this. Env override `AGENT_WATCHER_MAX_LOAD_AVG`. | +| `resource_guardrail.min_free_ram_gb` | `8.0` | Skip the tick if free RAM is below this. Env override `AGENT_WATCHER_MIN_FREE_RAM_GB`. | +| `npm_migration_commit` | `4d169a59e2` | Cherry-picked onto each fresh worktree (yarn→npm). | +| `default_repo` | `edge-react-gui` | Repo a spawned agent works in. | +| `worktrees_root` / `repos_root` | `~/git/.agent-worktrees` / `~/git` | Path roots. | + +### Guardrail defaults — why these values + +Tuned for this host: **128 GB RAM, 16 logical cores** (`sysctl hw.memsize` = +137438953472, `hw.logicalcpu` = 16). + +- `max_load_avg = 12.0` ≈ 0.75 × 16 cores. Leaves headroom before the machine is + saturated; an RN/Xcode build is CPU-heavy and spikes load, so we don't want to + pile a third build onto an already-busy box. +- `min_free_ram_gb = 8.0` (raised from the spec's conservative 4.0 floor). Each + lane is `claude` + Metro + an Xcode build + a booted sim clone, which can + transiently want 8–16 GB. Requiring ≥ 8 GB free before spawning another lane + keeps us off the swap cliff even though 128 GB is generous. +- `max_concurrent = 2` — the spec default. The hardware could sustain 3–4, but 2 + is the validated starting point; bump it in config once 2-wide is proven. + +## Inspecting `slots.json` + +```bash +cat ~/.config/agent-watcher/slots.json | jq +node ~/.config/agent-watcher/lib/slots.js list +node ~/.config/agent-watcher/lib/slots.js get --task-gid <gid> +``` + +Shape: + +```json +{ "slots": [ + { "slot_index": 0, "task_gid": "12…", "worktree_path": "…/edge-react-gui", + "sim_udid": "XXXX-…", "metro_port": 8081, "spawned_at": "2026-05-27T…Z" } +] } +``` + +Writes are atomic (tmpfile + rename) and serialized by an exclusive lock +(`slots.json.lock`, stale-stolen after 30 s), so concurrent allocate/release from +the watcher, watchdog, and CLI never corrupt the file. + +## Manual garbage collection + +`gc-worktrees.sh` is **not** on launchd. Run it by hand when you suspect leaked +worktrees (e.g. after a crash/reboot left a session half-cleaned): + +```bash +~/.config/agent-watcher/gc-worktrees.sh --dry-run # report orphans only +~/.config/agent-watcher/gc-worktrees.sh # tear them down +``` + +It scans `~/git/.agent-worktrees/`, asks Asana the status of each task, and reaps +any whose task is `Complete` or has been deleted. In-flight tasks are left alone. + +## Env-var contract (`$AGENT_SIM_UDID`, `$AGENT_METRO_PORT`) + +Watcher-spawned sessions get these exported in the bash that wraps `claude`: + +- `$AGENT_SIM_UDID` — the slot's cloned simulator UDID. `select-ios-sim.sh + --accept-udid` confirms it boots; `ios-rn-build.sh` targets it when `--udid` is + not passed. +- `$AGENT_METRO_PORT` — the slot's Metro port. `ios-rn-build.sh` passes `--port` + to `react-native run-ios` when it differs from 8081; `check-metro.sh` and + `cdp-attach.js` default their `--port`/`--metro` to it. + +Manual runs (no env set) behave exactly as before: sim is resolved by +name/runtime, Metro defaults to 8081. + +## Files + +| file | role | +|------|------| +| `asana-watcher.js` | spawner (multi-pick, cap, guardrail) | +| `rc-watchdog.js` | liveness + completion sweep (slot reaper) | +| `spawn-test-session.sh` | start a `claude --rc` tmux session (slot mode + legacy mode) | +| `setup-task-workspace.sh` / `cleanup-task-workspace.sh` | worktree create / teardown | +| `clone-ios-sim.sh` / `delete-ios-sim.sh` | per-slot sim clone / delete | +| `lib/slots.js` | atomic slot allocator (lib + CLI) | +| `slots.json` | slot state | +| `gc-worktrees.sh` | manual orphan cleanup | +| `resume-agent.sh` | resume a session; `--recover` re-provisions a missing slot | +| `asana-config.json` | project GIDs + `.watcher.*` knobs | +| `update-status.sh` | set `agent_status` (+ kanban section move) | + +## Known limitations + +- **Orphan cleanup on reboot**: launchd restarts the watcher/watchdog after a + reboot, but tmux sessions don't survive. Worktrees + sim clones from before the + reboot are orphaned until `gc-worktrees.sh` runs. There's no boot-time GC hook. +- **Sub-repo worktree caveats**: `edge-react-gui` already has nested worktrees + (`.claude/worktrees`, `staging`). `git worktree add` for an agent slot is + independent of those, but `worktree prune` operates repo-wide — don't prune + while another tool is mid-worktree-add. +- **Cloning a booted master**: `simctl clone` snapshots a booted master fine, but + the snapshot reflects the master's state at clone time. If the master is mid-PIN + entry the clone inherits that; clone from a settled master. +- **Metro port reuse**: ports are derived from `slot_index`, which is reused after + a slot frees. If a dead session left Metro bound to its port, the next slot on + that index can collide. The watchdog kills the tmux session (and thus Metro) + before freeing the slot, so this only bites if Metro was orphaned out-of-band. +- **Guardrail is a snapshot**: load/RAM are read once per tick. Two lanes spawned + in the same tick both see pre-spawn headroom; the cap (not the guardrail) is the + real backstop against oversubscription. diff --git a/agent-watcher/allocate-from-pool.sh b/agent-watcher/allocate-from-pool.sh new file mode 100755 index 0000000..311555f --- /dev/null +++ b/agent-watcher/allocate-from-pool.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# allocate-from-pool.sh — Atomically pull a free entry from the sim pool and +# mark it in_use for a task. Prints the UDID on stdout, status on stderr. +# +# Usage: +# allocate-from-pool.sh --task-gid <gid> +# +# Exit codes: +# 0 = allocated (UDID on stdout) +# 1 = no free entries in pool (caller must run ensure-sim-pool.sh first) +# 2 = pool file missing or malformed + +set -euo pipefail + +DIR="$HOME/.config/agent-watcher" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher"; mkdir -p "$STATE_DIR" +POOL="$STATE_DIR/pool.json" +LOCK="$DIR/pool.lock" + +TASK_GID="" +while [[ $# -gt 0 ]]; do + case "$1" in + --task-gid) TASK_GID="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ -n "$TASK_GID" ]] || { echo "Usage: allocate-from-pool.sh --task-gid <gid>" >&2; exit 1; } +[[ -f "$POOL" ]] || { echo "Pool file missing: $POOL (run ensure-sim-pool.sh first)" >&2; exit 2; } + +# Lock + atomic JSON update. +i=0 +while ! ( set -C; : > "$LOCK" ) 2>/dev/null; do + i=$((i + 1)) + [[ $i -gt 300 ]] && { echo "Could not acquire $LOCK after 30s" >&2; exit 1; } + sleep 0.1 +done +trap 'rm -f "$LOCK"' EXIT + +POOL_JSON=$(cat "$POOL") + +# Find first slot in state=free. +SLOT=$(jq -r '[.pool[] | select(.state == "free")] | first | .slot // empty' <<<"$POOL_JSON") +if [[ -z "$SLOT" ]]; then + FREE_COUNT=$(jq '[.pool[] | select(.state == "free")] | length' <<<"$POOL_JSON") + TOTAL=$(jq '.pool | length' <<<"$POOL_JSON") + echo ">> allocate-from-pool: no free entries (free=$FREE_COUNT total=$TOTAL)" >&2 + exit 1 +fi + +UDID=$(jq -r --arg s "$SLOT" '.pool[] | select(.slot == ($s | tonumber)) | .udid' <<<"$POOL_JSON") +if [[ -z "$UDID" || "$UDID" == "null" ]]; then + echo ">> allocate-from-pool: slot $SLOT has no UDID; pool is corrupt" >&2 + exit 2 +fi + +# Mark in_use with task_gid. +NEW_JSON=$(jq --arg s "$SLOT" --arg t "$TASK_GID" \ + '(.pool[] | select(.slot == ($s | tonumber)).state) = "in_use" + | (.pool[] | select(.slot == ($s | tonumber)).task_gid) = $t' <<<"$POOL_JSON") +tmp=$(mktemp) +jq . > "$tmp" <<<"$NEW_JSON" +mv "$tmp" "$POOL" + +echo ">> allocate-from-pool: slot $SLOT → $UDID (task $TASK_GID)" >&2 +echo "$UDID" diff --git a/agent-watcher/asana-api.js b/agent-watcher/asana-api.js new file mode 100644 index 0000000..82ecb40 --- /dev/null +++ b/agent-watcher/asana-api.js @@ -0,0 +1,114 @@ +// asana-api.js — Thin wrapper around the Asana REST API. +// Token source: ~/.config/agent-watcher/credentials.json (asana_token). +// Used by both the kanban setup and the watcher. + +const fs = require('node:fs') +const path = require('node:path') +const { execSync } = require('node:child_process') + +const HOME = process.env.HOME || '' +const CRED_FILE = path.join(HOME, '.config/agent-watcher/credentials.json') +const API_BASE = 'https://app.asana.com/api/1.0' + +function getToken() { + if (process.env.ASANA_TOKEN) return process.env.ASANA_TOKEN + try { + const data = JSON.parse(fs.readFileSync(CRED_FILE, 'utf8')) + if (data.asana_token) return data.asana_token + } catch {} + throw new Error(`ASANA_TOKEN not set and no token in ${CRED_FILE}`) +} + +const TOKEN = getToken() + +function request(method, endpoint, body) { + const url = endpoint.startsWith('http') ? endpoint : `${API_BASE}${endpoint}` + const args = [ + '-sS', + '-X', method, + '-H', `Authorization: Bearer ${TOKEN}`, + '-H', 'Content-Type: application/json', + '-H', 'Accept: application/json', + '-w', '\n%{http_code}', + ] + if (body !== undefined) { + args.push('-d', JSON.stringify(body)) + } + args.push(url) + + let raw + try { + raw = execSync(`curl ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }) + } catch (err) { + throw new Error(`curl failed for ${method} ${endpoint}: ${err.message}`) + } + + const lastNewline = raw.lastIndexOf('\n') + const codeStr = raw.slice(lastNewline + 1).trim() + const bodyStr = raw.slice(0, lastNewline) + const code = parseInt(codeStr, 10) + + let parsed + try { parsed = JSON.parse(bodyStr) } catch { parsed = { raw: bodyStr } } + + if (code < 200 || code >= 300) { + const errMsg = parsed?.errors?.map((e) => e.message).join('; ') || bodyStr + throw new Error(`Asana ${method} ${endpoint} -> ${code}: ${errMsg}`) + } + return parsed +} + +// ─── Convenience wrappers ──────────────────────────────────────────────────── + +function getMe() { + return request('GET', '/users/me').data +} + +function getWorkspaces() { + return request('GET', '/workspaces').data +} + +function getProject(projectGid) { + return request('GET', `/projects/${projectGid}`).data +} + +function listProjectTasks(projectGid, optFields) { + const q = optFields ? `?opt_fields=${encodeURIComponent(optFields)}` : '' + return request('GET', `/projects/${projectGid}/tasks${q}`).data +} + +function getTask(taskGid, optFields) { + const q = optFields ? `?opt_fields=${encodeURIComponent(optFields)}` : '' + return request('GET', `/tasks/${taskGid}${q}`).data +} + +function updateTask(taskGid, fields) { + return request('PUT', `/tasks/${taskGid}`, { data: fields }).data +} + +function createCustomField(workspaceGid, body) { + return request('POST', '/custom_fields', { data: { workspace: workspaceGid, ...body } }).data +} + +function addCustomFieldToProject(projectGid, customFieldGid) { + return request('POST', `/projects/${projectGid}/addCustomFieldSetting`, { + data: { custom_field: customFieldGid }, + }).data +} + +module.exports = { + request, + getMe, + getWorkspaces, + getProject, + listProjectTasks, + getTask, + updateTask, + createCustomField, + addCustomFieldToProject, + API_BASE, +} diff --git a/agent-watcher/asana-config.json b/agent-watcher/asana-config.json new file mode 100644 index 0000000..525c429 --- /dev/null +++ b/agent-watcher/asana-config.json @@ -0,0 +1,70 @@ +{ + "workspace_gid": "9976422036640", + "team_gid": "1190661726966109", + "team_name": "test", + "project_gid": "1215088146871429", + "project_name": "Agent MVP — Test Kanban", + "project_url": "https://app.asana.com/1/9976422036640/project/1215088146871429", + "user_gid": "1200972350160586", + "user_email": "jon@edge.app", + "custom_fields": { + "agent_status": { + "gid": "1215088146891589", + "options": { + "Pending": "1215088146891590", + "Planning": "1215088146891591", + "Developing": "1215088146891592", + "Reviewing": "1215088146891593", + "Testing": "1215088146891594", + "Complete": "1215088146891595" + }, + "section_gids": { + "Pending": "1215088146881144", + "Planning": "1215158802669790", + "Developing": "1215158705341597", + "Reviewing": "1215158736217710", + "Testing": "1215158740998633", + "Complete": "1215158738301289" + } + }, + "blocked": { + "gid": "1215088177189902", + "options": { + "No": "1215088177189903", + "Yes": "1215088177189904" + } + } + }, + "watcher": { + "_doc": { + "max_concurrent": "Max parallel agent sessions. Slot accounting is by LIVE tmux sessions, not Asana state. Env override: AGENT_WATCHER_MAX_CONCURRENT.", + "metro_base_port": "Slot N gets Metro port (metro_base_port + N). Slot 0 = 8081 (RN default).", + "master_sim": "The iOS 18 iPhone 16 Pro Max sim holding the test account; cloned per slot. Resolved by device+runtime unless a UDID is hardcoded.", + "resource_guardrail": "Pre-spawn safety gate. Skip spawning this tick if 1-min load avg > max_load_avg OR free RAM < min_free_ram_gb. Env overrides: AGENT_WATCHER_MAX_LOAD_AVG, AGENT_WATCHER_MIN_FREE_RAM_GB.", + "npm_migration_commit": "Commit cherry-picked onto each fresh agent worktree so RN tooling uses npm not yarn.", + "worktrees_root": "Where per-task worktrees live: <worktrees_root>/<task-gid>/<repo>/.", + "repos_root": "Where main repo checkouts live: <repos_root>/<repo>.", + "default_repo": "Repo a spawned agent works in (worktree + sim are provisioned for it).", + "sim_pool": "Pre-cloned iOS sims kept ready so per-task spawn is fast (no inline simctl clone). Size defaults to max_concurrent. Lifecycle: free → in_use (on allocate) → dirty (on watchdog reap) → free (next ensure-sim-pool refresh). Refresh deletes the stale sim and clones from master.", + "keep_completed_worktrees": "How many completed/retired worktrees to keep on disk for inspection/resume before the watchdog prunes the oldest. On completion the sim + slot are freed immediately; only the worktree is retained. Worktrees with a LIVE session are never counted or pruned. Default 5; set 0 to destroy on completion (old behavior)." + }, + "default_repo": "edge-react-gui", + "max_concurrent": 2, + "metro_base_port": 8081, + "master_sim": { + "device": "iPhone 16 Pro Max", + "runtime": "iOS 18" + }, + "resource_guardrail": { + "max_load_avg": 12.0, + "min_free_ram_gb": 8.0 + }, + "npm_migration_commit": "4d169a59e2", + "worktrees_root": "~/git/.agent-worktrees", + "repos_root": "~/git", + "sim_pool": { + "size": 2 + }, + "keep_completed_worktrees": 5 + } +} diff --git a/agent-watcher/asana-watcher.js b/agent-watcher/asana-watcher.js new file mode 100755 index 0000000..5b82af4 --- /dev/null +++ b/agent-watcher/asana-watcher.js @@ -0,0 +1,311 @@ +#!/usr/bin/env node +// asana-watcher.js — Poll Asana for Pending agent tasks; spawn up to +// MAX_CONCURRENT parallel tmux sessions, each in its own git worktree, on its own +// cloned iOS simulator, on its own Metro port, behind a resource guardrail. +// +// Per-tick flow: +// 1. MAX_CONCURRENT — config .watcher.max_concurrent (default 2); +// env override AGENT_WATCHER_MAX_CONCURRENT. +// 2. active — count of LIVE `claude-asana-*` tmux sessions. +// 3. active >= MAX → log "at cap (active=N max=M)", exit 0. +// 4. resource guardrail → 1-min load avg > max_load_avg OR free RAM < min_free_ram_gb +// → log "skipped this tick: guardrail (...)", exit 0. +// 5. fetch tasks; pick the oldest (MAX - active) Pending tasks. +// 6. per picked task: set Planning, setup worktree, clone sim, allocate slot, spawn. +// 7. slot allocations persisted to slots.json (via lib/slots.js). +// +// SLOT ACCOUNTING IS BY LIVE TMUX SESSIONS, NOT BY ASANA STATE. A task that is +// blocked or in-flight in Asana but whose tmux session has died does NOT hold a +// slot — that lane is free for a fresh spawn. slots.json is bookkeeping for +// teardown (which sim/worktree to reap); the concurrency cap is always enforced +// against the count of real live sessions. +// +// Usage: +// asana-watcher.js run for real (spawns sessions) +// asana-watcher.js --dry-run log decisions; no spawn, no Asana mutation +// asana-watcher.js --dry-run --simulate-pending 3 [--simulate-active 0] +// fabricate Pending tasks to test the cap logic +// +// Reads: +// ~/.config/agent-watcher/credentials.json (ASANA_TOKEN) +// ~/.config/agent-watcher/asana-config.json (project + custom field GIDs + .watcher.*) + +const fs = require('node:fs') +const path = require('node:path') +const { execSync, spawnSync } = require('node:child_process') +const api = require('./asana-api.js') +const slots = require('./lib/slots.js') + +const HOME = process.env.HOME || '' +const DIR = path.join(HOME, '.config/agent-watcher') +const CONFIG_PATH = path.join(DIR, 'asana-config.json') +const SESSION_PREFIX = 'claude-asana-' +const RC_READY_MARKER = 'Remote Control active' +const BYPASS_PROMPT_MARKER = 'Yes, I accept' +const RC_READY_TIMEOUT_MS = 60 * 1000 +const RC_READY_POLL_MS = 1000 + +const DRY_RUN = process.argv.includes('--dry-run') + +function log(msg) { + const ts = new Date().toISOString() + const tag = DRY_RUN ? '[DRY] ' : '' + console.log(`[${ts}] ${tag}${msg}`) +} + +function sh(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + } catch { + return '' + } +} + +function argInt(flag) { + const i = process.argv.indexOf(flag) + if (i === -1) return null + const v = parseInt(process.argv[i + 1], 10) + return Number.isFinite(v) ? v : null +} + +function loadConfig() { + return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) +} + +// ─── Concurrency cap + guardrail ─────────────────────────────────────────────── + +function maxConcurrent(cfg) { + const env = parseInt(process.env.AGENT_WATCHER_MAX_CONCURRENT || '', 10) + if (Number.isFinite(env) && env > 0) return env + return cfg.watcher?.max_concurrent || 2 +} + +function guardrailThresholds(cfg) { + const g = cfg.watcher?.resource_guardrail || {} + const envLoad = parseFloat(process.env.AGENT_WATCHER_MAX_LOAD_AVG || '') + const envFree = parseFloat(process.env.AGENT_WATCHER_MIN_FREE_RAM_GB || '') + return { + maxLoad: Number.isFinite(envLoad) ? envLoad : (g.max_load_avg ?? 12.0), + minFree: Number.isFinite(envFree) ? envFree : (g.min_free_ram_gb ?? 4.0), + } +} + +function countActiveSessions() { + const out = sh('tmux list-sessions -F "#{session_name}"') + if (!out) return 0 + return out.split('\n').filter((s) => s.startsWith(SESSION_PREFIX)).length +} + +function getLoadAvg() { + const m = sh('uptime').match(/load averages?:\s+([\d.]+)/i) + return m ? parseFloat(m[1]) : 0 +} + +function getFreeRamGb() { + const out = sh('vm_stat') + if (!out) return Infinity // can't read → don't gate on RAM + const pageM = out.match(/page size of (\d+) bytes/) + const pageSize = pageM ? parseInt(pageM[1], 10) : 16384 + const pages = (label) => { + const m = out.match(new RegExp(`${label}:\\s+(\\d+)`)) + return m ? parseInt(m[1], 10) : 0 + } + // "Available" ≈ free + speculative + inactive (inactive is reclaimable). + const bytes = (pages('Pages free') + pages('Pages speculative') + pages('Pages inactive')) * pageSize + return bytes / 1024 ** 3 +} + +// ─── Asana ───────────────────────────────────────────────────────────────────── + +function listAgentTasks(cfg) { + const optFields = 'name,custom_fields.gid,custom_fields.name,custom_fields.enum_value.name,created_at' + return api.listProjectTasks(cfg.project_gid, optFields) +} + +function getAgentStatus(task, cfg) { + const field = task.custom_fields?.find((f) => f.gid === cfg.custom_fields.agent_status.gid) + return field?.enum_value?.name || null +} + +function setStatusPlanning(taskGid) { + log(` setting task ${taskGid} agent_status=Planning`) + if (DRY_RUN) return + execSync(`${DIR}/update-status.sh ${taskGid} Planning`, { stdio: 'inherit' }) +} + +// ─── tmux / spawn ─────────────────────────────────────────────────────────────── + +function tmuxSessionExists(name) { + return sh(`tmux has-session -t "${name}" 2>/dev/null && echo yes`) === 'yes' +} + +function shCapture(cmd) { + // Run a helper that prints status to stderr (shown live) and its result to stdout (captured). + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }).trim() +} + +function waitRcReadyAndSendPrompt(sessionName, prompt) { + const deadline = Date.now() + RC_READY_TIMEOUT_MS + let ready = false + let acceptedBypass = false + while (Date.now() < deadline) { + const pane = sh(`tmux capture-pane -t "${sessionName}" -p`) + if (pane.includes(RC_READY_MARKER)) { ready = true; break } + if (!acceptedBypass && pane.includes(BYPASS_PROMPT_MARKER)) { + log(` bypass-permissions dialog detected; auto-accepting (Down + Enter)`) + execSync(`tmux send-keys -t "${sessionName}" Down`, { stdio: 'inherit' }) + execSync('sleep 0.3') + execSync(`tmux send-keys -t "${sessionName}" Enter`, { stdio: 'inherit' }) + acceptedBypass = true + } + execSync(`sleep ${RC_READY_POLL_MS / 1000}`) + } + if (!ready) log(` WARNING: RC ready marker not seen after ${RC_READY_TIMEOUT_MS}ms; sending prompt anyway`) + + // Send text then Enter separately — a single send-keys sometimes drops the Enter. + execSync(`tmux send-keys -t "${sessionName}" ${JSON.stringify(prompt)}`, { stdio: 'inherit' }) + execSync('sleep 1') + execSync(`tmux send-keys -t "${sessionName}" Enter`, { stdio: 'inherit' }) + log(` prompt sent: ${prompt}`) +} + +// Full per-task spawn: Planning → worktree → sim clone → slot allocate → tmux session. +function spawnForTask(task, cfg) { + const sessionName = `${SESSION_PREFIX}${task.gid}` + const label = `Asana: ${task.name}`.slice(0, 120) + const taskUrl = `https://app.asana.com/0/${cfg.project_gid}/${task.gid}` + const reposRoot = path.join(HOME, 'git') // spawn cwd: the agent picks/creates the repo worktree(s) itself + + log(`Spawning slot for: ${task.name} (gid=${task.gid}, cwd=${reposRoot})`) + + if (tmuxSessionExists(sessionName)) { + log(` session ${sessionName} already exists — refusing to spawn over it. Skipping.`) + return false + } + + if (DRY_RUN) { + log(` (dry-run) would: Planning → allocate slot/sim → spawn in ${reposRoot} (agent creates per-repo worktrees)`) + log(` (dry-run) would send-keys: /one-shot --yolo ${taskUrl}`) + return true + } + + setStatusPlanning(task.gid) + + // No eager worktree: the agent reads the task, determines the target repo(s), and + // creates co-located per-task worktrees itself (one-shot skill → setup-task-workspace). + // cwd is ~/git; cleanup/gc scan ~/git/.agent-worktrees/<gid>/ for whatever it created. + const worktreesParent = path.join(reposRoot, '.agent-worktrees', task.gid) + + let simUdid + try { + simUdid = shCapture(`${DIR}/allocate-from-pool.sh --task-gid ${task.gid}`).split('\n').pop() + } catch (e) { + log(` allocate-from-pool failed (${e.status ?? '?'}) — skipping spawn for ${task.gid}`) + return false + } + + const slot = slots.allocate({ task_gid: task.gid, worktree_path: worktreesParent, sim_udid: simUdid }) + log(` slot ${slot.slot_index}: metro ${slot.metro_port}, sim ${simUdid}`) + + const r = spawnSync(`${DIR}/spawn-test-session.sh`, [ + '--yolo', + '--slot-index', String(slot.slot_index), + '--task-gid', task.gid, + '--sim-udid', simUdid, + '--metro-port', String(slot.metro_port), + '--worktree-path', reposRoot, + '--label', label, + ], { stdio: 'inherit' }) + if (r.status !== 0) { + log(` spawn helper failed with exit code ${r.status}`) + return false + } + + waitRcReadyAndSendPrompt(sessionName, `/one-shot --yolo ${taskUrl}`) + return true +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +function main() { + const cfg = loadConfig() + const MAX = maxConcurrent(cfg) + log(`Watcher tick — project: ${cfg.project_name} (${cfg.project_gid}), max_concurrent=${MAX}`) + + const simulatePending = argInt('--simulate-pending') + const simulating = simulatePending != null + if (simulating && !DRY_RUN) { + log('--simulate-pending requires --dry-run (refusing to spawn against fake tasks)') + return + } + + // Step 2: active = live claude-asana-* sessions (or simulated). + const active = simulating ? (argInt('--simulate-active') ?? 0) : countActiveSessions() + log(`Active sessions: ${active}`) + + // Step 3: at cap. + if (active >= MAX) { + log(`at cap (active=${active} max=${MAX}) — nothing to spawn this tick`) + return + } + + // Step 4: resource guardrail. + const { maxLoad, minFree } = guardrailThresholds(cfg) + const load = getLoadAvg() + const freeGb = getFreeRamGb() + if (load > maxLoad || freeGb < minFree) { + log(`skipped this tick: guardrail (load=${load.toFixed(2)} max=${maxLoad}, free=${freeGb.toFixed(1)}GB min=${minFree}GB)`) + return + } + log(`guardrail ok (load=${load.toFixed(2)}/${maxLoad}, free=${freeGb.toFixed(1)}GB/${minFree}GB)`) + + const available = MAX - active + + // Step 5: gather + sort Pending tasks (oldest first). + let pending + if (simulating) { + pending = Array.from({ length: simulatePending }, (_, i) => ({ + gid: `SIM${i + 1}`, + name: `Simulated pending task ${i + 1}`, + created_at: new Date(Date.now() + i * 1000).toISOString(), + })) + } else { + const tasks = listAgentTasks(cfg) + log(`Fetched ${tasks.length} task(s) from project`) + pending = tasks.filter((t) => getAgentStatus(t, cfg) === 'Pending') + } + pending.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '')) + + if (pending.length === 0) { + log('No Pending tasks. Nothing to do.') + return + } + + const toSpawn = pending.slice(0, available) + const deferred = pending.slice(available) + log(`Pending=${pending.length}, free slots=${available} → spawning ${toSpawn.length}, deferring ${deferred.length}`) + + // Ensure the iOS-sim pool has enough free entries to cover all spawns this + // tick. Any dirty entries from prior reaps are refreshed here (delete stale + // sim + clone fresh). This is the only place where simctl clone runs; + // per-task allocation below is instant. + if (toSpawn.length > 0 && !DRY_RUN) { + const poolSize = cfg.watcher?.sim_pool?.size || MAX + log(`Ensuring iOS sim pool (size=${poolSize})…`) + const r = spawnSync(`${DIR}/ensure-sim-pool.sh`, ['--size', String(poolSize)], { stdio: 'inherit' }) + if (r.status !== 0) { + log(`ensure-sim-pool failed (exit ${r.status}) — skipping spawns this tick`) + return + } + } + + // Step 6 + 7: spawn each picked task (slot persisted inside spawnForTask). + for (const task of toSpawn) spawnForTask(task, cfg) + + // Anything beyond the cap waits for a future tick. + for (const task of deferred) { + log(`skipped: at cap (max=${MAX} would be exceeded) — "${task.name}" (gid=${task.gid}) deferred to a later tick`) + } +} + +main() diff --git a/agent-watcher/capture-runaway-forensics.sh b/agent-watcher/capture-runaway-forensics.sh new file mode 100755 index 0000000..a23d142 --- /dev/null +++ b/agent-watcher/capture-runaway-forensics.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# capture-runaway-forensics.sh — Snapshot a runaway `cli` fork chain to pin its spawn mechanism. +# +# Called by runaway-guard.sh on EARLY detection (before the kill), or run manually: +# capture-runaway-forensics.sh [<pgid>] +# +# Writes a timestamped report to ~/.config/agent-watcher/oom-repro/forensics/. +# Goal: while the chain's parents are still alive, capture enough to answer +# "what is spawning each new claude?" — confirming or refuting the recursive-/loop +# hypothesis. The first action is an INSTANT full `ps` snapshot to a side file so the +# parent lineage is preserved even if processes detach during the slower steps. +# +# Best-effort throughout; never exits non-zero (must not wedge the guard). + +set -u +PGID="${1:-}" +DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/forensics" +mkdir -p "$DIR" +TS=$(date +%Y%m%d-%H%M%S) +OUT="$DIR/runaway-$TS${PGID:+-pgid$PGID}.log" +RAW="$OUT.ps" + +# [0] INSTANT snapshot — one ps call, preserves lineage before anything detaches. +ps -axo pid,ppid,pgid,stat,etime,rss,comm,command > "$RAW" 2>/dev/null + +{ + echo "=== runaway forensic capture $TS ===" + echo "host uptime: $(uptime)" + echo "trigger pgid: ${PGID:-<none specified>}" + page=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384) + vm_stat 2>/dev/null | awk -v ps="$page" ' + /Pages free/{gsub("\\.","",$NF);printf "mem: free=%dMB",$NF*ps/1024/1024} + /occupied by compressor/{gsub("\\.","",$NF);printf " compressor=%dMB",$NF*ps/1024/1024} + END{print ""}' + echo "pressure: $(sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null) (1=NORMAL 2=WARN 4=CRITICAL)" + echo "swap: $(sysctl -n vm.swapusage 2>/dev/null)" + + echo + echo "--- [1] cli counts by process group ---" + # comm column ($7) is 'cli' for claude-code; group by pgid ($3). + awk '$7=="cli"{n[$3]++; tot++} END{print " total cli:", tot+0; for(g in n) print " pgid "g": "n[g]}' "$RAW" | sort -t: -k2 -rn | head + + echo + echo "--- [2] full tree for pgid ${PGID:-(top group)} ---" + TARGET="$PGID" + [ -z "$TARGET" ] && TARGET=$(awk '$7=="cli"{n[$3]++} END{m=0;for(g in n)if(n[g]>m){m=n[g];b=g}; print b}' "$RAW") + echo " target pgid: $TARGET" + awk -v g="$TARGET" '$3==g{print " "$0}' "$RAW" | head -40 + + echo + echo "--- [3] SEED: trace a chain member up to the first non-cli ancestor ---" + # Pick the lowest-PID cli in the target group (oldest = closest to the seed). + seed_start=$(awk -v g="$TARGET" '$7=="cli" && $3==g{print $1}' "$RAW" | sort -n | head -1) + echo " starting from cli pid $seed_start" + cur="$seed_start" + for _ in $(seq 1 200); do + line=$(awk -v p="$cur" '$1==p{print; exit}' "$RAW") + [ -z "$line" ] && { echo " pid $cur not in snapshot (detached)"; break; } + comm=$(echo "$line" | awk '{print $7}') + ppid=$(echo "$line" | awk '{print $2}') + if [ "$comm" != "cli" ]; then + echo " >>> SEED ANCESTOR (first non-cli): " + echo "$line" | sed 's/^/ /' + echo " >>> its parent:" + awk -v p="$ppid" '$1==p{print " "$0}' "$RAW" + break + fi + [ "$ppid" -le 1 ] && { echo " chain root pid $cur orphaned to launchd (seed already exited)"; echo "$line" | sed 's/^/ /'; break; } + cur="$ppid" + done + + echo + echo "--- [4] any live claude --resume / --rc / one-shot / loop process (the real launcher) ---" + # These keep their real argv (not masked to 'cli'); they reveal the launch intent. + awk '/claude (--|[a-z])/ && !/awk|grep|capture-runaway/{print " "$0}' "$RAW" | grep -iE "resume|--rc|one-shot|--yolo|loop|babysit" | head -10 + echo " (none above = launcher already exited / masked)" + + echo + echo "--- [5] SCHEDULER state (tests the cron / scheduled-respawn hypothesis) ---" + echo " system crontab:"; crontab -l 2>/dev/null | grep -iE "claude|cli|agent|loop" | sed 's/^/ /' || echo " (none)" + echo " launchd jobs (claude/agent):"; launchctl list 2>/dev/null | grep -iE "claude|jontz|agent" | sed 's/^/ /' + echo " at-queue:"; atq 2>/dev/null | head | sed 's/^/ /' || echo " (atq empty/unavailable)" + echo " NOTE: Claude Code /loop & /schedule crons are stored in claude state, not OS cron —" + echo " check CronList from a claude session separately if OS scheduler is clean." + + echo + echo "--- [6] tmux sessions + pane launch commands ---" + tmux list-panes -a -F " #{session_name} pane_pid=#{pane_pid} start=#{pane_start_command}" 2>/dev/null | head -20 || echo " (no tmux server)" + + echo + echo "--- [7] which worktree/session owns the chain (cwd of a sample cli) ---" + sample=$(awk -v g="$TARGET" '$7=="cli" && $3==g{print $1; exit}' "$RAW") + if [ -n "$sample" ]; then + cwd=$(lsof -a -p "$sample" -d cwd -Fn 2>/dev/null | grep '^n' | sed 's/^n//') + echo " cli $sample cwd: ${cwd:-<unreadable>}" + # cwd like ~/git/.agent-worktrees/<gid>/<repo> → find the session jsonl + tail it + gid=$(echo "$cwd" | grep -oE 'agent-worktrees/[0-9]+' | grep -oE '[0-9]+') + if [ -n "$gid" ]; then + echo " task gid: $gid" + sess=$(ls -t "$HOME/.claude/projects/"*"agent-worktrees-$gid"*/*.jsonl 2>/dev/null | head -1) + if [ -n "$sess" ]; then + echo " session: $sess" + echo " --- last 3 events of that session ---" + tail -3 "$sess" 2>/dev/null | cut -c1-300 | sed 's/^/ /' + fi + fi + fi + + echo + echo "--- [8] stack sample of one chain proc (dyld_start = held pre-exec; main = running) ---" + [ -n "$sample" ] && sample "$sample" 1 2>&1 | grep -E "Call graph|_dyld_start|main \(|Thread_" | head -6 | sed 's/^/ /' + + echo + echo "=== end capture $TS — raw ps at $RAW ===" +} > "$OUT" 2>&1 + +echo "$OUT" diff --git a/agent-watcher/cleanup-task-workspace.sh b/agent-watcher/cleanup-task-workspace.sh new file mode 100755 index 0000000..f3fedf9 --- /dev/null +++ b/agent-watcher/cleanup-task-workspace.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# cleanup-task-workspace.sh — Reverse of setup-task-workspace.sh. +# +# Removes a per-task worktree, deletes its env.json copy, and deletes the +# agent branch if it's safe (the branch matches our `agent/<gid>` convention). +# Used by rc-watchdog.js during the completion sweep and by gc-worktrees.sh. +# +# Usage: +# cleanup-task-workspace.sh --task-gid <gid> --repo <name> +# +# Best-effort by design: a partial failure (e.g. branch already gone) is warned +# about but does NOT fail the command. The watchdog must never get stuck because +# one piece of teardown didn't apply. +# +# Exit codes: +# 0 = always (best-effort teardown; warnings on stderr) +# 2 = usage error + +set -euo pipefail + +REPOS_ROOT="$HOME/git" +WORKTREES_ROOT="$HOME/git/.agent-worktrees" + +TASK_GID="" +REPO="" +while [[ $# -gt 0 ]]; do + case "$1" in + --task-gid) TASK_GID="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +[[ -n "$TASK_GID" && -n "$REPO" ]] || { + echo "Usage: cleanup-task-workspace.sh --task-gid <gid> --repo <name>" >&2 + exit 2 +} + +MAIN_REPO="$REPOS_ROOT/$REPO" +WT="$WORKTREES_ROOT/$TASK_GID/$REPO" +BRANCH="agent/$TASK_GID" + +# Delete the env.json copy first so we scrub the plaintext secrets even if the +# worktree-remove below fails and the dir lingers. (-e: it's now a real file, not +# a symlink; older worktrees may still have a symlink — rm -f handles both.) +if [[ -e "$WT/env.json" || -L "$WT/env.json" ]]; then + rm -f "$WT/env.json" && echo ">> cleanup-task-workspace: removed env.json" >&2 +fi + +# Remove the worktree (force — it may have build artifacts / uncommitted state). +if [[ -d "$MAIN_REPO/.git" ]]; then + if git -C "$MAIN_REPO" worktree remove --force "$WT" 2>/dev/null; then + echo ">> cleanup-task-workspace: removed worktree $WT" >&2 + else + echo ">> cleanup-task-workspace: WARN — worktree remove failed (already gone?); pruning" >&2 + git -C "$MAIN_REPO" worktree prune 2>/dev/null || true + fi + + # Delete the agent branch — safe because it matches our own naming convention. + if git -C "$MAIN_REPO" show-ref --verify --quiet "refs/heads/$BRANCH"; then + git -C "$MAIN_REPO" branch -D "$BRANCH" >/dev/null 2>&1 \ + && echo ">> cleanup-task-workspace: deleted branch $BRANCH" >&2 \ + || echo ">> cleanup-task-workspace: WARN — could not delete branch $BRANCH" >&2 + fi +else + echo ">> cleanup-task-workspace: WARN — main repo $MAIN_REPO missing; skipping git teardown" >&2 +fi + +# Drop the now-empty per-task parent dir if nothing else lives under it. +rmdir "$WORKTREES_ROOT/$TASK_GID" 2>/dev/null || true + +echo ">> cleanup-task-workspace: done ($TASK_GID / $REPO)" >&2 +exit 0 diff --git a/agent-watcher/clone-ios-sim.sh b/agent-watcher/clone-ios-sim.sh new file mode 100755 index 0000000..7a695e2 --- /dev/null +++ b/agent-watcher/clone-ios-sim.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# clone-ios-sim.sh — Clone the master iOS simulator into a per-slot sim. +# +# The master is the iOS 18 "iPhone 16 Pro Max" device that holds the test +# account (edge-rjqa3 / PIN 1111). Each parallel agent slot gets its own clone so +# concurrent UI tests don't fight over one simulator. +# +# Usage: +# clone-ios-sim.sh --name <clone-name> [--master <name-or-udid>] [--runtime <substr>] [--device <name>] +# +# --name REQUIRED. Name for the clone, e.g. "agent-slot-0". Used for idempotency: +# if a device with this name already exists, its UDID is returned and no +# new clone is made. +# --master Master device name or UDID to clone. If omitted, resolved from +# --device + --runtime (defaults below). +# --runtime Runtime substring for master resolution (default "iOS 18"). +# --device Device name for master resolution (default "iPhone 16 Pro Max"). +# +# Prints the clone's UDID on stdout, status on stderr. +# +# Idempotent: re-running with the same --name returns the existing clone's UDID. +# +# Exit codes: +# 0 = success (UDID on stdout) +# 1 = error (master not found, clone failed) +# 2 = ambiguous master (multiple matches; pass --master <udid>) + +set -euo pipefail + +NAME="" +MASTER="" +RUNTIME="iOS 18" +DEVICE="iPhone 16 Pro Max" + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) NAME="$2"; shift 2 ;; + --master) MASTER="$2"; shift 2 ;; + --runtime) RUNTIME="$2"; shift 2 ;; + --device) DEVICE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ -n "$NAME" ]] || { echo "Usage: clone-ios-sim.sh --name <clone-name> [--master <name-or-udid>]" >&2; exit 1; } +command -v xcrun >/dev/null 2>&1 || { echo "xcrun not found (install Xcode CLT)" >&2; exit 1; } + +udid_for_name() { + # Exact device-name match anywhere in the device list → first matching UDID. + xcrun simctl list devices 2>/dev/null \ + | grep -F "$1 (" \ + | grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' \ + | head -1 || true +} + +device_state() { + # Prints the parenthesized state for a UDID, e.g. Booted / Shutdown. + xcrun simctl list devices 2>/dev/null | grep -F "$1" | grep -oE '\(Booted\)|\(Shutdown\)|\(Shutting Down\)|\(Booting\)' | head -1 | tr -d '()' +} + +wait_for_shutdown() { + # simctl shutdown is async — poll until the device reaches Shutdown (≤30s). + local udid="$1" i + for ((i = 0; i < 30; i++)); do + [[ "$(device_state "$udid")" == "Shutdown" ]] && return 0 + sleep 1 + done + return 1 +} + +delete_by_name() { + # Remove any (possibly half-created) device with this name. + local u + for u in $(xcrun simctl list devices 2>/dev/null | grep -F "$1 (" | grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}'); do + xcrun simctl delete "$u" >/dev/null 2>&1 || true + done +} + +# Idempotency: a clone with this name already exists → return it, do not re-clone. +EXISTING=$(udid_for_name "$NAME") +if [[ -n "$EXISTING" ]]; then + echo ">> clone-ios-sim: clone '$NAME' already exists → $EXISTING" >&2 + echo "$EXISTING" + exit 0 +fi + +# Resolve the master UDID. +if [[ -z "$MASTER" ]]; then + 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 master simulator matching runtime='$RUNTIME' device='$DEVICE'" >&2 + exit 1 + elif [[ "$count" -gt 1 ]]; then + echo "Multiple master candidates for runtime='$RUNTIME' device='$DEVICE':" >&2 + echo "$UDIDS" | sed 's/^/ /' >&2 + echo "(pass --master <udid> to disambiguate)" >&2 + exit 2 + fi + MASTER="$UDIDS" +fi + +# `simctl clone` refuses a BOOTED source (CoreSimulator err 405). The master is +# only a template — its data volume (test account, PIN, wallet) persists across a +# shutdown — so we shut it down to clone, then restore its prior booted state. +# Consumers (select-ios-sim --boot) boot on demand regardless, so this is safe. +if [[ "$MASTER" =~ ^[0-9A-Fa-f-]{36}$ ]]; then + MASTER_UDID="$MASTER" +else + MASTER_UDID=$(udid_for_name "$MASTER") +fi + +MASTER_WAS_BOOTED=false +if [[ -n "$MASTER_UDID" ]] && [[ "$(device_state "$MASTER_UDID")" == "Booted" ]]; then + MASTER_WAS_BOOTED=true + echo ">> clone-ios-sim: master is booted; shutting down to clone (data persists)" >&2 + xcrun simctl shutdown "$MASTER_UDID" >/dev/null 2>&1 || true + if ! wait_for_shutdown "$MASTER_UDID"; then + echo ">> clone-ios-sim: WARN — master did not reach Shutdown in time; cloning anyway" >&2 + fi +fi + +echo ">> clone-ios-sim: cloning master '$MASTER' → '$NAME'" >&2 +if ! CLONE_UDID=$(xcrun simctl clone "$MASTER" "$NAME" 2>/tmp/clone-ios-sim.err); then + echo "clone failed:" >&2 + cat /tmp/clone-ios-sim.err >&2 + delete_by_name "$NAME" # drop any half-created device so re-runs stay idempotent + $MASTER_WAS_BOOTED && xcrun simctl boot "$MASTER_UDID" >/dev/null 2>&1 || true + exit 1 +fi + +# Restore the master's prior booted state (best-effort; boot returns promptly). +if $MASTER_WAS_BOOTED; then + xcrun simctl boot "$MASTER_UDID" >/dev/null 2>&1 || true + echo ">> clone-ios-sim: re-booted master to restore prior state" >&2 +fi + +echo ">> clone-ios-sim: created $NAME → $CLONE_UDID" >&2 +echo "$CLONE_UDID" diff --git a/agent-watcher/credentials.example.json b/agent-watcher/credentials.example.json new file mode 100644 index 0000000..95256cb --- /dev/null +++ b/agent-watcher/credentials.example.json @@ -0,0 +1,4 @@ +{ + "asana_token": "REPLACE_WITH_ASANA_PERSONAL_ACCESS_TOKEN", + "asana_github_secret": "REPLACE_WITH_ASANA_GITHUB_WIDGET_SECRET_OPTIONAL" +} diff --git a/agent-watcher/delete-ios-sim.sh b/agent-watcher/delete-ios-sim.sh new file mode 100755 index 0000000..ee84024 --- /dev/null +++ b/agent-watcher/delete-ios-sim.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# delete-ios-sim.sh — Shut down and delete a per-slot iOS simulator clone. +# +# The simctl-delete helper rc-watchdog.js uses during its completion sweep (it +# must NOT inline simctl calls — DRY). Pairs with clone-ios-sim.sh. +# +# Usage: +# delete-ios-sim.sh --udid <udid> +# +# Best-effort: shutting down an already-shut sim, or deleting a sim that's +# already gone, is treated as success. NEVER pass the master sim's UDID here — +# callers (watchdog, gc, cleanup) only pass UDIDs they read out of slots.json, +# which only ever holds clones. +# +# Exit codes: +# 0 = sim deleted (or already absent) +# 1 = delete failed for a present sim +# 2 = usage error + +set -euo pipefail + +UDID="" +while [[ $# -gt 0 ]]; do + case "$1" in + --udid) UDID="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +[[ -n "$UDID" ]] || { echo "Usage: delete-ios-sim.sh --udid <udid>" >&2; exit 2; } +command -v xcrun >/dev/null 2>&1 || { echo "xcrun not found (install Xcode CLT)" >&2; exit 1; } + +# Already gone? Nothing to do. +if ! xcrun simctl list devices 2>/dev/null | grep -q "$UDID"; then + echo ">> delete-ios-sim: $UDID not present (already deleted)" >&2 + exit 0 +fi + +xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true # no-op if already shut down + +if xcrun simctl delete "$UDID" >/dev/null 2>&1; then + echo ">> delete-ios-sim: deleted $UDID" >&2 + exit 0 +fi + +echo "delete-ios-sim: FAILED to delete $UDID" >&2 +exit 1 diff --git a/agent-watcher/ensure-sim-pool.sh b/agent-watcher/ensure-sim-pool.sh new file mode 100755 index 0000000..5d89fc1 --- /dev/null +++ b/agent-watcher/ensure-sim-pool.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# ensure-sim-pool.sh — Make the iOS-sim pool have N entries in "free" state. +# +# The pool is a small set of pre-cloned simulators waiting to be allocated to +# agent tasks. Allocate-from-pool returns one instantly (no clone wait). +# release-pool-entry marks one dirty when its task ends; this script refreshes +# dirty entries by deleting the stale sim and re-cloning from master. +# +# Per-entry state in pool.json: +# free — ready for allocation +# in_use — currently allocated to a task (do not touch) +# dirty — task is done; sim is stale; needs delete + re-clone +# +# Usage: +# ensure-sim-pool.sh [--size N] [--name-prefix <prefix>] +# +# --size pool size; default reads .watcher.sim_pool.size from +# asana-config.json, else .watcher.max_concurrent, else 2. +# --name-prefix sim name prefix; default "agent-sim-pool-". +# +# Behavior is idempotent. Re-running with a smaller --size shrinks the pool +# (deletes excess entries — but only if they're not in_use). in_use entries +# are NEVER deleted. +# +# Exit codes: +# 0 = pool is ready (all entries free, or at least all not-in_use entries are free) +# 1 = a clone operation failed (pool may be partially filled) + +set -euo pipefail + +DIR="$HOME/.config/agent-watcher" +CONFIG="$DIR/asana-config.json" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher"; mkdir -p "$STATE_DIR" +POOL="$STATE_DIR/pool.json" +LOCK="$DIR/pool.lock" + +SIZE="" +PREFIX="agent-sim-pool-" + +while [[ $# -gt 0 ]]; do + case "$1" in + --size) SIZE="$2"; shift 2 ;; + --name-prefix) PREFIX="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +# Resolve size from config if not passed. +if [[ -z "$SIZE" ]]; then + SIZE=$(jq -r '.watcher.sim_pool.size // .watcher.max_concurrent // 2' "$CONFIG") +fi + +log() { echo ">> ensure-sim-pool: $*" >&2; } + +# Atomic JSON update via lock + tmpfile. +acquire_lock() { + local i=0 + while ! ( set -C; : > "$LOCK" ) 2>/dev/null; do + i=$((i + 1)) + [[ $i -gt 300 ]] && { echo "Could not acquire $LOCK after 30s" >&2; exit 1; } + sleep 0.1 + done + trap 'rm -f "$LOCK"' EXIT +} +write_pool() { + local tmp; tmp=$(mktemp) + jq . > "$tmp" <<<"$1" + mv "$tmp" "$POOL" +} + +# Initialize pool.json if missing. +if [[ ! -f "$POOL" ]]; then + echo '{ "pool": [] }' > "$POOL" +fi + +acquire_lock + +POOL_JSON=$(cat "$POOL") + +# Step 1: drop entries beyond requested SIZE if they are not in_use. +# (We never force-evict an in_use entry; assume the watcher will reap it later +# and the next run of ensure-sim-pool will catch up.) +EXISTING_COUNT=$(jq '.pool | length' <<<"$POOL_JSON") +if [[ "$EXISTING_COUNT" -gt "$SIZE" ]]; then + for (( i = EXISTING_COUNT - 1; i >= SIZE; i-- )); do + STATE=$(jq -r ".pool[$i].state" <<<"$POOL_JSON") + UDID=$(jq -r ".pool[$i].udid" <<<"$POOL_JSON") + if [[ "$STATE" == "in_use" ]]; then + log "slot $i is in_use; skipping shrink" + continue + fi + if [[ -n "$UDID" && "$UDID" != "null" ]]; then + log "shrinking: deleting sim $UDID (slot $i, state $STATE)" + "$DIR/delete-ios-sim.sh" --udid "$UDID" 2>&1 | sed 's/^/ /' >&2 || true + fi + POOL_JSON=$(jq "del(.pool[$i])" <<<"$POOL_JSON") + done + write_pool "$POOL_JSON" +fi + +# Step 2: ensure each slot 0..SIZE-1 has an entry. +for (( slot = 0; slot < SIZE; slot++ )); do + PRESENT=$(jq -r ".pool[] | select(.slot == $slot) | .slot" <<<"$POOL_JSON" | head -1) + if [[ -z "$PRESENT" ]]; then + log "slot $slot missing — appending placeholder" + POOL_JSON=$(jq ".pool += [{slot: $slot, udid: null, state: \"dirty\"}]" <<<"$POOL_JSON") + fi +done +write_pool "$POOL_JSON" + +# Step 3: refresh anything in state=dirty (delete stale sim, clone fresh). +# Iterate via slot indices so we can rewrite the JSON between clones. +for (( slot = 0; slot < SIZE; slot++ )); do + POOL_JSON=$(cat "$POOL") + STATE=$(jq -r ".pool[] | select(.slot == $slot) | .state" <<<"$POOL_JSON") + UDID=$(jq -r ".pool[] | select(.slot == $slot) | .udid" <<<"$POOL_JSON") + NAME="${PREFIX}${slot}" + + if [[ "$STATE" != "dirty" ]]; then + continue + fi + + if [[ -n "$UDID" && "$UDID" != "null" ]]; then + log "slot $slot dirty — deleting stale sim $UDID" + "$DIR/delete-ios-sim.sh" --udid "$UDID" 2>&1 | sed 's/^/ /' >&2 || true + fi + + log "slot $slot — cloning fresh sim '$NAME'" + if NEW_UDID=$("$DIR/clone-ios-sim.sh" --name "$NAME" 2>&1 | tee /dev/stderr | tail -1); then + if [[ "$NEW_UDID" =~ ^[0-9A-Fa-f-]{36}$ ]]; then + POOL_JSON=$(jq --arg s "$slot" --arg u "$NEW_UDID" \ + '(.pool[] | select(.slot == ($s | tonumber)).udid) = $u + | (.pool[] | select(.slot == ($s | tonumber)).state) = "free"' <<<"$POOL_JSON") + write_pool "$POOL_JSON" + log "slot $slot — free ($NEW_UDID)" + else + log "slot $slot — clone produced no UDID; leaving dirty" + exit 1 + fi + else + log "slot $slot — clone failed; leaving dirty" + exit 1 + fi +done + +# Step 4: ensure orphan slots (no entry but in expected range) get filled. +# This catches the case where step 2 added a placeholder but step 3 already ran +# past it — should not happen but is cheap to guard against. +for (( slot = 0; slot < SIZE; slot++ )); do + POOL_JSON=$(cat "$POOL") + STATE=$(jq -r ".pool[] | select(.slot == $slot) | .state" <<<"$POOL_JSON") + UDID=$(jq -r ".pool[] | select(.slot == $slot) | .udid" <<<"$POOL_JSON") + if [[ "$STATE" == "free" && "$UDID" != "null" && -n "$UDID" ]]; then + continue + fi + if [[ "$STATE" == "in_use" ]]; then + continue + fi + log "slot $slot still not free — re-running refresh" + POOL_JSON=$(jq --arg s "$slot" '(.pool[] | select(.slot == ($s | tonumber)).state) = "dirty"' <<<"$POOL_JSON") + write_pool "$POOL_JSON" + exec "$0" --size "$SIZE" --name-prefix "$PREFIX" +done + +# Summary +FREE=$(jq '[.pool[] | select(.state == "free")] | length' "$POOL") +INUSE=$(jq '[.pool[] | select(.state == "in_use")] | length' "$POOL") +DIRTY=$(jq '[.pool[] | select(.state == "dirty")] | length' "$POOL") +log "pool ready: free=$FREE in_use=$INUSE dirty=$DIRTY (size=$SIZE)" diff --git a/agent-watcher/export-sim-accounts.sh b/agent-watcher/export-sim-accounts.sh new file mode 100755 index 0000000..023bb39 --- /dev/null +++ b/agent-watcher/export-sim-accounts.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# export-sim-accounts.sh — Snapshot the logged-in Edge accounts from this machine's +# master iPhone 16 Pro Max simulator into a self-applying tarball for another machine. +# +# Captures: the Edge.app, the account data (Documents/{logins,repos,local}), and the +# device keychain. SKIPS the multi-GB Zcash/Pirate shielded-sync caches — those re-sync +# from the network after login, so there's no reason to ship them. +# +# The tarball embeds an IMPORT.sh that, on the destination, finds that machine's +# iPhone 16 Pro Max sim, installs Edge, and replaces its account data + keychain — so +# pool clones of that master inherit the logged-in accounts. +# +# Usage: export-sim-accounts.sh [device-name] [out.tgz] +set -euo pipefail +DEVICE_NAME="${1:-iPhone 16 Pro Max}" +OUT="${2:-$HOME/Downloads/edge-sim-accounts.tgz}" +BUNDLE_ID="co.edgesecure.app" + +# Resolve the master sim (the one named "<device>", not the agent-sim-* pool clones). +UDID=$(xcrun simctl list devices available | grep -F "$DEVICE_NAME (" | grep -viE "agent-sim" | head -1 | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/') +[ -n "$UDID" ] || { echo "No '$DEVICE_NAME' sim found" >&2; exit 1; } +DEV="$HOME/Library/Developer/CoreSimulator/Devices/$UDID" +echo ">> master sim: $UDID" + +# Active Edge data container = the co.edgesecure.app container whose logins were touched most recently. +best=""; best_t=0 +for m in "$DEV"/data/Containers/Data/Application/*/.com.apple.mobile_container_manager.metadata.plist; do + [ -f "$m" ] || continue + [ "$(plutil -extract MCMMetadataIdentifier raw "$m" 2>/dev/null)" = "$BUNDLE_ID" ] || continue + c=$(dirname "$m"); lf=$(ls -t "$c"/Documents/logins/* 2>/dev/null | head -1); [ -n "$lf" ] || continue + t=$(stat -f %m "$lf"); [ "$t" -gt "$best_t" ] && { best_t=$t; best="$c"; } +done +[ -n "$best" ] || { echo "No Edge container with logins found (is Edge logged in on the master?)" >&2; exit 1; } +echo ">> active Edge data container: $best" + +# Active Edge.app = the newest co.edgesecure.app bundle. +app=""; app_t=0 +for a in "$DEV"/data/Containers/Bundle/Application/*/*.app; do + [ -d "$a" ] || continue + [ "$(plutil -extract CFBundleIdentifier raw "$a/Info.plist" 2>/dev/null)" = "$BUNDLE_ID" ] || continue + t=$(stat -f %m "$a"); [ "$t" -gt "$app_t" ] && { app_t=$t; app="$a"; } +done +[ -n "$app" ] || { echo "No Edge.app bundle found" >&2; exit 1; } +echo ">> Edge.app: $app" + +STAGE=$(mktemp -d) +mkdir -p "$STAGE/account/Documents" "$STAGE/app" +cp -R "$app" "$STAGE/app/" +for sub in logins repos local; do [ -d "$best/Documents/$sub" ] && cp -R "$best/Documents/$sub" "$STAGE/account/Documents/$sub"; done +cp -R "$DEV"/data/Library/Keychains "$STAGE/keychains" + +cat > "$STAGE/IMPORT.sh" <<'IMPORT' +#!/usr/bin/env bash +# Self-contained Edge sim-account import. Standalone usage on the new mac: +# mkdir -p /tmp/ea && tar xzf ~/Downloads/edge-sim-accounts.tgz -C /tmp/ea && /tmp/ea/IMPORT.sh +# Finds this machine's iPhone 16 Pro Max sim, installs Edge, drops in the logged-in +# accounts + keychain, and (if the orchestration is installed) refreshes the sim pool. +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd) +DEVICE_NAME="${1:-iPhone 16 Pro Max}" +BUNDLE_ID="co.edgesecure.app" +UDID=$(xcrun simctl list devices available | grep -F "$DEVICE_NAME (" | grep -viE "agent-sim" | head -1 | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/') +[ -n "$UDID" ] || { echo "No '$DEVICE_NAME' sim on this machine" >&2; exit 1; } +DEV="$HOME/Library/Developer/CoreSimulator/Devices/$UDID" +echo ">> dest master sim: $UDID" +APP=$(ls -d "$HERE"/app/*.app | head -1) +xcrun simctl boot "$UDID" 2>/dev/null || true +sleep 5 +xcrun simctl install "$UDID" "$APP" +xcrun simctl launch "$UDID" "$BUNDLE_ID" 2>/dev/null || true # first launch creates the data container +sleep 6 +xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true +CONT=$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data) +[ -d "$CONT" ] || { echo "Could not resolve Edge data container after install" >&2; exit 1; } +echo ">> dest Edge container: $CONT" +xcrun simctl shutdown "$UDID" 2>/dev/null || true +sleep 2 +mkdir -p "$CONT/Documents" +for sub in logins repos local; do + [ -d "$HERE/account/Documents/$sub" ] || continue + rm -rf "$CONT/Documents/$sub"; cp -R "$HERE/account/Documents/$sub" "$CONT/Documents/$sub" +done +[ -d "$HERE/keychains" ] && { rm -rf "$DEV/data/Library/Keychains"; cp -R "$HERE/keychains" "$DEV/data/Library/Keychains"; } +echo ">> accounts imported into the master sim ($UDID)." +if [ -x "$HOME/.config/agent-watcher/ensure-sim-pool.sh" ]; then + echo ">> refreshing the sim pool so clones inherit the logged-in master (~minutes)..." + rm -f "$HOME/.config/agent-watcher/pool.json" + "$HOME/.config/agent-watcher/ensure-sim-pool.sh" --size 2 || echo ">> (pool refresh failed; rerun it later)" +else + echo ">> (orchestration not installed here — skipped pool refresh)" +fi +echo ">> DONE. Boot the sim + open Edge: accounts present. If PIN login fails, password-login once (accounts are there); the YOLO BTC account auto-logs in." +IMPORT +chmod +x "$STAGE/IMPORT.sh" + +tar --disable-copyfile -czf "$OUT" -C "$STAGE" . +chmod 600 "$OUT" +rm -rf "$STAGE" +echo ">> exported $(du -sh "$OUT" | awk '{print $1}') → $OUT" diff --git a/agent-watcher/gc-worktrees.sh b/agent-watcher/gc-worktrees.sh new file mode 100755 index 0000000..e9d6198 --- /dev/null +++ b/agent-watcher/gc-worktrees.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# gc-worktrees.sh — Manual garbage-collector for orphaned agent worktrees. +# +# Scans ~/git/.agent-worktrees/<gid>/<repo>/ and, for each, asks Asana what the +# task's agent_status is. A worktree is an ORPHAN candidate when: +# - the task's agent_status is "Complete", OR +# - the task no longer exists (deleted in Asana). +# In-flight tasks (Planning/Developing/Reviewing/Testing) are always left alone. +# +# RETENTION CAP: orphan candidates are NOT all reaped. The newest --keep of them +# (by worktree mtime) are retained for inspection/resume; only the older ones are +# reaped. This mirrors the rc-watchdog retention policy so a manual run won't +# silently destroy worktrees the watchdog is deliberately keeping. --keep defaults +# to .watcher.keep_completed_worktrees from asana-config.json (fallback 5). Pass +# --all to reap every orphan (keep=0, the pre-retention behavior). +# +# Teardown reuses cleanup-task-workspace.sh (worktree+branch) and, when slots.json +# still holds the slot, delete-ios-sim.sh (sim) + slots.js release (slot entry). +# +# This is NOT on launchd — run it by hand when you suspect leaked worktrees +# (e.g. after a crash or reboot left sessions half-cleaned). +# +# Usage: +# gc-worktrees.sh [--dry-run] [--keep N | --all] +# +# Exit codes: +# 0 = scan complete (orphans removed, or none found) +# 1 = error (missing config/credentials) +# 2 = usage error + +set -euo pipefail + +DIR="$HOME/.config/agent-watcher" +WORKTREES_ROOT="$HOME/git/.agent-worktrees" +CONFIG="$DIR/asana-config.json" +CRED="$DIR/credentials.json" + +DRY_RUN=false +KEEP="" # empty → resolve from config below; --keep N overrides; --all sets 0 +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --keep) KEEP="$2"; shift 2 ;; + --all) KEEP=0; shift ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +[[ -f "$CONFIG" && -f "$CRED" ]] || { echo "Missing $CONFIG or $CRED" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "jq not found" >&2; exit 1; } + +if [[ ! -d "$WORKTREES_ROOT" ]]; then + echo ">> gc-worktrees: no worktrees root ($WORKTREES_ROOT) — nothing to do" + exit 0 +fi + +TOKEN=$(jq -r .asana_token "$CRED") +FIELD_GID=$(jq -r .custom_fields.agent_status.gid "$CONFIG") + +# Resolve the retention cap (newest N orphans kept). Default from config, fallback 5. +if [[ -z "$KEEP" ]]; then + KEEP=$(jq -r '.watcher.keep_completed_worktrees // 5' "$CONFIG") +fi +[[ "$KEEP" =~ ^[0-9]+$ ]] || { echo "Invalid --keep value: $KEEP" >&2; exit 2; } + +# Returns the agent_status name, or "__MISSING__" if the task 404s, or "" on error. +fetch_status() { + local gid="$1" + local resp + resp=$(curl -sS -H "Authorization: Bearer $TOKEN" \ + "https://app.asana.com/api/1.0/tasks/$gid?opt_fields=custom_fields.gid,custom_fields.enum_value.name" 2>/dev/null || echo '') + [[ -z "$resp" ]] && { echo ""; return; } + if echo "$resp" | jq -e '.errors[]? | select(.message | test("Not a recognized ID|does not exist"; "i"))' >/dev/null 2>&1; then + echo "__MISSING__"; return + fi + echo "$resp" | jq -r --arg f "$FIELD_GID" '.data.custom_fields[]? | select(.gid==$f) | .enum_value.name // ""' +} + +# Pass 1: classify every worktree. In-flight ones are left alone immediately. +# Complete/missing ones are orphan candidates, collected with their mtime so we +# can retain the newest $KEEP and reap only the rest. +ORPHANS=() # entries: "<mtime>\t<gid>\t<repo>\t<reason>" +kept_inflight=0 +for giddir in "$WORKTREES_ROOT"/*/; do + [[ -d "$giddir" ]] || continue + gid=$(basename "$giddir") + for repodir in "$giddir"*/; do + [[ -d "$repodir" ]] || continue + repo=$(basename "$repodir") + status=$(fetch_status "$gid") + + if [[ "$status" == "Complete" || "$status" == "__MISSING__" ]]; then + reason=$([[ "$status" == "__MISSING__" ]] && echo "task-deleted" || echo "Complete") + mtime=$(stat -f "%m" "$repodir") + ORPHANS+=("${mtime}"$'\t'"${gid}"$'\t'"${repo}"$'\t'"${reason}") + else + echo ">> gc-worktrees: keep $gid/$repo (in-flight: agent_status=${status:-unknown})" + kept_inflight=$((kept_inflight + 1)) + fi + done +done + +# Pass 2: newest $KEEP orphans are retained; the rest are reaped (oldest first). +removed=0 +retained=0 +if [[ ${#ORPHANS[@]} -gt 0 ]]; then + idx=0 + while IFS=$'\t' read -r _mtime gid repo reason; do + if [[ $idx -lt $KEEP ]]; then + echo ">> gc-worktrees: retain $gid/$repo ($reason; within keep=$KEEP)" + retained=$((retained + 1)) + else + echo ">> gc-worktrees: REAP $gid/$repo ($reason; beyond keep=$KEEP)" + if ! $DRY_RUN; then + # Tear down sim from the slot record (if any) before dropping the slot. + sim_udid=$(node "$DIR/lib/slots.js" get --task-gid "$gid" 2>/dev/null | jq -r '.sim_udid // empty' 2>/dev/null || true) + [[ -n "$sim_udid" ]] && "$DIR/delete-ios-sim.sh" --udid "$sim_udid" || true + "$DIR/cleanup-task-workspace.sh" --task-gid "$gid" --repo "$repo" || true + node "$DIR/lib/slots.js" release --task-gid "$gid" >/dev/null 2>&1 || true + fi + removed=$((removed + 1)) + fi + idx=$((idx + 1)) + done < <(printf '%s\n' "${ORPHANS[@]}" | sort -rn -t$'\t' -k1,1) +fi + +echo ">> gc-worktrees: done — keep=$KEEP, ${retained} completed worktree(s) retained, ${removed} $([[ $DRY_RUN == true ]] && echo "would be reaped" || echo "reaped"), ${kept_inflight} in-flight kept" +exit 0 diff --git a/agent-watcher/lib/slots.js b/agent-watcher/lib/slots.js new file mode 100755 index 0000000..0b8d5b8 --- /dev/null +++ b/agent-watcher/lib/slots.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// lib/slots.js — Slot allocator for parallel agent sessions. +// +// A "slot" is one parallel agent lane. Each slot owns a worktree, a cloned iOS +// simulator, and a Metro port. Slot accounting is persisted to slots.json so the +// watcher (allocator), the watchdog (reaper), resume-agent, and gc-worktrees all +// agree on what's live. +// +// State file: ${XDG_STATE_HOME:-~/.local/state}/agent-watcher/slots.json +// { "slots": [ { slot_index, task_gid, worktree_path, sim_udid, metro_port, spawned_at }, ... ] } +// +// Concurrency contract (smoke check D5): every read-modify-write goes through +// withLock() — an exclusive lock file acquired via O_CREAT|O_EXCL with a spin + +// stale-lock steal — and every write is atomic (tmpfile in the same dir + rename). +// Two concurrent writers therefore serialize and can never produce a torn file. +// +// Used as a library (require) by asana-watcher.js / rc-watchdog.js, AND as a CLI +// by the shell helpers (setup/cleanup/gc/resume): +// node lib/slots.js list +// node lib/slots.js get --task-gid <gid> +// node lib/slots.js allocate --task-gid <gid> --worktree-path <p> --sim-udid <u> [--metro-port <n>] +// node lib/slots.js release --task-gid <gid> +// node lib/slots.js metro-port --slot-index <n> +// +// Exit codes (CLI): 0 = ok, 1 = runtime error, 2 = usage error. + +'use strict' + +const fs = require('node:fs') +const path = require('node:path') + +const HOME = process.env.HOME || '' +const DIR = path.join(HOME, '.config/agent-watcher') +// Machine-local state lives under XDG state (not the committed config dir). +const STATE_DIR = process.env.XDG_STATE_HOME + ? path.join(process.env.XDG_STATE_HOME, 'agent-watcher') + : path.join(HOME, '.local/state/agent-watcher') +const SLOTS_PATH = process.env.AGENT_SLOTS_PATH || path.join(STATE_DIR, 'slots.json') +const LOCK_PATH = `${SLOTS_PATH}.lock` +const METRO_BASE_PORT = parseInt(process.env.AGENT_METRO_BASE_PORT || '8081', 10) +const LOCK_TIMEOUT_MS = 10_000 +const LOCK_STALE_MS = 30_000 + +function sleepSync(ms) { + // Block without spinning the CPU and without async — Atomics.wait on a throwaway buffer. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +function acquireLock() { + const start = Date.now() + for (;;) { + try { + const fd = fs.openSync(LOCK_PATH, 'wx') // exclusive create — throws EEXIST if held + fs.writeSync(fd, `${process.pid} ${new Date().toISOString()}`) + fs.closeSync(fd) + return + } catch (e) { + if (e.code !== 'EEXIST') throw e + // Lock is held. Steal it if it's stale (holder probably died mid-write). + try { + const st = fs.statSync(LOCK_PATH) + if (Date.now() - st.mtimeMs > LOCK_STALE_MS) { + fs.unlinkSync(LOCK_PATH) + continue + } + } catch { + // lock vanished between EEXIST and stat — retry immediately + continue + } + if (Date.now() - start > LOCK_TIMEOUT_MS) { + throw new Error(`slots: could not acquire ${LOCK_PATH} within ${LOCK_TIMEOUT_MS}ms`) + } + sleepSync(25) + } + } +} + +function releaseLock() { + try { fs.unlinkSync(LOCK_PATH) } catch { /* already gone — fine */ } +} + +function readSlotsRaw() { + try { + const parsed = JSON.parse(fs.readFileSync(SLOTS_PATH, 'utf8')) + if (parsed && Array.isArray(parsed.slots)) return parsed + } catch { /* missing or corrupt → start clean */ } + return { slots: [] } +} + +function writeSlotsAtomic(state) { + fs.mkdirSync(path.dirname(SLOTS_PATH), { recursive: true }) + const tmp = `${SLOTS_PATH}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}` + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n') + fs.renameSync(tmp, SLOTS_PATH) // atomic on the same filesystem +} + +function withLock(fn) { + acquireLock() + try { + return fn() + } finally { + releaseLock() + } +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +function list() { + return readSlotsRaw().slots +} + +function get(taskGid) { + return readSlotsRaw().slots.find((s) => s.task_gid === taskGid) || null +} + +function metroPortForIndex(slotIndex) { + return METRO_BASE_PORT + slotIndex +} + +// Lowest non-negative integer not already in use. +function firstFreeIndex(slots) { + const used = new Set(slots.map((s) => s.slot_index)) + let i = 0 + while (used.has(i)) i++ + return i +} + +// Idempotent: re-allocating an existing task_gid returns its current slot unchanged. +function allocate({ task_gid, worktree_path, sim_udid, metro_port }) { + if (!task_gid) throw new Error('allocate: task_gid is required') + return withLock(() => { + const state = readSlotsRaw() + const existing = state.slots.find((s) => s.task_gid === task_gid) + if (existing) return existing + const slotIndex = firstFreeIndex(state.slots) + const entry = { + slot_index: slotIndex, + task_gid, + worktree_path: worktree_path || null, + sim_udid: sim_udid || null, + metro_port: metro_port != null ? metro_port : metroPortForIndex(slotIndex), + spawned_at: new Date().toISOString(), + } + state.slots.push(entry) + state.slots.sort((a, b) => a.slot_index - b.slot_index) + writeSlotsAtomic(state) + return entry + }) +} + +// Idempotent: releasing an unknown task_gid is a no-op. Returns the removed entry or null. +function release(taskGid) { + if (!taskGid) throw new Error('release: task_gid is required') + return withLock(() => { + const state = readSlotsRaw() + const idx = state.slots.findIndex((s) => s.task_gid === taskGid) + if (idx === -1) return null + const [removed] = state.slots.splice(idx, 1) + writeSlotsAtomic(state) + return removed + }) +} + +module.exports = { list, get, allocate, release, metroPortForIndex, SLOTS_PATH, STATE_DIR } + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +function parseFlags(argv) { + const out = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a.startsWith('--')) out[a.slice(2)] = argv[++i] + } + return out +} + +function main() { + const [cmd, ...rest] = process.argv.slice(2) + const f = parseFlags(rest) + switch (cmd) { + case 'list': + process.stdout.write(JSON.stringify(list(), null, 2) + '\n') + return 0 + case 'get': { + if (!f['task-gid']) { console.error('get: --task-gid required'); return 2 } + const e = get(f['task-gid']) + process.stdout.write((e ? JSON.stringify(e, null, 2) : '') + '\n') + return e ? 0 : 0 + } + case 'allocate': { + if (!f['task-gid']) { console.error('allocate: --task-gid required'); return 2 } + const e = allocate({ + task_gid: f['task-gid'], + worktree_path: f['worktree-path'], + sim_udid: f['sim-udid'], + metro_port: f['metro-port'] != null ? parseInt(f['metro-port'], 10) : undefined, + }) + process.stdout.write(JSON.stringify(e, null, 2) + '\n') + return 0 + } + case 'release': { + if (!f['task-gid']) { console.error('release: --task-gid required'); return 2 } + const e = release(f['task-gid']) + process.stdout.write((e ? JSON.stringify(e, null, 2) : '') + '\n') + return 0 + } + case 'metro-port': { + if (f['slot-index'] == null) { console.error('metro-port: --slot-index required'); return 2 } + process.stdout.write(metroPortForIndex(parseInt(f['slot-index'], 10)) + '\n') + return 0 + } + default: + console.error('usage: slots.js <list|get|allocate|release|metro-port> [flags]') + return 2 + } +} + +if (require.main === module) { + try { + process.exit(main()) + } catch (e) { + console.error(`slots: ${e.message}`) + process.exit(1) + } +} diff --git a/agent-watcher/mem-trace.sh b/agent-watcher/mem-trace.sh new file mode 100755 index 0000000..c7b5c36 --- /dev/null +++ b/agent-watcher/mem-trace.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# mem-trace.sh — Persistent memory-growth logger. +# +# Logs top 15 RSS consumers + load + total proc count every $INTERVAL seconds. +# Designed to leave running during a normal workflow so growth patterns become +# obvious post-hoc. +# +# Usage: +# ~/.config/agent-watcher/mem-trace.sh [--interval 30] [--out /tmp/mem-trace.log] +# +# Output format (one tick per N seconds): +# == 16:21:22 | load=6.57 procs=959 free=42G == +# 2440MB Edge +# 2240MB Xcode +# ... (top 15) +# +# Stop with Ctrl-C. Analyze with `awk` / `grep` / eyeballing. + +set -u + +INTERVAL=30 +OUT="/tmp/mem-trace.log" + +while [[ $# -gt 0 ]]; do + case "$1" in + --interval) INTERVAL="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +echo "mem-trace: logging to $OUT every ${INTERVAL}s. Ctrl-C to stop." >&2 +echo "" >> "$OUT" +echo "### mem-trace START $(date) ###" >> "$OUT" + +while true; do + TS=$(date +"%H:%M:%S") + LOAD=$(uptime | awk -F'load averages:' '{print $2}' | awk '{print $1}') + PROCS=$(ps -ax | wc -l | tr -d ' ') + # Free + speculative pages count as "available" for our purposes + FREE_PAGES=$(vm_stat | awk '/Pages free:/ {gsub("\\.",""); print $3}') + PAGE_SIZE=$(sysctl -n hw.pagesize) + FREE_GB=$(awk -v p="$FREE_PAGES" -v ps="$PAGE_SIZE" 'BEGIN {printf "%.1f", p*ps/1024/1024/1024}') + { + echo "" + echo "== ${TS} | load=${LOAD} procs=${PROCS} free=${FREE_GB}GB ==" + ps -axo rss,comm | sort -k1 -nr | head -15 | awk '{ + cmd = $2; n = split(cmd, a, "/"); short = a[n] + printf " %5dMB %s\n", $1/1024, short + }' + } >> "$OUT" + sleep "$INTERVAL" +done diff --git a/agent-watcher/memory-monitor.sh b/agent-watcher/memory-monitor.sh new file mode 100755 index 0000000..b1e69b8 --- /dev/null +++ b/agent-watcher/memory-monitor.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# memory-monitor.sh — Sample macOS memory state, classify as green/warn/critical, +# alert on state changes that worsen. +# +# Notification mechanism (per macOS Sequoia constraints): +# critical → modal `display alert` (backgrounded, like ~/.bin/config-watch.sh) +# warn → subtle system sound (`afplay Tink.aiff`) + log +# recovery → log only (no UI interruption) +# +# `display notification` and terminal-notifier silently fail on Sequoia due to +# signing/bundle restrictions; modal alerts and afplay are the reliable paths. +# +# Designed for a 128 GB machine; thresholds scale by total RAM. +# +# State transitions: +# green → warn → sound + log +# green → critical → modal + log +# warn → critical → modal + log +# * → green → log only +# same level twice → no action +# +# State at ${XDG_STATE_HOME:-~/.local/state}/agent-watcher/memory-monitor.state +# Log at /tmp/memory-monitor.log + +set -uo pipefail + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher"; mkdir -p "$STATE_DIR" +STATE_FILE="$STATE_DIR/memory-monitor.state" +LOG_FILE="/tmp/memory-monitor.log" + +TOTAL_BYTES=$(sysctl -n hw.memsize) +PAGE_SIZE=$(sysctl -n hw.pagesize) + +vmstat_pages() { + vm_stat | awk -v key="$1" -v ps="$PAGE_SIZE" ' + $0 ~ key { gsub("\\.", "", $NF); print $NF * ps } + ' +} + +FREE_B=$(vmstat_pages "Pages free") +COMPRESSOR_B=$(vmstat_pages "Pages occupied by compressor") +PURGEABLE_B=$(vmstat_pages "Pages purgeable") + +# Parse swap "used = 0.00M" → bytes +SWAP_USED_B=$(sysctl -n vm.swapusage | awk -F'used = ' '{print $2}' | awk '{ + v = $1; unit = substr(v, length(v)) + num = substr(v, 1, length(v)-1) + 0 + if (unit == "M") print num * 1024 * 1024 + else if (unit == "G") print num * 1024 * 1024 * 1024 + else if (unit == "K") print num * 1024 + else print num +}') + +AVAIL_B=$(( FREE_B + PURGEABLE_B )) + +# Hundredths-of-percent (4500 = 45.00%) +pct() { echo $(( $1 * 100000 / TOTAL_BYTES )); } +AVAIL_P=$(pct $AVAIL_B) +COMP_P=$(pct $COMPRESSOR_B) + +CRIT_AVAIL=150 +CRIT_COMP=5000 +WARN_AVAIL=600 +WARN_COMP=2500 + +LEVEL="green" +REASON="" +if [[ "$AVAIL_P" -lt "$CRIT_AVAIL" ]] || [[ "$COMP_P" -gt "$CRIT_COMP" ]] || [[ "$SWAP_USED_B" -gt 0 ]]; then + LEVEL="critical" + REASON="avail=$((AVAIL_B/1024/1024/1024))GB comp=$((COMPRESSOR_B/1024/1024/1024))GB swap=$((SWAP_USED_B/1024/1024))MB" +elif [[ "$AVAIL_P" -lt "$WARN_AVAIL" ]] || [[ "$COMP_P" -gt "$WARN_COMP" ]]; then + LEVEL="warn" + REASON="avail=$((AVAIL_B/1024/1024/1024))GB comp=$((COMPRESSOR_B/1024/1024/1024))GB" +fi + +# Top 3 RSS consumers — useful for both alerts and the log +TOP3=$(ps -axro rss,comm 2>/dev/null | sort -k1 -nr | head -3 | awk '{ + cmd = $2; if (length(cmd) > 40) cmd = substr(cmd, 1, 40) "…" + printf "%s(%.1fGB)\n", cmd, $1/1024/1024 +}' | tr '\n' ' ') + +PREV_LEVEL=$(cat "$STATE_FILE" 2>/dev/null || echo "green") +TS=$(date '+%H:%M:%S') + +echo "$TS level=$LEVEL avail=$((AVAIL_B/1024/1024/1024))GB comp=$((COMPRESSOR_B/1024/1024/1024))GB swap=$((SWAP_USED_B/1024/1024))MB top3=[$TOP3]" >> "$LOG_FILE" + +# Notify on transition +if [[ "$LEVEL" != "$PREV_LEVEL" ]]; then + case "$LEVEL" in + critical) + # Modal alert — pattern from ~/.bin/config-watch.sh. Backgrounded so the + # poller returns immediately even if the alert is left open. + ESC_TITLE="Memory pressure — CRITICAL" + ESC_MSG="$REASON\n\nTop processes:\n$TOP3\n\nConsider quitting Xcode (lldb-rpc-server is the usual leaker)." + # CTA detaches the `open` call so it runs AFTER the modal closes and + # macOS finishes restoring focus (otherwise the focus-restore race wins + # and Activity Monitor stays buried). `do shell script` is more reliable + # than `tell application X to activate` from launchd-spawned osascript + # (the latter silently fails without Automation permission on Sequoia). + /usr/bin/osascript -e " + set ans to button returned of (display alert \"$ESC_TITLE\" message \"$ESC_MSG\" as critical buttons {\"Open Activity Monitor\", \"Dismiss\"} default button \"Open Activity Monitor\") + if ans is \"Open Activity Monitor\" then + do shell script \"(sleep 0.3; /usr/bin/open -a 'Activity Monitor') >/dev/null 2>&1 &\" + end if + " >/dev/null 2>&1 & + disown 2>/dev/null || true + ;; + warn) + # Subtle audio cue + log. No modal — don't interrupt for a warning. + afplay /System/Library/Sounds/Tink.aiff >/dev/null 2>&1 & + disown 2>/dev/null || true + echo "$TS WARN transition: $REASON | top3=[$TOP3]" >> "$LOG_FILE" + ;; + green) + # Recovery — log only. + echo "$TS recovered to green: avail=$((AVAIL_B/1024/1024/1024))GB" >> "$LOG_FILE" + ;; + esac + echo "$LEVEL" > "$STATE_FILE" +fi diff --git a/agent-watcher/oom-repro/HANDOFF.md b/agent-watcher/oom-repro/HANDOFF.md new file mode 100644 index 0000000..36d80f8 --- /dev/null +++ b/agent-watcher/oom-repro/HANDOFF.md @@ -0,0 +1,554 @@ +# OOM investigation — handoff + +This doc captures everything a fresh session needs to pick up the investigation. + +> **★ ACTUAL ROOT CAUSE FOUND — 2026-05-28 ~13:35 PDT (supersedes everything below)** +> +> **The OOM is caused by recursive claude-code session spawning, not npm install and not SentinelOne.** +> +> Two confirmed incidents, same mechanism: +> - **Overnight 02:17 AM**: agent-orchestration session `afeb9f53` (Asana task 1215201512214395, "Zcash ZIP-321 deeplink") spawned ~1500 `cli` (claude-code node) processes in ~5 min → VM compressor 0→70 GB → ~15 GB swap → jetsam killed ~1880 procs. +> - **Live 13:34 PM**: the SAME session was resumed (`claude --resume afeb9f53`, launched manually from a Warp shell) and re-detonated — caught it growing at ~475 procs/sec, compressor at 64 GB, swap exhausted to 471 MB free, total procs 4371. Killed it live. +> +> **Mechanism**: the session ran a `/loop` / "babysit PR until green" pattern under `--remote-control`. On resume, the loop re-arms by spawning a new `claude`, which spawns another — an unbounded self-replicating CHAIN (each `cli` spawns exactly one child `cli`), all sharing ONE process group, orphaning to launchd as parents detach. The `anon<node>` processes jetsam killed overnight were these `cli`, not npm workers. +> +> **The kill that works**: `kill -9 -<PGID>` (atomic process-group kill). `pkill -x cli` FAILS — the chain self-replicates faster than non-atomic kills clear it. During the live incident all 564 procs shared pgid 74806; one `kill -9 -74806` ended it. `pkill`/`pkill -STOP` loops lost the race repeatedly. +> +> **Toxic sessions — DO NOT RESUME** (each re-detonates on resume): +> - `afeb9f53-0509-490a-88fa-eb1ee6d094da` (task 1215201512214395) — confirmed bad, both incidents +> - `4373931a-397e-46d8-9515-122e588f038e` — killed alongside, likely same pattern +> - 3 other agent-worktree sessions exist (1098d0a2, 4af85ca3, e1dafd07, 86383ed4) — same orchestration, treat as suspect until inspected +> +> **Prevention now in place**: `~/.config/agent-watcher/runaway-guard.sh` + `com.jontz.runaway-guard` launchd job (loaded). Every 60s (3s inner cadence) it counts `cli` per process group and `kill -9 -<PGID>` any group ≥50 (RUNAWAY_CLI_THRESHOLD). Legit claude workflows fan out flat at ≤16 concurrent/group, so 50 is a safe separator. Logs to `~/.config/agent-watcher/runaway-guard.log`. +> +> **What this means for prior conclusions**: the APFS-clone fix in setup-task-workspace.sh (added 2026-05-28) addresses npm-install storms, now understood as a SECONDARY concern, not the trigger. The SentinelOne and code-signature findings below are real but unrelated to the OOM. The deep-research report (2026-05-28) is valid; its jetsam-mechanism findings are folded into the "macOS jetsam behavior" section below. +> +> **Box facts measured during the incident** (research had flagged these as unknown/refuted): +> - `kern.maxproc=16000`, `kern.maxprocperuid=10666` (NOT the 532/100 some docs claim) — so the cascade was memory-pressure-driven, not a process-count-limit hit; the chain peaked ~2800 procs, far below 16000. +> - `vm.compressor_mode=4` (compression+swap, default). Do NOT set to 2/disable swap — research confirms that makes jetsam fire SOONER. +> - `sysctl kern.memorystatus_vm_pressure_level` → 1=NORMAL 2=WARN 4=CRITICAL is a pollable early-warning signal (but node/CLI procs don't get the redemption path that UIKit apps do, so it's only useful for the monitor, not self-defense). +> +> --- +> +> **⚠ EARLIER VERDICT — 2026-05-27 ~12:00 PDT (still valid, but not the OOM cause)** +> +> **SentinelOne is NOT the OOM cause.** Confirmed by stopping the agent entirely and observing identical hang behavior. The T2 synthetic test (which initially looked like a smoking gun) was discovered to be testing a **macOS code-signature verification edge case**, not a real-world workflow problem: +> +> - `cp /bin/echo new_path; new_path` → hangs ~7s at `_dyld_start` (Apple's designated-requirement check) +> - `cp /bin/echo new_path; codesign --sign - new_path; new_path` → runs in 22 ms (ad-hoc re-sign removes the check) +> - Identical behavior with SentinelOne **stopped** — definitive proof +> +> Real workflows (Xcode builds, npm install, configure scripts) don't clone Apple-signed bootstrap binaries, so they don't hit this path. The OOM that prompted this investigation is **still un-root-caused**. Re-focus on the original suspect list (Xcode 26.3 + iOS sim runtime, lldb-rpc growth, Edge.app JS heap, Spotlight). See **"Refocused investigation plan"** section below. +> +> **What's still useful from this investigation:** +> - Persistent logger (now capturing memory + load + Claude.app + procs every 30s) — runs continuously +> - Claude.app finding (active streaming = wedge, static DOM = cheap) — actionable behavior change +> - SentinelOne path exclusions you added (DerivedData, .npm, .nvm, Caches in Performance Focus mode) — harmless and provide minor file-scan benefit; leave them in place + +## TL;DR + +Jon's Mac OOMed (became unresponsive) yesterday during normal Edge dev work on the "large" account. + +**Two adjacent UI-wedge issues observed during this session (2026-05-26)** — both are SEPARATE from the OOM investigation, but they look superficially like an OOM (UI unresponsive) so they need to be ruled out first when diagnosing. Memory was healthy throughout both (76 GB free, 5.7 GB compressor, no swap growth, load avg 4.5). + +### Issue A: WindowServer / UI subsystem wedge (~18:45 PDT) +Apps in the dock unresponsive to clicks, top menu bar disappeared, keyboard input only worked in the foreground app (Claude.app). Initially diagnosed as `WindowServer` at 42-47% CPU sustained, not memory. + +Attempted recoveries: +- `killall AltTab` → made the menu bar disappear (AltTab.app was holding WindowServer state) +- `killall Dock && killall SystemUIServer` → respawned, brought menu bar + dock back visually, but apps still unresponsive to clicks +- `killall karabiner_console_user_server` → didn't restore input to other apps + +Suspect: third-party WindowServer hooks (AltTab.app) + many concurrent Electron renderers (Cursor, Claude, Asana, Slack, Arc, Warp) accumulating state over a multi-hour session may have leaked into WindowServer. + +### Issue B: Claude.app renderer overload — likely root cause of Issue A +Investigation continued and revealed: `Claude Helper (Renderer)` was at 34-45% CPU and `Claude Helper` at 39-41% CPU, sustained. **Combined ~76-85% CPU on Claude.app alone.** The renderer was re-drawing the entire conversation DOM on every tool-result update. + +Within Claude.app's chat textarea: the text cursor disappeared, only-type-at-end behavior. This is Electron-renderer-pegged symptoms — input events arriving fine, but the renderer too CPU-busy to redraw cursor position in real time. + +Cause: this conversation accumulated many hours of dense tool calls (vm_stat dumps, ps outputs, large code edits, file writes, MCP responses). Each new tool result triggered the renderer to re-walk + re-paint a huge DOM tree. + +**Likely the proximate cause of Issue A** — Claude.app's renderer hammering WindowServer with frequent graphics updates may have starved the rest of the UI from getting GPU/compositor time. AltTab.app + other Electron apps amplified the strain. + +### What to do about it +- Don't run multi-hour Claude.app sessions for tool-heavy investigations. The renderer accumulates load. +- If Claude.app's renderer goes above ~30% CPU sustained, the conversation is too big; end and start a fresh one (the chat history is server-side, so context can be re-loaded). +- For sustained heavy work, prefer Cursor's Claude integration or the web version — both have different rendering models. +- Recovery path when wedged: `launchctl bootout user/$(id -u)` (logs out, kills Claude.app, frees WindowServer) — faster than reboot, conversation survives in the cloud. +- Hard reboot (hold power 10s) is the bulletproof fallback when even keyboard input outside the foreground app is dead. + +### Why this matters for the OOM investigation +Three reasons to keep them separate: +1. **Memory was healthy during Issue A** — vm_stat showed 76 GB free. Anyone running the OOM diagnostic during a UI wedge will see misleading "system unresponsive" symptoms and might incorrectly conclude OOM. Always check `vm_stat | grep -E "free|compressor|Swapouts"` BEFORE blaming memory. +2. **The trace logger captures only memory + load, not WindowServer/renderer CPU**. If the next investigation hits unresponsiveness, also capture `ps -axo pid,pcpu,rss,comm | sort -k2 -nr | head -10` to distinguish. +3. **Yesterday's actual OOM was during Edge dev work, no Claude.app involvement** — so Issue B isn't the cause of yesterday's event. But Issue B can MASK an OOM if both happen at once, and the symptoms overlap enough that an unwary diagnostic could conflate them. + Suspects in rough priority order: + +1. **Xcode 26.3 + iOS 18 sim runtime footprint** — 21 GB of sim-side processes (223 of them) plus 5.5 GB lldb-rpc-server per attached debug session. This is new since the laptop migration. +2. **SentinelOne EDR** — newly installed (today). Kernel-level interceptor for fork/exec/connect. Hypothesized to add fork-serialization tax during heavy spawn bursts. +3. **Edge.app's JS heap on the large account** — gradual growth observed (~200 MB/min during active use, ~400 MB/min during Cmd+Play cycles). Resets on relaunch. +4. **Spotlight (`mds_stores`) reindex storms** — post-reboot catch-up + DerivedData churn during build cycles. + +User's existing memory monitor (`com.jontz.memory-monitor`) didn't fire warnings before the OOM. Likely causes: +- 30s polling too slow for minute-scale spikes +- Modal alert via launchd-spawned osascript is fragile on Sequoia (silent failure) +- State machine only fires on level transition, no re-fire +- Thresholds too lenient (critical at avail<1.5% = avail<1.9GB on 128GB box) + +## Hardening Jon already has in place + +- `~/.npmrc` and `~/git/edge-react-gui/.npmrc`: `ignore-scripts=true` (blocks postinstall RCE) +- `~/.npmrc`: `min-release-age=7` (refuses packages <7 days old) +- `NPM_TOKEN` from env, not plaintext in npmrc + +These materially reduce supply-chain attack surface, which means SentinelOne file-write monitoring on `node_modules` provides marginal additional protection. Important context for the SentinelOne exclusion decision. + +## What's installed + +``` +~/.config/agent-watcher/oom-repro/ +├── HANDOFF.md # this file +├── scripts/ +│ ├── mem-trace-persistent.sh # Logger — one-shot, ~10MB transient, exits immediately +│ ├── install.sh # Loads/unloads the launchd job +│ └── oom-repro-suite.sh # Test driver T0–T6 +└── logs/ + ├── trace-YYYY-MM-DD.log # Persistent trace, one line per 30s, daily rotation, 7-day retention + └── tests/ + └── T<N>-YYYYMMDD-HHMMSS.log # Per-test snapshots (before/after) + +~/Library/LaunchAgents/com.jontz.mem-trace.plist # 30s scheduler for the persistent logger +``` + +The persistent logger is **loaded and running**. Survives reboots. Confirm with: +```bash +~/.config/agent-watcher/oom-repro/scripts/install.sh --status +``` + +## Existing related infra (separate concerns) + +| Label | Purpose | Status | +|---|---|---| +| `com.jontz.memory-monitor` | Jon's existing alerting monitor — modal on critical, audio on warn, log-only on recovery | Loaded. Thresholds too lenient (see "what we know"). Logs to `/tmp/memory-monitor.log`. | +| `com.jontz.asana-watcher` | Polls Asana for Pending agent tasks, spawns tmux sessions to run them | Loaded, idle. Not contributing to OOM. | +| `com.jontz.rc-watchdog` | Watches `claude-asana-*` tmux sessions for liveness | Loaded, idle. Not contributing to OOM. | +| `com.jontz.config-watch` | Watches security-sensitive config files (Cursor settings, .zshrc, etc.) for drift | Loaded, last exit 1 (= drift detected, expected). Not contributing to OOM. | +| `com.jontz.mem-trace` | **NEW** — the persistent OOM trace logger | Loaded. | +| `com.jontz.runaway-guard` | **NEW (2026-05-28)** — kills runaway `cli` fork-chain process groups (≥50 cli/PGID) via atomic `kill -9 -PGID`. 60s launchd interval, 3s inner cadence. The backstop against recursive claude-spawn OOMs. | Loaded. Logs to `~/.config/agent-watcher/runaway-guard.log` (only writes on a kill). | + +None of the agent-watcher launchd jobs run anything heavy. They spawn briefly, do their work, exit. + +## What we observed today (post-reboot at ~16:00 PDT) + +Reasonably-controlled trace data exists in `~/.config/agent-watcher/oom-repro/logs/trace-2026-05-26.log` from 17:18 onward, and in `/tmp/mem-trace.log` (earlier, may rotate away). + +Highlights from earlier in the session: +- **lldb-rpc-server**: 5.5 GB baseline when Xcode is attached to a sim. Drops to ~1 GB when sim is shut down. NOT a continuous leak — high allocation at attach time, slow creep (~50 MB per Cmd+Play cycle). +- **Sim subsystem**: 223 processes, total 21.12 GB RSS when iOS 18 iPhone 16 Pro Max is booted. Includes SpringBoard, WebKit, NewsToday2, healthappd, MobileCal, PosterBoard, etc. The iOS subsystem is heavier in Xcode 26.3 than in earlier versions. +- **Edge.app growth during active use**: 200 MB/min in observation; spikes to 400 MB/min during Cmd+Play. Resets to ~0.9 GB on each relaunch. +- **Process-spawn benchmark results (with SentinelOne fully active, warm caches)**: + - 200 parallel `node -e exit`: 467 ms (2.3 ms amortized per process) + - 5000 file writes in `/tmp`: 381 ms + - These are FAST. The benchmarks don't show heavy SentinelOne tax on warm cached operations. **Cold-cache behavior is unmeasured.** + +The "68 GB compressor + 9.4 GB swap" reading earlier in the session was a **transient spike caused by the 200-parallel-node-spawn benchmark itself** (12 GB burst → kernel emergency-compressed inactive pages). The compressor was reclaimed to <10 GB within 2 minutes. NOT evidence of a real OOM event in this session. + +## ⚠ SUPERSEDED — kept as audit trail of how the wrong conclusion was reached + +> The findings below were based on a flawed proxy (T2 cold-binary test). See the headline VERDICT at the top of this file. Skip past these to "Refocused investigation plan" unless you specifically want to audit how the investigation went sideways and back on track. + +## ~~Confirmed~~ Superseded finding (2026-05-26 19:05, T2 run) + +**SentinelOne creates SIGKILL-proof zombies on uncached binary spawn.** + +Ran `oom-repro-suite.sh T0 T1 T2`. T1 (cached `node -e exit`) scaled cleanly: +- 500 procs: 1554 ms (3 ms/proc) +- 1000 procs: 3076 ms (3 ms/proc) +- 2000 procs: 6482 ms (3 ms/proc) +- No compressor pressure, no swap, memory steady at ~35 GB free. + +T2 (100 unique never-seen binaries — `cp /bin/echo /tmp/oom-repro-uncached/x$i`, spawn all 100 in parallel, `wait`) **hung indefinitely**. 19 minutes later: +- 99 of 100 procs still alive (one survived past SentinelOne's gate; 99 stuck) +- `STAT=UE` = uninterruptible wait + trying to exit (the procs called `exit()` but the kernel won't let them through) +- `kill -9` did **not** terminate them — they are SIGKILL-proof +- The parent `wait` blocks until kernel releases the procs; we had to `kill` the suite parent to unstick +- Memory cost: 0 RSS each, but each consumes a PCB slot and inflates `procs` count permanently +- **Only a reboot clears them** — confirmed via `pgrep -af /tmp/oom-repro-uncached/` after SIGKILL attempt + +### Mechanism (inferred) +SentinelOne's kernel interceptor for `execve`/`exit` is performing some verdict on cold binaries that either takes much longer than expected, fails silently, or deadlocks. The verdict gate sits in the exit path, so procs that already called `exit()` get held in UE state. + +### Implications for the OOM event +- Any workflow that bursts one-shot uncached binaries (npm install scripts even with `ignore-scripts=true`, configure helpers, build sub-processes, Xcode build phases invoking shell scripts) leaves residual zombie PCBs. +- Each zombie is small individually but cumulative across a workday. +- This is consistent with the original "minute-scale spike" and "cumulative cliff after hours of work" hypotheses. +- The persistent logger's `procs=N` count is the canary — watch for it climbing without explanation. + +### What this means for SentinelOne action plan +The decision is no longer "do T2 numbers justify exclusions" — they overwhelmingly do. The recommended exclusions in the original plan should be applied. Once applied: +1. Reboot to clear the 100 existing zombies +2. Re-run `OOM_T2_COUNT=10 oom-repro-suite.sh T2` — should complete cleanly in <100 ms (no UE state) +3. If it still hangs, the exclusion didn't reach the kernel interceptor and SentinelOne admin needs to verify + +T2 has been hardened to default to N=10 with a 15s timeout (override with `OOM_T2_COUNT` and `OOM_T2_TIMEOUT` env vars) so reruns can't recreate the 100-zombie situation. + +## Confirmed finding (2026-05-27 ~01:36, T7 + T7-open) + +**Claude.app renderer trigger is active tool streaming, not DOM size at rest.** + +T7 (60s passive sample of this active investigation session): +- peak_renderer = 52.3%, peak_total = 85.5%, avg_total = 64.4% +- Sustained 60–85% total CPU while tool calls were arriving + +T7-open (10s baseline + 60s post-action, with Jon clicking a long historic chat in the sidebar at the audio cue): +- baseline (me still running tools): total 64–69% +- click-moment (action_t+6 to +8s): renderer RSS jumped 515M → 573M, new helper proc appeared (nprocs 10 → 11), brief total-CPU 47% spike +- **settled steady state (action_t+18s onward, no incoming tool calls): renderer 5–7%, total 18–22%** + +Implications: +- A long static DOM is cheap. Opening big historic chats is safe. +- The "long conversation = renderer wedge" rule from yesterday is wrong as stated. The correct rule: **long conversation + active streaming workload (continuous tool results arriving) = renderer wedge.** +- The wedge symptom from yesterday (renderer 34–45% sustained) needed BOTH conditions present. Once Jon stops sending messages, the conversation becomes idle and CPU drops to ~20%. +- Practical: don't bail out of a long investigation session just because it's been hours — bail when YOU are actively driving high tool-call density and the renderer goes >30% sustained. Stale tabs are fine to leave open. + +The persistent logger now records `claude=` on every 30s tick, so this can be retroactively verified across the workday — look for renderer CPU correlating with the timestamps when tool-heavy work was happening. + +## ~~Refined~~ Superseded finding (2026-05-27 ~11:30, post-exclusion T2 diagnostics) + +**The hang is in the macOS Endpoint Security (ES) framework exec-verdict gate, not in any post-exec scan that SentinelOne path exclusions cover.** + +After applying 4 path exclusions in **Agent Interoperability / Performance Focus** mode (the strongest mode this tenant exposes; East Coast Cybersecurity / Pax8 / `usea1-pax8-03.sentinelone.net`) and rebooting: + +- T2 still hangs identically with binaries in `~/Library/Caches/oom-repro-excluded-test/` (excluded path) vs `/tmp/oom-repro-uncached-control/` (non-excluded). 10/10 stuck in both. Confirmed via A/B. +- A **single** cold binary (no parallelism) hangs ~8s+. Each call individually times out. Not load-dependent. +- 5 sequential cold spawns hang one-at-a-time, each ~8s. +- `sample` on a stuck process shows: stuck at `_dyld_start` (the very first dyld instruction) for >22 minutes. Process never reached main(). Physical footprint: 96K, peak 96K — it has never run a single instruction. +- This is the ES framework's `es_event_exec_t` notify→authorize gate: macOS holds the process pre-exec until all ES subscribers respond. SentinelOne's agent (an ES subscriber) is not responding (fast) to verdicts for files in our excluded paths. + +### Why Performance Focus exclusions don't help here + +The Performance Focus description reads: *"The Agent will disable monitoring and all detection engines for processes that run from the excluded path."* The catch is "run from" — the verdict has to be returned **before** the process runs. The exclusion only kicks in after the agent has identified the binary's path, which requires processing the ES authorization call that's currently slow. Chicken-and-egg. + +### What this actually means for the OOM hypothesis + +Two scenarios: +1. **Yesterday's OOM was caused by this** — heavy workflows (Xcode build phases, npm install scripts, configure helpers) spawned many cold binaries; each took 5-30s+ to clear, accumulating UE zombies and load. The fork-side state per zombie is small but cumulative; load average went high; kernel pressure mounted. +2. **Yesterday's OOM was something else** — the synthetic T2 test exercises a path that real workflows may not hit (real exec is of properly-resident binaries, not freshly-copied ones; reputation cache may help). + +We can't distinguish (1) vs (2) until we instrument actual workflows. Suggested A/B test: time a clean Xcode build with SentinelOne in **passive mode** (`sentinelctl protect off` if admin allows, then re-enable). If Xcode is materially faster passive, scenario 1 is real and SentinelOne is the OOM amplifier. If similar, scenario 2. + +### Action items, in order of expected payoff + +1. **Email Pax8 / East Coast Cybersecurity support** asking to enable **Interoperability Extended** (a higher tier than Performance Focus) at the account level. Justify: "Developer workstation; cold binaries hang at dyld_start; path exclusions in Performance Focus do not bypass the ES framework verdict gate. Need Interoperability Extended to skip the exec interceptor for build paths." Attach the dyld_start `sample` output as evidence. +2. **Check "Missing Authorizations"** in `sentinelctl status`. On macOS Sequoia, ES subscribers need Full Disk Access + System Extension + Network Filter + Background Items. If any are missing, the agent may be on a slow fallback IPC path — which would explain why even "trusted" verdicts take seconds. +3. **Real-workflow A/B**: time `xcodebuild clean build` and `npm install` on `edge-react-gui` with current config; compare to numbers with SentinelOne in passive mode. This tells you whether the synthetic T2 result matters in practice. +4. **Stop running T2-style tests** in the meantime — each one creates ~10 permanent UE zombies that only clear on reboot. + +### Suite changes during this session + +- `mem-trace-persistent.sh`: now logs `claude=ren=X%/RSSM gpu=X%/RSSM main=X%/RSSM tot=X%/RSSM n=N` on every 30s tick +- `oom-repro-suite.sh`: added `T7` (60s Claude.app sample) and `T7-open` (guided historic-session trigger test) +- `oom-repro-suite.sh`: T2 hardened — defaults to `OOM_T2_COUNT=10` with `OOM_T2_TIMEOUT=15s` polling instead of indefinite `wait`, so it can no longer wedge the suite. Per-run output reports `stuck_count` explicitly. + +## Tonight's hypothesis test (set up 2026-05-28 ~17:15 PDT) + +**Hypothesis:** the recursive `cli` fork chain is seeded by an agent session running a `/loop`-style PR babysitter (or a resume of one), NOT by npm install or cron accumulation. We never captured the exact spawn line live; tonight's orchestration runs are the chance to confirm/refute. + +**What records it automatically:** +- `runaway-guard.sh` now captures forensics at a RECORD threshold (25 cli in one process group) BEFORE it kills (at 50). Capture runs `capture-runaway-forensics.sh <pgid>`, once per pgid per 10 min. +- `capture-runaway-forensics.sh` writes `~/.config/agent-watcher/oom-repro/forensics/runaway-<ts>-pgid<N>.log` with: an instant full `ps` snapshot (preserves lineage before procs detach), per-pgid cli counts, the offending group's tree, the **SEED ancestor trace** (walk the chain up to the first non-`cli` parent — this is the discriminator), any live `claude --resume/--rc/--yolo/loop` launcher, OS scheduler state (crontab/launchd/atq), tmux panes + start commands, the owning worktree's task gid + that session's last jsonl events, and a stack sample. +- Persistent logger (`mem-trace-persistent.sh`) records `cliCount=` and `pressure=` every 30s → the timeline. + +**How to read it tomorrow (decision tree on the SEED ancestor):** +- Seed is `claude --resume <id>` (esp. repeated/stacking) → resume-driven respawn; tighten the resume path. +- Seed is a `claude` running `/loop` / a `ScheduleWakeup`/cron-fired claude → confirms the `/loop` self-respawn hypothesis; the `/one-shot` Step-6 rewrite (bounded in-process watch) is the fix. +- Seed is `claude --rc` shelling out to another `claude` → something in the session invokes claude directly; find and remove it. +- OS scheduler section shows an armed cron firing claude → orphaned-cron vector; the `bugbot-in-watch` CronDelete rule covers the bugbot case. +- Chain procs all stuck at `_dyld_start` with no live seed → the seed already exited; rely on the instant `ps` snapshot's lineage + the worktree session jsonl tail. + +**If the guard fires tonight:** check `~/.config/agent-watcher/runaway-guard.log` for `RECORD`/`RUNAWAY` lines and read the referenced forensic file. The guard kills the chain at 50, so the box should stay healthy regardless of what we learn. + +## Definitive root cause of the T2 hang (2026-05-27 ~12:00 PDT) + +After ~6 hours of investigation, the cold-binary hang we kept seeing in T2 was **NOT caused by SentinelOne and is NOT related to the original OOM**. It is a macOS code-signature verification edge case. + +### How we proved it + +1. Disabled SentinelOne self-protection (`sentinelctl unprotect`) — T2 still hung identically (14.5s, 0/10 completed). +2. Stopped the full SentinelOne agent (`sentinelctl stop`, confirmed `sentineld` process gone) — T2 **still** hung identically (~14.5s, 0/10). +3. Ran T2-style variants to isolate: + + | Variant | Time | Result | + |---|---|---| + | Direct `/bin/echo` | 22 ms | OK | + | Symlink → `/bin/echo` | 20 ms | OK | + | `cp /bin/echo` (fresh inode, original signature) | **7107 ms** | **STUCK** | + | `cp` + `xattr -c` (no quarantine attrs) | 7981 ms | STUCK | + | `cp` + `codesign --sign -` (ad-hoc re-sign) | **22 ms** | **OK** | + | `cp /usr/bin/true` | 195 ms | OK | + | Fresh `/bin/sh` script | 137 ms | OK | + | Fresh python script | 138 ms | OK | + + The only mitigation that worked was **re-signing the copy with an ad-hoc signature**, which replaces Apple's original designated-requirement check with a trivial one. This is direct evidence that the hang is in Apple's signature verification, not in any EDR. + +### Mechanism + +`/bin/echo` is signed by Apple with a strict **designated requirement** — typically including the binary's identity and its install location. When the kernel execs a copy of `/bin/echo` at a new path: + +1. amfid (Apple Mobile File Integrity Daemon) is asked to verify the signature +2. The designated requirement check enters a slow Gatekeeper / notary-lookup path because the binary's identity/path doesn't match expectations +3. syspolicyd is involved (we see `Unable to initialize qtn_proc: 3` and `dispatch_mig_server returned 268435459` errors in the unified log on every exec, including unrelated Cursor helpers — the provenance sandbox is broken) +4. The verdict either takes ~7s or never returns +5. Process hangs at `_dyld_start` (sample shows literally zero instructions executed; Physical footprint 96K = just the initial allocation) +6. When/if the verdict does come back negative, kernel marks proc `UE` (uninterruptible-exit). These zombies are SIGKILL-proof and only clear on reboot. + +### Why this is irrelevant to the OOM + +Real workflows don't copy Apple system binaries: +- **Xcode builds** run already-compiled binaries from `~/Library/Developer/Xcode/DerivedData` (self-signed for their build path) and Apple toolchain binaries from `/Applications/Xcode.app` (signed for their actual location). +- **npm install** with `ignore-scripts=true` (Jon's config) doesn't exec node_modules scripts. +- **configure scripts** use `/bin/sh` directly (scripts are fast, see table above). + +The T2 test was a synthetic worst-case that never happens in normal use. The yesterday-OOM has a different cause. + +### A separate, unrelated finding from the diagnostics + +The `syspolicyd: Unable to initialize qtn_proc: 3` and `kernel: (AppleSystemPolicy) ASP: Unable to apply provenance sandbox` errors fire on **every exec on this machine**, including ones that don't hang (e.g., Cursor helpers). This is a macOS Sequoia-side bug or config issue separate from the T2 hang. Worth noting because: +- It might add small per-exec overhead even for non-hanging cases +- Could be a side-effect of the laptop migration +- Could be fixed by reinstalling syspolicyd / Apple system services. Low priority unless we see correlations with OOM. + +## Finding (2026-05-27 14:33, T8 second run) + +**Xcode + its build toolchain is a previously-unmodeled ~7 GB instant cost.** + +T8 run during a 5-min window where Xcode was launched showed: +- `free_mb` dropped 7.8 GB (54066 → 46241) +- All tracked suspects (sim/lldb/Edge/Claude/mds) barely moved +- Top RSS at end of window: **Xcode 1.2 GB + SwiftBuildService 3.9 GB + SourceKit-LSP 2.1 GB = 7.2 GB** +- Trace timeline shows `procs` jumped from 971 → 1029 in the 14:30:04 sample = Xcode launching + +This wasn't in the original suspect list because the focus was on already-running processes that *grow*. Xcode launch is a step-function cost. **Each Xcode launch immediately consumes ~7 GB before any build runs**, which: +- Combined with one booted sim (19-31 GB), takes you to 26-38 GB just from "Xcode is open with a sim". +- Combined with Edge.app + Cursor + Claude + Slack + GitKraken + Arc (~10-15 GB), takes you to 40-55 GB baseline. +- A 128 GB machine has 70-90 GB headroom from this baseline, which is normally plenty — but a sim re-boot (briefly 2x sim size), a heavy build burst, and an Edge JS heap that's been growing for hours can collectively push toward an OOM. + +### T8 updates from this finding + +1. **New tracked metric**: `xcode_mb` covers Xcode.app + SwiftBuildService + SourceKitService + SourceKit-LSP + dt.SKAgent + cc1as + swift-frontend. +2. **New CSV column**: `inactive_mb` (so we can distinguish "free went to inactive = file cache" from "free went somewhere else = real consumer"). +3. **New "top growers" section**: per-sample top-5 RSS basenames are tracked; the post-run summary computes max-min delta per basename, sorted. Catches any process outside the suspect list that grows during the window. +4. **New verdict logic**: when `free_mb` drops materially, T8 now decomposes the drop into (tracked-suspects gain) + (inactive gain) + (unaccounted). If unaccounted > 1 GB, it flags it for follow-up. + +### Implications for the OOM hypothesis + +The "cumulative cliff after hours of work" suspect just got more credible. The pattern likely looks like: +1. Morning baseline: ~15 GB used (browser + chat + IDE shell) +2. Open Xcode + sim: +7 GB + 20 GB = ~42 GB used (1 hr in) +3. Cmd+Play + Edge active session: +5 GB Edge + 5 GB lldb-rpc = ~52 GB (2 hr in) +4. Multiple Cmd+Play cycles: +50 MB lldb each = small but cumulative +5. Edge JS heap grows on large account: ~200 MB/min = +12 GB/hour +6. Spotlight reindexes during build cycles: occasional 1-2 GB bursts +7. At hour 6-8, total ~90 GB used, occasional bursts spike to 110 GB +8. One unlucky burst → kernel emergency compress → swap → cascade → OOM + +This is consistent with everything we've observed. The next time Jon does a full day of Edge dev, **let T8 run periodically (every hour for 5 min)** and inspect the trends. Or better: let the persistent logger do its work and read the trace post-OOM if it happens again. + +## Refocused investigation plan (active 2026-05-27) + +With SentinelOne ruled out, return to the original suspect list. Priority order based on evidence weight: + +### Suspect 1: lldb-rpc-server + iOS sim runtime footprint +- **Evidence**: post-reboot trace shows 5 GB lldb-rpc-server baseline when Xcode is attached +- **Mechanism**: each Cmd+Play cycle adds ~50 MB; sim subsystem is 21 GB; both grow under typical day-long Edge dev +- **Action**: T8 (new) — periodic memory snapshots of sim/lldb/Edge processes; the persistent logger already captures top-RSS but doesn't break out sim subprocesses +- **Mitigation if confirmed**: kill lldb-rpc-server periodically; or use `simctl launch` instead of Cmd+Play for non-debug Edge runs; or run a lighter Xcode version side-by-side + +### Suspect 2: Edge.app JS heap on the large account +- **Evidence**: ~200 MB/min during active use, 400 MB/min during Cmd+Play, resets on relaunch +- **Mechanism**: progressive JS heap accumulation; the "large account" has many wallets/transactions loaded +- **Action**: T8 includes Edge RSS sampling +- **Mitigation if confirmed**: periodic Edge relaunch; or investigate JS heap fix in edge-react-gui + +### Suspect 3: Spotlight reindex storms +- **Evidence**: `mds_stores` CPU spikes post-reboot and during DerivedData churn +- **Mechanism**: every build invalidates many files → Spotlight reindexes → I/O + CPU +- **Action**: T8 includes `mds_stores` RSS+CPU tracking +- **Mitigation if confirmed**: `mdutil -i off /Users/jontz/Library/Developer/Xcode/DerivedData` to exclude DerivedData from Spotlight (and possibly `~/git` too) + +### Suspect 4: Cumulative cliff after hours of dev work +- **Evidence**: yesterday's OOM was hours into a session; no single trigger event was visible +- **Mechanism**: combination of all three above accumulating + nothing reclaims until OOM-killer +- **Action**: long-running persistent logger captures this organically; on next OOM, compare 1h/4h/8h-before snapshots +- **Mitigation if confirmed**: harden the memory-monitor (see "memory monitor improvements" section below) so it warns before the cliff + +### Suspect 5 (NEW from this session): syspolicyd / AppleSystemPolicy errors +- **Evidence**: kernel + syspolicyd log spam on every exec; provenance sandbox failing to initialize +- **Mechanism**: unknown; may add small per-exec overhead even for non-hanging cases +- **Action**: T8 captures syspolicyd CPU+log volume +- **Mitigation if confirmed**: investigate syspolicyd reinstall; check if a system OS update fixes it + +## What we still don't know (post-correction) + +- Whether the original yesterday OOM was triggered by sim/lldb/Edge growth, Spotlight storm, or accumulation of all three +- Whether the syspolicyd/ASP errors are a separate macOS-side problem worth fixing on their own +- Whether the 100+ UE zombies created by the T2 tests (now sitting permanently in the process table) are themselves contributing measurable pressure — they're free of RSS but do consume PCB slots + +## How to use what's been built + +### Persistent logger +Already running. Each 30s tick appends one line to `~/.config/agent-watcher/oom-repro/logs/trace-YYYY-MM-DD.log`. Survives reboots. View: +```bash +tail -f ~/.config/agent-watcher/oom-repro/logs/trace-$(date +%Y-%m-%d).log +``` + +Each line: +``` +ts=HH:MM:SS load1=N load5=N procs=N freeMB=N inactiveMB=N wiredMB=N compressorMB=N swapoutsTotal=N top=<10 entries> +``` + +Signals to watch for: +- `freeMB` < 5000 sustained (~5 GB free) = memory pressure real +- `compressorMB` > 30000 (~30 GB) = compressor working hard +- `swapoutsTotal` growing fast (>10000 pages/min) = swap is being written +- `load1` > 30 sustained = process queue depth high, fork serialization possible + +### Test suite +After reboot, run: +```bash +~/.config/agent-watcher/oom-repro/scripts/oom-repro-suite.sh T0 T1 T2 T3 T4 T5 +``` + +T0–T2 are cheap (~1 min). T3+ are heavier (kill Metro, shutdown sim, etc.). Each writes to `~/.config/agent-watcher/oom-repro/logs/tests/`. + +For the long-form repro: +```bash +~/.config/agent-watcher/oom-repro/scripts/oom-repro-suite.sh T6 +``` +This runs T3+T4+T5 in sequence then idles. User uses Edge normally for 30+ min. Persistent logger captures everything. + +### Tests defined + +| Test | What | Status / Manual step | +|---|---|---| +| T0 | baseline snapshot of vm_stat, top RSS, process counts | active — none | +| T1 | 500/1000/2000 parallel `node -e exit` — measures cached-fork tax (proven harmless, kept as baseline) | active — none | +| T2 | ~~cold SentinelOne verdict tax~~ → renamed to **macOS code-signing edge case** demonstration. Defaults to N=3 with re-signed bypass available. **Do not run** unless explicitly investigating signature checks; each invocation creates ~N permanent UE zombies | quarantined — see comments in script | +| T3 | cold Metro boot timing | active — none | +| T4 | cold sim boot timing | active — none | +| T5 | Edge launch via `simctl launch`, 90s observation | active — needs booted sim (run T4 first) | +| T6 | T3+T4+T5 then idle for normal use | active — use Edge for 30+ min | +| T7 | Claude.app renderer characterization — 60s sampling | active — none | +| T7-open | Same sampler with guided "open historic chat" trigger | active — manual click | +| **T8 (NEW)** | **Suspect-process memory tracker** — 5 min of 10s-interval RSS samples for lldb-rpc-server, sim subsystem total, Edge.app, mds_stores, syspolicyd. Reports per-process growth rate per minute. The primary tool for the refocused investigation. | active — none | +| **T9 (NEW)** | **Real-workflow Xcode build timing** — clean + build the active edge-react-gui scheme, time it, snapshot memory before/after. Captures whether a single build cycle moves the needle. | active — requires edge-react-gui present and Xcode workspace path correct | + +### Why T7 / what triggers Claude.app renderer overload + +The handoff's Issue B hypothesis: long sessions accumulate a huge DOM (every tool call/result adds to the conversation), and each new tool-result update triggers a full re-render. The unknowns: +- Is it cumulative tool-result count, or DOM node count, or token count, that drives it? +- Does opening a *historic* long session reproduce it on demand (i.e. is it the DOM regardless of how it got there)? +- Is it specific to actively-streaming sessions (re-paint per token chunk), or any large convo? + +T7 captures a 60s steady-state sample of whatever Claude.app is doing right now. Useful as the per-session "convo too big yet?" check. + +T7-open is the controlled trigger test: do a baseline sample, then open a known-long historic conversation, sample again. If the renderer pegs above 50% during the post-action window, opening historic sessions reproduces the issue and the trigger is "DOM size at hydration", not "active streaming load". If it doesn't peg, the trigger is something else (incremental updates during active streaming) and a different test is needed. + +The persistent logger now also captures `claude=ren=X%/RSSM gpu=X%/RSSM main=X%/RSSM tot=X%/RSSM n=N` on every 30s tick, so you can correlate Claude.app load with overall system state across the full day. + +### ~~Interpreting T1 vs T2~~ Historical note + +T1 vs T2 interpretation logic from the original plan was based on a false premise (that T2 measured SentinelOne cold-verdict cost). Disregard the original interpretation. T2 is now understood to measure macOS code-signature verification, which exclusions don't affect. + +## ~~SentinelOne action plan~~ Historical — already executed + +Jon has admin on SentinelOne. The decision tree: + +1. **If T2 shows cold-binary spawn is 5x+ slower than T1**: apply path exclusions. +2. **If T2 ~= T1**: SentinelOne is not the bottleneck. Skip exclusions, look elsewhere (Xcode/sim footprint). + +Recommended exclusions if applying (low security cost given `ignore-scripts=true` + `min-release-age=7` are already in place): + +| Path | Why | +|---|---| +| `~/Library/Developer/Xcode/DerivedData` | Build cache; massive churn during builds, no security-sensitive contents | +| `~/.npm`, `~/.nvm/versions/node` | Package cache + node binaries; runs constantly | +| `~/git/**/node_modules` | High file count; ignore-scripts already prevents postinstall RCE | +| `~/Library/Caches` | OS+app caches | + +What to KEEP monitoring (essential, never exclude): +- `~/.ssh`, `~/.aws`, `~/Library/Keychains` — credential paths +- `~/Library/LaunchAgents`, `/Library/LaunchDaemons` — persistence vectors +- All process exec events (separate config from file-watch — keep enabled everywhere) +- All network egress monitoring + +Manual SentinelOne config steps: +1. Open SentinelOne management console (or `sentinelctl`, depending on which interface admin gives) +2. Find the policy applied to this Mac +3. Add the path exclusions above to the "File system exclusions" or equivalent section +4. KEEP "Process Execution" and "Network" monitoring enabled +5. Save and let the policy push to the endpoint (usually ~minute) + +Verify the exclusions didn't break detection — synthetic tests: +```bash +# Test 1: process exec outside dev paths should still trigger SentinelOne +/tmp/no_such_binary 2>/dev/null # benign, just see if SentinelOne logs the attempted exec + +# Test 2: credential-path access should still trigger +echo test | tee ~/.ssh/sentinel_test_pls_delete >/dev/null && rm ~/.ssh/sentinel_test_pls_delete + +# Test 3: suspicious-network connection should still trigger +curl -fsSL evil.example.invalid >/dev/null 2>&1 +``` + +Check SentinelOne console after each — if detection still fires, exclusions didn't blind it to the things that matter. + +Re-run T2 after exclusions are applied. If T2 elapsed_ms drops materially (5x+), the exclusion worked. + +## Monitor improvements deferred until after repro + +Jon's existing `com.jontz.memory-monitor` needs three changes once OOM is reproducible: +1. Drop poll interval from 30s → 10s +2. Add rate-of-change signal (warn if free drops >5 GB in 60s, independent of absolute level) +3. Repeat critical alerts every N ticks while in critical state (not just on transition) +4. Backup notification path (`say "memory critical"` via TTS) since osascript modals are fragile on Sequoia +5. Tighten thresholds: warn at `freeMB < 15000`, critical at `freeMB < 8000`, plus compressor-based critical at `compressorMB > 40000` + +Don't change the monitor until after T2 + the SentinelOne exclusion test — we need the original behavior as the comparison baseline. + +## Workflow for fresh session + +1. Reboot when ready +2. After login, run `~/.config/agent-watcher/oom-repro/scripts/install.sh --status` — confirm persistent logger is running +3. Run `~/.config/agent-watcher/oom-repro/scripts/oom-repro-suite.sh T0 T1 T2` — cheap controlled tests, takes ~5 min +4. Look at `~/.config/agent-watcher/oom-repro/logs/tests/T1-*.log` and `T2-*.log`. Compare elapsed_ms between T1 (cached) and T2 (uncached). +5. Run `oom-repro-suite.sh T6` — full workflow +6. Use Edge with the large account normally for 30+ min +7. Watch `tail -f ~/.config/agent-watcher/oom-repro/logs/trace-$(date +%Y-%m-%d).log` in another terminal +8. When the OOM signals appear (or you call uncle), stop. Capture the final trace line + vm_stat output for record. +9. Compare against the baseline (T0). The delta is the OOM trajectory. + +If T2 shows SentinelOne is the cost: apply exclusions per the action plan, reboot, repeat steps 1–8. Compare. Job done. + +If T2 doesn't show SentinelOne cost: the bottleneck is Xcode/sim/Edge itself. Options then are: +- Side-by-side install of Xcode 16.x (older Xcode has lighter lldb + lighter sim runtime) +- Run Edge via `simctl launch` instead of Cmd+Play (saves the 5.5 GB lldb-rpc-server) +- `killall lldb-rpc-server` periodically during Edge dev to reset its baseline +- Add agent-spawning gates that refuse to spawn when free memory < 20 GB + +## Files Jon has agreed are pending publish to edge-dev-agents + +These are local-ahead of `~/git/edge-dev-agents:jon`. Run `/convention-sync` when ready (after OOM investigation concludes, not now): + +- `~/.cursor/skills/pr-land/scripts/pr-land-comments.sh` +- `~/.cursor/skills/pr-land/scripts/pr-land-discover.sh` +- `~/.cursor/scripts/tool-sync.sh` +- `~/.cursor/skills/one-shot/SKILL.md` +- `~/.cursor/skills/pr-create/SKILL.md` +- `~/.cursor/skills/asana-task-update/SKILL.md` +- `~/.cursor/skills/asana-task-update/scripts/asana-task-update.sh` +- `~/.cursor/skills/install-deps.sh` +- `~/.cursor/skills/verify-repo.sh` +- `~/.cursor/skills/build-and-test/SKILL.md` (refactor) + `scripts/*.sh` (new) +- `~/.cursor/skills/debugger/SKILL.md` (new) + `scripts/*` (new) +- `~/.cursor/scripts/tool-sync.sh` (already listed, but for the agent-watcher: scripts under `~/.config/agent-watcher/` are NOT in scope for /convention-sync — those are host-specific) diff --git a/agent-watcher/oom-repro/scripts/install.sh b/agent-watcher/oom-repro/scripts/install.sh new file mode 100755 index 0000000..3432069 --- /dev/null +++ b/agent-watcher/oom-repro/scripts/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# install.sh — Install the persistent mem-trace launchd job. +# +# Idempotent. Re-run after script edits or after a reboot if needed. +# The job (com.jontz.mem-trace) runs every 30s, writes one line per tick +# to ~/.config/agent-watcher/oom-repro/logs/trace-YYYY-MM-DD.log +# +# Usage: +# install.sh # load (or reload) the plist +# install.sh --status # show whether it's running and recent log +# install.sh --stop # unload (stops the tick) + +set -euo pipefail + +PLIST="$HOME/Library/LaunchAgents/com.jontz.mem-trace.plist" +LABEL="com.jontz.mem-trace" +LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/oom-repro/logs" + +case "${1:-}" in + --status) + echo "=== launchctl status ===" + launchctl list | grep "$LABEL" || echo " (not loaded)" + echo + echo "=== most recent log file ===" + LATEST=$(ls -t "$LOG_DIR"/trace-*.log 2>/dev/null | head -1) + if [ -n "$LATEST" ]; then + echo " $LATEST" + echo + echo "=== last 3 lines ===" + tail -3 "$LATEST" + else + echo " (no trace logs yet)" + fi + exit 0 + ;; + --stop) + launchctl unload "$PLIST" 2>/dev/null && echo "Stopped." || echo "Already stopped." + exit 0 + ;; +esac + +[ -f "$PLIST" ] || { echo "Missing plist: $PLIST" >&2; exit 1; } +plutil -lint "$PLIST" >/dev/null + +# Reload (unload first so changes take effect) +launchctl unload "$PLIST" 2>/dev/null || true +launchctl load "$PLIST" + +# Verify it's loaded +if launchctl list | grep -q "$LABEL"; then + echo "Loaded: $LABEL" + echo "Trace logs: $LOG_DIR/trace-YYYY-MM-DD.log" + echo "First tick should appear within ~5 seconds (RunAtLoad)." +else + echo "Failed to load $LABEL" >&2 + exit 1 +fi diff --git a/agent-watcher/oom-repro/scripts/mem-trace-persistent.sh b/agent-watcher/oom-repro/scripts/mem-trace-persistent.sh new file mode 100755 index 0000000..c093598 --- /dev/null +++ b/agent-watcher/oom-repro/scripts/mem-trace-persistent.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# mem-trace-persistent.sh — One-shot memory snapshot. +# +# Invoked by launchd every 30s. Writes ONE timestamped line per invocation to a +# daily-rotated log under ~/.config/agent-watcher/oom-repro/logs/. +# +# Designed to add zero overhead between ticks (exits immediately) and ~10MB +# peak transient during the tick (vm_stat + ps + a few awks). No node, no +# python, no curl. +# +# Logs older than 7 days are auto-deleted on each tick. +# +# Line schema (one line per tick): +# ts=HH:MM:SS load1=N load5=N procs=N freeMB=N inactiveMB=N wiredMB=N \ +# compressorMB=N swapoutsTotal=N top=<10 RSS entries> claude=<summary> +# +# Each top entry is "RSSMB=procname". +# +# claude= field captures Claude.app helper CPU + RSS, since the prior session +# showed renderer pegging at 34-45% CPU as the conversation DOM grew (separate +# from memory pressure). Format: +# claude=ren=PCT%/RSSM gpu=PCT%/RSSM main=PCT%/RSSM tot=PCT%/RSSM n=N +# (empty if Claude.app isn't running) + +set -u + +LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/oom-repro/logs" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/trace-$(date +%Y-%m-%d).log" + +# Rotate: delete trace logs older than 7 days. Cheap to do every tick. +find "$LOG_DIR" -name "trace-*.log" -type f -mtime +7 -delete 2>/dev/null + +PAGE_SIZE=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384) + +# Capture vm_stat once, parse multiple keys from the same blob. +VM=$(vm_stat 2>/dev/null) + +vm_mb() { + echo "$VM" | awk -v k="$1" -v ps="$PAGE_SIZE" ' + $0 ~ k { gsub("\\.", "", $NF); printf "%d", ($NF * ps) / 1024 / 1024; exit } + ' +} + +TS=$(date +%H:%M:%S) +UP=$(uptime) +LOAD1=$(echo "$UP" | awk -F'load averages:' '{print $2}' | awk '{print $1}') +LOAD5=$(echo "$UP" | awk -F'load averages:' '{print $2}' | awk '{print $2}') +PROCS=$(ps -ax 2>/dev/null | wc -l | tr -d ' ') + +FREE_MB=$(vm_mb "Pages free") +INACTIVE_MB=$(vm_mb "Pages inactive") +WIRED_MB=$(vm_mb "Pages wired down") +COMPRESSOR_MB=$(vm_mb "Pages occupied by compressor") +SWAPOUTS=$(echo "$VM" | awk '/Swapouts:/ {gsub("\\.",""); print $NF; exit}') + +# macOS memory-pressure level: 1=NORMAL 2=WARN 4=CRITICAL. Pollable early-warning +# signal (research-confirmed 2026-05-28). Cheap single sysctl read. +PRESSURE=$(sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null || echo "?") +# cli process count — the canary for a recursive-claude-spawn fork chain (the +# confirmed OOM cause). A healthy box has a handful; a runaway hits hundreds fast. +CLI_COUNT=$(ps -axo comm 2>/dev/null | grep -c '^cli$') + +# Top 10 RSS consumers, compact format. +TOP=$(ps -axo rss,comm 2>/dev/null | sort -k1 -nr | head -10 | awk '{ + cmd=$2; n=split(cmd, a, "/"); short=a[n] + printf "%dM=%s ", $1/1024, short +}') + +# Claude.app summary — capture renderer + GPU + main + totals. Uses a single +# ps invocation; we identify processes by their /Applications/Claude.app/ path. +# Renderer = main app renderer (largest renderer by RSS, since multiple +# renderers exist for popovers/preview/etc). +CLAUDE=$(ps -axo pid,pcpu,rss,command 2>/dev/null | awk ' + /\/Applications\/Claude\.app\// { + pcpu=$2; rss=$3 + # classify by command segment + role="other" + if ($0 ~ /Helper \(Renderer\)/) role="renderer" + else if ($0 ~ /type=gpu-process/) role="gpu" + else if ($0 ~ /MacOS\/Claude /||$0 ~ /MacOS\/Claude$/) role="main" + else if ($0 ~ /Helper.*type=utility/) role="utility" + else if ($0 ~ /Helper.*type=/) role="helperX" + + n[role]++ + cpu[role]+=pcpu + mem[role]+=rss + # track max renderer for ren= (biggest = main UI) + if (role=="renderer" && rss>max_ren_rss) { max_ren_rss=rss; max_ren_cpu=pcpu } + + tot_cpu+=pcpu; tot_rss+=rss; tot_n++ + } + END { + if (tot_n==0) { print ""; exit } + printf "ren=%.0f%%/%dM gpu=%.0f%%/%dM main=%.0f%%/%dM tot=%.0f%%/%dM n=%d", + max_ren_cpu+0, max_ren_rss/1024, + cpu["gpu"]+0, mem["gpu"]/1024, + cpu["main"]+0, mem["main"]/1024, + tot_cpu, tot_rss/1024, + tot_n + } +') + +echo "ts=$TS load1=$LOAD1 load5=$LOAD5 procs=$PROCS freeMB=$FREE_MB inactiveMB=$INACTIVE_MB wiredMB=$WIRED_MB compressorMB=$COMPRESSOR_MB swapoutsTotal=$SWAPOUTS pressure=$PRESSURE cliCount=$CLI_COUNT top=$TOP claude=$CLAUDE" >> "$LOG_FILE" diff --git a/agent-watcher/oom-repro/scripts/oom-repro-suite.sh b/agent-watcher/oom-repro/scripts/oom-repro-suite.sh new file mode 100755 index 0000000..e2ec3c9 --- /dev/null +++ b/agent-watcher/oom-repro/scripts/oom-repro-suite.sh @@ -0,0 +1,641 @@ +#!/usr/bin/env bash +# oom-repro-suite.sh — Run controlled OOM-related benchmarks. +# +# Usage: +# oom-repro-suite.sh # default: T0 T1 T2 (cheap subset) +# oom-repro-suite.sh T0 T1 T2 T3 T4 # named tests +# oom-repro-suite.sh all # everything except T6 (which is open-ended) +# +# Tests: +# T0 baseline snapshot +# T1 process-spawn stress (500/1000/2000 parallel `node -e exit`) +# T2 uncached-binary spawn (100 unique never-seen binaries) +# T3 cold Metro boot timing (kills metro, clears cache, times --reset-cache boot) +# T4 cold sim boot timing (shutdown all, boot iOS 18 iPhone 16 Pro Max, time to SpringBoard) +# T5 Edge launch via simctl (NO Xcode/lldb attached, 90s observation) +# T6 long-form observation (runs T3+T4+T5 then idles; user works normally for 30+ min) +# T7 Claude.app renderer characterization (60s sampling at 2s, before/after annotated) +# T7-open Claude.app sampling with a guided action: open a long historic session +# T8 Suspect-process memory tracker — 5min × 10s sampling of lldb-rpc-server, sim, Edge, mds_stores, syspolicyd +# T9 Real-workflow Xcode clean+build timing (edge-react-gui) +# +# QUARANTINED: +# T2 was originally "SentinelOne cold-verdict tax" but turned out to be macOS code-signature +# verification of moved Apple binaries — unrelated to real workflows. Defaults dropped to +# N=3 and the test prints a warning. Don't run unless investigating signatures. +# +# Each test writes a dated log under ~/.config/agent-watcher/oom-repro/logs/tests/ +# The persistent trace (com.jontz.mem-trace) keeps logging across all tests. +# +# Exit codes: +# 0 = all requested tests completed (PASS/FAIL signal is in the log content, not exit code) +# 1 = setup error (missing tools, etc.) +# 2 = a test prerequisite was unmet (e.g. no booted sim for T5) + +set -u + +OOM_DIR="$HOME/.config/agent-watcher/oom-repro" +LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/oom-repro/logs/tests" +mkdir -p "$LOG_DIR" + +# ─── Prereq checks ─────────────────────────────────────────────────────────── +for cmd in vm_stat uptime ps xcrun node python3; do + command -v "$cmd" >/dev/null 2>&1 || { echo "Missing: $cmd"; exit 1; } +done + +# ─── Helpers ───────────────────────────────────────────────────────────────── +elapsed_ms() { python3 -c "import time;print(int(time.time()*1000))"; } + +snapshot() { + local label="$1" + local out="$2" + local page_size + page_size=$(sysctl -n hw.pagesize) + { + echo "=== $label ===" + date +"%Y-%m-%d %H:%M:%S" + uptime + echo + echo "--- vm_stat (MB) ---" + vm_stat | awk -v ps="$page_size" ' + /Pages free:/ { gsub("\\.","",$NF); printf " free = %d MB\n", $NF*ps/1024/1024 } + /Pages inactive:/ { gsub("\\.","",$NF); printf " inactive = %d MB\n", $NF*ps/1024/1024 } + /Pages wired down:/ { gsub("\\.","",$NF); printf " wired = %d MB\n", $NF*ps/1024/1024 } + /Pages occupied by compressor:/ { gsub("\\.","",$NF); printf " compressor = %d MB\n", $NF*ps/1024/1024 } + /Compressions:/ { gsub("\\.","",$NF); printf " compressions= %d (cumulative)\n", $NF } + /Swapouts:/ { gsub("\\.","",$NF); printf " swapouts = %d pages (%d MB cumulative)\n", $NF, $NF*ps/1024/1024 } + ' + echo + echo "--- top 15 RSS ---" + ps -axo rss,comm | sort -k1 -nr | head -15 | awk '{cmd=$2; n=split(cmd,a,"/"); short=a[n]; printf " %5dMB %s\n", $1/1024, short}' + echo + echo "--- process counts ---" + echo " total: $(ps -ax | wc -l | tr -d ' ')" + echo " node*: $(ps -axo comm | grep -c '^.*node$\|^.*\.bin/node\|/node$')" + echo " simruntime: $(ps -axo command | grep -ic simruntime)" + echo " sentinel*: $(ps -axo command | grep -ic sentinel)" + echo " mds*: $(ps -axo command | grep -ic mds)" + echo + } >> "$out" +} + +# ─── Tests ─────────────────────────────────────────────────────────────────── + +T0_baseline() { + local LOG="$LOG_DIR/T0-baseline-$(date +%Y%m%d-%H%M%S).log" + echo ">> T0 baseline → $LOG" + snapshot "T0 baseline" "$LOG" + echo "T0 done." +} + +T1_node_spawn() { + local LOG="$LOG_DIR/T1-node-spawn-$(date +%Y%m%d-%H%M%S).log" + echo ">> T1 node spawn stress → $LOG" + snapshot "T1 BEFORE" "$LOG" + for N in 500 1000 2000; do + echo " wave: $N parallel node spawns..." + local t0=$(elapsed_ms) + (for _ in $(seq 1 "$N"); do node -e 'process.exit(0)' & done; wait) 2>/dev/null + local t1=$(elapsed_ms) + { + echo + echo "--- T1 wave: $N parallel node -e exit ---" + echo "elapsed_ms = $((t1 - t0))" + echo "per_process_ms_amortized = $(( (t1 - t0) / N ))" + } >> "$LOG" + sleep 5 + done + snapshot "T1 AFTER" "$LOG" + echo "T1 done." +} + +T2_uncached_spawn() { + # T2 — QUARANTINED. Originally hypothesized to measure SentinelOne's cold-binary + # verdict tax. Investigation 2026-05-27 proved this is actually macOS code-signature + # verification of moved Apple binaries, NOT an EDR issue. Re-signing the copy with + # `codesign --sign -` makes the hang disappear, conclusively. SentinelOne stop also + # had no effect. Real workflows don't clone Apple bootstrap binaries, so this test + # doesn't model anything that happens in normal use. + # + # Default count dropped to 3 to minimize zombie creation if accidentally invoked. + # Each hung process becomes a permanent UE-state zombie (SIGKILL-proof, reboot only). + echo " ⚠ WARNING: T2 is quarantined — it measures a macOS code-signing edge case," + echo " NOT a real OOM-relevant issue. Each invocation creates ~N permanent UE zombies." + echo " Continue only if you specifically want to demo / re-verify the finding." + local N=${OOM_T2_COUNT:-3} + local TIMEOUT=${OOM_T2_TIMEOUT:-10} # seconds; bail rather than hanging the suite + local LOG="$LOG_DIR/T2-uncached-$(date +%Y%m%d-%H%M%S).log" + echo ">> T2 uncached-binary spawn (N=$N, timeout=${TIMEOUT}s) → $LOG" + snapshot "T2 BEFORE" "$LOG" + local BIN_DIR="/tmp/oom-repro-uncached" + rm -rf "$BIN_DIR"; mkdir -p "$BIN_DIR" + for i in $(seq 1 "$N"); do cp /bin/echo "$BIN_DIR/x$i"; done + + local t0; t0=$(elapsed_ms) + local pids=() + for i in $(seq 1 "$N"); do + "$BIN_DIR/x$i" >/dev/null 2>&1 & + pids+=($!) + done + + # Poll for completion up to TIMEOUT seconds. Don't use `wait` because + # SentinelOne-stuck procs in UE state would block it indefinitely. + local deadline=$(( $(date +%s) + TIMEOUT )) + local done_count=0 stuck_count=0 + while [ "$(date +%s)" -lt "$deadline" ]; do + done_count=0 + for p in "${pids[@]}"; do + kill -0 "$p" 2>/dev/null || done_count=$((done_count + 1)) + done + [ "$done_count" -eq "$N" ] && break + sleep 0.2 + done + local t1; t1=$(elapsed_ms) + stuck_count=$(( N - done_count )) + + { + echo + echo "--- T2: $N unique never-seen binaries spawned in parallel ---" + if [ "$stuck_count" -eq 0 ]; then + echo "elapsed_ms = $((t1 - t0))" + echo "per_process_ms_amortized = $(( (t1 - t0) / N ))" + echo "verdict: T2 completed cleanly (no stuck procs)" + else + echo "elapsed_ms = TIMEOUT after ${TIMEOUT}s" + echo "stuck_procs = $stuck_count of $N" + echo "VERDICT: SentinelOne held $stuck_count binaries in UE state — confirmed cold-verdict tax." + echo "Note: stuck procs are SIGKILL-proof and persist until reboot." + echo " PIDs: ${pids[*]}" + fi + echo "(comparison: T1 cached node baseline = ~3ms per proc. If T2 >> T1 OR has stuck procs, SentinelOne is the cost.)" + } >> "$LOG" + + # Best-effort cleanup of the binary copies (running procs hold them open + # but inode unlink is fine; we don't want leftover x* files). + rm -rf "$BIN_DIR" + snapshot "T2 AFTER" "$LOG" + echo "T2 done. $done_count/$N completed, $stuck_count stuck." + [ "$stuck_count" -gt 0 ] && echo " ⚠ $stuck_count unkillable UE zombies remain until reboot." +} + +T3_cold_metro() { + local LOG="$LOG_DIR/T3-cold-metro-$(date +%Y%m%d-%H%M%S).log" + echo ">> T3 cold Metro boot → $LOG" + snapshot "T3 BEFORE" "$LOG" + pkill -f "react-native start" 2>/dev/null + sleep 2 + rm -rf ~/Library/Caches/com.facebook.ReactNativeBuild 2>/dev/null + if ! [ -d ~/git/edge-react-gui ]; then + echo " edge-react-gui not at expected path; skipping T3" | tee -a "$LOG" + return + fi + rm -f /tmp/metro-boot.log + local t0=$(elapsed_ms) + (cd ~/git/edge-react-gui && npx react-native start --reset-cache > /tmp/metro-boot.log 2>&1) & + local metro_pid=$! + local ready=0 + for _ in $(seq 1 60); do + if grep -q -i "Welcome to Metro\|metro waiting\|Loading dependency graph" /tmp/metro-boot.log 2>/dev/null; then + local t1=$(elapsed_ms) + { + echo + echo "--- T3: Metro ready ---" + echo "elapsed_ms = $((t1 - t0))" + echo "metro_pid = $metro_pid" + } >> "$LOG" + ready=1 + break + fi + sleep 2 + done + if [ "$ready" = 0 ]; then + { + echo + echo "--- T3: Metro NEVER readied within 120s ---" + echo "metro_pid = $metro_pid (still running — pkill -f 'react-native start' to stop)" + tail -30 /tmp/metro-boot.log + } >> "$LOG" + fi + echo " Metro left running (PID $metro_pid). pkill -f 'react-native start' to stop." + snapshot "T3 AFTER" "$LOG" + echo "T3 done." +} + +T4_cold_sim_boot() { + local LOG="$LOG_DIR/T4-cold-sim-$(date +%Y%m%d-%H%M%S).log" + echo ">> T4 cold sim boot → $LOG" + snapshot "T4 BEFORE" "$LOG" + xcrun simctl shutdown all 2>&1 | head -5 + sleep 5 + local UDID + UDID=$(xcrun simctl list devices 2>/dev/null | awk '/-- iOS 18/,/^-- /' | grep "iPhone 16 Pro Max" | head -1 | grep -oE '[0-9A-F-]{36}') + if [ -z "$UDID" ]; then + echo " iPhone 16 Pro Max on iOS 18 not found; skipping T4" | tee -a "$LOG" + return + fi + echo " booting $UDID..." + local t0=$(elapsed_ms) + xcrun simctl boot "$UDID" 2>&1 + local ready=0 + for _ in $(seq 1 60); do + if xcrun simctl spawn "$UDID" launchctl list 2>/dev/null | grep -q SpringBoard; then + local t1=$(elapsed_ms) + { + echo + echo "--- T4: SpringBoard up ---" + echo "elapsed_ms = $((t1 - t0))" + echo "sim_child_processes = $(ps -axo command | grep -ic simruntime)" + } >> "$LOG" + ready=1 + break + fi + sleep 2 + done + open -a Simulator + if [ "$ready" = 0 ]; then + { + echo + echo "--- T4: SpringBoard NEVER readied within 120s ---" + } >> "$LOG" + fi + snapshot "T4 AFTER" "$LOG" + echo "T4 done." +} + +T5_edge_launch() { + local LOG="$LOG_DIR/T5-edge-launch-$(date +%Y%m%d-%H%M%S).log" + echo ">> T5 Edge launch (simctl, no Xcode) → $LOG" + snapshot "T5 BEFORE" "$LOG" + local UDID + UDID=$(xcrun simctl list devices booted 2>/dev/null | grep "iPhone 16 Pro Max" | head -1 | grep -oE '[0-9A-F-]{36}') + if [ -z "$UDID" ]; then + echo " no booted iPhone 16 Pro Max; run T4 first." | tee -a "$LOG" + return 2 + fi + xcrun simctl launch "$UDID" co.edgesecure.app 2>&1 | tee -a "$LOG" + sleep 2 + for t in 10 30 60 90; do + sleep $((t - 2)) # cumulative wait + local edge_rss + edge_rss=$(ps -axo rss,command | grep "/Edge\.app/.*Edge$" | grep -v grep | head -1 | awk '{printf "%dMB", $1/1024}') + local lldb_rss + lldb_rss=$(ps -axo rss,comm | grep lldb-rpc-server | grep -v grep | head -1 | awk '{printf "%dMB", $1/1024}') + [ -z "$edge_rss" ] && edge_rss="(not running)" + [ -z "$lldb_rss" ] && lldb_rss="(absent)" + echo " t+${t}s: Edge=$edge_rss lldb=$lldb_rss" | tee -a "$LOG" + done + snapshot "T5 AFTER" "$LOG" + echo "T5 done." +} + +claude_sample() { + # Emit one ps snapshot of all Claude.app processes, formatted for human reading. + # $1 = sample tag (e.g. "t+0s") + local tag="$1" + ps -axo pid,pcpu,rss,command 2>/dev/null | awk -v tag="$tag" ' + BEGIN { printf "[%s]\n", tag } + /\/Applications\/Claude\.app\// { + pcpu=$2; rss=$3 + role="other" + if ($0 ~ /Helper \(Renderer\)/) role="renderer" + else if ($0 ~ /type=gpu-process/) role="gpu" + else if ($0 ~ /MacOS\/Claude $/||$0 ~ /MacOS\/Claude$/) role="main" + else if ($0 ~ /type=utility/) role="utility" + else if ($0 ~ /Helper/) role="helper" + printf " pid=%-6s cpu=%5s%% rss=%6dM role=%s\n", $1, pcpu, rss/1024, role + tot_cpu+=pcpu; tot_rss+=rss; n++ + } + END { + if (n==0) print " (Claude.app not running)" + else printf " TOTAL: cpu=%.1f%% rss=%dM nprocs=%d\n", tot_cpu, tot_rss/1024, n + } + ' +} + +summarize_claude_samples() { + # Parse a claude-sample tmp file, print peak/avg stats. $1 = tmp path. + awk ' + /role=renderer/ { if ($3+0 > max_ren) max_ren=$3+0 } + /TOTAL: cpu=/ { + gsub("cpu=",""); gsub("%","") + v=$2+0 + if (v>max_tot) max_tot=v + sum_tot+=v; nt++ + } + END { + printf " peak_renderer_cpu = %.1f%%\n", max_ren + printf " peak_total_cpu = %.1f%%\n", max_tot + if (nt>0) printf " avg_total_cpu = %.1f%%\n", sum_tot/nt + if (max_ren+0 > 30) print " VERDICT: renderer peaked >30% — conversation likely too long; consider new chat." + else if (max_ren+0 > 15) print " VERDICT: renderer 15-30% — borderline; watch trend." + else print " VERDICT: renderer healthy (<15%)." + } + ' "$1" +} + +T7_claude_renderer() { + local LOG="$LOG_DIR/T7-claude-$(date +%Y%m%d-%H%M%S).log" + local TMP; TMP=$(mktemp -t T7-samples.XXXXXX) + echo ">> T7 Claude.app renderer characterization → $LOG" + echo " Sampling Claude.app every 2s for 60s (30 samples)." + echo " USE Claude.app normally during this window (scroll/switch focus/etc) to observe per-action CPU." + snapshot "T7 BEFORE" "$LOG" + for i in $(seq 0 29); do + claude_sample "t+$((i*2))s" >> "$TMP" + sleep 2 + done + { + echo + echo "--- T7: Claude.app samples (every 2s, 30 samples = 60s) ---" + cat "$TMP" + echo + echo "--- T7: peak/summary across all samples ---" + summarize_claude_samples "$TMP" + } >> "$LOG" + rm -f "$TMP" + snapshot "T7 AFTER" "$LOG" + echo "T7 done. Highlights:" + grep -E "peak_|avg_|VERDICT" "$LOG" | sed 's/^/ /' +} + +T7_open_session() { + local LOG="$LOG_DIR/T7-open-$(date +%Y%m%d-%H%M%S).log" + local TMP; TMP=$(mktemp -t T7-open-samples.XXXXXX) + echo ">> T7-open Claude.app open-historic-session test → $LOG" + echo " 10s baseline, then prompt to open a long historic session, then 60s sampling." + snapshot "T7-open BEFORE" "$LOG" + for i in $(seq 0 4); do + claude_sample "baseline_t+$((i*2))s" >> "$TMP" + sleep 2 + done + echo + echo ">>> NOW: in Claude.app, open a LONG historic conversation from the sidebar. <<<" + echo ">>> Sampling continues for 60s. <<<" + say "open a long historic chat now" 2>/dev/null & + for i in $(seq 0 29); do + claude_sample "action_t+$((i*2))s" >> "$TMP" + sleep 2 + done + { + echo + echo "--- T7-open: samples (10s baseline + 60s post-action) ---" + cat "$TMP" + echo + echo "--- T7-open: peak/summary ---" + summarize_claude_samples "$TMP" + } >> "$LOG" + rm -f "$TMP" + snapshot "T7-open AFTER" "$LOG" + echo "T7-open done. Highlights:" + grep -E "peak_|VERDICT" "$LOG" | sed 's/^/ /' +} + +T8_suspect_memory_tracker() { + # T8 — sample memory of the OOM-suspect processes every 10s for 5min. + # Reports per-process RSS at each sample + growth rate per minute. + # + # Suspects: lldb-rpc-server (Xcode debug session), iOS sim subsystem total, + # Edge.app, mds_stores (Spotlight), syspolicyd, com.apple.WebKit (Safari Tech + # Preview if running). Light enough to run while doing normal work. + local SAMPLES=${OOM_T8_SAMPLES:-30} # 30 × 10s = 5 min + local INTERVAL=${OOM_T8_INTERVAL:-10} # seconds between samples + local LOG="$LOG_DIR/T8-suspect-mem-$(date +%Y%m%d-%H%M%S).log" + local TMP; TMP=$(mktemp -t T8-samples.XXXXXX) + echo ">> T8 suspect-process memory tracker → $LOG" + echo " ${SAMPLES} samples × ${INTERVAL}s = $((SAMPLES * INTERVAL / 60)) min" + echo " USE Edge / Xcode normally during the window — that's the point" + snapshot "T8 BEFORE" "$LOG" + + # Header for CSV-style data. xcode_mb covers the full Xcode toolchain + # (Xcode.app, SwiftBuildService, SourceKit-LSP, dt.SKAgent, IDEs). + printf 'sample,ts,lldb_mb,sim_total_mb,sim_proc_count,xcode_mb,edge_mb,mds_mb,syspolicy_mb,webkit_mb,claude_total_mb,inactive_mb,procs,free_mb,compressor_mb\n' > "$TMP" + # Also track top-N processes per sample for "what grew that we don't track" detection. + local TOPN="${TMP}.topn" + : > "$TOPN" + + for i in $(seq 0 $((SAMPLES - 1))); do + ts=$(date +%H:%M:%S) + # Gather everything in one ps invocation (faster + less noise) + ps_out=$(ps -axo rss,command 2>/dev/null) + + lldb_mb=$(echo "$ps_out" | awk '/lldb-rpc-server/ && !/grep/ {sum += $1} END {printf "%d", sum/1024}') + # iOS sim total = anything under CoreSimulator runtime root OR is com.apple.CoreSimulator + sim_mb=$(echo "$ps_out" | awk '/CoreSimulator|simruntime|com\.apple\.simulator/ {sum += $1} END {printf "%d", sum/1024}') + sim_n=$(echo "$ps_out" | awk '/CoreSimulator|simruntime|com\.apple\.simulator/ {n++} END {printf "%d", n+0}') + # Xcode toolchain: the IDE itself, build, index and language servers. + xcode_mb=$(echo "$ps_out" | awk '/Xcode\.app|SwiftBuildService|SourceKitService|SourceKit-LSP|com\.apple\.dt\.SKAgent|cc1as|swift-frontend/ {sum += $1} END {printf "%d", sum/1024}') + edge_mb=$(echo "$ps_out" | awk '/\/Edge\.app\/.*\/Edge$|co\.edgesecure\.app/ {sum += $1} END {printf "%d", sum/1024}') + mds_mb=$(echo "$ps_out" | awk '/mds_stores|mdworker_shared|^[ ]*[0-9]+ +\/usr\/sbin\/mds$/ {sum += $1} END {printf "%d", sum/1024}') + sp_mb=$(echo "$ps_out" | awk '/syspolicyd|amfid$|com\.apple\.security/ {sum += $1} END {printf "%d", sum/1024}') + wk_mb=$(echo "$ps_out" | awk '/WebKit.WebContent|com\.apple\.WebKit/ {sum += $1} END {printf "%d", sum/1024}') + claude_mb=$(echo "$ps_out" | awk '/\/Applications\/Claude\.app\// {sum += $1} END {printf "%d", sum/1024}') + procs=$(echo "$ps_out" | wc -l | tr -d ' ') + page_size=$(sysctl -n hw.pagesize) + free_mb=$(vm_stat | awk -v ps="$page_size" '/Pages free:/ {gsub("\\.","",$NF); printf "%d", $NF*ps/1024/1024}') + inact_mb=$(vm_stat | awk -v ps="$page_size" '/Pages inactive:/ {gsub("\\.","",$NF); printf "%d", $NF*ps/1024/1024}') + comp_mb=$(vm_stat | awk -v ps="$page_size" '/Pages occupied by compressor:/ {gsub("\\.","",$NF); printf "%d", $NF*ps/1024/1024}') + printf '%d,%s,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d\n' \ + "$i" "$ts" "$lldb_mb" "$sim_mb" "$sim_n" "$xcode_mb" "$edge_mb" "$mds_mb" "$sp_mb" "$wk_mb" "$claude_mb" "$inact_mb" "$procs" "$free_mb" "$comp_mb" >> "$TMP" + + # Top 5 RSS process basenames at this sample — feeds the "unknown grower" detector + echo "$ps_out" | sort -k1 -nr | head -5 | awk -v s="$i" -v t="$ts" '{ + cmd=$2; n=split(cmd, a, "/"); bn=a[n] + sub(/[ \t].*$/, "", bn) # strip args + printf "%s,%s,%d,%s\n", s, t, $1/1024, bn + }' >> "$TOPN" + + [ "$i" -lt "$((SAMPLES - 1))" ] && sleep "$INTERVAL" + done + + { + echo + echo "--- T8: per-sample memory (MB) ---" + cat "$TMP" + echo + echo "--- T8: per-suspect summary (start → end, delta, MB/min growth) ---" + awk -F, ' + NR==1 { for (i=1; i<=NF; i++) col[$i]=i; next } + NR==2 { + for (k in col) if (k != "sample" && k != "ts") { idx = col[k]+0; start[k] = $idx } + first_ts = $(col["ts"]+0) + } + { for (k in col) if (k != "sample" && k != "ts") { idx = col[k]+0; end[k] = $idx } + last_ts = $(col["ts"]+0); n_samples = NR - 1 + } + END { + # rough minutes from sample count: (n-1) * INTERVAL / 60 + mins = (n_samples - 1) * '"$INTERVAL"' / 60 + printf "%-20s %10s %10s %10s %12s\n", "metric", "start_MB", "end_MB", "delta_MB", "MB/min" + for (k in start) { + d = end[k] - start[k] + rate = (mins > 0) ? d / mins : 0 + printf "%-20s %10s %10s %+10d %+12.1f\n", k, start[k], end[k], d, rate + } + } + ' "$TMP" + echo + echo "--- T8: verdict heuristics ---" + awk -F, ' + NR==1 { for (i=1; i<=NF; i++) col[$i]=i; next } + NR==2 { + lldb_s = $(col["lldb_mb"]+0) + sim_s = $(col["sim_total_mb"]+0) + edge_s = $(col["edge_mb"]+0) + xcode_s = $(col["xcode_mb"]+0) + free_s = $(col["free_mb"]+0) + inact_s = $(col["inactive_mb"]+0) + } + { + lldb_e = $(col["lldb_mb"]+0) + sim_e = $(col["sim_total_mb"]+0) + edge_e = $(col["edge_mb"]+0) + xcode_e = $(col["xcode_mb"]+0) + mds_e = $(col["mds_mb"]+0) + free_e = $(col["free_mb"]+0) + inact_e = $(col["inactive_mb"]+0) + n = NR - 1 + } + END { + mins = (n - 1) * '"$INTERVAL"' / 60 + if (mins == 0) { print " (only one sample)"; exit } + edge_rate = (edge_e - edge_s) / mins + lldb_rate = (lldb_e - lldb_s) / mins + sim_rate = (sim_e - sim_s) / mins + xcode_rate = (xcode_e - xcode_s) / mins + free_drop = free_s - free_e + inact_gain = inact_e - inact_s + tracked_gain = (edge_e - edge_s) + (lldb_e - lldb_s) + (sim_e - sim_s) + (xcode_e - xcode_s) + + if (edge_rate > 100) printf " ⚠ Edge growing %d MB/min — confirms Suspect 2 (JS heap)\n", edge_rate + if (lldb_rate > 50) printf " ⚠ lldb-rpc growing %d MB/min — confirms Suspect 1\n", lldb_rate + if (sim_rate > 100) printf " ⚠ sim subsystem growing %d MB/min — confirms Suspect 1\n", sim_rate + if (xcode_rate > 200) printf " ⚠ Xcode toolchain growing %d MB/min — heavy IDE+build load\n", xcode_rate + if (mds_e > 2000) printf " ⚠ mds_stores at %d MB — confirms Suspect 3 (Spotlight reindex)\n", mds_e + + if (free_drop > 2000) { + printf " free_mb dropped %d MB; tracked-suspects gained %d MB; inactive gained %d MB\n", free_drop, tracked_gain, inact_gain + unaccounted = free_drop - tracked_gain - inact_gain + if (unaccounted > 1000) + printf " ⚠ UNACCOUNTED %d MB consumed by processes outside suspect list (see top-growers below)\n", unaccounted + else + printf " ✓ memory shift accounted for (most likely tracked suspects + inactive/file-cache fill)\n" + } + + if (edge_rate < 50 && lldb_rate < 20 && sim_rate < 50 && xcode_rate < 50 && mds_e < 1000 && free_drop < 2000) + print " ✓ All suspects under threshold during this window. Either OOM is intermittent or trigger requires longer observation." + } + ' "$TMP" + echo + echo "--- T8: top growers (processes with biggest RSS delta across the window) ---" + # For each process basename seen in top-5, find max - min RSS in MB across samples. + local GROWERS="${TMP}.growers" + awk -F, ' + { rss = $3; bn = $4 + if (!(bn in min_rss) || rss < min_rss[bn]) min_rss[bn] = rss + if (!(bn in max_rss) || rss > max_rss[bn]) max_rss[bn] = rss + } + END { + for (b in min_rss) { + d = max_rss[b] - min_rss[b] + printf "%+10d\t%-40s %10d %10d\n", d, substr(b,1,40), min_rss[b], max_rss[b] + } + } + ' "$TOPN" | sort -k1 -nr > "$GROWERS" + printf "%-10s %-40s %10s %10s\n" "delta_MB" "process" "min_MB" "max_MB" + head -10 "$GROWERS" | awk -F'\t' '{print $1, $2}' OFS=' ' + rm -f "$GROWERS" + } >> "$LOG" + rm -f "$TMP" "$TOPN" + snapshot "T8 AFTER" "$LOG" + echo "T8 done. Verdict + growth summary:" + grep -E "verdict|MB/min|growing|threshold|^[a-z_]+ +[0-9]+ +" "$LOG" | tail -20 | sed 's/^/ /' +} + +T9_xcode_build_timing() { + # T9 — real-workflow Xcode clean+build. Times a single edge-react-gui iOS Debug + # build. Memory snapshot before+after; reports elapsed + memory delta. The first + # build after a reboot is the most expensive; subsequent ones are faster. + local LOG="$LOG_DIR/T9-xcode-$(date +%Y%m%d-%H%M%S).log" + local PROJECT_DIR="${OOM_T9_PROJECT:-$HOME/git/edge-react-gui}" + local SCHEME="${OOM_T9_SCHEME:-edge}" + local WORKSPACE="${OOM_T9_WORKSPACE:-$PROJECT_DIR/ios/edge.xcworkspace}" + + echo ">> T9 Xcode build timing → $LOG" + echo " project: $PROJECT_DIR" + echo " workspace: $WORKSPACE" + echo " scheme: $SCHEME" + + if [ ! -d "$PROJECT_DIR" ]; then + echo " edge-react-gui not at $PROJECT_DIR; skipping T9 (set OOM_T9_PROJECT)" + return 2 + fi + if [ ! -d "$WORKSPACE" ]; then + echo " workspace not at $WORKSPACE — try OOM_T9_WORKSPACE=/path/to/edge.xcworkspace" + # Try to autodetect + found=$(find "$PROJECT_DIR/ios" -maxdepth 2 -name "*.xcworkspace" -type d 2>/dev/null | head -1) + [ -n "$found" ] && echo " hint: found $found" + return 2 + fi + + snapshot "T9 BEFORE" "$LOG" + local t0; t0=$(elapsed_ms) + ( cd "$PROJECT_DIR" && xcodebuild -workspace "$WORKSPACE" -scheme "$SCHEME" -configuration Debug -destination 'generic/platform=iOS Simulator' clean build 2>&1 | tail -100 ) >> "$LOG" 2>&1 + local rc=$? + local t1; t1=$(elapsed_ms) + { + echo + echo "--- T9: xcodebuild result ---" + echo "elapsed_ms = $((t1 - t0))" + echo "elapsed_min = $(echo "scale=2; ($t1 - $t0) / 60000" | bc 2>/dev/null || python3 -c "print(f'{($t1 - $t0) / 60000:.2f}')")" + echo "exit_code = $rc" + } >> "$LOG" + snapshot "T9 AFTER" "$LOG" + echo "T9 done. elapsed=$(( (t1 - t0) / 1000 ))s exit=$rc" +} + +T6_long_observation() { + echo ">> T6 long observation: T3 + T4 + T5 then idle for 30+ min" + echo " Persistent trace continues recording every 30s." + echo " Stop manually when you've seen what you wanted." + T3_cold_metro + T4_cold_sim_boot + T5_edge_launch + echo + echo "T6 setup complete. Now use Edge with the large account normally." + echo " Trace log: ${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/oom-repro/logs/trace-$(date +%Y-%m-%d).log" + echo " Stop when: load > 30 OR compressor > 30 GB OR you call uncle." + echo " Inspect: tail -f ${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/oom-repro/logs/trace-$(date +%Y-%m-%d).log" +} + +# ─── Driver ────────────────────────────────────────────────────────────────── + +TESTS=("$@") +if [ ${#TESTS[@]} -eq 0 ]; then + TESTS=(T0 T1 T2) +fi + +if [ "${TESTS[0]:-}" = "all" ]; then + TESTS=(T0 T1 T2 T3 T4 T5) +fi + +for t in "${TESTS[@]}"; do + case "$t" in + T0) T0_baseline ;; + T1) T1_node_spawn ;; + T2) T2_uncached_spawn ;; + T3) T3_cold_metro ;; + T4) T4_cold_sim_boot ;; + T5) T5_edge_launch ;; + T6) T6_long_observation ;; + T7) T7_claude_renderer ;; + T7-open|T7open) T7_open_session ;; + T8) T8_suspect_memory_tracker ;; + T9) T9_xcode_build_timing ;; + *) echo "Unknown test: $t (valid: T0 T1 T2 T3 T4 T5 T6 T7 T7-open T8 T9 all)"; exit 1 ;; + esac +done + +echo +echo "All requested tests complete." +echo " Per-test logs: $LOG_DIR/" +echo " Persistent trace: ${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher/oom-repro/logs/trace-$(date +%Y-%m-%d).log" diff --git a/agent-watcher/rc-watchdog.js b/agent-watcher/rc-watchdog.js new file mode 100755 index 0000000..6705ad0 --- /dev/null +++ b/agent-watcher/rc-watchdog.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node +// rc-watchdog.js — Watchdog for claude-asana-* tmux sessions. +// +// Variants handled: +// - Variant 1 (RC bridge dead, claude alive): the pane footer ("Remote Control active") is the source of truth for the bridge. Absent + idle past IDLE_THRESHOLD_MS → revive (wake message, wait, `/remote-control`, then Esc to dismiss the modal). Present → do NOT ping on idleness; only a slow RC_HALFOPEN_BACKSTOP_MS re-register clears a half-open bridge the indicator can't see. Keystroke-only; never spawns a new claude. +// - Completion sweep: if Asana agent_status is Complete for a session's task GID, kill the tmux session to free resources. +// +// REMOVED 2026-05-28 — Variant 2 (process-death auto-resume): +// It detected "claude dead" and injected `claude --resume` into the pane. Two defects made it +// a memory-runaway hazard: (a) detection was broken — claude-code's process `comm` is `cli` +// (argv is renamed), so the /claude/ regex never matched and the watchdog believed claude was +// dead on EVERY tick; (b) the remedy spawned a claude. Combined with /one-shot's old `/loop` +// PR-watcher, resumes could stack and self-replicate into the fork chain that OOM'd the box +// twice (see oom-repro/HANDOFF.md). The watchdog no longer auto-resumes; dead sessions are +// logged and left for manual / Asana-driven handling. +// +// Spawn pattern this watchdog expects: +// tmux new-session -d -s "claude-asana-<id>" \ +// "bash -c 'cd ~/git && claude --rc \"<prompt>\" ; echo \"[claude exited at $(date)]\" ; exec bash'" +// The `exec bash` keeps the pane alive after claude exits. + +const { execSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const slots = require('./lib/slots.js') + +const HOME = process.env.HOME || '' +const DIR = path.join(HOME, '.config/agent-watcher') +const STATE_FILE = path.join(slots.STATE_DIR, 'watchdog-state.json') +const CRED_FILE = path.join(HOME, '.config/agent-watcher/credentials.json') +const CFG_FILE = path.join(HOME, '.config/agent-watcher/asana-config.json') +const IDLE_THRESHOLD_MS = 20 * 60 * 1000 +const RC_ACTIVE_MARKER = 'Remote Control active' // pane footer present iff the RC bridge is up (near-end view) +const RC_HALFOPEN_BACKSTOP_MS = 3 * 60 * 60 * 1000 // re-register even when "up", to clear a half-open bridge the indicator can't see +const SESSION_PREFIX = 'claude-asana-' +const WORKTREES_ROOT = path.join(HOME, 'git/.agent-worktrees') +const DEFAULT_KEEP_COMPLETED = 5 + +// Cache: token + status field GID + retention cap read once per process run. +let _token = null +let _statusFieldGid = null +let _keepCompleted = null +function getAsanaToken() { + if (_token !== null) return _token + try { + const data = JSON.parse(fs.readFileSync(CRED_FILE, 'utf8')) + _token = data.asana_token || '' + } catch { _token = '' } + return _token +} +function getStatusFieldGid() { + if (_statusFieldGid !== null) return _statusFieldGid + try { + const cfg = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8')) + _statusFieldGid = cfg.custom_fields?.agent_status?.gid || '' + } catch { _statusFieldGid = '' } + return _statusFieldGid +} +// How many completed worktrees to retain on disk before pruning the oldest. +function getKeepCompletedWorktrees() { + if (_keepCompleted !== null) return _keepCompleted + try { + const cfg = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8')) + const n = cfg.watcher?.keep_completed_worktrees + _keepCompleted = Number.isFinite(n) && n >= 0 ? n : DEFAULT_KEEP_COMPLETED + } catch { _keepCompleted = DEFAULT_KEEP_COMPLETED } + return _keepCompleted +} + +function fetchAgentStatus(taskGid) { + const token = getAsanaToken() + const fieldGid = getStatusFieldGid() + if (!token || !fieldGid) return null + const out = sh(`curl -sS -H "Authorization: Bearer ${token}" "https://app.asana.com/api/1.0/tasks/${taskGid}?opt_fields=custom_fields.gid,custom_fields.enum_value.name"`) + if (!out) return null + try { + const parsed = JSON.parse(out) + const field = parsed.data?.custom_fields?.find((f) => f.gid === fieldGid) + return field?.enum_value?.name || null + } catch { return null } +} + +function sh(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + } catch { + return '' + } +} + +function shStrict(cmd) { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }).trim() +} + +function listTargetSessions() { + const out = sh('tmux list-sessions -F "#{session_name}"') + return out.split('\n').filter((s) => s.startsWith(SESSION_PREFIX)) +} + +function capturePane(session) { + return sh(`tmux capture-pane -t "${session}" -p`) +} + +function getPanePid(session) { + const out = sh(`tmux list-panes -t "${session}" -F "#{pane_pid}"`) + const n = parseInt(out.split('\n')[0], 10) + return Number.isFinite(n) ? n : null +} + +// Recursively check whether any descendant of `pid` is a live claude-code process. +// claude-code's executable `comm` is `cli` (its argv[0] is renamed), NOT `claude`, so +// we match both: a path/name containing `claude`, OR a bare `cli`. Missing the `cli` +// case is what made the old death-path fire spuriously on every tick. +function claudeRunningUnder(pid, depth = 4) { + if (depth <= 0) return false + const childOut = sh(`pgrep -P ${pid}`) + if (!childOut) return false + const children = childOut.split('\n').filter(Boolean).map((s) => parseInt(s, 10)) + for (const child of children) { + const comm = sh(`ps -o comm= -p ${child}`).trim() + if (/(^|\/)claude($|\s)/.test(comm) || comm === 'cli' || comm.endsWith('/cli')) return true + if (claudeRunningUnder(child, depth - 1)) return true + } + return false +} + +function loadState() { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) + } catch { + return { sessions: {} } + } +} + +function saveState(state) { + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }) + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) +} + +function attemptRcRevive(session) { + log(`[${session}] RC revive: wake ping + /remote-control + Esc-dismiss.`) + sh(`tmux send-keys -t "${session}" "<watchdog-revive-ping>" Enter`) + sh('sleep 8') + sh(`tmux send-keys -t "${session}" "/remote-control" Enter`) + // `/remote-control` opens a blocking modal (Continue / Esc). Left open it + // intercepts ALL keystrokes and wedges the session input — prompts and pings + // do nothing until dismissed, which is what made idle sessions look "hung". + // Dismiss it with Escape ("continue": keeps RC active, just closes the modal) + // so this revive probe can never leave the session stuck behind the dialog. + sh('sleep 2') + sh(`tmux send-keys -t "${session}" Escape`) +} + +function log(msg) { + const ts = new Date().toISOString() + console.log(`[${ts}] ${msg}`) +} + +// On completion we KEEP the worktree on disk (up to keep_completed_worktrees, default 5) +// so it can be inspected or resumed afterward. We still immediately free the scarce +// concurrency resources: the cloned sim (released back to the pool, marked dirty for +// refresh) and the slot record. Worktree pruning to the cap happens in +// pruneRetainedWorktrees(). Best-effort; a partial failure must not wedge the sweep. +function releaseSimAndSlot(taskGid) { + let slot = null + try { slot = slots.get(taskGid) } catch { /* slots.json unreadable */ } + if (slot?.sim_udid) { + log(` [${taskGid}] releasing pool entry holding ${slot.sim_udid}`) + sh(`"${DIR}/release-pool-entry.sh" --task-gid "${taskGid}"`) + } + try { slots.release(taskGid); log(` [${taskGid}] slot freed (worktree retained for inspection)`) } + catch (e) { log(` [${taskGid}] slot release failed: ${e.message}`) } +} + +// Full teardown of one retained worktree: remove the worktree + branch (and any +// lingering sim/slot, though those were freed at completion). Used only by the prune. +function removeWorktree(taskGid, repo) { + log(` [${taskGid}] pruning retained worktree (repo ${repo})`) + try { const s = slots.get(taskGid); if (s?.sim_udid) sh(`"${DIR}/release-pool-entry.sh" --task-gid "${taskGid}"`) } catch { /* none */ } + sh(`"${DIR}/cleanup-task-workspace.sh" --task-gid "${taskGid}" --repo "${repo}"`) + try { slots.release(taskGid) } catch { /* already gone */ } +} + +// Enforce the retention cap. A worktree whose task still has a LIVE tmux session is +// never touched (and does not count against the cap). Among the rest — "retired" +// worktrees: completed, or whose session is gone — keep the newest +// keep_completed_worktrees by directory mtime and prune the older ones. +function pruneRetainedWorktrees(liveSessions) { + const keep = getKeepCompletedWorktrees() + let gidDirs + try { gidDirs = fs.readdirSync(WORKTREES_ROOT) } catch { return } // no worktrees root yet + const activeGids = new Set(liveSessions.map((s) => s.slice(SESSION_PREFIX.length))) + const retired = [] + for (const gid of gidDirs) { + if (activeGids.has(gid)) continue // task still running → keep, don't count + const gidDir = path.join(WORKTREES_ROOT, gid) + let repos + try { + if (!fs.statSync(gidDir).isDirectory()) continue + repos = fs.readdirSync(gidDir) + } catch { continue } + for (const repo of repos) { + const wt = path.join(gidDir, repo) + try { + const st = fs.statSync(wt) + if (st.isDirectory()) retired.push({ gid, repo, mtimeMs: st.mtimeMs }) + } catch { /* skip unreadable */ } + } + } + if (retired.length <= keep) { + if (retired.length > 0) log(`Worktree retention: ${retired.length}/${keep} retained; none pruned`) + return + } + retired.sort((a, b) => b.mtimeMs - a.mtimeMs) // newest first + const toPrune = retired.slice(keep) + log(`Worktree retention: ${retired.length} retired > cap ${keep} → pruning ${toPrune.length} oldest`) + for (const w of toPrune) removeWorktree(w.gid, w.repo) +} + +function main() { + const sessions = listTargetSessions() + if (sessions.length === 0) { + // No live sessions, but still run the retention prune below (completed worktrees + // linger after their sessions are gone and must be capped even when idle). + log(`No ${SESSION_PREFIX}* tmux sessions found.`) + } else { + log(`Watching ${sessions.length} session(s): ${sessions.join(', ')}`) + } + + const state = loadState() + const now = Date.now() + + for (const session of sessions) { + // Completion sweep: if Asana shows agent_status=Complete, kill the session. + const taskGid = session.slice(SESSION_PREFIX.length) + const agentStatus = fetchAgentStatus(taskGid) + if (agentStatus === 'Complete') { + log(`[${session}] Asana agent_status=Complete → killing session, freeing sim+slot, RETAINING worktree`) + sh(`tmux kill-session -t "${session}"`) + releaseSimAndSlot(taskGid) + delete state.sessions[session] + continue + } + + const panePid = getPanePid(session) + if (panePid === null) { + log(`[${session}] could not read pane PID; skipping`) + continue + } + + if (!claudeRunningUnder(panePid)) { + // Death-path auto-resume REMOVED (2026-05-28) — it was a memory-runaway vector. + // Log and leave the session for manual / Asana-driven handling; do NOT spawn claude. + log(`[${session}] claude not detected under pane (pid ${panePid}). NOT auto-resuming (death-path removed). Leaving for manual handling.`) + delete state.sessions[session] + continue + } + + const content = capturePane(session) + const rcUp = content.includes(RC_ACTIVE_MARKER) // near-end belief about the RC bridge + const prior = state.sessions[session] + if (!prior || prior.lastContent !== content) { + state.sessions[session] = { lastContent: content, lastChange: now } + } else { + // Indicator-as-source-of-truth: the pane footer reports whether the RC + // bridge is up. If it's DOWN, revive on the normal idle window. If it's UP, + // do NOT ping on mere idleness (that was the spurious-ping/modal-wedge bug) — + // but keep a slow backstop re-register, because "up" is only the near-end's + // view and can't detect a half-open bridge a remote machine can't attach to. + const idle = now - prior.lastChange + if (!rcUp && idle > IDLE_THRESHOLD_MS) { + log(`[${session}] RC indicator ABSENT + idle ${Math.round(idle / 60000)}m → reviving.`) + attemptRcRevive(session) + state.sessions[session] = { lastContent: content, lastChange: now } // reset so we don't re-fire until the pane settles + } else if (rcUp && idle > RC_HALFOPEN_BACKSTOP_MS) { + log(`[${session}] RC indicator present but idle ${Math.round(idle / 60000)}m → backstop re-register (half-open guard).`) + attemptRcRevive(session) + state.sessions[session] = { lastContent: content, lastChange: now } + } + } + } + + // Enforce the worktree retention cap (keep newest N completed/retired worktrees). + pruneRetainedWorktrees(sessions) + + // Garbage-collect state for sessions that no longer exist + for (const key of Object.keys(state.sessions)) { + if (!sessions.includes(key)) delete state.sessions[key] + } + + saveState(state) +} + +main() diff --git a/agent-watcher/release-pool-entry.sh b/agent-watcher/release-pool-entry.sh new file mode 100755 index 0000000..bcddb8c --- /dev/null +++ b/agent-watcher/release-pool-entry.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# release-pool-entry.sh — Mark the pool entry currently held by a task as dirty, +# so the next ensure-sim-pool.sh run refreshes it (delete stale sim, clone fresh). +# +# Called by the watchdog when a task reaches Complete. Best-effort: if the task +# isn't found in the pool (e.g. bootstrapped session that bypassed the pool), +# this script exits 0 with a notice on stderr — does NOT fail. +# +# Usage: +# release-pool-entry.sh --task-gid <gid> +# +# Exit codes: +# 0 = released (or task not found — non-fatal) +# 1 = pool file missing / malformed + +set -euo pipefail + +DIR="$HOME/.config/agent-watcher" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher"; mkdir -p "$STATE_DIR" +POOL="$STATE_DIR/pool.json" +LOCK="$DIR/pool.lock" + +TASK_GID="" +while [[ $# -gt 0 ]]; do + case "$1" in + --task-gid) TASK_GID="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ -n "$TASK_GID" ]] || { echo "Usage: release-pool-entry.sh --task-gid <gid>" >&2; exit 1; } +if [[ ! -f "$POOL" ]]; then + echo ">> release-pool-entry: no pool file at $POOL — nothing to release" >&2 + exit 0 +fi + +i=0 +while ! ( set -C; : > "$LOCK" ) 2>/dev/null; do + i=$((i + 1)) + [[ $i -gt 300 ]] && { echo "Could not acquire $LOCK after 30s" >&2; exit 1; } + sleep 0.1 +done +trap 'rm -f "$LOCK"' EXIT + +POOL_JSON=$(cat "$POOL") + +SLOT=$(jq -r --arg t "$TASK_GID" '.pool[] | select(.task_gid == $t) | .slot' <<<"$POOL_JSON" | head -1) +if [[ -z "$SLOT" ]]; then + echo ">> release-pool-entry: no pool slot held by task $TASK_GID (likely bootstrapped); nothing to release" >&2 + exit 0 +fi + +NEW_JSON=$(jq --arg s "$SLOT" \ + '(.pool[] | select(.slot == ($s | tonumber)).state) = "dirty" + | (.pool[] | select(.slot == ($s | tonumber)).task_gid) = null' <<<"$POOL_JSON") +tmp=$(mktemp) +jq . > "$tmp" <<<"$NEW_JSON" +mv "$tmp" "$POOL" + +echo ">> release-pool-entry: slot $SLOT marked dirty (was task $TASK_GID)" >&2 diff --git a/agent-watcher/resume-agent.sh b/agent-watcher/resume-agent.sh new file mode 100755 index 0000000..9a60311 --- /dev/null +++ b/agent-watcher/resume-agent.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# resume-agent.sh — Find and resume a watcher-spawned claude session. +# +# Watcher-spawned sessions have a unique signature: +# (a) project dir is enc(~/git) — e.g. -Users-<user>-git (cwd was ~/git when spawned) +# (b) the first user message starts with `/one-shot --yolo` +# Filtering on both excludes other claude sessions (this desktop app's history, +# ad-hoc terminal sessions, etc.) that may incidentally mention the same term. +# +# Usage: +# resume-agent.sh # picks the most recent watcher session +# resume-agent.sh <search-term> # filters to histories containing the term +# # (Asana task GID, task name fragment, etc.) +# resume-agent.sh --list # list candidates; do not resume +# resume-agent.sh <task-gid> --recover +# # before resuming, if the task's slot is gone +# # but Asana shows it in-flight, re-provision the +# # worktree + sim + Metro port (slot re-allocate). +# # Default (no --recover) just `claude --resume`. +# +# Exit codes: +# 0 = matched + resumed (or listed, with --list) +# 1 = no match / search produced no candidates + +set -euo pipefail + +DIR="$HOME/.config/agent-watcher" +DO_LIST=false +RECOVER=false +TERM="" +for arg in "$@"; do + case "$arg" in + --list) DO_LIST=true ;; + --recover) RECOVER=true ;; + -h|--help) + sed -n '2,/^$/p' "$0" | sed 's|^# \{0,1\}||' + exit 0 + ;; + *) TERM="$arg" ;; + esac +done + +# --recover: re-provision a missing slot for an in-flight task before resuming. +# No-op unless TERM is a bare task GID and the slot is actually gone. +recover_slot() { + local gid="$1" + [[ "$gid" =~ ^[0-9]+$ ]] || { echo ">> resume-agent: --recover needs a numeric task GID; skipping" >&2; return 0; } + + local existing + existing=$(node "$DIR/lib/slots.js" get --task-gid "$gid" 2>/dev/null | tr -d '[:space:]') + if [[ -n "$existing" ]]; then + echo ">> resume-agent: slot for $gid already present; no recovery needed" >&2 + return 0 + fi + + local cfg="$DIR/asana-config.json" cred="$DIR/credentials.json" + [[ -f "$cfg" && -f "$cred" ]] || { echo ">> resume-agent: missing config/credentials; cannot recover" >&2; return 0; } + local token field_gid status repo + token=$(jq -r .asana_token "$cred") + field_gid=$(jq -r .custom_fields.agent_status.gid "$cfg") + status=$(curl -sS -H "Authorization: Bearer $token" \ + "https://app.asana.com/api/1.0/tasks/$gid?opt_fields=custom_fields.gid,custom_fields.enum_value.name" 2>/dev/null \ + | jq -r --arg f "$field_gid" '.data.custom_fields[]? | select(.gid==$f) | .enum_value.name // ""') + + case "$status" in + Planning|Developing|Reviewing|Testing) + repo=$(jq -r '.watcher.default_repo // "edge-react-gui"' "$cfg") + echo ">> resume-agent: slot for $gid missing but Asana=$status → re-provisioning ($repo)" >&2 + local wt sim + wt=$("$DIR/setup-task-workspace.sh" --task-gid "$gid" --repo "$repo" | tail -1) + sim=$("$DIR/clone-ios-sim.sh" --name "agent-sim-$gid" | tail -1) + node "$DIR/lib/slots.js" allocate --task-gid "$gid" --worktree-path "$wt" --sim-udid "$sim" >/dev/null + echo ">> resume-agent: re-provisioned slot for $gid (wt=$wt sim=$sim)" >&2 + ;; + *) + echo ">> resume-agent: task $gid not in-flight (status='${status:-unknown}'); skipping re-allocation" >&2 + ;; + esac +} + +# Watcher-spawned sessions live under one of two shapes: +# ~/.claude/projects/<enc(~/git)>/<uuid>.jsonl +# (legacy: pre-parallelization, cwd was ~/git/) +# ~/.claude/projects/<enc(~/git)>--agent-worktrees-<task-gid>-<repo>/<uuid>.jsonl +# (current: per-task worktree under ~/git/.agent-worktrees/<gid>/<repo>/) +# claude encodes a project dir by replacing every "/" and "." in the cwd with "-". +# Derive the prefix from $HOME so this works under any macOS user (not just "jontz"). +# Both shapes share the enc(~/git) prefix, so one glob catches them all. +ENC_GIT_PREFIX=$(printf '%s' "$HOME/git" | sed 's#[/.]#-#g') +CANDIDATES=() +shopt -s nullglob +for d in "$HOME/.claude/projects/$ENC_GIT_PREFIX"*; do + [[ -d "$d" ]] || continue + for f in "$d"/*.jsonl; do + [[ -f "$f" ]] || continue + if head -20 "$f" | grep -q '"/one-shot --yolo' ; then + CANDIDATES+=("$f") + fi + done +done +shopt -u nullglob + +if [[ ${#CANDIDATES[@]} -eq 0 ]]; then + echo "No watcher-spawned sessions found in ~/.claude/projects/${ENC_GIT_PREFIX}*" >&2 + exit 1 +fi + +# Optionally filter by search term +if [[ -n "$TERM" ]]; then + FILTERED=() + for f in "${CANDIDATES[@]}"; do + if grep -q -- "$TERM" "$f"; then + FILTERED+=("$f") + fi + done + if [[ ${#FILTERED[@]} -eq 0 ]]; then + echo "No watcher-spawned session matches: $TERM" >&2 + echo "(candidates that exist but don't match the term:)" >&2 + for f in "${CANDIDATES[@]}"; do echo " $(basename "$f" .jsonl)" >&2; done + exit 1 + fi + CANDIDATES=("${FILTERED[@]}") +fi + +# Sort by mtime desc; emit one line per candidate with timestamp + UUID + first prompt preview. +emit_candidates() { + for f in "${CANDIDATES[@]}"; do + mtime=$(stat -f "%m" "$f") + uuid=$(basename "$f" .jsonl) + # Find the first user `/one-shot ...` line and pull a short preview of the prompt. + preview=$(head -30 "$f" | grep -m1 '"/one-shot --yolo' | sed -E 's/.*"(\/one-shot --yolo [^"]{0,80})[^"]*".*/\1/' | head -c 100) + printf "%s\t%s\t%s\n" "$mtime" "$uuid" "$preview" + done | sort -rn +} + +if $DO_LIST; then + echo "Watcher-spawned sessions (newest first):" + emit_candidates | awk -F'\t' '{ + t=$1; u=$2; p=$3 + cmd="date -r " t " +\"%Y-%m-%d %H:%M:%S\"" + cmd | getline ts + close(cmd) + printf " %s %s %s\n", ts, u, p + }' + exit 0 +fi + +if $RECOVER && [[ -n "$TERM" ]]; then + recover_slot "$TERM" +fi + +LATEST_UUID=$(emit_candidates | head -1 | cut -f2) + +# Find the matching JSONL file and read the session's original cwd from it. +# claude resumes the conversation by UUID but new tool calls run at the user's +# current shell cwd — for a worktree session, those paths won't resolve unless +# we `cd` to the original cwd first. +LATEST_JSONL="" +for f in "${CANDIDATES[@]}"; do + if [[ "$(basename "$f" .jsonl)" == "$LATEST_UUID" ]]; then + LATEST_JSONL="$f" + break + fi +done + +ORIG_CWD="" +if [[ -n "$LATEST_JSONL" ]]; then + # cwd is recorded on most JSONL records; the first non-null occurrence is the truth. + # `head -1` closes the pipe early; for a large history jq is still streaming and + # dies with SIGPIPE (141). `|| true` absorbs that so `set -e` doesn't abort here. + ORIG_CWD=$(jq -r 'select(.cwd != null) | .cwd' "$LATEST_JSONL" 2>/dev/null | head -1 || true) +fi + +# `claude --resume` scopes session lookup to the project dir derived from cwd. +# A worktree session lives under <worktrees_root>/<gid>/<repo>; claude resolves it +# from that exact dir or from the repos root (~/git), but NOT from $HOME. So: cd to +# the original cwd if it still exists (tool calls hit real files), else fall back to +# the repos root (proven to resolve reaped worktree sessions). Never $HOME. +if [[ -n "$ORIG_CWD" && -d "$ORIG_CWD" ]]; then + echo ">> resume-agent: cd $ORIG_CWD" >&2 + cd "$ORIG_CWD" +elif [[ -n "$ORIG_CWD" ]]; then + repos_root=$(jq -r '.watcher.repos_root // empty' "$DIR/asana-config.json" 2>/dev/null) + repos_root="${repos_root/#\~/$HOME}" + if [[ -n "$repos_root" && -d "$repos_root" ]]; then + echo ">> resume-agent: $ORIG_CWD gone (worktree reaped?) — resuming from repos root $repos_root" >&2 + cd "$repos_root" + else + echo ">> resume-agent: $ORIG_CWD gone and repos root unavailable — resuming from \$HOME (resume may fail)" >&2 + cd "$HOME" + fi +fi + +echo ">> resume-agent: resuming $LATEST_UUID (--dangerously-skip-permissions)" +exec claude --dangerously-skip-permissions --resume "$LATEST_UUID" diff --git a/agent-watcher/runaway-guard.sh b/agent-watcher/runaway-guard.sh new file mode 100755 index 0000000..dd3ebc0 --- /dev/null +++ b/agent-watcher/runaway-guard.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# runaway-guard.sh — Detect and atomically kill runaway claude-code 'cli' fork chains. +# +# THE FAILURE THIS PREVENTS (observed twice on 2026-05-28): +# A toxic agent session — one running a /loop or "babysit PR until green" pattern under +# --remote-control — can, on resume, spawn an unbounded self-replicating chain of 'cli' +# (claude-code node) processes. Each cli spawns one child cli; they all share one process +# group and orphan to launchd as parents detach. ~475 procs/sec. This filled 64 GB of VM +# compressor, exhausted swap, and triggered a macOS jetsam mass-kill (~1880 procs) twice. +# See oom-repro/HANDOFF.md "recursive claude-spawn" finding. +# +# WHY kill -9 -PGID (process group), not pkill: +# A self-replicating chain regenerates faster than non-atomic `pkill -x cli` can clear it — +# survivors spawned mid-kill continue the chain. Killing the whole process group in one +# syscall (kill -9 -PGID) is atomic and stops it dead. This is the ONLY thing that worked +# during the live incident. +# +# WHY a per-PGID threshold is safe: +# Legitimate claude workflows fan out as a flat tree capped at ~16 concurrent agents in one +# group (the harness caps concurrency at min(16, cores-2)). A fork chain reaches hundreds in +# one group within seconds. A threshold of ~50 cleanly separates the two with wide margin. +# +# CADENCE: loops internally every CHECK_INTERVAL seconds for ~LOOP_DURATION, then exits, so a +# 60s launchd StartInterval yields near-continuous coverage. The storm reaches thousands in +# tens of seconds, so a fast inner cadence matters; the atomic kill handles any size. +# +# Usage: +# runaway-guard.sh # run the internal loop (launchd entrypoint) +# runaway-guard.sh --once # single check (for testing) +# RUNAWAY_CLI_THRESHOLD=50 ... # per-process-group cli count that triggers a kill +# +# Exit codes: 0 always (a guard must never wedge the watcher chain). + +set -u + +THRESHOLD=${RUNAWAY_CLI_THRESHOLD:-50} # per-pgid cli count that triggers a kill +RECORD_THRESHOLD=${RUNAWAY_RECORD_THRESHOLD:-25} # earlier count that triggers a forensic capture (parents still alive) +CHECK_INTERVAL=${RUNAWAY_CHECK_INTERVAL:-3} +LOOP_DURATION=${RUNAWAY_LOOP_DURATION:-57} +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/agent-watcher"; mkdir -p "$STATE_DIR" +LOG="$STATE_DIR/runaway-guard.log" +FORENSIC_DIR="$STATE_DIR/forensics" +CAPTURE="$HOME/.config/agent-watcher/capture-runaway-forensics.sh" + +ts() { date "+%Y-%m-%dT%H:%M:%S"; } + +# Rotate the log if it grows past ~2MB (cheap, keeps it bounded). +if [[ -f "$LOG" ]] && [[ $(stat -f%z "$LOG" 2>/dev/null || echo 0) -gt 2097152 ]]; then + tail -2000 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG" +fi + +check_once() { + # Count cli processes per process group in one ps invocation. + local counts + counts=$(ps -axo pgid,comm 2>/dev/null | awk '$2=="cli"{c[$1]++} END{for(g in c) print c[g], g}') + [[ -z "$counts" ]] && return 0 + + local now; now=$(date +%s) + while read -r count pgid; do + [[ -z "$pgid" ]] && continue + + # FORENSIC CAPTURE at the early threshold — runs BEFORE any kill so the chain's + # parents/seed are still alive. Once per pgid per 10-min window (marker file). + if (( count >= RECORD_THRESHOLD )); then + local marker="$FORENSIC_DIR/.captured-$pgid" + local mt=0; [[ -f "$marker" ]] && mt=$(stat -f%m "$marker" 2>/dev/null || echo 0) + if (( now - mt > 600 )); then + echo "$(ts) RECORD: pgid=$pgid has $count cli (>= $RECORD_THRESHOLD) — capturing forensics" >> "$LOG" + local f; f=$(bash "$CAPTURE" "$pgid" 2>/dev/null) + echo "$(ts) forensics → ${f:-<capture failed>}" >> "$LOG" + mkdir -p "$FORENSIC_DIR" 2>/dev/null; touch "$marker" + fi + fi + + # KILL at the kill threshold — atomic process-group kill (leading '-'). + if (( count >= THRESHOLD )); then + { + echo "$(ts) RUNAWAY: pgid=$pgid has $count cli (>= $THRESHOLD) — kill -9 -$pgid" + kill -9 -"$pgid" 2>/dev/null + } >> "$LOG" + fi + done <<< "$counts" + + # Brief settle, then report residual so a persistent seed shows up across ticks. + sleep 1 + local remaining + remaining=$(ps -axo comm 2>/dev/null | grep -c '^cli$') + (( remaining > 0 )) && echo "$(ts) post-tick cli total=$remaining" >> "$LOG" +} + +if [[ "${1:-}" == "--once" ]]; then + check_once + exit 0 +fi + +# Internal loop: cover the whole launchd interval at a fast inner cadence. +elapsed=0 +while (( elapsed < LOOP_DURATION )); do + check_once + sleep "$CHECK_INTERVAL" + elapsed=$(( elapsed + CHECK_INTERVAL )) +done +exit 0 diff --git a/agent-watcher/set-github-secret.sh b/agent-watcher/set-github-secret.sh new file mode 100755 index 0000000..2720e74 --- /dev/null +++ b/agent-watcher/set-github-secret.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# set-github-secret.sh — Add or update ASANA_GITHUB_SECRET in credentials.json +# and ensure the ~/.zshrc loader exports it. +# +# Idempotent. Safe to re-run. + +set -euo pipefail + +CRED="$HOME/.config/agent-watcher/credentials.json" +ZSHRC="$HOME/.zshrc" +LOADER_LINE='export ASANA_GITHUB_SECRET=$(jq -r '"'"'.asana_github_secret // empty'"'"' ~/.config/agent-watcher/credentials.json 2>/dev/null)' +LOADER_MARKER='# agent-watcher: load ASANA_GITHUB_SECRET from credentials.json' + +ok() { printf " \033[32mOK\033[0m %s\n" "$1"; } +warn() { printf " \033[33m!!\033[0m %s\n" "$1"; } +fail() { printf " \033[31mERR\033[0m %s\n" "$1"; exit 1; } + +[[ -f "$CRED" ]] || fail "Missing $CRED — run setup.sh first to set ASANA_TOKEN." +command -v jq >/dev/null 2>&1 || fail "jq not found." + +if [[ -n $(jq -r '.asana_github_secret // empty' "$CRED") ]]; then + printf " asana_github_secret already set in credentials.json. Overwrite? [y/N] " + read -r yn + [[ "$yn" =~ ^[Yy]$ ]] || { warn "keeping existing secret"; exit 0; } +fi + +printf " Paste ASANA_GITHUB_SECRET (input hidden, Enter when done): " +stty -echo +IFS= read -r SECRET || true +stty echo +printf "\n" +[[ -n "${SECRET:-}" ]] || fail "empty input" + +# Merge into existing JSON (preserve asana_token + any other fields) +tmpfile=$(mktemp) +jq --arg s "$SECRET" '. + {asana_github_secret: $s}' "$CRED" > "$tmpfile" +mv "$tmpfile" "$CRED" +chmod 600 "$CRED" +unset SECRET +ok "merged into $CRED (mode 600)" + +# Add zshrc loader if missing +if grep -qF "$LOADER_MARKER" "$ZSHRC" 2>/dev/null; then + ok "zshrc loader already present" +else + { + echo "" + echo "$LOADER_MARKER" + echo "if [ -f ~/.config/agent-watcher/credentials.json ]; then" + echo " $LOADER_LINE" + echo "fi" + } >> "$ZSHRC" + ok "appended loader to $ZSHRC" +fi + +printf "\n\033[1;32mDone.\033[0m Open a new shell to pick up \$ASANA_GITHUB_SECRET, or run:\n" +printf " export ASANA_GITHUB_SECRET=\$(jq -r .asana_github_secret %s)\n" "$CRED" diff --git a/agent-watcher/setup-kanban-sections.sh b/agent-watcher/setup-kanban-sections.sh new file mode 100755 index 0000000..0a70f62 --- /dev/null +++ b/agent-watcher/setup-kanban-sections.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# setup-kanban-sections.sh — One-time (idempotent) setup of agent_status-aligned +# kanban sections in the Asana project + persist section GIDs into +# ~/.config/agent-watcher/asana-config.json so update-status.sh can move tasks +# to the matching section. +# +# Behavior: +# 1. Read existing sections from the project. +# 2. For each agent_status value (Pending, Planning, Developing, Reviewing, +# Testing, Complete): +# - If a section with that exact name exists, capture its GID. +# - If the project's default "Untitled section" still exists AND no +# matching section is found for the first missing status, RENAME the +# default to that status (avoids leaving a stray section behind). +# - Otherwise, CREATE the section. +# 3. Write the resulting section_gids map into asana-config.json under +# .custom_fields.agent_status.section_gids (sibling of .options). +# 4. Optionally backfill existing tasks: move each task to the section +# matching its current agent_status. Pass --backfill to enable. +# +# Re-running this script is safe — sections won't be duplicated. +# +# Usage: +# setup-kanban-sections.sh [--backfill] +# +# Exit codes: +# 0 = success (config + sections in sync) +# 1 = error (Asana API failure, malformed config) + +set -euo pipefail + +CONFIG="$HOME/.config/agent-watcher/asana-config.json" +CRED="$HOME/.config/agent-watcher/credentials.json" +API="https://app.asana.com/api/1.0" + +[[ -f "$CONFIG" ]] || { echo "Missing $CONFIG" >&2; exit 1; } +[[ -f "$CRED" ]] || { echo "Missing $CRED" >&2; exit 1; } + +BACKFILL=false +while [[ $# -gt 0 ]]; do + case "$1" in + --backfill) BACKFILL=true; shift ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +TOKEN=$(jq -r .asana_token "$CRED") +PROJECT_GID=$(jq -r .project_gid "$CONFIG") + +# Section names that mirror agent_status enum (order matters for the kanban view) +ORDERED_STATUSES=(Pending Planning Developing Reviewing Testing Complete) + +# ─── 1. Read existing sections ─────────────────────────────────────────────── +EXISTING=$(curl -sS -H "Authorization: Bearer $TOKEN" "$API/projects/$PROJECT_GID/sections?opt_fields=name,gid") +echo "$EXISTING" | jq -e .data >/dev/null 2>&1 || { + echo "Failed to fetch sections: $EXISTING" >&2 + exit 1 +} + +# ─── 2. Resolve each status to a section GID (find / rename / create) ──────── +declare -A SECTION_GIDS +UNTITLED_GID=$(echo "$EXISTING" | jq -r '.data[] | select(.name == "Untitled section") | .gid' | head -1) + +for status in "${ORDERED_STATUSES[@]}"; do + existing_gid=$(echo "$EXISTING" | jq -r --arg n "$status" '.data[] | select(.name == $n) | .gid' | head -1) + + if [[ -n "$existing_gid" ]]; then + SECTION_GIDS[$status]="$existing_gid" + echo ">> section '$status' already exists (gid=$existing_gid)" + continue + fi + + # Absorb the auto-created "Untitled section" on the first missing status + if [[ -n "$UNTITLED_GID" ]]; then + echo ">> renaming 'Untitled section' → '$status' (gid=$UNTITLED_GID)" + resp=$(curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "$(jq -n --arg n "$status" '{data: {name: $n}}')" \ + "$API/sections/$UNTITLED_GID") + new_gid=$(echo "$resp" | jq -r '.data.gid // empty') + [[ -n "$new_gid" ]] || { echo "Rename failed: $resp" >&2; exit 1; } + SECTION_GIDS[$status]="$new_gid" + UNTITLED_GID="" # only absorb once + continue + fi + + # Create it fresh + echo ">> creating section '$status'" + resp=$(curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "$(jq -n --arg n "$status" '{data: {name: $n}}')" \ + "$API/projects/$PROJECT_GID/sections") + new_gid=$(echo "$resp" | jq -r '.data.gid // empty') + [[ -n "$new_gid" ]] || { echo "Create failed for '$status': $resp" >&2; exit 1; } + SECTION_GIDS[$status]="$new_gid" +done + +# ─── 3. Persist into asana-config.json (merge into custom_fields.agent_status.section_gids) ── +SECTIONS_JSON=$(jq -n \ + --arg pe "${SECTION_GIDS[Pending]}" \ + --arg pl "${SECTION_GIDS[Planning]}" \ + --arg dv "${SECTION_GIDS[Developing]}" \ + --arg rv "${SECTION_GIDS[Reviewing]}" \ + --arg tn "${SECTION_GIDS[Testing]}" \ + --arg cp "${SECTION_GIDS[Complete]}" \ + '{Pending:$pe, Planning:$pl, Developing:$dv, Reviewing:$rv, Testing:$tn, Complete:$cp}') + +tmp=$(mktemp) +jq --argjson s "$SECTIONS_JSON" '.custom_fields.agent_status.section_gids = $s' "$CONFIG" > "$tmp" +mv "$tmp" "$CONFIG" +chmod 600 "$CONFIG" +echo ">> wrote section_gids into $CONFIG" + +# ─── 4. Optional backfill: move existing tasks to their current-status section ── +if $BACKFILL; then + echo ">> backfill: moving existing tasks to their agent_status section" + STATUS_FIELD_GID=$(jq -r .custom_fields.agent_status.gid "$CONFIG") + TASKS=$(curl -sS -H "Authorization: Bearer $TOKEN" \ + "$API/projects/$PROJECT_GID/tasks?opt_fields=name,custom_fields.gid,custom_fields.enum_value.name") + echo "$TASKS" | jq -c '.data[]' | while read -r task; do + gid=$(echo "$task" | jq -r '.gid') + name=$(echo "$task" | jq -r '.name' | cut -c1-60) + status=$(echo "$task" | jq -r --arg f "$STATUS_FIELD_GID" '.custom_fields[]? | select(.gid == $f) | .enum_value.name // empty') + if [[ -z "$status" ]]; then + echo " skip (no agent_status): $name" + continue + fi + target=$(jq -r --arg s "$status" '.custom_fields.agent_status.section_gids[$s] // empty' "$CONFIG") + if [[ -z "$target" ]]; then + echo " skip (no section for status '$status'): $name" + continue + fi + moveresp=$(curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "$(jq -n --arg t "$gid" '{data: {task: $t}}')" \ + "$API/sections/$target/addTask") + if echo "$moveresp" | jq -e .errors >/dev/null 2>&1; then + echo " FAIL move: $name → $status — $(echo "$moveresp" | jq -c .errors)" + else + echo " moved → $status: $name" + fi + done +fi + +echo ">> setup-kanban-sections: done" diff --git a/agent-watcher/setup-task-workspace.sh b/agent-watcher/setup-task-workspace.sh new file mode 100755 index 0000000..09320f0 --- /dev/null +++ b/agent-watcher/setup-task-workspace.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# setup-task-workspace.sh — Create a per-task git worktree for a parallel agent slot. +# +# Each parallel agent runs in its own worktree so concurrent sessions never share +# a working tree. The worktree lives at: +# ~/git/.agent-worktrees/<task-gid>/<repo>/ +# on a fresh branch `agent/<task-gid>` based on origin/develop (configurable). +# Works for ANY repo under ~/git (gui or a dependency), so one task can have several +# co-located worktrees that updot can sibling-link. +# env.json is COPIED (a real file, NOT a symlink) from the main checkout. A symlink is +# fragile: the repo's `configure` step (scripts/configure.ts → cleaner-config makeConfig) +# rewrites env.json, and if the link isn't resolving to a real file when that runs, the +# worktree ends up with a defaults-only skeleton (every secret blank/false/null). A real +# copy is read by configure and its real, in-schema values survive the rewrite. Copy also +# avoids the write-through footgun where a tool writing env.json clobbers the shared main +# file. env.json is gitignored, so the copy never lands in a commit/PR. +# node_modules is APFS-cloned (cp -c) from the main checkout so the agent session +# does NOT run a full `npm install`. A scratch install in a project this size spawns +# ~1500 node workers and OOM'd the machine on 2026-05-28 (see oom-repro/HANDOFF.md). +# The clone is copy-on-write: ~26s for a 2.6 GB / 164k-file tree, single-process, +# ~500 MB transient memory, near-zero new disk blocks. Each worktree's tree diverges +# only on files it changes; the session's own `npm install` reconciles just the diff. +# +# Usage: +# setup-task-workspace.sh --task-gid <gid> --repo <name> [--base <ref>] +# +# --task-gid REQUIRED. Asana task GID; namespaces the worktree + branch. +# --repo REQUIRED. Repo name under ~/git, e.g. edge-react-gui. +# --base Base ref for the new branch (default: origin/develop). +# +# Idempotent: if the worktree already exists it is reused (env.json copy re-ensured) +# and its path is returned without re-creating anything. +# +# Prints the worktree path on stdout, status on stderr. +# +# Exit codes: +# 0 = worktree ready (path on stdout) +# 1 = error (missing repo, worktree add failed) +# 2 = usage error + +set -euo pipefail + +CONFIG="$HOME/.config/agent-watcher/asana-config.json" +WORKTREES_ROOT="$HOME/git/.agent-worktrees" +REPOS_ROOT="$HOME/git" + +TASK_GID="" +REPO="" +BASE="" # empty = resolve per-repo below (prefer origin/develop, else the repo's default branch) + +while [[ $# -gt 0 ]]; do + case "$1" in + --task-gid) TASK_GID="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +[[ -n "$TASK_GID" && -n "$REPO" ]] || { + echo "Usage: setup-task-workspace.sh --task-gid <gid> --repo <name> [--base <ref>]" >&2 + exit 2 +} + +MAIN_REPO="$REPOS_ROOT/$REPO" +[[ -d "$MAIN_REPO/.git" ]] || { echo "Repo not found or not a git repo: $MAIN_REPO" >&2; exit 1; } + +# Resolve the base ref when not given on the CLI. Edge convention is `develop`; +# repos that don't use it (most deps/servers) fall back to their actual default +# branch (origin/HEAD), then origin/main, then origin/master. +if [[ -z "$BASE" ]]; then + if git -C "$MAIN_REPO" rev-parse --verify --quiet "origin/develop" >/dev/null 2>&1; then + BASE="origin/develop" + else + BASE="$(git -C "$MAIN_REPO" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/||')" + if [[ -z "$BASE" ]]; then + for cand in origin/main origin/master; do + git -C "$MAIN_REPO" rev-parse --verify --quiet "$cand" >/dev/null 2>&1 && { BASE="$cand"; break; } + done + fi + fi + [[ -z "$BASE" ]] && BASE="origin/develop" # last-resort + echo ">> setup-task-workspace: base ref for $REPO → $BASE" >&2 +fi + +WT="$WORKTREES_ROOT/$TASK_GID/$REPO" +BRANCH="agent/$TASK_GID" + +# ── APFS clone of node_modules from the main checkout ───────────────────────── +# Uses cp -c (clonefile) so it's instant and copy-on-write. Both paths live under +# ~/git, the same APFS volume, so the clone never falls back to a full byte copy. +# Best-effort: a failure warns and leaves the worktree without node_modules so the +# session can still recover via its own `npm install`. +clone_node_modules() { + local src="$MAIN_REPO/node_modules" + local dst="$WT/node_modules" + if [[ ! -d "$src" ]]; then + echo ">> setup-task-workspace: WARN — $src not present; skipping node_modules clone" >&2 + return 0 + fi + if [[ -e "$dst" ]]; then + echo ">> setup-task-workspace: node_modules already present in worktree; skipping clone" >&2 + return 0 + fi + local t0 t1 + t0=$(date +%s) + if cp -cR "$src" "$dst" 2>/tmp/setup-clone.log; then + t1=$(date +%s) + echo ">> setup-task-workspace: APFS-cloned node_modules in $((t1 - t0))s ($src → $dst)" >&2 + else + echo ">> setup-task-workspace: WARN — node_modules clone failed; session will need a full npm install" >&2 + cat /tmp/setup-clone.log >&2 + rm -rf "$dst" 2>/dev/null || true + fi +} + +# ── Copy env.json from the main checkout (durable real file, NOT a symlink) ─── +# rm first so we never write *through* an existing symlink into the shared main +# env.json. See the header comment for why a copy beats a symlink here. +ensure_env_json() { + if [[ -f "$MAIN_REPO/env.json" ]]; then + rm -f "$WT/env.json" + cp "$MAIN_REPO/env.json" "$WT/env.json" + echo ">> setup-task-workspace: copied env.json ← $MAIN_REPO/env.json" >&2 + else + echo ">> setup-task-workspace: WARN — $MAIN_REPO/env.json not found; worktree has NO secrets" >&2 + fi +} + +link_shared_memory() { + # Surface shared Claude memory (orchestration + user context) for this task so + # the spawned agent sees it. A worktree session reads auto-memory from the MAIN + # repo's memory dir (verified empirically), and the helper resolves the worktree + # path to that git-root dir, so passing "$WT" links the right place. Idempotent + # and non-fatal — never blocks setup. Output → stderr so stdout stays just "$WT". + local helper="$HOME/.claude/link-shared-memory.sh" + if [[ -x "$helper" ]]; then + "$helper" "$WT" >&2 || echo ">> setup-task-workspace: WARN — link-shared-memory failed (non-fatal)" >&2 + fi +} + +# ── Idempotent reuse ────────────────────────────────────────────────────────── +if git -C "$MAIN_REPO" worktree list --porcelain | grep -qxF "worktree $WT"; then + echo ">> setup-task-workspace: worktree already exists, reusing $WT" >&2 + ensure_env_json + clone_node_modules + link_shared_memory + echo "$WT" + exit 0 +fi + +mkdir -p "$WORKTREES_ROOT/$TASK_GID" + +# Best-effort refresh of the base ref so we branch from current develop. +git -C "$MAIN_REPO" fetch --quiet origin "${BASE#origin/}" 2>/dev/null \ + || echo ">> setup-task-workspace: WARN — fetch of $BASE failed; using local ref" >&2 + +echo ">> setup-task-workspace: git worktree add -b $BRANCH $WT $BASE" >&2 +# Route git's stdout to a log so the ONLY thing on our stdout is the worktree path. +if ! git -C "$MAIN_REPO" worktree add -b "$BRANCH" "$WT" "$BASE" >/tmp/setup-wt.log 2>&1; then + echo "worktree add failed:" >&2 + cat /tmp/setup-wt.log >&2 + rmdir "$WORKTREES_ROOT/$TASK_GID" 2>/dev/null || true + exit 1 +fi +cat /tmp/setup-wt.log >&2 + +# ── Copy env.json from the main checkout (real file; survives `configure`) ───── +ensure_env_json + +# ── Clone node_modules from the main checkout ─────────────────────────────────── +clone_node_modules + +# ── Surface shared Claude memory in this worktree (non-fatal) ───────────────── +link_shared_memory + +echo ">> setup-task-workspace: ready $WT (branch $BRANCH)" >&2 +echo "$WT" diff --git a/agent-watcher/setup.sh b/agent-watcher/setup.sh new file mode 100755 index 0000000..733c125 --- /dev/null +++ b/agent-watcher/setup.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# setup.sh — One-time setup for agent-watcher. +# +# Idempotent. Safe to re-run. +# +# Steps: +# 1. Install tmux if missing. +# 2. Verify jq is available. +# 3. Ensure ~/.config/agent-watcher/ exists with mode 700. +# 4. Prompt for Asana PAT (hidden input), write credentials.json mode 600. +# 5. Append a zshrc loader that exports ASANA_TOKEN from credentials.json. +# 6. Verify the PAT against Asana /users/me. + +set -euo pipefail + +CONFIG_DIR="$HOME/.config/agent-watcher" +CRED_FILE="$CONFIG_DIR/credentials.json" +ZSHRC="$HOME/.zshrc" +LOADER_MARKER="# agent-watcher: load ASANA_TOKEN from credentials.json" + +step() { printf "\n\033[1;34m=> %s\033[0m\n" "$1"; } +ok() { printf " \033[32mOK\033[0m %s\n" "$1"; } +warn() { printf " \033[33m!!\033[0m %s\n" "$1"; } +fail() { printf " \033[31mERR\033[0m %s\n" "$1"; exit 1; } + +# 1. tmux +step "tmux" +if command -v tmux >/dev/null 2>&1; then + ok "already installed ($(tmux -V))" +else + command -v brew >/dev/null 2>&1 || fail "Homebrew not found; install brew first" + brew install tmux + ok "installed" +fi + +# 2. jq +step "jq" +command -v jq >/dev/null 2>&1 || fail "jq not found; run: brew install jq" +ok "available" + +# 3. config dir +step "config dir" +mkdir -p "$CONFIG_DIR" +chmod 700 "$CONFIG_DIR" +ok "$CONFIG_DIR (mode 700)" + +# 4. credentials.json +step "credentials" +SKIP_PAT=false +if [[ -f "$CRED_FILE" ]]; then + printf " credentials.json already exists. Overwrite? [y/N] " + read -r yn + [[ "$yn" =~ ^[Yy]$ ]] || { warn "keeping existing credentials.json"; SKIP_PAT=true; } +fi + +if [[ "$SKIP_PAT" == false ]]; then + printf " Paste your Asana PAT (input hidden, press Enter when done): " + stty -echo + IFS= read -r ASANA_PAT || true + stty echo + printf "\n" + [[ -n "${ASANA_PAT:-}" ]] || fail "empty PAT" + umask 077 + jq -n --arg token "$ASANA_PAT" '{asana_token: $token}' > "$CRED_FILE" + chmod 600 "$CRED_FILE" + unset ASANA_PAT + ok "wrote $CRED_FILE (mode 600)" +fi + +# 5. zshrc loader +step "zshrc loader" +if grep -qF "$LOADER_MARKER" "$ZSHRC" 2>/dev/null; then + ok "already present in $ZSHRC" +else + cat >> "$ZSHRC" <<'EOF' + +# agent-watcher: load ASANA_TOKEN from credentials.json +if [ -f ~/.config/agent-watcher/credentials.json ]; then + export ASANA_TOKEN=$(jq -r .asana_token ~/.config/agent-watcher/credentials.json 2>/dev/null) +fi +EOF + ok "appended to $ZSHRC" +fi + +# 6. verify against Asana API +step "verify PAT against Asana /users/me" +TOKEN=$(jq -r .asana_token "$CRED_FILE") +RESPONSE=$(curl -sS -H "Authorization: Bearer $TOKEN" https://app.asana.com/api/1.0/users/me) || fail "curl failed" +NAME=$(echo "$RESPONSE" | jq -r '.data.name // empty') +EMAIL=$(echo "$RESPONSE" | jq -r '.data.email // empty') +if [[ -n "$NAME" ]]; then + ok "authenticated as: $NAME <$EMAIL>" +else + echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE" + fail "PAT did not authenticate" +fi +unset TOKEN + +printf "\n\033[1;32mSetup complete.\033[0m Open a new shell to pick up \$ASANA_TOKEN.\n" diff --git a/agent-watcher/spawn-test-session.sh b/agent-watcher/spawn-test-session.sh new file mode 100755 index 0000000..bbe03eb --- /dev/null +++ b/agent-watcher/spawn-test-session.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# spawn-test-session.sh — Start a tmux session running `claude --rc`, matching the +# spawn pattern the watchdog expects (pane survives claude exit via `exec bash`). +# +# TWO MODES: +# +# 1. SLOT MODE (parallel agent lane) — triggered by --slot-index: +# spawn-test-session.sh --yolo --slot-index <N> --task-gid <gid> \ +# --sim-udid <udid> --metro-port <port> --worktree-path <path> --label "<rc-label>" +# The wrapper bash exports $AGENT_SIM_UDID and $AGENT_METRO_PORT so build-and-test +# and debugger scripts inherit the slot's sim + Metro port transparently, and cwd +# is the slot's worktree (NOT ~/git). Session is named claude-asana-<task-gid>. +# The watcher (not this script) sends the /one-shot prompt once RC is ready. +# +# 2. LEGACY MODE (manual smoke tests) — when --slot-index is omitted: +# spawn-test-session.sh [--yolo] [session-id] [initial-prompt] +# cwd is ~/git, no per-slot env. Preserved so existing manual workflows still work. +# +# Exit codes: 0 = session spawned, 1 = error (session exists, missing tooling). + +set -euo pipefail + +YOLO=false +SLOT_INDEX="" +TASK_GID="" +SIM_UDID="" +METRO_PORT="" +WORKTREE_PATH="" +LABEL="" +POSITIONAL=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --yolo|-y) YOLO=true; shift ;; + --slot-index) SLOT_INDEX="$2"; shift 2 ;; + --task-gid) TASK_GID="$2"; shift 2 ;; + --sim-udid) SIM_UDID="$2"; shift 2 ;; + --metro-port) METRO_PORT="$2"; shift 2 ;; + --worktree-path) WORKTREE_PATH="$2"; shift 2 ;; + --label) LABEL="$2"; shift 2 ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done + +command -v tmux >/dev/null 2>&1 || { echo "tmux not found"; exit 1; } +command -v claude >/dev/null 2>&1 || { echo "claude CLI not found"; exit 1; } + +# ── Resolve mode ────────────────────────────────────────────────────────────── +if [[ -n "$SLOT_INDEX" ]]; then + # SLOT MODE + [[ -n "$TASK_GID" && -n "$WORKTREE_PATH" ]] || { + echo "slot mode requires --task-gid and --worktree-path" >&2; exit 1; } + ID="$TASK_GID" + CWD="$WORKTREE_PATH" + PROMPT="${LABEL:-Asana task $TASK_GID}" +else + # LEGACY MODE + ID="${POSITIONAL[0]:-test-mvp}" + PROMPT="${POSITIONAL[1]:-MVP test session. Reply "ack" so I can see this from mobile, then wait for further instructions.}" + CWD="$HOME/git" +fi + +SESSION="claude-asana-${ID}" + +if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Session '$SESSION' already exists. Attach with: tmux attach -t $SESSION" + echo "Or kill with: tmux kill-session -t $SESSION" + exit 1 +fi + +# Escape the prompt/label for embedding inside a double-quoted bash string. +ESC_PROMPT="${PROMPT//\\/\\\\}" # backslash +ESC_PROMPT="${ESC_PROMPT//\"/\\\"}" # double quote +ESC_PROMPT="${ESC_PROMPT//\$/\\\$}" # dollar (prevent var expansion in heredoc) +ESC_PROMPT="${ESC_PROMPT//\`/\\\`}" # backtick (prevent command substitution) + +if [[ "$YOLO" == true ]]; then + CLAUDE_INVOKE="claude --dangerously-skip-permissions --rc \"$ESC_PROMPT\"" +else + CLAUDE_INVOKE="claude --rc \"$ESC_PROMPT\"" +fi + +# Build the per-slot env exports (empty in legacy mode). +ENV_EXPORTS="" +if [[ -n "$SLOT_INDEX" ]]; then + [[ -n "$SIM_UDID" ]] && ENV_EXPORTS+="export AGENT_SIM_UDID=\"$SIM_UDID\" +" + [[ -n "$METRO_PORT" ]] && ENV_EXPORTS+="export AGENT_METRO_PORT=\"$METRO_PORT\" +" +fi + +# Write the inner command to a temp script; tmux execs it directly. The script +# lives in /tmp until macOS cleans it up — `exec bash` never returns, so we cannot +# reliably delete it ourselves without risking a race. +TMPSCRIPT=$(mktemp -t claude-spawn.XXXXXX) +cat > "$TMPSCRIPT" <<EOF +#!/usr/bin/env bash +${ENV_EXPORTS}cd "$CWD" +$CLAUDE_INVOKE +echo "[claude exited at \$(date)]" +exec bash +EOF +chmod +x "$TMPSCRIPT" + +tmux new-session -d -s "$SESSION" "bash $TMPSCRIPT" + +echo "Spawned tmux session: $SESSION${YOLO:+ (yolo)}" +if [[ -n "$SLOT_INDEX" ]]; then + echo " slot $SLOT_INDEX | cwd $CWD | sim ${SIM_UDID:-none} | metro ${METRO_PORT:-8081}" +fi +echo " Attach locally: tmux attach -t $SESSION" +echo " Kill session: tmux kill-session -t $SESSION" +echo " (inner cmd: $TMPSCRIPT)" diff --git a/agent-watcher/update-status.sh b/agent-watcher/update-status.sh new file mode 100755 index 0000000..7a5f53e --- /dev/null +++ b/agent-watcher/update-status.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# update-status.sh — Update agent_status (and optionally blocked) on an Asana task. +# As a side effect, also moves the task to the kanban section that matches the new +# status, so a Board view of the project reflects the agent_status in real time. +# +# Usage: +# update-status.sh <task_gid> <status_name> [--blocked yes|no] +# +# Status names: Pending | Planning | Developing | Reviewing | Testing | Complete +# +# Reads custom field GIDs and section GIDs from ~/.config/agent-watcher/asana-config.json +# and ASANA_TOKEN from credentials.json. +# +# Exit codes: +# 0 = success (custom-field update applied; section move best-effort) +# 1 = Asana API error on the custom-field update +# 2 = usage / missing config error +# +# Escape hatch: the section move is best-effort. If the kanban hasn't been set +# up yet (no section_gids in config) or the move call fails, we warn to stderr +# and still exit 0 — the canonical state is the custom field, not the section. + +set -euo pipefail + +CONFIG="$HOME/.config/agent-watcher/asana-config.json" +CRED="$HOME/.config/agent-watcher/credentials.json" + +[[ -f "$CONFIG" ]] || { echo "Missing $CONFIG" >&2; exit 2; } +[[ -f "$CRED" ]] || { echo "Missing $CRED" >&2; exit 2; } + +usage() { + cat <<EOF >&2 +Usage: $(basename "$0") <task_gid> <status_name> [--blocked yes|no] + status_name: Pending | Planning | Developing | Reviewing | Testing | Complete +EOF + exit 2 +} + +TASK_GID="${1:-}" +STATUS_NAME="${2:-}" +[[ -n "$TASK_GID" && -n "$STATUS_NAME" ]] || usage + +BLOCKED="" +if [[ "${3:-}" == "--blocked" ]]; then + BLOCKED="${4:-}" + [[ "$BLOCKED" == "yes" || "$BLOCKED" == "no" ]] || usage +fi + +TOKEN=$(jq -r .asana_token "$CRED") +STATUS_FIELD_GID=$(jq -r .custom_fields.agent_status.gid "$CONFIG") +STATUS_OPT_GID=$(jq -r --arg s "$STATUS_NAME" '.custom_fields.agent_status.options[$s] // empty' "$CONFIG") + +if [[ -z "$STATUS_OPT_GID" ]]; then + echo "Unknown status: $STATUS_NAME" >&2 + echo "Valid: $(jq -r '.custom_fields.agent_status.options | keys | join(", ")' "$CONFIG")" >&2 + exit 2 +fi + +# Build the custom_fields payload as JSON +CF_JSON=$(jq -n \ + --arg sf "$STATUS_FIELD_GID" \ + --arg so "$STATUS_OPT_GID" \ + '{($sf): $so}') + +if [[ -n "$BLOCKED" ]]; then + BLOCKED_FIELD_GID=$(jq -r .custom_fields.blocked.gid "$CONFIG") + if [[ "$BLOCKED" == "yes" ]]; then + BLOCKED_OPT_GID=$(jq -r .custom_fields.blocked.options.Yes "$CONFIG") + else + BLOCKED_OPT_GID=$(jq -r .custom_fields.blocked.options.No "$CONFIG") + fi + CF_JSON=$(jq -n \ + --argjson base "$CF_JSON" \ + --arg bf "$BLOCKED_FIELD_GID" \ + --arg bo "$BLOCKED_OPT_GID" \ + '$base + {($bf): $bo}') +fi + +PAYLOAD=$(jq -n --argjson cf "$CF_JSON" '{data: {custom_fields: $cf}}') + +RESPONSE=$(curl -sS \ + -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "https://app.asana.com/api/1.0/tasks/$TASK_GID") + +if echo "$RESPONSE" | jq -e .errors >/dev/null 2>&1; then + echo "Asana API error:" >&2 + echo "$RESPONSE" | jq . >&2 + exit 1 +fi + +echo "Updated task $TASK_GID: agent_status=$STATUS_NAME${BLOCKED:+, blocked=$BLOCKED}" + +# Best-effort section move so a Board view of the kanban reflects the status. +SECTION_GID=$(jq -r --arg s "$STATUS_NAME" '.custom_fields.agent_status.section_gids[$s] // empty' "$CONFIG") +if [[ -z "$SECTION_GID" ]]; then + echo ">> section move: skipped (no section_gids in config — run setup-kanban-sections.sh once)" >&2 +else + MOVE_RESP=$(curl -sS -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg t "$TASK_GID" '{data: {task: $t}}')" \ + "https://app.asana.com/api/1.0/sections/$SECTION_GID/addTask") + if echo "$MOVE_RESP" | jq -e .errors >/dev/null 2>&1; then + echo ">> section move: WARN — Asana returned errors: $(echo "$MOVE_RESP" | jq -c .errors)" >&2 + else + echo ">> section move: $STATUS_NAME" + fi +fi diff --git a/bin/link-shared-memory.sh b/bin/link-shared-memory.sh new file mode 100755 index 0000000..fe2d876 --- /dev/null +++ b/bin/link-shared-memory.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# link-shared-memory.sh +# Symlink the canonical shared memory notes (~/.claude/memory-shared/*.md) into +# the Claude auto-memory directory for a given working directory, and maintain a +# delimited "shared" block in that dir's MEMORY.md. Repo-specific notes (real +# files you add to the memory dir) are left untouched. +# +# Why: Claude auto-memory is keyed per project (git repo root, or cwd outside a +# repo) at ~/.claude/projects/<sanitized-path>/memory/ with no global tier. This +# script gives cross-cutting notes (orchestration, user-role) a single source of +# truth, surfaced wherever you choose to link them. +# +# Usage: +# link-shared-memory.sh [path] # path defaults to $PWD +# Idempotent: safe to re-run; refreshes symlinks and the shared block. + +set -euo pipefail + +SHARED_DIR="$HOME/.claude/memory-shared" +TARGET_CWD="${1:-$PWD}" + +[[ -d "$SHARED_DIR" ]] || { echo "Error: shared store $SHARED_DIR not found" >&2; exit 1; } + +# Resolve the target memory dir. Claude keys auto-memory by the MAIN git repo +# root: a session in a worktree reads memory from the main repo's dir, NOT the +# worktree path. Verified empirically (a worktree `claude -p` session loaded the +# git-root memory word but not a worktree-path one). So resolve via the common +# git dir (worktrees -> main repo root), else the cwd outside a repo. +cwd_abs="$(cd "$TARGET_CWD" && pwd)" +common="$(git -C "$TARGET_CWD" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" +if [[ -n "$common" ]]; then gitroot="$(cd "$(dirname "$common")" && pwd)"; else gitroot="$cwd_abs"; fi + +roots=("$gitroot") + +link_into() { + local root="$1" san memdir base f + # Sanitize the way Claude names project dirs: every "/" AND "." becomes "-" + # (e.g. /Users/jon/.agent-worktrees -> -Users-jon--agent-worktrees). + san="$(printf '%s' "$root" | sed 's#[/.]#-#g')" + memdir="$HOME/.claude/projects/$san/memory" + mkdir -p "$memdir" + # Symlink each shared note (skip a MEMORY.md in the store, if any). + for f in "$SHARED_DIR"/*.md; do + [[ -e "$f" ]] || continue + base="$(basename "$f")" + [[ "$base" == "MEMORY.md" ]] && continue + ln -sfn "$f" "$memdir/$base" + done + # Rebuild the delimited shared block in MEMORY.md, preserving any other lines. + MEMDIR="$memdir" SHARED_DIR="$SHARED_DIR" node -e ' + const fs = require("fs"); const path = require("path"); + const memDir = process.env.MEMDIR, sharedDir = process.env.SHARED_DIR; + const START = "<!-- shared-memory:start -->", END = "<!-- shared-memory:end -->"; + const files = fs.readdirSync(sharedDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md").sort(); + const lines = files.map(f => { + const txt = fs.readFileSync(path.join(sharedDir, f), "utf8"); + const fm = /^---\n([\s\S]*?)\n---/.exec(txt); + let name = f.replace(/\.md$/, "").replace(/_/g, " "), desc = ""; + if (fm) { + const n = /\bname:\s*"?([^"\n]+)"?/.exec(fm[1]); if (n) name = n[1].trim(); + const d = /\bdescription:\s*"?([^"\n]+)"?/.exec(fm[1]); if (d) desc = d[1].trim(); + } + return `- [${name}](${f})${desc ? " — " + desc : ""}`; + }); + const block = [START, "<!-- Symlinks to ~/.claude/memory-shared/ — managed by link-shared-memory.sh. Edit the source there. -->", ...lines, END].join("\n"); + const memFile = path.join(memDir, "MEMORY.md"); + let cur = fs.existsSync(memFile) ? fs.readFileSync(memFile, "utf8") : ""; + const re = new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\s\\S]*?" + END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + if (re.test(cur)) { + cur = cur.replace(re, block); + } else { + cur = block + (cur.trim() ? "\n\n" + cur.trim() + "\n" : "\n"); + } + fs.writeFileSync(memFile, cur.replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n")); + ' + echo ">> linked into $memdir" +} + +for r in "${roots[@]}"; do link_into "$r"; done +echo ">> shared notes: $(ls "$SHARED_DIR"/*.md 2>/dev/null | grep -v MEMORY.md | wc -l | tr -d ' ') | roots: ${roots[*]}" diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..d7ff35b --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# bootstrap.sh — Reproduce this agent setup on a fresh Mac from the cloned repo. +# +# Installs (repo -> home), idempotent, never clobbers secrets/state: +# .cursor/ -> ~/.cursor/ (skills, rules, scripts, README) +# agent-watcher/ -> ~/.config/agent-watcher/ (orchestration code + config) +# memory-shared/ -> ~/.claude/memory-shared/ (shared memory notes) +# bin/link-shared-memory.sh -> ~/.claude/link-shared-memory.sh +# Then: links ~/.claude/skills -> ~/.cursor/skills, regenerates ~/.claude/CLAUDE.md, +# and links shared memory into the standard entry points (~ and ~/git). +# +# Secrets are NOT in the repo. agent-watcher/credentials.json is seeded from +# credentials.example.json (fill it in afterward). Machine-local state (pools, +# logs, worktrees, watchdog state) is never copied. +# +# Usage: ./bootstrap.sh (run from the repo root after cloning) + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +say() { printf '\033[1;32m>>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!!\033[0m %s\n' "$*"; } + +have_rsync() { command -v rsync >/dev/null 2>&1; } +copy_tree() { # src dest (update files, preserve anything extra in dest) + local src="$1" dest="$2" + mkdir -p "$dest" + if have_rsync; then rsync -rlpt --exclude='.DS_Store' --exclude='.git' "$src/" "$dest/" + else cp -R "$src/." "$dest/"; fi +} + +# 1. Cursor skills/rules/scripts (still used; mirrors into ~/.cursor) +if [[ -d "$REPO/.cursor" ]]; then + say "Installing ~/.cursor from repo/.cursor" + copy_tree "$REPO/.cursor" "$HOME/.cursor" + [[ -f "$REPO/README.md" ]] && cp "$REPO/README.md" "$HOME/.cursor/README.md" +fi + +# 2. Orchestration code/config (preserve existing credentials.json + state) +if [[ -d "$REPO/agent-watcher" ]]; then + say "Installing ~/.config/agent-watcher from repo/agent-watcher (code/config only)" + copy_tree "$REPO/agent-watcher" "$HOME/.config/agent-watcher" + CRED="$HOME/.config/agent-watcher/credentials.json" + if [[ ! -f "$CRED" && -f "$HOME/.config/agent-watcher/credentials.example.json" ]]; then + cp "$HOME/.config/agent-watcher/credentials.example.json" "$CRED" + chmod 600 "$CRED" + warn "Seeded $CRED from example — EDIT IT and add your real asana_token." + fi +fi + +# 3. Shared memory store +if [[ -d "$REPO/memory-shared" ]]; then + say "Installing ~/.claude/memory-shared from repo/memory-shared" + copy_tree "$REPO/memory-shared" "$HOME/.claude/memory-shared" +fi + +# 4. Shared-memory link helper +if [[ -f "$REPO/bin/link-shared-memory.sh" ]]; then + say "Installing ~/.claude/link-shared-memory.sh" + cp "$REPO/bin/link-shared-memory.sh" "$HOME/.claude/link-shared-memory.sh" + chmod +x "$HOME/.claude/link-shared-memory.sh" +fi + +# 5. Claude compat: ~/.claude/skills -> ~/.cursor/skills + regenerate CLAUDE.md +if [[ -d "$HOME/.cursor/skills" ]]; then + if [[ -L "$HOME/.claude/skills" || ! -e "$HOME/.claude/skills" ]]; then + mkdir -p "$HOME/.claude" + ln -sfn "$HOME/.cursor/skills" "$HOME/.claude/skills" + say "Linked ~/.claude/skills -> ~/.cursor/skills" + else + warn "~/.claude/skills exists and is not a symlink — left as-is." + fi +fi +GEN="$HOME/.cursor/skills/convention-sync/scripts/generate-claude-md.sh" +[[ -x "$GEN" ]] && { say "Regenerating ~/.claude/CLAUDE.md"; "$GEN" >/dev/null || warn "generate-claude-md.sh failed (non-fatal)"; } + +# 6. Link shared memory into the standard entry points +if [[ -x "$HOME/.claude/link-shared-memory.sh" ]]; then + for d in "$HOME" "$HOME/git"; do + [[ -d "$d" ]] && "$HOME/.claude/link-shared-memory.sh" "$d" || true + done + say "Linked shared memory into ~ and ~/git (run link-shared-memory.sh <repo> for others)" +fi + +say "Bootstrap complete." +echo +echo "Next steps:" +echo " 1. Fill ~/.config/agent-watcher/credentials.json with your real asana_token (and asana_github_secret if used)." +echo " 2. Install Node deps used by the orchestration if needed (jq, node)." +echo " 3. Per repo where you want shared memory: ~/.claude/link-shared-memory.sh /path/to/repo" diff --git a/memory-shared/agent_orchestration.md b/memory-shared/agent_orchestration.md new file mode 100644 index 0000000..dc83983 --- /dev/null +++ b/memory-shared/agent_orchestration.md @@ -0,0 +1,15 @@ +--- +name: agent-orchestration +description: "Jon's in-progress autonomous agent orchestration project (agent-watcher + one-shot run reports)" +metadata: + node_type: memory + type: project + originSessionId: 0c8256a3-90a6-4a73-9002-9118441c36fd +--- + +Jon is building an autonomous agent orchestration system (ongoing, evolving — keep this high-level, expect details to change). + +- **Driver:** `~/.config/agent-watcher/` watches Asana tasks and spawns Claude/Codex agent sessions into per-task git worktrees, with pooled iOS sims, resource/OOM watchdog (`rc-watchdog.js`), and slot management. Asana custom fields (`agent_status`, `blocked`) are the run-state channel — not comments. +- **Per-task agent flow:** the `/one-shot` skill runs Planning → Developing → Reviewing → Testing → Complete, delegating to `/asana-plan`, `/im`, `/pr-create`, `/build-and-test`. +- **Completion documentation:** at the terminal state (complete or blocked), the agent fills `~/.cursor/skills/one-shot/templates/agent-run-report.md` and attaches it to the Asana task via `asana-task-update.sh --attach-file` (one attachment, at most one pointer comment). Report sections feed back into the system: Skill Gaps → `/author`, Orchestration Issues → harness fixes, Follow-ups & Risks → actionable proposals. +- **Convention sync:** skills/rules/scripts are authored under `~/.cursor/` and synced to the `edge-dev-agents` repo via `/convention-sync`; `~/.claude/CLAUDE.md` is generated from always-apply cursor rules (don't hand-edit it). diff --git a/memory-shared/socket_sfw_enforcement.md b/memory-shared/socket_sfw_enforcement.md new file mode 100644 index 0000000..c6bdf66 --- /dev/null +++ b/memory-shared/socket_sfw_enforcement.md @@ -0,0 +1,18 @@ +--- +name: socket-sfw-enforcement +description: Jon's local npm supply-chain guard — sfw (Socket Firewall) via PATH shims + agent hooks, and the fork-bomb gotcha +metadata: + type: project +--- + +Jon's machines wrap every `npm`/`npx`/`pnpm`/`yarn` call through **sfw (Socket Firewall Free)**, not the `socket` CLI (migrated 2026-06-02). Three pieces, all rooted at `$HOME` so they are user-agnostic: + +- **PATH shims** at `~/.agent-shims/{npm,npx,pnpm,yarn}` — each strips `~/.agent-shims` from PATH, then `exec sfw <tool>`. The shim dir is prepended to PATH in `.zshenv`/`.zprofile` and (last, after nvm init) in `.zshrc`. +- **Agent hooks**: `~/.claude/settings.json` PreToolUse(Bash) and `~/.cursor/hooks.json` beforeShellExecution both run `~/.agent-tools/socket-guard.mjs`, which denies bare npm/npx/pnpm/yarn and tells the agent to use `sfw npm` instead. +- **`~/.npmrc` hardenings**: `ignore-scripts=true`, `fund=false`. `min-release-age` is intentionally DISABLED (commented out) due to a bug — do not re-enable without asking. + +**THE GOTCHA (non-obvious; took a careful empirical test to find):** `sfw` resolves its wrapped package-manager command via PATH. So a naive shim `exec sfw npm` fork-bombs: npm→shim→sfw→npm→shim→sfw… Each shim MUST strip its own dir from PATH first so sfw's inner call lands on the real binary. The old `socket` CLI did NOT need this (it resolved the real npm internally). Verified 2026-06-02. + +Layer 3 (network sandbox / sfw enterprise proxy) is NOT installed — no enterprise license. The setup is the local file-based one above only. There is a full handoff doc for replicating this on another machine at `~/sfw-handoff.md`. + +See also [[working-style]] (verify empirically, not just wired up). diff --git a/memory-shared/user_role.md b/memory-shared/user_role.md new file mode 100644 index 0000000..a2909a4 --- /dev/null +++ b/memory-shared/user_role.md @@ -0,0 +1,12 @@ +--- +name: user-role +description: "Jon's company size and how that affects framing of advice" +metadata: + node_type: memory + type: user + originSessionId: 6626198d-f6ea-477e-a283-daa7b27ac022 +--- + +Tiny company. Jon IS the security team — there is no separate group to defer to. Don't frame recommendations as "ask your security team" or "your security team will want X data." He decides directly. + +Practical effect: skip the "convince stakeholders" framing on security/infra changes. Lead with the change + the data that justifies it, then he makes the call. Same with vendor/policy negotiations — he has admin on everything. diff --git a/memory-shared/working_style.md b/memory-shared/working_style.md new file mode 100644 index 0000000..9e92f4c --- /dev/null +++ b/memory-shared/working_style.md @@ -0,0 +1,46 @@ +--- +name: working-style +description: "'Until done' = take it all the way to a SUCCESSFUL TEST (verify empirically, not just 'wired up'); don't stop mid-task to ask — proceed with a sensible default and present deferred decisions as alternatives at the end" +metadata: + node_type: memory + type: preference + originSessionId: 0c8256a3-90a6-4a73-9002-9118441c36fd +--- + +When Jon says "continue until done" / "until done" (or similar persistence +directives), **"done" means carried all the way to a SUCCESSFUL TEST** — not +just implemented, wired up, or plumbed. Verify behavior empirically (actually +run it and observe the result); "looks correct" or "files are in place" is not +done. + +**Defer decisions to the end.** Do not halt mid-task to ask which option to +take. When a genuine decision or uncertainty arises, pick a sensible default, +proceed, and keep going to a tested result. Collect the open decision points and +present them at the END as alternatives to try — don't block on them mid-stream. + +Learned from the memory/orchestration task: the failure modes were (1) stopping +at plumbing instead of empirically testing, and (2) hedging mid-way (e.g. +supporting both options) instead of proceeding and deferring the choice. + +## Tooling & cost: interactive vs `-p`/headless + +**Guiding principle: be cost-conscious — squeeze the most out of the Max +subscription WITHOUT incurring extra (pay-as-you-go) cost.** That's the real goal; +the `-p` mechanics below are just how to honor it. + +- **Interactive Claude Code** — including the orchestration's tmux `claude --rc` + sessions — runs on the subscription with no extra metering. Prefer it for + anything long-lived or high-volume. Keep the agent fleet interactive; do NOT + convert it to `-p`. +- **`-p` / headless (Agent SDK)** on a Pro/Max sub draws from a **monthly INCLUDED + credit allotment** (resets monthly, no rollover — Max-20x $200, Max-5x $100, + Pro $20; confirm current plan). Up to that allotment it's effectively free; you + only pay extra (pay-as-you-go API rates) AFTER it's exhausted, and only if + overflow/usage-credits is enabled. This split takes effect ~2026-06-15. +- So `-p` is fine for occasional, low-volume one-off verification (well within the + monthly credit) where a clean programmatic assertion matters — and it's cleaner + than tmux scraping (stdout / `--output-format json` / exit code vs fragile + `capture-pane`). Avoid high-frequency `-p` loops that would burn the allotment + into paid overflow. +- Monitor remaining credit at `claude.ai/settings/usage` (the SDK's + `total_cost_usd` is a client-side estimate, not authoritative). diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..7b358ce --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# setup.sh — Bootstrap edge-dev-agents on a new machine. +# Usage: ./scripts/setup.sh +# +# Creates symlinks from ~/.cursor/ and ~/.claude/ into this repo's +# .cursor/ content, then generates ~/.claude/CLAUDE.md from alwaysApply rules. +# Idempotent — safe to re-run. + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CURSOR_SRC="$REPO_DIR/.cursor" + +if [[ ! -d "$CURSOR_SRC/skills" ]]; then + echo "ERROR: $CURSOR_SRC/skills not found. Is this the edge-dev-agents repo?" >&2 + exit 1 +fi + +# 1. Symlink ~/.cursor/{skills,rules,scripts} → repo equivalents +echo "Setting up ~/.cursor/ symlinks..." +mkdir -p "$HOME/.cursor" +for dir in skills rules scripts; do + target="$CURSOR_SRC/$dir" + link="$HOME/.cursor/$dir" + if [[ -L "$link" ]]; then + current="$(readlink "$link")" + if [[ "$current" == "$target" ]]; then + echo " $dir: already linked" + continue + fi + rm "$link" + elif [[ -d "$link" ]]; then + echo " WARNING: $link is a real directory, not a symlink. Skipping." + echo " Remove it manually if you want to link to the repo." + continue + fi + ln -s "$target" "$link" + echo " $dir: linked → $target" +done + +# 2. Symlink ~/.claude/skills → ~/.cursor/skills +echo "Setting up ~/.claude/skills symlink..." +mkdir -p "$HOME/.claude" +CLAUDE_SKILLS="$HOME/.claude/skills" +if [[ -L "$CLAUDE_SKILLS" ]]; then + current="$(readlink "$CLAUDE_SKILLS")" + if [[ "$current" != "$HOME/.cursor/skills" ]]; then + rm "$CLAUDE_SKILLS" + ln -s "$HOME/.cursor/skills" "$CLAUDE_SKILLS" + echo " skills: relinked → ~/.cursor/skills" + else + echo " skills: already linked" + fi +elif [[ ! -e "$CLAUDE_SKILLS" ]]; then + ln -s "$HOME/.cursor/skills" "$CLAUDE_SKILLS" + echo " skills: linked → ~/.cursor/skills" +fi + +# 3. Generate ~/.claude/CLAUDE.md from alwaysApply rules +GEN_SCRIPT="$CURSOR_SRC/skills/convention-sync/scripts/generate-claude-md.sh" +if [[ -x "$GEN_SCRIPT" ]]; then + echo "Generating ~/.claude/CLAUDE.md..." + "$GEN_SCRIPT" >/dev/null + echo " CLAUDE.md generated" +else + echo "WARNING: generate-claude-md.sh not found or not executable" +fi + +# 4. Make all .sh files executable +find "$REPO_DIR" -name "*.sh" -exec chmod +x {} + + +# 5. Check prerequisites +echo "" +echo "Checking prerequisites..." +for cmd in gh jq node; do + if command -v "$cmd" >/dev/null 2>&1; then + echo " $cmd: $(command -v "$cmd")" + else + echo " WARNING: $cmd not found" + fi +done + +echo "" +echo "Setup complete."