"""harbor: messages, threads, acknowledgements, claims, review items and stop flags for a team of AI agents. Project: --project , $HARBOR_PROJECT, or the nearest .harbor.toml above the working directory. Identity: --as , $HARBOR_AGENT, or the project's [identities] entry for the working directory. The human speaks from the chat page only, never from this command line. Start of every session: harbor brief Take work: harbor next (the best task you may do; claims its scope) While working: harbor doing "drafting 0x529350" (one line the whole team sees) Waiting for answers: harbor wait --timeout 600 (returns as soon as a message arrives) Before the session ends: harbor handoff "done X; Y in flight; next Z" Tasks: harbor task add "Name 0x445310" --scope fn:0x445310 --priority high --after 3 harbor tasks harbor task done 5 --note "..." harbor task block 5 --note "needs 0x4E2680" Messages: harbor send Claude "text" --thread fn:0x529350 --subject "AudioManagerTick" --kind question harbor reply 123 "text" harbor inbox harbor ack 123 --note "fixed in a2d7525" harbor thread fn:0x529350 harbor search "AudioManagerTick" harbor threads Claims: harbor claim fn:0x529350 --note "writing the body" harbor release --scope fn:0x529350 Review queue: harbor submit "AudioManagerTick readable" --scope fn:0x529350 --commit a2d7525 harbor review 7 approved --note "..." harbor land 7 --commit b4b44f0 harbor queue Overview: harbor status harbor digest --since 8h harbor attention Stop: harbor stop (a coordinator may stop anyone; only the human resumes, from the page) Long bodies: --file path (or --file - for stdin); attachments: --attach path (repeatable). """ import argparse import json import os import sys from pathlib import Path from . import projects from .core import CLAIM_KINDS, IMPORTANCE, ITEM_STATES, KINDS, OPEN_ITEM_STATES, TASK_STATES, Harbor, HarborError def fmt_message(m): tags = [f"thread {m['thread_id']} {m.get('topic', '')}".strip()] if m["kind"] != "note": tags.append(m["kind"]) if m["importance"] != "normal": tags.append(m["importance"]) if m.get("ack_required"): tags.append("ack") if m.get("reply_to"): tags.append(f"re #{m['reply_to']}") if m.get("corrects"): tags.append(f"corrects #{m['corrects']}") if m.get("due_at"): tags.append(f"due {m['due_at']}") to = ",".join(m["recipients"]) head = f"#{m['id']} {m['created_at'].replace('T', ' ')[:16]}Z {m['sender']} → {to} [{' · '.join(tags)}]" body = "\n".join(" " + line for line in m["body"].split("\n")) files = m.get("refs", {}).get("attachments") return head + "\n" + body + (f"\n attachments: {', '.join(files)}" if files else "") def fmt_task(task): who = task["assignee"] or "unassigned" extra = [] if task["scope"]: extra.append(task["scope"]) if task["waiting_on"]: extra.append("after " + ",".join(f"#{d}" for d in task["waiting_on"])) if task["due_at"]: extra.append(f"due {task['due_at']}") if task["item_id"]: extra.append(f"item #{task['item_id']}") if task["state"] == "blocked": extra.append(f"blocked: {task['block_reason']}") return (f"#{task['id']:>4} {task['state']:<11} {task['priority_name']:<6} {who:<9} {task['title']}" + (f" ({'; '.join(extra)})" if extra else "")) def body_from(args): if getattr(args, "file", None): return sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8") return " ".join(args.text) def refs_from(pairs): refs = {} for pair in pairs or []: if "=" not in pair: raise HarborError(f"--ref {pair!r}: use key=value, e.g. commit=b4b44f0 or fn=0x529350") key, value = pair.split("=", 1) refs[key.strip()] = value.strip() return refs def recipients(text): return None if not text else ("All" if text == "All" else [t.strip() for t in text.split(",") if t.strip()]) def build_parser(): parser = argparse.ArgumentParser(prog="harbor", description=__doc__.split("\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__) parser.add_argument("--as", dest="agent", default=None) parser.add_argument("--project", default=None, help="project name or path to its .toml") parser.add_argument("--json", action="store_true", help="machine-readable output") sub = parser.add_subparsers(dest="cmd", required=True) p = sub.add_parser("brief", help="what you need to know at the start of a session") p.add_argument("--mark-read", action="store_true") p = sub.add_parser("inbox", help="unread messages (urgent first); marks them read") p.add_argument("--all", action="store_true", help="recent messages, read or not") p.add_argument("--peek", action="store_true", help="do not mark read") p.add_argument("--limit", type=int, default=30) p = sub.add_parser("wait", help="block until a message for you arrives or a stop applies (then print it)") p.add_argument("--timeout", type=float, default=300, help="seconds (max 3600)") p.add_argument("--peek", action="store_true") for name in ("send", "reply"): p = sub.add_parser(name) if name == "send": p.add_argument("to", help="Agent, Agent,Agent or All") p.add_argument("--subject") p.add_argument("--thread", help="thread id or topic, e.g. fn:0x529350, commit:b4b44f0, general") else: p.add_argument("message_id", type=int) p.add_argument("--to", help="override recipients (Agent,Agent or All)") p.add_argument("text", nargs="*") p.add_argument("--file") p.add_argument("--attach", action="append", help="copy a file into Harbor and link it (repeatable)") p.add_argument("--kind", choices=KINDS, default="answer" if name == "reply" else "note") p.add_argument("--importance", choices=IMPORTANCE, default="normal") p.add_argument("--ack", action="store_true", help="ask the recipients to acknowledge") p.add_argument("--ref", action="append", help="key=value (commit=..., fn=0x..., file=...)") p.add_argument("--corrects", type=int, help="id of your earlier message this corrects") p.add_argument("--due", help="answer needed within 30m / 2h / 1d, or by an ISO time") p = sub.add_parser("ack", help="acknowledge messages addressed to you") p.add_argument("ids", type=int, nargs="+") p.add_argument("--note") p = sub.add_parser("doing", help="set the one line the team sees about what you are doing (no text clears it)") p.add_argument("text", nargs="*") p = sub.add_parser("thread", help="show a whole thread") p.add_argument("thread") for name in ("close", "reopen"): p = sub.add_parser(name, help=f"{name} a thread") p.add_argument("thread") p = sub.add_parser("threads", help="recent threads") p.add_argument("--limit", type=int, default=25) p.add_argument("--archive", action="store_true", help="include imported chat archives") p.add_argument("--open", action="store_true", help="hide closed threads") p = sub.add_parser("search", help="full-text search over every message") p.add_argument("query", nargs="+") p.add_argument("--limit", type=int, default=15) p = sub.add_parser("tail", help="the most recent messages") p.add_argument("count", type=int, nargs="?", default=20) p = sub.add_parser("claim", help="claim a function, range, name, task or file") p.add_argument("scope", help="fn:0xADDR, range:0xLO-0xHI, name:Symbol, task:text, file:path") p.add_argument("--kind", choices=sorted(CLAIM_KINDS), default="draft") p.add_argument("--ttl", type=float, help="hours (default by kind)") p.add_argument("--note") p.add_argument("--for", dest="for_agent", help="coordinator only: record the claim for another agent") p.add_argument("--override", action="store_true", help="coordinator only") p = sub.add_parser("release", help="release a claim") p.add_argument("claim_id", type=int, nargs="?") p.add_argument("--scope") p = sub.add_parser("claims", help="active claims") p.add_argument("--agent", dest="filter_agent") p.add_argument("--kind") p = sub.add_parser("submit", help="put a commit in the review queue") p.add_argument("title") p.add_argument("--scope") p.add_argument("--commit") p.add_argument("--branch") p.add_argument("--note") p.add_argument("--reviewer", action="append") p.add_argument("--task", type=int, help="the task this work completes (landing closes it)") p = sub.add_parser("review", help="move a review item") p.add_argument("item", type=int) p.add_argument("state", choices=[s for s in ITEM_STATES if s not in ("landed", "withdrawn")]) p.add_argument("--note") p.add_argument("--commit") p = sub.add_parser("withdraw", help="author withdraws an item") p.add_argument("item", type=int) p.add_argument("--note") p = sub.add_parser("land", help="coordinator: the items are on main (several items: one message per author)") p.add_argument("item", type=int, nargs="+") p.add_argument("--commit", required=True) p.add_argument("--note") p = sub.add_parser("item", help="one review item with its history") p.add_argument("item", type=int) p = sub.add_parser("queue", help="open review items") p.add_argument("--all", action="store_true") sub.add_parser("status", help="stop flags, presence, unread counts and the queue at a glance") p = sub.add_parser("tasks", help="the task list (open work by default)") p.add_argument("--all", action="store_true", help="include done and cancelled") p.add_argument("--mine", action="store_true") p.add_argument("--agent", dest="filter_agent") p = sub.add_parser("task", help="add, show or move a task") p.add_argument("action", choices=("add", "show", "take", "done", "block", "release", "cancel", "reopen", "bulk", "template")) p.add_argument("args", nargs="*", help="add: the title; template: name and scope; bulk: none (--file); " "others: the task id") p.add_argument("--template", help="bulk: run this template for every line") p.add_argument("--body") p.add_argument("--file") p.add_argument("--scope") p.add_argument("--for", dest="for_agent", help="assign to an agent (coordinators; or yourself)") p.add_argument("--priority", default="normal", choices=("urgent", "high", "normal", "low")) p.add_argument("--after", type=int, action="append", help="a task that must be done first (repeatable)") p.add_argument("--due", help="30m / 2h / 1d or an ISO time") p.add_argument("--note") p = sub.add_parser("next", help="take the best task you may do now (claims its scope)") p.add_argument("--peek", action="store_true", help="show it without taking it") p = sub.add_parser("handoff", help="record where you left off; shown at the start of your next session") p.add_argument("text", nargs="*") p.add_argument("--file") p = sub.add_parser("handoffs", help="recent handoffs") p.add_argument("--agent", dest="filter_agent") p.add_argument("--limit", type=int, default=5) for name in ("pin", "unpin"): p = sub.add_parser(name, help=f"{name} a message for the whole team") p.add_argument("message_id", type=int) if name == "pin": p.add_argument("--note", help="a one-line label") sub.add_parser("pins", help="pinned messages") sub.add_parser("decisions", help="recent messages of kind decision") p = sub.add_parser("prompt", help="a restart prompt for an agent (paste it into a fresh session)") p.add_argument("agent_name") sub.add_parser("doctor", help="check the project, the database, the server and every host integration") p = sub.add_parser("ask", help="ask the human (or an agent) a question with answer options") p.add_argument("question", nargs="+") p.add_argument("--options", help='answer buttons, separated by | e.g. "Yes|No|Later"') p.add_argument("--to", help="who answers (default: the human)") p.add_argument("--due", help="30m / 2h / an ISO time") p.add_argument("--fallback", help="a coordinator who may answer while the human is away") p.add_argument("--thread") p = sub.add_parser("answer", help="answer a question addressed to you") p.add_argument("question_id", type=int) p.add_argument("text", nargs="+") p = sub.add_parser("questions", help="open questions (to you and from you)") p.add_argument("--all", action="store_true", help="include answered ones") p = sub.add_parser("note", help="add to the team's memory about a scope (fn:0x..., file:..., name:...)") p.add_argument("scope") p.add_argument("text", nargs="+") p.add_argument("--replaces", type=int, help="the id of an older note this one corrects") p = sub.add_parser("notes", help="notes, all or for one scope") p.add_argument("scope", nargs="?") p = sub.add_parser("context", help="everything the team knows about a scope") p.add_argument("scope") p = sub.add_parser("metric", help="report a number the board charts (e.g. readable 779)") p.add_argument("name") p.add_argument("value") p.add_argument("--note") sub.add_parser("metrics", help="the latest value of every metric") p = sub.add_parser("pulse", help="who was active when (tool calls and messages per hour)") p.add_argument("--hours", type=int, default=12) p = sub.add_parser("wins", help="landed review items") p.add_argument("--since", default="24h") sub.add_parser("git", help="each agent's branch, last commit, uncommitted files and unsubmitted commits") p = sub.add_parser("summarize", help="pin a short summary to a thread (shown on top of it and in context)") p.add_argument("thread") p.add_argument("text", nargs="*") p.add_argument("--file") p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)") p.add_argument("scope") p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations") p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools", "debugger-tools", "match", "similar", "struct-audit", "behaviour"]) p.add_argument("target", nargs="?", default="", help="an address (0x4AD850) or a name; for ghidra, the tool") p.add_argument("arguments", nargs="?", default="{}", help="ghidra: the tool's arguments as JSON") p.add_argument("--decomp", help="match: the checkout or worktree to compile from") p.add_argument("--max-bytes", type=int, dest="max_bytes") p.add_argument("--count", type=int, default=16) p.add_argument("--as", dest="as_type", default="hex", choices=["hex", "int32", "uint32", "float", "double", "string"]) p = sub.add_parser("logbook", help="the project Logbook: add a log entry, set statistics, the chart or a milestone") p.add_argument("action", choices=["add", "stats", "map", "milestone", "show", "import", "refresh"]) p.add_argument("--title") p.add_argument("--kind", default="note") p.add_argument("--detail", help="the entry text (or --file)") p.add_argument("--file", help="a text file (add) or a JSON file (stats, map, milestone) or an export folder (import)") p.add_argument("--no-history", action="store_true", help="stats: do not add a trend point") p = sub.add_parser("next-functions", help="functions in your bands to decompile next, easiest first") p.add_argument("-n", type=int, default=10) p = sub.add_parser("defer", help="set a function aside with a reason and evidence") p.add_argument("address") p.add_argument("reason") p.add_argument("evidence") sub.add_parser("progress", help="functions and bytes per tier: exact, verified, readable, deferred, raw") p = sub.add_parser("feed", help="the latest tool calls of the team (or one agent), from the hooks") p.add_argument("--agent", dest="filter_agent") p.add_argument("--limit", type=int, default=30) sub.add_parser("templates", help="task templates defined in the project file") p = sub.add_parser("export", help="a thread as Markdown") p.add_argument("thread") p.add_argument("--out", help="write to this file instead of printing") p = sub.add_parser("digest", help="what happened in a window (default the last 8 hours)") p.add_argument("--since", default="8h", help="8h, 90m, 2d or an ISO time") sub.add_parser("attention", help="overdue acknowledgements, stale review items, quiet agents") for name in ("stop", "resume"): p = sub.add_parser(name, help=f"{name} everyone or one agent") p.add_argument("target", help="all or an agent name") p.add_argument("--relay", type=int, help="id of the human's chat-page message that asked for this") p.add_argument("--reason") p = sub.add_parser("pending-acks", help="messages still waiting for acknowledgement") p.add_argument("--overdue", type=int, default=0, help="minutes") p = sub.add_parser("import-legacy", help="import a plain `[time] From → To: text` chat history (idempotent)") p.add_argument("path") p = sub.add_parser("backup", help="snapshot the database into the project's backups folder") p.add_argument("--keep", type=int, default=14) sub.add_parser("whoami", help="which project and identity this directory resolves to") sub.add_parser("projects", help="registered projects") p = sub.add_parser("init", help="create a project") p.add_argument("name") p.add_argument("--title") p.add_argument("--agent", dest="roster", action="append", required=True, help="Name or Name:role (repeat for each agent; the human is added automatically)") p.add_argument("--human", default="User") p.add_argument("--coordinator", action="append", default=[]) p.add_argument("--database") p.add_argument("--mirror") p.add_argument("--marker", help="also write a .harbor.toml in this folder pointing at the project") p = sub.add_parser("hook", help="entry point for an agent host's hooks (see harbor.hooks)") p.add_argument("event") p.add_argument("--host", default="generic") p = sub.add_parser("install", help="connect an agent host (codex, opencode, deepseek, kimi, claude) to Harbor") p.add_argument("host", choices=("codex", "opencode", "deepseek", "kimi", "claude")) p.add_argument("--agent", dest="host_agent", help="the host's Harbor identity (default: Codex, OpenCode, DeepSeek, Kimi, Claude)") p.add_argument("--bind", action="store_true", help="bind the host to --project instead of the working folder") p.add_argument("--dry-run", action="store_true", help="show what would change") p.add_argument("--uninstall", action="store_true", help="remove Harbor's entries again") p.add_argument("--python", help='interpreter command for hosts to run, e.g. "py -3.12" (default: detected)') p.add_argument("--no-register", action="store_true", help="kimi: build and validate the plugin only") p = sub.add_parser("serve", help="run the chat page and board") p.add_argument("--port", type=int, default=8766) sub.add_parser("mcp", help="run the MCP server on stdio (identity from --as or $HARBOR_AGENT)") return parser def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") args = build_parser().parse_args(argv) cmd = args.cmd if cmd == "projects": home = projects.harbor_home() names = projects.list_names(home) marker = projects.find_marker() default = projects.default_name(home) rows = [{"name": n, "default": n == default} for n in names] text = f"Harbor home: {home}\n" + ("\n".join(f" {n}{' (default)' if n == default else ''}" for n in names) or " no registered projects") if marker: text += f"\nThis directory belongs to: {marker}" print(json.dumps(rows) if args.json else text) return if cmd == "init": home = projects.harbor_home() agents = [] for spec in args.roster: name, _, role = spec.partition(":") agents.append({"name": name.strip(), "role": role.strip()}) path = projects.write_project(home / "projects" / f"{args.name}.toml", args.name, args.title or args.name, agents, human=args.human, coordinators=args.coordinator, database=args.database, mirror=args.mirror) print(f"created {path}") if not projects.default_name(home): (home / "config.toml").write_text(f'default_project = "{args.name}"\n', encoding="utf-8") print(f"{args.name} is the default project") if args.marker: marker = Path(args.marker) / projects.MARKER if marker.exists(): raise HarborError(f"{marker} already exists") marker.write_text(f'project = "{args.name}"\n', encoding="utf-8") print(f"wrote {marker}") return if cmd == "install": from . import install bound = args.project if (args.bind or args.host == "kimi") else None try: report = install.install(args.host, agent=args.host_agent, project=bound, dry_run=args.dry_run, remove=args.uninstall, python=args.python, register=not args.no_register) except install.InstallError as error: raise HarborError(str(error)) print(("Dry run - nothing written.\n" if args.dry_run else "") + "\n".join(report)) return if cmd == "hook": # resolves the project from the host's payload, never raises from . import hooks sys.exit(hooks.run(args.event, args.project, args.agent, host=args.host)) project = projects.resolve(args.project) if cmd == "serve": from . import server server.main(["--port", str(args.port)] + (["--project", args.project] if args.project else [])) return if cmd == "mcp": if args.agent: os.environ["HARBOR_AGENT"] = args.agent from . import mcp_server mcp_server.main(project) return harbor = Harbor(project) source = "--as" if args.agent else None agent = args.agent or os.environ.get("HARBOR_AGENT") if agent and not source: source = "HARBOR_AGENT" if not agent: agent = project.identity_for(os.getcwd()) source = "working directory" if agent else None else: # a second session of the same app (Codex2 in the Codex app) copies the app's shared `--as Codex` # instructions; its working folder says who it is session = project.session_identity(agent, os.getcwd()) if session != agent: agent, source = session, f"working directory (same app as {agent})" recorded = [] def who(): if not agent: raise HarborError("say who you are: --as , set HARBOR_AGENT, or work inside a directory listed " "under [identities] in the project file") if agent == project.human: raise HarborError(f"{project.human} speaks from the chat page, not the command line") if agent not in project.ai_names: raise HarborError(f"unknown agent {agent!r}; this project has {', '.join(project.ai_names)}") if not recorded and cmd not in ("whoami", "prompt"): recorded.append(True) harbor.activity(agent, f"harbor {cmd}", os.environ.get("HARBOR_HOST", ""), channel="cli") return agent def out(value, text): print(json.dumps(value, ensure_ascii=False, indent=1, default=str) if args.json else text) if cmd == "whoami": out({"project": project.name, "source": str(project.source), "agent": agent, "identity_from": source}, f"project {project.name} ({project.title}) from {project.source}\n" f"agent {agent or '(none)'}" + (f" from {source}" if source else "")) elif cmd == "brief": text = harbor.brief(who(), mark_read=args.mark_read) out({"brief": text}, text) elif cmd == "inbox": rows = harbor.inbox(who(), unread_only=not args.all, limit=args.limit, mark_read=not args.peek) out(rows, "\n\n".join(fmt_message(m) for m in rows) or "No unread messages.") elif cmd == "wait": r = harbor.wait(who(), timeout=args.timeout, mark_read=not args.peek) if r["stopped"]: text = "STOP in force (" + ", ".join(r["stopped"]) + "): do not start or continue work." elif r["timed_out"]: text = "No new messages." else: text = "\n\n".join(fmt_message(m) for m in r["messages"]) out(r, text) elif cmd in ("send", "reply"): text = body_from(args) if cmd == "send": r = harbor.send(who(), recipients(args.to), text, subject=args.subject, thread=args.thread, kind=args.kind, importance=args.importance, ack_required=args.ack, refs=refs_from(args.ref), attachments=args.attach, corrects=args.corrects, due=args.due) else: r = harbor.send(who(), recipients(args.to), text, reply_to=args.message_id, kind=args.kind, importance=args.importance, ack_required=args.ack, refs=refs_from(args.ref), attachments=args.attach, corrects=args.corrects, due=args.due) lines = [f"sent #{r['id']} in thread {r['thread_id']} ({r['topic']}) to {', '.join(r['targets'])}"] lines += [f"warning: {w}" for w in r["warnings"]] out(r, "\n".join(lines)) elif cmd == "ack": out(harbor.ack(who(), args.ids, args.note), "acknowledged " + ", ".join(f"#{i}" for i in args.ids)) elif cmd == "doing": r = harbor.doing(who(), " ".join(args.text)) out(r, f"{r['agent']}: {r['doing'] or '(cleared)'}") elif cmd == "thread": thread, messages = harbor.thread(args.thread) out({"thread": thread, "messages": messages}, f"Thread {thread['id']} {thread['topic']}: {thread['subject']} ({thread['state']})\n\n" + "\n\n".join(fmt_message(m) for m in messages)) elif cmd in ("close", "reopen"): t = harbor.set_thread_state(who(), args.thread, "closed" if cmd == "close" else "open") out(t, f"thread {t['id']} {t['topic']} is {t['state']}") elif cmd == "threads": rows = harbor.threads(limit=args.limit, agent=agent if agent and agent != project.human else None, include_legacy=args.archive, include_closed=not args.open) out(rows, "\n".join(f"{r['id']:>5} {r['updated_at'][:16]}Z {r['messages']:>4} msgs" + (f" {r['unread']} unread" if r.get('unread') else "") + (f" [{r['state']}]" if r['state'] != 'open' else "") + f" {r['topic']}: {r['subject']}" for r in rows) or "No threads.") elif cmd == "search": rows = harbor.search(" ".join(args.query), limit=args.limit) out(rows, "\n\n".join(fmt_message(m) for m in rows) or "No matches.") elif cmd == "tail": rows = harbor.recent(args.count) out(rows, "\n\n".join(fmt_message(m) for m in rows)) elif cmd == "claim": r = harbor.claim(who(), args.scope, kind=args.kind, ttl_hours=args.ttl, note=args.note, override=args.override, on_behalf_of=args.for_agent) out(r, f"{'renewed' if r['renewed'] else 'claimed'} #{r['id']} {r['kind']} {r['scope']}" + (f" until {r['expires_at']}" if r["expires_at"] else " (standing)") + (f" overriding {r['conflicts']}" if r["conflicts"] else "")) elif cmd == "release": if args.claim_id is None and not args.scope: raise HarborError("give a claim id or --scope") out(harbor.release(who(), args.claim_id, args.scope), "released") elif cmd == "claims": rows = harbor.active_claims(agent=args.filter_agent, kind=args.kind) out(rows, "\n".join(f"#{c['id']:>4} {c['agent']:<9} {c['kind']:<7} {c['scope']:<32}" + (f" until {c['expires_at']}" if c['expires_at'] else " standing") + (f" {c['note']}" if c['note'] else "") for c in rows) or "No active claims.") elif cmd == "submit": r = harbor.submit(who(), args.title, scope=args.scope, commit=args.commit, branch=args.branch, note=args.note, reviewers=args.reviewer, task=args.task) out(r, f"review item #{r['id']} submitted (thread {r['thread_id']})") elif cmd == "review": r = harbor.transition(who(), args.item, args.state, note=args.note, commit=args.commit) out(r, f"item #{r['id']} is now {r['state']}") elif cmd == "withdraw": r = harbor.transition(who(), args.item, "withdrawn", note=args.note) out(r, f"item #{r['id']} withdrawn") elif cmd == "land": if len(args.item) == 1 and harbor.item(args.item[0])[0]["state"] == "approved": r = harbor.transition(who(), args.item[0], "landed", note=args.note, commit=args.commit) out(r, f"item #{r['id']} landed in {args.commit}") else: # approves items still under review on the way r = harbor.land_many(who(), args.item, args.commit, note=args.note) out(r, f"{len(r)} items landed in {args.commit}: " + ", ".join(f"#{i['id']}" for i in r)) elif cmd == "item": item, events = harbor.item(args.item) out({"item": item, "events": events}, f"#{item['id']} {item['state']} {item['scope'] or ''} {item['title']} (author {item['author']}, " f"commit {item['commit_hash'] or '-'})\n" + "\n".join( f" {e['at']} {e['agent']}: {e['from_state'] or '-'} -> {e['to_state']}" + (f" ({e['commit_hash']})" if e['commit_hash'] else "") + (f" {e['note']}" if e['note'] else "") for e in events)) elif cmd == "queue": rows = harbor.items(states=ITEM_STATES if args.all else OPEN_ITEM_STATES) out(rows, "\n".join(f"#{i['id']:>4} {i['state']:<17} {i['author']:<9} {i['scope'] or '':<18} {i['title']}" f" (since {i['created_at'][:16]}Z)" for i in rows) or "The review queue is empty.") elif cmd == "status": flags = harbor.flags() stopped = [a for a in project.ai_names if harbor.stopped(a)] trials = {a: harbor.trial(a) for a in project.ai_names if harbor.trial(a)} lines = [f"{project.title}: " + (f"stopped: {', '.join(stopped)}" if stopped else "nobody is stopped") + ("; trial runs: " + ", ".join(f"{a} until {u}" for a, u in trials.items()) if trials else "") + "."] agents = harbor.agents() for a in agents: n, u = harbor.unread_count(a["name"]) seen = harbor.minutes_since(a["last_seen"]) lines.append(f" {a['name']:<9} unread {n:>3}" + (f" ({u} urgent)" if u else " ") + f" seen {'never' if seen is None else str(seen) + ' min ago':<14}" + (f" doing: {a['doing']}" if a.get("doing") else "")) lines.append(f"Review queue: {len(harbor.items())} open. Active claims: {len(harbor.active_claims())}.") for a in harbor.attention(): lines.append(f"Attention: {a['text']}") out({"flags": flags, "agents": agents, "queue": len(harbor.items()), "attention": harbor.attention()}, "\n".join(lines)) elif cmd == "digest": d = harbor.digest(args.since) lines = [f"From {d['since']} to {d['until']}:"] for a in d["agents"]: if any(a[k] for k in ("messages", "submitted", "reviewed", "landed", "claims")): lines.append(f" {a['name']:<9} {a['messages']:>3} messages in {a['threads']} threads, " f"{a['submitted']} submitted, {a['reviewed']} reviewed, {a['landed']} landed, " f"{a['claims']} claims" + (f"; now: {a['doing']}" if a["doing"] else "")) for e in d["item_events"]: lines.append(f" item #{e['item_id']} {e['title']}: {e['from_state'] or '-'} -> {e['to_state']} by {e['agent']}") for f in d["flags"]: lines.append(f" {f['key']} {f['value']} by {f['set_by']} at {f['at']}" + (f": {f['reason']}" if f['reason'] else "")) if d["to_human"]: lines.append(f"Messages to {project.human}: " + ", ".join(f"#{m['id']} from {m['sender']}" for m in d["to_human"])) out(d, "\n".join(lines) if len(lines) > 1 else lines[0] + " nothing happened.") elif cmd == "attention": rows = harbor.attention() out(rows, "\n".join(a["text"] for a in rows) or "Nothing needs attention.") elif cmd in ("stop", "resume"): key = "stop:all" if args.target.lower() == "all" else f"stop:{args.target}" flags = harbor.set_flag(key, "on" if cmd == "stop" else "off", who(), "cli", reason=args.reason, relay_of=args.relay) out(flags, f"{key} is now {flags[key]['value']}") elif cmd == "pending-acks": rows = harbor.pending_acks(sender=args.agent, overdue_minutes=args.overdue) out(rows, "\n".join(f"#{r['id']} from {r['sender']} to {r['agent']} at {r['created_at']}" for r in rows) or "Nothing waiting for acknowledgement.") elif cmd == "import-legacy": if who() not in project.coordinators: raise HarborError("only a coordinator imports chat history") r = harbor.import_legacy(args.path) out(r, f"parsed {r['parsed']} messages, imported {r['added']} new") elif cmd == "tasks": states = TASK_STATES if args.all else ("open", "in_progress", "blocked", "review") rows = harbor.tasks(states=states, assignee=(who() if args.mine else args.filter_agent)) out(rows, "\n".join(fmt_task(x) for x in rows) or "No tasks.") elif cmd == "task": if args.action == "bulk": text = sys.stdin.read() if not args.file or args.file == "-" else Path(args.file).read_text(encoding="utf-8") r = harbor.bulk_tasks(who(), text, template=args.template, priority=args.priority, assignee=args.for_agent) out(r, "\n".join(["added " + fmt_task(x).strip() for x in r["created"]] + [f"skipped: {s}" for s in r["skipped"]]) or "nothing to add") elif args.action == "template": if not args.args: raise HarborError("task template [scope]") rows = harbor.run_template(who(), args.args[0], scope=args.args[1] if len(args.args) > 1 else None, title=args.body, owner=args.for_agent, priority=args.priority) out(rows, "\n".join("added " + fmt_task(x).strip() for x in rows)) elif args.action == "add": title = " ".join(args.args) body = Path(args.file).read_text(encoding="utf-8") if args.file else args.body r = harbor.add_task(who(), title, body=body, scope=args.scope, assignee=args.for_agent, priority=args.priority, after=args.after, due=args.due) out(r, "added " + fmt_task(r).strip()) else: if len(args.args) != 1 or not args.args[0].isdigit(): raise HarborError(f"task {args.action} needs one task id") tid = int(args.args[0]) if args.action == "show": task, events = harbor.task(tid) out({"task": task, "events": events}, fmt_task(task) + (f"\n{task['body']}" if task["body"] else "") + "\n" + "\n".join(f" {e['at']} {e['agent']}: {e['from_state'] or '-'} -> {e['to_state']}" + (f" ({e['note']})" if e['note'] else "") for e in events)) elif args.action == "take": r = harbor.take_task(who(), tid, args.note) out(r, "took " + fmt_task(r).strip()) else: state = {"done": "done", "block": "blocked", "release": "open", "cancel": "cancelled", "reopen": "open"}[args.action] r = harbor.update_task(who(), tid, state, note=args.note) out(r, fmt_task(r).strip()) elif cmd == "next": r = harbor.next_task(who(), take=not args.peek) if r and r["scope"]: r["context"] = harbor.context_text(r["scope"]) out(r, ("next: " if args.peek else "took ") + fmt_task(r).strip() + (f"\n\n{r['context']}" if r.get("context") else "") if r else "No task you can take right now (all assigned, waiting on other tasks, inside someone else's claim, " "or you are stopped).") elif cmd == "ask": q = harbor.ask(who(), " ".join(args.question), options=(args.options or "").split("|") if args.options else (), to=args.to, due=args.due, fallback=args.fallback, thread=args.thread) out(q, f"question #{q['id']} sent to {q['to_name']} (message #{q['message_id']}); `wait` returns when it is " "answered") elif cmd == "answer": q = harbor.answer(who(), args.question_id, " ".join(args.text)) out(q, f"answered #{q['id']}: {q['answer']}") elif cmd == "questions": me = who() rows = [q for q in harbor.questions(open_only=not args.all, limit=100) if me in (q["to_name"], q["asker"])] out(rows, "\n".join(f"#{q['id']} {q['asker']} -> {q['to_name']}: {q['question']}" + (f" [{' / '.join(q['options'])}]" if q["options"] else "") + (f" => {q['answer']} ({q['answered_by']})" if q["answer"] else "") for q in rows) or "No open questions.") elif cmd == "note": r = harbor.note(who(), args.scope, " ".join(args.text), replaces=args.replaces) out(r, f"note #{r['id']} on {r['scope']}") elif cmd == "notes": rows = harbor.notes(args.scope) out(rows, "\n".join(f"#{n['id']} {n['scope']} {n['author']} {n['created_at'][:10]}: {n['body']}" for n in rows) or "No notes.") elif cmd == "context": out(harbor.context(args.scope), harbor.context_text(args.scope, limit=15)) elif cmd == "metric": r = harbor.metric(who(), args.name, args.value, args.note) out(r, f"{r['name']} = {r['value']:g}") elif cmd == "metrics": rows = harbor.metrics() out(rows, "\n".join(f"{k:<20} {v['latest']['value']:g} ({v['latest']['by_agent']}, {v['latest']['at']})" + (f" {v['latest']['note']}" if v['latest']['note'] else "") for k, v in rows.items()) or "No metrics yet.") elif cmd == "pulse": data = harbor.pulse(hours=args.hours) hours = {} for agent, buckets in data["agents"].items(): for b in buckets: hours.setdefault(agent, {}).setdefault(b["bucket"][:13], 0) hours[agent][b["bucket"][:13]] += b["tools"] + b["messages"] marks = " .:-=+*#%@" stamps = sorted({h for row in hours.values() for h in row}) lines = [f"Activity per hour since {data['since']} (UTC hours {stamps[0][11:13] if stamps else '-'}" f"..{stamps[-1][11:13] if stamps else '-'}):"] for agent in harbor.agent_names(): row = hours.get(agent, {}) peak = max(row.values() or [1]) lines.append(f" {agent:<9} " + "".join(marks[min(9, int(9 * row.get(h, 0) / peak + 0.99))] if row.get(h) else " " for h in stamps)) out(data, "\n".join(lines)) elif cmd == "wins": rows = harbor.wins(args.since) out(rows, "\n".join(f"{w['at']} #{w['item_id']} {w['title']} by {w['author']}" for w in rows) or "No wins yet.") elif cmd == "git": radar = harbor.git_radar() lines = [] for agent, g in radar.items(): if not g: continue if g.get("error"): lines.append(f"{agent:<9} {g['workdir']}: {g['error']}") continue last = g["last_commit"] lines.append(f"{agent:<9} {g['branch']:<18} last {last['sha']} {harbor.minutes_since(last['at'])} min ago: " f"{last['subject'][:60]}") lines.append(f"{'':<9} {g['dirty']} uncommitted, {g['behind_main']} behind main, " f"{len(g['unsubmitted'])} commit(s) since the last submit") out(radar, "\n".join(lines) or "No agent has a workdir in the project file.") elif cmd == "summarize": text = Path(args.file).read_text(encoding="utf-8") if args.file else " ".join(args.text) r = harbor.summarize(who(), args.thread, text) out(r, f"summary #{r['id']} pinned to {r['thread']}") elif cmd == "re": from .reverse import Ghidra, Reverse r = None if args.action.startswith(("ghidra", "debugger", "match", "behaviour")) else Reverse(harbor.project.reverse) if args.action == "behaviour": from .reverse import behaviour_check res = behaviour_check(harbor.project.reverse, args.target, args.decomp) out(res, "\n".join(f"{r['address']} {r.get('verdict', ''):<8} {r.get('name', '')} {r.get('reason') or ''}" for r in res)) elif args.action in ("similar", "struct-audit"): from .reverse import run_toolkit res = (run_toolkit(harbor.project.reverse, "similar", [args.target]) if args.action == "similar" else run_toolkit(harbor.project.reverse, "struct_audit", ["--struct", args.target], json_to_file=True)) out(res, json.dumps(res, indent=1)) elif args.action == "match": from .reverse import match_functions res = match_functions(harbor.project.reverse, args.target, args.decomp) out(res, res["summary"] + "\n" + "\n".join( f"{x['address']} {x['status']:<9} {x.get('similarity', '')!s:<6} {x['file']} {x.get('note', '')}" for x in res["results"])) elif args.action == "debugger-tools": from .reverse import Debugger res = Debugger(harbor.project.reverse).tools() out(res, "\n".join(f"{t['name']:<22} {(t['description'] or '').splitlines()[0][:90] if t['description'] else ''}" for t in res)) elif args.action == "ghidra-tools": res = Ghidra(harbor.project.reverse).tools() out(res, "\n".join(f"{t['name']:<26} {t['description'].splitlines()[0][:90]}" for t in res)) elif args.action == "ghidra": text = Ghidra(harbor.project.reverse).call(args.target, json.loads(args.arguments)) out({"result": text}, text) elif args.action == "disasm": text = r.disassemble(args.target, args.max_bytes) out({"disassembly": text}, text) elif args.action == "xrefs": res = r.xrefs(args.target) out(res, json.dumps(res, indent=1)) elif args.action == "bytes": res = r.bytes_at(args.target, args.count, args.as_type) out(res, json.dumps(res, indent=1)) else: res = r.lookup(args.target) out(res, json.dumps(res, indent=1)) elif cmd == "logbook": from . import logbook project = harbor.project if args.action in ("add", "stats", "map", "milestone", "import", "refresh") and who() not in harbor.coordinators: raise HarborError("the Logbook is written by a coordinator") text = Path(args.file).read_text(encoding="utf-8") if args.file and args.action != "import" else None if args.action == "add": res = logbook.add_entry(project, args.title, args.detail or text, kind=args.kind) out(res, f"logged {res['id']}: {res['title']}") elif args.action == "stats": res = logbook.set_stats(project, json.loads(text), history_point=not args.no_history) out(res, "statistics updated") elif args.action == "map": res = logbook.set_map(project, json.loads(text)) out(res, f"chart updated ({res['cells']} cells)") elif args.action == "milestone": res = logbook.upsert_milestone(project, json.loads(text)) out(res, f"milestone {res['id']} saved") elif args.action == "refresh": from . import logbook_auto res = logbook_auto.refresh(project, force=True) out(res, json.dumps(res)) elif args.action == "import": res = logbook.import_export(project, args.file) out(res, json.dumps(res)) else: res = logbook.read_all(project, log_limit=10) out(res, "\n".join(f"{e['at'][:16]} [{e['kind']}] {e['title']}" for e in res["log"])) elif cmd in ("next-functions", "defer", "progress"): from .queue import FunctionQueue q = FunctionQueue(harbor) if cmd == "next-functions": rows = q.next_functions(who(), args.n, similar_readable=q.similar_readable()) out(rows, "\n".join(f"{r['address']:<10} {r['score']:<7} {(r['name'] or r['file'] or ''):<34} {r['why']}" for r in rows)) elif cmd == "defer": res = q.defer(who(), args.address, args.reason, args.evidence) out(res, f"deferred {args.address}: {args.reason}") else: res = q.progress() out(res, "\n".join(f"{t:<9} {v['functions']:>6} functions {v['bytes']:>9} bytes" for t, v in res["tiers"].items())) elif cmd == "who": rows = harbor.experts(args.scope) out(rows, "\n".join(f"{e['agent']:<9} " + ", ".join(f"{v} {k}" for k, v in e["detail"].items()) for e in rows) or "Nobody has worked on it yet.") elif cmd == "feed": rows = harbor.activity_feed(limit=args.limit, agent=args.filter_agent) out(rows, "\n".join(f"{r['at'][11:19]} {r['agent']:<9} {r['tool'] or '':<14} {r['target'] or ''}" for r in reversed(rows)) or "No tool activity recorded yet.") elif cmd == "templates": out(project.templates, "\n".join(f"{name}: " + " -> ".join(s.get("name", s["title"]) for s in spec["steps"]) for name, spec in project.templates.items()) or "No templates defined.") elif cmd == "export": text = harbor.export_thread(args.thread) if args.out: Path(args.out).write_text(text, encoding="utf-8") print(f"wrote {args.out}") else: print(text) elif cmd == "handoff": text = Path(args.file).read_text(encoding="utf-8") if args.file else " ".join(args.text) r = harbor.handoff(who(), text) out(r, f"handoff #{r['id']} saved; it opens your next brief") elif cmd == "handoffs": rows = harbor.handoffs(agent=args.filter_agent, limit=args.limit) out(rows, "\n\n".join(f"#{h['id']} {h['agent']} at {h['created_at']}\n" + "\n".join( " " + line for line in h["body"].split("\n")) for h in rows) or "No handoffs.") elif cmd in ("pin", "unpin"): rows = harbor.pin(who(), args.message_id, args.note) if cmd == "pin" else harbor.unpin(who(), args.message_id) out(rows, f"{len(rows)} pinned message(s)") elif cmd in ("pins", "decisions"): rows = harbor.pins() if cmd == "pins" else harbor.decisions() out(rows, "\n\n".join(fmt_message(m) for m in rows) or f"No {cmd}.") elif cmd == "prompt": text = harbor.restart_prompt(args.agent_name) out({"prompt": text}, text) elif cmd == "doctor": from . import doctor checks = doctor.run(project, harbor) out(checks, doctor.render(checks)) elif cmd == "backup": path = harbor.backup(keep=args.keep) out({"backup": str(path)}, f"backed up to {path}") def run(): try: main() except (HarborError, projects.ProjectError) as error: sys.exit(f"harbor: {error}") if __name__ == "__main__": run()