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.
- {kingdom_cli-0.5.0/src/kingdom_cli.egg-info → kingdom_cli-0.5.2}/PKG-INFO +3 -4
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/README.md +1 -1
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/pyproject.toml +2 -3
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/__init__.py +6 -10
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/helpers.py +48 -25
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/peasant.py +100 -31
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/ticket.py +63 -7
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/harness.py +4 -2
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/lord_harness.py +2 -1
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/SKILL.md +10 -8
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/tickets.md +8 -1
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/state.py +116 -40
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/worktree.py +65 -14
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2/src/kingdom_cli.egg-info}/PKG-INFO +3 -4
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli.py +53 -10
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_peasant.py +282 -2
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_current.py +27 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_lifecycle.py +148 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_list.py +14 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_show.py +51 -17
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_epic.py +1 -1
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_init.py +88 -10
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_state.py +98 -6
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_worktree.py +71 -5
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/LICENSE +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/setup.cfg +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/__init__.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/__main__.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/agent.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/breakdown.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/config.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/council.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/design.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/display.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/hook.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/lord.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/cli/plugin.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/config.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/__init__.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/base.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/bundle.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/council.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/council/worker.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/design.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/lord_worker.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/parsing.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/session.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/__init__.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/__init__.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/council.md +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/skill/references/peasants.md +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/synthesis.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/thread.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/ticket.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/__init__.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/app.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/chat.tcss +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/clipboard.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/poll.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/terminal.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/tui/widgets.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom/worker.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/SOURCES.txt +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/requires.txt +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/src/kingdom_cli.egg-info/top_level.txt +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_agent.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_breakdown.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_council.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_design_breakdown.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_plugin.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_status.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_deps.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_links.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_cli_ticket_worklog.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_config.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_council.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_design.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_done.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_harness.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_hook.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_lord.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_parsing.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_session.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_skill.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_stop_blocker.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_synthesis.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_thread.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_ticket.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_clipboard.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_integration.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_poll.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_reply.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_terminal.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_thinking_persistence.py +0 -0
- {kingdom_cli-0.5.0 → kingdom_cli-0.5.2}/tests/test_tui_widgets.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kingdom-cli"
|
|
3
|
-
version = "0.5.
|
|
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.
|
|
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
|
|
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
|
-
|
|
535
|
-
if
|
|
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
|
-
|
|
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
|
|
120
|
-
"""
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
153
|
-
|
|
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(
|
|
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 {
|
|
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 =
|
|
339
|
-
typer.echo(f"Running in hand mode (serial) on {
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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"
|
|
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.
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
1714
|
+
worktree_path = ctx.git_root
|
|
1646
1715
|
else:
|
|
1647
1716
|
# Worktree mode: use the ticket worktree
|
|
1648
|
-
worktree_path =
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
f
|
|
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>` —
|
|
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")
|