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.
Files changed (193) hide show
  1. taskops/__init__.py +39 -0
  2. taskops/_clock.py +32 -0
  3. taskops/_errors.py +160 -0
  4. taskops/_ids.py +63 -0
  5. taskops/_types.py +106 -0
  6. taskops/_version.py +7 -0
  7. taskops/assets/GUIDE.md +220 -0
  8. taskops/contracts/__init__.py +96 -0
  9. taskops/contracts/_fields.py +113 -0
  10. taskops/contracts/actor.py +30 -0
  11. taskops/contracts/board.py +142 -0
  12. taskops/contracts/commit.py +36 -0
  13. taskops/contracts/day.py +150 -0
  14. taskops/contracts/dep.py +23 -0
  15. taskops/contracts/event.py +58 -0
  16. taskops/contracts/gitstate.py +45 -0
  17. taskops/contracts/index.py +37 -0
  18. taskops/contracts/lease.py +43 -0
  19. taskops/contracts/log.py +59 -0
  20. taskops/contracts/remote.py +48 -0
  21. taskops/contracts/results.py +84 -0
  22. taskops/contracts/task.py +94 -0
  23. taskops/contracts/tools.py +110 -0
  24. taskops/contracts/wire.py +60 -0
  25. taskops/engine/__init__.py +34 -0
  26. taskops/engine/_blocks.py +48 -0
  27. taskops/engine/_briefs.py +92 -0
  28. taskops/engine/_chunks.py +66 -0
  29. taskops/engine/_closed.py +65 -0
  30. taskops/engine/_entries.py +126 -0
  31. taskops/engine/_events.py +55 -0
  32. taskops/engine/_opened.py +51 -0
  33. taskops/engine/_process.py +80 -0
  34. taskops/engine/_prompts.py +98 -0
  35. taskops/engine/_stream.py +129 -0
  36. taskops/engine/activity.py +94 -0
  37. taskops/engine/bus.py +42 -0
  38. taskops/engine/commitline.py +110 -0
  39. taskops/engine/day.py +142 -0
  40. taskops/engine/diffstat.py +63 -0
  41. taskops/engine/gitio.py +108 -0
  42. taskops/engine/gitstate.py +96 -0
  43. taskops/engine/history.py +76 -0
  44. taskops/engine/identity.py +84 -0
  45. taskops/engine/log.py +68 -0
  46. taskops/engine/machine.py +160 -0
  47. taskops/engine/narrate.py +91 -0
  48. taskops/engine/project.py +57 -0
  49. taskops/engine/replay.py +142 -0
  50. taskops/engine/reports.py +67 -0
  51. taskops/engine/scheduler.py +135 -0
  52. taskops/engine/transcript.py +127 -0
  53. taskops/engine/wire.py +92 -0
  54. taskops/engine/worker.py +115 -0
  55. taskops/py.typed +0 -0
  56. taskops/render/__init__.py +40 -0
  57. taskops/render/_closed_days.py +64 -0
  58. taskops/render/_dossier.py +68 -0
  59. taskops/render/_opened.py +64 -0
  60. taskops/render/_sections.py +51 -0
  61. taskops/render/_tasklist.py +72 -0
  62. taskops/render/_text.py +74 -0
  63. taskops/render/_verbatim.py +65 -0
  64. taskops/render/ansi.py +92 -0
  65. taskops/render/board.py +55 -0
  66. taskops/render/day.py +102 -0
  67. taskops/render/dispatch.py +104 -0
  68. taskops/render/inbox.py +27 -0
  69. taskops/render/log.py +47 -0
  70. taskops/render/recover.py +64 -0
  71. taskops/render/report.py +51 -0
  72. taskops/render/reports.py +57 -0
  73. taskops/render/results.py +96 -0
  74. taskops/render/session.py +75 -0
  75. taskops/render/task.py +88 -0
  76. taskops/render/tasklist.py +65 -0
  77. taskops/storage/__init__.py +36 -0
  78. taskops/storage/_ddl.py +78 -0
  79. taskops/storage/_delivered.py +51 -0
  80. taskops/storage/_deps.py +69 -0
  81. taskops/storage/_events.py +127 -0
  82. taskops/storage/_leases.py +108 -0
  83. taskops/storage/_rows.py +90 -0
  84. taskops/storage/_tasks.py +124 -0
  85. taskops/storage/locate.py +76 -0
  86. taskops/storage/schema.py +66 -0
  87. taskops/storage/store.py +120 -0
  88. taskops/storage/sync.py +139 -0
  89. taskops/transports/__init__.py +6 -0
  90. taskops/transports/cli/__init__.py +0 -0
  91. taskops/transports/cli/commands/__init__.py +3 -0
  92. taskops/transports/cli/commands/_digest.py +64 -0
  93. taskops/transports/cli/commands/_serve_init.py +65 -0
  94. taskops/transports/cli/commands/_shared.py +50 -0
  95. taskops/transports/cli/commands/_tasks_args.py +112 -0
  96. taskops/transports/cli/commands/_window.py +45 -0
  97. taskops/transports/cli/commands/ask.py +27 -0
  98. taskops/transports/cli/commands/dispatch.py +43 -0
  99. taskops/transports/cli/commands/init.py +48 -0
  100. taskops/transports/cli/commands/log.py +22 -0
  101. taskops/transports/cli/commands/plan.py +43 -0
  102. taskops/transports/cli/commands/pushpull.py +53 -0
  103. taskops/transports/cli/commands/recover.py +30 -0
  104. taskops/transports/cli/commands/remote.py +56 -0
  105. taskops/transports/cli/commands/report.py +82 -0
  106. taskops/transports/cli/commands/run_.py +60 -0
  107. taskops/transports/cli/commands/serve.py +74 -0
  108. taskops/transports/cli/commands/sync.py +22 -0
  109. taskops/transports/cli/commands/tasks.py +79 -0
  110. taskops/transports/cli/commands/ui.py +72 -0
  111. taskops/transports/cli/commands/update.py +25 -0
  112. taskops/transports/cli/main.py +85 -0
  113. taskops/transports/hooks/__init__.py +15 -0
  114. taskops/transports/hooks/__main__.py +63 -0
  115. taskops/transports/hooks/_args.py +28 -0
  116. taskops/transports/hooks/claude.py +78 -0
  117. taskops/transports/hooks/commit.py +61 -0
  118. taskops/transports/hooks/events.py +107 -0
  119. taskops/transports/hooks/record.py +55 -0
  120. taskops/transports/http/__init__.py +21 -0
  121. taskops/transports/http/_handler.py +132 -0
  122. taskops/transports/http/_wire.py +79 -0
  123. taskops/transports/http/_wsframes.py +116 -0
  124. taskops/transports/http/agentapi.py +69 -0
  125. taskops/transports/http/api.py +122 -0
  126. taskops/transports/http/exchange.py +80 -0
  127. taskops/transports/http/live.py +160 -0
  128. taskops/transports/http/policy.py +110 -0
  129. taskops/transports/http/projects.py +116 -0
  130. taskops/transports/http/reports.py +75 -0
  131. taskops/transports/http/router.py +90 -0
  132. taskops/transports/http/server.py +50 -0
  133. taskops/transports/http/static.py +86 -0
  134. taskops/transports/http/ui/app.js +59 -0
  135. taskops/transports/http/ui/index.html +23 -0
  136. taskops/transports/http/ui/style.css +1 -0
  137. taskops/transports/http/websocket.py +60 -0
  138. taskops/transports/mcp/__init__.py +19 -0
  139. taskops/transports/mcp/__main__.py +7 -0
  140. taskops/transports/mcp/_descriptions.py +102 -0
  141. taskops/transports/mcp/_reads.py +82 -0
  142. taskops/transports/mcp/_writes.py +72 -0
  143. taskops/transports/mcp/answers.py +44 -0
  144. taskops/transports/mcp/arguments.py +104 -0
  145. taskops/transports/mcp/dispatch.py +47 -0
  146. taskops/transports/mcp/protocol.py +83 -0
  147. taskops/transports/mcp/schema.py +50 -0
  148. taskops/transports/mcp/server.py +49 -0
  149. taskops/transports/mcp/tools.py +66 -0
  150. taskops/usecases/__init__.py +56 -0
  151. taskops/usecases/_entry.py +84 -0
  152. taskops/usecases/_facts.py +49 -0
  153. taskops/usecases/_freeing.py +93 -0
  154. taskops/usecases/_gitignore.py +94 -0
  155. taskops/usecases/_mirroring.py +57 -0
  156. taskops/usecases/_narrating.py +77 -0
  157. taskops/usecases/_project.py +67 -0
  158. taskops/usecases/_range.py +102 -0
  159. taskops/usecases/_reasons.py +54 -0
  160. taskops/usecases/_remotefile.py +62 -0
  161. taskops/usecases/_reportsync.py +108 -0
  162. taskops/usecases/_routing.py +91 -0
  163. taskops/usecases/_wireclient.py +141 -0
  164. taskops/usecases/ask.py +41 -0
  165. taskops/usecases/claim.py +104 -0
  166. taskops/usecases/dispatch.py +160 -0
  167. taskops/usecases/dossier.py +155 -0
  168. taskops/usecases/edit.py +71 -0
  169. taskops/usecases/exchange.py +86 -0
  170. taskops/usecases/feed.py +138 -0
  171. taskops/usecases/guard.py +122 -0
  172. taskops/usecases/hooks.py +127 -0
  173. taskops/usecases/index.py +62 -0
  174. taskops/usecases/ingest.py +60 -0
  175. taskops/usecases/log.py +134 -0
  176. taskops/usecases/narration.py +91 -0
  177. taskops/usecases/plan.py +128 -0
  178. taskops/usecases/pushpull.py +119 -0
  179. taskops/usecases/recover.py +84 -0
  180. taskops/usecases/remote.py +78 -0
  181. taskops/usecases/report.py +87 -0
  182. taskops/usecases/reportfile.py +106 -0
  183. taskops/usecases/session.py +134 -0
  184. taskops/usecases/setup.py +92 -0
  185. taskops/usecases/sync.py +78 -0
  186. taskops/usecases/update.py +123 -0
  187. taskops/usecases/view.py +112 -0
  188. taskops_cli-0.2.0.dist-info/METADATA +291 -0
  189. taskops_cli-0.2.0.dist-info/RECORD +193 -0
  190. taskops_cli-0.2.0.dist-info/WHEEL +5 -0
  191. taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
  192. taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  193. 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)
@@ -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
@@ -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."
@@ -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