2026-07-29 17:57:07 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Wire the task manager into the project it sits in.
|
|
|
|
|
|
|
|
|
|
python3 .task-manager/install.py # apply (idempotent)
|
|
|
|
|
python3 .task-manager/install.py --dry-run # report only, change nothing
|
2026-07-31 07:55:57 +02:00
|
|
|
python3 .task-manager/install.py --setup # ask the settings questions again
|
2026-07-29 17:57:07 +02:00
|
|
|
|
|
|
|
|
Vendor-specific wiring belongs to the configured agent adapter: this script
|
|
|
|
|
resolves the adapter (BOARD_AGENT_ADAPTER in manager/local/.env, default
|
|
|
|
|
"claude"; local/adapters/ overrides core/adapters/) and runs its `wire`
|
|
|
|
|
executable against the project root. Safe to run any time — after dropping
|
|
|
|
|
.task-manager/ into a new repo, and after every update.sh.
|
2026-07-30 07:09:46 +02:00
|
|
|
|
|
|
|
|
The distribution repo tracks its own development on its own board, so a
|
|
|
|
|
fresh clone arrives carrying those cards. The very first run in a host
|
|
|
|
|
project — vendored, before manager/local/ has ever been populated —
|
|
|
|
|
clears the stage directories, tasks/archive/, plans/ and reference/
|
|
|
|
|
(keeping task-template.md and .gitkeep files, printing every removal) and
|
|
|
|
|
then stamps manager/local/state/ so the guard is false on every later run.
|
2026-07-31 07:55:57 +02:00
|
|
|
|
|
|
|
|
A project with no manager/local/.env then gets one written, because the
|
|
|
|
|
settings that change what bench *is* — claim-on-move and syncing through
|
|
|
|
|
origin/main — are otherwise invisible to anyone who has not read
|
|
|
|
|
manager/core/.env.example. Setup asks the few questions it cannot answer
|
|
|
|
|
for the project and writes that example file with the answers substituted
|
|
|
|
|
in, so the rest of the settings are discoverable by opening the result.
|
|
|
|
|
It runs after first_boot_clean (writing .env early would flip the
|
|
|
|
|
first-boot guard and leave the distribution's cards in a host project), it
|
|
|
|
|
never asks without a terminal on stdin — install.py sits on the path of
|
|
|
|
|
start.sh, update.sh and anything automated — and an existing .env is never
|
|
|
|
|
touched except by an explicit --setup.
|
2026-07-29 17:57:07 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
2026-07-30 07:09:46 +02:00
|
|
|
import shutil
|
2026-07-29 17:57:07 +02:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
TM = Path(__file__).resolve().parent
|
|
|
|
|
LOCAL = TM / "manager" / "local"
|
|
|
|
|
CORE = TM / "manager" / "core"
|
|
|
|
|
|
2026-07-30 07:09:46 +02:00
|
|
|
STAGE_DIRS = ["backlog", "to-do", "in-progress", "review", "done"]
|
|
|
|
|
KEEP = {".gitkeep", "task-template.md"}
|
|
|
|
|
|
2026-07-29 17:57:07 +02:00
|
|
|
|
2026-07-29 19:55:57 +02:00
|
|
|
def _project_root() -> Path:
|
|
|
|
|
"""The host project's root: the git toplevel seen from the manager's
|
|
|
|
|
directory (same resolution as config._repo_root). Vendored installs
|
|
|
|
|
drop .task-manager/.git at clone time, so this finds the host repo;
|
|
|
|
|
when the manager IS the repo (self-hosted), it finds that repo itself.
|
|
|
|
|
No git → fall back to the vendored-layout assumption, the parent."""
|
|
|
|
|
try:
|
|
|
|
|
out = subprocess.check_output(
|
|
|
|
|
["git", "-C", str(TM), "rev-parse", "--show-toplevel"],
|
|
|
|
|
text=True, stderr=subprocess.DEVNULL,
|
|
|
|
|
).strip()
|
|
|
|
|
if out:
|
|
|
|
|
return Path(out)
|
|
|
|
|
except (subprocess.CalledProcessError, OSError):
|
|
|
|
|
pass
|
|
|
|
|
return TM.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PROJECT = _project_root()
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 07:09:46 +02:00
|
|
|
def _content_dirs(tm: Path) -> list[Path]:
|
|
|
|
|
tasks = tm / "tasks"
|
|
|
|
|
return ([tasks / stage for stage in STAGE_DIRS]
|
|
|
|
|
+ [tasks / "archive", tm / "plans", tm / "reference"])
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 07:22:33 +02:00
|
|
|
def first_boot(tm: Path, project: Path) -> bool:
|
|
|
|
|
"""True only on a vendored install's very first run — the one moment
|
|
|
|
|
anything in the stage directories can only be the distribution's own.
|
|
|
|
|
False in every other situation:
|
2026-07-30 07:09:46 +02:00
|
|
|
|
|
|
|
|
- self-hosted (the manager IS the repo): tasks/ is that repo's own
|
|
|
|
|
history, never distribution residue — including a fresh dev clone;
|
|
|
|
|
- already wired (local/.env or local/state/ exists): anything in the
|
|
|
|
|
stage directories can only be the host project's own work."""
|
|
|
|
|
if project.resolve() == tm.resolve():
|
2026-07-30 07:22:33 +02:00
|
|
|
return False
|
2026-07-30 07:09:46 +02:00
|
|
|
local = tm / "manager" / "local"
|
2026-07-30 07:22:33 +02:00
|
|
|
return not (local / ".env").exists() and not (local / "state").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def first_boot_leftovers(tm: Path) -> list[Path]:
|
|
|
|
|
"""The distribution's shipped cards, plans and reference documents —
|
|
|
|
|
the paths a first boot must clear."""
|
2026-07-30 07:09:46 +02:00
|
|
|
return [child
|
|
|
|
|
for d in _content_dirs(tm) if d.is_dir()
|
|
|
|
|
for child in sorted(d.iterdir()) if child.name not in KEEP]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def first_boot_clean(dry_run: bool) -> None:
|
|
|
|
|
"""First boot only: remove the distribution's shipped content and stamp
|
|
|
|
|
local/state/ so this never runs again — even if the adapter wire fails
|
|
|
|
|
(a host without .claude/ still gets the board via start.sh) or the host
|
2026-07-30 07:22:33 +02:00
|
|
|
creates cards before the next run. Off first boot nothing is touched,
|
|
|
|
|
not even the stamp."""
|
|
|
|
|
if not first_boot(TM, PROJECT):
|
|
|
|
|
return
|
|
|
|
|
leftovers = first_boot_leftovers(TM)
|
2026-07-30 07:09:46 +02:00
|
|
|
if leftovers:
|
|
|
|
|
print("first boot — clearing the distribution's own cards:")
|
|
|
|
|
verb = "would remove" if dry_run else "removed"
|
|
|
|
|
for path in leftovers:
|
|
|
|
|
print(f" {verb} {path.relative_to(TM)}")
|
|
|
|
|
if not dry_run:
|
2026-07-30 07:22:33 +02:00
|
|
|
if path.is_dir() and not path.is_symlink():
|
|
|
|
|
shutil.rmtree(path)
|
|
|
|
|
else:
|
|
|
|
|
path.unlink()
|
2026-07-30 07:09:46 +02:00
|
|
|
print()
|
2026-07-30 07:22:33 +02:00
|
|
|
if not dry_run:
|
2026-07-30 07:09:46 +02:00
|
|
|
(LOCAL / "state").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 07:55:57 +02:00
|
|
|
# ── First-run settings ────────────────────────────────────────────────
|
|
|
|
|
#
|
|
|
|
|
# Everything not asked about is written at its documented default, so the
|
2026-07-31 16:46:36 +02:00
|
|
|
# answers are only the ones no default can be right about and nothing
|
|
|
|
|
# can be read off the project: how this project works (solo or team)
|
|
|
|
|
# and which agent runs its headless jobs. The test command used to be a
|
|
|
|
|
# third question; it is detected instead — see TEST_COMMANDS.
|
2026-07-31 07:55:57 +02:00
|
|
|
|
|
|
|
|
ENV_EXAMPLE = CORE / ".env.example"
|
|
|
|
|
ENV_FILE = LOCAL / ".env"
|
|
|
|
|
|
|
|
|
|
TEAM_NOTE = """\
|
|
|
|
|
Team mode: moves claim and commit themselves, and boards converge
|
|
|
|
|
through origin/main. It wants a shared origin, merge rights for whoever
|
|
|
|
|
merges, and a local main that only ever advances through the board.
|
|
|
|
|
Solo — today's default — does none of it."""
|
|
|
|
|
|
2026-07-31 16:46:36 +02:00
|
|
|
# What runs this project's tests, read off the project rather than asked.
|
|
|
|
|
# It was a question once, and it was the wrong one to put to someone
|
|
|
|
|
# thirty seconds into their first run: it wants an answer about a project
|
|
|
|
|
# they may have just cloned, before anything has explained why the board
|
|
|
|
|
# needs it. The file that names a project's ecosystem usually names its
|
|
|
|
|
# test runner too, so the first match wins and no match writes nothing.
|
|
|
|
|
#
|
|
|
|
|
# A wrong guess costs nothing it did not already cost: the prefix simply
|
|
|
|
|
# never matches, and the agent is denied exactly as it would be with the
|
|
|
|
|
# key empty. What it must never do is guess something *broader* than the
|
|
|
|
|
# truth — every entry here is one runner, not a shell.
|
|
|
|
|
TEST_COMMANDS = [
|
|
|
|
|
("package.json", "npm test"),
|
|
|
|
|
("Cargo.toml", "cargo test"),
|
|
|
|
|
("go.mod", "go test ./..."),
|
|
|
|
|
("pyproject.toml", "python3 -m unittest"),
|
|
|
|
|
("setup.py", "python3 -m unittest"),
|
|
|
|
|
("tests", "python3 -m unittest"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def detect_test_command(root: Path) -> str:
|
|
|
|
|
"""The project's test runner, or "" when nothing here names one."""
|
|
|
|
|
for marker, command in TEST_COMMANDS:
|
|
|
|
|
if (root / marker).exists():
|
|
|
|
|
return command
|
|
|
|
|
return ""
|
2026-07-31 07:55:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Skipped(Exception):
|
|
|
|
|
"""Ctrl-D: stop asking. Answers already given stand, the rest of the
|
|
|
|
|
file stays at its documented defaults."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rel(path: Path) -> str:
|
|
|
|
|
"""A path as a reader would type it, relative to the project root."""
|
|
|
|
|
try:
|
|
|
|
|
return str(path.relative_to(PROJECT))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return str(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def env_values(text: str) -> dict[str, str]:
|
|
|
|
|
"""KEY=VALUE lines, # comments, optional quotes — config._load_env's
|
|
|
|
|
parser, minus the process environment (this is about the file)."""
|
|
|
|
|
values: dict[str, str] = {}
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
|
|
|
continue
|
|
|
|
|
key, _, value = line.partition("=")
|
|
|
|
|
values[key.strip()] = value.strip().strip("'\"")
|
|
|
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def env_on(value: str) -> bool:
|
|
|
|
|
"""config.flag's rule: anything but empty/0/false/no/off is on."""
|
|
|
|
|
return value.strip().lower() not in ("", "0", "false", "no", "off")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def substitute(base: str, answers: dict[str, str]) -> str:
|
|
|
|
|
"""`base` with the answered keys rewritten in place — every comment and
|
|
|
|
|
every other key intact, which is the point: the written file is where
|
|
|
|
|
the project reads what else it can change. A key the base does not
|
|
|
|
|
mention is appended rather than lost."""
|
|
|
|
|
out, placed = [], set()
|
|
|
|
|
for line in base.splitlines():
|
|
|
|
|
stripped = line.strip()
|
|
|
|
|
key = ("" if stripped.startswith("#") or "=" not in stripped
|
|
|
|
|
else stripped.partition("=")[0].strip())
|
|
|
|
|
if key in answers:
|
|
|
|
|
out.append(f"{key}={answers[key]}")
|
|
|
|
|
placed.add(key)
|
|
|
|
|
else:
|
|
|
|
|
out.append(line)
|
|
|
|
|
for key in [k for k in answers if k not in placed]:
|
|
|
|
|
out.append(f"{key}={answers[key]}")
|
|
|
|
|
return "\n".join(out).rstrip("\n") + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def adapter_choices() -> list[str]:
|
|
|
|
|
"""The adapters actually present, core's plus this project's own —
|
|
|
|
|
enumerated, never hardcoded, so a local/adapters/ entry shows up."""
|
|
|
|
|
names: list[str] = []
|
|
|
|
|
for base in (CORE / "adapters", LOCAL / "adapters"):
|
|
|
|
|
if base.is_dir():
|
|
|
|
|
for child in sorted(base.iterdir()):
|
|
|
|
|
if (child / "run").is_file() and child.name not in names:
|
|
|
|
|
names.append(child.name)
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ask(question: str, default: str, note: str = "") -> str:
|
|
|
|
|
if note:
|
|
|
|
|
print(f"\n{note}")
|
|
|
|
|
try:
|
|
|
|
|
answer = input(f" {question} [{default}]: ").strip()
|
|
|
|
|
except EOFError:
|
|
|
|
|
print()
|
|
|
|
|
raise _Skipped from None
|
|
|
|
|
return answer or default
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 16:46:36 +02:00
|
|
|
def ask_questions(current: dict[str, str], answers: dict[str, str],
|
|
|
|
|
held: dict[str, str] | None = None) -> None:
|
2026-07-31 07:55:57 +02:00
|
|
|
"""Fill `answers` in place — in place because a Ctrl-D part-way through
|
2026-07-31 16:46:36 +02:00
|
|
|
keeps what was already answered.
|
|
|
|
|
|
|
|
|
|
`current` is the example's values under the existing file's, which is
|
|
|
|
|
what a question should offer as its default. `held` is only what this
|
|
|
|
|
project itself has said — empty on a first run — because "keep what is
|
|
|
|
|
already there" must not mean "keep the example's default".
|
|
|
|
|
"""
|
|
|
|
|
held = held or {}
|
|
|
|
|
# Detected, not asked — and settled before the first question, so a
|
|
|
|
|
# Ctrl-D part-way through still leaves the project's own runner rather
|
|
|
|
|
# than the example's Python one. A value this project has already set
|
|
|
|
|
# wins: --setup must not undo a hand-edit. The example's default is
|
|
|
|
|
# not such a value, which is why this reads `held` and not `current`.
|
|
|
|
|
answers["BOARD_AGENT_COMMANDS"] = (
|
|
|
|
|
held.get("BOARD_AGENT_COMMANDS") or detect_test_command(PROJECT))
|
|
|
|
|
|
2026-07-31 07:55:57 +02:00
|
|
|
team = env_on(current.get("BOARD_SYNC", "")) or env_on(
|
|
|
|
|
current.get("BOARD_COMMIT_MOVES", ""))
|
|
|
|
|
while True:
|
|
|
|
|
reply = _ask("solo or team?", "team" if team else "solo",
|
|
|
|
|
note=TEAM_NOTE).lower()
|
|
|
|
|
if reply in ("solo", "s", "team", "t"):
|
|
|
|
|
break
|
|
|
|
|
print(" answer solo or team.")
|
|
|
|
|
team = reply.startswith("t")
|
|
|
|
|
answers["BOARD_COMMIT_MOVES"] = "1" if team else ""
|
|
|
|
|
answers["BOARD_SYNC"] = "1" if team else ""
|
|
|
|
|
|
|
|
|
|
choices = adapter_choices()
|
|
|
|
|
default_adapter = current.get("BOARD_AGENT_ADAPTER") or "claude"
|
|
|
|
|
if choices:
|
|
|
|
|
allowed = choices + ([default_adapter]
|
|
|
|
|
if default_adapter not in choices else [])
|
|
|
|
|
while True:
|
|
|
|
|
reply = _ask("which agent adapter?", default_adapter,
|
|
|
|
|
note=" Which coding agent runs headless jobs — "
|
|
|
|
|
f"here: {', '.join(choices)}.")
|
|
|
|
|
if reply in allowed:
|
|
|
|
|
break
|
|
|
|
|
print(f" no such adapter here — one of: {', '.join(allowed)}.")
|
|
|
|
|
answers["BOARD_AGENT_ADAPTER"] = reply
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setup(dry_run: bool, forced: bool) -> None:
|
|
|
|
|
"""Write manager/local/.env when there is none — or rewrite it, from
|
|
|
|
|
its own current values, when asked to with --setup. Silent and
|
|
|
|
|
side-effect-free in every other case."""
|
|
|
|
|
exists = ENV_FILE.is_file()
|
|
|
|
|
if (exists and not forced) or not ENV_EXAMPLE.is_file():
|
|
|
|
|
return
|
|
|
|
|
verb = "rewrite" if exists else "write"
|
|
|
|
|
if dry_run:
|
2026-07-31 16:46:36 +02:00
|
|
|
found = detect_test_command(PROJECT)
|
|
|
|
|
print(f"would ask: solo or team, which agent adapter — and "
|
|
|
|
|
f"{verb} {_rel(ENV_FILE)} from {_rel(ENV_EXAMPLE)}, with "
|
|
|
|
|
f"BOARD_AGENT_COMMANDS="
|
|
|
|
|
f"{found or '(nothing detected)'}.\n"
|
|
|
|
|
f"Dry run — nothing written.\n")
|
2026-07-31 07:55:57 +02:00
|
|
|
return
|
|
|
|
|
if not sys.stdin.isatty():
|
|
|
|
|
if exists:
|
|
|
|
|
print(f"--setup asks questions and there is no terminal to ask "
|
|
|
|
|
f"on — {_rel(ENV_FILE)} left as it is.\n")
|
|
|
|
|
else:
|
|
|
|
|
print(f"no {_rel(ENV_FILE)} — the defaults in {_rel(ENV_EXAMPLE)} "
|
|
|
|
|
f"apply; `python3 {_rel(Path(__file__).resolve())} --setup` "
|
|
|
|
|
f"asks the questions that write one.\n")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
example = ENV_EXAMPLE.read_text(encoding="utf-8")
|
|
|
|
|
base = ENV_FILE.read_text(encoding="utf-8") if exists else example
|
|
|
|
|
current = env_values(example)
|
|
|
|
|
current.update(env_values(base))
|
|
|
|
|
|
|
|
|
|
if exists:
|
|
|
|
|
print(f"Rewriting {_rel(ENV_FILE)} — its current values are the "
|
|
|
|
|
f"defaults below.")
|
|
|
|
|
else:
|
|
|
|
|
print(f"No {_rel(ENV_FILE)} yet — a few questions and bench writes "
|
|
|
|
|
f"one.")
|
|
|
|
|
print("Enter takes the default in [brackets]; Ctrl-D skips the rest.")
|
|
|
|
|
|
|
|
|
|
answers: dict[str, str] = {}
|
|
|
|
|
try:
|
2026-07-31 16:46:36 +02:00
|
|
|
ask_questions(current, answers,
|
|
|
|
|
held=env_values(base) if exists else {})
|
2026-07-31 07:55:57 +02:00
|
|
|
except _Skipped:
|
|
|
|
|
print(" skipped — the rest stay at their documented defaults.")
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
print(f"\n\nCancelled — {_rel(ENV_FILE)} not written.\n")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
ENV_FILE.write_text(substitute(base, answers), encoding="utf-8")
|
|
|
|
|
print(f"\nWrote {_rel(ENV_FILE)} — every other setting is in there, "
|
|
|
|
|
f"commented; edit it any time.\n")
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 17:57:07 +02:00
|
|
|
def adapter_name() -> str:
|
|
|
|
|
if os.environ.get("BOARD_AGENT_ADAPTER"):
|
|
|
|
|
return os.environ["BOARD_AGENT_ADAPTER"]
|
|
|
|
|
env_file = LOCAL / ".env"
|
|
|
|
|
if env_file.is_file():
|
|
|
|
|
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
key, _, value = line.strip().partition("=")
|
|
|
|
|
if key.strip() == "BOARD_AGENT_ADAPTER" and value.strip():
|
|
|
|
|
return value.strip().strip("'\"")
|
|
|
|
|
return "claude"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
2026-07-31 07:55:57 +02:00
|
|
|
args = sys.argv[1:]
|
|
|
|
|
# Order is load-bearing: setup writes local/.env, which is one of the
|
|
|
|
|
# two things first_boot() reads as "this project has been here before".
|
|
|
|
|
first_boot_clean(dry_run="--dry-run" in args)
|
|
|
|
|
setup(dry_run="--dry-run" in args, forced="--setup" in args)
|
2026-07-29 17:57:07 +02:00
|
|
|
name = adapter_name()
|
2026-07-31 07:55:57 +02:00
|
|
|
passthrough = [a for a in args if a != "--setup"]
|
|
|
|
|
sys.stdout.flush() # the wire's output is a child's: keep the order
|
2026-07-29 17:57:07 +02:00
|
|
|
for base in (LOCAL / "adapters", CORE / "adapters"):
|
|
|
|
|
wire = base / name / "wire"
|
|
|
|
|
if wire.is_file():
|
|
|
|
|
return subprocess.call(
|
2026-07-31 07:55:57 +02:00
|
|
|
[sys.executable, str(wire), str(PROJECT), *passthrough])
|
2026-07-29 17:57:07 +02:00
|
|
|
print(f"agent adapter '{name}' has no wire script — looked in "
|
|
|
|
|
f"{LOCAL / 'adapters' / name} and {CORE / 'adapters' / name}.")
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|