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.
Files changed (193) hide show
  1. taskops/__init__.py +39 -0
  2. taskops/_clock.py +32 -0
  3. taskops/_errors.py +160 -0
  4. taskops/_ids.py +63 -0
  5. taskops/_types.py +106 -0
  6. taskops/_version.py +7 -0
  7. taskops/assets/GUIDE.md +220 -0
  8. taskops/contracts/__init__.py +96 -0
  9. taskops/contracts/_fields.py +113 -0
  10. taskops/contracts/actor.py +30 -0
  11. taskops/contracts/board.py +142 -0
  12. taskops/contracts/commit.py +36 -0
  13. taskops/contracts/day.py +150 -0
  14. taskops/contracts/dep.py +23 -0
  15. taskops/contracts/event.py +58 -0
  16. taskops/contracts/gitstate.py +45 -0
  17. taskops/contracts/index.py +37 -0
  18. taskops/contracts/lease.py +43 -0
  19. taskops/contracts/log.py +59 -0
  20. taskops/contracts/remote.py +48 -0
  21. taskops/contracts/results.py +84 -0
  22. taskops/contracts/task.py +94 -0
  23. taskops/contracts/tools.py +110 -0
  24. taskops/contracts/wire.py +60 -0
  25. taskops/engine/__init__.py +34 -0
  26. taskops/engine/_blocks.py +48 -0
  27. taskops/engine/_briefs.py +92 -0
  28. taskops/engine/_chunks.py +66 -0
  29. taskops/engine/_closed.py +65 -0
  30. taskops/engine/_entries.py +126 -0
  31. taskops/engine/_events.py +55 -0
  32. taskops/engine/_opened.py +51 -0
  33. taskops/engine/_process.py +80 -0
  34. taskops/engine/_prompts.py +98 -0
  35. taskops/engine/_stream.py +129 -0
  36. taskops/engine/activity.py +94 -0
  37. taskops/engine/bus.py +42 -0
  38. taskops/engine/commitline.py +110 -0
  39. taskops/engine/day.py +142 -0
  40. taskops/engine/diffstat.py +63 -0
  41. taskops/engine/gitio.py +108 -0
  42. taskops/engine/gitstate.py +96 -0
  43. taskops/engine/history.py +76 -0
  44. taskops/engine/identity.py +84 -0
  45. taskops/engine/log.py +68 -0
  46. taskops/engine/machine.py +160 -0
  47. taskops/engine/narrate.py +91 -0
  48. taskops/engine/project.py +57 -0
  49. taskops/engine/replay.py +142 -0
  50. taskops/engine/reports.py +67 -0
  51. taskops/engine/scheduler.py +135 -0
  52. taskops/engine/transcript.py +127 -0
  53. taskops/engine/wire.py +92 -0
  54. taskops/engine/worker.py +115 -0
  55. taskops/py.typed +0 -0
  56. taskops/render/__init__.py +40 -0
  57. taskops/render/_closed_days.py +64 -0
  58. taskops/render/_dossier.py +68 -0
  59. taskops/render/_opened.py +64 -0
  60. taskops/render/_sections.py +51 -0
  61. taskops/render/_tasklist.py +72 -0
  62. taskops/render/_text.py +74 -0
  63. taskops/render/_verbatim.py +65 -0
  64. taskops/render/ansi.py +92 -0
  65. taskops/render/board.py +55 -0
  66. taskops/render/day.py +102 -0
  67. taskops/render/dispatch.py +104 -0
  68. taskops/render/inbox.py +27 -0
  69. taskops/render/log.py +47 -0
  70. taskops/render/recover.py +64 -0
  71. taskops/render/report.py +51 -0
  72. taskops/render/reports.py +57 -0
  73. taskops/render/results.py +96 -0
  74. taskops/render/session.py +75 -0
  75. taskops/render/task.py +88 -0
  76. taskops/render/tasklist.py +65 -0
  77. taskops/storage/__init__.py +36 -0
  78. taskops/storage/_ddl.py +78 -0
  79. taskops/storage/_delivered.py +51 -0
  80. taskops/storage/_deps.py +69 -0
  81. taskops/storage/_events.py +127 -0
  82. taskops/storage/_leases.py +108 -0
  83. taskops/storage/_rows.py +90 -0
  84. taskops/storage/_tasks.py +124 -0
  85. taskops/storage/locate.py +76 -0
  86. taskops/storage/schema.py +66 -0
  87. taskops/storage/store.py +120 -0
  88. taskops/storage/sync.py +139 -0
  89. taskops/transports/__init__.py +6 -0
  90. taskops/transports/cli/__init__.py +0 -0
  91. taskops/transports/cli/commands/__init__.py +3 -0
  92. taskops/transports/cli/commands/_digest.py +64 -0
  93. taskops/transports/cli/commands/_serve_init.py +65 -0
  94. taskops/transports/cli/commands/_shared.py +50 -0
  95. taskops/transports/cli/commands/_tasks_args.py +112 -0
  96. taskops/transports/cli/commands/_window.py +45 -0
  97. taskops/transports/cli/commands/ask.py +27 -0
  98. taskops/transports/cli/commands/dispatch.py +43 -0
  99. taskops/transports/cli/commands/init.py +48 -0
  100. taskops/transports/cli/commands/log.py +22 -0
  101. taskops/transports/cli/commands/plan.py +43 -0
  102. taskops/transports/cli/commands/pushpull.py +53 -0
  103. taskops/transports/cli/commands/recover.py +30 -0
  104. taskops/transports/cli/commands/remote.py +56 -0
  105. taskops/transports/cli/commands/report.py +82 -0
  106. taskops/transports/cli/commands/run_.py +60 -0
  107. taskops/transports/cli/commands/serve.py +74 -0
  108. taskops/transports/cli/commands/sync.py +22 -0
  109. taskops/transports/cli/commands/tasks.py +79 -0
  110. taskops/transports/cli/commands/ui.py +72 -0
  111. taskops/transports/cli/commands/update.py +25 -0
  112. taskops/transports/cli/main.py +85 -0
  113. taskops/transports/hooks/__init__.py +15 -0
  114. taskops/transports/hooks/__main__.py +63 -0
  115. taskops/transports/hooks/_args.py +28 -0
  116. taskops/transports/hooks/claude.py +78 -0
  117. taskops/transports/hooks/commit.py +61 -0
  118. taskops/transports/hooks/events.py +107 -0
  119. taskops/transports/hooks/record.py +55 -0
  120. taskops/transports/http/__init__.py +21 -0
  121. taskops/transports/http/_handler.py +132 -0
  122. taskops/transports/http/_wire.py +79 -0
  123. taskops/transports/http/_wsframes.py +116 -0
  124. taskops/transports/http/agentapi.py +69 -0
  125. taskops/transports/http/api.py +122 -0
  126. taskops/transports/http/exchange.py +80 -0
  127. taskops/transports/http/live.py +160 -0
  128. taskops/transports/http/policy.py +110 -0
  129. taskops/transports/http/projects.py +116 -0
  130. taskops/transports/http/reports.py +75 -0
  131. taskops/transports/http/router.py +90 -0
  132. taskops/transports/http/server.py +50 -0
  133. taskops/transports/http/static.py +86 -0
  134. taskops/transports/http/ui/app.js +59 -0
  135. taskops/transports/http/ui/index.html +23 -0
  136. taskops/transports/http/ui/style.css +1 -0
  137. taskops/transports/http/websocket.py +60 -0
  138. taskops/transports/mcp/__init__.py +19 -0
  139. taskops/transports/mcp/__main__.py +7 -0
  140. taskops/transports/mcp/_descriptions.py +102 -0
  141. taskops/transports/mcp/_reads.py +82 -0
  142. taskops/transports/mcp/_writes.py +72 -0
  143. taskops/transports/mcp/answers.py +44 -0
  144. taskops/transports/mcp/arguments.py +104 -0
  145. taskops/transports/mcp/dispatch.py +47 -0
  146. taskops/transports/mcp/protocol.py +83 -0
  147. taskops/transports/mcp/schema.py +50 -0
  148. taskops/transports/mcp/server.py +49 -0
  149. taskops/transports/mcp/tools.py +66 -0
  150. taskops/usecases/__init__.py +56 -0
  151. taskops/usecases/_entry.py +84 -0
  152. taskops/usecases/_facts.py +49 -0
  153. taskops/usecases/_freeing.py +93 -0
  154. taskops/usecases/_gitignore.py +94 -0
  155. taskops/usecases/_mirroring.py +57 -0
  156. taskops/usecases/_narrating.py +77 -0
  157. taskops/usecases/_project.py +67 -0
  158. taskops/usecases/_range.py +102 -0
  159. taskops/usecases/_reasons.py +54 -0
  160. taskops/usecases/_remotefile.py +62 -0
  161. taskops/usecases/_reportsync.py +108 -0
  162. taskops/usecases/_routing.py +91 -0
  163. taskops/usecases/_wireclient.py +141 -0
  164. taskops/usecases/ask.py +41 -0
  165. taskops/usecases/claim.py +104 -0
  166. taskops/usecases/dispatch.py +160 -0
  167. taskops/usecases/dossier.py +155 -0
  168. taskops/usecases/edit.py +71 -0
  169. taskops/usecases/exchange.py +86 -0
  170. taskops/usecases/feed.py +138 -0
  171. taskops/usecases/guard.py +122 -0
  172. taskops/usecases/hooks.py +127 -0
  173. taskops/usecases/index.py +62 -0
  174. taskops/usecases/ingest.py +60 -0
  175. taskops/usecases/log.py +134 -0
  176. taskops/usecases/narration.py +91 -0
  177. taskops/usecases/plan.py +128 -0
  178. taskops/usecases/pushpull.py +119 -0
  179. taskops/usecases/recover.py +84 -0
  180. taskops/usecases/remote.py +78 -0
  181. taskops/usecases/report.py +87 -0
  182. taskops/usecases/reportfile.py +106 -0
  183. taskops/usecases/session.py +134 -0
  184. taskops/usecases/setup.py +92 -0
  185. taskops/usecases/sync.py +78 -0
  186. taskops/usecases/update.py +123 -0
  187. taskops/usecases/view.py +112 -0
  188. taskops_cli-0.2.0.dist-info/METADATA +291 -0
  189. taskops_cli-0.2.0.dist-info/RECORD +193 -0
  190. taskops_cli-0.2.0.dist-info/WHEEL +5 -0
  191. taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
  192. taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  193. 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")