kingdom-cli 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/PKG-INFO +1 -1
  2. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/pyproject.toml +1 -1
  3. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/__init__.py +111 -1
  4. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/council.py +24 -49
  5. kingdom_cli-0.4.0/src/kingdom/cli/hook.py +225 -0
  6. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/peasant.py +157 -45
  7. kingdom_cli-0.4.0/src/kingdom/cli/plugin.py +151 -0
  8. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/ticket.py +54 -2
  9. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/base.py +4 -1
  10. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/harness.py +1 -1
  11. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/session.py +61 -0
  12. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/SKILL.md +7 -3
  13. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/council.md +5 -4
  14. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/app.py +4 -1
  15. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/worktree.py +39 -0
  16. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/PKG-INFO +1 -1
  17. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/SOURCES.txt +5 -0
  18. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli.py +112 -1
  19. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_council.py +72 -29
  20. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_peasant.py +413 -1
  21. kingdom_cli-0.4.0/tests/test_cli_plugin.py +237 -0
  22. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_lifecycle.py +135 -0
  23. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_council.py +21 -0
  24. kingdom_cli-0.4.0/tests/test_hook.py +441 -0
  25. kingdom_cli-0.4.0/tests/test_stop_blocker.py +122 -0
  26. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui.py +39 -59
  27. kingdom_cli-0.4.0/tests/test_worktree.py +114 -0
  28. kingdom_cli-0.3.2/tests/test_worktree.py +0 -25
  29. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/LICENSE +0 -0
  30. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/README.md +0 -0
  31. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/setup.cfg +0 -0
  32. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/__init__.py +0 -0
  33. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/__main__.py +0 -0
  34. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/agent.py +0 -0
  35. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/breakdown.py +0 -0
  36. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/config.py +0 -0
  37. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/design.py +0 -0
  38. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/display.py +0 -0
  39. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/helpers.py +0 -0
  40. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/config.py +0 -0
  41. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/__init__.py +0 -0
  42. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/bundle.py +0 -0
  43. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/council.py +0 -0
  44. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/worker.py +0 -0
  45. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/design.py +0 -0
  46. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/parsing.py +0 -0
  47. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/__init__.py +0 -0
  48. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/__init__.py +0 -0
  49. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/peasants.md +0 -0
  50. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/tickets.md +0 -0
  51. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/state.py +0 -0
  52. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/synthesis.py +0 -0
  53. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/thread.py +0 -0
  54. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/ticket.py +0 -0
  55. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/__init__.py +0 -0
  56. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/chat.tcss +0 -0
  57. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/clipboard.py +0 -0
  58. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/poll.py +0 -0
  59. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/terminal.py +0 -0
  60. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/widgets.py +0 -0
  61. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/worker.py +0 -0
  62. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
  63. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
  64. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/requires.txt +0 -0
  65. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/top_level.txt +0 -0
  66. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_agent.py +0 -0
  67. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_breakdown.py +0 -0
  68. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_design_breakdown.py +0 -0
  69. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_status.py +0 -0
  70. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_current.py +0 -0
  71. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_deps.py +0 -0
  72. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_links.py +0 -0
  73. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_list.py +0 -0
  74. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_show.py +0 -0
  75. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_worklog.py +0 -0
  76. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_config.py +0 -0
  77. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_design.py +0 -0
  78. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_done.py +0 -0
  79. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_harness.py +0 -0
  80. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_init.py +0 -0
  81. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_parsing.py +0 -0
  82. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_session.py +0 -0
  83. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_skill.py +0 -0
  84. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_state.py +0 -0
  85. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_synthesis.py +0 -0
  86. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_thread.py +0 -0
  87. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_ticket.py +0 -0
  88. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_clipboard.py +0 -0
  89. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_integration.py +0 -0
  90. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_poll.py +0 -0
  91. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_reply.py +0 -0
  92. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_terminal.py +0 -0
  93. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_thinking_persistence.py +0 -0
  94. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_widgets.py +0 -0
  95. {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kingdom-cli
3
- Version: 0.3.2
3
+ Version: 0.4.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.2"
3
+ version = "0.4.0"
4
4
  description = "A markdown file-based CLI for software development with multi-agent council design, markdown tickets, and background worker loops."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -40,6 +40,7 @@ from .council import council_app
40
40
  from .design import design_app, get_branch_paths, get_doc_status # noqa: F401 (re-export)
41
41
  from .display import error_console, print_error, styled_echo
42
42
  from .helpers import install_skill, is_git_repo, require_project_root, verbose_echo # noqa: F401
43
+ from .hook import hook_app
43
44
  from .peasant import ( # noqa: F401
44
45
  PeasantContext,
45
46
  launch_work_background,
@@ -47,6 +48,7 @@ from .peasant import ( # noqa: F401
47
48
  peasant_app,
48
49
  resolve_peasant_context,
49
50
  )
51
+ from .plugin import plugin_app
50
52
  from .ticket import format_ticket_line, format_ticket_summary, get_tickets_dir, ticket_app # noqa: F401
51
53
 
52
54
  NO_COLOR = "NO_COLOR" in os.environ or os.environ.get("TERM") == "dumb"
@@ -78,6 +80,8 @@ app.add_typer(council_app, name="council")
78
80
  app.add_typer(design_app, name="design")
79
81
  app.add_typer(peasant_app, name="peasant")
80
82
  app.add_typer(config_app, name="config")
83
+ app.add_typer(hook_app, name="hook")
84
+ app.add_typer(plugin_app, name="plugin")
81
85
  app.add_typer(ticket_app, name="ticket")
82
86
  app.add_typer(ticket_app, name="tk", hidden=True) # Alias for muscle memory
83
87
 
@@ -133,7 +137,10 @@ def start(
133
137
  if current_path.exists() and not force:
134
138
  existing = current_path.read_text(encoding="utf-8").strip()
135
139
  print_error(f"A session is already active: {existing}")
136
- error_console.print(" Use --force to override, or run `kd done` first.")
140
+ error_console.print(
141
+ " If that branch is finished, run `kd done` to clean it up before starting a new session.\n"
142
+ " If you need to switch mid-work, use `kd start --force` to override."
143
+ )
137
144
  raise typer.Exit(code=1)
138
145
 
139
146
  # Determine branch name
@@ -168,6 +175,109 @@ def start(
168
175
  typer.echo(f" Design: {design_path}")
169
176
 
170
177
 
178
+ @app.command(help="Switch the active kd session to another branch.")
179
+ def switch(
180
+ branch: Annotated[str | None, typer.Argument(help="Branch name to switch to.")] = None,
181
+ ) -> None:
182
+ """Switch the active kd session without changing git branch.
183
+
184
+ With an argument: validate the branch exists in .kd/branches/ and update .kd/current.
185
+ Without arguments: list all tracked branches, marking the current session and git branch.
186
+ """
187
+ base = require_project_root()
188
+ console = Console()
189
+
190
+ if branch is None:
191
+ # List mode: show all tracked branches
192
+ branches_dir = base / ".kd" / "branches"
193
+ if not branches_dir.exists() or not any(branches_dir.iterdir()):
194
+ typer.echo("No tracked branches. Use `kd start <branch>` to create one.")
195
+ return
196
+
197
+ # Determine current kd session
198
+ current_session: str | None = None
199
+ current_path = base / ".kd" / "current"
200
+ if current_path.exists():
201
+ current_session = current_path.read_text(encoding="utf-8").strip() or None
202
+
203
+ # Determine current git branch
204
+ git_branch = get_current_git_branch()
205
+ git_normalized = normalize_branch_name(git_branch) if git_branch else None
206
+
207
+ for branch_dir in sorted(branches_dir.iterdir()):
208
+ if not branch_dir.is_dir():
209
+ continue
210
+ name = branch_dir.name
211
+
212
+ # Read original branch name from state.json
213
+ state_path = branch_dir / "state.json"
214
+ original = name
215
+ branch_status = ""
216
+ if state_path.exists():
217
+ try:
218
+ state = read_json(state_path)
219
+ original = state.get("branch", name)
220
+ if state.get("status") == "done":
221
+ branch_status = " [dim](done)[/dim]"
222
+ except (FileNotFoundError, KeyError):
223
+ pass
224
+
225
+ # Count open tickets
226
+ tickets_dir = branch_dir / "tickets"
227
+ ticket_count = 0
228
+ if tickets_dir.exists():
229
+ ticket_count = sum(1 for f in tickets_dir.glob("*.md"))
230
+
231
+ # Build markers
232
+ markers = []
233
+ if name == current_session:
234
+ markers.append("[bold cyan]* session[/bold cyan]")
235
+ if name == git_normalized:
236
+ markers.append("[green]* git[/green]")
237
+ marker_str = f" ({', '.join(markers)})" if markers else ""
238
+
239
+ console.print(f" {original}{branch_status} [{ticket_count} tickets]{marker_str}")
240
+ return
241
+
242
+ # Switch mode: validate and update
243
+ normalized = normalize_branch_name(branch)
244
+ branch_dir = base / ".kd" / "branches" / normalized
245
+ if not branch_dir.exists():
246
+ print_error(f"Branch '{branch}' not found in .kd/branches/.")
247
+ error_console.print("Available branches:")
248
+ branches_dir = base / ".kd" / "branches"
249
+ if branches_dir.exists():
250
+ for d in sorted(branches_dir.iterdir()):
251
+ if d.is_dir():
252
+ error_console.print(f" {d.name}")
253
+ raise typer.Exit(code=1)
254
+
255
+ set_current_run(base, normalized)
256
+
257
+ # Print summary
258
+ state_path = branch_dir / "state.json"
259
+ original = branch
260
+ if state_path.exists():
261
+ try:
262
+ state = read_json(state_path)
263
+ original = state.get("branch", branch)
264
+ except (FileNotFoundError, KeyError):
265
+ pass
266
+
267
+ tickets_dir = branch_dir / "tickets"
268
+ tickets = list_tickets(tickets_dir) if tickets_dir.exists() else []
269
+ open_count = sum(1 for t in tickets if t.status != "closed")
270
+ closed_count = sum(1 for t in tickets if t.status == "closed")
271
+
272
+ git_branch = get_current_git_branch()
273
+ git_info = f" git: {git_branch}" if git_branch else ""
274
+ if git_branch and normalize_branch_name(git_branch) != normalized:
275
+ git_info += " [yellow](mismatch)[/yellow]"
276
+
277
+ console.print(f"Switched to [bold]{original}[/bold]")
278
+ console.print(f" {open_count} open, {closed_count} closed tickets{git_info}")
279
+
280
+
171
281
  @app.command(help="Mark the current session as done.")
172
282
  def done(
173
283
  feature: Annotated[str | None, typer.Argument(help="Branch name (defaults to current session).")] = None,
@@ -135,7 +135,9 @@ def topic_for_thread(base: Path, feature: str, thread_id: str) -> str:
135
135
  def council_ask(
136
136
  prompt: Annotated[str, typer.Argument(help="Prompt to send to council members.")],
137
137
  to: Annotated[str | None, typer.Option("--to", help="Send to a specific member only.")] = None,
138
- new_thread: Annotated[bool, typer.Option("--new-thread", help="Start a fresh thread.")] = False,
138
+ continue_thread: Annotated[
139
+ bool, typer.Option("--continue", "-c", help="Continue the current council thread.")
140
+ ] = False,
139
141
  json_output: Annotated[bool, typer.Option("--json", help="Output JSON format.")] = False,
140
142
  async_mode: Annotated[
141
143
  bool, typer.Option("--async", help="Dispatch in background, then watch for responses.")
@@ -203,16 +205,19 @@ def council_ask(
203
205
  print_error(f"Available: {', '.join(sorted(available_names))}")
204
206
  raise typer.Exit(code=1)
205
207
 
206
- # Determine thread: continue current, or create new
208
+ # Determine thread: new by default, or continue current
207
209
  current = get_current_thread(base, feature)
208
- start_new = new_thread or current is None
209
210
 
210
- # Recover from stale pointer: current_thread set but directory missing
211
- if not start_new and not thread_dir(base, feature, current).exists():
212
- set_current_thread(base, feature, None)
211
+ if continue_thread:
212
+ # --continue: reuse existing thread, error if none exists
213
+ if current is None or not thread_dir(base, feature, current).exists():
214
+ print_error("No active council thread on this branch. Drop --continue to start a new one.")
215
+ raise typer.Exit(code=1)
216
+ thread_id = current
217
+ start_new = False
218
+ else:
219
+ # Default: always start a new thread
213
220
  start_new = True
214
-
215
- if start_new:
216
221
  thread_id = f"council-{secrets.token_hex(2)}"
217
222
  if to:
218
223
  member_names = [to]
@@ -220,8 +225,6 @@ def council_ask(
220
225
  member_names = [m.name for m in c.members]
221
226
  create_thread(base, feature, thread_id, ["king", *member_names], "council")
222
227
  set_current_thread(base, feature, thread_id)
223
- else:
224
- thread_id = current
225
228
 
226
229
  tdir = thread_dir(base, feature, thread_id)
227
230
  verbose_echo(f"thread: {thread_id} ({'new' if start_new else 'continuing'})")
@@ -444,11 +447,10 @@ def council_review(
444
447
  worklog=worklog,
445
448
  )
446
449
 
447
- # Delegate to council_ask with --new-thread and review phase
450
+ # Delegate to council_ask — new thread by default now
448
451
  council_ask(
449
452
  prompt=review_prompt,
450
453
  to=to,
451
- new_thread=True,
452
454
  json_output=False,
453
455
  async_mode=async_mode,
454
456
  no_watch=no_watch,
@@ -670,6 +672,7 @@ def council_list() -> None:
670
672
 
671
673
  # Get topic from first king message
672
674
  messages = list_messages(base, feature, t.id)
675
+ msg_count = len(messages)
673
676
  topic = ""
674
677
  for msg in messages:
675
678
  if msg.from_ == "king":
@@ -686,7 +689,8 @@ def council_list() -> None:
686
689
  member_parts.append(f"{symbol} {name}")
687
690
  members_str = " ".join(member_parts) if member_parts else ""
688
691
 
689
- console.print(f"[bold]{t.id}[/bold] [dim]{created}[/dim]{marker}")
692
+ count_str = f"[dim]{msg_count} msg{'s' if msg_count != 1 else ''}[/dim]"
693
+ console.print(f"[bold]{t.id}[/bold] [dim]{created}[/dim] {count_str}{marker}")
690
694
  if topic:
691
695
  console.print(f" {topic}")
692
696
  if members_str:
@@ -1114,7 +1118,6 @@ def display_rich_panels(responses, thread_id, console):
1114
1118
  @council_app.command("chat", help="Open council chat TUI.")
1115
1119
  def council_chat(
1116
1120
  thread_id: Annotated[str | None, typer.Argument(help="Thread ID to open.")] = None,
1117
- new: Annotated[bool, typer.Option("--new", help="Create a new thread.")] = False,
1118
1121
  debug: Annotated[
1119
1122
  bool,
1120
1123
  typer.Option(
@@ -1136,53 +1139,25 @@ def council_chat(
1136
1139
  ) -> None:
1137
1140
  """Open the council chat TUI.
1138
1141
 
1139
- Opens an existing thread, creates a new one, or lists recent threads.
1142
+ Creates a new thread by default, or opens an existing one by ID.
1140
1143
  """
1141
1144
  import kingdom.cli as _cli
1142
1145
  from kingdom.config import load_config
1143
- from kingdom.thread import create_thread, list_threads
1146
+ from kingdom.thread import create_thread
1144
1147
 
1145
1148
  base = require_project_root()
1146
1149
  feature = _cli.resolve_current_run(base)
1147
1150
  cfg = load_config(base)
1148
1151
 
1149
- if new:
1152
+ if thread_id:
1153
+ tid = resolve_council_thread_id(base, feature, thread_id, command="chat")
1154
+ set_current_thread(base, feature, tid)
1155
+ else:
1156
+ # Default: new thread (same as ask)
1150
1157
  tid = f"council-{secrets.token_hex(2)}"
1151
1158
  member_names = cfg.council.members or list(cfg.agents)
1152
1159
  create_thread(base, feature, tid, ["king", *member_names], "council")
1153
1160
  set_current_thread(base, feature, tid)
1154
- elif thread_id:
1155
- tid = resolve_council_thread_id(base, feature, thread_id, command="chat")
1156
- set_current_thread(base, feature, tid)
1157
- else:
1158
- current = get_current_thread(base, feature)
1159
- if current:
1160
- from kingdom.thread import thread_dir
1161
-
1162
- if thread_dir(base, feature, current).exists():
1163
- tid = current
1164
- else:
1165
- set_current_thread(base, feature, None)
1166
- current = None
1167
-
1168
- if not current:
1169
- threads = list_threads(base, feature)
1170
- if threads:
1171
- typer.echo("Recent threads:")
1172
- for t in reversed(threads[-5:]):
1173
- created = t.created_at.strftime("%Y-%m-%d %H:%M")
1174
- members = ", ".join(m for m in t.members if m != "king")
1175
- typer.echo(f" {t.id} {created} [{members}]")
1176
- typer.echo()
1177
- typer.echo("Usage: kd council chat <thread-id> or kd council chat --new")
1178
- raise typer.Exit(code=0)
1179
- else:
1180
- # Auto-create a thread when none exist
1181
- tid = f"council-{secrets.token_hex(2)}"
1182
- member_names = cfg.council.members or list(cfg.agents)
1183
- create_thread(base, feature, tid, ["king", *member_names], "council")
1184
- set_current_thread(base, feature, tid)
1185
- typer.echo(f"Created new thread: {tid}")
1186
1161
 
1187
1162
  from kingdom.tui.app import ChatApp
1188
1163
  from kingdom.tui.terminal import in_tmux_control_mode
@@ -0,0 +1,225 @@
1
+ """Hook event handlers for Claude Code integration.
2
+
3
+ The ``kd hook run`` subcommand reads a JSON payload from stdin, dispatches to
4
+ the appropriate handler based on ``hook_event_name``, and writes any response
5
+ JSON to stdout. It is designed to be invoked by Claude Code as the hook
6
+ command registered in ``.claude/settings.json``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import typer
19
+
20
+ hook_app = typer.Typer(name="hook", help="Claude Code hook handlers (internal).")
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Constants
24
+ # ---------------------------------------------------------------------------
25
+
26
+ SESSION_START_BRIEF = (
27
+ "KINGDOM WORKFLOW: You are working in a project managed by the kd CLI."
28
+ " Before coding or research, ensure work is tracked with a ticket.\n"
29
+ " 1. TICKET FIRST — King says something? Ask yourself: does this need a"
30
+ " ticket? Bug, idea, complaint, scope change → kd tk create immediately.\n"
31
+ " 2. LOG PROACTIVELY — Decision made, root cause found, scope changed,"
32
+ " work completed → kd tk log. The King should never have to ask.\n"
33
+ " 3. MOVE vs CREATE — Work belongs elsewhere → kd tk move. New problem"
34
+ " noticed → kd tk create --backlog."
35
+ )
36
+
37
+ USER_PROMPT_REMINDER = (
38
+ "Kingdom: keep the ticket accurate (body/AC/status/worklog)."
39
+ " Requirement or acceptance-criteria change -> edit ticket markdown now."
40
+ " Work/findings -> kd tk log. New bug/scope -> kd tk create|move."
41
+ )
42
+
43
+ WORK_TOOLS = {"WebSearch", "WebFetch", "Edit", "Write"}
44
+
45
+ STALE_TTL_SECONDS = 86400 # 24 hours
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def runtime_dir(project_dir: str | None = None) -> Path:
53
+ """Return the .kd/runtime directory, creating it if needed."""
54
+ base = Path(project_dir) if project_dir else Path(".")
55
+ d = base / ".kd" / "runtime"
56
+ d.mkdir(parents=True, exist_ok=True)
57
+ return d
58
+
59
+
60
+ def state_file_for(project_dir: str | None, session_id: str) -> Path:
61
+ """Return the turn-state file path for a given session."""
62
+ return runtime_dir(project_dir) / f"turn-{session_id}.json"
63
+
64
+
65
+ def read_turn_state(path: Path) -> dict | None:
66
+ """Read turn state, returning None on any error (fail-open)."""
67
+ try:
68
+ return json.loads(path.read_text())
69
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
70
+ return None
71
+
72
+
73
+ def write_turn_state(path: Path, state: dict) -> None:
74
+ path.write_text(json.dumps(state))
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Event handlers — each returns a string to print to stdout (or empty).
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def handle_session_start(data: dict) -> str:
83
+ return json.dumps(
84
+ {
85
+ "hookSpecificOutput": {
86
+ "hookEventName": "SessionStart",
87
+ "additionalContext": SESSION_START_BRIEF,
88
+ }
89
+ }
90
+ )
91
+
92
+
93
+ def handle_user_prompt_submit(data: dict) -> str:
94
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
95
+ session_id = data.get("session_id", "")
96
+
97
+ if session_id:
98
+ sf = state_file_for(project_dir, session_id)
99
+ write_turn_state(sf, {"had_work": False, "did_log": False})
100
+
101
+ # TTL cleanup: remove stale turn-state files.
102
+ cutoff = time.time() - STALE_TTL_SECONDS
103
+ rd = runtime_dir(project_dir)
104
+ for f in rd.glob("turn-*.json"):
105
+ try:
106
+ if f.stat().st_mtime < cutoff:
107
+ f.unlink()
108
+ except OSError:
109
+ pass
110
+
111
+ return json.dumps(
112
+ {
113
+ "hookSpecificOutput": {
114
+ "hookEventName": "UserPromptSubmit",
115
+ "additionalContext": USER_PROMPT_REMINDER,
116
+ }
117
+ }
118
+ )
119
+
120
+
121
+ def handle_post_tool_use(data: dict) -> str:
122
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
123
+ session_id = data.get("session_id", "")
124
+ if not session_id:
125
+ return ""
126
+
127
+ sf = state_file_for(project_dir, session_id)
128
+ state = read_turn_state(sf)
129
+ if state is None:
130
+ return ""
131
+
132
+ tool = data.get("tool_name", "")
133
+
134
+ if tool in WORK_TOOLS:
135
+ state["had_work"] = True
136
+
137
+ if tool == "Bash":
138
+ cmd = data.get("tool_input", {}).get("command", "")
139
+ if "kd tk log" in cmd or "kd ticket log" in cmd:
140
+ state["did_log"] = True
141
+
142
+ write_turn_state(sf, state)
143
+ return ""
144
+
145
+
146
+ def handle_stop(data: dict) -> str:
147
+ # If stop_hook_active is set, another stop handler is running — bail.
148
+ if data.get("stop_hook_active", False):
149
+ return ""
150
+
151
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
152
+ session_id = data.get("session_id", "")
153
+ if not session_id:
154
+ return ""
155
+
156
+ sf = state_file_for(project_dir, session_id)
157
+ state = read_turn_state(sf)
158
+ if state is None:
159
+ return ""
160
+
161
+ if not (state.get("had_work") and not state.get("did_log")):
162
+ return ""
163
+
164
+ # Check for an active ticket (fail-open on any error).
165
+ try:
166
+ proc = subprocess.run(
167
+ ["kd", "tk", "current", "--id"],
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:
176
+ return "" # Timeout or error — fail open.
177
+
178
+ result = {
179
+ "decision": "block",
180
+ "reason": (
181
+ f"KINGDOM: You did meaningful work this turn but didn't log it."
182
+ f" Run: kd tk log {ticket_id} 'summary of what you did'"
183
+ ),
184
+ }
185
+ return json.dumps(result)
186
+
187
+
188
+ # Map event names to handlers.
189
+ HANDLERS = {
190
+ "SessionStart": handle_session_start,
191
+ "UserPromptSubmit": handle_user_prompt_submit,
192
+ "PostToolUse": handle_post_tool_use,
193
+ "Stop": handle_stop,
194
+ }
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # CLI command
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ @hook_app.command("run", help="Process a Claude Code hook event from stdin.")
202
+ def hook_run() -> None:
203
+ """Read hook payload JSON from stdin, dispatch by event, write response."""
204
+ # Bypass all processing if requested.
205
+ if os.environ.get("KD_HOOK_BYPASS") == "1":
206
+ return
207
+
208
+ try:
209
+ raw = sys.stdin.read()
210
+ data = json.loads(raw) if raw.strip() else {}
211
+ except (json.JSONDecodeError, OSError):
212
+ return # Fail open on bad input.
213
+
214
+ event = data.get("hook_event_name", "")
215
+ handler = HANDLERS.get(event)
216
+ if handler is None:
217
+ return # Unknown event — silent.
218
+
219
+ try:
220
+ output = handler(data)
221
+ except Exception:
222
+ return # Fail open on handler errors.
223
+
224
+ if output:
225
+ typer.echo(output)