From fdb454414d0a1e8f70f686bacb32c30ab6063c34 Mon Sep 17 00:00:00 2001 From: "Timmy Jr." Date: Fri, 7 Aug 2026 13:27:37 +0000 Subject: [PATCH] Create Stackchain Lab agent collaboration loop --- .gitea/issue_template/task.md | 24 ++ AGENTS.md | 13 + HOST_SETUP.md | 32 +++ PROTOCOL.md | 52 ++++ pyproject.toml | 7 + scripts/__pycache__/lab_loop.cpython-311.pyc | Bin 0 -> 18023 bytes scripts/lab_loop.py | 242 ++++++++++++++++++ .../__pycache__/test_lab_loop.cpython-311.pyc | Bin 0 -> 3611 bytes tests/test_lab_loop.py | 38 +++ 9 files changed, 408 insertions(+) create mode 100644 .gitea/issue_template/task.md create mode 100644 AGENTS.md create mode 100644 HOST_SETUP.md create mode 100644 PROTOCOL.md create mode 100644 pyproject.toml create mode 100644 scripts/__pycache__/lab_loop.cpython-311.pyc create mode 100644 scripts/lab_loop.py create mode 100644 tests/__pycache__/test_lab_loop.cpython-311.pyc create mode 100644 tests/test_lab_loop.py diff --git a/.gitea/issue_template/task.md b/.gitea/issue_template/task.md new file mode 100644 index 0000000..6927755 --- /dev/null +++ b/.gitea/issue_template/task.md @@ -0,0 +1,24 @@ +--- +name: Lab Loop Task +title: "[Task] " +about: A bounded Timmy/Vincent collaboration ticket +labels: "state:ready,priority:P1" +--- + +## Outcome + +What must exist or be decided? + +## Owner + +Choose exactly one: `agent:timmy` or `agent:vincent`. + +## Acceptance evidence + +- [ ] Artifact, commit, source, or test result is linked +- [ ] The evidence has been independently verified +- [ ] Next state is review, blocked, or an explicit single-agent handoff + +## Constraints + +List privacy, hardware, licensing, time, or deployment limits. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb96cd3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# Agent instructions + +Follow `PROTOCOL.md`. + +- Identify yourself through `LAB_AGENT`; supported initial identities are `timmy` and `vincent`. +- Process at most one ready issue per cycle. +- Do not post status updates, greetings, thanks, or automated acknowledgments. +- Claim before touching work. +- Return verified artifacts, decisions, or a single blocker. +- Handoff to exactly one agent. Never create reciprocal automatic handoffs. +- Do not close issues automatically; move completed work to `state:review`. +- Keep tokens in the host environment under `GITEA_TOKEN`. +- Never force-push or mutate another agent's active branch. diff --git a/HOST_SETUP.md b/HOST_SETUP.md new file mode 100644 index 0000000..1854dbe --- /dev/null +++ b/HOST_SETUP.md @@ -0,0 +1,32 @@ +# Independent host connection + +Each Hermes host uses its own Forge account and scoped token. Transfer tokens privately; never place them in Telegram groups or this repository. + +```bash +git clone https://forge.alexanderwhitestone.com/git/stackchain/stackchain-lab-loop.git +cd stackchain-lab-loop +export LAB_AGENT=vincent # or timmy +export GITEA_TOKEN='' +python3 scripts/lab_loop.py next +``` + +A worker cycle processes no more than one issue: + +1. Run `next`. +2. If the result is `idle`, exit silently. +3. Claim the returned issue: + ```bash + python3 scripts/lab_loop.py claim ISSUE_NUMBER + ``` +4. Read the complete issue and comments, perform the bounded work, and verify the result. +5. Finish for human review: + ```bash + python3 scripts/lab_loop.py finish ISSUE_NUMBER --file /tmp/review-comment.md + ``` + or hand off once: + ```bash + python3 scripts/lab_loop.py handoff ISSUE_NUMBER --to timmy --file /tmp/handoff.md + ``` +6. Do not post Telegram status. Gitea holds the durable conversation. + +Recommended Hermes cron behavior: run every 20–30 minutes, work only `state:ready` issues assigned to this agent, and deliver locally unless an issue carries `human-gate`. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..b27afd8 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,52 @@ +# Stackchain Lab Loop + +This repository is the durable conversation between independently hosted agents. + +## Rules + +1. Gitea issues are the queue and issue comments are the thread. +2. An agent acts only on an open issue carrying both `agent:` and `state:ready`. +3. Claim before work. A claim changes `state:ready` to `state:claimed` and leaves one `[CLAIM]` receipt. +4. Post only substantive outputs: a decision, evidence, artifact, blocker, or handoff. Never post progress chatter or acknowledgments. +5. A handoff names exactly one next agent and changes the issue back to `state:ready`. +6. Finished work enters `state:review`. Humans approve closure or request another handoff. +7. One issue per concern. Branches use `/-`; no force pushes. +8. Claims must include reproducible evidence. Links must be fetched before being reported as working. +9. Tokens stay on each agent's host. Never commit credentials or paste them into issue threads. +10. Three consecutive failures move the issue to `state:blocked` for human review. + +## Labels + +- `agent:timmy`, `agent:vincent`: exclusive next owner +- `state:ready`, `state:claimed`, `state:review`, `state:blocked`: lifecycle +- `priority:P0`, `priority:P1`, `priority:P2`: urgency +- `kind:build`, `kind:research`, `kind:creative`, `kind:ops`: work type +- `human-gate`: explicit Alexander/operator decision required + +## Comment forms + +```text +[CLAIM] agent=timmy + +[DECISION] + + +[EVIDENCE] + + +[HANDOFF] to=vincent + + +[BLOCKED] + +``` + +## Loop + +`READY → CLAIMED → REVIEW` + +or + +`READY → CLAIMED → HANDOFF(new agent) → READY` + +The agent utility is `scripts/lab_loop.py`. Run `python3 scripts/lab_loop.py --help` for commands. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e9204ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "stackchain-lab-loop" +version = "0.1.0" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/scripts/__pycache__/lab_loop.cpython-311.pyc b/scripts/__pycache__/lab_loop.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..15cc71ad0436de033bb04c8f453dbcb542a181aa GIT binary patch literal 18023 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 literal 0 HcmV?d00001 diff --git a/scripts/lab_loop.py b/scripts/lab_loop.py new file mode 100644 index 0000000..0f4cfc9 --- /dev/null +++ b/scripts/lab_loop.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Small, dependency-free client for the Stackchain Lab Gitea issue loop.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +DEFAULT_BASE = "https://forge.alexanderwhitestone.com/git/api/v1" +DEFAULT_REPO = "stackchain/stackchain-lab-loop" +AGENTS = {"timmy", "vincent"} +STATES = {"ready", "claimed", "review", "blocked"} + + +class LoopError(RuntimeError): + pass + + +class Gitea: + def __init__(self, base: str, repo: str, token: str): + self.base = base.rstrip("/") + self.repo = repo.strip("/") + self.token = token + + def request(self, method: str, path: str, payload: Any | None = None) -> Any: + data = None if payload is None else json.dumps(payload).encode() + req = urllib.request.Request( + f"{self.base}{path}", + data=data, + method=method, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"token {self.token}", + "User-Agent": "stackchain-lab-loop/0.1", + }, + ) + try: + with urllib.request.urlopen(req, timeout=20) as response: + raw = response.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace") + raise LoopError(f"Gitea {method} {path} failed: HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise LoopError(f"Gitea {method} {path} failed: {exc.reason}") from exc + + def labels(self) -> dict[str, int]: + rows = self.request("GET", f"/repos/{self.repo}/labels?limit=100") + return {row["name"]: int(row["id"]) for row in rows} + + def issue(self, number: int) -> dict[str, Any]: + return self.request("GET", f"/repos/{self.repo}/issues/{number}") + + def issues(self) -> list[dict[str, Any]]: + return self.request("GET", f"/repos/{self.repo}/issues?state=open&type=issues&limit=100") + + def set_labels(self, number: int, names: set[str]) -> None: + label_map = self.labels() + missing = sorted(names - label_map.keys()) + if missing: + raise LoopError(f"Missing repository labels: {', '.join(missing)}") + self.request( + "PATCH", + f"/repos/{self.repo}/issues/{number}", + {"labels": [label_map[name] for name in sorted(names)]}, + ) + + def comment(self, number: int, body: str) -> None: + if not body.strip(): + raise LoopError("Refusing to post an empty comment") + self.request("POST", f"/repos/{self.repo}/issues/{number}/comments", {"body": body.strip()}) + + +def issue_label_names(issue: dict[str, Any]) -> set[str]: + return {label["name"] for label in issue.get("labels", [])} + + +def transition(names: set[str], *, agent: str | None = None, state: str | None = None) -> set[str]: + result = {name for name in names if not name.startswith("agent:") and not name.startswith("state:")} + if agent: + if agent not in AGENTS: + raise LoopError(f"Unsupported agent: {agent}") + result.add(f"agent:{agent}") + if state: + if state not in STATES: + raise LoopError(f"Unsupported state: {state}") + result.add(f"state:{state}") + return result + + +def validate_owner(issue: dict[str, Any], agent: str, required_state: str) -> set[str]: + names = issue_label_names(issue) + if f"agent:{agent}" not in names: + raise LoopError(f"Issue #{issue['number']} is not owned by agent:{agent}") + if f"state:{required_state}" not in names: + raise LoopError(f"Issue #{issue['number']} is not state:{required_state}") + return names + + +def read_body(args: argparse.Namespace, default: str = "") -> str: + if getattr(args, "file", None): + return Path(args.file).read_text() + if getattr(args, "body", None): + return args.body + return default + + +def cmd_next(api: Gitea, agent: str, _args: argparse.Namespace) -> int: + candidates = [] + for issue in api.issues(): + labels = issue_label_names(issue) + if f"agent:{agent}" in labels and "state:ready" in labels: + candidates.append(issue) + candidates.sort(key=lambda row: int(row["number"])) + if not candidates: + print(json.dumps({"status": "idle", "agent": agent})) + return 3 + issue = candidates[0] + print(json.dumps({ + "status": "ready", + "agent": agent, + "number": issue["number"], + "title": issue["title"], + "url": issue["html_url"], + "labels": sorted(issue_label_names(issue)), + "body": issue.get("body") or "", + }, indent=2)) + return 0 + + +def cmd_claim(api: Gitea, agent: str, args: argparse.Namespace) -> int: + issue = api.issue(args.number) + names = validate_owner(issue, agent, "ready") + api.set_labels(args.number, transition(names, agent=agent, state="claimed")) + api.comment(args.number, f"[CLAIM] agent={agent}") + verified = api.issue(args.number) + validate_owner(verified, agent, "claimed") + print(json.dumps({"status": "claimed", "number": args.number, "agent": agent})) + return 0 + + +def cmd_comment(api: Gitea, agent: str, args: argparse.Namespace) -> int: + issue = api.issue(args.number) + validate_owner(issue, agent, "claimed") + api.comment(args.number, read_body(args)) + print(json.dumps({"status": "commented", "number": args.number, "agent": agent})) + return 0 + + +def cmd_handoff(api: Gitea, agent: str, args: argparse.Namespace) -> int: + if args.to == agent: + raise LoopError("Refusing a handoff to the same agent") + issue = api.issue(args.number) + names = validate_owner(issue, agent, "claimed") + body = read_body(args, f"Continue the bounded task in issue #{args.number}.") + api.comment(args.number, f"[HANDOFF] from={agent} to={args.to}\n\n{body.strip()}") + api.set_labels(args.number, transition(names, agent=args.to, state="ready")) + verified = api.issue(args.number) + validate_owner(verified, args.to, "ready") + print(json.dumps({"status": "handed_off", "number": args.number, "from": agent, "to": args.to})) + return 0 + + +def cmd_finish(api: Gitea, agent: str, args: argparse.Namespace) -> int: + issue = api.issue(args.number) + names = validate_owner(issue, agent, "claimed") + body = read_body(args) + if body.strip(): + api.comment(args.number, f"[REVIEW] agent={agent}\n\n{body.strip()}") + api.set_labels(args.number, transition(names, agent=agent, state="review")) + verified = api.issue(args.number) + validate_owner(verified, agent, "review") + print(json.dumps({"status": "review", "number": args.number, "agent": agent})) + return 0 + + +def cmd_block(api: Gitea, agent: str, args: argparse.Namespace) -> int: + issue = api.issue(args.number) + names = validate_owner(issue, agent, "claimed") + body = read_body(args) + api.comment(args.number, f"[BLOCKED] agent={agent}\n\n{body.strip()}") + api.set_labels(args.number, transition(names, agent=agent, state="blocked")) + print(json.dumps({"status": "blocked", "number": args.number, "agent": agent})) + return 0 + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--base", default=os.getenv("GITEA_BASE", DEFAULT_BASE)) + p.add_argument("--repo", default=os.getenv("LAB_REPO", DEFAULT_REPO)) + p.add_argument("--agent", default=os.getenv("LAB_AGENT", "")) + sub = p.add_subparsers(dest="command", required=True) + sub.add_parser("next") + for name in ("claim", "comment", "finish", "block"): + cmd = sub.add_parser(name) + cmd.add_argument("number", type=int) + if name != "claim": + source = cmd.add_mutually_exclusive_group(required=True) + source.add_argument("--body") + source.add_argument("--file") + handoff = sub.add_parser("handoff") + handoff.add_argument("number", type=int) + handoff.add_argument("--to", required=True, choices=sorted(AGENTS)) + source = handoff.add_mutually_exclusive_group() + source.add_argument("--body") + source.add_argument("--file") + return p + + +def main() -> int: + args = parser().parse_args() + if args.agent not in AGENTS: + raise LoopError("Set LAB_AGENT to timmy or vincent, or pass --agent") + token = os.getenv("GITEA_TOKEN", "") + if not token: + raise LoopError("GITEA_TOKEN is required") + api = Gitea(args.base, args.repo, token) + handlers = { + "next": cmd_next, + "claim": cmd_claim, + "comment": cmd_comment, + "handoff": cmd_handoff, + "finish": cmd_finish, + "block": cmd_block, + } + return handlers[args.command](api, args.agent, args) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except LoopError as exc: + print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr) + raise SystemExit(2) diff --git a/tests/__pycache__/test_lab_loop.cpython-311.pyc b/tests/__pycache__/test_lab_loop.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eda06fad8f311c17e1c9596abafe18db0d389b20 GIT binary patch literal 3611 zcmb_eO>7fK6rTOFH_kd>+9q*|LMqx)m*xjh+!7U|P@trx1_dgL4_!IlC0VjwhuL)? z5mi$ul{mI)E09{GNJwc=IHXc5aU`62Tq9RRt0E!wP;oN~2b5Fa8~?;k>^4%_U4Nc= zGxO%n`<$VqgXwtF`$H-V-%Vg3tw4|e}nah?= zQx(3@;7Xm;{1B!T#grtdpK1~AjYrZoi;`c%*!D*UtGO3Jt zR<|8HRc41={qV!Xu6_=1os7W&ZgF35SIHdoH$P5AF@#*@G+q<#h_wm>4$>`d9u&67 zl!iP&H3Wq5#bYA$)&k>(r45W^4O3fdPLAr9GvFBM^we7O1T`|$aHa-M?^_Gn4rpT_ zWhRZZu6h1R!%D$aPojEKo4U<=5?ks?Y#!rG(KV=odo&kUv$<=*;wB{yu(Ua8+d6fQ zUCJg+ie3XW(bmlio{!nbwo&%3uC=~in#nl5z$`VM8cP~hH;i|ipweCkb;%HIsv@mt zV#+&#bamaZD%B^v>VU(dza` z05fDI6nEKo&9@>e59Rg;a{D8t^_$7L^Glbq6~MqY$#U9Im{QapxUS?xW2v>7P$B>+!izD2hoVLQSr2yFl*BhwCOdv7t_+7Oo| zLsgm3uvJ6T(-Rp79B_J|fnBz+wgq>64*>NZbJg*u1B>;#p=y@{{mU&JV2wLe^7$1|6O=%fqYXsS;6`~(`ib9i zcK2!iT4QU1CXVqNO${zr2WG0wXVz6RuXyRUX2{fYOe2AFGLET)>M1qMp9rs zt1kzubbYp_qq~rVX(}SpR&1mG=xzkm zRfK2m?@8<)Q73oRULc_ET*?|yUeu9{GgiEI*bQ^n7S^Ij_Aq$W40+tzw$k1?x6N(q zTZ-O2?C$^AZ9Q3#gb)^mP~e(WkV#XpSW9kpE=UVgcO$v*(R}#my)WI*M&09MIoZg| zhASI&V=Rg;AAO{B+~}X%vv4q{^yig+SLw&#Dvq%U7+g^dt|}TE*Z`%t+BoZRz3{Hy zI5ykDE^HwHO#Yc3uUimoZbmK3F?Zva%M~Xjp{puK^$?Ic|k)n_=%`@~Qh@U%|JNgA;9r8*;uq@)QT2t&hfts9c+VAcS2Z ayds8XPRxGt)v4K2Gwdx0MC^b8CgwlS!fn_9 literal 0 HcmV?d00001 diff --git a/tests/test_lab_loop.py b/tests/test_lab_loop.py new file mode 100644 index 0000000..0216acc --- /dev/null +++ b/tests/test_lab_loop.py @@ -0,0 +1,38 @@ +import unittest + +from scripts.lab_loop import LoopError, issue_label_names, transition, validate_owner + + +class LoopProtocolTests(unittest.TestCase): + def test_transition_replaces_agent_and_state_but_preserves_kind_and_priority(self): + labels = {"agent:timmy", "state:claimed", "priority:P1", "kind:build"} + self.assertEqual( + transition(labels, agent="vincent", state="ready"), + {"agent:vincent", "state:ready", "priority:P1", "kind:build"}, + ) + + def test_same_agent_transition_is_idempotent(self): + labels = {"agent:timmy", "state:ready", "priority:P0"} + self.assertEqual(transition(labels, agent="timmy", state="ready"), labels) + + def test_invalid_agent_fails_closed(self): + with self.assertRaises(LoopError): + transition(set(), agent="unknown", state="ready") + + def test_validate_owner_requires_both_labels(self): + issue = { + "number": 7, + "labels": [{"name": "agent:vincent"}, {"name": "state:claimed"}], + } + self.assertEqual(validate_owner(issue, "vincent", "claimed"), {"agent:vincent", "state:claimed"}) + with self.assertRaises(LoopError): + validate_owner(issue, "timmy", "claimed") + with self.assertRaises(LoopError): + validate_owner(issue, "vincent", "ready") + + def test_issue_label_names_handles_missing_labels(self): + self.assertEqual(issue_label_names({}), set()) + + +if __name__ == "__main__": + unittest.main()