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,72 @@
|
|
|
1
|
+
"""`taskops ui` — the live board, and its API, on one port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from ....transports.http import Policy, bound_port, build_server
|
|
10
|
+
from ....usecases import locate
|
|
11
|
+
from ._shared import add_target, repo_of
|
|
12
|
+
|
|
13
|
+
__all__ = ["register"]
|
|
14
|
+
|
|
15
|
+
DEFAULT_PORT = 2140
|
|
16
|
+
|
|
17
|
+
_HELP = "serve the live web interface (board, activity, reports)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
21
|
+
"""Two parsers, one `run`.
|
|
22
|
+
|
|
23
|
+
`studio` was the name until the web interface stopped being one screen, and a command in
|
|
24
|
+
somebody's shell history — or in a script, or a README they already wrote — must not start
|
|
25
|
+
failing because we renamed it. The alias registers with no `help`, so it never appears in
|
|
26
|
+
`taskops --help`: it is a bridge for existing muscle memory, not a second documented way in.
|
|
27
|
+
"""
|
|
28
|
+
_flags(sub.add_parser("ui", help=_HELP))
|
|
29
|
+
_flags(sub.add_parser("studio")).set_defaults(deprecated_name=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _flags(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
33
|
+
"""Every flag on both parsers, from one place — an alias that drifted on `--readonly`
|
|
34
|
+
would be worse than no alias at all."""
|
|
35
|
+
add_target(parser)
|
|
36
|
+
parser.add_argument("--host", default="127.0.0.1",
|
|
37
|
+
help="bind address (default: loopback only)")
|
|
38
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
39
|
+
parser.add_argument("--token", default=os.environ.get("TASKOPS_API_TOKEN", ""),
|
|
40
|
+
help="require `Authorization: Bearer <token>` ($TASKOPS_API_TOKEN)")
|
|
41
|
+
parser.add_argument("--readonly", action="store_true",
|
|
42
|
+
help="refuse every write — for a board on a shared screen")
|
|
43
|
+
parser.add_argument("--rate-limit", type=int, default=0, dest="rate_limit",
|
|
44
|
+
help="requests per minute, 0 for none")
|
|
45
|
+
parser.set_defaults(run=run, deprecated_name=False)
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(args: argparse.Namespace) -> str:
|
|
50
|
+
"""Blocks until interrupted.
|
|
51
|
+
|
|
52
|
+
The banner goes to STDERR so it stays visible when stdout is redirected, and it is the only
|
|
53
|
+
thing this command prints — the request log is silenced in the handler, or the URL a person
|
|
54
|
+
needs would scroll away under the board's own polling. The deprecation line joins it there
|
|
55
|
+
for the same reason: it is a note to a human, never part of the output.
|
|
56
|
+
"""
|
|
57
|
+
if getattr(args, "deprecated_name", False):
|
|
58
|
+
print("taskops studio is now taskops ui", file=sys.stderr)
|
|
59
|
+
root = locate(repo_of(args))
|
|
60
|
+
policy = Policy(token=args.token, readonly=bool(args.readonly),
|
|
61
|
+
rate_limit=int(args.rate_limit))
|
|
62
|
+
server = build_server(str(args.host), int(args.port), root, policy)
|
|
63
|
+
print(f"taskops ui → http://{args.host}:{bound_port(server)}/ ({root})"
|
|
64
|
+
+ (" [token required]" if args.token else "")
|
|
65
|
+
+ (" [read-only]" if args.readonly else ""), file=sys.stderr)
|
|
66
|
+
try:
|
|
67
|
+
server.serve_forever()
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
print("\nstopped", file=sys.stderr)
|
|
70
|
+
finally:
|
|
71
|
+
server.server_close()
|
|
72
|
+
return ""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""A transition, a comment, a mention — the write path, reached from `taskops tasks`.
|
|
2
|
+
|
|
3
|
+
`taskops update` was the agent's spelling and left with the rest of the agent protocol; the
|
|
4
|
+
FUNCTION could not leave, because `tasks done` and `tasks release` are this call with the
|
|
5
|
+
status already chosen. A second door onto `done` that skipped this is exactly what the guard
|
|
6
|
+
in `usecases.update` exists to prevent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
|
|
13
|
+
from ....render import render_update
|
|
14
|
+
from ....usecases import update as apply
|
|
15
|
+
from ._shared import repo_of
|
|
16
|
+
|
|
17
|
+
__all__ = ["run"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(args: argparse.Namespace) -> str:
|
|
21
|
+
mentions = tuple(p.strip() for p in str(args.mentions).split(",") if p.strip())
|
|
22
|
+
return render_update(apply(repo_of(args), str(args.task), actor=args.actor,
|
|
23
|
+
status=args.status, comment=args.comment,
|
|
24
|
+
mentions=mentions, blocked_on=args.blocked_on,
|
|
25
|
+
no_code=bool(args.no_code)))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The CLI: parse, dispatch, and translate a failure into an exit code.
|
|
2
|
+
|
|
3
|
+
The commands hold no argparse beyond their own flags and no decision beyond which renderer
|
|
4
|
+
to use. That is what lets the same behaviour be reached from MCP without a second
|
|
5
|
+
implementation, and it keeps this file about the terminal.
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 fine, 1 an engine failure, 2 bad usage. Nothing here means anything else — the
|
|
8
|
+
one command that used an exit code as an ANSWER was `guard`, and it left with the rest of the
|
|
9
|
+
wiring for `transports/hooks`, where the caller is a hook rather than a person.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from ..._errors import TaskopsError
|
|
19
|
+
from ..._version import __version__
|
|
20
|
+
from .commands import init, pushpull, recover, remote, report, run_, serve, sync, tasks, ui
|
|
21
|
+
|
|
22
|
+
__all__ = ["main", "build_parser"]
|
|
23
|
+
|
|
24
|
+
_COMMANDS = (init, ui, serve, tasks, run_, report, recover, sync, remote, pushpull)
|
|
25
|
+
"""Every command there is. Eleven, and `--help` lists all eleven.
|
|
26
|
+
|
|
27
|
+
`serve` sits next to `ui` because it is the same transport with a different audience: `ui`
|
|
28
|
+
serves the repository you are standing in, `serve` serves a directory of them over the network,
|
|
29
|
+
each behind its own token.
|
|
30
|
+
|
|
31
|
+
`remote`, `push` and `pull` are the developer's, which is why they are here and NOT on the
|
|
32
|
+
MCP surface: an agent works a board, it does not decide when this machine talks to a server.
|
|
33
|
+
They sit beside `sync` rather than replacing it — a team with no server converges through git
|
|
34
|
+
exactly as before, and that path is not deprecated by this one.
|
|
35
|
+
|
|
36
|
+
There used to be thirteen more, registered and hidden — the agent protocol (`next`, `update`,
|
|
37
|
+
`ask`, `plan`, `dispatch`, `log`) and the wiring (`guard`, `hook`, `ingest`, `brief`, `inbox`,
|
|
38
|
+
`track`, `checkout`). Hiding them made the help page honest and left the door open: git and
|
|
39
|
+
Claude Code still came in through the developer's binary. The agent's door is `taskops.mcp`
|
|
40
|
+
and the wiring's is `python -m taskops.transports.hooks`, so these are gone rather than
|
|
41
|
+
hidden, and the machinery that hid them went with them.
|
|
42
|
+
|
|
43
|
+
`run` is listed even though it is experimental and costs money, because the alternative was
|
|
44
|
+
worse: it lived as `dispatch --spawn`, a flag on a hidden command, so the one thing here that
|
|
45
|
+
spends money was the hardest thing to find and the easiest to hit by accident."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
"""Every command registers its own flags. Adding one is a new module in `commands/`,
|
|
50
|
+
never a branch here."""
|
|
51
|
+
parser = argparse.ArgumentParser(
|
|
52
|
+
prog="taskops",
|
|
53
|
+
description="The shared task list for Claude Code agents: persistent tasks, "
|
|
54
|
+
"atomic claims, commits bound to the work that motivated them.")
|
|
55
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
56
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
57
|
+
for module in _COMMANDS:
|
|
58
|
+
module.register(sub)
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
63
|
+
"""Run one command. A typed failure prints ONE line naming what to do.
|
|
64
|
+
|
|
65
|
+
A traceback is for the person who can fix the code; the reader here is trying to use the
|
|
66
|
+
tool, or is a hook that will show the line to an agent.
|
|
67
|
+
"""
|
|
68
|
+
args = build_parser().parse_args(argv)
|
|
69
|
+
try:
|
|
70
|
+
output = args.run(args)
|
|
71
|
+
except (TaskopsError, OSError) as err:
|
|
72
|
+
# OSError too: a path that does not exist or cannot be read is an ordinary mistake,
|
|
73
|
+
# and the person who made it wants one line, not a traceback through pathlib.
|
|
74
|
+
print(f"taskops: {err}", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
except KeyboardInterrupt:
|
|
77
|
+
print("interrupted", file=sys.stderr)
|
|
78
|
+
return 130
|
|
79
|
+
if output:
|
|
80
|
+
print(output)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__": # `python -m taskops.transports.cli.main`
|
|
85
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The wiring transport: the door git and Claude Code come through.
|
|
2
|
+
|
|
3
|
+
Three audiences, three doors. `cli/` is the developer's, `mcp/` is the agent's, and this one
|
|
4
|
+
belongs to the two callers that are neither — git, which runs shell scripts, and Claude Code,
|
|
5
|
+
which runs `{"type": "command"}` entries. Both EXECUTE something, so something executable has
|
|
6
|
+
to exist; what this module changes is that it stopped being the developer's `taskops`.
|
|
7
|
+
|
|
8
|
+
Nobody types `python -m taskops.transports.hooks`. It is written into `.git/hooks/*` by
|
|
9
|
+
`usecases.hooks` and into `plugin/hooks/hooks.json`, and read by nothing else.
|
|
10
|
+
|
|
11
|
+
It is a transport like the other two and obeys the same fence: thin, no `storage`, no
|
|
12
|
+
`engine` — `test_transports_never_reach_past_the_use_cases` is what enforces it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`python -m taskops.transports.hooks <event>` — the executable the wiring names.
|
|
2
|
+
|
|
3
|
+
Seven flat subcommands, one per thing that can fire:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pre-tool-use post-tool-use session-start stop Claude Code, JSON on stdin
|
|
7
|
+
commit Claude Code, exit 2 = DENY
|
|
8
|
+
ingest commit|branch sync git: post-commit, post-checkout, post-merge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Flat, and named after the EVENT rather than the internal verb, because the reader of these
|
|
12
|
+
names is somebody staring at a `hooks.json` entry or a line in `.git/hooks/post-commit`.
|
|
13
|
+
|
|
14
|
+
Exit codes are the contract and they differ per subcommand, which is why `main` returns
|
|
15
|
+
whatever `run` returned instead of flattening it: `commit` answers 2 to deny, the Claude Code
|
|
16
|
+
events answer 0 always with the decision inside the JSON, and everything fails OPEN.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Sequence
|
|
24
|
+
|
|
25
|
+
from ..._errors import TaskopsError
|
|
26
|
+
from . import claude, commit, record
|
|
27
|
+
|
|
28
|
+
__all__ = ["main", "build_parser"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="python -m taskops.transports.hooks",
|
|
34
|
+
description="taskops wiring: the commands git and Claude Code run. Not for typing — "
|
|
35
|
+
"`taskops init` writes them into .git/hooks, and the plugin ships the rest.")
|
|
36
|
+
sub = parser.add_subparsers(dest="event", required=True, metavar="<event>")
|
|
37
|
+
for module in (claude, commit, record):
|
|
38
|
+
module.register(sub)
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
43
|
+
"""Run one hook. A typed failure is ONE line on stderr, never a traceback.
|
|
44
|
+
|
|
45
|
+
Code 1 for a failure the way the CLI does it, because git's line ends in `|| true` and
|
|
46
|
+
Claude Code reads any non-zero-but-not-2 as a non-blocking error. The one code that means
|
|
47
|
+
something is 2, and only `commit` returns it.
|
|
48
|
+
"""
|
|
49
|
+
args = build_parser().parse_args(argv)
|
|
50
|
+
try:
|
|
51
|
+
output = args.run(args)
|
|
52
|
+
except (TaskopsError, OSError) as err:
|
|
53
|
+
print(f"taskops: {err}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
if isinstance(output, int):
|
|
56
|
+
return output
|
|
57
|
+
if output:
|
|
58
|
+
print(output)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""What every wiring subcommand's parser repeats.
|
|
2
|
+
|
|
3
|
+
`--repo` defaults to the cwd because the callers are hooks: git runs them from inside the
|
|
4
|
+
repository, and Claude Code runs them from the project directory, so requiring the path would
|
|
5
|
+
make every hook line longer for no information.
|
|
6
|
+
|
|
7
|
+
A copy of `cli/commands/_shared` rather than an import of it: a transport that imported
|
|
8
|
+
another transport would make the developer's CLI a dependency of git's wiring, which is the
|
|
9
|
+
coupling this whole module exists to remove. It is four lines, and they do not drift because
|
|
10
|
+
`test_hook_wiring` runs the real installed hook.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
__all__ = ["add_target", "repo_of"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def add_target(parser: argparse.ArgumentParser) -> None:
|
|
22
|
+
parser.add_argument("--repo", default=".",
|
|
23
|
+
help="path in the repository (default: the current directory)")
|
|
24
|
+
parser.add_argument("--actor", default="", help="who is calling")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def repo_of(args: argparse.Namespace) -> Path:
|
|
28
|
+
return Path(str(args.repo))
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""`pre-tool-use · post-tool-use · session-start · stop` — the Claude Code hook protocol.
|
|
2
|
+
|
|
3
|
+
The whole integration in four subcommands. A hook receives a JSON object on stdin and may
|
|
4
|
+
answer with one on stdout; this is the wire, and `events` is what each event means. The names
|
|
5
|
+
are the EVENTS themselves, because that is what the person wiring `hooks.json` is holding —
|
|
6
|
+
not a verb taskops happens to call it internally.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
PreToolUse permissionDecision: "deny" refuse the tool call, with a reason
|
|
10
|
+
updatedInput: {...} REWRITE it — this is how the Task trailer
|
|
11
|
+
gets into the agent's own `git commit`
|
|
12
|
+
SessionStart additionalContext: "..." inject what the session holds and its messages
|
|
13
|
+
PostToolUse additionalContext: "..." deliver a message another agent just wrote
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Everything here FAILS OPEN with an empty object. A hook that raised would block the tool call
|
|
17
|
+
it was inspecting, and blocking a developer's commit because taskops had a bad day is how a
|
|
18
|
+
coordination tool gets uninstalled.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from typing import Any, Callable, cast
|
|
27
|
+
|
|
28
|
+
from ..._errors import TaskopsError
|
|
29
|
+
from . import events
|
|
30
|
+
|
|
31
|
+
__all__ = ["register", "HANDLERS"]
|
|
32
|
+
|
|
33
|
+
HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|
34
|
+
"pre-tool-use": events.pre_tool_use,
|
|
35
|
+
"post-tool-use": events.post_tool_use,
|
|
36
|
+
"session-start": events.session_start,
|
|
37
|
+
"stop": events.stop,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
42
|
+
"""One parser per event, flat. A `hook <event>` group would put a word in every line of
|
|
43
|
+
`hooks.json` that carries no information — the module name already said "hook"."""
|
|
44
|
+
for name in sorted(HANDLERS):
|
|
45
|
+
parser = sub.add_parser(name, help=f"the Claude Code {name} hook (stdin JSON)")
|
|
46
|
+
parser.set_defaults(run=run, event=name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(args: argparse.Namespace) -> int:
|
|
50
|
+
"""Read the event, print the response, exit 0 ALWAYS.
|
|
51
|
+
|
|
52
|
+
Zero even for a denial: the decision travels in the JSON, and a non-zero exit is ALSO read
|
|
53
|
+
as a denial — so returning both would refuse the call twice, once without the reason
|
|
54
|
+
attached, and the agent would see the version that tells it nothing.
|
|
55
|
+
"""
|
|
56
|
+
handler = HANDLERS[str(args.event)]
|
|
57
|
+
try:
|
|
58
|
+
response = handler(_stdin())
|
|
59
|
+
except (TaskopsError, OSError, ValueError):
|
|
60
|
+
response = {}
|
|
61
|
+
if response:
|
|
62
|
+
print(json.dumps(response))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _stdin() -> dict[str, Any]:
|
|
67
|
+
"""The event payload, or {} for anything unreadable.
|
|
68
|
+
|
|
69
|
+
A hook run by hand has no stdin at all, so the tty check comes first — without it, running
|
|
70
|
+
`stop` in a terminal would block forever waiting for input nobody is going to send.
|
|
71
|
+
"""
|
|
72
|
+
if sys.stdin.isatty():
|
|
73
|
+
return {}
|
|
74
|
+
try:
|
|
75
|
+
parsed: object = json.loads(sys.stdin.read() or "{}")
|
|
76
|
+
except (json.JSONDecodeError, OSError):
|
|
77
|
+
return {}
|
|
78
|
+
return cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`commit` — may this commit proceed? The verdict, as an exit code.
|
|
2
|
+
|
|
3
|
+
Was `taskops guard commit`. The name is now the thing being decided, because the caller is a
|
|
4
|
+
hook line asking about a commit, not a person reaching for a noun.
|
|
5
|
+
|
|
6
|
+
Claude Code's hook protocol is the reason this subcommand returns an int instead of text:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
exit 0 -> allow. stdout is shown to the user, not to the model.
|
|
10
|
+
exit 2 -> DENY. stderr goes to the MODEL, which is how the agent learns the reason.
|
|
11
|
+
other -> a non-blocking error; the tool call proceeds.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
So a refusal must be written to STDERR with code 2, and anything else — including a crash in
|
|
15
|
+
taskops itself — must let the commit through. That last part is deliberate: a coordination
|
|
16
|
+
tool that blocks commits because its database was locked has broken the thing it exists to
|
|
17
|
+
support, and there is a `post-commit` hook that will record the commit anyway.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from ..._errors import TaskopsError
|
|
26
|
+
from ...render import render_verdict
|
|
27
|
+
from ...usecases import check_commit
|
|
28
|
+
from ._args import add_target, repo_of
|
|
29
|
+
|
|
30
|
+
__all__ = ["register", "DENY", "ALLOW"]
|
|
31
|
+
|
|
32
|
+
DENY = 2
|
|
33
|
+
ALLOW = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
37
|
+
parser = sub.add_parser("commit", help="decide whether a commit may proceed")
|
|
38
|
+
add_target(parser)
|
|
39
|
+
parser.add_argument("--message", default="", help="the commit message being written")
|
|
40
|
+
parser.set_defaults(run=run)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(args: argparse.Namespace) -> int:
|
|
44
|
+
"""The verdict, as an exit code. Prints the amended message on stdout when allowed.
|
|
45
|
+
|
|
46
|
+
Every unexpected failure ALLOWS. Fail-open is the right default for a guard in the commit
|
|
47
|
+
path: the cost of wrongly allowing one commit is a missing association that `post-commit`
|
|
48
|
+
will usually add anyway, and the cost of wrongly blocking is a developer who cannot commit
|
|
49
|
+
and a tool they will uninstall.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
verdict = check_commit(repo_of(args), str(args.message), actor=str(args.actor))
|
|
53
|
+
except (TaskopsError, OSError) as err:
|
|
54
|
+
print(f"taskops guard could not run ({err}) — allowing the commit", file=sys.stderr)
|
|
55
|
+
return ALLOW
|
|
56
|
+
if not verdict.allowed:
|
|
57
|
+
print(render_verdict(verdict), file=sys.stderr)
|
|
58
|
+
return DENY
|
|
59
|
+
if verdict.message and verdict.message != args.message:
|
|
60
|
+
print(verdict.message)
|
|
61
|
+
return ALLOW
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""One Claude Code hook event -> the JSON object the harness reads back.
|
|
2
|
+
|
|
3
|
+
Split from `claude` the same way `mcp/dispatch` is split from `mcp/protocol`: that module is
|
|
4
|
+
the wire (read stdin, route, write stdout), and this one is what each event MEANS. Every
|
|
5
|
+
handler here returns `{}` when there is nothing to say, and that silence is load-bearing —
|
|
6
|
+
these fire on every tool call, so a handler that always spoke would inject noise into a
|
|
7
|
+
session hundreds of times.
|
|
8
|
+
|
|
9
|
+
This is also where `brief`/`inbox`/`track`/`checkout` ended up. They used to be four CLI
|
|
10
|
+
commands a hook line typed one at a time; the events call the same use cases directly, so the
|
|
11
|
+
commands were a spelling of this file that a person could reach and nobody should.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
|
|
18
|
+
from ...render import render_brief, render_inbox
|
|
19
|
+
from ...usecases import brief, check_command, checkout, inbox, track
|
|
20
|
+
|
|
21
|
+
__all__ = ["pre_tool_use", "post_tool_use", "session_start", "stop"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pre_tool_use(payload: dict[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""Guard a `git commit`; pass everything else straight through.
|
|
26
|
+
|
|
27
|
+
The matcher can only filter on the TOOL name, so this runs on every Bash call — which is
|
|
28
|
+
why the not-a-commit path must be free, and why recognising a commit belongs to the use
|
|
29
|
+
case rather than being reimplemented here.
|
|
30
|
+
"""
|
|
31
|
+
command = str(input_of(payload).get("command", ""))
|
|
32
|
+
verdict = check_command(cwd(payload), command)
|
|
33
|
+
if verdict is None:
|
|
34
|
+
return {}
|
|
35
|
+
if not verdict.allowed:
|
|
36
|
+
return _deny(verdict.reason)
|
|
37
|
+
if verdict.command and verdict.command != command:
|
|
38
|
+
return _rewrite(verdict.command)
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _deny(reason: str) -> dict[str, Any]:
|
|
43
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
|
44
|
+
"permissionDecision": "deny",
|
|
45
|
+
"permissionDecisionReason": f"taskops: {reason}"}}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rewrite(command: str) -> dict[str, Any]:
|
|
49
|
+
"""Allow, with the trailer injected. The reason is given, so the edit is never silent —
|
|
50
|
+
an agent whose command was changed underneath it deserves to be told which and why."""
|
|
51
|
+
return {"hookSpecificOutput": {
|
|
52
|
+
"hookEventName": "PreToolUse", "permissionDecision": "allow",
|
|
53
|
+
"permissionDecisionReason": "taskops: added the Task trailer to bind this commit",
|
|
54
|
+
"updatedInput": {"command": command}}}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def session_start(payload: dict[str, Any]) -> dict[str, Any]:
|
|
58
|
+
"""Inject what this session holds and who messaged it."""
|
|
59
|
+
said = render_brief(brief(cwd(payload), session=session_of(payload)))
|
|
60
|
+
return _context("SessionStart", said)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def post_tool_use(payload: dict[str, Any]) -> dict[str, Any]:
|
|
64
|
+
"""Two jobs on the cheapest hook: report activity, and deliver new messages.
|
|
65
|
+
|
|
66
|
+
This is what makes agent-to-agent messaging feel immediate — a message written by another
|
|
67
|
+
agent reaches this one on its very next tool call. Both halves are one indexed query, and
|
|
68
|
+
both are usually zero rows, because anything expensive here taxes every tool call an agent
|
|
69
|
+
ever makes.
|
|
70
|
+
"""
|
|
71
|
+
where = cwd(payload)
|
|
72
|
+
track(where, summary=_summary(payload), session=session_of(payload))
|
|
73
|
+
return _context("PostToolUse", render_inbox(inbox(where)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _summary(payload: dict[str, Any]) -> str:
|
|
77
|
+
"""What the live board shows as "doing": the tool, and the file or command it touched."""
|
|
78
|
+
tool = str(payload.get("tool_name", "?"))
|
|
79
|
+
target = input_of(payload).get("file_path") or input_of(payload).get("command") or ""
|
|
80
|
+
return f"{tool} {str(target)[:60]}".strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stop(payload: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
"""The auto-standup: post the session's own account to every task it holds."""
|
|
85
|
+
checkout(cwd(payload), summary="Session ended.", session=session_of(payload))
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _context(event: str, text: str) -> dict[str, Any]:
|
|
90
|
+
"""The inject-context shape, or {} for nothing to inject."""
|
|
91
|
+
if not text:
|
|
92
|
+
return {}
|
|
93
|
+
return {"hookSpecificOutput": {"hookEventName": event, "additionalContext": text}}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def input_of(payload: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
found: object = payload.get("tool_input")
|
|
98
|
+
return cast("dict[str, Any]", found) if isinstance(found, dict) else {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cwd(payload: dict[str, Any]) -> str:
|
|
102
|
+
"""Where the session is. Defaults to "." so a hand-run hook still works."""
|
|
103
|
+
return str(payload.get("cwd") or ".")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def session_of(payload: dict[str, Any]) -> str:
|
|
107
|
+
return str(payload.get("session_id") or "")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""`ingest commit|branch` and `sync` — what git tells taskops after the fact.
|
|
2
|
+
|
|
3
|
+
The other half of git-binding. `commit` runs BEFORE a commit and can deny; `ingest` runs after
|
|
4
|
+
one and only records — which is why it also catches every commit the guard never saw: a
|
|
5
|
+
human's terminal commit, a `--no-verify`, a rebase landing on a task branch.
|
|
6
|
+
|
|
7
|
+
`sync` is here rather than left on the developer's CLI for one reason: `post-merge` is a git
|
|
8
|
+
hook line, and a git hook line may not enter through the door a person types. `taskops sync`
|
|
9
|
+
still exists for the person, reaching the same use case; this is the same call with its
|
|
10
|
+
report thrown away, because git redirects the hook's output to /dev/null anyway.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
|
|
17
|
+
from ...usecases import ingest_branch, ingest_commit
|
|
18
|
+
from ...usecases import sync as reconcile
|
|
19
|
+
from ._args import add_target, repo_of
|
|
20
|
+
|
|
21
|
+
__all__ = ["register"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
25
|
+
parser = sub.add_parser("ingest", help="record a commit or a branch against its task")
|
|
26
|
+
add_target(parser)
|
|
27
|
+
parser.add_argument("what", choices=("commit", "branch"))
|
|
28
|
+
parser.add_argument("ref", nargs="?", default="", help="a sha, or a branch name")
|
|
29
|
+
parser.set_defaults(run=run)
|
|
30
|
+
|
|
31
|
+
merged = sub.add_parser("sync", help="import what a merge just brought in (post-merge)")
|
|
32
|
+
add_target(merged)
|
|
33
|
+
merged.set_defaults(run=run_sync)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(args: argparse.Namespace) -> str:
|
|
37
|
+
"""Silent when there was nothing to bind.
|
|
38
|
+
|
|
39
|
+
A commit on a non-task branch is completely normal — this hook fires on every commit in
|
|
40
|
+
the repository, including the ones taskops has no opinion about — so saying so would put a
|
|
41
|
+
line of noise in front of a developer on every unrelated commit.
|
|
42
|
+
"""
|
|
43
|
+
where = repo_of(args)
|
|
44
|
+
if args.what == "branch":
|
|
45
|
+
event = ingest_branch(where, str(args.ref), actor=str(args.actor))
|
|
46
|
+
return f"taskops: {event['task']} on {event['body'].get('branch')}" if event else ""
|
|
47
|
+
event = ingest_commit(where, str(args.ref) or "HEAD", actor=str(args.actor))
|
|
48
|
+
return f"taskops: recorded {str(event['body'].get('sha'))[:12]} on {event['task']}" \
|
|
49
|
+
if event else ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_sync(args: argparse.Namespace) -> str:
|
|
53
|
+
"""Silent by design: the report belongs to `taskops sync`, which a person reads."""
|
|
54
|
+
reconcile(repo_of(args))
|
|
55
|
+
return ""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The HTTP transport: the studio's UI and its JSON API on one port.
|
|
2
|
+
|
|
3
|
+
Layered like the MCP one and for the same reasons — `_wire` is the request/reply shape, `policy`
|
|
4
|
+
is who may do what, `api` is the endpoints, `live` is the SSE feed, `static` serves the built
|
|
5
|
+
bundle, `router` maps a path to one of them, and `server` is the only file that knows about HTTP.
|
|
6
|
+
|
|
7
|
+
This is the one place the codebase is allowed to be async-adjacent, and it is not: it uses threads
|
|
8
|
+
(`ThreadingHTTPServer`), which is what lets a live browser park in a generator while the sync
|
|
9
|
+
engine underneath stays sync. See `live` for why the feed is SSE rather than a WebSocket.
|
|
10
|
+
|
|
11
|
+
`projects` is the one piece that is not part of a single board: it mounts many of them under
|
|
12
|
+
`/<project>/`, which is what `taskops serve` opens and `taskops ui` never uses.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .policy import Policy
|
|
18
|
+
from .projects import mount
|
|
19
|
+
from .server import bound_port, build_server, serve_route
|
|
20
|
+
|
|
21
|
+
__all__ = ["Policy", "build_server", "serve_route", "bound_port", "mount"]
|