agentkernel-cli 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.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Self-improvement seam (design §13, Phase 7).
|
|
2
|
+
|
|
3
|
+
The kernel records structured telemetry from turn one. ``SelfImprover`` reads a
|
|
4
|
+
session trace, asks the configured provider to suggest one concise rule or
|
|
5
|
+
system-prompt addition, and writes the result as a markdown note in
|
|
6
|
+
``.agentkernel/improvements``. It is intentionally lightweight — enough to close
|
|
7
|
+
the loop, with room for a future richer analyzer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from agentkernel.providers import Provider
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_REFLECTION_SYSTEM_PROMPT = (
|
|
23
|
+
"You are a self-improvement analyst for an agent kernel. "
|
|
24
|
+
"Given a session trace, propose one concise rule, instruction, or "
|
|
25
|
+
"system-prompt addition that would improve future runs. "
|
|
26
|
+
"Return only the rule text followed by a brief rationale."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Improvement:
|
|
32
|
+
suggestion: str
|
|
33
|
+
rule: str
|
|
34
|
+
trace_path: str
|
|
35
|
+
output_path: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_trace(path: Path) -> list[dict[str, Any]]:
|
|
39
|
+
"""Load the JSONL trace written by ``JsonlTelemetry``."""
|
|
40
|
+
records: list[dict[str, Any]] = []
|
|
41
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
42
|
+
for line in handle:
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
records.append(json.loads(line))
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
continue
|
|
50
|
+
return records
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _summarize_trace(records: list[dict[str, Any]]) -> str:
|
|
54
|
+
"""Build a compact textual summary suitable for an LLM prompt."""
|
|
55
|
+
lines: list[str] = []
|
|
56
|
+
for i, record in enumerate(records):
|
|
57
|
+
lines.append(f"--- turn {i} ---")
|
|
58
|
+
lines.append(f"model: {record.get('model', 'unknown')}")
|
|
59
|
+
lines.append(f"stop_reason: {record.get('stop_reason', 'unknown')}")
|
|
60
|
+
for call in record.get("tool_calls", []):
|
|
61
|
+
lines.append(
|
|
62
|
+
f"tool: {call.get('name')} approved={call.get('approved')} "
|
|
63
|
+
f"error={call.get('is_error')}"
|
|
64
|
+
)
|
|
65
|
+
# Note: the redacted JSONL trace (design §12) does not carry assistant
|
|
66
|
+
# text or raw tool args, so reflection works from the structural signal
|
|
67
|
+
# — tools used, errors, stop reasons, and token/cost figures.
|
|
68
|
+
cost = record.get("estimated_cost_usd")
|
|
69
|
+
if cost is not None:
|
|
70
|
+
lines.append(f"cost_usd: {cost}")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SelfImprover:
|
|
75
|
+
"""Reflect on a completed session and emit a proposed improvement note."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, provider: Provider, output_dir: str | Path) -> None:
|
|
78
|
+
self.provider = provider
|
|
79
|
+
self.output_dir = Path(output_dir)
|
|
80
|
+
|
|
81
|
+
def analyze_trace(self, trace_path: str | Path) -> Improvement:
|
|
82
|
+
trace_path = Path(trace_path)
|
|
83
|
+
records = _load_trace(trace_path)
|
|
84
|
+
summary = _summarize_trace(records)
|
|
85
|
+
prompt = (
|
|
86
|
+
f"Session trace summary:\n{summary}\n\n"
|
|
87
|
+
"Propose one concise improvement rule for the agent. "
|
|
88
|
+
"Start with the rule, then a one-line rationale."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
from agentkernel.types import Message
|
|
92
|
+
|
|
93
|
+
messages = [Message(role="user", content=prompt)]
|
|
94
|
+
response = self.provider.complete(
|
|
95
|
+
messages,
|
|
96
|
+
[],
|
|
97
|
+
max_tokens=1024,
|
|
98
|
+
system=_REFLECTION_SYSTEM_PROMPT,
|
|
99
|
+
)
|
|
100
|
+
suggestion = response.message.content.strip()
|
|
101
|
+
rule = suggestion.splitlines()[0] if suggestion else suggestion
|
|
102
|
+
|
|
103
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
|
105
|
+
output_path = self.output_dir / f"improvement-{timestamp}.md"
|
|
106
|
+
output_path.write_text(
|
|
107
|
+
f"---\n"
|
|
108
|
+
f"type: improvement\n"
|
|
109
|
+
f"trace: {trace_path}\n"
|
|
110
|
+
f"timestamp: {timestamp}\n"
|
|
111
|
+
f"---\n\n"
|
|
112
|
+
f"{suggestion}\n",
|
|
113
|
+
encoding="utf-8",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return Improvement(
|
|
117
|
+
suggestion=suggestion,
|
|
118
|
+
rule=rule,
|
|
119
|
+
trace_path=str(trace_path),
|
|
120
|
+
output_path=str(output_path),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def latest_trace(self, log_dir: str | Path) -> Path | None:
|
|
124
|
+
"""Return the most recent ``*.jsonl`` trace in ``log_dir``."""
|
|
125
|
+
log_dir = Path(log_dir)
|
|
126
|
+
if not log_dir.is_dir():
|
|
127
|
+
return None
|
|
128
|
+
traces = sorted(
|
|
129
|
+
(p for p in log_dir.iterdir() if p.suffix == ".jsonl"),
|
|
130
|
+
key=lambda p: p.stat().st_mtime,
|
|
131
|
+
reverse=True,
|
|
132
|
+
)
|
|
133
|
+
return traces[0] if traces else None
|
agentkernel/insights.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Usage insights from session traces (design §18.7).
|
|
2
|
+
|
|
3
|
+
Aggregates the JSONL telemetry under ``log_dir`` — the stable per-turn schema
|
|
4
|
+
from §12 — into a usage/cost/tool-frequency report. Pure reading: it never calls
|
|
5
|
+
a provider, so it works offline and costs nothing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ModelStats:
|
|
19
|
+
turns: int = 0
|
|
20
|
+
input_tokens: int = 0
|
|
21
|
+
output_tokens: int = 0
|
|
22
|
+
cost: float = 0.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolStats:
|
|
27
|
+
calls: int = 0
|
|
28
|
+
errors: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Insights:
|
|
33
|
+
sessions: int = 0
|
|
34
|
+
turns: int = 0
|
|
35
|
+
input_tokens: int = 0
|
|
36
|
+
output_tokens: int = 0
|
|
37
|
+
cache_read_tokens: int = 0
|
|
38
|
+
cache_write_tokens: int = 0
|
|
39
|
+
total_cost: float = 0.0
|
|
40
|
+
compactions: int = 0
|
|
41
|
+
models: dict[str, ModelStats] = field(default_factory=lambda: defaultdict(ModelStats))
|
|
42
|
+
tools: dict[str, ToolStats] = field(default_factory=lambda: defaultdict(ToolStats))
|
|
43
|
+
models_without_price: set[str] = field(default_factory=set)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _within(ts: str, cutoff: datetime | None) -> bool:
|
|
47
|
+
if cutoff is None:
|
|
48
|
+
return True
|
|
49
|
+
try:
|
|
50
|
+
return datetime.fromisoformat(ts) >= cutoff
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return True # undated records are kept rather than silently dropped
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def aggregate_traces(log_dir: str | Path, *, days: int | None = None) -> Insights:
|
|
56
|
+
"""Aggregate every ``*.jsonl`` trace under ``log_dir`` into one ``Insights``."""
|
|
57
|
+
directory = Path(log_dir)
|
|
58
|
+
cutoff = datetime.now(UTC) - timedelta(days=days) if days else None
|
|
59
|
+
ins = Insights()
|
|
60
|
+
if not directory.is_dir():
|
|
61
|
+
return ins
|
|
62
|
+
|
|
63
|
+
for trace in sorted(directory.glob("*.jsonl")):
|
|
64
|
+
counted_session = False
|
|
65
|
+
for line in trace.read_text(encoding="utf-8").splitlines():
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if not line:
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
rec = json.loads(line)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
if not _within(rec.get("ts", ""), cutoff):
|
|
74
|
+
continue
|
|
75
|
+
if not counted_session:
|
|
76
|
+
ins.sessions += 1
|
|
77
|
+
counted_session = True
|
|
78
|
+
|
|
79
|
+
ins.turns += 1
|
|
80
|
+
ins.input_tokens += rec.get("input_tokens", 0)
|
|
81
|
+
ins.output_tokens += rec.get("output_tokens", 0)
|
|
82
|
+
ins.cache_read_tokens += rec.get("cache_read_tokens", 0)
|
|
83
|
+
ins.cache_write_tokens += rec.get("cache_write_tokens", 0)
|
|
84
|
+
if rec.get("compaction"):
|
|
85
|
+
ins.compactions += 1
|
|
86
|
+
|
|
87
|
+
model = rec.get("model", "unknown")
|
|
88
|
+
ms = ins.models[model]
|
|
89
|
+
ms.turns += 1
|
|
90
|
+
ms.input_tokens += rec.get("input_tokens", 0)
|
|
91
|
+
ms.output_tokens += rec.get("output_tokens", 0)
|
|
92
|
+
|
|
93
|
+
cost = rec.get("estimated_cost_usd")
|
|
94
|
+
if cost is None:
|
|
95
|
+
ins.models_without_price.add(model)
|
|
96
|
+
else:
|
|
97
|
+
ins.total_cost += cost
|
|
98
|
+
ms.cost += cost
|
|
99
|
+
|
|
100
|
+
for call in rec.get("tool_calls", []) or []:
|
|
101
|
+
ts_ = ins.tools[call.get("name", "?")]
|
|
102
|
+
ts_.calls += 1
|
|
103
|
+
if call.get("is_error"):
|
|
104
|
+
ts_.errors += 1
|
|
105
|
+
return ins
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_insights(ins: Insights, *, days: int | None = None) -> str:
|
|
109
|
+
"""Render an ``Insights`` as a readable text report."""
|
|
110
|
+
scope = f" (last {days} day(s))" if days else ""
|
|
111
|
+
if ins.turns == 0:
|
|
112
|
+
return f"No trace records found{scope}."
|
|
113
|
+
|
|
114
|
+
lines = [f"Usage insights{scope}", ""]
|
|
115
|
+
lines.append(f" sessions: {ins.sessions}")
|
|
116
|
+
lines.append(f" turns: {ins.turns}")
|
|
117
|
+
lines.append(
|
|
118
|
+
f" tokens: in={ins.input_tokens:,} out={ins.output_tokens:,} "
|
|
119
|
+
f"cache_read={ins.cache_read_tokens:,} cache_write={ins.cache_write_tokens:,}"
|
|
120
|
+
)
|
|
121
|
+
cost_note = "" if not ins.models_without_price else (
|
|
122
|
+
f" (excludes {len(ins.models_without_price)} model(s) with no price table)"
|
|
123
|
+
)
|
|
124
|
+
lines.append(f" est. cost: ${ins.total_cost:.4f}{cost_note}")
|
|
125
|
+
lines.append(f" compactions: {ins.compactions}")
|
|
126
|
+
|
|
127
|
+
lines.append("")
|
|
128
|
+
lines.append("By model:")
|
|
129
|
+
for name, ms in sorted(ins.models.items(), key=lambda kv: kv[1].turns, reverse=True):
|
|
130
|
+
lines.append(
|
|
131
|
+
f" {name}: {ms.turns} turns, in={ms.input_tokens:,} out={ms.output_tokens:,}, "
|
|
132
|
+
f"${ms.cost:.4f}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if ins.tools:
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append("Tool usage (most used first):")
|
|
138
|
+
for name, ts_ in sorted(ins.tools.items(), key=lambda kv: kv[1].calls, reverse=True):
|
|
139
|
+
err = f", {ts_.errors} error(s)" if ts_.errors else ""
|
|
140
|
+
lines.append(f" {name}: {ts_.calls} call(s){err}")
|
|
141
|
+
return "\n".join(lines)
|
agentkernel/kanban.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""A lightweight work-queue board for multi-agent coordination (design §18.3).
|
|
2
|
+
|
|
3
|
+
A durable JSON board of tasks that a long mission can fan out across — the parent
|
|
4
|
+
(or a human) files tasks, and workers (often spawned sub-agents) claim, work, and
|
|
5
|
+
complete or block them. Deliberately "lite": one JSON file, whole-file
|
|
6
|
+
read-modify-write, no daemon or dispatcher. For heavy multi-worker contention a
|
|
7
|
+
real SQLite board (cf. Hermes) would be the next step; this covers the common
|
|
8
|
+
case with a fraction of the surface area.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
STATUSES = ("todo", "in_progress", "done", "blocked")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _now() -> str:
|
|
23
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Task:
|
|
28
|
+
id: str
|
|
29
|
+
title: str
|
|
30
|
+
status: str = "todo"
|
|
31
|
+
assignee: str | None = None
|
|
32
|
+
notes: list[str] = field(default_factory=list)
|
|
33
|
+
created: str = ""
|
|
34
|
+
updated: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Board:
|
|
38
|
+
"""JSON-backed kanban board. Each mutation rewrites the whole file."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, path: str | Path) -> None:
|
|
41
|
+
self._path = Path(path)
|
|
42
|
+
|
|
43
|
+
# --- persistence ------------------------------------------------------
|
|
44
|
+
def _read(self) -> list[Task]:
|
|
45
|
+
if not self._path.is_file():
|
|
46
|
+
return []
|
|
47
|
+
try:
|
|
48
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
49
|
+
except (json.JSONDecodeError, OSError):
|
|
50
|
+
return []
|
|
51
|
+
return [Task(**t) for t in raw if isinstance(t, dict)]
|
|
52
|
+
|
|
53
|
+
def _write(self, tasks: list[Task]) -> None:
|
|
54
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self._path.write_text(
|
|
56
|
+
json.dumps([asdict(t) for t in tasks], indent=2), encoding="utf-8"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _update(self, task_id: str, mutate) -> Task | None:
|
|
60
|
+
tasks = self._read()
|
|
61
|
+
for t in tasks:
|
|
62
|
+
if t.id == task_id:
|
|
63
|
+
mutate(t)
|
|
64
|
+
t.updated = _now()
|
|
65
|
+
self._write(tasks)
|
|
66
|
+
return t
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# --- operations -------------------------------------------------------
|
|
70
|
+
def list(self, status: str | None = None) -> list[Task]:
|
|
71
|
+
tasks = self._read()
|
|
72
|
+
return [t for t in tasks if status is None or t.status == status]
|
|
73
|
+
|
|
74
|
+
def get(self, task_id: str) -> Task | None:
|
|
75
|
+
return next((t for t in self._read() if t.id == task_id), None)
|
|
76
|
+
|
|
77
|
+
def add(self, title: str) -> Task:
|
|
78
|
+
task = Task(id=uuid.uuid4().hex[:8], title=title.strip(), created=_now(), updated=_now())
|
|
79
|
+
tasks = self._read()
|
|
80
|
+
tasks.append(task)
|
|
81
|
+
self._write(tasks)
|
|
82
|
+
return task
|
|
83
|
+
|
|
84
|
+
def claim(self, task_id: str, assignee: str) -> Task | None:
|
|
85
|
+
def _claim(t: Task) -> None:
|
|
86
|
+
t.assignee = assignee
|
|
87
|
+
t.status = "in_progress"
|
|
88
|
+
return self._update(task_id, _claim)
|
|
89
|
+
|
|
90
|
+
def complete(self, task_id: str) -> Task | None:
|
|
91
|
+
return self._update(task_id, lambda t: setattr(t, "status", "done"))
|
|
92
|
+
|
|
93
|
+
def block(self, task_id: str, reason: str) -> Task | None:
|
|
94
|
+
def _block(t: Task) -> None:
|
|
95
|
+
t.status = "blocked"
|
|
96
|
+
if reason:
|
|
97
|
+
t.notes.append(f"blocked: {reason}")
|
|
98
|
+
return self._update(task_id, _block)
|
|
99
|
+
|
|
100
|
+
def comment(self, task_id: str, text: str) -> Task | None:
|
|
101
|
+
return self._update(task_id, lambda t: t.notes.append(text))
|
|
102
|
+
|
|
103
|
+
def next_todo(self) -> Task | None:
|
|
104
|
+
"""The first unclaimed task, for a worker pulling work off the board."""
|
|
105
|
+
return next((t for t in self._read() if t.status == "todo"), None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_MARKS = {"todo": "[ ]", "in_progress": "[~]", "done": "[x]", "blocked": "[!]"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_task(t: Task) -> str:
|
|
112
|
+
mark = _MARKS.get(t.status, "[ ]")
|
|
113
|
+
who = f" @{t.assignee}" if t.assignee else ""
|
|
114
|
+
return f"{mark} {t.id}{who} {t.title}"
|