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,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)))
|