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/model.py ADDED
@@ -0,0 +1,158 @@
1
+ """Task data model: statuses, transitions and registry validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .config import Config
8
+
9
+ STATUSES = (
10
+ "draft",
11
+ "planned",
12
+ "in_progress",
13
+ "in_review",
14
+ "blocked",
15
+ "done",
16
+ "cancelled",
17
+ )
18
+ TERMINAL = frozenset({"done", "cancelled"})
19
+ # Started but not finished: such a task is the queue head and nothing may be inserted before it.
20
+ IN_FLIGHT = frozenset({"in_progress", "in_review", "blocked"})
21
+
22
+ TRANSITIONS: dict[str, frozenset[str]] = {
23
+ "draft": frozenset({"planned", "cancelled"}),
24
+ "planned": frozenset({"in_progress", "cancelled"}),
25
+ "in_progress": frozenset({"in_review", "blocked", "cancelled"}),
26
+ "blocked": frozenset({"in_progress", "cancelled"}),
27
+ "in_review": frozenset({"done", "in_progress", "cancelled"}),
28
+ "done": frozenset(),
29
+ "cancelled": frozenset(),
30
+ }
31
+
32
+ REQUIRED_FIELDS = ("id", "title", "status", "verification_cmd")
33
+ OPTIONAL_FIELDS = (
34
+ "phase",
35
+ "owner",
36
+ "depends_on",
37
+ "spec_source",
38
+ "verification_result",
39
+ "review_result",
40
+ "blocked_reason",
41
+ "notes",
42
+ "links",
43
+ "created",
44
+ "updated",
45
+ "started",
46
+ "finished",
47
+ )
48
+
49
+ #: Statuses a new task may start in; work starts through ``claim``.
50
+ ADD_STATUSES = ("draft", "planned")
51
+
52
+
53
+ class ValidationError(ValueError):
54
+ """Raised when a task or registry violates the schema or flow rules."""
55
+
56
+
57
+ def can_transition(current: str, new: str) -> bool:
58
+ """Return True when the status change is allowed."""
59
+ return new in TRANSITIONS.get(current, frozenset())
60
+
61
+
62
+ def is_green(result: Any) -> bool:
63
+ """True when a verification or review result is a dict with ``ok: true``."""
64
+ return isinstance(result, dict) and result.get("ok") is True
65
+
66
+
67
+ def is_review(result: Any, reviewer: str) -> bool:
68
+ """True when a result is green and carries the configured reviewer identity."""
69
+ return is_green(result) and result.get("by") == reviewer
70
+
71
+
72
+ def phase_of(task: dict[str, Any]) -> int:
73
+ """The task's phase; tasks without one are in phase 0."""
74
+ phase = task.get("phase", 0)
75
+ return phase if isinstance(phase, int) and not isinstance(phase, bool) else 0
76
+
77
+
78
+ def _validate_task(task: dict[str, Any], config: Config) -> list[str]:
79
+ errors: list[str] = []
80
+ tid = task.get("id", "<missing>")
81
+ match = config.id_regex.fullmatch(tid) if isinstance(tid, str) else None
82
+ if match is None:
83
+ errors.append(f"{tid}: id must match {config.id_pattern}")
84
+ return errors
85
+ for name in REQUIRED_FIELDS:
86
+ value = task.get(name)
87
+ if not isinstance(value, str) or not value:
88
+ errors.append(f"{tid}: field {name!r} is required (non-empty string)")
89
+ phase = task.get("phase", 0)
90
+ if not isinstance(phase, int) or isinstance(phase, bool) or phase < 0:
91
+ errors.append(f"{tid}: phase must be an integer >= 0")
92
+ elif "phase" in match.groupdict() and match.group("phase") is not None and phase != int(match.group("phase")):
93
+ errors.append(f"{tid}: phase {phase} does not match the id")
94
+ status = task.get("status")
95
+ if status not in STATUSES:
96
+ errors.append(f"{tid}: unknown status {status!r}")
97
+ owner = task.get("owner")
98
+ if owner is not None and config.owners is not None and owner not in config.owners:
99
+ errors.append(f"{tid}: unknown owner {owner!r}")
100
+ deps = task.get("depends_on", [])
101
+ if not isinstance(deps, list) or not all(isinstance(d, str) for d in deps):
102
+ errors.append(f"{tid}: depends_on must be a list of task ids")
103
+ if task.get("blocked_reason") is not None and status != "blocked":
104
+ errors.append(f"{tid}: blocked_reason set but status is {status!r}")
105
+ if status == "blocked" and not task.get("blocked_reason"):
106
+ errors.append(f"{tid}: blocked requires non-empty blocked_reason")
107
+ if status in ("in_review", "done") and not is_green(task.get("verification_result")):
108
+ errors.append(f"{tid}: status {status!r} requires a green verification_result")
109
+ if status == "done" and config.require_review and not is_review(task.get("review_result"), config.reviewer):
110
+ errors.append(
111
+ f"{tid}: status 'done' requires a green review_result "
112
+ f"({{'ok': true, 'by': '{config.reviewer}', ...}})"
113
+ )
114
+ if not isinstance(task.get("notes", []), list):
115
+ errors.append(f"{tid}: notes must be a list")
116
+ if not isinstance(task.get("links", []), list):
117
+ errors.append(f"{tid}: links must be a list")
118
+ return errors
119
+
120
+
121
+ def validate(data: dict[str, Any], config: Config) -> list[str]:
122
+ """Validate the whole registry JSON. Returns a list of error strings."""
123
+ if not isinstance(data, dict):
124
+ return ["registry root must be a JSON object"]
125
+ tasks = data.get("tasks")
126
+ if not isinstance(tasks, list):
127
+ return ["registry must contain a 'tasks' list"]
128
+ errors: list[str] = []
129
+ version = data.get("version", 1)
130
+ if version != 1:
131
+ errors.append(f"unsupported registry version {version!r}")
132
+ known: dict[str, dict[str, Any]] = {}
133
+ phases = [phase_of(t) for t in tasks if isinstance(t, dict)]
134
+ if phases != sorted(phases):
135
+ errors.append("tasks are not grouped by ascending phase (reorder with 'move' or run 'taskgate sort')")
136
+ position = {t["id"]: i for i, t in enumerate(tasks) if isinstance(t, dict) and isinstance(t.get("id"), str)}
137
+ for task in tasks:
138
+ if not isinstance(task, dict):
139
+ errors.append("each task must be a JSON object")
140
+ continue
141
+ errors.extend(_validate_task(task, config))
142
+ tid = task.get("id")
143
+ if isinstance(tid, str):
144
+ if tid in known:
145
+ errors.append(f"{tid}: duplicate task id")
146
+ known[tid] = task
147
+ allowed = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
148
+ for task in tasks:
149
+ if not isinstance(task, dict):
150
+ continue
151
+ tid = task.get("id", "<missing>")
152
+ for dep in task.get("depends_on") or []:
153
+ if dep not in known:
154
+ errors.append(f"{tid}: depends_on references unknown task {dep!r}")
155
+ elif position.get(dep, 0) >= position.get(tid, 0):
156
+ errors.append(f"{tid}: depends_on {dep!r}, which is not placed before it")
157
+ errors.extend(f"{tid}: unknown field {name!r}" for name in task if name not in allowed)
158
+ return errors
taskgate/ordering.py ADDED
@@ -0,0 +1,119 @@
1
+ """Execution order of the registry: queue head, current phase and claim gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from .config import Config
9
+ from .model import IN_FLIGHT, TERMINAL, phase_of, validate
10
+
11
+ MOVABLE = frozenset({"draft", "planned"})
12
+
13
+
14
+ def _natural(text: str) -> list[tuple[int, int, str]]:
15
+ """Sort key splitting digit runs, so ``T-2`` sorts before ``T-10``."""
16
+ return [(0, int(p), "") if p.isdigit() else (1, 0, p) for p in re.split(r"(\d+)", text) if p]
17
+
18
+
19
+ def sorted_tasks(tasks: list[Any]) -> list[Any]:
20
+ """Tasks sorted by phase, then id; the ``sort`` command's order, not the execution order."""
21
+ def key(task: Any) -> tuple[int, list[tuple[int, int, str]]]:
22
+ return (phase_of(task), _natural(str(task.get("id", "")))) if isinstance(task, dict) else (0, [])
23
+
24
+ return sorted(tasks, key=key)
25
+
26
+
27
+ def execution_order(tasks: list[Any]) -> list[dict[str, Any]]:
28
+ """Tasks in execution order: their position in the registry (``move`` changes it)."""
29
+ return [task for task in tasks if isinstance(task, dict)]
30
+
31
+
32
+ def queue_head(tasks: list[Any]) -> dict[str, Any] | None:
33
+ """First task in execution order that is not terminal, or None when all are."""
34
+ for task in execution_order(tasks):
35
+ if task.get("status") not in TERMINAL:
36
+ return task
37
+ return None
38
+
39
+
40
+ def current_phase(tasks: list[Any]) -> int:
41
+ """Phase of the queue head; the highest phase present once everything is terminal."""
42
+ head = queue_head(tasks)
43
+ if head is not None:
44
+ return phase_of(head)
45
+ return max((phase_of(t) for t in execution_order(tasks)), default=0)
46
+
47
+
48
+ def claim_blockers(data: dict[str, Any], task: dict[str, Any], config: Config) -> list[str]:
49
+ """Reasons ``task`` may not be started now; empty when claiming is allowed.
50
+
51
+ Execution is strictly ordered: every task before it must be terminal. For a
52
+ task in a later phase that is the phase gate: all earlier phases are closed
53
+ and the whole registry passes ``validate`` (green verification, and review
54
+ evidence on every ``done`` task when review is required).
55
+ """
56
+ reasons = [f"registry invalid: {e}" for e in validate(data, config)]
57
+ tasks = data.get("tasks", [])
58
+ for earlier in execution_order(tasks):
59
+ if earlier.get("id") == task.get("id"):
60
+ break
61
+ if earlier.get("status") in TERMINAL:
62
+ continue
63
+ if phase_of(earlier) != phase_of(task):
64
+ reasons.append(
65
+ f"phase gate: phase {phase_of(earlier)} is not closed "
66
+ f"({earlier.get('id')} is {earlier.get('status')!r})"
67
+ )
68
+ else:
69
+ reasons.append(f"order: {earlier.get('id')} comes first and is {earlier.get('status')!r}")
70
+ statuses = {t.get("id"): t.get("status") for t in execution_order(tasks)}
71
+ for dep in task.get("depends_on") or []:
72
+ if statuses.get(dep) != "done":
73
+ reasons.append(f"dependency {dep} is {statuses.get(dep)!r}, not 'done'")
74
+ return reasons
75
+
76
+
77
+ def move_blockers(tasks: list[Any], task_id: str, anchor_id: str, after: bool) -> list[str]:
78
+ """Reasons ``task_id`` may not be placed before/after ``anchor_id``; empty when the move is allowed.
79
+
80
+ Only tasks that have not started can move, and never in front of a task that is
81
+ in flight or done: execution history stays where it happened.
82
+ """
83
+ ordered = execution_order(tasks)
84
+ by_id = {t.get("id"): t for t in ordered}
85
+ if task_id not in by_id:
86
+ return [f"unknown task {task_id}"]
87
+ if anchor_id not in by_id:
88
+ return [f"unknown task {anchor_id}"]
89
+ if task_id == anchor_id:
90
+ return ["a task cannot be moved relative to itself"]
91
+ status = by_id[task_id].get("status")
92
+ if status not in MOVABLE:
93
+ return [f"{task_id} is {status!r}; only {', '.join(sorted(MOVABLE))} tasks can be moved"]
94
+ rest = [t for t in ordered if t.get("id") != task_id]
95
+ index = next(i for i, t in enumerate(rest) if t.get("id") == anchor_id) + (1 if after else 0)
96
+ return [
97
+ f"{t.get('id')} is {t.get('status')!r} and already started or finished; "
98
+ f"{task_id} cannot be placed in front of it"
99
+ for t in rest[index:]
100
+ if t.get("status") in IN_FLIGHT or t.get("status") == "done"
101
+ ]
102
+
103
+
104
+ def moved(tasks: list[Any], task_id: str, anchor_id: str, after: bool) -> list[Any]:
105
+ """The task list with ``task_id`` placed before/after ``anchor_id``; callers check ``move_blockers`` first."""
106
+ ordered = execution_order(tasks)
107
+ task = next(t for t in ordered if t.get("id") == task_id)
108
+ rest = [t for t in ordered if t.get("id") != task_id]
109
+ index = next(i for i, t in enumerate(rest) if t.get("id") == anchor_id) + (1 if after else 0)
110
+ return [*rest[:index], task, *rest[index:]]
111
+
112
+
113
+ def insert_position(tasks: list[Any], phase: int) -> int:
114
+ """Index where a new task of ``phase`` goes: after the last task of that or any earlier phase."""
115
+ last = -1
116
+ for index, task in enumerate(tasks):
117
+ if isinstance(task, dict) and phase_of(task) <= phase:
118
+ last = index
119
+ return last + 1
taskgate/render.py ADDED
@@ -0,0 +1,39 @@
1
+ """Markdown view of the registry (drift-checked by ``render --check``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .config import Config
8
+ from .model import phase_of
9
+
10
+ HEADER_NOTE = "<!-- GENERATED by `taskgate render` from tasks.json. DO NOT EDIT BY HAND. -->"
11
+
12
+ BADGES = {
13
+ "draft": "draft",
14
+ "planned": "planned",
15
+ "in_progress": "in-progress",
16
+ "in_review": "in-review",
17
+ "blocked": "**blocked**",
18
+ "done": "done",
19
+ "cancelled": "cancelled",
20
+ }
21
+
22
+
23
+ def render(data: dict[str, Any], config: Config) -> str:
24
+ """Render the registry as Markdown tables grouped by phase."""
25
+ tasks = [t for t in data.get("tasks", []) if isinstance(t, dict)]
26
+ lines = [f"# {config.title}", "", HEADER_NOTE, ""]
27
+ if not tasks:
28
+ lines += ["_(no tasks)_", ""]
29
+ for phase in sorted({phase_of(t) for t in tasks}):
30
+ group = [t for t in tasks if phase_of(t) == phase]
31
+ done = sum(1 for t in group if t.get("status") == "done")
32
+ label = config.phase_names.get(phase, f"Phase {phase}")
33
+ lines += [f"## {label} ({done}/{len(group)} done)", "", "| ID | Status | Owner | Title |", "| --- | --- | --- | --- |"]
34
+ for t in group:
35
+ info = str(t.get("blocked_reason") or t.get("title", "")).replace("|", "\\|")
36
+ status = str(t.get("status", "?"))
37
+ lines.append(f"| {t.get('id', '?')} | {BADGES.get(status, status)} | {t.get('owner') or '-'} | {info} |")
38
+ lines.append("")
39
+ return "\n".join(lines).rstrip() + "\n"
taskgate/store.py ADDED
@@ -0,0 +1,143 @@
1
+ """Concurrency-safe storage for the task registry (JSON + JSONL log).
2
+
3
+ Files under ``<root>/<data_dir>/`` (default ``.taskgate/``):
4
+
5
+ - ``tasks.json`` -- source of truth (commit it; edit through the CLI)
6
+ - ``tasks.log.jsonl`` -- append-only mutation log ({op, task, at, by, detail})
7
+ - ``tasks.md`` -- generated Markdown view
8
+ - ``.lock`` -- lock file serializing read-modify-write
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import os
16
+ import sys
17
+ import tempfile
18
+ from collections.abc import Iterator
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import IO, Any
24
+
25
+ from . import config as config_mod
26
+ from . import render
27
+ from .config import Config
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Project:
32
+ """A located project: its root directory and configuration."""
33
+
34
+ root: Path
35
+ config: Config
36
+
37
+ @property
38
+ def data_dir(self) -> Path:
39
+ """Directory holding the registry files."""
40
+ return self.root / self.config.data_dir
41
+
42
+ @property
43
+ def tasks_file(self) -> Path:
44
+ """Path of the JSON registry."""
45
+ return self.data_dir / "tasks.json"
46
+
47
+ @property
48
+ def log_file(self) -> Path:
49
+ """Path of the append-only JSONL log."""
50
+ return self.data_dir / "tasks.log.jsonl"
51
+
52
+ @property
53
+ def view_file(self) -> Path:
54
+ """Path of the generated Markdown view."""
55
+ return self.data_dir / "tasks.md"
56
+
57
+
58
+ def open_project(*, create: bool = False) -> Project:
59
+ """Locate the project (``create`` = ``init``: use the current directory or ``TASKGATE_ROOT``)."""
60
+ root = config_mod.init_root() if create else config_mod.find_root()
61
+ return Project(root, config_mod.load(root))
62
+
63
+
64
+ def now() -> str:
65
+ """ISO-8601 UTC timestamp with second precision."""
66
+ return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
67
+
68
+
69
+ if sys.platform == "win32":
70
+ import msvcrt
71
+
72
+ def _lock(fh: IO[bytes]) -> None:
73
+ fh.seek(0)
74
+ msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
75
+
76
+ def _unlock(fh: IO[bytes]) -> None:
77
+ fh.seek(0)
78
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
79
+ else:
80
+ import fcntl
81
+
82
+ def _lock(fh: IO[bytes]) -> None:
83
+ fcntl.flock(fh, fcntl.LOCK_EX)
84
+
85
+ def _unlock(fh: IO[bytes]) -> None:
86
+ fcntl.flock(fh, fcntl.LOCK_UN)
87
+
88
+
89
+ @contextmanager
90
+ def locked(project: Project) -> Iterator[None]:
91
+ """Exclusive file lock around registry read-modify-write."""
92
+ project.data_dir.mkdir(parents=True, exist_ok=True)
93
+ with (project.data_dir / ".lock").open("a+b") as fh:
94
+ _lock(fh)
95
+ try:
96
+ yield
97
+ finally:
98
+ _unlock(fh)
99
+
100
+
101
+ def load(project: Project) -> dict[str, Any]:
102
+ """Load the registry; fails cleanly when not initialized."""
103
+ path = project.tasks_file
104
+ if not path.exists():
105
+ raise SystemExit(f"error: {path} not found -- run 'taskgate init' first")
106
+ data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
107
+ return data
108
+
109
+
110
+ def save(project: Project, data: dict[str, Any]) -> None:
111
+ """Atomically write the registry (temp file + os.replace) and refresh the Markdown view."""
112
+ path = project.tasks_file
113
+ data["updated"] = now()
114
+ text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
115
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
116
+ try:
117
+ # write through the descriptor and close it before the replace: Windows refuses to replace an open file
118
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
119
+ fh.write(text)
120
+ os.replace(tmp_name, path)
121
+ except BaseException:
122
+ with contextlib.suppress(OSError):
123
+ os.unlink(tmp_name)
124
+ raise
125
+ # keep the view in step with every registry change, so 'render --check' never goes red by itself
126
+ project.view_file.write_text(render.render(data, project.config), encoding="utf-8")
127
+
128
+
129
+ def append_log(project: Project, op: str, task_id: str, by: str, detail: dict[str, Any]) -> None:
130
+ """Append one mutation event to the JSONL log."""
131
+ entry = {"op": op, "task": task_id, "at": now(), "by": by, "detail": detail}
132
+ project.data_dir.mkdir(parents=True, exist_ok=True)
133
+ with project.log_file.open("a", encoding="utf-8") as fh:
134
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
135
+
136
+
137
+ def read_seed(path: str | Path) -> list[dict[str, Any]]:
138
+ """Read a seed file expecting {'tasks': [...]} or a bare task array."""
139
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
140
+ tasks = raw.get("tasks") if isinstance(raw, dict) else raw
141
+ if not isinstance(tasks, list):
142
+ raise SystemExit("error: seed file must contain a 'tasks' list")
143
+ return tasks
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskgate
3
+ Version: 0.1.0
4
+ Summary: Gated task registry with an MCP server: strict status flow, verification and review evidence, for Claude Code, Codex, VS Code Copilot and other MCP clients.
5
+ Author: prachwal
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/prachwal/taskgate
8
+ Project-URL: Repository, https://github.com/prachwal/taskgate
9
+ Project-URL: Issues, https://github.com/prachwal/taskgate/issues
10
+ Keywords: mcp,model-context-protocol,task-tracker,agents,workflow,claude-code,codex,copilot
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: ruff>=0.6; extra == "dev"
26
+ Requires-Dist: mypy>=1.11; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # taskgate
30
+
31
+ A task registry that makes "done" mean something. Tasks move through a strict status flow, and the
32
+ transitions that matter are gated by evidence: a task enters review only with a green verification, and
33
+ closes only with a review from a *different* identity that lists the commands it checked. It ships as a CLI
34
+ and as an MCP server, so coding agents (Claude Code, Codex, VS Code Copilot, Cursor, Gemini CLI) and humans
35
+ work from the same registry.
36
+
37
+ - stdlib-only, no runtime dependencies; Python 3.11+; Linux, macOS and Windows
38
+ - state is plain files you commit: `tasks.json` (source of truth), an append-only `tasks.log.jsonl` and a
39
+ generated `tasks.md`
40
+ - one code path: every MCP tool calls the same CLI command a human would run
41
+
42
+ ## Quick start
43
+
44
+ ```sh
45
+ cd your-project
46
+ uvx taskgate init
47
+ uvx taskgate add --id T-1 --title "Parse the header" --verification-cmd "pytest tests/test_header.py"
48
+ uvx taskgate next # which task may start now
49
+ uvx taskgate claim T-1 --by dev
50
+ ```
51
+
52
+ Add it to your agent (prints a ready-to-paste block; see [docs/clients.md](docs/clients.md)):
53
+
54
+ ```sh
55
+ uvx taskgate config-snippet claude # also: vscode, codex, cursor, gemini, generic
56
+ ```
57
+
58
+ From a checkout instead of PyPI: `uvx --from /path/to/taskgate taskgate ...`, or
59
+ `taskgate config-snippet claude --from /path/to/taskgate`.
60
+
61
+ ## The flow
62
+
63
+ ```
64
+ draft -> planned -> in_progress -> in_review -> done
65
+ | ^ |
66
+ v | +--> in_progress (review rejected)
67
+ blocked
68
+ any non-terminal status -> cancelled
69
+ ```
70
+
71
+ | Rule | What it enforces |
72
+ | --- | --- |
73
+ | order | `claim` works only on tasks whose predecessors in the registry are `done` or `cancelled` |
74
+ | phase gate | a task in a later phase waits until every earlier phase is closed and the registry validates |
75
+ | dependencies | `depends_on` tasks must be `done` before the task can start |
76
+ | verification | `in_review` and `done` need a green `verification_result`; the tool stamps who recorded it and when |
77
+ | review | `done` needs a green `review_result` from the reviewer identity, different from the verifier, listing `checks`, recorded after a delay, in the same call as the closing update |
78
+ | fresh evidence | a new verification voids an earlier review; results are never accepted together in one call |
79
+ | planning | adding, moving and re-linking tasks is limited to the `planners` identities; started work is never reordered |
80
+
81
+ Identities (`--by`, or the `TASKGATE_BY` environment variable, default `human`) are *declared by the caller*.
82
+ This is a process barrier that keeps roles apart and leaves a trail in the log; it is not authentication.
83
+
84
+ ## Configuration
85
+
86
+ Optional `taskgate.toml` in the project root; every key has a default
87
+ ([examples/taskgate.toml](examples/taskgate.toml)).
88
+
89
+ | Key | Default | Meaning |
90
+ | --- | --- | --- |
91
+ | `data_dir` | `.taskgate` | where `tasks.json`, `tasks.log.jsonl`, `tasks.md` live |
92
+ | `title` | `Tasks` | heading of the Markdown view |
93
+ | `id_pattern` | `^[A-Za-z][A-Za-z0-9]*-\d+$` | task id regex; a named group `(?P<phase>\d+)` ties the id to the phase |
94
+ | `phase_names` | none | `{0 = "Foundations"}` labels for the view |
95
+ | `planners` | `["human"]` | identities allowed to add, move and re-link tasks |
96
+ | `reviewer` | `"reviewer"` | identity whose `review_result` closes a task |
97
+ | `owners` | any | restrict `owner` values |
98
+ | `require_review` | `true` | `false`: `done` needs only a green verification |
99
+ | `review_delay_seconds` | `60` | minimum time between verification and review |
100
+ | `[quality]` | none | named gates for the `quality_check` tool, each a list of argv arrays |
101
+
102
+ The project root is `TASKGATE_ROOT` if set, otherwise the nearest ancestor of the working directory that has
103
+ `taskgate.toml` or `.taskgate/`.
104
+ [examples/embersail.toml](examples/embersail.toml) reproduces a stricter real-world setup (ids `P<phase>-NN`,
105
+ architect/human planners) to show the engine is not tied to one convention.
106
+
107
+ ## Commands
108
+
109
+ `init`, `validate`, `render [--check]`, `next`, `list [--status]`, `claim`, `update`, `add`, `sort`, `move`,
110
+ `depends`, `mcp`, `config-snippet`. `taskgate <command> --help` lists the flags.
111
+
112
+ Recording evidence, in separate calls:
113
+
114
+ ```sh
115
+ taskgate update T-1 --verification-result '{"ok": true}' --by dev
116
+ taskgate update T-1 --status in_review --by dev
117
+ taskgate update T-1 --status done --by reviewer \
118
+ --review-result '{"ok": true, "by": "reviewer", "checks": ["pytest -q"]}'
119
+ ```
120
+
121
+ ## MCP tools
122
+
123
+ `task_list`, `task_next`, `task_validate` (read-only, so clients can skip the approval prompt), `task_claim`,
124
+ `task_update`, `task_add`, `task_move`, `task_depends`, `task_sort`, `task_render` and `quality_check`.
125
+ Tool failures come back as `isError` results rather than protocol errors, so an agent sees the reason.
126
+
127
+ The server speaks newline-delimited JSON-RPC 2.0 over stdio. Run it with `taskgate mcp` or `taskgate-mcp`.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ python -m pip install -e ".[dev]"
133
+ ruff check . && mypy && pytest
134
+ ```
135
+
136
+ ## Releasing
137
+
138
+ Pushing to `main` runs the tests on Linux, macOS and Windows and builds the package. If `version` in
139
+ `pyproject.toml` has no `v<version>` tag yet, the workflow publishes to PyPI through trusted publishing and
140
+ creates the tag and GitHub release. To release: bump `version` in `pyproject.toml` and
141
+ `src/taskgate/__init__.py` (a test keeps them equal), add a changelog entry, merge to `main`.
142
+
143
+ One-time setup, either way: a GitHub environment named `pypi`. Then either store a PyPI API token as the
144
+ repository secret `PYPI_API_TOKEN` (the first release of a new project needs an account-wide token; narrow it
145
+ to the project afterwards), or add a (pending) trusted publisher on PyPI for this repository, workflow
146
+ `ci.yml`, environment `pypi`. With the secret set it is used; without it the workflow falls back to trusted
147
+ publishing.
148
+
149
+ ## Status
150
+
151
+ Alpha. Not included: synchronization with a forge (GitLab/GitHub issues). It originated in the Embersail
152
+ project's task tooling.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,26 @@
1
+ taskgate/__init__.py,sha256=KQRurLvJLBB1S11i8bcMmWaZjN4egELAwGOxl437JRQ,81
2
+ taskgate/__main__.py,sha256=_mBTtnO0nj4fpU3qtGFOhFOYYw1KQpKTY5RINDn4mBA,59
3
+ taskgate/cli.py,sha256=1vkhrmgo7R1M0A6UvKb7lbEp-lbGOBCZNlF6z6exUFQ,5817
4
+ taskgate/clients.py,sha256=bFVEDUZfpRqC-q_KAPloiInJgCO-dTTO1w61_202f5M,2289
5
+ taskgate/config.py,sha256=woN9-IJUpaRlHMWtJoWNukw0pEOKaVEJ43OlHUsabpk,5419
6
+ taskgate/model.py,sha256=mMCNPB5UfmbSlnjiPz52D9-VHrVjRFYJ_tfbZ7JA4Y8,6381
7
+ taskgate/ordering.py,sha256=tGsCTCLzmrXZEMmecZdYEHAH7XJf4l4U2tlP6Dra0i4,5122
8
+ taskgate/render.py,sha256=rC9MQHAAF6w5ZpuWhfi3pj__w8f1nlRBsdMzqDH5lVY,1519
9
+ taskgate/store.py,sha256=em2OirUm0Uxvl_du2PxfEUN1niKV0K_52RK7r1lCFk0,4775
10
+ taskgate/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
11
+ taskgate/commands/common.py,sha256=P9mus4R67NESbiaoGq2vq1ghdgaP7yOX05SJfCLbiCs,735
12
+ taskgate/commands/flow.py,sha256=VBuCVM_Jre4A5I61Z_HjzwLZht9pPsTUolpgLFqRmvI,13591
13
+ taskgate/commands/plan.py,sha256=A4U8v6Cnh2nExXRb6TZLchgx0TZUklw_Ad-JD6ZTdbc,3703
14
+ taskgate/commands/registry.py,sha256=HJeZYmCvLuGwYIimnIVWw-lRnsqggJXUH9pXckLimJo,2537
15
+ taskgate/mcp/__init__.py,sha256=DPNmcSjY97H_p61aGFQ6sjLLpPNW0TCaEQdiuJtqyQQ,18
16
+ taskgate/mcp/bridge.py,sha256=qHSagZCYbN6quoTxlW8fdF1ZxdSZdlkJwidDZMtU97s,1512
17
+ taskgate/mcp/protocol.py,sha256=F42oWjglCxp3o3lIHex0TWQdOjyklQ6kGOqLZr5OBsQ,2922
18
+ taskgate/mcp/registry.py,sha256=IkHDg-YJIV3I3D7Ix_9-5llPQde0bW7NBAAk4Auf_1E,1324
19
+ taskgate/mcp/server.py,sha256=lC6at5gTFilIFtUPfyqIAn9KZNsGyQD2VozkzewIHVQ,770
20
+ taskgate/mcp/tools.py,sha256=wig9GKsiG8kPK1kOPlMitC8BAVp_Z9ULwzlvuWZR-8o,8488
21
+ taskgate-0.1.0.dist-info/licenses/LICENSE,sha256=jlaUGO2RF00wZygs00x6e-BvTrCA0ItRm6dbOiD7fhs,1065
22
+ taskgate-0.1.0.dist-info/METADATA,sha256=UduwINQFUPGmJC6yre2NNAB1m9kzfaYZrLkztQ1rZzk,7245
23
+ taskgate-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
24
+ taskgate-0.1.0.dist-info/entry_points.txt,sha256=P7H-jPTXFEwyvFLaBxXx048TcL9iciUpgfpar3H31Ko,85
25
+ taskgate-0.1.0.dist-info/top_level.txt,sha256=aIqc3CrLLTaF6B2gaHU4yRCH0LZNX7OPK6dJHYLMh0U,9
26
+ taskgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ taskgate = taskgate.cli:run
3
+ taskgate-mcp = taskgate.mcp.server:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 prachwal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.