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,83 @@
|
|
|
1
|
+
"""JSON-RPC, as a pure function: a message in, a response object out.
|
|
2
|
+
|
|
3
|
+
No socket and no stdin, so every method is testable by calling it — the transport work is
|
|
4
|
+
tested once, in `server`, instead of by every case that wants to know what `tools/call`
|
|
5
|
+
answers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..._version import __version__
|
|
13
|
+
from .answers import Answer, failure
|
|
14
|
+
from .tools import listing
|
|
15
|
+
|
|
16
|
+
__all__ = ["PROTOCOL", "INSTRUCTIONS", "respond"]
|
|
17
|
+
|
|
18
|
+
PROTOCOL = "2024-11-05"
|
|
19
|
+
|
|
20
|
+
# Returned by `initialize` and injected into the calling agent's context ONCE. With tool
|
|
21
|
+
# search on, the SCHEMAS stay deferred until the agent goes looking, so this is the only
|
|
22
|
+
# taskops text a session is guaranteed to see. What belongs here is the mental model and
|
|
23
|
+
# the routing BETWEEN tools — never a restatement of each tool's own description — and it
|
|
24
|
+
# must stay short, because it costs context in every session that loads this server.
|
|
25
|
+
INSTRUCTIONS = """taskops is the shared task list for this repository. Tasks persist across sessions, machines and developers, so work you claim is work nobody else will start.
|
|
26
|
+
|
|
27
|
+
The loop, and it is short:
|
|
28
|
+
- taskops_next — claim work. Returns the spec, the branch to create, and a warning if another agent is editing the same files. Do this before coding, not after.
|
|
29
|
+
- taskops_update — progress, a comment, or `status=done`. `mentions` sends a message to another actor's agent; they see it within one tool call.
|
|
30
|
+
- taskops_ask — read a task in full before you touch a file you did not claim.
|
|
31
|
+
|
|
32
|
+
Two rules the server enforces rather than suggests. A commit must be on the claimed task's branch (`tk/<id>/<slug>`) — the trailer is added for you. And `done` requires a commit bound to the task, so the board cannot say finished about work that does not exist.
|
|
33
|
+
|
|
34
|
+
Your claim is a LEASE: every taskops call renews it, and if your process dies it expires and the task returns to the queue. If a write is refused for a missing lease, claim again — do not work around it."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def respond(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
38
|
+
"""The response to one message, or None if it deserves none.
|
|
39
|
+
|
|
40
|
+
A message without an id is a notification, and answering one is a protocol violation
|
|
41
|
+
some hosts read as a broken server. Returning before any work also means a notification
|
|
42
|
+
can never cost a database open.
|
|
43
|
+
"""
|
|
44
|
+
mid = message.get("id")
|
|
45
|
+
if mid is None:
|
|
46
|
+
return None
|
|
47
|
+
return {"jsonrpc": "2.0", "id": mid, "result": _result(message)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _result(message: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
method = message.get("method", "")
|
|
52
|
+
if method == "initialize":
|
|
53
|
+
return {"protocolVersion": PROTOCOL, "capabilities": {"tools": {}},
|
|
54
|
+
"serverInfo": {"name": "taskops", "version": __version__},
|
|
55
|
+
"instructions": INSTRUCTIONS}
|
|
56
|
+
if method == "tools/list":
|
|
57
|
+
return {"tools": listing()}
|
|
58
|
+
if method == "tools/call":
|
|
59
|
+
params: dict[str, Any] = message.get("params") or {}
|
|
60
|
+
return _content(_call(params))
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _call(params: dict[str, Any]) -> Answer:
|
|
65
|
+
"""The engine is imported HERE, not at module scope.
|
|
66
|
+
|
|
67
|
+
A host lists the tools before it asks anything, and that handshake must not pay for a
|
|
68
|
+
sqlite connection or a git subprocess.
|
|
69
|
+
"""
|
|
70
|
+
from .dispatch import call_tool
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
return call_tool(str(params.get("name", "")),
|
|
74
|
+
dict(params.get("arguments") or {}))
|
|
75
|
+
except Exception as err: # noqa: BLE001 — last resort
|
|
76
|
+
# `call_tool` handles every failure it can name; anything reaching here is a bug,
|
|
77
|
+
# and the agent still needs a sentence rather than a hang.
|
|
78
|
+
return failure(f"{type(err).__name__}: {err}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _content(result: Answer) -> dict[str, Any]:
|
|
82
|
+
payload: dict[str, Any] = {"content": [{"type": "text", "text": result.text}]}
|
|
83
|
+
return {**payload, "isError": True} if result.failed else payload
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""A params contract -> the JSON Schema a host validates against.
|
|
2
|
+
|
|
3
|
+
Generated, never written twice. The alternative — a JSON literal beside the TypedDict — is
|
|
4
|
+
two declarations of one thing, and the one that drifts is always the schema, because
|
|
5
|
+
nothing type-checks it: the tool keeps advertising a parameter the dispatch stopped
|
|
6
|
+
reading, and the agent keeps sending it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, get_args, get_type_hints
|
|
12
|
+
|
|
13
|
+
__all__ = ["json_schema"]
|
|
14
|
+
|
|
15
|
+
_JSON_TYPES: dict[Any, str] = {str: "string", bool: "boolean", int: "integer",
|
|
16
|
+
float: "number"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def json_schema(params: type[Any]) -> dict[str, Any]:
|
|
20
|
+
"""One tool's `inputSchema`, derived from its TypedDict.
|
|
21
|
+
|
|
22
|
+
Required-ness comes from the TypedDict's own totality rather than a hand-written list,
|
|
23
|
+
so an optional key cannot be advertised as required by whoever forgot the second edit.
|
|
24
|
+
See `contracts/__init__` for why that totality has to be spelled with a class split.
|
|
25
|
+
"""
|
|
26
|
+
required: frozenset[str] = getattr(params, "__required_keys__", frozenset())
|
|
27
|
+
hints = get_type_hints(params, include_extras=True)
|
|
28
|
+
return {"type": "object",
|
|
29
|
+
"properties": {name: _property(hint) for name, hint in hints.items()},
|
|
30
|
+
"required": sorted(required)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _property(hint: Any) -> dict[str, Any]:
|
|
34
|
+
"""One field: its JSON type, its allowed values, and what it is for."""
|
|
35
|
+
described = getattr(hint, "__metadata__", ())
|
|
36
|
+
annotation = getattr(hint, "__origin__", hint) if described else hint
|
|
37
|
+
schema: dict[str, Any] = {"description": " ".join(str(d) for d in described)}
|
|
38
|
+
if getattr(annotation, "__origin__", None) is list or annotation is list:
|
|
39
|
+
# An array of objects, with the ITEM shape left OPEN on purpose: `plan` accepts
|
|
40
|
+
# the habitual spellings a model reaches for (a bare `after` instead of a list, a
|
|
41
|
+
# comma-separated `files` string), and a schema that pinned the canonical forms
|
|
42
|
+
# would have the host reject the very inputs `_entry` goes out of its way to read.
|
|
43
|
+
return {**schema, "type": "array", "items": {"type": "object"}}
|
|
44
|
+
choices = list(get_args(annotation))
|
|
45
|
+
if choices and all(isinstance(c, (str, int, bool)) for c in choices):
|
|
46
|
+
# A Literal: the allowed values ARE the documentation. Rendered as a bare string,
|
|
47
|
+
# the agent can send anything and only finds out at dispatch.
|
|
48
|
+
return {**schema, "type": _JSON_TYPES.get(type(choices[0]), "string"),
|
|
49
|
+
"enum": choices}
|
|
50
|
+
return {**schema, "type": _JSON_TYPES.get(annotation, "string")}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The stdio loop: one JSON object per line in, one response per line out.
|
|
2
|
+
|
|
3
|
+
Everything protocol-shaped happens in `protocol`; what is left here is the wire. Written
|
|
4
|
+
against two streams rather than sys.stdin/sys.stdout directly, so the loop's real behaviour
|
|
5
|
+
— a malformed line, a notification, the order of replies — is testable without a subprocess.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import IO, Any, cast
|
|
13
|
+
|
|
14
|
+
from .protocol import respond
|
|
15
|
+
|
|
16
|
+
__all__ = ["serve", "main"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def serve(reader: IO[str], writer: IO[str]) -> None:
|
|
20
|
+
"""Answer messages until the input ends.
|
|
21
|
+
|
|
22
|
+
A line that is not a JSON object is SKIPPED, not fatal: a host that writes one piece of
|
|
23
|
+
garbage should not lose the rest of the session, and there is no id to answer it with
|
|
24
|
+
anyway.
|
|
25
|
+
"""
|
|
26
|
+
for raw in reader:
|
|
27
|
+
line = raw.strip()
|
|
28
|
+
if not line:
|
|
29
|
+
continue
|
|
30
|
+
message = _parse(line)
|
|
31
|
+
reply = respond(message) if message is not None else None
|
|
32
|
+
if reply is not None:
|
|
33
|
+
writer.write(json.dumps(reply) + "\n")
|
|
34
|
+
writer.flush()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse(line: str) -> dict[str, Any] | None:
|
|
38
|
+
try:
|
|
39
|
+
message: object = json.loads(line)
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return None
|
|
42
|
+
return cast("dict[str, Any]", message) if isinstance(message, dict) else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> int:
|
|
46
|
+
"""`python -m taskops.transports.mcp`, and what a host registers:
|
|
47
|
+
`claude mcp add taskops -- python3 -m taskops.transports.mcp`."""
|
|
48
|
+
serve(sys.stdin, sys.stdout)
|
|
49
|
+
return 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The tools an agent can see — five, and each one earns its slot.
|
|
2
|
+
|
|
3
|
+
Every tool costs the calling agent context and a routing decision, so the surface carries
|
|
4
|
+
only what taskops alone can do: turn a plan into a persistent graph (`plan`), hand out work
|
|
5
|
+
without two agents taking the same piece (`next`), record what happened and tell somebody
|
|
6
|
+
(`update`), read a task's whole context (`ask`), and generate the report nobody wants to
|
|
7
|
+
write (`report`).
|
|
8
|
+
|
|
9
|
+
There is deliberately no sixth tool for messaging. Sending is `update` with `mentions`,
|
|
10
|
+
because a message about a task belongs in that task's thread — a separate chat tool would
|
|
11
|
+
produce conversations nobody can find later. Receiving is not a tool at all: the inbox rides
|
|
12
|
+
along with `next` and `ask`, and hooks deliver it between calls.
|
|
13
|
+
|
|
14
|
+
The description text lives in `_descriptions`, for the same reason the SQL lives in
|
|
15
|
+
`storage/_ddl` — reviewing a change here should not mean re-reading sixty lines of prose.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ...contracts.tools import (
|
|
24
|
+
AskParams,
|
|
25
|
+
DispatchParams,
|
|
26
|
+
NextParams,
|
|
27
|
+
PlanParams,
|
|
28
|
+
RecoverParams,
|
|
29
|
+
ReportParams,
|
|
30
|
+
UpdateParams,
|
|
31
|
+
)
|
|
32
|
+
from . import _descriptions as text
|
|
33
|
+
from .schema import json_schema
|
|
34
|
+
|
|
35
|
+
__all__ = ["Tool", "TOOLS", "listing"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Tool:
|
|
40
|
+
"""A name, what it is for, and the contract its arguments satisfy."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
params: type[Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
TOOLS: tuple[Tool, ...] = (
|
|
48
|
+
Tool("taskops_next", text.NEXT, NextParams),
|
|
49
|
+
Tool("taskops_update", text.UPDATE, UpdateParams),
|
|
50
|
+
Tool("taskops_ask", text.ASK, AskParams),
|
|
51
|
+
Tool("taskops_plan", text.PLAN, PlanParams),
|
|
52
|
+
Tool("taskops_dispatch", text.DISPATCH, DispatchParams),
|
|
53
|
+
Tool("taskops_recover", text.RECOVER, RecoverParams),
|
|
54
|
+
Tool("taskops_report", text.REPORT, ReportParams),
|
|
55
|
+
)
|
|
56
|
+
"""Ordered by how often an agent needs them, not alphabetically.
|
|
57
|
+
|
|
58
|
+
With tool search on, a host may surface only the first few names, so `next` and `update` —
|
|
59
|
+
the two that carry every session — must not sit below the planning tool a session uses once.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def listing() -> list[dict[str, Any]]:
|
|
64
|
+
"""The `tools/list` payload: description and schema, generated together."""
|
|
65
|
+
return [{"name": tool.name, "description": tool.description,
|
|
66
|
+
"inputSchema": json_schema(tool.params)} for tool in TOOLS]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Layer 5 — one module per verb, sync, returning contracts.
|
|
2
|
+
|
|
3
|
+
Every transport calls THESE, so a behaviour is implemented once and three surfaces
|
|
4
|
+
cannot drift apart. `tests/architecture` enforces the other half of that: a transport
|
|
5
|
+
may not import `storage` or `engine` directly, which is what stops a fourth place where
|
|
6
|
+
a decision lives.
|
|
7
|
+
|
|
8
|
+
Sync all the way down. taskops is sqlite and a state machine; the http edge may be
|
|
9
|
+
async and calls in from a threadpool, but there is no async twin of the engine.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from . import narration
|
|
15
|
+
from ._project import locate
|
|
16
|
+
from ._range import Selector, parse_date
|
|
17
|
+
from .ask import ask, search
|
|
18
|
+
from .claim import next_task
|
|
19
|
+
from .dispatch import DispatchResult, dispatch
|
|
20
|
+
from .dossier import digest, read_report, report_path, write_report
|
|
21
|
+
from .edit import edit
|
|
22
|
+
from .exchange import MAX_BATCH, MAX_PAGE, accept_events, pull_events
|
|
23
|
+
from .feed import follow, is_wire
|
|
24
|
+
from .guard import Verdict, check_command, check_commit
|
|
25
|
+
from .index import report_index
|
|
26
|
+
from .ingest import ingest_branch, ingest_commit
|
|
27
|
+
from .log import session_log
|
|
28
|
+
from .plan import plan
|
|
29
|
+
from .pushpull import Exchange, pull, push
|
|
30
|
+
from .recover import Recovered, recover
|
|
31
|
+
from .remote import add_remote, read_remote, remove_remote, require_remote
|
|
32
|
+
from .report import activity, board, day, fleet, period, standup
|
|
33
|
+
from .reportfile import read_report_file, write_report_file
|
|
34
|
+
from .session import Brief, brief, checkout, inbox, track
|
|
35
|
+
from .setup import InitReport, init
|
|
36
|
+
from .sync import rebuild, sync
|
|
37
|
+
from .update import update
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# the five MCP tools
|
|
41
|
+
"plan", "next_task", "update", "edit", "ask", "search", "board", "standup", "fleet", "activity",
|
|
42
|
+
"day", "period", "Selector",
|
|
43
|
+
"dispatch", "DispatchResult", "write_report", "read_report", "digest", "report_path",
|
|
44
|
+
"report_index", "recover", "Recovered",
|
|
45
|
+
# the remote exchange: events and report files over HTTP
|
|
46
|
+
"accept_events", "pull_events", "MAX_BATCH", "MAX_PAGE",
|
|
47
|
+
"read_report_file", "write_report_file",
|
|
48
|
+
# the CLI verbs the hooks call
|
|
49
|
+
"init", "InitReport", "check_commit", "check_command", "Verdict",
|
|
50
|
+
"ingest_commit", "ingest_branch",
|
|
51
|
+
"brief", "Brief", "inbox", "checkout", "track", "sync", "rebuild", "follow", "locate", "session_log",
|
|
52
|
+
# the remote: one server, and the two verbs that converge with it
|
|
53
|
+
"add_remote", "read_remote", "require_remote", "remove_remote", "push", "pull", "Exchange",
|
|
54
|
+
# the live narration: started in the background, watched on the wire
|
|
55
|
+
"narration", "is_wire", "parse_date",
|
|
56
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Reading one plan entry — the fields, as a model actually writes them.
|
|
2
|
+
|
|
3
|
+
Split from `plan` by what the reader has to know: that module builds a graph, this one
|
|
4
|
+
decides what a caller meant when it packaged a field the way the schema did not name.
|
|
5
|
+
Every tolerance here is a habit observed in the field, not a defensive reflex — and each
|
|
6
|
+
one is the difference between rejecting a correct plan over a bracket and accepting it.
|
|
7
|
+
|
|
8
|
+
Nothing here guesses at a MEANING. A title is required and stays required; what gets
|
|
9
|
+
tolerated is only shape: a string where a list was declared, one value where a list of
|
|
10
|
+
one was.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
__all__ = ["title_of", "priority_of", "optional", "strings", "references", "blocked_ids"]
|
|
18
|
+
|
|
19
|
+
DEFAULT_PRIORITY = 2
|
|
20
|
+
"""The middle of the 0-3 band. A caller with no opinion about urgency has not said
|
|
21
|
+
"urgent", and defaulting to 0 would make every unconsidered task jump the queue."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def title_of(entry: dict[str, Any]) -> str:
|
|
25
|
+
return str(entry.get("title", "")).strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def priority_of(entry: dict[str, Any]) -> int:
|
|
29
|
+
"""`bool` is excluded explicitly: `True == 1` in Python, so a caller that sent
|
|
30
|
+
`priority: true` would silently get an urgent task."""
|
|
31
|
+
found = entry.get("priority")
|
|
32
|
+
if isinstance(found, bool) or not isinstance(found, int):
|
|
33
|
+
return DEFAULT_PRIORITY
|
|
34
|
+
return found
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def optional(entry: dict[str, Any], key: str) -> str | None:
|
|
38
|
+
found = entry.get(key)
|
|
39
|
+
return str(found) if isinstance(found, str) and found.strip() else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strings(entry: dict[str, Any], key: str) -> list[str]:
|
|
43
|
+
"""A list field, tolerating the comma-separated string a model reaches for anyway."""
|
|
44
|
+
found: object = entry.get(key)
|
|
45
|
+
if isinstance(found, str):
|
|
46
|
+
return [part.strip() for part in found.split(",") if part.strip()]
|
|
47
|
+
if isinstance(found, list):
|
|
48
|
+
items = cast("list[object]", found)
|
|
49
|
+
return [str(item).strip() for item in items if str(item).strip()]
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def blocked_ids(entry: dict[str, Any]) -> list[str]:
|
|
54
|
+
"""`blocks` — the EXISTING tasks this new card must finish before.
|
|
55
|
+
|
|
56
|
+
The inverse of `after`, and it exists for one flow: an agent mid-task discovers a prerequisite
|
|
57
|
+
and needs "create this card AND make my current task wait for it" to be ONE call. Two calls meant
|
|
58
|
+
two chances to do only the first, and the missing half is always the edge — leaving a card nobody
|
|
59
|
+
is waiting for and a task that looks ready and is not.
|
|
60
|
+
|
|
61
|
+
Ids only, never indexes: a card cannot block one created after it in the same batch without a
|
|
62
|
+
cycle, so accepting an index here would only ever be a mistake worth rejecting.
|
|
63
|
+
"""
|
|
64
|
+
found: object = entry.get("blocks")
|
|
65
|
+
if isinstance(found, str):
|
|
66
|
+
return [part.strip() for part in found.split(",") if part.strip()]
|
|
67
|
+
if isinstance(found, list):
|
|
68
|
+
items = cast("list[object]", found)
|
|
69
|
+
return [str(item).strip() for item in items if str(item).strip()]
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def references(entry: dict[str, Any]) -> list[object]:
|
|
74
|
+
"""The `after` field, however it was packaged.
|
|
75
|
+
|
|
76
|
+
A bare value is wrapped: a model with one dependency writes `after: 0` about as
|
|
77
|
+
often as `after: [0]`, and refusing the first rejects a correct plan over a bracket.
|
|
78
|
+
Returned as `object` on purpose — an index and a task id are both legal, and
|
|
79
|
+
telling them apart is `plan`'s decision, not this module's.
|
|
80
|
+
"""
|
|
81
|
+
found: object = entry.get("after")
|
|
82
|
+
if found is None or found == "":
|
|
83
|
+
return []
|
|
84
|
+
return list(cast("list[object]", found)) if isinstance(found, list) else [found]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Gathering what the state machine is allowed to know.
|
|
2
|
+
|
|
3
|
+
`engine.machine` holds guards that are pure functions of `Facts`, which is what lets every rule be
|
|
4
|
+
tested from literals with no database. Something still has to READ those facts, and this is it — the
|
|
5
|
+
one place that turns a task plus a store plus git into the record the machine decides on.
|
|
6
|
+
|
|
7
|
+
Its own module because the reading is not the deciding. `update` orchestrates; the machine judges;
|
|
8
|
+
this fetches. When a guard needs a new fact, the change is one function here and one line there.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .._clock import now
|
|
14
|
+
from ..contracts import Task
|
|
15
|
+
from ..engine import Facts, branch_state, open_children
|
|
16
|
+
from ..storage import Store
|
|
17
|
+
|
|
18
|
+
__all__ = ["facts_for", "unpushed_on"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def facts_for(store: Store, task: Task, actor: str, *, no_code: bool,
|
|
22
|
+
justification: str) -> Facts:
|
|
23
|
+
"""Everything the guards may ask about, read once."""
|
|
24
|
+
lease = store.leases.get(task["id"])
|
|
25
|
+
return Facts(
|
|
26
|
+
task=task, actor=actor,
|
|
27
|
+
has_live_lease=store.leases.held_by(task["id"], actor, now()),
|
|
28
|
+
commits=len(store.events.of_task(task["id"], kinds=("commit",))),
|
|
29
|
+
open_children=open_children(store, task["id"]),
|
|
30
|
+
no_code=no_code, justification=justification,
|
|
31
|
+
unpushed=unpushed_on(store, lease["branch"] if lease else ""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def unpushed_on(store: Store, branch: str) -> int:
|
|
35
|
+
"""Commits on this branch that no remote has. 0 when there is no branch or no remote.
|
|
36
|
+
|
|
37
|
+
Read at CLOSE time rather than stored: it changes every time anybody pushes, so a cached answer
|
|
38
|
+
would be wrong more often than right.
|
|
39
|
+
|
|
40
|
+
A branch with no upstream returns 0 and not "everything", deliberately. Git has nothing to
|
|
41
|
+
compare against there, so any number would be invented — and a solo developer with no remote at
|
|
42
|
+
all would otherwise see every task reported as unpushed forever.
|
|
43
|
+
"""
|
|
44
|
+
if not branch:
|
|
45
|
+
return 0
|
|
46
|
+
state = branch_state(store.root, branch)
|
|
47
|
+
if not state["exists"] or not state["upstream"]:
|
|
48
|
+
return 0
|
|
49
|
+
return state["ahead"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Freeing one stuck card, and writing down what its worker left behind.
|
|
2
|
+
|
|
3
|
+
Split from `recover` because that module DECIDES what is stuck and this one does the freeing. The note
|
|
4
|
+
each of these writes is the part that matters most: a card that comes back with no explanation looks
|
|
5
|
+
like a card nobody ever started, and the next agent rewrites from zero the file sitting in a directory
|
|
6
|
+
two levels down. It names the PATH — "partial work exists" sends an agent looking, a path sends it
|
|
7
|
+
reading.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .._clock import now
|
|
15
|
+
from ..contracts import Lease
|
|
16
|
+
from ..engine import record
|
|
17
|
+
from ..engine.gitstate import porcelain
|
|
18
|
+
from ..engine.worker import worktree_for
|
|
19
|
+
from ..storage import Store
|
|
20
|
+
|
|
21
|
+
__all__ = ["release_lease", "unassign", "Stuck"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Stuck:
|
|
25
|
+
"""One card that was recovered, and what was left behind in its worktree."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, task: str, actor: str, silent_for: float, commits: int,
|
|
28
|
+
leftovers: list[str], tree: Path) -> None:
|
|
29
|
+
self.task = task
|
|
30
|
+
self.actor = actor
|
|
31
|
+
self.silent_for = silent_for
|
|
32
|
+
self.commits = commits
|
|
33
|
+
"""Commits already bound to the card. These SURVIVE — they are in git."""
|
|
34
|
+
|
|
35
|
+
self.leftovers = leftovers
|
|
36
|
+
"""Uncommitted paths in the worker's worktree. The part that is easy to lose."""
|
|
37
|
+
|
|
38
|
+
self.tree = tree
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def release_lease(store: Store, lease: Lease, who: str, quiet: float) -> Stuck:
|
|
42
|
+
"""Hand one card back, and write down what its worker left in the tree.
|
|
43
|
+
|
|
44
|
+
The comment is the whole point of doing this here rather than leaving it to the lease timer: a
|
|
45
|
+
card that came back with no explanation looks like a card nobody ever started, and the next agent
|
|
46
|
+
rewrites from zero the file that is sitting in a directory two levels down.
|
|
47
|
+
"""
|
|
48
|
+
tree = worktree_for(store.root, store.tasks.need(lease["task"]))
|
|
49
|
+
leftovers = porcelain(tree)
|
|
50
|
+
commits = len(store.events.of_task(lease["task"], kinds=("commit",)))
|
|
51
|
+
store.leases.release(lease["task"])
|
|
52
|
+
store.tasks.set_status(lease["task"], "ready", when=now())
|
|
53
|
+
store.tasks.set_assignee(lease["task"], "", when=now())
|
|
54
|
+
record(store, task=lease["task"], actor=who, kind="released",
|
|
55
|
+
body={"text": _note(lease, quiet, commits, leftovers, tree),
|
|
56
|
+
"recovered_from": lease["actor"], "leftovers": leftovers})
|
|
57
|
+
return Stuck(task=lease["task"], actor=lease["actor"], silent_for=quiet,
|
|
58
|
+
commits=commits, leftovers=leftovers, tree=tree)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def unassign(store: Store, task_id: str, assignee: str, who: str) -> Stuck:
|
|
62
|
+
"""Free a card whose worker was never started. Same bookkeeping, different reason."""
|
|
63
|
+
tree = worktree_for(store.root, store.tasks.need(task_id))
|
|
64
|
+
leftovers = porcelain(tree)
|
|
65
|
+
commits = len(store.events.of_task(task_id, kinds=("commit",)))
|
|
66
|
+
store.tasks.set_assignee(task_id, "", when=now())
|
|
67
|
+
record(store, task=task_id, actor=who, kind="released",
|
|
68
|
+
body={"text": f"Recovered: assigned to {assignee}, which never started. "
|
|
69
|
+
f"Back in the open pool."
|
|
70
|
+
+ (f" UNCOMMITTED work survives in {tree}: {', '.join(leftovers)}."
|
|
71
|
+
if leftovers else ""),
|
|
72
|
+
"recovered_from": assignee, "leftovers": leftovers, "never_started": True})
|
|
73
|
+
return Stuck(task=task_id, actor=assignee, silent_for=0.0, commits=commits,
|
|
74
|
+
leftovers=leftovers, tree=tree)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _note(lease: Lease, quiet: float, commits: int, leftovers: list[str],
|
|
78
|
+
tree: Path) -> str:
|
|
79
|
+
"""The comment left on the card. Written for whoever picks it up next.
|
|
80
|
+
|
|
81
|
+
It names the PATH, not just the fact that something is there: "partial work exists" sends the
|
|
82
|
+
next agent looking, and a path sends it reading.
|
|
83
|
+
"""
|
|
84
|
+
lines = [f"Recovered: {lease['actor']} went silent for {int(quiet // 60)}m and the card was "
|
|
85
|
+
f"handed back."]
|
|
86
|
+
if commits:
|
|
87
|
+
lines.append(f"{commits} commit(s) are already bound to it and are safe in git.")
|
|
88
|
+
if leftovers:
|
|
89
|
+
lines.append(f"UNCOMMITTED work survives in {tree}: {', '.join(leftovers)}. "
|
|
90
|
+
f"Read it before starting from scratch.")
|
|
91
|
+
else:
|
|
92
|
+
lines.append("Nothing uncommitted was left behind.")
|
|
93
|
+
return " ".join(lines)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The block `taskops init` writes into `.gitignore`, and how it grows without being rewritten.
|
|
2
|
+
|
|
3
|
+
Its own module because it stopped being a detail of init the moment it started guarding a
|
|
4
|
+
SECRET. The rule this file encodes is: everything under `.taskops/` is ignored except the
|
|
5
|
+
event log — which is the whole replication story — and the list is written out path by path
|
|
6
|
+
rather than as `.taskops/*` plus exceptions, so that a person reading it can see what each
|
|
7
|
+
line is for.
|
|
8
|
+
|
|
9
|
+
That explicitness has a cost the token found first: a new file under `.taskops/` is TRACKED
|
|
10
|
+
by default. `remote.json` holds a bearer, and a token in git history is still a token after
|
|
11
|
+
somebody deletes the file. Hence `_UPGRADES` — a project initialised by an older taskops must
|
|
12
|
+
gain that line the next time init runs, or upgrading in place is one `git add .` from a leak.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..storage import PROJECT_DIR
|
|
20
|
+
|
|
21
|
+
__all__ = ["ignore"]
|
|
22
|
+
|
|
23
|
+
MARKER = "# taskops"
|
|
24
|
+
|
|
25
|
+
REPORTS_NOTE = f"# {PROJECT_DIR}/reports/ is COMMITTED — a written dossier is not derived state"
|
|
26
|
+
"""A COMMENT, not a rule, and that is the point.
|
|
27
|
+
|
|
28
|
+
`reports/` is tracked, so the correct entry here is no entry at all — but "no entry" is
|
|
29
|
+
indistinguishable from an oversight, and the next person tidying this block would add
|
|
30
|
+
`{PROJECT_DIR}/*` and untrack every report in the project. The line says why the hole exists.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
REMOTE_RULE = f"{PROJECT_DIR}/remote.json"
|
|
34
|
+
"""The remote's URL and its BEARER TOKEN. The one line here that guards a secret.
|
|
35
|
+
|
|
36
|
+
Everything else is ignored because committing it would be noise; this one because committing
|
|
37
|
+
it would be a leak that outlives the commit. The file is also written 0600
|
|
38
|
+
(`usecases/remote.py`) — belt and braces, on purpose, because the two failures are different:
|
|
39
|
+
the mode stops another account on this machine, the ignore stops the whole internet.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
GUIDE_NOTE = f"{PROJECT_DIR}/GUIDE.md"
|
|
43
|
+
"""Ignored rather than committed, which looks wrong at first.
|
|
44
|
+
|
|
45
|
+
It is GENERATED: it ships inside the package and init rewrites it every run, so it always
|
|
46
|
+
describes the version of taskops actually installed. Tracking a file that a command overwrites
|
|
47
|
+
would leave `git status` dirty after every init, and two developers on different versions would
|
|
48
|
+
fight over its contents forever. It also removed a real merge conflict — two clones that each
|
|
49
|
+
ran `taskops init` could not pull from one another, because the incoming commit carried files
|
|
50
|
+
both sides had independently created untracked.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
BLOCK = f"""
|
|
54
|
+
{MARKER} — commit events.jsonl and NOTHING else under {PROJECT_DIR}/
|
|
55
|
+
{PROJECT_DIR}/db.sqlite
|
|
56
|
+
{PROJECT_DIR}/db.sqlite-wal
|
|
57
|
+
{PROJECT_DIR}/db.sqlite-shm
|
|
58
|
+
{GUIDE_NOTE}
|
|
59
|
+
{PROJECT_DIR}/workers/
|
|
60
|
+
{PROJECT_DIR}/trees/
|
|
61
|
+
{REMOTE_RULE}
|
|
62
|
+
{REPORTS_NOTE}
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_UPGRADES = (REPORTS_NOTE, REMOTE_RULE)
|
|
66
|
+
"""Lines added to the block AFTER projects existed with it. Order is the order they land in."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ignore(root: Path) -> None:
|
|
70
|
+
"""Write the block, once. Matched on the MARKER rather than on the paths.
|
|
71
|
+
|
|
72
|
+
A developer may reformat those lines, and appending a duplicate block on every init is
|
|
73
|
+
how a `.gitignore` becomes forty lines of the same thing.
|
|
74
|
+
"""
|
|
75
|
+
path = root / ".gitignore"
|
|
76
|
+
current = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
77
|
+
if MARKER in current:
|
|
78
|
+
_upgrade(path, current)
|
|
79
|
+
return
|
|
80
|
+
separator = "" if current.endswith("\n") or not current else "\n"
|
|
81
|
+
path.write_text(current + separator + BLOCK, encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _upgrade(path: Path, current: str) -> None:
|
|
85
|
+
"""Append whatever this taskops adds to a block an older one wrote. Idempotent.
|
|
86
|
+
|
|
87
|
+
Appending rather than rewriting the block: the developer may have edited those lines, and
|
|
88
|
+
a tool that replaces a file it does not own loses whatever they added.
|
|
89
|
+
"""
|
|
90
|
+
missing = [line for line in _UPGRADES if line not in current]
|
|
91
|
+
if not missing:
|
|
92
|
+
return
|
|
93
|
+
separator = "" if current.endswith("\n") else "\n"
|
|
94
|
+
path.write_text(current + separator + "\n".join(missing) + "\n", encoding="utf-8")
|