Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/ITEMS.md` — add a row first.
- Cite `Implements: <ITEM-ID>` on PRs/commits.
- Do not invent work outside `docs/proposals/active/<id>/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 <n>
→ update ITEMS.md status
→ (optional, separate) selective promotion of ready commits to master
```

## Security
Expand Down
299 changes: 299 additions & 0 deletions archwiz/linear_client.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +24 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Fallback capture_message is not defined when only init_sentry fails in linear_client

archwiz/linear_client.py only imports and shims capture_exception, while archwiz/linear_sync.py shims both capture_exception and capture_message. That's consistent with current usage, but note the shims are only installed when the import or init_sentry() raises; if a future edit adds capture_message use in the client it would NameError in the fallback path. Splitting the import from the init_sentry() call would make the fallback semantics clearer.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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")
Comment on lines +32 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Unused configuration constants in the new modules

PROJECT_NAME is read from the environment but never used — issues created via create_issue (archwiz/linear_client.py:153-185) are not attached to the configured project, so the documented "Project: termux-monorepo hardening" convention in docs/LINEAR-AGENT-PROTOCOL.md:86-88 is not enforced by the CLI. Similarly LOG_DIR imported at archwiz/linear_sync.py:24 is unused.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



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()
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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
Comment on lines +108 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Status updates can use a workflow state belonging to a different team

Workflow state ids are collected from every team in the workspace and stored under the same names (team_states() at archwiz/linear_client.py:108-125), ignoring the configured team, so a start/done command may apply another team's state and be rejected or land wrongly.
Impact: The start/done commands can fail or move an issue into a state that does not belong to its team.

Mechanism: unfiltered teams query with name/type collisions

team_states() queries teams { nodes { name states { nodes { id name type } } } } with no filter and writes out[name.lower()] = id and out[type.lower()] = id for every team, so later teams overwrite earlier ones. cmd_start (archwiz/linear_client.py:206-211) and cmd_done (archwiz/linear_client.py:221-226) then pass whatever id survived to issueUpdate for an issue that may belong to a different team. Note TEAM_NAME (archwiz/linear_client.py:32) is defined but only used in create_issue. archwiz/linear_sync.py:142-171 has the same problem in its all-teams fallback path.

Filter the states query by the issue's team (or by TEAM_NAME) before building the mapping.

Prompt for agents
archwiz/linear_client.py team_states() gathers workflow states from all teams into one flat name->id map, with later teams silently overwriting earlier entries, and ignores the TEAM_NAME/LINEAR_TEAM configuration that is otherwise honoured in create_issue. cmd_start/cmd_done then send a possibly foreign team's stateId to issueUpdate. Scope the states lookup to the relevant team - either filter teams by TEAM_NAME, or resolve the team from the issue being updated (issue { team { id } }) and fetch that team's states. archwiz/linear_sync.py list_team_states has the same all-teams fallback and should be scoped similarly.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



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())
Loading
Loading