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
taskops/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """taskops — the coordination substrate Claude Code does not have.
2
+
3
+ Persistent tasks with a dependency DAG, atomic claims that survive a crashed
4
+ agent, every commit bound to the task that motivated it, and a live board a
5
+ human can watch while a hundred agents work.
6
+
7
+ The error types are exported eagerly because a caller has to be able to write
8
+ `except taskops.LeaseHeld` before it has opened anything. Everything else stays
9
+ behind its own module so that importing this package costs a dict lookup — the
10
+ MCP host imports it to list tools long before it asks for any work.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ._errors import (
16
+ AlreadyWritten,
17
+ BadRequest,
18
+ GuardFailed,
19
+ IllegalTransition,
20
+ LeaseHeld,
21
+ NoLease,
22
+ NoSuchTask,
23
+ NotInitialized,
24
+ TaskopsError,
25
+ )
26
+ from ._version import __version__
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "TaskopsError",
31
+ "NotInitialized",
32
+ "NoSuchTask",
33
+ "IllegalTransition",
34
+ "LeaseHeld",
35
+ "NoLease",
36
+ "GuardFailed",
37
+ "BadRequest",
38
+ "AlreadyWritten",
39
+ ]
taskops/_clock.py ADDED
@@ -0,0 +1,32 @@
1
+ """Layer 0 — the one place that asks what time it is.
2
+
3
+ Leases expire, heartbeats renew and events are ordered, so "now" is load-bearing
4
+ here rather than incidental. Routing it through one function is what lets a test
5
+ of "the agent died and its lease lapsed" run in microseconds instead of waiting
6
+ fifteen real minutes for the TTL.
7
+
8
+ `time.time()` and not `monotonic()`: these timestamps are compared ACROSS
9
+ machines and survive a restart, which a monotonic clock cannot do. The cost is
10
+ that a badly skewed clock skews a TTL — bounded, and visible in the studio,
11
+ where a lease from the future is obvious.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+
18
+ __all__ = ["now", "LEASE_TTL", "HEARTBEAT_GRACE"]
19
+
20
+ LEASE_TTL = 900.0
21
+ """Seconds a claim survives without a heartbeat. Every taskops call an agent
22
+ makes renews it, so this bounds how long a task stays stuck after a CRASH —
23
+ not how long a legitimately slow task may run."""
24
+
25
+ HEARTBEAT_GRACE = 60.0
26
+ """Extra seconds the studio waits before calling a session dead. A lease renews
27
+ on tool calls, and an agent that spends two minutes thinking has not gone away."""
28
+
29
+
30
+ def now() -> float:
31
+ """Wall-clock seconds since the epoch."""
32
+ return time.time()
taskops/_errors.py ADDED
@@ -0,0 +1,160 @@
1
+ """Structured errors: a small taxonomy every boundary maps ONCE.
2
+
3
+ Errors are data, not strings — a stable machine `code` plus an `http_status` — so
4
+ each transport translates the TYPE at one catch site instead of matching message
5
+ text. Each subclass also inherits the builtin a caller would plausibly already be
6
+ catching (the `json.JSONDecodeError(ValueError)` trick). Every message names what
7
+ to DO: the reader is an agent mid-turn, and this is all it gets to act on.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ __all__ = [
15
+ "TaskopsError", "NotInitialized", "NoSuchTask", "IllegalTransition", "LeaseHeld", "NoLease",
16
+ "GuardFailed", "BadRequest", "AlreadyWritten", "AlreadyNarrating", "NarrationFailed",
17
+ "ReportConflict", "Unreachable",
18
+ ]
19
+
20
+
21
+ class TaskopsError(Exception):
22
+ """Root. `except TaskopsError` catches everything the engine raises."""
23
+
24
+ code = "error"
25
+ http_status = 500
26
+
27
+
28
+ class NotInitialized(TaskopsError, FileNotFoundError):
29
+ """No `.taskops/` at or above the given path."""
30
+
31
+ code = "not_initialized"
32
+ http_status = 404
33
+
34
+ @classmethod
35
+ def at(cls, path: str | Path) -> "NotInitialized":
36
+ return cls(f"no taskops project at or above {path} — run `taskops init` in the "
37
+ f"repository root")
38
+
39
+
40
+ class NoSuchTask(TaskopsError, KeyError):
41
+ """A task id nobody created — usually a hallucinated or a stale one."""
42
+
43
+ code = "no_such_task"
44
+ http_status = 404
45
+
46
+ @classmethod
47
+ def named(cls, task: str) -> "NoSuchTask":
48
+ return cls(f"no task {task} — list what exists with `taskops report board`")
49
+
50
+
51
+ class IllegalTransition(TaskopsError, ValueError):
52
+ """A status move the machine does not allow."""
53
+
54
+ code = "illegal_transition"
55
+ http_status = 409
56
+
57
+ @classmethod
58
+ def between(
59
+ cls, *, task: str, old: str, new: str, allowed: tuple[str, ...]
60
+ ) -> "IllegalTransition":
61
+ legal = ", ".join(allowed) if allowed else "nothing — it is terminal"
62
+ return cls(f"{task} is {old} and cannot go to {new}; from {old} it can go to {legal}")
63
+
64
+
65
+ class LeaseHeld(TaskopsError, RuntimeError):
66
+ """Somebody else is on it and their lease has not expired."""
67
+
68
+ code = "lease_held"
69
+ http_status = 409
70
+
71
+ @classmethod
72
+ def by(cls, *, task: str, actor: str, seconds: int) -> "LeaseHeld":
73
+ return cls(f"{task} is claimed by {actor} for another {seconds}s — "
74
+ f"pick another task, or message them on it")
75
+
76
+
77
+ class NoLease(TaskopsError, RuntimeError):
78
+ """Working on a task nobody claimed. Its own type, not a GuardFailed,
79
+ because it has ONE fix the agent can apply unaided — which the message is."""
80
+
81
+ code = "no_lease"
82
+ http_status = 409
83
+
84
+ @classmethod
85
+ def on(cls, *, task: str, actor: str) -> "NoLease":
86
+ return cls(f"{actor} holds no live lease on {task} — claim it with "
87
+ f"taskops_next, or `taskops claim {task}`")
88
+
89
+
90
+ class GuardFailed(TaskopsError, ValueError):
91
+ """A transition the machine allows but the project's rules refuse. Separate
92
+ from IllegalTransition: that one is "the arrow does not exist", this one is
93
+ "you have not earned it yet" — and only this one is fixable by doing work."""
94
+
95
+ code = "guard_failed"
96
+ http_status = 400
97
+
98
+
99
+ class BadRequest(TaskopsError, ValueError):
100
+ """An argument that cannot mean anything. Raised at the edges, not inside."""
101
+
102
+ code = "bad_request"
103
+ http_status = 400
104
+
105
+
106
+ class AlreadyWritten(TaskopsError, FileExistsError):
107
+ """A generated file that exists and would be OVERWRITTEN. 409, never 500.
108
+
109
+ A written report is something somebody may have already read, cited, or narrated by
110
+ hand; silently regenerating it would rewrite history under them. Refusing and naming
111
+ `--force` leaves the choice with the person who knows whether the old one mattered.
112
+ """
113
+
114
+ code = "already_written"
115
+ http_status = 409
116
+
117
+
118
+ class AlreadyNarrating(TaskopsError, RuntimeError):
119
+ """A narration of that report is already running IN THIS PROCESS. 409.
120
+
121
+ Two models writing the same file is not a slow path, it is corruption: both hold the
122
+ dossier they read at the start and each rewrites the whole file when it flushes, so
123
+ whichever finishes last silently erases the other. Refusing the second is the only
124
+ outcome that leaves a readable report — and the first one is still streaming, so the
125
+ person who clicked twice is already looking at what they asked for.
126
+ """
127
+
128
+ code = "already_narrating"
129
+ http_status = 409
130
+
131
+
132
+ class NarrationFailed(TaskopsError, RuntimeError):
133
+ """The `claude` CLI could not write the narration. 502: an upstream did not answer.
134
+
135
+ Its own type because the fix is never in taskops — the binary is missing, the session is
136
+ not logged in, or the model refused — and the message has to say which. Everything else
137
+ about the report still worked: the dossier is on disk either way, so this never costs the
138
+ facts, only the prose.
139
+ """
140
+
141
+ code = "narration_failed"
142
+ http_status = 502
143
+
144
+
145
+ class ReportConflict(TaskopsError, FileExistsError):
146
+ """A DIFFERENT narration of one report, not a newer one — `ours`/`theirs` are the stamps."""
147
+
148
+ code = "report_conflict"
149
+ http_status = 409
150
+ ours = -1
151
+ theirs = -1
152
+
153
+
154
+ class Unreachable(TaskopsError, ConnectionError):
155
+ """The server did not answer at all — no status, no body. 502, and never a reason to
156
+ write locally instead: that is how two machines end up holding one card. Its own type
157
+ so `usecases._routing` can catch exactly it and say which URL went quiet."""
158
+
159
+ code = "unreachable"
160
+ http_status = 502
taskops/_ids.py ADDED
@@ -0,0 +1,63 @@
1
+ """Layer 0 — how a task and an event get their names.
2
+
3
+ Two different jobs, and the difference is the whole design:
4
+
5
+ **Task ids are RANDOM** (`tk-4f2a9c`). Many machines create tasks without
6
+ talking to each other, so an id must be collision-free without coordination —
7
+ which rules out a counter. Random also means unguessable, so a task id in a
8
+ branch name leaks no ordering information about the project.
9
+
10
+ **Event ids are the CONTENT, hashed.** The event log is replicated by `git pull`
11
+ and by the relay, and the same event can arrive by both paths: with a content
12
+ hash, importing it twice is a primary-key no-op instead of a duplicate comment
13
+ in somebody's inbox. It also makes the log verifiable — an event whose id does
14
+ not match its content was edited after the fact.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import secrets
22
+ from typing import Any
23
+
24
+ __all__ = ["new_task_id", "event_id", "slugify", "TASK_PREFIX"]
25
+
26
+ TASK_PREFIX = "tk-"
27
+ _TASK_BYTES = 3 # 6 hex chars: 16.7M ids, and the whole id fits a branch name
28
+ _EVENT_CHARS = 16
29
+
30
+
31
+ def new_task_id() -> str:
32
+ """A fresh task id. Uniqueness is checked at INSERT, not assumed here."""
33
+ return TASK_PREFIX + secrets.token_hex(_TASK_BYTES)
34
+
35
+
36
+ def event_id(*, task: str, actor: str, kind: str, body: dict[str, Any], ts: float) -> str:
37
+ """The id of an event, derived from everything the event says.
38
+
39
+ `sort_keys` and a fixed float format matter more than they look: two
40
+ machines must hash the same event to the same id, and Python's dict order
41
+ and `repr(float)` are not a contract across versions.
42
+ """
43
+ payload = json.dumps(
44
+ {"task": task, "actor": actor, "kind": kind, "body": body, "ts": f"{ts:.6f}"},
45
+ sort_keys=True,
46
+ separators=(",", ":"),
47
+ default=str,
48
+ )
49
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:_EVENT_CHARS]
50
+
51
+
52
+ def slugify(text: str, *, limit: int = 32) -> str:
53
+ """A title -> the branch-safe half of `tk/<id>/<slug>`.
54
+
55
+ Deliberately lossy and never parsed back: the id is what identifies the
56
+ task, so this only has to be readable in a `git branch` listing. Everything
57
+ git or a shell would treat as special becomes a dash.
58
+ """
59
+ kept = [c.lower() if c.isalnum() else "-" for c in text.strip()]
60
+ slug = "".join(kept).strip("-")
61
+ while "--" in slug:
62
+ slug = slug.replace("--", "-")
63
+ return slug[:limit].strip("-") or "task"
taskops/_types.py ADDED
@@ -0,0 +1,106 @@
1
+ """Layer 0 — the vocabulary. Every state and every event kind, named once.
2
+
3
+ These are `Literal`s rather than enums on purpose: the values cross a JSON
4
+ boundary in both directions (the MCP wire, the event log in git, the studio's
5
+ fetch), and a Literal IS the wire value, so there is no encode/decode step where
6
+ a rename can half-land. The MCP `inputSchema` generator turns them into an
7
+ `enum`, which is how an agent learns the allowed values before it guesses one.
8
+
9
+ Imports nothing from the package, so any layer may import it without thinking
10
+ about cycles. `tests/architecture` enforces that.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Literal, get_args
16
+
17
+ __all__ = [
18
+ "Status",
19
+ "ActorKind",
20
+ "EventKind",
21
+ "OPEN_STATUSES",
22
+ "CLOSED_STATUSES",
23
+ "WORKING_STATUSES",
24
+ "STATUSES",
25
+ "EVENT_KINDS",
26
+ "LOCAL_ONLY_KINDS",
27
+ "EDITABLE_FIELDS",
28
+ ]
29
+
30
+ Status = Literal[
31
+ "backlog", "ready", "claimed", "in_progress", "blocked", "review", "done", "cancelled"
32
+ ]
33
+ """Where a task is. See `engine.machine` for which moves between these are legal.
34
+
35
+ `ready` is a stored status and not a derived view, so that "what can I pick up"
36
+ is one indexed read rather than a graph walk per caller. `engine.machine.unblock`
37
+ is the ONE writer that moves a task between `backlog` and `ready`, which is what
38
+ keeps the stored value from drifting from the dependency graph.
39
+ """
40
+
41
+ ActorKind = Literal["dev", "agent"]
42
+ """A human or one of their agents. The distinction is not cosmetic: guards that
43
+ demand a justification accept one from a dev and reject it from an agent."""
44
+
45
+ EventKind = Literal[
46
+ "created",
47
+ "claimed",
48
+ "released",
49
+ "status",
50
+ "comment",
51
+ "commit",
52
+ "branch",
53
+ "blocked",
54
+ "unblocked",
55
+ "handoff",
56
+ "review",
57
+ "eval",
58
+ "done",
59
+ "message", # directed chat: agent↔agent, dev↔agent
60
+ "activity", # a session's heartbeat: a tool ran, a file was touched
61
+ "edited", # a field of the card itself changed: title, spec or priority
62
+ ]
63
+ """What happened. The event log is append-only and every projection — the board,
64
+ a standup, an inbox — is derived from it, so a new fact about a task is a new
65
+ kind here rather than a column somewhere."""
66
+
67
+ STATUSES: tuple[Status, ...] = (
68
+ "backlog",
69
+ "ready",
70
+ "claimed",
71
+ "in_progress",
72
+ "blocked",
73
+ "review",
74
+ "done",
75
+ "cancelled",
76
+ )
77
+
78
+ EVENT_KINDS: tuple[EventKind, ...] = get_args(EventKind)
79
+ """The same values as the `Literal`, DERIVED rather than retyped.
80
+
81
+ Two hand-written lists is how a kind ends up legal at the type level and unknown to the
82
+ MCP schema that iterates the tuple — a mismatch nothing catches, because the type check
83
+ passes and the runtime list is merely short. `STATUSES` keeps its literal spelling: it is
84
+ also the display order, which is a separate decision that happens to agree today."""
85
+
86
+ EDITABLE_FIELDS: tuple[str, ...] = ("title", "spec", "priority")
87
+ """The columns a person may rewrite after a card exists. Named here rather than in
88
+ `storage` because three layers ask the same question — the CLI validates a flag, the
89
+ use case records one event per field, and replay refuses a body naming anything else."""
90
+
91
+ OPEN_STATUSES: frozenset[str] = frozenset(
92
+ {"backlog", "ready", "claimed", "in_progress", "blocked", "review"}
93
+ )
94
+ """A dependency in one of these still blocks whatever waits on it."""
95
+
96
+ CLOSED_STATUSES: frozenset[str] = frozenset({"done", "cancelled"})
97
+ """`cancelled` closes a dependency as surely as `done` does: a task nobody will
98
+ ever do must not hold its dependents hostage forever."""
99
+
100
+ WORKING_STATUSES: frozenset[str] = frozenset({"claimed", "in_progress", "review"})
101
+ """Statuses that require a live lease. Losing the lease drops the task out."""
102
+
103
+ LOCAL_ONLY_KINDS: frozenset[str] = frozenset({"activity"})
104
+ """Kinds that never reach the git-committed log. `activity` is a per-keystroke
105
+ heartbeat: replicating it through git would add thousands of lines a day to a
106
+ file whose whole value is that a human can read its diff."""
taskops/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """The single source of the version. `pyproject.toml` reads this attribute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.2.0"
@@ -0,0 +1,220 @@
1
+ # taskops — how work happens in this repository
2
+
3
+ You are reading the shared task list's manual. One document for agents and humans, on
4
+ purpose: two documents drift, and then the two audiences are told different things.
5
+
6
+ `taskops init` copies this to `.taskops/GUIDE.md`. If anything here contradicts what the
7
+ tools actually do, the tools are right and this file is a bug.
8
+
9
+ ---
10
+
11
+ ## The one-paragraph version
12
+
13
+ Tasks live in this repository, not in anybody's session. You **claim** one before you code,
14
+ and the claim is a lease nobody else can take. Your commits are bound to the task that
15
+ motivated them — enforced, not suggested. When you finish, whatever was waiting on your task
16
+ becomes available to everyone else automatically. Every developer's agents share the same
17
+ list, and it travels by `git push` and `git pull` with no server involved.
18
+
19
+ ## The loop
20
+
21
+ ```
22
+ taskops_next ──▶ work ──▶ git commit ──▶ taskops_update status=done
23
+ │ │ │
24
+ a lease, the spec, the guard adds whatever was waiting
25
+ and a collision `Task: tk-…` on you becomes ready
26
+ warning for you for everyone
27
+ ```
28
+
29
+ **Start every session by finding out where you stand.** If you do not already hold a task,
30
+ call `taskops_next`. It returns the spec, the exact branch to create, the conversation so
31
+ far, and a warning listing any other agent editing the same files. If nothing is available
32
+ it tells you *why*, which is usually something you can act on.
33
+
34
+ **Work on the branch it names** — `tk/<task-id>/<slug>`. Not a branch of your own choosing:
35
+ the commit guard matches this exact shape, and an invented name gets your own commits denied.
36
+
37
+ **Commit normally.** The guard adds the `Task:` trailer. You do not write it.
38
+
39
+ **Close with `taskops_update status=done`.** It will be refused if no commit is bound to the
40
+ task. That refusal is the feature — see below.
41
+
42
+ ## The two rules the server enforces
43
+
44
+ These are not conventions. The server rejects the call.
45
+
46
+ **1. A commit belongs to a claimed task.** You must be on the task's branch and hold its
47
+ lease. Why: a commit nobody can attribute is a commit nobody can review against the work it
48
+ was supposed to do, and the moment one is allowed the board stops being a complete picture of
49
+ what changed.
50
+
51
+ **2. `done` requires a commit bound to the task.** Otherwise "done" means only that an agent
52
+ said so — which is exactly what a human reading a board instead of the diff is trying to
53
+ avoid. If a task legitimately produced no code (research, a decision, docs elsewhere), pass
54
+ `no_code: true` **with a comment saying what it produced instead**. That is recorded, so a
55
+ review can see which closures had no code and why.
56
+
57
+ ## Your claim is a lease, not an assignment
58
+
59
+ It expires. Every taskops call you make renews it, so a task that takes an hour is fine — you
60
+ do not have to do anything special. What the deadline actually bounds is a **crash**: if your
61
+ process dies, the lease lapses within fifteen minutes and the task returns to the queue
62
+ instead of sitting there looking claimed forever.
63
+
64
+ Two consequences worth knowing:
65
+
66
+ - If a write is refused for a missing lease, **claim again**. Do not work around it. Another
67
+ agent may genuinely have taken the task while you were gone.
68
+ - If you are out of context or out of depth, use `status: released` **with a comment** saying
69
+ where you got to. That is the honest move and it is always allowed. Abandoning the task
70
+ silently also works, eventually — but it throws away everything you learned.
71
+
72
+ ## Talking to other agents
73
+
74
+ `taskops_update` with `mentions` puts a message in another actor's inbox:
75
+
76
+ ```
77
+ taskops_update task=tk-4f2a9c
78
+ comment="I'm rewriting the tokenizer in parser.py — hold off until I land this."
79
+ mentions="agent:ana/api-1,dev:ana"
80
+ ```
81
+
82
+ They see it within one tool call of their own, and it appears live on the board. The message
83
+ lives in the task's thread, so it is still findable in three weeks — which is why there is no
84
+ separate chat tool.
85
+
86
+ **When to use it:** the collision warning in `taskops_next` and `taskops_ask` lists other
87
+ tasks touching your files. That list is the one thing that prevents a merge conflict rather
88
+ than reporting it afterwards. Message them *before* you edit, not after the merge.
89
+
90
+ ## Writing a spec that works
91
+
92
+ `taskops_plan` is where most of the damage gets done, because the reader of a `spec` is a
93
+ **fresh agent with none of your context** — possibly on another developer's machine, three
94
+ days from now.
95
+
96
+ A spec that works says:
97
+
98
+ - what **done** looks like, concretely enough to disagree with
99
+ - what must **not** change
100
+ - **where to start** — the files, the sibling to copy, the test that pins the behaviour
101
+
102
+ A one-line spec is the single most common cause of an agent doing the wrong thing correctly.
103
+
104
+ Also name `files`. It is how the scheduler avoids handing two agents the same file, and it is
105
+ what the collision warning is computed from.
106
+
107
+ ## Dependencies
108
+
109
+ `after` in `taskops_plan` accepts the 0-based **index** of an earlier task in the same batch,
110
+ so a whole plan lands in one call:
111
+
112
+ ```json
113
+ [
114
+ {"title": "Add the events table", "spec": "…", "files": ["storage/_events.py"]},
115
+ {"title": "Board reads the table", "spec": "…", "after": [0]}
116
+ ]
117
+ ```
118
+
119
+ Nothing polls. When a task closes, everything that was only waiting on it becomes ready
120
+ immediately, and `taskops_update` tells you what you just handed to the fleet.
121
+
122
+ If you discover a dependency mid-task, use `blocked_on` — it adds the edge **and** marks you
123
+ blocked. A dependency that lives only in a comment is one the scheduler will walk somebody
124
+ straight into.
125
+
126
+ ## If you are an ORCHESTRATOR: dispatching workers
127
+
128
+ `taskops_dispatch` assigns cards, creates a git worktree per card, and hands you a **brief per
129
+ card**. It starts nothing. You then spawn ONE SUB-AGENT PER BRIEF, all in a single message so they
130
+ run in parallel — they use the session you are already in.
131
+
132
+ ```
133
+ taskops_plan → the cards
134
+ taskops_dispatch → N briefs ← nothing is running yet
135
+ your Agent tool → N sub-agents ← paste one brief each, in ONE message
136
+ ```
137
+
138
+ Two rules for the workers, and both are in the brief already:
139
+
140
+ - **Each works inside its own worktree** (`.taskops/trees/<id>/`), which is already checked out on
141
+ the card's branch. A worktree is per CARD, not per agent.
142
+ - **Nobody ever runs `git switch`.** Sub-agents share the repository, so switching would move the
143
+ branch under every other worker at once. This is the whole reason the worktree exists.
144
+
145
+ Pass `spawn: true` only if you want detached processes that outlive your session — each one opens a
146
+ NEW Claude session, which is rarely what you want.
147
+
148
+ A spawned worker inherits your environment MINUS the Anthropic credentials
149
+ (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`). The `claude` CLI prefers an
150
+ exported key over the logged-in subscription, so without this every worker would quietly bill per
151
+ token against a plan you already pay for. `taskops run --use-api-key` asks for the other mode out
152
+ loud; no MCP tool can.
153
+
154
+ **If you dispatch and then do not spawn**, the cards sit assigned to workers that never existed. Run
155
+ `taskops recover` to hand them back.
156
+
157
+ ## When a fleet dies
158
+
159
+ Workers get killed: a session ends, a balance runs out, somebody hits ctrl-C. Their cards stay
160
+ `claimed` until their leases expire, which is fifteen minutes of a board that looks busy and is not.
161
+
162
+ ```
163
+ taskops recover # releases every card whose worker has gone quiet
164
+ taskops recover --force # …including the ones still reporting, for a fleet that is alive and wrong
165
+ ```
166
+
167
+ It clears the assignment as well as the lease — a card handed back still assigned to a dead worker is
168
+ one NOBODY can pick up, since the scheduler hides it from everyone else.
169
+
170
+ And it writes on each card what survived: commits are safe in git, and **uncommitted work is named
171
+ with its path**, because a killed agent writes before it commits. Read that before starting over.
172
+
173
+ ## Reading the board
174
+
175
+ - `taskops_report board` — every column, who holds what
176
+ - `taskops_report standup --since 24h` — what changed, per actor, and what needs a human
177
+ - `taskops_report fleet` — which agents are alive right now, on what, touching what file
178
+
179
+ In `fleet`, `SILENT` means the agent still holds a claim but has gone quiet past the grace
180
+ period. That row is shown rather than hidden, because it is the one somebody needs to act on.
181
+
182
+ ## Multi-developer, no server
183
+
184
+ `.taskops/events.jsonl` is an append-only log, and it is **committed**. Two developers'
185
+ agents converge through `git pull`: appending to different ends of a file is the one edit git
186
+ merges without help, and every event's id is its content hashed, so importing the same event
187
+ twice does nothing.
188
+
189
+ `.taskops/db.sqlite` is a **cache** and is gitignored. It can be rebuilt from the log, so
190
+ deleting it loses nothing but live leases.
191
+
192
+ The `post-merge` hook syncs for you. Run `taskops sync` by hand any time; it is idempotent.
193
+
194
+ ## Three doors, and which one is yours
195
+
196
+ ```
197
+ taskops <cmd> a person, at a terminal. seven commands.
198
+ taskops_* (MCP) you. seven tools — this is your door.
199
+ python -m taskops.transports.hooks git and Claude Code. never type it.
200
+ ```
201
+
202
+ Yours is the MCP tools. If one of them is refused, the refusal names what to do — read it
203
+ rather than reaching for the CLI, which no longer carries `next`, `update`, `ask`, `plan` or
204
+ `log` at all. What a person types is `taskops tasks …`, and what a hook runs is the third
205
+ line, which exists only because git cannot speak MCP.
206
+
207
+ ## If something looks wrong
208
+
209
+ - **"no taskops project"** — run `taskops init` in the repository root.
210
+ - **A commit was denied** — read the message; it names the branch to switch to or the task to
211
+ claim. Do not use `--no-verify` to get around it. The `post-commit` hook will record the
212
+ commit anyway, and you will have a commit whose task nobody agreed on.
213
+ - **`taskops_next` says nothing is ready** — read the reason. "Everything blocked" is worth
214
+ reporting to a human; "everything claimed" means ask again shortly.
215
+ - **Hooks are not firing** — they live in `.git/hooks`, which is not tracked, so a fresh
216
+ clone has none, and a repository set up by an older taskops has a line naming a command that
217
+ has since moved. `taskops init` again repairs both: it chains onto hooks somebody else put
218
+ there, and rewrites the line it wrote itself. Worth knowing why nothing warned you — every
219
+ hook line ends in `|| true`, so a hook pointing at a command that does not exist fails
220
+ completely silently, and commits just stop appearing on their cards.