akernel-runtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. akernel_runtime-0.1.0.dist-info/METADATA +270 -0
  2. akernel_runtime-0.1.0.dist-info/RECORD +40 -0
  3. akernel_runtime-0.1.0.dist-info/WHEEL +5 -0
  4. akernel_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  5. akernel_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. akernel_runtime-0.1.0.dist-info/licenses/NOTICE +4 -0
  7. akernel_runtime-0.1.0.dist-info/top_level.txt +1 -0
  8. context_kernel/__init__.py +4 -0
  9. context_kernel/__main__.py +5 -0
  10. context_kernel/agent_reports.py +188 -0
  11. context_kernel/benchmarks.py +493 -0
  12. context_kernel/budget.py +72 -0
  13. context_kernel/cli.py +2953 -0
  14. context_kernel/context.py +161 -0
  15. context_kernel/evals.py +347 -0
  16. context_kernel/global_memory.py +126 -0
  17. context_kernel/loop.py +1617 -0
  18. context_kernel/marketplace.py +194 -0
  19. context_kernel/marketplace_data/skills/context_budget.json +27 -0
  20. context_kernel/marketplace_data/skills/context_compaction.json +27 -0
  21. context_kernel/marketplace_data/skills/edit_file.json +27 -0
  22. context_kernel/marketplace_data/skills/index.json +66 -0
  23. context_kernel/marketplace_data/skills/long_task_planning.json +27 -0
  24. context_kernel/marketplace_data/skills/multi_file_bugfix.json +28 -0
  25. context_kernel/memory.py +515 -0
  26. context_kernel/models.py +144 -0
  27. context_kernel/planner.py +155 -0
  28. context_kernel/policy.py +271 -0
  29. context_kernel/project.py +317 -0
  30. context_kernel/providers.py +1264 -0
  31. context_kernel/report_costs.py +375 -0
  32. context_kernel/runner.py +78 -0
  33. context_kernel/skills.py +318 -0
  34. context_kernel/state_writer.py +108 -0
  35. context_kernel/storage.py +171 -0
  36. context_kernel/tasks.py +549 -0
  37. context_kernel/text.py +42 -0
  38. context_kernel/tokenizer.py +22 -0
  39. context_kernel/tools.py +544 -0
  40. context_kernel/verifier.py +77 -0
context_kernel/cli.py ADDED
@@ -0,0 +1,2953 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from contextlib import redirect_stdout
5
+ from getpass import getpass
6
+ import io
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import sys
12
+ from typing import Any
13
+
14
+ from .agent_reports import build_agent_cost_report, load_agent_report, render_agent_cost_report
15
+ from .benchmarks import BenchmarkRunner, benchmark_ref, render_benchmark_evidence_markdown
16
+ from .budget import DEFAULT_PROFILE, profile_names
17
+ from .context import ContextBuilder
18
+ from .evals import EvalRunner
19
+ from .global_memory import pull_global_memories, push_global_memories
20
+ from .loop import AgentLoop, summarize_tool_result
21
+ from .marketplace import install_marketplace_skill, is_remote_reference, list_marketplace_skills
22
+ from .memory import ALLOWED_KINDS, MemoryStore
23
+ from .planner import ExecutionPlanner
24
+ from .policy import FILE_OPERATIONS, check_command_policy, check_file_policy, summarize_command_policy
25
+ from .project import load_project_profile, scan_project
26
+ from .providers import env_value, list_provider_models, normalize_openai_base_url
27
+ from .report_costs import build_benchmark_cost_report, build_eval_cost_report, render_cost_report
28
+ from .runner import AgentRunner
29
+ from .skills import (
30
+ SkillRegistry,
31
+ compile_markdown_skill,
32
+ compile_markdown_skill_with_provider,
33
+ inspect_skill,
34
+ validate_skill_file,
35
+ )
36
+ from .state_writer import StateWriter
37
+ from .storage import Workspace
38
+ from .tasks import MILESTONE_STATUSES, TASK_STATUSES, TaskStore
39
+ from .tools import ToolExecutor
40
+ from .verifier import verify_trace
41
+
42
+
43
+ DEFAULT_PRIMARY_MODEL = "gpt-5.5"
44
+ DEFAULT_AUXILIARY_MODEL = "gpt-5.3-codex"
45
+ COMMAND_NAMES = {
46
+ "agent",
47
+ "bench",
48
+ "chat",
49
+ "compare",
50
+ "context",
51
+ "doctor",
52
+ "eval",
53
+ "init",
54
+ "memory",
55
+ "models",
56
+ "plan",
57
+ "policy",
58
+ "project",
59
+ "run",
60
+ "setup",
61
+ "skill",
62
+ "task",
63
+ "tool",
64
+ "trace",
65
+ }
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> None:
69
+ configure_console_output()
70
+ parser = build_parser()
71
+ raw_argv = sys.argv[1:] if argv is None else list(argv)
72
+ raw_argv = normalize_default_chat_args(raw_argv)
73
+ args = parser.parse_args(raw_argv)
74
+ try:
75
+ args.func(args)
76
+ except Exception as exc:
77
+ raise SystemExit(f"error: {exc}") from exc
78
+
79
+
80
+ def normalize_default_chat_args(raw_argv: list[str]) -> list[str]:
81
+ """Let bare `akernel` accept chat flags without spelling out `chat`."""
82
+ if not raw_argv:
83
+ return ["chat"]
84
+ if "-h" in raw_argv or "--help" in raw_argv:
85
+ return raw_argv
86
+
87
+ index = 0
88
+ while index < len(raw_argv):
89
+ token = raw_argv[index]
90
+ if token == "--workspace":
91
+ if index + 1 >= len(raw_argv):
92
+ return raw_argv
93
+ index += 2
94
+ continue
95
+ if token.startswith("--workspace="):
96
+ index += 1
97
+ continue
98
+ break
99
+
100
+ if index >= len(raw_argv):
101
+ return raw_argv[:index] + ["chat"]
102
+ if raw_argv[index] in COMMAND_NAMES:
103
+ return raw_argv
104
+ if raw_argv[index].startswith("-"):
105
+ return raw_argv[:index] + ["chat"] + raw_argv[index:]
106
+ return raw_argv
107
+
108
+
109
+ def build_parser() -> argparse.ArgumentParser:
110
+ parser = argparse.ArgumentParser(prog="akernel", description="Context Kernel CLI")
111
+ parser.add_argument("--workspace", default=".", help="Workspace root containing .akernel state.")
112
+ parser.set_defaults(
113
+ func=cmd_chat,
114
+ provider="openai",
115
+ model=None,
116
+ aux_model=None,
117
+ model_routing="auto",
118
+ aux_review="auto",
119
+ base_url=None,
120
+ budget=None,
121
+ profile=DEFAULT_PROFILE,
122
+ task=None,
123
+ title="Interactive chat",
124
+ max_steps=5,
125
+ no_remember=False,
126
+ allow_over_budget=False,
127
+ expect_json=False,
128
+ ui="auto",
129
+ )
130
+ subparsers = parser.add_subparsers(dest="command")
131
+
132
+ init_parser = subparsers.add_parser("init", help="Initialize a Context Kernel workspace.")
133
+ init_parser.add_argument("path", nargs="?", default=".")
134
+ init_parser.add_argument("--scan", action="store_true", help="Scan the project and save .akernel/project.json.")
135
+ init_parser.add_argument("--no-config-update", action="store_true", help="Do not extend safe command roots from scan results.")
136
+ init_parser.set_defaults(func=cmd_init)
137
+
138
+ setup_parser = subparsers.add_parser("setup", help="Configure project-local provider environment.")
139
+ setup_parser.add_argument("--api-key", default=None, help="OpenAI-compatible API key. If omitted, prompt securely.")
140
+ setup_parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL. `/v1` is added when missing.")
141
+ setup_parser.add_argument("--model", default=None, help="Default model id, for example gpt-5.5.")
142
+ setup_parser.add_argument("--aux-model", default=None, help="Auxiliary model id for planning, review, and compression.")
143
+ setup_parser.add_argument("--env-file", default=None, help="Environment file path. Defaults to .env in the current project.")
144
+ setup_parser.add_argument("--force", action="store_true", help="Rewrite an existing env file without keeping old values.")
145
+ setup_parser.add_argument("--verify", action="store_true", help="List provider models after writing configuration.")
146
+ setup_parser.set_defaults(func=cmd_setup)
147
+
148
+ skill_parser = subparsers.add_parser("skill", help="Manage skill contracts.")
149
+ skill_sub = skill_parser.add_subparsers(dest="skill_command", required=True)
150
+ skill_register = skill_sub.add_parser("register", help="Register a skill JSON file.")
151
+ skill_register.add_argument("json_file")
152
+ skill_register.set_defaults(func=cmd_skill_register)
153
+ skill_list = skill_sub.add_parser("list", help="List registered skills.")
154
+ skill_list.set_defaults(func=cmd_skill_list)
155
+ skill_show = skill_sub.add_parser("show", help="Show a registered skill.")
156
+ skill_show.add_argument("skill_id")
157
+ skill_show.add_argument("--level", choices=["l0", "l1", "l2", "l3"], default="l1")
158
+ skill_show.set_defaults(func=cmd_skill_show)
159
+ skill_compile = skill_sub.add_parser("compile", help="Compile a Markdown skill into structured JSON.")
160
+ skill_compile.add_argument("markdown_file")
161
+ skill_compile.add_argument("--id", default=None, help="Override compiled skill id.")
162
+ skill_compile.add_argument("--output", default=None, help="Output JSON path. Defaults next to source.")
163
+ skill_compile.add_argument("--provider", choices=["local", "openai"], default="local")
164
+ skill_compile.add_argument("--model", default=None, help="Provider model id used when --provider is openai.")
165
+ skill_compile.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override.")
166
+ skill_compile.add_argument("--register", action="store_true", help="Register the compiled skill in the current workspace.")
167
+ skill_compile.set_defaults(func=cmd_skill_compile)
168
+ skill_validate = skill_sub.add_parser("validate", help="Validate a skill JSON file.")
169
+ skill_validate.add_argument("json_file")
170
+ skill_validate.set_defaults(func=cmd_skill_validate)
171
+ skill_inspect = skill_sub.add_parser("inspect", help="Inspect skill load levels and token estimates.")
172
+ skill_inspect.add_argument("skill_id")
173
+ skill_inspect.add_argument("--budget", type=int, default=300)
174
+ skill_inspect.set_defaults(func=cmd_skill_inspect)
175
+ skill_market_list = skill_sub.add_parser("market-list", help="List skills from a marketplace index.")
176
+ skill_market_list.add_argument("--index", default=None, help="Marketplace index JSON path, file URL, or HTTP(S) URL.")
177
+ skill_market_list.add_argument("--json", action="store_true")
178
+ skill_market_list.set_defaults(func=cmd_skill_market_list)
179
+ skill_market_install = skill_sub.add_parser("market-install", help="Install a skill from a marketplace index.")
180
+ skill_market_install.add_argument("skill_id")
181
+ skill_market_install.add_argument("--index", default=None, help="Marketplace index JSON path, file URL, or HTTP(S) URL.")
182
+ skill_market_install.add_argument("--trust-remote", action="store_true", help="Allow installing a skill fetched from a remote marketplace source.")
183
+ skill_market_install.add_argument("--ignore-compat", action="store_true", help="Install even when marketplace compatibility metadata does not match.")
184
+ skill_market_install.add_argument("--json", action="store_true")
185
+ skill_market_install.set_defaults(func=cmd_skill_market_install)
186
+
187
+ memory_parser = subparsers.add_parser("memory", help="Manage structured memory.")
188
+ memory_sub = memory_parser.add_subparsers(dest="memory_command", required=True)
189
+ memory_add = memory_sub.add_parser("add", help="Add a memory record.")
190
+ memory_add.add_argument("--kind", required=True, choices=sorted(ALLOWED_KINDS))
191
+ memory_add.add_argument("--text", required=True)
192
+ memory_add.add_argument("--tags", default="", help="Comma-separated tags.")
193
+ memory_add.set_defaults(func=cmd_memory_add)
194
+ memory_show = memory_sub.add_parser("show", help="Show one memory record.")
195
+ memory_show.add_argument("record_id")
196
+ memory_show.add_argument("--include-archived", action="store_true")
197
+ memory_show.set_defaults(func=cmd_memory_show)
198
+ memory_list = memory_sub.add_parser("list", help="List memory records.")
199
+ memory_list.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
200
+ memory_list.add_argument("--all", action="store_true", help="Include archived records.")
201
+ memory_list.set_defaults(func=cmd_memory_list)
202
+ memory_search = memory_sub.add_parser("search", help="Search memory records.")
203
+ memory_search.add_argument("query")
204
+ memory_search.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
205
+ memory_search.add_argument("--limit", type=int, default=5)
206
+ memory_search.set_defaults(func=cmd_memory_search)
207
+ memory_audit = memory_sub.add_parser("audit", help="Explain memory retention scores without archiving.")
208
+ memory_audit.add_argument("--json", action="store_true")
209
+ memory_audit.add_argument("--limit", type=int, default=20)
210
+ memory_audit.set_defaults(func=cmd_memory_audit)
211
+ memory_update = memory_sub.add_parser("update", help="Update an active memory record.")
212
+ memory_update.add_argument("record_id")
213
+ memory_update.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
214
+ memory_update.add_argument("--text")
215
+ memory_update.add_argument("--tags", default=None, help="Comma-separated tags. Pass an empty value to clear.")
216
+ memory_update.set_defaults(func=cmd_memory_update)
217
+ memory_forget = memory_sub.add_parser("forget", help="Archive a memory record so it no longer enters context.")
218
+ memory_forget.add_argument("record_id")
219
+ memory_forget.set_defaults(func=cmd_memory_forget)
220
+ memory_prune = memory_sub.add_parser("prune", help="Archive lower-priority memory records by count or token budget.")
221
+ memory_prune.add_argument("--max-records", type=int, default=None)
222
+ memory_prune.add_argument("--max-tokens", type=int, default=None)
223
+ memory_prune.add_argument("--dry-run", action="store_true")
224
+ memory_prune.add_argument("--json", action="store_true")
225
+ memory_prune.set_defaults(func=cmd_memory_prune)
226
+ memory_global_push = memory_sub.add_parser("global-push", help="Copy active project memories into the global memory store.")
227
+ memory_global_push.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
228
+ memory_global_push.add_argument("--namespace", default=None, help="Global memory namespace. Defaults to the project directory name.")
229
+ memory_global_push.add_argument("--tag", default=None, help="Only push memories containing this tag.")
230
+ memory_global_push.add_argument("--dry-run", action="store_true", help="Preview records without copying them.")
231
+ memory_global_push.add_argument("--global-root", default=None)
232
+ memory_global_push.add_argument("--json", action="store_true")
233
+ memory_global_push.set_defaults(func=cmd_memory_global_push)
234
+ memory_global_pull = memory_sub.add_parser("global-pull", help="Copy memories from the global memory store into this project.")
235
+ memory_global_pull.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
236
+ memory_global_pull.add_argument("--namespace", default=None, help="Only pull memories from this namespace.")
237
+ memory_global_pull.add_argument("--source-project", default=None, help="Only pull memories pushed by this source project name.")
238
+ memory_global_pull.add_argument("--tag", default=None, help="Only pull memories containing this tag.")
239
+ memory_global_pull.add_argument("--limit", type=int, default=None)
240
+ memory_global_pull.add_argument("--dry-run", action="store_true", help="Preview records without copying them.")
241
+ memory_global_pull.add_argument("--global-root", default=None)
242
+ memory_global_pull.add_argument("--json", action="store_true")
243
+ memory_global_pull.set_defaults(func=cmd_memory_global_pull)
244
+
245
+ run_parser = subparsers.add_parser("run", help="Build context and run a provider.")
246
+ run_parser.add_argument("request")
247
+ add_provider_args(run_parser)
248
+ add_budget_args(run_parser)
249
+ run_parser.add_argument("--show-packet", action="store_true")
250
+ run_parser.add_argument("--allow-over-budget", action="store_true", help="Execute even when preflight budget verification fails.")
251
+ run_parser.add_argument("--expect-json", action="store_true", help="Require the provider response to be valid JSON.")
252
+ run_parser.add_argument("--remember", action="store_true", help="Write an explicit task-state memory from the run trace.")
253
+ run_parser.add_argument("--task", default=None, help="Attach the run trace and written memories to a task session.")
254
+ run_parser.add_argument("--resume", action="store_true", help="Inject the task brief into the context packet. Requires --task.")
255
+ run_parser.set_defaults(func=cmd_run)
256
+
257
+ agent_parser = subparsers.add_parser("agent", help="Run bounded agent loops.")
258
+ agent_sub = agent_parser.add_subparsers(dest="agent_command", required=True)
259
+ agent_run = agent_sub.add_parser("run", help="Run a bounded plan/run/verify/state loop.")
260
+ agent_run.add_argument("request")
261
+ add_provider_args(agent_run)
262
+ add_budget_args(agent_run)
263
+ agent_run.add_argument("--task", default=None, help="Continue an existing task; otherwise create a new one.")
264
+ agent_run.add_argument("--max-steps", type=int, default=5, help="Maximum loop steps to run.")
265
+ agent_run.add_argument("--model-routing", choices=["auto", "primary", "auxiliary"], default="auto", help="Choose how agent steps select primary vs auxiliary model.")
266
+ agent_run.add_argument("--aux-review", choices=["auto", "off", "always"], default="auto", help="Run auxiliary context review before selected agent steps.")
267
+ agent_run.add_argument("--no-remember", action="store_true", help="Do not write explicit task-state memory.")
268
+ agent_run.add_argument("--allow-over-budget", action="store_true", help="Execute even when preflight budget verification fails.")
269
+ agent_run.add_argument("--expect-json", action="store_true", help="Require provider responses to be valid JSON.")
270
+ agent_run.add_argument("--json", action="store_true")
271
+ agent_run.set_defaults(func=cmd_agent_run)
272
+ agent_list = agent_sub.add_parser("list", help="List saved agent loop reports.")
273
+ agent_list.set_defaults(func=cmd_agent_list)
274
+ agent_show = agent_sub.add_parser("show", help="Show a saved agent loop report.")
275
+ agent_show.add_argument("run_id")
276
+ agent_show.set_defaults(func=cmd_agent_show)
277
+ agent_cost = agent_sub.add_parser("cost", help="Inspect token cost and pressure for a saved agent loop report.")
278
+ agent_cost.add_argument("run_id")
279
+ agent_cost.add_argument("--json", action="store_true")
280
+ agent_cost.set_defaults(func=cmd_agent_cost)
281
+
282
+ chat_parser = subparsers.add_parser("chat", help="Start an interactive Claude Code-style task session.")
283
+ add_provider_args(chat_parser)
284
+ add_budget_args(chat_parser)
285
+ chat_parser.add_argument("--task", default=None, help="Continue an existing task session.")
286
+ chat_parser.add_argument("--title", default="Interactive chat", help="Title for a new task session.")
287
+ chat_parser.add_argument("--max-steps", type=int, default=5, help="Maximum agent loop steps per user message.")
288
+ chat_parser.add_argument("--model-routing", choices=["auto", "primary", "auxiliary"], default="auto", help="Choose how agent steps select primary vs auxiliary model.")
289
+ chat_parser.add_argument("--aux-review", choices=["auto", "off", "always"], default="auto", help="Run auxiliary context review before selected agent steps.")
290
+ chat_parser.add_argument("--no-remember", action="store_true", help="Do not write explicit task-state memory.")
291
+ chat_parser.add_argument("--allow-over-budget", action="store_true", help="Execute even when preflight budget verification fails.")
292
+ chat_parser.add_argument("--expect-json", action="store_true", help="Require provider responses to be valid JSON.")
293
+ chat_parser.add_argument("--ui", choices=["auto", "classic", "tui"], default="auto", help="Choose interactive UI mode. auto uses TUI only on real terminals.")
294
+ chat_parser.set_defaults(provider="openai", func=cmd_chat)
295
+
296
+ models_parser = subparsers.add_parser("models", help="List models from a provider.")
297
+ models_parser.add_argument("--provider", choices=["mock", "openai"], default="openai")
298
+ models_parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override.")
299
+ models_parser.set_defaults(func=cmd_models)
300
+
301
+ doctor_parser = subparsers.add_parser("doctor", help="Check local project configuration.")
302
+ doctor_parser.set_defaults(func=cmd_doctor)
303
+
304
+ project_parser = subparsers.add_parser("project", help="Scan and inspect project profile metadata.")
305
+ project_sub = project_parser.add_subparsers(dest="project_command", required=True)
306
+ project_scan = project_sub.add_parser("scan", help="Scan the workspace and write .akernel/project.json.")
307
+ project_scan.add_argument("--no-config-update", action="store_true", help="Do not extend safe command roots from scan results.")
308
+ project_scan.add_argument("--json", action="store_true", help="Print the full project profile JSON.")
309
+ project_scan.set_defaults(func=cmd_project_scan)
310
+ project_show = project_sub.add_parser("show", help="Show the saved project profile.")
311
+ project_show.add_argument("--json", action="store_true", help="Print the full project profile JSON.")
312
+ project_show.set_defaults(func=cmd_project_show)
313
+
314
+ plan_parser = subparsers.add_parser("plan", help="Create an execution plan without calling a provider.")
315
+ plan_parser.add_argument("request")
316
+ add_budget_args(plan_parser)
317
+ plan_parser.add_argument("--task", default=None, help="Use a task session for planning.")
318
+ plan_parser.add_argument("--resume", action="store_true", help="Inject the task brief into the planned context. Requires --task.")
319
+ plan_parser.add_argument("--json", action="store_true", help="Print the full plan JSON.")
320
+ plan_parser.set_defaults(func=cmd_plan)
321
+
322
+ policy_parser = subparsers.add_parser("policy", help="Check tool and file operation policy contracts.")
323
+ policy_sub = policy_parser.add_subparsers(dest="policy_command", required=True)
324
+ policy_file = policy_sub.add_parser("file", help="Check a planned file operation.")
325
+ policy_file.add_argument("operation", choices=sorted(FILE_OPERATIONS))
326
+ policy_file.add_argument("path")
327
+ policy_file.add_argument("--allow-destructive", action="store_true")
328
+ policy_file.add_argument("--json", action="store_true")
329
+ policy_file.set_defaults(func=cmd_policy_file)
330
+ policy_command = policy_sub.add_parser("command", help="Check a planned shell command without running it.")
331
+ policy_command.add_argument("--allow-destructive", action="store_true")
332
+ policy_command.add_argument("--json", action="store_true")
333
+ policy_command.add_argument("command", nargs=argparse.REMAINDER)
334
+ policy_command.set_defaults(func=cmd_policy_command)
335
+
336
+ tool_parser = subparsers.add_parser("tool", help="Execute local tools through policy contracts.")
337
+ tool_sub = tool_parser.add_subparsers(dest="tool_command", required=True)
338
+ tool_read = tool_sub.add_parser("read", help="Read a workspace file through policy.")
339
+ tool_read.add_argument("path")
340
+ tool_read.add_argument("--max-chars", type=int, default=8000)
341
+ tool_read.add_argument("--json", action="store_true")
342
+ tool_read.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
343
+ tool_read.set_defaults(func=cmd_tool_read)
344
+ tool_write = tool_sub.add_parser("write", help="Write a workspace file through policy.")
345
+ tool_write.add_argument("path")
346
+ tool_write.add_argument("--text", required=True)
347
+ tool_write.add_argument("--json", action="store_true")
348
+ tool_write.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
349
+ tool_write.set_defaults(func=cmd_tool_write)
350
+ tool_patch = tool_sub.add_parser("patch", help="Patch a workspace file with structured replacement modes.")
351
+ tool_patch.add_argument("path")
352
+ tool_patch.add_argument("--old")
353
+ tool_patch.add_argument("--new", required=True)
354
+ tool_patch.add_argument("--replace-all", action="store_true", help="Replace every match of --old instead of requiring a single match.")
355
+ tool_patch.add_argument("--occurrence", type=int, default=None, help="Replace only the nth match of --old.")
356
+ tool_patch.add_argument("--start-anchor", default=None, help="Replace the block that starts after this anchor.")
357
+ tool_patch.add_argument("--end-anchor", default=None, help="Replace the block that ends before this anchor.")
358
+ tool_patch.add_argument("--include-anchors", action="store_true", help="Replace the anchors together with the block body.")
359
+ tool_patch.add_argument("--json", action="store_true")
360
+ tool_patch.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
361
+ tool_patch.set_defaults(func=cmd_tool_patch)
362
+ tool_batch_patch = tool_sub.add_parser("batch-patch", help="Apply multiple structured patches from a JSON spec file.")
363
+ tool_batch_patch.add_argument("--specs-file", required=True, help="JSON file containing an array of patch specs, or an object with an edits array.")
364
+ tool_batch_patch.add_argument("--json", action="store_true")
365
+ tool_batch_patch.add_argument("--task", default=None, help="Attach the batch trace to a task session.")
366
+ tool_batch_patch.set_defaults(func=cmd_tool_batch_patch)
367
+ tool_delete = tool_sub.add_parser("delete", help="Delete a workspace file through destructive policy.")
368
+ tool_delete.add_argument("path")
369
+ tool_delete.add_argument("--allow-destructive", action="store_true")
370
+ tool_delete.add_argument("--json", action="store_true")
371
+ tool_delete.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
372
+ tool_delete.set_defaults(func=cmd_tool_delete)
373
+ tool_exec = tool_sub.add_parser("exec", help="Run a safe command through policy.")
374
+ tool_exec.add_argument("--allow-destructive", action="store_true")
375
+ tool_exec.add_argument("--timeout", type=int, default=30)
376
+ tool_exec.add_argument("--json", action="store_true")
377
+ tool_exec.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
378
+ tool_exec.add_argument("command", nargs=argparse.REMAINDER)
379
+ tool_exec.set_defaults(func=cmd_tool_exec)
380
+ tool_list = tool_sub.add_parser("list", help="List tool execution traces.")
381
+ tool_list.set_defaults(func=cmd_tool_list)
382
+ tool_show = tool_sub.add_parser("show", help="Show a tool execution trace.")
383
+ tool_show.add_argument("trace_id")
384
+ tool_show.set_defaults(func=cmd_tool_show)
385
+
386
+ task_parser = subparsers.add_parser("task", help="Manage resumable task sessions.")
387
+ task_sub = task_parser.add_subparsers(dest="task_command", required=True)
388
+ task_start = task_sub.add_parser("start", help="Start a task session.")
389
+ task_start.add_argument("title")
390
+ task_start.add_argument("--goal", default=None)
391
+ task_start.add_argument("--plan", action="store_true", help="Create a structured long-task plan immediately.")
392
+ task_start.set_defaults(func=cmd_task_start)
393
+ task_list = task_sub.add_parser("list", help="List task sessions.")
394
+ task_list.add_argument("--status", choices=sorted(TASK_STATUSES))
395
+ task_list.set_defaults(func=cmd_task_list)
396
+ task_status = task_sub.add_parser("status", help="Show a task session.")
397
+ task_status.add_argument("task_id")
398
+ task_status.add_argument("--json", action="store_true")
399
+ task_status.set_defaults(func=cmd_task_status)
400
+ task_brief = task_sub.add_parser("brief", help="Build a compact resume brief for a task.")
401
+ task_brief.add_argument("task_id")
402
+ task_brief.add_argument("--json", action="store_true")
403
+ task_brief.set_defaults(func=cmd_task_brief)
404
+ task_plan = task_sub.add_parser("plan", help="Create or refresh a structured long-task plan.")
405
+ task_plan.add_argument("task_id")
406
+ task_plan.add_argument("--goal", default=None)
407
+ task_plan.add_argument("--force", action="store_true", help="Replace an existing structured plan.")
408
+ task_plan.add_argument("--json", action="store_true")
409
+ task_plan.set_defaults(func=cmd_task_plan)
410
+ task_next = task_sub.add_parser("next", help="Show the next resumable checkpoint for a task.")
411
+ task_next.add_argument("task_id")
412
+ task_next.add_argument("--json", action="store_true")
413
+ task_next.set_defaults(func=cmd_task_next)
414
+ task_checkpoint = task_sub.add_parser("checkpoint", help="Record long-task checkpoint progress.")
415
+ task_checkpoint.add_argument("task_id")
416
+ task_checkpoint.add_argument("--note", required=True)
417
+ task_checkpoint.add_argument("--milestone", default=None)
418
+ task_checkpoint.add_argument("--status", choices=sorted(MILESTONE_STATUSES), default=None)
419
+ task_checkpoint.set_defaults(func=cmd_task_checkpoint)
420
+ task_step = task_sub.add_parser("step", help="Append a checkpoint note.")
421
+ task_step.add_argument("task_id")
422
+ task_step.add_argument("--note", required=True)
423
+ task_step.set_defaults(func=cmd_task_step)
424
+ task_attach = task_sub.add_parser("attach", help="Attach a run/tool/memory reference.")
425
+ task_attach.add_argument("task_id")
426
+ task_attach.add_argument("kind", choices=["run", "tool", "memory"])
427
+ task_attach.add_argument("ref_id")
428
+ task_attach.set_defaults(func=cmd_task_attach)
429
+ task_block = task_sub.add_parser("block", help="Mark a task as blocked.")
430
+ task_block.add_argument("task_id")
431
+ task_block.add_argument("--note", required=True)
432
+ task_block.set_defaults(func=cmd_task_block)
433
+ task_complete = task_sub.add_parser("complete", help="Mark a task as completed.")
434
+ task_complete.add_argument("task_id")
435
+ task_complete.add_argument("--note", default=None)
436
+ task_complete.set_defaults(func=cmd_task_complete)
437
+
438
+ context_parser = subparsers.add_parser("context", help="Inspect context assembly without provider execution.")
439
+ context_parser.add_argument("request")
440
+ add_budget_args(context_parser)
441
+ context_parser.add_argument("--task", default=None, help="Use a task session for context assembly.")
442
+ context_parser.add_argument("--resume", action="store_true", help="Inject the task brief into the context packet. Requires --task.")
443
+ context_parser.set_defaults(func=cmd_context)
444
+
445
+ compare_parser = subparsers.add_parser("compare", help="Compare minimal context against a full-load baseline.")
446
+ compare_parser.add_argument("request")
447
+ add_budget_args(compare_parser)
448
+ compare_parser.add_argument("--json", action="store_true", help="Print the full comparison JSON.")
449
+ compare_parser.set_defaults(func=cmd_compare)
450
+
451
+ eval_parser = subparsers.add_parser("eval", help="Run comparison fixtures.")
452
+ eval_sub = eval_parser.add_subparsers(dest="eval_command", required=True)
453
+ eval_run = eval_sub.add_parser("run", help="Run eval fixture JSON.")
454
+ eval_run.add_argument("fixture")
455
+ add_budget_args(eval_run)
456
+ add_eval_provider_args(eval_run)
457
+ eval_run.add_argument("--json", action="store_true", help="Print the full eval report JSON.")
458
+ eval_run.add_argument("--no-save", action="store_true", help="Do not persist the eval report.")
459
+ eval_run.set_defaults(func=cmd_eval_run)
460
+ eval_list = eval_sub.add_parser("list", help="List saved eval reports.")
461
+ eval_list.set_defaults(func=cmd_eval_list)
462
+ eval_show = eval_sub.add_parser("show", help="Show a saved eval report.")
463
+ eval_show.add_argument("report_id")
464
+ eval_show.set_defaults(func=cmd_eval_show)
465
+ eval_cost = eval_sub.add_parser("cost", help="Inspect token cost hotspots for a saved eval report.")
466
+ eval_cost.add_argument("report_id")
467
+ eval_cost.add_argument("--json", action="store_true", help="Print the full eval cost JSON.")
468
+ eval_cost.set_defaults(func=cmd_eval_cost)
469
+ eval_diff = eval_sub.add_parser("diff", help="Compare two saved eval reports.")
470
+ eval_diff.add_argument("before_id")
471
+ eval_diff.add_argument("after_id")
472
+ eval_diff.add_argument("--json", action="store_true", help="Print the full diff JSON.")
473
+ eval_diff.add_argument("--fail-on-regression", action="store_true", help="Exit non-zero when regressions are detected.")
474
+ eval_diff.set_defaults(func=cmd_eval_diff)
475
+
476
+ bench_parser = subparsers.add_parser("bench", help="Run benchmark fixture directories.")
477
+ bench_sub = bench_parser.add_subparsers(dest="bench_command", required=True)
478
+ bench_run = bench_sub.add_parser("run", help="Run all eval fixtures in a directory.")
479
+ bench_run.add_argument("directory")
480
+ add_budget_args(bench_run)
481
+ add_eval_provider_args(bench_run)
482
+ bench_run.add_argument("--json", action="store_true", help="Print the full benchmark report JSON.")
483
+ bench_run.add_argument("--no-save", action="store_true", help="Do not persist the benchmark report.")
484
+ bench_run.set_defaults(func=cmd_bench_run)
485
+ bench_gate = bench_sub.add_parser("gate", help="Run a benchmark and fail if it regresses against a saved baseline.")
486
+ bench_gate.add_argument("directory")
487
+ add_budget_args(bench_gate)
488
+ add_eval_provider_args(bench_gate)
489
+ bench_gate.add_argument("--baseline-report", default=None, help="Saved benchmark report id to compare against.")
490
+ bench_gate.add_argument("--require-baseline", action="store_true", help="Exit non-zero when no matching baseline report exists.")
491
+ bench_gate.add_argument("--json", action="store_true", help="Print the full benchmark gate JSON.")
492
+ bench_gate.set_defaults(func=cmd_bench_gate)
493
+ bench_list = bench_sub.add_parser("list", help="List saved benchmark reports.")
494
+ bench_list.set_defaults(func=cmd_bench_list)
495
+ bench_show = bench_sub.add_parser("show", help="Show a saved benchmark report.")
496
+ bench_show.add_argument("report_id")
497
+ bench_show.set_defaults(func=cmd_bench_show)
498
+ bench_cost = bench_sub.add_parser("cost", help="Inspect token cost hotspots for a saved benchmark report.")
499
+ bench_cost.add_argument("report_id")
500
+ bench_cost.add_argument("--json", action="store_true", help="Print the full benchmark cost JSON.")
501
+ bench_cost.set_defaults(func=cmd_bench_cost)
502
+ bench_diff = bench_sub.add_parser("diff", help="Compare two saved benchmark reports.")
503
+ bench_diff.add_argument("before_id")
504
+ bench_diff.add_argument("after_id")
505
+ bench_diff.add_argument("--json", action="store_true", help="Print the full benchmark diff JSON.")
506
+ bench_diff.add_argument("--fail-on-regression", action="store_true", help="Exit non-zero when regressions are detected.")
507
+ bench_diff.set_defaults(func=cmd_bench_diff)
508
+ bench_export = bench_sub.add_parser("export", help="Export a benchmark report as Markdown.")
509
+ bench_export.add_argument("report_id")
510
+ bench_export.add_argument("--output", default=None, help="Output markdown path.")
511
+ bench_export.set_defaults(func=cmd_bench_export)
512
+ bench_evidence = bench_sub.add_parser("evidence", help="Summarize saved benchmark reports as token-savings evidence.")
513
+ bench_evidence.add_argument("report_ids", nargs="*", help="Specific report ids. Defaults to recent saved reports.")
514
+ bench_evidence.add_argument("--limit", type=int, default=None, help="Limit recent reports when no ids are provided.")
515
+ bench_evidence.add_argument("--output", default=None, help="Write Markdown evidence to this path.")
516
+ bench_evidence.add_argument("--json", action="store_true", help="Print the full evidence JSON.")
517
+ bench_evidence.add_argument("--fail-under", type=float, default=None, help="Exit non-zero if total savings percent is below this threshold.")
518
+ bench_evidence.set_defaults(func=cmd_bench_evidence)
519
+
520
+ trace_parser = subparsers.add_parser("trace", help="Inspect run traces.")
521
+ trace_sub = trace_parser.add_subparsers(dest="trace_command", required=True)
522
+ trace_list = trace_sub.add_parser("list", help="List traces.")
523
+ trace_list.set_defaults(func=cmd_trace_list)
524
+ trace_show = trace_sub.add_parser("show", help="Show a trace.")
525
+ trace_show.add_argument("trace_id")
526
+ trace_show.set_defaults(func=cmd_trace_show)
527
+ trace_verify = trace_sub.add_parser("verify", help="Re-run verifier checks on a saved trace.")
528
+ trace_verify.add_argument("trace_id")
529
+ trace_verify.add_argument("--expect-json", action="store_true")
530
+ trace_verify.set_defaults(func=cmd_trace_verify)
531
+ trace_remember = trace_sub.add_parser("remember", help="Write memory records from a saved trace.")
532
+ trace_remember.add_argument("trace_id")
533
+ trace_remember.add_argument("--dry-run", action="store_true", help="Show proposed memory records without writing them.")
534
+ trace_remember.set_defaults(func=cmd_trace_remember)
535
+
536
+ return parser
537
+
538
+
539
+ def configure_console_output() -> None:
540
+ for stream in [sys.stdout, sys.stderr]:
541
+ reconfigure = getattr(stream, "reconfigure", None)
542
+ if callable(reconfigure):
543
+ reconfigure(encoding="utf-8", errors="replace")
544
+
545
+
546
+ def add_budget_args(parser: argparse.ArgumentParser) -> None:
547
+ parser.add_argument("--budget", type=int, default=None, help="Override the selected profile's token budget.")
548
+ parser.add_argument("--profile", choices=profile_names(), default=DEFAULT_PROFILE, help="Budget profile.")
549
+
550
+
551
+ def add_provider_args(parser: argparse.ArgumentParser) -> None:
552
+ parser.add_argument("--provider", choices=["mock", "openai"], default="mock")
553
+ parser.add_argument("--model", default=None, help="Provider model id. Defaults to gpt-5.5 for openai.")
554
+ parser.add_argument("--aux-model", default=None, help="Auxiliary model id for planning, review, and compression.")
555
+ parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override.")
556
+
557
+
558
+ def add_eval_provider_args(parser: argparse.ArgumentParser) -> None:
559
+ parser.add_argument("--execute", action="store_true", help="Execute each eval task with a provider.")
560
+ parser.add_argument("--provider", choices=["mock", "openai"], default="mock", help="Provider used with --execute.")
561
+ parser.add_argument("--model", default=None, help="Provider model id used with --execute.")
562
+ parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override used with --execute.")
563
+
564
+
565
+ def workspace_from_args(args: argparse.Namespace, *, initialized: bool = True) -> Workspace:
566
+ workspace = Workspace(Path(args.workspace))
567
+ if initialized:
568
+ workspace.require_initialized()
569
+ return workspace
570
+
571
+
572
+ def chat_workspace_from_args(args: argparse.Namespace) -> Workspace:
573
+ workspace = Workspace(Path(args.workspace))
574
+ if not workspace.state.exists():
575
+ workspace.init()
576
+ print(f"initialized workspace: {workspace.state}")
577
+ return workspace
578
+
579
+
580
+ def cmd_init(args: argparse.Namespace) -> None:
581
+ workspace = Workspace(Path(args.path))
582
+ workspace.init()
583
+ print(f"initialized: {workspace.state}")
584
+ if args.scan:
585
+ profile = scan_project(workspace, update_config=not args.no_config_update)
586
+ print_project_scan_summary(profile, config_updated=not args.no_config_update)
587
+
588
+
589
+ def cmd_project_scan(args: argparse.Namespace) -> None:
590
+ workspace = workspace_from_args(args)
591
+ profile = scan_project(workspace, update_config=not args.no_config_update)
592
+ if args.json:
593
+ print_json(profile)
594
+ return
595
+ print_project_scan_summary(profile, config_updated=not args.no_config_update)
596
+
597
+
598
+ def cmd_project_show(args: argparse.Namespace) -> None:
599
+ workspace = workspace_from_args(args)
600
+ profile = load_project_profile(workspace)
601
+ if not profile:
602
+ raise FileNotFoundError(f"No project profile found: {workspace.project_file}. Run `akernel project scan` first.")
603
+ if args.json:
604
+ print_json(profile)
605
+ return
606
+ print_project_scan_summary(profile, config_updated=False)
607
+
608
+
609
+ def cmd_setup(args: argparse.Namespace) -> None:
610
+ env_path = Path(args.env_file) if args.env_file else Path.cwd() / ".env"
611
+ existing = parse_simple_env(env_path) if env_path.exists() and not args.force else {}
612
+ interactive = sys.stdin.isatty()
613
+
614
+ api_key = args.api_key
615
+ if api_key is None:
616
+ current_key = existing.get("CONTEXT_KERNEL_OPENAI_API_KEY", "")
617
+ if current_key and interactive:
618
+ typed = getpass("API key [keep existing]: ").strip()
619
+ api_key = typed or current_key
620
+ elif interactive:
621
+ api_key = getpass("API key: ").strip()
622
+ else:
623
+ api_key = current_key
624
+ if not api_key:
625
+ raise ValueError("Missing API key. Run `akernel setup --api-key <key>` or use interactive setup.")
626
+
627
+ base_url = args.base_url
628
+ if base_url is None:
629
+ default_base_url = existing.get("CONTEXT_KERNEL_OPENAI_BASE_URL") or "https://clarmy.cloud/v1"
630
+ base_url = prompt_text("Base URL", default_base_url, interactive=interactive)
631
+ base_url = normalize_openai_base_url(base_url)
632
+
633
+ model = args.model
634
+ if model is None:
635
+ default_model = existing.get("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
636
+ model = prompt_text("Primary model", default_model, interactive=interactive)
637
+
638
+ aux_model = args.aux_model
639
+ if aux_model is None:
640
+ default_aux_model = existing.get("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
641
+ aux_model = prompt_text("Auxiliary model", default_aux_model, interactive=interactive)
642
+
643
+ write_project_env(env_path, api_key=api_key, base_url=base_url, model=model, aux_model=aux_model)
644
+ print(f"configured: {env_path}")
645
+ print("api_key: set")
646
+ print(f"base_url: {base_url}")
647
+ print(f"primary_model: {model}")
648
+ print(f"auxiliary_model: {aux_model}")
649
+ if args.verify:
650
+ previous = {
651
+ "CONTEXT_KERNEL_OPENAI_API_KEY": os.environ.get("CONTEXT_KERNEL_OPENAI_API_KEY"),
652
+ "CONTEXT_KERNEL_OPENAI_BASE_URL": os.environ.get("CONTEXT_KERNEL_OPENAI_BASE_URL"),
653
+ "CONTEXT_KERNEL_OPENAI_MODEL": os.environ.get("CONTEXT_KERNEL_OPENAI_MODEL"),
654
+ "CONTEXT_KERNEL_OPENAI_AUX_MODEL": os.environ.get("CONTEXT_KERNEL_OPENAI_AUX_MODEL"),
655
+ }
656
+ try:
657
+ os.environ["CONTEXT_KERNEL_OPENAI_API_KEY"] = api_key
658
+ os.environ["CONTEXT_KERNEL_OPENAI_BASE_URL"] = base_url
659
+ os.environ["CONTEXT_KERNEL_OPENAI_MODEL"] = model
660
+ os.environ["CONTEXT_KERNEL_OPENAI_AUX_MODEL"] = aux_model
661
+ models = list_provider_models("openai", base_url=base_url)
662
+ finally:
663
+ restore_env(previous)
664
+ print("models:")
665
+ for item in models[:20]:
666
+ print(f"- {item}")
667
+
668
+
669
+ def parse_simple_env(path: Path) -> dict[str, str]:
670
+ values: dict[str, str] = {}
671
+ if not path.exists():
672
+ return values
673
+ for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
674
+ line = raw_line.strip()
675
+ if not line or line.startswith("#") or "=" not in line:
676
+ continue
677
+ key, value = line.split("=", 1)
678
+ values[key.strip().lstrip("\ufeff")] = value.strip().strip('"').strip("'")
679
+ return values
680
+
681
+
682
+ def prompt_text(label: str, default: str, *, interactive: bool) -> str:
683
+ if not interactive:
684
+ return default
685
+ value = input(f"{label} [{default}]: ").strip()
686
+ return value or default
687
+
688
+
689
+ def write_project_env(path: Path, *, api_key: str, base_url: str, model: str, aux_model: str) -> None:
690
+ path.parent.mkdir(parents=True, exist_ok=True)
691
+ lines = [
692
+ f"CONTEXT_KERNEL_OPENAI_API_KEY={api_key}",
693
+ f"CONTEXT_KERNEL_OPENAI_BASE_URL={base_url}",
694
+ f"CONTEXT_KERNEL_OPENAI_MODEL={model}",
695
+ f"CONTEXT_KERNEL_OPENAI_AUX_MODEL={aux_model}",
696
+ ]
697
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
698
+
699
+
700
+ def restore_env(previous: dict[str, str | None]) -> None:
701
+ for key, value in previous.items():
702
+ if value is None:
703
+ os.environ.pop(key, None)
704
+ else:
705
+ os.environ[key] = value
706
+
707
+
708
+ def cmd_skill_register(args: argparse.Namespace) -> None:
709
+ workspace = workspace_from_args(args)
710
+ skill = SkillRegistry(workspace).register(Path(args.json_file))
711
+ print(f"registered skill: {skill.id} ({skill.name})")
712
+
713
+
714
+ def cmd_skill_list(args: argparse.Namespace) -> None:
715
+ workspace = workspace_from_args(args)
716
+ skills = SkillRegistry(workspace).all()
717
+ if not skills:
718
+ print("no skills registered")
719
+ return
720
+ for skill in skills:
721
+ print(f"{skill.id}\t{skill.name}\t{skill.summary}")
722
+
723
+
724
+ def cmd_skill_show(args: argparse.Namespace) -> None:
725
+ workspace = workspace_from_args(args)
726
+ skill = SkillRegistry(workspace).get(args.skill_id)
727
+ print_json(skill.render_level(args.level))
728
+
729
+
730
+ def cmd_skill_compile(args: argparse.Namespace) -> None:
731
+ source = Path(args.markdown_file)
732
+ metadata = None
733
+ if args.provider == "local":
734
+ skill = compile_markdown_skill(source, skill_id=args.id)
735
+ else:
736
+ skill, metadata = compile_markdown_skill_with_provider(
737
+ source,
738
+ provider_name=args.provider,
739
+ model=args.model,
740
+ base_url=args.base_url,
741
+ skill_id=args.id,
742
+ )
743
+ output = Path(args.output) if args.output else source.with_suffix(".json")
744
+ Workspace.write_json(output, skill.to_dict())
745
+ print(f"compiled skill: {skill.id} -> {output}")
746
+ if metadata:
747
+ print(f"provider: {metadata['provider']} model={metadata['model']} tokens={metadata['total_tokens']}")
748
+ if args.register:
749
+ workspace = workspace_from_args(args)
750
+ registered = SkillRegistry(workspace).register(output)
751
+ print(f"registered skill: {registered.id} ({registered.name})")
752
+
753
+
754
+ def cmd_skill_validate(args: argparse.Namespace) -> None:
755
+ print_json(validate_skill_file(Path(args.json_file)))
756
+
757
+
758
+ def cmd_skill_inspect(args: argparse.Namespace) -> None:
759
+ workspace = workspace_from_args(args)
760
+ skill = SkillRegistry(workspace).get(args.skill_id)
761
+ print_json(inspect_skill(skill, args.budget))
762
+
763
+
764
+ def cmd_skill_market_list(args: argparse.Namespace) -> None:
765
+ index = args.index if args.index else None
766
+ skills = list_marketplace_skills(index)
767
+ if args.json:
768
+ print_json({"count": len(skills), "skills": skills})
769
+ return
770
+ if not skills:
771
+ print("no marketplace skills")
772
+ return
773
+ for skill in skills:
774
+ compat = skill.get("compatibility_check", {})
775
+ remote = "remote" if skill.get("remote") else "local"
776
+ print(
777
+ f"{skill.get('id')}\t{skill.get('name')}\t"
778
+ f"v{skill.get('version', '0.0.0')}\t{remote}\t"
779
+ f"compat={'ok' if compat.get('ok', True) else 'blocked'}\t"
780
+ f"{skill.get('summary', '')}"
781
+ )
782
+
783
+
784
+ def cmd_skill_market_install(args: argparse.Namespace) -> None:
785
+ workspace = workspace_from_args(args)
786
+ index = args.index if args.index else None
787
+ trust_remote = args.trust_remote
788
+ if index and is_remote_reference(index) and not trust_remote and sys.stdin.isatty():
789
+ answer = input(f"Install from remote marketplace {index}? Type 'yes' to trust this source: ").strip().casefold()
790
+ trust_remote = answer == "yes"
791
+ result = install_marketplace_skill(
792
+ workspace,
793
+ args.skill_id,
794
+ index=index,
795
+ trust_remote=trust_remote,
796
+ ignore_compat=args.ignore_compat,
797
+ )
798
+ if args.json:
799
+ print_json(result)
800
+ return
801
+ print(f"installed marketplace skill: {result['id']} ({result['name']})")
802
+ print(f"version: {result.get('version')}")
803
+ print(f"source: {result.get('source')}")
804
+ print(f"compatibility: {'ok' if result.get('compatibility', {}).get('ok') else 'warning'}")
805
+
806
+
807
+ def cmd_memory_add(args: argparse.Namespace) -> None:
808
+ workspace = workspace_from_args(args)
809
+ tags = [tag.strip() for tag in args.tags.split(",") if tag.strip()]
810
+ record = MemoryStore(workspace).add(kind=args.kind, text=args.text, tags=tags)
811
+ print(f"memory: {record.id} ({record.kind})")
812
+
813
+
814
+ def cmd_memory_show(args: argparse.Namespace) -> None:
815
+ workspace = workspace_from_args(args)
816
+ record = MemoryStore(workspace).get(args.record_id, include_archived=args.include_archived)
817
+ print_json(record.to_dict())
818
+
819
+
820
+ def cmd_memory_list(args: argparse.Namespace) -> None:
821
+ workspace = workspace_from_args(args)
822
+ records = MemoryStore(workspace).all(kind=args.kind, include_archived=args.all)
823
+ if not records:
824
+ print("no memory records")
825
+ return
826
+ for record in records:
827
+ status = "archived" if record.archived_at else "active"
828
+ print(f"{record.id}\t{record.kind}\t{status}\t{record.text}")
829
+
830
+
831
+ def cmd_memory_search(args: argparse.Namespace) -> None:
832
+ workspace = workspace_from_args(args)
833
+ results = MemoryStore(workspace).search(args.query, kind=args.kind, limit=args.limit)
834
+ if not results:
835
+ print("no matching memory")
836
+ return
837
+ for item in results:
838
+ print(f"{item.record.id}\t{item.record.kind}\tscore={item.score}\t{item.record.text}")
839
+
840
+
841
+ def cmd_memory_audit(args: argparse.Namespace) -> None:
842
+ workspace = workspace_from_args(args)
843
+ decisions = sorted(MemoryStore(workspace).retention_analysis(), key=lambda item: item["retention_key"], reverse=True)
844
+ if args.json:
845
+ print_json({"count": len(decisions), "decisions": [strip_cli_retention_key(item) for item in decisions]})
846
+ return
847
+ if not decisions:
848
+ print("no memory records")
849
+ return
850
+ for decision in decisions[: args.limit]:
851
+ record = decision["record"]
852
+ recoverability = decision["recoverability"]
853
+ print(
854
+ f"{record['id']}\t{record['kind']}\tscore={decision['score']}\t"
855
+ f"tokens={decision['token_cost']}\trecoverable={recoverability['level']}\t{record['text']}"
856
+ )
857
+ print(f" reasons: {', '.join(decision['reasons'])}")
858
+
859
+
860
+ def cmd_memory_update(args: argparse.Namespace) -> None:
861
+ workspace = workspace_from_args(args)
862
+ tags = None if args.tags is None else [tag.strip() for tag in args.tags.split(",") if tag.strip()]
863
+ record = MemoryStore(workspace).update(args.record_id, kind=args.kind, text=args.text, tags=tags)
864
+ print(f"updated memory: {record.id} ({record.kind})")
865
+
866
+
867
+ def cmd_memory_forget(args: argparse.Namespace) -> None:
868
+ workspace = workspace_from_args(args)
869
+ removed = MemoryStore(workspace).forget(args.record_id)
870
+ if not removed:
871
+ raise KeyError(f"Memory record not found: {args.record_id}")
872
+ print(f"forgot memory: {args.record_id}")
873
+
874
+
875
+ def cmd_memory_prune(args: argparse.Namespace) -> None:
876
+ workspace = workspace_from_args(args)
877
+ result = MemoryStore(workspace).prune(
878
+ max_records=args.max_records,
879
+ max_tokens=args.max_tokens,
880
+ dry_run=args.dry_run,
881
+ )
882
+ if args.json:
883
+ print_json(result)
884
+ return
885
+ action = "would archive" if args.dry_run else "archived"
886
+ print(
887
+ f"memory_prune: active_before={result['active_before']} "
888
+ f"kept={result['kept']} {action}={result['candidate_count']} "
889
+ f"kept_tokens={result['kept_tokens']}"
890
+ )
891
+ for decision in result.get("candidate_decisions", [])[:5]:
892
+ record = decision["record"]
893
+ recoverability = decision["recoverability"]
894
+ print(
895
+ f"- {record['id']} {record['kind']} score={decision['score']} "
896
+ f"tokens={decision['token_cost']} recoverable={recoverability['level']}: {record['text']}"
897
+ )
898
+ print(f" reasons: {', '.join(decision['reasons'])}")
899
+
900
+
901
+ def strip_cli_retention_key(decision: dict[str, object]) -> dict[str, object]:
902
+ return {key: value for key, value in decision.items() if key != "retention_key"}
903
+
904
+
905
+ def cmd_memory_global_push(args: argparse.Namespace) -> None:
906
+ workspace = workspace_from_args(args)
907
+ result = push_global_memories(
908
+ workspace,
909
+ kind=args.kind,
910
+ namespace=args.namespace,
911
+ tag=args.tag,
912
+ dry_run=args.dry_run,
913
+ global_root=Path(args.global_root) if args.global_root else None,
914
+ )
915
+ if args.json:
916
+ print_json(result)
917
+ return
918
+ action = "would copy" if result.get("dry_run") else "copied"
919
+ print(
920
+ f"global_push: {action} {result['candidate_count']} memory record(s) "
921
+ f"to {result['target']} namespace={result['namespace']}"
922
+ )
923
+ print_global_memory_preview(result)
924
+
925
+
926
+ def cmd_memory_global_pull(args: argparse.Namespace) -> None:
927
+ workspace = workspace_from_args(args)
928
+ result = pull_global_memories(
929
+ workspace,
930
+ kind=args.kind,
931
+ namespace=args.namespace,
932
+ source_project=args.source_project,
933
+ tag=args.tag,
934
+ limit=args.limit,
935
+ dry_run=args.dry_run,
936
+ global_root=Path(args.global_root) if args.global_root else None,
937
+ )
938
+ if args.json:
939
+ print_json(result)
940
+ return
941
+ action = "would copy" if result.get("dry_run") else "copied"
942
+ print(f"global_pull: {action} {result['candidate_count']} memory record(s) from {result['source']}")
943
+ print_global_memory_preview(result)
944
+
945
+
946
+ def print_global_memory_preview(result: dict[str, Any], *, limit: int = 5) -> None:
947
+ records = result.get("records", [])
948
+ if not isinstance(records, list) or not records:
949
+ return
950
+ for record in records[:limit]:
951
+ if not isinstance(record, dict):
952
+ continue
953
+ tags = ", ".join(record.get("tags", [])[:6])
954
+ print(f"- {record.get('id')} {record.get('kind')} tags=[{tags}] {record.get('text')}")
955
+ if len(records) > limit:
956
+ print(f"... {len(records) - limit} more")
957
+
958
+
959
+ def cmd_context(args: argparse.Namespace) -> None:
960
+ workspace = workspace_from_args(args)
961
+ ensure_resume_args(args)
962
+ packet = ContextBuilder(workspace).build(
963
+ args.request,
964
+ args.budget,
965
+ args.profile,
966
+ task_id=args.task,
967
+ resume=args.resume,
968
+ )
969
+ print_json(packet)
970
+
971
+
972
+ def cmd_compare(args: argparse.Namespace) -> None:
973
+ workspace = workspace_from_args(args)
974
+ comparison = ContextBuilder(workspace).compare(args.request, args.budget, args.profile)
975
+ if args.json:
976
+ print_json(comparison)
977
+ return
978
+
979
+ print(f"request: {comparison['request']}")
980
+ print(f"profile: {comparison['profile']}")
981
+ print(f"budget: {comparison['budget']}")
982
+ print(f"kernel_tokens: {comparison['kernel']['estimated_tokens']}")
983
+ print(f"baseline_tokens: {comparison['baseline']['estimated_tokens']}")
984
+ print(f"savings: {comparison['savings']['estimated_tokens']} tokens ({comparison['savings']['percent']}%)")
985
+ print(f"kernel_selected: memory={comparison['kernel']['selected_memory']} skills={comparison['kernel']['selected_skills']}")
986
+ print(f"baseline_loaded: memory={comparison['baseline']['loaded_memory']} skills={comparison['baseline']['loaded_skills']} level={comparison['baseline']['skill_level']}")
987
+
988
+
989
+ def cmd_run(args: argparse.Namespace) -> None:
990
+ workspace = workspace_from_args(args)
991
+ ensure_resume_args(args)
992
+ ensure_task_attachable(workspace, args.task)
993
+ trace = AgentRunner(workspace).run(
994
+ args.request,
995
+ provider_name=args.provider,
996
+ budget=args.budget,
997
+ profile=args.profile,
998
+ model=args.model,
999
+ base_url=args.base_url,
1000
+ allow_over_budget=args.allow_over_budget,
1001
+ expect_json=args.expect_json,
1002
+ remember=args.remember,
1003
+ task_id=args.task,
1004
+ resume=args.resume,
1005
+ )
1006
+ print(trace["response"]["text"])
1007
+ print("")
1008
+ print(f"trace: {trace['id']}")
1009
+ print(f"tokens: input={trace['response']['input_tokens']} output={trace['response']['output_tokens']} total={trace['response']['total_tokens']}")
1010
+ print(f"verifier: {'ok' if trace['verifier']['ok'] else 'failed'}")
1011
+ if trace.get("state", {}).get("enabled"):
1012
+ print(f"state: wrote {trace['state']['written_count']} memory record(s)")
1013
+ if args.task:
1014
+ attach_run_to_task(workspace, args.task, trace)
1015
+ print(f"task: attached run {trace['id']} to {args.task}")
1016
+ if args.show_packet:
1017
+ print_json(trace["context_packet"])
1018
+
1019
+
1020
+ def cmd_agent_run(args: argparse.Namespace) -> None:
1021
+ workspace = workspace_from_args(args)
1022
+ ensure_task_attachable(workspace, args.task)
1023
+ report = AgentLoop(workspace).run(
1024
+ args.request,
1025
+ provider_name=args.provider,
1026
+ budget=args.budget,
1027
+ profile=args.profile,
1028
+ model=args.model,
1029
+ aux_model=args.aux_model,
1030
+ model_routing=args.model_routing,
1031
+ aux_review=args.aux_review,
1032
+ base_url=args.base_url,
1033
+ task_id=args.task,
1034
+ max_steps=args.max_steps,
1035
+ remember=not args.no_remember,
1036
+ allow_over_budget=args.allow_over_budget,
1037
+ expect_json=args.expect_json,
1038
+ )
1039
+ if args.json:
1040
+ print_json(report)
1041
+ return
1042
+
1043
+ print_agent_report(report)
1044
+
1045
+
1046
+ def cmd_chat(args: argparse.Namespace) -> None:
1047
+ workspace = chat_workspace_from_args(args)
1048
+ tasks = TaskStore(workspace)
1049
+ if args.task:
1050
+ ensure_task_attachable(workspace, args.task)
1051
+ task_id = args.task
1052
+ else:
1053
+ task = tasks.start(args.title, goal="Interactive agent chat session")
1054
+ task_id = task["id"]
1055
+
1056
+ last_report: dict[str, Any] | None = None
1057
+ state: dict[str, Any] = {"last_report": None}
1058
+ pending_context: list[str] = []
1059
+ if resolve_chat_ui(args) == "tui":
1060
+ run_chat_loop_tui(workspace, tasks, task_id, args)
1061
+ return
1062
+
1063
+ print_chat_header(workspace, task_id, args)
1064
+ while True:
1065
+ try:
1066
+ request = input(chat_prompt(args)).strip()
1067
+ except EOFError:
1068
+ print("")
1069
+ break
1070
+ except KeyboardInterrupt:
1071
+ print("")
1072
+ print("interrupted")
1073
+ break
1074
+ if not request:
1075
+ continue
1076
+ lowered = request.lower()
1077
+ if lowered in {"/exit", "/quit", "exit", "quit"}:
1078
+ print("bye")
1079
+ break
1080
+ if lowered == "/help":
1081
+ print_chat_help()
1082
+ continue
1083
+ if lowered == "/compact":
1084
+ print_task_brief_panel(tasks, task_id)
1085
+ continue
1086
+ if lowered == "/paste":
1087
+ pasted = read_paste_block()
1088
+ if not pasted:
1089
+ chat_notice("Paste", "No pasted task was captured.")
1090
+ continue
1091
+ request = pasted
1092
+ elif request.startswith("@"):
1093
+ attach_chat_file(workspace, tasks, task_id, request[1:].strip(), pending_context)
1094
+ continue
1095
+ elif request.startswith("!"):
1096
+ run_chat_command(workspace, tasks, task_id, request[1:].strip(), pending_context)
1097
+ continue
1098
+ if lowered == "/status":
1099
+ print_status_panel(workspace, task_id, args)
1100
+ continue
1101
+ if lowered == "/config":
1102
+ print_config_panel()
1103
+ continue
1104
+ if lowered == "/task":
1105
+ print_json(tasks.get(task_id))
1106
+ continue
1107
+ if lowered == "/model":
1108
+ print_model_panel(args)
1109
+ continue
1110
+ if lowered == "/runs":
1111
+ print_recent_agent_runs(workspace, limit=5)
1112
+ continue
1113
+ if lowered == "/clear":
1114
+ clear_chat_screen()
1115
+ print_chat_header(workspace, task_id, args)
1116
+ continue
1117
+ if lowered == "/cost":
1118
+ if last_report is None:
1119
+ print("no agent run yet")
1120
+ else:
1121
+ print(render_agent_cost_report(build_agent_cost_report(last_report)))
1122
+ continue
1123
+
1124
+ request_for_agent = merge_pending_context(request, pending_context)
1125
+ pending_context.clear()
1126
+ print_chat_turn_start(request_for_agent, args)
1127
+ last_report = AgentLoop(workspace).run(
1128
+ request_for_agent,
1129
+ provider_name=args.provider,
1130
+ budget=args.budget,
1131
+ profile=args.profile,
1132
+ model=args.model,
1133
+ aux_model=args.aux_model,
1134
+ model_routing=args.model_routing,
1135
+ aux_review=args.aux_review,
1136
+ base_url=args.base_url,
1137
+ task_id=task_id,
1138
+ max_steps=args.max_steps,
1139
+ remember=not args.no_remember,
1140
+ allow_over_budget=args.allow_over_budget,
1141
+ expect_json=args.expect_json,
1142
+ )
1143
+ print_chat_report(last_report)
1144
+
1145
+
1146
+ def print_chat_header(workspace: Workspace, task_id: str, args: argparse.Namespace) -> None:
1147
+ model = primary_model(args)
1148
+ base_url = args.base_url or env_value("CONTEXT_KERNEL_OPENAI_BASE_URL") or ""
1149
+ api_key_set = bool(env_value("CONTEXT_KERNEL_OPENAI_API_KEY"))
1150
+ chat_banner(
1151
+ "Context Kernel Agent",
1152
+ "Token-frugal task runner for long-lived AI workspaces.",
1153
+ )
1154
+ chat_panel(
1155
+ "Session",
1156
+ [
1157
+ ("cwd", compact_path(Path.cwd())),
1158
+ ("workspace", compact_path(workspace.root)),
1159
+ ("task", task_id),
1160
+ ("provider", args.provider),
1161
+ ("primary", model),
1162
+ ("auxiliary", auxiliary_model(args)),
1163
+ ("routing", args.model_routing),
1164
+ ("review", args.aux_review),
1165
+ ("profile", args.profile),
1166
+ ("loop", f"max {args.max_steps} steps per message"),
1167
+ ("state", workspace_state_summary(workspace)),
1168
+ ],
1169
+ )
1170
+ if args.provider == "openai" and (not api_key_set or not base_url):
1171
+ chat_notice("Setup needed", "Run `akernel setup` before sending OpenAI-backed tasks.")
1172
+ chat_panel(
1173
+ "Start",
1174
+ [
1175
+ ("type", "Describe a task in natural language and press Enter."),
1176
+ ("include", "@path attaches a workspace file; !cmd runs a policy-checked command."),
1177
+ ("compose", "/paste captures a multi-line task; /compact shows the task brief."),
1178
+ ("inspect", "/status, /model, /task, /runs, /cost"),
1179
+ ("control", "/help, /config, /clear, /exit"),
1180
+ ],
1181
+ )
1182
+
1183
+
1184
+ def resolve_chat_ui(args: argparse.Namespace) -> str:
1185
+ requested = getattr(args, "ui", "auto")
1186
+ if requested in {"classic", "tui"}:
1187
+ return requested
1188
+ if os.environ.get("AKERNEL_UI"):
1189
+ value = os.environ["AKERNEL_UI"].strip().lower()
1190
+ if value in {"classic", "tui"}:
1191
+ return value
1192
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
1193
+ return "classic"
1194
+ if os.environ.get("TERM", "").lower() == "dumb":
1195
+ return "classic"
1196
+ return "tui"
1197
+
1198
+
1199
+ def run_chat_loop_tui(
1200
+ workspace: Workspace,
1201
+ tasks: TaskStore,
1202
+ task_id: str,
1203
+ args: argparse.Namespace,
1204
+ ) -> None:
1205
+ last_report: dict[str, Any] | None = None
1206
+ pending_context: list[str] = []
1207
+ transcript: list[dict[str, str]] = [
1208
+ {
1209
+ "role": "system",
1210
+ "title": "Welcome",
1211
+ "text": "Describe a task, attach files with @path, run safe commands with !command, or type /help.",
1212
+ }
1213
+ ]
1214
+ use_alt_screen = sys.stdout.isatty() and not os.environ.get("AKERNEL_NO_ALT_SCREEN")
1215
+ if use_alt_screen:
1216
+ print("\033[?1049h", end="")
1217
+ try:
1218
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
1219
+ while True:
1220
+ try:
1221
+ request = input(tui_prompt(args)).strip()
1222
+ except EOFError:
1223
+ break
1224
+ except KeyboardInterrupt:
1225
+ transcript.append({"role": "system", "title": "Interrupted", "text": "Keyboard interrupt received."})
1226
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="interrupted")
1227
+ break
1228
+ if not request:
1229
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
1230
+ continue
1231
+ lowered = request.lower()
1232
+ if lowered in {"/exit", "/quit", "exit", "quit"}:
1233
+ break
1234
+ if lowered == "/clear":
1235
+ transcript.clear()
1236
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="cleared")
1237
+ continue
1238
+ state["last_report"] = last_report
1239
+ if handle_tui_command(
1240
+ request,
1241
+ lowered,
1242
+ workspace=workspace,
1243
+ tasks=tasks,
1244
+ task_id=task_id,
1245
+ args=args,
1246
+ pending_context=pending_context,
1247
+ transcript=transcript,
1248
+ state=state,
1249
+ ):
1250
+ last_report = state.get("last_report")
1251
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
1252
+ continue
1253
+
1254
+ request_for_agent = merge_pending_context(request, pending_context)
1255
+ pending_context.clear()
1256
+ transcript.append({"role": "user", "title": "You", "text": request_for_agent})
1257
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="running")
1258
+ last_report = AgentLoop(workspace).run(
1259
+ request_for_agent,
1260
+ provider_name=args.provider,
1261
+ budget=args.budget,
1262
+ profile=args.profile,
1263
+ model=args.model,
1264
+ aux_model=args.aux_model,
1265
+ model_routing=args.model_routing,
1266
+ aux_review=args.aux_review,
1267
+ base_url=args.base_url,
1268
+ task_id=task_id,
1269
+ max_steps=args.max_steps,
1270
+ remember=not args.no_remember,
1271
+ allow_over_budget=args.allow_over_budget,
1272
+ expect_json=args.expect_json,
1273
+ )
1274
+ transcript.append({"role": "assistant", "title": "Assistant", "text": format_tui_report(last_report)})
1275
+ render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
1276
+ finally:
1277
+ if use_alt_screen:
1278
+ print("\033[?1049l", end="")
1279
+ print("bye")
1280
+
1281
+
1282
+ def handle_tui_command(
1283
+ request: str,
1284
+ lowered: str,
1285
+ *,
1286
+ workspace: Workspace,
1287
+ tasks: TaskStore,
1288
+ task_id: str,
1289
+ args: argparse.Namespace,
1290
+ pending_context: list[str],
1291
+ transcript: list[dict[str, str]],
1292
+ state: dict[str, Any],
1293
+ ) -> bool:
1294
+ last_report = state.get("last_report")
1295
+ if lowered == "/help":
1296
+ transcript.append({"role": "system", "title": "Help", "text": format_chat_help_text()})
1297
+ return True
1298
+ if lowered == "/compact":
1299
+ transcript.append({"role": "system", "title": "Compact Brief", "text": capture_chat_output(lambda: print_task_brief_panel(tasks, task_id))})
1300
+ return True
1301
+ if lowered == "/status":
1302
+ transcript.append({"role": "system", "title": "Status", "text": capture_chat_output(lambda: print_status_panel(workspace, task_id, args))})
1303
+ return True
1304
+ if lowered == "/config":
1305
+ transcript.append({"role": "system", "title": "Config", "text": capture_chat_output(print_config_panel)})
1306
+ return True
1307
+ if lowered == "/task":
1308
+ transcript.append({"role": "system", "title": "Task", "text": json.dumps(tasks.get(task_id), indent=2, ensure_ascii=False)})
1309
+ return True
1310
+ if lowered == "/model":
1311
+ transcript.append({"role": "system", "title": "Model Roles", "text": capture_chat_output(lambda: print_model_panel(args))})
1312
+ return True
1313
+ if lowered == "/runs":
1314
+ transcript.append({"role": "system", "title": "Recent Runs", "text": capture_chat_output(lambda: print_recent_agent_runs(workspace, limit=5))})
1315
+ return True
1316
+ if lowered == "/cost":
1317
+ text = "no agent run yet" if last_report is None else render_agent_cost_report(build_agent_cost_report(last_report))
1318
+ transcript.append({"role": "system", "title": "Cost", "text": text})
1319
+ return True
1320
+ if lowered == "/paste":
1321
+ transcript.append({"role": "system", "title": "Paste", "text": "Paste mode uses the terminal below. Finish with /end."})
1322
+ pasted = read_paste_block()
1323
+ if pasted:
1324
+ transcript.append({"role": "user", "title": "Pasted Task", "text": pasted})
1325
+ request_for_agent = merge_pending_context(pasted, pending_context)
1326
+ pending_context.clear()
1327
+ report = AgentLoop(workspace).run(
1328
+ request_for_agent,
1329
+ provider_name=args.provider,
1330
+ budget=args.budget,
1331
+ profile=args.profile,
1332
+ model=args.model,
1333
+ aux_model=args.aux_model,
1334
+ model_routing=args.model_routing,
1335
+ aux_review=args.aux_review,
1336
+ base_url=args.base_url,
1337
+ task_id=task_id,
1338
+ max_steps=args.max_steps,
1339
+ remember=not args.no_remember,
1340
+ allow_over_budget=args.allow_over_budget,
1341
+ expect_json=args.expect_json,
1342
+ )
1343
+ state["last_report"] = report
1344
+ transcript.append({"role": "assistant", "title": "Assistant", "text": format_tui_report(report)})
1345
+ return True
1346
+ if request.startswith("@"):
1347
+ text = capture_chat_output(lambda: attach_chat_file(workspace, tasks, task_id, request[1:].strip(), pending_context))
1348
+ transcript.append({"role": "system", "title": "Attach File", "text": text})
1349
+ return True
1350
+ if request.startswith("!"):
1351
+ text = capture_chat_output(lambda: run_chat_command(workspace, tasks, task_id, request[1:].strip(), pending_context))
1352
+ transcript.append({"role": "system", "title": "Command", "text": text})
1353
+ return True
1354
+ return False
1355
+
1356
+
1357
+ def capture_chat_output(func: Any) -> str:
1358
+ buffer = io.StringIO()
1359
+ with redirect_stdout(buffer):
1360
+ func()
1361
+ return buffer.getvalue().strip()
1362
+
1363
+
1364
+ def format_chat_help_text() -> str:
1365
+ rows = [
1366
+ ("/help", "show this command palette"),
1367
+ ("/status", "show workspace and runtime status"),
1368
+ ("/model", "show primary and auxiliary model roles"),
1369
+ ("/config", "show setup and environment guidance"),
1370
+ ("/compact", "show the compact task brief used for resume context"),
1371
+ ("/paste", "enter a multi-line task; finish with /end"),
1372
+ ("@path", "attach a workspace file to the next task"),
1373
+ ("!command", "run a policy-checked command and attach its summary"),
1374
+ ("/task", "print the current task session JSON"),
1375
+ ("/runs", "list recent agent runs"),
1376
+ ("/cost", "print the last agent run cost report"),
1377
+ ("/clear", "clear the transcript"),
1378
+ ("/exit", "leave the interactive session"),
1379
+ ]
1380
+ return "\n".join(f"{name:<10} {description}" for name, description in rows)
1381
+
1382
+
1383
+ def tui_prompt(args: argparse.Namespace) -> str:
1384
+ return chat_color("\nakernel", "cyan", bold=True) + chat_color(f" [{primary_model(args)}]", "dim") + "> "
1385
+
1386
+
1387
+ def render_chat_tui_screen(
1388
+ workspace: Workspace,
1389
+ task_id: str,
1390
+ args: argparse.Namespace,
1391
+ transcript: list[dict[str, str]],
1392
+ last_report: dict[str, Any] | None,
1393
+ pending_context: list[str],
1394
+ *,
1395
+ status: str,
1396
+ ) -> None:
1397
+ screen = build_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status=status)
1398
+ print("\033[2J\033[H" + screen, end="")
1399
+
1400
+
1401
+ def build_chat_tui_screen(
1402
+ workspace: Workspace,
1403
+ task_id: str,
1404
+ args: argparse.Namespace,
1405
+ transcript: list[dict[str, str]],
1406
+ last_report: dict[str, Any] | None,
1407
+ pending_context: list[str],
1408
+ *,
1409
+ status: str,
1410
+ ) -> str:
1411
+ width = chat_width()
1412
+ height = max(24, shutil.get_terminal_size((width, 32)).lines)
1413
+ right_width = min(40, max(32, width // 3))
1414
+ left_width = max(46, width - right_width - 3)
1415
+ header = tui_header_lines(workspace, args, last_report, status=status, width=width)
1416
+ footer = tui_footer_lines(width)
1417
+ body_height = max(10, height - len(header) - len(footer) - 1)
1418
+ lines = header
1419
+ body = tui_body_lines(transcript, left_width, status=status)
1420
+ side = tui_sidebar_lines(workspace, task_id, args, last_report, pending_context, right_width)
1421
+ body = body[-body_height:]
1422
+ side = side[:body_height]
1423
+ for index in range(body_height):
1424
+ left = body[index] if index < len(body) else ""
1425
+ right = side[index] if index < len(side) else ""
1426
+ lines.append(f"{left:<{left_width}} {chat_color('|', 'dim')} {right:<{right_width}}")
1427
+ lines.extend(footer)
1428
+ return "\n".join(lines)
1429
+
1430
+
1431
+ def tui_header_lines(
1432
+ workspace: Workspace,
1433
+ args: argparse.Namespace,
1434
+ last_report: dict[str, Any] | None,
1435
+ *,
1436
+ status: str,
1437
+ width: int,
1438
+ ) -> list[str]:
1439
+ status_label = status.upper()
1440
+ status_color = "green" if status == "ready" else "yellow" if status == "running" else "cyan"
1441
+ tokens = 0 if not last_report else last_report.get("totals", {}).get("total_tokens", 0)
1442
+ title = f" Context Kernel TUI // AKERNEL // {status_label} "
1443
+ subtitle = (
1444
+ f"{compact_path(workspace.root)} | provider={args.provider} | "
1445
+ f"primary={primary_model(args)} | aux={auxiliary_model(args)} | last_tokens={tokens}"
1446
+ )
1447
+ return [
1448
+ chat_color(tui_rule(title, width), status_color, bold=True),
1449
+ truncate_line(subtitle, width),
1450
+ tui_command_strip(width),
1451
+ ]
1452
+
1453
+
1454
+ def tui_footer_lines(width: int) -> list[str]:
1455
+ return [
1456
+ tui_rule(" Input ", width),
1457
+ truncate_line("Type a task. Use /help for palette, /compact for resume brief, @path for files, !command for checked shell, /exit to quit.", width),
1458
+ "",
1459
+ ]
1460
+
1461
+
1462
+ def tui_command_strip(width: int) -> str:
1463
+ commands = " /help /status /model /compact /runs /cost @file !cmd "
1464
+ return chat_color(truncate_line(commands.center(width, "-"), width), "dim")
1465
+
1466
+
1467
+ def tui_body_lines(transcript: list[dict[str, str]], width: int, *, status: str = "ready") -> list[str]:
1468
+ if not transcript:
1469
+ return ["No messages yet. Start with one concrete task."]
1470
+ lines: list[str] = []
1471
+ lines.append(f"Transcript [{status}]")
1472
+ lines.append("-" * min(width, 22))
1473
+ for item in transcript:
1474
+ title = item.get("title", item.get("role", "message"))
1475
+ role = item.get("role", "system")
1476
+ label = tui_role_label(role, title)
1477
+ lines.append("")
1478
+ lines.append(truncate_line(f"+-- {label} " + "-" * max(0, width - len(label) - 5), width))
1479
+ prefix = "| " if role != "user" else "> "
1480
+ for line in wrap_plain(item.get("text", ""), width=max(20, width - len(prefix))).splitlines():
1481
+ lines.append(truncate_line(prefix + line, width))
1482
+ lines.append("")
1483
+ return lines
1484
+
1485
+
1486
+ def tui_role_label(role: str, title: str) -> str:
1487
+ labels = {
1488
+ "user": "YOU",
1489
+ "assistant": "AGENT",
1490
+ "system": "SYSTEM",
1491
+ }
1492
+ base = labels.get(role, role.upper())
1493
+ return f"{base}: {title}" if title and title.casefold() != base.casefold() else base
1494
+
1495
+
1496
+ def tui_sidebar_lines(
1497
+ workspace: Workspace,
1498
+ task_id: str,
1499
+ args: argparse.Namespace,
1500
+ last_report: dict[str, Any] | None,
1501
+ pending_context: list[str],
1502
+ width: int,
1503
+ ) -> list[str]:
1504
+ rows = tui_section("Cockpit", width)
1505
+ rows.extend(
1506
+ [
1507
+ f"provider: {args.provider}",
1508
+ f"profile: {getattr(args, 'profile', DEFAULT_PROFILE)}",
1509
+ f"routing: {getattr(args, 'model_routing', 'auto')}",
1510
+ f"steps: {getattr(args, 'max_steps', '?')}",
1511
+ f"pending: {len(pending_context)}",
1512
+ "",
1513
+ ]
1514
+ )
1515
+ rows.extend(tui_section("Model Stack", width))
1516
+ rows.extend(
1517
+ [
1518
+ f"primary: {primary_model(args)}",
1519
+ f"auxiliary: {auxiliary_model(args)}",
1520
+ f"review: {getattr(args, 'aux_review', 'auto')}",
1521
+ "",
1522
+ ]
1523
+ )
1524
+ rows.extend(tui_task_panel(workspace, task_id, width))
1525
+ if last_report:
1526
+ rows.extend(tui_last_run_panel(last_report, width))
1527
+ diagnostic = last_report.get("diagnostic")
1528
+ if isinstance(diagnostic, dict) and diagnostic:
1529
+ rows.extend([""])
1530
+ rows.extend(tui_section("Diagnostic", width))
1531
+ rows.append(str(diagnostic.get("category", "")))
1532
+ rows.extend(wrap_plain(str(diagnostic.get("suggestion", "")), width=max(20, width)).splitlines())
1533
+ rows.append("")
1534
+ rows.extend(tui_section("Workspace", width))
1535
+ rows.extend(wrap_plain(workspace_state_summary(workspace), width=max(20, width)).splitlines())
1536
+ return [truncate_line(line, width) for line in rows]
1537
+
1538
+
1539
+ def tui_section(title: str, width: int) -> list[str]:
1540
+ label = f"[ {title} ]"
1541
+ return [label, "-" * min(width, len(label) + 6)]
1542
+
1543
+
1544
+ def tui_task_panel(workspace: Workspace, task_id: str, width: int) -> list[str]:
1545
+ rows = tui_section("Mission", width)
1546
+ try:
1547
+ task = TaskStore(workspace).get(task_id)
1548
+ except (KeyError, FileNotFoundError):
1549
+ rows.extend([f"task {task_id}", "status unknown", ""])
1550
+ return rows
1551
+ rows.extend(
1552
+ [
1553
+ f"task: {task.get('id', task_id)}",
1554
+ f"status: {task.get('status', 'unknown')}",
1555
+ f"title: {truncate_line(str(task.get('title', '')), max(10, width - 10))}",
1556
+ ]
1557
+ )
1558
+ plan = task.get("plan")
1559
+ if isinstance(plan, dict):
1560
+ progress = plan.get("milestones", [])
1561
+ completed = sum(1 for item in progress if item.get("status") == "completed")
1562
+ active = next((item for item in progress if item.get("status") == "active"), None)
1563
+ rows.append(f"plan: {completed}/{len(progress)} done")
1564
+ if active:
1565
+ rows.append(f"active: {active.get('id')} {truncate_line(str(active.get('title', '')), max(8, width - 13))}")
1566
+ rows.append("")
1567
+ return rows
1568
+
1569
+
1570
+ def tui_last_run_panel(report: dict[str, Any], width: int) -> list[str]:
1571
+ rows = tui_section("Last Run Timeline", width)
1572
+ rows.extend(
1573
+ [
1574
+ f"id: {report.get('id')}",
1575
+ f"status: {report.get('status')}",
1576
+ f"tokens: {report.get('totals', {}).get('total_tokens', 0)}",
1577
+ ]
1578
+ )
1579
+ steps = report.get("steps", [])
1580
+ if steps:
1581
+ compact_actions = " -> ".join(str((step.get("action") or {}).get("action") or "none") for step in steps)
1582
+ rows.append(f"actions: {truncate_line(compact_actions, max(10, width - 9))}")
1583
+ for step in steps[:4]:
1584
+ action = str((step.get("action") or {}).get("action") or "none")
1585
+ ok = "ok" if step.get("verifier_ok", True) else "check"
1586
+ rows.append(f" {step.get('index', '?')}. {action} [{ok}]")
1587
+ if len(steps) > 4:
1588
+ rows.append(f" ... +{len(steps) - 4} more")
1589
+ return rows
1590
+
1591
+
1592
+ def format_tui_report(report: dict[str, Any]) -> str:
1593
+ actions = " -> ".join(str((step.get("action") or {}).get("action") or "none") for step in report.get("steps", []))
1594
+ parts = [
1595
+ f"status: {report.get('status')}",
1596
+ f"agent_run: {report.get('id')}",
1597
+ f"steps: {len(report.get('steps', []))}/{report.get('max_steps')}",
1598
+ f"tokens: {report.get('totals', {}).get('total_tokens', 0)}",
1599
+ ]
1600
+ if actions:
1601
+ parts.append(f"actions: {actions}")
1602
+ diagnostic = report.get("diagnostic")
1603
+ if isinstance(diagnostic, dict) and diagnostic:
1604
+ parts.append(f"diagnostic: {diagnostic.get('category')}")
1605
+ parts.append(f"next: {diagnostic.get('suggestion')}")
1606
+ if report.get("final_response"):
1607
+ parts.append("")
1608
+ parts.append(str(report["final_response"]))
1609
+ return "\n".join(parts)
1610
+
1611
+
1612
+ def tui_rule(title: str, width: int) -> str:
1613
+ text = title[:width]
1614
+ remaining = max(0, width - len(text))
1615
+ left = remaining // 2
1616
+ right = remaining - left
1617
+ return "=" * left + text + "=" * right
1618
+
1619
+
1620
+ def wrap_plain(text: str, *, width: int) -> str:
1621
+ return "\n".join(wrap_chat_text(text, width=width).splitlines())
1622
+
1623
+
1624
+ def truncate_line(text: str, width: int) -> str:
1625
+ value = str(text)
1626
+ return value if len(value) <= width else value[: max(0, width - 3)] + "..."
1627
+
1628
+
1629
+ def print_chat_turn_start(request: str, args: argparse.Namespace) -> None:
1630
+ preview = request if len(request) <= chat_width() - 14 else request[: chat_width() - 17] + "..."
1631
+ print("")
1632
+ print(chat_rule("New Task"))
1633
+ print(chat_color(f"you {preview}", "bold"))
1634
+ print(chat_color("agent building minimal context -> planning -> running bounded loop", "dim"))
1635
+ print(chat_color(f"runtime provider={args.provider} max_steps={args.max_steps}", "dim"))
1636
+
1637
+
1638
+ def print_chat_report(report: dict[str, Any]) -> None:
1639
+ actions = [
1640
+ str((step.get("action") or {}).get("action") or "none")
1641
+ for step in report.get("steps", [])
1642
+ ]
1643
+ print("")
1644
+ print(chat_rule("Result"))
1645
+ chat_panel(
1646
+ "Run Summary",
1647
+ [
1648
+ ("status", str(report["status"])),
1649
+ ("steps", f"{len(report['steps'])}/{report['max_steps']}"),
1650
+ ("tokens", str(report["totals"]["total_tokens"])),
1651
+ ("agent_run:", str(report["id"])),
1652
+ ],
1653
+ )
1654
+ if actions:
1655
+ print(chat_color("Actions", "cyan"))
1656
+ print(wrap_chat_text(" -> ".join(actions), indent=" "))
1657
+ print(chat_color("Models", "cyan"))
1658
+ print(wrap_chat_text(model_routing_summary(report), indent=" "))
1659
+ review_text = aux_review_summary(report)
1660
+ if review_text:
1661
+ print(chat_color("Review", "cyan"))
1662
+ print(wrap_chat_text(review_text, indent=" "))
1663
+ if report.get("state", {}).get("enabled"):
1664
+ print(chat_color(f"Memory wrote {report['state']['written_count']} record(s)", "dim"))
1665
+ if report.get("final_response"):
1666
+ print("")
1667
+ print(chat_color("Assistant", "green", bold=True))
1668
+ print(wrap_chat_text(str(report["final_response"]), indent=" "))
1669
+ print("")
1670
+ print(chat_color(f"Next /cost for cost report | akernel agent show {report['id']} for trace", "dim"))
1671
+
1672
+
1673
+ def print_chat_help() -> None:
1674
+ chat_panel(
1675
+ "Command Palette",
1676
+ [
1677
+ ("/help", "show this command palette"),
1678
+ ("/status", "show workspace and runtime status"),
1679
+ ("/model", "show primary and auxiliary model roles"),
1680
+ ("/config", "show setup and environment guidance"),
1681
+ ("/compact", "show the compact task brief used for resume context"),
1682
+ ("/paste", "enter a multi-line task; finish with /end"),
1683
+ ("@path", "attach a workspace file to the next task"),
1684
+ ("!command", "run a policy-checked command and attach its summary"),
1685
+ ("/task", "print the current task session JSON"),
1686
+ ("/runs", "list recent agent runs"),
1687
+ ("/cost", "print the last agent run cost report"),
1688
+ ("/clear", "clear and redraw the session header"),
1689
+ ("/exit", "leave the interactive session"),
1690
+ ],
1691
+ )
1692
+ print(chat_color("Tip Ask one concrete task at a time; Context Kernel keeps the packet lean.", "dim"))
1693
+
1694
+
1695
+ def print_recent_agent_runs(workspace: Workspace, *, limit: int) -> None:
1696
+ reports = list_agent_reports(workspace)[:limit]
1697
+ if not reports:
1698
+ chat_notice("Recent Runs", "No agent runs yet.")
1699
+ return
1700
+ print(chat_rule("Recent Runs"))
1701
+ for report in reports:
1702
+ request = str(report.get("request", ""))
1703
+ if len(request) > 52:
1704
+ request = request[:49] + "..."
1705
+ print(
1706
+ f" {report['id']} "
1707
+ f"{report.get('status', ''):<10} "
1708
+ f"steps={len(report.get('steps', [])):<2} "
1709
+ f"tokens={report.get('totals', {}).get('total_tokens', 0):<5} "
1710
+ f"{request}"
1711
+ )
1712
+
1713
+
1714
+ def attach_chat_file(
1715
+ workspace: Workspace,
1716
+ tasks: TaskStore,
1717
+ task_id: str,
1718
+ path: str,
1719
+ pending_context: list[str],
1720
+ ) -> None:
1721
+ if not path:
1722
+ chat_notice("Attach File", "Usage: @relative/path.txt")
1723
+ return
1724
+ result = ToolExecutor(workspace).read_file(path)
1725
+ tasks.attach(task_id, "tool", result["id"])
1726
+ summary = summarize_tool_result(result)
1727
+ tasks.step(
1728
+ task_id,
1729
+ f"User attached file {path}: {summary}",
1730
+ kind="chat_file",
1731
+ refs={"tool_traces": [result["id"]]},
1732
+ )
1733
+ if result["ok"] and not result["blocked"]:
1734
+ output = result.get("output", {})
1735
+ content = str(output.get("content", ""))
1736
+ pending_context.append(
1737
+ f"Attached file `{path}` ({output.get('size_chars', len(content))} chars, "
1738
+ f"truncated={bool(output.get('truncated'))}):\n{content}"
1739
+ )
1740
+ chat_notice("Attached File", f"{path} is attached to the next task.")
1741
+ else:
1742
+ chat_notice("Attach Failed", summary)
1743
+
1744
+
1745
+ def run_chat_command(
1746
+ workspace: Workspace,
1747
+ tasks: TaskStore,
1748
+ task_id: str,
1749
+ command: str,
1750
+ pending_context: list[str],
1751
+ ) -> None:
1752
+ if not command:
1753
+ chat_notice("Command", "Usage: !python -c \"print(123)\"")
1754
+ return
1755
+ result = ToolExecutor(workspace).run_command(command)
1756
+ tasks.attach(task_id, "tool", result["id"])
1757
+ summary = summarize_tool_result(result)
1758
+ tasks.step(
1759
+ task_id,
1760
+ f"User ran command `{command}`: {summary}",
1761
+ kind="chat_command",
1762
+ refs={"tool_traces": [result["id"]]},
1763
+ )
1764
+ output = result.get("output", {})
1765
+ pending_context.append(
1766
+ "Command result attached to the next task:\n"
1767
+ f"command: {command}\n"
1768
+ f"ok: {result.get('ok')}\n"
1769
+ f"blocked: {result.get('blocked')}\n"
1770
+ f"summary: {summary}\n"
1771
+ f"stdout: {str(output.get('stdout', ''))[:1200]}\n"
1772
+ f"stderr: {str(output.get('stderr', ''))[:800]}"
1773
+ )
1774
+ title = "Command Complete" if result.get("ok") else "Command Blocked" if result.get("blocked") else "Command Failed"
1775
+ chat_notice(title, summary)
1776
+
1777
+
1778
+ def read_paste_block() -> str:
1779
+ print(chat_color("Paste mode. Finish with /end on its own line.", "dim"))
1780
+ lines: list[str] = []
1781
+ while True:
1782
+ try:
1783
+ line = input(chat_color("paste> ", "dim"))
1784
+ except EOFError:
1785
+ break
1786
+ if line.strip().lower() == "/end":
1787
+ break
1788
+ lines.append(line)
1789
+ return "\n".join(lines).strip()
1790
+
1791
+
1792
+ def merge_pending_context(request: str, pending_context: list[str]) -> str:
1793
+ if not pending_context:
1794
+ return request
1795
+ context = "\n\n".join(pending_context)
1796
+ return (
1797
+ "Use the attached local context below when helpful. "
1798
+ "Do not assume it is complete if the task needs more evidence.\n\n"
1799
+ f"{context}\n\nUser task:\n{request}"
1800
+ )
1801
+
1802
+
1803
+ def print_task_brief_panel(tasks: TaskStore, task_id: str) -> None:
1804
+ brief = tasks.brief(task_id)
1805
+ rows = [
1806
+ ("task", brief["task"]["id"]),
1807
+ ("title", brief["task"]["title"]),
1808
+ ("status", brief["task"]["status"]),
1809
+ ("estimated_tokens", str(brief.get("estimated_tokens", 0))),
1810
+ ("recent_steps", str(len(brief.get("recent_steps", [])))),
1811
+ ("run_traces", str(len(brief.get("linked_run_traces", [])))),
1812
+ ("tool_traces", str(len(brief.get("linked_tool_traces", [])))),
1813
+ ("memories", str(len(brief.get("linked_memory", [])))),
1814
+ ]
1815
+ chat_panel("Compact Brief", rows)
1816
+ latest = brief.get("recent_steps", [])[-3:]
1817
+ if latest:
1818
+ print(chat_color("Recent", "cyan"))
1819
+ for step in latest:
1820
+ print(wrap_chat_text(f"{step.get('kind')}: {step.get('note')}", indent=" "))
1821
+
1822
+
1823
+ def print_model_panel(args: argparse.Namespace) -> None:
1824
+ chat_panel(
1825
+ "Model Roles",
1826
+ [
1827
+ ("provider", args.provider),
1828
+ ("primary", primary_model(args)),
1829
+ ("auxiliary", auxiliary_model(args)),
1830
+ ("routing", "auto can delegate low/medium first-step planning to auxiliary"),
1831
+ ("review_role", "auxiliary reviews primary-model steps when enabled"),
1832
+ ("mode", args.model_routing),
1833
+ ("review", args.aux_review),
1834
+ ("base_url", args.base_url or env_value("CONTEXT_KERNEL_OPENAI_BASE_URL") or "default"),
1835
+ ],
1836
+ )
1837
+
1838
+
1839
+ def print_status_panel(workspace: Workspace, task_id: str, args: argparse.Namespace) -> None:
1840
+ chat_panel(
1841
+ "Status",
1842
+ [
1843
+ ("cwd", compact_path(Path.cwd())),
1844
+ ("workspace", compact_path(workspace.root)),
1845
+ ("task", task_id),
1846
+ ("provider", args.provider),
1847
+ ("primary", primary_model(args)),
1848
+ ("auxiliary", auxiliary_model(args)),
1849
+ ("routing", args.model_routing),
1850
+ ("review", args.aux_review),
1851
+ ("profile", args.profile),
1852
+ ("state", workspace_state_summary(workspace)),
1853
+ ],
1854
+ )
1855
+
1856
+
1857
+ def print_config_panel() -> None:
1858
+ chat_panel(
1859
+ "Config",
1860
+ [
1861
+ ("setup", "akernel setup"),
1862
+ ("env", "CONTEXT_KERNEL_OPENAI_API_KEY, CONTEXT_KERNEL_OPENAI_BASE_URL"),
1863
+ ("models", "CONTEXT_KERNEL_OPENAI_MODEL, CONTEXT_KERNEL_OPENAI_AUX_MODEL"),
1864
+ ("scope", "current project .env first, installed Context Kernel .env fallback"),
1865
+ ],
1866
+ )
1867
+
1868
+
1869
+ def chat_prompt(args: argparse.Namespace) -> str:
1870
+ model = primary_model(args)
1871
+ return "\n" + chat_color("akernel", "cyan", bold=True) + chat_color(f" [{model}]", "dim") + "> "
1872
+
1873
+
1874
+ def primary_model(args: argparse.Namespace) -> str:
1875
+ return args.model or env_value("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
1876
+
1877
+
1878
+ def auxiliary_model(args: argparse.Namespace) -> str:
1879
+ return getattr(args, "aux_model", None) or env_value("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
1880
+
1881
+
1882
+ def model_routing_summary(report: dict[str, Any]) -> str:
1883
+ parts = []
1884
+ for step in report.get("steps", []):
1885
+ role = step.get("model_role") or "primary"
1886
+ model = step.get("model") or "default"
1887
+ reason = step.get("routing_reason") or ""
1888
+ label = f"step {step.get('index')}: {role} ({model})"
1889
+ parts.append(f"{label} - {reason}" if reason else label)
1890
+ if not parts:
1891
+ routing = report.get("model_routing", {})
1892
+ return f"{routing.get('mode', 'auto')}: no provider step was executed"
1893
+ return "; ".join(parts)
1894
+
1895
+
1896
+ def aux_review_summary(report: dict[str, Any]) -> str:
1897
+ parts = []
1898
+ for step in report.get("steps", []):
1899
+ review = step.get("aux_review", {})
1900
+ if not isinstance(review, dict) or not review.get("enabled"):
1901
+ continue
1902
+ parts.append(
1903
+ f"step {step.get('index')}: {review.get('risk')} risk, "
1904
+ f"{review.get('recommendation')} via {review.get('model')} "
1905
+ f"({review.get('tokens', {}).get('total_tokens', 0)}t)"
1906
+ )
1907
+ return "; ".join(parts)
1908
+
1909
+
1910
+ def clear_chat_screen() -> None:
1911
+ if sys.stdout.isatty():
1912
+ print("\033[2J\033[H", end="")
1913
+ else:
1914
+ print("\n" * 30)
1915
+
1916
+
1917
+ def chat_width() -> int:
1918
+ return max(88, min(shutil.get_terminal_size((112, 20)).columns, 132))
1919
+
1920
+
1921
+ def chat_color(text: str, color: str, *, bold: bool = False) -> str:
1922
+ if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
1923
+ return text
1924
+ codes = {
1925
+ "cyan": "36",
1926
+ "green": "32",
1927
+ "yellow": "33",
1928
+ "red": "31",
1929
+ "dim": "2",
1930
+ "bold": "1",
1931
+ }
1932
+ selected: list[str] = []
1933
+ if bold:
1934
+ selected.append("1")
1935
+ selected.append(codes.get(color, "0"))
1936
+ return f"\033[{';'.join(selected)}m{text}\033[0m"
1937
+
1938
+
1939
+ def chat_banner(title: str, subtitle: str) -> None:
1940
+ width = chat_width()
1941
+ print("")
1942
+ print(chat_color("=" * width, "cyan", bold=True))
1943
+ print(chat_color(title, "cyan", bold=True))
1944
+ print(chat_color(subtitle, "dim"))
1945
+ print(chat_color("=" * width, "cyan", bold=True))
1946
+
1947
+
1948
+ def chat_rule(title: str) -> str:
1949
+ width = chat_width()
1950
+ label = f" {title} "
1951
+ remaining = max(0, width - len(label))
1952
+ left = remaining // 2
1953
+ right = remaining - left
1954
+ return chat_color("-" * left + label + "-" * right, "cyan")
1955
+
1956
+
1957
+ def chat_panel(title: str, rows: list[tuple[str, str]]) -> None:
1958
+ width = chat_width()
1959
+ print("")
1960
+ print(chat_color(f"[ {title} ]", "cyan", bold=True))
1961
+ key_width = max(len(key) for key, _ in rows)
1962
+ for key, value in rows:
1963
+ prefix = f" {key:<{key_width}} "
1964
+ wrapped = wrap_chat_text(str(value), indent=" " * len(prefix), width=width)
1965
+ lines = wrapped.splitlines() or [""]
1966
+ print(chat_color(prefix, "dim") + lines[0].lstrip())
1967
+ for line in lines[1:]:
1968
+ print(line)
1969
+
1970
+
1971
+ def chat_notice(title: str, message: str) -> None:
1972
+ print("")
1973
+ print(chat_color(f"! {title}", "yellow", bold=True))
1974
+ print(wrap_chat_text(message, indent=" "))
1975
+
1976
+
1977
+ def wrap_chat_text(text: str, *, indent: str = "", width: int | None = None) -> str:
1978
+ width = width or chat_width()
1979
+ usable = max(30, width - len(indent))
1980
+ lines: list[str] = []
1981
+ for paragraph in text.splitlines() or [""]:
1982
+ words = paragraph.split()
1983
+ if not words:
1984
+ lines.append(indent.rstrip())
1985
+ continue
1986
+ current = words[0]
1987
+ for word in words[1:]:
1988
+ if len(current) + 1 + len(word) > usable:
1989
+ lines.append(indent + current)
1990
+ current = word
1991
+ else:
1992
+ current += " " + word
1993
+ lines.append(indent + current)
1994
+ return "\n".join(lines)
1995
+
1996
+
1997
+ def compact_path(path: Path) -> str:
1998
+ text = str(path)
1999
+ width = chat_width() - 18
2000
+ if len(text) <= width:
2001
+ return text
2002
+ return "..." + text[-max(12, width - 3) :]
2003
+
2004
+
2005
+ def workspace_state_summary(workspace: Workspace) -> str:
2006
+ skills = len(list(workspace.skills_dir.glob("*.json"))) if workspace.skills_dir.exists() else 0
2007
+ runs = len(list(workspace.agent_runs_dir.glob("*.json"))) if workspace.agent_runs_dir.exists() else 0
2008
+ project = 1 if workspace.project_file.exists() else 0
2009
+ try:
2010
+ memories = len(MemoryStore(workspace).all())
2011
+ except Exception:
2012
+ memories = 0
2013
+ return f"{skills} skills, {memories} memories, {runs} runs, {project} project profiles"
2014
+
2015
+
2016
+ def print_project_scan_summary(profile: dict[str, Any], *, config_updated: bool) -> None:
2017
+ print(f"project_profile: {profile.get('root')}")
2018
+ print(f"languages: {', '.join(profile.get('languages', [])) or 'unknown'}")
2019
+ managers = profile.get("package_managers", [])
2020
+ print(f"package_managers: {', '.join(managers) if managers else 'none'}")
2021
+ commands = profile.get("commands", {})
2022
+ if commands:
2023
+ for name, command in commands.items():
2024
+ print(f"command_{name}: {command}")
2025
+ else:
2026
+ print("commands: none")
2027
+ print(f"key_files: {', '.join(profile.get('key_files', [])[:8]) or 'none'}")
2028
+ instructions = profile.get("instructions", [])
2029
+ print(f"instructions: {', '.join(item.get('path', '') for item in instructions[:4]) if instructions else 'none'}")
2030
+ print(f"command_roots: {', '.join(profile.get('command_roots', [])[:16])}")
2031
+ print(f"config_updated: {config_updated}")
2032
+
2033
+
2034
+ def print_agent_report(report: dict[str, Any]) -> None:
2035
+ print(f"agent_run: {report['id']}")
2036
+ print(f"task: {report['task_id']}")
2037
+ print(f"status: {report['status']}")
2038
+ print(f"steps: {len(report['steps'])}/{report['max_steps']}")
2039
+ print(f"tokens: total={report['totals']['total_tokens']} input={report['totals']['input_tokens']} output={report['totals']['output_tokens']}")
2040
+ routing = report.get("model_routing", {})
2041
+ if routing:
2042
+ print(
2043
+ "model_routing: "
2044
+ f"mode={routing.get('mode')} "
2045
+ f"primary={routing.get('primary_model')} "
2046
+ f"auxiliary={routing.get('auxiliary_model')} "
2047
+ f"review={routing.get('aux_review')}"
2048
+ )
2049
+ if report.get("state", {}).get("enabled"):
2050
+ print(f"state: wrote {report['state']['written_count']} memory record(s)")
2051
+ print_agent_diagnostic(report.get("diagnostic"))
2052
+ for step in report["steps"]:
2053
+ trace = step["trace_id"] or "none"
2054
+ tokens = step.get("tokens", {}).get("total_tokens", 0)
2055
+ action = (step.get("action") or {}).get("action", "none")
2056
+ model_part = f" model={step.get('model_role')}:{step.get('model') or 'default'}" if step.get("model_role") else ""
2057
+ review = step.get("aux_review", {})
2058
+ review_part = ""
2059
+ if isinstance(review, dict) and review.get("enabled"):
2060
+ review_part = f" review={review.get('risk')}:{review.get('recommendation')}"
2061
+ tool = step.get("tool", {})
2062
+ tool_part = f" tool={tool.get('name')}:{tool.get('id')}" if tool else ""
2063
+ print(f"- step {step['index']}: {step['status']} action={action} trace={trace} tokens={tokens}{model_part}{review_part}{tool_part}")
2064
+ print_agent_diagnostic(step.get("diagnostic"), prefix=" ")
2065
+ if report.get("final_response"):
2066
+ print("")
2067
+ print(report["final_response"])
2068
+
2069
+
2070
+ def print_agent_diagnostic(diagnostic: Any, *, prefix: str = "") -> None:
2071
+ if not isinstance(diagnostic, dict) or not diagnostic:
2072
+ return
2073
+ category = diagnostic.get("category") or "unknown"
2074
+ message = diagnostic.get("message") or ""
2075
+ suggestion = diagnostic.get("suggestion") or ""
2076
+ print(f"{prefix}diagnostic: {category}")
2077
+ if message:
2078
+ print(f"{prefix}reason: {message}")
2079
+ if suggestion:
2080
+ print(f"{prefix}next: {suggestion}")
2081
+
2082
+
2083
+ def cmd_agent_list(args: argparse.Namespace) -> None:
2084
+ workspace = workspace_from_args(args)
2085
+ reports = list_agent_reports(workspace)
2086
+ if not reports:
2087
+ print("no agent runs")
2088
+ return
2089
+ for report in reports:
2090
+ print(
2091
+ f"{report['id']}\t"
2092
+ f"{report.get('created_at', '')}\t"
2093
+ f"{report.get('status', '')}\t"
2094
+ f"steps={len(report.get('steps', []))}\t"
2095
+ f"task={report.get('task_id', '')}\t"
2096
+ f"{report.get('request', '')}"
2097
+ )
2098
+
2099
+
2100
+ def cmd_agent_show(args: argparse.Namespace) -> None:
2101
+ workspace = workspace_from_args(args)
2102
+ print_json(load_agent_report(workspace, args.run_id))
2103
+
2104
+
2105
+ def cmd_agent_cost(args: argparse.Namespace) -> None:
2106
+ workspace = workspace_from_args(args)
2107
+ cost = build_agent_cost_report(load_agent_report(workspace, args.run_id))
2108
+ if args.json:
2109
+ print_json(cost)
2110
+ return
2111
+ print(render_agent_cost_report(cost))
2112
+
2113
+
2114
+ def cmd_models(args: argparse.Namespace) -> None:
2115
+ for model in list_provider_models(args.provider, base_url=args.base_url):
2116
+ print(model)
2117
+
2118
+
2119
+ def cmd_doctor(args: argparse.Namespace) -> None:
2120
+ workspace = Workspace(Path(args.workspace))
2121
+ config = workspace.load_config()
2122
+ command_policy = summarize_command_policy(workspace)
2123
+ base_url = env_value("CONTEXT_KERNEL_OPENAI_BASE_URL")
2124
+ api_key = env_value("CONTEXT_KERNEL_OPENAI_API_KEY")
2125
+ model = env_value("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
2126
+ aux_model = env_value("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
2127
+ print(f"project_root: {Path.cwd().resolve()}")
2128
+ print(f"workspace: {workspace.root}")
2129
+ print(f"workspace_initialized: {workspace.state.exists()}")
2130
+ print(f"workspace_config: {workspace.config_file}")
2131
+ print(f"workspace_config_version: {config.get('version')}")
2132
+ print(f"project_env_api_key_set: {bool(api_key)}")
2133
+ print(f"project_env_base_url: {normalize_openai_base_url(base_url or '') if base_url else ''}")
2134
+ print(f"project_env_primary_model: {model}")
2135
+ print(f"project_env_auxiliary_model: {aux_model}")
2136
+ profile = load_project_profile(workspace)
2137
+ print(f"project_profile: {workspace.project_file if profile else ''}")
2138
+ print(f"project_summary: {profile.get('summary', '') if profile else ''}")
2139
+ print(f"command_allowed_roots: {', '.join(command_policy['allowed_roots'])}")
2140
+ print(f"command_blocked_terms: {', '.join(command_policy['blocked_terms'])}")
2141
+
2142
+
2143
+ def cmd_plan(args: argparse.Namespace) -> None:
2144
+ workspace = workspace_from_args(args)
2145
+ ensure_resume_args(args)
2146
+ plan = ExecutionPlanner(workspace).plan(
2147
+ args.request,
2148
+ args.budget,
2149
+ args.profile,
2150
+ task_id=args.task,
2151
+ resume=args.resume,
2152
+ )
2153
+ if args.json:
2154
+ print_json(plan)
2155
+ return
2156
+
2157
+ print(f"request: {plan['request']}")
2158
+ print(f"profile: {plan['profile']}")
2159
+ print(f"route: {plan['route']['mode']} ({plan['route']['complexity']})")
2160
+ if plan["task"]["resume"]:
2161
+ print(f"task: {plan['task']['id']} resume tokens={plan['task']['estimated_tokens']}")
2162
+ print(f"tokens: used={plan['budget']['estimated_used']} total={plan['budget']['total']} remaining={plan['budget']['estimated_remaining']}")
2163
+ print(f"savings: {plan['savings']['estimated_tokens']} tokens ({plan['savings']['percent']}%)")
2164
+ print(f"selected: memory={len(plan['selection']['memory'])} skills={len(plan['selection']['skills'])}")
2165
+ print(f"policy: {'review required' if plan['policy']['requires_policy_check'] else 'clear'}")
2166
+ print(f"command_roots: {', '.join(plan['policy']['command_policy']['allowed_roots'])}")
2167
+ if plan["warnings"]:
2168
+ print("warnings:")
2169
+ for warning in plan["warnings"]:
2170
+ print(f"- {warning}")
2171
+
2172
+
2173
+ def cmd_policy_file(args: argparse.Namespace) -> None:
2174
+ workspace = workspace_from_args(args)
2175
+ result = check_file_policy(
2176
+ workspace,
2177
+ args.operation,
2178
+ args.path,
2179
+ allow_destructive=args.allow_destructive,
2180
+ )
2181
+ print_policy_result(result, args.json)
2182
+
2183
+
2184
+ def cmd_policy_command(args: argparse.Namespace) -> None:
2185
+ workspace = workspace_from_args(args, initialized=False)
2186
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
2187
+ result = check_command_policy(
2188
+ " ".join(command),
2189
+ workspace=workspace if workspace.state.exists() else None,
2190
+ allow_destructive=args.allow_destructive,
2191
+ )
2192
+ print_policy_result(result, args.json)
2193
+
2194
+
2195
+ def cmd_tool_read(args: argparse.Namespace) -> None:
2196
+ workspace = workspace_from_args(args)
2197
+ ensure_task_attachable(workspace, args.task)
2198
+ result = ToolExecutor(workspace).read_file(args.path, max_chars=args.max_chars)
2199
+ attach_tool_to_task_if_requested(workspace, args.task, result)
2200
+ if args.json:
2201
+ print_json(result)
2202
+ return
2203
+ print_tool_result(result)
2204
+ if result["ok"]:
2205
+ print(result["output"]["content"])
2206
+
2207
+
2208
+ def cmd_tool_write(args: argparse.Namespace) -> None:
2209
+ workspace = workspace_from_args(args)
2210
+ ensure_task_attachable(workspace, args.task)
2211
+ result = ToolExecutor(workspace).write_file(args.path, args.text)
2212
+ attach_tool_to_task_if_requested(workspace, args.task, result)
2213
+ if args.json:
2214
+ print_json(result)
2215
+ return
2216
+ print_tool_result(result)
2217
+
2218
+
2219
+ def cmd_tool_patch(args: argparse.Namespace) -> None:
2220
+ workspace = workspace_from_args(args)
2221
+ ensure_task_attachable(workspace, args.task)
2222
+ if (args.start_anchor or args.end_anchor) and args.old:
2223
+ raise ValueError("tool patch cannot combine --old with --start-anchor/--end-anchor")
2224
+ if not (args.start_anchor or args.end_anchor) and not args.old:
2225
+ raise ValueError("tool patch requires --old, or both --start-anchor and --end-anchor")
2226
+ result = ToolExecutor(workspace).patch_file(
2227
+ args.path,
2228
+ args.old or "",
2229
+ args.new,
2230
+ replace_all=args.replace_all,
2231
+ occurrence=args.occurrence,
2232
+ start_anchor=args.start_anchor,
2233
+ end_anchor=args.end_anchor,
2234
+ include_anchors=args.include_anchors,
2235
+ )
2236
+ attach_tool_to_task_if_requested(workspace, args.task, result)
2237
+ if args.json:
2238
+ print_json(result)
2239
+ return
2240
+ print_tool_result(result)
2241
+
2242
+
2243
+ def cmd_tool_batch_patch(args: argparse.Namespace) -> None:
2244
+ workspace = workspace_from_args(args)
2245
+ ensure_task_attachable(workspace, args.task)
2246
+ edits = load_batch_patch_specs(Path(args.specs_file))
2247
+ result = ToolExecutor(workspace).batch_patch(edits)
2248
+ attach_tool_to_task_if_requested(workspace, args.task, result)
2249
+ if args.json:
2250
+ print_json(result)
2251
+ return
2252
+ print_tool_result(result)
2253
+ output = result.get("output", {})
2254
+ if "applied_count" in output:
2255
+ print(f"applied_count: {output['applied_count']}")
2256
+ if output.get("rolled_back"):
2257
+ print("rolled_back: true")
2258
+
2259
+
2260
+ def load_batch_patch_specs(path: Path) -> list[dict[str, object]]:
2261
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
2262
+ edits = payload.get("edits") if isinstance(payload, dict) else payload
2263
+ if not isinstance(edits, list):
2264
+ raise ValueError("batch-patch specs file must contain a JSON array or an object with an `edits` array.")
2265
+ if not all(isinstance(edit, dict) for edit in edits):
2266
+ raise ValueError("batch-patch edits must be JSON objects.")
2267
+ return edits
2268
+
2269
+
2270
+ def cmd_tool_delete(args: argparse.Namespace) -> None:
2271
+ workspace = workspace_from_args(args)
2272
+ ensure_task_attachable(workspace, args.task)
2273
+ result = ToolExecutor(workspace).delete_file(args.path, allow_destructive=args.allow_destructive)
2274
+ attach_tool_to_task_if_requested(workspace, args.task, result)
2275
+ if args.json:
2276
+ print_json(result)
2277
+ return
2278
+ print_tool_result(result)
2279
+
2280
+
2281
+ def cmd_tool_exec(args: argparse.Namespace) -> None:
2282
+ workspace = workspace_from_args(args)
2283
+ ensure_task_attachable(workspace, args.task)
2284
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
2285
+ result = ToolExecutor(workspace).run_command(
2286
+ " ".join(command),
2287
+ allow_destructive=args.allow_destructive,
2288
+ timeout_seconds=args.timeout,
2289
+ )
2290
+ attach_tool_to_task_if_requested(workspace, args.task, result)
2291
+ if args.json:
2292
+ print_json(result)
2293
+ return
2294
+ print_tool_result(result)
2295
+ output = result.get("output", {})
2296
+ if "exit_code" in output:
2297
+ print(f"exit_code: {output['exit_code']}")
2298
+ if output.get("stdout"):
2299
+ print("stdout:")
2300
+ print(output["stdout"].rstrip())
2301
+ if output.get("stderr"):
2302
+ print("stderr:")
2303
+ print(output["stderr"].rstrip())
2304
+
2305
+
2306
+ def cmd_tool_list(args: argparse.Namespace) -> None:
2307
+ workspace = workspace_from_args(args)
2308
+ traces = ToolExecutor(workspace).list_traces()
2309
+ if not traces:
2310
+ print("no tool traces")
2311
+ return
2312
+ for trace in traces:
2313
+ status = "blocked" if trace["blocked"] else "ok" if trace["ok"] else "failed"
2314
+ print(f"{trace['id']}\t{trace['created_at']}\t{trace['tool']}\t{status}\t{trace['subject']}")
2315
+
2316
+
2317
+ def cmd_tool_show(args: argparse.Namespace) -> None:
2318
+ workspace = workspace_from_args(args)
2319
+ print_json(ToolExecutor(workspace).get_trace(args.trace_id))
2320
+
2321
+
2322
+ def cmd_task_start(args: argparse.Namespace) -> None:
2323
+ workspace = workspace_from_args(args)
2324
+ task = TaskStore(workspace).start(args.title, args.goal, with_plan=args.plan)
2325
+ print(f"task: {task['id']} active {task['title']}")
2326
+ if task.get("plan"):
2327
+ active = task["plan"]["milestones"][0]
2328
+ print(f"plan: {len(task['plan']['milestones'])} milestones active={active['id']} {active['title']}")
2329
+
2330
+
2331
+ def cmd_task_list(args: argparse.Namespace) -> None:
2332
+ workspace = workspace_from_args(args)
2333
+ tasks = TaskStore(workspace).list(status=args.status)
2334
+ if not tasks:
2335
+ print("no tasks")
2336
+ return
2337
+ for task in tasks:
2338
+ print(f"{task['id']}\t{task['status']}\tsteps={len(task['steps'])}\t{task['title']}")
2339
+
2340
+
2341
+ def cmd_task_status(args: argparse.Namespace) -> None:
2342
+ workspace = workspace_from_args(args)
2343
+ task = TaskStore(workspace).get(args.task_id)
2344
+ if args.json:
2345
+ print_json(task)
2346
+ return
2347
+ print(f"task: {task['id']}")
2348
+ print(f"title: {task['title']}")
2349
+ print(f"status: {task['status']}")
2350
+ print(f"steps: {len(task['steps'])}")
2351
+ print(
2352
+ "refs: "
2353
+ f"runs={len(task['refs']['run_traces'])} "
2354
+ f"tools={len(task['refs']['tool_traces'])} "
2355
+ f"memories={len(task['refs']['memories'])}"
2356
+ )
2357
+ if task.get("plan"):
2358
+ progress = task_plan_progress_text(task["plan"])
2359
+ active = next((item for item in task["plan"]["milestones"] if item.get("status") == "active"), None)
2360
+ print(f"plan: {progress}")
2361
+ if active:
2362
+ print(f"active: {active['id']} {active['title']}")
2363
+ if task["steps"]:
2364
+ latest = task["steps"][-1]
2365
+ print(f"latest: [{latest['kind']}] {latest['note']}")
2366
+
2367
+
2368
+ def cmd_task_brief(args: argparse.Namespace) -> None:
2369
+ workspace = workspace_from_args(args)
2370
+ brief = TaskStore(workspace).brief(args.task_id)
2371
+ if args.json:
2372
+ print_json(brief)
2373
+ return
2374
+ task = brief["task"]
2375
+ print(f"task: {task['id']} {task['status']}")
2376
+ print(f"title: {task['title']}")
2377
+ print(f"goal: {task['goal']}")
2378
+ print(f"estimated_tokens: {brief['estimated_tokens']}")
2379
+ print(f"recent_steps: {len(brief['recent_steps'])}")
2380
+ for step in brief["recent_steps"][-3:]:
2381
+ print(f"- [{step['kind']}] {step['note']}")
2382
+ if brief.get("plan"):
2383
+ plan = brief["plan"]
2384
+ progress = plan["progress"]
2385
+ active = plan.get("active_milestone")
2386
+ print(
2387
+ "plan: "
2388
+ f"{progress['completed']}/{progress['total']} completed "
2389
+ f"blocked={progress['blocked']} skipped={progress['skipped']}"
2390
+ )
2391
+ if active:
2392
+ print(f"active: {active['id']} {active['title']}")
2393
+ print(
2394
+ "linked: "
2395
+ f"memory={len(brief['linked_memory'])} "
2396
+ f"runs={len(brief['linked_run_traces'])} "
2397
+ f"tools={len(brief['linked_tool_traces'])}"
2398
+ )
2399
+
2400
+
2401
+ def cmd_task_plan(args: argparse.Namespace) -> None:
2402
+ workspace = workspace_from_args(args)
2403
+ task = TaskStore(workspace).plan(args.task_id, goal=args.goal, force=args.force)
2404
+ if args.json:
2405
+ print_json(task["plan"])
2406
+ return
2407
+ print_task_plan(task)
2408
+
2409
+
2410
+ def cmd_task_next(args: argparse.Namespace) -> None:
2411
+ workspace = workspace_from_args(args)
2412
+ checkpoint = TaskStore(workspace).next_checkpoint(args.task_id)
2413
+ if args.json:
2414
+ print_json(checkpoint)
2415
+ return
2416
+ milestone = checkpoint.get("milestone")
2417
+ print(f"task: {checkpoint['task_id']} {checkpoint['task_status']}")
2418
+ print(f"progress: {checkpoint['plan_progress']['completed']}/{checkpoint['plan_progress']['total']} completed")
2419
+ if milestone:
2420
+ print(f"next: {milestone['id']} {milestone['title']} [{milestone['status']}]")
2421
+ print(f"objective: {milestone['objective']}")
2422
+ for item in milestone.get("acceptance", []):
2423
+ print(f"- {item}")
2424
+ print(f"resume: {checkpoint['resume_prompt']}")
2425
+
2426
+
2427
+ def cmd_task_checkpoint(args: argparse.Namespace) -> None:
2428
+ workspace = workspace_from_args(args)
2429
+ task = TaskStore(workspace).checkpoint(
2430
+ args.task_id,
2431
+ args.note,
2432
+ milestone_id=args.milestone,
2433
+ status=args.status,
2434
+ )
2435
+ print(f"task: {task['id']} checkpoint added steps={len(task['steps'])}")
2436
+ if task.get("plan"):
2437
+ print(f"plan: {task_plan_progress_text(task['plan'])}")
2438
+
2439
+
2440
+ def print_task_plan(task: dict[str, Any]) -> None:
2441
+ plan = task["plan"]
2442
+ print(f"task: {task['id']}")
2443
+ print(f"objective: {plan['objective']}")
2444
+ print(f"progress: {task_plan_progress_text(plan)}")
2445
+ for milestone in plan.get("milestones", []):
2446
+ print(f"- {milestone['id']} [{milestone['status']}] {milestone['title']}")
2447
+ print(f" objective: {milestone['objective']}")
2448
+
2449
+
2450
+ def task_plan_progress_text(plan: dict[str, Any]) -> str:
2451
+ milestones = plan.get("milestones", [])
2452
+ total = len(milestones)
2453
+ completed = sum(1 for item in milestones if item.get("status") == "completed")
2454
+ blocked = sum(1 for item in milestones if item.get("status") == "blocked")
2455
+ skipped = sum(1 for item in milestones if item.get("status") == "skipped")
2456
+ return f"{completed}/{total} completed blocked={blocked} skipped={skipped}"
2457
+
2458
+
2459
+ def attach_run_to_task(workspace: Workspace, task_id: str, trace: dict[str, Any]) -> None:
2460
+ store = TaskStore(workspace)
2461
+ store.attach(task_id, "run", trace["id"])
2462
+ for record in trace.get("state", {}).get("records", []):
2463
+ store.attach(task_id, "memory", record["id"])
2464
+
2465
+
2466
+ def ensure_task_attachable(workspace: Workspace, task_id: str | None) -> None:
2467
+ if not task_id:
2468
+ return
2469
+ task = TaskStore(workspace).get(task_id)
2470
+ if task.get("status") == "completed":
2471
+ raise ValueError(f"Task is completed and cannot receive new traces: {task_id}")
2472
+
2473
+
2474
+ def ensure_resume_args(args: argparse.Namespace) -> None:
2475
+ if getattr(args, "resume", False) and not getattr(args, "task", None):
2476
+ raise ValueError("--resume requires --task <task-id>")
2477
+
2478
+
2479
+ def attach_tool_to_task_if_requested(workspace: Workspace, task_id: str | None, result: dict[str, Any]) -> None:
2480
+ if not task_id:
2481
+ return
2482
+ TaskStore(workspace).attach(task_id, "tool", result["id"])
2483
+
2484
+
2485
+ def cmd_task_step(args: argparse.Namespace) -> None:
2486
+ workspace = workspace_from_args(args)
2487
+ task = TaskStore(workspace).step(args.task_id, args.note)
2488
+ print(f"task: {task['id']} step added steps={len(task['steps'])}")
2489
+
2490
+
2491
+ def cmd_task_attach(args: argparse.Namespace) -> None:
2492
+ workspace = workspace_from_args(args)
2493
+ task = TaskStore(workspace).attach(args.task_id, args.kind, args.ref_id)
2494
+ print(f"task: {task['id']} attached {args.kind}:{args.ref_id}")
2495
+
2496
+
2497
+ def cmd_task_block(args: argparse.Namespace) -> None:
2498
+ workspace = workspace_from_args(args)
2499
+ task = TaskStore(workspace).set_status(args.task_id, "blocked", note=args.note)
2500
+ print(f"task: {task['id']} blocked")
2501
+
2502
+
2503
+ def cmd_task_complete(args: argparse.Namespace) -> None:
2504
+ workspace = workspace_from_args(args)
2505
+ task = TaskStore(workspace).set_status(args.task_id, "completed", note=args.note)
2506
+ print(f"task: {task['id']} completed")
2507
+
2508
+
2509
+ def cmd_eval_run(args: argparse.Namespace) -> None:
2510
+ workspace = workspace_from_args(args)
2511
+ report = EvalRunner(workspace).run_fixture(
2512
+ Path(args.fixture),
2513
+ default_budget=args.budget,
2514
+ default_profile=args.profile,
2515
+ save=not args.no_save,
2516
+ execute_provider=args.provider if args.execute else None,
2517
+ execute_model=args.model,
2518
+ execute_base_url=args.base_url,
2519
+ )
2520
+ if args.json:
2521
+ print_json(report)
2522
+ return
2523
+
2524
+ summary = report["summary"]
2525
+ print(f"report: {report['id']}")
2526
+ print(f"fixture: {report['fixture']}")
2527
+ print(f"tasks: {summary['task_count']}")
2528
+ print(f"profile: {args.profile}")
2529
+ print(f"avg_savings: {summary['average_savings_percent']}%")
2530
+ print(f"total_kernel_tokens: {summary['total_kernel_tokens']}")
2531
+ print(f"total_baseline_tokens: {summary['total_baseline_tokens']}")
2532
+ if summary["executed_tasks"]:
2533
+ print(f"executed_tasks: {summary['executed_tasks']}")
2534
+ print(f"execution_tokens: {summary['total_execution_tokens']}")
2535
+ if summary.get("blocked_tasks"):
2536
+ print(f"blocked_tasks: {summary['blocked_tasks']}")
2537
+ print(f"checks: {summary['passed_checks']}/{summary['total_checks']}")
2538
+ for task in report["tasks"]:
2539
+ execution = task.get("execution", {})
2540
+ execution_text = f"\texec_tokens={execution.get('total_tokens')}" if execution else ""
2541
+ print(
2542
+ f"{task['id']}\t"
2543
+ f"savings={task['savings']['percent']}%\t"
2544
+ f"kernel={task['kernel']['estimated_tokens']}\t"
2545
+ f"baseline={task['baseline']['estimated_tokens']}\t"
2546
+ f"checks={task['checks']['passed']}/{task['checks']['total']}"
2547
+ f"{execution_text}"
2548
+ )
2549
+
2550
+
2551
+ def cmd_eval_list(args: argparse.Namespace) -> None:
2552
+ workspace = workspace_from_args(args)
2553
+ reports = EvalRunner(workspace).list_reports()
2554
+ if not reports:
2555
+ print("no eval reports")
2556
+ return
2557
+ for report in reports:
2558
+ print(
2559
+ f"{report['id']}\t"
2560
+ f"{report['created_at']}\t"
2561
+ f"tasks={report['task_count']}\t"
2562
+ f"avg_savings={report['average_savings_percent']}%\t"
2563
+ f"checks={report['checks']}\t"
2564
+ f"{report['name']}"
2565
+ )
2566
+
2567
+
2568
+ def cmd_eval_show(args: argparse.Namespace) -> None:
2569
+ workspace = workspace_from_args(args)
2570
+ print_json(EvalRunner(workspace).get_report(args.report_id))
2571
+
2572
+
2573
+ def cmd_eval_cost(args: argparse.Namespace) -> None:
2574
+ workspace = workspace_from_args(args)
2575
+ cost = build_eval_cost_report(EvalRunner(workspace).get_report(args.report_id))
2576
+ if args.json:
2577
+ print_json(cost)
2578
+ return
2579
+ print(render_cost_report(cost))
2580
+
2581
+
2582
+ def cmd_eval_diff(args: argparse.Namespace) -> None:
2583
+ workspace = workspace_from_args(args)
2584
+ diff = EvalRunner(workspace).diff_reports(args.before_id, args.after_id)
2585
+ if args.json:
2586
+ print_json(diff)
2587
+ enforce_regression_gate(diff, enabled=args.fail_on_regression, label="eval diff")
2588
+ return
2589
+
2590
+ summary = diff["summary_delta"]
2591
+ print(f"before: {diff['before']['id']}")
2592
+ print(f"after: {diff['after']['id']}")
2593
+ print(f"kernel_tokens_delta: {summary['kernel_tokens']}")
2594
+ print(f"baseline_tokens_delta: {summary['baseline_tokens']}")
2595
+ print(f"savings_tokens_delta: {summary['savings_tokens']}")
2596
+ print(f"savings_percent_delta: {summary['savings_percent']}")
2597
+ print(f"checks_delta: {summary['passed_checks']}/{summary['total_checks']}")
2598
+ print(f"regressions: {len(diff['regressions'])}")
2599
+ cost_diff = diff.get("cost_diff", {})
2600
+ if cost_diff:
2601
+ hotspot = cost_diff.get("hotspot_change", {})
2602
+ weakest = cost_diff.get("weakest_savings_change", {})
2603
+ print(f"cost_regressions: {len(diff.get('cost_regressions', []))}")
2604
+ print(
2605
+ f"hotspot_delta: {hotspot.get('before_scope', '')} -> {hotspot.get('after_scope', '')} "
2606
+ f"({hotspot.get('metric_delta', 0)})"
2607
+ )
2608
+ print(
2609
+ f"weakest_savings_delta: {weakest.get('before_scope', '')} -> {weakest.get('after_scope', '')} "
2610
+ f"({weakest.get('metric_delta', 0)})"
2611
+ )
2612
+ for task in diff["tasks"]:
2613
+ if task["status"] != "changed":
2614
+ print(f"{task['id']}\t{task['status']}")
2615
+ continue
2616
+ print(
2617
+ f"{task['id']}\t"
2618
+ f"kernel_delta={task['kernel_token_delta']}\t"
2619
+ f"savings_delta={task['savings_percent_delta']}%\t"
2620
+ f"checks_delta={task['passed_check_delta']}/{task['total_check_delta']}"
2621
+ )
2622
+ enforce_regression_gate(diff, enabled=args.fail_on_regression, label="eval diff")
2623
+
2624
+
2625
+ def cmd_bench_run(args: argparse.Namespace) -> None:
2626
+ workspace = workspace_from_args(args)
2627
+ report = BenchmarkRunner(workspace).run_directory(
2628
+ Path(args.directory),
2629
+ default_budget=args.budget,
2630
+ default_profile=args.profile,
2631
+ save=not args.no_save,
2632
+ execute_provider=args.provider if args.execute else None,
2633
+ execute_model=args.model,
2634
+ execute_base_url=args.base_url,
2635
+ )
2636
+ if args.json:
2637
+ print_json(report)
2638
+ return
2639
+
2640
+ print_benchmark_report(report)
2641
+
2642
+
2643
+ def cmd_bench_gate(args: argparse.Namespace) -> None:
2644
+ workspace = workspace_from_args(args)
2645
+ runner = BenchmarkRunner(workspace)
2646
+ report = runner.run_directory(
2647
+ Path(args.directory),
2648
+ default_budget=args.budget,
2649
+ default_profile=args.profile,
2650
+ save=True,
2651
+ execute_provider=args.provider if args.execute else None,
2652
+ execute_model=args.model,
2653
+ execute_base_url=args.base_url,
2654
+ )
2655
+ baseline_result = runner.find_baseline(
2656
+ Path(args.directory),
2657
+ baseline_id=args.baseline_report,
2658
+ exclude_id=report["id"],
2659
+ )
2660
+
2661
+ if baseline_result is None:
2662
+ report_ok = benchmark_report_ok(report)
2663
+ result = {
2664
+ "status": "missing_baseline" if report_ok else "failed",
2665
+ "report": report,
2666
+ "report_ok": report_ok,
2667
+ "baseline": None,
2668
+ "baseline_match": None,
2669
+ "diff": None,
2670
+ }
2671
+ if args.json:
2672
+ print_json(result)
2673
+ else:
2674
+ print(f"report: {report['id']}")
2675
+ print(f"benchmark: {report['benchmark']}")
2676
+ print("baseline: none")
2677
+ print(f"status: {result['status']}")
2678
+ print_benchmark_check_summary(report)
2679
+ print("note: no saved benchmark report matched this directory")
2680
+ enforce_benchmark_report_gate(report, label="benchmark gate")
2681
+ if args.require_baseline:
2682
+ raise RuntimeError(f"benchmark gate could not find baseline for {Path(args.directory)}")
2683
+ return
2684
+
2685
+ baseline = baseline_result["report"]
2686
+ diff = runner.diff_reports(baseline["id"], report["id"])
2687
+ report_ok = benchmark_report_ok(report)
2688
+ result = {
2689
+ "status": "passed" if diff.get("ok", False) and report_ok else "failed",
2690
+ "report": report,
2691
+ "report_ok": report_ok,
2692
+ "baseline": benchmark_ref(baseline),
2693
+ "baseline_match": baseline_result["match"],
2694
+ "diff": diff,
2695
+ }
2696
+ if args.json:
2697
+ print_json(result)
2698
+ enforce_benchmark_report_gate(report, label="benchmark gate")
2699
+ enforce_regression_gate(diff, enabled=True, label="benchmark gate")
2700
+ return
2701
+
2702
+ print(f"report: {report['id']}")
2703
+ print(f"benchmark: {report['benchmark']}")
2704
+ print(f"baseline: {baseline['id']}")
2705
+ print(f"baseline_match: {baseline_result['match']}")
2706
+ print(f"status: {result['status']}")
2707
+ print_benchmark_check_summary(report)
2708
+ print_benchmark_diff(diff)
2709
+ enforce_benchmark_report_gate(report, label="benchmark gate")
2710
+ enforce_regression_gate(diff, enabled=True, label="benchmark gate")
2711
+
2712
+
2713
+ def cmd_bench_list(args: argparse.Namespace) -> None:
2714
+ workspace = workspace_from_args(args)
2715
+ reports = BenchmarkRunner(workspace).list_reports()
2716
+ if not reports:
2717
+ print("no benchmark reports")
2718
+ return
2719
+ for report in reports:
2720
+ print(
2721
+ f"{report['id']}\t"
2722
+ f"{report['created_at']}\t"
2723
+ f"fixtures={report['fixture_count']}\t"
2724
+ f"tasks={report['task_count']}\t"
2725
+ f"avg_savings={report['average_savings_percent']}%\t"
2726
+ f"checks={report['checks']}\t"
2727
+ f"{report['name']}"
2728
+ )
2729
+
2730
+
2731
+ def cmd_bench_show(args: argparse.Namespace) -> None:
2732
+ workspace = workspace_from_args(args)
2733
+ print_json(BenchmarkRunner(workspace).get_report(args.report_id))
2734
+
2735
+
2736
+ def cmd_bench_cost(args: argparse.Namespace) -> None:
2737
+ workspace = workspace_from_args(args)
2738
+ cost = build_benchmark_cost_report(BenchmarkRunner(workspace).get_report(args.report_id))
2739
+ if args.json:
2740
+ print_json(cost)
2741
+ return
2742
+ print(render_cost_report(cost))
2743
+
2744
+
2745
+ def cmd_bench_diff(args: argparse.Namespace) -> None:
2746
+ workspace = workspace_from_args(args)
2747
+ diff = BenchmarkRunner(workspace).diff_reports(args.before_id, args.after_id)
2748
+ if args.json:
2749
+ print_json(diff)
2750
+ enforce_regression_gate(diff, enabled=args.fail_on_regression, label="benchmark diff")
2751
+ return
2752
+
2753
+ print_benchmark_diff(diff)
2754
+ enforce_regression_gate(diff, enabled=args.fail_on_regression, label="benchmark diff")
2755
+
2756
+
2757
+ def cmd_bench_export(args: argparse.Namespace) -> None:
2758
+ workspace = workspace_from_args(args)
2759
+ output = Path(args.output) if args.output else None
2760
+ path = BenchmarkRunner(workspace).export_markdown(args.report_id, output=output)
2761
+ print(f"exported: {path}")
2762
+
2763
+
2764
+ def cmd_bench_evidence(args: argparse.Namespace) -> None:
2765
+ workspace = workspace_from_args(args)
2766
+ runner = BenchmarkRunner(workspace)
2767
+ report_ids = args.report_ids or None
2768
+ evidence = runner.evidence(report_ids, limit=args.limit)
2769
+ if args.output:
2770
+ path = runner.export_evidence_markdown(report_ids, limit=args.limit, output=Path(args.output))
2771
+ print(f"exported: {path}")
2772
+ if args.json:
2773
+ print_json(evidence)
2774
+ elif not args.output:
2775
+ print(render_benchmark_evidence_markdown(evidence).rstrip())
2776
+ if args.fail_under is not None and evidence["total_savings_percent"] < args.fail_under:
2777
+ raise SystemExit(
2778
+ f"benchmark evidence below threshold: {evidence['total_savings_percent']}% < {args.fail_under}%"
2779
+ )
2780
+
2781
+
2782
+ def cmd_trace_list(args: argparse.Namespace) -> None:
2783
+ workspace = workspace_from_args(args)
2784
+ paths = sorted(workspace.traces_dir.glob("*.json"))
2785
+ if not paths:
2786
+ print("no traces")
2787
+ return
2788
+ for path in paths:
2789
+ trace = Workspace.read_json(path)
2790
+ response = trace.get("response", {})
2791
+ print(f"{trace['id']}\t{trace['created_at']}\t{trace['provider']}\ttokens={response.get('total_tokens')}\t{trace['request']}")
2792
+
2793
+
2794
+ def cmd_trace_show(args: argparse.Namespace) -> None:
2795
+ workspace = workspace_from_args(args)
2796
+ path = workspace.traces_dir / f"{args.trace_id}.json"
2797
+ if not path.exists():
2798
+ raise KeyError(f"Unknown trace: {args.trace_id}")
2799
+ print_json(Workspace.read_json(path))
2800
+
2801
+
2802
+ def cmd_trace_verify(args: argparse.Namespace) -> None:
2803
+ workspace = workspace_from_args(args)
2804
+ path = workspace.traces_dir / f"{args.trace_id}.json"
2805
+ if not path.exists():
2806
+ raise KeyError(f"Unknown trace: {args.trace_id}")
2807
+ result = verify_trace(Workspace.read_json(path), expect_json=args.expect_json)
2808
+ print_json(result)
2809
+
2810
+
2811
+ def cmd_trace_remember(args: argparse.Namespace) -> None:
2812
+ workspace = workspace_from_args(args)
2813
+ path = workspace.traces_dir / f"{args.trace_id}.json"
2814
+ if not path.exists():
2815
+ raise KeyError(f"Unknown trace: {args.trace_id}")
2816
+ trace = Workspace.read_json(path)
2817
+ writer = StateWriter(workspace)
2818
+ if args.dry_run:
2819
+ print_json({"enabled": False, "candidates": writer.propose_from_trace(trace)})
2820
+ return
2821
+ result = writer.write_from_trace(trace)
2822
+ trace["state"] = result
2823
+ Workspace.write_json(path, trace)
2824
+ print(f"state: wrote {result['written_count']} memory record(s)")
2825
+
2826
+
2827
+ def print_policy_result(result: dict[str, Any], as_json: bool) -> None:
2828
+ if as_json:
2829
+ print_json(result)
2830
+ return
2831
+ print(f"{result['status']}: {result['kind']} {result['operation']} {result['subject']}")
2832
+ for reason in result["reasons"]:
2833
+ print(f"- {reason}")
2834
+
2835
+
2836
+ def print_tool_result(result: dict[str, Any]) -> None:
2837
+ status = "blocked" if result["blocked"] else "ok" if result["ok"] else "failed"
2838
+ print(f"{status}: {result['tool']} trace={result['id']}")
2839
+ if result.get("error"):
2840
+ print(f"error: {result['error']}")
2841
+ print(f"policy: {result['policy']['status']}")
2842
+
2843
+
2844
+ def list_agent_reports(workspace: Workspace) -> list[dict[str, Any]]:
2845
+ workspace.agent_runs_dir.mkdir(parents=True, exist_ok=True)
2846
+ reports = [Workspace.read_json(path) for path in sorted(workspace.agent_runs_dir.glob("*.json"))]
2847
+ return sorted(reports, key=lambda report: report.get("created_at", ""), reverse=True)
2848
+
2849
+
2850
+ def print_benchmark_report(report: dict[str, Any]) -> None:
2851
+ summary = report["summary"]
2852
+ print(f"report: {report['id']}")
2853
+ print(f"benchmark: {report['benchmark']}")
2854
+ print(f"fixtures: {summary['fixture_count']}")
2855
+ print(f"tasks: {summary['task_count']}")
2856
+ print(f"avg_savings: {summary['average_savings_percent']}%")
2857
+ print(f"total_kernel_tokens: {summary['total_kernel_tokens']}")
2858
+ print(f"total_baseline_tokens: {summary['total_baseline_tokens']}")
2859
+ if summary["executed_tasks"]:
2860
+ print(f"executed_tasks: {summary['executed_tasks']}")
2861
+ print(f"execution_tokens: {summary['total_execution_tokens']}")
2862
+ if summary.get("blocked_tasks"):
2863
+ print(f"blocked_tasks: {summary['blocked_tasks']}")
2864
+ print(f"checks: {summary['passed_checks']}/{summary['total_checks']}")
2865
+ for fixture in report["fixtures"]:
2866
+ fixture_summary = fixture["summary"]
2867
+ print(
2868
+ f"{Path(fixture['fixture']).name}\t"
2869
+ f"tasks={fixture_summary['task_count']}\t"
2870
+ f"avg_savings={fixture_summary['average_savings_percent']}%\t"
2871
+ f"checks={fixture_summary['passed_checks']}/{fixture_summary['total_checks']}"
2872
+ )
2873
+
2874
+
2875
+ def print_benchmark_check_summary(report: dict[str, Any]) -> None:
2876
+ summary = report.get("summary", {})
2877
+ print(f"current_checks: {summary.get('passed_checks', 0)}/{summary.get('total_checks', 0)}")
2878
+
2879
+
2880
+ def benchmark_report_ok(report: dict[str, Any]) -> bool:
2881
+ return bool(report.get("summary", {}).get("ok", False))
2882
+
2883
+
2884
+ def enforce_benchmark_report_gate(report: dict[str, Any], *, label: str) -> None:
2885
+ if benchmark_report_ok(report):
2886
+ return
2887
+ summary = report.get("summary", {})
2888
+ passed = summary.get("passed_checks", 0)
2889
+ total = summary.get("total_checks", 0)
2890
+ raise RuntimeError(f"{label} current benchmark checks failed: {passed}/{total}")
2891
+
2892
+
2893
+ def print_benchmark_diff(diff: dict[str, Any]) -> None:
2894
+ summary = diff["summary_delta"]
2895
+ print(f"before: {diff['before']['id']}")
2896
+ print(f"after: {diff['after']['id']}")
2897
+ print(f"fixtures_delta: {summary['fixtures']}")
2898
+ print(f"tasks_delta: {summary['tasks']}")
2899
+ print(f"kernel_tokens_delta: {summary['kernel_tokens']}")
2900
+ print(f"baseline_tokens_delta: {summary['baseline_tokens']}")
2901
+ print(f"savings_tokens_delta: {summary['savings_tokens']}")
2902
+ print(f"savings_percent_delta: {summary['savings_percent']}")
2903
+ print(f"checks_delta: {summary['passed_checks']}/{summary['total_checks']}")
2904
+ print(f"execution_tokens_delta: {summary['execution_tokens']}")
2905
+ print(f"regressions: {len(diff['regressions'])}")
2906
+ cost_diff = diff.get("cost_diff", {})
2907
+ if cost_diff:
2908
+ hotspot = cost_diff.get("hotspot_change", {})
2909
+ weakest = cost_diff.get("weakest_savings_change", {})
2910
+ print(f"cost_regressions: {len(diff.get('cost_regressions', []))}")
2911
+ print(
2912
+ f"hotspot_delta: {hotspot.get('before_scope', '')} -> {hotspot.get('after_scope', '')} "
2913
+ f"({hotspot.get('metric_delta', 0)})"
2914
+ )
2915
+ print(
2916
+ f"weakest_savings_delta: {weakest.get('before_scope', '')} -> {weakest.get('after_scope', '')} "
2917
+ f"({weakest.get('metric_delta', 0)})"
2918
+ )
2919
+ for fixture in diff["fixtures"]:
2920
+ if fixture["status"] != "changed":
2921
+ print(f"{fixture['fixture']}\t{fixture['status']}")
2922
+ continue
2923
+ fixture_summary = fixture["summary_delta"]
2924
+ print(
2925
+ f"{fixture['fixture']}\t"
2926
+ f"kernel_delta={fixture_summary['kernel_tokens']}\t"
2927
+ f"savings_delta={fixture_summary['savings_percent']}%\t"
2928
+ f"checks_delta={fixture_summary['passed_checks']}/{fixture_summary['total_checks']}\t"
2929
+ f"regressions={len(fixture['regressions'])}"
2930
+ )
2931
+
2932
+
2933
+ def enforce_regression_gate(diff: dict[str, Any], *, enabled: bool, label: str) -> None:
2934
+ if not enabled or diff.get("ok", True):
2935
+ return
2936
+ reasons: list[str] = []
2937
+ regressions = diff.get("regressions", [])
2938
+ cost_regressions = diff.get("cost_regressions", [])
2939
+ if regressions:
2940
+ reasons.append(f"{len(regressions)} behavior regression(s)")
2941
+ if cost_regressions:
2942
+ reasons.append(f"{len(cost_regressions)} cost regression(s)")
2943
+ if not reasons:
2944
+ reasons.append("regressions detected")
2945
+ raise RuntimeError(f"{label} found regressions: {', '.join(reasons)}")
2946
+
2947
+
2948
+ def print_json(data: dict[str, Any]) -> None:
2949
+ print(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True))
2950
+
2951
+
2952
+ if __name__ == "__main__":
2953
+ main()