kingdom-cli 0.2.1__tar.gz → 0.3.0__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 (95) hide show
  1. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/PKG-INFO +20 -5
  2. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/README.md +19 -4
  3. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/pyproject.toml +1 -1
  4. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/agent.py +10 -2
  5. kingdom_cli-0.3.0/src/kingdom/cli/__init__.py +466 -0
  6. kingdom_cli-0.3.0/src/kingdom/cli/config.py +138 -0
  7. kingdom_cli-0.3.0/src/kingdom/cli/council.py +1220 -0
  8. kingdom_cli-0.3.0/src/kingdom/cli/design.py +94 -0
  9. kingdom_cli-0.3.0/src/kingdom/cli/display.py +42 -0
  10. kingdom_cli-0.3.0/src/kingdom/cli/helpers.py +148 -0
  11. kingdom_cli-0.3.0/src/kingdom/cli/peasant.py +1370 -0
  12. kingdom_cli-0.3.0/src/kingdom/cli/ticket.py +1229 -0
  13. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/config.py +3 -10
  14. kingdom_cli-0.3.0/src/kingdom/council/__init__.py +48 -0
  15. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/council/council.py +8 -2
  16. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/council/worker.py +2 -11
  17. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/design.py +1 -1
  18. kingdom_cli-0.3.0/src/kingdom/harness.py +1144 -0
  19. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/parsing.py +30 -1
  20. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/skill/SKILL.md +0 -6
  21. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/skill/references/peasants.md +1 -5
  22. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/state.py +47 -105
  23. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/thread.py +25 -20
  24. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/ticket.py +248 -77
  25. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/app.py +15 -12
  26. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/widgets.py +2 -2
  27. kingdom_cli-0.3.0/src/kingdom/worker.py +54 -0
  28. kingdom_cli-0.3.0/src/kingdom/worktree.py +133 -0
  29. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom_cli.egg-info/PKG-INFO +20 -5
  30. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom_cli.egg-info/SOURCES.txt +19 -4
  31. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_cli.py +83 -218
  32. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_cli_council.py +109 -108
  33. kingdom_cli-0.3.0/tests/test_cli_design_breakdown.py +78 -0
  34. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_cli_peasant.py +580 -143
  35. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_cli_status.py +19 -23
  36. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_cli_ticket_current.py +10 -10
  37. kingdom_cli-0.3.0/tests/test_cli_ticket_deps.py +382 -0
  38. kingdom_cli-0.3.0/tests/test_cli_ticket_lifecycle.py +904 -0
  39. kingdom_cli-0.3.0/tests/test_cli_ticket_links.py +83 -0
  40. kingdom_cli-0.3.0/tests/test_cli_ticket_list.py +953 -0
  41. kingdom_cli-0.3.0/tests/test_cli_ticket_show.py +340 -0
  42. kingdom_cli-0.3.0/tests/test_cli_ticket_worklog.py +102 -0
  43. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_config.py +7 -13
  44. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_council.py +66 -7
  45. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_done.py +15 -38
  46. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_harness.py +720 -178
  47. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_init.py +35 -195
  48. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_parsing.py +66 -1
  49. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_session.py +0 -7
  50. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_state.py +38 -6
  51. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_thread.py +24 -12
  52. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_ticket.py +380 -15
  53. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui.py +53 -43
  54. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui_integration.py +0 -8
  55. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui_reply.py +0 -10
  56. kingdom_cli-0.3.0/tests/test_worker.py +106 -0
  57. kingdom_cli-0.3.0/tests/test_worktree.py +25 -0
  58. kingdom_cli-0.2.1/src/kingdom/cli.py +0 -4960
  59. kingdom_cli-0.2.1/src/kingdom/council/__init__.py +0 -13
  60. kingdom_cli-0.2.1/src/kingdom/harness.py +0 -729
  61. kingdom_cli-0.2.1/tests/test_cli_design_breakdown.py +0 -83
  62. kingdom_cli-0.2.1/tests/test_cli_ticket.py +0 -3087
  63. kingdom_cli-0.2.1/tests/test_cli_whoami.py +0 -46
  64. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/LICENSE +0 -0
  65. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/setup.cfg +0 -0
  66. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/__init__.py +0 -0
  67. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/__main__.py +0 -0
  68. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/breakdown.py +0 -0
  69. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/council/base.py +0 -0
  70. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/council/bundle.py +0 -0
  71. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/session.py +0 -0
  72. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/skill/__init__.py +0 -0
  73. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/skill/references/__init__.py +0 -0
  74. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/skill/references/council.md +0 -0
  75. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/skill/references/tickets.md +0 -0
  76. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/synthesis.py +0 -0
  77. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/__init__.py +0 -0
  78. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/chat.tcss +0 -0
  79. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/clipboard.py +0 -0
  80. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/poll.py +0 -0
  81. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom/tui/terminal.py +0 -0
  82. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
  83. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
  84. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom_cli.egg-info/requires.txt +0 -0
  85. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/src/kingdom_cli.egg-info/top_level.txt +0 -0
  86. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_agent.py +0 -0
  87. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_breakdown.py +0 -0
  88. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_design.py +0 -0
  89. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_skill.py +0 -0
  90. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_synthesis.py +0 -0
  91. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui_clipboard.py +0 -0
  92. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui_poll.py +0 -0
  93. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui_terminal.py +0 -0
  94. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/tests/test_tui_thinking_persistence.py +0 -0
  95. {kingdom_cli-0.2.1 → kingdom_cli-0.3.0}/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.0
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.0"
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,466 @@
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
+ install_skill()
128
+
129
+ # Check for existing current run
130
+ current_path = state_root(base) / "current"
131
+ if current_path.exists() and not force:
132
+ existing = current_path.read_text(encoding="utf-8").strip()
133
+ print_error(f"A session is already active: {existing}")
134
+ error_console.print(" Use --force to override, or run `kd done` first.")
135
+ raise typer.Exit(code=1)
136
+
137
+ # Determine branch name
138
+ if branch is None:
139
+ branch = get_current_git_branch()
140
+ if branch is None:
141
+ print_error("Detached HEAD state. Please provide a branch name:")
142
+ error_console.print(" kd start <branch-name>")
143
+ raise typer.Exit(code=1)
144
+
145
+ # Normalize branch name for directory
146
+ normalized = normalize_branch_name(branch)
147
+
148
+ # Create branch layout
149
+ branch_dir = ensure_branch_layout(base, branch)
150
+
151
+ # Initialize design doc with template
152
+ design_path = branch_dir / "design.md"
153
+ ensure_design_initialized(design_path, branch)
154
+
155
+ # Write .kd/current with normalized name
156
+ set_current_run(base, normalized)
157
+
158
+ # Update state.json with original branch name
159
+ state_path = branch_dir / "state.json"
160
+ state = read_json(state_path)
161
+ state["branch"] = branch
162
+ write_json(state_path, state)
163
+
164
+ typer.echo(f"Started session for branch {branch}")
165
+ typer.echo(f" Location: {branch_dir}")
166
+ typer.echo(f" Design: {design_path}")
167
+
168
+
169
+ @app.command(help="Mark the current session as done.")
170
+ def done(
171
+ feature: Annotated[str | None, typer.Argument(help="Branch name (defaults to current session).")] = None,
172
+ force: Annotated[bool, typer.Option("--force", "-f", help="Close even if open tickets remain.")] = False,
173
+ ) -> None:
174
+ """Mark a session as done (status transition only, no file moves)."""
175
+ from datetime import UTC, datetime
176
+
177
+ base = require_project_root()
178
+
179
+ # Resolve feature: use argument or fall back to current session
180
+ if feature is None:
181
+ try:
182
+ feature = resolve_current_run(base)
183
+ except RuntimeError:
184
+ print_error("No active session. Pass the branch name: `kd done <branch>`")
185
+ raise typer.Exit(code=1) from None
186
+
187
+ # Get the branch directory (normalized name)
188
+ normalized = normalize_branch_name(feature)
189
+ source_dir = branch_root(base, feature)
190
+
191
+ # Check if it exists
192
+ if not source_dir.exists():
193
+ print_error(f"Branch '{feature}' not found.")
194
+ raise typer.Exit(code=1)
195
+
196
+ # Check for open tickets
197
+ if not force:
198
+ tickets_dir = source_dir / "tickets"
199
+ open_tickets = [t for t in list_tickets(tickets_dir) if t.status != "closed"]
200
+ if open_tickets:
201
+ print_error(f"{len(open_tickets)} open ticket(s) on '{feature}':")
202
+ for t in open_tickets:
203
+ error_console.print(f" {t.id} \\[{t.status}] {t.title}")
204
+ error_console.print("\nClose tickets, move them to backlog with `kd tk move`, or use --force.")
205
+ raise typer.Exit(code=1)
206
+
207
+ # Update state.json with status and timestamp
208
+ state_path = source_dir / "state.json"
209
+ if state_path.exists():
210
+ state = read_json(state_path)
211
+ else:
212
+ state = {}
213
+ state["status"] = "done"
214
+ state["done_at"] = datetime.now(UTC).isoformat()
215
+ write_json(state_path, state)
216
+
217
+ # Clean up associated worktrees (read from state.json worktrees map)
218
+ worktrees = state.get("worktrees", {})
219
+ if worktrees:
220
+ if not force:
221
+ names = ", ".join(worktrees.keys())
222
+ typer.confirm(f"Remove {len(worktrees)} worktree(s) ({names})?", abort=True)
223
+ for ticket_id, wt_path in worktrees.items():
224
+ wt = Path(wt_path)
225
+ if wt.exists():
226
+ result = subprocess.run(
227
+ ["git", "worktree", "remove", "--force", str(wt)],
228
+ capture_output=True,
229
+ text=True,
230
+ )
231
+ if result.returncode != 0:
232
+ typer.echo(f"Warning: Failed to remove worktree {ticket_id}: {result.stderr.strip()}")
233
+ state["worktrees"] = {}
234
+ write_json(state_path, state)
235
+
236
+ # Clear current session pointer (only if this was the current session)
237
+ current_path = state_root(base) / "current"
238
+ session_cleared = False
239
+ if current_path.exists():
240
+ current_feature = current_path.read_text(encoding="utf-8").strip()
241
+ if current_feature == normalized:
242
+ clear_current_run(base)
243
+ session_cleared = True
244
+
245
+ # Summary
246
+ tickets_dir = source_dir / "tickets"
247
+ all_tickets = list_tickets(tickets_dir)
248
+ closed_count = sum(1 for t in all_tickets if t.status == "closed")
249
+
250
+ console = Console()
251
+
252
+ lines: list[str] = []
253
+ if closed_count:
254
+ lines.append(f"[cyan]{closed_count}[/cyan] tickets closed")
255
+ if session_cleared:
256
+ lines.append("Session cleared")
257
+
258
+ push_reminder = ""
259
+ try:
260
+ rev_result = subprocess.run(
261
+ ["git", "rev-list", "--count", "@{u}..HEAD"],
262
+ capture_output=True,
263
+ text=True,
264
+ )
265
+ if rev_result.returncode == 0:
266
+ ahead = int(rev_result.stdout.strip())
267
+ if ahead > 0:
268
+ push_reminder = f"[yellow]{ahead} unpushed commit(s) — remember to push[/yellow]"
269
+ else:
270
+ push_reminder = "[yellow]No upstream branch — remember to push[/yellow]"
271
+ except (subprocess.SubprocessError, ValueError) as exc:
272
+ push_reminder = f"[yellow]Could not check upstream status: {exc}[/yellow]"
273
+
274
+ if push_reminder:
275
+ lines.append(push_reminder)
276
+
277
+ body = "\n".join(lines) if lines else "[dim]No additional info[/dim]"
278
+ panel = Panel(body, title=f"[bold green]Done: {feature}[/bold green]", border_style="green")
279
+ console.print(panel)
280
+
281
+
282
+ @app.command(help="Show current branch, design doc status, and breakdown status.")
283
+ def status(
284
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON for machine consumption.")] = False,
285
+ ) -> None:
286
+ base = require_project_root()
287
+ try:
288
+ feature = resolve_current_run(base)
289
+ except RuntimeError as exc:
290
+ print_error(str(exc))
291
+ raise typer.Exit(code=1) from None
292
+
293
+ normalized = normalize_branch_name(feature)
294
+ bdir = branch_root(base, feature)
295
+ state_path = bdir / "state.json"
296
+ design_path = bdir / "design.md"
297
+ breakdown_path = bdir / "breakdown.md"
298
+
299
+ # Read state to get original branch name
300
+ if state_path.exists():
301
+ state = read_json(state_path)
302
+ else:
303
+ state = {}
304
+
305
+ # Original branch name (stored in state.json) vs normalized directory name
306
+ original_branch = state.get("branch", feature)
307
+
308
+ # Get design and breakdown status
309
+ design_status = get_doc_status(design_path)
310
+ breakdown_status = get_doc_status(breakdown_path)
311
+
312
+ # Get design doc path relative to base for display
313
+ design_path_str = str(design_path.relative_to(base)) if design_path.exists() else None
314
+
315
+ # Get ticket counts
316
+ tickets_dir = get_tickets_dir(base)
317
+ tickets = list_tickets(tickets_dir) if tickets_dir.exists() else []
318
+
319
+ # Count by status
320
+ status_counts = {"open": 0, "in_progress": 0, "in_review": 0, "closed": 0}
321
+ for ticket in tickets:
322
+ if ticket.status in status_counts:
323
+ status_counts[ticket.status] += 1
324
+
325
+ # Count ready tickets (open/in_progress with all deps closed — excludes in_review)
326
+ status_by_id = {t.id: t.status for t in tickets}
327
+ ready_count = 0
328
+ for ticket in tickets:
329
+ if ticket.status not in ("open", "in_progress"):
330
+ continue
331
+ all_deps_closed = all(status_by_id.get(dep, "unknown") == "closed" for dep in ticket.deps)
332
+ if all_deps_closed:
333
+ ready_count += 1
334
+
335
+ # Design approved status
336
+ design_approved = state.get("design_approved", False)
337
+
338
+ # Build output structure
339
+ output = {
340
+ "branch": original_branch,
341
+ "normalized_branch": normalized,
342
+ "design_path": design_path_str,
343
+ "design_status": design_status,
344
+ "design_approved": design_approved,
345
+ "breakdown_status": breakdown_status,
346
+ "tickets": status_counts,
347
+ "ready_count": ready_count,
348
+ }
349
+
350
+ # Group tickets by assignee
351
+ role = os.environ.get("KD_ROLE", "")
352
+ agent_name = os.environ.get("KD_AGENT_NAME", "")
353
+ if not role:
354
+ role = "hand" if os.environ.get("CLAUDECODE") else "king"
355
+
356
+ assigned: dict[str, list[Ticket]] = {}
357
+ for ticket in tickets:
358
+ if ticket.assignee:
359
+ assigned.setdefault(ticket.assignee, []).append(ticket)
360
+
361
+ output["role"] = role
362
+ output["agent_name"] = agent_name
363
+ output["assignments"] = {k: [t.id for t in v] for k, v in assigned.items()}
364
+
365
+ if output_json:
366
+ typer.echo(json.dumps(output, indent=2))
367
+ else:
368
+ # Human-readable output
369
+ typer.echo(f"Branch: {original_branch}")
370
+ if design_path_str:
371
+ approved_str = " (approved)" if design_approved else ""
372
+ typer.echo(f"Design: {design_path_str}{approved_str}")
373
+ typer.echo()
374
+ total = sum(status_counts.values())
375
+ typer.echo(
376
+ f"Tickets: {status_counts['open']} open, {status_counts['in_progress']} in progress, "
377
+ f"{status_counts['in_review']} in review, {status_counts['closed']} closed, "
378
+ f"{ready_count} ready ({total} total)"
379
+ )
380
+
381
+ if assigned:
382
+ typer.echo()
383
+ typer.echo("Assignments:")
384
+ for assignee, assignee_tickets in assigned.items():
385
+ for t in assignee_tickets:
386
+ typer.echo(f" {assignee}: {t.id} [{t.status}] {t.title}")
387
+
388
+
389
+ @app.command(help="Check config and agent CLIs.")
390
+ def doctor(
391
+ output_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
392
+ ) -> None:
393
+ """Validate config and verify agent CLIs are installed."""
394
+ from kingdom.state import state_root as _state_root
395
+
396
+ base = require_project_root()
397
+ has_issues = False
398
+
399
+ # 1. Config validation
400
+ config_path = _state_root(base) / "config.json"
401
+ config_ok, config_error = check_config(base)
402
+
403
+ if not config_ok:
404
+ has_issues = True
405
+
406
+ if output_json:
407
+ config_result = {"exists": config_path.exists(), "valid": config_ok, "error": config_error}
408
+ else:
409
+ typer.echo("\nConfig:")
410
+ if not config_path.exists():
411
+ styled_echo(" ○ No config.json (using defaults)", fg=typer.colors.YELLOW)
412
+ elif config_ok:
413
+ styled_echo(" ✓ config.json valid", fg=typer.colors.GREEN)
414
+ else:
415
+ styled_echo(f" ✗ config.json: {config_error}", fg=typer.colors.RED)
416
+
417
+ # 2. Agent CLI checks (skip if config is invalid — can't resolve agents)
418
+ cli_results: dict[str, dict[str, bool | str | None]] = {}
419
+ cli_issues: list[dict[str, str]] = []
420
+
421
+ if config_ok:
422
+ doctor_checks = get_doctor_checks(base)
423
+ for check in doctor_checks:
424
+ installed, error = check_cli(check["command"])
425
+ cli_results[check["name"]] = {"installed": installed, "error": error}
426
+ if not installed:
427
+ cli_issues.append({"name": check["name"], "hint": check["install_hint"]})
428
+
429
+ if output_json:
430
+ console = Console()
431
+ console.print_json(json.dumps({"config": config_result, "agents": cli_results}, indent=2))
432
+ else:
433
+ if not config_ok:
434
+ typer.echo("\nAgent CLIs:")
435
+ styled_echo(" ○ Skipped (fix config first)", fg=typer.colors.YELLOW)
436
+ else:
437
+ typer.echo("\nAgent CLIs:")
438
+ for check in doctor_checks:
439
+ name = check["name"]
440
+ result = cli_results[name]
441
+ if result["installed"]:
442
+ styled_echo(f" ✓ {name:12} (installed)", fg=typer.colors.GREEN)
443
+ else:
444
+ styled_echo(f" ✗ {name:12} (not found)", fg=typer.colors.RED)
445
+
446
+ if cli_issues:
447
+ typer.echo("\nIssues found:")
448
+ for issue in cli_issues:
449
+ typer.echo(f" {issue['name']}: {issue['hint']}")
450
+ typer.echo()
451
+
452
+ if has_issues or cli_issues:
453
+ raise typer.Exit(code=1)
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Entry point
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ def main() -> None:
462
+ app()
463
+
464
+
465
+ if __name__ == "__main__":
466
+ main()