diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index a0e9d656b8a..38202e461ed 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -230,6 +230,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Optional hex color (e.g. '#8b5cf6') for the dashboard") b_create.add_argument("--switch", action="store_true", help="Switch to the new board after creating it") + b_create.add_argument("--default-workdir", default=None, + help="Default workspace path for tasks created on this board") b_rm = boards_sub.add_parser( "rm", aliases=["remove", "delete"], @@ -258,6 +260,14 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu b_rename.add_argument("slug") b_rename.add_argument("name", help="New display name") + b_set_wd = boards_sub.add_parser( + "set-default-workdir", + help="Set the default workspace path for tasks on a board", + ) + b_set_wd.add_argument("slug") + b_set_wd.add_argument("path", nargs="?", default=None, + help="Absolute path to use as default workdir. Omit to clear.") + # --- create --- p_create = sub.add_parser("create", help="Create a new task") p_create.add_argument("title", help="Task title") @@ -846,6 +856,8 @@ def _dispatch_boards(args: argparse.Namespace) -> int: return _cmd_boards_show(args) if sub == "rename": return _cmd_boards_rename(args) + if sub == "set-default-workdir": + return _cmd_boards_set_default_workdir(args) print(f"kanban boards: unknown action {sub!r}", file=sys.stderr) return 2 @@ -916,6 +928,7 @@ def _cmd_boards_create(args: argparse.Namespace) -> int: description=args.description, icon=args.icon, color=args.color, + default_workdir=args.default_workdir, ) verb = "already exists" if already else "created" print(f"Board {meta['slug']!r} {verb}.") @@ -996,6 +1009,25 @@ def _cmd_boards_rename(args: argparse.Namespace) -> int: return 0 +def _cmd_boards_set_default_workdir(args: argparse.Namespace) -> int: + try: + normed = kb._normalize_board_slug(args.slug) + except ValueError as exc: + print(f"kanban boards set-default-workdir: {exc}", file=sys.stderr) + return 2 + if not normed or not kb.board_exists(normed): + print(f"kanban boards set-default-workdir: board {args.slug!r} does not exist", + file=sys.stderr) + return 1 + meta = kb.write_board_metadata(normed, default_workdir=args.path) + new_val = meta.get("default_workdir") + if new_val: + print(f"Board {normed!r} default workdir set to {new_val!r}.") + else: + print(f"Board {normed!r} default workdir cleared.") + return 0 + + # --------------------------------------------------------------------------- diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index b8afac6c16e..ce3d5df754f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -404,6 +404,7 @@ def read_board_metadata(board: Optional[str] = None) -> dict: "description": "", "icon": "", "color": "", + "default_workdir": None, "created_at": None, "archived": False, } @@ -430,6 +431,7 @@ def write_board_metadata( icon: Optional[str] = None, color: Optional[str] = None, archived: Optional[bool] = None, + default_workdir: Optional[str] = None, ) -> dict: """Create / update ``board.json`` for ``board``. @@ -451,6 +453,8 @@ def write_board_metadata( meta["color"] = str(color) if archived is not None: meta["archived"] = bool(archived) + if default_workdir is not None: + meta["default_workdir"] = str(default_workdir) if default_workdir else None if not meta.get("created_at"): meta["created_at"] = int(time.time()) path = board_metadata_path(slug) @@ -470,6 +474,7 @@ def create_board( description: Optional[str] = None, icon: Optional[str] = None, color: Optional[str] = None, + default_workdir: Optional[str] = None, ) -> dict: """Create a new board directory + DB + metadata. Idempotent. @@ -486,6 +491,7 @@ def create_board( description=description, icon=icon, color=color, + default_workdir=default_workdir, ) # Touch the DB so list_boards() sees it immediately. init_db(board=normed) @@ -1294,6 +1300,7 @@ def create_task( max_runtime_seconds: Optional[int] = None, skills: Optional[Iterable[str]] = None, max_retries: Optional[int] = None, + board: Optional[str] = None, ) -> str: """Create a new task and optionally link it under parent tasks. @@ -1391,6 +1398,15 @@ def create_task( now = int(time.time()) + # Resolve workspace_path from board-level default_workdir when the + # caller did not specify one explicitly. + if workspace_path is None: + board_slug = board if board else get_current_board() + board_meta = read_board_metadata(board_slug) + board_default = board_meta.get("default_workdir") + if board_default: + workspace_path = str(board_default) + # Retry once on the extremely unlikely id collision. for attempt in range(2): task_id = _new_task_id() diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 0332b052b47..bd7797ce45a 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1744,3 +1744,44 @@ def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch): conn.close() age = kb.task_age(task) assert age["created_age_seconds"] is None + + +# --------------------------------------------------------------------------- +# Board-level default_workdir +# --------------------------------------------------------------------------- + + +def test_create_task_without_workspace_inherits_board_default_workdir(kanban_home, monkeypatch): + """Board with default_workdir → create_task without workspace_path → inherits default.""" + default_wd = "/home/user/project" + kb.create_board("work-proj", default_workdir=default_wd) + + with kb.connect(board="work-proj") as conn: + tid = kb.create_task(conn, title="inherited", board="work-proj") + t = kb.get_task(conn, tid) + assert t is not None + assert t.workspace_path == default_wd + + +def test_create_task_without_workspace_no_default_stays_none(kanban_home): + """Board without default_workdir → create_task without workspace_path → stays None.""" + kb.create_board("empty-board") + + with kb.connect(board="empty-board") as conn: + tid = kb.create_task(conn, title="none", board="empty-board") + t = kb.get_task(conn, tid) + assert t is not None + assert t.workspace_path is None + + +def test_create_task_with_explicit_workspace_ignores_board_default(kanban_home): + """create_task with explicit workspace_path → ignores board default.""" + kb.create_board("custom-ws-board", default_workdir="/board/default") + + explicit = "/my/explicit/path" + with kb.connect(board="custom-ws-board") as conn: + tid = kb.create_task(conn, title="explicit", workspace_path=explicit, board="custom-ws-board") + t = kb.get_task(conn, tid) + assert t is not None + assert t.workspace_path == explicit + assert t.workspace_path != "/board/default"