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,57 @@
|
|
|
1
|
+
"""The time-window reports: a standup, and who is live.
|
|
2
|
+
|
|
3
|
+
Both answer "what is going on" rather than "what should I do", and both are generated —
|
|
4
|
+
which is the claim worth protecting. A standup nobody typed cannot be out of date, and it
|
|
5
|
+
cannot flatter anybody either.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..contracts import Fleet, Standup
|
|
11
|
+
from ._text import ago, bullet, table, truncate
|
|
12
|
+
|
|
13
|
+
__all__ = ["render_standup", "render_fleet"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_standup(standup: Standup) -> str:
|
|
17
|
+
"""What changed in the window, per actor, then what needs a human."""
|
|
18
|
+
if not standup["events"]:
|
|
19
|
+
return "# standup\n\nNothing happened in this window."
|
|
20
|
+
parts = [f"# standup — {len(standup['events'])} event(s), "
|
|
21
|
+
f"{len(standup['actors'])} actor(s)", ""]
|
|
22
|
+
for actor in standup["actors"]:
|
|
23
|
+
mine = [e for e in standup["events"] if e["actor"] == actor]
|
|
24
|
+
kinds = sorted({e["kind"] for e in mine})
|
|
25
|
+
parts.append(f"**{actor}** — {len(mine)} event(s): {', '.join(kinds)}")
|
|
26
|
+
parts += ["", f"Done: {len(standup['done'])} · in flight: "
|
|
27
|
+
f"{len(standup['in_flight'])} · blocked: {len(standup['blocked'])}"]
|
|
28
|
+
return "\n".join(parts + _needs_a_human(standup))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _needs_a_human(standup: Standup) -> list[str]:
|
|
32
|
+
"""Blocked tasks, called what they are.
|
|
33
|
+
|
|
34
|
+
A standup's real job is surfacing the thing no agent can resolve. Listing blocked
|
|
35
|
+
tasks under a neutral heading buries them among the counts; naming the ask is what
|
|
36
|
+
makes a human read the section.
|
|
37
|
+
"""
|
|
38
|
+
if not standup["blocked"]:
|
|
39
|
+
return []
|
|
40
|
+
return ["", "### Blocked — needs a human", "",
|
|
41
|
+
bullet([f"{t['id']} — {truncate(t['title'], 60)}"
|
|
42
|
+
for t in standup["blocked"]])]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_fleet(fleet: Fleet) -> str:
|
|
46
|
+
"""Who is live, and who has gone quiet while still holding a claim.
|
|
47
|
+
|
|
48
|
+
A silent member is SHOWN, never filtered out: a claim nobody is honouring is exactly
|
|
49
|
+
the row somebody needs to act on, and hiding it is how a board loses its credibility.
|
|
50
|
+
"""
|
|
51
|
+
if not fleet["members"]:
|
|
52
|
+
return "# fleet\n\nNo live claims."
|
|
53
|
+
rows = [[m["actor"], m["task"], "yes" if m["alive"] else "SILENT",
|
|
54
|
+
ago(m["last_seen"]), truncate(m["doing"] or "—", 40)]
|
|
55
|
+
for m in fleet["members"]]
|
|
56
|
+
return "\n".join([f"# fleet — {len(fleet['members'])} live claim(s)", "",
|
|
57
|
+
table(["actor", "task", "alive", "last seen", "doing"], rows)])
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""What an ACTION returns — a plan, a claim, an update.
|
|
2
|
+
|
|
3
|
+
Different from a board: the reader just did something and needs to know what happened and
|
|
4
|
+
what to do next, so every one of these ends with the consequence rather than a summary.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..contracts import EditResult, NextResult, PlanResult, Task, UpdateResult
|
|
10
|
+
from ._text import bullet, table, truncate
|
|
11
|
+
from .task import render_claim
|
|
12
|
+
|
|
13
|
+
__all__ = ["render_plan", "render_next", "render_update", "render_search", "render_edit"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_plan(result: PlanResult) -> str:
|
|
17
|
+
"""The ids created, in the order the caller listed them.
|
|
18
|
+
|
|
19
|
+
In ORDER because the caller has its own plan in mind and needs to map ids onto it;
|
|
20
|
+
sorting by priority here would silently break that correspondence.
|
|
21
|
+
"""
|
|
22
|
+
rows = [[task["id"], truncate(task["title"], 50), str(task["priority"]),
|
|
23
|
+
", ".join(d["task"] for d in result["deps"]
|
|
24
|
+
if d["blocks"] == task["id"]) or "—"]
|
|
25
|
+
for task in result["created"]]
|
|
26
|
+
return "\n".join([f"# planned {len(result['created'])} task(s)", "",
|
|
27
|
+
table(["id", "title", "pri", "after"], rows), "",
|
|
28
|
+
_readiness(result)])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _readiness(result: PlanResult) -> str:
|
|
32
|
+
"""The one line worth reading twice.
|
|
33
|
+
|
|
34
|
+
A plan where NOTHING is ready is almost always a mistake in the `after` references —
|
|
35
|
+
a cycle, or an index off by one — and it is invisible until the first `next` returns
|
|
36
|
+
nothing. Saying it here turns a puzzle twenty minutes later into a sentence now.
|
|
37
|
+
"""
|
|
38
|
+
if result["unblocked"]:
|
|
39
|
+
return f"{len(result['unblocked'])} ready to start now"
|
|
40
|
+
return ("⚠ NOTHING is ready — every task waits on another. Check the `after` "
|
|
41
|
+
"references for a cycle or an off-by-one index")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_next(result: NextResult) -> str:
|
|
45
|
+
"""A claim, or the reason there is none — never a bare "nothing available"."""
|
|
46
|
+
if result["claim"] is not None:
|
|
47
|
+
return render_claim(result["claim"])
|
|
48
|
+
return "\n".join(["# nothing to claim", "", result["reason"], "",
|
|
49
|
+
f"_{result['ready']} ready · {result['working']} in flight · "
|
|
50
|
+
f"{result['blocked']} waiting_"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_search(tasks: list[Task], query: str) -> str:
|
|
54
|
+
"""Search results, or a sentence saying nothing matched.
|
|
55
|
+
|
|
56
|
+
A sentence rather than an empty list: an agent shown nothing cannot tell a failed search
|
|
57
|
+
from a genuinely empty project, and it will retry the same query. Ends by naming the
|
|
58
|
+
next call, because a list of ids without one invites the agent to guess at the tool.
|
|
59
|
+
"""
|
|
60
|
+
if not tasks:
|
|
61
|
+
return (f"nothing matches `{query}` — check the board with taskops_report, or plan "
|
|
62
|
+
f"the work if it does not exist yet")
|
|
63
|
+
lines = [f"- {t['id']} ({t['status']}) — {truncate(t['title'], 60)}" for t in tasks]
|
|
64
|
+
return "\n".join([f"{len(tasks)} task(s) matching `{query}`:", "", *lines, "",
|
|
65
|
+
"Read one in full with taskops_ask task=<id>."])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_edit(result: EditResult) -> str:
|
|
69
|
+
"""Which fields moved, and the title as it now reads.
|
|
70
|
+
|
|
71
|
+
Says nothing changed when nothing did, rather than printing a card that looks edited:
|
|
72
|
+
the whole risk of an edit command is believing a rewrite landed when the value passed
|
|
73
|
+
was the value already stored.
|
|
74
|
+
"""
|
|
75
|
+
task = result["task"]
|
|
76
|
+
if not result["changed"]:
|
|
77
|
+
return f"{task['id']} — nothing changed; the values passed were already stored"
|
|
78
|
+
return "\n".join([f"{task['id']} — edited {', '.join(result['changed'])}", "",
|
|
79
|
+
f"{truncate(task['title'], 70)} (priority {task['priority']})"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_update(result: UpdateResult) -> str:
|
|
83
|
+
"""The new status, and what it set free.
|
|
84
|
+
|
|
85
|
+
The unblocked list is the point: an agent that just closed a task learns what it
|
|
86
|
+
handed to the fleet, which is the difference between finishing work and finishing
|
|
87
|
+
work somebody can pick up.
|
|
88
|
+
"""
|
|
89
|
+
parts = [f"{result['task']['id']} → {result['task']['status']}"]
|
|
90
|
+
if result["unblocked"]:
|
|
91
|
+
parts += ["", f"Unblocked {len(result['unblocked'])} task(s):",
|
|
92
|
+
bullet([f"{t['id']} — {truncate(t['title'], 60)}"
|
|
93
|
+
for t in result["unblocked"]])]
|
|
94
|
+
if result["notified"]:
|
|
95
|
+
parts += ["", f"Notified: {', '.join(result['notified'])}"]
|
|
96
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""What a HOOK prints. A different audience from everything else in `render/`.
|
|
2
|
+
|
|
3
|
+
These two outputs are not read by a person choosing what to do next — they are injected
|
|
4
|
+
into a session's context by SessionStart, or shown to an agent as the reason its commit
|
|
5
|
+
was denied. So they are short, they lead with the action, and they never include anything
|
|
6
|
+
the agent cannot act on. A brief that pastes a whole board costs every session the tokens
|
|
7
|
+
to read a board it did not ask for.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from ..contracts import Event, Lease
|
|
15
|
+
from ._text import ago, bullet, truncate
|
|
16
|
+
|
|
17
|
+
__all__ = ["render_brief", "render_verdict"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BriefLike(Protocol):
|
|
21
|
+
"""What `render_brief` needs, structurally.
|
|
22
|
+
|
|
23
|
+
A Protocol so `render/` never imports `usecases.Brief` — that would point layer 4 at
|
|
24
|
+
layer 5 and break the rule that keeps rendering testable from literals. The FIELDS are
|
|
25
|
+
real contracts, though: `Lease` and `Event` live in layer 1, so typing them properly
|
|
26
|
+
costs nothing and a loose `dict[str, object]` here was hiding real key errors.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
actor: str
|
|
30
|
+
held: list[Lease]
|
|
31
|
+
messages: list[Event]
|
|
32
|
+
ready: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class VerdictLike(Protocol):
|
|
36
|
+
allowed: bool
|
|
37
|
+
reason: str
|
|
38
|
+
task: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_brief(brief: BriefLike) -> str:
|
|
42
|
+
"""The SessionStart injection: who you are, what you hold, who spoke to you.
|
|
43
|
+
|
|
44
|
+
Returns "" when there is nothing at all — no tasks, no messages. A session that is
|
|
45
|
+
told "you are dev:berna and you hold nothing" has paid tokens to learn nothing, and a
|
|
46
|
+
hook that prints nothing is a hook that costs nothing.
|
|
47
|
+
"""
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
if brief.held:
|
|
50
|
+
parts += [f"You ({brief.actor}) hold {len(brief.held)} task(s):",
|
|
51
|
+
bullet([f"{lease['task']}"
|
|
52
|
+
+ (f" on `{lease['branch']}`" if lease["branch"] else
|
|
53
|
+
" — no branch yet")
|
|
54
|
+
for lease in brief.held])]
|
|
55
|
+
if brief.messages:
|
|
56
|
+
parts += ["", f"📬 {len(brief.messages)} message(s):",
|
|
57
|
+
bullet([f"{m['actor']} on {m['task']} ({ago(m['ts'])}): "
|
|
58
|
+
f"{truncate(str(m['body'].get('text', '')), 100)}"
|
|
59
|
+
for m in brief.messages])]
|
|
60
|
+
if not parts and brief.ready:
|
|
61
|
+
return f"taskops: {brief.ready} task(s) ready. Run taskops_next to claim one."
|
|
62
|
+
if not parts:
|
|
63
|
+
return ""
|
|
64
|
+
return "taskops — " + "\n".join(parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render_verdict(verdict: VerdictLike) -> str:
|
|
68
|
+
"""The commit guard's answer, as the agent reads it in a denial.
|
|
69
|
+
|
|
70
|
+
Only the reason. The agent asked to commit and was refused, so the one useful thing is
|
|
71
|
+
what to do instead — anything else buries it.
|
|
72
|
+
"""
|
|
73
|
+
if verdict.allowed:
|
|
74
|
+
return ""
|
|
75
|
+
return f"taskops: commit blocked — {verdict.reason}"
|
taskops/render/task.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""One task, rendered for the agent about to work on it.
|
|
2
|
+
|
|
3
|
+
The ORDER is the design. An agent reads top-down and may stop early, so what it must not
|
|
4
|
+
miss comes first: what this is, then what would make it collide with somebody, then the
|
|
5
|
+
spec, then the conversation. A collision warning below a long spec is a section an agent
|
|
6
|
+
skims past — and the cost of missing it is two agents rewriting each other's work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..contracts import Claim, TaskView
|
|
12
|
+
from ._sections import commits_section, thread_section
|
|
13
|
+
from ._text import STATUS_MARK, bullet, truncate
|
|
14
|
+
from .inbox import render_inbox
|
|
15
|
+
|
|
16
|
+
__all__ = ["render_view", "render_claim"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_claim(claim: Claim) -> str:
|
|
20
|
+
"""A fresh claim: the exact branch command, the inbox, then the task."""
|
|
21
|
+
task = claim["view"]["task"]
|
|
22
|
+
head = [f"# {task['id']} — {task['title']}", "",
|
|
23
|
+
"Claimed. Create the branch and work there:", "",
|
|
24
|
+
f" git switch -c {claim['branch']}", ""]
|
|
25
|
+
messages = render_inbox(claim["inbox"])
|
|
26
|
+
return "\n".join(head + ([messages, ""] if messages else [])) \
|
|
27
|
+
+ render_view(claim["view"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_view(view: TaskView) -> str:
|
|
31
|
+
"""The task in full. Used by `ask`, and by `next` under the claim header."""
|
|
32
|
+
task = view["task"]
|
|
33
|
+
parts = [f"## {STATUS_MARK.get(task['status'], '?')} {task['status']} · "
|
|
34
|
+
f"priority {task['priority']}" + _lease_line(view), ""]
|
|
35
|
+
parts += _collisions(view)
|
|
36
|
+
parts += ["### Spec", "", task["spec"] or "_(no spec — ask before guessing)_", ""]
|
|
37
|
+
parts += _graph(view)
|
|
38
|
+
parts += thread_section(view["thread"])
|
|
39
|
+
parts += commits_section(view)
|
|
40
|
+
return "\n".join(parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _lease_line(view: TaskView) -> str:
|
|
44
|
+
lease = view["lease"]
|
|
45
|
+
if lease is None:
|
|
46
|
+
return ""
|
|
47
|
+
where = f" on `{lease['branch']}`" if lease["branch"] else ""
|
|
48
|
+
return f" · held by {lease['actor']}{where}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _collisions(view: TaskView) -> list[str]:
|
|
52
|
+
"""Who else is in these files, and what to do about it.
|
|
53
|
+
|
|
54
|
+
The one section that changes what an agent does BEFORE it starts. It ends with the
|
|
55
|
+
action rather than the observation: told only that somebody else is in the file, an
|
|
56
|
+
agent proceeds anyway.
|
|
57
|
+
"""
|
|
58
|
+
others = view["neighbours"]
|
|
59
|
+
if not others:
|
|
60
|
+
return []
|
|
61
|
+
lines = [f"{o['id']} ({o['status']}, {o['created_by']}) — {truncate(o['title'], 50)}"
|
|
62
|
+
for o in others]
|
|
63
|
+
return ["### ⚠ Also touching these files", "", bullet(lines), "",
|
|
64
|
+
"_Message them with taskops_update mentions=… before editing shared files._",
|
|
65
|
+
""]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _graph(view: TaskView) -> list[str]:
|
|
69
|
+
"""Both directions of the DAG, plus the subtasks.
|
|
70
|
+
|
|
71
|
+
"Blocking N" is stated as a count in the heading on purpose: it is the argument for
|
|
72
|
+
finishing this today rather than tomorrow, and a number reads as urgency where a
|
|
73
|
+
bare list reads as trivia.
|
|
74
|
+
"""
|
|
75
|
+
out: list[str] = []
|
|
76
|
+
if view["blocked_by"]:
|
|
77
|
+
out += ["### Waiting on", "",
|
|
78
|
+
bullet([f"{t['id']} ({t['status']}) — {truncate(t['title'], 60)}"
|
|
79
|
+
for t in view["blocked_by"]]), ""]
|
|
80
|
+
if view["blocks"]:
|
|
81
|
+
out += [f"### Blocking {len(view['blocks'])} task(s)", "",
|
|
82
|
+
bullet([f"{t['id']} — {truncate(t['title'], 60)}"
|
|
83
|
+
for t in view["blocks"]]), ""]
|
|
84
|
+
if view["children"]:
|
|
85
|
+
out += ["### Subtasks", "",
|
|
86
|
+
bullet([f"{STATUS_MARK.get(t['status'], '?')} {t['id']} — "
|
|
87
|
+
f"{truncate(t['title'], 60)}" for t in view["children"]]), ""]
|
|
88
|
+
return out
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The task list as ONE line per card — what `taskops tasks` prints.
|
|
2
|
+
|
|
3
|
+
`render.board` answers "what is the state of the work": eight columns, always all of them,
|
|
4
|
+
because a missing column reads as a bug. This answers a different question — "what is on
|
|
5
|
+
my list" — so the columns collapse into a single ordered list. A reader scanning for an id
|
|
6
|
+
does not want a heading and a table rule between every second row.
|
|
7
|
+
|
|
8
|
+
Open cards lead, always: that is the question nine times out of ten, and closed ones
|
|
9
|
+
accumulate for the life of a project, so listing them by default would push the answer off
|
|
10
|
+
the screen. But a project whose cards are ALL closed used to print `no open tasks (6 in
|
|
11
|
+
total)` and nothing else — the same mistake the board fixed by never hiding a column. A
|
|
12
|
+
reader who cannot see the finished work cannot tell whether the tool lost it or whether the
|
|
13
|
+
project is done, so with nothing open the closed cards are what this prints, under a
|
|
14
|
+
heading that says which they are. `--all` shows both groups, `--status` picks exactly one.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .._types import CLOSED_STATUSES, OPEN_STATUSES
|
|
20
|
+
from ..contracts import Board
|
|
21
|
+
from ._tasklist import capped, lines, summary
|
|
22
|
+
|
|
23
|
+
__all__ = ["render_tasklist"]
|
|
24
|
+
|
|
25
|
+
Section = tuple[str, list[str]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_tasklist(board: Board, *, show_all: bool = False,
|
|
29
|
+
status: str | None = None) -> str:
|
|
30
|
+
"""A line per task, then one summary line. See the module docstring for which tasks.
|
|
31
|
+
|
|
32
|
+
`status` is trusted here — the caller validates it, because naming the legal values in
|
|
33
|
+
a refusal is an edge's job and this renderer is reached by three of them.
|
|
34
|
+
"""
|
|
35
|
+
if status is not None:
|
|
36
|
+
rows = lines(board, frozenset({status}), dated=status in CLOSED_STATUSES)
|
|
37
|
+
if not rows:
|
|
38
|
+
return f"no {status} tasks ({board['total']} in total)"
|
|
39
|
+
return _joined([(f"## {status} ({len(rows)})", rows)], board,
|
|
40
|
+
closed=len(rows) if status in CLOSED_STATUSES else 0)
|
|
41
|
+
open_rows = lines(board, OPEN_STATUSES)
|
|
42
|
+
closed_rows = lines(board, CLOSED_STATUSES, dated=True)
|
|
43
|
+
if show_all:
|
|
44
|
+
return _joined([("## open", open_rows), ("## closed", closed_rows)], board,
|
|
45
|
+
closed=len(closed_rows))
|
|
46
|
+
if open_rows:
|
|
47
|
+
return _joined([("", open_rows)], board, closed=0)
|
|
48
|
+
if not closed_rows:
|
|
49
|
+
return f"no open tasks ({board['total']} in total)"
|
|
50
|
+
return _joined([("## nothing open — closed tasks, newest first", closed_rows)],
|
|
51
|
+
board, closed=len(closed_rows))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _joined(sections: list[Section], board: Board, *, closed: int) -> str:
|
|
55
|
+
"""Headed groups, each capped, then the summary.
|
|
56
|
+
|
|
57
|
+
The heading is "" for the ordinary open list, which is how that case keeps the shape it
|
|
58
|
+
has always had: a heading over the only group on the page is a line that says nothing.
|
|
59
|
+
"""
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
for heading, rows in sections:
|
|
62
|
+
if heading:
|
|
63
|
+
parts += [heading, ""]
|
|
64
|
+
parts += (capped(rows) if rows else ["_none_"]) + [""]
|
|
65
|
+
return "\n".join([*parts, summary(board, closed)])
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Layer 2 — the database, and the only package that writes SQL.
|
|
2
|
+
|
|
3
|
+
`sync` is exported alongside `Store` because the two are one story: the SQLite file
|
|
4
|
+
is a cache of the committed log, and `sync` is what makes that true in both
|
|
5
|
+
directions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .locate import (
|
|
11
|
+
DB_FILE,
|
|
12
|
+
GUIDE_FILE,
|
|
13
|
+
LOG_FILE,
|
|
14
|
+
PROJECT_DIR,
|
|
15
|
+
REPORTS_DIR,
|
|
16
|
+
find_root,
|
|
17
|
+
resolve_root,
|
|
18
|
+
)
|
|
19
|
+
from .store import BUSY_TIMEOUT, Store
|
|
20
|
+
from .sync import all_events, event_from, export_events, import_events
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Store",
|
|
24
|
+
"BUSY_TIMEOUT",
|
|
25
|
+
"PROJECT_DIR",
|
|
26
|
+
"DB_FILE",
|
|
27
|
+
"LOG_FILE",
|
|
28
|
+
"GUIDE_FILE",
|
|
29
|
+
"REPORTS_DIR",
|
|
30
|
+
"find_root",
|
|
31
|
+
"resolve_root",
|
|
32
|
+
"export_events",
|
|
33
|
+
"import_events",
|
|
34
|
+
"all_events",
|
|
35
|
+
"event_from",
|
|
36
|
+
]
|
taskops/storage/_ddl.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The tables, as SQL. Data, not logic — which is why it is not in `schema.py`.
|
|
2
|
+
|
|
3
|
+
That module is the part with bugs in it: pragmas, ALTERs, tolerating exactly one
|
|
4
|
+
kind of failure. This one is a declaration, and keeping them apart means a review
|
|
5
|
+
of a migration is not also a review of sixty lines of unchanged DDL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["DDL"]
|
|
11
|
+
|
|
12
|
+
DDL = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
title TEXT NOT NULL,
|
|
16
|
+
spec TEXT NOT NULL DEFAULT '',
|
|
17
|
+
status TEXT NOT NULL,
|
|
18
|
+
priority INTEGER NOT NULL DEFAULT 2,
|
|
19
|
+
parent TEXT,
|
|
20
|
+
labels TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
21
|
+
files TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
22
|
+
created_by TEXT NOT NULL,
|
|
23
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
24
|
+
created REAL NOT NULL,
|
|
25
|
+
updated REAL NOT NULL
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
|
|
30
|
+
|
|
31
|
+
-- `task` must finish before `blocks` can start. Queried from BOTH ends, so both
|
|
32
|
+
-- directions are indexed: the primary key covers one, idx_deps_blocks the other.
|
|
33
|
+
CREATE TABLE IF NOT EXISTS deps (
|
|
34
|
+
task TEXT NOT NULL,
|
|
35
|
+
blocks TEXT NOT NULL,
|
|
36
|
+
PRIMARY KEY (task, blocks)
|
|
37
|
+
);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_deps_blocks ON deps(blocks);
|
|
39
|
+
|
|
40
|
+
-- `task` is the PRIMARY KEY, and that is the entire locking story: two agents
|
|
41
|
+
-- racing to claim one task are two INSERTs on one key, and SQLite settles it.
|
|
42
|
+
CREATE TABLE IF NOT EXISTS leases (
|
|
43
|
+
task TEXT PRIMARY KEY,
|
|
44
|
+
actor TEXT NOT NULL,
|
|
45
|
+
session TEXT NOT NULL DEFAULT '',
|
|
46
|
+
branch TEXT NOT NULL DEFAULT '',
|
|
47
|
+
acquired REAL NOT NULL,
|
|
48
|
+
expires REAL NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_leases_actor ON leases(actor);
|
|
51
|
+
|
|
52
|
+
-- seq orders events on THIS machine; ts orders them across machines. Both are
|
|
53
|
+
-- needed: seq is what a live feed streams from, ts is what survives a sync.
|
|
54
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
55
|
+
id TEXT PRIMARY KEY, -- the content, hashed: import twice = no-op
|
|
56
|
+
seq INTEGER,
|
|
57
|
+
task TEXT NOT NULL,
|
|
58
|
+
actor TEXT NOT NULL,
|
|
59
|
+
kind TEXT NOT NULL,
|
|
60
|
+
body TEXT NOT NULL DEFAULT '{}', -- JSON object, shaped by kind
|
|
61
|
+
ts REAL NOT NULL,
|
|
62
|
+
exported INTEGER NOT NULL DEFAULT 0 -- has the committed log seen this one
|
|
63
|
+
);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_events_task ON events(task, ts);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind, ts);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_events_export ON events(exported);
|
|
67
|
+
|
|
68
|
+
-- Delivery is per (actor, event), never a timestamp cursor: an agent's hooks fire
|
|
69
|
+
-- in an order nobody controls, and a cursor would skip whatever arrived late.
|
|
70
|
+
CREATE TABLE IF NOT EXISTS delivered (
|
|
71
|
+
actor TEXT NOT NULL,
|
|
72
|
+
event TEXT NOT NULL,
|
|
73
|
+
ts REAL NOT NULL,
|
|
74
|
+
PRIMARY KEY (actor, event)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
|
78
|
+
"""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Who has been shown what — the inbox's memory.
|
|
2
|
+
|
|
3
|
+
A per-pair table rather than a `last_seen` timestamp per actor. The reason is the
|
|
4
|
+
delivery path: an agent's messages reach it through whichever hook fires first, in
|
|
5
|
+
an order nobody controls, and events arrive from two replication paths with
|
|
6
|
+
timestamps set by two machines. A cursor would advance past a message that landed
|
|
7
|
+
a second late and that message would never be delivered — silently, which is the
|
|
8
|
+
worst way for a coordination system to fail.
|
|
9
|
+
|
|
10
|
+
The cost is one row per (actor, message). Bounded by conversation volume, not by
|
|
11
|
+
event volume: only directed kinds are ever tracked here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sqlite3
|
|
17
|
+
|
|
18
|
+
from ..contracts import Event
|
|
19
|
+
from ._rows import to_event
|
|
20
|
+
|
|
21
|
+
__all__ = ["DeliveredTable"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeliveredTable:
|
|
25
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
26
|
+
self.db = db
|
|
27
|
+
|
|
28
|
+
def pending(self, actor: str, *, limit: int = 50) -> list[Event]:
|
|
29
|
+
"""Directed events this actor has not been shown, oldest first.
|
|
30
|
+
|
|
31
|
+
"Directed at" is `mentions` containing the actor id, matched in SQL with a
|
|
32
|
+
LIKE over the JSON body. Crude and deliberate: the alternative is a
|
|
33
|
+
mentions join table, and a mention is read once and never queried by
|
|
34
|
+
anything else — so the join table would exist purely to make this one
|
|
35
|
+
LIKE prettier. Substring collision is prevented by quoting: the body
|
|
36
|
+
stores `"agent:ana/api-1"` with its quotes, so `dev:an` cannot match
|
|
37
|
+
`dev:ana`.
|
|
38
|
+
"""
|
|
39
|
+
rows = self.db.execute(
|
|
40
|
+
'SELECT e.* FROM events e WHERE e.kind IN ("message", "handoff") '
|
|
41
|
+
"AND e.body LIKE ? AND NOT EXISTS ("
|
|
42
|
+
" SELECT 1 FROM delivered d WHERE d.actor = ? AND d.event = e.id) "
|
|
43
|
+
"ORDER BY e.ts, e.seq LIMIT ?",
|
|
44
|
+
(f'%"{actor}"%', actor, limit)).fetchall()
|
|
45
|
+
return [to_event(row) for row in rows]
|
|
46
|
+
|
|
47
|
+
def mark(self, actor: str, ids: list[str], *, when: float) -> None:
|
|
48
|
+
"""Record delivery. Idempotent, so re-running a hook costs nothing."""
|
|
49
|
+
self.db.executemany(
|
|
50
|
+
"INSERT OR IGNORE INTO delivered (actor, event, ts) VALUES (?, ?, ?)",
|
|
51
|
+
[(actor, event_id, when) for event_id in ids])
|
taskops/storage/_deps.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The dependency edges. `task` must finish before `blocks` can start."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from .._types import CLOSED_STATUSES
|
|
8
|
+
from ..contracts import Dep
|
|
9
|
+
|
|
10
|
+
__all__ = ["DepTable"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DepTable:
|
|
14
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
15
|
+
self.db = db
|
|
16
|
+
|
|
17
|
+
def add(self, task: str, blocks: str) -> None:
|
|
18
|
+
"""Idempotent: the same edge asserted twice is the same fact."""
|
|
19
|
+
self.db.execute("INSERT OR IGNORE INTO deps (task, blocks) VALUES (?, ?)", (task, blocks))
|
|
20
|
+
|
|
21
|
+
def remove(self, task: str, blocks: str) -> None:
|
|
22
|
+
self.db.execute("DELETE FROM deps WHERE task=? AND blocks=?", (task, blocks))
|
|
23
|
+
|
|
24
|
+
def all(self) -> list[Dep]:
|
|
25
|
+
rows = self.db.execute("SELECT task, blocks FROM deps").fetchall()
|
|
26
|
+
return [Dep(task=str(r["task"]), blocks=str(r["blocks"])) for r in rows]
|
|
27
|
+
|
|
28
|
+
def blockers_of(self, task_id: str) -> list[str]:
|
|
29
|
+
"""Every id this task waits on, closed or not."""
|
|
30
|
+
rows = self.db.execute("SELECT task FROM deps WHERE blocks=?", (task_id,)).fetchall()
|
|
31
|
+
return [str(row["task"]) for row in rows]
|
|
32
|
+
|
|
33
|
+
def dependents_of(self, task_id: str) -> list[str]:
|
|
34
|
+
"""Every id waiting on this one."""
|
|
35
|
+
rows = self.db.execute("SELECT blocks FROM deps WHERE task=?", (task_id,)).fetchall()
|
|
36
|
+
return [str(row["blocks"]) for row in rows]
|
|
37
|
+
|
|
38
|
+
def open_blockers_of(self, task_id: str) -> list[str]:
|
|
39
|
+
"""The blockers that are still open — the ones that actually block.
|
|
40
|
+
|
|
41
|
+
A JOIN rather than two queries and a filter in Python: this runs for every
|
|
42
|
+
task in the ready-set computation, and the ready-set is recomputed after
|
|
43
|
+
every write in the system.
|
|
44
|
+
"""
|
|
45
|
+
marks = ", ".join("?" * len(CLOSED_STATUSES))
|
|
46
|
+
closed = tuple(sorted(CLOSED_STATUSES))
|
|
47
|
+
rows = self.db.execute(
|
|
48
|
+
f"SELECT d.task FROM deps d JOIN tasks t ON t.id = d.task "
|
|
49
|
+
f"WHERE d.blocks = ? AND t.status NOT IN ({marks})",
|
|
50
|
+
(task_id, *closed),
|
|
51
|
+
).fetchall()
|
|
52
|
+
return [str(row["task"]) for row in rows]
|
|
53
|
+
|
|
54
|
+
def unblocked_by(self, task_id: str) -> list[str]:
|
|
55
|
+
"""Ids that have NO open blocker left once `task_id` is closed.
|
|
56
|
+
|
|
57
|
+
Asked after a task closes, so `task_id` is already closed in the same
|
|
58
|
+
transaction — which is why this needs no special case for it. The
|
|
59
|
+
NOT EXISTS reads as the question: dependents with nothing open left.
|
|
60
|
+
"""
|
|
61
|
+
marks = ", ".join("?" * len(CLOSED_STATUSES))
|
|
62
|
+
closed = tuple(sorted(CLOSED_STATUSES))
|
|
63
|
+
rows = self.db.execute(
|
|
64
|
+
f"SELECT d.blocks AS id FROM deps d WHERE d.task = ? AND NOT EXISTS ("
|
|
65
|
+
f" SELECT 1 FROM deps o JOIN tasks t ON t.id = o.task"
|
|
66
|
+
f" WHERE o.blocks = d.blocks AND t.status NOT IN ({marks}))",
|
|
67
|
+
(task_id, *closed),
|
|
68
|
+
).fetchall()
|
|
69
|
+
return [str(row["id"]) for row in rows]
|