| 1 | """Zion PM control CLI. Pure local state ops (no model calls) - safe to spam. |
| 2 | |
| 3 | Usage: |
| 4 | pm add "<task>" [--project P] [--type code|research|outreach|browser|ops|misc] |
| 5 | pm ls [--all] |
| 6 | pm show <id> |
| 7 | pm approvals |
| 8 | pm approve <id> | pm reject <id> [--reason "..."] |
| 9 | pm status |
| 10 | pm activity [N] |
| 11 | pm tick |
| 12 | pm start | pm stop |
| 13 | """ |
| 14 | import json, os, sys, time, subprocess, argparse, uuid |
| 15 | |
| 16 | PM = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 17 | ST = os.path.join(PM, "state") |
| 18 | INBOX = os.path.join(PM, "inbox") |
| 19 | |
| 20 | def _load(p, default): |
| 21 | try: |
| 22 | with open(p) as f: return json.load(f) |
| 23 | except Exception: return default |
| 24 | |
| 25 | def _save(p, obj): |
| 26 | tmp = p + ".tmp" |
| 27 | with open(tmp, "w") as f: json.dump(obj, f, indent=2) |
| 28 | os.replace(tmp, p) |
| 29 | |
| 30 | def _ts(): return time.strftime("%Y-%m-%dT%H:%M:%S") |
| 31 | |
| 32 | def _log(event, **kw): |
| 33 | line = {"ts": _ts(), "event": event, **kw} |
| 34 | with open(os.path.join(ST, "activity.jsonl"), "a") as f: |
| 35 | f.write(json.dumps(line) + "\n") |
| 36 | |
| 37 | def cmd_add(a): |
| 38 | t = _load(os.path.join(ST, "tasks.json"), {"tasks": []}) |
| 39 | tid = "t-" + uuid.uuid4().hex[:8] |
| 40 | task = {"id": tid, "title": a.task, "project": a.project, "type": a.type, |
| 41 | "status": "open", "created": _ts(), "artifacts": [], "worker": None} |
| 42 | t["tasks"].append(task) |
| 43 | _save(os.path.join(ST, "tasks.json"), t) |
| 44 | with open(os.path.join(INBOX, tid + ".task"), "w") as f: |
| 45 | f.write(a.task + "\n") |
| 46 | _log("task_added", id=tid, title=a.task) |
| 47 | print(f"added {tid}: {a.task}") |
| 48 | |
| 49 | def cmd_ls(a): |
| 50 | t = _load(os.path.join(ST, "tasks.json"), {"tasks": []}) |
| 51 | for x in t["tasks"]: |
| 52 | if a.all or x["status"] not in ("done", "cancelled"): |
| 53 | print(f"{x['id']} [{x['status']:>10}] {x.get('project') or '-':<14} {x['title']}") |
| 54 | |
| 55 | def cmd_show(a): |
| 56 | t = _load(os.path.join(ST, "tasks.json"), {"tasks": []}) |
| 57 | for x in t["tasks"]: |
| 58 | if x["id"] == a.id: print(json.dumps(x, indent=2)); return |
| 59 | print("not found", file=sys.stderr); sys.exit(1) |
| 60 | |
| 61 | def cmd_approvals(a): |
| 62 | o = _load(os.path.join(ST, "outbox.json"), {"pending": [], "history": []}) |
| 63 | if not o["pending"]: print("no pending approvals"); return |
| 64 | for p in o["pending"]: |
| 65 | print(f"--- {p['id']} ({p['kind']}) task={p.get('task')}") |
| 66 | print(f" {p.get('preview','')}") |
| 67 | |
| 68 | def _move_outbox(oid, decision, reason): |
| 69 | o = _load(os.path.join(ST, "outbox.json"), {"pending": [], "history": []}) |
| 70 | keep, hit = [], None |
| 71 | for p in o["pending"]: |
| 72 | (hit := p) if p["id"] == oid else keep.append(p) |
| 73 | if not hit: print("not found", file=sys.stderr); sys.exit(1) |
| 74 | hit["decision"] = decision; hit["decided_at"] = _ts(); hit["reason"] = reason |
| 75 | o["pending"] = keep; o["history"].append(hit) |
| 76 | _save(os.path.join(ST, "outbox.json"), o) |
| 77 | _log("outbox_" + decision, id=oid, kind=hit["kind"]) |
| 78 | return hit |
| 79 | |
| 80 | def cmd_approve(a): |
| 81 | hit = _move_outbox(a.id, "approved", a.reason) |
| 82 | print(f"approved {a.id}. run `pm flush` (or the conductor will) to execute.") |
| 83 | |
| 84 | def cmd_reject(a): |
| 85 | _move_outbox(a.id, "rejected", a.reason); print(f"rejected {a.id}") |
| 86 | |
| 87 | def cmd_flush(a): |
| 88 | subprocess.run(["bash", os.path.join(PM, "bin", "flush-outbox.sh")]) |
| 89 | |
| 90 | def cmd_status(a): |
| 91 | c = _load(os.path.join(ST, "conductor_state.json"), {}) |
| 92 | print(json.dumps(c, indent=2)) |
| 93 | wdir = os.path.join(PM, "workers") |
| 94 | running = [d for d in os.listdir(wdir) if os.path.isdir(os.path.join(wdir, d)) |
| 95 | and not os.path.exists(os.path.join(wdir, d, "result.json"))] if os.path.isdir(wdir) else [] |
| 96 | print("running workers:", running or "none") |
| 97 | |
| 98 | def cmd_activity(a): |
| 99 | p = os.path.join(ST, "activity.jsonl") |
| 100 | if not os.path.exists(p): print("(no activity)"); return |
| 101 | lines = open(p).read().splitlines()[-a.n:] |
| 102 | for l in lines: print(l) |
| 103 | |
| 104 | def cmd_tick(a): |
| 105 | subprocess.run(["bash", os.path.join(PM, "bin", "conductor.sh"), "--once"]) |
| 106 | |
| 107 | def cmd_start(a): |
| 108 | subprocess.run(["bash", os.path.join(PM, "bin", "conductor.sh"), "--daemon"]) |
| 109 | |
| 110 | def cmd_stop(a): |
| 111 | subprocess.run(["tmux", "kill-session", "-t", "zion-pm"]) |
| 112 | print("stopped zion-pm loop") |
| 113 | |
| 114 | def main(): |
| 115 | ap = argparse.ArgumentParser(prog="pm") |
| 116 | sub = ap.add_subparsers(dest="cmd", required=True) |
| 117 | s = sub.add_parser("add"); s.add_argument("task"); s.add_argument("--project", default=None) |
| 118 | s.add_argument("--type", default="misc"); s.set_defaults(fn=cmd_add) |
| 119 | s = sub.add_parser("ls"); s.add_argument("--all", action="store_true"); s.set_defaults(fn=cmd_ls) |
| 120 | s = sub.add_parser("show"); s.add_argument("id"); s.set_defaults(fn=cmd_show) |
| 121 | s = sub.add_parser("approvals"); s.set_defaults(fn=cmd_approvals) |
| 122 | s = sub.add_parser("approve"); s.add_argument("id"); s.add_argument("--reason", default=""); s.set_defaults(fn=cmd_approve) |
| 123 | s = sub.add_parser("reject"); s.add_argument("id"); s.add_argument("--reason", default=""); s.set_defaults(fn=cmd_reject) |
| 124 | s = sub.add_parser("flush"); s.set_defaults(fn=cmd_flush) |
| 125 | s = sub.add_parser("status"); s.set_defaults(fn=cmd_status) |
| 126 | s = sub.add_parser("activity"); s.add_argument("n", nargs="?", type=int, default=20); s.set_defaults(fn=cmd_activity) |
| 127 | s = sub.add_parser("tick"); s.set_defaults(fn=cmd_tick) |
| 128 | s = sub.add_parser("start"); s.set_defaults(fn=cmd_start) |
| 129 | s = sub.add_parser("stop"); s.set_defaults(fn=cmd_stop) |
| 130 | a = ap.parse_args(); a.fn(a) |
| 131 | |
| 132 | if __name__ == "__main__": |
| 133 | main() |