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,129 @@
1
+ """One `claude` process, read a delta at a time — and stripped of the developer's world.
2
+
3
+ Split from `narrate` because two different things live here. `narrate` decides how a dossier is
4
+ cut and stitched; this decides what a single reading COSTS and how soon the caller sees it.
5
+
6
+ **Why streaming at all.** `subprocess.run(capture_output=True)` returns nothing until the process
7
+ exits, and a whole-project dossier is narrated in several passes of minutes each — so
8
+ `report all --digest` showed an empty terminal for a quarter of an hour and read as a hang. It
9
+ was reported as one. `--output-format stream-json` on its own does NOT fix that: it emits a
10
+ single line carrying the finished text. Partial output needs `--include-partial-messages`, which
11
+ in turn requires `--verbose` and `-p`.
12
+
13
+ The wire format itself — which line means what, and the trap in it — is read by `_events`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import subprocess
20
+ from typing import Iterator
21
+
22
+ from .._clock import now
23
+ from .._errors import NarrationFailed
24
+ from ._events import check_result, parsed, text_delta
25
+ from .worker import DROPPED_ENV
26
+
27
+ __all__ = ["stream_narration", "TIMEOUT", "ISOLATION"]
28
+
29
+ TIMEOUT = 900.0
30
+ """Seconds before ONE reading is abandoned. Long enough for a real read, short enough that
31
+ `--digest` cannot hang a terminal somebody left running forever.
32
+
33
+ 240s until a slice of `report all` on axion-v3 (45 closed cards, 340 KB of dossier) ran past
34
+ it and the whole digest was thrown away after twenty minutes of work — five good slices lost
35
+ with it, which is the worst outcome available.
36
+ """
37
+
38
+ ISOLATION = ("--tools", "", "--max-turns", "1", "--setting-sources=", "--strict-mcp-config")
39
+ """What a bare `claude -p` must NOT inherit. This is the cost fix, and it is most of the clock.
40
+
41
+ Measured on this machine against claude CLI 2.1.220: a bare `claude -p` loaded the developer's
42
+ whole world — 43 skills, 6 MCP servers, 8 subagents and the hooks — and spent **32,541
43
+ cache-creation tokens and USD 0.33 to write three lines**. Every flag deletes one source of it:
44
+ `--setting-sources=` (empty) drops the user, project and local settings files, which is where the
45
+ skills, the subagents and the hooks come from; `--strict-mcp-config` drops the MCP servers, whose
46
+ tool schemas are the bulk of that prompt; `--tools ""` says the narrator reads no files and runs
47
+ no commands, because it is handed the dossier and has nothing to look up; `--max-turns 1` makes
48
+ that literal — one answer, no agent loop.
49
+
50
+ The narration is a paragraph about a text the caller already has. None of that machinery could
51
+ have helped it, and all of it was being paid for on every pass.
52
+ """
53
+
54
+ _STREAM = ("--output-format", "stream-json", "--verbose", "--include-partial-messages")
55
+ """Ask for deltas. `--include-partial-messages` is the one that streams; it is refused without
56
+ `--verbose`, and `stream-json` alone emits one line with the finished text."""
57
+
58
+
59
+ def stream_narration(prompt: str, *, model: str = "",
60
+ timeout: float = TIMEOUT) -> Iterator[str]:
61
+ """The prose of one reading, in the order it is written. Raises `NarrationFailed`.
62
+
63
+ A plain synchronous generator: the engine is sync and tested that way, and a narration is
64
+ one process read line by line, which asyncio would buy nothing for.
65
+ """
66
+ command = ["claude", "-p", prompt, *_STREAM, *ISOLATION]
67
+ if model:
68
+ command += ["--model", model]
69
+ try:
70
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
71
+ text=True, bufsize=1, env=_env())
72
+ except FileNotFoundError as missing:
73
+ raise NarrationFailed(
74
+ "`claude` is not on PATH — the narration is written by the Claude Code CLI you are "
75
+ "already logged into. Install it, or write the section by hand") from missing
76
+ try:
77
+ yield from _deltas(process, now() + timeout, timeout)
78
+ finally:
79
+ # Unconditional: a terminal killed mid-narration must not leave an orphan `claude`
80
+ # holding a subscription slot, and a generator abandoned early lands here too.
81
+ process.kill()
82
+ process.wait()
83
+
84
+
85
+ def _deltas(process: "subprocess.Popen[str]", deadline: float, timeout: float) -> Iterator[str]:
86
+ """Text deltas until the `result` event, refusing to run past the deadline.
87
+
88
+ `wait(timeout=)` alone would only bound the time AFTER stdout closes, so a process that
89
+ dribbles a byte a minute would never hit it. The clock is read on every line instead.
90
+ """
91
+ if process.stdout is None: # pragma: no cover - PIPE was requested
92
+ raise NarrationFailed("claude produced no output stream")
93
+ for line in process.stdout:
94
+ if now() > deadline:
95
+ raise NarrationFailed(f"the narration took longer than {int(timeout)}s and was "
96
+ f"abandoned — the dossier is still on disk")
97
+ event = parsed(line)
98
+ if event.get("type") == "result":
99
+ check_result(event)
100
+ return
101
+ text = text_delta(event)
102
+ if text:
103
+ yield text
104
+ _ended(process)
105
+
106
+
107
+ def _ended(process: "subprocess.Popen[str]") -> None:
108
+ """stdout closed with no `result`. Say why, using whatever the process left behind."""
109
+ process.wait()
110
+ said = (process.stderr.read() if process.stderr else "").strip().splitlines()
111
+ if process.returncode:
112
+ raise NarrationFailed(f"claude exited {process.returncode}: "
113
+ f"{said[-1] if said else 'no output'}")
114
+ raise NarrationFailed("claude answered with nothing — check `claude` runs and is "
115
+ "logged in (`claude -p hello`)")
116
+
117
+
118
+ def _env() -> dict[str, str]:
119
+ """The developer's environment, minus the API credentials.
120
+
121
+ `worker.DROPPED_ENV` names them and the reason is identical here: an exported
122
+ `ANTHROPIC_API_KEY` makes the CLI bill per token while the subscription sits unused. One
123
+ constant, two callers — and it is why this stays a subprocess: the flag-translating SDK
124
+ merges its `env` over the parent's, so it cannot DELETE a variable, only add one.
125
+ """
126
+ kept = dict(os.environ)
127
+ for name in DROPPED_ENV:
128
+ kept.pop(name, None)
129
+ return kept
@@ -0,0 +1,94 @@
1
+ """The state of the WORKERS: what happened in a window, and who is live right now.
2
+
3
+ Both projections read events and leases, where `project` reads tasks and deps. The
4
+ seam is deliberate: how liveness is judged changes often (a grace period, a new
5
+ activity source) and how a board is drawn should not move when it does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .._clock import HEARTBEAT_GRACE, now
11
+ from ..contracts import (
12
+ BranchState,
13
+ Event,
14
+ Fleet,
15
+ FleetMember,
16
+ Lease,
17
+ Standup,
18
+ Task,
19
+ )
20
+ from ..storage import Store
21
+ from .gitstate import branch_states, unknown
22
+
23
+ __all__ = ["standup", "fleet", "tasks_of"]
24
+
25
+ _IN_FLIGHT = ("claimed", "in_progress", "review")
26
+
27
+
28
+ def standup(store: Store, *, since: float, actor: str = "") -> Standup:
29
+ """What changed in a window. The auto-generated status report.
30
+
31
+ Derived from EVENTS rather than from task rows, so it reports what happened
32
+ instead of only where things landed: a task claimed, worked on and handed back
33
+ shows its whole arc, where a row-based report would show it as untouched.
34
+ """
35
+ events = [e for e in store.events.since(since)
36
+ if not actor or e["actor"] == actor]
37
+ touched = tasks_of(store, [e["task"] for e in events])
38
+ return Standup(repo=str(store.root), since=since,
39
+ actors=sorted({e["actor"] for e in events}), events=events,
40
+ done=[t for t in touched if t["status"] == "done"],
41
+ in_flight=[t for t in touched if t["status"] in _IN_FLIGHT],
42
+ blocked=[t for t in touched if t["status"] == "blocked"])
43
+
44
+
45
+ def tasks_of(store: Store, ids: list[str]) -> list[Task]:
46
+ """The tasks those events were about, deduplicated, missing ones dropped.
47
+
48
+ Dropped rather than reported: an event can legitimately name a task this machine
49
+ has not pulled yet, and a standup is not where a sync gap gets explained.
50
+ """
51
+ out: list[Task] = []
52
+ for task_id in dict.fromkeys(ids):
53
+ found = store.tasks.get(task_id)
54
+ if found is not None:
55
+ out.append(found)
56
+ return out
57
+
58
+
59
+ def fleet(store: Store, *, at: float | None = None) -> Fleet:
60
+ """Who is working right now, and on what.
61
+
62
+ Every live lease appears, including the ones this view no longer believes: an
63
+ agent whose signal went quiet past the grace period still holds its claim, and a
64
+ board that hid it would be hiding the exact row somebody needs to act on.
65
+ """
66
+ when = now() if at is None else at
67
+ doing = store.events.latest_by_task("activity")
68
+ # ONE subprocess for the whole fleet, not one per member: this runs on every board refresh, and
69
+ # a git call per agent is what turns a live board into a spinning one at ten agents.
70
+ branches = branch_states(store.root)
71
+ return Fleet(repo=str(store.root),
72
+ members=[_member(lease, doing.get(lease["task"]), when, branches)
73
+ for lease in store.leases.live(when)])
74
+
75
+
76
+ def _member(lease: Lease, activity: Event | None, when: float,
77
+ branches: dict[str, BranchState]) -> FleetMember:
78
+ """One live session. `last_seen` falls back to the CLAIM, not to zero.
79
+
80
+ An agent whose hooks are not installed reports no activity at all, and reading
81
+ that as "last seen at the epoch" would mark every such session dead — which is
82
+ every session running plain Claude Code without the plugin.
83
+
84
+ `git` separates three states a board used to collapse into one: pushed, unpushed, and a branch
85
+ this clone cannot see at all because the agent is on another machine. Only the last is genuinely
86
+ unknown, and `unknown()` says so instead of reporting a clean zero.
87
+ """
88
+ last_seen = activity["ts"] if activity else lease["acquired"]
89
+ summary = activity["body"].get("summary", "") if activity else ""
90
+ return FleetMember(actor=lease["actor"], session=lease["session"],
91
+ task=lease["task"], branch=lease["branch"],
92
+ alive=(when - last_seen) < HEARTBEAT_GRACE,
93
+ last_seen=last_seen, doing=str(summary),
94
+ git=branches.get(lease["branch"], unknown(lease["branch"])))
taskops/engine/bus.py ADDED
@@ -0,0 +1,42 @@
1
+ """The in-process fan-out: a write here, a live board there.
2
+
3
+ Deliberately small, and deliberately NOT the delivery guarantee. Every taskops
4
+ process is short-lived and separate — MCP is a stdio transport, so each editor
5
+ session launches its own server, and each git hook is a fresh interpreter — so a
6
+ subscriber in one process cannot possibly see a publish in another.
7
+
8
+ That is why the studio does NOT rely on this. It polls `events.after_seq(cursor)`,
9
+ which is an indexed integer scan over a table every process writes to, and pushes
10
+ what it finds to browsers. This bus is the fast path for subscribers that live in
11
+ the SAME process as the writer — the studio's own writes, and the relay — so a
12
+ board updates without waiting for the next poll tick.
13
+
14
+ The honest summary: SQLite is the transport, and this is a latency optimisation. A
15
+ missed publish costs a fraction of a second, never a lost event.
16
+
17
+ The fan-out MACHINERY is `wire.Broadcast`, shared with the ephemeral channel that
18
+ carries narration deltas. Two singletons over one implementation, because the
19
+ difference between them is what they carry and who is allowed to persist it — not
20
+ how a listener is registered. A second hand-written publish/subscribe would drift.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Callable, TypeAlias
26
+
27
+ from ..contracts import Event
28
+ from .wire import Broadcast
29
+
30
+ __all__ = ["EventBus", "BUS"]
31
+
32
+ Listener = Callable[[Event], None]
33
+
34
+ EventBus: TypeAlias = Broadcast[Event]
35
+ """The bus's type, named. Kept as a name because it is the thing every caller and
36
+ every test refers to, and because "an EventBus" says what a `Broadcast[Event]` is
37
+ for."""
38
+
39
+ BUS: EventBus = Broadcast()
40
+ """The process-wide bus. A module-level singleton because the alternative is
41
+ threading one through every use case to serve one optional subscriber — and the
42
+ subscriber is always the same object: whatever is streaming to browsers."""
@@ -0,0 +1,110 @@
1
+ """Reading and rewriting a `git commit` shell command.
2
+
3
+ This exists because of what a Claude Code `PreToolUse` hook can do: return `updatedInput`
4
+ and have the tool call run with a MODIFIED command. So the `Task:` trailer can be injected
5
+ into the agent's own `git commit -m …` before it executes — the agent never writes it, never
6
+ forgets it, and never sees a failure about it.
7
+
8
+ Parsing shell is a bad idea in general, so the scope here is deliberately narrow: recognise
9
+ `git commit` with a `-m` message, and rebuild the same command with a different message.
10
+ Anything else — a compound command, `-F`, an editor commit with no `-m` — is reported as
11
+ "cannot rewrite", and the caller falls back to allowing or denying without a rewrite. Guessing
12
+ at a command somebody is about to run is how a coordination tool breaks a commit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shlex
18
+
19
+ __all__ = ["is_commit", "message_of", "with_message"]
20
+
21
+ _MESSAGE_FLAGS = ("-m", "--message")
22
+
23
+
24
+ def is_commit(command: str) -> bool:
25
+ """Is this a `git commit`."""
26
+ return _subcommand(_words(command)) == "commit"
27
+
28
+
29
+ def message_of(command: str) -> str:
30
+ """The `-m` message, or "" when there is not exactly one to read.
31
+
32
+ "" covers several different situations on purpose — no `-m` at all (an editor commit),
33
+ `-F` (a file), or several `-m` flags (git joins them into paragraphs). The caller cannot
34
+ do anything useful with any of them beyond declining to rewrite, so they collapse.
35
+ """
36
+ words = _words(command)
37
+ found = [words[i + 1] for i, word in enumerate(words[:-1])
38
+ if word in _MESSAGE_FLAGS]
39
+ return found[0] if len(found) == 1 else ""
40
+
41
+
42
+ def with_message(command: str, message: str) -> str:
43
+ """The same command with its `-m` value replaced. "" if it cannot be done safely.
44
+
45
+ A compound command is refused outright. `git commit -m x && git push` would need this to
46
+ understand shell operators to rewrite correctly, and a rewrite that dropped the second
47
+ half would be far worse than no rewrite at all — the agent's push would silently not
48
+ happen.
49
+ """
50
+ if _is_compound(command) or not message_of(command):
51
+ return ""
52
+ words = _words(command)
53
+ out: list[str] = []
54
+ skip = False
55
+ for word in words:
56
+ if skip:
57
+ out.append(message)
58
+ skip = False
59
+ continue
60
+ out.append(word)
61
+ skip = word in _MESSAGE_FLAGS
62
+ return shlex.join(out)
63
+
64
+
65
+ def _words(command: str) -> list[str]:
66
+ """Shell-split, or [] if the command cannot be lexed.
67
+
68
+ Never raises: the input is whatever an agent typed, and an unbalanced quote must read as
69
+ "not something I can analyse" rather than take the hook down and block the commit.
70
+ """
71
+ try:
72
+ return shlex.split(command)
73
+ except ValueError:
74
+ return []
75
+
76
+
77
+ def _is_compound(command: str) -> bool:
78
+ """Does the command chain, pipe, redirect or substitute.
79
+
80
+ Checked on the RAW string rather than the lexed words, because shlex strips exactly the
81
+ operators this is looking for — after splitting, `a && b` and `a b` are hard to tell
82
+ apart, and mistaking one for the other is what would drop half a command.
83
+ """
84
+ return any(token in command for token in ("&&", "||", ";", "|", "$(", "`", ">", "<"))
85
+
86
+
87
+ _VALUED_FLAGS = ("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path")
88
+
89
+
90
+ def _subcommand(words: list[str]) -> str:
91
+ """The git subcommand: the first bare word after `git`, or "".
92
+
93
+ Parsed properly rather than by looking for "commit" near the front, which is what this did
94
+ first — and `git log --grep commit` was then read as a commit, so the guard would have
95
+ inspected a read-only command. Git's own pre-subcommand flags are skipped, including the
96
+ ones that take a value (`git -C /tmp commit`), because otherwise their argument would be
97
+ mistaken for the subcommand.
98
+ """
99
+ if not words or words[0] != "git":
100
+ return ""
101
+ index = 1
102
+ while index < len(words):
103
+ word = words[index]
104
+ if word in _VALUED_FLAGS:
105
+ index += 2
106
+ elif word.startswith("-"):
107
+ index += 1
108
+ else:
109
+ return word
110
+ return ""
taskops/engine/day.py ADDED
@@ -0,0 +1,142 @@
1
+ """A span of calendar days, projected out of the log — the deterministic dossier.
2
+
3
+ Its own module rather than a third window report beside `standup`, because the WINDOW is the
4
+ design. A standup asks "what changed in the last 24 hours" and moves every time it is run; this
5
+ asks "what happened on Tuesday", or "what happened in July", and July is the same tomorrow.
6
+ That is what makes this report worth writing down and worth diffing against the next one.
7
+
8
+ A day is the SMALLEST case, not the only one. "How did the project go" is not a question about
9
+ Tuesday, and answering it by reading thirty daily files is how nobody answers it — so the same
10
+ assembly runs over any span of whole days, ending at a midnight the reader would recognise.
11
+
12
+ Local midnight, not UTC. The reader lives in a timezone and closed a card at 23:50 in it; a UTC
13
+ day would file that under the wrong date for everyone west of Greenwich and be defensible only
14
+ to the machine.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+
21
+ from .._clock import now
22
+ from .._errors import BadRequest
23
+ from .._types import LOCAL_ONLY_KINDS, WORKING_STATUSES
24
+ from ..contracts import PeriodReport
25
+ from ..storage import Store
26
+ from ._closed import closed_cards
27
+ from ._opened import opened_cards, waiting_tasks
28
+ from .activity import tasks_of
29
+ from .history import rolls
30
+
31
+ __all__ = ["period_report", "day_report", "label_of", "first_date", "window", "shift",
32
+ "date_of", "MAX_EVENTS", "MAX_CLOSED"]
33
+
34
+ MAX_EVENTS = 20_000
35
+ """A ceiling, not a page. A day that overflows it is a day nobody could read anyway, and the
36
+ alternative — a silent 500-row default — would produce a dossier that looks complete."""
37
+
38
+ MAX_CLOSED = 200
39
+ """How many closed cards one report renders in full. A month can close more than a person
40
+ will read, and every card carries its whole commit list — so the cap is real. What is NOT
41
+ allowed is cutting quietly: the overflow is counted into `dropped` and printed."""
42
+
43
+
44
+ def day_report(store: Store, date: str) -> PeriodReport:
45
+ """Everything that happened on `date` (`YYYY-MM-DD`) — the one-day case of a period."""
46
+ return period_report(store, date, date)
47
+
48
+
49
+ def period_report(store: Store, start_date: str, end_date: str,
50
+ label: str = "") -> PeriodReport:
51
+ """Every day from `start_date` to `end_date` INCLUSIVE, from events and git alone.
52
+
53
+ One assembly, not two: a week is a day with a wider window, and a second copy of this
54
+ would be the copy that forgets to exclude heartbeats.
55
+
56
+ `label` is an override for the one name the dates cannot produce: `report all` covers a
57
+ span that is only incidentally 2026-07-14..2026-07-28, and calling its file that would
58
+ mean yesterday's "everything" and today's are two different documents.
59
+ """
60
+ start, end = window(start_date)[0], window(end_date)[1]
61
+ if end <= start:
62
+ raise BadRequest(f"`{start_date}` is after `{end_date}` — a range runs forwards")
63
+ events = [e for e in store.events.since(start - 1.0, limit=MAX_EVENTS)
64
+ if start <= e["ts"] < end and e["kind"] not in LOCAL_ONLY_KINDS]
65
+ touched = tasks_of(store, [e["task"] for e in events])
66
+ dones = [e for e in events if e["kind"] == "done"]
67
+ opened = opened_cards(store, events)
68
+ return PeriodReport(
69
+ repo=str(store.root), from_date=start_date, to_date=end_date,
70
+ label=label or label_of(start_date, end_date),
71
+ closed=closed_cards(store, dones[-MAX_CLOSED:]),
72
+ dropped=max(0, len(dones) - MAX_CLOSED),
73
+ opened=opened,
74
+ in_flight=[t for t in touched if t["status"] in WORKING_STATUSES],
75
+ blocked=[t for t in touched if t["status"] == "blocked"],
76
+ waiting=waiting_tasks(touched, opened),
77
+ conversations=[e for e in events if e["kind"] in ("comment", "message")],
78
+ actors=rolls(events),
79
+ commits_total=sum(1 for e in events if e["kind"] == "commit"))
80
+
81
+
82
+ def label_of(start_date: str, end_date: str) -> str:
83
+ """`2026-07-28` for one day, `2026-07-22..2026-07-28` for a range.
84
+
85
+ Derived in ONE place because it is both the report's heading and the name of the file it
86
+ is written to — computing it twice is how a `2026-07-22..28.md` ends up titled otherwise.
87
+ """
88
+ return start_date if start_date == end_date else f"{start_date}..{end_date}"
89
+
90
+
91
+ def first_date(store: Store) -> str:
92
+ """The local date of the OLDEST event in the log — where `report all` starts.
93
+
94
+ Read from the log rather than from the repository's first commit: taskops knows about the
95
+ project from its first event, and claiming to cover days that predate the log would be
96
+ reporting silence as a fact.
97
+ """
98
+ oldest = store.events.since(-1.0, limit=1)
99
+ return date_of(oldest[0]["ts"]) if oldest else date_of(now())
100
+
101
+
102
+ def window(date: str) -> tuple[float, float]:
103
+ """`2026-07-28` -> the timestamps of local midnight and the NEXT local midnight.
104
+
105
+ Both ends come from `mktime`, which normalises an out-of-range day (`32 July` is
106
+ `1 August`) and applies the zone's own rules — so the day the clocks change is 23 or 25
107
+ hours long here, exactly as it was for the person who worked it. `start + 86400` would
108
+ be wrong twice a year, and wrong in the direction of losing an hour of somebody's work.
109
+ """
110
+ try:
111
+ parsed = time.strptime(date, "%Y-%m-%d")
112
+ except ValueError:
113
+ raise BadRequest(f"`{date}` is not a date — use YYYY-MM-DD, "
114
+ f"e.g. 2026-07-28") from None
115
+ fields = (parsed.tm_year, parsed.tm_mon, parsed.tm_mday)
116
+ return (_midnight(fields), _midnight((fields[0], fields[1], fields[2] + 1)))
117
+
118
+
119
+ def shift(date: str, *, days: int = 0, months: int = 0) -> str:
120
+ """`2026-07-28`, `days=-6` -> `2026-07-22`. What turns `--last 7d` into a start date.
121
+
122
+ `mktime` does the calendar: an out-of-range field is normalised, so `months=-1` on the
123
+ 31st of a month with no 31st lands on the 1st or the 3rd of the next one rather than
124
+ raising. Approximate at the edges and never wrong about how long a month is, which is the
125
+ trade a hand-rolled `date - 30` gets backwards.
126
+ """
127
+ parsed = time.strptime(date, "%Y-%m-%d")
128
+ return date_of(_midnight((parsed.tm_year, parsed.tm_mon + months,
129
+ parsed.tm_mday + days)))
130
+
131
+
132
+ def _midnight(fields: tuple[int, int, int]) -> float:
133
+ """Local 00:00 of a (year, month, day). `-1` for `tm_isdst` lets libc decide, which is
134
+ the only way to get the right answer on the hour a DST transition repeats."""
135
+ year, month, day = fields
136
+ return time.mktime((year, month, day, 0, 0, 0, 0, 0, -1))
137
+
138
+
139
+ def date_of(ts: float) -> str:
140
+ """The local calendar date a timestamp falls on. The inverse of `window`, and what turns
141
+ "now" into a default without a second notion of where a day starts."""
142
+ return time.strftime("%Y-%m-%d", time.localtime(ts))
@@ -0,0 +1,63 @@
1
+ """How big each commit was, for a whole day's worth of them in ONE subprocess.
2
+
3
+ `git diff-tree --numstat` takes one commit; handing it several makes it diff the first
4
+ against the rest, which is a different question and a wrong answer. `git log --no-walk`
5
+ is the batched form: it prints the named commits and nothing they descend from, so N shas
6
+ cost one process instead of N — the same reasoning as `gitstate.branch_states`.
7
+
8
+ Same degrade rule as the rest of `gitio`: never raises. A report that cannot be produced
9
+ because a sha was garbage-collected is worse than one that reports zeros.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ __all__ = ["numstats", "parse"]
18
+
19
+
20
+ def numstats(root: Path, shas: list[str]) -> dict[str, tuple[int, int]]:
21
+ """`{sha: (additions, deletions)}` for the shas git could resolve. ONE subprocess.
22
+
23
+ Shas git does not know are simply absent from the result — the caller reads a missing
24
+ key as zeros, so an unknown commit and an empty commit render the same way, which is
25
+ the honest thing to say when git will not tell us the difference.
26
+ """
27
+ wanted = [sha for sha in dict.fromkeys(shas) if sha]
28
+ if not wanted:
29
+ return {}
30
+ try:
31
+ done = subprocess.run(["git", "log", "--numstat", "--format=%H", "--no-walk",
32
+ *wanted], cwd=root, capture_output=True, text=True,
33
+ timeout=20, check=False)
34
+ except (OSError, subprocess.SubprocessError):
35
+ return {}
36
+ return parse(done.stdout) if done.returncode == 0 else {}
37
+
38
+
39
+ def parse(out: str) -> dict[str, tuple[int, int]]:
40
+ """The `--numstat` stream -> totals per commit.
41
+
42
+ A bare line is a sha (the `%H` header) and a three-column line is one file. Binary files
43
+ report `-` for both counts, which is not zero but is the only number that can be summed,
44
+ so they contribute nothing rather than breaking the parse.
45
+
46
+ Split out and pure so the format can be tested from a literal string: reproducing this
47
+ from a real repository would mean building commits in a fixture to assert on arithmetic.
48
+ """
49
+ totals: dict[str, tuple[int, int]] = {}
50
+ current = ""
51
+ for line in out.splitlines():
52
+ parts = line.split("\t")
53
+ if len(parts) == 1 and line.strip():
54
+ current = line.strip()
55
+ totals.setdefault(current, (0, 0))
56
+ elif len(parts) == 3 and current:
57
+ adds, dels = totals[current]
58
+ totals[current] = (adds + _count(parts[0]), dels + _count(parts[1]))
59
+ return totals
60
+
61
+
62
+ def _count(cell: str) -> int:
63
+ return int(cell) if cell.isdigit() else 0