#!/usr/bin/env python3 """远端需求分流收据(task.sh triage / triage-decide / start 共用的唯一分流依据)。 事实来源是 origin:`git ls-remote` 取分支头,Gitea open PR 取 base/head 关系,候选分支提交中的 任务卡取 initiative 元数据。本地 `.task/` 不参与发现。 子命令: discover --receipt PATH --requirement TEXT 查询远端并写收据;无候选 -> NEW;有候选 -> DECISION_REQUIRED(退出 5,不自动选择 JOIN)。 decide --receipt PATH --verdict JOIN|NEW [--initiative BRANCH] --reason TEXT 复查远端;候选集合或任一候选的 HEAD/卡片/PR 字段变化则拒绝(退出 7,收据不改)。 verify --receipt PATH --kind standalone|initiative|child [--initiative BRANCH] [--max-age-seconds N] 复查远端并核对收据;供 task.sh start 在创建任何 worktree/分支之前调用(拒绝退出 8)。 退出码:0 成功;1 本地错误;2 用法;5 DECISION_REQUIRED;6 REMOTE_UNVERIFIED;7 决策被拒;8 核验被拒。 仅使用标准库。token 只读自环境变量 GITEA_TOKEN 或 ~/.secrets/gitea 的 ACCESS_TOKEN=,不打印、不写入收据。 """ import argparse import datetime as dt import hashlib import http.client import json import os import re import subprocess import sys import urllib.error import urllib.parse import urllib.request SCHEMA = "agent-system/task-triage/v1" DEFAULT_API = "https://git-home.u1680832.nyat.app:30319/api/v1" DEFAULT_TARGET = "main" DEFAULT_MAX_AGE = 14400 PAGE_LIMIT = 50 MAX_PAGES = 100 SECRETS_FILE = "~/.secrets/gitea" CARD_RE = re.compile(r"^\.task/[^/]+\.md$") CANDIDATE_KEYS = ("branch", "head_sha", "card_path", "card_sha256", "pr_number", "pr_state", "pr_base", "pr_head_sha") RECEIPT_KEYS = ("repo", "root_target", "root_target_sha", "requirement", "discovered_at", "candidates", "discovery_verdict", "final_verdict", "decision") EXIT_LOCAL = 1 EXIT_USAGE = 2 EXIT_DECISION_REQUIRED = 5 EXIT_REMOTE = 6 EXIT_DECIDE = 7 EXIT_VERIFY = 8 class Stop(Exception): """受控失败:退出码 + 不含 token 的说明。""" def __init__(self, code, message): super().__init__(message) self.code = code self.message = message def utc_now(): return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def parse_time(text): value = dt.datetime.fromisoformat(text) if value.tzinfo is None: value = value.replace(tzinfo=dt.timezone.utc) return value def sha256_bytes(data): return hashlib.sha256(data).hexdigest() def as_dict(value): return value if isinstance(value, dict) else {} def target_branch(): return os.environ.get("TASK_TARGET_BRANCH", "").strip() or DEFAULT_TARGET def git(*args): return subprocess.run(["git", *args], capture_output=True) def redact_url(url): """去掉 URL 中的 userinfo(https://user:pw@host 与 user@host:path 两种形式)。""" if "://" in url: parts = urllib.parse.urlsplit(url) host = parts.netloc.rsplit("@", 1)[-1] return urllib.parse.urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment)) scp = re.match(r"^[^/@]+@([^/]+:.*)$", url) return scp.group(1) if scp else url def repo_slug(origin_url): slug = os.environ.get("GITEA_REPO", "").strip() if not slug: if "://" in origin_url: path = urllib.parse.urlsplit(origin_url).path else: path = origin_url.split(":", 1)[1] if re.match(r"^[^/]+:", origin_url) else origin_url segments = [s for s in path.split("/") if s] if len(segments) < 2: raise Stop(EXIT_REMOTE, "cannot derive owner/repo from origin; set GITEA_REPO") name = segments[-1][:-4] if segments[-1].endswith(".git") else segments[-1] slug = f"{segments[-2]}/{name}" parts = slug.split("/") if len(parts) != 2 or not all(parts) or any(p in (".", "..") for p in parts): raise Stop(EXIT_REMOTE, "invalid owner/repo slug") return slug def api_base(): return (os.environ.get("GITEA_API_URL", "").strip() or DEFAULT_API).rstrip("/") def read_token(): token = os.environ.get("GITEA_TOKEN", "").strip() if token: return token try: with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh: for raw in fh: line = raw.strip() if line.startswith("- "): line = line[2:].strip() if line.startswith("ACCESS_TOKEN="): return line.split("=", 1)[1].strip() or None except (OSError, UnicodeDecodeError): return None return None def remote_heads(): proc = git("ls-remote", "--heads", "origin") if proc.returncode != 0: raise Stop(EXIT_REMOTE, "git ls-remote --heads origin failed") heads = {} for line in proc.stdout.decode("utf-8", "replace").splitlines(): sha, _, ref = line.partition("\t") if ref.startswith("refs/heads/"): heads[ref[len("refs/heads/"):]] = sha return heads def http_get_json(url, token): request = urllib.request.Request( url, headers={"Accept": "application/json", "Authorization": f"token {token}"}) try: with urllib.request.urlopen(request, timeout=20) as response: body = response.read() except urllib.error.HTTPError as exc: raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}") except (urllib.error.URLError, http.client.HTTPException, OSError) as exc: raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})") try: return json.loads(body.decode("utf-8")) except ValueError: raise Stop(EXIT_REMOTE, "Gitea API returned invalid JSON") def open_pulls(api, slug, token): owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/")) pulls = [] for page in range(1, MAX_PAGES + 1): url = f"{api}/repos/{owner}/{name}/pulls?state=open&limit={PAGE_LIMIT}&page={page}" batch = http_get_json(url, token) if not isinstance(batch, list): raise Stop(EXIT_REMOTE, "Gitea pulls response is not a list") if not batch: return pulls pulls.extend(batch) raise Stop(EXIT_REMOTE, f"open pull requests exceed {MAX_PAGES} pages") def fetch_head(branch): proc = git("fetch", "-q", "origin", f"refs/heads/{branch}") if proc.returncode != 0: raise Stop(EXIT_REMOTE, f"cannot fetch origin/{branch}") rev = git("rev-parse", "--verify", "-q", "FETCH_HEAD^{commit}") if rev.returncode != 0: raise Stop(EXIT_REMOTE, f"fetched origin/{branch} has no commit") return rev.stdout.decode("utf-8", "replace").strip() def task_card_paths(commit): proc = git("ls-tree", "-r", "-z", "--name-only", commit, "--", ".task") if proc.returncode != 0: raise Stop(EXIT_REMOTE, "cannot list task cards at candidate head") names = proc.stdout.decode("utf-8", "replace").split("\0") return sorted(name for name in names if CARD_RE.match(name)) def blob_bytes(commit, path): proc = git("cat-file", "blob", f"{commit}:{path}") if proc.returncode != 0: raise Stop(EXIT_REMOTE, f"cannot read {path} at candidate head") return proc.stdout def card_branch(text): for line in text.splitlines(): match = re.match(r"^- 工作区:.*分支:([A-Za-z0-9._/-]+)", line) if match: return match.group(1) return None def card_status(text): match = re.search(r";状态:([^;\n]*)", text) return match.group(1).strip() if match else "" def branch_meta(text, target): """分支元数据行 '- 分支类型:…;field:value…';旧卡缺省为 standalone、不接收需求。""" match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M) if not match: return {"kind": "standalone", "initiative_id": "", "root_target": target, "accepting": "false"} line = match.group(1) def field(name, default): found = re.search(";" + name + r":([^;]*)", line) return found.group(1).strip() if found else default return { "kind": line.split(";", 1)[0].strip(), "initiative_id": field("initiative_id", ""), "root_target": field("root_target", target), "accepting": field("accepting_requirements", "false"), } def goal_excerpt(text): lines, inside = [], False for line in text.splitlines(): if line.startswith("## "): if inside: break inside = line.strip() == "## 目标与非目标" continue if inside and line.strip(): lines.append(line.strip()) if len(lines) == 5: break return " / ".join(lines)[:300] def inspect_branch(target, branch, head_sha, pr): fetched = fetch_head(branch) if fetched != head_sha: raise Stop(EXIT_REMOTE, f"{branch} moved during discovery; retry") matches = [] for path in task_card_paths(head_sha): raw = blob_bytes(head_sha, path) text = raw.decode("utf-8", "replace") if card_branch(text) != branch: continue meta = branch_meta(text, target) if meta["kind"] != "initiative" or meta["accepting"] != "true" or meta["root_target"] != target: continue if card_status(text).startswith("已完成"): continue matches.append((path, raw, text, meta)) if not matches: return None if len(matches) > 1: raise Stop(EXIT_REMOTE, f"ambiguous initiative cards on {branch}") path, raw, text, meta = matches[0] head = as_dict(pr.get("head")) return { "branch": branch, "head_sha": head_sha, "pr_number": pr["number"], "pr_state": pr.get("state"), "pr_base": as_dict(pr.get("base")).get("ref"), "pr_head_sha": head.get("sha") or "", "card_path": path, "card_sha256": sha256_bytes(raw), "initiative_id": meta["initiative_id"], "goal_excerpt": goal_excerpt(text), } def candidates_from(target, api, slug, token, heads): by_branch = {} for pr in open_pulls(api, slug, token): if not isinstance(pr, dict) or pr.get("state") != "open": continue base = as_dict(pr.get("base")).get("ref") head = as_dict(pr.get("head")).get("ref") if base != target or not isinstance(head, str) or head == target or head not in heads: continue if not isinstance(pr.get("number"), int): raise Stop(EXIT_REMOTE, "malformed pull request number") by_branch.setdefault(head, []).append(pr) found = [] for branch in sorted(by_branch): if len(by_branch[branch]) != 1: raise Stop(EXIT_REMOTE, f"ambiguous: several open PRs target {target} from {branch}") candidate = inspect_branch(target, branch, heads[branch], by_branch[branch][0]) if candidate: found.append(candidate) return found def snapshot(target): origin = git("remote", "get-url", "origin") if origin.returncode != 0: raise Stop(EXIT_REMOTE, "origin remote is not configured") origin_url = origin.stdout.decode("utf-8", "replace").strip() heads = remote_heads() if target not in heads: raise Stop(EXIT_REMOTE, f"origin has no {target} branch") slug = repo_slug(origin_url) api = api_base() token = read_token() if not token: raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN") return { "repo": {"origin_url": redact_url(origin_url), "api_base": redact_url(api), "repo_slug": slug}, "root_target": target, "root_target_sha": heads[target], "candidates": candidates_from(target, api, slug, token, heads), } def fingerprint(candidates): return sorted(tuple(c[key] for key in CANDIDATE_KEYS) for c in candidates) def check_shape(receipt): missing = [key for key in RECEIPT_KEYS if key not in receipt] if missing: raise ValueError(f"missing {missing[0]}") if not isinstance(receipt["candidates"], list): raise ValueError("candidates") for cand in receipt["candidates"]: if not isinstance(cand, dict) or any(key not in cand for key in CANDIDATE_KEYS): raise ValueError("candidate") if not isinstance(as_dict(receipt["requirement"]).get("sha256"), str): raise ValueError("requirement") parse_time(receipt["discovered_at"]) def load_receipt(path, code): try: with open(path, "rb") as fh: raw = fh.read() except OSError: raise Stop(code, f"receipt missing or unreadable: {path}") try: receipt = json.loads(raw.decode("utf-8")) except ValueError: raise Stop(code, "receipt is not valid JSON") if not isinstance(receipt, dict) or receipt.get("schema") != SCHEMA: raise Stop(code, f"receipt schema is not {SCHEMA}") try: check_shape(receipt) except (ValueError, TypeError): raise Stop(code, "receipt is malformed") return receipt, raw def write_receipt(path, receipt): data = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n" tmp = f"{path}.tmp-{os.getpid()}" try: with open(tmp, "w", encoding="utf-8") as fh: fh.write(data) os.replace(tmp, path) except OSError as exc: raise Stop(EXIT_LOCAL, f"cannot write receipt {path}: {exc.strerror}") def describe(cand): return (f"[candidate] branch={cand['branch']} head_sha={cand['head_sha']} pr=#{cand['pr_number']} " f"base={cand['pr_base']} card={cand['card_path']} card_sha256={cand['card_sha256']} " f"initiative_id={cand['initiative_id'] or '无'}") def cmd_discover(args): text = args.requirement.strip() if not text: raise Stop(EXIT_USAGE, "requirement text is required") snap = snapshot(target_branch()) now = utc_now() receipt = { "schema": SCHEMA, **snap, "requirement": {"sha256": sha256_bytes(text.encode("utf-8")), "summary": " ".join(text.split())[:200]}, "discovered_at": now, } path = os.path.realpath(args.receipt) if not snap["candidates"]: receipt.update(discovery_verdict="NEW", final_verdict="NEW", decision={ "verdict": "NEW", "selected_initiative": None, "rationale": "no accepting initiative", "decided_at": now}) write_receipt(args.receipt, receipt) print(f"[verdict] NEW — 远端没有接纳中的 initiative;收据:{path}") return 0 receipt.update(discovery_verdict="DECISION_REQUIRED", final_verdict="DECISION_REQUIRED", decision=None) write_receipt(args.receipt, receipt) print(f"[triage] 远端接纳中的 initiative 候选: {len(snap['candidates'])}(以 origin 为准,本地 .task 不参与)") for cand in snap["candidates"]: print(describe(cand)) if cand["goal_excerpt"]: print(f"[goal] branch={cand['branch']} {cand['goal_excerpt']}") print(f"[verdict] DECISION_REQUIRED — 须人工决策:JOIN 其一,或 NEW 并说明不适配理由;收据:{path}") for cand in snap["candidates"]: print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=JOIN " f"INITIATIVE={cand['branch']} REASON='<加入理由>'") print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=NEW REASON='<不适配理由>'") return EXIT_DECISION_REQUIRED def cmd_decide(args): if args.verdict not in ("JOIN", "NEW"): raise Stop(EXIT_DECIDE, "verdict must be JOIN or NEW") receipt, _ = load_receipt(args.receipt, EXIT_DECIDE) if receipt["discovery_verdict"] != "DECISION_REQUIRED": raise Stop(EXIT_DECIDE, f"no decision needed: discovery verdict is {receipt['discovery_verdict']}") reason = args.reason.strip() if not reason: raise Stop(EXIT_DECIDE, "--reason is required") initiative = args.initiative.strip() if args.verdict == "JOIN" and not initiative: raise Stop(EXIT_DECIDE, "JOIN requires --initiative") if args.verdict == "NEW" and initiative: raise Stop(EXIT_DECIDE, "NEW must not select an initiative") current = snapshot(receipt["root_target"]) if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]): raise Stop(EXIT_DECIDE, "candidate set changed since discovery; re-run make task-triage") if args.verdict == "JOIN" and initiative not in {c["branch"] for c in receipt["candidates"]}: raise Stop(EXIT_DECIDE, f"{initiative} is not a discovered candidate") receipt["decision"] = {"verdict": args.verdict, "selected_initiative": initiative or None, "rationale": reason, "decided_at": utc_now()} receipt["final_verdict"] = args.verdict write_receipt(args.receipt, receipt) print(f"[decided] verdict={args.verdict} selected_initiative={initiative or '无'} " f"receipt={os.path.realpath(args.receipt)}") return 0 def cmd_verify(args): receipt, raw = load_receipt(args.receipt, EXIT_VERIFY) final = receipt["final_verdict"] decision = as_dict(receipt["decision"]) if final not in ("JOIN", "NEW") or decision.get("verdict") != final: raise Stop(EXIT_VERIFY, "receipt has no final JOIN/NEW decision; run make task-triage-decide") age = (dt.datetime.now(dt.timezone.utc) - parse_time(receipt["discovered_at"])).total_seconds() if age > args.max_age_seconds: raise Stop(EXIT_VERIFY, f"receipt is stale ({int(age)}s old, max {args.max_age_seconds}s); re-run make task-triage") if age < -300: raise Stop(EXIT_VERIFY, "receipt discovered_at is in the future") if receipt["root_target"] != target_branch(): raise Stop(EXIT_VERIFY, "receipt root target does not match TASK_TARGET_BRANCH") selected = decision.get("selected_initiative") if args.kind == "child": if final != "JOIN" or not args.initiative or args.initiative != selected: raise Stop(EXIT_VERIFY, f"child requires a JOIN receipt selecting {args.initiative or '(none)'}") elif final != "NEW": raise Stop(EXIT_VERIFY, f"{args.kind} requires a NEW receipt, got {final}") current = snapshot(receipt["root_target"]) if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]): raise Stop(EXIT_VERIFY, "remote initiative candidates changed since triage; re-run make task-triage") head = next((c["head_sha"] for c in receipt["candidates"] if c["branch"] == selected), None) print(f"[triage-verified] verdict={final} selected_initiative={selected or '无'} " f"requirement_sha256={receipt['requirement']['sha256']} receipt_sha256={sha256_bytes(raw)}") print(f"[triage-head] selected_head_sha={head or '无'}") return 0 def main(argv=None): parser = argparse.ArgumentParser(prog="task_triage.py", description="远端需求分流收据") sub = parser.add_subparsers(dest="cmd", required=True) p = sub.add_parser("discover") p.add_argument("--receipt", required=True) p.add_argument("--requirement", required=True) p = sub.add_parser("decide") p.add_argument("--receipt", required=True) p.add_argument("--verdict", required=True) p.add_argument("--initiative", default="") p.add_argument("--reason", default="") p = sub.add_parser("verify") p.add_argument("--receipt", required=True) p.add_argument("--kind", required=True, choices=("standalone", "initiative", "child")) p.add_argument("--initiative", default="") p.add_argument("--max-age-seconds", type=int, default=DEFAULT_MAX_AGE) args = parser.parse_args(argv) handlers = {"discover": cmd_discover, "decide": cmd_decide, "verify": cmd_verify} try: return handlers[args.cmd](args) except Stop as stop: if stop.code == EXIT_REMOTE: print(f"[verdict] REMOTE_UNVERIFIED — {stop.message}") else: print(f"[triage] refused ({stop.code}): {stop.message}", file=sys.stderr) return stop.code if __name__ == "__main__": sys.exit(main())