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