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.
Files changed (99) hide show
  1. {kingdom_cli-0.5.2/src/kingdom_cli.egg-info → kingdom_cli-0.6.0}/PKG-INFO +1 -1
  2. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/pyproject.toml +1 -1
  3. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/agent.py +48 -3
  4. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/hook.py +113 -12
  5. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/ticket.py +101 -13
  6. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/SKILL.md +30 -4
  7. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/tickets.md +5 -1
  8. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/state.py +88 -2
  9. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/ticket.py +15 -2
  10. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0/src/kingdom_cli.egg-info}/PKG-INFO +1 -1
  11. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_agent.py +75 -0
  12. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_lifecycle.py +72 -2
  13. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_list.py +119 -0
  14. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_hook.py +206 -0
  15. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_state.py +13 -0
  16. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/LICENSE +0 -0
  17. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/README.md +0 -0
  18. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/setup.cfg +0 -0
  19. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/__init__.py +0 -0
  20. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/__main__.py +0 -0
  21. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/breakdown.py +0 -0
  22. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/__init__.py +0 -0
  23. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/config.py +0 -0
  24. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/council.py +0 -0
  25. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/design.py +0 -0
  26. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/display.py +0 -0
  27. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/helpers.py +0 -0
  28. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/lord.py +0 -0
  29. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/peasant.py +0 -0
  30. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/cli/plugin.py +0 -0
  31. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/config.py +0 -0
  32. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/__init__.py +0 -0
  33. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/base.py +0 -0
  34. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/bundle.py +0 -0
  35. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/council.py +0 -0
  36. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/council/worker.py +0 -0
  37. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/design.py +0 -0
  38. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/harness.py +0 -0
  39. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/lord_harness.py +0 -0
  40. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/lord_worker.py +0 -0
  41. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/parsing.py +0 -0
  42. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/session.py +0 -0
  43. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/__init__.py +0 -0
  44. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/__init__.py +0 -0
  45. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/council.md +0 -0
  46. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/skill/references/peasants.md +0 -0
  47. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/synthesis.py +0 -0
  48. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/thread.py +0 -0
  49. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/__init__.py +0 -0
  50. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/app.py +0 -0
  51. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/chat.tcss +0 -0
  52. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/clipboard.py +0 -0
  53. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/poll.py +0 -0
  54. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/terminal.py +0 -0
  55. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/tui/widgets.py +0 -0
  56. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/worker.py +0 -0
  57. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom/worktree.py +0 -0
  58. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/SOURCES.txt +0 -0
  59. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
  60. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
  61. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/requires.txt +0 -0
  62. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/src/kingdom_cli.egg-info/top_level.txt +0 -0
  63. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_breakdown.py +0 -0
  64. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli.py +0 -0
  65. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_council.py +0 -0
  66. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_design_breakdown.py +0 -0
  67. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_peasant.py +0 -0
  68. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_plugin.py +0 -0
  69. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_status.py +0 -0
  70. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_current.py +0 -0
  71. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_deps.py +0 -0
  72. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_links.py +0 -0
  73. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_show.py +0 -0
  74. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_cli_ticket_worklog.py +0 -0
  75. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_config.py +0 -0
  76. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_council.py +0 -0
  77. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_design.py +0 -0
  78. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_done.py +0 -0
  79. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_epic.py +0 -0
  80. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_harness.py +0 -0
  81. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_init.py +0 -0
  82. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_lord.py +0 -0
  83. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_parsing.py +0 -0
  84. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_session.py +0 -0
  85. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_skill.py +0 -0
  86. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_stop_blocker.py +0 -0
  87. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_synthesis.py +0 -0
  88. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_thread.py +0 -0
  89. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_ticket.py +0 -0
  90. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui.py +0 -0
  91. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_clipboard.py +0 -0
  92. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_integration.py +0 -0
  93. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_poll.py +0 -0
  94. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_reply.py +0 -0
  95. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_terminal.py +0 -0
  96. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_thinking_persistence.py +0 -0
  97. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_tui_widgets.py +0 -0
  98. {kingdom_cli-0.5.2 → kingdom_cli-0.6.0}/tests/test_worker.py +0 -0
  99. {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.5.2
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.5.2"
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(["-c", 'sandbox_permissions=["disk-full-read-access"]'])
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
- try:
166
- proc = subprocess.run(
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
- all_backlog_tickets = list_tickets(backlog_dir) if backlog_dir.exists() else []
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, {t.id: "backlog" for t in tickets})
608
+ output_tickets_json(tickets, location_map)
570
609
  else:
571
610
  if not tickets:
572
- closed_count = sum(1 for t in all_backlog_tickets if t.status == "closed")
573
- if closed_count and not include_closed and status is None and not ready and not blocked:
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
- closed_count = sum(1 for _, t in pairs if t.status == "closed")
599
- if closed_count and not include_closed and status is None and not ready and not blocked:
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
- closed_count = sum(1 for t in all_branch_tickets if t.status == "closed")
617
- if closed_count and not include_closed and status is None and not ready and not blocked:
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) -> 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
- update_ticket_status(ticket_id, "in_progress", assignee="hand")
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 when you are already editing ticket content; use `kd tk log` for quick one-off entries:
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. Prefer reading and editing the ticket Markdown directly when making multiple 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.
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. Prefer direct Markdown edits after reading the ticket file; use `kd tk log` for quick 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.
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