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,43 @@
1
+ """Preparing worker agents, one per card — the flags and the call `taskops run` is built on.
2
+
3
+ `taskops dispatch` was hidden once `taskops run` took over the half of this that starts
4
+ processes, and it is gone now: an orchestrator dispatches over MCP (`taskops_dispatch`), and
5
+ the person who wants processes types `run`. What stays is this module, because `run`'s flags
6
+ are declared here — a second copy of that parser would drift the first time either grew an
7
+ option.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+
14
+ from ....render import render_dispatch
15
+ from ....usecases import dispatch as launch_workers
16
+ from ._shared import add_target, repo_of
17
+
18
+ __all__ = ["add_dispatch_args", "dispatch_with"]
19
+
20
+
21
+ def add_dispatch_args(parser: argparse.ArgumentParser) -> None:
22
+ """Every flag a dispatch takes except `--spawn` — which is the one thing that tells the
23
+ two commands apart, and so is declared by each of them."""
24
+ add_target(parser)
25
+ parser.add_argument("tasks", nargs="*", help="task ids (default: the best ready ones)")
26
+ parser.add_argument("--count", type=int, default=0, help="how many to launch (default 3)")
27
+ parser.add_argument("--prefix", default="", help="worker name prefix (default 'w')")
28
+ parser.add_argument("--model", default="", help="model for the workers")
29
+ parser.add_argument("--dry-run", action="store_true", dest="dry_run",
30
+ help="show what would launch, change nothing")
31
+ parser.add_argument("--actor", default="", help="who is dispatching")
32
+
33
+
34
+ def dispatch_with(args: argparse.Namespace, *, spawn: bool) -> str:
35
+ """The one call both commands make. `spawn` is the only thing either of them decides.
36
+
37
+ `--use-api-key` is read with `getattr` because only `run` declares it; `dispatch` keeps the
38
+ flag set it always had rather than growing one that its own help would have to explain."""
39
+ return render_dispatch(launch_workers(
40
+ repo_of(args), tasks=tuple(str(t) for t in args.tasks), count=int(args.count),
41
+ actor=str(args.actor), prefix=str(args.prefix), model=str(args.model),
42
+ dry_run=bool(args.dry_run), spawn=spawn,
43
+ use_api_key=bool(getattr(args, "use_api_key", False))))
@@ -0,0 +1,48 @@
1
+ """`taskops init` — make a repository coordinated, and say what actually happened."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ....usecases import InitReport
8
+ from ....usecases import init as setup
9
+ from ._shared import add_target, repo_of
10
+
11
+ __all__ = ["register"]
12
+
13
+
14
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
15
+ parser = sub.add_parser("init", help="create .taskops/ and install the git hooks")
16
+ add_target(parser)
17
+ parser.add_argument("--no-hooks", action="store_true", dest="no_hooks",
18
+ help="skip the git hooks (the MCP tools work without them)")
19
+ parser.set_defaults(run=run)
20
+
21
+
22
+ def run(args: argparse.Namespace) -> str:
23
+ report = setup(repo_of(args), install_git_hooks=not args.no_hooks)
24
+ return _describe(report)
25
+
26
+
27
+ def _describe(report: InitReport) -> str:
28
+ """Report the SKIPPED hooks too. An init that quietly installed two of three would
29
+ leave a project whose commits are only sometimes recorded, which is worse than none.
30
+
31
+ And say that re-running is the REPAIR. Hooks live in `.git/hooks`, which is untracked, so
32
+ a fresh clone has none — and a repository set up before the wiring moved to
33
+ `taskops.transports.hooks` has hook lines naming a command that no longer exists. Every
34
+ hook line ends in `|| true`, so that failure is completely silent; the only thing a person
35
+ can act on is knowing that `taskops init` again fixes it.
36
+ """
37
+ lines = [f"{'created' if report.created else 'already a'} taskops project at "
38
+ f"{report.root}"]
39
+ if report.adopted:
40
+ lines.append(f"adopted {report.adopted} change(s) from the committed log")
41
+ if report.hooks:
42
+ lines.append(f"hooks installed: {', '.join(report.hooks)}")
43
+ for skipped in report.skipped:
44
+ lines.append(f"hook skipped — {skipped}")
45
+ lines.append("")
46
+ lines.append("Register the MCP server with:")
47
+ lines.append(" claude mcp add taskops -- python3 -m taskops.transports.mcp")
48
+ return "\n".join(lines)
@@ -0,0 +1,22 @@
1
+ """Read what the agent said while working on a card — reached as `taskops tasks log`.
2
+
3
+ The top-level `taskops log` was one of the agent's, and the agent reads a card over MCP. The
4
+ function is untouched: `tasks log` points straight at it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+
11
+ from ....render import render_log
12
+ from ....usecases import session_log
13
+ from ._shared import repo_of
14
+
15
+ __all__ = ["run"]
16
+
17
+
18
+ def run(args: argparse.Namespace) -> str:
19
+ from ....usecases.log import MAX_ENTRIES
20
+
21
+ return render_log(session_log(repo_of(args), str(args.task),
22
+ limit=int(args.limit) or MAX_ENTRIES))
@@ -0,0 +1,43 @@
1
+ """Create tasks from a JSON file or stdin — reached as `taskops tasks plan`.
2
+
3
+ JSON rather than flags: a plan is a nested structure with dependencies in it, and every
4
+ attempt to express one in shell arguments turns into a worse JSON. `-` reads stdin, which is
5
+ what makes `... | taskops tasks plan -` work from a script.
6
+
7
+ The top-level `taskops plan` was the agent's, and agents have `taskops_plan`. The subparser
8
+ went; the function is still the only creator of a card, which is what keeps a hand-typed card
9
+ carrying the same event body a planned one does.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from typing import Any, cast
18
+
19
+ from ...._errors import BadRequest
20
+ from ....render import render_plan
21
+ from ....usecases import plan as create
22
+ from ._shared import repo_of
23
+
24
+ __all__ = ["run"]
25
+
26
+
27
+ def run(args: argparse.Namespace) -> str:
28
+ return render_plan(create(repo_of(args), _entries(str(args.source)),
29
+ actor=str(args.actor)))
30
+
31
+
32
+ def _entries(source: str) -> list[dict[str, Any]]:
33
+ raw = sys.stdin.read() if source == "-" else open(source, encoding="utf-8").read()
34
+ try:
35
+ parsed: object = json.loads(raw)
36
+ except json.JSONDecodeError as bad:
37
+ raise BadRequest(f"{source} is not valid JSON: {bad}") from bad
38
+ if isinstance(parsed, dict):
39
+ parsed = [cast("object", parsed)]
40
+ if not isinstance(parsed, list):
41
+ raise BadRequest("expected a JSON array of {title, spec, after?} objects")
42
+ items = cast("list[object]", parsed)
43
+ return [cast("dict[str, Any]", item) for item in items if isinstance(item, dict)]
@@ -0,0 +1,53 @@
1
+ """`taskops push` and `taskops pull` — sync against the remote instead of through git.
2
+
3
+ Two commands in one module because they are one summary: both print the same four numbers,
4
+ and a second module would be a second place to keep that sentence honest.
5
+
6
+ A conflicted report is printed on its OWN line, after the summary, and the exit code stays 0.
7
+ Nothing was lost — the refusal is the point — so this is a decision waiting for a person, not
8
+ a failure; making it exit non-zero would break the `push && git push` line everyone writes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+
15
+ from ....usecases import Exchange
16
+ from ....usecases import pull as receive
17
+ from ....usecases import push as send
18
+ from ._shared import add_target, repo_of
19
+
20
+ __all__ = ["register"]
21
+
22
+
23
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
24
+ pushing = sub.add_parser("push", help="send this board to the remote, then take theirs")
25
+ add_target(pushing)
26
+ pushing.add_argument("--force", action="store_true",
27
+ help="overwrite a report the server refused to replace "
28
+ "(whatever narration is on the server is lost)")
29
+ pushing.set_defaults(run=run_push)
30
+ pulling = sub.add_parser("pull", help="take the remote's events and reports")
31
+ add_target(pulling)
32
+ pulling.set_defaults(run=run_pull)
33
+
34
+
35
+ def run_push(args: argparse.Namespace) -> str:
36
+ return _summary("pushed", send(repo_of(args), force=bool(args.force)))
37
+
38
+
39
+ def run_pull(args: argparse.Namespace) -> str:
40
+ return _summary("pulled", receive(repo_of(args)))
41
+
42
+
43
+ def _summary(verb: str, done: Exchange) -> str:
44
+ """Events both ways, reports both ways, then every stand-off in full."""
45
+ swap = done.reports
46
+ parts = [f"{done.accepted} event(s) out", f"{done.events_in} in"]
47
+ if done.unblocked:
48
+ parts.append(f"{len(done.unblocked)} now ready")
49
+ if swap.uploaded or swap.downloaded:
50
+ parts.append(f"reports {len(swap.uploaded)} up, {len(swap.downloaded)} down")
51
+ lines = [f"{verb}: " + ", ".join(parts)]
52
+ lines += [f" ! {clash}" for clash in swap.conflicts]
53
+ return "\n".join(lines)
@@ -0,0 +1,30 @@
1
+ """`taskops recover` — hand back the cards of workers that died."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ....render import render_recover
8
+ from ....usecases import recover as run_recovery
9
+ from ._shared import add_target, repo_of
10
+
11
+ __all__ = ["register"]
12
+
13
+
14
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
15
+ parser = sub.add_parser("recover", help="release cards held by workers that went silent")
16
+ add_target(parser)
17
+ parser.add_argument("--force", action="store_true",
18
+ help="release even the workers still reporting")
19
+ parser.add_argument("--grace", type=float, default=0.0,
20
+ help="seconds of silence to tolerate (default: the fleet's own grace)")
21
+ parser.add_argument("--actor", default="", help="who is recovering")
22
+ parser.set_defaults(run=run)
23
+
24
+
25
+ def run(args: argparse.Namespace) -> str:
26
+ from ...._clock import HEARTBEAT_GRACE
27
+
28
+ return render_recover(run_recovery(
29
+ repo_of(args), actor=str(args.actor), force=bool(args.force),
30
+ grace=float(args.grace) or HEARTBEAT_GRACE))
@@ -0,0 +1,56 @@
1
+ """`taskops remote` — where this project syncs, and the token that proves who it is.
2
+
3
+ With no subcommand it SHOWS, for the same reason `taskops tasks` lists: the thing you want
4
+ nine times out of ten should not cost a second turn. What it shows never includes the token —
5
+ it prints a length and nothing else, because the whole point of a terminal is that somebody
6
+ may be looking at it, and this is the one value in the system that a screenshot leaks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from ....usecases import add_remote, read_remote, remove_remote
14
+ from ._shared import add_target, repo_of
15
+
16
+ __all__ = ["register"]
17
+
18
+
19
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
20
+ parser = sub.add_parser("remote", help="show, set or drop the server this project syncs with")
21
+ add_target(parser)
22
+ parser.set_defaults(run=run_show, subcommand="")
23
+ inner = parser.add_subparsers(dest="subcommand", metavar="<subcommand>")
24
+ adding = inner.add_parser("add", help="register the one remote for this project")
25
+ adding.add_argument("url", help="the server's base URL, e.g. https://taskops.example.com")
26
+ adding.add_argument("--token", required=True, help="the bearer the server issued you")
27
+ add_target(adding, inherit=True)
28
+ adding.set_defaults(run=run_add)
29
+ dropping = inner.add_parser("remove", help="forget the remote and its token")
30
+ add_target(dropping, inherit=True)
31
+ dropping.set_defaults(run=run_remove)
32
+
33
+
34
+ def run_show(args: argparse.Namespace) -> str:
35
+ found = read_remote(repo_of(args))
36
+ if found is None:
37
+ return ("no remote — this project syncs through git (`taskops sync`). Add one with "
38
+ "`taskops remote add <url> --token <token>`")
39
+ return (f"{found['url']}\n token {_masked(found['token'])}\n"
40
+ f" cursor seq {found['cursor']} of the server's log")
41
+
42
+
43
+ def run_add(args: argparse.Namespace) -> str:
44
+ added = add_remote(repo_of(args), str(args.url), str(args.token))
45
+ return (f"remote set to {added['url']} — the token is in .taskops/remote.json (mode 0600, "
46
+ f"gitignored). `taskops push` to send this board up.")
47
+
48
+
49
+ def run_remove(args: argparse.Namespace) -> str:
50
+ return f"remote {remove_remote(repo_of(args))} removed, token deleted"
51
+
52
+
53
+ def _masked(token: str) -> str:
54
+ """Its LENGTH, never a prefix. A prefix is enough to recognise a token in a leaked log
55
+ and enough to confirm a guess, and it answers no question worth answering."""
56
+ return f"set ({len(token)} characters)" if token else "MISSING — the server will refuse"
@@ -0,0 +1,82 @@
1
+ """`taskops report` — the generated views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from ...._errors import BadRequest
9
+ from ....render import render_board, render_day, render_fleet, render_standup
10
+ from ....usecases import (
11
+ Selector,
12
+ board,
13
+ fleet,
14
+ period,
15
+ standup,
16
+ write_report,
17
+ )
18
+ from ._digest import stream_digest
19
+ from ._shared import add_target, repo_of
20
+ from ._window import selector
21
+
22
+ __all__ = ["register", "DOSSIERS"]
23
+
24
+ DOSSIERS = ("day", "range", "all")
25
+ """The kinds that cover a span of days and can therefore be written and narrated. `board` and
26
+ `standup` cannot: one is a snapshot with no window, the other moves every time it is run."""
27
+
28
+
29
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
30
+ parser = sub.add_parser("report", help="board, standup, day, range, all, or fleet")
31
+ add_target(parser)
32
+ parser.add_argument("kind", nargs="?", default="board",
33
+ choices=("board", "standup", "day", "range", "all", "fleet"))
34
+ parser.add_argument("--since", default="24h", help="standup window: 24h, 7d, 30m")
35
+ parser.add_argument("--date", default="today",
36
+ help="which day the dossier covers: today, yesterday, YYYY-MM-DD")
37
+ parser.add_argument("--last", default="",
38
+ help="range: how far back from --to, e.g. 7d, 2w, 1m")
39
+ parser.add_argument("--from", dest="from_date", default="",
40
+ help="range: the first day, YYYY-MM-DD")
41
+ parser.add_argument("--to", default="",
42
+ help="range: the last day (inclusive), YYYY-MM-DD; default today")
43
+ parser.add_argument("--actor", default="", help="restrict a standup to one actor")
44
+ parser.add_argument("--write", action="store_true",
45
+ help="persist the dossier to .taskops/reports/<label>.md")
46
+ parser.add_argument("--force", action="store_true",
47
+ help="with --write: regenerate a report that already exists")
48
+ parser.add_argument("--digest", action="store_true",
49
+ help="write it, then have Claude narrate what it means "
50
+ "(uses your logged-in subscription, never an API key)")
51
+ parser.add_argument("--model", default="",
52
+ help="with --digest: the model to narrate with")
53
+ parser.set_defaults(run=run)
54
+
55
+
56
+ def run(args: argparse.Namespace) -> str:
57
+ where = repo_of(args)
58
+ if args.kind not in DOSSIERS and (args.write or args.force or args.digest):
59
+ raise BadRequest("--write, --force and --digest only apply to `report day`, "
60
+ "`report range` and `report all` — a board and a standup are "
61
+ "regenerated on demand")
62
+ if args.kind == "standup":
63
+ return render_standup(standup(where, since=str(args.since), actor=str(args.actor)))
64
+ if args.kind in DOSSIERS:
65
+ return _dossier(args, where, selector(args))
66
+ if args.kind == "fleet":
67
+ return render_fleet(fleet(where))
68
+ return render_board(board(where))
69
+
70
+
71
+ def _dossier(args: argparse.Namespace, where: Path, sel: Selector) -> str:
72
+ """Printed, or written and its PATH printed.
73
+
74
+ The path rather than the dossier: what the caller does next is read or commit that file,
75
+ and a command that dumps 300 lines it just saved makes the one useful line scroll away.
76
+ """
77
+ if args.digest:
78
+ return stream_digest(where, sel, kind=str(args.kind), model=str(args.model),
79
+ force=bool(args.force))
80
+ if not args.write:
81
+ return render_day(period(where, sel))
82
+ return f"wrote {write_report(where, sel, force=bool(args.force))}"
@@ -0,0 +1,60 @@
1
+ """`taskops run` — the spawn path, under the name that says what it does.
2
+
3
+ The capability existed as `dispatch --spawn`, which is how a flag hides a decision: a person
4
+ scanning the help had no way to tell that one word turns a free preparation into N NEW billed
5
+ Claude sessions. Same code, honest name, and the price stated BEFORE anything starts rather
6
+ than on the invoice.
7
+
8
+ `--yes` exists because a fleet script cannot answer a prompt. Everything else that reads a
9
+ board is free; this is the one command that is not, so an unattended caller has to say so.
10
+
11
+ `--use-api-key` is the other half of the same honesty. Workers do NOT inherit the Anthropic
12
+ credentials (`engine.worker.DROPPED_ENV`), because an exported key silently outranks the
13
+ subscription and turns every dispatched worker into a per-token invoice. This flag gives the
14
+ capability back to whoever types it, which is the only person entitled to decide it. CLI only:
15
+ no MCP tool can spend an API balance on the caller's behalf.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import sys
22
+
23
+ from .dispatch import add_dispatch_args, dispatch_with
24
+
25
+ __all__ = ["register", "WARNING"]
26
+
27
+ WARNING = ("⚠ each worker is a NEW Claude session on your logged-in subscription, counting "
28
+ "against its limits — for free parallelism dispatch sub-agents from a session "
29
+ "(taskops_dispatch). Add --use-api-key to bill per token instead.")
30
+
31
+
32
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
33
+ parser = sub.add_parser("run",
34
+ help="run cards with headless Claude workers (experimental, billed)")
35
+ add_dispatch_args(parser)
36
+ parser.add_argument("--yes", action="store_true",
37
+ help="skip the confirmation (for unattended callers)")
38
+ parser.add_argument("--use-api-key", action="store_true", dest="use_api_key",
39
+ help="let the workers see ANTHROPIC_API_KEY — they then BILL PER TOKEN "
40
+ "instead of using your logged-in subscription")
41
+ parser.set_defaults(run=run)
42
+
43
+
44
+ def run(args: argparse.Namespace) -> str:
45
+ """Warn, confirm, then spawn. A dry run is free, so it skips both."""
46
+ if not args.dry_run and not _confirmed(bool(args.yes)):
47
+ return "aborted — nothing started"
48
+ return dispatch_with(args, spawn=True)
49
+
50
+
51
+ def _confirmed(yes: bool) -> bool:
52
+ """The warning goes to stderr so a piped render stays a render; the answer comes from
53
+ stdin, and no tty (a hook, a CI job) counts as "did not agree" rather than as yes."""
54
+ print(WARNING, file=sys.stderr)
55
+ if yes:
56
+ return True
57
+ try:
58
+ return input("start them? [y/N] ").strip().lower() in ("y", "yes")
59
+ except EOFError:
60
+ return False
@@ -0,0 +1,74 @@
1
+ """`taskops serve` — many projects' boards on one port, each behind its own token.
2
+
3
+ `taskops ui` serves the repository you are standing in; this serves a DIRECTORY OF PROJECTS and
4
+ is the thing meant to sit on a host. The difference that matters is not the routing — see
5
+ `transports.http.projects` for that — it is the trust model: there is no ambient "it's only
6
+ loopback" here, so every project has a secret and a project without one is not served.
7
+
8
+ It runs no git hooks and no guard, and `serve init` creates the project with
9
+ `install_git_hooks=False`: this is a store of boards, not a working tree. The code stays in git
10
+ where it belongs; what centralises is the board, so that two agents racing for the same task
11
+ compete inside ONE sqlite.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from ....transports.http import bound_port, mount, serve_route
21
+ from ._serve_init import create
22
+
23
+ __all__ = ["register"]
24
+
25
+ DEFAULT_PORT = 2160
26
+ DEFAULT_ROOT = "~/taskops-server"
27
+
28
+ _HELP = "many projects' boards on one port, each behind a token"
29
+
30
+
31
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
32
+ parser = sub.add_parser("serve", help=_HELP)
33
+ _root(parser, inherit=False)
34
+ parser.add_argument("--host", default="127.0.0.1",
35
+ help="bind address (default: loopback only)")
36
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
37
+ parser.add_argument("--readonly", action="store_true",
38
+ help="refuse every write, on every project")
39
+ parser.add_argument("--rate-limit", type=int, default=0, dest="rate_limit",
40
+ help="requests per minute, 0 for none")
41
+ parser.set_defaults(run=run)
42
+ inner = parser.add_subparsers(dest="serve_command", metavar="<subcommand>")
43
+ created = inner.add_parser("init", help="create a project here and mint its token")
44
+ created.add_argument("project", help="its name and its URL segment: [a-z0-9-]")
45
+ _root(created, inherit=True)
46
+ created.set_defaults(run=run_init)
47
+
48
+
49
+ def _root(parser: argparse.ArgumentParser, *, inherit: bool) -> None:
50
+ """`--root` on BOTH parsers, so `serve init x --root /srv` reads as naturally as
51
+ `serve --root /srv init x`. The subcommand's copy defaults to SUPPRESS, or argparse would
52
+ write its default over the value the parent already parsed."""
53
+ parser.add_argument("--root", default=argparse.SUPPRESS if inherit else DEFAULT_ROOT,
54
+ help=f"the directory of projects (default: {DEFAULT_ROOT})")
55
+
56
+
57
+ def run(args: argparse.Namespace) -> str:
58
+ """Blocks until interrupted. The banner goes to stderr, like `taskops ui`'s."""
59
+ root = Path(str(args.root)).expanduser().resolve()
60
+ route = mount(root, readonly=bool(args.readonly), rate_limit=int(args.rate_limit))
61
+ server = serve_route(str(args.host), int(args.port), route)
62
+ print(f"taskops serve → http://{args.host}:{bound_port(server)}/<project>/ ({root})"
63
+ + (" [read-only]" if args.readonly else ""), file=sys.stderr)
64
+ try:
65
+ server.serve_forever()
66
+ except KeyboardInterrupt:
67
+ print("\nstopped", file=sys.stderr)
68
+ finally:
69
+ server.server_close()
70
+ return ""
71
+
72
+
73
+ def run_init(args: argparse.Namespace) -> str:
74
+ return create(Path(str(args.root)).expanduser().resolve(), str(args.project))
@@ -0,0 +1,22 @@
1
+ """`taskops sync` — reconcile with what git brought in. Called from post-merge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ....usecases import sync as reconcile
8
+ from ._shared import add_target, repo_of
9
+
10
+ __all__ = ["register"]
11
+
12
+
13
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
14
+ parser = sub.add_parser("sync", help="import and export the committed event log")
15
+ add_target(parser)
16
+ parser.set_defaults(run=run)
17
+
18
+
19
+ def run(args: argparse.Namespace) -> str:
20
+ report = reconcile(repo_of(args))
21
+ unblocked = (f", {len(report.unblocked)} now ready" if report.unblocked else "")
22
+ return (f"synced: {report.imported} event(s) in, {report.exported} out{unblocked}")
@@ -0,0 +1,79 @@
1
+ """`taskops tasks` — everything a person does to the task list, under one name.
2
+
3
+ Almost nothing here is new behaviour: the subcommands reach `run` functions that already
4
+ existed, and the two exceptions (`list`, `add`) are thin over `usecases.board` and
5
+ `usecases.plan`. The grouping is for the reader of `--help`, which had grown to nineteen
6
+ commands mixing three audiences — a person's task list, an agent's claim/update protocol,
7
+ and the git and session plumbing that only a hook ever types. A person had to know which
8
+ was which before they could find the one command they wanted.
9
+
10
+ `taskops tasks` with no subcommand lists, because the list is what you want nine times out
11
+ of ten and a group name that prints usage is a name that costs a second turn.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ from typing import Any
18
+
19
+ from ...._errors import BadRequest
20
+ from ...._types import STATUSES
21
+ from ....render import render_edit, render_plan, render_tasklist
22
+ from ....usecases import board
23
+ from ....usecases import edit as rewrite
24
+ from ....usecases import plan as create
25
+ from ._shared import add_actor, add_target, repo_of
26
+ from ._tasks_args import add_list_flags, add_subcommands
27
+
28
+ __all__ = ["register"]
29
+
30
+
31
+ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
32
+ parser = sub.add_parser("tasks", help="list, read, create and close tasks")
33
+ add_target(parser)
34
+ add_actor(parser)
35
+ add_list_flags(parser)
36
+ parser.set_defaults(run=run_list, subcommand="")
37
+ add_subcommands(parser, listing=run_list, adding=run_add, editing=run_edit)
38
+
39
+
40
+ def run_list(args: argparse.Namespace) -> str:
41
+ """The list, filtered by what was asked for.
42
+
43
+ `--status` is validated HERE and not by argparse `choices`, so the refusal reads like
44
+ every other one in the package — the name that was rejected, then the legal values —
45
+ rather than argparse's usage dump, which buries the answer under the whole grammar.
46
+ """
47
+ status = getattr(args, "status", None)
48
+ if status is not None and status not in STATUSES:
49
+ raise BadRequest(f"`{status}` is not a status — use one of {', '.join(STATUSES)}")
50
+ return render_tasklist(board(repo_of(args)),
51
+ show_all=bool(getattr(args, "all", False)), status=status)
52
+
53
+
54
+ def run_add(args: argparse.Namespace) -> str:
55
+ """One card through the batch door.
56
+
57
+ `plan` stays the only creator even for a single task, so a card typed at a terminal
58
+ carries exactly the event body a planned one does — a second insert path here is how the
59
+ log would start holding two shapes of `created`, one of which another machine cannot
60
+ replay. The flags are read into the same dict `plan` takes from JSON, so `_entry` keeps
61
+ owning what every field means.
62
+ """
63
+ entry: dict[str, Any] = {
64
+ "title": str(args.title), "spec": str(args.spec),
65
+ "labels": str(args.labels), "files": str(args.files),
66
+ "after": [part.strip() for part in str(args.after).split(",") if part.strip()]}
67
+ if args.priority is not None:
68
+ entry["priority"] = int(args.priority)
69
+ return render_plan(create(repo_of(args), [entry], actor=str(args.actor)))
70
+
71
+
72
+ def run_edit(args: argparse.Namespace) -> str:
73
+ """Rewrite a card. `None` means "not passed" all the way down, which is why the flags
74
+ default to it rather than to "": an empty spec is a legitimate edit (somebody clearing a
75
+ brief that was wrong), and a default of "" could not tell that from silence."""
76
+ return render_edit(rewrite(
77
+ repo_of(args), str(args.task), title=args.title, spec=args.spec,
78
+ priority=None if args.priority is None else int(args.priority),
79
+ actor=str(args.actor)))