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.
@@ -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()
@@ -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:]
@@ -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()
@@ -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