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,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"]