Python · 101142 bytes Raw Blame History
1 """Main CLI entry point."""
2
3 import asyncio
4 import json
5 import re
6 import sys
7 from typing import Any
8
9 import click
10 from rich import box
11 from rich.console import Console, Group
12 from rich.markdown import Markdown
13 from rich.panel import Panel
14 from rich.prompt import Confirm, Prompt
15 from rich.table import Table
16 from rich.text import Text
17
18 from ..runtime.inspection import (
19 CheckStatus,
20 DoctorReport,
21 ExploreContinuitySnapshot,
22 PermissionCheckResult,
23 PermissionSnapshot,
24 PromptDiffSnapshot,
25 PromptPreview,
26 StatusSnapshot,
27 WorkflowArtifactDiffSnapshot,
28 WorkflowTimelineSnapshot,
29 collect_doctor_report,
30 collect_explore_continuity_snapshot,
31 collect_permission_snapshot,
32 collect_prompt_diff,
33 collect_prompt_preview,
34 collect_status_snapshot,
35 collect_workflow_artifact_diffs,
36 collect_workflow_timeline,
37 dry_run_permission_check,
38 list_session_summaries,
39 load_session_detail,
40 project_workflow_timeline,
41 reset_explore_continuity,
42 )
43 from ..runtime.owner_metadata import format_runtime_owner_label
44 from ..runtime.permissions import PermissionMode
45 from ..runtime.runtime_api import RuntimeShellOwner, build_runtime_shell_owner
46 from ..runtime.workflow_timeline_read_model import (
47 format_evidence_provenance_brief,
48 summarize_observed_verification,
49 workflow_entry_evidence_rollup,
50 )
51 from ..utils.file_mutations import (
52 build_file_mutation_preview,
53 is_file_mutation_tool,
54 render_file_mutation_preview,
55 )
56 from .options import inject_resume_target
57 from .rendering import (
58 format_dod_status,
59 format_permission_mode,
60 format_workflow_mode,
61 )
62
63 console = Console()
64 SPECIAL_COMMANDS = {
65 "doctor",
66 "status",
67 "session",
68 "explore",
69 "permissions",
70 "prompt",
71 "workflow",
72 }
73
74 try:
75 import httpx
76
77 HttpReadTimeout = httpx.ReadTimeout
78 except ModuleNotFoundError: # pragma: no cover - exercised in minimal test envs
79 httpx = None
80
81 class HttpReadTimeout(Exception):
82 """Fallback timeout type when httpx is unavailable at import time."""
83
84
85 def format_size(size_bytes: int) -> str:
86 """Format bytes as human-readable size."""
87 for unit in ["B", "KB", "MB", "GB", "TB"]:
88 if size_bytes < 1024:
89 return f"{size_bytes:.1f} {unit}"
90 size_bytes /= 1024
91 return f"{size_bytes:.1f} PB"
92
93
94 async def select_model_interactive() -> str | None:
95 """Show interactive model selection menu.
96
97 Returns:
98 Selected model name, or None if cancelled/no models.
99 """
100 from prompt_toolkit import PromptSession
101 from prompt_toolkit.completion import WordCompleter
102
103 from ..config import get_last_model
104 from ..llm.ollama import OllamaBackend
105
106 # Create a temporary client to list models
107 backend = OllamaBackend(model="")
108 models = await backend.list_models()
109 await backend.close()
110
111 if not models:
112 console.print("[red]No models found. Is Ollama running?[/red]")
113 console.print("Start with: [cyan]ollama serve[/cyan]")
114 console.print("Pull a model: [cyan]ollama pull llama3.1:8b[/cyan]")
115 return None
116
117 # Get last used model for highlighting
118 last_model = get_last_model()
119
120 # Sort by size (largest first) for better display
121 models.sort(key=lambda m: m["size"], reverse=True)
122
123 # Display available models
124 console.print("\n[bold]Available Models:[/bold]\n")
125 table = Table(show_header=True, header_style="bold cyan")
126 table.add_column("#", style="dim", width=3)
127 table.add_column("Model", style="green")
128 table.add_column("Size", justify="right")
129 table.add_column("", width=6) # For "last" indicator
130
131 model_names = []
132 default_idx = 0
133 for i, model in enumerate(models, 1):
134 name = model["name"]
135 model_names.append(name)
136 size = format_size(model["size"])
137 # Mark last used model
138 indicator = "[yellow]← last[/yellow]" if name == last_model else ""
139 if name == last_model:
140 default_idx = i - 1
141 table.add_row(str(i), name, size, indicator)
142
143 console.print(table)
144 console.print()
145
146 # Create completer for model names
147 completer = WordCompleter(model_names + [str(i) for i in range(1, len(models) + 1)])
148
149 # Build prompt with default hint
150 default_model = models[default_idx]["name"] if last_model else models[0]["name"]
151 prompt_text = f"Select model [default: {default_model}]: "
152
153 session = PromptSession(completer=completer)
154 try:
155 choice = await asyncio.to_thread(
156 session.prompt,
157 prompt_text,
158 )
159 choice = choice.strip()
160
161 if not choice:
162 return default_model # Use last-used or first model
163
164 # Try as number first
165 try:
166 idx = int(choice) - 1
167 if 0 <= idx < len(models):
168 return models[idx]["name"]
169 except ValueError:
170 pass
171
172 # Try as name (partial match)
173 for name in model_names:
174 if choice in name or name in choice:
175 return name
176
177 console.print(f"[yellow]Unknown model: {choice}[/yellow]")
178 return None
179
180 except (EOFError, KeyboardInterrupt):
181 console.print("\n[dim]Cancelled[/dim]")
182 return None
183
184
185 def clean_response(text: str) -> str:
186 """Clean up response text by removing any leaked tool call syntax."""
187 # Remove tool_call tags
188 text = re.sub(r"</?tool_call>", "", text)
189 # Remove bare JSON tool calls that might have leaked
190 text = re.sub(
191 r'\{[^{}]*"name"\s*:\s*"[^"]+"\s*,\s*"(?:arguments|parameters)"\s*:\s*\{[^{}]*\}[^{}]*\}',
192 "",
193 text,
194 )
195 # Clean up multiple newlines
196 text = re.sub(r"\n{3,}", "\n\n", text)
197 return text.strip()
198
199
200 @click.command()
201 @click.option("--model", "-m", default=None, help="Model to use (default: llama3.1:8b)")
202 @click.option("--select-model", "-s", is_flag=True, help="Interactively select model from available")
203 @click.option("--backend", "-b", default="ollama", help="LLM backend (ollama)")
204 @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
205 @click.option(
206 "--permission-mode",
207 type=click.Choice(
208 ["read-only", "workspace-write", "danger-full-access", "prompt", "allow"],
209 case_sensitive=False,
210 ),
211 default="workspace-write",
212 show_default=True,
213 help="Runtime permission mode for tool execution",
214 )
215 @click.option("--react", is_flag=True, help="Force ReAct mode (text-based tool calling)")
216 @click.option("--no-context", is_flag=True, help="Skip auto-detecting project context")
217 @click.option("--plan", is_flag=True, help="Start the task in plan mode")
218 @click.option("--clarify", is_flag=True, help="Start the task in clarify mode")
219 @click.option("--resume-target", hidden=True, default=None)
220 @click.option("--no-recover", is_flag=True, help="Disable auto-recovery from tool errors")
221 @click.option("--no-tui", is_flag=True, help="Use simple Rich output instead of full TUI")
222 @click.option("--ctx", type=int, default=8192, help="Context window size (default: 8192, smaller = faster)")
223 @click.option("--gpu", type=int, default=-1, help="GPU layers (default: -1 = all, 0 = CPU only)")
224 @click.option("--timeout", type=int, default=None, help="Request timeout in seconds (default: auto based on model size)")
225 # Reasoning options
226 @click.option("--decompose", is_flag=True, help="Enable task decomposition (break complex tasks into subtasks)")
227 @click.option("--critique", is_flag=True, help="Enable self-critique (review responses before finalizing)")
228 @click.option("--confidence", is_flag=True, help="Enable confidence scoring (rate certainty before actions)")
229 @click.option("--verify", is_flag=True, help="Enable post-action verification (check results)")
230 @click.option("--reason", is_flag=True, help="Enable all reasoning stages (decompose + critique + confidence + verify)")
231 @click.argument("prompt", required=False)
232 def cli(
233 model: str | None,
234 select_model: bool,
235 backend: str,
236 yes: bool,
237 permission_mode: str,
238 react: bool,
239 no_context: bool,
240 plan: bool,
241 clarify: bool,
242 resume_target: str | None,
243 no_recover: bool,
244 no_tui: bool,
245 ctx: int,
246 gpu: int,
247 timeout: int | None,
248 decompose: bool,
249 critique: bool,
250 confidence: bool,
251 verify: bool,
252 reason: bool,
253 prompt: str | None,
254 ) -> None:
255 """Loader - Local AI coding assistant."""
256 asyncio.run(_main(
257 model, select_model, backend, yes, permission_mode, react, no_context, plan, clarify, resume_target, no_recover,
258 no_tui, ctx, gpu, timeout, decompose, critique, confidence, verify, reason, prompt
259 ))
260
261
262 def main() -> None:
263 """Entry-point wrapper that supports `--resume [session-id]` syntax."""
264
265 argv = inject_resume_target(sys.argv[1:])
266 if argv and argv[0] in {"-h", "--help"}:
267 click.echo(_loader_help_text())
268 return
269
270 if argv and argv[0] == "session" and len(argv) >= 3 and argv[1] == "resume":
271 cli.main(
272 args=["--resume-target", argv[2], *argv[3:]],
273 prog_name="loader",
274 )
275 return
276
277 if argv and argv[0] in SPECIAL_COMMANDS:
278 _run_special_command(argv)
279 return
280
281 cli.main(args=argv, prog_name="loader")
282
283
284 async def _main(
285 model: str | None,
286 select_model: bool,
287 backend: str,
288 yes: bool,
289 permission_mode: str,
290 react: bool,
291 no_context: bool,
292 plan: bool,
293 clarify: bool,
294 resume_target: str | None,
295 no_recover: bool,
296 no_tui: bool,
297 ctx: int | None,
298 gpu: int | None,
299 timeout: float | None,
300 decompose: bool,
301 critique: bool,
302 confidence: bool,
303 verify: bool,
304 reason: bool,
305 prompt: str | None,
306 ) -> None:
307 from ..agent.loop import AgentConfig, ReasoningConfig
308 from ..config import get_default_model, get_last_model, set_last_model
309 from ..llm.ollama import OllamaBackend
310 from ..tools.base import create_default_registry
311
312 if plan and clarify:
313 raise click.UsageError("Choose only one of --plan or --clarify.")
314
315 # Handle model selection
316 if select_model:
317 # Explicitly requested model selection
318 selected = await select_model_interactive()
319 if selected is None:
320 return
321 model = selected
322 elif model is None:
323 # No model specified - use saved default or fallback
324 model = get_default_model()
325 saved = get_last_model()
326 if saved:
327 console.print(f"[dim]Using saved model: {model}[/dim]")
328
329 # Initialize backend with performance options
330 llm = OllamaBackend(
331 model=model,
332 force_react=react,
333 num_ctx=ctx,
334 num_gpu=gpu,
335 timeout=timeout,
336 )
337
338 # Check health
339 if not await llm.health_check():
340 console.print("[red]Error: Cannot connect to Ollama. Is it running?[/red]")
341 console.print("Start it with: ollama serve")
342 console.print(f"\nOr pull the model: [cyan]ollama pull {model}[/cyan]")
343 # Offer to select a different model
344 console.print("\nTry [cyan]loader --select-model[/cyan] to choose from available models.")
345 return
346
347 await llm.describe_model()
348
349 # Probe the model's actual tool calling behavior at runtime temperature.
350 # Runs 3 rounds — model must pass all 3 to be considered native.
351 # This catches models like devstral that work at temp=0 but are
352 # unreliable at the actual runtime temperature.
353 if not react and hasattr(llm, "probe_native_tool_support"):
354 from ..agent.loop import AgentConfig as _ProbeCfg
355 from ..tools.base import create_default_registry as _probe_registry
356 probe_temp = _ProbeCfg.temperature
357 probe_schemas = _probe_registry().get_schemas()
358 console.print(f"[dim]Probing tool support (temp={probe_temp}, 3 rounds)...[/dim]", end="")
359 native = await llm.probe_native_tool_support(
360 temperature=probe_temp, tools=probe_schemas,
361 )
362 console.print(f" [dim]{'native' if native else 'react'}[/dim]")
363 mode_str = "ReAct" if react or not llm.supports_native_tools() else "Native"
364
365 # Save this model as the new default
366 set_last_model(model)
367
368 # Create registry with confirmation setting
369 registry = create_default_registry()
370 registry.skip_confirmation = yes
371
372 # Configure reasoning stages
373 # --reason enables all, otherwise use individual flags
374 reasoning_config = ReasoningConfig(
375 decomposition=reason or decompose,
376 self_critique=reason or critique,
377 confidence_scoring=reason or confidence,
378 verification=reason or verify,
379 )
380
381 config = AgentConfig(
382 force_react=react,
383 auto_context=not no_context,
384 auto_plan=False,
385 auto_recover=not no_recover,
386 permission_mode=PermissionMode.from_str(permission_mode),
387 workflow_mode_override="clarify" if clarify else ("plan" if plan else None),
388 reasoning=reasoning_config,
389 )
390 try:
391 shell_owner = build_runtime_shell_owner(
392 backend=llm,
393 registry=registry,
394 config=config,
395 owner_kind="runtime",
396 )
397 except ValueError as exc:
398 console.print(f"[red]Permission policy error:[/red] {exc}")
399 return
400 resumed = False
401 if resume_target is not None:
402 session_id = None if resume_target == "__latest__" else resume_target
403 resumed = shell_owner.resume_session(session_id)
404 if not resumed and session_id is None:
405 console.print("[yellow]No previous session found; starting a new session.[/yellow]")
406 elif not resumed:
407 console.print(f"[red]Session not found:[/red] {session_id}")
408 return
409 else:
410 console.print(f"[dim]Resumed session: {shell_owner.session.session_id}[/dim]")
411
412 # Show reasoning status if enabled
413 reasoning_active = []
414 if reasoning_config.decomposition:
415 reasoning_active.append("decompose")
416 if reasoning_config.self_critique:
417 reasoning_active.append("critique")
418 if reasoning_config.confidence_scoring:
419 reasoning_active.append("confidence")
420 if reasoning_config.verification:
421 reasoning_active.append("verify")
422 if reasoning_active:
423 console.print(f"[dim]Reasoning: {', '.join(reasoning_active)}[/dim]")
424
425 # Single prompt mode always uses simple output
426 if prompt:
427 # Build status line for non-TUI mode
428 timeout_mins = int(llm.timeout / 60)
429 status_parts = [
430 f"Model: {model}",
431 f"Mode: {mode_str}",
432 (
433 "Capabilities: "
434 f"{shell_owner.capability_profile.preferred_tool_call_format}/"
435 f"{shell_owner.capability_profile.verification_strictness}"
436 ),
437 f"Workflow: {format_workflow_mode(shell_owner.workflow_mode)}",
438 f"Permissions: {format_permission_mode(shell_owner.active_permission_mode)}",
439 f"Session: {shell_owner.session.session_id}",
440 ]
441 if shell_owner.project_context:
442 status_parts.append(f"Project: {shell_owner.project_context.project_type}")
443 status_parts.append(f"Timeout: {timeout_mins}m")
444 if yes:
445 status_parts.append("Confirm: off")
446
447 console.print(Panel.fit(
448 "[bold blue]Loader[/bold blue]\n" + " | ".join(status_parts),
449 border_style="blue",
450 ))
451 await run_once(shell_owner, prompt, skip_confirmation=yes)
452 return
453
454 # Interactive mode - use TUI unless --no-tui
455 if no_tui:
456 # Build status line for non-TUI mode
457 timeout_mins = int(llm.timeout / 60)
458 status_parts = [
459 f"Model: {model}",
460 f"Mode: {mode_str}",
461 (
462 "Capabilities: "
463 f"{shell_owner.capability_profile.preferred_tool_call_format}/"
464 f"{shell_owner.capability_profile.verification_strictness}"
465 ),
466 f"Workflow: {format_workflow_mode(shell_owner.workflow_mode)}",
467 f"Permissions: {format_permission_mode(shell_owner.active_permission_mode)}",
468 f"Session: {shell_owner.session.session_id}",
469 ]
470 if shell_owner.project_context:
471 status_parts.append(f"Project: {shell_owner.project_context.project_type}")
472 status_parts.append(f"Timeout: {timeout_mins}m")
473 if yes:
474 status_parts.append("Confirm: off")
475
476 console.print(Panel.fit(
477 "[bold blue]Loader[/bold blue]\n" + " | ".join(status_parts),
478 border_style="blue",
479 ))
480 console.print(
481 "[dim]Type 'exit' to quit, 'clear' to reset conversation, "
482 "'jobs' to inspect bash jobs[/dim]\n"
483 )
484 await run_interactive(shell_owner, skip_confirmation=yes)
485 else:
486 # Launch TUI
487 from ..ui.app import LoaderApp
488
489 app = LoaderApp(
490 shell_owner=shell_owner,
491 model_name=model,
492 mode=mode_str,
493 capability_profile=(
494 f"{shell_owner.capability_profile.preferred_tool_call_format}/"
495 f"{shell_owner.capability_profile.verification_strictness}"
496 ),
497 session_id=shell_owner.session.session_id,
498 workflow_mode=shell_owner.workflow_mode,
499 turn_phase=shell_owner.session.active_turn_phase or "",
500 permission_mode=shell_owner.active_permission_mode,
501 )
502 await app.run_async()
503
504
505 def _format_tool_args(args: dict | None) -> str:
506 """Format tool arguments for display."""
507 if not args:
508 return ""
509 parts = []
510 for k, v in args.items():
511 parts.append(f"{k}={_format_tool_arg_value(k, v)}")
512 return ", ".join(parts)
513
514
515 def _format_tool_arg_value(key: str, value: Any) -> str:
516 """Format one tool argument value as plain text safe for Rich Text rendering."""
517 if isinstance(value, str):
518 limit = 200 if key in ("file_path", "path") else (80 if key == "content" else 40)
519 if len(value) > limit:
520 value = value[: limit - 3] + "..."
521 return json.dumps(value)
522
523 if key == "hunks" and isinstance(value, list):
524 return f"{len(value)} hunk" if len(value) == 1 else f"{len(value)} hunks"
525 if key == "todos" and isinstance(value, list):
526 return f"{len(value)} todo" if len(value) == 1 else f"{len(value)} todos"
527 if isinstance(value, list):
528 return f"{len(value)} item" if len(value) == 1 else f"{len(value)} items"
529 if isinstance(value, dict):
530 keys = ", ".join(sorted(value.keys())[:4])
531 suffix = "" if len(value) <= 4 else ", ..."
532 return f"{{{keys}{suffix}}}"
533
534 rendered = repr(value)
535 if len(rendered) > 80:
536 rendered = rendered[:77] + "..."
537 return rendered
538
539
540 _SPECIAL_TOOL_LABELS = {
541 "write": "Write",
542 "edit": "Edit",
543 "patch": "Patch",
544 "bash": "Bash",
545 "bash_jobs": "Bash Jobs",
546 "bash_wait": "Bash Wait",
547 "bash_kill": "Bash Kill",
548 }
549
550
551 def _tool_label(tool_name: str, phase: str | None = None) -> str:
552 label = _SPECIAL_TOOL_LABELS.get(tool_name, tool_name)
553 if phase == "verification":
554 return f"Verify {label}"
555 return label
556
557
558 def _truncate_tool_text(
559 text: str,
560 *,
561 line_limit: int,
562 char_limit: int = 6_000,
563 ) -> tuple[str, bool]:
564 lines = text.splitlines()
565 if len(lines) <= line_limit and len(text) <= char_limit:
566 return text, False
567
568 preview = "\n".join(lines[:line_limit])
569 if len(preview) > char_limit:
570 preview = preview[:char_limit]
571 return preview, True
572
573
574 def _render_bash_call(tool_args: dict | None, *, phase: str | None = None):
575 command = str((tool_args or {}).get("command", "")).strip() or "(empty command)"
576 title = _tool_label("bash", phase)
577 border_style = "magenta" if phase == "verification" else "cyan"
578 return Group(
579 Text(title, style=f"bold {border_style}"),
580 Panel(
581 Text(command),
582 title="Command",
583 border_style=border_style,
584 box=box.SQUARE,
585 expand=True,
586 ),
587 )
588
589
590 def _render_bash_result(
591 content: str,
592 *,
593 metadata: dict[str, Any] | None,
594 is_error: bool,
595 phase: str | None = None,
596 ) -> Panel:
597 metadata = metadata or {}
598 title = _tool_label("bash", phase)
599 lines = []
600 status_value = str(metadata.get("status", "failed" if is_error else "completed"))
601 lines.append(f"Status: {status_value.replace('_', ' ')}")
602 if metadata.get("job_id"):
603 lines.append(f"Job: {metadata['job_id']}")
604 if metadata.get("pid"):
605 lines.append(f"PID: {metadata['pid']}")
606 if metadata.get("exit_code") is not None:
607 lines.append(f"Exit: {metadata['exit_code']}")
608 if metadata.get("background") is not None:
609 lines.append(
610 f"Mode: {'background' if metadata.get('background') else 'foreground'}"
611 )
612
613 stdout_text = str(metadata.get("stdout", "") or "")
614 stderr_text = str(metadata.get("stderr", "") or "")
615 show_summary_note = (
616 (not stdout_text and not stderr_text and bool(content.strip()))
617 or status_value not in {"completed", "running"}
618 )
619 body = "\n".join(lines)
620 if show_summary_note and content.strip():
621 preview, truncated = _truncate_tool_text(content, line_limit=20)
622 body = f"{body}\n\n{preview}" if body else preview
623 if truncated:
624 body += "\n… truncated for display; full result preserved in session"
625
626 border_style = "red" if is_error else ("magenta" if phase == "verification" else "green")
627 return Panel(
628 Text(body or "(no output)"),
629 title=f"[bold {border_style}]{title}[/bold {border_style}]",
630 border_style=border_style,
631 box=box.SQUARE,
632 expand=True,
633 )
634
635
636 def _render_file_mutation_call(
637 tool_name: str,
638 tool_args: dict | None,
639 *,
640 phase: str | None = None,
641 ):
642 preview = build_file_mutation_preview(tool_name, tool_args=tool_args)
643 if preview is None:
644 return None
645 border_style = "magenta" if phase == "verification" else "cyan"
646 return Group(
647 Text(_tool_label(tool_name, phase), style=f"bold {border_style}"),
648 render_file_mutation_preview(
649 preview,
650 border_style=border_style,
651 title="Preview",
652 max_lines=40,
653 max_chars=6_000,
654 ),
655 )
656
657
658 def _render_file_mutation_result(
659 tool_name: str,
660 *,
661 metadata: dict[str, Any] | None,
662 phase: str | None = None,
663 ):
664 preview = build_file_mutation_preview(tool_name, metadata=metadata)
665 if preview is None:
666 return None
667 border_style = "magenta" if phase == "verification" else "green"
668 return Group(
669 Text(_tool_label(tool_name, phase), style=f"bold {border_style}"),
670 render_file_mutation_preview(
671 preview,
672 border_style=border_style,
673 title="Diff",
674 max_lines=60,
675 max_chars=6_000,
676 ),
677 )
678
679
680 def _print_tool_call(tool_name: str, tool_args: dict | None, phase: str | None = None) -> None:
681 if tool_name == "bash":
682 console.print(_render_bash_call(tool_args, phase=phase))
683 return
684 if is_file_mutation_tool(tool_name):
685 renderable = _render_file_mutation_call(tool_name, tool_args, phase=phase)
686 if renderable is not None:
687 console.print(renderable)
688 return
689
690 args_str = _format_tool_args(tool_args)
691 text = Text()
692 text.append("> ", style="cyan")
693 text.append(_tool_label(tool_name, phase), style="bold cyan")
694 if args_str:
695 text.append(f"({args_str})")
696 console.print(text)
697
698
699 def _print_tool_result(
700 tool_name: str,
701 content: str,
702 *,
703 metadata: dict[str, Any] | None = None,
704 is_error: bool = False,
705 phase: str | None = None,
706 preview_lines: int = 10,
707 ) -> None:
708 if tool_name == "bash":
709 console.print(
710 _render_bash_result(
711 content,
712 metadata=metadata,
713 is_error=is_error,
714 phase=phase,
715 )
716 )
717 return
718 if not is_error and is_file_mutation_tool(tool_name):
719 renderable = _render_file_mutation_result(
720 tool_name,
721 metadata=metadata,
722 phase=phase,
723 )
724 if renderable is not None:
725 console.print(renderable)
726 return
727
728 preview, truncated = _truncate_tool_text(content, line_limit=preview_lines)
729 renderable = Text(preview or "(no output)")
730 if truncated:
731 renderable.append(
732 "\n... truncated for display; full result preserved in session",
733 style="dim",
734 )
735 border_style = "red" if is_error else ("magenta" if phase == "verification" else "dim")
736 console.print(Panel(renderable, border_style=border_style))
737
738
739 def _parse_local_bash_command(user_input: str) -> tuple[str, dict[str, object]] | None:
740 parts = user_input.strip().split()
741 if not parts:
742 return None
743
744 command = parts[0].lstrip("/").lower()
745 if command == "jobs":
746 if len(parts) > 2:
747 raise ValueError("Usage: jobs [limit]")
748 tool_args: dict[str, object] = {}
749 if len(parts) == 2:
750 tool_args["limit"] = max(1, int(parts[1]))
751 return "bash_jobs", tool_args
752
753 if command == "wait":
754 if len(parts) not in {2, 3}:
755 raise ValueError("Usage: wait <job-id> [timeout-seconds]")
756 tool_args = {"job_id": parts[1]}
757 if len(parts) == 3:
758 tool_args["timeout"] = float(parts[2])
759 return "bash_wait", tool_args
760
761 if command == "kill":
762 if len(parts) not in {2, 3}:
763 raise ValueError("Usage: kill <job-id> [force-after-ms]")
764 tool_args = {"job_id": parts[1]}
765 if len(parts) == 3:
766 tool_args["force_after_ms"] = int(parts[2])
767 return "bash_kill", tool_args
768
769 return None
770
771
772 async def _run_local_bash_command(
773 shell_owner: RuntimeShellOwner,
774 tool_name: str,
775 tool_args: dict[str, object],
776 ) -> None:
777 _print_tool_call(tool_name, tool_args, phase="local")
778 result = await shell_owner.registry.execute(tool_name, **tool_args)
779 _print_tool_result(
780 tool_name,
781 result.output,
782 metadata=result.metadata,
783 is_error=result.is_error,
784 phase="local",
785 preview_lines=8,
786 )
787
788
789 def _get_bash_tool(shell_owner: RuntimeShellOwner):
790 from ..tools.shell_tools import BashTool
791
792 tool = shell_owner.registry.get("bash")
793 return tool if isinstance(tool, BashTool) else None
794
795
796 async def _interrupt_active_foreground_bash(shell_owner: RuntimeShellOwner) -> bool:
797 bash_tool = _get_bash_tool(shell_owner)
798 if bash_tool is None:
799 return False
800
801 result = await bash_tool.manager.interrupt_active_foreground()
802 if result is None:
803 return False
804
805 _print_tool_result(
806 "bash",
807 result.output,
808 metadata=result.metadata,
809 is_error=result.is_error,
810 phase="local",
811 preview_lines=8,
812 )
813 return True
814
815
816 async def run_once(
817 shell_owner: RuntimeShellOwner,
818 prompt: str,
819 skip_confirmation: bool = False,
820 ) -> None:
821 """Run a single prompt through one shell-compatible runtime owner."""
822 import time
823
824 from ..tools.base import ConfirmationRequired
825
826 thinking_start = None
827 streamed_response = False
828
829 def on_event(event):
830 nonlocal thinking_start, streamed_response
831 if event.type == "thinking":
832 thinking_start = time.time()
833 console.print("[dim]Generating...[/dim]", end="")
834 elif event.type == "stream":
835 if thinking_start:
836 elapsed = time.time() - thinking_start
837 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
838 thinking_start = None
839 streamed_response = True
840 console.print()
841 console.print(event.content, end="", markup=False)
842 if event.is_stream_end:
843 console.print()
844 elif event.type == "plan":
845 console.print(Panel(event.content, title="[bold]Plan[/bold]", border_style="blue"))
846 elif event.type == "workflow_mode":
847 console.print(
848 f"[dim]Workflow: {format_workflow_mode(event.workflow_mode or 'execute')}[/dim]"
849 )
850 elif event.type == "artifact":
851 console.print(
852 Panel(
853 f"{event.content}\n[dim]{event.artifact_path}[/dim]",
854 title=f"[bold cyan]{event.artifact_kind or 'artifact'}[/bold cyan]",
855 border_style="cyan",
856 )
857 )
858 elif event.type == "step":
859 console.print(f"\n[bold yellow]{event.step_info}[/bold yellow]")
860 elif event.type == "tool_call":
861 if thinking_start:
862 elapsed = time.time() - thinking_start
863 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
864 thinking_start = None
865 _print_tool_call(
866 getattr(event, "tool_name", "") or "",
867 getattr(event, "tool_args", None),
868 getattr(event, "phase", None),
869 )
870 elif event.type == "tool_result":
871 _print_tool_result(
872 getattr(event, "tool_name", "") or "",
873 event.content,
874 metadata=getattr(event, "tool_metadata", None),
875 is_error=getattr(event, "is_error", False),
876 phase=getattr(event, "phase", None),
877 preview_lines=10,
878 )
879 elif event.type == "dod_status":
880 console.print(f"[dim]{format_dod_status(event)}[/dim]")
881 elif event.type == "recovery":
882 console.print(f"[yellow]Recovering from error ({event.recovery_attempt}/3)...[/yellow]")
883 elif event.type == "error":
884 console.print(Panel(event.content, title="[red]Error[/red]", border_style="red"))
885 elif event.type == "response":
886 pass # We'll print the full response at the end
887
888 try:
889 response = await shell_owner.run(
890 prompt,
891 on_event=on_event,
892 on_user_question=_ask_user_question_cli,
893 )
894 if not streamed_response:
895 console.print(Markdown(clean_response(response)))
896 except HttpReadTimeout:
897 console.print("\n[red]Request timed out.[/red]")
898 console.print("[dim]The model is taking too long. Try a smaller model or simpler prompt.[/dim]")
899 return
900 except KeyboardInterrupt:
901 console.print()
902 if not await _interrupt_active_foreground_bash(shell_owner):
903 console.print("[yellow]Cancelled.[/yellow]")
904 return
905 except ConfirmationRequired as e:
906 console.print(f"\n[yellow]Confirmation required:[/yellow] {e.message}")
907 if e.details:
908 console.print(f"[dim]{e.details}[/dim]")
909 if Confirm.ask("Proceed?"):
910 shell_owner.registry.skip_confirmation = True
911 streamed_response = False # Reset for continuation
912 try:
913 response = await shell_owner.run(
914 "Continue with the previous action.",
915 on_event=on_event,
916 on_user_question=_ask_user_question_cli,
917 )
918 if not streamed_response:
919 console.print(Markdown(clean_response(response)))
920 except KeyboardInterrupt:
921 console.print()
922 if not await _interrupt_active_foreground_bash(shell_owner):
923 console.print("[yellow]Cancelled.[/yellow]")
924 finally:
925 shell_owner.registry.skip_confirmation = skip_confirmation
926 else:
927 console.print("[red]Aborted.[/red]")
928
929
930 async def run_interactive(
931 shell_owner: RuntimeShellOwner,
932 skip_confirmation: bool = False,
933 ) -> None:
934 """Run the simple interactive chat loop for one shell-compatible owner."""
935 import os
936
937 from prompt_toolkit import PromptSession
938 from prompt_toolkit.history import FileHistory
939
940 from ..tools.base import ConfirmationRequired
941
942 history_file = os.path.expanduser("~/.loader_history")
943 session = PromptSession(history=FileHistory(history_file))
944
945 console.print(
946 "[dim]Type 'exit' to quit, 'clear' to reset conversation, "
947 "'jobs' to inspect bash jobs[/dim]\n"
948 )
949
950 while True:
951 try:
952 user_input = await asyncio.to_thread(
953 session.prompt,
954 "You: ",
955 )
956 except (EOFError, KeyboardInterrupt):
957 console.print("\nGoodbye!")
958 break
959
960 user_input = user_input.strip()
961 if not user_input:
962 continue
963
964 if user_input.lower() == "exit":
965 console.print("Goodbye!")
966 break
967
968 if user_input.lower() == "clear":
969 shell_owner.clear_history()
970 console.print("[dim]Conversation cleared[/dim]")
971 continue
972
973 try:
974 local_bash = _parse_local_bash_command(user_input)
975 except ValueError as exc:
976 console.print(f"[red]{exc}[/red]\n")
977 continue
978
979 if local_bash is not None:
980 tool_name, tool_args = local_bash
981 await _run_local_bash_command(shell_owner, tool_name, tool_args)
982 console.print()
983 continue
984
985 import time
986 thinking_start = None
987 streaming_started = False
988 streamed_response = False # Track if we displayed via streaming
989
990 def on_event(event):
991 nonlocal thinking_start, streaming_started, streamed_response
992 if event.type == "thinking":
993 thinking_start = time.time()
994 streaming_started = False
995 console.print("[dim]Generating...[/dim]", end="")
996 elif event.type == "stream":
997 if thinking_start and not streaming_started:
998 # First stream chunk - clear the "Generating..." and start fresh
999 elapsed = time.time() - thinking_start
1000 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
1001 thinking_start = None
1002 streaming_started = True
1003 streamed_response = True
1004 console.print() # New line before streamed content
1005 # Print the chunk without newline
1006 console.print(event.content, end="", markup=False)
1007 if event.is_stream_end:
1008 console.print() # Final newline
1009 elif event.type == "plan":
1010 if thinking_start:
1011 elapsed = time.time() - thinking_start
1012 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
1013 thinking_start = None
1014 console.print(Panel(event.content, title="[bold]Plan[/bold]", border_style="blue"))
1015 elif event.type == "workflow_mode":
1016 console.print(
1017 f"\n[dim]Workflow: {format_workflow_mode(event.workflow_mode or 'execute')}[/dim]"
1018 )
1019 elif event.type == "artifact":
1020 console.print(
1021 Panel(
1022 f"{event.content}\n[dim]{event.artifact_path}[/dim]",
1023 title=f"[bold cyan]{event.artifact_kind or 'artifact'}[/bold cyan]",
1024 border_style="cyan",
1025 )
1026 )
1027 elif event.type == "step":
1028 console.print(f"\n[bold yellow]{event.step_info}[/bold yellow]")
1029 elif event.type == "tool_call":
1030 if thinking_start:
1031 elapsed = time.time() - thinking_start
1032 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
1033 thinking_start = None
1034 if streaming_started:
1035 console.print() # New line after any streamed content
1036 streaming_started = False
1037 _print_tool_call(
1038 getattr(event, "tool_name", "") or "",
1039 getattr(event, "tool_args", None),
1040 getattr(event, "phase", None),
1041 )
1042 elif event.type == "tool_result":
1043 _print_tool_result(
1044 getattr(event, "tool_name", "") or "",
1045 event.content,
1046 metadata=getattr(event, "tool_metadata", None),
1047 is_error=getattr(event, "is_error", False),
1048 phase=getattr(event, "phase", None),
1049 preview_lines=3,
1050 )
1051 elif event.type == "dod_status":
1052 console.print(f"\n[dim]{format_dod_status(event)}[/dim]")
1053 elif event.type == "recovery":
1054 console.print(f"\n[yellow]Recovering from error ({event.recovery_attempt}/3)...[/yellow]")
1055 elif event.type == "error":
1056 console.print(Panel(event.content, title="[red]Error[/red]", border_style="red"))
1057
1058 try:
1059 response = await shell_owner.run(
1060 user_input,
1061 on_event=on_event,
1062 on_user_question=_ask_user_question_cli,
1063 )
1064 console.print()
1065 # Only print markdown response if we didn't stream it
1066 if not streamed_response:
1067 console.print(Markdown(clean_response(response)))
1068 console.print()
1069 except HttpReadTimeout:
1070 console.print("\n[red]Request timed out.[/red]")
1071 console.print("[dim]The model is taking too long to respond. Try:[/dim]")
1072 console.print(" • A smaller model (e.g., [cyan]loader -m llama3.1:8b[/cyan])")
1073 console.print(" • A simpler prompt")
1074 console.print(" • Check if Ollama is overloaded")
1075 continue
1076 except KeyboardInterrupt:
1077 console.print()
1078 if not await _interrupt_active_foreground_bash(shell_owner):
1079 console.print("[yellow]Cancelled.[/yellow]")
1080 console.print()
1081 continue
1082 except ConfirmationRequired as e:
1083 console.print(f"\n[yellow]Confirmation required:[/yellow] {e.message}")
1084 if e.details:
1085 console.print(f"[dim]{e.details}[/dim]")
1086 if Confirm.ask("Proceed?"):
1087 shell_owner.registry.skip_confirmation = True
1088 streamed_response = False # Reset for continuation
1089 try:
1090 response = await shell_owner.run(
1091 "Continue with the previous action.",
1092 on_event=on_event,
1093 on_user_question=_ask_user_question_cli,
1094 )
1095 console.print()
1096 if not streamed_response:
1097 console.print(Markdown(clean_response(response)))
1098 console.print()
1099 except KeyboardInterrupt:
1100 console.print()
1101 if not await _interrupt_active_foreground_bash(shell_owner):
1102 console.print("[yellow]Cancelled.[/yellow]")
1103 console.print()
1104 finally:
1105 shell_owner.registry.skip_confirmation = skip_confirmation
1106 else:
1107 console.print("[red]Aborted.[/red]\n")
1108
1109
1110 async def _ask_user_question_cli(
1111 question: str,
1112 options: list[str] | None,
1113 ) -> str:
1114 """Prompt the CLI user for an AskUserQuestion response."""
1115
1116 console.print()
1117 console.print(
1118 Panel(question, title="[bold cyan]Question[/bold cyan]", border_style="cyan")
1119 )
1120 if options:
1121 for index, option in enumerate(options, start=1):
1122 console.print(f" [cyan]{index}.[/cyan] {option}")
1123 answer = await asyncio.to_thread(Prompt.ask, "Answer", default="1")
1124 answer = answer.strip()
1125 if answer.isdigit():
1126 selected = int(answer) - 1
1127 if 0 <= selected < len(options):
1128 return options[selected]
1129 return answer
1130
1131 return await asyncio.to_thread(Prompt.ask, "Answer")
1132
1133
1134 @click.command(name="doctor")
1135 @click.option("--model", "-m", default=None, help="Model to inspect (default: saved model)")
1136 @click.option("--backend", "-b", default="ollama", help="Backend to inspect")
1137 @click.option(
1138 "--permission-mode",
1139 type=click.Choice(
1140 ["read-only", "workspace-write", "danger-full-access", "prompt", "allow"],
1141 case_sensitive=False,
1142 ),
1143 default="workspace-write",
1144 show_default=True,
1145 help="Permission mode to audit against tool requirements",
1146 )
1147 def doctor_cli(
1148 model: str | None,
1149 backend: str,
1150 permission_mode: str,
1151 ) -> None:
1152 """Inspect Loader runtime health without entering the agent loop."""
1153
1154 asyncio.run(_doctor_main(model=model, backend=backend, permission_mode=permission_mode))
1155
1156
1157 @click.command(name="status")
1158 @click.option("--model", "-m", default=None, help="Model to summarize (default: saved model)")
1159 @click.option(
1160 "--permission-mode",
1161 type=click.Choice(
1162 ["read-only", "workspace-write", "danger-full-access", "prompt", "allow"],
1163 case_sensitive=False,
1164 ),
1165 default="workspace-write",
1166 show_default=True,
1167 help="Fallback permission mode when no session exists",
1168 )
1169 def status_cli(
1170 model: str | None,
1171 permission_mode: str,
1172 ) -> None:
1173 """Show the current Loader status from persisted state."""
1174
1175 _status_main(model=model, permission_mode=permission_mode)
1176
1177
1178 @click.command(name="explore")
1179 @click.option("--model", "-m", default=None, help="Model to use for explore mode")
1180 @click.option("--select-model", "-s", is_flag=True, help="Interactively select model")
1181 @click.option("--backend", "-b", default="ollama", help="LLM backend (ollama)")
1182 @click.option("--react", is_flag=True, help="Force ReAct mode")
1183 @click.option("--no-context", is_flag=True, help="Skip auto-detecting project context")
1184 @click.option("--fresh", is_flag=True, help="Ignore persisted explore history for this query")
1185 @click.option("--status", "show_status", is_flag=True, help="Show persisted explore continuity and exit")
1186 @click.option("--reset", is_flag=True, help="Clear persisted explore continuity before exiting or running")
1187 @click.option("--ctx", type=int, default=8192, help="Context window size")
1188 @click.option("--gpu", type=int, default=-1, help="GPU layers (-1 = all, 0 = CPU only)")
1189 @click.option("--timeout", type=int, default=None, help="Request timeout in seconds")
1190 @click.argument("prompt", required=False)
1191 def explore_cli(
1192 model: str | None,
1193 select_model: bool,
1194 backend: str,
1195 react: bool,
1196 no_context: bool,
1197 fresh: bool,
1198 show_status: bool,
1199 reset: bool,
1200 ctx: int,
1201 gpu: int,
1202 timeout: int | None,
1203 prompt: str | None,
1204 ) -> None:
1205 """Run a read-only lookup query through the explore lane."""
1206
1207 if show_status:
1208 _print_explore_continuity_snapshot(collect_explore_continuity_snapshot())
1209 return
1210
1211 if reset:
1212 had_state = reset_explore_continuity()
1213 message = (
1214 "Cleared persisted explore continuity."
1215 if had_state
1216 else "No persisted explore continuity was present."
1217 )
1218 console.print(Panel.fit(message, border_style="blue"))
1219 if prompt is None:
1220 return
1221 fresh = True
1222
1223 if prompt is None:
1224 raise click.UsageError(
1225 "Missing prompt. Pass a lookup question or use --status/--reset."
1226 )
1227
1228 asyncio.run(
1229 _explore_main(
1230 model=model,
1231 select_model=select_model,
1232 backend=backend,
1233 react=react,
1234 no_context=no_context,
1235 fresh=fresh,
1236 ctx=ctx,
1237 gpu=gpu,
1238 timeout=timeout,
1239 prompt=prompt,
1240 )
1241 )
1242
1243
1244 @click.group(name="session")
1245 def session_cli() -> None:
1246 """Inspect persisted Loader sessions."""
1247
1248
1249 @click.group(name="permissions")
1250 def permissions_cli() -> None:
1251 """Inspect and dry-run Loader permission policy."""
1252
1253
1254 @click.group(name="prompt")
1255 def prompt_cli() -> None:
1256 """Inspect Loader prompt construction without a live turn."""
1257
1258
1259 @click.group(name="workflow")
1260 def workflow_cli() -> None:
1261 """Inspect persisted Loader workflow history."""
1262
1263
1264 @session_cli.command("list")
1265 def session_list_cli() -> None:
1266 """List persisted sessions."""
1267
1268 _session_list_main()
1269
1270
1271 @session_cli.command("show")
1272 @click.argument("session_id")
1273 def session_show_cli(session_id: str) -> None:
1274 """Show one persisted session in detail."""
1275
1276 _session_show_main(session_id)
1277
1278
1279 @permissions_cli.command("show")
1280 @click.option(
1281 "--permission-mode",
1282 type=click.Choice(
1283 ["read-only", "workspace-write", "danger-full-access", "prompt", "allow"],
1284 case_sensitive=False,
1285 ),
1286 default="workspace-write",
1287 show_default=True,
1288 help="Permission mode to inspect",
1289 )
1290 def permissions_show_cli(permission_mode: str) -> None:
1291 """Show the active permission rules and normalized policy state."""
1292
1293 _permissions_show_main(permission_mode=permission_mode)
1294
1295
1296 @permissions_cli.command("check")
1297 @click.option(
1298 "--permission-mode",
1299 type=click.Choice(
1300 ["read-only", "workspace-write", "danger-full-access", "prompt", "allow"],
1301 case_sensitive=False,
1302 ),
1303 default="workspace-write",
1304 show_default=True,
1305 help="Permission mode to dry-run against",
1306 )
1307 @click.option(
1308 "--args",
1309 "args_json",
1310 default=None,
1311 help="JSON object of tool arguments to dry-run",
1312 )
1313 @click.argument("tool_name")
1314 @click.argument("input_value", required=False)
1315 def permissions_check_cli(
1316 permission_mode: str,
1317 args_json: str | None,
1318 tool_name: str,
1319 input_value: str | None,
1320 ) -> None:
1321 """Dry-run one hypothetical tool request against the active permission policy."""
1322
1323 _permissions_check_main(
1324 tool_name=tool_name,
1325 input_value=input_value,
1326 args_json=args_json,
1327 permission_mode=permission_mode,
1328 )
1329
1330
1331 @prompt_cli.command("show")
1332 @click.option("--model", "-m", default=None, help="Model to preview (default: saved model)")
1333 @click.option(
1334 "--workflow-mode",
1335 type=click.Choice(["clarify", "plan", "execute", "verify"], case_sensitive=False),
1336 default=None,
1337 help="Override the workflow mode used for the preview",
1338 )
1339 @click.option(
1340 "--permission-mode",
1341 type=click.Choice(
1342 ["read-only", "workspace-write", "danger-full-access", "prompt", "allow"],
1343 case_sensitive=False,
1344 ),
1345 default=None,
1346 help="Override the permission mode used for the preview",
1347 )
1348 @click.option("--react", is_flag=True, help="Force ReAct formatting for the preview")
1349 @click.argument("current_task", required=False)
1350 def prompt_show_cli(
1351 model: str | None,
1352 workflow_mode: str | None,
1353 permission_mode: str | None,
1354 react: bool,
1355 current_task: str | None,
1356 ) -> None:
1357 """Render the current prompt contract without sending a model request."""
1358
1359 _prompt_show_main(
1360 model=model,
1361 workflow_mode=workflow_mode,
1362 permission_mode=permission_mode,
1363 react=react,
1364 current_task=current_task,
1365 )
1366
1367
1368 @prompt_cli.command("diff")
1369 @click.option("--full", is_flag=True, help="Show the full unified prompt diff")
1370 @click.argument("session_id", required=False)
1371 def prompt_diff_cli(full: bool, session_id: str | None) -> None:
1372 """Compare the latest persisted prompt contracts."""
1373
1374 _prompt_diff_main(session_id=session_id, full=full)
1375
1376
1377 @workflow_cli.command("show")
1378 @click.option(
1379 "--mode",
1380 type=click.Choice(["clarify", "plan", "execute", "verify"], case_sensitive=False),
1381 default=None,
1382 help="Filter timeline entries to one workflow mode",
1383 )
1384 @click.option(
1385 "--kind",
1386 type=click.Choice(
1387 [
1388 "route",
1389 "handoff",
1390 "reentry",
1391 "clarify_continue",
1392 "clarify_exit",
1393 "plan_refresh",
1394 "verify_skip",
1395 "repair_retry",
1396 "repair_fail",
1397 "completion_check",
1398 "completion_continue",
1399 "completion_complete",
1400 "completion_finalize",
1401 ],
1402 case_sensitive=False,
1403 ),
1404 default=None,
1405 help="Filter timeline entries to one event kind",
1406 )
1407 @click.option(
1408 "--policy",
1409 "accountability_only",
1410 is_flag=True,
1411 help="Show only unified repair, verification, and completion accountability events",
1412 )
1413 @click.option(
1414 "--limit",
1415 type=click.IntRange(min=1),
1416 default=8,
1417 show_default=True,
1418 help="Show only the most recent matching entries",
1419 )
1420 @click.option("--diff", "show_diff", is_flag=True, help="Show persisted artifact diffs")
1421 @click.option("--full-diff", is_flag=True, help="Show the full unified artifact diffs")
1422 @click.argument("session_id", required=False)
1423 def workflow_show_cli(
1424 mode: str | None,
1425 kind: str | None,
1426 accountability_only: bool,
1427 limit: int,
1428 show_diff: bool,
1429 full_diff: bool,
1430 session_id: str | None,
1431 ) -> None:
1432 """Show the persisted workflow timeline for the latest or named session."""
1433
1434 _workflow_show_main(
1435 session_id=session_id,
1436 mode=mode,
1437 kind=kind,
1438 accountability_only=accountability_only,
1439 limit=limit,
1440 show_diff=show_diff,
1441 full_diff=full_diff,
1442 )
1443
1444
1445 def _run_special_command(argv: list[str]) -> None:
1446 command = argv[0]
1447 if command == "doctor":
1448 doctor_cli.main(args=argv[1:], prog_name="loader doctor")
1449 return
1450 if command == "status":
1451 status_cli.main(args=argv[1:], prog_name="loader status")
1452 return
1453 if command == "explore":
1454 explore_cli.main(args=argv[1:], prog_name="loader explore")
1455 return
1456 if command == "permissions":
1457 if len(argv) == 1:
1458 click.echo(_permissions_help_text())
1459 return
1460 permissions_cli.main(args=argv[1:], prog_name="loader permissions")
1461 return
1462 if command == "prompt":
1463 if len(argv) == 1:
1464 click.echo(_prompt_help_text())
1465 return
1466 prompt_cli.main(args=argv[1:], prog_name="loader prompt")
1467 return
1468 if command == "workflow":
1469 if len(argv) == 1:
1470 click.echo(_workflow_help_text())
1471 return
1472 workflow_cli.main(args=argv[1:], prog_name="loader workflow")
1473 return
1474 if command == "session":
1475 if len(argv) == 1:
1476 click.echo(_session_help_text())
1477 return
1478 session_cli.main(args=argv[1:], prog_name="loader session")
1479 return
1480
1481
1482 def _loader_help_text() -> str:
1483 ctx = click.Context(cli, info_name="loader")
1484 base_help = cli.get_help(ctx)
1485 extra = "\n".join(
1486 [
1487 "",
1488 "Additional Commands:",
1489 " loader doctor Inspect backend, workspace, and state health",
1490 " loader status Show persisted runtime status",
1491 " loader explore <prompt> Run a fast read-only lookup query",
1492 " loader prompt show Preview the current prompt contract",
1493 " loader prompt diff Compare the latest persisted prompt contracts",
1494 " loader permissions show Display normalized permission rules",
1495 " loader permissions check Dry-run one permission decision",
1496 " loader workflow show Show the persisted workflow timeline",
1497 " loader session list List persisted sessions",
1498 " loader session show <id> Show one persisted session",
1499 " loader session resume <id> Resume a persisted session through the main runtime",
1500 ]
1501 )
1502 return base_help + extra
1503
1504
1505 def _permissions_help_text() -> str:
1506 return "\n".join(
1507 [
1508 "Usage: loader permissions [COMMAND]",
1509 "",
1510 "Commands:",
1511 " show Display normalized permission rules and source metadata",
1512 " check <tool> [input] Dry-run one permission decision for a tool request",
1513 ]
1514 )
1515
1516
1517 def _prompt_help_text() -> str:
1518 return "\n".join(
1519 [
1520 "Usage: loader prompt [COMMAND]",
1521 "",
1522 "Commands:",
1523 " show [task] Render the current prompt contract without a model call",
1524 " diff [id] Compare the latest persisted prompt contracts",
1525 ]
1526 )
1527
1528
1529 def _workflow_help_text() -> str:
1530 return "\n".join(
1531 [
1532 "Usage: loader workflow [COMMAND]",
1533 "",
1534 "Commands:",
1535 " show [id] Show the persisted workflow timeline with optional filters",
1536 " Add --diff to compare the latest persisted artifacts",
1537 ]
1538 )
1539
1540
1541 def _session_help_text() -> str:
1542 return "\n".join(
1543 [
1544 "Usage: loader session [COMMAND]",
1545 "",
1546 "Commands:",
1547 " list List persisted sessions",
1548 " show <id> Show one persisted session",
1549 " resume <id> Resume a persisted session through the main runtime",
1550 ]
1551 )
1552
1553
1554 def _status_color(status: CheckStatus) -> str:
1555 return {
1556 CheckStatus.PASS: "green",
1557 CheckStatus.WARN: "yellow",
1558 CheckStatus.FAIL: "red",
1559 }[status]
1560
1561
1562 def _render_check_status(status: CheckStatus) -> str:
1563 color = _status_color(status)
1564 return f"[{color}]{status.value}[/{color}]"
1565
1566
1567 async def _doctor_main(
1568 *,
1569 model: str | None,
1570 backend: str,
1571 permission_mode: str,
1572 ) -> None:
1573 report = await collect_doctor_report(
1574 model=model,
1575 backend=backend,
1576 permission_mode=permission_mode,
1577 )
1578 _print_doctor_report(report)
1579
1580
1581 async def _explore_main(
1582 *,
1583 model: str | None,
1584 select_model: bool,
1585 backend: str,
1586 react: bool,
1587 no_context: bool,
1588 fresh: bool,
1589 ctx: int,
1590 gpu: int,
1591 timeout: int | None,
1592 prompt: str,
1593 ) -> None:
1594 from ..agent.loop import AgentConfig
1595 from ..config import get_default_model, get_last_model, set_last_model
1596 from ..llm.ollama import OllamaBackend
1597 from ..runtime.permissions import PermissionMode
1598
1599 if select_model:
1600 selected = await select_model_interactive()
1601 if selected is None:
1602 return
1603 model = selected
1604 elif model is None:
1605 model = get_default_model()
1606 if get_last_model():
1607 console.print(f"[dim]Using saved model: {model}[/dim]")
1608
1609 llm = OllamaBackend(
1610 model=model,
1611 force_react=react,
1612 num_ctx=ctx,
1613 num_gpu=gpu,
1614 timeout=timeout,
1615 )
1616 if not await llm.health_check():
1617 console.print("[red]Error: Cannot connect to Ollama. Is it running?[/red]")
1618 console.print("Start it with: ollama serve")
1619 console.print(f"\nOr pull the model: [cyan]ollama pull {model}[/cyan]")
1620 return
1621
1622 await llm.describe_model()
1623 set_last_model(model)
1624
1625 try:
1626 shell_owner = build_runtime_shell_owner(
1627 backend=llm,
1628 registry=None,
1629 config=AgentConfig(
1630 auto_context=not no_context,
1631 force_react=react,
1632 permission_mode=PermissionMode.READ_ONLY,
1633 stream=False,
1634 ),
1635 owner_kind="runtime",
1636 )
1637 except ValueError as exc:
1638 console.print(f"[red]Permission policy error:[/red] {exc}")
1639 return
1640 mode_str = "ReAct" if shell_owner.use_react else "Native"
1641 console.print(
1642 Panel.fit(
1643 "[bold blue]Loader Explore[/bold blue]\n"
1644 + " | ".join(
1645 [
1646 f"Model: {model}",
1647 f"Mode: {mode_str}",
1648 "Lane: explore",
1649 ("History: fresh" if fresh else "History: continue"),
1650 "Permissions: read-only",
1651 ]
1652 ),
1653 border_style="blue",
1654 )
1655 )
1656
1657 def on_event(event) -> None:
1658 if event.type == "tool_call":
1659 _print_tool_call(
1660 getattr(event, "tool_name", "") or "",
1661 getattr(event, "tool_args", None),
1662 getattr(event, "phase", None),
1663 )
1664 elif event.type == "tool_result":
1665 _print_tool_result(
1666 getattr(event, "tool_name", "") or "",
1667 event.content,
1668 metadata=getattr(event, "tool_metadata", None),
1669 is_error=getattr(event, "is_error", False),
1670 phase=getattr(event, "phase", None),
1671 preview_lines=8,
1672 )
1673
1674 response = await shell_owner.run_explore(prompt, on_event=on_event, fresh=fresh)
1675 console.print(Markdown(clean_response(response)))
1676
1677
1678 def _print_doctor_report(report: DoctorReport) -> None:
1679 overall_color = _status_color(report.overall_status)
1680 console.print(
1681 Panel.fit(
1682 "\n".join(
1683 [
1684 f"[bold]Model:[/bold] {report.model}",
1685 f"[bold]Workspace:[/bold] {report.project_root}",
1686 f"[bold]Capabilities:[/bold] {report.capability_profile.model_name} / {report.capability_profile.preferred_tool_call_format}",
1687 f"[bold]Permission Mode:[/bold] {report.permission_mode}",
1688 (
1689 "[bold]Permission Prompting:[/bold] "
1690 + ("enabled" if report.permission_prompting_enabled else "disabled")
1691 ),
1692 (
1693 "[bold]Permission Rules:[/bold] "
1694 f"{report.permission_rule_counts['allow']} allow / "
1695 f"{report.permission_rule_counts['deny']} deny / "
1696 f"{report.permission_rule_counts['ask']} ask"
1697 ),
1698 f"[bold]Rules Source:[/bold] {report.permission_rules_source}",
1699 f"[bold]Overall:[/bold] [{overall_color}]{report.overall_status.value}[/{overall_color}]",
1700 ]
1701 ),
1702 title="[bold blue]Loader Doctor[/bold blue]",
1703 border_style=overall_color,
1704 )
1705 )
1706
1707 checks = Table(show_header=True, header_style="bold cyan")
1708 checks.add_column("Check", style="white")
1709 checks.add_column("Status", width=8)
1710 checks.add_column("Message", style="white")
1711 checks.add_column("Remediation", style="dim")
1712 for check in report.checks:
1713 checks.add_row(
1714 check.name,
1715 _render_check_status(check.status),
1716 check.message,
1717 check.remediation,
1718 )
1719 console.print(checks)
1720
1721 permissions = Table(show_header=True, header_style="bold cyan")
1722 permissions.add_column("Tool", style="white")
1723 permissions.add_column("Required", style="white")
1724 permissions.add_column("Resolution", style="white")
1725 for item in report.tool_permissions:
1726 resolution = {
1727 "allow": "[green]allow[/green]",
1728 "deny": "[red]deny[/red]",
1729 "ask": "[magenta]ask[/magenta]",
1730 }.get(item.resolution, item.resolution)
1731 permissions.add_row(item.tool_name, item.required_mode, resolution)
1732 console.print()
1733 console.print(permissions)
1734
1735
1736 def _status_main(
1737 *,
1738 model: str | None,
1739 permission_mode: str,
1740 ) -> None:
1741 snapshot = collect_status_snapshot(model=model, permission_mode=permission_mode)
1742 _print_status_snapshot(snapshot)
1743
1744
1745 def _print_status_snapshot(snapshot: StatusSnapshot) -> None:
1746 table = Table(show_header=False, box=None)
1747 table.add_column("Field", style="bold cyan")
1748 table.add_column("Value", style="white")
1749 table.add_row("Workspace", str(snapshot.project_root))
1750 table.add_row("Project", snapshot.project_type)
1751 table.add_row("Model", snapshot.model)
1752 table.add_row("Capabilities", f"{snapshot.capability_profile.preferred_tool_call_format} / {snapshot.capability_profile.verification_strictness}")
1753 table.add_row("Session", snapshot.active_session_id or "none")
1754 table.add_row(
1755 "Runtime Owner",
1756 (
1757 format_runtime_owner_label(
1758 snapshot.runtime_owner_type,
1759 snapshot.runtime_owner_path,
1760 )
1761 or "none"
1762 ),
1763 )
1764 if snapshot.runtime_boundary_summary:
1765 table.add_row("Boundary", snapshot.runtime_boundary_summary)
1766 table.add_row("Workflow", snapshot.workflow_mode)
1767 if snapshot.workflow_decision_kind:
1768 table.add_row("Decision Kind", snapshot.workflow_decision_kind)
1769 if snapshot.workflow_reason_summary or snapshot.workflow_reason_code:
1770 table.add_row(
1771 "Workflow Reason",
1772 _format_workflow_reason(
1773 summary=snapshot.workflow_reason_summary,
1774 code=snapshot.workflow_reason_code,
1775 ),
1776 )
1777 if snapshot.workflow_scheduled_next_mode:
1778 table.add_row("Scheduled Next", snapshot.workflow_scheduled_next_mode)
1779 table.add_row("Phase", snapshot.active_turn_phase or "idle")
1780 if snapshot.completion_decision_summary or snapshot.completion_decision_code:
1781 table.add_row(
1782 "Completion Decision",
1783 _format_completion_decision(
1784 summary=snapshot.completion_decision_summary,
1785 code=snapshot.completion_decision_code,
1786 ),
1787 )
1788 if snapshot.latest_policy_summary:
1789 table.add_row("Latest Policy", snapshot.latest_policy_summary)
1790 if snapshot.latest_policy_blocking_evidence:
1791 table.add_row(
1792 "Policy Evidence Needed",
1793 _format_policy_evidence_items(snapshot.latest_policy_blocking_evidence),
1794 )
1795 if snapshot.latest_policy_supporting_evidence:
1796 table.add_row(
1797 "Policy Evidence Satisfied",
1798 _format_policy_evidence_items(snapshot.latest_policy_supporting_evidence),
1799 )
1800 if snapshot.latest_policy_observed_verification:
1801 table.add_row(
1802 "Observed Verification",
1803 _format_policy_evidence_items(snapshot.latest_policy_observed_verification),
1804 )
1805 if snapshot.last_turn_transition_summary:
1806 table.add_row("Last Transition", snapshot.last_turn_transition_summary)
1807 table.add_row("Permission Mode", snapshot.permission_mode)
1808 table.add_row("Prompt Format", snapshot.prompt_format or "unknown")
1809 table.add_row(
1810 "Prompt Sections",
1811 ", ".join(snapshot.prompt_sections) if snapshot.prompt_sections else "none",
1812 )
1813 table.add_row(
1814 "Permission Rules",
1815 (
1816 f"{snapshot.permission_rule_counts['allow']} allow / "
1817 f"{snapshot.permission_rule_counts['deny']} deny / "
1818 f"{snapshot.permission_rule_counts['ask']} ask"
1819 ),
1820 )
1821 table.add_row(
1822 "Permission Prompting",
1823 "enabled" if snapshot.permission_prompting_enabled else "disabled",
1824 )
1825 table.add_row(
1826 "Rules Status",
1827 "valid" if snapshot.permission_rules_valid else "invalid",
1828 )
1829 table.add_row("Rules Source", snapshot.permission_rules_source)
1830 table.add_row("Task", snapshot.current_task or "none")
1831 table.add_row("Messages", str(snapshot.message_count))
1832 table.add_row("Explore Turns", str(snapshot.explore_turn_count))
1833 table.add_row("Explore Messages", str(snapshot.explore_message_count))
1834 table.add_row("Explore Updated", snapshot.explore_updated_at or "none")
1835 table.add_row("Explore History", snapshot.explore_history_mode or "none")
1836 table.add_row("Explore Query", _preview_text(snapshot.explore_last_query))
1837 table.add_row("DoD", snapshot.dod_status or "none")
1838 table.add_row("Pending", str(snapshot.dod_pending_items_count))
1839 table.add_row("Last Verify", snapshot.last_verification_result or "none")
1840 if snapshot.verification_state_summary:
1841 table.add_row("Verification State", snapshot.verification_state_summary)
1842 if snapshot.usage:
1843 table.add_row(
1844 "Usage",
1845 ", ".join(f"{key}={value}" for key, value in sorted(snapshot.usage.items())),
1846 )
1847 table.add_row("Compactions", str(snapshot.compaction_count))
1848
1849 console.print(
1850 Panel.fit(
1851 table,
1852 title="[bold blue]Loader Status[/bold blue]",
1853 border_style="blue",
1854 )
1855 )
1856
1857 if snapshot.recent_verification:
1858 evidence = Table(show_header=True, header_style="bold cyan")
1859 evidence.add_column("Result", width=8)
1860 evidence.add_column("Kind", width=10)
1861 evidence.add_column("Attempt", width=16)
1862 evidence.add_column("Command", style="white")
1863 evidence.add_column("Detail", style="dim")
1864 for item in snapshot.recent_verification:
1865 result = {
1866 "planned": "[blue]planned[/blue]",
1867 "pending": "[cyan]pending[/cyan]",
1868 "stale": "[yellow]stale[/yellow]",
1869 "passed": "[green]pass[/green]",
1870 "failed": "[red]fail[/red]",
1871 "skipped": "[yellow]skip[/yellow]",
1872 "missing": "[magenta]missing[/magenta]",
1873 }.get(item.status, item.status)
1874 evidence.add_row(
1875 result,
1876 item.kind,
1877 item.attempt or "-",
1878 item.command,
1879 item.detail or "-",
1880 )
1881 console.print(
1882 Panel.fit(
1883 evidence,
1884 title="[bold blue]Recent Verification[/bold blue]",
1885 border_style="blue",
1886 )
1887 )
1888
1889
1890 def _print_explore_continuity_snapshot(snapshot: ExploreContinuitySnapshot) -> None:
1891 table = Table(show_header=False, box=None)
1892 table.add_column("Field", style="bold cyan")
1893 table.add_column("Value", style="white")
1894 table.add_row("Workspace", str(snapshot.project_root))
1895 table.add_row("Continuity", "present" if snapshot.exists else "none")
1896 table.add_row("Turns", str(snapshot.turn_count))
1897 table.add_row("Messages", str(snapshot.message_count))
1898 table.add_row("Updated", snapshot.updated_at or "none")
1899 table.add_row("History Mode", snapshot.last_history_mode or "none")
1900 table.add_row("Model", snapshot.model_name or "unknown")
1901 table.add_row("Last Query", _preview_text(snapshot.last_query))
1902 table.add_row("Last Response", _preview_text(snapshot.last_response))
1903 console.print(
1904 Panel.fit(
1905 table,
1906 title="[bold blue]Loader Explore State[/bold blue]",
1907 border_style="blue",
1908 )
1909 )
1910
1911
1912 def _preview_text(text: str | None, *, width: int = 80) -> str:
1913 """Return one compact single-line preview for status tables."""
1914
1915 if not text:
1916 return "none"
1917 normalized = re.sub(r"\s+", " ", text.strip())
1918 if len(normalized) <= width:
1919 return normalized
1920 return normalized[: width - 1].rstrip() + "..."
1921
1922
1923 def _format_policy_evidence_items(items: list[str], *, limit: int = 2) -> str:
1924 visible = list(dict.fromkeys(items))[:limit]
1925 return "; ".join(visible) if visible else "none"
1926
1927
1928 def _session_list_main() -> None:
1929 entries = list_session_summaries()
1930 if not entries:
1931 console.print("[yellow]No persisted sessions found.[/yellow]")
1932 return
1933
1934 for index, entry in enumerate(entries):
1935 policy_summary = (
1936 f"{entry.permission_rule_counts['allow']} allow / "
1937 f"{entry.permission_rule_counts['deny']} deny / "
1938 f"{entry.permission_rule_counts['ask']} ask"
1939 )
1940 if entry.permission_prompting_enabled:
1941 policy_summary = f"{policy_summary} (prompting enabled)"
1942 else:
1943 policy_summary = f"{policy_summary} (prompting disabled)"
1944
1945 table = Table(show_header=False, box=None)
1946 table.add_column("Field", style="bold cyan")
1947 table.add_column("Value", style="white")
1948 table.add_row("Current", "yes" if entry.is_current else "no")
1949 table.add_row("Created", entry.created_at)
1950 table.add_row("Updated", entry.updated_at)
1951 table.add_row("Messages", str(entry.message_count))
1952 table.add_row(
1953 "Runtime Owner",
1954 (
1955 format_runtime_owner_label(
1956 entry.runtime_owner_type,
1957 entry.runtime_owner_path,
1958 )
1959 or "none"
1960 ),
1961 )
1962 if entry.runtime_boundary_summary:
1963 table.add_row("Boundary", entry.runtime_boundary_summary)
1964 table.add_row("Workflow", entry.workflow_mode)
1965 if entry.workflow_decision_kind:
1966 table.add_row("Decision Kind", entry.workflow_decision_kind)
1967 if entry.workflow_reason_summary or entry.workflow_reason_code:
1968 table.add_row(
1969 "Workflow Reason",
1970 _format_workflow_reason(
1971 summary=entry.workflow_reason_summary,
1972 code=entry.workflow_reason_code,
1973 ),
1974 )
1975 table.add_row("Phase", entry.active_turn_phase or "idle")
1976 if entry.completion_decision_summary or entry.completion_decision_code:
1977 table.add_row(
1978 "Completion Decision",
1979 _format_completion_decision(
1980 summary=entry.completion_decision_summary,
1981 code=entry.completion_decision_code,
1982 ),
1983 )
1984 if entry.last_turn_transition_summary:
1985 table.add_row("Last Transition", entry.last_turn_transition_summary)
1986 table.add_row("Permission Mode", entry.permission_mode)
1987 table.add_row("Prompt", entry.prompt_format or "unknown")
1988 table.add_row("Permission Rules", policy_summary)
1989 table.add_row("Rules Source", entry.permission_rules_source or "none")
1990 table.add_row("DoD", entry.dod_status or "none")
1991 table.add_row("Task", entry.current_task or "none")
1992 console.print(
1993 Panel.fit(
1994 table,
1995 title=f"[bold blue]{entry.session_id}[/bold blue]",
1996 border_style="blue",
1997 )
1998 )
1999 if index < len(entries) - 1:
2000 console.print()
2001
2002
2003 def _session_show_main(session_id: str) -> None:
2004 try:
2005 detail = load_session_detail(session_id)
2006 except FileNotFoundError:
2007 console.print(f"[red]Session not found:[/red] {session_id}")
2008 raise SystemExit(1) from None
2009
2010 snapshot = detail.snapshot
2011 table = Table(show_header=False, box=None)
2012 table.add_column("Field", style="bold cyan")
2013 table.add_column("Value", style="white")
2014 table.add_row("Session", snapshot.session_id)
2015 table.add_row("Current", "yes" if detail.is_current else "no")
2016 table.add_row("Created", snapshot.created_at)
2017 table.add_row("Updated", snapshot.updated_at)
2018 table.add_row("Messages", str(len(snapshot.messages)))
2019 table.add_row(
2020 "Runtime Owner",
2021 (
2022 format_runtime_owner_label(
2023 snapshot.runtime_owner_type,
2024 snapshot.runtime_owner_path,
2025 )
2026 or "none"
2027 ),
2028 )
2029 if detail.runtime_boundary_summary:
2030 table.add_row("Boundary", detail.runtime_boundary_summary)
2031 table.add_row("Workflow", snapshot.workflow_mode)
2032 if snapshot.workflow_decision_kind:
2033 table.add_row("Decision Kind", snapshot.workflow_decision_kind)
2034 if snapshot.workflow_reason_summary or snapshot.workflow_reason_code:
2035 table.add_row(
2036 "Workflow Reason",
2037 _format_workflow_reason(
2038 summary=snapshot.workflow_reason_summary,
2039 code=snapshot.workflow_reason_code,
2040 ),
2041 )
2042 table.add_row("Phase", snapshot.active_turn_phase or "idle")
2043 if (
2044 snapshot.last_completion_decision_summary
2045 or snapshot.last_completion_decision_code
2046 ):
2047 table.add_row(
2048 "Completion Decision",
2049 _format_completion_decision(
2050 summary=snapshot.last_completion_decision_summary,
2051 code=snapshot.last_completion_decision_code,
2052 ),
2053 )
2054 projection = project_workflow_timeline(
2055 snapshot.workflow_timeline,
2056 workflow_ledger=snapshot.workflow_ledger,
2057 )
2058 if projection.latest_policy_summary:
2059 table.add_row("Latest Policy", projection.latest_policy_summary)
2060 latest_policy_evidence = projection.latest_policy_evidence
2061 if latest_policy_evidence and latest_policy_evidence.blocking:
2062 table.add_row(
2063 "Policy Evidence Needed",
2064 _format_policy_evidence_items(latest_policy_evidence.blocking),
2065 )
2066 if latest_policy_evidence and latest_policy_evidence.supporting:
2067 table.add_row(
2068 "Policy Evidence Satisfied",
2069 _format_policy_evidence_items(latest_policy_evidence.supporting),
2070 )
2071 if projection.latest_policy_observed_verification:
2072 table.add_row(
2073 "Observed Verification",
2074 _format_policy_evidence_items(projection.latest_policy_observed_verification),
2075 )
2076 if snapshot.last_turn_transition_summary:
2077 table.add_row("Last Transition", snapshot.last_turn_transition_summary)
2078 table.add_row("Permission Mode", snapshot.permission_mode)
2079 table.add_row("Prompt Format", snapshot.prompt_format or "unknown")
2080 table.add_row(
2081 "Prompt Sections",
2082 ", ".join(snapshot.prompt_sections) if snapshot.prompt_sections else "none",
2083 )
2084 table.add_row(
2085 "Permission Prompting",
2086 "enabled" if snapshot.permission_prompting_enabled else "disabled",
2087 )
2088 table.add_row(
2089 "Permission Rules",
2090 (
2091 f"{snapshot.permission_rule_counts['allow']} allow / "
2092 f"{snapshot.permission_rule_counts['deny']} deny / "
2093 f"{snapshot.permission_rule_counts['ask']} ask"
2094 ),
2095 )
2096 table.add_row("Rules Source", snapshot.permission_rules_source or "none")
2097 table.add_row("Task", snapshot.current_task or "none")
2098 table.add_row("Active DoD", snapshot.active_dod_path or "none")
2099 if detail.verification_state_summary:
2100 table.add_row("Verification State", detail.verification_state_summary)
2101 if snapshot.usage:
2102 table.add_row(
2103 "Usage",
2104 ", ".join(f"{key}={value}" for key, value in sorted(snapshot.usage.items())),
2105 )
2106 if snapshot.compaction is not None:
2107 table.add_row("Compactions", str(snapshot.compaction.count))
2108
2109 console.print(
2110 Panel.fit(
2111 table,
2112 title="[bold blue]Loader Session[/bold blue]",
2113 border_style="blue",
2114 )
2115 )
2116
2117 if detail.definition_of_done is not None:
2118 dod_table = Table(show_header=False, box=None)
2119 dod_table.add_column("Field", style="bold magenta")
2120 dod_table.add_column("Value", style="white")
2121 dod_table.add_row("Status", detail.definition_of_done.status)
2122 dod_table.add_row("Pending", ", ".join(detail.definition_of_done.pending_items) or "none")
2123 dod_table.add_row("Completed", ", ".join(detail.definition_of_done.completed_items) or "none")
2124 dod_table.add_row(
2125 "Last Verify",
2126 detail.definition_of_done.last_verification_result or "none",
2127 )
2128 console.print(dod_table)
2129
2130 if detail.recent_verification:
2131 console.print()
2132 verification = Table(show_header=True, header_style="bold cyan")
2133 verification.add_column("Result", width=8)
2134 verification.add_column("Kind", width=10)
2135 verification.add_column("Attempt", width=16)
2136 verification.add_column("Command", style="white")
2137 verification.add_column("Detail", style="dim")
2138 for item in detail.recent_verification:
2139 result = {
2140 "planned": "[blue]planned[/blue]",
2141 "pending": "[cyan]pending[/cyan]",
2142 "stale": "[yellow]stale[/yellow]",
2143 "passed": "[green]pass[/green]",
2144 "failed": "[red]fail[/red]",
2145 "skipped": "[yellow]skip[/yellow]",
2146 "missing": "[magenta]missing[/magenta]",
2147 }.get(item.status, item.status)
2148 verification.add_row(
2149 result,
2150 item.kind,
2151 item.attempt or "-",
2152 item.command,
2153 item.detail or "-",
2154 )
2155 console.print(
2156 Panel.fit(
2157 verification,
2158 title="[bold blue]Recent Verification[/bold blue]",
2159 border_style="blue",
2160 )
2161 )
2162
2163 if snapshot.completion_trace:
2164 console.print()
2165 _print_completion_trace_entries(snapshot.completion_trace)
2166
2167 if projection.policy_entries:
2168 console.print()
2169 _print_workflow_timeline_entries(
2170 projection.policy_entries[-5:],
2171 title="[bold blue]Policy Timeline[/bold blue]",
2172 )
2173
2174 if projection.entries:
2175 console.print()
2176 _print_workflow_timeline_entries(
2177 projection.entries[-5:],
2178 title="[bold blue]Workflow Timeline[/bold blue]",
2179 )
2180
2181
2182 def _workflow_show_main(
2183 *,
2184 session_id: str | None,
2185 mode: str | None,
2186 kind: str | None,
2187 accountability_only: bool,
2188 limit: int | None,
2189 show_diff: bool,
2190 full_diff: bool,
2191 ) -> None:
2192 try:
2193 snapshot: WorkflowTimelineSnapshot = collect_workflow_timeline(
2194 session_id=session_id,
2195 mode=mode,
2196 kind=kind,
2197 accountability_only=accountability_only,
2198 limit=limit,
2199 )
2200 except FileNotFoundError:
2201 console.print(f"[red]Session not found:[/red] {session_id}")
2202 raise SystemExit(1) from None
2203
2204 if snapshot.session_id is None:
2205 console.print("[yellow]No persisted workflow timeline found.[/yellow]")
2206 return
2207
2208 table = Table(show_header=False, box=None)
2209 table.add_column("Field", style="bold cyan")
2210 table.add_column("Value", style="white")
2211 table.add_row("Workspace", str(snapshot.project_root))
2212 table.add_row("Session", snapshot.session_id or "none")
2213 table.add_row("Current", "yes" if snapshot.is_current else "no")
2214 table.add_row(
2215 "Runtime Owner",
2216 (
2217 format_runtime_owner_label(
2218 snapshot.runtime_owner_type,
2219 snapshot.runtime_owner_path,
2220 )
2221 or "none"
2222 ),
2223 )
2224 if snapshot.runtime_boundary_summary:
2225 table.add_row("Boundary", snapshot.runtime_boundary_summary)
2226 table.add_row("Workflow", snapshot.workflow_mode)
2227 table.add_row("Task", snapshot.current_task or "none")
2228 if snapshot.verification_state_summary:
2229 table.add_row("Verification State", snapshot.verification_state_summary)
2230 table.add_row("Entries", f"{len(snapshot.entries)} shown / {snapshot.total_entries} total")
2231 if snapshot.latest_policy_summary:
2232 table.add_row("Latest Policy", snapshot.latest_policy_summary)
2233 if snapshot.latest_policy_blocking_evidence:
2234 table.add_row(
2235 "Policy Evidence Needed",
2236 _format_policy_evidence_items(snapshot.latest_policy_blocking_evidence),
2237 )
2238 if snapshot.latest_policy_supporting_evidence:
2239 table.add_row(
2240 "Policy Evidence Satisfied",
2241 _format_policy_evidence_items(snapshot.latest_policy_supporting_evidence),
2242 )
2243 if snapshot.latest_policy_observed_verification:
2244 table.add_row(
2245 "Observed Verification",
2246 _format_policy_evidence_items(snapshot.latest_policy_observed_verification),
2247 )
2248 table.add_row(
2249 "Filters",
2250 _format_workflow_filters(
2251 mode=snapshot.selected_mode,
2252 kind=snapshot.selected_kind,
2253 accountability_only=snapshot.selected_accountability_only,
2254 limit=snapshot.entry_limit,
2255 ),
2256 )
2257 console.print(
2258 Panel.fit(
2259 table,
2260 title="[bold blue]Loader Workflow[/bold blue]",
2261 border_style="blue",
2262 )
2263 )
2264 if snapshot.highlights:
2265 console.print()
2266 _print_workflow_highlights(
2267 snapshot.highlights,
2268 title="[bold blue]Workflow Answers[/bold blue]",
2269 )
2270 if snapshot.workflow_ledger.has_items():
2271 console.print()
2272 _print_workflow_ledger(
2273 snapshot.workflow_ledger,
2274 title="[bold blue]Workflow Ledger[/bold blue]",
2275 )
2276 if show_diff:
2277 console.print()
2278 artifact_diffs = collect_workflow_artifact_diffs(session_id=session_id)
2279 _print_workflow_artifact_diffs(
2280 artifact_diffs,
2281 show_full=full_diff,
2282 )
2283 console.print()
2284 _print_workflow_timeline_entries(
2285 snapshot.entries,
2286 title=(
2287 "[bold blue]Policy Timeline[/bold blue]"
2288 if snapshot.selected_accountability_only
2289 else "[bold blue]Workflow Timeline[/bold blue]"
2290 ),
2291 )
2292
2293
2294 def _permissions_show_main(*, permission_mode: str) -> None:
2295 snapshot = collect_permission_snapshot(permission_mode=permission_mode)
2296 _print_permission_snapshot(snapshot)
2297
2298
2299 def _prompt_show_main(
2300 *,
2301 model: str | None,
2302 workflow_mode: str | None,
2303 permission_mode: str | None,
2304 react: bool,
2305 current_task: str | None,
2306 ) -> None:
2307 preview = collect_prompt_preview(
2308 model=model,
2309 workflow_mode=workflow_mode,
2310 permission_mode=permission_mode,
2311 current_task=current_task,
2312 force_react=react,
2313 )
2314 _print_prompt_preview(preview)
2315
2316
2317 def _prompt_diff_main(
2318 *,
2319 session_id: str | None,
2320 full: bool,
2321 ) -> None:
2322 try:
2323 snapshot: PromptDiffSnapshot = collect_prompt_diff(session_id=session_id)
2324 except FileNotFoundError:
2325 console.print(f"[red]Session not found:[/red] {session_id}")
2326 raise SystemExit(1) from None
2327
2328 if snapshot.session_id is None or snapshot.current is None:
2329 console.print("[yellow]No persisted prompt history found.[/yellow]")
2330 return
2331
2332 _print_prompt_diff(snapshot, show_full=full)
2333
2334
2335 def _permissions_check_main(
2336 *,
2337 tool_name: str,
2338 input_value: str | None,
2339 args_json: str | None,
2340 permission_mode: str,
2341 ) -> None:
2342 from ..tools.base import create_default_registry
2343
2344 registry = create_default_registry()
2345 registry.configure_workspace_root(".")
2346 tool = registry.get(tool_name)
2347 if tool is None:
2348 available = ", ".join(sorted(item.name for item in registry.list_tools()))
2349 raise click.ClickException(
2350 f"Unknown tool `{tool_name}`. Available tools: {available}"
2351 )
2352
2353 arguments = _coerce_permission_check_arguments(
2354 tool,
2355 input_value=input_value,
2356 args_json=args_json,
2357 )
2358 try:
2359 result = dry_run_permission_check(
2360 tool_name,
2361 arguments,
2362 permission_mode=permission_mode,
2363 registry=registry,
2364 )
2365 except ValueError as exc:
2366 raise click.ClickException(
2367 f"{exc}. Run `loader permissions show` after repairing the rule file."
2368 ) from exc
2369 _print_permission_check_result(result)
2370
2371
2372 def _print_permission_snapshot(snapshot: PermissionSnapshot) -> None:
2373 table = Table(show_header=False, box=None)
2374 table.add_column("Field", style="bold cyan")
2375 table.add_column("Value", style="white")
2376 table.add_row("Workspace", str(snapshot.project_root))
2377 table.add_row("Permission Mode", snapshot.active_mode)
2378 table.add_row(
2379 "Permission Prompting",
2380 "enabled" if snapshot.prompting_enabled else "disabled",
2381 )
2382 table.add_row(
2383 "Permission Rules",
2384 (
2385 f"{snapshot.rule_counts['allow']} allow / "
2386 f"{snapshot.rule_counts['deny']} deny / "
2387 f"{snapshot.rule_counts['ask']} ask"
2388 ),
2389 )
2390 table.add_row("Rules Status", "valid" if snapshot.rules_valid else "invalid")
2391 table.add_row("Rules Source", snapshot.rules_source)
2392
2393 border_style = "blue" if snapshot.rules_valid else "red"
2394 console.print(
2395 Panel.fit(
2396 table,
2397 title="[bold blue]Loader Permissions[/bold blue]",
2398 border_style=border_style,
2399 )
2400 )
2401
2402 if snapshot.rules_error:
2403 console.print(
2404 Panel(
2405 snapshot.rules_error,
2406 title="[bold red]Rule Error[/bold red]",
2407 border_style="red",
2408 )
2409 )
2410
2411 rules_table = Table(show_header=True, header_style="bold cyan")
2412 rules_table.add_column("Disposition", style="white")
2413 rules_table.add_column("Tool", style="white")
2414 rules_table.add_column("Contains", style="white")
2415 rules_table.add_column("Path Contains", style="white")
2416
2417 has_rules = False
2418 for disposition in ("allow", "deny", "ask"):
2419 color = {
2420 "allow": "green",
2421 "deny": "red",
2422 "ask": "magenta",
2423 }[disposition]
2424 for item in snapshot.normalized_rules.get(disposition, []):
2425 has_rules = True
2426 rules_table.add_row(
2427 f"[{color}]{disposition}[/{color}]",
2428 item.tool_name or "*",
2429 item.contains or "-",
2430 item.path_contains or "-",
2431 )
2432
2433 if has_rules:
2434 console.print(rules_table)
2435 else:
2436 console.print("[dim]No allow/deny/ask rules configured.[/dim]")
2437
2438
2439 def _print_permission_check_result(result: PermissionCheckResult) -> None:
2440 decision = {
2441 "allow": "[green]allow[/green]",
2442 "deny": "[red]deny[/red]",
2443 "ask": "[magenta]ask[/magenta]",
2444 }.get(result.decision, result.decision)
2445
2446 table = Table(show_header=False, box=None)
2447 table.add_column("Field", style="bold cyan")
2448 table.add_column("Value", style="white")
2449 table.add_row("Workspace", str(result.project_root))
2450 table.add_row("Tool", result.tool_name)
2451 table.add_row("Permission Mode", result.active_mode)
2452 table.add_row(
2453 "Permission Prompting",
2454 "enabled" if result.prompting_enabled else "disabled",
2455 )
2456 table.add_row("Required Mode", result.required_mode)
2457 table.add_row("Decision", decision)
2458 table.add_row("Input Summary", result.input_summary)
2459 table.add_row("Path Hint", result.path_hint or "none")
2460 table.add_row("Matched Rule", result.matched_rule or "none")
2461 table.add_row("Rule Disposition", result.matched_disposition or "none")
2462 table.add_row("Reason", result.reason or "none")
2463 table.add_row("Rules Source", result.rules_source)
2464 console.print(
2465 Panel.fit(
2466 table,
2467 title="[bold blue]Permission Check[/bold blue]",
2468 border_style="blue",
2469 )
2470 )
2471
2472
2473 def _print_prompt_preview(preview: PromptPreview) -> None:
2474 table = Table(show_header=False, box=None)
2475 table.add_column("Field", style="bold cyan")
2476 table.add_column("Value", style="white")
2477 table.add_row("Workspace", str(preview.project_root))
2478 table.add_row("Model", preview.model)
2479 table.add_row(
2480 "Capabilities",
2481 (
2482 f"{preview.capability_profile.preferred_tool_call_format} / "
2483 f"{preview.capability_profile.verification_strictness}"
2484 ),
2485 )
2486 table.add_row("Session", preview.active_session_id or "none")
2487 table.add_row("Workflow", preview.workflow_mode)
2488 if preview.workflow_decision_kind:
2489 table.add_row("Decision Kind", preview.workflow_decision_kind)
2490 if preview.workflow_reason_summary or preview.workflow_reason_code:
2491 table.add_row(
2492 "Workflow Reason",
2493 _format_workflow_reason(
2494 summary=preview.workflow_reason_summary,
2495 code=preview.workflow_reason_code,
2496 ),
2497 )
2498 table.add_row("Permission Mode", preview.permission_mode)
2499 table.add_row("Prompt Format", preview.prompt_format)
2500 table.add_row(
2501 "Dynamic Sections",
2502 ", ".join(preview.prompt_sections) if preview.prompt_sections else "none",
2503 )
2504 table.add_row("Task", preview.current_task or "none")
2505
2506 console.print(
2507 Panel.fit(
2508 table,
2509 title="[bold blue]Prompt Preview[/bold blue]",
2510 border_style="blue",
2511 )
2512 )
2513 console.print(
2514 Panel(
2515 Markdown(preview.content),
2516 title="[bold blue]Prompt Body[/bold blue]",
2517 border_style="blue",
2518 )
2519 )
2520
2521
2522 def _print_prompt_diff(snapshot: PromptDiffSnapshot, *, show_full: bool) -> None:
2523 table = Table(show_header=False, box=None)
2524 table.add_column("Field", style="bold cyan")
2525 table.add_column("Value", style="white")
2526 table.add_row("Workspace", str(snapshot.project_root))
2527 table.add_row("Session", snapshot.session_id or "none")
2528 table.add_row("Task", snapshot.current_task or "none")
2529 table.add_row(
2530 "Current",
2531 _format_prompt_snapshot(snapshot.current) if snapshot.current else "none",
2532 )
2533 table.add_row(
2534 "Previous",
2535 _format_prompt_snapshot(snapshot.previous) if snapshot.previous else "none",
2536 )
2537 console.print(
2538 Panel.fit(
2539 table,
2540 title="[bold blue]Prompt Diff[/bold blue]",
2541 border_style="blue",
2542 )
2543 )
2544
2545 if snapshot.highlights:
2546 console.print()
2547 _print_workflow_highlights(
2548 snapshot.highlights,
2549 title="[bold blue]Prompt Changes[/bold blue]",
2550 )
2551
2552 if show_full:
2553 body = snapshot.unified_diff or "[dim]No prompt body diff available.[/dim]"
2554 console.print()
2555 console.print(
2556 Panel(
2557 body,
2558 title="[bold blue]Prompt Unified Diff[/bold blue]",
2559 border_style="blue",
2560 )
2561 )
2562
2563
2564 def _format_prompt_snapshot(snapshot) -> str:
2565 parts = [snapshot.workflow_mode, snapshot.permission_mode, snapshot.prompt_format]
2566 if snapshot.prompt_sections:
2567 parts.append(f"sections={len(snapshot.prompt_sections)}")
2568 return " / ".join(parts)
2569
2570
2571 def _print_workflow_artifact_diffs(
2572 snapshot: WorkflowArtifactDiffSnapshot,
2573 *,
2574 show_full: bool,
2575 ) -> None:
2576 if not snapshot.entries:
2577 console.print("[dim]No persisted artifact diffs are available for this session.[/dim]")
2578 return
2579
2580 if snapshot.highlights:
2581 _print_workflow_highlights(
2582 snapshot.highlights,
2583 title="[bold blue]Artifact Changes[/bold blue]",
2584 )
2585
2586 table = Table(show_header=True, header_style="bold cyan")
2587 table.add_column("Kind", style="white")
2588 table.add_column("Current", style="white")
2589 table.add_column("Previous", style="white")
2590 table.add_column("Summary", style="dim")
2591
2592 for entry in snapshot.entries:
2593 table.add_row(
2594 entry.kind,
2595 entry.current_path.name,
2596 entry.previous_path.name if entry.previous_path else "none",
2597 "; ".join(entry.highlights[:2]) or "none",
2598 )
2599
2600 console.print(
2601 Panel(
2602 table,
2603 title="[bold blue]Artifact Diff Summary[/bold blue]",
2604 border_style="blue",
2605 )
2606 )
2607
2608 if show_full:
2609 for entry in snapshot.entries:
2610 console.print()
2611 console.print(
2612 Panel(
2613 entry.unified_diff or "[dim]No diff available.[/dim]",
2614 title=f"[bold blue]{entry.kind} Diff[/bold blue]",
2615 border_style="blue",
2616 )
2617 )
2618
2619
2620 def _print_workflow_timeline_entries(
2621 entries: list,
2622 *,
2623 title: str,
2624 limit: int | None = None,
2625 ) -> None:
2626 if not entries:
2627 console.print("[dim]No workflow timeline entries recorded.[/dim]")
2628 return
2629
2630 visible_entries = list(entries[-limit:] if limit is not None else entries)
2631 table = Table(show_header=True, header_style="bold cyan")
2632 table.add_column("Time", style="white")
2633 table.add_column("Kind", style="white")
2634 table.add_column("Mode", style="white")
2635 table.add_column("Summary", style="white")
2636 table.add_column("Context", style="dim")
2637
2638 for entry in reversed(visible_entries):
2639 table.add_row(
2640 entry.timestamp or "-",
2641 entry.kind,
2642 entry.mode,
2643 entry.summary,
2644 _format_workflow_timeline_context(entry),
2645 )
2646
2647 console.print(Panel(table, title=title, border_style="blue"))
2648
2649
2650 def _format_workflow_timeline_context(entry) -> str:
2651 parts: list[str] = []
2652 evidence_rollup = workflow_entry_evidence_rollup(entry)
2653 if entry.reason_code:
2654 parts.append(f"code={entry.reason_code}")
2655 if entry.decision_kind:
2656 parts.append(entry.decision_kind)
2657 if entry.clarify_stage:
2658 parts.append(f"stage={entry.clarify_stage}")
2659 if entry.clarify_pressure_kind:
2660 parts.append(f"pressure={entry.clarify_pressure_kind}")
2661 if entry.policy_stage:
2662 parts.append(f"policy-stage={entry.policy_stage}")
2663 if entry.policy_outcome:
2664 parts.append(f"policy-outcome={entry.policy_outcome}")
2665 if entry.pressure_pass_complete:
2666 parts.append("pressure-pass=done")
2667 if entry.missing_readiness_gates:
2668 parts.append(f"gates={','.join(entry.missing_readiness_gates)}")
2669 if entry.scheduled_next_mode:
2670 parts.append(f"next={entry.scheduled_next_mode}")
2671 if entry.runner_up_mode:
2672 parts.append(f"runner-up={entry.runner_up_mode}")
2673 if entry.prompt_format:
2674 parts.append(f"prompt={entry.prompt_format}")
2675 if entry.prompt_sections:
2676 parts.append(f"sections={len(entry.prompt_sections)}")
2677 if entry.unresolved_questions:
2678 parts.append(f"open={len(entry.unresolved_questions)}")
2679 parts.append(f"next-question={entry.unresolved_questions[0]}")
2680 if evidence_rollup.blocking:
2681 parts.append(f"needs={_format_policy_evidence_items(evidence_rollup.blocking)}")
2682 if evidence_rollup.supporting:
2683 parts.append(
2684 f"satisfied={_format_policy_evidence_items(evidence_rollup.supporting)}"
2685 )
2686 if entry.evidence_summary and not evidence_rollup.blocking and not evidence_rollup.supporting:
2687 parts.append(f"evidence={'; '.join(entry.evidence_summary[:2])}")
2688 if entry.evidence_provenance:
2689 parts.append(
2690 "provenance="
2691 + format_evidence_provenance_brief(entry.evidence_provenance)
2692 )
2693 observed = summarize_observed_verification(entry.verification_observations)
2694 if observed:
2695 parts.append(f"observed={_format_policy_evidence_items(observed)}")
2696 if entry.signal_summary:
2697 parts.append(f"signals={'; '.join(entry.signal_summary[:2])}")
2698 if entry.artifact_paths:
2699 parts.append(f"artifacts={len(entry.artifact_paths)}")
2700 return ", ".join(parts) or "-"
2701
2702
2703 def _print_workflow_ledger(
2704 ledger,
2705 *,
2706 title: str,
2707 ) -> None:
2708 table = Table(show_header=False, box=None)
2709 table.add_column("Section", style="bold cyan")
2710 table.add_column("Details", style="white")
2711
2712 for label, items in (
2713 ("Assumptions", ledger.assumptions),
2714 ("Acceptance Anchors", ledger.acceptance_anchors),
2715 ("Decision Boundaries", ledger.decision_boundaries),
2716 ):
2717 if not items:
2718 continue
2719 table.add_row(
2720 label,
2721 "\n".join(_format_workflow_ledger_item(item) for item in items[:4]),
2722 )
2723
2724 console.print(Panel.fit(table, title=title, border_style="blue"))
2725
2726
2727 def _format_workflow_ledger_item(item) -> str:
2728 details = [item.status]
2729 if item.updated_phase and item.updated_phase != item.introduced_phase:
2730 details.append(f"updated={item.updated_phase}")
2731 elif item.introduced_phase:
2732 details.append(f"phase={item.introduced_phase}")
2733 if item.evidence:
2734 details.append(f"evidence={item.evidence[0]}")
2735 return f"- {item.text} ({', '.join(details)})"
2736
2737
2738 def _print_workflow_highlights(
2739 highlights: list[str],
2740 *,
2741 title: str,
2742 ) -> None:
2743 lines = [f"- {item}" for item in highlights]
2744 console.print(
2745 Panel(
2746 "\n".join(lines),
2747 title=title,
2748 border_style="blue",
2749 )
2750 )
2751
2752
2753 def _format_workflow_filters(
2754 *,
2755 mode: str | None,
2756 kind: str | None,
2757 accountability_only: bool,
2758 limit: int | None,
2759 ) -> str:
2760 parts: list[str] = []
2761 if mode:
2762 parts.append(f"mode={mode}")
2763 if kind:
2764 parts.append(f"kind={kind}")
2765 if accountability_only:
2766 parts.append("policy-only")
2767 if limit is not None:
2768 parts.append(f"limit={limit}")
2769 return ", ".join(parts) or "none"
2770
2771
2772 def _format_workflow_reason(*, summary: str | None, code: str | None) -> str:
2773 if summary and code:
2774 return f"{summary} ({code})"
2775 return summary or code or "none"
2776
2777
2778 def _format_completion_decision(*, summary: str | None, code: str | None) -> str:
2779 if summary and code:
2780 return f"{summary} ({code})"
2781 return summary or code or "none"
2782
2783
2784 def _print_completion_trace_entries(entries) -> None:
2785 table = Table(show_header=True, header_style="bold cyan")
2786 table.add_column("Stage", style="white")
2787 table.add_column("Outcome", style="white")
2788 table.add_column("Decision", style="white")
2789 table.add_column("Evidence", style="white")
2790 for entry in entries:
2791 evidence_parts: list[str] = []
2792 evidence_rollup = workflow_entry_evidence_rollup(entry)
2793 if evidence_rollup.blocking:
2794 evidence_parts.append(
2795 "needed=" + _format_policy_evidence_items(evidence_rollup.blocking)
2796 )
2797 if evidence_rollup.supporting:
2798 evidence_parts.append(
2799 "satisfied=" + _format_policy_evidence_items(evidence_rollup.supporting)
2800 )
2801 if entry.evidence_summary:
2802 evidence_parts.append("; ".join(entry.evidence_summary[:2]))
2803 if entry.evidence_provenance:
2804 evidence_parts.append(
2805 "provenance="
2806 + format_evidence_provenance_brief(entry.evidence_provenance)
2807 )
2808 observed = summarize_observed_verification(entry.verification_observations)
2809 if observed:
2810 evidence_parts.append(
2811 "observed=" + _format_policy_evidence_items(observed)
2812 )
2813 table.add_row(
2814 entry.stage,
2815 entry.outcome,
2816 _format_completion_decision(
2817 summary=entry.decision_summary,
2818 code=entry.decision_code,
2819 ),
2820 "\n".join(evidence_parts) or "-",
2821 )
2822 console.print(
2823 Panel.fit(
2824 table,
2825 title="[bold blue]Completion Trace[/bold blue]",
2826 border_style="blue",
2827 )
2828 )
2829
2830
2831 def _coerce_permission_check_arguments(
2832 tool,
2833 *,
2834 input_value: str | None,
2835 args_json: str | None,
2836 ) -> dict[str, object]:
2837 arguments = _parse_permission_args_json(args_json)
2838 if input_value is not None:
2839 target_key = _simple_permission_input_key(tool)
2840 if target_key is None:
2841 raise click.ClickException(
2842 "This tool does not support a simple positional input. "
2843 "Provide structured arguments with `--args`."
2844 )
2845 if target_key in arguments:
2846 raise click.ClickException(
2847 f"`{target_key}` was provided twice; use either the positional input or `--args`."
2848 )
2849 arguments[target_key] = input_value
2850
2851 required_fields = list(tool.parameters.get("required", []))
2852 missing = [field for field in required_fields if field not in arguments]
2853 if missing:
2854 raise click.ClickException(
2855 f"Tool `{tool.name}` is missing required arguments: {', '.join(missing)}. "
2856 "Provide them with `--args` as a JSON object."
2857 )
2858 return arguments
2859
2860
2861 def _parse_permission_args_json(args_json: str | None) -> dict[str, object]:
2862 if not args_json:
2863 return {}
2864 try:
2865 payload = json.loads(args_json)
2866 except json.JSONDecodeError as exc:
2867 raise click.ClickException(
2868 f"`--args` must be valid JSON: {exc.msg}"
2869 ) from exc
2870 if not isinstance(payload, dict):
2871 raise click.ClickException("`--args` must decode to a JSON object.")
2872 return payload
2873
2874
2875 def _simple_permission_input_key(tool) -> str | None:
2876 explicit_keys = {
2877 "bash": "command",
2878 "read": "file_path",
2879 "write": "file_path",
2880 "edit": "file_path",
2881 "patch": "file_path",
2882 "glob": "pattern",
2883 "grep": "pattern",
2884 "git": "action",
2885 "project_memory_read": "section",
2886 "notepad_read": "section",
2887 }
2888 if tool.name in explicit_keys:
2889 return explicit_keys[tool.name]
2890
2891 parameters = tool.parameters
2892 properties = parameters.get("properties", {})
2893 required_fields = list(parameters.get("required", []))
2894 if len(required_fields) != 1:
2895 return None
2896 candidate = required_fields[0]
2897 schema = properties.get(candidate, {})
2898 if schema.get("type") == "string":
2899 return candidate
2900 return None
2901
2902
2903 if __name__ == "__main__":
2904 main()