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,45 @@
|
|
|
1
|
+
"""Where a task's code actually IS — on a branch, and whether anybody else can see it.
|
|
2
|
+
|
|
3
|
+
The gap this closes: a task marked `done` whose commits never left the machine is invisible to every
|
|
4
|
+
teammate, and the board said "done" anyway. Commits proved that work happened; this proves it is
|
|
5
|
+
REACHABLE. Those are different claims and only one of them was being made.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
__all__ = ["BranchState"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BranchState(TypedDict):
|
|
16
|
+
"""One branch's relationship to its remote."""
|
|
17
|
+
|
|
18
|
+
branch: str
|
|
19
|
+
upstream: str
|
|
20
|
+
"""`origin/tk/tk-4f2a9c/…`, or "" when the branch has never been pushed at all.
|
|
21
|
+
|
|
22
|
+
Empty is the common case for a task branch and the one that matters most: it means the work
|
|
23
|
+
exists on exactly one laptop.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
ahead: int
|
|
27
|
+
"""Commits here the remote does not have. Nonzero = unpushed work."""
|
|
28
|
+
|
|
29
|
+
behind: int
|
|
30
|
+
"""Commits the remote has and this branch does not — somebody else moved it."""
|
|
31
|
+
|
|
32
|
+
pushed: bool
|
|
33
|
+
"""`ahead == 0` AND there is an upstream. The one boolean a card needs.
|
|
34
|
+
|
|
35
|
+
Derived rather than left to each reader, because "pushed" from two integers and a string is a
|
|
36
|
+
calculation three renderers would each get subtly wrong.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
exists: bool
|
|
40
|
+
"""False when the branch is not in this repository at all.
|
|
41
|
+
|
|
42
|
+
Not the same as unpushed: an agent on another machine holds a lease naming a branch this clone
|
|
43
|
+
has never heard of, and a board that reported that as "0 commits, unpushed" would be describing
|
|
44
|
+
a branch it cannot see as though it had looked.
|
|
45
|
+
"""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The INDEX of what has been written up — one row per file in `.taskops/reports/`.
|
|
2
|
+
|
|
3
|
+
Deliberately not `ReportFile`: that carries the whole markdown, and a listing of thirty days
|
|
4
|
+
that shipped thirty dossiers would be a megabyte of text nobody on the screen is reading. This
|
|
5
|
+
is the row you click, and the body arrives on `GET /api/report` when you do.
|
|
6
|
+
|
|
7
|
+
`label` is the file's STEM and is treated as an opaque string everywhere. Today it is a date;
|
|
8
|
+
a report over a range is named for the range (`2026-07-22..2026-07-28`, `all`), and anything
|
|
9
|
+
that parsed the label as a day would break on the first one of those.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TypedDict
|
|
15
|
+
|
|
16
|
+
__all__ = ["ReportEntry"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReportEntry(TypedDict):
|
|
20
|
+
label: str
|
|
21
|
+
"""The file's stem — a date, or a range. Opaque: what you pass back to read or narrate it."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
exists: bool
|
|
25
|
+
"""False for the one row that is not a file: today, when nobody has written it up yet. It
|
|
26
|
+
is listed anyway, because 'generate today's report' is the reason this screen exists."""
|
|
27
|
+
|
|
28
|
+
stale: bool
|
|
29
|
+
missing_events: int
|
|
30
|
+
"""How many of the day's events landed after the report was generated. Always 0 for a label
|
|
31
|
+
that is not a single day — a range has no window this can be counted against."""
|
|
32
|
+
|
|
33
|
+
has_narration: bool
|
|
34
|
+
"""Whether the prose section has been written. The dossier's facts are free to regenerate;
|
|
35
|
+
the narration is the part a model was paid for and a human may have edited."""
|
|
36
|
+
|
|
37
|
+
bytes: int
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The lease — a claim that expires, which is what makes a crash survivable.
|
|
2
|
+
|
|
3
|
+
A boolean `assigned_to` column cannot express "this agent said it was working on
|
|
4
|
+
it and then its process was killed", so a board built on one accumulates tasks
|
|
5
|
+
nobody is doing and nobody can take. A lease has a deadline: every taskops call
|
|
6
|
+
the holder makes pushes it out, and silence hands the task back.
|
|
7
|
+
|
|
8
|
+
Not a filesystem lock, for the same reason: a lock file outlives the process that
|
|
9
|
+
took it, and cleaning one up correctly is exactly the problem a TTL already
|
|
10
|
+
solved.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TypedDict
|
|
16
|
+
|
|
17
|
+
__all__ = ["Lease"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Lease(TypedDict):
|
|
21
|
+
"""Who is on a task, until when, and where they are doing it."""
|
|
22
|
+
|
|
23
|
+
task: str
|
|
24
|
+
actor: str
|
|
25
|
+
|
|
26
|
+
session: str
|
|
27
|
+
"""The Claude Code session id, when the claim came from an agent.
|
|
28
|
+
|
|
29
|
+
The join key to everything outside taskops: the transcript at
|
|
30
|
+
`~/.claude/projects/<slug>/<session>.jsonl`, and the hook invocations that
|
|
31
|
+
report activity. Without it, a live board can show that a task is claimed but
|
|
32
|
+
not what its agent is doing right now.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
branch: str
|
|
36
|
+
"""`tk/<id>/<slug>`, once the agent creates it. Empty until then — a claim
|
|
37
|
+
happens before the branch, and the guard that binds commits to tasks needs to
|
|
38
|
+
tell "no branch yet" apart from "on the wrong branch"."""
|
|
39
|
+
|
|
40
|
+
acquired: float
|
|
41
|
+
expires: float
|
|
42
|
+
"""When silence gives the task back. Compared against `_clock.now()`, so it
|
|
43
|
+
is wall-clock and survives a restart on either side."""
|
taskops/contracts/log.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""An agent's conversation, as the card shows it.
|
|
2
|
+
|
|
3
|
+
Claude Code writes every session to a JSONL transcript. taskops does NOT copy those into its own event
|
|
4
|
+
log, and that is deliberate: one session here was 198 KB for 39 entries, `events.jsonl` is committed to
|
|
5
|
+
git, and the whole value of that file is that a human can read its diff. So the card stores a POINTER
|
|
6
|
+
and the transcript is read on demand.
|
|
7
|
+
|
|
8
|
+
The honest consequence, worth stating where the type is defined: a transcript is LOCAL. A teammate who
|
|
9
|
+
pulls the log gets the card, the commits and the conversation summary, but not the transcript — that
|
|
10
|
+
file only exists on the machine the agent ran on.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Literal, TypedDict
|
|
16
|
+
|
|
17
|
+
__all__ = ["LogEntry", "SessionLog", "EntryKind"]
|
|
18
|
+
|
|
19
|
+
EntryKind = Literal["prompt", "thinking", "text", "tool", "result", "other"]
|
|
20
|
+
"""What one entry IS, flattened from the transcript's shape.
|
|
21
|
+
|
|
22
|
+
The raw format nests differently per role — an assistant message holds a list of content blocks, each
|
|
23
|
+
with its own type — and a reader wants one flat stream. `other` is the escape hatch: the format is not
|
|
24
|
+
documented as stable, so an entry this version does not recognise is shown rather than dropped.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LogEntry(TypedDict):
|
|
29
|
+
"""One turn, one thought, or one tool call."""
|
|
30
|
+
|
|
31
|
+
kind: EntryKind
|
|
32
|
+
text: str
|
|
33
|
+
"""The readable content. For a tool call, a one-line summary rather than the arguments — a
|
|
34
|
+
dashboard showing forty lines of JSON per Edit is a dashboard nobody scrolls."""
|
|
35
|
+
|
|
36
|
+
tool: str
|
|
37
|
+
"""The tool name for `kind == "tool"`, else "". Kept separate from `text` so a viewer can badge
|
|
38
|
+
it without parsing prose back apart."""
|
|
39
|
+
|
|
40
|
+
ts: float
|
|
41
|
+
session: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionLog(TypedDict):
|
|
45
|
+
"""Everything one card's agents said, in order."""
|
|
46
|
+
|
|
47
|
+
task: str
|
|
48
|
+
sessions: list[str]
|
|
49
|
+
"""The session ids found for this card. More than one is normal: a card can be released and
|
|
50
|
+
picked up again, and each attempt is its own session."""
|
|
51
|
+
|
|
52
|
+
entries: list[LogEntry]
|
|
53
|
+
source: str
|
|
54
|
+
"""Where the transcripts were read from, so a viewer showing nothing can say WHY — a missing
|
|
55
|
+
directory and an empty conversation look identical otherwise."""
|
|
56
|
+
|
|
57
|
+
truncated: bool
|
|
58
|
+
"""True when entries were dropped to stay under a limit. Reported rather than silent: a viewer
|
|
59
|
+
that quietly showed the first hundred turns of a long session would be lying about the ending."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The ONE server this project syncs against, and this machine's place in its log.
|
|
2
|
+
|
|
3
|
+
One remote per project, deliberately. Two would be federation — the same event arriving
|
|
4
|
+
under two cursors, a report whose "who saw more" question has three answers — and none of
|
|
5
|
+
that is designed. `remote add` refuses the second one by name rather than silently keeping
|
|
6
|
+
a list nobody reads.
|
|
7
|
+
|
|
8
|
+
This is the only contract that carries a SECRET. It is written to `.taskops/remote.json`
|
|
9
|
+
with mode 0600, that path is inside the block `taskops init` gitignores, and no renderer
|
|
10
|
+
ever prints `token` — a token in a commit is the exact disaster the file mode exists to
|
|
11
|
+
prevent, and it survives every rotation of the branch it landed on.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TypedDict
|
|
17
|
+
|
|
18
|
+
__all__ = ["Remote"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Remote(TypedDict):
|
|
22
|
+
url: str
|
|
23
|
+
"""The server's base, with no trailing slash — `https://taskops.example.com`."""
|
|
24
|
+
|
|
25
|
+
token: str
|
|
26
|
+
"""The bearer sent on every request. Never printed, never rendered, never committed."""
|
|
27
|
+
|
|
28
|
+
pushed: int
|
|
29
|
+
"""The last LOCAL `seq` this machine has sent UP. Its own number, never the server's.
|
|
30
|
+
|
|
31
|
+
The first version drained the `exported` flag instead — shared with the git-log export —
|
|
32
|
+
and on any project that had ever run `taskops sync`, everything was already marked and
|
|
33
|
+
`push` sent nothing, silently, forever. Found live on the first real project connected:
|
|
34
|
+
a 370-event board pushed as `0 event(s) out`. Two sinks need two cursors.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
cursor: int
|
|
38
|
+
"""The last `seq` this machine imported FROM THAT SERVER.
|
|
39
|
+
|
|
40
|
+
A SERVER-side number, and it is only ever compared against server-side numbers: every
|
|
41
|
+
sqlite numbers its own rows, so measuring this against a local `seq` would be a
|
|
42
|
+
coincidence pretending to be an ordering.
|
|
43
|
+
|
|
44
|
+
If the server forgets it — a store recreated from scratch — the next `GET /api/sync`
|
|
45
|
+
answers from 0 and the client re-imports everything. That is a no-op, not a repair
|
|
46
|
+
job: ids are content hashes, so `relay` accepts each event exactly once no matter how
|
|
47
|
+
many times it is offered.
|
|
48
|
+
"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""What the use cases hand back — the shapes the renderers turn into text.
|
|
2
|
+
|
|
3
|
+
Separate from the entities because they answer a QUESTION rather than describing
|
|
4
|
+
a thing: a `Claim` is not stored anywhere, it is the assembled answer to "what
|
|
5
|
+
should I work on", including the parts the agent would otherwise have to ask three
|
|
6
|
+
more times for.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TypedDict
|
|
12
|
+
|
|
13
|
+
from .dep import Dep
|
|
14
|
+
from .event import Inbox
|
|
15
|
+
from .lease import Lease
|
|
16
|
+
from .task import Task, TaskView
|
|
17
|
+
|
|
18
|
+
__all__ = ["PlanResult", "Claim", "NextResult", "UpdateResult", "EditResult"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PlanResult(TypedDict):
|
|
22
|
+
"""What a decomposition created. Returned in creation order, which is the
|
|
23
|
+
order the caller listed them — so an agent can map its own plan onto ids."""
|
|
24
|
+
|
|
25
|
+
created: list[Task]
|
|
26
|
+
deps: list[Dep]
|
|
27
|
+
unblocked: list[str]
|
|
28
|
+
"""Ids that came out of `plan` already pickable. Usually the leaves; if this
|
|
29
|
+
is empty the caller built a graph where nothing can start, which is a planning
|
|
30
|
+
bug worth seeing immediately rather than discovering at the first `next`."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Claim(TypedDict):
|
|
34
|
+
"""A task, claimed, with everything needed to start on it."""
|
|
35
|
+
|
|
36
|
+
view: TaskView
|
|
37
|
+
lease: Lease
|
|
38
|
+
branch: str
|
|
39
|
+
"""The branch to create: `tk/<id>/<slug>`. Handed over rather than left to the
|
|
40
|
+
agent to compose, because the commit guard matches on this exact shape."""
|
|
41
|
+
|
|
42
|
+
inbox: Inbox
|
|
43
|
+
"""Delivered with the claim. An agent that just picked up work is the most
|
|
44
|
+
likely to have been messaged about it, and this is a read it was making
|
|
45
|
+
anyway — so the inbox rides along instead of costing another call."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NextResult(TypedDict):
|
|
49
|
+
"""The answer to "what should I do", including "nothing, and here is why"."""
|
|
50
|
+
|
|
51
|
+
claim: Claim | None
|
|
52
|
+
reason: str
|
|
53
|
+
"""Empty when a claim was made. Otherwise the actual obstacle — everything is
|
|
54
|
+
blocked, everything is claimed by someone else, or the project is finished.
|
|
55
|
+
An agent told "nothing ready" with no reason asks a human; told which, it can
|
|
56
|
+
often unblock itself."""
|
|
57
|
+
|
|
58
|
+
ready: int
|
|
59
|
+
working: int
|
|
60
|
+
blocked: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UpdateResult(TypedDict):
|
|
64
|
+
"""The effect of one update: the new state, and what it set free."""
|
|
65
|
+
|
|
66
|
+
task: Task
|
|
67
|
+
unblocked: list[Task]
|
|
68
|
+
"""Tasks that became pickable because this one closed. The auto-unblock, made
|
|
69
|
+
visible: an agent finishing a task learns what it just handed to the fleet."""
|
|
70
|
+
|
|
71
|
+
notified: list[str]
|
|
72
|
+
"""Actors whose inbox this reached — the mentions, resolved."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EditResult(TypedDict):
|
|
76
|
+
"""The card after a rewrite, and which fields actually moved.
|
|
77
|
+
|
|
78
|
+
`changed` rather than a bare task: an edit that asked for a new title and got none —
|
|
79
|
+
because the value was already there — is worth saying out loud, since the alternative
|
|
80
|
+
is a caller believing it landed something the log has no event for.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
task: Task
|
|
84
|
+
changed: list[str]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The task, and the whole of what an agent needs to read to work on one.
|
|
2
|
+
|
|
3
|
+
`Task` is the row. `TaskView` is the answer to "what am I doing and what does it
|
|
4
|
+
touch" — assembled once so an agent does not have to make five calls to find out
|
|
5
|
+
that something else is already editing the file it was about to rewrite.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
from .._types import Status
|
|
13
|
+
from .commit import CommitRef
|
|
14
|
+
from .event import Event
|
|
15
|
+
from .lease import Lease
|
|
16
|
+
|
|
17
|
+
__all__ = ["Task", "TaskView"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Task(TypedDict):
|
|
21
|
+
"""One unit of work. Flat on purpose: this IS the row and the wire format."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
title: str
|
|
25
|
+
|
|
26
|
+
spec: str
|
|
27
|
+
"""The brief, complete enough that an agent reads it and needs nothing else.
|
|
28
|
+
|
|
29
|
+
The single most important field in the system and the one most often written
|
|
30
|
+
badly. A title is a label; a spec says what done looks like, what must not
|
|
31
|
+
change, and where to look — because the reader is a fresh context that was
|
|
32
|
+
not in the room when the work was decided.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
status: Status
|
|
36
|
+
priority: int
|
|
37
|
+
"""0 urgent … 3 someday. Lower sorts first, which is why it is not a Literal:
|
|
38
|
+
a project that wants five bands should not need an engine change."""
|
|
39
|
+
|
|
40
|
+
parent: str | None
|
|
41
|
+
"""An epic's id. The TREE is here; the DAG is `deps` — they answer different
|
|
42
|
+
questions ("what is this part of" vs "what must happen first")."""
|
|
43
|
+
|
|
44
|
+
labels: list[str]
|
|
45
|
+
files: list[str]
|
|
46
|
+
"""The edit surface, as the planner understands it. A hint, never a lock: the
|
|
47
|
+
scheduler uses it to avoid handing two agents the same file, and being wrong
|
|
48
|
+
costs a merge conflict rather than a wrong answer."""
|
|
49
|
+
|
|
50
|
+
created_by: str
|
|
51
|
+
|
|
52
|
+
assignee: str
|
|
53
|
+
"""Who this card is FOR, or "" for the open pool.
|
|
54
|
+
|
|
55
|
+
Set by a planner that knows who should do the work, and by `dispatch` before it launches a
|
|
56
|
+
worker. It is not a claim: an assigned card still has to be claimed, so the lease stays the only
|
|
57
|
+
thing that says somebody is actually on it. What assignment changes is the SCHEDULER — an
|
|
58
|
+
assigned card is invisible to every other agent, which is what makes "this one is yours" mean
|
|
59
|
+
something instead of being a label anybody can ignore.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
created: float
|
|
63
|
+
updated: float
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TaskView(TypedDict):
|
|
67
|
+
"""Everything about one task, in one read."""
|
|
68
|
+
|
|
69
|
+
task: Task
|
|
70
|
+
lease: Lease | None
|
|
71
|
+
blocked_by: list[Task]
|
|
72
|
+
"""Open dependencies. Empty is the whole reason a task is pickable."""
|
|
73
|
+
|
|
74
|
+
blocks: list[Task]
|
|
75
|
+
"""What is waiting on this one — the argument for finishing it today."""
|
|
76
|
+
|
|
77
|
+
children: list[Task]
|
|
78
|
+
neighbours: list[Task]
|
|
79
|
+
"""Tasks whose `files` intersect this one's. The collision warning: an agent
|
|
80
|
+
that reads this knows who to message before it starts, not after the merge."""
|
|
81
|
+
|
|
82
|
+
thread: list[Event]
|
|
83
|
+
"""Comments and directed messages, oldest first. The conversation about this
|
|
84
|
+
task, from every actor that has ever touched it."""
|
|
85
|
+
|
|
86
|
+
commits: list[CommitRef]
|
|
87
|
+
"""The commits bound to this task, oldest first — with their subjects and the files they touched.
|
|
88
|
+
|
|
89
|
+
`list[str]` at first, which threw away the subject and the file list the event already carried: a
|
|
90
|
+
finished card rendered as a column of hashes, and the only substance on the page was whatever the
|
|
91
|
+
agent happened to put in a comment.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
history: list[Event]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""What a tool call carries IN — the inputs, as types.
|
|
2
|
+
|
|
3
|
+
The MCP `inputSchema` is generated from these (`transports/mcp/schema.py`), so a parameter is
|
|
4
|
+
declared once and cannot exist on the wire without existing in the dispatch, or the reverse — which
|
|
5
|
+
is how a tool ends up advertising a flag nobody reads.
|
|
6
|
+
|
|
7
|
+
The description TEXT lives in `_fields`. It is the contract, not decoration — the only thing a
|
|
8
|
+
calling agent reads before choosing arguments — but it is prose, and keeping it out of here means a
|
|
9
|
+
change to a tool's SHAPE is reviewable on its own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Annotated, Any, Literal, TypedDict
|
|
15
|
+
|
|
16
|
+
from . import _fields as f
|
|
17
|
+
|
|
18
|
+
__all__ = ["PlanParams", "NextParams", "UpdateParams", "AskParams", "ReportParams",
|
|
19
|
+
"DispatchParams", "RecoverParams"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Target(TypedDict):
|
|
23
|
+
"""Which repository. Every tool needs it; none of them guesses it."""
|
|
24
|
+
|
|
25
|
+
repo_path: f.Repo
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _PlanRequired(_Target):
|
|
29
|
+
tasks: Annotated[list[dict[str, Any]], f.TASKS]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PlanParams(_PlanRequired, total=False):
|
|
33
|
+
actor: f.Actor
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NextParams(_Target, total=False):
|
|
37
|
+
actor: f.Actor
|
|
38
|
+
session: f.Session
|
|
39
|
+
labels: Annotated[str, f.LABELS]
|
|
40
|
+
task: Annotated[str, f.CLAIM_ONE]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _UpdateRequired(_Target):
|
|
44
|
+
task: f.TaskId
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UpdateParams(_UpdateRequired, total=False):
|
|
48
|
+
status: Annotated[Literal["in_progress", "blocked", "review", "done", "released",
|
|
49
|
+
"cancelled"], f.STATUS]
|
|
50
|
+
comment: Annotated[str, f.COMMENT]
|
|
51
|
+
mentions: Annotated[str, f.MENTIONS]
|
|
52
|
+
blocked_on: Annotated[str, f.BLOCKED_ON]
|
|
53
|
+
no_code: Annotated[bool, f.NO_CODE]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AskParams(_Target, total=False):
|
|
57
|
+
task: Annotated[str, f.ASK_TASK]
|
|
58
|
+
query: Annotated[str, f.ASK_QUERY]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DispatchParams(_Target, total=False):
|
|
62
|
+
"""Prepare worker agents for cards. The caller spawns them as its own sub-agents.
|
|
63
|
+
|
|
64
|
+
There is deliberately NO `spawn` field here, and its absence is the design rather than an
|
|
65
|
+
omission. The use case can start detached processes and `taskops dispatch --spawn` does, because
|
|
66
|
+
that is a human at a terminal asking for it — but a model calling a tool must not be able to make
|
|
67
|
+
this package launch another Claude Code. An agent inside a session already HAS a way to run work
|
|
68
|
+
in parallel: its own sub-agent tool, on the subscription that is already paid for. Spawning from
|
|
69
|
+
here opens a new billed session per worker, which is how a real fleet drained an API balance
|
|
70
|
+
mid-run and left six cards claimed by processes that no longer existed.
|
|
71
|
+
|
|
72
|
+
A capability the engine has and a transport withholds is a normal thing for this codebase — the
|
|
73
|
+
surfaces are thin, not identical.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
tasks: Annotated[str, f.DISPATCH_TASKS]
|
|
77
|
+
count: Annotated[int, f.DISPATCH_COUNT]
|
|
78
|
+
prefix: Annotated[str, f.PREFIX]
|
|
79
|
+
model: Annotated[str, f.MODEL]
|
|
80
|
+
dry_run: Annotated[bool, f.DRY_RUN]
|
|
81
|
+
actor: f.Actor
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RecoverParams(_Target, total=False):
|
|
85
|
+
"""Hand back the cards of workers that died. The other half of dispatching a fleet."""
|
|
86
|
+
|
|
87
|
+
force: Annotated[bool, f.RECOVER_FORCE]
|
|
88
|
+
grace: Annotated[int, f.RECOVER_GRACE]
|
|
89
|
+
actor: f.Actor
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ReportParams(_Target, total=False):
|
|
93
|
+
"""The four views a model may ask for, and no more.
|
|
94
|
+
|
|
95
|
+
`fleet` and `burndown` were advertised here and are not any more. One answered a question
|
|
96
|
+
agents stopped having — "who is free" means nothing when a worker is created on demand,
|
|
97
|
+
and the studio dropped the panel for the same reason — while the other was never
|
|
98
|
+
implemented and replied with a sentence saying so. A tool that lists a kind an agent
|
|
99
|
+
cannot use spends the description budget teaching it to pick wrong. `fleet` survives as a
|
|
100
|
+
use case: the HTTP api still serves it, because a human looking at a board does want to
|
|
101
|
+
know which claim has gone quiet.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
kind: Annotated[Literal["board", "standup", "day", "range"], f.REPORT_KIND]
|
|
105
|
+
actor: Annotated[str, f.REPORT_ACTOR]
|
|
106
|
+
since: Annotated[str, f.SINCE]
|
|
107
|
+
date: Annotated[str, f.DATE]
|
|
108
|
+
last: Annotated[str, f.LAST]
|
|
109
|
+
from_date: Annotated[str, f.FROM_DATE]
|
|
110
|
+
to: Annotated[str, f.TO_DATE]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""A message that travels on the SOCKET and is never stored.
|
|
2
|
+
|
|
3
|
+
The counterpart of `Event`, defined by what it is NOT: an event is the durable
|
|
4
|
+
record — hashed, written to sqlite, exported to `events.jsonl` and committed —
|
|
5
|
+
and a wire message is a frame the studio pushes to whoever happens to be looking.
|
|
6
|
+
Nothing persists it, nothing replays it, and a browser that reconnects has simply
|
|
7
|
+
missed it.
|
|
8
|
+
|
|
9
|
+
That distinction is load-bearing, not stylistic. The first thing this carries is
|
|
10
|
+
the narration of a report, arriving a few characters at a time; a thousand prose
|
|
11
|
+
fragments in `events.jsonl` would destroy the one property that file has, which is
|
|
12
|
+
that a human can read its diff. The FILE on disk is the durable copy of a
|
|
13
|
+
narration — this is only the window onto it being written.
|
|
14
|
+
|
|
15
|
+
`kind` is open for the same reason `Event.kind` is: a reader that does not know a
|
|
16
|
+
kind ignores the frame, and a newer taskops may send kinds this one never heard of.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import TypedDict
|
|
22
|
+
|
|
23
|
+
__all__ = ["WireMessage"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WireMessage(TypedDict):
|
|
27
|
+
"""One ephemeral frame: what happened, to which report, and the text of it."""
|
|
28
|
+
|
|
29
|
+
kind: str
|
|
30
|
+
"""Namespaced by subject — `narration.delta`, `narration.pass`, `narration.done`,
|
|
31
|
+
`narration.failed`. A consumer switches on it and drops what it does not know."""
|
|
32
|
+
|
|
33
|
+
label: str
|
|
34
|
+
"""Which report this is about (`2026-07-28`, `2026-07-22..2026-07-28`, `all`).
|
|
35
|
+
|
|
36
|
+
Never absent: two narrations can be in flight at once, and a delta that cannot
|
|
37
|
+
say which document it belongs to would be appended to whichever panel is open.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
text: str
|
|
41
|
+
"""The payload, shaped by `kind` — a fragment of prose for a delta, `2/4` for a
|
|
42
|
+
pass, the failure verbatim for a failure, empty for a completion."""
|
|
43
|
+
|
|
44
|
+
root: str
|
|
45
|
+
"""WHICH project emitted it: the absolute path of the store, as `str(store.root)`.
|
|
46
|
+
|
|
47
|
+
A path and not a "project name" on purpose. The emitter is a narration running deep
|
|
48
|
+
inside a use case; it does not know — and must not have to know — under which URL
|
|
49
|
+
prefix some server happened to mount it, or whether it is mounted at all. The root is
|
|
50
|
+
the one identifier both ends already share, and the consumer (`usecases.feed.follow`)
|
|
51
|
+
resolves its own from the very same function.
|
|
52
|
+
|
|
53
|
+
Never absent. The channel is process-global, so a server holding many boards open
|
|
54
|
+
broadcasts every frame to all of them; a message that cannot say where it came from
|
|
55
|
+
would be prose from one project appearing on another project's screen. That is why a
|
|
56
|
+
message WITHOUT this field is dropped rather than delivered — see `follow`.
|
|
57
|
+
|
|
58
|
+
It never reaches the browser: `transports.http.live` strips it before framing, because
|
|
59
|
+
it is a path on the server's filesystem and no client has any business reading it.
|
|
60
|
+
"""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Layer 3 — the decisions. Sync, and it never talks to a caller.
|
|
2
|
+
|
|
3
|
+
Split from `usecases` by WHO it is for: these functions decide things (may this move
|
|
4
|
+
happen, which task is next, is that agent alive), and a use case orchestrates them
|
|
5
|
+
into an answer a transport can render. That is why nothing here formats text and
|
|
6
|
+
nothing here reads an argument a model wrote.
|
|
7
|
+
|
|
8
|
+
`machine` is the only module allowed to decide a status move — `tests/architecture`
|
|
9
|
+
enforces it — because a transition table plus one convenient status check elsewhere
|
|
10
|
+
is two state machines, and the convenient one always forgets the guard.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from . import replay
|
|
16
|
+
from .activity import fleet, standup
|
|
17
|
+
from .bus import BUS, EventBus
|
|
18
|
+
from .day import date_of, day_report, first_date, label_of, period_report, shift
|
|
19
|
+
from .gitstate import branch_state, branch_states
|
|
20
|
+
from .history import activity
|
|
21
|
+
from .identity import parse, resolve
|
|
22
|
+
from .log import build, record, relay
|
|
23
|
+
from .machine import Facts, allowed_from, check_move
|
|
24
|
+
from .narrate import OnPass, OnText, narrate
|
|
25
|
+
from .project import board, counts
|
|
26
|
+
from .reports import NO_STAMP, missing_events, stamp, stamped_seq
|
|
27
|
+
from .scheduler import branch_for, claim, open_children, ready_tasks, sweep_dead, unblock
|
|
28
|
+
from .wire import WIRE, Broadcast, is_wire
|
|
29
|
+
from .worker import Launched, launch
|
|
30
|
+
|
|
31
|
+
__all__ = ["Facts", "check_move", "allowed_from", "resolve", "parse", "record",
|
|
32
|
+
"build", "relay", "BUS", "EventBus", "unblock", "ready_tasks", "claim",
|
|
33
|
+
"branch_for", "sweep_dead", "open_children", "board", "standup", "fleet", "activity", "day_report", "period_report", "first_date", "label_of", "shift", "date_of",
|
|
34
|
+
"counts", "replay", "narrate", "OnPass", "OnText", "stamp", "stamped_seq", "NO_STAMP", "missing_events", "branch_state", "branch_states", "launch", "Launched", "WIRE", "Broadcast", "is_wire"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Reading the CONTENT of a transcript entry: a tool call's arguments, a tool result's payload.
|
|
2
|
+
|
|
3
|
+
Split from `_entries` by depth rather than by topic. That module walks the entry's shape and decides
|
|
4
|
+
what each line IS; this one reaches inside a block and decides what to SHOW of it — which is where the
|
|
5
|
+
judgement about brevity lives, and the part that gets tuned after looking at a real dashboard.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
__all__ = ["summarise", "result_text"]
|
|
13
|
+
|
|
14
|
+
_IDENTIFYING = ("file_path", "command", "path", "pattern", "query", "task", "prompt")
|
|
15
|
+
"""Argument names that say WHICH thing a tool acted on, in the order they are worth showing.
|
|
16
|
+
|
|
17
|
+
`file_path` first because Read/Write/Edit are most of any transcript, then `command` for Bash. A call
|
|
18
|
+
whose arguments contain none of these falls back to naming its keys, which at least says what shape it
|
|
19
|
+
had.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def summarise(tool: str, arguments: object) -> str:
|
|
24
|
+
"""A tool call in ONE line: the argument that identifies it, never the whole payload.
|
|
25
|
+
|
|
26
|
+
A dashboard showing the full `input` of every Edit is a dashboard nobody scrolls — the diff is
|
|
27
|
+
already in git, and what a reader wants here is which file was touched.
|
|
28
|
+
"""
|
|
29
|
+
if not isinstance(arguments, dict):
|
|
30
|
+
return tool
|
|
31
|
+
args = cast("dict[str, Any]", arguments)
|
|
32
|
+
for key in ("file_path", "command", "path", "pattern", "query", "task", "prompt"):
|
|
33
|
+
found = args.get(key)
|
|
34
|
+
if isinstance(found, str) and found.strip():
|
|
35
|
+
return f"{found.strip()[:200]}"
|
|
36
|
+
return ", ".join(sorted(args)[:4])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def result_text(content: object) -> str:
|
|
40
|
+
"""A tool result, which arrives as a string or as a list of blocks."""
|
|
41
|
+
if isinstance(content, str):
|
|
42
|
+
return content
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
blocks = cast("list[object]", content)
|
|
45
|
+
parts = [str(cast("dict[str, Any]", block).get("text", ""))
|
|
46
|
+
for block in blocks if isinstance(block, dict)]
|
|
47
|
+
return "\n".join(part for part in parts if part)
|
|
48
|
+
return ""
|