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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The two blocks a WRITTEN report carries verbatim: the card's spec, and every word said on it.
|
|
2
|
+
|
|
3
|
+
Split from `_dossier` because it owns one idea — text that is quoted whole rather than
|
|
4
|
+
summarised — and because the density switch lives here rather than in four `if` statements
|
|
5
|
+
spread across the renderer.
|
|
6
|
+
|
|
7
|
+
Why verbatim at all: a report is meant to be read INSTEAD of the git log, and a diff cannot
|
|
8
|
+
say what was ASKED or what was DECIDED. The spec is the ask, the comments are the reasoning,
|
|
9
|
+
and both are lost the moment they are truncated to one line. The terminal keeps the short
|
|
10
|
+
form — nobody wants a screen of quoted essays — so the same projection is printed at two
|
|
11
|
+
densities and never forked into two renderers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from ..contracts import Event, Task
|
|
19
|
+
from ._text import truncate
|
|
20
|
+
|
|
21
|
+
__all__ = ["Detail", "spec_block", "said_block"]
|
|
22
|
+
|
|
23
|
+
Detail = Literal["brief", "full"]
|
|
24
|
+
"""How much of the text survives. `brief` is the terminal, `full` is the file on disk."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def spec_block(task: Task, detail: Detail) -> list[str]:
|
|
28
|
+
"""What was ASKED, quoted whole — `full` only.
|
|
29
|
+
|
|
30
|
+
Without it the narration can only describe what was delivered, and "delivered" with
|
|
31
|
+
nothing to compare it against is a changelog, not a report. Quoted with `>` so a spec
|
|
32
|
+
that is itself markdown cannot inject headings into the dossier's own outline.
|
|
33
|
+
"""
|
|
34
|
+
spec = task["spec"].strip()
|
|
35
|
+
if detail == "brief" or not spec:
|
|
36
|
+
return []
|
|
37
|
+
return ["", " **Pedido**", "", *_quote(spec)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def said_block(mine: list[Event], detail: Detail) -> list[str]:
|
|
41
|
+
"""The conversation on one card.
|
|
42
|
+
|
|
43
|
+
`brief`: a count and the last line, which is the hand-off note. `full`: every comment
|
|
44
|
+
in order, attributed, whole — that is where the reasoning and the surprises live, and a
|
|
45
|
+
report that drops all but the last one cannot say why anything was done.
|
|
46
|
+
"""
|
|
47
|
+
if not mine:
|
|
48
|
+
return []
|
|
49
|
+
if detail == "brief":
|
|
50
|
+
text = str(mine[-1]["body"].get("text", ""))
|
|
51
|
+
return [f" {len(mine)} comment(s) · last: {truncate(text, 120)}"]
|
|
52
|
+
out = ["", f" **{len(mine)} comment(s)**"]
|
|
53
|
+
for event in mine:
|
|
54
|
+
out += ["", f" **{event['actor']}**:", "", *_quote(str(event["body"].get("text", "")))]
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _quote(text: str) -> list[str]:
|
|
59
|
+
"""Every line of `text` as an indented blockquote, blank lines included.
|
|
60
|
+
|
|
61
|
+
Blank lines carry the `>` too: a markdown blockquote broken by a bare empty line becomes
|
|
62
|
+
two quotes with a paragraph between them, and a multi-paragraph spec would stop reading
|
|
63
|
+
as one quoted thing.
|
|
64
|
+
"""
|
|
65
|
+
return [f" > {line}".rstrip() for line in text.strip().splitlines()]
|
taskops/render/ansi.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Markdown for a terminal, rendered as it arrives. taskops' first ansi vocabulary.
|
|
2
|
+
|
|
3
|
+
A narration is streamed a delta at a time, and a delta is not a line: it splits words, and it
|
|
4
|
+
splits `**bo` from `ld**`. So nothing may be styled until the line it belongs to is COMPLETE —
|
|
5
|
+
`Ink.feed` buffers fragments and hands back only the lines that have ended. Anything else
|
|
6
|
+
produces a `**` that never closes and an escape sequence in the middle of a word.
|
|
7
|
+
|
|
8
|
+
Pure, like everything in `render/`: fragments in, rendered lines out. Nothing here prints, opens
|
|
9
|
+
a file, or asks whether it is talking to a terminal — the transport that owns stdout decides
|
|
10
|
+
that and passes the answer in. With `colour=False` a line comes back byte for byte as it went
|
|
11
|
+
in, which is exactly what the digest used to emit, so the plain path costs nothing and a
|
|
12
|
+
redirected `--digest` is unchanged.
|
|
13
|
+
|
|
14
|
+
ANSI never reaches the FILE. The report on disk is written from the joined text, not from this.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from ._text import STATUS_MARK
|
|
22
|
+
|
|
23
|
+
__all__ = ["Ink", "BULLET"]
|
|
24
|
+
|
|
25
|
+
RESET = "\033[0m"
|
|
26
|
+
BOLD = "\033[1m"
|
|
27
|
+
TITLE = "\033[1;4m"
|
|
28
|
+
"""Bold + underline, for `#` only. A narration's one top-level heading is the report's own
|
|
29
|
+
title; every deeper level is a section inside it and gets plain bold."""
|
|
30
|
+
CODE = "\033[36m"
|
|
31
|
+
|
|
32
|
+
BULLET = STATUS_MARK["backlog"]
|
|
33
|
+
"""The glyph a `- ` becomes. Borrowed from the board's register rather than chosen here: two
|
|
34
|
+
vocabularies for "a small thing in a list" is how a terminal ends up with `•` in one command
|
|
35
|
+
and `·` in the next, and the reader learns neither."""
|
|
36
|
+
|
|
37
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
38
|
+
_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
|
39
|
+
_CODE = re.compile(r"`([^`]+)`")
|
|
40
|
+
_BULLET = re.compile(r"^(\s*)[-*]\s+")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Ink:
|
|
44
|
+
"""A stream of markdown fragments, turned into finished terminal lines."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, colour: bool) -> None:
|
|
47
|
+
self._colour = colour
|
|
48
|
+
self._held = ""
|
|
49
|
+
self._fenced = False
|
|
50
|
+
|
|
51
|
+
def feed(self, fragment: str) -> list[str]:
|
|
52
|
+
"""The lines COMPLETED by this fragment. Usually none — deltas are smaller than lines."""
|
|
53
|
+
self._held += fragment
|
|
54
|
+
if "\n" not in self._held:
|
|
55
|
+
return []
|
|
56
|
+
*done, self._held = self._held.split("\n")
|
|
57
|
+
return [self._paint(line) for line in done]
|
|
58
|
+
|
|
59
|
+
def flush(self) -> list[str]:
|
|
60
|
+
"""Whatever is left when the narration ends — a last line with no trailing newline is
|
|
61
|
+
still a line, and dropping it loses the final sentence."""
|
|
62
|
+
rest, self._held = self._held, ""
|
|
63
|
+
return [self._paint(rest)] if rest else []
|
|
64
|
+
|
|
65
|
+
def _paint(self, line: str) -> str:
|
|
66
|
+
"""One whole line, styled. Verbatim when colour is off or a fence is open.
|
|
67
|
+
|
|
68
|
+
The fence toggles on its own line and suppresses everything inside it: `**` in a shell
|
|
69
|
+
snippet is a glob, and a `#` is a comment, not a heading.
|
|
70
|
+
"""
|
|
71
|
+
if line.lstrip().startswith("```"):
|
|
72
|
+
self._fenced = not self._fenced
|
|
73
|
+
return line
|
|
74
|
+
if not self._colour or self._fenced:
|
|
75
|
+
return line
|
|
76
|
+
head = _HEADING.match(line)
|
|
77
|
+
if head:
|
|
78
|
+
# No inline pass over a heading: an inner RESET would end the heading's own style
|
|
79
|
+
# halfway through it, and the rest of the title would come out plain.
|
|
80
|
+
mark = TITLE if len(head.group(1)) == 1 else BOLD
|
|
81
|
+
return f"{mark}{head.group(2)}{RESET}"
|
|
82
|
+
return _spans(_BULLET.sub(rf"\1{BULLET} ", line))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _spans(text: str) -> str:
|
|
86
|
+
"""Inline styling, applied only WITHIN a finished line.
|
|
87
|
+
|
|
88
|
+
Never across the buffer: a `**` whose partner is in the next fragment is not emphasis yet,
|
|
89
|
+
and treating it as one emits an opening escape that nothing ever closes.
|
|
90
|
+
"""
|
|
91
|
+
text = _CODE.sub(rf"{CODE}\1{RESET}", text)
|
|
92
|
+
return _BOLD.sub(rf"{BOLD}\1{RESET}", text)
|
taskops/render/board.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The board as text — the state of the work, column by column.
|
|
2
|
+
|
|
3
|
+
Read by BOTH a human in a terminal and a model in a context window, which settles most
|
|
4
|
+
of the formatting questions: no colour, no box drawing, one glyph per status, counts
|
|
5
|
+
rather than bars. What an ACTION returns lives in `results`; the time-window reports live
|
|
6
|
+
in `reports`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..contracts import Board
|
|
12
|
+
from ._text import STATUS_MARK, table, truncate
|
|
13
|
+
|
|
14
|
+
__all__ = ["render_board"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_board(board: Board) -> str:
|
|
18
|
+
"""EVERY column, always, with a line where one is empty.
|
|
19
|
+
|
|
20
|
+
Hiding empty columns was the first version, on the theory that four of eight are usually empty and
|
|
21
|
+
showing them is noise. It reads as a bug instead: a reader who cannot see a `done` column cannot
|
|
22
|
+
tell whether nothing is finished or whether the board has no such state — and "where are my done
|
|
23
|
+
cards" is the question it produced. An empty column that says it is empty answers that in place.
|
|
24
|
+
"""
|
|
25
|
+
parts = [f"# board — {board['total']} task(s), {board['ready']} ready", ""]
|
|
26
|
+
for column in board["columns"]:
|
|
27
|
+
parts += [f"## {STATUS_MARK.get(column['status'], '?')} {column['status']} "
|
|
28
|
+
f"({len(column['cards'])})", ""]
|
|
29
|
+
if not column["cards"]:
|
|
30
|
+
parts += ["_none_", ""]
|
|
31
|
+
continue
|
|
32
|
+
rows = [[card["task"]["id"], truncate(card["task"]["title"], 46),
|
|
33
|
+
str(card["task"]["priority"]),
|
|
34
|
+
card["lease"]["actor"] if card["lease"] else "—",
|
|
35
|
+
_counts(card["blocked_by"], card["blocks"], card["commits"])]
|
|
36
|
+
for card in column["cards"]]
|
|
37
|
+
parts += [table(["id", "title", "pri", "who", "deps/commits"], rows), ""]
|
|
38
|
+
return "\n".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _counts(blocked_by: int, blocks: int, commits: int) -> str:
|
|
42
|
+
"""`↑2 ↓1 ◆3` — waiting on, blocking, commits. "—" when there is nothing.
|
|
43
|
+
|
|
44
|
+
Three zeroes read as data a reader has to parse; one dash reads as "nothing to see",
|
|
45
|
+
which is what it means. The arrows are directional so the two dependency counts cannot
|
|
46
|
+
be mistaken for each other, which numbers alone always are.
|
|
47
|
+
"""
|
|
48
|
+
bits: list[str] = []
|
|
49
|
+
if blocked_by:
|
|
50
|
+
bits.append(f"↑{blocked_by}")
|
|
51
|
+
if blocks:
|
|
52
|
+
bits.append(f"↓{blocks}")
|
|
53
|
+
if commits:
|
|
54
|
+
bits.append(f"◆{commits}")
|
|
55
|
+
return " ".join(bits) if bits else "—"
|
taskops/render/day.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The dossier as markdown: what closed, what was planned, what is still moving, what was said.
|
|
2
|
+
|
|
3
|
+
Ordered by what a person reads first at the end of a day — the finished work, then the work
|
|
4
|
+
that was planned, then the work that is neither, then the conversation, then who did it. A
|
|
5
|
+
reader who stops after the first section has still got the answer to "what shipped", which is
|
|
6
|
+
usually the question — and on a day that shipped nothing, `## Abierto` is right underneath.
|
|
7
|
+
|
|
8
|
+
EVERY open card lands in exactly one section. `in_flight` and `blocked` used to be the only
|
|
9
|
+
ones drawn, so `ready` and `backlog` — all planned-but-unstarted work — were rendered nowhere
|
|
10
|
+
and a freshly planned project reported that nothing had happened at all.
|
|
11
|
+
|
|
12
|
+
ONE function for a day and for a month. A wider window changes exactly two things — the title
|
|
13
|
+
is a label rather than a date, and the closed cards get a heading per day — and nothing else,
|
|
14
|
+
so a range report is the report somebody already knows how to read.
|
|
15
|
+
|
|
16
|
+
Pure like every renderer here: this is reproducible from a literal dict, with no database and
|
|
17
|
+
no git in sight.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from ..contracts import Event, PeriodReport
|
|
23
|
+
from ._closed_days import closed_section
|
|
24
|
+
from ._opened import moving_section, opened_section
|
|
25
|
+
from ._text import table, truncate
|
|
26
|
+
from ._verbatim import Detail
|
|
27
|
+
|
|
28
|
+
__all__ = ["render_day"]
|
|
29
|
+
|
|
30
|
+
TALK_LINE = 160
|
|
31
|
+
"""Characters of a comment the TERMINAL prints. `full` prints all of it — the conversation is
|
|
32
|
+
where the reasoning is, and 160 characters of it is a hint that something was said."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render_day(day: PeriodReport, detail: Detail = "brief") -> str:
|
|
36
|
+
"""The whole window. A window with nothing in any section says so in one line rather than
|
|
37
|
+
printing empty headings — a report made of section titles reads as broken, not as quiet.
|
|
38
|
+
|
|
39
|
+
Emptiness is judged on the SECTIONS, not on `actors`. It was judged on actors, and that is
|
|
40
|
+
precisely how the silent-planning-day bug hid: four cards created means one actor with four
|
|
41
|
+
tasks, so the report was not "empty", it just had no section that could hold a `ready` card
|
|
42
|
+
and printed three headings with nothing under them. A report that draws nothing must say
|
|
43
|
+
nothing happened; a report that has something to draw must draw it.
|
|
44
|
+
"""
|
|
45
|
+
body = (closed_section(day, detail) + opened_section(day, detail) + moving_section(day)
|
|
46
|
+
+ _talk(day, detail))
|
|
47
|
+
if not _anything(day):
|
|
48
|
+
quiet = ("on this day" if day["from_date"] == day["to_date"]
|
|
49
|
+
else "in this window")
|
|
50
|
+
return f"# {day['label']}\n\nNothing happened {quiet}."
|
|
51
|
+
return "\n".join([_headline(day), ""] + body + _actors(day))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _anything(day: PeriodReport) -> bool:
|
|
55
|
+
"""Whether the window holds a single fact worth a heading.
|
|
56
|
+
|
|
57
|
+
`dropped` counts: a window that closed more cards than the cap renders has plenty to say,
|
|
58
|
+
and answering it with "nothing happened" would be the loudest possible lie.
|
|
59
|
+
"""
|
|
60
|
+
return bool(day["closed"] or day["opened"] or day["in_flight"] or day["blocked"]
|
|
61
|
+
or day["waiting"] or day["conversations"] or day["commits_total"]
|
|
62
|
+
or day["dropped"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _headline(day: PeriodReport) -> str:
|
|
66
|
+
"""The counts, with `opened` and `waiting` shown only when they are not zero.
|
|
67
|
+
|
|
68
|
+
Conditional and not unconditional: those two numbers exist to stop the summary line
|
|
69
|
+
claiming a planning day was empty, and a window that opened nothing does not need to be
|
|
70
|
+
told so — while every dossier ever committed keeps the header it was written with.
|
|
71
|
+
"""
|
|
72
|
+
counts = [f"{len(day['closed'])} closed", f"{len(day['opened'])} opened",
|
|
73
|
+
f"{len(day['in_flight'])} in flight", f"{len(day['blocked'])} blocked",
|
|
74
|
+
f"{len(day['waiting'])} waiting", f"{day['commits_total']} commit(s)",
|
|
75
|
+
f"{len(day['actors'])} actor(s)"]
|
|
76
|
+
drop = {f"0 {name}" for name in ("opened", "waiting")}
|
|
77
|
+
return f"# {day['label']} — " + " · ".join(c for c in counts if c not in drop)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _talk(day: PeriodReport, detail: Detail = "brief") -> list[str]:
|
|
81
|
+
"""Every comment and message of the day, in the log's own order — a conversation read
|
|
82
|
+
top-down. Truncated per line in the terminal so one essay cannot push the rest of the day
|
|
83
|
+
off screen; whole in the file, where the essay is the point."""
|
|
84
|
+
if not day["conversations"]:
|
|
85
|
+
return []
|
|
86
|
+
lines = [f"**{e['actor']}** on {e['task']}: {_body(e, detail)}"
|
|
87
|
+
for e in day["conversations"]]
|
|
88
|
+
return [f"## Conversaciones ({len(lines)})", "", "\n\n".join(lines), ""]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _body(event: Event, detail: Detail) -> str:
|
|
92
|
+
text = str(event["body"].get("text", ""))
|
|
93
|
+
return text.strip() if detail == "full" else truncate(text, TALK_LINE)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _actors(day: PeriodReport) -> list[str]:
|
|
97
|
+
if not day["actors"]:
|
|
98
|
+
return []
|
|
99
|
+
rows = [[roll["actor"], str(roll["tasks"]), str(roll["commits"]),
|
|
100
|
+
str(roll["comments"]), str(roll["done"])] for roll in day["actors"]]
|
|
101
|
+
return ["## Por actor", "",
|
|
102
|
+
table(["actor", "tasks", "commits", "comments", "closed"], rows)]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""What a dispatch returns: the workers that started, and the cards that did not.
|
|
2
|
+
|
|
3
|
+
Its own module because the reader is in a different situation from every other result — they have
|
|
4
|
+
just launched processes they cannot see, so what this owes them is where to look.
|
|
5
|
+
|
|
6
|
+
The Protocols are here rather than an import of `usecases.dispatch`, because that would point layer 4
|
|
7
|
+
at layer 5. An architecture test enforces it, and that is how the first version got caught: a
|
|
8
|
+
`TYPE_CHECKING` guard hides an import from the runtime but not from the rule, and the rule is right.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from ._text import table
|
|
17
|
+
|
|
18
|
+
__all__ = ["render_dispatch", "DispatchLike", "Worker"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Worker(Protocol):
|
|
22
|
+
"""One launched worker, structurally.
|
|
23
|
+
|
|
24
|
+
Read-only properties rather than plain attributes: a mutable Protocol attribute is invariant, so
|
|
25
|
+
it would only match a class whose annotations are identical — and `Launched` assigns in
|
|
26
|
+
`__init__`. Rendering never writes to these anyway.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def actor(self) -> str: ...
|
|
31
|
+
@property
|
|
32
|
+
def task(self) -> str: ...
|
|
33
|
+
@property
|
|
34
|
+
def pid(self) -> int: ...
|
|
35
|
+
@property
|
|
36
|
+
def branch(self) -> str: ...
|
|
37
|
+
@property
|
|
38
|
+
def brief(self) -> str: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DispatchLike(Protocol):
|
|
42
|
+
# `Sequence` and not `list`: list is INVARIANT, so `list[Launched]` does not satisfy
|
|
43
|
+
# `list[Worker]` even though `Launched` satisfies `Worker`. Sequence is covariant, and a renderer
|
|
44
|
+
# has no business writing to it.
|
|
45
|
+
@property
|
|
46
|
+
def launched(self) -> Sequence[Worker]: ...
|
|
47
|
+
@property
|
|
48
|
+
def skipped(self) -> Sequence[str]: ...
|
|
49
|
+
@property
|
|
50
|
+
def planned(self) -> bool: ...
|
|
51
|
+
@property
|
|
52
|
+
def spawned(self) -> bool: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_dispatch(result: DispatchLike) -> str:
|
|
56
|
+
"""The workers that were launched, and the cards that were not.
|
|
57
|
+
|
|
58
|
+
The skipped list is not an afterthought: a dispatch that quietly started three of five would leave
|
|
59
|
+
a planner believing five agents are working, and the two cards nobody started would look assigned
|
|
60
|
+
and never move. It ends with where to look, because a detached process nobody can find is a
|
|
61
|
+
process nobody trusts.
|
|
62
|
+
"""
|
|
63
|
+
if not result.launched and not result.skipped:
|
|
64
|
+
return ("nothing to dispatch — no ready, unassigned card. Plan some work, or check "
|
|
65
|
+
"`taskops_report board` for what is blocked")
|
|
66
|
+
preview = result.planned
|
|
67
|
+
rows = [[w.actor, w.task, "—" if preview else str(w.pid), w.branch]
|
|
68
|
+
for w in result.launched]
|
|
69
|
+
heading = (f"# would dispatch {len(result.launched)} worker(s) — NOTHING STARTED"
|
|
70
|
+
if preview else
|
|
71
|
+
f"# dispatched {len(result.launched)} worker(s)" if result.spawned else
|
|
72
|
+
f"# prepared {len(result.launched)} worker(s) — SPAWN THEM BELOW")
|
|
73
|
+
parts = [heading, "", table(["worker", "task", "pid", "branch"], rows)]
|
|
74
|
+
if result.skipped:
|
|
75
|
+
parts += ["", f"⚠ NOT started: {', '.join(result.skipped)}",
|
|
76
|
+
"_Not ready, already assigned, or the process failed to start._"]
|
|
77
|
+
if preview:
|
|
78
|
+
parts += ["", "A preview: no card was assigned, no worktree made, nothing started. "
|
|
79
|
+
"Drop `dry_run` to go ahead."]
|
|
80
|
+
elif result.spawned:
|
|
81
|
+
parts += ["", "They are detached processes, running now. Watch them with "
|
|
82
|
+
"`taskops_report fleet`; their output is in .taskops/workers/."]
|
|
83
|
+
elif result.launched:
|
|
84
|
+
parts += _handover(result)
|
|
85
|
+
return "\n".join(parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _handover(result: DispatchLike) -> list[str]:
|
|
89
|
+
"""The briefs, and the instruction that the remaining half is the caller's job.
|
|
90
|
+
|
|
91
|
+
This is the part that makes the default mode work at all: the cards are assigned and their
|
|
92
|
+
worktrees exist, but nothing is DOING them until the orchestrator spawns a sub-agent per brief.
|
|
93
|
+
Saying it plainly matters — a dispatch that returned a table and stopped reads like work in
|
|
94
|
+
flight, and the cards would sit assigned to workers that were never started.
|
|
95
|
+
"""
|
|
96
|
+
out = ["", f"ASSIGNED and ready. Now spawn {len(result.launched)} sub-agent(s) IN THIS SESSION "
|
|
97
|
+
f"— one per brief below, all in one message so they run in parallel. They will use this "
|
|
98
|
+
f"session's subscription rather than opening new billed ones.", ""]
|
|
99
|
+
for worker in result.launched:
|
|
100
|
+
out += [f"── brief for {worker.actor} ({worker.task}) " + "─" * 20, "",
|
|
101
|
+
worker.brief, ""]
|
|
102
|
+
out += ["If you do not spawn them, run `taskops recover` to hand the cards back rather than "
|
|
103
|
+
"leaving them assigned to workers that never existed."]
|
|
104
|
+
return out
|
taskops/render/inbox.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Messages addressed to one actor — the agent-to-agent channel, as text.
|
|
2
|
+
|
|
3
|
+
Its own module because of WHERE it appears: at the top of a claim, and inside a session
|
|
4
|
+
brief. Both are places an agent reads before doing anything, which is the whole point — a
|
|
5
|
+
message read after the work is a message that cost the work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..contracts import Inbox
|
|
11
|
+
from ._text import ago
|
|
12
|
+
|
|
13
|
+
__all__ = ["render_inbox"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_inbox(inbox: Inbox) -> str:
|
|
17
|
+
"""The messages, or "" when there are none.
|
|
18
|
+
|
|
19
|
+
Empty string rather than "no messages": this gets embedded in other renders, and a
|
|
20
|
+
line saying nothing happened is a line every caller then has to decide to strip.
|
|
21
|
+
"""
|
|
22
|
+
if not inbox["messages"]:
|
|
23
|
+
return ""
|
|
24
|
+
lines = [f"**{e['actor']}** on {e['task']} ({ago(e['ts'])}): "
|
|
25
|
+
f"{e['body'].get('text', '')}" for e in inbox["messages"]]
|
|
26
|
+
return "\n".join([f"### 📬 {len(inbox['messages'])} message(s) for you", "",
|
|
27
|
+
"\n\n".join(lines)])
|
taskops/render/log.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""An agent's conversation, rendered for a person catching up on a card.
|
|
2
|
+
|
|
3
|
+
Shaped by what a reader is actually asking: not "replay everything", but "what did it decide, what did
|
|
4
|
+
it touch, and how did it end". So thinking is marked as thinking, a tool call is one line naming the
|
|
5
|
+
file, and the whole thing reads top-down like a transcript rather than like a data dump.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..contracts import LogEntry, SessionLog
|
|
11
|
+
from ._text import ago
|
|
12
|
+
|
|
13
|
+
__all__ = ["render_log"]
|
|
14
|
+
|
|
15
|
+
_MARK = {"prompt": "▸ you", "thinking": "· thinking", "text": "◆ agent",
|
|
16
|
+
"tool": "→", "result": " ←", "other": "·"}
|
|
17
|
+
"""One marker per kind. `result` is indented because it belongs to the call above it, which is the
|
|
18
|
+
only structure in the stream a reader needs to see."""
|
|
19
|
+
|
|
20
|
+
_RESULT_LINES = 3
|
|
21
|
+
"""Lines kept from a tool result. They are the longest entries by far and the least read — a file's
|
|
22
|
+
contents came back from a Read, and the reader is looking for what the agent DID with it."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_log(log: SessionLog) -> str:
|
|
26
|
+
if not log["entries"]:
|
|
27
|
+
return f"# {log['task']} — no conversation found\n\n{log['source']}"
|
|
28
|
+
head = [f"# {log['task']} — conversation", "",
|
|
29
|
+
f"_{len(log['entries'])} entries · {len(log['sessions'])} session(s)"
|
|
30
|
+
+ (" · TRUNCATED to the most recent" if log["truncated"] else "") + "_", ""]
|
|
31
|
+
return "\n".join(head + [_entry(e) for e in log["entries"]])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _entry(entry: LogEntry) -> str:
|
|
35
|
+
mark = _MARK.get(entry["kind"], "·")
|
|
36
|
+
when = ago(entry["ts"]) if entry["ts"] else ""
|
|
37
|
+
if entry["kind"] == "tool":
|
|
38
|
+
return f"{mark} **{entry['tool']}** `{entry['text']}`"
|
|
39
|
+
if entry["kind"] == "result":
|
|
40
|
+
return f"{mark} {_short(entry['text'])}"
|
|
41
|
+
return f"\n{mark} _{when}_\n{entry['text']}\n"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _short(text: str) -> str:
|
|
45
|
+
lines = [line for line in text.splitlines() if line.strip()][:_RESULT_LINES]
|
|
46
|
+
joined = " / ".join(line.strip()[:120] for line in lines)
|
|
47
|
+
return joined or "(empty)"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What a recovery freed, written for somebody staring at a stuck board.
|
|
2
|
+
|
|
3
|
+
The leftovers are the point of this render. A card that came back with no explanation looks like a
|
|
4
|
+
card nobody started, and the next agent rewrites from zero the file sitting in a directory two levels
|
|
5
|
+
down — which is what nearly happened the day this was needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from ._text import table
|
|
14
|
+
|
|
15
|
+
__all__ = ["render_recover", "RecoveredLike"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StuckLike(Protocol):
|
|
19
|
+
@property
|
|
20
|
+
def task(self) -> str: ...
|
|
21
|
+
@property
|
|
22
|
+
def actor(self) -> str: ...
|
|
23
|
+
@property
|
|
24
|
+
def silent_for(self) -> float: ...
|
|
25
|
+
@property
|
|
26
|
+
def commits(self) -> int: ...
|
|
27
|
+
@property
|
|
28
|
+
def leftovers(self) -> Sequence[str]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RecoveredLike(Protocol):
|
|
32
|
+
"""Structural, so `render/` never imports `usecases` — an invariant test enforces it."""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def released(self) -> Sequence[StuckLike]: ...
|
|
36
|
+
@property
|
|
37
|
+
def alive(self) -> Sequence[str]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_recover(result: RecoveredLike) -> str:
|
|
41
|
+
if not result.released:
|
|
42
|
+
return _nothing(result)
|
|
43
|
+
rows = [[s.task, s.actor, f"{int(s.silent_for // 60)}m",
|
|
44
|
+
str(s.commits) if s.commits else "—",
|
|
45
|
+
f"{len(s.leftovers)} file(s)" if s.leftovers else "—"]
|
|
46
|
+
for s in result.released]
|
|
47
|
+
parts = [f"# recovered {len(result.released)} card(s)", "",
|
|
48
|
+
table(["task", "was held by", "silent", "commits", "uncommitted"], rows),
|
|
49
|
+
"", "They are `ready` and unassigned again — dispatch or claim them normally."]
|
|
50
|
+
salvage = [s for s in result.released if s.leftovers]
|
|
51
|
+
if salvage:
|
|
52
|
+
parts += ["", "⚠ UNCOMMITTED work survives — read it before redoing the task:"]
|
|
53
|
+
parts += [f" {s.task}: {', '.join(s.leftovers)}" for s in salvage]
|
|
54
|
+
if result.alive:
|
|
55
|
+
parts += ["", f"Still reporting, left alone: {', '.join(result.alive)}"]
|
|
56
|
+
return "\n".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _nothing(result: RecoveredLike) -> str:
|
|
60
|
+
"""Nothing stuck is the GOOD outcome, and it has to read like one rather than like a failure."""
|
|
61
|
+
if result.alive:
|
|
62
|
+
return ("Nothing to recover — every worker is still reporting: "
|
|
63
|
+
+ ", ".join(result.alive))
|
|
64
|
+
return "Nothing to recover — no live claims at all."
|
taskops/render/report.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The written report as a FILE: a fingerprint, the dossier, and a slot for a narration.
|
|
2
|
+
|
|
3
|
+
Separate from `render_day` because the two have different readers. The dossier is printed to
|
|
4
|
+
a terminal and posted into a reply; this is the thing that lands in `.taskops/reports/` and
|
|
5
|
+
gets committed, and it carries two lines the terminal version must never have — a machine
|
|
6
|
+
header, and an empty section a human or a session is expected to fill in.
|
|
7
|
+
|
|
8
|
+
Pure like everything else here: given a stamp and a dossier it is a string, so the shape of a
|
|
9
|
+
committed report is testable without a database, a clock or a disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__all__ = ["render_report", "narrated", "is_pending", "NARRATION", "PENDING"]
|
|
15
|
+
|
|
16
|
+
NARRATION = "## Narración"
|
|
17
|
+
"""The section `/taskops:digest` replaces. A HEADING and not a comment marker, because the
|
|
18
|
+
file is read by people: an editor that hides the narration inside `<!-- -->` fences would be
|
|
19
|
+
optimising for the tool that writes it over the reader it is written for."""
|
|
20
|
+
|
|
21
|
+
PENDING = "_pendiente — generala con `taskops report day --digest`_"
|
|
22
|
+
"""The placeholder body. It names the COMMAND, so a report opened by somebody who has never
|
|
23
|
+
generated one still tells them what is missing and exactly what produces it."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_report(stamp: str, dossier: str) -> str:
|
|
27
|
+
"""Header, dossier, empty narration. The narration goes LAST on purpose.
|
|
28
|
+
|
|
29
|
+
A generated report whose first screen is prose invites the reader to trust the prose; put
|
|
30
|
+
the facts first and the narration is read as a reading OF them, which is what it is.
|
|
31
|
+
"""
|
|
32
|
+
return "\n".join([stamp, "", dossier.strip("\n"), "", NARRATION, "", PENDING, ""])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def narrated(report: str, prose: str) -> str:
|
|
36
|
+
"""The same report with its narration section replaced. Everything above it is untouched.
|
|
37
|
+
|
|
38
|
+
Splitting on the heading rather than rewriting the file from the dossier is what makes this
|
|
39
|
+
safe to run on a report somebody has already edited: the facts on disk stay exactly as they
|
|
40
|
+
were fingerprinted, and only the last section moves.
|
|
41
|
+
"""
|
|
42
|
+
head, marker, _ = report.partition(NARRATION)
|
|
43
|
+
body = head if marker else report.rstrip("\n") + "\n\n"
|
|
44
|
+
return "\n".join([body.rstrip("\n"), "", NARRATION, "", prose.strip("\n"), ""])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_pending(report: str) -> bool:
|
|
48
|
+
"""True when nothing has written the narration yet. A report whose prose somebody wrote by
|
|
49
|
+
hand must not be silently replaced, so the caller checks this before regenerating."""
|
|
50
|
+
_, marker, tail = report.partition(NARRATION)
|
|
51
|
+
return not marker or not tail.strip() or PENDING in tail
|