kingdom-cli 0.5.0__tar.gz → 0.5.2__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 (99) hide show
  1. {kingdom_cli-0.5.0/src/kingdom_cli.egg-info → kingdom_cli-0.5.2}/PKG-INFO +3 -4
  2. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/README.md +1 -1
  3. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/pyproject.toml +2 -3
  4. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/__init__.py +6 -10
  5. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/helpers.py +48 -25
  6. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/peasant.py +100 -31
  7. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/ticket.py +63 -7
  8. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/harness.py +4 -2
  9. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/lord_harness.py +2 -1
  10. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/SKILL.md +10 -8
  11. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/tickets.md +8 -1
  12. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/state.py +116 -40
  13. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/worktree.py +65 -14
  14. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2/src/kingdom_cli.egg-info}/PKG-INFO +3 -4
  15. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli.py +53 -10
  16. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_peasant.py +282 -2
  17. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_current.py +27 -0
  18. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_lifecycle.py +148 -0
  19. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_list.py +14 -0
  20. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_show.py +51 -17
  21. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_epic.py +1 -1
  22. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_init.py +88 -10
  23. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_state.py +98 -6
  24. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_worktree.py +71 -5
  25. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/LICENSE +0 -0
  26. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/setup.cfg +0 -0
  27. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/__init__.py +0 -0
  28. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/__main__.py +0 -0
  29. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/agent.py +0 -0
  30. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/breakdown.py +0 -0
  31. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/config.py +0 -0
  32. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/council.py +0 -0
  33. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/design.py +0 -0
  34. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/display.py +0 -0
  35. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/hook.py +0 -0
  36. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/lord.py +0 -0
  37. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/plugin.py +0 -0
  38. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/config.py +0 -0
  39. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/__init__.py +0 -0
  40. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/base.py +0 -0
  41. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/bundle.py +0 -0
  42. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/council.py +0 -0
  43. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/worker.py +0 -0
  44. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/design.py +0 -0
  45. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/lord_worker.py +0 -0
  46. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/parsing.py +0 -0
  47. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/session.py +0 -0
  48. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/__init__.py +0 -0
  49. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/__init__.py +0 -0
  50. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/council.md +0 -0
  51. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/peasants.md +0 -0
  52. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/synthesis.py +0 -0
  53. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/thread.py +0 -0
  54. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/ticket.py +0 -0
  55. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/__init__.py +0 -0
  56. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/app.py +0 -0
  57. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/chat.tcss +0 -0
  58. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/clipboard.py +0 -0
  59. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/poll.py +0 -0
  60. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/terminal.py +0 -0
  61. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/widgets.py +0 -0
  62. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/worker.py +0 -0
  63. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/SOURCES.txt +0 -0
  64. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
  65. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
  66. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/requires.txt +0 -0
  67. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/top_level.txt +0 -0
  68. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_agent.py +0 -0
  69. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_breakdown.py +0 -0
  70. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_council.py +0 -0
  71. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_design_breakdown.py +0 -0
  72. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_plugin.py +0 -0
  73. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_status.py +0 -0
  74. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_deps.py +0 -0
  75. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_links.py +0 -0
  76. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_worklog.py +0 -0
  77. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_config.py +0 -0
  78. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_council.py +0 -0
  79. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_design.py +0 -0
  80. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_done.py +0 -0
  81. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_harness.py +0 -0
  82. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_hook.py +0 -0
  83. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_lord.py +0 -0
  84. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_parsing.py +0 -0
  85. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_session.py +0 -0
  86. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_skill.py +0 -0
  87. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_stop_blocker.py +0 -0
  88. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_synthesis.py +0 -0
  89. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_thread.py +0 -0
  90. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_ticket.py +0 -0
  91. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui.py +0 -0
  92. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_clipboard.py +0 -0
  93. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_integration.py +0 -0
  94. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_poll.py +0 -0
  95. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_reply.py +0 -0
  96. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_terminal.py +0 -0
  97. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_thinking_persistence.py +0 -0
  98. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_widgets.py +0 -0
  99. {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kingdom-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -10,14 +10,13 @@ Project-URL: Issues, https://github.com/jbohnslav/kingdom/issues
10
10
  Project-URL: Changelog, https://github.com/jbohnslav/kingdom/releases
11
11
  Keywords: cli,ai,development,workflow,agents,multi-model,tickets
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.10
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
16
15
  Classifier: Programming Language :: Python :: 3.13
17
16
  Classifier: Intended Audience :: Developers
18
17
  Classifier: Topic :: Software Development
19
18
  Classifier: Environment :: Console
20
- Requires-Python: >=3.10
19
+ Requires-Python: >=3.11
21
20
  Description-Content-Type: text/markdown
22
21
  License-File: LICENSE
23
22
  Requires-Dist: rich>=14.3.1
@@ -45,7 +44,7 @@ Gastown minus the polecats.
45
44
  ## Install
46
45
 
47
46
  ```bash
48
- uv tool install kingdom-cli
47
+ uv tool install kingdom-cli # add --python 3.11 if Python 3.11+ is not installed yet
49
48
  ```
50
49
 
51
50
  ## Workflow Scales
@@ -18,7 +18,7 @@ Gastown minus the polecats.
18
18
  ## Install
19
19
 
20
20
  ```bash
21
- uv tool install kingdom-cli
21
+ uv tool install kingdom-cli # add --python 3.11 if Python 3.11+ is not installed yet
22
22
  ```
23
23
 
24
24
  ## Workflow Scales
@@ -1,15 +1,14 @@
1
1
  [project]
2
2
  name = "kingdom-cli"
3
- version = "0.5.0"
3
+ version = "0.5.2"
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
- requires-python = ">=3.10"
6
+ requires-python = ">=3.11"
7
7
  license = "Apache-2.0"
8
8
  authors = [{ name = "James Robinson-Bohnslav" }]
9
9
  keywords = ["cli", "ai", "development", "workflow", "agents", "multi-model", "tickets"]
10
10
  classifiers = [
11
11
  "Programming Language :: Python :: 3",
12
- "Programming Language :: Python :: 3.10",
13
12
  "Programming Language :: Python :: 3.11",
14
13
  "Programming Language :: Python :: 3.12",
15
14
  "Programming Language :: Python :: 3.13",
@@ -503,7 +503,7 @@ def status(
503
503
 
504
504
  @app.command(help="Upgrade the CLI and refresh skill files.")
505
505
  def update() -> None:
506
- """Run ``uv tool upgrade kingdom-cli`` then refresh Claude skill files."""
506
+ """Run ``uv tool upgrade kingdom-cli`` then refresh local agent skill files."""
507
507
  console = Console()
508
508
 
509
509
  # Step 1: uv tool upgrade kingdom-cli
@@ -531,17 +531,13 @@ def update() -> None:
531
531
 
532
532
  # Step 2: refresh skill files
533
533
  typer.echo("Refreshing skill files...")
534
- skill_target = Path.home() / ".claude" / "skills" / "kingdom"
535
- if skill_target.is_symlink():
534
+ skill_status = install_skill()
535
+ if skill_status == "refreshed":
536
+ styled_echo(" ✓ Skill files refreshed", fg=typer.colors.GREEN)
537
+ elif skill_status == "skipped":
536
538
  styled_echo(" ○ Skipped (dev symlink)", fg=typer.colors.YELLOW)
537
- skill_status = "skipped (dev symlink)"
538
539
  else:
539
- if install_skill():
540
- styled_echo(" ✓ Skill files refreshed", fg=typer.colors.GREEN)
541
- skill_status = "refreshed"
542
- else:
543
- styled_echo(" ✗ Skill refresh failed (see warning above)", fg=typer.colors.RED)
544
- skill_status = "failed"
540
+ styled_echo(" ✗ Skill refresh failed (see warning above)", fg=typer.colors.RED)
545
541
 
546
542
  # Summary
547
543
  typer.echo()
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import os
6
6
  import subprocess
7
7
  from pathlib import Path
8
+ from typing import Literal
8
9
 
9
10
  import typer
10
11
 
@@ -13,6 +14,8 @@ from kingdom.ticket import AmbiguousTicketMatch, Ticket, find_ticket
13
14
 
14
15
  from .display import print_error
15
16
 
17
+ SkillInstallStatus = Literal["refreshed", "skipped", "failed"]
18
+
16
19
 
17
20
  def verbose_echo(message: str) -> None:
18
21
  """Print a debug message to stderr when --verbose is set."""
@@ -116,38 +119,58 @@ def ensure_feature_branch(feature: str) -> None:
116
119
  typer.echo(f"Warning: current branch '{current}' does not match feature '{feature}'.")
117
120
 
118
121
 
119
- def install_skill() -> bool:
120
- """Install the bundled kingdom skill to ~/.claude/skills/kingdom/.
122
+ def skill_install_targets(home: Path) -> list[Path]:
123
+ """Return Claude's skill target, plus Cursor/Codex when their config dirs exist."""
124
+ targets = [home / ".claude" / "skills" / "kingdom"]
125
+ for agent_dir in (".cursor", ".codex"):
126
+ root = home / agent_dir
127
+ if root.is_dir():
128
+ targets.append(root / "skills" / "kingdom")
129
+ return targets
130
+
131
+
132
+ def install_skill() -> SkillInstallStatus:
133
+ """Install the bundled kingdom skill to supported local agent skill dirs.
121
134
 
122
- Copies SKILL.md and reference files from the package into the Claude
123
- skills directory. Skips if the target is a symlink (dev setup).
124
- Warns and continues on permission or filesystem errors.
135
+ Copies SKILL.md and reference files from the package into Claude, and into
136
+ Cursor/Codex when their top-level config directories already exist. Skips
137
+ individual targets that are symlinks (dev setup).
138
+ Warns on permission or filesystem errors.
125
139
 
126
- Returns True on success (including symlink skip), False on error.
140
+ Returns "refreshed" when any target was written, "skipped" when every
141
+ target was a symlink, and "failed" on error.
127
142
  """
128
143
  from importlib.resources import as_file, files
129
144
 
130
145
  try:
131
- target = Path.home() / ".claude" / "skills" / "kingdom"
132
-
133
- # Don't overwrite a dev symlink
134
- if target.is_symlink():
135
- return True
136
-
146
+ targets = skill_install_targets(Path.home())
137
147
  skill_pkg = files("kingdom.skill")
138
-
139
- target.mkdir(parents=True, exist_ok=True)
140
- with as_file(skill_pkg / "SKILL.md") as src:
141
- (target / "SKILL.md").write_bytes(src.read_bytes())
142
-
143
- refs_target = target / "references"
144
- refs_target.mkdir(exist_ok=True)
145
148
  refs_pkg = skill_pkg / "references"
146
- for item in refs_pkg.iterdir():
147
- if item.name.endswith(".md"):
148
- with as_file(item) as src:
149
- (refs_target / item.name).write_bytes(src.read_bytes())
149
+ skipped_targets = 0
150
+ refreshed_targets = 0
151
+
152
+ for target in targets:
153
+ if target.is_symlink():
154
+ skipped_targets += 1
155
+ continue
156
+
157
+ target.mkdir(parents=True, exist_ok=True)
158
+ with as_file(skill_pkg / "SKILL.md") as src:
159
+ (target / "SKILL.md").write_bytes(src.read_bytes())
160
+
161
+ refs_target = target / "references"
162
+ refs_target.mkdir(exist_ok=True)
163
+ for item in refs_pkg.iterdir():
164
+ if item.name.endswith(".md"):
165
+ with as_file(item) as src:
166
+ (refs_target / item.name).write_bytes(src.read_bytes())
167
+ refreshed_targets += 1
150
168
  except (OSError, RuntimeError) as exc:
151
169
  typer.echo(f"Warning: could not install skill ({exc})")
152
- return False
153
- return True
170
+ return "failed"
171
+
172
+ if refreshed_targets:
173
+ return "refreshed"
174
+ if skipped_targets:
175
+ return "skipped"
176
+ return "refreshed"
@@ -24,6 +24,8 @@ from kingdom.parsing import parse_iso_datetime
24
24
  from kingdom.state import (
25
25
  backlog_root,
26
26
  branch_root,
27
+ find_git_root,
28
+ get_current_git_branch,
27
29
  logs_root,
28
30
  normalize_branch_name,
29
31
  resolve_current_run,
@@ -32,9 +34,9 @@ from kingdom.ticket import Ticket, move_ticket, write_ticket
32
34
  from kingdom.worktree import (
33
35
  check_uncommitted_changes,
34
36
  create_worktree,
37
+ existing_worktree_path_for,
35
38
  remove_worktree,
36
39
  run_init_script,
37
- worktree_path_for,
38
40
  )
39
41
 
40
42
  from .display import error_console, print_error, styled_echo
@@ -58,6 +60,34 @@ class PeasantContext(NamedTuple):
58
60
  ticket_path: Path
59
61
  full_ticket_id: str
60
62
  feature: str
63
+ git_root: Path
64
+
65
+
66
+ def resolve_invocation_git_root(base: Path) -> Path:
67
+ """Return the checkout git commands should operate on.
68
+
69
+ When ``kd`` is invoked under the Kingdom project root, use that root even if
70
+ a nested repo or submodule is closer to cwd. When ``base`` points at a
71
+ sibling checkout that owns ``.kd/``, fall back to git discovery for the
72
+ invocation worktree.
73
+ """
74
+ cwd = Path.cwd().resolve()
75
+ base = base.resolve()
76
+
77
+ if cwd == base or base in cwd.parents:
78
+ return base
79
+
80
+ current = cwd
81
+ while True:
82
+ if (current / ".git").exists():
83
+ return current
84
+ if current == base:
85
+ return base
86
+ if current.parent == current:
87
+ break
88
+ current = current.parent
89
+
90
+ return find_git_root(cwd) or base
61
91
 
62
92
 
63
93
  def resolve_peasant_context(ticket_id: str, base: Path | None = None, auto_pull: bool = False) -> PeasantContext:
@@ -77,6 +107,7 @@ def resolve_peasant_context(ticket_id: str, base: Path | None = None, auto_pull:
77
107
  from kingdom.session import find_peasant_branch
78
108
 
79
109
  base = base or require_project_root()
110
+ git_root = resolve_invocation_git_root(base)
80
111
 
81
112
  # For non-start commands, try to find the peasant's owning branch first.
82
113
  # Resolve the full ticket ID before building the session name — the user
@@ -92,6 +123,7 @@ def resolve_peasant_context(ticket_id: str, base: Path | None = None, auto_pull:
92
123
  ticket_path=ticket_path,
93
124
  full_ticket_id=ticket.id,
94
125
  feature=owning_branch,
126
+ git_root=git_root,
95
127
  )
96
128
 
97
129
  # Fall back to current session (required for start, fine for others)
@@ -114,6 +146,7 @@ def resolve_peasant_context(ticket_id: str, base: Path | None = None, auto_pull:
114
146
  ticket_path=ticket_path,
115
147
  full_ticket_id=full_ticket_id,
116
148
  feature=feature,
149
+ git_root=git_root,
117
150
  )
118
151
 
119
152
 
@@ -308,10 +341,10 @@ def peasant_start(
308
341
 
309
342
  # 0. Preflight: warn on uncommitted changes (skipped in hand mode or with --no-preflight)
310
343
  if not hand and not no_preflight:
311
- uncommitted = check_uncommitted_changes(base)
344
+ uncommitted = check_uncommitted_changes(ctx.git_root, ignore_kd=True)
312
345
  if uncommitted:
313
346
  error_console.print(
314
- f"[yellow]Warning:[/yellow] {len(uncommitted)} uncommitted change(s) in {base}.\n"
347
+ f"[yellow]Warning:[/yellow] {len(uncommitted)} uncommitted change(s) in {ctx.git_root}.\n"
315
348
  " Worktrees are created from the last commit — uncommitted changes won't be included.\n"
316
349
  " Commit or stash first, or use [bold]--hand[/bold] to work in the current directory."
317
350
  )
@@ -335,11 +368,11 @@ def peasant_start(
335
368
  f"on this checkout. Stop it first or use worktree mode."
336
369
  )
337
370
  raise typer.Exit(code=1)
338
- worktree_path = base
339
- typer.echo(f"Running in hand mode (serial) on {base}")
371
+ worktree_path = ctx.git_root
372
+ typer.echo(f"Running in hand mode (serial) on {ctx.git_root}")
340
373
  else:
341
374
  try:
342
- worktree_path = create_worktree(base, full_ticket_id, log=typer.echo)
375
+ worktree_path = create_worktree(base, full_ticket_id, log=typer.echo, git_root=ctx.git_root)
343
376
  except RuntimeError as exc:
344
377
  print_error(str(exc))
345
378
  raise typer.Exit(code=1) from None
@@ -368,18 +401,18 @@ def peasant_start(
368
401
  # The worker reads session state immediately on startup (e.g. hand_mode for
369
402
  # branch validation), so it must be initialized before the process exists.
370
403
  now = datetime.now(UTC).isoformat()
371
- update_agent_state(
372
- base,
373
- feature,
374
- session_name,
375
- status="working",
376
- ticket=full_ticket_id,
377
- thread=thread_id,
378
- agent_backend=agent,
379
- started_at=now,
380
- last_activity=now,
381
- hand_mode=hand,
382
- )
404
+ session_fields = {
405
+ "status": "working",
406
+ "ticket": full_ticket_id,
407
+ "thread": thread_id,
408
+ "agent_backend": agent,
409
+ "started_at": now,
410
+ "last_activity": now,
411
+ "hand_mode": hand,
412
+ }
413
+ if existing.resume_id and existing.agent_backend and existing.agent_backend != agent:
414
+ session_fields["resume_id"] = None
415
+ update_agent_state(base, feature, session_name, **session_fields)
383
416
 
384
417
  # 5. Launch harness
385
418
  try:
@@ -606,7 +639,13 @@ def peasant_show(
606
639
  from kingdom.state import read_json
607
640
 
608
641
  st = read_json(branch_root(ctx.base, ctx.feature) / "state.json")
609
- git_ref = st.get("branch", ctx.feature)
642
+ git_ref = st.get("branch")
643
+ if not git_ref:
644
+ current_git_branch = get_current_git_branch(ctx.git_root)
645
+ if current_git_branch and normalize_branch_name(current_git_branch) == normalize_branch_name(ctx.feature):
646
+ git_ref = current_git_branch
647
+ else:
648
+ git_ref = ctx.feature
610
649
  log_spec = [f"{git_ref}..{branch_name}"]
611
650
  commits: list[str] = []
612
651
  try:
@@ -614,7 +653,7 @@ def peasant_show(
614
653
  ["git", "log", "--oneline", *log_spec],
615
654
  capture_output=True,
616
655
  text=True,
617
- cwd=str(ctx.base),
656
+ cwd=str(ctx.git_root),
618
657
  timeout=10,
619
658
  )
620
659
  if result.returncode == 0 and result.stdout.strip():
@@ -931,7 +970,6 @@ def peasant_watch(
931
970
 
932
971
  import kingdom.cli as _cli
933
972
  from kingdom.session import get_agent_state
934
- from kingdom.worktree import worktree_path_for
935
973
 
936
974
  ctx = _cli.resolve_peasant_context(ticket_id)
937
975
  session_name = peasant_session_name(ctx.full_ticket_id)
@@ -941,7 +979,11 @@ def peasant_watch(
941
979
 
942
980
  # Resolve worktree path for activity polling
943
981
  state = get_agent_state(ctx.base, ctx.feature, session_name)
944
- worktree = ctx.base if state.hand_mode else worktree_path_for(ctx.base, ctx.full_ticket_id)
982
+ worktree = (
983
+ ctx.git_root
984
+ if state.hand_mode
985
+ else existing_worktree_path_for(ctx.base, ctx.full_ticket_id, feature=ctx.feature)
986
+ )
945
987
 
946
988
  # Resolve agent backend for NDJSON stream decoding
947
989
  agent_backend = ""
@@ -1186,7 +1228,7 @@ def peasant_clean(
1186
1228
  typer.confirm(f"Remove worktree for {ctx.full_ticket_id}?", abort=True)
1187
1229
 
1188
1230
  try:
1189
- remove_worktree(ctx.base, ctx.full_ticket_id)
1231
+ remove_worktree(ctx.base, ctx.full_ticket_id, git_root=ctx.git_root, feature=ctx.feature)
1190
1232
  typer.echo(f"{ctx.full_ticket_id}: worktree removed")
1191
1233
  except FileNotFoundError:
1192
1234
  print_error(f"No worktree found for {ctx.full_ticket_id}")
@@ -1259,7 +1301,7 @@ def peasant_sync(
1259
1301
  raise typer.Exit(code=1)
1260
1302
 
1261
1303
  # Find worktree
1262
- worktree_path = worktree_path_for(base, full_ticket_id)
1304
+ worktree_path = existing_worktree_path_for(base, full_ticket_id, feature=feature)
1263
1305
  if not worktree_path.exists():
1264
1306
  print_error(f"No worktree found for {full_ticket_id}. Has the peasant been started?")
1265
1307
  raise typer.Exit(code=1)
@@ -1395,7 +1437,7 @@ def peasant_review(
1395
1437
  ["git", "diff", diff_spec, "--stat"],
1396
1438
  capture_output=True,
1397
1439
  text=True,
1398
- cwd=str(base),
1440
+ cwd=str(ctx.git_root),
1399
1441
  )
1400
1442
  diff_output = diff_result.stdout.strip()
1401
1443
  diff_err = diff_result.stderr.strip()
@@ -1488,7 +1530,7 @@ def peasant_accept(
1488
1530
  ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1489
1531
  capture_output=True,
1490
1532
  text=True,
1491
- cwd=str(base),
1533
+ cwd=str(ctx.git_root),
1492
1534
  ).stdout.strip()
1493
1535
  if "branch" in state_json:
1494
1536
  # Original git branch name stored — compare exactly
@@ -1514,13 +1556,13 @@ def peasant_accept(
1514
1556
  ["git", "merge-base", "--is-ancestor", branch_name, "HEAD"],
1515
1557
  capture_output=True,
1516
1558
  text=True,
1517
- cwd=str(base),
1559
+ cwd=str(ctx.git_root),
1518
1560
  )
1519
1561
  if already_merged.returncode == 0:
1520
1562
  typer.echo(f"{branch_name} already merged into {feature}, skipping merge")
1521
1563
  else:
1522
1564
  # Check for uncommitted changes before attempting merge
1523
- uncommitted = check_uncommitted_changes(base)
1565
+ uncommitted = check_uncommitted_changes(ctx.git_root, ignore_kd=True)
1524
1566
  if uncommitted:
1525
1567
  print_error(
1526
1568
  f"Uncommitted changes on {git_branch_name} — commit or stash before accepting.\n"
@@ -1532,7 +1574,7 @@ def peasant_accept(
1532
1574
  ["git", "merge", branch_name, "--no-edit"],
1533
1575
  capture_output=True,
1534
1576
  text=True,
1535
- cwd=str(base),
1577
+ cwd=str(ctx.git_root),
1536
1578
  )
1537
1579
  if merge_result.returncode != 0:
1538
1580
  # Integration failed — keep in_review, show recovery steps
@@ -1558,9 +1600,36 @@ def peasant_accept(
1558
1600
  status="done",
1559
1601
  last_activity=datetime.now(UTC).isoformat(),
1560
1602
  )
1603
+ if not state.hand_mode:
1604
+ cleanup_accepted_peasant(ctx, branch_name)
1561
1605
  typer.echo(f"{full_ticket_id}: accepted — ticket closed")
1562
1606
 
1563
1607
 
1608
+ def cleanup_accepted_peasant(ctx: PeasantContext, branch_name: str) -> None:
1609
+ """Best-effort cleanup after accepting a peasant's work."""
1610
+ try:
1611
+ remove_worktree(ctx.base, ctx.full_ticket_id, git_root=ctx.git_root, feature=ctx.feature)
1612
+ typer.echo(f"Removed worktree for {ctx.full_ticket_id}")
1613
+ except FileNotFoundError:
1614
+ styled_echo(f"Warning: no worktree found for {ctx.full_ticket_id}; skipping worktree cleanup.", fg="yellow")
1615
+ except RuntimeError as exc:
1616
+ styled_echo(f"Warning: could not remove worktree for {ctx.full_ticket_id}: {exc}", fg="yellow")
1617
+
1618
+ result = subprocess.run(
1619
+ ["git", "branch", "-D", branch_name],
1620
+ capture_output=True,
1621
+ text=True,
1622
+ cwd=str(ctx.git_root),
1623
+ timeout=10,
1624
+ )
1625
+ if result.returncode == 0:
1626
+ typer.echo(f"Deleted branch {branch_name}")
1627
+ return
1628
+
1629
+ message = result.stderr.strip() or result.stdout.strip() or "unknown error"
1630
+ styled_echo(f"Warning: could not delete branch {branch_name}: {message}", fg="yellow")
1631
+
1632
+
1564
1633
  @peasant_app.command("reject", help="Reject a peasant's work with feedback.")
1565
1634
  def peasant_reject(
1566
1635
  ticket_id: Annotated[str, typer.Argument(help="Ticket ID.")],
@@ -1642,10 +1711,10 @@ def peasant_reject(
1642
1711
  "Stop it first or use --no-resume."
1643
1712
  )
1644
1713
  raise typer.Exit(code=1)
1645
- worktree_path = base
1714
+ worktree_path = ctx.git_root
1646
1715
  else:
1647
1716
  # Worktree mode: use the ticket worktree
1648
- worktree_path = worktree_path_for(base, full_ticket_id)
1717
+ worktree_path = existing_worktree_path_for(base, full_ticket_id, feature=feature)
1649
1718
  if not worktree_path.exists():
1650
1719
  print_error(f"worktree missing for {full_ticket_id}. Run `kd peasant start` to recreate.")
1651
1720
  raise typer.Exit(code=1)
@@ -344,10 +344,16 @@ def render_ticket_panel(
344
344
 
345
345
  @ticket_app.command("create", help="Create a new ticket.")
346
346
  def ticket_create(
347
- title: Annotated[str, typer.Argument(help="Ticket title.")],
347
+ title: Annotated[str | None, typer.Argument(help="Ticket title.")] = None,
348
+ short_title_or_type: Annotated[
349
+ str | None,
350
+ typer.Option("-t", help="Ticket title. With a positional title, treated as legacy ticket type."),
351
+ ] = None,
352
+ title_option: Annotated[str | None, typer.Option("--title", help="Ticket title.")] = None,
348
353
  description: Annotated[str | None, typer.Option("-d", "--description", help="Ticket description.")] = None,
354
+ body_text: Annotated[str | None, typer.Option("-b", "--body", help="Ticket description/body.")] = None,
349
355
  priority: Annotated[str, typer.Option("-p", "--priority", help="Priority (0-3 or p0-p3, 0 is highest).")] = "2",
350
- ticket_type: Annotated[str, typer.Option("-t", "--type", help="Ticket type (task, bug, feature, epic).")] = "task",
356
+ ticket_type: Annotated[str, typer.Option("--type", help="Ticket type (task, bug, feature, epic).")] = "task",
351
357
  backlog: Annotated[bool, typer.Option("--backlog", help="Create in backlog instead of current branch.")] = False,
352
358
  dep: Annotated[list[str] | None, typer.Option("--dep", help="Ticket ID(s) this depends on.")] = None,
353
359
  parent: Annotated[str | None, typer.Option("--parent", help="Parent ticket ID.")] = None,
@@ -359,6 +365,33 @@ def ticket_create(
359
365
 
360
366
  base = require_project_root()
361
367
 
368
+ if title and title_option:
369
+ print_error("Provide the ticket title either positionally or with --title, not both.")
370
+ raise typer.Exit(code=1)
371
+
372
+ if title_option and short_title_or_type:
373
+ print_error("Provide the ticket title with either -t or --title, not both.")
374
+ raise typer.Exit(code=1)
375
+
376
+ if title and short_title_or_type:
377
+ if ticket_type != "task":
378
+ print_error("Provide ticket type with either legacy -t shorthand or --type, not both.")
379
+ raise typer.Exit(code=1)
380
+ ticket_type = short_title_or_type
381
+ elif title_option:
382
+ title = title_option
383
+ elif short_title_or_type:
384
+ title = short_title_or_type
385
+
386
+ if not title:
387
+ print_error("Missing ticket title. Provide it positionally or with -t/--title.")
388
+ raise typer.Exit(code=1)
389
+
390
+ if description and body_text:
391
+ print_error("Provide ticket body with either --description or --body, not both.")
392
+ raise typer.Exit(code=1)
393
+ description = description or body_text
394
+
362
395
  # Parse and validate priority (accepts 0-3, p0-p3, P0-P3)
363
396
  from kingdom.ticket import clamp_priority
364
397
 
@@ -597,8 +630,12 @@ def ticket_show(
597
630
  ] = None,
598
631
  all_tickets: Annotated[bool, typer.Option("--all", "-a", help="Show all tickets on the current branch.")] = False,
599
632
  output_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
633
+ rich: Annotated[bool, typer.Option("--rich", help="Render the human-friendly Rich panel view.")] = False,
600
634
  ) -> None:
601
- """Display one or more tickets by ID (supports partial matching). With no args, shows ticket assigned to 'hand'."""
635
+ """Display one or more tickets as raw Markdown by default.
636
+
637
+ Use --rich for the framed human view. With no args, shows the ticket assigned to 'hand'.
638
+ """
602
639
  base = require_project_root()
603
640
 
604
641
  # Resolve tickets to show as (Ticket, Path) pairs
@@ -646,22 +683,41 @@ def ticket_show(
646
683
  ticket_to_json(ticket, detailed=True, base=base, path=ticket_path) for ticket, ticket_path in pairs
647
684
  ]
648
685
  typer.echo(json.dumps(results_json if len(results_json) > 1 else results_json[0], indent=2))
649
- else:
686
+ elif rich:
650
687
  console = Console()
651
688
  cached_tickets = collect_all_tickets(base) if len(pairs) > 1 else None
652
689
  for i, (ticket, ticket_path) in enumerate(pairs):
653
690
  if i > 0:
654
691
  console.print() # separator between tickets
655
692
  console.print(render_ticket_panel(ticket, ticket_path, base, all_tickets=cached_tickets))
693
+ else:
694
+ for i, (_, ticket_path) in enumerate(pairs):
695
+ if i > 0:
696
+ typer.echo()
697
+ typer.echo(ticket_path.read_text(encoding="utf-8").rstrip())
698
+ typer.echo()
699
+ typer.echo(f"File: {ticket_path.resolve()}")
700
+
701
+
702
+ @ticket_app.command("find", help="Find a ticket file by ID.")
703
+ def ticket_find(
704
+ ticket_id: Annotated[str, typer.Argument(help="Ticket ID (full or partial).")],
705
+ ) -> None:
706
+ """Print the full path to a ticket file."""
707
+ base = require_project_root()
708
+ _, ticket_path = resolve_ticket_or_exit(base, ticket_id)
709
+ typer.echo(ticket_path.resolve())
656
710
 
657
711
 
658
- def update_ticket_status(ticket_id: str, new_status: str) -> None:
712
+ def update_ticket_status(ticket_id: str, new_status: str, *, assignee: str | None = None) -> None:
659
713
  """Helper to update a ticket's status."""
660
714
  base = require_project_root()
661
715
 
662
716
  ticket, ticket_path = resolve_ticket_or_exit(base, ticket_id)
663
717
  old_status = ticket.status
664
718
  ticket.status = new_status
719
+ if assignee is not None:
720
+ ticket.assignee = assignee
665
721
  write_ticket(ticket, ticket_path)
666
722
 
667
723
  # Auto-archive: closing a backlog ticket moves it to archive/backlog/tickets/
@@ -690,8 +746,8 @@ def update_ticket_status(ticket_id: str, new_status: str) -> None:
690
746
  def ticket_start(
691
747
  ticket_id: Annotated[str, typer.Argument(help="Ticket ID (full or partial).")],
692
748
  ) -> None:
693
- """Set ticket status to in_progress."""
694
- update_ticket_status(ticket_id, "in_progress")
749
+ """Set ticket status to in_progress and assign it to the Hand."""
750
+ update_ticket_status(ticket_id, "in_progress", assignee="hand")
695
751
 
696
752
 
697
753
  @ticket_app.command("current", help="Show the in-progress ticket for this branch.")
@@ -113,8 +113,10 @@ def build_prompt(
113
113
  if ticket_id:
114
114
  parts.append(
115
115
  "Keep the ticket worklog updated as you work. Log important decisions, "
116
- "tradeoffs, things you noticed, or King input using "
117
- f'`kd tk log {ticket_id} "message"` or by editing the ticket file directly. '
116
+ "tradeoffs, things you noticed, or King input by editing the ticket "
117
+ f"Markdown directly after reading it. Use `kd tk show {ticket_id}` to "
118
+ f'print the raw ticket file; use `kd tk log {ticket_id} "message"` '
119
+ "only for quick one-off entries. "
118
120
  "The worklog is how the King stays informed about what you're doing and why."
119
121
  )
120
122
  else:
@@ -487,7 +487,8 @@ def build_lord_prompt(
487
487
  parts.append("`kd peasant status --json` — Check all active peasant statuses")
488
488
  parts.append("`kd peasant show <ticket-id> --json` — Show detailed peasant history")
489
489
  parts.append(f"`kd tk list --parent {epic_id}` — List all epic children")
490
- parts.append("`kd tk show <ticket-id>` — Show ticket details")
490
+ parts.append("`kd tk show <ticket-id>` — Print raw ticket Markdown")
491
+ parts.append("`kd tk show <ticket-id> --rich` — Show framed ticket details")
491
492
  parts.append("")
492
493
  parts.append("### Reviewing completed work")
493
494
  parts.append("`kd peasant review <ticket-id>` — Show diff, worklog, council feedback")