kingdom-cli 0.3.2__tar.gz → 0.5.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 (100) hide show
  1. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/PKG-INFO +1 -1
  2. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/pyproject.toml +1 -1
  3. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/agent.py +75 -0
  4. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/__init__.py +117 -4
  5. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/council.py +343 -97
  6. kingdom_cli-0.5.0/src/kingdom/cli/hook.py +225 -0
  7. kingdom_cli-0.5.0/src/kingdom/cli/lord.py +382 -0
  8. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/peasant.py +364 -117
  9. kingdom_cli-0.5.0/src/kingdom/cli/plugin.py +151 -0
  10. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/ticket.py +186 -7
  11. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/config.py +69 -3
  12. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/council/base.py +8 -2
  13. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/harness.py +36 -21
  14. kingdom_cli-0.5.0/src/kingdom/lord_harness.py +936 -0
  15. kingdom_cli-0.5.0/src/kingdom/lord_worker.py +50 -0
  16. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/session.py +63 -1
  17. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/skill/SKILL.md +23 -3
  18. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/skill/references/council.md +5 -4
  19. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/thread.py +129 -1
  20. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/ticket.py +59 -5
  21. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/app.py +4 -1
  22. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/worktree.py +39 -0
  23. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom_cli.egg-info/PKG-INFO +1 -1
  24. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom_cli.egg-info/SOURCES.txt +10 -0
  25. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_agent.py +54 -0
  26. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli.py +112 -1
  27. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_council.py +277 -29
  28. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_peasant.py +809 -2
  29. kingdom_cli-0.5.0/tests/test_cli_plugin.py +237 -0
  30. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_current.py +71 -0
  31. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_deps.py +68 -0
  32. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_lifecycle.py +246 -0
  33. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_config.py +81 -0
  34. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_council.py +36 -0
  35. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_done.py +106 -0
  36. kingdom_cli-0.5.0/tests/test_epic.py +236 -0
  37. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_harness.py +6 -0
  38. kingdom_cli-0.5.0/tests/test_hook.py +441 -0
  39. kingdom_cli-0.5.0/tests/test_lord.py +1278 -0
  40. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_skill.py +3 -4
  41. kingdom_cli-0.5.0/tests/test_stop_blocker.py +122 -0
  42. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_ticket.py +161 -11
  43. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui.py +39 -59
  44. kingdom_cli-0.5.0/tests/test_worktree.py +114 -0
  45. kingdom_cli-0.3.2/tests/test_worktree.py +0 -25
  46. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/LICENSE +0 -0
  47. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/README.md +0 -0
  48. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/setup.cfg +0 -0
  49. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/__init__.py +0 -0
  50. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/__main__.py +0 -0
  51. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/breakdown.py +0 -0
  52. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/config.py +0 -0
  53. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/design.py +0 -0
  54. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/display.py +0 -0
  55. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/cli/helpers.py +0 -0
  56. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/council/__init__.py +0 -0
  57. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/council/bundle.py +0 -0
  58. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/council/council.py +0 -0
  59. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/council/worker.py +0 -0
  60. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/design.py +0 -0
  61. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/parsing.py +0 -0
  62. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/skill/__init__.py +0 -0
  63. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/skill/references/__init__.py +0 -0
  64. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/skill/references/peasants.md +0 -0
  65. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/skill/references/tickets.md +0 -0
  66. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/state.py +0 -0
  67. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/synthesis.py +0 -0
  68. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/__init__.py +0 -0
  69. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/chat.tcss +0 -0
  70. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/clipboard.py +0 -0
  71. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/poll.py +0 -0
  72. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/terminal.py +0 -0
  73. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/tui/widgets.py +0 -0
  74. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom/worker.py +0 -0
  75. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom_cli.egg-info/dependency_links.txt +0 -0
  76. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom_cli.egg-info/entry_points.txt +0 -0
  77. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom_cli.egg-info/requires.txt +0 -0
  78. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/src/kingdom_cli.egg-info/top_level.txt +0 -0
  79. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_breakdown.py +0 -0
  80. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_design_breakdown.py +0 -0
  81. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_status.py +0 -0
  82. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_links.py +0 -0
  83. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_list.py +0 -0
  84. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_show.py +0 -0
  85. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_cli_ticket_worklog.py +0 -0
  86. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_design.py +0 -0
  87. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_init.py +0 -0
  88. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_parsing.py +0 -0
  89. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_session.py +0 -0
  90. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_state.py +0 -0
  91. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_synthesis.py +0 -0
  92. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_thread.py +0 -0
  93. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_clipboard.py +0 -0
  94. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_integration.py +0 -0
  95. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_poll.py +0 -0
  96. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_reply.py +0 -0
  97. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_terminal.py +0 -0
  98. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_thinking_persistence.py +0 -0
  99. {kingdom_cli-0.3.2 → kingdom_cli-0.5.0}/tests/test_tui_widgets.py +0 -0
  100. {kingdom_cli-0.3.2 → kingdom_cli-0.5.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.5.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.5.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"
@@ -62,6 +62,7 @@ class AgentConfig:
62
62
  install_hint: str = ""
63
63
  model: str = ""
64
64
  extra_flags: list[str] = field(default_factory=list)
65
+ reasoning_effort: str = ""
65
66
 
66
67
 
67
68
  def resolve_agent(name: str, agent_def: AgentDef) -> AgentConfig:
@@ -85,6 +86,7 @@ def resolve_agent(name: str, agent_def: AgentDef) -> AgentConfig:
85
86
  install_hint=defaults["install_hint"],
86
87
  model=agent_def.model,
87
88
  extra_flags=list(agent_def.extra_flags),
89
+ reasoning_effort=agent_def.reasoning_effort,
88
90
  )
89
91
 
90
92
 
@@ -310,6 +312,63 @@ RESPONSE_PARSERS: dict[str, ResponseParser] = {
310
312
  }
311
313
 
312
314
 
315
+ def extract_codex_error(stdout: str, stderr: str, code: int) -> str | None:
316
+ """Extract a human-meaningful error from Codex JSONL output.
317
+
318
+ Prefers terminal failure events over transient reconnect notices so callers
319
+ can surface the actual cause instead of a generic exit code.
320
+ """
321
+ del code # Error extraction only depends on emitted output.
322
+
323
+ turn_failed: list[str] = []
324
+ item_errors: list[str] = []
325
+ event_errors: list[str] = []
326
+
327
+ for line in stdout.strip().split("\n"):
328
+ if not line:
329
+ continue
330
+ try:
331
+ event = json.loads(line)
332
+ except json.JSONDecodeError:
333
+ continue
334
+ if not isinstance(event, dict):
335
+ continue
336
+
337
+ event_type = event.get("type")
338
+ if event_type == "turn.failed":
339
+ error = event.get("error", {})
340
+ if isinstance(error, dict):
341
+ message = error.get("message")
342
+ if isinstance(message, str) and message:
343
+ turn_failed.append(message)
344
+ elif event_type == "item.completed":
345
+ item = event.get("item", {})
346
+ if isinstance(item, dict) and item.get("type") == "error":
347
+ message = item.get("message") or item.get("text")
348
+ if isinstance(message, str) and message:
349
+ item_errors.append(message)
350
+ elif event_type == "error":
351
+ message = event.get("message")
352
+ if isinstance(message, str) and message:
353
+ event_errors.append(message)
354
+
355
+ for candidates in (turn_failed, item_errors, event_errors):
356
+ if candidates:
357
+ return candidates[-1]
358
+
359
+ stripped = stderr.strip()
360
+ if stripped:
361
+ return stripped
362
+ return None
363
+
364
+
365
+ ErrorExtractor = Callable[[str, str, int], str | None]
366
+
367
+ ERROR_EXTRACTORS: dict[str, ErrorExtractor] = {
368
+ "codex": extract_codex_error,
369
+ }
370
+
371
+
313
372
  # ---------------------------------------------------------------------------
314
373
  # Backend-specific command builders
315
374
  # ---------------------------------------------------------------------------
@@ -373,6 +432,8 @@ def build_codex_command(
373
432
  parts.extend(["-c", 'sandbox_permissions=["disk-full-read-access"]'])
374
433
  if config.model:
375
434
  parts.extend(["--model", config.model])
435
+ if config.reasoning_effort:
436
+ parts.extend(["-c", f"model_reasoning_effort={config.reasoning_effort}"])
376
437
  if config.extra_flags:
377
438
  parts.extend(config.extra_flags)
378
439
  if session_id:
@@ -493,6 +554,20 @@ def parse_response(config: AgentConfig, stdout: str, stderr: str, code: int) ->
493
554
  return parser(stdout, stderr, code)
494
555
 
495
556
 
557
+ def extract_error(config: AgentConfig, stdout: str, stderr: str, code: int) -> str | None:
558
+ """Extract a backend-aware error message from CLI output."""
559
+ extractor = ERROR_EXTRACTORS.get(config.backend)
560
+ if extractor is not None:
561
+ error = extractor(stdout, stderr, code)
562
+ if error:
563
+ return error
564
+
565
+ stripped = stderr.strip()
566
+ if stripped:
567
+ return stripped
568
+ return None
569
+
570
+
496
571
  # ---------------------------------------------------------------------------
497
572
  # Stream text extractors — extract text from individual NDJSON lines
498
573
  # ---------------------------------------------------------------------------
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import os
11
11
  import subprocess
12
+ import sys
12
13
  from pathlib import Path
13
14
  from typing import Annotated
14
15
 
@@ -40,6 +41,8 @@ from .council import council_app
40
41
  from .design import design_app, get_branch_paths, get_doc_status # noqa: F401 (re-export)
41
42
  from .display import error_console, print_error, styled_echo
42
43
  from .helpers import install_skill, is_git_repo, require_project_root, verbose_echo # noqa: F401
44
+ from .hook import hook_app
45
+ from .lord import lord_app
43
46
  from .peasant import ( # noqa: F401
44
47
  PeasantContext,
45
48
  launch_work_background,
@@ -47,6 +50,7 @@ from .peasant import ( # noqa: F401
47
50
  peasant_app,
48
51
  resolve_peasant_context,
49
52
  )
53
+ from .plugin import plugin_app
50
54
  from .ticket import format_ticket_line, format_ticket_summary, get_tickets_dir, ticket_app # noqa: F401
51
55
 
52
56
  NO_COLOR = "NO_COLOR" in os.environ or os.environ.get("TERM") == "dumb"
@@ -78,6 +82,9 @@ app.add_typer(council_app, name="council")
78
82
  app.add_typer(design_app, name="design")
79
83
  app.add_typer(peasant_app, name="peasant")
80
84
  app.add_typer(config_app, name="config")
85
+ app.add_typer(hook_app, name="hook")
86
+ app.add_typer(lord_app, name="lord")
87
+ app.add_typer(plugin_app, name="plugin")
81
88
  app.add_typer(ticket_app, name="ticket")
82
89
  app.add_typer(ticket_app, name="tk", hidden=True) # Alias for muscle memory
83
90
 
@@ -133,7 +140,10 @@ def start(
133
140
  if current_path.exists() and not force:
134
141
  existing = current_path.read_text(encoding="utf-8").strip()
135
142
  print_error(f"A session is already active: {existing}")
136
- error_console.print(" Use --force to override, or run `kd done` first.")
143
+ error_console.print(
144
+ " If that branch is finished, run `kd done` to clean it up before starting a new session.\n"
145
+ " If you need to switch mid-work, use `kd start --force` to override."
146
+ )
137
147
  raise typer.Exit(code=1)
138
148
 
139
149
  # Determine branch name
@@ -168,6 +178,109 @@ def start(
168
178
  typer.echo(f" Design: {design_path}")
169
179
 
170
180
 
181
+ @app.command(help="Switch the active kd session to another branch.")
182
+ def switch(
183
+ branch: Annotated[str | None, typer.Argument(help="Branch name to switch to.")] = None,
184
+ ) -> None:
185
+ """Switch the active kd session without changing git branch.
186
+
187
+ With an argument: validate the branch exists in .kd/branches/ and update .kd/current.
188
+ Without arguments: list all tracked branches, marking the current session and git branch.
189
+ """
190
+ base = require_project_root()
191
+ console = Console()
192
+
193
+ if branch is None:
194
+ # List mode: show all tracked branches
195
+ branches_dir = base / ".kd" / "branches"
196
+ if not branches_dir.exists() or not any(branches_dir.iterdir()):
197
+ typer.echo("No tracked branches. Use `kd start <branch>` to create one.")
198
+ return
199
+
200
+ # Determine current kd session
201
+ current_session: str | None = None
202
+ current_path = base / ".kd" / "current"
203
+ if current_path.exists():
204
+ current_session = current_path.read_text(encoding="utf-8").strip() or None
205
+
206
+ # Determine current git branch
207
+ git_branch = get_current_git_branch()
208
+ git_normalized = normalize_branch_name(git_branch) if git_branch else None
209
+
210
+ for branch_dir in sorted(branches_dir.iterdir()):
211
+ if not branch_dir.is_dir():
212
+ continue
213
+ name = branch_dir.name
214
+
215
+ # Read original branch name from state.json
216
+ state_path = branch_dir / "state.json"
217
+ original = name
218
+ branch_status = ""
219
+ if state_path.exists():
220
+ try:
221
+ state = read_json(state_path)
222
+ original = state.get("branch", name)
223
+ if state.get("status") == "done":
224
+ branch_status = " [dim](done)[/dim]"
225
+ except (FileNotFoundError, KeyError):
226
+ pass
227
+
228
+ # Count open tickets
229
+ tickets_dir = branch_dir / "tickets"
230
+ ticket_count = 0
231
+ if tickets_dir.exists():
232
+ ticket_count = sum(1 for f in tickets_dir.glob("*.md"))
233
+
234
+ # Build markers
235
+ markers = []
236
+ if name == current_session:
237
+ markers.append("[bold cyan]* session[/bold cyan]")
238
+ if name == git_normalized:
239
+ markers.append("[green]* git[/green]")
240
+ marker_str = f" ({', '.join(markers)})" if markers else ""
241
+
242
+ console.print(f" {original}{branch_status} [{ticket_count} tickets]{marker_str}")
243
+ return
244
+
245
+ # Switch mode: validate and update
246
+ normalized = normalize_branch_name(branch)
247
+ branch_dir = base / ".kd" / "branches" / normalized
248
+ if not branch_dir.exists():
249
+ print_error(f"Branch '{branch}' not found in .kd/branches/.")
250
+ error_console.print("Available branches:")
251
+ branches_dir = base / ".kd" / "branches"
252
+ if branches_dir.exists():
253
+ for d in sorted(branches_dir.iterdir()):
254
+ if d.is_dir():
255
+ error_console.print(f" {d.name}")
256
+ raise typer.Exit(code=1)
257
+
258
+ set_current_run(base, normalized)
259
+
260
+ # Print summary
261
+ state_path = branch_dir / "state.json"
262
+ original = branch
263
+ if state_path.exists():
264
+ try:
265
+ state = read_json(state_path)
266
+ original = state.get("branch", branch)
267
+ except (FileNotFoundError, KeyError):
268
+ pass
269
+
270
+ tickets_dir = branch_dir / "tickets"
271
+ tickets = list_tickets(tickets_dir) if tickets_dir.exists() else []
272
+ open_count = sum(1 for t in tickets if t.status != "closed")
273
+ closed_count = sum(1 for t in tickets if t.status == "closed")
274
+
275
+ git_branch = get_current_git_branch()
276
+ git_info = f" git: {git_branch}" if git_branch else ""
277
+ if git_branch and normalize_branch_name(git_branch) != normalized:
278
+ git_info += " [yellow](mismatch)[/yellow]"
279
+
280
+ console.print(f"Switched to [bold]{original}[/bold]")
281
+ console.print(f" {open_count} open, {closed_count} closed tickets{git_info}")
282
+
283
+
171
284
  @app.command(help="Mark the current session as done.")
172
285
  def done(
173
286
  feature: Annotated[str | None, typer.Argument(help="Branch name (defaults to current session).")] = None,
@@ -219,7 +332,7 @@ def done(
219
332
  # Clean up associated worktrees (read from state.json worktrees map)
220
333
  worktrees = state.get("worktrees", {})
221
334
  if worktrees:
222
- if not force:
335
+ if not force and sys.stdin.isatty():
223
336
  names = ", ".join(worktrees.keys())
224
337
  typer.confirm(f"Remove {len(worktrees)} worktree(s) ({names})?", abort=True)
225
338
  for ticket_id, wt_path in worktrees.items():
@@ -324,11 +437,11 @@ def status(
324
437
  if ticket.status in status_counts:
325
438
  status_counts[ticket.status] += 1
326
439
 
327
- # Count ready tickets (open/in_progress with all deps closed — excludes in_review)
440
+ # Count ready tickets (open with all deps closed — startable, not already started)
328
441
  status_by_id = {t.id: t.status for t in tickets}
329
442
  ready_count = 0
330
443
  for ticket in tickets:
331
- if ticket.status not in ("open", "in_progress"):
444
+ if ticket.status != "open":
332
445
  continue
333
446
  all_deps_closed = all(status_by_id.get(dep, "unknown") == "closed" for dep in ticket.deps)
334
447
  if all_deps_closed: