Starting a phase cuts phase/<stem> from the newest main it can see and works the list into it: each member branched from the phase's tip, run headless, merged back when its checks are green, the next one started. At the end one PR into main, for a human. The human gate moves from every card to the phase boundary, and the promise survives: the board merges into a branch it created, inside a scope you opened. The runner is a beat, not an agent — everything it decides is already structured state, and an agent paid to poll would be the wrong tool at the wrong price. It holds no registry of where a phase is. Two durable things carry the memory, and the board already writes both: git, where a member is finished when its branch is contained in the phase branch, and the card, which grows a ## Phase log the runner adds one line to per decision. The log is what tells "this member has run and it ended badly" from "the phase has not reached it yet" — without it a restarted board would relaunch a run that died. Containment alone is not enough to call a member merged: a clean exit that committed nothing leaves an empty branch that is contained. The card has to have settled into review/ too, or a broken launch would hide exactly where it always tries to. Five conditions halt, each already a visible state on the card, and a halt is written once and then held. Running the phase again is the person's decision and is what clears it — the run is scoped to its own log line, so a member whose run died is launchable again. A dependency that has not landed is a wait, not a halt. Merges are additive throughout: main into the phase branch on every beat so a long run does not drift into one enormous conflict, members into it as they go green, nothing rebased and nothing force-pushed. A conflict aborts, leaves the branch as it was, and halts naming the files that collided. The actor rule decides who runs it, written where it already lives: the phase card's assignee. A replica renders the phase and advances nothing. Reachable through /api/phase/run and the ticker; the header chip and the card actions are a separate card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 lines
3.2 KiB
Python
Executable File
90 lines
3.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Live kanban board for ../tasks/ — board, session timeline, heads-up display.
|
|
|
|
python3 .task-manager/manager/board.py # serve on :26071, open a browser
|
|
python3 .task-manager/manager/board.py --port 9000 --no-open
|
|
|
|
The manager sits cleanly on top of the tasks/ directory: it reads and moves
|
|
task files, but the tasks work as a plain folder kanban without it. See
|
|
../AGENTS.md for the workflow and the module map:
|
|
|
|
config.py paths, stages, launch configuration
|
|
state.py shared registries, event persistence, SSE fan-out
|
|
taskfiles.py reading/moving task files (the only code touching tasks/)
|
|
events.py hook payloads → displayable events, session registry
|
|
sync.py origin/main as the shared board: push on move, pull on a beat
|
|
agents.py headless work/review agents: launch, reap, stop, diff
|
|
phases.py a phase run: its own branch, its members merged into it
|
|
watch.py 2s disk poller narrating moves made outside the API
|
|
httpd.py HTTP routes, SSE stream, the page itself
|
|
.prompts/ agent prompt templates (read fresh on every launch)
|
|
|
|
Stdlib only, no install.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import errno
|
|
import threading
|
|
import webbrowser
|
|
from http.server import ThreadingHTTPServer
|
|
|
|
import config
|
|
import drive
|
|
import events
|
|
import github
|
|
import httpd
|
|
import phases
|
|
import state
|
|
import sync
|
|
import watch
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--port", type=int, default=config.PORT)
|
|
parser.add_argument("--no-open", action="store_true", help="don't open a browser")
|
|
args = parser.parse_args()
|
|
state.serve_port = args.port
|
|
|
|
url = f"http://127.0.0.1:{args.port}/"
|
|
try:
|
|
server = ThreadingHTTPServer(("127.0.0.1", args.port), httpd.Handler)
|
|
except OSError as exc:
|
|
if exc.errno != errno.EADDRINUSE:
|
|
raise
|
|
# The port is pinned, so this is nearly always the board already running.
|
|
print(f"Port {args.port} is already in use — assuming the board is up at {url}")
|
|
if not args.no_open:
|
|
webbrowser.open(url)
|
|
return
|
|
|
|
# parents=True: local/state/ is gitignored and ships empty, so a fresh
|
|
# checkout has neither it nor its children — boot must create the chain.
|
|
config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
config.AGENT_DIR.mkdir(parents=True, exist_ok=True)
|
|
events.load_disk_sessions()
|
|
threading.Thread(target=watch.watcher, daemon=True).start()
|
|
threading.Thread(target=github.poller, daemon=True).start()
|
|
threading.Thread(target=github.reconcile, daemon=True).start()
|
|
threading.Thread(target=phases.beat, daemon=True).start()
|
|
if config.SYNC:
|
|
# Team mode: board commits publish themselves and a beat pulls what
|
|
# the other boards published. Off, neither thread nor hook exists.
|
|
sync.install()
|
|
threading.Thread(target=sync.beat, daemon=True).start()
|
|
drive.adopt()
|
|
|
|
print(f"Task board for {config.TASKS}\n {url}\n Ctrl-C to stop")
|
|
if not args.no_open:
|
|
webbrowser.open(url)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nstopped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|