kingdom-cli 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/PKG-INFO +20 -5
  2. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/README.md +19 -4
  3. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/pyproject.toml +1 -1
  4. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/agent.py +10 -2
  5. kingdom_cli-0.3.1/src/kingdom/cli/__init__.py +525 -0
  6. kingdom_cli-0.3.1/src/kingdom/cli/config.py +138 -0
  7. kingdom_cli-0.3.1/src/kingdom/cli/council.py +1220 -0
  8. kingdom_cli-0.3.1/src/kingdom/cli/design.py +95 -0
  9. kingdom_cli-0.3.1/src/kingdom/cli/display.py +42 -0
  10. kingdom_cli-0.3.1/src/kingdom/cli/helpers.py +153 -0
  11. kingdom_cli-0.3.1/src/kingdom/cli/peasant.py +1420 -0
  12. kingdom_cli-0.3.1/src/kingdom/cli/ticket.py +1233 -0
  13. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/config.py +3 -10
  14. kingdom_cli-0.3.1/src/kingdom/council/__init__.py +48 -0
  15. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/council/council.py +8 -2
  16. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/council/worker.py +2 -11
  17. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/design.py +1 -1
  18. kingdom_cli-0.3.1/src/kingdom/harness.py +1144 -0
  19. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/parsing.py +30 -1
  20. kingdom_cli-0.3.1/src/kingdom/skill/SKILL.md +264 -0
  21. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/skill/references/council.md +1 -3
  22. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/skill/references/peasants.md +7 -4
  23. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/skill/references/tickets.md +9 -7
  24. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/state.py +47 -105
  25. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/thread.py +25 -20
  26. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/ticket.py +268 -79
  27. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/app.py +15 -12
  28. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/widgets.py +2 -2
  29. kingdom_cli-0.3.1/src/kingdom/worker.py +54 -0
  30. kingdom_cli-0.3.1/src/kingdom/worktree.py +133 -0
  31. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom_cli.egg-info/PKG-INFO +20 -5
  32. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom_cli.egg-info/SOURCES.txt +19 -4
  33. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_cli.py +177 -218
  34. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_cli_council.py +109 -108
  35. kingdom_cli-0.3.1/tests/test_cli_design_breakdown.py +78 -0
  36. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_cli_peasant.py +746 -160
  37. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_cli_status.py +19 -23
  38. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_cli_ticket_current.py +10 -10
  39. kingdom_cli-0.3.1/tests/test_cli_ticket_deps.py +382 -0
  40. kingdom_cli-0.3.1/tests/test_cli_ticket_lifecycle.py +903 -0
  41. kingdom_cli-0.3.1/tests/test_cli_ticket_links.py +83 -0
  42. kingdom_cli-0.3.1/tests/test_cli_ticket_list.py +953 -0
  43. kingdom_cli-0.3.1/tests/test_cli_ticket_show.py +340 -0
  44. kingdom_cli-0.3.1/tests/test_cli_ticket_worklog.py +102 -0
  45. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_config.py +7 -13
  46. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_council.py +66 -7
  47. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_done.py +15 -38
  48. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_harness.py +720 -178
  49. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_init.py +35 -195
  50. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_parsing.py +66 -1
  51. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_session.py +0 -7
  52. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_skill.py +51 -1
  53. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_state.py +38 -6
  54. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_thread.py +24 -12
  55. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_ticket.py +412 -19
  56. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui.py +53 -43
  57. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_integration.py +0 -8
  58. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_reply.py +0 -10
  59. kingdom_cli-0.3.1/tests/test_worker.py +106 -0
  60. kingdom_cli-0.3.1/tests/test_worktree.py +25 -0
  61. kingdom_cli-0.2.1/src/kingdom/cli.py +0 -4960
  62. kingdom_cli-0.2.1/src/kingdom/council/__init__.py +0 -13
  63. kingdom_cli-0.2.1/src/kingdom/harness.py +0 -729
  64. kingdom_cli-0.2.1/src/kingdom/skill/SKILL.md +0 -160
  65. kingdom_cli-0.2.1/tests/test_cli_design_breakdown.py +0 -83
  66. kingdom_cli-0.2.1/tests/test_cli_ticket.py +0 -3087
  67. kingdom_cli-0.2.1/tests/test_cli_whoami.py +0 -46
  68. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/LICENSE +0 -0
  69. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/setup.cfg +0 -0
  70. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/__init__.py +0 -0
  71. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/__main__.py +0 -0
  72. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/breakdown.py +0 -0
  73. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/council/base.py +0 -0
  74. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/council/bundle.py +0 -0
  75. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/session.py +0 -0
  76. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/skill/__init__.py +0 -0
  77. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/skill/references/__init__.py +0 -0
  78. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/synthesis.py +0 -0
  79. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/__init__.py +0 -0
  80. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/chat.tcss +0 -0
  81. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/clipboard.py +0 -0
  82. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/poll.py +0 -0
  83. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom/tui/terminal.py +0 -0
  84. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
  85. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
  86. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom_cli.egg-info/requires.txt +0 -0
  87. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/src/kingdom_cli.egg-info/top_level.txt +0 -0
  88. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_agent.py +0 -0
  89. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_breakdown.py +0 -0
  90. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_design.py +0 -0
  91. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_synthesis.py +0 -0
  92. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_clipboard.py +0 -0
  93. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_poll.py +0 -0
  94. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_terminal.py +0 -0
  95. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_thinking_persistence.py +0 -0
  96. {kingdom_cli-0.2.1 → kingdom_cli-0.3.1}/tests/test_tui_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kingdom-cli
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: A markdown file-based CLI for software development with multi-agent council design, markdown tickets, and background worker loops.
5
5
  Author: James Robinson-Bohnslav
6
6
  License-Expression: Apache-2.0
@@ -58,10 +58,12 @@ Design with the council, break into tickets, dispatch peasants, review, and merg
58
58
 
59
59
  ```bash
60
60
  kd start # initialize branch session
61
- kd chat --new # discuss design with the council TUI
61
+ kd council chat --new # discuss design with the council TUI
62
62
  kd design approve # lock in the design
63
- kd breakdown # generate tickets from the design
63
+ # create tickets from the design (via the kingdom skill or manually)
64
64
  kd peasant start <id> # dispatch parallel workers
65
+ kd peasant review <id> # review completed work
66
+ kd peasant accept <id> # accept and close, or reject with feedback
65
67
  kd done # archive and clean up
66
68
  ```
67
69
 
@@ -95,7 +97,6 @@ Design docs, council sessions, and peasant workers are all optional. A branch wi
95
97
  ## Getting Started
96
98
 
97
99
  ```bash
98
- kd init # one-time: create .kd/ directory
99
100
  kd start # start a session on the current branch
100
101
  ```
101
102
 
@@ -103,7 +104,7 @@ Configure council agent CLIs in `.kd/config.json` (check effective config with `
103
104
 
104
105
  ## Chat Modes
105
106
 
106
- The council chat TUI (`kd chat`) supports four modes, configured via `council.chat.mode`:
107
+ The council chat TUI (`kd council chat`) supports four modes, configured via `council.chat.mode`:
107
108
 
108
109
  | Mode | First turn | Auto-turns | Default |
109
110
  |------|-----------|------------|---------|
@@ -136,6 +137,20 @@ All state lives in `.kd/` as plain Markdown and JSON files, tracked in git along
136
137
 
137
138
  No database. No server. Just files on disk.
138
139
 
140
+ ## Commands
141
+
142
+ | Group | Description |
143
+ |-------|-------------|
144
+ | `kd start` / `kd done` / `kd status` | Branch lifecycle — initialize, finish, and inspect sessions |
145
+ | `kd design` | Manage design documents (`show`, `approve`) |
146
+ | `kd council` | Multi-model council — `ask`, `chat`, `review`, `show`, `list`, `watch`, `reset`, `retry` |
147
+ | `kd ticket` (alias `kd tk`) | Ticket management — `create`, `list`, `show`, `start`, `close`, `deps`, and more |
148
+ | `kd peasant` | Worker agents — `start`, `stop`, `review`, `accept`, `reject`, `msg`, `read`, `watch` |
149
+ | `kd config` | View and manage configuration |
150
+ | `kd doctor` | Check config and agent CLIs |
151
+
152
+ Run `kd <command> --help` for full flags and options.
153
+
139
154
  ## Development
140
155
 
141
156
  ```bash
@@ -31,10 +31,12 @@ Design with the council, break into tickets, dispatch peasants, review, and merg
31
31
 
32
32
  ```bash
33
33
  kd start # initialize branch session
34
- kd chat --new # discuss design with the council TUI
34
+ kd council chat --new # discuss design with the council TUI
35
35
  kd design approve # lock in the design
36
- kd breakdown # generate tickets from the design
36
+ # create tickets from the design (via the kingdom skill or manually)
37
37
  kd peasant start <id> # dispatch parallel workers
38
+ kd peasant review <id> # review completed work
39
+ kd peasant accept <id> # accept and close, or reject with feedback
38
40
  kd done # archive and clean up
39
41
  ```
40
42
 
@@ -68,7 +70,6 @@ Design docs, council sessions, and peasant workers are all optional. A branch wi
68
70
  ## Getting Started
69
71
 
70
72
  ```bash
71
- kd init # one-time: create .kd/ directory
72
73
  kd start # start a session on the current branch
73
74
  ```
74
75
 
@@ -76,7 +77,7 @@ Configure council agent CLIs in `.kd/config.json` (check effective config with `
76
77
 
77
78
  ## Chat Modes
78
79
 
79
- The council chat TUI (`kd chat`) supports four modes, configured via `council.chat.mode`:
80
+ The council chat TUI (`kd council chat`) supports four modes, configured via `council.chat.mode`:
80
81
 
81
82
  | Mode | First turn | Auto-turns | Default |
82
83
  |------|-----------|------------|---------|
@@ -109,6 +110,20 @@ All state lives in `.kd/` as plain Markdown and JSON files, tracked in git along
109
110
 
110
111
  No database. No server. Just files on disk.
111
112
 
113
+ ## Commands
114
+
115
+ | Group | Description |
116
+ |-------|-------------|
117
+ | `kd start` / `kd done` / `kd status` | Branch lifecycle — initialize, finish, and inspect sessions |
118
+ | `kd design` | Manage design documents (`show`, `approve`) |
119
+ | `kd council` | Multi-model council — `ask`, `chat`, `review`, `show`, `list`, `watch`, `reset`, `retry` |
120
+ | `kd ticket` (alias `kd tk`) | Ticket management — `create`, `list`, `show`, `start`, `close`, `deps`, and more |
121
+ | `kd peasant` | Worker agents — `start`, `stop`, `review`, `accept`, `reject`, `msg`, `read`, `watch` |
122
+ | `kd config` | View and manage configuration |
123
+ | `kd doctor` | Check config and agent CLIs |
124
+
125
+ Run `kd <command> --help` for full flags and options.
126
+
112
127
  ## Development
113
128
 
114
129
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kingdom-cli"
3
- version = "0.2.1"
3
+ version = "0.3.1"
4
4
  description = "A markdown file-based CLI for software development with multi-agent council design, markdown tickets, and background worker loops."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -435,13 +435,19 @@ COMMAND_BUILDERS: dict[str, CommandBuilder] = {
435
435
  }
436
436
 
437
437
 
438
- def clean_agent_env(role: str | None = None, agent_name: str | None = None) -> dict[str, str]:
438
+ def clean_agent_env(
439
+ role: str | None = None,
440
+ agent_name: str | None = None,
441
+ kd_base: str | None = None,
442
+ ) -> dict[str, str]:
439
443
  """Return an env dict safe for spawning agent CLI subprocesses.
440
444
 
441
445
  Strips ``CLAUDECODE`` so child ``claude`` processes don't refuse to start
442
446
  with a "nested session" error when ``kd`` is invoked from inside Claude Code.
443
447
 
444
- Optionally injects ``KD_ROLE`` and ``KD_AGENT_NAME`` for ``kd whoami``.
448
+ Optionally injects ``KD_ROLE``, ``KD_AGENT_NAME``, and ``KD_BASE``.
449
+ ``KD_BASE`` ensures worktree agents resolve the project root against the
450
+ main repo, not the worktree's copy of ``.kd/``.
445
451
  """
446
452
  import os
447
453
 
@@ -450,6 +456,8 @@ def clean_agent_env(role: str | None = None, agent_name: str | None = None) -> d
450
456
  env["KD_ROLE"] = role
451
457
  if agent_name:
452
458
  env["KD_AGENT_NAME"] = agent_name
459
+ if kd_base:
460
+ env["KD_BASE"] = kd_base
453
461
  return env
454
462
 
455
463
 
@@ -0,0 +1,525 @@
1
+ """Command-line interface for Kingdom.
2
+
3
+ Usage example:
4
+ kd --help
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+
19
+ from kingdom.council import Council, create_council # noqa: F401 (Council used by tests)
20
+ from kingdom.design import ensure_design_initialized
21
+ from kingdom.state import (
22
+ branch_root,
23
+ clear_current_run,
24
+ ensure_base_layout,
25
+ ensure_branch_layout,
26
+ find_project_root,
27
+ get_current_git_branch,
28
+ normalize_branch_name,
29
+ read_json,
30
+ resolve_current_run,
31
+ set_current_run,
32
+ state_root,
33
+ write_json,
34
+ )
35
+ from kingdom.ticket import Ticket, list_tickets
36
+ from kingdom.worktree import create_worktree, remove_worktree, worktree_path_for # noqa: F401
37
+
38
+ from .config import check_cli, check_config, config_app, get_doctor_checks
39
+ from .council import council_app
40
+ from .design import design_app, get_branch_paths, get_doc_status # noqa: F401 (re-export)
41
+ from .display import error_console, print_error, styled_echo
42
+ from .helpers import install_skill, is_git_repo, require_project_root, verbose_echo # noqa: F401
43
+ from .peasant import ( # noqa: F401
44
+ PeasantContext,
45
+ launch_work_background,
46
+ launch_work_tmux,
47
+ peasant_app,
48
+ resolve_peasant_context,
49
+ )
50
+ from .ticket import format_ticket_line, format_ticket_summary, get_tickets_dir, ticket_app # noqa: F401
51
+
52
+ NO_COLOR = "NO_COLOR" in os.environ or os.environ.get("TERM") == "dumb"
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Main app
56
+ # ---------------------------------------------------------------------------
57
+
58
+ app = typer.Typer(
59
+ name="kd",
60
+ help="Kingdom CLI.",
61
+ add_completion=False,
62
+ )
63
+
64
+
65
+ @app.callback()
66
+ def app_callback(
67
+ ctx: typer.Context,
68
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Print debug output.")] = False,
69
+ ) -> None:
70
+ ctx.ensure_object(dict)["verbose"] = verbose
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Sub-app mounting
75
+ # ---------------------------------------------------------------------------
76
+
77
+ app.add_typer(council_app, name="council")
78
+ app.add_typer(design_app, name="design")
79
+ app.add_typer(peasant_app, name="peasant")
80
+ app.add_typer(config_app, name="config")
81
+ app.add_typer(ticket_app, name="ticket")
82
+ app.add_typer(ticket_app, name="tk", hidden=True) # Alias for muscle memory
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Top-level commands
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ @app.command(help="Initialize a branch-based session and state.")
91
+ def start(
92
+ branch: Annotated[str | None, typer.Argument(help="Branch name (defaults to current git branch).")] = None,
93
+ force: Annotated[
94
+ bool, typer.Option("--force", "-f", help="Force start even if a session is already active.")
95
+ ] = False,
96
+ ) -> None:
97
+ # If KD_BASE is explicitly set, require it to be valid — no auto-init fallback.
98
+ # Otherwise, fall back to cwd so auto-init can create .kd/ in a fresh repo.
99
+ if os.environ.get("KD_BASE"):
100
+ base = require_project_root()
101
+ else:
102
+ try:
103
+ base = find_project_root()
104
+ except ValueError:
105
+ base = Path.cwd()
106
+
107
+ # Auto-init if .kd/ doesn't exist (with git check)
108
+ if not state_root(base).exists():
109
+ if not is_git_repo(base):
110
+ print_error("Not a git repository. Initialize a git repo first, then run `kd start`.")
111
+ raise typer.Exit(code=1)
112
+ # Always auto-init at the git root, not wherever cwd happens to be
113
+ try:
114
+ git_root_result = subprocess.run(
115
+ ["git", "rev-parse", "--show-toplevel"],
116
+ capture_output=True,
117
+ text=True,
118
+ cwd=base,
119
+ timeout=5,
120
+ )
121
+ if git_root_result.returncode == 0 and git_root_result.stdout.strip():
122
+ base = Path(git_root_result.stdout.strip())
123
+ except (FileNotFoundError, subprocess.TimeoutExpired):
124
+ pass # Fall back to using base as-is
125
+ typer.echo("Auto-initializing .kd/ directory...")
126
+ ensure_base_layout(base)
127
+
128
+ # Always refresh bundled skill (skips symlinks for dev setups)
129
+ install_skill()
130
+
131
+ # Check for existing current run
132
+ current_path = state_root(base) / "current"
133
+ if current_path.exists() and not force:
134
+ existing = current_path.read_text(encoding="utf-8").strip()
135
+ print_error(f"A session is already active: {existing}")
136
+ error_console.print(" Use --force to override, or run `kd done` first.")
137
+ raise typer.Exit(code=1)
138
+
139
+ # Determine branch name
140
+ if branch is None:
141
+ branch = get_current_git_branch()
142
+ if branch is None:
143
+ print_error("Detached HEAD state. Please provide a branch name:")
144
+ error_console.print(" kd start <branch-name>")
145
+ raise typer.Exit(code=1)
146
+
147
+ # Normalize branch name for directory
148
+ normalized = normalize_branch_name(branch)
149
+
150
+ # Create branch layout
151
+ branch_dir = ensure_branch_layout(base, branch)
152
+
153
+ # Initialize design doc with template
154
+ design_path = branch_dir / "design.md"
155
+ ensure_design_initialized(design_path, branch)
156
+
157
+ # Write .kd/current with normalized name
158
+ set_current_run(base, normalized)
159
+
160
+ # Update state.json with original branch name
161
+ state_path = branch_dir / "state.json"
162
+ state = read_json(state_path)
163
+ state["branch"] = branch
164
+ write_json(state_path, state)
165
+
166
+ typer.echo(f"Started session for branch {branch}")
167
+ typer.echo(f" Location: {branch_dir}")
168
+ typer.echo(f" Design: {design_path}")
169
+
170
+
171
+ @app.command(help="Mark the current session as done.")
172
+ def done(
173
+ feature: Annotated[str | None, typer.Argument(help="Branch name (defaults to current session).")] = None,
174
+ force: Annotated[bool, typer.Option("--force", "-f", help="Close even if open tickets remain.")] = False,
175
+ ) -> None:
176
+ """Mark a session as done (status transition only, no file moves)."""
177
+ from datetime import UTC, datetime
178
+
179
+ base = require_project_root()
180
+
181
+ # Resolve feature: use argument or fall back to current session
182
+ if feature is None:
183
+ try:
184
+ feature = resolve_current_run(base)
185
+ except RuntimeError:
186
+ print_error("No active session. Pass the branch name: `kd done <branch>`")
187
+ raise typer.Exit(code=1) from None
188
+
189
+ # Get the branch directory (normalized name)
190
+ normalized = normalize_branch_name(feature)
191
+ source_dir = branch_root(base, feature)
192
+
193
+ # Check if it exists
194
+ if not source_dir.exists():
195
+ print_error(f"Branch '{feature}' not found.")
196
+ raise typer.Exit(code=1)
197
+
198
+ # Check for open tickets
199
+ if not force:
200
+ tickets_dir = source_dir / "tickets"
201
+ open_tickets = [t for t in list_tickets(tickets_dir) if t.status != "closed"]
202
+ if open_tickets:
203
+ print_error(f"{len(open_tickets)} open ticket(s) on '{feature}':")
204
+ for t in open_tickets:
205
+ error_console.print(f" {t.id} \\[{t.status}] {t.title}")
206
+ error_console.print("\nClose tickets, move them to backlog with `kd tk move`, or use --force.")
207
+ raise typer.Exit(code=1)
208
+
209
+ # Update state.json with status and timestamp
210
+ state_path = source_dir / "state.json"
211
+ if state_path.exists():
212
+ state = read_json(state_path)
213
+ else:
214
+ state = {}
215
+ state["status"] = "done"
216
+ state["done_at"] = datetime.now(UTC).isoformat()
217
+ write_json(state_path, state)
218
+
219
+ # Clean up associated worktrees (read from state.json worktrees map)
220
+ worktrees = state.get("worktrees", {})
221
+ if worktrees:
222
+ if not force:
223
+ names = ", ".join(worktrees.keys())
224
+ typer.confirm(f"Remove {len(worktrees)} worktree(s) ({names})?", abort=True)
225
+ for ticket_id, wt_path in worktrees.items():
226
+ wt = Path(wt_path)
227
+ if wt.exists():
228
+ result = subprocess.run(
229
+ ["git", "worktree", "remove", "--force", str(wt)],
230
+ capture_output=True,
231
+ text=True,
232
+ )
233
+ if result.returncode != 0:
234
+ typer.echo(f"Warning: Failed to remove worktree {ticket_id}: {result.stderr.strip()}")
235
+ state["worktrees"] = {}
236
+ write_json(state_path, state)
237
+
238
+ # Clear current session pointer (only if this was the current session)
239
+ current_path = state_root(base) / "current"
240
+ session_cleared = False
241
+ if current_path.exists():
242
+ current_feature = current_path.read_text(encoding="utf-8").strip()
243
+ if current_feature == normalized:
244
+ clear_current_run(base)
245
+ session_cleared = True
246
+
247
+ # Summary
248
+ tickets_dir = source_dir / "tickets"
249
+ all_tickets = list_tickets(tickets_dir)
250
+ closed_count = sum(1 for t in all_tickets if t.status == "closed")
251
+
252
+ console = Console()
253
+
254
+ lines: list[str] = []
255
+ if closed_count:
256
+ lines.append(f"[cyan]{closed_count}[/cyan] tickets closed")
257
+ if session_cleared:
258
+ lines.append("Session cleared")
259
+
260
+ push_reminder = ""
261
+ try:
262
+ rev_result = subprocess.run(
263
+ ["git", "rev-list", "--count", "@{u}..HEAD"],
264
+ capture_output=True,
265
+ text=True,
266
+ )
267
+ if rev_result.returncode == 0:
268
+ ahead = int(rev_result.stdout.strip())
269
+ if ahead > 0:
270
+ push_reminder = f"[yellow]{ahead} unpushed commit(s) — remember to push[/yellow]"
271
+ else:
272
+ push_reminder = "[yellow]No upstream branch — remember to push[/yellow]"
273
+ except (subprocess.SubprocessError, ValueError) as exc:
274
+ push_reminder = f"[yellow]Could not check upstream status: {exc}[/yellow]"
275
+
276
+ if push_reminder:
277
+ lines.append(push_reminder)
278
+
279
+ body = "\n".join(lines) if lines else "[dim]No additional info[/dim]"
280
+ panel = Panel(body, title=f"[bold green]Done: {feature}[/bold green]", border_style="green")
281
+ console.print(panel)
282
+
283
+
284
+ @app.command(help="Show current branch, design doc status, and breakdown status.")
285
+ def status(
286
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON for machine consumption.")] = False,
287
+ ) -> None:
288
+ base = require_project_root()
289
+ try:
290
+ feature = resolve_current_run(base)
291
+ except RuntimeError as exc:
292
+ print_error(str(exc))
293
+ raise typer.Exit(code=1) from None
294
+
295
+ normalized = normalize_branch_name(feature)
296
+ bdir = branch_root(base, feature)
297
+ state_path = bdir / "state.json"
298
+ design_path = bdir / "design.md"
299
+ breakdown_path = bdir / "breakdown.md"
300
+
301
+ # Read state to get original branch name
302
+ if state_path.exists():
303
+ state = read_json(state_path)
304
+ else:
305
+ state = {}
306
+
307
+ # Original branch name (stored in state.json) vs normalized directory name
308
+ original_branch = state.get("branch", feature)
309
+
310
+ # Get design and breakdown status
311
+ design_status = get_doc_status(design_path)
312
+ breakdown_status = get_doc_status(breakdown_path)
313
+
314
+ # Get design doc path relative to base for display
315
+ design_path_str = str(design_path.relative_to(base)) if design_path.exists() else None
316
+
317
+ # Get ticket counts
318
+ tickets_dir = get_tickets_dir(base)
319
+ tickets = list_tickets(tickets_dir) if tickets_dir.exists() else []
320
+
321
+ # Count by status
322
+ status_counts = {"open": 0, "in_progress": 0, "in_review": 0, "closed": 0}
323
+ for ticket in tickets:
324
+ if ticket.status in status_counts:
325
+ status_counts[ticket.status] += 1
326
+
327
+ # Count ready tickets (open/in_progress with all deps closed — excludes in_review)
328
+ status_by_id = {t.id: t.status for t in tickets}
329
+ ready_count = 0
330
+ for ticket in tickets:
331
+ if ticket.status not in ("open", "in_progress"):
332
+ continue
333
+ all_deps_closed = all(status_by_id.get(dep, "unknown") == "closed" for dep in ticket.deps)
334
+ if all_deps_closed:
335
+ ready_count += 1
336
+
337
+ # Design approved status
338
+ design_approved = state.get("design_approved", False)
339
+
340
+ # Build output structure
341
+ output = {
342
+ "branch": original_branch,
343
+ "normalized_branch": normalized,
344
+ "design_path": design_path_str,
345
+ "design_status": design_status,
346
+ "design_approved": design_approved,
347
+ "breakdown_status": breakdown_status,
348
+ "tickets": status_counts,
349
+ "ready_count": ready_count,
350
+ }
351
+
352
+ # Group tickets by assignee
353
+ role = os.environ.get("KD_ROLE", "")
354
+ agent_name = os.environ.get("KD_AGENT_NAME", "")
355
+ if not role:
356
+ role = "hand" if os.environ.get("CLAUDECODE") else "king"
357
+
358
+ assigned: dict[str, list[Ticket]] = {}
359
+ for ticket in tickets:
360
+ if ticket.assignee:
361
+ assigned.setdefault(ticket.assignee, []).append(ticket)
362
+
363
+ output["role"] = role
364
+ output["agent_name"] = agent_name
365
+ output["assignments"] = {k: [t.id for t in v] for k, v in assigned.items()}
366
+
367
+ if output_json:
368
+ typer.echo(json.dumps(output, indent=2))
369
+ else:
370
+ # Human-readable output
371
+ typer.echo(f"Branch: {original_branch}")
372
+ if design_path_str:
373
+ approved_str = " (approved)" if design_approved else ""
374
+ typer.echo(f"Design: {design_path_str}{approved_str}")
375
+ typer.echo()
376
+ total = sum(status_counts.values())
377
+ typer.echo(
378
+ f"Tickets: {status_counts['open']} open, {status_counts['in_progress']} in progress, "
379
+ f"{status_counts['in_review']} in review, {status_counts['closed']} closed, "
380
+ f"{ready_count} ready ({total} total)"
381
+ )
382
+
383
+ if assigned:
384
+ typer.echo()
385
+ typer.echo("Assignments:")
386
+ for assignee, assignee_tickets in assigned.items():
387
+ for t in assignee_tickets:
388
+ typer.echo(f" {assignee}: {t.id} [{t.status}] {t.title}")
389
+
390
+
391
+ @app.command(help="Upgrade the CLI and refresh skill files.")
392
+ def update() -> None:
393
+ """Run ``uv tool upgrade kingdom-cli`` then refresh Claude skill files."""
394
+ console = Console()
395
+
396
+ # Step 1: uv tool upgrade kingdom-cli
397
+ typer.echo("Upgrading kingdom-cli...")
398
+ try:
399
+ result = subprocess.run(
400
+ ["uv", "tool", "upgrade", "kingdom-cli"],
401
+ capture_output=True,
402
+ text=True,
403
+ timeout=120,
404
+ )
405
+ upgrade_ok = result.returncode == 0
406
+ upgrade_output = result.stdout.strip() or result.stderr.strip()
407
+ except FileNotFoundError:
408
+ upgrade_ok = False
409
+ upgrade_output = "uv not found — install it first (https://docs.astral.sh/uv/)"
410
+ except subprocess.TimeoutExpired:
411
+ upgrade_ok = False
412
+ upgrade_output = "uv tool upgrade timed out"
413
+
414
+ if upgrade_ok:
415
+ styled_echo(f" ✓ {upgrade_output or 'already up to date'}", fg=typer.colors.GREEN)
416
+ else:
417
+ styled_echo(f" ✗ {upgrade_output}", fg=typer.colors.RED)
418
+
419
+ # Step 2: refresh skill files
420
+ typer.echo("Refreshing skill files...")
421
+ skill_target = Path.home() / ".claude" / "skills" / "kingdom"
422
+ if skill_target.is_symlink():
423
+ styled_echo(" ○ Skipped (dev symlink)", fg=typer.colors.YELLOW)
424
+ skill_status = "skipped (dev symlink)"
425
+ else:
426
+ if install_skill():
427
+ styled_echo(" ✓ Skill files refreshed", fg=typer.colors.GREEN)
428
+ skill_status = "refreshed"
429
+ else:
430
+ styled_echo(" ✗ Skill refresh failed (see warning above)", fg=typer.colors.RED)
431
+ skill_status = "failed"
432
+
433
+ # Summary
434
+ typer.echo()
435
+ upgrade_summary = "upgraded" if upgrade_ok else "failed"
436
+ console.print(
437
+ Panel(
438
+ f"CLI: {upgrade_summary} | Skills: {skill_status}",
439
+ title="[bold]kd update[/bold]",
440
+ border_style="green" if upgrade_ok else "yellow",
441
+ )
442
+ )
443
+
444
+ if not upgrade_ok or skill_status == "failed":
445
+ raise typer.Exit(code=1)
446
+
447
+
448
+ @app.command(help="Check config and agent CLIs.")
449
+ def doctor(
450
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
451
+ ) -> None:
452
+ """Validate config and verify agent CLIs are installed."""
453
+ from kingdom.state import state_root as _state_root
454
+
455
+ base = require_project_root()
456
+ has_issues = False
457
+
458
+ # 1. Config validation
459
+ config_path = _state_root(base) / "config.json"
460
+ config_ok, config_error = check_config(base)
461
+
462
+ if not config_ok:
463
+ has_issues = True
464
+
465
+ if output_json:
466
+ config_result = {"exists": config_path.exists(), "valid": config_ok, "error": config_error}
467
+ else:
468
+ typer.echo("\nConfig:")
469
+ if not config_path.exists():
470
+ styled_echo(" ○ No config.json (using defaults)", fg=typer.colors.YELLOW)
471
+ elif config_ok:
472
+ styled_echo(" ✓ config.json valid", fg=typer.colors.GREEN)
473
+ else:
474
+ styled_echo(f" ✗ config.json: {config_error}", fg=typer.colors.RED)
475
+
476
+ # 2. Agent CLI checks (skip if config is invalid — can't resolve agents)
477
+ cli_results: dict[str, dict[str, bool | str | None]] = {}
478
+ cli_issues: list[dict[str, str]] = []
479
+
480
+ if config_ok:
481
+ doctor_checks = get_doctor_checks(base)
482
+ for check in doctor_checks:
483
+ installed, error = check_cli(check["command"])
484
+ cli_results[check["name"]] = {"installed": installed, "error": error}
485
+ if not installed:
486
+ cli_issues.append({"name": check["name"], "hint": check["install_hint"]})
487
+
488
+ if output_json:
489
+ console = Console()
490
+ console.print_json(json.dumps({"config": config_result, "agents": cli_results}, indent=2))
491
+ else:
492
+ if not config_ok:
493
+ typer.echo("\nAgent CLIs:")
494
+ styled_echo(" ○ Skipped (fix config first)", fg=typer.colors.YELLOW)
495
+ else:
496
+ typer.echo("\nAgent CLIs:")
497
+ for check in doctor_checks:
498
+ name = check["name"]
499
+ result = cli_results[name]
500
+ if result["installed"]:
501
+ styled_echo(f" ✓ {name:12} (installed)", fg=typer.colors.GREEN)
502
+ else:
503
+ styled_echo(f" ✗ {name:12} (not found)", fg=typer.colors.RED)
504
+
505
+ if cli_issues:
506
+ typer.echo("\nIssues found:")
507
+ for issue in cli_issues:
508
+ typer.echo(f" {issue['name']}: {issue['hint']}")
509
+ typer.echo()
510
+
511
+ if has_issues or cli_issues:
512
+ raise typer.Exit(code=1)
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # Entry point
517
+ # ---------------------------------------------------------------------------
518
+
519
+
520
+ def main() -> None:
521
+ app()
522
+
523
+
524
+ if __name__ == "__main__":
525
+ main()