diff --git a/AGENTS.md b/AGENTS.md index 7ad7246a5..a9bbc5cc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,40 +5,56 @@ Instructions for coding agents (Grok, Claude, Codex, Devin, ChatGPT, local runne ## Read first (in order) 1. **This file** (`AGENTS.md`) -2. [`docs/proposals/registry.yaml`](docs/proposals/registry.yaml) — what is active -3. [`docs/proposals/PROCESS.md`](docs/proposals/PROCESS.md) — post / debate / consensus / close -4. [`docs/PR-SUMMARY-PROCESS.md`](docs/PR-SUMMARY-PROCESS.md) — who may rewrite PR bodies (multi-agent) -5. [`docs/ARCHW1Z-GATE.md`](docs/ARCHW1Z-GATE.md) — repo-gate + termux-smoke -6. [`docs/ARCHW1Z-STATUS.md`](docs/ARCHW1Z-STATUS.md) — living board -7. [`docs/proposals/AGENTIC-PERMISSIONS.md`](docs/proposals/AGENTIC-PERMISSIONS.md) — human-only edges +2. [`docs/LINEAR-AGENT-PROTOCOL.md`](docs/LINEAR-AGENT-PROTOCOL.md) — **Linear hooks for every agent action** +3. [`docs/proposals/registry.yaml`](docs/proposals/registry.yaml) — what is active +4. [`docs/proposals/PROCESS.md`](docs/proposals/PROCESS.md) — post / debate / consensus / close +5. [`docs/PR-SUMMARY-PROCESS.md`](docs/PR-SUMMARY-PROCESS.md) — who may rewrite PR bodies (multi-agent) +6. [`docs/ARCHW1Z-GATE.md`](docs/ARCHW1Z-GATE.md) — repo-gate + termux-smoke +7. [`docs/ARCHW1Z-STATUS.md`](docs/ARCHW1Z-STATUS.md) — living board +8. [`docs/proposals/AGENTIC-PERMISSIONS.md`](docs/proposals/AGENTIC-PERMISSIONS.md) — human-only edges + **branch model** +9. [`docs/SENTRY_LINEAR.md`](docs/SENTRY_LINEAR.md) — Sentry multi-project + Linear bridge Optional: `CLAUDE.md`, `CONTRIBUTING.md`. ## Hard rules - Target **`master-staging`**, not raw `master`, for integration work. -- Both gates must pass before merge: +- **`master-staging` is a permanent integration spine** — never merge it wholesale into `master`. Promotion to `master` is **selective** (cherry-pick / focused promotion PRs only). Operator: *"master-staging is for selective merge to master meaning master-staging is meant to never merge to master completely."* +- Both gates must pass before merge to staging: - `python3 scripts/ci/repo_gate.py` - `python3 scripts/ci/termux_smoke.py` -- Do not invent work outside `docs/proposals/active//ITEMS.md` — add a row first. -- Cite `Implements: ` on PRs/commits. +- Do not invent work outside `docs/proposals/active//ITEMS.md` — add a row first **and** a Linear `TER-*` issue. +- Cite **`Implements: TER-N`** (and proposal item IDs) on PRs/commits. +- **Linear is mandatory for agent actions** — see protocol: + - Start work → Linear **In Progress** + - Open PR (base **`master-staging`**) → comment on TER-* with PR URL + - Merge to **`master-staging`** → Linear **Done** + evidence + - MCP: `linear___save_issue` / `linear___list_issues` + CLI: `python3 -m archwiz.linear_client start|done|status|comment TER-N` - **No** wholesale merge of PR #6 (TER-9) or PR #2 (Rust CI) — see disposition comments. - **No** Class 3/4 artifacts in git (session stores, browser profiles, tokens). -- Unposted chat is not consensus — write Review log or DEBATE.md. +- Unposted chat is not consensus — write Review log or DEBATE.md (and Linear comment if execution-related). - PR body rewrites: follow `docs/PR-SUMMARY-PROCESS.md` roster (not a single-agent monopoly). ## Debate & close - Debate: MANIFEST Review log, optional DEBATE.md, linked PR/issue. - Close: all items terminal + Review log outcome + move `active/` → `closed/` + registry update. -- Full rules: `docs/proposals/PROCESS.md` §§ consensus / closing. +- Close related **Linear TER-*** explicitly (Done / Canceled) — proposal close does not auto-close Linear. +- Full rules: `docs/proposals/PROCESS.md` §§ consensus / closing · `docs/LINEAR-AGENT-PROTOCOL.md`. ## Preferred execution loop ```text -registry.yaml → pick todo item → branch from master-staging - → implement → PR with Implements: ID → gates green → merge +registry.yaml + Linear list_issues → pick todo + → linear_client start TER-N (or MCP save_issue In Progress) + → branch from master-staging (prefer Linear gitBranchName) + → implement → PR (base master-staging) with Implements: TER-N[, ITEM-ID] + → comment on Linear issue with PR URL + → gates green → merge to master-staging + → linear_client done TER-N --pr → update ITEMS.md status + → (optional, separate) selective promotion of ready commits to master ``` ## Security diff --git a/archwiz/linear_client.py b/archwiz/linear_client.py new file mode 100644 index 000000000..cfbaaae9f --- /dev/null +++ b/archwiz/linear_client.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Linear client CLI for agent hooks (on-device / CI). + +Requires LINEAR_API_KEY. See docs/LINEAR-AGENT-PROTOCOL.md. + +Usage: + python3 -m archwiz.linear_client status TER-14 + python3 -m archwiz.linear_client start TER-14 + python3 -m archwiz.linear_client done TER-14 --pr 16 + python3 -m archwiz.linear_client comment TER-14 "PR opened: https://..." + python3 -m archwiz.linear_client create --title "..." [--priority 2] +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any, Dict, Optional + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from archwiz.sentry_init import init_sentry, capture_exception + init_sentry() +except Exception: + def capture_exception(exc): # type: ignore + pass + +LINEAR_API = "https://api.linear.app/graphql" +TEAM_NAME = os.environ.get("LINEAR_TEAM", "Termux-monorepo_linear") +PROJECT_NAME = os.environ.get("LINEAR_PROJECT", "termux-monorepo hardening") + + +def _api_key() -> str: + key = os.environ.get("LINEAR_API_KEY") or os.environ.get("LINEAR_API_TOKEN") + if not key: + print("LINEAR_API_KEY not set", file=sys.stderr) + sys.exit(2) + return key + + +def _http_post(body: dict) -> dict: + headers = { + "Authorization": _api_key(), + "Content-Type": "application/json", + } + try: + import requests + r = requests.post(LINEAR_API, headers=headers, json=body, timeout=30) + r.raise_for_status() + data = r.json() + except ImportError: + import urllib.request + req = urllib.request.Request( + LINEAR_API, + data=json.dumps(body).encode("utf-8"), + headers=headers, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + if "errors" in data: + raise RuntimeError(data["errors"]) + return data.get("data", {}) + + +def gql(query: str, variables: Optional[dict] = None) -> dict: + return _http_post({"query": query, "variables": variables or {}}) + + +def get_issue(identifier: str) -> Optional[dict]: + q = """ + query($id: String!) { + issue(id: $id) { + id identifier title url priority + state { id name type } + assignee { name } + project { name } + } + } + """ + try: + return gql(q, {"id": identifier}).get("issue") + except Exception: + # fallback by number + try: + num = int(identifier.split("-")[-1]) + except ValueError: + return None + q2 = """ + query($n: Float!) { + issues(filter: { number: { eq: $n } }, first: 1) { + nodes { + id identifier title url priority + state { id name type } + assignee { name } + project { name } + } + } + } + """ + nodes = gql(q2, {"n": float(num)}).get("issues", {}).get("nodes", []) + return nodes[0] if nodes else None + + +def team_states() -> Dict[str, str]: + q = """ + query { + teams { + nodes { + name + states { nodes { id name type } } + } + } + } + """ + data = gql(q) + out: Dict[str, str] = {} + for t in data.get("teams", {}).get("nodes", []): + for s in t.get("states", {}).get("nodes", []): + out[s["name"].lower()] = s["id"] + out[s["type"].lower()] = s["id"] + return out + + +def set_state(issue_id: str, state_id: str) -> bool: + q = """ + mutation($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success + issue { identifier state { name } } + } + } + """ + data = gql(q, {"id": issue_id, "stateId": state_id}) + return bool(data.get("issueUpdate", {}).get("success")) + + +def add_comment(issue_id: str, body: str) -> bool: + q = """ + mutation($id: String!, $body: String!) { + commentCreate(input: { issueId: $id, body: $body }) { + success + } + } + """ + data = gql(q, {"id": issue_id, "body": body}) + return bool(data.get("commentCreate", {}).get("success")) + + +def create_issue(title: str, description: str = "", priority: int = 0) -> Optional[dict]: + # resolve team id + tq = """ + query { + teams { nodes { id name } } + } + """ + teams = gql(tq).get("teams", {}).get("nodes", []) + team_id = None + for t in teams: + if t["name"] == TEAM_NAME or TEAM_NAME.lower() in t["name"].lower(): + team_id = t["id"] + break + if not team_id and teams: + team_id = teams[0]["id"] + if not team_id: + raise RuntimeError("No Linear team found") + + q = """ + mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier title url } + } + } + """ + inp: Dict[str, Any] = {"teamId": team_id, "title": title} + if description: + inp["description"] = description + if priority: + inp["priority"] = priority + data = gql(q, {"input": inp}) + return data.get("issueCreate", {}).get("issue") + + +def cmd_status(identifier: str) -> int: + issue = get_issue(identifier) + if not issue: + print(f"Not found: {identifier}", file=sys.stderr) + return 1 + state = (issue.get("state") or {}).get("name", "?") + print(f"{issue['identifier']} [{state}] {issue.get('title')}") + print(f" url: {issue.get('url')}") + if issue.get("assignee"): + print(f" assignee: {issue['assignee'].get('name')}") + return 0 + + +def cmd_start(identifier: str) -> int: + issue = get_issue(identifier) + if not issue: + print(f"Not found: {identifier}", file=sys.stderr) + return 1 + states = team_states() + sid = states.get("in progress") or states.get("started") + if not sid: + print("No In Progress state", file=sys.stderr) + return 1 + ok = set_state(issue["id"], sid) + print(f"start {identifier}: {ok}") + return 0 if ok else 1 + + +def cmd_done(identifier: str, pr: Optional[int] = None) -> int: + issue = get_issue(identifier) + if not issue: + print(f"Not found: {identifier}", file=sys.stderr) + return 1 + states = team_states() + sid = states.get("done") or states.get("completed") + if not sid: + print("No Done state", file=sys.stderr) + return 1 + ok = set_state(issue["id"], sid) + if ok and pr: + add_comment( + issue["id"], + f"Completed via PR #{pr} (agent hook). See docs/LINEAR-AGENT-PROTOCOL.md.", + ) + print(f"done {identifier}: {ok}") + return 0 if ok else 1 + + +def cmd_comment(identifier: str, body: str) -> int: + issue = get_issue(identifier) + if not issue: + print(f"Not found: {identifier}", file=sys.stderr) + return 1 + ok = add_comment(issue["id"], body) + print(f"comment {identifier}: {ok}") + return 0 if ok else 1 + + +def cmd_create(title: str, description: str, priority: int) -> int: + issue = create_issue(title, description, priority) + if not issue: + print("create failed", file=sys.stderr) + return 1 + print(f"created {issue['identifier']}: {issue.get('url')}") + return 0 + + +def main(argv: Optional[list] = None) -> int: + p = argparse.ArgumentParser(prog="linear_client") + sub = p.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("status") + s.add_argument("id") + + s = sub.add_parser("start") + s.add_argument("id") + + s = sub.add_parser("done") + s.add_argument("id") + s.add_argument("--pr", type=int, default=None) + + s = sub.add_parser("comment") + s.add_argument("id") + s.add_argument("body") + + s = sub.add_parser("create") + s.add_argument("--title", required=True) + s.add_argument("--description", default="") + s.add_argument("--priority", type=int, default=0) + + args = p.parse_args(argv) + try: + if args.cmd == "status": + return cmd_status(args.id) + if args.cmd == "start": + return cmd_start(args.id) + if args.cmd == "done": + return cmd_done(args.id, args.pr) + if args.cmd == "comment": + return cmd_comment(args.id, args.body) + if args.cmd == "create": + return cmd_create(args.title, args.description, args.priority) + except Exception as exc: + capture_exception(exc) + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + # support both `python -m archwiz.linear_client` and direct script + raise SystemExit(main()) diff --git a/archwiz/linear_sync.py b/archwiz/linear_sync.py new file mode 100644 index 000000000..22f85e6a1 --- /dev/null +++ b/archwiz/linear_sync.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +Linear Sync Bridge for ArchWiz. + +Syncs local task status (taDone.md / master_tasks.json) to Linear.app +using the Linear GraphQL API when LINEAR_API_KEY is set. + +Falls back to dry-run / report mode when the key is absent so the bridge +remains usable for agents and CI without secrets. + +Requires: requests (stdlib urllib used as fallback) +Optional: Sentry via archwiz.sentry_init +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Add root to path for config import +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from archwiz.config import ARCHWIZ_DIR, WORKSPACE_DIR, LOG_DIR + +try: + from archwiz.sentry_init import init_sentry, capture_exception, capture_message + init_sentry() +except Exception: + def capture_exception(exc): # type: ignore + pass + def capture_message(msg, level="info"): # type: ignore + pass + +LINEAR_API = "https://api.linear.app/graphql" +TEAM_KEY = os.environ.get("LINEAR_TEAM", "Termux-monorepo_linear") + + +def _http_post(url: str, headers: Dict[str, str], body: dict) -> dict: + """Minimal HTTP POST with requests or urllib.""" + try: + import requests + r = requests.post(url, headers=headers, json=body, timeout=30) + r.raise_for_status() + return r.json() + except ImportError: + import urllib.request + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def get_tasks() -> List[Dict[str, Any]]: + master_tasks = ARCHWIZ_DIR / "master_tasks.json" + if not master_tasks.exists(): + return [] + try: + with open(master_tasks, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as exc: + print(f"Failed to read {master_tasks}: {exc}", file=sys.stderr) + capture_exception(exc) + return [] + if isinstance(data, dict): + data = data.get("tasks", []) + if not isinstance(data, list): + print(f"Unexpected task format in {master_tasks}", file=sys.stderr) + return [] + return [t for t in data if isinstance(t, dict)] + + +def get_done_tasks() -> List[str]: + tadone = WORKSPACE_DIR / "termux-multi-agent" / "taDone.md" + if tadone.exists(): + return tadone.read_text(encoding="utf-8").splitlines() + # also check archwiz/taDone.md symlink target + alt = ARCHWIZ_DIR / "taDone.md" + if alt.exists(): + return alt.read_text(encoding="utf-8").splitlines() + return [] + + +def linear_query(api_key: str, query: str, variables: Optional[dict] = None) -> dict: + headers = { + "Authorization": api_key, + "Content-Type": "application/json", + } + body = {"query": query, "variables": variables or {}} + result = _http_post(LINEAR_API, headers, body) + if "errors" in result: + raise RuntimeError(f"Linear GraphQL errors: {result['errors']}") + return result.get("data", {}) + + +def find_issue_by_identifier(api_key: str, identifier: str) -> Optional[dict]: + """Look up Linear issue by identifier e.g. TER-5.""" + q = """ + query($id: String!) { + issue(id: $id) { + id + identifier + title + state { id name type } + } + } + """ + try: + data = linear_query(api_key, q, {"id": identifier}) + return data.get("issue") + except Exception: + # fallback: search by number + try: + num = int(identifier.split("-")[-1]) + except ValueError: + return None + q2 = """ + query($filter: IssueFilter) { + issues(filter: $filter, first: 1) { + nodes { id identifier title state { id name type } } + } + } + """ + data = linear_query(api_key, q2, {"filter": {"number": {"eq": num}}}) + nodes = data.get("issues", {}).get("nodes", []) + return nodes[0] if nodes else None + + +def update_issue_state(api_key: str, issue_id: str, state_id: str) -> bool: + q = """ + mutation($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success + issue { id identifier state { name } } + } + } + """ + data = linear_query(api_key, q, {"id": issue_id, "stateId": state_id}) + return bool(data.get("issueUpdate", {}).get("success")) + + +def list_team_states(api_key: str, team_name: str) -> Dict[str, str]: + """Return map of state name (lower) -> state id.""" + q = """ + query($name: String!) { + teams(filter: { name: { eq: $name } }) { + nodes { + id + states { nodes { id name type } } + } + } + } + """ + data = linear_query(api_key, q, {"name": team_name}) + teams = data.get("teams", {}).get("nodes", []) + if not teams: + q2 = """ + query { + teams { + nodes { id name key states { nodes { id name type } } } + } + } + """ + data = linear_query(api_key, q2) + teams = data.get("teams", {}).get("nodes", []) + mapping: Dict[str, str] = {} + for t in teams: + for s in t.get("states", {}).get("nodes", []): + mapping[s["name"].lower()] = s["id"] + mapping[s["type"].lower()] = s["id"] + return mapping + + +def sync_to_linear(dry_run: bool = False) -> None: + print("--- Linear Sync Bridge ---") + tasks = get_tasks() + done_lines = get_done_tasks() + print(f"Found {len(tasks)} tasks in master_tasks.json") + print(f"Found {len(done_lines)} entries in taDone.md") + + api_key = os.environ.get("LINEAR_API_KEY") or os.environ.get("LINEAR_API_TOKEN") + if not api_key: + print("LINEAR_API_KEY not set — running in report-only mode.") + dry_run = True + + states: Dict[str, str] = {} + if not dry_run and api_key: + try: + states = list_team_states(api_key, TEAM_KEY) + print(f"Loaded {len(states)} Linear states") + except Exception as exc: + print(f"Failed to load Linear states: {exc}", file=sys.stderr) + capture_exception(exc) + dry_run = True + + done_state_id = states.get("done") or states.get("completed") + todo_state_id = states.get("todo") or states.get("unstarted") or states.get("backlog") + + for task in tasks: + task_id = str(task.get("id") or task.get("identifier") or "") + title = task.get("title") or task.get("name") or task_id + is_done = any(task_id and task_id in line for line in done_lines) + status = "DONE" if is_done else "TODO" + print(f" [{task_id}] {title[:60]} -> {status}") + + if dry_run or not api_key or not task_id: + continue + + try: + issue = find_issue_by_identifier(api_key, task_id) + if not issue: + print(f" (no Linear issue for {task_id})") + continue + target_state = done_state_id if is_done else todo_state_id + if not target_state: + print(" (no matching state id)") + continue + current = (issue.get("state") or {}).get("name", "").lower() + if (is_done and current in ("done", "completed")) or ( + not is_done and current in ("todo", "backlog", "unstarted") + ): + print(" (already in correct state)") + continue + ok = update_issue_state(api_key, issue["id"], target_state) + print(f" updated: {ok}") + if ok: + capture_message(f"Linear sync: {task_id} -> {status}") + except Exception as exc: + print(f" error: {exc}", file=sys.stderr) + capture_exception(exc) + + print("Sync complete.") + + +if __name__ == "__main__": + dry = "--dry-run" in sys.argv or "-n" in sys.argv + sync_to_linear(dry_run=dry) diff --git a/archwiz/sentry_init.py b/archwiz/sentry_init.py new file mode 100644 index 000000000..daf5e1891 --- /dev/null +++ b/archwiz/sentry_init.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Sentry SDK bootstrap for termux-monorepo / ArchWiz. + +Import and call init_sentry() as early as possible in any long-running process +(dashboard, dispatch pipeline, deepcli, autonomous runner, aiohttp apps, etc.). + +Multiple Sentry projects are provisioned under the same org: + - python — default CLI / ArchWiz / deepcli + - aiohttp — aiohttp web services + +Override with SENTRY_DSN or SENTRY_PROJECT=python|aiohttp. +Browser (JS) and Rust use separate SDKs — see docs/SENTRY_LINEAR.md. +""" +from __future__ import annotations + +import os +import sys +from typing import Optional + +# Official project DSNs from Sentry GitHub integration +DSN_PYTHON = ( + "https://a922fa6cd019e401e779d420d28b155c@o4511844213522432.ingest.us.sentry.io/4511844223680512" +) +DSN_AIOHTTP = ( + "https://c7fb0bb5cf4210fae90119131c12b320@o4511844213522432.ingest.us.sentry.io/4511844256055296" +) + +PROJECT_DSNS = { + "python": DSN_PYTHON, + "aiohttp": DSN_AIOHTTP, +} + +DEFAULT_DSN = DSN_PYTHON +_initialized = False + + +def _resolve_dsn(dsn: Optional[str] = None, project: Optional[str] = None) -> Optional[str]: + if dsn: + return dsn + env_dsn = os.environ.get("SENTRY_DSN") + if env_dsn: + return env_dsn + proj = (project or os.environ.get("SENTRY_PROJECT") or "python").strip().lower() + return PROJECT_DSNS.get(proj, DEFAULT_DSN) + + +def init_sentry( + dsn: Optional[str] = None, + *, + project: Optional[str] = None, + traces_sample_rate: float = 1.0, + profile_session_sample_rate: float = 1.0, + profile_lifecycle: str = "trace", + enable_logs: bool = True, + send_default_pii: bool = True, +) -> bool: + """Initialize Sentry SDK. Idempotent. Returns True if active. + + project: "python" (default) or "aiohttp" — selects the matching DSN + when SENTRY_DSN is not set. + """ + global _initialized + if _initialized: + return True + + resolved = _resolve_dsn(dsn, project) + if not resolved: + return False + + try: + import sentry_sdk + from sentry_sdk.integrations.logging import LoggingIntegration + except ImportError: + print( + "[sentry] sentry-sdk not installed. Run: pip install 'sentry-sdk'", + file=sys.stderr, + ) + return False + + logging_integration = LoggingIntegration( + level=None, # capture all levels as breadcrumbs + event_level=None, # do not auto-send log records as events + ) + + # AIOHTTPIntegration is auto-enabled when aiohttp is importable + sentry_sdk.init( + dsn=resolved, + send_default_pii=send_default_pii, + enable_logs=enable_logs, + traces_sample_rate=traces_sample_rate, + profile_session_sample_rate=profile_session_sample_rate, + profile_lifecycle=profile_lifecycle, + integrations=[logging_integration], + environment=os.environ.get("ARCHWIZ_ENV", "local"), + release=os.environ.get("SENTRY_RELEASE"), + ) + _initialized = True + return True + + +def capture_exception(exc: BaseException) -> None: + if not _initialized: + init_sentry() + try: + import sentry_sdk + sentry_sdk.capture_exception(exc) + except Exception: + pass + + +def capture_message(msg: str, level: str = "info") -> None: + if not _initialized: + init_sentry() + try: + import sentry_sdk + sentry_sdk.capture_message(msg, level=level) + except Exception: + pass + + +def start_profiler() -> None: + if not _initialized: + init_sentry() + try: + import sentry_sdk + sentry_sdk.profiler.start_profiler() + except Exception: + pass + + +def stop_profiler() -> None: + try: + import sentry_sdk + sentry_sdk.profiler.stop_profiler() + except Exception: + pass + + +if __name__ == "__main__": + proj = sys.argv[1] if len(sys.argv) > 1 else "python" + ok = init_sentry(project=proj) + print(f"Sentry initialized ({proj}): {ok}") + if ok: + capture_message(f"termux-monorepo sentry_init self-test [{proj}]", level="info") + print("Test message sent. Check Sentry dashboard.") diff --git a/docs/LINEAR-AGENT-PROTOCOL.md b/docs/LINEAR-AGENT-PROTOCOL.md new file mode 100644 index 000000000..4c58744bb --- /dev/null +++ b/docs/LINEAR-AGENT-PROTOCOL.md @@ -0,0 +1,194 @@ +# Linear Agent Protocol + +**Status:** binding for all coding agents (Grok, Claude, Codex, Devin, ChatGPT, local runners). + +Linear is the **operational tracker** for work that ships. Proposal process (`docs/proposals/PROCESS.md`) remains the debate/consensus layer; Linear tracks execution state, ownership, and git branch linkage. + +Team: **Termux-monorepo_linear** (key prefix `TER-`) +Project: **termux-monorepo hardening** + +--- + +## 0. Branch model (binding) + +``` +feature/* ──PR──► master-staging ← permanent integration spine + │ + │ selective cherry-pick / focused promotion PRs only + │ NEVER merge master-staging → master wholesale + ▼ + master ← stable, green, protected +``` + +- **`master-staging` is a permanent gate**, not a temporary buffer. +- Agent “done” = merged to **`master-staging`** (not necessarily to `master`). +- Promotion to `master` is **selective** only. Operator note (TER-14): *"master-staging is for selective merge to master meaning master-staging is meant to never merge to master completely."* + +--- + +## 1. Hard rules (agents MUST) + +1. **Every non-trivial agent action** that creates a branch, opens a PR, or closes work **must** reference a Linear issue (`TER-N`). +2. Prefer **updating an existing TER-*** over creating a new one. Create only when no issue covers the work. +3. PR title or body **must** include `Implements: TER-N` (and proposal item IDs when applicable). +4. Branch names **should** match Linear’s suggested `gitBranchName` when starting from an issue (e.g. `timerloggedout/ter-14-…`). +5. On start of work → set Linear state to **In Progress**. +6. On PR open → comment on Linear issue with PR URL (or attach link). +7. On merge to **`master-staging`** (or explicit completion) → set Linear state to **Done** (or leave In Progress if residual). +8. Do **not** invent work outside Linear + `docs/proposals/active/*/ITEMS.md`. If needed, create Linear issue **and** ITEMS row. +9. Do **not** open a PR that merges all of `master-staging` into `master`. Promotion PRs must be selective (specific commits or a narrow feature already on staging). + +Unposted chat is not Linear state. If it is not on the issue, it did not happen for tracking purposes. + +--- + +## 2. State machine + +| Linear state | When agents set it | +|--------------|--------------------| +| **Backlog** | Parked / not started | +| **Todo** | Ready to pick | +| **In Progress** | Agent started branch / PR | +| **Done** | Merged to **`master-staging`** or explicitly completed | +| **Canceled** | Won’t do (with reason in description) | +| **Duplicate** | Point to canonical TER-N | + +Priority map: P0 → Urgent (1), P1 → High (2), P2 → Medium (3), P3 → Low (4). + +--- + +## 3. Action → Linear hooks + +| Agent action | Linear hook | +|--------------|-------------| +| Pick work | `list_issues` filter Todo/Backlog; claim via assignee if available | +| Start implementation | `save_issue` → state **In Progress**; ensure `gitBranchName` used | +| Open PR | Comment on issue with PR URL; body `Implements: TER-N`; base = **`master-staging`** | +| Push significant commits | Optional short comment (milestone only; avoid noise) | +| PR merged to **`master-staging`** | state **Done**; append evidence (PR/commit) to description | +| Selective promote to `master` | Separate narrow PR; do not close staging; optional Linear comment | +| Blocked (human-only) | Comment + leave **In Progress** or move **Backlog**; cite `AGENTIC-PERMISSIONS.md` | +| New work discovered | `save_issue` create; link parent if sub-task; add ITEMS row | +| Close proposal | Ensure related TER-* are Done/Canceled; comment cross-ref | + +### MCP tools (Grok / connected agents) + +``` +linear___list_issues # discover / filter +linear___get_issue # full detail +linear___save_issue # create or update (id=TER-N) +linear___list_comments # thread +linear___list_issue_statuses # state names for team +linear___save_document # optional long-form on issue/project +``` + +### On-device / CI (no MCP) + +```bash +export LINEAR_API_KEY="lin_api_..." +python3 archwiz/linear_sync.py --dry-run # report +python3 archwiz/linear_sync.py # push local taDone → Linear states +python3 -m archwiz.linear_client status TER-14 +python3 -m archwiz.linear_client start TER-14 +python3 -m archwiz.linear_client done TER-14 --pr 16 +``` + +See `archwiz/linear_client.py`. + +--- + +## 4. Create vs update template + +**Create (only if no existing TER-* fits):** + +```text +Title: +Team: Termux-monorepo_linear +Project: termux-monorepo hardening +Priority: 1–4 +Description: + ## Goal + ... + ## Context + ... + ## Acceptance + - [ ] ... + Implements proposal item: (if any) +``` + +**Update on start:** + +```text +id: TER-N +state: In Progress +``` + +**Update on complete (merged to master-staging):** + +```text +id: TER-N +state: Done +# description append: Evidence: PR #X merged to master-staging YYYY-MM-DD +``` + +--- + +## 5. Relationship to proposal process + +```text +registry.yaml / ITEMS.md ← consensus & itemization (PROCESS.md) + ↕ must cite each other +Linear TER-* ← execution board (this protocol) + ↕ Implements: TER-N +GitHub PR → master-staging ← code (integration spine) + ↕ selective only +GitHub PR → master ← promotion of ready slices +``` + +- Proposal **ITEMS** may map 1:1 or N:1 to TER-*. +- PR must cite **both** when both exist: `Implements: TER-14, M-02`. +- Closing a proposal does not auto-close Linear; agents close TER-* explicitly. + +--- + +## 6. TER-14 scope + +**TER-14** = Sentry multi-project + Linear GraphQL bridge + **this protocol** + branch-model clarification. + +Related: +- TER-5 (dispatch logging) — observability adjacent +- TER-2 (tools connected) — Done; MCP available +- Manus PR #13 — path norm + mock bridge (superseded for Linear by PR #16) + +When Sentry+Linear PR merges to **`master-staging`**, mark **TER-14 Done**. + +--- + +## 7. Checklist (paste into agent runbooks) + +```text +[ ] Read AGENTS.md + this protocol +[ ] list_issues — pick or create TER-N +[ ] save_issue → In Progress +[ ] Branch from master-staging (prefer Linear gitBranchName) +[ ] Implement; commits reference TER-N +[ ] PR → master-staging with Implements: TER-N +[ ] Comment on Linear issue with PR URL +[ ] Gates green; merge to master-staging +[ ] save_issue → Done + evidence +[ ] Update ITEMS.md if proposal-linked +[ ] Never open wholesale master-staging → master merge +``` + +--- + +## 8. Failure modes + +| Symptom | Action | +|---------|--------| +| No LINEAR_API_KEY on device | Use MCP tools only; or dry-run `linear_sync.py` | +| MCP write denied | Fall back to GitHub issue comment + request Operator grant | +| Orphan PR (no TER-*) | Open/link TER-* before merge; do not merge orphan P0 | +| Duplicate TER-* | Mark Duplicate; point to canonical | +| PR base is `master` for feature work | Retarget to `master-staging` | +| Wholesale staging→master PR | Reject; split into selective promotion | diff --git a/docs/SENTRY_LINEAR.md b/docs/SENTRY_LINEAR.md new file mode 100644 index 000000000..729593131 --- /dev/null +++ b/docs/SENTRY_LINEAR.md @@ -0,0 +1,217 @@ +# Sentry + Linear Integration + +Sentry is provisioned under org `o4511844213522432` with **four projects** for this monorepo: + +| Platform | Project ID | DSN key (prefix) | Primary use | +|----------|------------|------------------|-------------| +| Python (default) | `4511844223680512` | `a922fa6c…` | ArchWiz, deepcli, CLIs | +| Python aiohttp | `4511844256055296` | `c7fb0bb5…` | aiohttp web services | +| Browser JavaScript | `4511844264640512` | `2fbd3c77…` | web UIs (commingle-swarm/web, dashboards) | +| Rust | `4511844272111616` | `8b6f33db…` | harmonizer, synthegration-cli, maxc | + +--- + +## Python (ArchWiz / deepcli) + +### Install + +```bash +pip install "sentry-sdk" +``` + +### Init + +```python +from archwiz.sentry_init import init_sentry, capture_exception, capture_message + +init_sentry() # default python project +# init_sentry(project="aiohttp") # aiohttp project DSN +``` + +Or full snippet: + +```python +import sentry_sdk + +sentry_sdk.init( + dsn="https://a922fa6cd019e401e779d420d28b155c@o4511844213522432.ingest.us.sentry.io/4511844223680512", + send_default_pii=True, + enable_logs=True, + traces_sample_rate=1.0, + profile_session_sample_rate=1.0, + profile_lifecycle="trace", +) +``` + +### Verify + +```bash +python3 archwiz/sentry_init.py +python3 archwiz/sentry_init.py aiohttp +``` + +```python +1 / 0 # intentional error +from sentry_sdk import metrics +metrics.count("checkout.failed", 1) +sentry_sdk.logger.info("info log") +``` + +--- + +## Python + aiohttp + +AIOHTTP integration is **auto-enabled** when `aiohttp` is importable. Init **before** creating the app: + +```python +from aiohttp import web +import sentry_sdk + +sentry_sdk.init( + dsn="https://c7fb0bb5cf4210fae90119131c12b320@o4511844213522432.ingest.us.sentry.io/4511844256055296", + send_default_pii=True, + enable_logs=True, + traces_sample_rate=1.0, + profile_session_sample_rate=1.0, + profile_lifecycle="trace", +) + +async def hello(request): + 1 / 0 # test error + return web.Response(text="Hello, world") + +app = web.Application() +app.add_routes([web.get("/", hello)]) +web.run_app(app) +``` + +Or via helper: `init_sentry(project="aiohttp")`. + +Python 3.6 only: also `pip install aiocontextvars`. + +--- + +## Browser JavaScript + +### npm / yarn / pnpm + +```bash +npm install --save @sentry/browser +``` + +```javascript +import * as Sentry from "@sentry/browser"; + +Sentry.init({ + dsn: "https://2fbd3c77388239145b6dd872f1e054aa@o4511844213522432.ingest.us.sentry.io/4511844264640512", + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + ], + tracesSampleRate: 1.0, + tracePropagationTargets: ["localhost", /^https:\/\/yourserver\.io\/api/], + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, +}); + +// verify +Sentry.metrics.count("test_counter", 1); +// myUndefinedFunction(); +``` + +Starter module: `docs/sentry/browser-init.js` + +### Loader Script (no bundler) + +```html + + +``` + +Useful for static pages such as `termux-ecosystem-architecture.html`. + +--- + +## Rust + +### Cargo.toml + +```toml +[dependencies] +sentry = "0.49.0" +``` + +### Init + verify + +```rust +fn main() { + let _guard = sentry::init(( + "https://8b6f33db85568dc94e5db28dfe5eee72@o4511844213522432.ingest.us.sentry.io/4511844272111616", + sentry::ClientOptions { + release: sentry::release_name!(), + send_default_pii: true, + ..Default::default() + }, + )); + + // Sentry will capture this + panic!("Everything is on fire!"); +} +``` + +Apply in crates under `harmonizer-prod_cli/`, `synthegration-cli/`, `workspace/maxc/`, `appliedSxi/maxc/` as needed. Example: `docs/sentry/rust_main_example.rs`. + +--- + +## Linear Sync Bridge + +`archwiz/linear_sync.py` reads local `master_tasks.json` + `taDone.md` and updates matching Linear issues (e.g. TER-5). + +### Setup + +```bash +export LINEAR_API_KEY="lin_api_..." +export LINEAR_TEAM="Termux-monorepo_linear" # optional +python3 archwiz/linear_sync.py --dry-run +python3 archwiz/linear_sync.py +``` + +Dashboard menu **[8] Linear Sync** (Manus PR #13) invokes the same script. + +| Local | Linear state | +|-------|--------------| +| task id in taDone.md | Done / completed | +| otherwise | Todo / unstarted / Backlog | + +Agents can also use Linear MCP tools (`linear___save_issue`, etc.) without the Python bridge. + +--- + +## Env overrides + +| Variable | Effect | +|----------|--------| +| `SENTRY_DSN` | Force a specific DSN (wins over project) | +| `SENTRY_PROJECT` | `python` \| `aiohttp` | +| `SENTRY_RELEASE` | Release name tag | +| `ARCHWIZ_ENV` | Sentry environment tag | +| `LINEAR_API_KEY` | Enable live Linear updates | + +--- + +## PR / branch + +- Branch: `feature/sentry-linear-integration` +- PR: #16 → `master-staging` +- Linear: TER-14 diff --git a/docs/proposals/AGENTIC-PERMISSIONS.md b/docs/proposals/AGENTIC-PERMISSIONS.md index 27ef38f0c..9c2e706c2 100644 --- a/docs/proposals/AGENTIC-PERMISSIONS.md +++ b/docs/proposals/AGENTIC-PERMISSIONS.md @@ -1,8 +1,8 @@ # Why You Still Have To Do Anything — Agentic Permissions -Honest boundary list. Everything else is already agent-operable via the GitHub connector on this account. +Honest boundary list. Everything else is already agent-operable via the GitHub + Linear connectors on this account. -## What the agent CAN do today (proven this session) +## What the agent CAN do today (proven) | Action | Status | |--------|--------| @@ -12,8 +12,11 @@ Honest boundary list. Everything else is already agent-operable via the GitHub c | Open PRs | ✅ | | Comment on issues/PRs | ✅ | | Merge PRs (when mergeable + allowed) | ✅ (merged #11) | -| Retarget PR base branch | ✅ (retargeted #10 → master-staging) | -| Submit PR reviews (COMMENT / REQUEST_CHANGES / APPROVE) | ✅ tool present | +| Retarget PR base branch | ✅ | +| Submit PR reviews (COMMENT / REQUEST_CHANGES / APPROVE) | ✅ | +| **Linear: list / get / create / update issues** | ✅ MCP `linear___*` | +| **Linear: start / done / comment via CLI** | ✅ `archwiz/linear_client.py` | +| **Linear agent protocol (binding)** | ✅ `docs/LINEAR-AGENT-PROTOCOL.md` | ## What still needs YOU (human-only or policy) @@ -25,7 +28,42 @@ Honest boundary list. Everything else is already agent-operable via the GitHub c | **GitHub App permission gaps** | Some orgs restrict Administration, Secrets, Workflows, or Environments | Settings → Applications → installed app → **Repository permissions**: Contents R/W, PRs R/W, Checks R/W, Commit statuses R/W, Workflows R/W (if editing Actions), Administration R if managing protection rules | | **Device-side Termux state** | Agent runs in cloud connector, not on your phone | Optional: self-hosted runner on Termux **or** you run `termux_smoke.py` locally when hardware-specific | | **Provider API keys / browser logins** | Auth is interactive / ToS-bound | Store in Termux-local env only; agent uses capability registry (`authenticated: true/false`) never the raw secret | -| **Linear / external trackers** | Only if not connected | Connect Linear MCP (already partially available) and grant write | +| **LINEAR_API_KEY on device** | Needed only for on-device `linear_client` / `linear_sync` (MCP path does not need it) | Export in Termux env; never commit | + +## Linear is connected + +Agents **must** follow `docs/LINEAR-AGENT-PROTOCOL.md`: + +- Start work → issue **In Progress** +- Open PR → `Implements: TER-N` + comment PR URL on issue +- Merge to **`master-staging`** → **Done** + evidence + +Connected agents use MCP (`linear___save_issue`, etc.). On-device/CI use: + +```bash +export LINEAR_API_KEY=lin_api_... +python3 -m archwiz.linear_client start TER-14 +python3 -m archwiz.linear_client done TER-14 --pr 16 +``` + +## Branch model (binding) + +``` +feature/* ──PR──► master-staging ← integration spine (always exists) + │ + │ selective cherry-pick / focused promotion PRs only + │ NEVER merge master-staging → master wholesale + ▼ + master ← stable, green, protected +``` + +**`master-staging` is a permanent gate, not a temporary buffer.** + +- Agents land work on `master-staging` via feature PRs. +- Promotion to `master` is **selective**: only commits/PRs that are ready, never “merge the whole staging branch.” +- `master-staging` must **not** be deleted, fast-forward-merged away, or treated as disposable. + +See also Operator note on TER-14: *"master-staging is for selective merge to master meaning master-staging is meant to never merge to master completely."* ## Minimum permission checklist (GitHub App / token) @@ -53,20 +91,20 @@ On `master`: On `master-staging`: -- Prefer **no** protection or soft protection so agents can iterate quickly; promote to `master` only when both gates are green. +- Soft or no protection so agents can iterate; **keep the branch permanently** as the integration target. ## Fully agentic target state ``` -Proposal posted → registry.yaml updated by agent - → items executed on branches off master-staging - → PRs opened, gates run, agent merges to master-staging - → agent opens promotion PR to master - → required checks pass → agent merges to master +Linear TER-* + registry.yaml → agent picks Todo + → Linear In Progress + branch off master-staging + → PR Implements: TER-N → gates green → merge to master-staging + → Linear Done + ITEMS.md update + → selective promotion PR(s) of ready commits to master (never wholesale staging merge) ``` Human intervenes only for: credential rotation, destructive history ops, and first-time permission grants above. ## ChatGPT connector note -ChatGPT's GitHub connector previously returned `403 Resource not accessible by integration` on write. This Grok connector **can** write. If you want ChatGPT to execute the same pipeline, mirror the permission checklist on the ChatGPT GitHub App installation. +ChatGPT's GitHub connector previously returned `403 Resource not accessible by integration` on write. This Grok connector **can** write. If you want ChatGPT to execute the same pipeline, mirror the permission checklist on the ChatGPT GitHub App installation. Connect Linear MCP (or pass LINEAR_API_KEY) for full tracker parity. diff --git a/docs/sentry/aiohttp_example.py b/docs/sentry/aiohttp_example.py new file mode 100644 index 000000000..ce5af0320 --- /dev/null +++ b/docs/sentry/aiohttp_example.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Minimal aiohttp + Sentry verify app (project 4511844256055296).""" +from aiohttp import web +import sentry_sdk + +sentry_sdk.init( + dsn="https://c7fb0bb5cf4210fae90119131c12b320@o4511844213522432.ingest.us.sentry.io/4511844256055296", + send_default_pii=True, + enable_logs=True, + traces_sample_rate=1.0, + profile_session_sample_rate=1.0, + profile_lifecycle="trace", +) + + +async def hello(request): + 1 / 0 # intentional — appears in Sentry linked to the transaction + return web.Response(text="Hello, world") + + +app = web.Application() +app.add_routes([web.get("/", hello)]) + +if __name__ == "__main__": + web.run_app(app) diff --git a/docs/sentry/browser-init.js b/docs/sentry/browser-init.js new file mode 100644 index 000000000..2240cb94b --- /dev/null +++ b/docs/sentry/browser-init.js @@ -0,0 +1,27 @@ +/** + * Browser Sentry init for termux-monorepo web surfaces + * (commingle-swarm/web, static dashboards, etc.) + * + * npm install --save @sentry/browser + */ +import * as Sentry from "@sentry/browser"; + +Sentry.init({ + dsn: "https://2fbd3c77388239145b6dd872f1e054aa@o4511844213522432.ingest.us.sentry.io/4511844264640512", + dataCollection: { + // userInfo: false, + // httpBodies: [], + }, + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + ], + tracesSampleRate: 1.0, + tracePropagationTargets: ["localhost", /^https:\/\/yourserver\.io\/api/], + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, +}); + +// Optional self-test (remove in production): +// Sentry.metrics.count("test_counter", 1); +// myUndefinedFunction(); diff --git a/docs/sentry/loader-snippet.html b/docs/sentry/loader-snippet.html new file mode 100644 index 000000000..0d0c0c6e2 --- /dev/null +++ b/docs/sentry/loader-snippet.html @@ -0,0 +1,17 @@ + + + diff --git a/docs/sentry/rust_main_example.rs b/docs/sentry/rust_main_example.rs new file mode 100644 index 000000000..1eaa54c8d --- /dev/null +++ b/docs/sentry/rust_main_example.rs @@ -0,0 +1,20 @@ +// Example Sentry bootstrap for Rust crates in this monorepo +// (harmonizer-prod_cli, synthegration-cli, workspace/maxc, appliedSxi/maxc) +// +// Cargo.toml: +// [dependencies] +// sentry = "0.49.0" + +fn main() { + let _guard = sentry::init(( + "https://8b6f33db85568dc94e5db28dfe5eee72@o4511844213522432.ingest.us.sentry.io/4511844272111616", + sentry::ClientOptions { + release: sentry::release_name!(), + send_default_pii: true, + ..Default::default() + }, + )); + + // Sentry will capture this + panic!("Everything is on fire!"); +}