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
taskops/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""taskops — the coordination substrate Claude Code does not have.
|
|
2
|
+
|
|
3
|
+
Persistent tasks with a dependency DAG, atomic claims that survive a crashed
|
|
4
|
+
agent, every commit bound to the task that motivated it, and a live board a
|
|
5
|
+
human can watch while a hundred agents work.
|
|
6
|
+
|
|
7
|
+
The error types are exported eagerly because a caller has to be able to write
|
|
8
|
+
`except taskops.LeaseHeld` before it has opened anything. Everything else stays
|
|
9
|
+
behind its own module so that importing this package costs a dict lookup — the
|
|
10
|
+
MCP host imports it to list tools long before it asks for any work.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._errors import (
|
|
16
|
+
AlreadyWritten,
|
|
17
|
+
BadRequest,
|
|
18
|
+
GuardFailed,
|
|
19
|
+
IllegalTransition,
|
|
20
|
+
LeaseHeld,
|
|
21
|
+
NoLease,
|
|
22
|
+
NoSuchTask,
|
|
23
|
+
NotInitialized,
|
|
24
|
+
TaskopsError,
|
|
25
|
+
)
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"TaskopsError",
|
|
31
|
+
"NotInitialized",
|
|
32
|
+
"NoSuchTask",
|
|
33
|
+
"IllegalTransition",
|
|
34
|
+
"LeaseHeld",
|
|
35
|
+
"NoLease",
|
|
36
|
+
"GuardFailed",
|
|
37
|
+
"BadRequest",
|
|
38
|
+
"AlreadyWritten",
|
|
39
|
+
]
|
taskops/_clock.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Layer 0 — the one place that asks what time it is.
|
|
2
|
+
|
|
3
|
+
Leases expire, heartbeats renew and events are ordered, so "now" is load-bearing
|
|
4
|
+
here rather than incidental. Routing it through one function is what lets a test
|
|
5
|
+
of "the agent died and its lease lapsed" run in microseconds instead of waiting
|
|
6
|
+
fifteen real minutes for the TTL.
|
|
7
|
+
|
|
8
|
+
`time.time()` and not `monotonic()`: these timestamps are compared ACROSS
|
|
9
|
+
machines and survive a restart, which a monotonic clock cannot do. The cost is
|
|
10
|
+
that a badly skewed clock skews a TTL — bounded, and visible in the studio,
|
|
11
|
+
where a lease from the future is obvious.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
__all__ = ["now", "LEASE_TTL", "HEARTBEAT_GRACE"]
|
|
19
|
+
|
|
20
|
+
LEASE_TTL = 900.0
|
|
21
|
+
"""Seconds a claim survives without a heartbeat. Every taskops call an agent
|
|
22
|
+
makes renews it, so this bounds how long a task stays stuck after a CRASH —
|
|
23
|
+
not how long a legitimately slow task may run."""
|
|
24
|
+
|
|
25
|
+
HEARTBEAT_GRACE = 60.0
|
|
26
|
+
"""Extra seconds the studio waits before calling a session dead. A lease renews
|
|
27
|
+
on tool calls, and an agent that spends two minutes thinking has not gone away."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def now() -> float:
|
|
31
|
+
"""Wall-clock seconds since the epoch."""
|
|
32
|
+
return time.time()
|
taskops/_errors.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Structured errors: a small taxonomy every boundary maps ONCE.
|
|
2
|
+
|
|
3
|
+
Errors are data, not strings — a stable machine `code` plus an `http_status` — so
|
|
4
|
+
each transport translates the TYPE at one catch site instead of matching message
|
|
5
|
+
text. Each subclass also inherits the builtin a caller would plausibly already be
|
|
6
|
+
catching (the `json.JSONDecodeError(ValueError)` trick). Every message names what
|
|
7
|
+
to DO: the reader is an agent mid-turn, and this is all it gets to act on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TaskopsError", "NotInitialized", "NoSuchTask", "IllegalTransition", "LeaseHeld", "NoLease",
|
|
16
|
+
"GuardFailed", "BadRequest", "AlreadyWritten", "AlreadyNarrating", "NarrationFailed",
|
|
17
|
+
"ReportConflict", "Unreachable",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskopsError(Exception):
|
|
22
|
+
"""Root. `except TaskopsError` catches everything the engine raises."""
|
|
23
|
+
|
|
24
|
+
code = "error"
|
|
25
|
+
http_status = 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotInitialized(TaskopsError, FileNotFoundError):
|
|
29
|
+
"""No `.taskops/` at or above the given path."""
|
|
30
|
+
|
|
31
|
+
code = "not_initialized"
|
|
32
|
+
http_status = 404
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def at(cls, path: str | Path) -> "NotInitialized":
|
|
36
|
+
return cls(f"no taskops project at or above {path} — run `taskops init` in the "
|
|
37
|
+
f"repository root")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NoSuchTask(TaskopsError, KeyError):
|
|
41
|
+
"""A task id nobody created — usually a hallucinated or a stale one."""
|
|
42
|
+
|
|
43
|
+
code = "no_such_task"
|
|
44
|
+
http_status = 404
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def named(cls, task: str) -> "NoSuchTask":
|
|
48
|
+
return cls(f"no task {task} — list what exists with `taskops report board`")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IllegalTransition(TaskopsError, ValueError):
|
|
52
|
+
"""A status move the machine does not allow."""
|
|
53
|
+
|
|
54
|
+
code = "illegal_transition"
|
|
55
|
+
http_status = 409
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def between(
|
|
59
|
+
cls, *, task: str, old: str, new: str, allowed: tuple[str, ...]
|
|
60
|
+
) -> "IllegalTransition":
|
|
61
|
+
legal = ", ".join(allowed) if allowed else "nothing — it is terminal"
|
|
62
|
+
return cls(f"{task} is {old} and cannot go to {new}; from {old} it can go to {legal}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LeaseHeld(TaskopsError, RuntimeError):
|
|
66
|
+
"""Somebody else is on it and their lease has not expired."""
|
|
67
|
+
|
|
68
|
+
code = "lease_held"
|
|
69
|
+
http_status = 409
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def by(cls, *, task: str, actor: str, seconds: int) -> "LeaseHeld":
|
|
73
|
+
return cls(f"{task} is claimed by {actor} for another {seconds}s — "
|
|
74
|
+
f"pick another task, or message them on it")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NoLease(TaskopsError, RuntimeError):
|
|
78
|
+
"""Working on a task nobody claimed. Its own type, not a GuardFailed,
|
|
79
|
+
because it has ONE fix the agent can apply unaided — which the message is."""
|
|
80
|
+
|
|
81
|
+
code = "no_lease"
|
|
82
|
+
http_status = 409
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def on(cls, *, task: str, actor: str) -> "NoLease":
|
|
86
|
+
return cls(f"{actor} holds no live lease on {task} — claim it with "
|
|
87
|
+
f"taskops_next, or `taskops claim {task}`")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GuardFailed(TaskopsError, ValueError):
|
|
91
|
+
"""A transition the machine allows but the project's rules refuse. Separate
|
|
92
|
+
from IllegalTransition: that one is "the arrow does not exist", this one is
|
|
93
|
+
"you have not earned it yet" — and only this one is fixable by doing work."""
|
|
94
|
+
|
|
95
|
+
code = "guard_failed"
|
|
96
|
+
http_status = 400
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BadRequest(TaskopsError, ValueError):
|
|
100
|
+
"""An argument that cannot mean anything. Raised at the edges, not inside."""
|
|
101
|
+
|
|
102
|
+
code = "bad_request"
|
|
103
|
+
http_status = 400
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AlreadyWritten(TaskopsError, FileExistsError):
|
|
107
|
+
"""A generated file that exists and would be OVERWRITTEN. 409, never 500.
|
|
108
|
+
|
|
109
|
+
A written report is something somebody may have already read, cited, or narrated by
|
|
110
|
+
hand; silently regenerating it would rewrite history under them. Refusing and naming
|
|
111
|
+
`--force` leaves the choice with the person who knows whether the old one mattered.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
code = "already_written"
|
|
115
|
+
http_status = 409
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AlreadyNarrating(TaskopsError, RuntimeError):
|
|
119
|
+
"""A narration of that report is already running IN THIS PROCESS. 409.
|
|
120
|
+
|
|
121
|
+
Two models writing the same file is not a slow path, it is corruption: both hold the
|
|
122
|
+
dossier they read at the start and each rewrites the whole file when it flushes, so
|
|
123
|
+
whichever finishes last silently erases the other. Refusing the second is the only
|
|
124
|
+
outcome that leaves a readable report — and the first one is still streaming, so the
|
|
125
|
+
person who clicked twice is already looking at what they asked for.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
code = "already_narrating"
|
|
129
|
+
http_status = 409
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class NarrationFailed(TaskopsError, RuntimeError):
|
|
133
|
+
"""The `claude` CLI could not write the narration. 502: an upstream did not answer.
|
|
134
|
+
|
|
135
|
+
Its own type because the fix is never in taskops — the binary is missing, the session is
|
|
136
|
+
not logged in, or the model refused — and the message has to say which. Everything else
|
|
137
|
+
about the report still worked: the dossier is on disk either way, so this never costs the
|
|
138
|
+
facts, only the prose.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
code = "narration_failed"
|
|
142
|
+
http_status = 502
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ReportConflict(TaskopsError, FileExistsError):
|
|
146
|
+
"""A DIFFERENT narration of one report, not a newer one — `ours`/`theirs` are the stamps."""
|
|
147
|
+
|
|
148
|
+
code = "report_conflict"
|
|
149
|
+
http_status = 409
|
|
150
|
+
ours = -1
|
|
151
|
+
theirs = -1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Unreachable(TaskopsError, ConnectionError):
|
|
155
|
+
"""The server did not answer at all — no status, no body. 502, and never a reason to
|
|
156
|
+
write locally instead: that is how two machines end up holding one card. Its own type
|
|
157
|
+
so `usecases._routing` can catch exactly it and say which URL went quiet."""
|
|
158
|
+
|
|
159
|
+
code = "unreachable"
|
|
160
|
+
http_status = 502
|
taskops/_ids.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Layer 0 — how a task and an event get their names.
|
|
2
|
+
|
|
3
|
+
Two different jobs, and the difference is the whole design:
|
|
4
|
+
|
|
5
|
+
**Task ids are RANDOM** (`tk-4f2a9c`). Many machines create tasks without
|
|
6
|
+
talking to each other, so an id must be collision-free without coordination —
|
|
7
|
+
which rules out a counter. Random also means unguessable, so a task id in a
|
|
8
|
+
branch name leaks no ordering information about the project.
|
|
9
|
+
|
|
10
|
+
**Event ids are the CONTENT, hashed.** The event log is replicated by `git pull`
|
|
11
|
+
and by the relay, and the same event can arrive by both paths: with a content
|
|
12
|
+
hash, importing it twice is a primary-key no-op instead of a duplicate comment
|
|
13
|
+
in somebody's inbox. It also makes the log verifiable — an event whose id does
|
|
14
|
+
not match its content was edited after the fact.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import secrets
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
__all__ = ["new_task_id", "event_id", "slugify", "TASK_PREFIX"]
|
|
25
|
+
|
|
26
|
+
TASK_PREFIX = "tk-"
|
|
27
|
+
_TASK_BYTES = 3 # 6 hex chars: 16.7M ids, and the whole id fits a branch name
|
|
28
|
+
_EVENT_CHARS = 16
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def new_task_id() -> str:
|
|
32
|
+
"""A fresh task id. Uniqueness is checked at INSERT, not assumed here."""
|
|
33
|
+
return TASK_PREFIX + secrets.token_hex(_TASK_BYTES)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def event_id(*, task: str, actor: str, kind: str, body: dict[str, Any], ts: float) -> str:
|
|
37
|
+
"""The id of an event, derived from everything the event says.
|
|
38
|
+
|
|
39
|
+
`sort_keys` and a fixed float format matter more than they look: two
|
|
40
|
+
machines must hash the same event to the same id, and Python's dict order
|
|
41
|
+
and `repr(float)` are not a contract across versions.
|
|
42
|
+
"""
|
|
43
|
+
payload = json.dumps(
|
|
44
|
+
{"task": task, "actor": actor, "kind": kind, "body": body, "ts": f"{ts:.6f}"},
|
|
45
|
+
sort_keys=True,
|
|
46
|
+
separators=(",", ":"),
|
|
47
|
+
default=str,
|
|
48
|
+
)
|
|
49
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:_EVENT_CHARS]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def slugify(text: str, *, limit: int = 32) -> str:
|
|
53
|
+
"""A title -> the branch-safe half of `tk/<id>/<slug>`.
|
|
54
|
+
|
|
55
|
+
Deliberately lossy and never parsed back: the id is what identifies the
|
|
56
|
+
task, so this only has to be readable in a `git branch` listing. Everything
|
|
57
|
+
git or a shell would treat as special becomes a dash.
|
|
58
|
+
"""
|
|
59
|
+
kept = [c.lower() if c.isalnum() else "-" for c in text.strip()]
|
|
60
|
+
slug = "".join(kept).strip("-")
|
|
61
|
+
while "--" in slug:
|
|
62
|
+
slug = slug.replace("--", "-")
|
|
63
|
+
return slug[:limit].strip("-") or "task"
|
taskops/_types.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Layer 0 — the vocabulary. Every state and every event kind, named once.
|
|
2
|
+
|
|
3
|
+
These are `Literal`s rather than enums on purpose: the values cross a JSON
|
|
4
|
+
boundary in both directions (the MCP wire, the event log in git, the studio's
|
|
5
|
+
fetch), and a Literal IS the wire value, so there is no encode/decode step where
|
|
6
|
+
a rename can half-land. The MCP `inputSchema` generator turns them into an
|
|
7
|
+
`enum`, which is how an agent learns the allowed values before it guesses one.
|
|
8
|
+
|
|
9
|
+
Imports nothing from the package, so any layer may import it without thinking
|
|
10
|
+
about cycles. `tests/architecture` enforces that.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Literal, get_args
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Status",
|
|
19
|
+
"ActorKind",
|
|
20
|
+
"EventKind",
|
|
21
|
+
"OPEN_STATUSES",
|
|
22
|
+
"CLOSED_STATUSES",
|
|
23
|
+
"WORKING_STATUSES",
|
|
24
|
+
"STATUSES",
|
|
25
|
+
"EVENT_KINDS",
|
|
26
|
+
"LOCAL_ONLY_KINDS",
|
|
27
|
+
"EDITABLE_FIELDS",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
Status = Literal[
|
|
31
|
+
"backlog", "ready", "claimed", "in_progress", "blocked", "review", "done", "cancelled"
|
|
32
|
+
]
|
|
33
|
+
"""Where a task is. See `engine.machine` for which moves between these are legal.
|
|
34
|
+
|
|
35
|
+
`ready` is a stored status and not a derived view, so that "what can I pick up"
|
|
36
|
+
is one indexed read rather than a graph walk per caller. `engine.machine.unblock`
|
|
37
|
+
is the ONE writer that moves a task between `backlog` and `ready`, which is what
|
|
38
|
+
keeps the stored value from drifting from the dependency graph.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
ActorKind = Literal["dev", "agent"]
|
|
42
|
+
"""A human or one of their agents. The distinction is not cosmetic: guards that
|
|
43
|
+
demand a justification accept one from a dev and reject it from an agent."""
|
|
44
|
+
|
|
45
|
+
EventKind = Literal[
|
|
46
|
+
"created",
|
|
47
|
+
"claimed",
|
|
48
|
+
"released",
|
|
49
|
+
"status",
|
|
50
|
+
"comment",
|
|
51
|
+
"commit",
|
|
52
|
+
"branch",
|
|
53
|
+
"blocked",
|
|
54
|
+
"unblocked",
|
|
55
|
+
"handoff",
|
|
56
|
+
"review",
|
|
57
|
+
"eval",
|
|
58
|
+
"done",
|
|
59
|
+
"message", # directed chat: agent↔agent, dev↔agent
|
|
60
|
+
"activity", # a session's heartbeat: a tool ran, a file was touched
|
|
61
|
+
"edited", # a field of the card itself changed: title, spec or priority
|
|
62
|
+
]
|
|
63
|
+
"""What happened. The event log is append-only and every projection — the board,
|
|
64
|
+
a standup, an inbox — is derived from it, so a new fact about a task is a new
|
|
65
|
+
kind here rather than a column somewhere."""
|
|
66
|
+
|
|
67
|
+
STATUSES: tuple[Status, ...] = (
|
|
68
|
+
"backlog",
|
|
69
|
+
"ready",
|
|
70
|
+
"claimed",
|
|
71
|
+
"in_progress",
|
|
72
|
+
"blocked",
|
|
73
|
+
"review",
|
|
74
|
+
"done",
|
|
75
|
+
"cancelled",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
EVENT_KINDS: tuple[EventKind, ...] = get_args(EventKind)
|
|
79
|
+
"""The same values as the `Literal`, DERIVED rather than retyped.
|
|
80
|
+
|
|
81
|
+
Two hand-written lists is how a kind ends up legal at the type level and unknown to the
|
|
82
|
+
MCP schema that iterates the tuple — a mismatch nothing catches, because the type check
|
|
83
|
+
passes and the runtime list is merely short. `STATUSES` keeps its literal spelling: it is
|
|
84
|
+
also the display order, which is a separate decision that happens to agree today."""
|
|
85
|
+
|
|
86
|
+
EDITABLE_FIELDS: tuple[str, ...] = ("title", "spec", "priority")
|
|
87
|
+
"""The columns a person may rewrite after a card exists. Named here rather than in
|
|
88
|
+
`storage` because three layers ask the same question — the CLI validates a flag, the
|
|
89
|
+
use case records one event per field, and replay refuses a body naming anything else."""
|
|
90
|
+
|
|
91
|
+
OPEN_STATUSES: frozenset[str] = frozenset(
|
|
92
|
+
{"backlog", "ready", "claimed", "in_progress", "blocked", "review"}
|
|
93
|
+
)
|
|
94
|
+
"""A dependency in one of these still blocks whatever waits on it."""
|
|
95
|
+
|
|
96
|
+
CLOSED_STATUSES: frozenset[str] = frozenset({"done", "cancelled"})
|
|
97
|
+
"""`cancelled` closes a dependency as surely as `done` does: a task nobody will
|
|
98
|
+
ever do must not hold its dependents hostage forever."""
|
|
99
|
+
|
|
100
|
+
WORKING_STATUSES: frozenset[str] = frozenset({"claimed", "in_progress", "review"})
|
|
101
|
+
"""Statuses that require a live lease. Losing the lease drops the task out."""
|
|
102
|
+
|
|
103
|
+
LOCAL_ONLY_KINDS: frozenset[str] = frozenset({"activity"})
|
|
104
|
+
"""Kinds that never reach the git-committed log. `activity` is a per-keystroke
|
|
105
|
+
heartbeat: replicating it through git would add thousands of lines a day to a
|
|
106
|
+
file whose whole value is that a human can read its diff."""
|
taskops/_version.py
ADDED
taskops/assets/GUIDE.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# taskops — how work happens in this repository
|
|
2
|
+
|
|
3
|
+
You are reading the shared task list's manual. One document for agents and humans, on
|
|
4
|
+
purpose: two documents drift, and then the two audiences are told different things.
|
|
5
|
+
|
|
6
|
+
`taskops init` copies this to `.taskops/GUIDE.md`. If anything here contradicts what the
|
|
7
|
+
tools actually do, the tools are right and this file is a bug.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The one-paragraph version
|
|
12
|
+
|
|
13
|
+
Tasks live in this repository, not in anybody's session. You **claim** one before you code,
|
|
14
|
+
and the claim is a lease nobody else can take. Your commits are bound to the task that
|
|
15
|
+
motivated them — enforced, not suggested. When you finish, whatever was waiting on your task
|
|
16
|
+
becomes available to everyone else automatically. Every developer's agents share the same
|
|
17
|
+
list, and it travels by `git push` and `git pull` with no server involved.
|
|
18
|
+
|
|
19
|
+
## The loop
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
taskops_next ──▶ work ──▶ git commit ──▶ taskops_update status=done
|
|
23
|
+
│ │ │
|
|
24
|
+
a lease, the spec, the guard adds whatever was waiting
|
|
25
|
+
and a collision `Task: tk-…` on you becomes ready
|
|
26
|
+
warning for you for everyone
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Start every session by finding out where you stand.** If you do not already hold a task,
|
|
30
|
+
call `taskops_next`. It returns the spec, the exact branch to create, the conversation so
|
|
31
|
+
far, and a warning listing any other agent editing the same files. If nothing is available
|
|
32
|
+
it tells you *why*, which is usually something you can act on.
|
|
33
|
+
|
|
34
|
+
**Work on the branch it names** — `tk/<task-id>/<slug>`. Not a branch of your own choosing:
|
|
35
|
+
the commit guard matches this exact shape, and an invented name gets your own commits denied.
|
|
36
|
+
|
|
37
|
+
**Commit normally.** The guard adds the `Task:` trailer. You do not write it.
|
|
38
|
+
|
|
39
|
+
**Close with `taskops_update status=done`.** It will be refused if no commit is bound to the
|
|
40
|
+
task. That refusal is the feature — see below.
|
|
41
|
+
|
|
42
|
+
## The two rules the server enforces
|
|
43
|
+
|
|
44
|
+
These are not conventions. The server rejects the call.
|
|
45
|
+
|
|
46
|
+
**1. A commit belongs to a claimed task.** You must be on the task's branch and hold its
|
|
47
|
+
lease. Why: a commit nobody can attribute is a commit nobody can review against the work it
|
|
48
|
+
was supposed to do, and the moment one is allowed the board stops being a complete picture of
|
|
49
|
+
what changed.
|
|
50
|
+
|
|
51
|
+
**2. `done` requires a commit bound to the task.** Otherwise "done" means only that an agent
|
|
52
|
+
said so — which is exactly what a human reading a board instead of the diff is trying to
|
|
53
|
+
avoid. If a task legitimately produced no code (research, a decision, docs elsewhere), pass
|
|
54
|
+
`no_code: true` **with a comment saying what it produced instead**. That is recorded, so a
|
|
55
|
+
review can see which closures had no code and why.
|
|
56
|
+
|
|
57
|
+
## Your claim is a lease, not an assignment
|
|
58
|
+
|
|
59
|
+
It expires. Every taskops call you make renews it, so a task that takes an hour is fine — you
|
|
60
|
+
do not have to do anything special. What the deadline actually bounds is a **crash**: if your
|
|
61
|
+
process dies, the lease lapses within fifteen minutes and the task returns to the queue
|
|
62
|
+
instead of sitting there looking claimed forever.
|
|
63
|
+
|
|
64
|
+
Two consequences worth knowing:
|
|
65
|
+
|
|
66
|
+
- If a write is refused for a missing lease, **claim again**. Do not work around it. Another
|
|
67
|
+
agent may genuinely have taken the task while you were gone.
|
|
68
|
+
- If you are out of context or out of depth, use `status: released` **with a comment** saying
|
|
69
|
+
where you got to. That is the honest move and it is always allowed. Abandoning the task
|
|
70
|
+
silently also works, eventually — but it throws away everything you learned.
|
|
71
|
+
|
|
72
|
+
## Talking to other agents
|
|
73
|
+
|
|
74
|
+
`taskops_update` with `mentions` puts a message in another actor's inbox:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
taskops_update task=tk-4f2a9c
|
|
78
|
+
comment="I'm rewriting the tokenizer in parser.py — hold off until I land this."
|
|
79
|
+
mentions="agent:ana/api-1,dev:ana"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
They see it within one tool call of their own, and it appears live on the board. The message
|
|
83
|
+
lives in the task's thread, so it is still findable in three weeks — which is why there is no
|
|
84
|
+
separate chat tool.
|
|
85
|
+
|
|
86
|
+
**When to use it:** the collision warning in `taskops_next` and `taskops_ask` lists other
|
|
87
|
+
tasks touching your files. That list is the one thing that prevents a merge conflict rather
|
|
88
|
+
than reporting it afterwards. Message them *before* you edit, not after the merge.
|
|
89
|
+
|
|
90
|
+
## Writing a spec that works
|
|
91
|
+
|
|
92
|
+
`taskops_plan` is where most of the damage gets done, because the reader of a `spec` is a
|
|
93
|
+
**fresh agent with none of your context** — possibly on another developer's machine, three
|
|
94
|
+
days from now.
|
|
95
|
+
|
|
96
|
+
A spec that works says:
|
|
97
|
+
|
|
98
|
+
- what **done** looks like, concretely enough to disagree with
|
|
99
|
+
- what must **not** change
|
|
100
|
+
- **where to start** — the files, the sibling to copy, the test that pins the behaviour
|
|
101
|
+
|
|
102
|
+
A one-line spec is the single most common cause of an agent doing the wrong thing correctly.
|
|
103
|
+
|
|
104
|
+
Also name `files`. It is how the scheduler avoids handing two agents the same file, and it is
|
|
105
|
+
what the collision warning is computed from.
|
|
106
|
+
|
|
107
|
+
## Dependencies
|
|
108
|
+
|
|
109
|
+
`after` in `taskops_plan` accepts the 0-based **index** of an earlier task in the same batch,
|
|
110
|
+
so a whole plan lands in one call:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
[
|
|
114
|
+
{"title": "Add the events table", "spec": "…", "files": ["storage/_events.py"]},
|
|
115
|
+
{"title": "Board reads the table", "spec": "…", "after": [0]}
|
|
116
|
+
]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Nothing polls. When a task closes, everything that was only waiting on it becomes ready
|
|
120
|
+
immediately, and `taskops_update` tells you what you just handed to the fleet.
|
|
121
|
+
|
|
122
|
+
If you discover a dependency mid-task, use `blocked_on` — it adds the edge **and** marks you
|
|
123
|
+
blocked. A dependency that lives only in a comment is one the scheduler will walk somebody
|
|
124
|
+
straight into.
|
|
125
|
+
|
|
126
|
+
## If you are an ORCHESTRATOR: dispatching workers
|
|
127
|
+
|
|
128
|
+
`taskops_dispatch` assigns cards, creates a git worktree per card, and hands you a **brief per
|
|
129
|
+
card**. It starts nothing. You then spawn ONE SUB-AGENT PER BRIEF, all in a single message so they
|
|
130
|
+
run in parallel — they use the session you are already in.
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
taskops_plan → the cards
|
|
134
|
+
taskops_dispatch → N briefs ← nothing is running yet
|
|
135
|
+
your Agent tool → N sub-agents ← paste one brief each, in ONE message
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Two rules for the workers, and both are in the brief already:
|
|
139
|
+
|
|
140
|
+
- **Each works inside its own worktree** (`.taskops/trees/<id>/`), which is already checked out on
|
|
141
|
+
the card's branch. A worktree is per CARD, not per agent.
|
|
142
|
+
- **Nobody ever runs `git switch`.** Sub-agents share the repository, so switching would move the
|
|
143
|
+
branch under every other worker at once. This is the whole reason the worktree exists.
|
|
144
|
+
|
|
145
|
+
Pass `spawn: true` only if you want detached processes that outlive your session — each one opens a
|
|
146
|
+
NEW Claude session, which is rarely what you want.
|
|
147
|
+
|
|
148
|
+
A spawned worker inherits your environment MINUS the Anthropic credentials
|
|
149
|
+
(`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`). The `claude` CLI prefers an
|
|
150
|
+
exported key over the logged-in subscription, so without this every worker would quietly bill per
|
|
151
|
+
token against a plan you already pay for. `taskops run --use-api-key` asks for the other mode out
|
|
152
|
+
loud; no MCP tool can.
|
|
153
|
+
|
|
154
|
+
**If you dispatch and then do not spawn**, the cards sit assigned to workers that never existed. Run
|
|
155
|
+
`taskops recover` to hand them back.
|
|
156
|
+
|
|
157
|
+
## When a fleet dies
|
|
158
|
+
|
|
159
|
+
Workers get killed: a session ends, a balance runs out, somebody hits ctrl-C. Their cards stay
|
|
160
|
+
`claimed` until their leases expire, which is fifteen minutes of a board that looks busy and is not.
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
taskops recover # releases every card whose worker has gone quiet
|
|
164
|
+
taskops recover --force # …including the ones still reporting, for a fleet that is alive and wrong
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
It clears the assignment as well as the lease — a card handed back still assigned to a dead worker is
|
|
168
|
+
one NOBODY can pick up, since the scheduler hides it from everyone else.
|
|
169
|
+
|
|
170
|
+
And it writes on each card what survived: commits are safe in git, and **uncommitted work is named
|
|
171
|
+
with its path**, because a killed agent writes before it commits. Read that before starting over.
|
|
172
|
+
|
|
173
|
+
## Reading the board
|
|
174
|
+
|
|
175
|
+
- `taskops_report board` — every column, who holds what
|
|
176
|
+
- `taskops_report standup --since 24h` — what changed, per actor, and what needs a human
|
|
177
|
+
- `taskops_report fleet` — which agents are alive right now, on what, touching what file
|
|
178
|
+
|
|
179
|
+
In `fleet`, `SILENT` means the agent still holds a claim but has gone quiet past the grace
|
|
180
|
+
period. That row is shown rather than hidden, because it is the one somebody needs to act on.
|
|
181
|
+
|
|
182
|
+
## Multi-developer, no server
|
|
183
|
+
|
|
184
|
+
`.taskops/events.jsonl` is an append-only log, and it is **committed**. Two developers'
|
|
185
|
+
agents converge through `git pull`: appending to different ends of a file is the one edit git
|
|
186
|
+
merges without help, and every event's id is its content hashed, so importing the same event
|
|
187
|
+
twice does nothing.
|
|
188
|
+
|
|
189
|
+
`.taskops/db.sqlite` is a **cache** and is gitignored. It can be rebuilt from the log, so
|
|
190
|
+
deleting it loses nothing but live leases.
|
|
191
|
+
|
|
192
|
+
The `post-merge` hook syncs for you. Run `taskops sync` by hand any time; it is idempotent.
|
|
193
|
+
|
|
194
|
+
## Three doors, and which one is yours
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
taskops <cmd> a person, at a terminal. seven commands.
|
|
198
|
+
taskops_* (MCP) you. seven tools — this is your door.
|
|
199
|
+
python -m taskops.transports.hooks git and Claude Code. never type it.
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Yours is the MCP tools. If one of them is refused, the refusal names what to do — read it
|
|
203
|
+
rather than reaching for the CLI, which no longer carries `next`, `update`, `ask`, `plan` or
|
|
204
|
+
`log` at all. What a person types is `taskops tasks …`, and what a hook runs is the third
|
|
205
|
+
line, which exists only because git cannot speak MCP.
|
|
206
|
+
|
|
207
|
+
## If something looks wrong
|
|
208
|
+
|
|
209
|
+
- **"no taskops project"** — run `taskops init` in the repository root.
|
|
210
|
+
- **A commit was denied** — read the message; it names the branch to switch to or the task to
|
|
211
|
+
claim. Do not use `--no-verify` to get around it. The `post-commit` hook will record the
|
|
212
|
+
commit anyway, and you will have a commit whose task nobody agreed on.
|
|
213
|
+
- **`taskops_next` says nothing is ready** — read the reason. "Everything blocked" is worth
|
|
214
|
+
reporting to a human; "everything claimed" means ask again shortly.
|
|
215
|
+
- **Hooks are not firing** — they live in `.git/hooks`, which is not tracked, so a fresh
|
|
216
|
+
clone has none, and a repository set up by an older taskops has a line naming a command that
|
|
217
|
+
has since moved. `taskops init` again repairs both: it chains onto hooks somebody else put
|
|
218
|
+
there, and rewrites the line it wrote itself. Worth knowing why nothing warned you — every
|
|
219
|
+
hook line ends in `|| true`, so a hook pointing at a command that does not exist fails
|
|
220
|
+
completely silently, and commits just stop appearing on their cards.
|