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,45 @@
1
+ """Where a task's code actually IS — on a branch, and whether anybody else can see it.
2
+
3
+ The gap this closes: a task marked `done` whose commits never left the machine is invisible to every
4
+ teammate, and the board said "done" anyway. Commits proved that work happened; this proves it is
5
+ REACHABLE. Those are different claims and only one of them was being made.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TypedDict
11
+
12
+ __all__ = ["BranchState"]
13
+
14
+
15
+ class BranchState(TypedDict):
16
+ """One branch's relationship to its remote."""
17
+
18
+ branch: str
19
+ upstream: str
20
+ """`origin/tk/tk-4f2a9c/…`, or "" when the branch has never been pushed at all.
21
+
22
+ Empty is the common case for a task branch and the one that matters most: it means the work
23
+ exists on exactly one laptop.
24
+ """
25
+
26
+ ahead: int
27
+ """Commits here the remote does not have. Nonzero = unpushed work."""
28
+
29
+ behind: int
30
+ """Commits the remote has and this branch does not — somebody else moved it."""
31
+
32
+ pushed: bool
33
+ """`ahead == 0` AND there is an upstream. The one boolean a card needs.
34
+
35
+ Derived rather than left to each reader, because "pushed" from two integers and a string is a
36
+ calculation three renderers would each get subtly wrong.
37
+ """
38
+
39
+ exists: bool
40
+ """False when the branch is not in this repository at all.
41
+
42
+ Not the same as unpushed: an agent on another machine holds a lease naming a branch this clone
43
+ has never heard of, and a board that reported that as "0 commits, unpushed" would be describing
44
+ a branch it cannot see as though it had looked.
45
+ """
@@ -0,0 +1,37 @@
1
+ """The INDEX of what has been written up — one row per file in `.taskops/reports/`.
2
+
3
+ Deliberately not `ReportFile`: that carries the whole markdown, and a listing of thirty days
4
+ that shipped thirty dossiers would be a megabyte of text nobody on the screen is reading. This
5
+ is the row you click, and the body arrives on `GET /api/report` when you do.
6
+
7
+ `label` is the file's STEM and is treated as an opaque string everywhere. Today it is a date;
8
+ a report over a range is named for the range (`2026-07-22..2026-07-28`, `all`), and anything
9
+ that parsed the label as a day would break on the first one of those.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TypedDict
15
+
16
+ __all__ = ["ReportEntry"]
17
+
18
+
19
+ class ReportEntry(TypedDict):
20
+ label: str
21
+ """The file's stem — a date, or a range. Opaque: what you pass back to read or narrate it."""
22
+
23
+ path: str
24
+ exists: bool
25
+ """False for the one row that is not a file: today, when nobody has written it up yet. It
26
+ is listed anyway, because 'generate today's report' is the reason this screen exists."""
27
+
28
+ stale: bool
29
+ missing_events: int
30
+ """How many of the day's events landed after the report was generated. Always 0 for a label
31
+ that is not a single day — a range has no window this can be counted against."""
32
+
33
+ has_narration: bool
34
+ """Whether the prose section has been written. The dossier's facts are free to regenerate;
35
+ the narration is the part a model was paid for and a human may have edited."""
36
+
37
+ bytes: int
@@ -0,0 +1,43 @@
1
+ """The lease — a claim that expires, which is what makes a crash survivable.
2
+
3
+ A boolean `assigned_to` column cannot express "this agent said it was working on
4
+ it and then its process was killed", so a board built on one accumulates tasks
5
+ nobody is doing and nobody can take. A lease has a deadline: every taskops call
6
+ the holder makes pushes it out, and silence hands the task back.
7
+
8
+ Not a filesystem lock, for the same reason: a lock file outlives the process that
9
+ took it, and cleaning one up correctly is exactly the problem a TTL already
10
+ solved.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TypedDict
16
+
17
+ __all__ = ["Lease"]
18
+
19
+
20
+ class Lease(TypedDict):
21
+ """Who is on a task, until when, and where they are doing it."""
22
+
23
+ task: str
24
+ actor: str
25
+
26
+ session: str
27
+ """The Claude Code session id, when the claim came from an agent.
28
+
29
+ The join key to everything outside taskops: the transcript at
30
+ `~/.claude/projects/<slug>/<session>.jsonl`, and the hook invocations that
31
+ report activity. Without it, a live board can show that a task is claimed but
32
+ not what its agent is doing right now.
33
+ """
34
+
35
+ branch: str
36
+ """`tk/<id>/<slug>`, once the agent creates it. Empty until then — a claim
37
+ happens before the branch, and the guard that binds commits to tasks needs to
38
+ tell "no branch yet" apart from "on the wrong branch"."""
39
+
40
+ acquired: float
41
+ expires: float
42
+ """When silence gives the task back. Compared against `_clock.now()`, so it
43
+ is wall-clock and survives a restart on either side."""
@@ -0,0 +1,59 @@
1
+ """An agent's conversation, as the card shows it.
2
+
3
+ Claude Code writes every session to a JSONL transcript. taskops does NOT copy those into its own event
4
+ log, and that is deliberate: one session here was 198 KB for 39 entries, `events.jsonl` is committed to
5
+ git, and the whole value of that file is that a human can read its diff. So the card stores a POINTER
6
+ and the transcript is read on demand.
7
+
8
+ The honest consequence, worth stating where the type is defined: a transcript is LOCAL. A teammate who
9
+ pulls the log gets the card, the commits and the conversation summary, but not the transcript — that
10
+ file only exists on the machine the agent ran on.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Literal, TypedDict
16
+
17
+ __all__ = ["LogEntry", "SessionLog", "EntryKind"]
18
+
19
+ EntryKind = Literal["prompt", "thinking", "text", "tool", "result", "other"]
20
+ """What one entry IS, flattened from the transcript's shape.
21
+
22
+ The raw format nests differently per role — an assistant message holds a list of content blocks, each
23
+ with its own type — and a reader wants one flat stream. `other` is the escape hatch: the format is not
24
+ documented as stable, so an entry this version does not recognise is shown rather than dropped.
25
+ """
26
+
27
+
28
+ class LogEntry(TypedDict):
29
+ """One turn, one thought, or one tool call."""
30
+
31
+ kind: EntryKind
32
+ text: str
33
+ """The readable content. For a tool call, a one-line summary rather than the arguments — a
34
+ dashboard showing forty lines of JSON per Edit is a dashboard nobody scrolls."""
35
+
36
+ tool: str
37
+ """The tool name for `kind == "tool"`, else "". Kept separate from `text` so a viewer can badge
38
+ it without parsing prose back apart."""
39
+
40
+ ts: float
41
+ session: str
42
+
43
+
44
+ class SessionLog(TypedDict):
45
+ """Everything one card's agents said, in order."""
46
+
47
+ task: str
48
+ sessions: list[str]
49
+ """The session ids found for this card. More than one is normal: a card can be released and
50
+ picked up again, and each attempt is its own session."""
51
+
52
+ entries: list[LogEntry]
53
+ source: str
54
+ """Where the transcripts were read from, so a viewer showing nothing can say WHY — a missing
55
+ directory and an empty conversation look identical otherwise."""
56
+
57
+ truncated: bool
58
+ """True when entries were dropped to stay under a limit. Reported rather than silent: a viewer
59
+ that quietly showed the first hundred turns of a long session would be lying about the ending."""
@@ -0,0 +1,48 @@
1
+ """The ONE server this project syncs against, and this machine's place in its log.
2
+
3
+ One remote per project, deliberately. Two would be federation — the same event arriving
4
+ under two cursors, a report whose "who saw more" question has three answers — and none of
5
+ that is designed. `remote add` refuses the second one by name rather than silently keeping
6
+ a list nobody reads.
7
+
8
+ This is the only contract that carries a SECRET. It is written to `.taskops/remote.json`
9
+ with mode 0600, that path is inside the block `taskops init` gitignores, and no renderer
10
+ ever prints `token` — a token in a commit is the exact disaster the file mode exists to
11
+ prevent, and it survives every rotation of the branch it landed on.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TypedDict
17
+
18
+ __all__ = ["Remote"]
19
+
20
+
21
+ class Remote(TypedDict):
22
+ url: str
23
+ """The server's base, with no trailing slash — `https://taskops.example.com`."""
24
+
25
+ token: str
26
+ """The bearer sent on every request. Never printed, never rendered, never committed."""
27
+
28
+ pushed: int
29
+ """The last LOCAL `seq` this machine has sent UP. Its own number, never the server's.
30
+
31
+ The first version drained the `exported` flag instead — shared with the git-log export —
32
+ and on any project that had ever run `taskops sync`, everything was already marked and
33
+ `push` sent nothing, silently, forever. Found live on the first real project connected:
34
+ a 370-event board pushed as `0 event(s) out`. Two sinks need two cursors.
35
+ """
36
+
37
+ cursor: int
38
+ """The last `seq` this machine imported FROM THAT SERVER.
39
+
40
+ A SERVER-side number, and it is only ever compared against server-side numbers: every
41
+ sqlite numbers its own rows, so measuring this against a local `seq` would be a
42
+ coincidence pretending to be an ordering.
43
+
44
+ If the server forgets it — a store recreated from scratch — the next `GET /api/sync`
45
+ answers from 0 and the client re-imports everything. That is a no-op, not a repair
46
+ job: ids are content hashes, so `relay` accepts each event exactly once no matter how
47
+ many times it is offered.
48
+ """
@@ -0,0 +1,84 @@
1
+ """What the use cases hand back — the shapes the renderers turn into text.
2
+
3
+ Separate from the entities because they answer a QUESTION rather than describing
4
+ a thing: a `Claim` is not stored anywhere, it is the assembled answer to "what
5
+ should I work on", including the parts the agent would otherwise have to ask three
6
+ more times for.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TypedDict
12
+
13
+ from .dep import Dep
14
+ from .event import Inbox
15
+ from .lease import Lease
16
+ from .task import Task, TaskView
17
+
18
+ __all__ = ["PlanResult", "Claim", "NextResult", "UpdateResult", "EditResult"]
19
+
20
+
21
+ class PlanResult(TypedDict):
22
+ """What a decomposition created. Returned in creation order, which is the
23
+ order the caller listed them — so an agent can map its own plan onto ids."""
24
+
25
+ created: list[Task]
26
+ deps: list[Dep]
27
+ unblocked: list[str]
28
+ """Ids that came out of `plan` already pickable. Usually the leaves; if this
29
+ is empty the caller built a graph where nothing can start, which is a planning
30
+ bug worth seeing immediately rather than discovering at the first `next`."""
31
+
32
+
33
+ class Claim(TypedDict):
34
+ """A task, claimed, with everything needed to start on it."""
35
+
36
+ view: TaskView
37
+ lease: Lease
38
+ branch: str
39
+ """The branch to create: `tk/<id>/<slug>`. Handed over rather than left to the
40
+ agent to compose, because the commit guard matches on this exact shape."""
41
+
42
+ inbox: Inbox
43
+ """Delivered with the claim. An agent that just picked up work is the most
44
+ likely to have been messaged about it, and this is a read it was making
45
+ anyway — so the inbox rides along instead of costing another call."""
46
+
47
+
48
+ class NextResult(TypedDict):
49
+ """The answer to "what should I do", including "nothing, and here is why"."""
50
+
51
+ claim: Claim | None
52
+ reason: str
53
+ """Empty when a claim was made. Otherwise the actual obstacle — everything is
54
+ blocked, everything is claimed by someone else, or the project is finished.
55
+ An agent told "nothing ready" with no reason asks a human; told which, it can
56
+ often unblock itself."""
57
+
58
+ ready: int
59
+ working: int
60
+ blocked: int
61
+
62
+
63
+ class UpdateResult(TypedDict):
64
+ """The effect of one update: the new state, and what it set free."""
65
+
66
+ task: Task
67
+ unblocked: list[Task]
68
+ """Tasks that became pickable because this one closed. The auto-unblock, made
69
+ visible: an agent finishing a task learns what it just handed to the fleet."""
70
+
71
+ notified: list[str]
72
+ """Actors whose inbox this reached — the mentions, resolved."""
73
+
74
+
75
+ class EditResult(TypedDict):
76
+ """The card after a rewrite, and which fields actually moved.
77
+
78
+ `changed` rather than a bare task: an edit that asked for a new title and got none —
79
+ because the value was already there — is worth saying out loud, since the alternative
80
+ is a caller believing it landed something the log has no event for.
81
+ """
82
+
83
+ task: Task
84
+ changed: list[str]
@@ -0,0 +1,94 @@
1
+ """The task, and the whole of what an agent needs to read to work on one.
2
+
3
+ `Task` is the row. `TaskView` is the answer to "what am I doing and what does it
4
+ touch" — assembled once so an agent does not have to make five calls to find out
5
+ that something else is already editing the file it was about to rewrite.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TypedDict
11
+
12
+ from .._types import Status
13
+ from .commit import CommitRef
14
+ from .event import Event
15
+ from .lease import Lease
16
+
17
+ __all__ = ["Task", "TaskView"]
18
+
19
+
20
+ class Task(TypedDict):
21
+ """One unit of work. Flat on purpose: this IS the row and the wire format."""
22
+
23
+ id: str
24
+ title: str
25
+
26
+ spec: str
27
+ """The brief, complete enough that an agent reads it and needs nothing else.
28
+
29
+ The single most important field in the system and the one most often written
30
+ badly. A title is a label; a spec says what done looks like, what must not
31
+ change, and where to look — because the reader is a fresh context that was
32
+ not in the room when the work was decided.
33
+ """
34
+
35
+ status: Status
36
+ priority: int
37
+ """0 urgent … 3 someday. Lower sorts first, which is why it is not a Literal:
38
+ a project that wants five bands should not need an engine change."""
39
+
40
+ parent: str | None
41
+ """An epic's id. The TREE is here; the DAG is `deps` — they answer different
42
+ questions ("what is this part of" vs "what must happen first")."""
43
+
44
+ labels: list[str]
45
+ files: list[str]
46
+ """The edit surface, as the planner understands it. A hint, never a lock: the
47
+ scheduler uses it to avoid handing two agents the same file, and being wrong
48
+ costs a merge conflict rather than a wrong answer."""
49
+
50
+ created_by: str
51
+
52
+ assignee: str
53
+ """Who this card is FOR, or "" for the open pool.
54
+
55
+ Set by a planner that knows who should do the work, and by `dispatch` before it launches a
56
+ worker. It is not a claim: an assigned card still has to be claimed, so the lease stays the only
57
+ thing that says somebody is actually on it. What assignment changes is the SCHEDULER — an
58
+ assigned card is invisible to every other agent, which is what makes "this one is yours" mean
59
+ something instead of being a label anybody can ignore.
60
+ """
61
+
62
+ created: float
63
+ updated: float
64
+
65
+
66
+ class TaskView(TypedDict):
67
+ """Everything about one task, in one read."""
68
+
69
+ task: Task
70
+ lease: Lease | None
71
+ blocked_by: list[Task]
72
+ """Open dependencies. Empty is the whole reason a task is pickable."""
73
+
74
+ blocks: list[Task]
75
+ """What is waiting on this one — the argument for finishing it today."""
76
+
77
+ children: list[Task]
78
+ neighbours: list[Task]
79
+ """Tasks whose `files` intersect this one's. The collision warning: an agent
80
+ that reads this knows who to message before it starts, not after the merge."""
81
+
82
+ thread: list[Event]
83
+ """Comments and directed messages, oldest first. The conversation about this
84
+ task, from every actor that has ever touched it."""
85
+
86
+ commits: list[CommitRef]
87
+ """The commits bound to this task, oldest first — with their subjects and the files they touched.
88
+
89
+ `list[str]` at first, which threw away the subject and the file list the event already carried: a
90
+ finished card rendered as a column of hashes, and the only substance on the page was whatever the
91
+ agent happened to put in a comment.
92
+ """
93
+
94
+ history: list[Event]
@@ -0,0 +1,110 @@
1
+ """What a tool call carries IN — the inputs, as types.
2
+
3
+ The MCP `inputSchema` is generated from these (`transports/mcp/schema.py`), so a parameter is
4
+ declared once and cannot exist on the wire without existing in the dispatch, or the reverse — which
5
+ is how a tool ends up advertising a flag nobody reads.
6
+
7
+ The description TEXT lives in `_fields`. It is the contract, not decoration — the only thing a
8
+ calling agent reads before choosing arguments — but it is prose, and keeping it out of here means a
9
+ change to a tool's SHAPE is reviewable on its own.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Annotated, Any, Literal, TypedDict
15
+
16
+ from . import _fields as f
17
+
18
+ __all__ = ["PlanParams", "NextParams", "UpdateParams", "AskParams", "ReportParams",
19
+ "DispatchParams", "RecoverParams"]
20
+
21
+
22
+ class _Target(TypedDict):
23
+ """Which repository. Every tool needs it; none of them guesses it."""
24
+
25
+ repo_path: f.Repo
26
+
27
+
28
+ class _PlanRequired(_Target):
29
+ tasks: Annotated[list[dict[str, Any]], f.TASKS]
30
+
31
+
32
+ class PlanParams(_PlanRequired, total=False):
33
+ actor: f.Actor
34
+
35
+
36
+ class NextParams(_Target, total=False):
37
+ actor: f.Actor
38
+ session: f.Session
39
+ labels: Annotated[str, f.LABELS]
40
+ task: Annotated[str, f.CLAIM_ONE]
41
+
42
+
43
+ class _UpdateRequired(_Target):
44
+ task: f.TaskId
45
+
46
+
47
+ class UpdateParams(_UpdateRequired, total=False):
48
+ status: Annotated[Literal["in_progress", "blocked", "review", "done", "released",
49
+ "cancelled"], f.STATUS]
50
+ comment: Annotated[str, f.COMMENT]
51
+ mentions: Annotated[str, f.MENTIONS]
52
+ blocked_on: Annotated[str, f.BLOCKED_ON]
53
+ no_code: Annotated[bool, f.NO_CODE]
54
+
55
+
56
+ class AskParams(_Target, total=False):
57
+ task: Annotated[str, f.ASK_TASK]
58
+ query: Annotated[str, f.ASK_QUERY]
59
+
60
+
61
+ class DispatchParams(_Target, total=False):
62
+ """Prepare worker agents for cards. The caller spawns them as its own sub-agents.
63
+
64
+ There is deliberately NO `spawn` field here, and its absence is the design rather than an
65
+ omission. The use case can start detached processes and `taskops dispatch --spawn` does, because
66
+ that is a human at a terminal asking for it — but a model calling a tool must not be able to make
67
+ this package launch another Claude Code. An agent inside a session already HAS a way to run work
68
+ in parallel: its own sub-agent tool, on the subscription that is already paid for. Spawning from
69
+ here opens a new billed session per worker, which is how a real fleet drained an API balance
70
+ mid-run and left six cards claimed by processes that no longer existed.
71
+
72
+ A capability the engine has and a transport withholds is a normal thing for this codebase — the
73
+ surfaces are thin, not identical.
74
+ """
75
+
76
+ tasks: Annotated[str, f.DISPATCH_TASKS]
77
+ count: Annotated[int, f.DISPATCH_COUNT]
78
+ prefix: Annotated[str, f.PREFIX]
79
+ model: Annotated[str, f.MODEL]
80
+ dry_run: Annotated[bool, f.DRY_RUN]
81
+ actor: f.Actor
82
+
83
+
84
+ class RecoverParams(_Target, total=False):
85
+ """Hand back the cards of workers that died. The other half of dispatching a fleet."""
86
+
87
+ force: Annotated[bool, f.RECOVER_FORCE]
88
+ grace: Annotated[int, f.RECOVER_GRACE]
89
+ actor: f.Actor
90
+
91
+
92
+ class ReportParams(_Target, total=False):
93
+ """The four views a model may ask for, and no more.
94
+
95
+ `fleet` and `burndown` were advertised here and are not any more. One answered a question
96
+ agents stopped having — "who is free" means nothing when a worker is created on demand,
97
+ and the studio dropped the panel for the same reason — while the other was never
98
+ implemented and replied with a sentence saying so. A tool that lists a kind an agent
99
+ cannot use spends the description budget teaching it to pick wrong. `fleet` survives as a
100
+ use case: the HTTP api still serves it, because a human looking at a board does want to
101
+ know which claim has gone quiet.
102
+ """
103
+
104
+ kind: Annotated[Literal["board", "standup", "day", "range"], f.REPORT_KIND]
105
+ actor: Annotated[str, f.REPORT_ACTOR]
106
+ since: Annotated[str, f.SINCE]
107
+ date: Annotated[str, f.DATE]
108
+ last: Annotated[str, f.LAST]
109
+ from_date: Annotated[str, f.FROM_DATE]
110
+ to: Annotated[str, f.TO_DATE]
@@ -0,0 +1,60 @@
1
+ """A message that travels on the SOCKET and is never stored.
2
+
3
+ The counterpart of `Event`, defined by what it is NOT: an event is the durable
4
+ record — hashed, written to sqlite, exported to `events.jsonl` and committed —
5
+ and a wire message is a frame the studio pushes to whoever happens to be looking.
6
+ Nothing persists it, nothing replays it, and a browser that reconnects has simply
7
+ missed it.
8
+
9
+ That distinction is load-bearing, not stylistic. The first thing this carries is
10
+ the narration of a report, arriving a few characters at a time; a thousand prose
11
+ fragments in `events.jsonl` would destroy the one property that file has, which is
12
+ that a human can read its diff. The FILE on disk is the durable copy of a
13
+ narration — this is only the window onto it being written.
14
+
15
+ `kind` is open for the same reason `Event.kind` is: a reader that does not know a
16
+ kind ignores the frame, and a newer taskops may send kinds this one never heard of.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TypedDict
22
+
23
+ __all__ = ["WireMessage"]
24
+
25
+
26
+ class WireMessage(TypedDict):
27
+ """One ephemeral frame: what happened, to which report, and the text of it."""
28
+
29
+ kind: str
30
+ """Namespaced by subject — `narration.delta`, `narration.pass`, `narration.done`,
31
+ `narration.failed`. A consumer switches on it and drops what it does not know."""
32
+
33
+ label: str
34
+ """Which report this is about (`2026-07-28`, `2026-07-22..2026-07-28`, `all`).
35
+
36
+ Never absent: two narrations can be in flight at once, and a delta that cannot
37
+ say which document it belongs to would be appended to whichever panel is open.
38
+ """
39
+
40
+ text: str
41
+ """The payload, shaped by `kind` — a fragment of prose for a delta, `2/4` for a
42
+ pass, the failure verbatim for a failure, empty for a completion."""
43
+
44
+ root: str
45
+ """WHICH project emitted it: the absolute path of the store, as `str(store.root)`.
46
+
47
+ A path and not a "project name" on purpose. The emitter is a narration running deep
48
+ inside a use case; it does not know — and must not have to know — under which URL
49
+ prefix some server happened to mount it, or whether it is mounted at all. The root is
50
+ the one identifier both ends already share, and the consumer (`usecases.feed.follow`)
51
+ resolves its own from the very same function.
52
+
53
+ Never absent. The channel is process-global, so a server holding many boards open
54
+ broadcasts every frame to all of them; a message that cannot say where it came from
55
+ would be prose from one project appearing on another project's screen. That is why a
56
+ message WITHOUT this field is dropped rather than delivered — see `follow`.
57
+
58
+ It never reaches the browser: `transports.http.live` strips it before framing, because
59
+ it is a path on the server's filesystem and no client has any business reading it.
60
+ """
@@ -0,0 +1,34 @@
1
+ """Layer 3 — the decisions. Sync, and it never talks to a caller.
2
+
3
+ Split from `usecases` by WHO it is for: these functions decide things (may this move
4
+ happen, which task is next, is that agent alive), and a use case orchestrates them
5
+ into an answer a transport can render. That is why nothing here formats text and
6
+ nothing here reads an argument a model wrote.
7
+
8
+ `machine` is the only module allowed to decide a status move — `tests/architecture`
9
+ enforces it — because a transition table plus one convenient status check elsewhere
10
+ is two state machines, and the convenient one always forgets the guard.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import replay
16
+ from .activity import fleet, standup
17
+ from .bus import BUS, EventBus
18
+ from .day import date_of, day_report, first_date, label_of, period_report, shift
19
+ from .gitstate import branch_state, branch_states
20
+ from .history import activity
21
+ from .identity import parse, resolve
22
+ from .log import build, record, relay
23
+ from .machine import Facts, allowed_from, check_move
24
+ from .narrate import OnPass, OnText, narrate
25
+ from .project import board, counts
26
+ from .reports import NO_STAMP, missing_events, stamp, stamped_seq
27
+ from .scheduler import branch_for, claim, open_children, ready_tasks, sweep_dead, unblock
28
+ from .wire import WIRE, Broadcast, is_wire
29
+ from .worker import Launched, launch
30
+
31
+ __all__ = ["Facts", "check_move", "allowed_from", "resolve", "parse", "record",
32
+ "build", "relay", "BUS", "EventBus", "unblock", "ready_tasks", "claim",
33
+ "branch_for", "sweep_dead", "open_children", "board", "standup", "fleet", "activity", "day_report", "period_report", "first_date", "label_of", "shift", "date_of",
34
+ "counts", "replay", "narrate", "OnPass", "OnText", "stamp", "stamped_seq", "NO_STAMP", "missing_events", "branch_state", "branch_states", "launch", "Launched", "WIRE", "Broadcast", "is_wire"]
@@ -0,0 +1,48 @@
1
+ """Reading the CONTENT of a transcript entry: a tool call's arguments, a tool result's payload.
2
+
3
+ Split from `_entries` by depth rather than by topic. That module walks the entry's shape and decides
4
+ what each line IS; this one reaches inside a block and decides what to SHOW of it — which is where the
5
+ judgement about brevity lives, and the part that gets tuned after looking at a real dashboard.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, cast
11
+
12
+ __all__ = ["summarise", "result_text"]
13
+
14
+ _IDENTIFYING = ("file_path", "command", "path", "pattern", "query", "task", "prompt")
15
+ """Argument names that say WHICH thing a tool acted on, in the order they are worth showing.
16
+
17
+ `file_path` first because Read/Write/Edit are most of any transcript, then `command` for Bash. A call
18
+ whose arguments contain none of these falls back to naming its keys, which at least says what shape it
19
+ had.
20
+ """
21
+
22
+
23
+ def summarise(tool: str, arguments: object) -> str:
24
+ """A tool call in ONE line: the argument that identifies it, never the whole payload.
25
+
26
+ A dashboard showing the full `input` of every Edit is a dashboard nobody scrolls — the diff is
27
+ already in git, and what a reader wants here is which file was touched.
28
+ """
29
+ if not isinstance(arguments, dict):
30
+ return tool
31
+ args = cast("dict[str, Any]", arguments)
32
+ for key in ("file_path", "command", "path", "pattern", "query", "task", "prompt"):
33
+ found = args.get(key)
34
+ if isinstance(found, str) and found.strip():
35
+ return f"{found.strip()[:200]}"
36
+ return ", ".join(sorted(args)[:4])
37
+
38
+
39
+ def result_text(content: object) -> str:
40
+ """A tool result, which arrives as a string or as a list of blocks."""
41
+ if isinstance(content, str):
42
+ return content
43
+ if isinstance(content, list):
44
+ blocks = cast("list[object]", content)
45
+ parts = [str(cast("dict[str, Any]", block).get("text", ""))
46
+ for block in blocks if isinstance(block, dict)]
47
+ return "\n".join(part for part in parts if part)
48
+ return ""