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,92 @@
1
+ """What a worker is TOLD. Two audiences, two texts, and the difference matters.
2
+
3
+ A sub-agent of the current session and a spawned process are not the same reader:
4
+
5
+ - A **sub-agent** shares the parent's working directory, so it has to be told which worktree is its
6
+ own and told never to switch branches. Nothing else stops it editing `main` — and if two of them
7
+ did, they would overwrite each other in the one place no lease can protect.
8
+ - A **process** starts in its worktree already, with `$TASKOPS_ROOT` pointing at the shared board, so
9
+ its brief can be short.
10
+
11
+ Both end the same way, and that sentence is the most important one in either: an agent that guesses
12
+ when the spec is wrong produces a commit somebody has to read and undo. Told to release instead, it
13
+ hands back everything it learned.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from ..contracts import Task
21
+ from .scheduler import branch_for
22
+
23
+ __all__ = ["prompt_for", "brief_for"]
24
+
25
+
26
+ def prompt_for(task: Task, actor: str = "") -> str:
27
+ """What the worker is told. Short on purpose — the SPEC lives in the task.
28
+
29
+ It names the claim explicitly (`task=<id>`), because a worker launched for one card must not go
30
+ shopping in the pool: the card is already assigned to it, and a bare `taskops_next` would hand it
31
+ whatever sorts first.
32
+
33
+ It NAMES the worker's actor id, and that is not cosmetic. A dispatched worker that was not told
34
+ who it is invents an identity — one called itself `agent:claude/worker` and claimed a card
35
+ assigned to a different worker, which is exactly the failure `engine.identity` warns about. The
36
+ id is also in `$TASKOPS_ACTOR`, so this is belt and braces on purpose: the environment is what
37
+ the tools read, and the prompt is what stops the model from overriding it with an argument.
38
+
39
+ The last sentence is the important one. A background agent that guesses when the spec is wrong
40
+ produces work nobody asked for and a commit somebody has to read; told to release instead, it
41
+ leaves the next agent everything it learned.
42
+ """
43
+ return (f"You are taskops worker `{actor}`. Do NOT use any other actor id — it is already "
44
+ f"set in your environment, so never pass `actor` to a taskops tool. "
45
+ f"Read .taskops/GUIDE.md, then run "
46
+ f"`taskops_next task={task['id']}` to claim your task and read its spec. "
47
+ f"Do the work, commit it on the branch the claim names, then close the task with "
48
+ f"`taskops_update task={task['id']} status=done` and a comment saying what you did. "
49
+ f"If you get stuck or the spec is wrong, do NOT guess: use "
50
+ f"`taskops_update status=released` with a comment explaining where you got to.")
51
+
52
+
53
+ def brief_for(root: Path, task: Task, actor: str, tree: Path) -> str:
54
+ """The prompt for a SUB-AGENT of the current session. What `dispatch` hands back.
55
+
56
+ Different from `prompt_for` in the two ways that matter for a sub-agent rather than a process:
57
+
58
+ - It names the WORKTREE PATH and says to work there. A sub-agent shares the parent's working
59
+ directory, so nothing stops it editing `main` unless told otherwise — and if two of them did,
60
+ they would overwrite each other in the one place no lease can protect.
61
+ - It says never to `git switch`. The worktree is ALREADY on the card's branch; switching in the
62
+ shared checkout would move the branch under every other agent at once, which is the failure
63
+ that makes people conclude parallel agents cannot work on separate branches.
64
+
65
+ `$TASKOPS_ROOT` is not needed here: a sub-agent inherits the session's environment and the
66
+ project resolves by walking up from the worktree, which is inside the repository.
67
+ """
68
+ return (
69
+ f"You are taskops worker `{actor}` on card {task['id']}: {task['title']}\n\n"
70
+ f"YOUR WORKTREE: {tree}\n"
71
+ f"Work ONLY inside that directory. It is already checked out on branch "
72
+ f"`{branch_for(task)}` — never run `git switch`/`git checkout <branch>`, because other "
73
+ f"workers share this repository and switching would move their branch too.\n\n"
74
+ f"Steps:\n"
75
+ f"1. `taskops_next repo_path={root} task={task['id']} actor={actor}` — claims it and "
76
+ f"prints the full spec plus a warning if another worker touches your files.\n"
77
+ f"2. Do the work in {tree}. To RE-READ your card at any point — the spec, the thread, the "
78
+ f"commits, and which other workers touch your files — call "
79
+ f"`taskops_ask repo_path={root} task={task['id']}`. That one is read-only and safe to call as "
80
+ f"often as you like, unlike step 1 which claims. Read .taskops/GUIDE.md if anything about the "
81
+ f"process is unclear.\n"
82
+ f"3. Commit inside your worktree: `git -C {tree} add -A && git -C {tree} commit -m '...' "
83
+ f"-m 'Task: {task['id']}'`. The trailer is what binds the commit to the card.\n"
84
+ f"4. `taskops_update repo_path={root} task={task['id']} actor={actor} status=done "
85
+ f"comment='<what you did>'`.\n\n"
86
+ f"If you get stuck or the spec is wrong, do NOT guess: "
87
+ f"`taskops_update repo_path={root} task={task['id']} actor={actor} status=released "
88
+ f"comment='<where you got to and what blocked you>'`. That returns the card with everything "
89
+ f"you learned, which is worth far more than a wrong commit."
90
+ )
91
+
92
+
@@ -0,0 +1,66 @@
1
+ """Cutting a long dossier into slices a single reading can actually cover.
2
+
3
+ A full-detail dossier for a whole project is not three paragraphs of facts: it is every card's
4
+ spec, every comment, every file of every commit. Handed to one call it fits in the window long
5
+ before it fits in the attention — the answer stops enumerating and starts summarising, which
6
+ is precisely the failure this card exists to fix.
7
+
8
+ So past a threshold the dossier is read in slices and the parts are stitched. The alternative
9
+ somebody always reaches for — trimming the prompt to fit — is banned outright: a report that
10
+ silently omits the last nine cards is worse than one that took three calls to write.
11
+
12
+ Pure: given a string it returns strings. The model lives in `narrate`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__ = ["CHUNK_CHARS", "slices"]
18
+
19
+ CHUNK_CHARS = 60_000
20
+ """Roughly 15k tokens of dossier per reading.
21
+
22
+ Not a context limit — the window is far larger. It is an ATTENTION budget, measured against
23
+ what the prompt asks for: a paragraph per card, naming that card's spec, files and comments.
24
+ Past this much input a single answer reliably starts collapsing cards into sentences, and the
25
+ narration silently becomes the summary it was supposed to replace. Lower, and a project pays
26
+ for stitching it does not need.
27
+ """
28
+
29
+ _BOUNDARIES = ("### ", "✓ **")
30
+ """Where a slice may be cut: a day heading in a range report, or a card block. Never mid-card —
31
+ half a card's commits under one reading and half under another produces two partial paragraphs
32
+ about the same work, and the stitch has no way to know they are the same card.
33
+ """
34
+
35
+
36
+ def slices(dossier: str, limit: int = CHUNK_CHARS) -> list[str]:
37
+ """The dossier as one string, or as several that each start with its title line.
38
+
39
+ The header — everything above the first card — rides along on EVERY slice, because it
40
+ carries the window, the counts and the language the narration must be written in.
41
+ """
42
+ if len(dossier) <= limit:
43
+ return [dossier]
44
+ head, blocks = _split(dossier)
45
+ out: list[str] = []
46
+ current = ""
47
+ for block in blocks:
48
+ if current and len(current) + len(block) > limit:
49
+ out.append(head + current)
50
+ current = ""
51
+ current += block
52
+ return [*out, head + current] if current else out or [dossier]
53
+
54
+
55
+ def _split(dossier: str) -> tuple[str, list[str]]:
56
+ """`(header, blocks)`, where a block starts at a boundary line and runs to the next one."""
57
+ head: list[str] = []
58
+ blocks: list[list[str]] = []
59
+ for line in dossier.splitlines(keepends=True):
60
+ if line.startswith(_BOUNDARIES):
61
+ blocks.append([line])
62
+ elif blocks:
63
+ blocks[-1].append(line)
64
+ else:
65
+ head.append(line)
66
+ return "".join(head), ["".join(block) for block in blocks]
@@ -0,0 +1,65 @@
1
+ """The cards that reached `done`, assembled with their commits and their sizes.
2
+
3
+ Split from `day` because it is the expensive half: every closed card needs its whole history
4
+ read back, and all of their commits need git. Keeping it here is what lets `day` stay a
5
+ description of the window rather than a description of a card.
6
+
7
+ The batching is the point. One `numstats` call covers every commit of every card closed that
8
+ day, so a report of twenty cards is one subprocess — not one per card, which is how a daily
9
+ report turns into a spinning terminal on a busy project.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import cast
15
+
16
+ from ..contracts import ClosedCard, CommitStat, Event, Task
17
+ from ..storage import Store
18
+ from .diffstat import numstats
19
+
20
+ __all__ = ["closed_cards"]
21
+
22
+
23
+ def closed_cards(store: Store, dones: list[Event]) -> list[ClosedCard]:
24
+ """One card per `done` event, oldest close first. Cards this clone does not have are
25
+ dropped — an event can name a task that is still in a teammate's log."""
26
+ histories = {done["task"]: store.events.of_task(done["task"]) for done in dones}
27
+ stats = numstats(store.root, [str(event["body"].get("sha", ""))
28
+ for history in histories.values() for event in history
29
+ if event["kind"] == "commit"])
30
+ out: list[ClosedCard] = []
31
+ for done in dones:
32
+ task = store.tasks.get(done["task"])
33
+ if task is not None:
34
+ out.append(_card(task, done, histories[done["task"]], stats))
35
+ return out
36
+
37
+
38
+ def _card(task: Task, done: Event, history: list[Event],
39
+ stats: dict[str, tuple[int, int]]) -> ClosedCard:
40
+ """`claimed_ts` is the LAST claim before the close, not the first.
41
+
42
+ A card released once and picked up again by somebody else was not being worked on in
43
+ between, and measuring from the first claim would report a two-hour task as two days.
44
+ """
45
+ claims = [e["ts"] for e in history
46
+ if e["kind"] == "claimed" and e["ts"] <= done["ts"]]
47
+ return ClosedCard(task=task, actor=done["actor"], done_ts=done["ts"],
48
+ claimed_ts=max(claims) if claims else done["ts"],
49
+ commits=[_stat(e, stats) for e in history if e["kind"] == "commit"])
50
+
51
+
52
+ def _stat(event: Event, stats: dict[str, tuple[int, int]]) -> CommitStat:
53
+ """A `commit` event plus what git says it weighed.
54
+
55
+ Coerced field by field, like `usecases.view._commit`: the body is an open dict written by
56
+ whatever taskops version ingested it, and a newer one may not have said `files` at all.
57
+ """
58
+ body = event["body"]
59
+ raw: object = body.get("files")
60
+ files = cast("list[object]", raw) if isinstance(raw, list) else []
61
+ sha = str(body.get("sha", ""))
62
+ adds, dels = stats.get(sha, (0, 0))
63
+ return CommitStat(sha=sha, subject=str(body.get("subject", "")),
64
+ files=[str(path) for path in files], actor=event["actor"],
65
+ ts=event["ts"], additions=adds, deletions=dels)
@@ -0,0 +1,126 @@
1
+ """One raw transcript entry -> one flat `LogEntry`. The shape-flattening, on its own.
2
+
3
+ The raw format nests differently per role: an assistant entry holds `message.content`, a LIST of blocks
4
+ each with its own type (`thinking`, `text`, `tool_use`), while a user entry's content may be a plain
5
+ string or a list containing `tool_result`. A reader wants one flat stream in time order, so one raw
6
+ entry can produce several `LogEntry`s.
7
+
8
+ Split from `transcript` because that module answers "where is the file" and this one answers "what does
9
+ a line mean" — and the second is the half that will need editing when the format moves.
10
+
11
+ Nothing is dropped. An entry this version does not recognise becomes `other` with whatever text can be
12
+ salvaged, because the format is not documented as stable and a silently shorter conversation is worse
13
+ than an odd-looking line.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, cast
19
+
20
+ from ..contracts import LogEntry
21
+ from ._blocks import result_text, summarise
22
+ from .transcript import clip
23
+
24
+ __all__ = ["flatten"]
25
+
26
+ _SKIP = frozenset({"mode", "permission-mode", "ai-title", "last-prompt", "queue-operation",
27
+ "file-history-snapshot", "file-history-delta", "attachment"})
28
+ """Entry types that are Claude Code's own bookkeeping, not conversation.
29
+
30
+ Verified against a real 1018-line transcript, where these were a third of it: mode switches, the
31
+ generated title, snapshots of files for undo. Keeping them would bury the twenty entries a person
32
+ actually wants to read.
33
+ """
34
+
35
+
36
+ def flatten(entry: dict[str, Any]) -> list[LogEntry]:
37
+ """Zero, one, or several readable entries from one raw line."""
38
+ kind = str(entry.get("type", ""))
39
+ if kind in _SKIP:
40
+ return []
41
+ session = str(entry.get("sessionId") or entry.get("session_id") or "")
42
+ ts = _time(entry)
43
+ message = entry.get("message")
44
+ if not isinstance(message, dict):
45
+ return _fallback(entry, session, ts)
46
+ return _from_message(cast("dict[str, Any]", message), kind, session, ts)
47
+
48
+
49
+ def _from_message(message: dict[str, Any], kind: str, session: str,
50
+ ts: float) -> list[LogEntry]:
51
+ content = message.get("content")
52
+ if isinstance(content, str):
53
+ return _entry("prompt" if kind == "user" else "text", content, "", ts, session)
54
+ if not isinstance(content, list):
55
+ return []
56
+ out: list[LogEntry] = []
57
+ for block in cast("list[object]", content):
58
+ if isinstance(block, dict):
59
+ out += _from_block(cast("dict[str, Any]", block), kind, session, ts)
60
+ return out
61
+
62
+
63
+ def _from_block(block: dict[str, Any], kind: str, session: str,
64
+ ts: float) -> list[LogEntry]:
65
+ """One content block. The four shapes that actually occur, plus a catch-all."""
66
+ shape = str(block.get("type", ""))
67
+ if shape == "thinking":
68
+ return _entry("thinking", str(block.get("thinking", "")), "", ts, session)
69
+ if shape == "text":
70
+ return _entry("prompt" if kind == "user" else "text",
71
+ str(block.get("text", "")), "", ts, session)
72
+ if shape == "tool_use":
73
+ name = str(block.get("name", "?"))
74
+ return _entry("tool", summarise(name, block.get("input")), name, ts, session)
75
+ if shape == "tool_result":
76
+ return _entry("result", result_text(block.get("content")), "", ts, session)
77
+ return _entry("other", shape, "", ts, session) if shape else []
78
+
79
+
80
+ def _fallback(entry: dict[str, Any], session: str, ts: float) -> list[LogEntry]:
81
+ """An entry with no `message`: system notices and anything new.
82
+
83
+ Kept rather than dropped, because the alternative is a conversation that is quietly missing the
84
+ line that explained why it stopped.
85
+ """
86
+ for key in ("content", "text", "summary"):
87
+ found = entry.get(key)
88
+ if isinstance(found, str) and found.strip():
89
+ return _entry("other", found, "", ts, session)
90
+ return []
91
+
92
+
93
+ def _entry(kind: str, text: str, tool: str, ts: float,
94
+ session: str) -> list[LogEntry]:
95
+ """One entry, or NONE when there is nothing to show.
96
+
97
+ Empty content is dropped, and `thinking` is why. Extended thinking is REDACTED in the transcript —
98
+ the block keeps its `signature` and its `thinking` field is the empty string — so rendering it
99
+ produced a blank `· thinking` line before every single assistant turn. That is not a formatting
100
+ nit: it doubled the length of the log with rows that say nothing, and it looked like taskops had
101
+ failed to read something rather than like there being nothing to read.
102
+
103
+ A `tool` entry survives an empty text, because the tool NAME is the content there.
104
+ """
105
+ body = clip(text)
106
+ if not body and kind != "tool":
107
+ return []
108
+ return [LogEntry(kind=kind, # type: ignore[typeddict-item]
109
+ text=body, tool=tool, ts=ts, session=session)]
110
+
111
+
112
+ def _time(entry: dict[str, Any]) -> float:
113
+ """The entry's timestamp as epoch seconds, or 0.
114
+
115
+ The transcript writes ISO 8601; the rest of taskops uses epoch floats, so converting here keeps
116
+ every consumer on one clock.
117
+ """
118
+ from datetime import datetime
119
+
120
+ raw = entry.get("timestamp")
121
+ if not isinstance(raw, str):
122
+ return 0.0
123
+ try:
124
+ return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
125
+ except ValueError:
126
+ return 0.0
@@ -0,0 +1,55 @@
1
+ """The `stream-json` wire format, read. Pure: a line in, a fragment out, no process in sight.
2
+
3
+ Split from `_stream` so the shape of the CLI's events can be tested from string literals, which
4
+ is the only way to pin the one trap in them.
5
+
6
+ Every stdout line of `claude --output-format stream-json --include-partial-messages` is one JSON
7
+ object. Visible text arrives as
8
+ `{"type": "stream_event", "event": {"delta": {"type": "text_delta", "text": "…"}}}` — but the
9
+ THINKING deltas arrive FIRST and carry `"text": None`, so a reader that takes `delta["text"]`
10
+ because the key is there yields `None` and crashes on the first join. Filter on the delta's TYPE.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, cast
17
+
18
+ from .._errors import NarrationFailed
19
+
20
+ __all__ = ["parsed", "text_delta", "check_result"]
21
+
22
+
23
+ def parsed(line: str) -> dict[str, Any]:
24
+ """One stdout line as an object, or `{}`.
25
+
26
+ A line that is not JSON is skipped rather than fatal: the CLI prints the odd warning on
27
+ stdout, and throwing away a finished narration over one of them would be absurd.
28
+ """
29
+ try:
30
+ loaded: object = json.loads(line)
31
+ except json.JSONDecodeError:
32
+ return {}
33
+ return cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {}
34
+
35
+
36
+ def text_delta(event: dict[str, Any]) -> str:
37
+ """The visible text of a partial message, or "" for anything else — including a thinking
38
+ delta, whose `text` is `None` and which always arrives first."""
39
+ if event.get("type") != "stream_event":
40
+ return ""
41
+ inner: dict[str, Any] = event.get("event") or {}
42
+ delta: dict[str, Any] = inner.get("delta") or {}
43
+ return str(delta.get("text") or "") if delta.get("type") == "text_delta" else ""
44
+
45
+
46
+ def check_result(event: dict[str, Any]) -> None:
47
+ """The `result` event ends the answer, and may end it because it failed.
48
+
49
+ A usage limit, a refused flag and a model that does not exist all arrive here with a zero
50
+ exit code, so a caller that only looked at the exit status would write an empty narration
51
+ into the report and call it done.
52
+ """
53
+ if event.get("is_error"):
54
+ detail = str(event.get("result") or event.get("subtype") or "no reason given")
55
+ raise NarrationFailed(f"claude ended the narration: {detail}")
@@ -0,0 +1,51 @@
1
+ """The cards a window OPENED — planned work, with the edges that say what can start.
2
+
3
+ Split from `day` for the same reason `_closed` is: assembling a card needs the graph, and the
4
+ module that describes a window should not also know how the `deps` table is read from both
5
+ ends.
6
+
7
+ This exists because a freshly planned project reported nothing at all. `in_flight` covers
8
+ `claimed`/`in_progress`/`review` and `blocked` covers `blocked`, so `backlog` and `ready` —
9
+ which is ALL planned-but-unstarted work — belonged to no section, and a day spent writing four
10
+ specs rendered as `0 closed · 0 in flight · 0 blocked` over three empty headings. The same
11
+ class of bug as a finished project answering `tasks list` with silence: a filter that describes
12
+ some states, and everything else falling through it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .._types import CLOSED_STATUSES
18
+ from ..contracts import Event, OpenedCard, Task
19
+ from ..storage import Store
20
+
21
+ __all__ = ["opened_cards", "waiting_tasks"]
22
+
23
+
24
+ def opened_cards(store: Store, events: list[Event]) -> list[OpenedCard]:
25
+ """One card per `created` event in the window, oldest first, still open.
26
+
27
+ Created AND closed inside the window means `closed` already tells the whole story with its
28
+ commits and its conversation; listing it here too would invite a reader to count it twice.
29
+ A card this clone does not have is dropped, like everywhere else — an event can name a task
30
+ that still lives in a teammate's log.
31
+ """
32
+ out: list[OpenedCard] = []
33
+ for event in events:
34
+ task = store.tasks.get(event["task"]) if event["kind"] == "created" else None
35
+ if task is not None and task["status"] not in CLOSED_STATUSES:
36
+ out.append(OpenedCard(task=task,
37
+ waiting_on=store.deps.open_blockers_of(task["id"]),
38
+ blocking=store.deps.dependents_of(task["id"])))
39
+ return out
40
+
41
+
42
+ def waiting_tasks(touched: list[Task], opened: list[OpenedCard]) -> list[Task]:
43
+ """Open cards nobody has started — minus the ones this window created.
44
+
45
+ Those are in `opened`, where they carry their dependencies as well, and a card printed in
46
+ both sections reads as two cards to anybody scanning. So every open card the window touched
47
+ appears exactly once, and which section it is in says whether it is new.
48
+ """
49
+ new = {card["task"]["id"] for card in opened}
50
+ return [t for t in touched
51
+ if t["status"] in ("ready", "backlog") and t["id"] not in new]
@@ -0,0 +1,80 @@
1
+ """Running a detached process in a git worktree. The mechanics, not the policy.
2
+
3
+ Split from `worker` by what a reader needs to know: that module decides WHAT to launch and what to
4
+ tell it, this one knows how to get a process running somewhere it cannot damage anybody else.
5
+
6
+ Two decisions carry it:
7
+
8
+ **A worktree per worker.** Parallel agents editing one working tree overwrite each other, and no
9
+ amount of lease bookkeeping fixes that — a lease coordinates who owns a TASK, not whose bytes are on
10
+ disk. `git worktree` gives each worker its own directory on its own branch, and they merge through
11
+ git like any two developers.
12
+
13
+ **Detached, output to a file.** A dispatched worker outlives the call that launched it, so it is
14
+ started in its own session and its stdout goes to a file. Not a pipe: nobody is reading it, and a
15
+ full pipe buffer deadlocks the child.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ __all__ = ["make_worktree", "spawn"]
25
+
26
+
27
+ def make_worktree(root: Path, tree: Path, branch: str) -> bool:
28
+ """Create the worktree; True if it is usable. Reuses the branch when it already exists.
29
+
30
+ Two `git worktree add` forms, because the branch may or may not exist yet: `-b` creates it, and
31
+ plain `add` attaches to one an earlier dispatch already made. Trying `-b` on an existing branch
32
+ fails, which would strand a re-dispatched task in the main checkout beside its siblings.
33
+ """
34
+ tree.parent.mkdir(parents=True, exist_ok=True)
35
+ if tree.is_dir():
36
+ return True
37
+ if _git(root, "worktree", "add", "-b", branch, str(tree)):
38
+ return True
39
+ return _git(root, "worktree", "add", str(tree), branch)
40
+
41
+
42
+ def spawn(command: list[str], *, cwd: Path, log: Path, env: dict[str, str],
43
+ drop: tuple[str, ...] = ()) -> int:
44
+ """Start a detached process, output to `log`. Returns its pid, or 0 if it could not start.
45
+
46
+ `start_new_session` puts it in its own process group, so a ctrl-C in the terminal that ran
47
+ dispatch does not kill the workers it launched — they are meant to outlive it.
48
+
49
+ The environment is INHERITED and then extended, because a worker needs the developer's PATH and
50
+ credentials to run `claude` at all. `drop` is the other direction, and merging alone cannot
51
+ express it: a key set to `""` is still a key the child sees, so the names in `drop` are POPPED
52
+ from the merged copy and reach the child as genuinely unset. WHICH names is policy, and policy
53
+ lives in `worker`.
54
+ """
55
+ log.parent.mkdir(parents=True, exist_ok=True)
56
+ child = {**os.environ, **env}
57
+ for name in drop:
58
+ child.pop(name, None)
59
+ try:
60
+ with log.open("wb") as sink:
61
+ process = subprocess.Popen(command, cwd=cwd, stdout=sink,
62
+ stderr=subprocess.STDOUT,
63
+ stdin=subprocess.DEVNULL,
64
+ start_new_session=True,
65
+ env=child)
66
+ return process.pid
67
+ except OSError:
68
+ # 0 rather than raising: dispatching five workers where one fails to start should report
69
+ # four running and one that did not, never lose the four.
70
+ return 0
71
+
72
+
73
+ def _git(root: Path, *args: str) -> bool:
74
+ """True if the command succeeded. Never raises, like everything that shells out to git here."""
75
+ try:
76
+ done = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True,
77
+ timeout=30, check=False)
78
+ except (OSError, subprocess.SubprocessError):
79
+ return False
80
+ return done.returncode == 0
@@ -0,0 +1,98 @@
1
+ """What the model is asked for. Prose, kept out of `narrate` so the mechanism stays readable.
2
+
3
+ The whole design of these three strings is one instruction: the narration is the DURABLE
4
+ RECORD, not a summary of it. Somebody who reads this instead of the git log has to come away
5
+ knowing what was asked, what was delivered, what was decided, and what is still owed. So the
6
+ prompt demands a paragraph per card and says out loud that length is not the problem —
7
+ because a model left to its own judgement writes three tidy paragraphs about twenty cards,
8
+ which is exactly the report this replaces.
9
+
10
+ "Invent NOTHING" survives from the first version and is the one rule that outranks the rest:
11
+ an exhaustive report that is partly fiction is worse than a short true one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __all__ = ["PROMPT", "CHUNK_PROMPT", "STITCH_PROMPT"]
17
+
18
+ _RULES = """Rules:
19
+ - EVERY closed card gets its own paragraph. Do not group cards together to save room, and do
20
+ not drop a card because it looks small. If the dossier lists 24 cards, 24 cards are covered.
21
+ - Each card's paragraph says, in this order: what was ASKED (its **Pedido** block), what was
22
+ actually DELIVERED (the commits, the files they touched, the diff size), what was DECIDED or
23
+ DISCOVERED along the way (its comments — quote the phrase that matters), and what it cost
24
+ (how long it was held, how many commits, how big the diff).
25
+ - Where the delivery does not match the ask — something asked for and not visible in the
26
+ commits, or something shipped that nobody asked for — SAY SO. That gap is the single most
27
+ valuable line this report can contain.
28
+ - Name the decisions and the surprises: the thing a reader would not guess from the titles.
29
+ - LENGTH IS NOT A PROBLEM. OMISSION IS. This document is read INSTEAD of the git log.
30
+ - Invent NOTHING. Every claim must trace to a line in the dossier. If the dossier is silent
31
+ about something, the narration is silent about it too — do not fill a gap with a guess.
32
+ - Do not flatter anybody and do not editorialise about pace.
33
+ - Markdown. Use `###` for sub-headings; no top-level heading (the section already has one).
34
+ """
35
+
36
+ _STRUCTURE = """Structure it in four parts, in this order:
37
+ 1. **Lo que necesita un humano** — anything blocked, anything still claimed, anything that
38
+ looks wrong (a card closed minutes after being claimed with no commits, a spec whose
39
+ delivery you cannot find). If there is nothing, one line saying so.
40
+ 2. **Por área** — one `###` section per area of the codebase the work touched (infer the areas
41
+ from the file paths in the commits, not from card ids), and inside it the paragraph per card.
42
+ 3. **Decisiones y sorpresas** — what was decided, what was discovered, what was reversed.
43
+ 4. **Lo que queda abierto** — what is still owed: unfinished cards, follow-ups named in a
44
+ comment, debts somebody wrote down and nobody closed.
45
+ """
46
+
47
+ PROMPT = ("""You are writing the narration of an engineering report. It is the durable record of
48
+ what was done — somebody reads it a month from now INSTEAD of the git log, and it has to leave
49
+ them knowing what happened.
50
+
51
+ Below is the dossier: what closed, each card's spec, its commits with their files and diff
52
+ sizes, the whole conversation, and a roll-up per actor. It was generated from an append-only
53
+ event log, so every fact in it is true.
54
+
55
+ Write the narration in the SAME LANGUAGE the cards and comments are written in.
56
+
57
+ """
58
+ + _STRUCTURE + "\n" + _RULES + """
59
+ Output ONLY the narration text.
60
+
61
+ --- DOSSIER ---
62
+ """)
63
+
64
+ CHUNK_PROMPT = ("""You are writing PART of the narration of an engineering report. The dossier was
65
+ too long for one reading, so you are given one slice of it; another pass will stitch the parts
66
+ together. Cover YOUR slice completely and say nothing about what is not in it.
67
+
68
+ Write in the SAME LANGUAGE the cards and comments are written in.
69
+
70
+ """
71
+ + _RULES + """
72
+ Do not write an introduction, a conclusion, or a "lo que queda abierto" section — this is a
73
+ middle of a document. Start straight at the `###` sections.
74
+
75
+ Output ONLY the narration text for this slice.
76
+
77
+ --- DOSSIER SLICE ---
78
+ """)
79
+
80
+ STITCH_PROMPT = ("""Below are the parts of one engineering report's narration, written from
81
+ consecutive slices of the same dossier, in order.
82
+
83
+ Assemble them into ONE document with the structure below. KEEP EVERY CARD PARAGRAPH — merge the
84
+ `###` sections that describe the same area of the codebase, reorder them, and rewrite the
85
+ connecting sentences, but do not delete, shorten or summarise a card's paragraph. Dropping a
86
+ card here would defeat the reason the dossier was read in slices at all.
87
+
88
+ Add the parts a slice could not write: the opening "lo que necesita un humano", and the closing
89
+ "lo que queda abierto". Both must come from what the parts actually say — invent nothing.
90
+
91
+ """
92
+ + _STRUCTURE + """
93
+ Write in the SAME LANGUAGE the parts are written in. Markdown, no top-level heading.
94
+
95
+ Output ONLY the assembled narration.
96
+
97
+ --- PARTS ---
98
+ """)