taskgate 0.1.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.
- taskgate/__init__.py +3 -0
- taskgate/__main__.py +5 -0
- taskgate/cli.py +133 -0
- taskgate/clients.py +52 -0
- taskgate/commands/__init__.py +1 -0
- taskgate/commands/common.py +25 -0
- taskgate/commands/flow.py +307 -0
- taskgate/commands/plan.py +101 -0
- taskgate/commands/registry.py +70 -0
- taskgate/config.py +135 -0
- taskgate/mcp/__init__.py +1 -0
- taskgate/mcp/bridge.py +42 -0
- taskgate/mcp/protocol.py +76 -0
- taskgate/mcp/registry.py +44 -0
- taskgate/mcp/server.py +25 -0
- taskgate/mcp/tools.py +207 -0
- taskgate/model.py +158 -0
- taskgate/ordering.py +119 -0
- taskgate/render.py +39 -0
- taskgate/store.py +143 -0
- taskgate-0.1.0.dist-info/METADATA +156 -0
- taskgate-0.1.0.dist-info/RECORD +26 -0
- taskgate-0.1.0.dist-info/WHEEL +5 -0
- taskgate-0.1.0.dist-info/entry_points.txt +3 -0
- taskgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- taskgate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Registry maintenance commands: init, validate, render, sort."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .. import model, ordering, render, store
|
|
10
|
+
from .common import EMPTY_REGISTRY, identity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
14
|
+
project = store.open_project(create=True)
|
|
15
|
+
path = project.tasks_file
|
|
16
|
+
if path.exists() and not args.force:
|
|
17
|
+
print(f"error: {path} already exists (use --force to overwrite)", file=sys.stderr)
|
|
18
|
+
return 1
|
|
19
|
+
tasks = store.read_seed(args.seed) if args.seed else EMPTY_REGISTRY["tasks"]
|
|
20
|
+
with store.locked(project):
|
|
21
|
+
store.save(project, {"version": 1, "tasks": tasks})
|
|
22
|
+
store.append_log(project, "init", "-", identity(args), {"seed": args.seed or "-"})
|
|
23
|
+
print(f"initialized {path} ({len(tasks)} tasks)")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
28
|
+
project = store.open_project()
|
|
29
|
+
data = store.load(project)
|
|
30
|
+
errors = model.validate(data, project.config)
|
|
31
|
+
if errors:
|
|
32
|
+
for error in errors:
|
|
33
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
print(f"OK: {len(data.get('tasks', []))} tasks, schema + flow rules valid")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_render(args: argparse.Namespace) -> int:
|
|
40
|
+
project = store.open_project()
|
|
41
|
+
data = store.load(project)
|
|
42
|
+
text = render.render(data, project.config)
|
|
43
|
+
path = project.view_file
|
|
44
|
+
if args.check:
|
|
45
|
+
if not path.exists():
|
|
46
|
+
print(f"error: {path} missing (run 'render' first)", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
if path.read_text(encoding="utf-8") != text:
|
|
49
|
+
print(f"error: {path} drifted from tasks.json (run 'render')", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
print("OK: view in sync")
|
|
52
|
+
return 0
|
|
53
|
+
target = Path(args.out) if args.out else path
|
|
54
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
target.write_text(text, encoding="utf-8")
|
|
56
|
+
print(f"rendered {target}")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_sort(args: argparse.Namespace) -> int:
|
|
61
|
+
project = store.open_project()
|
|
62
|
+
with store.locked(project):
|
|
63
|
+
data = store.load(project)
|
|
64
|
+
before = [t.get("id") for t in data.get("tasks", [])]
|
|
65
|
+
data["tasks"] = ordering.sorted_tasks(data.get("tasks", []))
|
|
66
|
+
store.save(project, data)
|
|
67
|
+
reordered = before != [t.get("id") for t in data["tasks"]]
|
|
68
|
+
store.append_log(project, "sort", "-", identity(args), {"reordered": reordered})
|
|
69
|
+
print("sorted tasks.json" if reordered else "already sorted")
|
|
70
|
+
return 0
|
taskgate/config.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Project configuration: ``taskgate.toml`` at the project root (optional, every key has a default)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
CONFIG_FILE = "taskgate.toml"
|
|
13
|
+
DEFAULT_DATA_DIR = ".taskgate"
|
|
14
|
+
DEFAULT_ID_PATTERN = r"^[A-Za-z][A-Za-z0-9]*-\d+$"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Config:
|
|
19
|
+
"""Policy and layout of one project.
|
|
20
|
+
|
|
21
|
+
``planners`` are the identities allowed to add, move and re-link tasks, and
|
|
22
|
+
``reviewer`` is the identity whose result closes a task. Identities are
|
|
23
|
+
declared by the caller (``--by`` / ``TASKGATE_BY``): this is a process
|
|
24
|
+
barrier between roles, not authentication.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
data_dir: str = DEFAULT_DATA_DIR
|
|
28
|
+
title: str = "Tasks"
|
|
29
|
+
id_pattern: str = DEFAULT_ID_PATTERN
|
|
30
|
+
phase_names: dict[int, str] = field(default_factory=dict)
|
|
31
|
+
planners: tuple[str, ...] = ("human",)
|
|
32
|
+
reviewer: str = "reviewer"
|
|
33
|
+
owners: tuple[str, ...] | None = None
|
|
34
|
+
require_review: bool = True
|
|
35
|
+
review_delay_seconds: int = 60
|
|
36
|
+
quality: dict[str, list[list[str]]] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def id_regex(self) -> re.Pattern[str]:
|
|
40
|
+
"""Compiled ``id_pattern``; a named group ``phase`` ties the id to the task's phase."""
|
|
41
|
+
return re.compile(self.id_pattern)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _strings(raw: Any, key: str) -> tuple[str, ...]:
|
|
45
|
+
if not isinstance(raw, list) or not all(isinstance(x, str) and x for x in raw):
|
|
46
|
+
raise SystemExit(f"error: {CONFIG_FILE}: {key} must be a list of non-empty strings")
|
|
47
|
+
return tuple(raw)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse(raw: dict[str, Any]) -> Config:
|
|
51
|
+
"""Build a ``Config`` from parsed TOML, rejecting unknown keys and bad values."""
|
|
52
|
+
known = {
|
|
53
|
+
"data_dir", "title", "id_pattern", "phase_names", "planners", "reviewer",
|
|
54
|
+
"owners", "require_review", "review_delay_seconds", "quality",
|
|
55
|
+
}
|
|
56
|
+
unknown = sorted(set(raw) - known)
|
|
57
|
+
if unknown:
|
|
58
|
+
raise SystemExit(f"error: {CONFIG_FILE}: unknown keys {', '.join(unknown)}")
|
|
59
|
+
base = Config()
|
|
60
|
+
values: dict[str, Any] = {}
|
|
61
|
+
for key in ("data_dir", "title", "id_pattern", "reviewer"):
|
|
62
|
+
if key in raw:
|
|
63
|
+
if not isinstance(raw[key], str) or not raw[key]:
|
|
64
|
+
raise SystemExit(f"error: {CONFIG_FILE}: {key} must be a non-empty string")
|
|
65
|
+
values[key] = raw[key]
|
|
66
|
+
for key in ("planners", "owners"):
|
|
67
|
+
if key in raw:
|
|
68
|
+
values[key] = _strings(raw[key], key)
|
|
69
|
+
for key in ("require_review",):
|
|
70
|
+
if key in raw:
|
|
71
|
+
if not isinstance(raw[key], bool):
|
|
72
|
+
raise SystemExit(f"error: {CONFIG_FILE}: {key} must be true or false")
|
|
73
|
+
values[key] = raw[key]
|
|
74
|
+
if "review_delay_seconds" in raw:
|
|
75
|
+
delay = raw["review_delay_seconds"]
|
|
76
|
+
if not isinstance(delay, int) or isinstance(delay, bool) or delay < 0:
|
|
77
|
+
raise SystemExit(f"error: {CONFIG_FILE}: review_delay_seconds must be an integer >= 0")
|
|
78
|
+
values["review_delay_seconds"] = delay
|
|
79
|
+
if "phase_names" in raw:
|
|
80
|
+
names = raw["phase_names"]
|
|
81
|
+
if not isinstance(names, dict) or not all(k.isdigit() and isinstance(v, str) for k, v in names.items()):
|
|
82
|
+
raise SystemExit(f"error: {CONFIG_FILE}: phase_names must map phase numbers to strings")
|
|
83
|
+
values["phase_names"] = {int(k): v for k, v in names.items()}
|
|
84
|
+
if "quality" in raw:
|
|
85
|
+
gates = raw["quality"]
|
|
86
|
+
ok = isinstance(gates, dict) and all(
|
|
87
|
+
isinstance(cmds, list) and cmds and all(
|
|
88
|
+
isinstance(c, list) and c and all(isinstance(a, str) for a in c) for c in cmds
|
|
89
|
+
)
|
|
90
|
+
for cmds in gates.values()
|
|
91
|
+
)
|
|
92
|
+
if not ok:
|
|
93
|
+
raise SystemExit(
|
|
94
|
+
f"error: {CONFIG_FILE}: quality.<name> must be a non-empty list of argv arrays"
|
|
95
|
+
)
|
|
96
|
+
values["quality"] = {name: [list(c) for c in cmds] for name, cmds in gates.items()}
|
|
97
|
+
config = Config(**{**base.__dict__, **values})
|
|
98
|
+
try:
|
|
99
|
+
re.compile(config.id_pattern)
|
|
100
|
+
except re.error as exc:
|
|
101
|
+
raise SystemExit(f"error: {CONFIG_FILE}: id_pattern is not a valid regex: {exc}") from exc
|
|
102
|
+
return config
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load(root: Path) -> Config:
|
|
106
|
+
"""Read ``taskgate.toml`` from ``root``; defaults when it does not exist."""
|
|
107
|
+
path = root / CONFIG_FILE
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
return Config()
|
|
110
|
+
try:
|
|
111
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
112
|
+
except tomllib.TOMLDecodeError as exc:
|
|
113
|
+
raise SystemExit(f"error: {path}: {exc}") from exc
|
|
114
|
+
return parse(raw)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_root(start: Path | None = None) -> Path:
|
|
118
|
+
"""Project root: ``TASKGATE_ROOT``, else the nearest ancestor with a config or data directory."""
|
|
119
|
+
env = os.environ.get("TASKGATE_ROOT")
|
|
120
|
+
if env:
|
|
121
|
+
return Path(env).expanduser().resolve()
|
|
122
|
+
here = (start or Path.cwd()).resolve()
|
|
123
|
+
for parent in (here, *here.parents):
|
|
124
|
+
if (parent / CONFIG_FILE).is_file() or (parent / DEFAULT_DATA_DIR).is_dir():
|
|
125
|
+
return parent
|
|
126
|
+
raise SystemExit(
|
|
127
|
+
"error: no taskgate project found above the current directory "
|
|
128
|
+
"(run 'taskgate init' or set TASKGATE_ROOT)"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def init_root() -> Path:
|
|
133
|
+
"""Where ``init`` creates a project: ``TASKGATE_ROOT`` or the current directory."""
|
|
134
|
+
env = os.environ.get("TASKGATE_ROOT")
|
|
135
|
+
return Path(env).expanduser().resolve() if env else Path.cwd().resolve()
|
taskgate/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server."""
|
taskgate/mcp/bridge.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Adapters to the outside world: the CLI and subprocesses.
|
|
2
|
+
|
|
3
|
+
Tool modules call these through the module (``bridge.run_cli(...)``), so tests
|
|
4
|
+
can replace one function here and every tool sees it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import io
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from .. import config as config_mod
|
|
14
|
+
from ..cli import main as cli_main
|
|
15
|
+
|
|
16
|
+
PROCESS_TIMEOUT_SECONDS = 1200
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_cli(argv: list[str]) -> str:
|
|
20
|
+
"""Run a CLI command capturing stdout/stderr; raise on non-zero exit."""
|
|
21
|
+
out, err = io.StringIO(), io.StringIO()
|
|
22
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
23
|
+
code = cli_main(argv)
|
|
24
|
+
output = out.getvalue().strip()
|
|
25
|
+
if code != 0:
|
|
26
|
+
raise RuntimeError(err.getvalue().strip() or output or f"exit code {code}")
|
|
27
|
+
return output or "ok"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_process(command: list[str]) -> tuple[int, str]:
|
|
31
|
+
"""Run a quality-gate command in the project root and capture its (tail of) output."""
|
|
32
|
+
root = config_mod.find_root()
|
|
33
|
+
try:
|
|
34
|
+
completed = subprocess.run(
|
|
35
|
+
command, cwd=root, capture_output=True, text=True, check=False, timeout=PROCESS_TIMEOUT_SECONDS
|
|
36
|
+
)
|
|
37
|
+
except subprocess.TimeoutExpired as exc:
|
|
38
|
+
return 124, f"timeout: {' '.join(command)} ({exc.timeout}s)"
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
return 127, f"cannot run {' '.join(command)}: {exc}"
|
|
41
|
+
output = "\n".join(part.strip() for part in (completed.stdout, completed.stderr) if part.strip())
|
|
42
|
+
return completed.returncode, output[-4000:]
|
taskgate/mcp/protocol.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 dispatcher and stdio loop (MCP stdio transport, newline-delimited).
|
|
2
|
+
|
|
3
|
+
Everything written to stdout is protocol; tool output is captured into the
|
|
4
|
+
content envelope, so handler print chatter never touches the stream.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import registry
|
|
14
|
+
|
|
15
|
+
DEFAULT_PROTOCOL_VERSION = "2025-06-18"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ok(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
23
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle_request(
|
|
27
|
+
request: dict[str, Any],
|
|
28
|
+
*,
|
|
29
|
+
tools: dict[str, registry.ToolSpec],
|
|
30
|
+
server_info: dict[str, str],
|
|
31
|
+
) -> dict[str, Any] | None:
|
|
32
|
+
"""Pure JSON-RPC dispatcher. Returns None for notifications."""
|
|
33
|
+
method = request.get("method", "")
|
|
34
|
+
request_id = request.get("id")
|
|
35
|
+
if method.startswith("notifications/") or request_id is None:
|
|
36
|
+
return None
|
|
37
|
+
if method == "initialize":
|
|
38
|
+
params = request.get("params") or {}
|
|
39
|
+
return _ok(request_id, {
|
|
40
|
+
"protocolVersion": params.get("protocolVersion", DEFAULT_PROTOCOL_VERSION),
|
|
41
|
+
"capabilities": {"tools": {}},
|
|
42
|
+
"serverInfo": server_info,
|
|
43
|
+
})
|
|
44
|
+
if method == "ping":
|
|
45
|
+
return _ok(request_id, {})
|
|
46
|
+
if method == "tools/list":
|
|
47
|
+
return _ok(request_id, {"tools": [{k: v for k, v in spec.items() if k != "handler"} for spec in tools.values()]})
|
|
48
|
+
if method == "tools/call":
|
|
49
|
+
params = request.get("params") or {}
|
|
50
|
+
name = str(params.get("name", ""))
|
|
51
|
+
spec = tools.get(name)
|
|
52
|
+
if spec is None:
|
|
53
|
+
return _error(request_id, -32601, f"unknown tool {name!r}")
|
|
54
|
+
try:
|
|
55
|
+
text = spec["handler"](params.get("arguments") or {})
|
|
56
|
+
except (Exception, SystemExit) as exc: # tool errors ride in the envelope
|
|
57
|
+
return _ok(request_id, {"content": [{"type": "text", "text": str(exc)}], "isError": True})
|
|
58
|
+
return _ok(request_id, {"content": [{"type": "text", "text": text}], "isError": False})
|
|
59
|
+
return _error(request_id, -32601, f"method not found: {method}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def serve(*, tools: dict[str, registry.ToolSpec], server_info: dict[str, str]) -> None:
|
|
63
|
+
"""Run the stdio loop until EOF, answering each line on stdout."""
|
|
64
|
+
for line in sys.stdin:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
request = json.loads(line)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
response: dict[str, Any] | None = _error(None, -32700, "parse error")
|
|
72
|
+
else:
|
|
73
|
+
response = handle_request(request, tools=tools, server_info=server_info)
|
|
74
|
+
if response is not None:
|
|
75
|
+
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
76
|
+
sys.stdout.flush()
|
taskgate/mcp/registry.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Tool table, decorator and argument helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
Handler = Callable[[dict[str, Any]], str]
|
|
9
|
+
ToolSpec = dict[str, Any]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Registry:
|
|
13
|
+
"""A tool table: name -> spec (``name``, ``description``, ``inputSchema``, ``handler``)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.tools: dict[str, ToolSpec] = {}
|
|
17
|
+
|
|
18
|
+
def tool(
|
|
19
|
+
self, name: str, description: str, input_schema: dict[str, Any], *, read_only: bool = False
|
|
20
|
+
) -> Callable[[Handler], Handler]:
|
|
21
|
+
"""Decorator registering a handler. ``read_only`` lets clients skip the approval prompt."""
|
|
22
|
+
|
|
23
|
+
def decorator(func: Handler) -> Handler:
|
|
24
|
+
spec: ToolSpec = {
|
|
25
|
+
"name": name,
|
|
26
|
+
"description": description,
|
|
27
|
+
"inputSchema": {"type": "object", **input_schema},
|
|
28
|
+
"annotations": {"readOnlyHint": read_only},
|
|
29
|
+
"handler": func,
|
|
30
|
+
}
|
|
31
|
+
self.tools[name] = spec
|
|
32
|
+
return func
|
|
33
|
+
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
REGISTRY = Registry()
|
|
38
|
+
TOOLS = REGISTRY.tools
|
|
39
|
+
tool = REGISTRY.tool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def arg(value: dict[str, Any], key: str, default: Any = None) -> Any:
|
|
43
|
+
"""Read ``key`` from a tool's arguments with an optional default."""
|
|
44
|
+
return value.get(key, default)
|
taskgate/mcp/server.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""MCP stdio server for the task registry (stdlib-only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import __version__
|
|
8
|
+
from . import protocol, registry, tools # noqa: F401 (importing tools registers them)
|
|
9
|
+
|
|
10
|
+
SERVER_INFO = {"name": "taskgate", "version": __version__}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_request(request: dict[str, Any]) -> dict[str, Any] | None:
|
|
14
|
+
"""JSON-RPC dispatcher bound to this server's tools. Returns None for notifications."""
|
|
15
|
+
return protocol.handle_request(request, tools=registry.TOOLS, server_info=SERVER_INFO)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def serve() -> None:
|
|
19
|
+
"""Run the stdio loop until EOF."""
|
|
20
|
+
protocol.serve(tools=registry.TOOLS, server_info=SERVER_INFO)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run() -> None:
|
|
24
|
+
"""Console-script entry point (``taskgate-mcp``)."""
|
|
25
|
+
serve()
|
taskgate/mcp/tools.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""MCP tools: thin wrappers over the CLI commands, plus the configurable quality gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .. import config as config_mod
|
|
9
|
+
from .. import model
|
|
10
|
+
from . import bridge
|
|
11
|
+
from .registry import arg, tool
|
|
12
|
+
|
|
13
|
+
_STATUSES = list(model.STATUSES)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@tool(
|
|
17
|
+
"task_list",
|
|
18
|
+
"List tasks (optional status filter).",
|
|
19
|
+
{"properties": {"status": {"type": "string", "enum": _STATUSES}}},
|
|
20
|
+
read_only=True,
|
|
21
|
+
)
|
|
22
|
+
def task_list(args: dict[str, Any]) -> str:
|
|
23
|
+
return bridge.run_cli(["list", *(["--status", arg(args, "status")] if arg(args, "status") else [])])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@tool(
|
|
27
|
+
"task_next",
|
|
28
|
+
"Show the one task that may start now: the queue head, planned, with dependencies and the phase gate met.",
|
|
29
|
+
{"properties": {"owner": {"type": "string"}}},
|
|
30
|
+
read_only=True,
|
|
31
|
+
)
|
|
32
|
+
def task_next(args: dict[str, Any]) -> str:
|
|
33
|
+
return bridge.run_cli(["next", *(["--owner", arg(args, "owner")] if arg(args, "owner") else [])])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@tool(
|
|
37
|
+
"task_claim",
|
|
38
|
+
"Claim a planned task (-> in_progress, sets owner and started). Refused unless every earlier task is "
|
|
39
|
+
"done/cancelled (order and phase gate) and its dependencies are done.",
|
|
40
|
+
{"properties": {"id": {"type": "string"}, "by": {"type": "string"}, "force": {"type": "boolean"}},
|
|
41
|
+
"required": ["id"]},
|
|
42
|
+
)
|
|
43
|
+
def task_claim(args: dict[str, Any]) -> str:
|
|
44
|
+
argv = ["claim", arg(args, "id")]
|
|
45
|
+
if arg(args, "by"):
|
|
46
|
+
argv += ["--by", arg(args, "by")]
|
|
47
|
+
if arg(args, "force"):
|
|
48
|
+
argv.append("--force")
|
|
49
|
+
return bridge.run_cli(argv)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@tool(
|
|
53
|
+
"task_update",
|
|
54
|
+
"Advance a task's status and record evidence (transitions are enforced). Send verification_result and "
|
|
55
|
+
"review_result in SEPARATE calls: the caller identity (by) is the verifier; a review must come from the "
|
|
56
|
+
"configured reviewer identity, a different one, after the configured delay since the stored verification, "
|
|
57
|
+
"must list the commands it checked in checks=[...]; closing as done needs a fresh review_result in the same "
|
|
58
|
+
"call (unless the project sets require_review = false).",
|
|
59
|
+
{"properties": {
|
|
60
|
+
"id": {"type": "string"},
|
|
61
|
+
"status": {"type": "string", "enum": _STATUSES},
|
|
62
|
+
"reason": {"type": "string"},
|
|
63
|
+
"verification_result": {"type": "object"},
|
|
64
|
+
"review_result": {"type": "object"},
|
|
65
|
+
"by": {"type": "string"},
|
|
66
|
+
}, "required": ["id"]},
|
|
67
|
+
)
|
|
68
|
+
def task_update(args: dict[str, Any]) -> str:
|
|
69
|
+
argv = ["update", arg(args, "id")]
|
|
70
|
+
for key, flag in (("status", "--status"), ("reason", "--reason"), ("by", "--by")):
|
|
71
|
+
if arg(args, key):
|
|
72
|
+
argv += [flag, arg(args, key)]
|
|
73
|
+
for key, flag in (("verification_result", "--verification-result"), ("review_result", "--review-result")):
|
|
74
|
+
if arg(args, key) is not None:
|
|
75
|
+
argv += [flag, json.dumps(arg(args, key))]
|
|
76
|
+
return bridge.run_cli(argv)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@tool(
|
|
80
|
+
"task_render",
|
|
81
|
+
"Render the Markdown view from tasks.json; check=true fails on drift instead of writing.",
|
|
82
|
+
{"properties": {"check": {"type": "boolean", "default": False}}},
|
|
83
|
+
)
|
|
84
|
+
def task_render(args: dict[str, Any]) -> str:
|
|
85
|
+
return bridge.run_cli(["render", *(["--check"] if arg(args, "check", False) else [])])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@tool("task_validate", "Validate tasks.json: schema, status transitions and evidence gates.", {"properties": {}},
|
|
89
|
+
read_only=True)
|
|
90
|
+
def task_validate(args: dict[str, Any]) -> str:
|
|
91
|
+
return bridge.run_cli(["validate"])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@tool(
|
|
95
|
+
"task_add",
|
|
96
|
+
"Add a draft/planned task (duplicate ids and closed phases are rejected). Only identities listed in "
|
|
97
|
+
"'planners' of taskgate.toml may add tasks; pass by explicitly.",
|
|
98
|
+
{"properties": {
|
|
99
|
+
"id": {"type": "string"},
|
|
100
|
+
"title": {"type": "string"},
|
|
101
|
+
"phase": {"type": "integer", "minimum": 0},
|
|
102
|
+
"verification_cmd": {"type": "string"},
|
|
103
|
+
"spec": {"type": "string"},
|
|
104
|
+
"status": {"type": "string", "enum": list(model.ADD_STATUSES)},
|
|
105
|
+
"owner": {"type": "string"},
|
|
106
|
+
"depends": {"type": "array", "items": {"type": "string"}},
|
|
107
|
+
"by": {"type": "string"},
|
|
108
|
+
}, "required": ["id", "title", "verification_cmd", "by"]},
|
|
109
|
+
)
|
|
110
|
+
def task_add(args: dict[str, Any]) -> str:
|
|
111
|
+
argv = ["add", "--id", arg(args, "id"), "--title", arg(args, "title"),
|
|
112
|
+
"--verification-cmd", arg(args, "verification_cmd"), "--by", arg(args, "by")]
|
|
113
|
+
if arg(args, "phase") is not None:
|
|
114
|
+
argv += ["--phase", str(arg(args, "phase"))]
|
|
115
|
+
for key, flag in (("spec", "--spec"), ("status", "--status"), ("owner", "--owner")):
|
|
116
|
+
if arg(args, key):
|
|
117
|
+
argv += [flag, arg(args, key)]
|
|
118
|
+
if arg(args, "depends"):
|
|
119
|
+
argv += ["--depends", ",".join(arg(args, "depends"))]
|
|
120
|
+
return bridge.run_cli(argv)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@tool(
|
|
124
|
+
"task_sort",
|
|
125
|
+
"Reset the execution order to phase, then id; discards any order set with task_move.",
|
|
126
|
+
{"properties": {"by": {"type": "string"}}},
|
|
127
|
+
)
|
|
128
|
+
def task_sort(args: dict[str, Any]) -> str:
|
|
129
|
+
return bridge.run_cli(["sort", *(["--by", arg(args, "by")] if arg(args, "by") else [])])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@tool(
|
|
133
|
+
"task_move",
|
|
134
|
+
"Change the execution order: place a draft/planned task directly before or after another. Never in front "
|
|
135
|
+
"of a task that is in flight or done, and phases stay ascending. Planners only; pass exactly one of "
|
|
136
|
+
"before/after.",
|
|
137
|
+
{"properties": {"id": {"type": "string"}, "before": {"type": "string"}, "after": {"type": "string"},
|
|
138
|
+
"by": {"type": "string"}}, "required": ["id", "by"]},
|
|
139
|
+
)
|
|
140
|
+
def task_move(args: dict[str, Any]) -> str:
|
|
141
|
+
before, after = arg(args, "before"), arg(args, "after")
|
|
142
|
+
if bool(before) == bool(after):
|
|
143
|
+
raise ValueError("pass exactly one of before or after")
|
|
144
|
+
return bridge.run_cli(["move", arg(args, "id"), "--before" if before else "--after", before or after,
|
|
145
|
+
"--by", arg(args, "by")])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@tool(
|
|
149
|
+
"task_depends",
|
|
150
|
+
"Change the dependencies of a draft/planned task: set (replace; [] clears), add and/or remove ids. Each "
|
|
151
|
+
"dependency must exist and be placed before the task (use task_move first). Planners only.",
|
|
152
|
+
{"properties": {
|
|
153
|
+
"id": {"type": "string"},
|
|
154
|
+
"set": {"type": "array", "items": {"type": "string"}},
|
|
155
|
+
"add": {"type": "array", "items": {"type": "string"}},
|
|
156
|
+
"remove": {"type": "array", "items": {"type": "string"}},
|
|
157
|
+
"by": {"type": "string"},
|
|
158
|
+
}, "required": ["id", "by"]},
|
|
159
|
+
)
|
|
160
|
+
def task_depends(args: dict[str, Any]) -> str:
|
|
161
|
+
argv = ["depends", arg(args, "id")]
|
|
162
|
+
for key in ("set", "add", "remove"):
|
|
163
|
+
if arg(args, key) is not None:
|
|
164
|
+
argv += [f"--{key}", ",".join(arg(args, key))]
|
|
165
|
+
return bridge.run_cli([*argv, "--by", arg(args, "by")])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
BUILTIN_GATES = ("tasks",)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@tool(
|
|
172
|
+
"quality_check",
|
|
173
|
+
"Run quality gates and return a compact JSON report. Gate names come from [quality] in taskgate.toml; the "
|
|
174
|
+
"built-in gate 'tasks' validates the registry and checks the Markdown view. Without 'checks' every gate runs.",
|
|
175
|
+
{"properties": {"checks": {"type": "array", "items": {"type": "string"}}}},
|
|
176
|
+
)
|
|
177
|
+
def quality_check(args: dict[str, Any]) -> str:
|
|
178
|
+
gates = config_mod.load(config_mod.find_root()).quality
|
|
179
|
+
available = [*gates, *BUILTIN_GATES]
|
|
180
|
+
requested = arg(args, "checks")
|
|
181
|
+
if requested is None:
|
|
182
|
+
requested = available
|
|
183
|
+
if not requested:
|
|
184
|
+
raise ValueError("checks must contain at least one gate")
|
|
185
|
+
unknown = [name for name in requested if name not in available]
|
|
186
|
+
if unknown:
|
|
187
|
+
raise ValueError(f"unknown quality checks: {', '.join(unknown)} (available: {', '.join(available)})")
|
|
188
|
+
results: list[dict[str, Any]] = []
|
|
189
|
+
ok = True
|
|
190
|
+
for name in requested:
|
|
191
|
+
if name in BUILTIN_GATES:
|
|
192
|
+
for command in (["validate"], ["render", "--check"]):
|
|
193
|
+
try:
|
|
194
|
+
code, output = 0, bridge.run_cli(command)
|
|
195
|
+
except RuntimeError as exc:
|
|
196
|
+
code, output = 1, str(exc)
|
|
197
|
+
ok = ok and code == 0
|
|
198
|
+
results.append({"check": name, "command": f"taskgate {' '.join(command)}", "exit": code, "output": output})
|
|
199
|
+
continue
|
|
200
|
+
for command in gates[name]:
|
|
201
|
+
code, output = bridge.run_process(command)
|
|
202
|
+
ok = ok and code == 0
|
|
203
|
+
results.append({"check": name, "command": " ".join(command), "exit": code, "output": output})
|
|
204
|
+
report = json.dumps({"ok": ok, "results": results}, ensure_ascii=False, indent=1)
|
|
205
|
+
if not ok:
|
|
206
|
+
raise RuntimeError(report)
|
|
207
|
+
return report
|