taskops-cli 0.2.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.
- taskops/__init__.py +39 -0
- taskops/_clock.py +32 -0
- taskops/_errors.py +160 -0
- taskops/_ids.py +63 -0
- taskops/_types.py +106 -0
- taskops/_version.py +7 -0
- taskops/assets/GUIDE.md +220 -0
- taskops/contracts/__init__.py +96 -0
- taskops/contracts/_fields.py +113 -0
- taskops/contracts/actor.py +30 -0
- taskops/contracts/board.py +142 -0
- taskops/contracts/commit.py +36 -0
- taskops/contracts/day.py +150 -0
- taskops/contracts/dep.py +23 -0
- taskops/contracts/event.py +58 -0
- taskops/contracts/gitstate.py +45 -0
- taskops/contracts/index.py +37 -0
- taskops/contracts/lease.py +43 -0
- taskops/contracts/log.py +59 -0
- taskops/contracts/remote.py +48 -0
- taskops/contracts/results.py +84 -0
- taskops/contracts/task.py +94 -0
- taskops/contracts/tools.py +110 -0
- taskops/contracts/wire.py +60 -0
- taskops/engine/__init__.py +34 -0
- taskops/engine/_blocks.py +48 -0
- taskops/engine/_briefs.py +92 -0
- taskops/engine/_chunks.py +66 -0
- taskops/engine/_closed.py +65 -0
- taskops/engine/_entries.py +126 -0
- taskops/engine/_events.py +55 -0
- taskops/engine/_opened.py +51 -0
- taskops/engine/_process.py +80 -0
- taskops/engine/_prompts.py +98 -0
- taskops/engine/_stream.py +129 -0
- taskops/engine/activity.py +94 -0
- taskops/engine/bus.py +42 -0
- taskops/engine/commitline.py +110 -0
- taskops/engine/day.py +142 -0
- taskops/engine/diffstat.py +63 -0
- taskops/engine/gitio.py +108 -0
- taskops/engine/gitstate.py +96 -0
- taskops/engine/history.py +76 -0
- taskops/engine/identity.py +84 -0
- taskops/engine/log.py +68 -0
- taskops/engine/machine.py +160 -0
- taskops/engine/narrate.py +91 -0
- taskops/engine/project.py +57 -0
- taskops/engine/replay.py +142 -0
- taskops/engine/reports.py +67 -0
- taskops/engine/scheduler.py +135 -0
- taskops/engine/transcript.py +127 -0
- taskops/engine/wire.py +92 -0
- taskops/engine/worker.py +115 -0
- taskops/py.typed +0 -0
- taskops/render/__init__.py +40 -0
- taskops/render/_closed_days.py +64 -0
- taskops/render/_dossier.py +68 -0
- taskops/render/_opened.py +64 -0
- taskops/render/_sections.py +51 -0
- taskops/render/_tasklist.py +72 -0
- taskops/render/_text.py +74 -0
- taskops/render/_verbatim.py +65 -0
- taskops/render/ansi.py +92 -0
- taskops/render/board.py +55 -0
- taskops/render/day.py +102 -0
- taskops/render/dispatch.py +104 -0
- taskops/render/inbox.py +27 -0
- taskops/render/log.py +47 -0
- taskops/render/recover.py +64 -0
- taskops/render/report.py +51 -0
- taskops/render/reports.py +57 -0
- taskops/render/results.py +96 -0
- taskops/render/session.py +75 -0
- taskops/render/task.py +88 -0
- taskops/render/tasklist.py +65 -0
- taskops/storage/__init__.py +36 -0
- taskops/storage/_ddl.py +78 -0
- taskops/storage/_delivered.py +51 -0
- taskops/storage/_deps.py +69 -0
- taskops/storage/_events.py +127 -0
- taskops/storage/_leases.py +108 -0
- taskops/storage/_rows.py +90 -0
- taskops/storage/_tasks.py +124 -0
- taskops/storage/locate.py +76 -0
- taskops/storage/schema.py +66 -0
- taskops/storage/store.py +120 -0
- taskops/storage/sync.py +139 -0
- taskops/transports/__init__.py +6 -0
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +3 -0
- taskops/transports/cli/commands/_digest.py +64 -0
- taskops/transports/cli/commands/_serve_init.py +65 -0
- taskops/transports/cli/commands/_shared.py +50 -0
- taskops/transports/cli/commands/_tasks_args.py +112 -0
- taskops/transports/cli/commands/_window.py +45 -0
- taskops/transports/cli/commands/ask.py +27 -0
- taskops/transports/cli/commands/dispatch.py +43 -0
- taskops/transports/cli/commands/init.py +48 -0
- taskops/transports/cli/commands/log.py +22 -0
- taskops/transports/cli/commands/plan.py +43 -0
- taskops/transports/cli/commands/pushpull.py +53 -0
- taskops/transports/cli/commands/recover.py +30 -0
- taskops/transports/cli/commands/remote.py +56 -0
- taskops/transports/cli/commands/report.py +82 -0
- taskops/transports/cli/commands/run_.py +60 -0
- taskops/transports/cli/commands/serve.py +74 -0
- taskops/transports/cli/commands/sync.py +22 -0
- taskops/transports/cli/commands/tasks.py +79 -0
- taskops/transports/cli/commands/ui.py +72 -0
- taskops/transports/cli/commands/update.py +25 -0
- taskops/transports/cli/main.py +85 -0
- taskops/transports/hooks/__init__.py +15 -0
- taskops/transports/hooks/__main__.py +63 -0
- taskops/transports/hooks/_args.py +28 -0
- taskops/transports/hooks/claude.py +78 -0
- taskops/transports/hooks/commit.py +61 -0
- taskops/transports/hooks/events.py +107 -0
- taskops/transports/hooks/record.py +55 -0
- taskops/transports/http/__init__.py +21 -0
- taskops/transports/http/_handler.py +132 -0
- taskops/transports/http/_wire.py +79 -0
- taskops/transports/http/_wsframes.py +116 -0
- taskops/transports/http/agentapi.py +69 -0
- taskops/transports/http/api.py +122 -0
- taskops/transports/http/exchange.py +80 -0
- taskops/transports/http/live.py +160 -0
- taskops/transports/http/policy.py +110 -0
- taskops/transports/http/projects.py +116 -0
- taskops/transports/http/reports.py +75 -0
- taskops/transports/http/router.py +90 -0
- taskops/transports/http/server.py +50 -0
- taskops/transports/http/static.py +86 -0
- taskops/transports/http/ui/app.js +59 -0
- taskops/transports/http/ui/index.html +23 -0
- taskops/transports/http/ui/style.css +1 -0
- taskops/transports/http/websocket.py +60 -0
- taskops/transports/mcp/__init__.py +19 -0
- taskops/transports/mcp/__main__.py +7 -0
- taskops/transports/mcp/_descriptions.py +102 -0
- taskops/transports/mcp/_reads.py +82 -0
- taskops/transports/mcp/_writes.py +72 -0
- taskops/transports/mcp/answers.py +44 -0
- taskops/transports/mcp/arguments.py +104 -0
- taskops/transports/mcp/dispatch.py +47 -0
- taskops/transports/mcp/protocol.py +83 -0
- taskops/transports/mcp/schema.py +50 -0
- taskops/transports/mcp/server.py +49 -0
- taskops/transports/mcp/tools.py +66 -0
- taskops/usecases/__init__.py +56 -0
- taskops/usecases/_entry.py +84 -0
- taskops/usecases/_facts.py +49 -0
- taskops/usecases/_freeing.py +93 -0
- taskops/usecases/_gitignore.py +94 -0
- taskops/usecases/_mirroring.py +57 -0
- taskops/usecases/_narrating.py +77 -0
- taskops/usecases/_project.py +67 -0
- taskops/usecases/_range.py +102 -0
- taskops/usecases/_reasons.py +54 -0
- taskops/usecases/_remotefile.py +62 -0
- taskops/usecases/_reportsync.py +108 -0
- taskops/usecases/_routing.py +91 -0
- taskops/usecases/_wireclient.py +141 -0
- taskops/usecases/ask.py +41 -0
- taskops/usecases/claim.py +104 -0
- taskops/usecases/dispatch.py +160 -0
- taskops/usecases/dossier.py +155 -0
- taskops/usecases/edit.py +71 -0
- taskops/usecases/exchange.py +86 -0
- taskops/usecases/feed.py +138 -0
- taskops/usecases/guard.py +122 -0
- taskops/usecases/hooks.py +127 -0
- taskops/usecases/index.py +62 -0
- taskops/usecases/ingest.py +60 -0
- taskops/usecases/log.py +134 -0
- taskops/usecases/narration.py +91 -0
- taskops/usecases/plan.py +128 -0
- taskops/usecases/pushpull.py +119 -0
- taskops/usecases/recover.py +84 -0
- taskops/usecases/remote.py +78 -0
- taskops/usecases/report.py +87 -0
- taskops/usecases/reportfile.py +106 -0
- taskops/usecases/session.py +134 -0
- taskops/usecases/setup.py +92 -0
- taskops/usecases/sync.py +78 -0
- taskops/usecases/update.py +123 -0
- taskops/usecases/view.py +112 -0
- taskops_cli-0.2.0.dist-info/METADATA +291 -0
- taskops_cli-0.2.0.dist-info/RECORD +193 -0
- taskops_cli-0.2.0.dist-info/WHEEL +5 -0
- taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
taskops/engine/wire.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The EPHEMERAL channel: things a watcher wants to see, that nobody should store.
|
|
2
|
+
|
|
3
|
+
Same shape as the event bus and deliberately a different object. `BUS` carries
|
|
4
|
+
`Event`s, and everything downstream of it — the cursor read, the export, the commit —
|
|
5
|
+
treats what it sees as a fact worth keeping. A narration delta is not that: it is
|
|
6
|
+
one of thousands of prose fragments per report, useful for the two minutes somebody
|
|
7
|
+
is watching the model write and worthless afterwards.
|
|
8
|
+
|
|
9
|
+
Publishing it here rather than there is what keeps `events.jsonl` readable as a diff.
|
|
10
|
+
The FILE is the durable copy; this is the window.
|
|
11
|
+
|
|
12
|
+
In-process only, like the bus, and for once that is not a limitation: the digest runs
|
|
13
|
+
inside the studio's own `ThreadingHTTPServer`, so the thread that publishes and the
|
|
14
|
+
thread parked in the live feed are the same process by construction.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Callable, Generic, TypeVar
|
|
21
|
+
|
|
22
|
+
from ..contracts import WireMessage
|
|
23
|
+
|
|
24
|
+
__all__ = ["Broadcast", "WIRE", "is_wire"]
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Broadcast(Generic[T]):
|
|
30
|
+
"""Publish/subscribe over a list, with a lock and no queue.
|
|
31
|
+
|
|
32
|
+
Synchronous dispatch: a listener runs on the publisher's thread, which keeps a
|
|
33
|
+
dropped message impossible for in-process subscribers and puts the cost of a slow
|
|
34
|
+
listener where it can be seen. Every listener here does one thing — hand the item
|
|
35
|
+
to its own outbound queue — which is the shape this requires.
|
|
36
|
+
|
|
37
|
+
`subscribe` returns the way to UNDO itself rather than exposing a `remove`: a
|
|
38
|
+
caller cannot hold a subscription it has no handle to, and a websocket that closes
|
|
39
|
+
cannot forget which listener was its own.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._listeners: list[Callable[[T], None]] = []
|
|
45
|
+
|
|
46
|
+
def subscribe(self, listener: Callable[[T], None]) -> Callable[[], None]:
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._listeners.append(listener)
|
|
49
|
+
|
|
50
|
+
def cancel() -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
if listener in self._listeners:
|
|
53
|
+
self._listeners.remove(listener)
|
|
54
|
+
|
|
55
|
+
return cancel
|
|
56
|
+
|
|
57
|
+
def publish(self, item: T) -> None:
|
|
58
|
+
"""Hand it to every listener. One that raises does not stop the rest.
|
|
59
|
+
|
|
60
|
+
A subscriber is a UI connection, and a browser that vanished mid-write must not
|
|
61
|
+
be able to abort the work that was publishing to it.
|
|
62
|
+
"""
|
|
63
|
+
with self._lock:
|
|
64
|
+
listeners = tuple(self._listeners)
|
|
65
|
+
for listener in listeners:
|
|
66
|
+
try:
|
|
67
|
+
listener(item)
|
|
68
|
+
except Exception: # noqa: BLE001 — a UI must not break a write
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
def __len__(self) -> int:
|
|
72
|
+
with self._lock:
|
|
73
|
+
return len(self._listeners)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
WIRE: Broadcast[WireMessage] = Broadcast()
|
|
77
|
+
"""The process-wide ephemeral channel. A module-level singleton for the same reason
|
|
78
|
+
`BUS` is: threading it through every use case to serve one optional subscriber, which
|
|
79
|
+
is always the same object — whatever is streaming to browsers."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_wire(item: object) -> bool:
|
|
83
|
+
"""Whether a thing off the feed is an ephemeral frame rather than an `Event`.
|
|
84
|
+
|
|
85
|
+
Structural, because both are plain dicts on the wire and the feed yields them down
|
|
86
|
+
one channel. `label` and `text` together are what an `Event` never has — it carries
|
|
87
|
+
`id`, `task` and `body` — so this cannot mistake one for the other.
|
|
88
|
+
|
|
89
|
+
It lives in the engine rather than in `contracts`, where the types are, because
|
|
90
|
+
layer 1 holds no logic at all; transports reach it through `usecases`.
|
|
91
|
+
"""
|
|
92
|
+
return isinstance(item, dict) and "label" in item and "text" in item
|
taskops/engine/worker.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""What to launch for one card, and what to tell it. The piece that makes a fleet real.
|
|
2
|
+
|
|
3
|
+
Everything else in taskops coordinates agents somebody else started. This starts them — the difference
|
|
4
|
+
between "a hundred agents could work in parallel" and a human opening a hundred terminals.
|
|
5
|
+
|
|
6
|
+
The mechanics of getting a process running live in `_process`; what is decided here is the command,
|
|
7
|
+
the tools it may use, the identity it runs as, and the prompt.
|
|
8
|
+
|
|
9
|
+
**`$TASKOPS_ROOT` is the load-bearing line.** A worker runs in a `git worktree`, which has no
|
|
10
|
+
`.taskops/` of its own — so without it a worker would resolve a fresh empty project inside its own
|
|
11
|
+
directory and coordinate with nobody. One board, N working trees.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..contracts import Task
|
|
19
|
+
from ._briefs import brief_for, prompt_for
|
|
20
|
+
from ._process import make_worktree, spawn
|
|
21
|
+
from .identity import ENV_ACTOR
|
|
22
|
+
from .scheduler import branch_for
|
|
23
|
+
|
|
24
|
+
__all__ = ["Launched", "launch", "prepare", "worktree_for", "brief_for", "prompt_for",
|
|
25
|
+
"WORKERS_DIR", "TREES_DIR", "TOOLS", "DROPPED_ENV"]
|
|
26
|
+
|
|
27
|
+
DROPPED_ENV = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL")
|
|
28
|
+
"""API credentials the worker does NOT inherit. The money rule, stated as a constant.
|
|
29
|
+
|
|
30
|
+
The `claude` CLI prefers an exported key over the logged-in subscription, so a developer who has
|
|
31
|
+
one in their shell was silently billing every dispatched worker per token — while the plan they
|
|
32
|
+
already pay for sat unused. A background agent nobody is watching must not be able to spend money
|
|
33
|
+
the caller did not choose to spend, and the subscription login is the default everybody expects.
|
|
34
|
+
|
|
35
|
+
`ANTHROPIC_BASE_URL` goes with them: pointing a worker at a proxy or a gateway is the same class of
|
|
36
|
+
surprise, decided by whoever exported a variable years ago rather than by whoever typed the command.
|
|
37
|
+
`taskops run --use-api-key` is the way to ask for the other mode, out loud."""
|
|
38
|
+
|
|
39
|
+
WORKERS_DIR = ".taskops/workers"
|
|
40
|
+
"""Where a worker's log goes. Under `.taskops/` so one gitignore rule covers it."""
|
|
41
|
+
|
|
42
|
+
TREES_DIR = ".taskops/trees"
|
|
43
|
+
"""Where the worktrees go. INSIDE the repository but gitignored, which is deliberate: next to the
|
|
44
|
+
project they are easy to find and easy to delete, and somewhere under /tmp an interrupted worker's
|
|
45
|
+
uncommitted work disappears on the next reboot."""
|
|
46
|
+
|
|
47
|
+
TOOLS = ("Read", "Write", "Edit", "Bash", "Glob", "Grep", "TodoWrite", "mcp__taskops")
|
|
48
|
+
"""What a dispatched worker may use. An ALLOWlist, so a new tool is unavailable until somebody adds
|
|
49
|
+
it here — the right direction for a process nobody is watching.
|
|
50
|
+
|
|
51
|
+
`mcp__taskops` is the whole point: a worker that cannot call taskops cannot claim, comment or close.
|
|
52
|
+
No `WebFetch` and no `WebSearch` — a background agent reaching the network is a decision for whoever
|
|
53
|
+
dispatches it, not a default.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Launched:
|
|
58
|
+
"""A worker that was started, and where to look at it."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, *, actor: str, task: str, pid: int, tree: Path, log: Path,
|
|
61
|
+
branch: str, brief: str = "") -> None:
|
|
62
|
+
self.actor = actor
|
|
63
|
+
self.task = task
|
|
64
|
+
self.pid = pid
|
|
65
|
+
"""0 when no process was started — the default, where the caller spawns a sub-agent."""
|
|
66
|
+
|
|
67
|
+
self.tree = tree
|
|
68
|
+
self.log = log
|
|
69
|
+
self.branch = branch
|
|
70
|
+
self.brief = brief
|
|
71
|
+
"""The prompt to hand a sub-agent, verbatim. Empty for a spawned worker, which got it as an
|
|
72
|
+
argument instead."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def worktree_for(root: Path, task: Task) -> Path:
|
|
76
|
+
return root / TREES_DIR / task["id"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prepare(root: Path, task: Task, *, actor: str) -> Launched:
|
|
80
|
+
"""Make the worktree and write the brief. Starts NO process.
|
|
81
|
+
|
|
82
|
+
The default half of dispatch: the caller passes `.brief` to its own sub-agent tool, so the worker
|
|
83
|
+
runs on the session's existing subscription rather than opening a new billed one.
|
|
84
|
+
"""
|
|
85
|
+
tree = worktree_for(root, task)
|
|
86
|
+
make_worktree(root, tree, branch_for(task))
|
|
87
|
+
return Launched(actor=actor, task=task["id"], pid=0, tree=tree, log=Path(""),
|
|
88
|
+
branch=branch_for(task), brief=brief_for(root, task, actor, tree))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def launch(root: Path, task: Task, *, actor: str, model: str = "",
|
|
92
|
+
use_api_key: bool = False) -> Launched:
|
|
93
|
+
"""Start a headless Claude Code on its own worktree. Returns immediately.
|
|
94
|
+
|
|
95
|
+
A worktree that cannot be made is NOT fatal — a repository with no commits has no HEAD to branch
|
|
96
|
+
from — and the worker runs in the main checkout instead. That is fine for one worker and wrong for
|
|
97
|
+
several, which is why `dispatch` is the thing that decides whether to allow it.
|
|
98
|
+
|
|
99
|
+
`use_api_key` keeps `DROPPED_ENV` in the child's environment: the worker then bills per token
|
|
100
|
+
instead of using the subscription. Default OFF, and no MCP tool can turn it on.
|
|
101
|
+
"""
|
|
102
|
+
tree = worktree_for(root, task)
|
|
103
|
+
branch = branch_for(task)
|
|
104
|
+
usable = make_worktree(root, tree, branch)
|
|
105
|
+
log = root / WORKERS_DIR / f"{task['id']}.log"
|
|
106
|
+
command = ["claude", "-p", prompt_for(task, actor),
|
|
107
|
+
"--permission-mode", "acceptEdits",
|
|
108
|
+
"--allowedTools", ",".join(TOOLS)]
|
|
109
|
+
if model:
|
|
110
|
+
command += ["--model", model]
|
|
111
|
+
pid = spawn(command, cwd=tree if usable else root, log=log,
|
|
112
|
+
env={"TASKOPS_ROOT": str(root), ENV_ACTOR: actor},
|
|
113
|
+
drop=() if use_api_key else DROPPED_ENV)
|
|
114
|
+
return Launched(actor=actor, task=task["id"], pid=pid, tree=tree, log=log,
|
|
115
|
+
branch=branch, brief="")
|
taskops/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Layer 4 — a contract in, a string out. No I/O, ever.
|
|
2
|
+
|
|
3
|
+
`tests/architecture` enforces the purity: nothing here may import storage, engine,
|
|
4
|
+
usecases, sqlite3 or subprocess. That is what lets one renderer serve the CLI, the MCP
|
|
5
|
+
reply and the web UI's markdown export, and it is why a rendering bug is reproducible from
|
|
6
|
+
a literal dict.
|
|
7
|
+
|
|
8
|
+
Split by AUDIENCE rather than by data type — the board (state), results (what an action
|
|
9
|
+
did), reports (a time window), task and inbox (what an agent reads to work), session (what
|
|
10
|
+
a hook injects). Each one answers a different question, so each one orders its output
|
|
11
|
+
differently.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .ansi import Ink
|
|
17
|
+
from .board import render_board
|
|
18
|
+
from .day import render_day
|
|
19
|
+
from .dispatch import render_dispatch
|
|
20
|
+
from .inbox import render_inbox
|
|
21
|
+
from .log import render_log
|
|
22
|
+
from .recover import render_recover
|
|
23
|
+
from .report import NARRATION, PENDING, is_pending, narrated, render_report
|
|
24
|
+
from .reports import render_fleet, render_standup
|
|
25
|
+
from .results import (
|
|
26
|
+
render_edit,
|
|
27
|
+
render_next,
|
|
28
|
+
render_plan,
|
|
29
|
+
render_search,
|
|
30
|
+
render_update,
|
|
31
|
+
)
|
|
32
|
+
from .session import render_brief, render_verdict
|
|
33
|
+
from .task import render_claim, render_view
|
|
34
|
+
from .tasklist import render_tasklist
|
|
35
|
+
|
|
36
|
+
__all__ = ["render_view", "render_claim", "render_inbox", "render_board",
|
|
37
|
+
"render_standup", "render_fleet", "render_plan", "render_next",
|
|
38
|
+
"render_update", "render_search", "render_edit", "render_dispatch", "render_brief",
|
|
39
|
+
"render_verdict", "render_recover", "render_log", "render_day",
|
|
40
|
+
"render_tasklist", "render_report", "narrated", "is_pending", "NARRATION", "PENDING", "Ink"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The `## Cerrado` section — flat for one day, grouped BY DAY for anything wider.
|
|
2
|
+
|
|
3
|
+
Split from `day` because the grouping is the only thing a month-long report changes, and the
|
|
4
|
+
module that decides the ORDER of a dossier should not grow a second concern the day somebody
|
|
5
|
+
asks for a quarter.
|
|
6
|
+
|
|
7
|
+
A hundred cards under one heading is a wall nobody scrolls. Under a heading per day, newest
|
|
8
|
+
first, the same hundred cards answer "what happened the week of the 22nd" by eye — which is
|
|
9
|
+
the question a range report exists for.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from ..contracts import ClosedCard, Event, PeriodReport
|
|
17
|
+
from ._dossier import card_block
|
|
18
|
+
from ._verbatim import Detail
|
|
19
|
+
|
|
20
|
+
__all__ = ["closed_section"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def closed_section(day: PeriodReport, detail: Detail = "brief") -> list[str]:
|
|
24
|
+
"""The heading, the cards, and — when the cap cut some — how many are missing."""
|
|
25
|
+
one_day = day["from_date"] == day["to_date"]
|
|
26
|
+
if not day["closed"]:
|
|
27
|
+
nothing = "Nothing closed on this day." if one_day else "Nothing closed in this window."
|
|
28
|
+
return ["## Cerrado", "", nothing, *_dropped(day), ""]
|
|
29
|
+
count = len(day["closed"])
|
|
30
|
+
body = (_flat(day["closed"], day["conversations"], detail) if one_day
|
|
31
|
+
else _by_day(day["closed"], day["conversations"], detail))
|
|
32
|
+
return [f"## Cerrado ({count})", "", body, *_dropped(day), ""]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dropped(day: PeriodReport) -> list[str]:
|
|
36
|
+
"""Never a silent truncation. The same rule the activity view follows: a report that shows
|
|
37
|
+
200 of 400 cards without saying so is worse than one that shows none, because the reader
|
|
38
|
+
has no way to know the number they are about to quote is short."""
|
|
39
|
+
if not day["dropped"]:
|
|
40
|
+
return []
|
|
41
|
+
return ["", f"_{day['dropped']} more card(s) closed in this window and are not shown — "
|
|
42
|
+
f"narrow the range to read them._"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _flat(cards: list[ClosedCard], said: list[Event], detail: Detail) -> str:
|
|
46
|
+
"""One day: no day headings. A `## 2026-07-28` under a `# 2026-07-28` title is noise, and
|
|
47
|
+
it is what keeps a single day's report byte-identical to what it always was."""
|
|
48
|
+
return "\n\n".join("\n".join(card_block(card, said, detail)) for card in cards)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _by_day(cards: list[ClosedCard], said: list[Event], detail: Detail) -> str:
|
|
52
|
+
"""Newest day first, with its own count. The cards inside a day keep the log's order —
|
|
53
|
+
oldest close first — so a day still reads the way the day happened."""
|
|
54
|
+
days: dict[str, list[ClosedCard]] = {}
|
|
55
|
+
for card in cards:
|
|
56
|
+
days.setdefault(_date_of(card["done_ts"]), []).append(card)
|
|
57
|
+
return "\n\n".join(f"### {date} ({len(group)})\n\n" + _flat(group, said, detail)
|
|
58
|
+
for date, group in sorted(days.items(), reverse=True))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _date_of(ts: float) -> str:
|
|
62
|
+
"""The LOCAL date a close landed on — the same calendar the window was cut on, so a card
|
|
63
|
+
closed at 23:50 heads the day its reader worked, not the next one in UTC."""
|
|
64
|
+
return time.strftime("%Y-%m-%d", time.localtime(ts))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""One closed card, as the day's dossier prints it.
|
|
2
|
+
|
|
3
|
+
Split from `day` for the same reason `_sections` is split from `task`: that module decides the
|
|
4
|
+
ORDER of the report — which is its whole design, since a reader stops early — and this one
|
|
5
|
+
decides the CONTENT of the block that keeps growing.
|
|
6
|
+
|
|
7
|
+
TWO DENSITIES, ONE RENDERER. `detail="brief"` is what a terminal has always printed; `"full"`
|
|
8
|
+
is what `--write` puts on disk, and it adds the card's spec, every comment whole, and every
|
|
9
|
+
file of every commit. A second renderer for the file would drift from this one within a week,
|
|
10
|
+
and then a committed report and a printed one would disagree about what happened.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ..contracts import ClosedCard, CommitStat, Event
|
|
16
|
+
from ._text import STATUS_MARK, span, truncate
|
|
17
|
+
from ._verbatim import Detail, said_block, spec_block
|
|
18
|
+
|
|
19
|
+
__all__ = ["card_block"]
|
|
20
|
+
|
|
21
|
+
BRIEF_FILES = 4
|
|
22
|
+
"""Files named per commit in the terminal. `full` names them all: a written report is where
|
|
23
|
+
somebody looks up which files a card touched, and `+9 more` is exactly the answer they came
|
|
24
|
+
for being withheld."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def card_block(card: ClosedCard, conversations: list[Event],
|
|
28
|
+
detail: Detail = "brief") -> list[str]:
|
|
29
|
+
"""The card, who closed it, how long it was held, what it shipped, and what was said.
|
|
30
|
+
|
|
31
|
+
The duration is claim -> done and is printed even when it is minutes, because a card
|
|
32
|
+
closed four minutes after being claimed is the single most useful thing this report can
|
|
33
|
+
surface: it is either a trivial fix or an agent that closed something it never did.
|
|
34
|
+
"""
|
|
35
|
+
task = card["task"]
|
|
36
|
+
held = span(max(0.0, card["done_ts"] - card["claimed_ts"]))
|
|
37
|
+
mine = [e for e in conversations if e["task"] == task["id"]]
|
|
38
|
+
lines = [f"{STATUS_MARK['done']} **{task['id']}** — {truncate(task['title'], 70)}",
|
|
39
|
+
f" {card['actor']} · held {held} · "
|
|
40
|
+
f"{len(card['commits'])} commit(s){_weight(card['commits'])}"]
|
|
41
|
+
lines += spec_block(task, detail)
|
|
42
|
+
lines += [f" {line}" for commit in card["commits"] for line in _commit(commit, detail)]
|
|
43
|
+
return lines + said_block(mine, detail)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _weight(commits: list[CommitStat]) -> str:
|
|
47
|
+
"""The card's total diff, or "" when git had nothing to say.
|
|
48
|
+
|
|
49
|
+
Suppressed rather than printed as `+0 -0`: zeros here mean "git could not answer", and a
|
|
50
|
+
card that really did ship nothing is already visible as zero commits.
|
|
51
|
+
"""
|
|
52
|
+
adds = sum(c["additions"] for c in commits)
|
|
53
|
+
dels = sum(c["deletions"] for c in commits)
|
|
54
|
+
return f" · +{adds} -{dels}" if adds or dels else ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _commit(commit: CommitStat, detail: Detail) -> list[str]:
|
|
58
|
+
"""Sha, subject, size, then the files — the same shape `_sections.commits_section` uses,
|
|
59
|
+
so a commit reads identically on a card and in a day's report."""
|
|
60
|
+
head = (f"`{commit['sha'][:12]}` {commit['subject'] or '(no subject)'} "
|
|
61
|
+
f"(+{commit['additions']} -{commit['deletions']})")
|
|
62
|
+
files = commit["files"]
|
|
63
|
+
if not files:
|
|
64
|
+
return [head]
|
|
65
|
+
if detail == "full":
|
|
66
|
+
return [head, " " + ", ".join(files)]
|
|
67
|
+
extra = "" if len(files) <= BRIEF_FILES else f" +{len(files) - BRIEF_FILES} more"
|
|
68
|
+
return [head, f" {', '.join(files[:BRIEF_FILES])}{extra}"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The `## Abierto` section — what this window PLANNED, and what it is waiting on.
|
|
2
|
+
|
|
3
|
+
The section that did not exist. A window whose whole content was four specs and a dependency
|
|
4
|
+
chain printed `0 closed · 0 in flight · 0 blocked` and three empty headings, because nothing
|
|
5
|
+
rendered `backlog` or `ready`. Everything below is about making that window legible.
|
|
6
|
+
|
|
7
|
+
The DAG is the point, not decoration. On a planning day the only questions worth answering are
|
|
8
|
+
"which of these can somebody start right now" and "what is waiting on what", and a list of
|
|
9
|
+
titles answers neither. So every card says either that it is ready, or exactly what it waits
|
|
10
|
+
for — and, in the written rendering, what was ASKED, quoted whole.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ..contracts import OpenedCard, PeriodReport, Task
|
|
16
|
+
from ._text import STATUS_MARK, bullet, truncate
|
|
17
|
+
from ._verbatim import Detail, spec_block
|
|
18
|
+
|
|
19
|
+
__all__ = ["opened_section", "moving_section"]
|
|
20
|
+
|
|
21
|
+
READY = "listo para empezar"
|
|
22
|
+
"""What an empty `waiting_on` is printed as. Saying it OUT LOUD rather than leaving the line
|
|
23
|
+
off: a card with no dependency line and a card whose dependencies nobody computed look
|
|
24
|
+
identical, and "what can I pick up" is the question this section exists to answer."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def opened_section(day: PeriodReport, detail: Detail = "brief") -> list[str]:
|
|
28
|
+
"""Cards created in this window, with their edges. Absent when the window created none."""
|
|
29
|
+
if not day["opened"]:
|
|
30
|
+
return []
|
|
31
|
+
blocks = ["\n".join(_card(card, detail)) for card in day["opened"]]
|
|
32
|
+
return [f"## Abierto ({len(blocks)})", "", "\n\n".join(blocks), ""]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _card(card: OpenedCard, detail: Detail) -> list[str]:
|
|
36
|
+
task = card["task"]
|
|
37
|
+
waits = f" espera a: {', '.join(card['waiting_on'])}" if card["waiting_on"] else f" {READY}"
|
|
38
|
+
lines = [f"{STATUS_MARK[task['status']]} **{task['id']}** — {truncate(task['title'], 70)}",
|
|
39
|
+
f" {task['status']} · priority {task['priority']}{_tags(task)}", waits]
|
|
40
|
+
if card["blocking"]:
|
|
41
|
+
lines.append(f" bloquea a: {', '.join(card['blocking'])}")
|
|
42
|
+
return lines + spec_block(task, detail)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tags(task: Task) -> str:
|
|
46
|
+
"""Labels and files, when there are any. The files are what an agent picking the card up
|
|
47
|
+
collides on, so they belong beside it rather than one `ask` away."""
|
|
48
|
+
parts = [", ".join(task["labels"]), ", ".join(task["files"])]
|
|
49
|
+
return "".join(f" · {part}" for part in parts if part)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def moving_section(day: PeriodReport) -> list[str]:
|
|
53
|
+
"""Every OPEN card the window touched and did not create, marked by status.
|
|
54
|
+
|
|
55
|
+
One section and not three, ordered blocked -> working -> unstarted: they are all "open,
|
|
56
|
+
not new", the glyph already says which, and the order puts the row that needs a human at
|
|
57
|
+
the top. `waiting` is the half that used to be dropped on the floor.
|
|
58
|
+
"""
|
|
59
|
+
rows = day["blocked"] + day["in_flight"] + day["waiting"]
|
|
60
|
+
if not rows:
|
|
61
|
+
return []
|
|
62
|
+
return ["## Sigue abierto", "",
|
|
63
|
+
bullet([f"{STATUS_MARK[t['status']]} {t['id']} — {truncate(t['title'], 70)}"
|
|
64
|
+
for t in rows]), ""]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The sections a task view is built from: the thread and the commits.
|
|
2
|
+
|
|
3
|
+
Split from `task` because that module decides the ORDER — which is its whole design, since an agent
|
|
4
|
+
reads top-down and may stop early — and these decide the CONTENT of two blocks that keep growing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..contracts import Event, TaskView
|
|
10
|
+
from ._text import ago
|
|
11
|
+
|
|
12
|
+
__all__ = ["thread_section", "commits_section"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def thread_section(thread: list[Event]) -> list[str]:
|
|
16
|
+
if not thread:
|
|
17
|
+
return []
|
|
18
|
+
lines = [f"**{e['actor']}** ({ago(e['ts'])}): {e['body'].get('text', '')}"
|
|
19
|
+
for e in thread]
|
|
20
|
+
return ["### Thread", "", "\n\n".join(lines), ""]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def commits_section(view: TaskView) -> list[str]:
|
|
24
|
+
"""Sha, subject, and the files each commit touched.
|
|
25
|
+
|
|
26
|
+
The subject is the point. This printed bare twelve-character hashes for a while, which made a
|
|
27
|
+
finished card look like it had recorded nothing — while the `commit` event underneath had carried
|
|
28
|
+
the subject and the file list the whole time. The data was recorded, stored and replicated, and
|
|
29
|
+
then dropped one layer before anybody could read it.
|
|
30
|
+
|
|
31
|
+
Short shas still: the full forty characters is noise nobody reads, and anybody following one up
|
|
32
|
+
pastes it into git, where twelve is unambiguous.
|
|
33
|
+
"""
|
|
34
|
+
if not view["commits"]:
|
|
35
|
+
return []
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
for commit in view["commits"]:
|
|
38
|
+
lines.append(f"`{commit['sha'][:12]}` {commit['subject'] or '(no subject)'}")
|
|
39
|
+
if commit["files"]:
|
|
40
|
+
lines.append(f" {_files(commit['files'])}")
|
|
41
|
+
return [f"### Commits ({len(view['commits'])})", "", "\n".join(lines), ""]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _files(files: list[str]) -> str:
|
|
45
|
+
"""The paths, truncated by COUNT rather than by characters.
|
|
46
|
+
|
|
47
|
+
A commit that touched forty files would push the rest of the card off the screen, and the first
|
|
48
|
+
few plus a count is what a reader actually uses.
|
|
49
|
+
"""
|
|
50
|
+
shown = ", ".join(files[:4])
|
|
51
|
+
return shown if len(files) <= 4 else f"{shown} +{len(files) - 4} more"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The row, the cap and the summary of `taskops tasks`. Pure, like the rest of `render/`.
|
|
2
|
+
|
|
3
|
+
Split out of `tasklist.py` for the file budget, and the seam is the honest one: that module
|
|
4
|
+
decides WHICH cards a reader is asking for, this one decides what one of them looks like on
|
|
5
|
+
a line and how a long list ends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .._types import OPEN_STATUSES
|
|
11
|
+
from ..contracts import Board, Card
|
|
12
|
+
from ._text import STATUS_MARK, ago, truncate
|
|
13
|
+
|
|
14
|
+
__all__ = ["CAP", "lines", "capped", "summary"]
|
|
15
|
+
|
|
16
|
+
CAP = 10
|
|
17
|
+
"""How many rows of one group get printed before the rest become a count.
|
|
18
|
+
|
|
19
|
+
Closed cards accumulate for the life of a project, so an uncapped list would be read only
|
|
20
|
+
as far as the first screen anyway — and a reader who scrolls past forty finished cards to
|
|
21
|
+
reach the summary stops opening the command. Ten is a screen; `+N more` says the rest are
|
|
22
|
+
there, which is the part an empty tail would not.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def lines(board: Board, statuses: frozenset[str], *, dated: bool = False) -> list[str]:
|
|
27
|
+
"""One line per card in `statuses`, newest-updated first when `dated`.
|
|
28
|
+
|
|
29
|
+
Open cards keep the board's own order (priority, as the columns were built), because a
|
|
30
|
+
list you are about to pick from is ordered by what to do next. Closed cards have no next,
|
|
31
|
+
so they are ordered by when they stopped moving — which is what "what was worked on" asks.
|
|
32
|
+
"""
|
|
33
|
+
cards = [(card, column["status"]) for column in board["columns"]
|
|
34
|
+
if column["status"] in statuses for card in column["cards"]]
|
|
35
|
+
if dated:
|
|
36
|
+
cards.sort(key=lambda pair: pair[0]["task"]["updated"], reverse=True)
|
|
37
|
+
return [_line(card, status, dated=dated) for card, status in cards]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _line(card: Card, status: str, *, dated: bool = False) -> str:
|
|
41
|
+
"""`◐ tk-4f2a9c claimed Regroup the CLI ← agent:berna/v21`
|
|
42
|
+
|
|
43
|
+
Padded rather than tabulated: a markdown table costs three characters of framing per
|
|
44
|
+
column and this view exists to be compact. The holder is suffixed with an arrow instead
|
|
45
|
+
of a column of its own, because most rows have none and a mostly-empty column is a
|
|
46
|
+
column of noise. A closed card has no holder and trades that suffix for its age, which
|
|
47
|
+
is the only thing left that distinguishes yesterday's work from last quarter's.
|
|
48
|
+
"""
|
|
49
|
+
who = card["lease"]["actor"] if card["lease"] else ""
|
|
50
|
+
tail = f" {ago(card['task']['updated'])}" if dated else (f" ← {who}" if who else "")
|
|
51
|
+
return (f"{STATUS_MARK.get(status, '?')} {card['task']['id']} {status:<12}"
|
|
52
|
+
f"{truncate(card['task']['title'], 52)}{tail}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def capped(rows: list[str]) -> list[str]:
|
|
56
|
+
"""At most `CAP` rows, then a line saying how many were left out."""
|
|
57
|
+
if len(rows) <= CAP:
|
|
58
|
+
return list(rows)
|
|
59
|
+
return [*rows[:CAP], f"+{len(rows) - CAP} more"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def summary(board: Board, closed: int) -> str:
|
|
63
|
+
"""`3 open, 1 ready` — and the closed count whenever closed cards are on screen.
|
|
64
|
+
|
|
65
|
+
The `ready` count is the one number that says whether adding another agent would help,
|
|
66
|
+
and a person reading their list is exactly the person deciding that. The closed count
|
|
67
|
+
appears only when closed rows do, so the line never describes something not shown.
|
|
68
|
+
"""
|
|
69
|
+
count = sum(len(column["cards"]) for column in board["columns"]
|
|
70
|
+
if column["status"] in OPEN_STATUSES)
|
|
71
|
+
line = f"{count} open, {board['ready']} ready"
|
|
72
|
+
return f"{line}, {closed} closed" if closed else line
|
taskops/render/_text.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Text primitives. Pure functions of strings — no I/O anywhere in `render/`.
|
|
2
|
+
|
|
3
|
+
That purity is enforced (`tests/architecture`) and it is what lets the same renderer
|
|
4
|
+
serve the CLI, the MCP reply and the web UI's markdown export: a rendering bug is
|
|
5
|
+
reproducible from a literal dict with no database in sight.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .._clock import now
|
|
11
|
+
|
|
12
|
+
__all__ = ["ago", "span", "bullet", "table", "truncate", "STATUS_MARK"]
|
|
13
|
+
|
|
14
|
+
STATUS_MARK = {"backlog": "·", "ready": "○", "claimed": "◐", "in_progress": "●",
|
|
15
|
+
"blocked": "✕", "review": "◆", "done": "✓", "cancelled": "—"}
|
|
16
|
+
"""One glyph per status, so a board scans vertically without reading words.
|
|
17
|
+
|
|
18
|
+
ASCII-ish and deliberately not emoji: this is read in a terminal by a human and in a
|
|
19
|
+
context window by a model, and emoji cost several tokens each for the same information.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ago(ts: float, *, at: float | None = None) -> str:
|
|
24
|
+
"""`1 800 000 042.0` -> "3m ago". Coarse on purpose.
|
|
25
|
+
|
|
26
|
+
Nobody acts differently on 187 versus 190 seconds, and a precise duration invites a
|
|
27
|
+
reader to compare two of them — which is a comparison between two clocks that may not
|
|
28
|
+
agree, since these timestamps come from different machines.
|
|
29
|
+
"""
|
|
30
|
+
seconds = max(0.0, (now() if at is None else at) - ts)
|
|
31
|
+
return f"{span(seconds)} ago" if seconds >= 60.0 else "just now"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def span(seconds: float) -> str:
|
|
35
|
+
"""`5400.0` -> "1h". A DURATION, where `ago` is a distance from now.
|
|
36
|
+
|
|
37
|
+
The same coarseness and the same arithmetic, which is why `ago` is written in terms of
|
|
38
|
+
it: two rounding rules for two ways of printing the same number is how a card ends up
|
|
39
|
+
saying it took 2h beside a claim that says 3h ago.
|
|
40
|
+
"""
|
|
41
|
+
for size, unit in ((86400.0, "d"), (3600.0, "h"), (60.0, "m")):
|
|
42
|
+
if seconds >= size:
|
|
43
|
+
return f"{int(seconds // size)}{unit}"
|
|
44
|
+
return "under a minute"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def truncate(text: str, limit: int) -> str:
|
|
48
|
+
"""One line, at most `limit` characters. Collapses newlines first.
|
|
49
|
+
|
|
50
|
+
A spec's first line is often a sentence that continues, and pasting the raw newline
|
|
51
|
+
into a table cell breaks the table for every row after it.
|
|
52
|
+
"""
|
|
53
|
+
flat = " ".join(text.split())
|
|
54
|
+
return flat if len(flat) <= limit else flat[: limit - 1].rstrip() + "…"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def bullet(lines: list[str], *, indent: int = 0) -> str:
|
|
58
|
+
prefix = " " * indent + "- "
|
|
59
|
+
return "\n".join(prefix + line for line in lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def table(headers: list[str], rows: list[list[str]]) -> str:
|
|
63
|
+
"""A markdown table, or "" for no rows.
|
|
64
|
+
|
|
65
|
+
Empty rather than a header with nothing under it: a reader shown an empty table has
|
|
66
|
+
to work out whether the query failed or the answer is genuinely nothing, and the
|
|
67
|
+
caller knows which and can say so in a sentence.
|
|
68
|
+
"""
|
|
69
|
+
if not rows:
|
|
70
|
+
return ""
|
|
71
|
+
head = "| " + " | ".join(headers) + " |"
|
|
72
|
+
rule = "|" + "|".join("---" for _ in headers) + "|"
|
|
73
|
+
body = ["| " + " | ".join(cell for cell in row) + " |" for row in rows]
|
|
74
|
+
return "\n".join([head, rule, *body])
|