taskops-cli 0.2.0__py3-none-any.whl → 0.3.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 (303) hide show
  1. taskops/__init__.py +11 -24
  2. taskops/_clock.py +21 -20
  3. taskops/_errors.py +30 -135
  4. taskops/_ids.py +55 -47
  5. taskops/_json.py +48 -0
  6. taskops/_locate.py +106 -0
  7. taskops/_version.py +2 -5
  8. taskops/_wire.py +77 -0
  9. taskops/board.py +196 -0
  10. taskops/cli/__init__.py +7 -0
  11. taskops/cli/__main__.py +7 -0
  12. taskops/cli/admin.py +111 -0
  13. taskops/cli/claude.py +196 -0
  14. taskops/cli/commands.py +173 -0
  15. taskops/cli/enrol.py +45 -0
  16. taskops/cli/main.py +165 -0
  17. taskops/cli/operate.py +178 -0
  18. taskops/cli/push.py +195 -0
  19. taskops/cli/remote.py +141 -0
  20. taskops/cli/serving.py +157 -0
  21. taskops/cli/watch.py +51 -0
  22. taskops/cli/wording.py +93 -0
  23. taskops/core/__init__.py +3 -0
  24. taskops/core/actors.py +77 -0
  25. taskops/core/challenge.py +98 -0
  26. taskops/core/chapters.py +68 -0
  27. taskops/core/event.py +101 -0
  28. taskops/core/graph.py +142 -0
  29. taskops/core/hours.py +123 -0
  30. taskops/core/machine.py +135 -0
  31. taskops/core/mentions.py +88 -0
  32. taskops/core/replay.py +146 -0
  33. taskops/core/review.py +66 -0
  34. taskops/core/scope.py +113 -0
  35. taskops/core/seams.py +127 -0
  36. taskops/core/types.py +196 -0
  37. taskops/gitwork/__init__.py +3 -0
  38. taskops/gitwork/bind.py +150 -0
  39. taskops/gitwork/catchup.py +59 -0
  40. taskops/gitwork/claudefiles.py +97 -0
  41. taskops/gitwork/diff.py +154 -0
  42. taskops/gitwork/install.py +125 -0
  43. taskops/gitwork/landing.py +104 -0
  44. taskops/gitwork/patch.py +64 -0
  45. taskops/gitwork/remote.py +147 -0
  46. taskops/gitwork/run.py +102 -0
  47. taskops/gitwork/sig.py +109 -0
  48. taskops/gitwork/trailer.py +60 -0
  49. taskops/gitwork/trees.py +114 -0
  50. taskops/http/__init__.py +3 -0
  51. taskops/http/admin.py +153 -0
  52. taskops/http/auth.py +85 -0
  53. taskops/http/feed.py +178 -0
  54. taskops/http/gitdoor.py +103 -0
  55. taskops/http/grants.py +69 -0
  56. taskops/http/handler.py +164 -0
  57. taskops/http/ingest.py +181 -0
  58. taskops/http/login.py +155 -0
  59. taskops/http/mounts.py +170 -0
  60. taskops/http/rpc.py +141 -0
  61. taskops/http/scoped.py +40 -0
  62. taskops/http/server.py +53 -0
  63. taskops/http/static.py +96 -0
  64. taskops/http/upstream.py +141 -0
  65. taskops/http/watcher.py +76 -0
  66. taskops/identity.py +112 -0
  67. taskops/mcp/__init__.py +3 -0
  68. taskops/{transports/mcp → mcp}/__main__.py +1 -1
  69. taskops/mcp/before.py +149 -0
  70. taskops/mcp/boards.py +62 -0
  71. taskops/mcp/boardview.py +152 -0
  72. taskops/mcp/brief.py +105 -0
  73. taskops/mcp/dossier.py +162 -0
  74. taskops/mcp/fields.py +79 -0
  75. taskops/mcp/gitmoves.py +191 -0
  76. taskops/mcp/hello.py +76 -0
  77. taskops/mcp/integrate.py +161 -0
  78. taskops/mcp/render.py +88 -0
  79. taskops/mcp/schema.py +166 -0
  80. taskops/mcp/server.py +143 -0
  81. taskops/mcp/thread.py +40 -0
  82. taskops/mcp/tools.py +167 -0
  83. taskops/session.py +109 -0
  84. taskops/store/__init__.py +3 -0
  85. taskops/store/cache.py +164 -0
  86. taskops/store/creds.py +165 -0
  87. taskops/store/live.py +193 -0
  88. taskops/store/log.py +77 -0
  89. taskops/store/pubkeys.py +61 -0
  90. taskops/store/reviews.py +94 -0
  91. taskops/store/server.py +185 -0
  92. taskops/store/stores.py +110 -0
  93. taskops/ui/app.js +60 -0
  94. taskops/ui/index.html +20 -0
  95. taskops/ui/style.css +1 -0
  96. taskops/verbs/__init__.py +154 -0
  97. taskops/verbs/_args.py +97 -0
  98. taskops/verbs/_cards.py +59 -0
  99. taskops/verbs/_context.py +189 -0
  100. taskops/verbs/_facts.py +163 -0
  101. taskops/verbs/_mentions.py +80 -0
  102. taskops/verbs/_rows.py +46 -0
  103. taskops/verbs/_waiting.py +57 -0
  104. taskops/verbs/assign.py +115 -0
  105. taskops/verbs/card.py +54 -0
  106. taskops/verbs/events.py +60 -0
  107. taskops/verbs/plan.py +154 -0
  108. taskops/verbs/project.py +109 -0
  109. taskops/verbs/pulse.py +165 -0
  110. taskops/verbs/record.py +99 -0
  111. taskops/verbs/report.py +120 -0
  112. taskops/verbs/review.py +88 -0
  113. taskops/verbs/take.py +106 -0
  114. taskops/verbs/update.py +193 -0
  115. taskops_cli-0.3.0.dist-info/METADATA +239 -0
  116. taskops_cli-0.3.0.dist-info/RECORD +119 -0
  117. {taskops_cli-0.2.0.dist-info → taskops_cli-0.3.0.dist-info}/WHEEL +1 -2
  118. taskops_cli-0.3.0.dist-info/entry_points.txt +2 -0
  119. taskops/_types.py +0 -106
  120. taskops/assets/GUIDE.md +0 -220
  121. taskops/contracts/__init__.py +0 -96
  122. taskops/contracts/_fields.py +0 -113
  123. taskops/contracts/actor.py +0 -30
  124. taskops/contracts/board.py +0 -142
  125. taskops/contracts/commit.py +0 -36
  126. taskops/contracts/day.py +0 -150
  127. taskops/contracts/dep.py +0 -23
  128. taskops/contracts/event.py +0 -58
  129. taskops/contracts/gitstate.py +0 -45
  130. taskops/contracts/index.py +0 -37
  131. taskops/contracts/lease.py +0 -43
  132. taskops/contracts/log.py +0 -59
  133. taskops/contracts/remote.py +0 -48
  134. taskops/contracts/results.py +0 -84
  135. taskops/contracts/task.py +0 -94
  136. taskops/contracts/tools.py +0 -110
  137. taskops/contracts/wire.py +0 -60
  138. taskops/engine/__init__.py +0 -34
  139. taskops/engine/_blocks.py +0 -48
  140. taskops/engine/_briefs.py +0 -92
  141. taskops/engine/_chunks.py +0 -66
  142. taskops/engine/_closed.py +0 -65
  143. taskops/engine/_entries.py +0 -126
  144. taskops/engine/_events.py +0 -55
  145. taskops/engine/_opened.py +0 -51
  146. taskops/engine/_process.py +0 -80
  147. taskops/engine/_prompts.py +0 -98
  148. taskops/engine/_stream.py +0 -129
  149. taskops/engine/activity.py +0 -94
  150. taskops/engine/bus.py +0 -42
  151. taskops/engine/commitline.py +0 -110
  152. taskops/engine/day.py +0 -142
  153. taskops/engine/diffstat.py +0 -63
  154. taskops/engine/gitio.py +0 -108
  155. taskops/engine/gitstate.py +0 -96
  156. taskops/engine/history.py +0 -76
  157. taskops/engine/identity.py +0 -84
  158. taskops/engine/log.py +0 -68
  159. taskops/engine/machine.py +0 -160
  160. taskops/engine/narrate.py +0 -91
  161. taskops/engine/project.py +0 -57
  162. taskops/engine/replay.py +0 -142
  163. taskops/engine/reports.py +0 -67
  164. taskops/engine/scheduler.py +0 -135
  165. taskops/engine/transcript.py +0 -127
  166. taskops/engine/wire.py +0 -92
  167. taskops/engine/worker.py +0 -115
  168. taskops/render/__init__.py +0 -40
  169. taskops/render/_closed_days.py +0 -64
  170. taskops/render/_dossier.py +0 -68
  171. taskops/render/_opened.py +0 -64
  172. taskops/render/_sections.py +0 -51
  173. taskops/render/_tasklist.py +0 -72
  174. taskops/render/_text.py +0 -74
  175. taskops/render/_verbatim.py +0 -65
  176. taskops/render/ansi.py +0 -92
  177. taskops/render/board.py +0 -55
  178. taskops/render/day.py +0 -102
  179. taskops/render/dispatch.py +0 -104
  180. taskops/render/inbox.py +0 -27
  181. taskops/render/log.py +0 -47
  182. taskops/render/recover.py +0 -64
  183. taskops/render/report.py +0 -51
  184. taskops/render/reports.py +0 -57
  185. taskops/render/results.py +0 -96
  186. taskops/render/session.py +0 -75
  187. taskops/render/task.py +0 -88
  188. taskops/render/tasklist.py +0 -65
  189. taskops/storage/__init__.py +0 -36
  190. taskops/storage/_ddl.py +0 -78
  191. taskops/storage/_delivered.py +0 -51
  192. taskops/storage/_deps.py +0 -69
  193. taskops/storage/_events.py +0 -127
  194. taskops/storage/_leases.py +0 -108
  195. taskops/storage/_rows.py +0 -90
  196. taskops/storage/_tasks.py +0 -124
  197. taskops/storage/locate.py +0 -76
  198. taskops/storage/schema.py +0 -66
  199. taskops/storage/store.py +0 -120
  200. taskops/storage/sync.py +0 -139
  201. taskops/transports/__init__.py +0 -6
  202. taskops/transports/cli/__init__.py +0 -0
  203. taskops/transports/cli/commands/__init__.py +0 -3
  204. taskops/transports/cli/commands/_digest.py +0 -64
  205. taskops/transports/cli/commands/_serve_init.py +0 -65
  206. taskops/transports/cli/commands/_shared.py +0 -50
  207. taskops/transports/cli/commands/_tasks_args.py +0 -112
  208. taskops/transports/cli/commands/_window.py +0 -45
  209. taskops/transports/cli/commands/ask.py +0 -27
  210. taskops/transports/cli/commands/dispatch.py +0 -43
  211. taskops/transports/cli/commands/init.py +0 -48
  212. taskops/transports/cli/commands/log.py +0 -22
  213. taskops/transports/cli/commands/plan.py +0 -43
  214. taskops/transports/cli/commands/pushpull.py +0 -53
  215. taskops/transports/cli/commands/recover.py +0 -30
  216. taskops/transports/cli/commands/remote.py +0 -56
  217. taskops/transports/cli/commands/report.py +0 -82
  218. taskops/transports/cli/commands/run_.py +0 -60
  219. taskops/transports/cli/commands/serve.py +0 -74
  220. taskops/transports/cli/commands/sync.py +0 -22
  221. taskops/transports/cli/commands/tasks.py +0 -79
  222. taskops/transports/cli/commands/ui.py +0 -72
  223. taskops/transports/cli/commands/update.py +0 -25
  224. taskops/transports/cli/main.py +0 -85
  225. taskops/transports/hooks/__init__.py +0 -15
  226. taskops/transports/hooks/__main__.py +0 -63
  227. taskops/transports/hooks/_args.py +0 -28
  228. taskops/transports/hooks/claude.py +0 -78
  229. taskops/transports/hooks/commit.py +0 -61
  230. taskops/transports/hooks/events.py +0 -107
  231. taskops/transports/hooks/record.py +0 -55
  232. taskops/transports/http/__init__.py +0 -21
  233. taskops/transports/http/_handler.py +0 -132
  234. taskops/transports/http/_wire.py +0 -79
  235. taskops/transports/http/_wsframes.py +0 -116
  236. taskops/transports/http/agentapi.py +0 -69
  237. taskops/transports/http/api.py +0 -122
  238. taskops/transports/http/exchange.py +0 -80
  239. taskops/transports/http/live.py +0 -160
  240. taskops/transports/http/policy.py +0 -110
  241. taskops/transports/http/projects.py +0 -116
  242. taskops/transports/http/reports.py +0 -75
  243. taskops/transports/http/router.py +0 -90
  244. taskops/transports/http/server.py +0 -50
  245. taskops/transports/http/static.py +0 -86
  246. taskops/transports/http/ui/app.js +0 -59
  247. taskops/transports/http/ui/index.html +0 -23
  248. taskops/transports/http/ui/style.css +0 -1
  249. taskops/transports/http/websocket.py +0 -60
  250. taskops/transports/mcp/__init__.py +0 -19
  251. taskops/transports/mcp/_descriptions.py +0 -102
  252. taskops/transports/mcp/_reads.py +0 -82
  253. taskops/transports/mcp/_writes.py +0 -72
  254. taskops/transports/mcp/answers.py +0 -44
  255. taskops/transports/mcp/arguments.py +0 -104
  256. taskops/transports/mcp/dispatch.py +0 -47
  257. taskops/transports/mcp/protocol.py +0 -83
  258. taskops/transports/mcp/schema.py +0 -50
  259. taskops/transports/mcp/server.py +0 -49
  260. taskops/transports/mcp/tools.py +0 -66
  261. taskops/usecases/__init__.py +0 -56
  262. taskops/usecases/_entry.py +0 -84
  263. taskops/usecases/_facts.py +0 -49
  264. taskops/usecases/_freeing.py +0 -93
  265. taskops/usecases/_gitignore.py +0 -94
  266. taskops/usecases/_mirroring.py +0 -57
  267. taskops/usecases/_narrating.py +0 -77
  268. taskops/usecases/_project.py +0 -67
  269. taskops/usecases/_range.py +0 -102
  270. taskops/usecases/_reasons.py +0 -54
  271. taskops/usecases/_remotefile.py +0 -62
  272. taskops/usecases/_reportsync.py +0 -108
  273. taskops/usecases/_routing.py +0 -91
  274. taskops/usecases/_wireclient.py +0 -141
  275. taskops/usecases/ask.py +0 -41
  276. taskops/usecases/claim.py +0 -104
  277. taskops/usecases/dispatch.py +0 -160
  278. taskops/usecases/dossier.py +0 -155
  279. taskops/usecases/edit.py +0 -71
  280. taskops/usecases/exchange.py +0 -86
  281. taskops/usecases/feed.py +0 -138
  282. taskops/usecases/guard.py +0 -122
  283. taskops/usecases/hooks.py +0 -127
  284. taskops/usecases/index.py +0 -62
  285. taskops/usecases/ingest.py +0 -60
  286. taskops/usecases/log.py +0 -134
  287. taskops/usecases/narration.py +0 -91
  288. taskops/usecases/plan.py +0 -128
  289. taskops/usecases/pushpull.py +0 -119
  290. taskops/usecases/recover.py +0 -84
  291. taskops/usecases/remote.py +0 -78
  292. taskops/usecases/report.py +0 -87
  293. taskops/usecases/reportfile.py +0 -106
  294. taskops/usecases/session.py +0 -134
  295. taskops/usecases/setup.py +0 -92
  296. taskops/usecases/sync.py +0 -78
  297. taskops/usecases/update.py +0 -123
  298. taskops/usecases/view.py +0 -112
  299. taskops_cli-0.2.0.dist-info/METADATA +0 -291
  300. taskops_cli-0.2.0.dist-info/RECORD +0 -193
  301. taskops_cli-0.2.0.dist-info/entry_points.txt +0 -2
  302. taskops_cli-0.2.0.dist-info/licenses/LICENSE +0 -21
  303. taskops_cli-0.2.0.dist-info/top_level.txt +0 -1
taskops/__init__.py CHANGED
@@ -1,39 +1,26 @@
1
- """taskops — the coordination substrate Claude Code does not have.
1
+ """taskops — a shared work board for teams of coding agents.
2
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.
3
+ The public surface is deliberately tiny: open a board, call a verb, handle one
4
+ error tree. Everything else (MCP tools, the HTTP server, the git hooks) is a
5
+ transport built on top of exactly this.
11
6
  """
12
7
 
13
8
  from __future__ import annotations
14
9
 
15
10
  from ._errors import (
16
- AlreadyWritten,
11
+ Refused,
12
+ NotFound,
17
13
  BadRequest,
18
- GuardFailed,
19
- IllegalTransition,
20
- LeaseHeld,
21
- NoLease,
22
- NoSuchTask,
23
- NotInitialized,
14
+ Unreachable,
24
15
  TaskopsError,
25
16
  )
26
17
  from ._version import __version__
27
18
 
28
19
  __all__ = [
29
- "__version__",
30
20
  "TaskopsError",
31
- "NotInitialized",
32
- "NoSuchTask",
33
- "IllegalTransition",
34
- "LeaseHeld",
35
- "NoLease",
36
- "GuardFailed",
21
+ "Refused",
22
+ "NotFound",
23
+ "Unreachable",
37
24
  "BadRequest",
38
- "AlreadyWritten",
25
+ "__version__",
39
26
  ]
taskops/_clock.py CHANGED
@@ -1,32 +1,33 @@
1
- """Layer 0 — the one place that asks what time it is.
1
+ """The only module that reads the clock.
2
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.
3
+ `tests/test_architecture.py` pins this: `time.time`, `datetime.now`,
4
+ `time.monotonic`, `localtime` and `strftime` may not appear anywhere else
5
+ (except `core/hours.py`, which does calendar arithmetic and says so). v1 let a
6
+ stray `strftime` through and a report cut days in two timezones at once.
7
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.
8
+ Tests freeze time through `set_now`; nothing else may.
12
9
  """
13
10
 
14
11
  from __future__ import annotations
15
12
 
16
13
  import time
17
14
 
18
- __all__ = ["now", "LEASE_TTL", "HEARTBEAT_GRACE"]
15
+ _frozen: float | None = None
19
16
 
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
17
 
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."""
18
+ def now() -> float:
19
+ """Wall-clock seconds. The single source of 'when'."""
20
+ return _frozen if _frozen is not None else time.time()
28
21
 
29
22
 
30
- def now() -> float:
31
- """Wall-clock seconds since the epoch."""
32
- return time.time()
23
+ def datestamp(when: float) -> str:
24
+ """`YYYY-MM-DD`, local. Here and not at the call site for the reason above:
25
+ the archive a `board push` leaves behind is named after a day, and a day is
26
+ a calendar fact — the one kind of formatting this module exists to own."""
27
+ return time.strftime("%Y-%m-%d", time.localtime(when))
28
+
29
+
30
+ def set_now(value: float | None) -> None:
31
+ """Freeze (or unfreeze) the clock. Tests only — never called by the package."""
32
+ global _frozen
33
+ _frozen = value
taskops/_errors.py CHANGED
@@ -1,160 +1,55 @@
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.
1
+ """The one error tree.
2
+
3
+ Every exception that escapes this package descends from `TaskopsError`. A
4
+ foreign exception is converted at the boundary that raises it (`raise X from
5
+ err`) — a caller never sees `sqlite3.OperationalError` or
6
+ `json.JSONDecodeError`. Each class carries a stable `code` because that code
7
+ crosses the wire in the RPC envelope, so the string lives here and nowhere
8
+ else.
9
+
10
+ `Refused` is the interesting one: a rule said no, and **its message contains
11
+ the call that fixes it, verbatim**. That was the best habit of v1 and it is
12
+ kept word for word.
8
13
  """
9
14
 
10
15
  from __future__ import annotations
11
16
 
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
17
 
21
18
  class TaskopsError(Exception):
22
- """Root. `except TaskopsError` catches everything the engine raises."""
19
+ """Root of everything this package raises."""
23
20
 
24
21
  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
22
 
68
- code = "lease_held"
69
- http_status = 409
70
23
 
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")
24
+ class Refused(TaskopsError):
25
+ """A rule said no. The message must name the way out."""
75
26
 
27
+ code = "refused"
76
28
 
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
29
 
81
- code = "no_lease"
82
- http_status = 409
30
+ class NotFound(TaskopsError):
31
+ """A card, milestone, board or event that does not exist."""
83
32
 
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}`")
33
+ code = "not_found"
88
34
 
89
35
 
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."""
36
+ class Unreachable(TaskopsError):
37
+ """The remote board did not answer. Never silently degrades to local."""
94
38
 
95
- code = "guard_failed"
96
- http_status = 400
39
+ code = "unreachable"
97
40
 
98
41
 
99
- class BadRequest(TaskopsError, ValueError):
100
- """An argument that cannot mean anything. Raised at the edges, not inside."""
42
+ class BadRequest(TaskopsError):
43
+ """Malformed input: bad actor grammar, unknown verb, wrong argument shape."""
101
44
 
102
45
  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
46
 
128
- code = "already_narrating"
129
- http_status = 409
130
47
 
48
+ CODES: dict[str, type[TaskopsError]] = {
49
+ cls.code: cls for cls in (Refused, NotFound, Unreachable, BadRequest, TaskopsError)
50
+ }
131
51
 
132
- class NarrationFailed(TaskopsError, RuntimeError):
133
- """The `claude` CLI could not write the narration. 502: an upstream did not answer.
134
52
 
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
53
+ def from_code(code: str, message: str) -> TaskopsError:
54
+ """Rebuild an error carried over the wire. Unknown code → the root type."""
55
+ return CODES.get(code, TaskopsError)(message)
taskops/_ids.py CHANGED
@@ -1,63 +1,71 @@
1
- """Layer 0 — how a task and an event get their names.
1
+ """Identifiers.
2
2
 
3
- Two different jobs, and the difference is the whole design:
3
+ Two shapes, both deliberate:
4
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.
5
+ * a card id is **random** (`tk-` + 6 hex). It is a name, not a hash: it must
6
+ stay stable while the card is edited, and it is what the git branch and the
7
+ commit trailer are made of.
8
+ * an event id is the **sha256 of its canonical form, 32 hex**. Content-hash
9
+ ids are what make the log idempotent (`INSERT OR IGNORE` and replaying twice
10
+ is free). v1 truncated to 16 hex — 64 bits — and a collision there is a
11
+ silently dropped event, so 128 bits it is.
15
12
  """
16
13
 
17
14
  from __future__ import annotations
18
15
 
19
- import hashlib
20
- import json
21
16
  import secrets
22
- from typing import Any
23
-
24
- __all__ = ["new_task_id", "event_id", "slugify", "TASK_PREFIX"]
17
+ from hashlib import sha256
25
18
 
26
19
  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
20
+ MILESTONE_PREFIX = "ms-"
21
+ EVENT_ID_LEN = 32
29
22
 
30
23
 
31
24
  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,
25
+ """`tk-` + 6 random hex. Collision-checked by the caller's INSERT."""
26
+ return TASK_PREFIX + secrets.token_hex(3)
27
+
28
+
29
+ def new_milestone_id() -> str:
30
+ return MILESTONE_PREFIX + secrets.token_hex(3)
31
+
32
+
33
+ def is_task_id(value: str) -> bool:
34
+ return _is(value, TASK_PREFIX)
35
+
36
+
37
+ def is_milestone_id(value: str) -> bool:
38
+ return _is(value, MILESTONE_PREFIX)
39
+
40
+
41
+ def _is(value: str, prefix: str) -> bool:
42
+ body = value[len(prefix) :]
43
+ return (
44
+ value.startswith(prefix) and len(body) == 6 and all(c in "0123456789abcdef" for c in body)
48
45
  )
49
- return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:_EVENT_CHARS]
50
46
 
51
47
 
52
- def slugify(text: str, *, limit: int = 32) -> str:
53
- """A title -> the branch-safe half of `tk/<id>/<slug>`.
48
+ def digest(canonical: str) -> str:
49
+ """sha256 of an already-canonical string, truncated to 128 bits."""
50
+ return sha256(canonical.encode("utf-8")).hexdigest()[:EVENT_ID_LEN]
51
+
52
+
53
+ def new_token() -> str:
54
+ """A credential or invite secret. Only the sha256 of this is ever stored.
55
+
56
+ **Never starts with `-`.** `token_urlsafe` draws from `A-Za-z0-9_-`, so
57
+ roughly one token in 64 begins with a hyphen — and every one of those is a
58
+ token that cannot be passed to the CLI: `taskops join x --invite -Ab9…`
59
+ makes argparse read the secret as an option and refuse with *expected one
60
+ argument*. It fails for the user who was unlucky, on a value they cannot
61
+ influence, with a message about the flag rather than the value.
62
+
63
+ Fixed HERE, at the one place a secret is minted, rather than by quoting or
64
+ `--invite=` at each call site: the token also travels in URLs, briefs and
65
+ shell snippets nobody controls. Found by CI — the runner drew a leading
66
+ hyphen on the first run this suite ever had off this laptop.
54
67
 
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"
68
+ Rerolling (not stripping) keeps every token the full 24 bytes of entropy."""
69
+ while (token := secrets.token_urlsafe(24)).startswith("-"):
70
+ pass
71
+ return token
taskops/_json.py ADDED
@@ -0,0 +1,48 @@
1
+ """Foreign JSON becomes a typed mapping HERE, or it becomes nothing.
2
+
3
+ Every boundary that reads JSON somebody else wrote — a config file, an HTTP
4
+ envelope, an MCP argument object — goes through `as_object`. One place to be
5
+ lenient, everywhere else typed. v1 spread this coercion over fifteen call
6
+ sites and one of them read `claim="false"` as True.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, cast
12
+
13
+
14
+ def as_object(value: object) -> dict[str, Any]:
15
+ """A JSON object as `dict[str, Any]`. Anything else is an empty mapping."""
16
+ if not isinstance(value, dict):
17
+ return {}
18
+ return {str(k): v for k, v in cast("dict[Any, Any]", value).items()}
19
+
20
+
21
+ def as_rows(value: object) -> list[dict[str, Any]]:
22
+ """A JSON array of objects. A non-object entry becomes an empty mapping."""
23
+ if not isinstance(value, list):
24
+ return []
25
+ return [as_object(x) for x in cast("list[object]", value)]
26
+
27
+
28
+ def as_strings(value: object) -> list[str]:
29
+ if not isinstance(value, list):
30
+ return []
31
+ return [x for x in cast("list[object]", value) if isinstance(x, str)]
32
+
33
+
34
+ def text(value: object, default: str = "") -> str:
35
+ """A string field that may be missing or of the wrong type."""
36
+ return value if isinstance(value, str) else default
37
+
38
+
39
+ def query(url: str) -> dict[str, str]:
40
+ """`…?token=abc&invite=xyz` → the parameters, without a urllib import at
41
+ every call site. Anything malformed is simply absent."""
42
+ _, _, tail = url.partition("?")
43
+ found: dict[str, str] = {}
44
+ for part in tail.split("&"):
45
+ key, sep, value = part.partition("=")
46
+ if sep and key:
47
+ found[key] = value
48
+ return found
taskops/_locate.py ADDED
@@ -0,0 +1,106 @@
1
+ """Where the board is, and what its config says. Nothing here opens one.
2
+
3
+ Split out of `board.py` along the seam that file's own history names: v1 kept
4
+ this as `storage/locate.py`, and the two questions really are separate — *which
5
+ directory is this project's* is answered from the filesystem alone, while *how
6
+ do I talk to its board* needs the stores, the verbs and the network.
7
+
8
+ Two files make a project, and only `init`/`join` ever write them:
9
+
10
+ .taskops/board.json {"url": …} committed — the address travels with the code
11
+ .taskops/remote.json {"token", "token_expires", "login"} 0600, gitignored —
12
+ the secret never travels, and with a `login` block the
13
+ token is a SESSION this machine re-mints (`session.py`)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import json
20
+ from typing import Any
21
+ from pathlib import Path
22
+
23
+ from ._json import as_object
24
+
25
+ DIR = ".taskops"
26
+
27
+ # "A board lives here." Written by BOTH `init` and `join`, so it is the one
28
+ # marker every project has and nothing else creates.
29
+ ADDRESS = "board.json"
30
+
31
+
32
+ def is_project(candidate: Path) -> bool:
33
+ """A directory is a project when `.taskops/board.json` is in it.
34
+
35
+ The FILE, not the directory, and the distinction is not pedantry: v1 kept
36
+ its sessions in `~/.taskops/`, so that directory exists on every machine
37
+ that ever ran it — and matching the directory alone made the HOME DIRECTORY
38
+ a project: `taskops init` in a fresh repo under it walked up, adopted HOME,
39
+ and wrote the board, both git hooks, `.mcp.json` and the Claude settings
40
+ there instead of in the repo. v1 hit and fixed this the same way
41
+ (`storage/locate.py`); v2 shipped the bare check and reproduced it.
42
+ """
43
+ return (candidate / DIR / ADDRESS).is_file()
44
+
45
+
46
+ def find_root(start: Path) -> Path:
47
+ """The nearest project, else the git root, else `start`.
48
+
49
+ A project wins over `.git` deliberately: a worker's worktree lives at
50
+ `<repo>/.taskops/trees/tk-…` and has a `.git` file of its own. Answering
51
+ "the worktree" there would look for the credential in the wrong place and
52
+ silently fall back to a local board — one of v1's split-brain routes. The
53
+ walk finds `<repo>` anyway, because that is where the address file is.
54
+ """
55
+ here = start.resolve()
56
+ chain = [here, *here.parents]
57
+ for candidate in chain:
58
+ if is_project(candidate):
59
+ return candidate
60
+ for candidate in chain:
61
+ if (candidate / ".git").exists():
62
+ return candidate
63
+ return here
64
+
65
+
66
+ def read_config(root: Path) -> dict[str, Any]:
67
+ """board.json (committed) merged with remote.json (secret). Missing is fine."""
68
+ out: dict[str, Any] = {}
69
+ for name in (ADDRESS, "remote.json"):
70
+ path = root / DIR / name
71
+ if not path.exists():
72
+ continue
73
+ try:
74
+ data: Any = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, ValueError):
76
+ continue # a broken config means "not configured", never a crash on read
77
+ out.update(as_object(data))
78
+ return out
79
+
80
+
81
+ def write_remote(root: Path, fields: dict[str, Any]) -> None:
82
+ """Merge `fields` into remote.json, keeping every other key it holds, 0600.
83
+
84
+ The WRITER of the secret half, beside its reader, because the shape of that
85
+ file is one fact: a session refresh (`session.remember`), the key that minted
86
+ it (`session.cache_login`) and the host and board this checkout operates
87
+ (`cli/remote.py`) each write ONE block and must not eat the others'.
88
+
89
+ `login` is therefore MERGED FIELD BY FIELD and never replaced whole. Three
90
+ writers own three fields of it — `remote add` the host, a sign-in the
91
+ principal and the key, `board create` the board's name — and a plain
92
+ top-level update would have the sign-in silently drop the recorded name, so
93
+ the bare `board push` after it would go back to guessing the directory."""
94
+ path = root / DIR / "remote.json"
95
+ body: dict[str, Any] = {}
96
+ if path.exists():
97
+ try:
98
+ body = as_object(json.loads(path.read_text(encoding="utf-8")))
99
+ except (OSError, ValueError):
100
+ body = {}
101
+ if "login" in fields:
102
+ fields = {**fields, "login": {**as_object(body.get("login")), **as_object(fields["login"])}}
103
+ body.update(fields)
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
106
+ os.chmod(path, 0o600)
taskops/_version.py CHANGED
@@ -1,7 +1,4 @@
1
- """The single source of the version. `pyproject.toml` reads this attribute."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
- __all__ = ["__version__"]
6
-
7
- __version__ = "0.2.0"
3
+ __title__ = "taskops"
4
+ __version__ = "0.3.0" # single source of truth; pyproject reads it dynamically