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,57 @@
1
+ """Bringing the local board level with a write that happened on the server.
2
+
3
+ The pull that follows a remote write carries the EVENTS, and events are enough for most of
4
+ what a board shows — but not for a claim. `engine.replay` deliberately does not materialise
5
+ leases: importing one would mean claiming a task on behalf of an agent running on another
6
+ machine, which is the opposite of what this whole feature is for. So a pull alone leaves the
7
+ winner's own board saying `ready`, and the commit guard reads that board — it refuses a commit
8
+ from an actor with no live lease HERE. The agent would have won the race and then been told it
9
+ had never claimed anything.
10
+
11
+ What is safe is narrower than what replay refuses, and this module is exactly that narrow: the
12
+ only lease written here is the one the server just GRANTED TO THIS CALLER, handed back in the
13
+ answer. It is not inferred from somebody else's event; it is the receipt for a claim this
14
+ machine made a moment ago. Everything else about other agents' claims still stays out.
15
+
16
+ The result is also applied verbatim rather than recomputed: the timestamps are the server's, so
17
+ a later status event from that same server is still newer than what is written here and replays
18
+ normally. Stamping `now()` instead would silently make the local row look newer than the server
19
+ it came from, and every subsequent pull would decline to update it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ from ..contracts import Claim, Task
27
+ from ._project import project
28
+
29
+ __all__ = ["mirror_claim", "mirror_update"]
30
+
31
+ _LEASE_ENDS = ("ready", "done", "cancelled")
32
+
33
+
34
+ def mirror_claim(start: Path | str, claim: Claim) -> None:
35
+ """Record the lease the server granted, and put the card in `claimed` here too."""
36
+ lease = claim["lease"]
37
+ with project(start) as store:
38
+ if store.tasks.get(lease["task"]) is None:
39
+ return
40
+ store.leases.release(lease["task"])
41
+ store.leases.acquire(lease)
42
+ store.tasks.set_status(lease["task"], "claimed", when=lease["acquired"])
43
+
44
+
45
+ def mirror_update(start: Path | str, task: Task) -> None:
46
+ """Take the task exactly as the server returned it, and drop the lease if it ended.
47
+
48
+ The status is copied rather than replayed because this caller has the authoritative row
49
+ in its hands; the lease has to be dropped here because releasing one is local state and
50
+ `replay` has no business writing it, so nothing else would ever clear it.
51
+ """
52
+ with project(start) as store:
53
+ if store.tasks.get(task["id"]) is None:
54
+ return
55
+ store.tasks.set_status(task["id"], task["status"], when=task["updated"])
56
+ if task["status"] in _LEASE_ENDS:
57
+ store.leases.release(task["id"])
@@ -0,0 +1,77 @@
1
+ """The narration reaching the FILE while it is still being written.
2
+
3
+ The first version wrote the prose once, at the end. On a day that is one reading that is
4
+ thirty seconds of `_pendiente_`; on `report all`, which narrates in several passes, it is a
5
+ quarter of an hour during which the file on disk is indistinguishable from one nobody ever
6
+ narrated — and if the process dies at minute fourteen, that is exactly what it becomes.
7
+
8
+ So the text is flushed as it arrives: every `FLUSH_CHARS` of prose, and at every pass
9
+ boundary. A flush is a whole-file rewrite through `render.narrated`, which is the same call
10
+ the final write makes, so a partial file is never a different SHAPE of file — only a shorter
11
+ one. Nothing here decides anything; it is the writing half of `dossier.digest`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from ..engine import OnPass, OnText
19
+ from ..render import narrated
20
+
21
+ __all__ = ["Progressive", "FLUSH_CHARS"]
22
+
23
+ FLUSH_CHARS = 400
24
+ """Prose written since the last flush before the file is rewritten. Roughly a paragraph.
25
+
26
+ A report is tens of kilobytes and a narration arrives over minutes, so this is a handful of
27
+ rewrites per pass — cheap enough to ignore, frequent enough that a person watching the file
28
+ sees it grow rather than jump.
29
+ """
30
+
31
+
32
+ class Progressive:
33
+ """Wraps a caller's callbacks so the same deltas also land on disk as they arrive."""
34
+
35
+ def __init__(self, path: Path, report: str, *,
36
+ on_pass: OnPass = None, on_text: OnText = None) -> None:
37
+ self._path = path
38
+ self._report = report
39
+ self._on_pass = on_pass
40
+ self._on_text = on_text
41
+ self._written: list[str] = []
42
+ self._chars = 0
43
+ self._flushed = 0
44
+
45
+ def text(self, delta: str) -> None:
46
+ """A fragment: keep it, pass it on, and flush once enough has piled up."""
47
+ self._written.append(delta)
48
+ self._chars += len(delta)
49
+ if self._on_text:
50
+ self._on_text(delta)
51
+ if self._chars - self._flushed >= FLUSH_CHARS:
52
+ self.flush()
53
+
54
+ def passed(self, n: int, total: int) -> None:
55
+ """A pass is starting, so the previous one is complete — the natural moment to flush."""
56
+ self.flush()
57
+ if self._on_pass:
58
+ self._on_pass(n, total)
59
+
60
+ def finish(self, prose: str) -> Path:
61
+ """The FINAL write, from the text `narrate` returned rather than from the fragments.
62
+
63
+ Not the same string as the last flush, and that is why this exists: a multi-pass
64
+ narration returns the STITCHED reading, not the concatenation of the slices somebody
65
+ watched go by. The partial file is a live view; this is the document.
66
+ """
67
+ self._path.write_text(narrated(self._report, prose), encoding="utf-8")
68
+ return self._path
69
+
70
+ def flush(self) -> None:
71
+ """The report as it stands, narrated with the prose so far. A no-op before the first
72
+ delta, because a file whose narration section is empty reads as a taskops bug."""
73
+ prose = "".join(self._written)
74
+ if not prose.strip():
75
+ return
76
+ self._path.write_text(narrated(self._report, prose), encoding="utf-8")
77
+ self._flushed = self._chars
@@ -0,0 +1,67 @@
1
+ """Opening the project, and the heartbeat every call pays for.
2
+
3
+ Two things every use case needs and none of them should re-derive:
4
+
5
+ **The root.** Callers pass whatever path they happen to be in — an agent its cwd, a
6
+ git hook wherever git ran it — so resolution walks up for `.taskops/` exactly once,
7
+ here.
8
+
9
+ **The heartbeat.** Any taskops call an agent makes is proof it is alive, so every
10
+ call renews that agent's leases. This is what makes the TTL bound a CRASH rather
11
+ than a slow task: an agent working for an hour keeps its claim without doing anything
12
+ special, and one whose process died stops renewing within a single call.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Generator
18
+ from contextlib import contextmanager
19
+ from pathlib import Path
20
+
21
+ from .._clock import LEASE_TTL, now
22
+ from ..contracts import Actor
23
+ from ..engine import identity, sweep_dead
24
+ from ..storage import Store, resolve_root
25
+
26
+ __all__ = ["project", "caller", "heartbeat", "locate"]
27
+
28
+
29
+ def locate(start: Path | str) -> Path:
30
+ """Which project a path belongs to. Raises `NotInitialized` naming the fix.
31
+
32
+ Exported because the studio needs the root BEFORE it opens a port — it serves one repository,
33
+ and binding to a directory with no project would be a server that answers every request with
34
+ the same error. A transport may not call `storage.resolve_root` itself (an invariant test says
35
+ so, and it caught exactly this), so the resolution is a use case like any other.
36
+ """
37
+ return resolve_root(start)
38
+
39
+
40
+ @contextmanager
41
+ def project(start: Path | str) -> Generator[Store]:
42
+ """An open Store on the project containing `start`. Commits on a clean exit."""
43
+ with Store(resolve_root(start)) as store:
44
+ yield store
45
+
46
+
47
+ def caller(store: Store, asked: str = "") -> Actor:
48
+ """Who is calling. Resolved from the argument, the environment, then git."""
49
+ return identity.resolve(store.root, asked)
50
+
51
+
52
+ def heartbeat(store: Store, actor: str, *, at: float | None = None) -> None:
53
+ """Push this actor's LIVE lease deadlines out, and expire every dead one.
54
+
55
+ Both halves belong together: the sweep is what makes another agent's crash
56
+ visible, and running it on every call means no daemon has to exist to notice one.
57
+
58
+ An already-expired lease is NOT revived, and that is the important case. By the
59
+ time it lapsed another agent may have claimed the task, so quietly handing it
60
+ back would produce two holders and no error. The late agent instead finds out at
61
+ its next write, where the lease guard names the fix — which is the one path that
62
+ cannot end with two agents editing the same files believing they own them.
63
+ """
64
+ when = now() if at is None else at
65
+ for lease in store.leases.of_actor(actor, when):
66
+ store.leases.renew(task_id=lease["task"], actor=actor, expires=when + LEASE_TTL)
67
+ sweep_dead(store, at=when)
@@ -0,0 +1,102 @@
1
+ """Which days a report covers — the selector a human types, resolved to two dates.
2
+
3
+ Four ways of saying the same thing (one day, the last N, an explicit span, everything) and
4
+ ONE place that turns any of them into `from_date, to_date`. That is deliberate: the dossier
5
+ writer needs the same answer as the reader, and a second resolution is how a file named
6
+ `2026-07-22..2026-07-28.md` ends up holding a different week.
7
+
8
+ Strict, like `parse_window` and `parse_date`. A selector nobody can read is REFUSED and never
9
+ widened to something plausible: a report quietly covering the wrong month looks exactly like
10
+ a correct one, and it is the shape somebody pastes into a review.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import NamedTuple
16
+
17
+ from .._clock import now
18
+ from .._errors import BadRequest
19
+ from ..engine import date_of, first_date, shift
20
+ from ..storage import Store
21
+
22
+ __all__ = ["is_day", "Selector", "resolve", "parse_date"]
23
+
24
+ _UNITS = {"d": 1, "w": 7}
25
+
26
+
27
+ class Selector(NamedTuple):
28
+ """What the caller asked for, before a store is open. Defaults to today."""
29
+
30
+ date: str = ""
31
+ """The START of the window, or the whole of it when nothing else is set: `today`,
32
+ `yesterday`, or `YYYY-MM-DD` — whatever `parse_date` accepts."""
33
+
34
+ to: str = ""
35
+ """The last day, INCLUSIVE. Empty means today for a range, and `date` for a single day."""
36
+
37
+ last: str = ""
38
+ """`7d`, `2w`, `1m` — a span ending at `to`."""
39
+
40
+ whole: bool = False
41
+ """`report all`: from the log's first event to today."""
42
+
43
+
44
+ def resolve(store: Store, sel: Selector) -> tuple[str, str, str]:
45
+ """`Selector` -> the two dates a `period_report` runs between, and what to call them.
46
+
47
+ Order matters: `whole` wins, then `last`, then an explicit pair, then the single day.
48
+ They are not combinable, and letting one silently override another inside a report would
49
+ be worse than the argument parser refusing the combination up front.
50
+
51
+ `all` is labelled `all` and not by its dates on purpose: it is a standing name for "the
52
+ whole project", so `report all --digest` keeps updating ONE file instead of leaving a
53
+ trail of near-identical ones whose end date happens to differ.
54
+ """
55
+ if sel.whole:
56
+ return first_date(store), parse_date(sel.to), "all"
57
+ if sel.last:
58
+ end = parse_date(sel.to)
59
+ return _back(end, sel.last), end, ""
60
+ if sel.to:
61
+ return parse_date(sel.date), parse_date(sel.to), ""
62
+ return parse_date(sel.date), parse_date(sel.date), ""
63
+
64
+
65
+ def _back(end: str, last: str) -> str:
66
+ """`2026-07-28`, `7d` -> `2026-07-22`. INCLUSIVE of both ends: `7d` is seven days of
67
+ work, not seven days ago plus today, which would be eight."""
68
+ raw = last.strip().lower()
69
+ number, unit = raw[:-1], raw[-1:]
70
+ if not number.isdigit() or int(number) <= 0 or unit not in (*_UNITS, "m"):
71
+ raise BadRequest(f"`{last}` is not a span — use a positive number then d, w or m, "
72
+ f"e.g. 7d, 2w or 1m")
73
+ if unit == "m":
74
+ return shift(shift(end, months=-int(number)), days=1)
75
+ return shift(end, days=1 - int(number) * _UNITS[unit])
76
+
77
+
78
+ def parse_date(text: str) -> str:
79
+ """`yesterday` -> `2026-07-27`. Strict, for the same reason `parse_window` is.
80
+
81
+ `yesterday` is 24 hours back read as a LOCAL date rather than "today minus one" in
82
+ arithmetic on a date, which would have to know how long a month is. The calendar is
83
+ libc's problem, and libc is the only one that knows about the two days a year that are
84
+ not 24 hours long.
85
+ """
86
+ raw = text.strip().lower()
87
+ if raw in ("", "today"):
88
+ return date_of(now())
89
+ if raw == "yesterday":
90
+ return date_of(now() - 86400.0)
91
+ if len(raw) == 10 and raw[4] == "-" and raw[7] == "-" and raw.replace("-", "").isdigit():
92
+ return raw
93
+ raise BadRequest(f"`{text}` is not a day — use `today`, `yesterday`, or a date "
94
+ f"like 2026-07-28")
95
+
96
+
97
+ def is_day(label: str) -> bool:
98
+ """Whether a report label names one calendar day. The ONE place this shape is read:
99
+ the index and the report reader both branch on it, and two copies is how one of them
100
+ keeps accepting a shape the other started refusing."""
101
+ return (len(label) == 10 and label[4] == "-" and label[7] == "-"
102
+ and label.replace("-", "").isdigit())
@@ -0,0 +1,54 @@
1
+ """Why there is nothing to claim. Prose with branches in it, kept out of the claim path.
2
+
3
+ Split from `claim` because the two answer different questions: that module decides what an agent GETS,
4
+ this one explains what it did not get. Every branch here exists because a specific unhelpful answer
5
+ sent an agent to ask a human when it could have acted:
6
+
7
+ - "nothing ready" → it asks a human. "waiting on tk-abc" → it goes and does tk-abc.
8
+ - "cannot claim" → it retries the same call. "assigned to agent:ana/w2" → it asks for its own.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from ..engine import counts
14
+ from ..storage import Store
15
+
16
+ __all__ = ["why_nothing", "why_not_this"]
17
+
18
+
19
+ def why_nothing(store: Store, labels: tuple[str, ...], wanted: str, asker: str = "") -> str:
20
+ """Why there is nothing to do. "Nothing ready" alone sends the agent to ask a
21
+ human; told WHICH — all blocked, all taken, or genuinely finished — it can act."""
22
+ if wanted:
23
+ return why_not_this(store, wanted, asker)
24
+ numbers = counts(store)
25
+ if numbers["open"] == 0:
26
+ return "nothing open — the project is finished, or nothing has been planned"
27
+ if labels:
28
+ return (f"no ready task carries {', '.join(labels)}. Drop the label filter, "
29
+ f"or plan the work that is missing")
30
+ if numbers["ready"] == 0 and numbers["blocked"]:
31
+ return (f"{numbers['blocked']} task(s) are waiting on a dependency and "
32
+ f"{numbers['working']} are in flight — unblocking one of those is "
33
+ f"the useful move")
34
+ return (f"every ready task was claimed by another agent in the last moment "
35
+ f"({numbers['working']} in flight) — ask again")
36
+
37
+
38
+ def why_not_this(store: Store, wanted: str, asker: str = "") -> str:
39
+ """Why THIS task cannot be claimed. Each status has a different useful answer.
40
+
41
+ A blocked task names its blockers, because "not ready" alone sends the agent to a human when
42
+ the actionable move is usually to go work on the blocker instead.
43
+ """
44
+ task = store.tasks.need(wanted)
45
+ if task["status"] == "backlog":
46
+ blockers = ", ".join(store.deps.open_blockers_of(wanted)) or "unresolved deps"
47
+ return (f"{wanted} is waiting on {blockers} — finish those first, or claim one "
48
+ f"of them with taskops_next")
49
+ if task["status"] in ("done", "cancelled"):
50
+ return f"{wanted} is already {task['status']} — nothing to do there"
51
+ if task["assignee"] and task["assignee"] != asker:
52
+ return (f"{wanted} is assigned to {task['assignee']} — ask taskops_next with no task "
53
+ f"and it will give you something that is yours")
54
+ return f"{wanted} is {task['status']}, held by someone else — read it with taskops_ask"
@@ -0,0 +1,62 @@
1
+ """Reading and writing `.taskops/remote.json` — the file mechanics, nothing else.
2
+
3
+ Split from `remote` by what each half is: that module decides WHAT a remote is (one per
4
+ project, a URL and a secret, two cursors that never compare), this one knows how to put it
5
+ on disk without leaking it. The file is written through `os.open` with mode 0600 rather than
6
+ `write_text` followed by a `chmod`, because the two-step version publishes the token to every
7
+ user on the machine for the width of one syscall.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any, cast
16
+
17
+ from ..contracts import Remote
18
+ from ..storage import PROJECT_DIR, resolve_root
19
+
20
+ __all__ = ["REMOTE_FILE", "remote_path", "load", "write"]
21
+
22
+ REMOTE_FILE = f"{PROJECT_DIR}/remote.json"
23
+
24
+
25
+ def remote_path(root: Path) -> Path:
26
+ return root / REMOTE_FILE
27
+
28
+
29
+ def load(start: Path | str) -> Remote | None:
30
+ """The configured remote, or None. Never raises on a corrupt file.
31
+
32
+ A hand-edited `remote.json` with a trailing comma should read as "no remote configured",
33
+ which `remote add` can then fix — refusing to run and refusing to be repaired is a state
34
+ with no way out of it.
35
+ """
36
+ path = remote_path(resolve_root(start))
37
+ if not path.is_file():
38
+ return None
39
+ try:
40
+ parsed: Any = json.loads(path.read_text(encoding="utf-8"))
41
+ except (json.JSONDecodeError, OSError):
42
+ return None
43
+ if not isinstance(parsed, dict):
44
+ return None
45
+ fields = cast("dict[str, Any]", parsed)
46
+ if not fields.get("url"):
47
+ return None
48
+ return Remote(url=str(fields["url"]), token=str(fields.get("token", "")),
49
+ pushed=int(fields.get("pushed", 0) or 0),
50
+ cursor=int(fields.get("cursor", 0) or 0))
51
+
52
+
53
+ def write(root: Path, remote: Remote) -> Remote:
54
+ path = remote_path(root)
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
57
+ with os.fdopen(handle, "w", encoding="utf-8") as out:
58
+ json.dump(remote, out, indent=2, sort_keys=True)
59
+ out.write("\n")
60
+ # A file that already existed keeps its old mode through O_CREAT, so say it again.
61
+ os.chmod(path, 0o600)
62
+ return remote
@@ -0,0 +1,108 @@
1
+ """Reports across the wire — the half of sync that can destroy something irrecoverable.
2
+
3
+ Events are safe to move in bulk: they are facts with content-hash ids, so the union of two
4
+ logs is the correct log. A report is not. Its dossier regenerates from the log any time, but
5
+ the NARRATION under it was written once — by a model somebody paid for, or by a person — and
6
+ nothing in the system can reconstruct it. So the rule here is the opposite of the one for
7
+ events: never overwrite, ever, unless a human said `--force` after reading what they would
8
+ lose.
9
+
10
+ The comparison is `stamped_seq`: line one of every generated report carries the log's
11
+ `max_seq` at the moment it was rendered, so a larger stamp means "this copy saw more of the
12
+ history". It is a HEURISTIC and worth saying so — two machines number their own sqlite rows,
13
+ so the stamps are strictly comparable only once both have synced. The pathological case (two
14
+ independent narrations of the same day, neither machine having seen the other) falls to the
15
+ server's equal-seq-different-content 409, which is the honest answer: nobody can decide that
16
+ one for you.
17
+
18
+ This module never regenerates a dossier. Owning the store is what earns the right to render,
19
+ and a client that "helpfully" re-rendered what it downloaded would be uploading a report the
20
+ server has never seen the events for.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from ..engine import stamped_seq
29
+ from ..storage import REPORTS_DIR
30
+ from ._wireclient import Wire
31
+ from .dossier import report_path
32
+
33
+ __all__ = ["ReportSwap", "exchange"]
34
+
35
+
36
+ class ReportSwap:
37
+ """Which reports moved, and which ones refused to."""
38
+
39
+ def __init__(self) -> None:
40
+ self.uploaded: list[str] = []
41
+ self.downloaded: list[str] = []
42
+ self.conflicts: list[str] = []
43
+ """One sentence per stand-off, naming BOTH seqs and the two ways out. A count would
44
+ be useless: the answer is per report, and it is `pull` for one and `--force` for the
45
+ next."""
46
+
47
+
48
+ def exchange(wire: Wire, root: Path, *, upload: bool, force: bool = False) -> ReportSwap:
49
+ """Reconcile every report both sides know about. `upload` is False on a plain `pull`."""
50
+ swap = ReportSwap()
51
+ for label in _labels(wire, root):
52
+ _reconcile(wire, root, label, swap, upload=upload, force=force)
53
+ return swap
54
+
55
+
56
+ def _reconcile(wire: Wire, root: Path, label: str, swap: ReportSwap, *,
57
+ upload: bool, force: bool) -> None:
58
+ path = report_path(root, label)
59
+ ours = path.read_text(encoding="utf-8") if path.is_file() else ""
60
+ theirs = wire.get_report(label)
61
+ if not _served(theirs):
62
+ if upload and ours:
63
+ _send(wire, label, ours, swap, force=force)
64
+ return
65
+ content = str(theirs.get("content", ""))
66
+ if not ours or int(theirs.get("max_seq", 0) or 0) > stamped_seq(ours):
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ path.write_text(content, encoding="utf-8")
69
+ swap.downloaded.append(label)
70
+ elif upload and content != ours:
71
+ _send(wire, label, ours, swap, force=force)
72
+
73
+
74
+ def _send(wire: Wire, label: str, text: str, swap: ReportSwap, *, force: bool) -> None:
75
+ answer = wire.put_report(label, text, force=force)
76
+ if answer.get("status") == 409:
77
+ swap.conflicts.append(_clash(label, answer, stamped_seq(text)))
78
+ else:
79
+ swap.uploaded.append(label)
80
+
81
+
82
+ def _clash(label: str, answer: dict[str, Any], mine: int) -> str:
83
+ """The 409 in a sentence. Note the INVERSION: the body's `ours` is the SERVER's copy and
84
+ its `theirs` is what we just offered, because the server wrote it from where it stands."""
85
+ server = int(answer.get("ours", 0) or 0)
86
+ ours = int(answer.get("theirs", mine) or mine)
87
+ return (f"{label}: the server's copy is stamped at seq {server}, yours at {ours} — "
88
+ f"nothing was overwritten. Run `taskops pull` to take the server's, or "
89
+ f"`taskops push --force` to replace it (any narration there is lost).")
90
+
91
+
92
+ def _served(answer: dict[str, Any]) -> bool:
93
+ return answer.get("status") is None and "content" in answer
94
+
95
+
96
+ def _labels(wire: Wire, root: Path) -> list[str]:
97
+ """Every label either side has, sorted so the output is stable.
98
+
99
+ The server's list is best-effort (`Wire.list_reports` explains why): on a server without
100
+ that route this degrades to the labels on this disk, which still reconciles everything
101
+ this machine has ever written — only a report that exists SOLELY on the server stays out
102
+ of reach, and it arrives the moment anyone here generates that window.
103
+ """
104
+ directory = root / REPORTS_DIR
105
+ local: set[str] = set()
106
+ if directory.is_dir():
107
+ local = {path.stem for path in directory.glob("*.md")}
108
+ return sorted(local | set(wire.list_reports()))
@@ -0,0 +1,91 @@
1
+ """Where a WRITE happens: here, or in the server that other machines also write to.
2
+
3
+ Push and pull make two boards converge; they do not make a claim safe. Between two syncs,
4
+ two agents on two machines can both find the same card `ready` and both take it — each
5
+ sqlite grants its own lease because neither knows about the other. The engine already solves
6
+ this *within* one database (two INSERTs on one primary key, one winner, pinned by a
7
+ fifty-thread test); the fix is therefore not a new algorithm but a new PLACE: when a project
8
+ has a remote, `next` and `update` are executed in the server's sqlite, where the race is the
9
+ same race the engine already wins.
10
+
11
+ **The decision lives here, in the use cases, and not in a transport.** The CLI, the MCP tools
12
+ and the local HTTP board all call `next_task` and `update`; putting the routing in any one of
13
+ them would mean an agent that claims safely through MCP and unsafely through `taskops claim`.
14
+
15
+ **The server must never route to itself.** It runs these very use cases, so a `remote.json`
16
+ that happened to sit in the store it serves would make it POST to itself, forever. That is
17
+ why every server endpoint passes `local=True`: a flag the caller must opt into is checkable,
18
+ and `tests/e2e/test_agentwire.py` plants exactly that file and asserts the server answers.
19
+
20
+ **Offline never falls back to a local claim.** A remote-configured project whose server is
21
+ unreachable raises, naming the URL. Quietly claiming locally instead would be the collision
22
+ this module exists to prevent, delivered by the very code meant to prevent it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+ from typing import Any, Callable, cast
29
+
30
+ from .._errors import Unreachable
31
+ from ..contracts import NextResult, Remote, UpdateResult
32
+ from ..engine import identity
33
+ from ._mirroring import mirror_claim, mirror_update
34
+ from ._project import locate
35
+ from ._wireclient import Wire
36
+ from .remote import read_remote
37
+
38
+ __all__ = ["routed", "whoami", "claim_remotely", "update_remotely"]
39
+
40
+
41
+ def routed(start: Path | str, local: bool) -> Remote | None:
42
+ """The remote this write belongs to, or None when it belongs to this disk."""
43
+ return None if local else read_remote(start)
44
+
45
+
46
+ def whoami(start: Path | str, actor: str) -> str:
47
+ """Resolve the actor HERE, before it crosses the wire.
48
+
49
+ The server cannot do it: it has neither this machine's `$TASKOPS_ACTOR` nor its git
50
+ config, so an empty actor would arrive and resolve to the server's own identity — every
51
+ remote agent filed as one `dev:` on the box.
52
+ """
53
+ return identity.resolve(locate(start), actor)["id"]
54
+
55
+
56
+ def claim_remotely(start: Path | str, remote: Remote, body: dict[str, Any]) -> NextResult:
57
+ answer = cast("NextResult", _relay(start, remote, lambda wire: wire.claim(body)))
58
+ if answer["claim"] is not None:
59
+ mirror_claim(start, answer["claim"])
60
+ return answer
61
+
62
+
63
+ def update_remotely(start: Path | str, remote: Remote, body: dict[str, Any]) -> UpdateResult:
64
+ answer = cast("UpdateResult", _relay(start, remote, lambda wire: wire.change(body)))
65
+ mirror_update(start, answer["task"])
66
+ return answer
67
+
68
+
69
+ def _relay(start: Path | str, remote: Remote,
70
+ send: Callable[[Wire], dict[str, Any]]) -> dict[str, Any]:
71
+ """Do it there, then pull. The pull is NOT best effort — see below.
72
+
73
+ Without it the agent holds a lease the server knows about and its own board does not, and
74
+ the board is what the commit guard, `taskops brief` and every render read. So a failed
75
+ pull fails the whole call: a half-success that reports a claim the local tooling will then
76
+ deny is worse than an error naming the network.
77
+
78
+ The pull carries everybody's events; `_mirroring` then writes the one thing an event cannot
79
+ carry — the lease this caller was just granted.
80
+ """
81
+ from .pushpull import pull
82
+
83
+ try:
84
+ answer = send(Wire(remote["url"], remote["token"]))
85
+ except Unreachable as gone:
86
+ raise Unreachable(
87
+ f"this project's writes go to {remote['url']}, which did not answer "
88
+ f"({gone}) — taskops will NOT claim locally instead, because a local claim "
89
+ f"could collide with another machine's; retry, or check the network") from gone
90
+ pull(start)
91
+ return answer