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,87 @@
1
+ """`taskops_report` — the projections, and the window parsing that feeds them.
2
+
3
+ The reports are generated from the log and never written by hand, which is the whole
4
+ claim: a standup nobody typed cannot be out of date, and it cannot flatter anybody
5
+ either. What this module owns is turning a human's `24h` into a timestamp.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from .._clock import now
13
+ from .._errors import BadRequest
14
+ from ..contracts import Activity, Board, DayReport, Fleet, PeriodReport, Standup
15
+ from ..engine import activity as build_activity
16
+ from ..engine import board as build_board
17
+ from ..engine import day_report as build_day
18
+ from ..engine import fleet as build_fleet
19
+ from ..engine import period_report as build_period
20
+ from ..engine import standup as build_standup
21
+ from ._project import project
22
+ from ._range import Selector, parse_date, resolve
23
+
24
+ __all__ = ["board", "standup", "fleet", "activity", "day", "period", "parse_window",
25
+ "parse_date", "DEFAULT_WINDOW", "HISTORY_WINDOW"]
26
+
27
+ DEFAULT_WINDOW = "24h"
28
+
29
+ HISTORY_WINDOW = "30d"
30
+ """The activity view's default. Wider than a standup's on purpose: one asks what happened since
31
+ yesterday, the other is where somebody goes to find out what was ever done here."""
32
+
33
+ _UNITS = {"m": 60.0, "h": 3600.0, "d": 86400.0, "w": 604800.0}
34
+
35
+
36
+ def board(start: Path | str) -> Board:
37
+ with project(start) as store:
38
+ return build_board(store)
39
+
40
+
41
+ def standup(start: Path | str, *, since: str = DEFAULT_WINDOW, actor: str = "") -> Standup:
42
+ with project(start) as store:
43
+ return build_standup(store, since=now() - parse_window(since), actor=actor)
44
+
45
+
46
+ def activity(start: Path | str, *, since: str = HISTORY_WINDOW) -> Activity:
47
+ with project(start) as store:
48
+ return build_activity(store, since=now() - parse_window(since))
49
+
50
+
51
+ def fleet(start: Path | str) -> Fleet:
52
+ with project(start) as store:
53
+ return build_fleet(store)
54
+
55
+
56
+ def day(start: Path | str, date_text: str = "") -> DayReport:
57
+ with project(start) as store:
58
+ return build_day(store, parse_date(date_text))
59
+
60
+
61
+ def period(start: Path | str, sel: Selector | None = None) -> PeriodReport:
62
+ """The report over whatever window the selector names — a day, a week, or all of it.
63
+
64
+ `day` above is this with both ends on the same date; keeping it as its own name is for
65
+ the callers that only ever want one, not a second code path.
66
+ """
67
+ with project(start) as store:
68
+ return build_period(store, *resolve(store, sel or Selector()))
69
+
70
+
71
+
72
+
73
+ def parse_window(text: str) -> float:
74
+ """`24h` -> 86400.0. Raises on anything it cannot read.
75
+
76
+ Strict rather than defaulting, unlike most readers here: a window silently read as
77
+ 24 hours when the caller wrote `7days` produces a report that is WRONG and looks
78
+ right, and a standup that quietly covers the wrong period is worse than an error.
79
+ """
80
+ raw = text.strip().lower()
81
+ if not raw:
82
+ return parse_window(DEFAULT_WINDOW)
83
+ number, unit = raw[:-1], raw[-1:]
84
+ if unit not in _UNITS or not number.isdigit() or int(number) <= 0:
85
+ raise BadRequest(f"`{text}` is not a window — use a positive number then "
86
+ f"one of {', '.join(sorted(_UNITS))}, e.g. 24h or 7d")
87
+ return int(number) * _UNITS[unit]
@@ -0,0 +1,106 @@
1
+ """Moving a written report between machines WITHOUT losing a narration.
2
+
3
+ The dossier in a report is regenerable — it is a rendering of the log, and the log replicates.
4
+ The NARRATION is not. It is prose a model wrote once, or a person edited by hand, and there is
5
+ no second copy anywhere. So this is the one thing in taskops that syncs with a rule instead of
6
+ a union, and the rule is built to fail LOUD rather than to be clever.
7
+
8
+ The rule, in the order it is applied:
9
+
10
+ 1. The server does not have the file → store it.
11
+ 2. Byte-identical to what we have → store it (a no-op, so a re-push is quiet).
12
+ 3. Both sides carry a stamp and theirs is HIGHER → store it. A higher `max_seq` means that
13
+ copy was generated over more of the log, so it is the later account of the same day.
14
+ 4. Anything else → `ReportConflict`, carrying both stamps. That covers theirs being older,
15
+ the two being equal but different, and either side being UNSTAMPED — an unstamped file was
16
+ written or edited outside taskops, which is precisely the copy nobody may clobber.
17
+
18
+ `force` skips straight to storing, and the refusal says what would be lost so the person
19
+ choosing has the sentence in front of them.
20
+
21
+ **The server NEVER regenerates.** It serves the bytes it has. Regenerating belongs to the
22
+ machine that owns the store, because a regeneration here would replace somebody's prose with a
23
+ fresh dossier and report success.
24
+
25
+ **The honest limit of comparing stamps.** `max_seq` is each sqlite's own numbering, so two
26
+ machines' stamps are not rigorously comparable. In the real flow they are close enough to be
27
+ useful — a report is narrated against ONE store, and after a sync the server's seqs dominate —
28
+ which makes rule 3 a heuristic for "who saw more". The pathological case is two independent
29
+ narrations of the same day written on two machines that never synced; those land in rule 4,
30
+ which is the honest answer, and `force` is the valve.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from .._errors import BadRequest, ReportConflict
39
+ from ..engine import NO_STAMP, stamped_seq
40
+ from ._project import project
41
+ from .dossier import report_path
42
+
43
+ __all__ = ["read_report_file", "write_report_file"]
44
+
45
+ _ALLOWED = set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.")
46
+ """Labels are `2026-07-28`, `2026-07-22..2026-07-28`, `all`. A separator can never appear, so
47
+ a label from the network cannot address a path outside `.taskops/reports/`."""
48
+
49
+
50
+ def read_report_file(start: Path | str, label: str) -> dict[str, Any] | None:
51
+ """The bytes on disk for one label, with the stamp they carry. None if there is no file.
52
+
53
+ None and not a generated dossier: the caller is a replication client deciding whether to
54
+ push, and answering with a report this server never wrote would make it believe there is
55
+ something here to lose.
56
+ """
57
+ with project(start) as store:
58
+ path = report_path(store.root, _checked(label))
59
+ if not path.is_file():
60
+ return None
61
+ content = path.read_text(encoding="utf-8")
62
+ return {"label": label, "content": content, "max_seq": stamped_seq(content)}
63
+
64
+
65
+ def write_report_file(start: Path | str, label: str, content: str, *,
66
+ force: bool = False) -> dict[str, bool]:
67
+ """Apply the rule above. Returns `{"stored": True}` or raises `ReportConflict`."""
68
+ with project(start) as store:
69
+ path = report_path(store.root, _checked(label))
70
+ current = path.read_text(encoding="utf-8") if path.is_file() else None
71
+ if force or current is None or current == content:
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text(content, encoding="utf-8")
74
+ return {"stored": True}
75
+ ours, theirs = stamped_seq(current), stamped_seq(content)
76
+ if ours != NO_STAMP and theirs > ours:
77
+ path.write_text(content, encoding="utf-8")
78
+ return {"stored": True}
79
+ raise _conflict(label, ours=ours, theirs=theirs)
80
+
81
+
82
+ def _conflict(label: str, *, ours: int, theirs: int) -> ReportConflict:
83
+ """The refusal, with the sentence that names what the caller is actually looking at."""
84
+ clash = ReportConflict(f"{label}: {_why(ours, theirs)} — read this server's copy first, "
85
+ f"or push again with force (its narration is then lost)")
86
+ clash.ours, clash.theirs = ours, theirs
87
+ return clash
88
+
89
+
90
+ def _why(ours: int, theirs: int) -> str:
91
+ if NO_STAMP in (ours, theirs):
92
+ return ("one of the two copies carries no taskops stamp, so it was written or edited "
93
+ "by hand and nothing can tell which is newer")
94
+ if ours == theirs:
95
+ return (f"two different narrations of the same report, both generated at seq {ours} — "
96
+ f"prose cannot be merged and nobody here can pick")
97
+ return (f"this server's copy was generated at seq {ours} and yours at {theirs}, so yours "
98
+ f"saw LESS of the log")
99
+
100
+
101
+ def _checked(label: str) -> str:
102
+ text = label.strip()
103
+ if not text or text.startswith(".") or not set(text) <= _ALLOWED:
104
+ raise BadRequest(f"{label!r} is not a report label — it names a file in "
105
+ f".taskops/reports/, like `2026-07-28` or `all`")
106
+ return text
@@ -0,0 +1,134 @@
1
+ """A session opening and closing — and how a message reaches another agent.
2
+
3
+ Claude Code cannot be pushed to mid-turn: a session only ever "listens" when a hook
4
+ fires. That is the constraint the whole delivery design follows from, and it is worth
5
+ stating plainly rather than pretending otherwise.
6
+
7
+ ```
8
+ SessionStart -> brief() the agent starts knowing its tasks and its messages
9
+ PostToolUse -> inbox() anything that arrived since lands in its next tool call
10
+ Stop -> checkout() its work becomes a comment on the task, unprompted
11
+ ```
12
+
13
+ So "real time" here means: within one tool call of the sender writing it, which for a
14
+ working agent is seconds. The human-facing real time is the studio, which sees the
15
+ event the moment it is committed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from .._clock import now
23
+ from ..contracts import Event, Inbox, Lease
24
+ from ..engine import record
25
+ from ..storage import Store
26
+ from ._project import caller, heartbeat, project
27
+ from .view import inbox_for
28
+
29
+ __all__ = ["brief", "inbox", "checkout", "track", "Brief"]
30
+
31
+
32
+ class Brief:
33
+ """What a session needs to know before its first prompt."""
34
+
35
+ def __init__(self, *, actor: str, session: str, held: list[Lease],
36
+ messages: list[Event], ready: int) -> None:
37
+ self.actor = actor
38
+ self.session = session
39
+ self.held = held
40
+ self.messages = messages
41
+ self.ready = ready
42
+
43
+
44
+ def brief(start: Path | str, *, session: str = "", actor: str = "") -> Brief:
45
+ """The SessionStart read: who am I, what do I hold, who spoke to me.
46
+
47
+ Leases held by this SESSION are re-associated on resume: Claude Code re-runs
48
+ SessionStart with `source=resume`, and without this an agent that resumed would
49
+ look like a new actor holding nothing while its claims sat there.
50
+ """
51
+ with project(start) as store:
52
+ who = caller(store, actor)["id"]
53
+ heartbeat(store, who)
54
+ held = store.leases.of_actor(who, now())
55
+ if session:
56
+ _adopt(store, held, session)
57
+ return Brief(actor=who, session=session, held=held,
58
+ messages=inbox_for(store, who)["messages"],
59
+ ready=len(store.tasks.with_status(("ready",))))
60
+
61
+
62
+ def _adopt(store: Store, held: list[Lease], session: str) -> None:
63
+ """Point this actor's leases at the session that is now running them.
64
+
65
+ A resumed session gets a NEW id, so the lease's old one names a process that is gone
66
+ — and the live board would show a claim whose transcript nobody can open.
67
+ """
68
+ for lease in held:
69
+ if lease["session"] != session:
70
+ store.leases.set_session(task_id=lease["task"], session=session)
71
+
72
+
73
+ def inbox(start: Path | str, *, actor: str = "") -> Inbox:
74
+ """The PostToolUse read: messages this actor has not seen, marked delivered.
75
+
76
+ Cheap on purpose — one indexed query and usually zero rows. It runs after every
77
+ tool call an agent makes, so anything expensive here is a tax on all of its work.
78
+ """
79
+ with project(start) as store:
80
+ who = caller(store, actor)["id"]
81
+ heartbeat(store, who)
82
+ return inbox_for(store, who)
83
+
84
+
85
+ def track(start: Path | str, *, summary: str, task: str = "", actor: str = "",
86
+ session: str = "") -> Event | None:
87
+ """The heartbeat with content: what tool touched what file, for the live board.
88
+
89
+ It also STAMPS the session id onto the actor's leases, and that half is what makes a conversation
90
+ findable. A dispatched worker is easy — it runs in a per-card worktree, so its transcript has a
91
+ directory of its own. A card worked on interactively has neither, and every `claimed` event in a
92
+ real project turned out to carry an empty `session`, because nothing was passing one: the tool
93
+ accepts it and no caller supplied it. This hook receives it on every tool call, so stamping it
94
+ here costs nothing and closes the gap.
95
+
96
+ Local-only (`LOCAL_ONLY_KINDS`), so it never reaches the committed log. Without a task it is
97
+ dropped rather than filed under an empty id: an activity event nobody can attribute is noise that
98
+ would still cost a row per tool call.
99
+ """
100
+ with project(start) as store:
101
+ who = caller(store, actor)["id"]
102
+ heartbeat(store, who)
103
+ held = store.leases.of_actor(who, now())
104
+ if session:
105
+ _stamp(store, held, session)
106
+ target = task or (held[0]["task"] if len(held) == 1 else "")
107
+ if not target:
108
+ return None
109
+ return record(store, task=target, actor=who, kind="activity",
110
+ body={"summary": summary, "session": session})
111
+
112
+
113
+ def _stamp(store: Store, held: list[Lease], session: str) -> None:
114
+ """Record which session is working each held card. Cheap, idempotent, and only writes on change."""
115
+ for lease in held:
116
+ if lease["session"] != session:
117
+ store.leases.set_session(task_id=lease["task"], session=session)
118
+
119
+
120
+ def checkout(start: Path | str, *, summary: str, session: str = "",
121
+ actor: str = "") -> list[Event]:
122
+ """The Stop read: the session's own account of itself, on every task it holds.
123
+
124
+ An auto-standup, and the reason a task's thread is worth reading at 9am: an agent
125
+ that was killed mid-thought still leaves what it had. Leases are deliberately NOT
126
+ released — a session ending is not the same as work being handed back, and Claude
127
+ Code ends a session every time a developer closes a terminal.
128
+ """
129
+ with project(start) as store:
130
+ who = caller(store, actor)["id"]
131
+ heartbeat(store, who)
132
+ return [record(store, task=lease["task"], actor=who, kind="comment",
133
+ body={"text": summary, "session": session, "auto": True})
134
+ for lease in store.leases.of_actor(who, now())]
@@ -0,0 +1,92 @@
1
+ """`taskops init` — making a repository coordinated.
2
+
3
+ Three things, and the order is deliberate: the project directory, the gitignore entry,
4
+ then the git hooks. If hook installation fails the project still works — every hook here
5
+ is an optimisation of something the MCP tools already do — so a partial init leaves a
6
+ usable system rather than a broken one.
7
+
8
+ Existing hooks are CHAINED, never overwritten. A repository's `post-commit` may already
9
+ run a linter somebody depends on, and a coordination tool that silently deleted it would
10
+ deserve everything that followed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from ..storage import GUIDE_FILE, LOG_FILE, PROJECT_DIR, Store, find_root
18
+ from ._gitignore import ignore
19
+ from .hooks import install_hooks
20
+
21
+ __all__ = ["init", "InitReport"]
22
+
23
+ _GUIDE_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "GUIDE.md"
24
+
25
+ class InitReport:
26
+ """What init actually did, so the CLI can report it instead of claiming success."""
27
+
28
+ def __init__(self, *, root: Path, created: bool, hooks: list[str],
29
+ skipped: list[str], adopted: int = 0) -> None:
30
+ self.root = root
31
+ self.created = created
32
+ self.hooks = hooks
33
+ self.skipped = skipped
34
+ self.adopted = adopted
35
+ """Tasks materialised from a log that was already in the working tree.
36
+
37
+ Non-zero on a FRESH CLONE, which is the case this exists for. Following the usage guide
38
+ end to end, a teammate cloned, ran `taskops init`, and saw an empty board: the log was
39
+ sitting right there in the checkout and nothing had read it. Now init reads it.
40
+ """
41
+
42
+
43
+ def init(start: Path | str, *, install_git_hooks: bool = True) -> InitReport:
44
+ """Create `.taskops/`, ignore the cache, install the hooks. Idempotent.
45
+
46
+ Re-running is safe and is the supported way to repair a project whose hooks were
47
+ lost — which happens on every fresh clone, because `.git/hooks` is not tracked.
48
+ """
49
+ root = Path(start).expanduser().resolve()
50
+ existing = find_root(root)
51
+ created = existing is None
52
+ root = existing or root
53
+ (root / PROJECT_DIR).mkdir(parents=True, exist_ok=True)
54
+ ignore(root)
55
+ _guide(root)
56
+ (root / LOG_FILE).touch(exist_ok=True)
57
+ adopted = _adopt(root)
58
+ hooks, skipped = install_hooks(root) if install_git_hooks else ([], [])
59
+ return InitReport(root=root, created=created, hooks=hooks, skipped=skipped,
60
+ adopted=adopted)
61
+
62
+
63
+ def _adopt(root: Path) -> int:
64
+ """Open the database (which applies the schema) and read whatever log is already here.
65
+
66
+ THE fresh-clone step, and it was missing: a teammate cloned a repository whose checkout
67
+ carried the whole event log, ran `taskops init`, and saw an empty board — the log was sitting
68
+ right there and nothing had read it. Adoption is just an import+replay, both idempotent, so on
69
+ a brand-new project this is a no-op and on a re-run it changes nothing.
70
+ """
71
+ from ..engine import replay, unblock
72
+ from ..storage import import_events
73
+
74
+ with Store(root) as store:
75
+ applied = replay.apply(store, import_events(store))
76
+ unblock(store)
77
+ return applied
78
+
79
+
80
+ def _guide(root: Path) -> None:
81
+ """Copy the agent-facing manual into the project, OVERWRITING it every init.
82
+
83
+ Overwriting on purpose, unlike `.gitignore`: this file ships with the package and
84
+ describes what this version of the tools actually does. A stale copy that survived an
85
+ upgrade would be a document telling agents to use a rule that no longer exists — and
86
+ since it is committed, everyone on the team would read the wrong one. Anything a project
87
+ wants to add about its own conventions belongs in CLAUDE.md, which taskops never touches.
88
+ """
89
+ destination = root / GUIDE_FILE
90
+ if _GUIDE_SOURCE.is_file():
91
+ destination.write_text(_GUIDE_SOURCE.read_text(encoding="utf-8"),
92
+ encoding="utf-8")
@@ -0,0 +1,78 @@
1
+ """`taskops sync` — reconciling with what git brought in.
2
+
3
+ Four steps, and the order is the design:
4
+
5
+ ```
6
+ import the log's new events into the local cache (storage)
7
+ replay those events into tasks and dependencies (engine)
8
+ unblock re-derive what is now pickable (engine)
9
+ export this machine's new events into the log (storage)
10
+ ```
11
+
12
+ **Replay is the step that was missing**, and its absence was a real bug: following the usage guide,
13
+ a teammate's `git pull` imported every event and left them looking at an empty board. Events are the
14
+ source of truth, so something has to turn them into rows — see `engine.replay`.
15
+
16
+ Import before export, deliberately: importing can close a task whose dependents this machine is
17
+ about to be asked about, and exporting first would publish a view of the graph that was already
18
+ stale when it was written.
19
+
20
+ Every step is idempotent, so a developer who runs this in a loop out of nervousness costs themselves
21
+ nothing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ from ..engine import replay, unblock
29
+ from ..storage import all_events, export_events, import_events
30
+ from ._project import project
31
+
32
+ __all__ = ["sync", "rebuild", "SyncReport"]
33
+
34
+
35
+ class SyncReport:
36
+ """What moved, in both directions, plus what the import actually changed."""
37
+
38
+ def __init__(self, *, imported: int, applied: int, exported: int,
39
+ unblocked: list[str]) -> None:
40
+ self.imported = imported
41
+ self.applied = applied
42
+ """Events that changed local state. Lower than `imported` and that is normal — a comment is
43
+ worth keeping and says nothing about what a task IS."""
44
+
45
+ self.exported = exported
46
+ self.unblocked = unblocked
47
+
48
+
49
+ def sync(start: Path | str) -> SyncReport:
50
+ """Pull the log into the cache, apply it, re-derive readiness, push local events out.
51
+
52
+ `unblock` at the end is the point of the whole operation: a teammate finishing a task is only
53
+ useful here once the tasks waiting on it become pickable, and that promotion happens now rather
54
+ than at the next `next` call — so somebody who just ran `git pull` sees the real queue.
55
+ """
56
+ with project(start) as store:
57
+ fresh = import_events(store)
58
+ applied = replay.apply(store, fresh)
59
+ freed = unblock(store)
60
+ return SyncReport(imported=len(fresh), applied=applied,
61
+ exported=export_events(store), unblocked=freed)
62
+
63
+
64
+ def rebuild(start: Path | str) -> SyncReport:
65
+ """Re-apply the ENTIRE log. The disaster-recovery path, for a deleted cache.
66
+
67
+ Different from `sync` in one way that matters: it replays every event rather than only the new
68
+ ones, because after `rm .taskops/db.sqlite` nothing is new — the log is all there is.
69
+
70
+ Additive, never a truncate-and-replay: leases are live state the log does not describe, so
71
+ wiping rows to rebuild them would drop every agent's claim to recreate what the log can
72
+ reconcile anyway.
73
+ """
74
+ with project(start) as store:
75
+ imported = len(import_events(store))
76
+ applied = replay.apply(store, all_events(store.root))
77
+ return SyncReport(imported=imported, applied=applied,
78
+ exported=export_events(store), unblocked=unblock(store))
@@ -0,0 +1,123 @@
1
+ """`taskops_update` — a transition, a comment and a notification, in one call.
2
+
3
+ One call because they are one event in the agent's head: "I finished this, here is what
4
+ happened, tell Ana's agent". Three calls would mean three chances to do two of the
5
+ three, and the missing one is always the comment.
6
+
7
+ This is the only place that assembles `Facts` for the state machine. Everything the
8
+ guards ask about — a live lease, the commits bound to the task, its open children — is
9
+ read here and handed over as data, which is what lets those rules be tested from
10
+ literals.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from .._clock import now
18
+ from .._errors import BadRequest
19
+ from .._types import Status
20
+ from ..contracts import Task, UpdateResult
21
+ from ..engine import check_move, record, unblock
22
+ from ..storage import Store
23
+ from ._facts import facts_for
24
+ from ._project import caller, heartbeat, project
25
+ from ._routing import routed, update_remotely, whoami
26
+
27
+ __all__ = ["update", "RELEASE"]
28
+
29
+ RELEASE = "released"
30
+ """Not a status — the WORD an agent uses to hand work back. It maps to `ready` plus
31
+ dropping the lease, and it reads as an action because that is what the agent is doing.
32
+ Spelling it `ready` in the tool would invite an agent to "set ready" as a way of
33
+ skipping the queue."""
34
+
35
+
36
+ def update(start: Path | str, task_id: str, *, actor: str = "", status: str = "",
37
+ comment: str = "", mentions: tuple[str, ...] = (), blocked_on: str = "",
38
+ no_code: bool = False, local: bool = False) -> UpdateResult:
39
+ """Apply whatever was asked, in the order that keeps the graph honest.
40
+
41
+ `local` is the server's flag, exactly as in `next_task`: a project with a remote runs
42
+ its transitions THERE, so the lease guard that refuses a `done` from a machine that
43
+ never held the lease is checked against the one database everybody writes to.
44
+ """
45
+ if not (status or comment or blocked_on):
46
+ raise BadRequest("nothing to do — pass a `status`, a `comment`, or `blocked_on`")
47
+ if (remote := routed(start, local)) is not None:
48
+ return update_remotely(start, remote, {
49
+ "task": task_id, "actor": whoami(start, actor), "status": status,
50
+ "comment": comment, "mentions": list(mentions),
51
+ "blocked_on": blocked_on, "no_code": no_code})
52
+ with project(start) as store:
53
+ who = caller(store, actor)["id"]
54
+ heartbeat(store, who)
55
+ task = store.tasks.need(task_id)
56
+ if comment or mentions:
57
+ _say(store, task_id, who, comment, mentions)
58
+ if blocked_on:
59
+ _block_on(store, task, who, blocked_on)
60
+ if status:
61
+ task = _move(store, task, who, status, comment, no_code)
62
+ freed = unblock(store)
63
+ return UpdateResult(task=store.tasks.need(task_id),
64
+ unblocked=[store.tasks.need(i) for i in freed],
65
+ notified=list(mentions))
66
+
67
+
68
+ def _say(store: Store, task_id: str, who: str, text: str, mentions: tuple[str, ...]) -> None:
69
+ """Record the comment. Mentions make it a `message`, which is what an inbox reads.
70
+
71
+ The kind carries the routing rather than a separate flag: `delivered.pending`
72
+ selects on kind and body, so a comment nobody was addressed in cannot end up in
73
+ anybody's inbox by accident.
74
+ """
75
+ record(store, task=task_id, actor=who,
76
+ kind="message" if mentions else "comment",
77
+ body={"text": text, "mentions": list(mentions)})
78
+
79
+
80
+ def _block_on(store: Store, task: Task, who: str, blocker: str) -> None:
81
+ """Add the edge a discovery revealed, and say so in the log.
82
+
83
+ The edge is added even when the task is not moved to `blocked`: the graph is the
84
+ thing other agents schedule against, and a dependency that lived only in a comment
85
+ is a dependency the scheduler will hand somebody straight into.
86
+ """
87
+ store.tasks.need(blocker)
88
+ if blocker == task["id"]:
89
+ raise BadRequest(f"{blocker} cannot block itself")
90
+ store.deps.add(blocker, task["id"])
91
+ record(store, task=task["id"], actor=who, kind="blocked", body={"on": blocker})
92
+
93
+
94
+ def _move(store: Store, task: Task, who: str, asked: str, comment: str,
95
+ no_code: bool) -> Task:
96
+ """Check the move, then apply it. `released` also drops the lease."""
97
+ target: Status = "ready" if asked == RELEASE else _status(asked)
98
+ facts = facts_for(store, task, who, no_code=no_code, justification=comment)
99
+ check_move(facts, target)
100
+ if target in ("ready", "done", "cancelled"):
101
+ store.leases.release(task["id"])
102
+ store.tasks.set_status(task["id"], target, when=now())
103
+ record(store, task=task["id"], actor=who,
104
+ kind="done" if target == "done" else "status",
105
+ body={"from": task["status"], "to": target, "released": asked == RELEASE,
106
+ "unpushed": facts.unpushed})
107
+ return store.tasks.need(task["id"])
108
+
109
+
110
+ def _status(asked: str) -> Status:
111
+ """A caller's word -> a status the machine knows.
112
+
113
+ Validated here rather than trusted from the schema: the CLI and the HTTP surface
114
+ reach this too, and only MCP hosts enforce an enum.
115
+ """
116
+ known = ("backlog", "ready", "claimed", "in_progress", "blocked", "review",
117
+ "done", "cancelled")
118
+ if asked not in known:
119
+ raise BadRequest(f"`{asked}` is not a status — use one of "
120
+ f"{', '.join(known)}, or `{RELEASE}` to hand the task back")
121
+ return asked # type: ignore[return-value]
122
+
123
+