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
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,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.
|