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.
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/PKG-INFO +1 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/pyproject.toml +1 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/__init__.py +111 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/council.py +24 -49
- kingdom_cli-0.4.0/src/kingdom/cli/hook.py +225 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/peasant.py +157 -45
- kingdom_cli-0.4.0/src/kingdom/cli/plugin.py +151 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/ticket.py +54 -2
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/base.py +4 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/harness.py +1 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/session.py +61 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/SKILL.md +7 -3
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/council.md +5 -4
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/app.py +4 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/worktree.py +39 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/PKG-INFO +1 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/SOURCES.txt +5 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli.py +112 -1
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_council.py +72 -29
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_peasant.py +413 -1
- kingdom_cli-0.4.0/tests/test_cli_plugin.py +237 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_lifecycle.py +135 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_council.py +21 -0
- kingdom_cli-0.4.0/tests/test_hook.py +441 -0
- kingdom_cli-0.4.0/tests/test_stop_blocker.py +122 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui.py +39 -59
- kingdom_cli-0.4.0/tests/test_worktree.py +114 -0
- kingdom_cli-0.3.2/tests/test_worktree.py +0 -25
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/LICENSE +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/README.md +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/setup.cfg +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/__init__.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/__main__.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/agent.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/breakdown.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/config.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/design.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/display.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/cli/helpers.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/config.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/__init__.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/bundle.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/council.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/council/worker.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/design.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/parsing.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/__init__.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/__init__.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/peasants.md +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/skill/references/tickets.md +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/state.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/synthesis.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/thread.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/ticket.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/__init__.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/chat.tcss +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/clipboard.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/poll.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/terminal.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/tui/widgets.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom/worker.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/requires.txt +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/src/kingdom_cli.egg-info/top_level.txt +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_agent.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_breakdown.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_design_breakdown.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_status.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_current.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_deps.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_links.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_list.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_show.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_cli_ticket_worklog.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_config.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_design.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_done.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_harness.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_init.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_parsing.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_session.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_skill.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_state.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_synthesis.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_thread.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_ticket.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_clipboard.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_integration.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_poll.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_reply.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_terminal.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_thinking_persistence.py +0 -0
- {kingdom_cli-0.3.2 → kingdom_cli-0.4.0}/tests/test_tui_widgets.py +0 -0
- {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
|
+
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
|
+
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|