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.
- taskops/__init__.py +39 -0
- taskops/_clock.py +32 -0
- taskops/_errors.py +160 -0
- taskops/_ids.py +63 -0
- taskops/_types.py +106 -0
- taskops/_version.py +7 -0
- taskops/assets/GUIDE.md +220 -0
- taskops/contracts/__init__.py +96 -0
- taskops/contracts/_fields.py +113 -0
- taskops/contracts/actor.py +30 -0
- taskops/contracts/board.py +142 -0
- taskops/contracts/commit.py +36 -0
- taskops/contracts/day.py +150 -0
- taskops/contracts/dep.py +23 -0
- taskops/contracts/event.py +58 -0
- taskops/contracts/gitstate.py +45 -0
- taskops/contracts/index.py +37 -0
- taskops/contracts/lease.py +43 -0
- taskops/contracts/log.py +59 -0
- taskops/contracts/remote.py +48 -0
- taskops/contracts/results.py +84 -0
- taskops/contracts/task.py +94 -0
- taskops/contracts/tools.py +110 -0
- taskops/contracts/wire.py +60 -0
- taskops/engine/__init__.py +34 -0
- taskops/engine/_blocks.py +48 -0
- taskops/engine/_briefs.py +92 -0
- taskops/engine/_chunks.py +66 -0
- taskops/engine/_closed.py +65 -0
- taskops/engine/_entries.py +126 -0
- taskops/engine/_events.py +55 -0
- taskops/engine/_opened.py +51 -0
- taskops/engine/_process.py +80 -0
- taskops/engine/_prompts.py +98 -0
- taskops/engine/_stream.py +129 -0
- taskops/engine/activity.py +94 -0
- taskops/engine/bus.py +42 -0
- taskops/engine/commitline.py +110 -0
- taskops/engine/day.py +142 -0
- taskops/engine/diffstat.py +63 -0
- taskops/engine/gitio.py +108 -0
- taskops/engine/gitstate.py +96 -0
- taskops/engine/history.py +76 -0
- taskops/engine/identity.py +84 -0
- taskops/engine/log.py +68 -0
- taskops/engine/machine.py +160 -0
- taskops/engine/narrate.py +91 -0
- taskops/engine/project.py +57 -0
- taskops/engine/replay.py +142 -0
- taskops/engine/reports.py +67 -0
- taskops/engine/scheduler.py +135 -0
- taskops/engine/transcript.py +127 -0
- taskops/engine/wire.py +92 -0
- taskops/engine/worker.py +115 -0
- taskops/py.typed +0 -0
- taskops/render/__init__.py +40 -0
- taskops/render/_closed_days.py +64 -0
- taskops/render/_dossier.py +68 -0
- taskops/render/_opened.py +64 -0
- taskops/render/_sections.py +51 -0
- taskops/render/_tasklist.py +72 -0
- taskops/render/_text.py +74 -0
- taskops/render/_verbatim.py +65 -0
- taskops/render/ansi.py +92 -0
- taskops/render/board.py +55 -0
- taskops/render/day.py +102 -0
- taskops/render/dispatch.py +104 -0
- taskops/render/inbox.py +27 -0
- taskops/render/log.py +47 -0
- taskops/render/recover.py +64 -0
- taskops/render/report.py +51 -0
- taskops/render/reports.py +57 -0
- taskops/render/results.py +96 -0
- taskops/render/session.py +75 -0
- taskops/render/task.py +88 -0
- taskops/render/tasklist.py +65 -0
- taskops/storage/__init__.py +36 -0
- taskops/storage/_ddl.py +78 -0
- taskops/storage/_delivered.py +51 -0
- taskops/storage/_deps.py +69 -0
- taskops/storage/_events.py +127 -0
- taskops/storage/_leases.py +108 -0
- taskops/storage/_rows.py +90 -0
- taskops/storage/_tasks.py +124 -0
- taskops/storage/locate.py +76 -0
- taskops/storage/schema.py +66 -0
- taskops/storage/store.py +120 -0
- taskops/storage/sync.py +139 -0
- taskops/transports/__init__.py +6 -0
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +3 -0
- taskops/transports/cli/commands/_digest.py +64 -0
- taskops/transports/cli/commands/_serve_init.py +65 -0
- taskops/transports/cli/commands/_shared.py +50 -0
- taskops/transports/cli/commands/_tasks_args.py +112 -0
- taskops/transports/cli/commands/_window.py +45 -0
- taskops/transports/cli/commands/ask.py +27 -0
- taskops/transports/cli/commands/dispatch.py +43 -0
- taskops/transports/cli/commands/init.py +48 -0
- taskops/transports/cli/commands/log.py +22 -0
- taskops/transports/cli/commands/plan.py +43 -0
- taskops/transports/cli/commands/pushpull.py +53 -0
- taskops/transports/cli/commands/recover.py +30 -0
- taskops/transports/cli/commands/remote.py +56 -0
- taskops/transports/cli/commands/report.py +82 -0
- taskops/transports/cli/commands/run_.py +60 -0
- taskops/transports/cli/commands/serve.py +74 -0
- taskops/transports/cli/commands/sync.py +22 -0
- taskops/transports/cli/commands/tasks.py +79 -0
- taskops/transports/cli/commands/ui.py +72 -0
- taskops/transports/cli/commands/update.py +25 -0
- taskops/transports/cli/main.py +85 -0
- taskops/transports/hooks/__init__.py +15 -0
- taskops/transports/hooks/__main__.py +63 -0
- taskops/transports/hooks/_args.py +28 -0
- taskops/transports/hooks/claude.py +78 -0
- taskops/transports/hooks/commit.py +61 -0
- taskops/transports/hooks/events.py +107 -0
- taskops/transports/hooks/record.py +55 -0
- taskops/transports/http/__init__.py +21 -0
- taskops/transports/http/_handler.py +132 -0
- taskops/transports/http/_wire.py +79 -0
- taskops/transports/http/_wsframes.py +116 -0
- taskops/transports/http/agentapi.py +69 -0
- taskops/transports/http/api.py +122 -0
- taskops/transports/http/exchange.py +80 -0
- taskops/transports/http/live.py +160 -0
- taskops/transports/http/policy.py +110 -0
- taskops/transports/http/projects.py +116 -0
- taskops/transports/http/reports.py +75 -0
- taskops/transports/http/router.py +90 -0
- taskops/transports/http/server.py +50 -0
- taskops/transports/http/static.py +86 -0
- taskops/transports/http/ui/app.js +59 -0
- taskops/transports/http/ui/index.html +23 -0
- taskops/transports/http/ui/style.css +1 -0
- taskops/transports/http/websocket.py +60 -0
- taskops/transports/mcp/__init__.py +19 -0
- taskops/transports/mcp/__main__.py +7 -0
- taskops/transports/mcp/_descriptions.py +102 -0
- taskops/transports/mcp/_reads.py +82 -0
- taskops/transports/mcp/_writes.py +72 -0
- taskops/transports/mcp/answers.py +44 -0
- taskops/transports/mcp/arguments.py +104 -0
- taskops/transports/mcp/dispatch.py +47 -0
- taskops/transports/mcp/protocol.py +83 -0
- taskops/transports/mcp/schema.py +50 -0
- taskops/transports/mcp/server.py +49 -0
- taskops/transports/mcp/tools.py +66 -0
- taskops/usecases/__init__.py +56 -0
- taskops/usecases/_entry.py +84 -0
- taskops/usecases/_facts.py +49 -0
- taskops/usecases/_freeing.py +93 -0
- taskops/usecases/_gitignore.py +94 -0
- taskops/usecases/_mirroring.py +57 -0
- taskops/usecases/_narrating.py +77 -0
- taskops/usecases/_project.py +67 -0
- taskops/usecases/_range.py +102 -0
- taskops/usecases/_reasons.py +54 -0
- taskops/usecases/_remotefile.py +62 -0
- taskops/usecases/_reportsync.py +108 -0
- taskops/usecases/_routing.py +91 -0
- taskops/usecases/_wireclient.py +141 -0
- taskops/usecases/ask.py +41 -0
- taskops/usecases/claim.py +104 -0
- taskops/usecases/dispatch.py +160 -0
- taskops/usecases/dossier.py +155 -0
- taskops/usecases/edit.py +71 -0
- taskops/usecases/exchange.py +86 -0
- taskops/usecases/feed.py +138 -0
- taskops/usecases/guard.py +122 -0
- taskops/usecases/hooks.py +127 -0
- taskops/usecases/index.py +62 -0
- taskops/usecases/ingest.py +60 -0
- taskops/usecases/log.py +134 -0
- taskops/usecases/narration.py +91 -0
- taskops/usecases/plan.py +128 -0
- taskops/usecases/pushpull.py +119 -0
- taskops/usecases/recover.py +84 -0
- taskops/usecases/remote.py +78 -0
- taskops/usecases/report.py +87 -0
- taskops/usecases/reportfile.py +106 -0
- taskops/usecases/session.py +134 -0
- taskops/usecases/setup.py +92 -0
- taskops/usecases/sync.py +78 -0
- taskops/usecases/update.py +123 -0
- taskops/usecases/view.py +112 -0
- taskops_cli-0.2.0.dist-info/METADATA +291 -0
- taskops_cli-0.2.0.dist-info/RECORD +193 -0
- taskops_cli-0.2.0.dist-info/WHEEL +5 -0
- taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|