kingdom-cli 0.5.2__tar.gz → 0.6.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.
- {kingdom_cli-0.5.2/src/kingdom_cli.egg-info → kingdom_cli-0.6.0}/PKG-INFO +1 -1
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/pyproject.toml +1 -1
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/agent.py +48 -3
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/hook.py +113 -12
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/ticket.py +101 -13
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/SKILL.md +30 -4
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/tickets.md +5 -1
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/state.py +88 -2
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/ticket.py +15 -2
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0/src/kingdom_cli.egg-info}/PKG-INFO +1 -1
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_agent.py +75 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_lifecycle.py +72 -2
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_list.py +119 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_hook.py +206 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_state.py +13 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/LICENSE +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/README.md +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/setup.cfg +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/__init__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/__main__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/breakdown.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/__init__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/config.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/council.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/design.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/display.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/helpers.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/lord.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/peasant.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/plugin.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/config.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/__init__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/base.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/bundle.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/council.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/worker.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/design.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/harness.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/lord_harness.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/lord_worker.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/parsing.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/session.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/__init__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/__init__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/council.md +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/peasants.md +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/synthesis.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/thread.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/__init__.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/app.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/chat.tcss +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/clipboard.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/poll.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/terminal.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/widgets.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/worker.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/worktree.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/SOURCES.txt +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/requires.txt +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/top_level.txt +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_breakdown.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_council.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_design_breakdown.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_peasant.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_plugin.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_status.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_current.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_deps.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_links.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_show.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_worklog.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_config.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_council.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_design.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_done.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_epic.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_harness.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_init.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_lord.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_parsing.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_session.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_skill.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_stop_blocker.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_synthesis.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_thread.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_ticket.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_clipboard.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_integration.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_poll.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_reply.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_terminal.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_thinking_persistence.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_widgets.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_worker.py +0 -0
- {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kingdom-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kingdom-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.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.11"
|
|
@@ -10,14 +10,19 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
+
import os
|
|
13
14
|
import shlex
|
|
14
15
|
from collections.abc import Callable
|
|
15
16
|
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
16
18
|
|
|
17
19
|
from kingdom.config import AgentDef
|
|
18
20
|
|
|
19
21
|
logger = logging.getLogger(__name__)
|
|
20
22
|
|
|
23
|
+
CODEX_CLI_PATH_ENV = "CODEX_CLI_PATH"
|
|
24
|
+
CODEX_APP_CLI_PATH = Path("/Applications/Codex.app/Contents/Resources/codex")
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
# ---------------------------------------------------------------------------
|
|
23
28
|
# Backend defaults — CLI invocation details that live in code, not config
|
|
@@ -429,7 +434,16 @@ def build_codex_command(
|
|
|
429
434
|
if skip_permissions:
|
|
430
435
|
parts.insert(1, "--dangerously-bypass-approvals-and-sandbox")
|
|
431
436
|
else:
|
|
432
|
-
parts.extend(
|
|
437
|
+
parts.extend(
|
|
438
|
+
[
|
|
439
|
+
"-c",
|
|
440
|
+
'sandbox_permissions=["disk-full-read-access"]',
|
|
441
|
+
"-c",
|
|
442
|
+
"mcp_servers={}",
|
|
443
|
+
"-c",
|
|
444
|
+
"plugins={}",
|
|
445
|
+
]
|
|
446
|
+
)
|
|
433
447
|
if config.model:
|
|
434
448
|
parts.extend(["--model", config.model])
|
|
435
449
|
if config.reasoning_effort:
|
|
@@ -496,6 +510,38 @@ COMMAND_BUILDERS: dict[str, CommandBuilder] = {
|
|
|
496
510
|
}
|
|
497
511
|
|
|
498
512
|
|
|
513
|
+
def executable_file(path: Path) -> bool:
|
|
514
|
+
"""Return True when path points at an executable file."""
|
|
515
|
+
return path.is_file() and os.access(path, os.X_OK)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def find_codex_cli_path(env: dict[str, str]) -> Path | None:
|
|
519
|
+
"""Find the preferred Codex CLI binary for spawned agent processes."""
|
|
520
|
+
configured = env.get(CODEX_CLI_PATH_ENV)
|
|
521
|
+
if configured and executable_file(Path(configured)):
|
|
522
|
+
return Path(configured)
|
|
523
|
+
if executable_file(CODEX_APP_CLI_PATH):
|
|
524
|
+
return CODEX_APP_CLI_PATH
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def prepend_path_entry(path_value: str, entry: Path) -> str:
|
|
529
|
+
"""Prepend entry to PATH unless it is already present."""
|
|
530
|
+
entry_text = str(entry)
|
|
531
|
+
parts = [part for part in path_value.split(os.pathsep) if part]
|
|
532
|
+
parts = [part for part in parts if part != entry_text]
|
|
533
|
+
return os.pathsep.join([entry_text, *parts])
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def prefer_codex_cli_path(env: dict[str, str]) -> None:
|
|
537
|
+
"""Make plain ``codex`` resolve to the preferred Codex CLI binary."""
|
|
538
|
+
codex_cli = find_codex_cli_path(env)
|
|
539
|
+
if codex_cli is None:
|
|
540
|
+
return
|
|
541
|
+
env[CODEX_CLI_PATH_ENV] = str(codex_cli)
|
|
542
|
+
env["PATH"] = prepend_path_entry(env.get("PATH", ""), codex_cli.parent)
|
|
543
|
+
|
|
544
|
+
|
|
499
545
|
def clean_agent_env(
|
|
500
546
|
role: str | None = None,
|
|
501
547
|
agent_name: str | None = None,
|
|
@@ -510,9 +556,8 @@ def clean_agent_env(
|
|
|
510
556
|
``KD_BASE`` ensures worktree agents resolve the project root against the
|
|
511
557
|
main repo, not the worktree's copy of ``.kd/``.
|
|
512
558
|
"""
|
|
513
|
-
import os
|
|
514
|
-
|
|
515
559
|
env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
|
|
560
|
+
prefer_codex_cli_path(env)
|
|
516
561
|
if role:
|
|
517
562
|
env["KD_ROLE"] = role
|
|
518
563
|
if agent_name:
|
|
@@ -17,6 +17,17 @@ from pathlib import Path
|
|
|
17
17
|
|
|
18
18
|
import typer
|
|
19
19
|
|
|
20
|
+
from kingdom.state import (
|
|
21
|
+
archive_root,
|
|
22
|
+
backlog_root,
|
|
23
|
+
branch_root,
|
|
24
|
+
find_project_root,
|
|
25
|
+
normalize_branch_name,
|
|
26
|
+
read_terminal_ticket_context,
|
|
27
|
+
resolve_current_run,
|
|
28
|
+
)
|
|
29
|
+
from kingdom.ticket import read_ticket
|
|
30
|
+
|
|
20
31
|
hook_app = typer.Typer(name="hook", help="Claude Code hook handlers (internal).")
|
|
21
32
|
|
|
22
33
|
# ---------------------------------------------------------------------------
|
|
@@ -36,6 +47,7 @@ SESSION_START_BRIEF = (
|
|
|
36
47
|
|
|
37
48
|
USER_PROMPT_REMINDER = (
|
|
38
49
|
"Kingdom: keep the ticket accurate (body/AC/status/worklog)."
|
|
50
|
+
" Prefer the last ticket started in this terminal with `kd tk start` when logging."
|
|
39
51
|
" Requirement or acceptance-criteria change -> edit ticket markdown now."
|
|
40
52
|
" Work/findings -> kd tk log. New bug/scope -> kd tk create|move."
|
|
41
53
|
)
|
|
@@ -74,6 +86,100 @@ def write_turn_state(path: Path, state: dict) -> None:
|
|
|
74
86
|
path.write_text(json.dumps(state))
|
|
75
87
|
|
|
76
88
|
|
|
89
|
+
def tool_file_path(data: dict) -> str | None:
|
|
90
|
+
file_path = data.get("tool_input", {}).get("file_path")
|
|
91
|
+
if isinstance(file_path, str) and file_path:
|
|
92
|
+
return file_path
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_ticket_markdown_path(file_path: str, project_dir: str | None) -> bool:
|
|
97
|
+
path = Path(file_path)
|
|
98
|
+
if not path.is_absolute():
|
|
99
|
+
base = Path(project_dir) if project_dir else Path(".")
|
|
100
|
+
path = base / path
|
|
101
|
+
|
|
102
|
+
path = path.resolve(strict=False)
|
|
103
|
+
return path.suffix == ".md" and path.parent.name == "tickets" and ".kd" in path.parts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_ticket_markdown_tool_use(data: dict, project_dir: str | None) -> bool:
|
|
107
|
+
file_path = tool_file_path(data)
|
|
108
|
+
return file_path is not None and is_ticket_markdown_path(file_path, project_dir)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ticket_paths_for_terminal_context(base: Path, ticket_id: str, feature: str, location: str | None) -> list[Path]:
|
|
112
|
+
if location == "backlog":
|
|
113
|
+
tickets_dir = backlog_root(base) / "tickets"
|
|
114
|
+
elif location and location.startswith("archive:"):
|
|
115
|
+
tickets_dir = archive_root(base) / location.removeprefix("archive:") / "tickets"
|
|
116
|
+
elif location and location.startswith("branch:"):
|
|
117
|
+
tickets_dir = branch_root(base, location.removeprefix("branch:")) / "tickets"
|
|
118
|
+
else:
|
|
119
|
+
tickets_dir = branch_root(base, feature) / "tickets"
|
|
120
|
+
|
|
121
|
+
return [tickets_dir / f"{ticket_id}.md", tickets_dir / f"kin-{ticket_id}.md"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def terminal_context_ticket_is_current(base: Path, ticket_id: str, feature: str, location: str | None = None) -> bool:
|
|
125
|
+
candidate_paths = ticket_paths_for_terminal_context(base, ticket_id, feature, location)
|
|
126
|
+
for ticket_path in candidate_paths:
|
|
127
|
+
try:
|
|
128
|
+
ticket = read_ticket(ticket_path)
|
|
129
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
130
|
+
continue
|
|
131
|
+
return (
|
|
132
|
+
ticket.id == ticket_id
|
|
133
|
+
and ticket.status == "in_progress"
|
|
134
|
+
and not (ticket.assignee or "").startswith("peasant-")
|
|
135
|
+
)
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def terminal_context_base(project_dir: str | None) -> Path:
|
|
140
|
+
fallback = Path(project_dir) if project_dir else Path(".")
|
|
141
|
+
try:
|
|
142
|
+
return find_project_root(fallback)
|
|
143
|
+
except ValueError:
|
|
144
|
+
return fallback
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def find_stop_ticket_id(project_dir: str | None, session_id: str) -> str | None:
|
|
148
|
+
base = terminal_context_base(project_dir)
|
|
149
|
+
terminal_context = read_terminal_ticket_context(base, session_id=session_id)
|
|
150
|
+
if terminal_context:
|
|
151
|
+
try:
|
|
152
|
+
current_feature = normalize_branch_name(resolve_current_run(base))
|
|
153
|
+
except (RuntimeError, ValueError):
|
|
154
|
+
current_feature = None
|
|
155
|
+
ticket_id = terminal_context["ticket_id"]
|
|
156
|
+
if (
|
|
157
|
+
current_feature
|
|
158
|
+
and terminal_context.get("feature") == current_feature
|
|
159
|
+
and terminal_context_ticket_is_current(
|
|
160
|
+
base,
|
|
161
|
+
ticket_id,
|
|
162
|
+
current_feature,
|
|
163
|
+
terminal_context.get("location"),
|
|
164
|
+
)
|
|
165
|
+
):
|
|
166
|
+
return ticket_id
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
proc = subprocess.run(
|
|
170
|
+
["kd", "tk", "current", "--id", "--exclude-peasant"],
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
timeout=5,
|
|
174
|
+
)
|
|
175
|
+
ticket_id = proc.stdout.strip()
|
|
176
|
+
if proc.returncode != 0 or not ticket_id:
|
|
177
|
+
return None
|
|
178
|
+
return ticket_id
|
|
179
|
+
except Exception:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
77
183
|
# ---------------------------------------------------------------------------
|
|
78
184
|
# Event handlers — each returns a string to print to stdout (or empty).
|
|
79
185
|
# ---------------------------------------------------------------------------
|
|
@@ -130,10 +236,14 @@ def handle_post_tool_use(data: dict) -> str:
|
|
|
130
236
|
return ""
|
|
131
237
|
|
|
132
238
|
tool = data.get("tool_name", "")
|
|
239
|
+
ticket_markdown_edit = is_ticket_markdown_tool_use(data, project_dir)
|
|
133
240
|
|
|
134
|
-
if tool in WORK_TOOLS:
|
|
241
|
+
if tool in WORK_TOOLS and not ticket_markdown_edit:
|
|
135
242
|
state["had_work"] = True
|
|
136
243
|
|
|
244
|
+
if ticket_markdown_edit:
|
|
245
|
+
state["did_log"] = True
|
|
246
|
+
|
|
137
247
|
if tool == "Bash":
|
|
138
248
|
cmd = data.get("tool_input", {}).get("command", "")
|
|
139
249
|
if "kd tk log" in cmd or "kd ticket log" in cmd:
|
|
@@ -162,17 +272,8 @@ def handle_stop(data: dict) -> str:
|
|
|
162
272
|
return ""
|
|
163
273
|
|
|
164
274
|
# Check for an active ticket (fail-open on any error).
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
["kd", "tk", "current", "--id", "--exclude-peasant"],
|
|
168
|
-
capture_output=True,
|
|
169
|
-
text=True,
|
|
170
|
-
timeout=5,
|
|
171
|
-
)
|
|
172
|
-
ticket_id = proc.stdout.strip()
|
|
173
|
-
if proc.returncode != 0 or not ticket_id:
|
|
174
|
-
return "" # No active ticket — fail open.
|
|
175
|
-
except Exception:
|
|
275
|
+
ticket_id = find_stop_ticket_id(project_dir, session_id)
|
|
276
|
+
if not ticket_id:
|
|
176
277
|
return "" # Timeout or error — fail open.
|
|
177
278
|
|
|
178
279
|
result = {
|
|
@@ -27,6 +27,7 @@ from kingdom.state import (
|
|
|
27
27
|
branch_root,
|
|
28
28
|
branches_root,
|
|
29
29
|
normalize_branch_name,
|
|
30
|
+
record_terminal_ticket_context,
|
|
30
31
|
resolve_current_run,
|
|
31
32
|
)
|
|
32
33
|
from kingdom.ticket import (
|
|
@@ -471,6 +472,11 @@ def ticket_list(
|
|
|
471
472
|
bool, typer.Option("--include-done", help="Include tickets from done branches (with --all).")
|
|
472
473
|
] = False,
|
|
473
474
|
include_closed: Annotated[bool, typer.Option("--closed", help="Include closed tickets in output.")] = False,
|
|
475
|
+
recently_closed: Annotated[
|
|
476
|
+
bool,
|
|
477
|
+
typer.Option("--recently-closed", "--recent", help="Show closed tickets ordered by most recent closure."),
|
|
478
|
+
] = False,
|
|
479
|
+
limit: Annotated[int | None, typer.Option("--limit", "-n", help="Limit the number of tickets shown.")] = None,
|
|
474
480
|
ready: Annotated[bool, typer.Option("--ready", help="Show only tickets ready to work (no open deps).")] = False,
|
|
475
481
|
blocked: Annotated[bool, typer.Option("--blocked", help="Show only tickets blocked by open deps.")] = False,
|
|
476
482
|
status: Annotated[
|
|
@@ -497,6 +503,14 @@ def ticket_list(
|
|
|
497
503
|
print_error("--ready and --blocked are mutually exclusive.")
|
|
498
504
|
raise typer.Exit(code=1)
|
|
499
505
|
|
|
506
|
+
if recently_closed and (ready or blocked):
|
|
507
|
+
print_error("--recently-closed cannot be combined with --ready or --blocked.")
|
|
508
|
+
raise typer.Exit(code=1)
|
|
509
|
+
|
|
510
|
+
if limit is not None and limit < 1:
|
|
511
|
+
print_error("--limit must be 1 or greater.")
|
|
512
|
+
raise typer.Exit(code=1)
|
|
513
|
+
|
|
500
514
|
# --jq implies --json
|
|
501
515
|
if jq_filter:
|
|
502
516
|
output_json = True
|
|
@@ -507,17 +521,31 @@ def ticket_list(
|
|
|
507
521
|
print_error(f"Invalid status '{status}'. Valid statuses: {', '.join(sorted(STATUSES))}")
|
|
508
522
|
raise typer.Exit(code=1)
|
|
509
523
|
|
|
524
|
+
if recently_closed:
|
|
525
|
+
if status is not None and status != "closed":
|
|
526
|
+
print_error("--recently-closed can only be combined with --status closed.")
|
|
527
|
+
raise typer.Exit(code=1)
|
|
528
|
+
status = "closed"
|
|
529
|
+
include_closed = True
|
|
530
|
+
|
|
510
531
|
if priority is not None and priority not in (0, 1, 2, 3):
|
|
511
532
|
print_error(f"Invalid priority {priority}. Must be 0, 1, 2, or 3.")
|
|
512
533
|
raise typer.Exit(code=1)
|
|
513
534
|
|
|
535
|
+
def order_and_limit(tickets: list[Ticket]) -> list[Ticket]:
|
|
536
|
+
if recently_closed:
|
|
537
|
+
tickets = sorted(tickets, key=lambda ticket: ticket.closed_at or ticket.created, reverse=True)
|
|
538
|
+
if limit is not None:
|
|
539
|
+
tickets = tickets[:limit]
|
|
540
|
+
return tickets
|
|
541
|
+
|
|
514
542
|
def apply_all_filters(tickets: list[Ticket], status_by_id: dict[str, str]) -> list[Ticket]:
|
|
515
543
|
filtered = filter_tickets_by_status(tickets, status, include_closed)
|
|
516
544
|
filtered = filter_tickets(filtered, assignee=assignee, tag=tag, priority=priority)
|
|
517
545
|
filtered = filter_tickets_by_deps(filtered, status_by_id, ready=ready, blocked=blocked)
|
|
518
546
|
if resolved_parent_id:
|
|
519
547
|
filtered = [t for t in filtered if t.parent == resolved_parent_id]
|
|
520
|
-
return filtered
|
|
548
|
+
return order_and_limit(filtered)
|
|
521
549
|
|
|
522
550
|
def output_tickets_json(tickets: list[Ticket], location_map: dict[str, str] | None = None) -> None:
|
|
523
551
|
"""Output tickets as JSON, optionally piping through jq."""
|
|
@@ -557,20 +585,34 @@ def ticket_list(
|
|
|
557
585
|
|
|
558
586
|
# Build a global status lookup for dep-based filtering and dep annotations.
|
|
559
587
|
# Always include done branches so deps in done branches get correct ✓ marks.
|
|
560
|
-
all_known_tickets = collect_all_tickets(base, include_done=True)
|
|
588
|
+
all_known_tickets = collect_all_tickets(base, include_archive=recently_closed, include_done=True)
|
|
561
589
|
status_by_id = {t.id: t.status for t in all_known_tickets}
|
|
562
590
|
|
|
563
591
|
if backlog:
|
|
564
592
|
backlog_dir = backlog_root(base) / "tickets"
|
|
565
|
-
|
|
593
|
+
backlog_pairs: list[tuple[str, Ticket]] = []
|
|
594
|
+
if backlog_dir.exists():
|
|
595
|
+
backlog_pairs.extend(("backlog", ticket) for ticket in list_tickets(backlog_dir))
|
|
596
|
+
if recently_closed:
|
|
597
|
+
archive_backlog_dir = archive_root(base) / "backlog" / "tickets"
|
|
598
|
+
if archive_backlog_dir.exists():
|
|
599
|
+
backlog_pairs.extend(("archive:backlog", ticket) for ticket in list_tickets(archive_backlog_dir))
|
|
600
|
+
all_backlog_tickets = [ticket for _, ticket in backlog_pairs]
|
|
566
601
|
tickets = apply_all_filters(all_backlog_tickets, status_by_id)
|
|
602
|
+
filtered_ids = {ticket.id for ticket in tickets}
|
|
603
|
+
location_map = {
|
|
604
|
+
ticket.id: location_name for location_name, ticket in backlog_pairs if ticket.id in filtered_ids
|
|
605
|
+
}
|
|
567
606
|
|
|
568
607
|
if output_json:
|
|
569
|
-
output_tickets_json(tickets,
|
|
608
|
+
output_tickets_json(tickets, location_map)
|
|
570
609
|
else:
|
|
571
610
|
if not tickets:
|
|
572
|
-
|
|
573
|
-
|
|
611
|
+
if recently_closed:
|
|
612
|
+
typer.echo("No recently closed backlog tickets.")
|
|
613
|
+
elif (closed_count := sum(1 for t in all_backlog_tickets if t.status == "closed")) and (
|
|
614
|
+
not include_closed and status is None and not ready and not blocked
|
|
615
|
+
):
|
|
574
616
|
typer.echo(f"No open backlog tickets ({closed_count} closed). Use --closed to show them.")
|
|
575
617
|
else:
|
|
576
618
|
typer.echo('No backlog tickets. Create one with `kd tk create --backlog "title"`.')
|
|
@@ -580,7 +622,7 @@ def ticket_list(
|
|
|
580
622
|
return
|
|
581
623
|
|
|
582
624
|
if all_tickets:
|
|
583
|
-
pairs = collect_tickets_by_location(base, include_done=include_done)
|
|
625
|
+
pairs = collect_tickets_by_location(base, include_archive=recently_closed, include_done=include_done)
|
|
584
626
|
all_filtered: list[Ticket] = []
|
|
585
627
|
location_map: dict[str, str] = {}
|
|
586
628
|
for location_name, ticket in pairs:
|
|
@@ -595,8 +637,11 @@ def ticket_list(
|
|
|
595
637
|
output_tickets_json(all_filtered, location_map)
|
|
596
638
|
else:
|
|
597
639
|
if not all_filtered:
|
|
598
|
-
|
|
599
|
-
|
|
640
|
+
if recently_closed:
|
|
641
|
+
typer.echo("No recently closed tickets found across any branch or backlog.")
|
|
642
|
+
elif (closed_count := sum(1 for _, t in pairs if t.status == "closed")) and (
|
|
643
|
+
not include_closed and status is None and not ready and not blocked
|
|
644
|
+
):
|
|
600
645
|
typer.echo(f"No open tickets ({closed_count} closed). Use --closed to show them.")
|
|
601
646
|
else:
|
|
602
647
|
typer.echo('No tickets found across any branch or backlog. Create one with `kd tk create "title"`.')
|
|
@@ -613,8 +658,11 @@ def ticket_list(
|
|
|
613
658
|
output_tickets_json(tickets)
|
|
614
659
|
else:
|
|
615
660
|
if not tickets:
|
|
616
|
-
|
|
617
|
-
|
|
661
|
+
if recently_closed:
|
|
662
|
+
typer.echo("No recently closed tickets found on this branch.")
|
|
663
|
+
elif (closed_count := sum(1 for t in all_branch_tickets if t.status == "closed")) and (
|
|
664
|
+
not include_closed and status is None and not ready and not blocked
|
|
665
|
+
):
|
|
618
666
|
typer.echo(f"No open tickets ({closed_count} closed). Use --closed to show them.")
|
|
619
667
|
else:
|
|
620
668
|
typer.echo('No tickets found. Create one with `kd tk create "title"`.')
|
|
@@ -709,7 +757,7 @@ def ticket_find(
|
|
|
709
757
|
typer.echo(ticket_path.resolve())
|
|
710
758
|
|
|
711
759
|
|
|
712
|
-
def update_ticket_status(ticket_id: str, new_status: str, *, assignee: str | None = None) ->
|
|
760
|
+
def update_ticket_status(ticket_id: str, new_status: str, *, assignee: str | None = None) -> Ticket:
|
|
713
761
|
"""Helper to update a ticket's status."""
|
|
714
762
|
base = require_project_root()
|
|
715
763
|
|
|
@@ -741,13 +789,53 @@ def update_ticket_status(ticket_id: str, new_status: str, *, assignee: str | Non
|
|
|
741
789
|
for t in unblocked:
|
|
742
790
|
typer.echo(f" {t.id} [P{t.priority}] — {t.title}")
|
|
743
791
|
|
|
792
|
+
return ticket
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def terminal_context_location_for_start(base: Path, ticket_path: Path) -> str:
|
|
796
|
+
backlog_tickets = (backlog_root(base) / "tickets").resolve()
|
|
797
|
+
archive_backlog_tickets = (archive_root(base) / "backlog" / "tickets").resolve()
|
|
798
|
+
ticket_parent = ticket_path.parent.resolve()
|
|
799
|
+
|
|
800
|
+
if ticket_parent in (backlog_tickets, archive_backlog_tickets):
|
|
801
|
+
return "backlog"
|
|
802
|
+
|
|
803
|
+
archive = archive_root(base).resolve()
|
|
804
|
+
try:
|
|
805
|
+
archived = ticket_parent.relative_to(archive)
|
|
806
|
+
except ValueError:
|
|
807
|
+
pass
|
|
808
|
+
else:
|
|
809
|
+
if len(archived.parts) >= 2 and archived.parts[1] == "tickets":
|
|
810
|
+
return f"archive:{archived.parts[0]}"
|
|
811
|
+
|
|
812
|
+
branches = branches_root(base).resolve()
|
|
813
|
+
try:
|
|
814
|
+
relative = ticket_parent.relative_to(branches)
|
|
815
|
+
except ValueError:
|
|
816
|
+
return f"branch:{normalize_branch_name(resolve_current_run(base))}"
|
|
817
|
+
|
|
818
|
+
if len(relative.parts) >= 2 and relative.parts[1] == "tickets":
|
|
819
|
+
return f"branch:{relative.parts[0]}"
|
|
820
|
+
return f"branch:{normalize_branch_name(resolve_current_run(base))}"
|
|
821
|
+
|
|
744
822
|
|
|
745
823
|
@ticket_app.command("start", help="Mark a ticket as in_progress.")
|
|
746
824
|
def ticket_start(
|
|
747
825
|
ticket_id: Annotated[str, typer.Argument(help="Ticket ID (full or partial).")],
|
|
748
826
|
) -> None:
|
|
749
827
|
"""Set ticket status to in_progress and assign it to the Hand."""
|
|
750
|
-
|
|
828
|
+
base = require_project_root()
|
|
829
|
+
try:
|
|
830
|
+
feature = resolve_current_run(base)
|
|
831
|
+
except RuntimeError:
|
|
832
|
+
print_error("No active session. Use `kd start` first.")
|
|
833
|
+
raise typer.Exit(code=1) from None
|
|
834
|
+
|
|
835
|
+
_, ticket_path = resolve_ticket_or_exit(base, ticket_id)
|
|
836
|
+
location = terminal_context_location_for_start(base, ticket_path)
|
|
837
|
+
ticket = update_ticket_status(ticket_id, "in_progress", assignee="hand")
|
|
838
|
+
record_terminal_ticket_context(base, ticket.id, feature=feature, location=location)
|
|
751
839
|
|
|
752
840
|
|
|
753
841
|
@ticket_app.command("current", help="Show the in-progress ticket for this branch.")
|
|
@@ -69,7 +69,7 @@ kd council ask "Should we use WebSockets or SSE for real-time updates? We need l
|
|
|
69
69
|
|
|
70
70
|
**King says "ask the council."** Every `kd council ask` creates a new thread by default. If the conversation is a continuation of an active design discussion, use `kd council ask --continue "follow-up question"` to append to the current thread. When ambiguous, check existing threads with `kd council list` or `kd council show <thread>`, or ask the King a brief clarifying question before running the command.
|
|
71
71
|
|
|
72
|
-
**King wants to note progress.** Update the active ticket's Markdown directly
|
|
72
|
+
**King wants to note progress.** Update the active ticket's Markdown directly for anything more than a short one-line note. Use `kd tk log` only for quick one-off worklog entries:
|
|
73
73
|
|
|
74
74
|
```
|
|
75
75
|
kd tk log ab12 "Finished the API refactor, all endpoint tests passing"
|
|
@@ -123,7 +123,7 @@ Use A when the King wants to be hands-on. Use B for throughput on well-scoped ti
|
|
|
123
123
|
This applies regardless of execution mode.
|
|
124
124
|
|
|
125
125
|
- **One at a time per worker**: `kd tk start <id>` → do the work → `kd tk close <id>` → commit → next ticket.
|
|
126
|
-
- **Worklog**: append progress notes to the ticket's `## Worklog` section as you go.
|
|
126
|
+
- **Worklog**: append progress notes to the ticket's `## Worklog` section as you go. Edit the ticket Markdown file directly when making ticket updates in a turn; use `kd tk log` only for a quick one-off entry. Log what you're doing, what you found, commands and results, decisions and why. The King reads these to stay informed — don't make them ask.
|
|
127
127
|
- **Acceptance criteria**: only close a ticket when all acceptance criteria are met and the full test suite is green.
|
|
128
128
|
- **Decisions**: ask the King or consult the council (`kd council ask "..."`) for difficult design decisions — don't guess. Never silently resolve ambiguity on architectural, product, or UX tradeoffs.
|
|
129
129
|
- **For bugs: test BEFORE you fix!** Write a test that fails in the current state, fix it, then verify.
|
|
@@ -132,17 +132,42 @@ This applies regardless of execution mode.
|
|
|
132
132
|
- **Commit often**: commit code changes as you go. Commit `.kd/` changes (ticket state, worklogs) alongside code.
|
|
133
133
|
- **One-off tests**: write a script or temp file to test an end-to-end flow with real data. Then consider if it can be an automated integration test.
|
|
134
134
|
|
|
135
|
+
## Ticket Markdown Is the Source of Truth
|
|
136
|
+
|
|
137
|
+
Ticket files are plain Markdown and are meant to be edited directly. Direct file edits are normal, expected, and often the best tool for ticket content changes.
|
|
138
|
+
|
|
139
|
+
Use `kd` for lifecycle and discovery: create, pull, move, start, close, reopen, assign, deps, list, show, and find. For creation, prefer making a quick stub with the CLI, then immediately editing the ticket Markdown for the real content:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
kd tk create "Short working title"
|
|
143
|
+
kd tk find <id>
|
|
144
|
+
# edit the printed Markdown file directly
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Keep CLI flags minimal when creating the stub. Rich descriptions, acceptance criteria, investigation notes, and scope changes belong in the Markdown file where agents can write clearly.
|
|
148
|
+
|
|
149
|
+
Once you need to change ticket content, use `kd tk find <id>` or the `File:` line from `kd tk show <id>` to locate the file, then edit the Markdown directly.
|
|
150
|
+
|
|
151
|
+
Edit the Markdown file directly for:
|
|
152
|
+
|
|
153
|
+
- ticket descriptions, requirements, and scope changes
|
|
154
|
+
- acceptance criteria additions, rewrites, and checkbox updates
|
|
155
|
+
- multi-line worklog entries with context, evidence, commands, and decisions
|
|
156
|
+
- correcting stale or misleading ticket text
|
|
157
|
+
|
|
158
|
+
Do not search for a CLI command to encode every Markdown edit. If you already know the ticket file path and the change belongs in the ticket body, patch the file.
|
|
159
|
+
|
|
135
160
|
## Automatic Worklog Updates
|
|
136
161
|
|
|
137
162
|
Proactively update the ticket worklog whenever a durable state change occurs. The King should never have to say "update the worklog." Log against the active ticket without asking which ticket — you know what you're working on.
|
|
138
163
|
|
|
139
164
|
The threshold is **durable state change**, not every chat turn. If future-you or another agent would need this fact, log it now.
|
|
140
165
|
|
|
141
|
-
**Log before you continue.** When you discover durable information — a relevant issue, a root cause, a key finding from research — update the ticket immediately, before continuing your work.
|
|
166
|
+
**Log before you continue.** When you discover durable information — a relevant issue, a root cause, a key finding from research — update the ticket immediately, before continuing your work. Edit the ticket Markdown directly after reading the file; use `kd tk log` only for short one-off entries. The worklog survives context compaction; chat doesn't. Don't let valuable findings exist only in conversation history that will be compressed away.
|
|
142
167
|
|
|
143
168
|
**Edit ticket body vs worklog.** If the King changes requirements or acceptance criteria, update the ticket's Markdown directly (description, AC section). The worklog is for timeline events — decisions, findings, progress, blockers. Don't stuff requirements into `kd tk log`; edit the ticket file instead.
|
|
144
169
|
|
|
145
|
-
**LLM-friendly ticket reading.** `kd tk show <id>` prints raw ticket Markdown by default so agents can read the same file they should edit. The final `File:` line gives the ticket path; when you only need that path, use `kd tk find <id>`, which searches branch, backlog, and closed/archive tickets and prints the full file path. This also works from a sibling git worktree whose checkout does not have `.kd/`. Use `kd tk show <id> --rich` only when a framed human display is useful.
|
|
170
|
+
**LLM-friendly ticket reading.** `kd tk show <id>` prints raw ticket Markdown by default so agents can read the same file they should edit. The final `File:` line gives the ticket path; when you only need that path, use `kd tk find <id>`, which searches branch, backlog, and closed/archive tickets and prints the full file path. After you have the path, edit that file directly for content changes. This also works from a sibling git worktree whose checkout does not have `.kd/`. Use `kd tk show <id> --rich` only when a framed human display is useful.
|
|
146
171
|
|
|
147
172
|
**Decision made** — King says "let's go with raw TypeScript over React":
|
|
148
173
|
|
|
@@ -242,6 +267,7 @@ Accept is idempotent: if the ticket branch is already merged into the feature br
|
|
|
242
267
|
kd start / status / done # branch lifecycle
|
|
243
268
|
kd design show / design approve # design doc
|
|
244
269
|
kd tk list / show / find / list --ready # inspect tickets; find prints the ticket file path
|
|
270
|
+
kd tk list --recently-closed --limit 10 # inspect recently completed work
|
|
245
271
|
kd tk start <id> / close <id> # work a ticket
|
|
246
272
|
kd tk current # show active ticket
|
|
247
273
|
kd tk pull <id>... # pull from backlog
|
|
@@ -62,6 +62,8 @@ kd tk unassign <id> # clear assignment
|
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
64
|
kd tk list # list all tickets on current branch
|
|
65
|
+
kd tk list --recently-closed # show closed tickets ordered by most recent close
|
|
66
|
+
kd tk list --recent --limit 10 # short alias plus result limit
|
|
65
67
|
kd tk show <id> # print raw ticket Markdown (LLM-friendly)
|
|
66
68
|
kd tk show <id> --rich # show framed human-friendly ticket details
|
|
67
69
|
kd tk move <id> --to <branch|backlog> # move to another branch or backlog
|
|
@@ -76,6 +78,8 @@ The backlog at `.kd/backlog/tickets/` holds tickets not assigned to any branch.
|
|
|
76
78
|
|
|
77
79
|
- **Commit `.kd/` changes as you go** — ticket state changes, closures, and moves are tracked in git
|
|
78
80
|
- **Use dependencies** to enforce ordering when tickets have prerequisites
|
|
81
|
+
- **Edit ticket Markdown directly** for body text, acceptance criteria, scope changes, and multi-line worklog entries
|
|
82
|
+
- **Use `kd tk log` only for short one-off worklog notes**; do not contort ticket content edits through CLI commands
|
|
79
83
|
- **Append a work log** to the ticket body when closing — record key decisions and what was done
|
|
80
|
-
- **Prefer direct Markdown edits** when agents need to update ticket body, acceptance criteria, and worklog repeatedly in one turn
|
|
81
84
|
- **Use `kd tk list --ready`** to find the next unblocked ticket rather than picking arbitrarily
|
|
85
|
+
- **Use `kd tk list --recently-closed --limit 10`** when reviewing recently completed work
|