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,71 @@
1
+ """`taskops tasks edit` — rewriting the card itself: its title, its spec, its priority.
2
+
3
+ Until this existed, a spec was whatever the planner typed at 9am and nothing could correct
4
+ it: an agent that read a brief which had since turned out to be wrong had no door back, and
5
+ the only workaround was cancelling the card and planning a new one, which threw away its
6
+ thread and its commits.
7
+
8
+ Separate from `update` on purpose. `update` moves a card through the state machine and talks
9
+ about the WORK; this rewrites what the work IS. They record different events, they answer to
10
+ different guards, and folding them together would put a spec rewrite behind the same lease
11
+ and transition checks that exist to protect a status.
12
+
13
+ **One event per field**, never one `edited` carrying three. Replay applies each event on its
14
+ own merits, so a body holding three fields would make the newer-wins rule all-or-nothing —
15
+ two people editing different fields of the same card would clobber each other instead of
16
+ converging on both edits.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from .._clock import now
24
+ from .._errors import BadRequest
25
+ from .._types import CLOSED_STATUSES
26
+ from ..contracts import EditResult, Task
27
+ from ..engine import record
28
+ from ..storage import Store
29
+ from ._project import caller, heartbeat, project
30
+
31
+ __all__ = ["edit"]
32
+
33
+ CLOSED_REFUSAL = "closed cards are history — open a new card referencing it"
34
+ """Why a done or cancelled card is frozen. The log is what a standup, a report and a
35
+ teammate's clone read back; letting somebody rewrite the spec of work already delivered
36
+ would rewrite the record of what was delivered, which is the one thing this system exists
37
+ to keep honest."""
38
+
39
+
40
+ def edit(start: Path | str, task_id: str, *, title: str | None = None,
41
+ spec: str | None = None, priority: int | None = None,
42
+ actor: str = "") -> EditResult:
43
+ """Apply whatever fields were passed, recording one `edited` event for each."""
44
+ asked = {"title": title, "spec": spec, "priority": priority}
45
+ wanted = {field: value for field, value in asked.items() if value is not None}
46
+ if not wanted:
47
+ raise BadRequest("nothing to edit — pass a `title`, a `spec` or a `priority`")
48
+ with project(start) as store:
49
+ who = caller(store, actor)["id"]
50
+ heartbeat(store, who)
51
+ task = store.tasks.need(task_id)
52
+ if task["status"] in CLOSED_STATUSES:
53
+ raise BadRequest(CLOSED_REFUSAL)
54
+ changed = [f for f, value in wanted.items() if _apply(store, task, who, f, value)]
55
+ return EditResult(task=store.tasks.need(task_id), changed=changed)
56
+
57
+
58
+ def _apply(store: Store, task: Task, who: str, field: str, value: object) -> bool:
59
+ """Write one field, and say so in the log. A no-op edit records NOTHING.
60
+
61
+ An event whose `from` equals its `to` is a lie about the card's history and, worse,
62
+ bumps `updated` — which is the arbitrator replay uses, so a redundant edit on one
63
+ machine would win an argument against a real edit on another.
64
+ """
65
+ before = task[field] # type: ignore[literal-required]
66
+ if before == value:
67
+ return False
68
+ store.tasks.set_field(task["id"], field, value, when=now())
69
+ record(store, task=task["id"], actor=who, kind="edited",
70
+ body={"field": field, "from": before, "to": value})
71
+ return True
@@ -0,0 +1,86 @@
1
+ """Events over HTTP: the half of remote sync that a server performs.
2
+
3
+ The git path (`storage.sync`) and this one move the SAME facts and rely on the same property —
4
+ ids are content hashes, so importing an event twice is a primary-key no-op. Nothing here
5
+ resolves a conflict because there is none to resolve: events are facts about the past, and the
6
+ union of two logs is the correct log.
7
+
8
+ **Seq is local, and that is the whole cursor design.** A puller's `after` is a number in the
9
+ SERVER's sequence, so a client keeps one cursor PER remote and may never mix two. Nothing on
10
+ the wire carries a seq inside an event for exactly that reason (`EventTable.page_after`).
11
+
12
+ **Local-only kinds are filtered in BOTH directions.** Outbound because `activity` is a
13
+ per-tool-call heartbeat and replicating it would add thousands of rows a day for nothing;
14
+ inbound because a server does not trust a client to have remembered the rule.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .._errors import BadRequest
23
+ from .._types import LOCAL_ONLY_KINDS
24
+ from ..contracts import Event
25
+ from ..engine import relay, replay, unblock
26
+ from ..storage import event_from
27
+ from ._project import project
28
+
29
+ __all__ = ["accept_events", "pull_events", "MAX_BATCH", "MAX_PAGE"]
30
+
31
+ MAX_BATCH = 500
32
+ """Events per POST. A cap and not a stream: the body is read into memory before anything looks
33
+ at it, so the limit has to bind before the allocation does. A client with more sends again."""
34
+
35
+ MAX_PAGE = 500
36
+
37
+
38
+ def accept_events(start: Path | str, raw: list[Any]) -> dict[str, int]:
39
+ """Relay a foreign batch. Returns how many were NEW here, and this server's cursor.
40
+
41
+ `accepted` is the idempotency signal the client logs: pushing the same batch twice answers
42
+ 0 the second time, which is how a person reading the output can tell a retry from a
43
+ duplicate import.
44
+
45
+ The whole batch is coerced BEFORE anything is written, so a malformed event at index 40
46
+ cannot leave 39 relayed and the caller unsure how far it got.
47
+
48
+ Accepted events are MATERIALISED — `replay` then `unblock` — before this returns. Without
49
+ that, a push landed its events and the server's board stayed empty: the exact bug the git
50
+ path hit once and `replay.py` tells the story of. The smoke that caught it here asked the
51
+ server's board for the pushed card and got eight empty columns.
52
+ """
53
+ if len(raw) > MAX_BATCH:
54
+ raise BadRequest(f"{len(raw)} events in one push — send at most {MAX_BATCH} per "
55
+ f"request and repeat until the cursor stops moving")
56
+ events = [_coerce(item, index) for index, item in enumerate(raw)]
57
+ with project(start) as store:
58
+ fresh = [e for e in events if e is not None and relay(store, e)]
59
+ if fresh:
60
+ replay.apply(store, fresh)
61
+ unblock(store)
62
+ return {"accepted": len(fresh), "max_seq": store.events.max_seq()}
63
+
64
+
65
+ def pull_events(start: Path | str, *, after: int = 0, limit: int = MAX_PAGE) -> dict[str, Any]:
66
+ """One page of this server's log after a cursor, plus where the cursor now stands.
67
+
68
+ `more` reports that the PAGE was full, not that events are certainly waiting — the cheap
69
+ answer, and the client's loop stops one request later either way.
70
+ """
71
+ size = max(1, min(limit, MAX_PAGE))
72
+ with project(start) as store:
73
+ page, cursor = store.events.page_after(after, limit=size)
74
+ shared = [e for e in page if e["kind"] not in LOCAL_ONLY_KINDS]
75
+ return {"events": shared, "max_seq": cursor, "more": len(page) == size}
76
+
77
+
78
+ def _coerce(item: Any, index: int) -> Event | None:
79
+ """A wire object -> an event to relay, or None for one to drop. Raises on nonsense.
80
+
81
+ The id is NOT recomputed — see `engine.log.relay` for why rebuilding it forks history.
82
+ """
83
+ event = event_from(item)
84
+ if event is None:
85
+ raise BadRequest(f"events[{index}] is not an event — it needs at least `id` and `kind`")
86
+ return None if event["kind"] in LOCAL_ONLY_KINDS else event
@@ -0,0 +1,138 @@
1
+ """Following the event log as it grows — what a live board (or a terminal) tails.
2
+
3
+ This is a use case rather than something the HTTP layer does itself, and the architecture test
4
+ is what said so: the feed needs a bus subscription and a cursor read, both of which are engine
5
+ and storage. Putting it here means the transport frames SSE and nothing else, and it means the
6
+ same feed can be tailed from the CLI without a second implementation.
7
+
8
+ **Why it polls a cursor rather than only listening to the bus.** Every writer is a different
9
+ process — MCP is stdio, so each editor session launches its own server, and each git hook is a
10
+ fresh interpreter. An in-process bus cannot see any of them. So the source of truth is
11
+ `events.after_seq(cursor)`, an indexed integer scan, and the bus subscription is layered on top
12
+ purely so the studio's OWN writes (a human's comment) appear instantly rather than on the next
13
+ tick. Both paths end at the same cursor read, so an event can never be delivered twice.
14
+
15
+ **And why it also carries things that are NOT events.** A narration delta is not a fact worth
16
+ storing — see `contracts.wire` — so it never reaches the database and there is no cursor that
17
+ could produce it. It rides the same generator because a browser has ONE socket: the alternative
18
+ is a second stream, a second subscription and a second lifetime to leak. A wire message is
19
+ yielded straight through, and the consequence is documented rather than hidden: it CANNOT be
20
+ recovered. A browser that reconnects missed whatever passed while it was away, and that is fine,
21
+ because the file on disk is the durable copy and the socket is only the window onto it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import queue
27
+ from pathlib import Path
28
+ from typing import Iterator
29
+
30
+ from ..contracts import Event, WireMessage
31
+ from ..engine import BUS, WIRE, is_wire
32
+ from ..storage import Store, resolve_root
33
+
34
+ __all__ = ["follow", "TICK", "is_wire"]
35
+
36
+ TICK = 0.5
37
+ """Seconds between cursor polls. Under the threshold where a board feels polled rather than
38
+ live, and two queries a second against an indexed integer column is nothing."""
39
+
40
+ _SIGNAL_DEPTH = 256
41
+
42
+
43
+ def follow(start: Path | str, after: int = -1, *,
44
+ tick: float = TICK) -> Iterator[Event | WireMessage | None]:
45
+ """Yield every event as it lands, every wire message as it is published, and `None` on
46
+ each quiet tick.
47
+
48
+ The `None` is not a placeholder — it is how a consumer gets control back on a schedule
49
+ without this module knowing what it wants to do with it. The studio emits an SSE keepalive;
50
+ a terminal tailer redraws a clock; a test stops. Without it, a silent hour would be an hour
51
+ with no way to notice the connection died.
52
+
53
+ `after < 0` means "from now": a board that opened is asking what happens NEXT, and replaying
54
+ the entire history into it would be a spike of thousands of frames it has no use for.
55
+
56
+ **Wire messages are filtered by ROOT, and that filter lives HERE.** `WIRE` is process-global,
57
+ so a server holding many projects open (`transports.http.projects`) broadcasts every narration
58
+ delta to every board — which is prose from one project on another project's screen, and that
59
+ prose names its strategies and its data. Events cannot leak: they come from each project's own
60
+ sqlite, through the cursor read below. A wire message has no such gate, so this generator
61
+ supplies one: it already resolved its own root, and it compares against the root the publisher
62
+ stamped with the same `resolve_root`.
63
+
64
+ It belongs to this function and not to a transport for one reason each: `live.py` sees frames
65
+ but would have to be told which project it is framing for (and every future consumer would
66
+ have to be told again), while `projects.py` mounts whole routers and never sees an individual
67
+ frame at all. One filter, on the only object that knows both the root and the message.
68
+
69
+ A message with NO root is DROPPED, not broadcast. It is the only safe default: an unlabelled
70
+ frame is exactly the pre-fix message that leaked, and delivering it everywhere to be
71
+ compatible with an older publisher would preserve the bug this closes. The cost of dropping
72
+ is a few seconds of missing animation while a mixed-version server is restarted; the cost of
73
+ delivering is one project's prose on another's screen.
74
+ """
75
+ root = resolve_root(start)
76
+ origin = str(root)
77
+ signal: "queue.Queue[WireMessage | None]" = queue.Queue(maxsize=_SIGNAL_DEPTH)
78
+ # An event enqueues `None` — the SIGNAL to read the cursor, because the row is the payload.
79
+ # A wire message enqueues ITSELF, because there is no row and never will be.
80
+ cancels = (BUS.subscribe(lambda _event: _offer(signal, None)),
81
+ WIRE.subscribe(lambda message: _offer(signal, message)))
82
+ store = Store(root, check_same_thread=False)
83
+ try:
84
+ cursor = store.events.max_seq() if after < 0 else after
85
+ while True:
86
+ messages = [m for m in _drain(signal, tick) if m.get("root") == origin]
87
+ yield from messages
88
+ fresh = store.events.after_seq(cursor)
89
+ yield from fresh
90
+ if fresh:
91
+ cursor = store.events.max_seq()
92
+ elif not messages:
93
+ # Only when NOTHING was delivered. A narration streaming at fifty deltas a second
94
+ # would otherwise emit fifty keepalive ticks a second and burn the stream's
95
+ # lifetime (`live.MAX_TICKS`) in the middle of the thing somebody is watching.
96
+ yield None
97
+ finally:
98
+ # All of them, always. A browser tab that closes leaves this generator un-advanced, and
99
+ # without the release every closed tab would strand a subscriber holding a sqlite
100
+ # connection — which on a board somebody leaves open all day IS the failure mode.
101
+ for cancel in cancels:
102
+ cancel()
103
+ store.close()
104
+
105
+
106
+ def _drain(signal: "queue.Queue[WireMessage | None]", tick: float) -> list[WireMessage]:
107
+ """Block up to one tick, then take everything that piled up. Returns the wire messages.
108
+
109
+ The `None`s are DISCARDED on purpose: they are a wake-up signal, and the cursor read that
110
+ follows is what decides which events to send. Treating them as the payload would give one
111
+ event two delivery paths and a duplicate whenever both fired. Wire messages are the opposite
112
+ — they ARE the payload, since nothing else in this process knows they happened.
113
+ """
114
+ out: list[WireMessage] = []
115
+ try:
116
+ first = signal.get(timeout=tick)
117
+ except queue.Empty:
118
+ return out
119
+ if first is not None:
120
+ out.append(first)
121
+ while not signal.empty():
122
+ item = signal.get_nowait()
123
+ if item is not None:
124
+ out.append(item)
125
+ return out
126
+
127
+
128
+ def _offer(signal: "queue.Queue[WireMessage | None]", item: WireMessage | None) -> None:
129
+ """Never block a WRITER. A full queue means a consumer is behind, and the cursor poll will
130
+ catch it up anyway — so dropping the signal costs half a second, where blocking here would
131
+ stall the use case that just recorded the event.
132
+
133
+ A dropped WIRE message is a lost fragment of prose and nothing more: the file on disk is
134
+ being written in parallel, so the reader loses a moment of the animation, never the text."""
135
+ try:
136
+ signal.put_nowait(item)
137
+ except queue.Full:
138
+ return
@@ -0,0 +1,122 @@
1
+ """The commit guard — where git-binding stops being a convention.
2
+
3
+ Called from a Claude Code `PreToolUse` hook on `git commit`. It answers one question:
4
+ may this commit happen, and with what message. The hook turns a refusal into a DENY
5
+ that the agent reads before the commit runs, so a task-less commit never exists rather
6
+ than being reported afterwards.
7
+
8
+ Why enforce at all, when a `post-commit` hook could just record whatever happens: a
9
+ commit with no task is a commit nobody can attribute at review time, and the moment
10
+ one is allowed the board stops being a complete picture of what changed. The refusal is
11
+ also cheap to satisfy — the fix is one `taskops_next` call, and the message says so.
12
+
13
+ What it deliberately does NOT do is judge the CONTENT. Whether the diff matches the
14
+ spec is a question for a model, and putting a model in the path of every commit would
15
+ add seconds and a failure mode to the most common action in the repository.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from .._clock import now
23
+ from ..engine import commitline, gitio
24
+ from ..storage import Store
25
+ from ._project import caller, heartbeat, project
26
+
27
+ __all__ = ["Verdict", "check_commit", "check_command"]
28
+
29
+
30
+ class Verdict:
31
+ """Allowed or not, the message the commit should carry, and the command to run.
32
+
33
+ A class rather than a tuple because a caller reads all four fields, and a bare
34
+ `(bool, str, str, str)` at a call site is where two of them get swapped.
35
+ """
36
+
37
+ def __init__(self, *, allowed: bool, reason: str = "", message: str = "",
38
+ task: str = "", command: str = "") -> None:
39
+ self.allowed = allowed
40
+ self.reason = reason
41
+ self.message = message
42
+ self.task = task
43
+ self.command = command
44
+ """The rewritten shell command, or "" if there is nothing to change.
45
+
46
+ Set only by `check_command`. It exists so the hook transport can hand back an
47
+ `updatedInput` without knowing anything about git commit syntax — deciding what a
48
+ command becomes is a decision, and decisions do not live in a transport.
49
+ """
50
+
51
+
52
+ def check_command(start: Path | str, command: str, *, actor: str = "") -> Verdict | None:
53
+ """Judge a whole `git commit` shell command. None if it is not one.
54
+
55
+ The Claude Code matcher can only filter on the TOOL name, so the hook sees every Bash
56
+ call and something has to recognise a commit. That something is here rather than in the
57
+ transport, along with the rewrite: the transport passes through what it received and gets
58
+ back what to do.
59
+ """
60
+ if not commitline.is_commit(command):
61
+ return None
62
+ verdict = check_commit(start, commitline.message_of(command), actor=actor)
63
+ if verdict.allowed and verdict.message:
64
+ verdict.command = commitline.with_message(command, verdict.message)
65
+ return verdict
66
+
67
+
68
+ def check_commit(start: Path | str, message: str, *, actor: str = "") -> Verdict:
69
+ """May this commit run, and what should its message say.
70
+
71
+ The task is taken from the BRANCH, not from the caller's claims. An agent holding
72
+ three leases and committing on one branch means exactly one of them, and asking it
73
+ to also state which invites the one answer that is wrong.
74
+ """
75
+ with project(start) as store:
76
+ who = caller(store, actor)["id"]
77
+ heartbeat(store, who)
78
+ branch = gitio.current_branch(store.root)
79
+ return _judge(store, who, branch, message)
80
+
81
+
82
+ def _judge(store: Store, who: str, branch: str, message: str) -> Verdict:
83
+ on_branch = gitio.task_of_branch(branch)
84
+ stated = gitio.task_of_message(message)
85
+ if not on_branch:
86
+ return _no_branch(store, who, branch, message, stated)
87
+ if stated and stated != on_branch:
88
+ return Verdict(allowed=False, task=on_branch,
89
+ reason=f"the message says {stated} but the branch is for "
90
+ f"{on_branch} — fix whichever is wrong, do not guess")
91
+ if not store.leases.held_by(on_branch, who, now()):
92
+ return Verdict(allowed=False, task=on_branch,
93
+ reason=f"{who} holds no live lease on {on_branch}. Claim it "
94
+ f"(taskops_next task={on_branch}) before committing — "
95
+ f"another agent may have taken it while you worked")
96
+ return Verdict(allowed=True, task=on_branch,
97
+ message=gitio.add_trailer(message, on_branch))
98
+
99
+
100
+ def _no_branch(store: Store, who: str, branch: str, message: str,
101
+ stated: str) -> Verdict:
102
+ """Not on a `tk/...` branch. The one case with a genuinely useful suggestion.
103
+
104
+ A trailer alone is accepted: a developer cherry-picking onto main, or committing a
105
+ fix straight to a trunk-based repository, is doing something legitimate — and the
106
+ trailer is what actually binds the commit, since a branch name does not survive a
107
+ squash. Only the case with NEITHER is refused, and then the reason names the branch
108
+ to switch to, because "claim a task first" without the command is a puzzle.
109
+ """
110
+ if stated and store.tasks.get(stated) is not None:
111
+ return Verdict(allowed=True, task=stated, message=message)
112
+ held = store.leases.of_actor(who, now())
113
+ if not held:
114
+ return Verdict(allowed=False,
115
+ reason=f"`{branch or 'HEAD'}` is not a task branch and {who} "
116
+ f"holds no task. Run taskops_next, then commit on the "
117
+ f"branch it names")
118
+ suggestions = ", ".join(f"tk/{lease['task']}/…" for lease in held[:3])
119
+ return Verdict(allowed=False,
120
+ reason=f"`{branch}` is not a task branch. You hold "
121
+ f"{len(held)} task(s) — switch to one of {suggestions} "
122
+ f"(taskops_ask gives the exact branch name)")
@@ -0,0 +1,127 @@
1
+ """Installing the git hooks, without destroying the ones already there.
2
+
3
+ Three decisions carry this file.
4
+
5
+ **The command is the WIRING transport, not the developer's CLI.** `taskops` is what a person
6
+ types; `python -m taskops.transports.hooks` is what git runs. They were the same binary until
7
+ the separation the project declares turned out to be cosmetic, and a shared door is a door
8
+ whose surface is decided by the wrong audience.
9
+
10
+ **The interpreter is absolute, not `taskops` on PATH.** Git hooks run with whatever
11
+ environment git had, and that is routinely not the shell's: a virtualenv that is not active,
12
+ a GUI client with a minimal PATH, a `git commit` from an editor. A bare `taskops` there
13
+ resolves to nothing and the hook does nothing — silently, because every line ends in
14
+ `|| true`. Embedding `sys.executable -m taskops...` binds the hook to the interpreter that
15
+ installed it, which is the one that definitely has taskops in it. (Found by the end-to-end
16
+ test, where `git commit` could not see the venv that pytest was running from.)
17
+
18
+ **Existing hooks are CHAINED, never overwritten.** A repository's `post-commit` may already
19
+ run something somebody depends on, so an existing script is kept and our line is appended
20
+ under a marker — which is also how a re-install finds the line it wrote last time and
21
+ REWRITES it. That refresh is what repairs a repository initialised before the wiring moved
22
+ out of the CLI: its hook names a command that no longer exists, and `|| true` means it says
23
+ nothing about it. `taskops init` again is the whole repair.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import stat
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ __all__ = ["install_hooks", "HOOKS", "MARKER", "runner"]
33
+
34
+ MARKER = "# >>> taskops >>>"
35
+
36
+ HOOKS: dict[str, str] = {
37
+ "post-commit": "ingest commit HEAD",
38
+ "post-checkout": "ingest branch",
39
+ "post-merge": "sync",
40
+ }
41
+ """Why these three, and why nothing else:
42
+
43
+ - **post-commit** catches every commit, including the ones the PreToolUse guard never saw —
44
+ a human's terminal commit, a `--no-verify`, a rebase landing on a task branch.
45
+ - **post-checkout** records the branch on the lease, so the board can show where an agent is
46
+ working without asking it to report that separately.
47
+ - **post-merge** imports what a `git pull` just brought in. That is the moment another
48
+ developer's events become visible, and the only automatic sync point that matters.
49
+
50
+ Notably NOT `pre-commit`: refusing a commit is the guard's job, and the guard runs inside
51
+ Claude Code where a refusal reaches the agent as text it can act on. A `pre-commit` refusal
52
+ reaches a human as a failed command with no context, and an agent as an error it will try to
53
+ work around.
54
+ """
55
+
56
+
57
+ def runner() -> str:
58
+ """The command prefix a hook uses: this interpreter, running the wiring transport.
59
+
60
+ Renaming this is the change that breaks in TOTAL silence — every hook line ends in
61
+ `|| true`, so a module that no longer exists just stops binding commits to cards and
62
+ nothing anywhere says so. `tests/e2e/test_hook_wiring.py` commits in a real repository and
63
+ asserts the binding happened; that test is the only thing standing between a rename and a
64
+ board that quietly loses its commits.
65
+ """
66
+ return f"{sys.executable} -m taskops.transports.hooks"
67
+
68
+
69
+ def install_hooks(root: Path) -> tuple[list[str], list[str]]:
70
+ """Install into `.git/hooks`. Returns (installed, skipped-with-reason).
71
+
72
+ Skipping rather than raising: this runs inside `init`, and a directory that is not a git
73
+ repository yet is an ordinary state — `git init` then `taskops init` again is the fix, and
74
+ the report says so.
75
+ """
76
+ hooks_dir = root / ".git" / "hooks"
77
+ if not hooks_dir.is_dir():
78
+ return [], [f"{root}/.git/hooks does not exist — not a git repository yet"]
79
+ installed: list[str] = []
80
+ skipped: list[str] = []
81
+ for name, verb in HOOKS.items():
82
+ problem = _install_one(hooks_dir / name, f"{runner()} {verb} >/dev/null 2>&1 || true")
83
+ (skipped if problem else installed).append(f"{name}: {problem}" if problem else name)
84
+ return installed, skipped
85
+
86
+
87
+ def _install_one(path: Path, command: str) -> str:
88
+ """"" on success, else the reason. Appends under the marker if a hook already exists."""
89
+ try:
90
+ if path.is_file() and MARKER in path.read_text(encoding="utf-8"):
91
+ return "" if _refresh(path, command) else "already installed"
92
+ if path.is_file():
93
+ _append(path, command)
94
+ else:
95
+ path.write_text(f"#!/bin/sh\n{MARKER}\n{command}\n", encoding="utf-8")
96
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
97
+ except OSError as err:
98
+ return str(err)
99
+ return ""
100
+
101
+
102
+ def _refresh(path: Path, command: str) -> bool:
103
+ """Rewrite OUR line when it no longer says what it should. True if anything changed.
104
+
105
+ Everything above the marker is the repository's own script and is kept byte for byte;
106
+ everything below it is ours, and ours is the only part that is allowed to move. Without
107
+ this, `init` on an already-initialised repository reports "already installed" over a hook
108
+ line pointing at a module that was renamed — the silent failure this file warns about,
109
+ reached through the command that is supposed to be the repair.
110
+ """
111
+ current = path.read_text(encoding="utf-8")
112
+ head, _, ours = current.partition(MARKER)
113
+ if ours.strip() == command:
114
+ return False
115
+ path.write_text(f"{head}{MARKER}\n{command}\n", encoding="utf-8")
116
+ return True
117
+
118
+
119
+ def _append(path: Path, command: str) -> None:
120
+ """Add our line to an existing hook, keeping what was there.
121
+
122
+ Appended rather than prepended: the existing script is what the repository's owner put
123
+ there, and it gets to decide the exit code. Our line cannot fail anyway — it ends in
124
+ `|| true`.
125
+ """
126
+ current = path.read_text(encoding="utf-8").rstrip("\n")
127
+ path.write_text(f"{current}\n\n{MARKER}\n{command}\n", encoding="utf-8")
@@ -0,0 +1,62 @@
1
+ """What has been written up — the listing behind the Reports view.
2
+
3
+ The one read here that does NOT go through `day_report`: this answers "which reports exist",
4
+ which is a question about the directory, not about the log. It reads each file only far enough
5
+ to answer whether it is stale and whether anybody narrated it, and never returns a body.
6
+
7
+ Today is listed even when its file is missing, because the screen's main verb is generating it
8
+ and a list that shows nothing on a fresh repository would offer nothing to press.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from .._clock import now
16
+ from ..contracts import ReportEntry
17
+ from ..engine import date_of, missing_events, stamped_seq
18
+ from ..render import is_pending
19
+ from ..storage import REPORTS_DIR, Store
20
+ from ._project import project
21
+ from ._range import is_day
22
+ from .dossier import report_path
23
+
24
+ __all__ = ["report_index"]
25
+
26
+
27
+ def report_index(start: Path | str) -> list[ReportEntry]:
28
+ """Every report on disk, newest first, with today at the top whether or not it exists."""
29
+ with project(start) as store:
30
+ today = date_of(now())
31
+ directory = store.root / REPORTS_DIR
32
+ found = sorted(directory.glob("*.md"), key=lambda path: path.stem, reverse=True) \
33
+ if directory.is_dir() else []
34
+ entries = [_entry(store, path) for path in found]
35
+ if not any(entry["label"] == today for entry in entries):
36
+ entries.insert(0, ReportEntry(label=today, path=str(report_path(store.root, today)),
37
+ exists=False, stale=False, missing_events=0,
38
+ has_narration=False, bytes=0))
39
+ return entries
40
+
41
+
42
+ def _entry(store: Store, path: Path) -> ReportEntry:
43
+ text = path.read_text(encoding="utf-8", errors="replace")
44
+ behind = _behind(store, path.stem, text)
45
+ return ReportEntry(label=path.stem, path=str(path), exists=True, stale=behind > 0,
46
+ missing_events=behind, has_narration=not is_pending(text),
47
+ bytes=len(text.encode("utf-8")))
48
+
49
+
50
+ def _behind(store: Store, label: str, text: str) -> int:
51
+ """Staleness is only meaningful for a SINGLE day.
52
+
53
+ `missing_events` counts inside one calendar window, so a report named for a range — or for
54
+ `all` — has no window to count against. The label is opaque by contract: anything that
55
+ assumed it parses as a date would raise the first time somebody writes a weekly report.
56
+ """
57
+ if not is_day(label):
58
+ return 0
59
+ # Both ends are the same date: a day IS the one-day window, which is exactly why the
60
+ # range work could generalise `missing_events` without this call meaning something else.
61
+ return missing_events(store, label, label, stamped_seq(text))
62
+
@@ -0,0 +1,60 @@
1
+ """Recording what git actually did. Called from a `post-commit` hook.
2
+
3
+ This is the half of git-binding that does not need permission. The guard runs before a
4
+ commit and can refuse; this runs after one and only records — which is why it also
5
+ catches every commit the guard never saw: a human's `git commit` in a terminal, a
6
+ `--no-verify`, a merge, a rebase that rewrote history onto a task branch.
7
+
8
+ Recording is unconditional and idempotent. A commit whose task cannot be determined is
9
+ skipped rather than attributed to a guess, because a wrong attribution is worse than a
10
+ missing one: the board would show evidence for a task that has none, and the `done`
11
+ guard would let it close.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from ..contracts import Event
19
+ from ..engine import gitio, record
20
+ from ._project import caller, project
21
+
22
+ __all__ = ["ingest_commit"]
23
+
24
+
25
+ def ingest_commit(start: Path | str, sha: str = "HEAD", *, actor: str = "") -> Event | None:
26
+ """Bind one commit to its task. None if there is no task to bind it to.
27
+
28
+ The task comes from the trailer FIRST and the branch second. That order matters
29
+ after a rebase: the branch is whatever is checked out now, the trailer is what the
30
+ author wrote, and when they disagree the author is right.
31
+ """
32
+ with project(start) as store:
33
+ resolved = gitio.head_sha(store.root) if sha == "HEAD" else sha
34
+ message = gitio.commit_message(store.root, resolved)
35
+ task = gitio.task_of_message(message) or \
36
+ gitio.task_of_branch(gitio.current_branch(store.root))
37
+ if not task or store.tasks.get(task) is None:
38
+ return None
39
+ return record(store, task=task, actor=caller(store, actor)["id"],
40
+ kind="commit",
41
+ body={"sha": resolved, "subject": message.splitlines()[0],
42
+ "files": gitio.changed_files(store.root, resolved)})
43
+
44
+
45
+ def ingest_branch(start: Path | str, branch: str = "", *, actor: str = "") -> Event | None:
46
+ """Note that a task's branch exists, and record it on the lease.
47
+
48
+ Called from `post-checkout`. The lease carries the branch so the live board can show
49
+ where an agent is working, and so a later `taskops_ask` can name the branch instead
50
+ of asking the agent to remember it.
51
+ """
52
+ with project(start) as project_store:
53
+ name = branch or gitio.current_branch(project_store.root)
54
+ task = gitio.task_of_branch(name)
55
+ if not task or project_store.tasks.get(task) is None:
56
+ return None
57
+ project_store.leases.set_branch(task_id=task, branch=name)
58
+ return record(project_store, task=task,
59
+ actor=caller(project_store, actor)["id"],
60
+ kind="branch", body={"branch": name})