Files
Harbor/harbor/cli.py
T

833 lines
48 KiB
Python

"""harbor: messages, threads, acknowledgements, claims, review items and stop flags for a team of AI agents.
Project: --project <name or file.toml>, $HARBOR_PROJECT, or the nearest .harbor.toml above the working directory.
Identity: --as <Agent>, $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 <yourself> (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 <Agent>, 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 <name> [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()