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/__init__.py
ADDED
taskgate/__main__.py
ADDED
taskgate/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""CLI entrypoint: ``taskgate`` / ``python -m taskgate``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__, clients, model
|
|
9
|
+
from .commands.flow import cmd_add, cmd_claim, cmd_list, cmd_next, cmd_update
|
|
10
|
+
from .commands.plan import cmd_depends, cmd_move
|
|
11
|
+
from .commands.registry import cmd_init, cmd_render, cmd_sort, cmd_validate
|
|
12
|
+
|
|
13
|
+
BY_HELP = "identity (default TASKGATE_BY env / 'human')"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _cmd_snippet(args: argparse.Namespace) -> int:
|
|
17
|
+
try:
|
|
18
|
+
print(clients.snippet(args.client, source=args.source, root=args.root), end="")
|
|
19
|
+
except ValueError as exc:
|
|
20
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
21
|
+
return 1
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cmd_mcp(_args: argparse.Namespace) -> int:
|
|
26
|
+
from .mcp import server # lazy: the server imports this module's main()
|
|
27
|
+
|
|
28
|
+
server.serve()
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
"""Assemble the argparse tree."""
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="taskgate",
|
|
36
|
+
description="Gated task registry: strict status flow with verification and review evidence.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--version", action="version", version=f"taskgate {__version__}")
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
p = sub.add_parser("init", help="create the registry in the current directory (or TASKGATE_ROOT)")
|
|
42
|
+
p.add_argument("--seed", help="path to seed JSON (tasks array)")
|
|
43
|
+
p.add_argument("--force", action="store_true", help="overwrite existing registry")
|
|
44
|
+
p.add_argument("--by", help=BY_HELP)
|
|
45
|
+
p.set_defaults(func=cmd_init)
|
|
46
|
+
|
|
47
|
+
p = sub.add_parser("validate", help="schema + flow-rule validation")
|
|
48
|
+
p.set_defaults(func=cmd_validate)
|
|
49
|
+
|
|
50
|
+
p = sub.add_parser("render", help="generate the Markdown view")
|
|
51
|
+
p.add_argument("--check", action="store_true", help="fail when the view drifted from tasks.json")
|
|
52
|
+
p.add_argument("--out", help="alternative output path (ignored with --check)")
|
|
53
|
+
p.set_defaults(func=cmd_render)
|
|
54
|
+
|
|
55
|
+
p = sub.add_parser("next", help="show the one task that may start now (queue head)")
|
|
56
|
+
p.add_argument("--owner", help="only tasks unowned or owned by this identity")
|
|
57
|
+
p.set_defaults(func=cmd_next)
|
|
58
|
+
|
|
59
|
+
p = sub.add_parser("list", help="list all tasks")
|
|
60
|
+
p.add_argument("--status", choices=model.STATUSES, help="filter by status")
|
|
61
|
+
p.set_defaults(func=cmd_list)
|
|
62
|
+
|
|
63
|
+
p = sub.add_parser("claim", help="claim a planned task (-> in_progress)")
|
|
64
|
+
p.add_argument("id")
|
|
65
|
+
p.add_argument("--by", help=BY_HELP)
|
|
66
|
+
p.add_argument("--force", action="store_true", help="steal from another owner")
|
|
67
|
+
p.set_defaults(func=cmd_claim)
|
|
68
|
+
|
|
69
|
+
p = sub.add_parser("update", help="change status / record verification or review")
|
|
70
|
+
p.add_argument("id")
|
|
71
|
+
p.add_argument("--status", choices=model.STATUSES)
|
|
72
|
+
p.add_argument("--reason", help="required by blocked; recorded for cancelled")
|
|
73
|
+
p.add_argument("--verification-result", help="JSON object, e.g. '{\"ok\": true}'")
|
|
74
|
+
p.add_argument(
|
|
75
|
+
"--review-result",
|
|
76
|
+
help="JSON object from the reviewer, e.g. '{\"ok\": true, \"by\": \"reviewer\", \"checks\": [\"cmd\"]}'",
|
|
77
|
+
)
|
|
78
|
+
p.add_argument("--by", help=BY_HELP)
|
|
79
|
+
p.set_defaults(func=cmd_update)
|
|
80
|
+
|
|
81
|
+
p = sub.add_parser("add", help="append a new task to the registry")
|
|
82
|
+
p.add_argument("--id", required=True, help="id matching the configured id_pattern")
|
|
83
|
+
p.add_argument("--title", required=True)
|
|
84
|
+
p.add_argument("--phase", type=int, help="phase (default: from the id's 'phase' group, else 0)")
|
|
85
|
+
p.add_argument("--verification-cmd", required=True, dest="verification_cmd")
|
|
86
|
+
p.add_argument("--spec", help="spec_source anchor (path + section)")
|
|
87
|
+
p.add_argument("--status", default="planned", choices=model.ADD_STATUSES)
|
|
88
|
+
p.add_argument("--owner")
|
|
89
|
+
p.add_argument("--depends", help="comma-separated task ids")
|
|
90
|
+
p.add_argument("--by", help=BY_HELP)
|
|
91
|
+
p.set_defaults(func=cmd_add)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("sort", help="reset the execution order to phase, then id")
|
|
94
|
+
p.add_argument("--by", help=BY_HELP)
|
|
95
|
+
p.set_defaults(func=cmd_sort)
|
|
96
|
+
|
|
97
|
+
p = sub.add_parser("move", help="place a draft/planned task before or after another")
|
|
98
|
+
p.add_argument("id")
|
|
99
|
+
anchor = p.add_mutually_exclusive_group(required=True)
|
|
100
|
+
anchor.add_argument("--before", metavar="ID", help="place directly before this task")
|
|
101
|
+
anchor.add_argument("--after", metavar="ID", help="place directly after this task")
|
|
102
|
+
p.add_argument("--by", help=BY_HELP)
|
|
103
|
+
p.set_defaults(func=cmd_move)
|
|
104
|
+
|
|
105
|
+
p = sub.add_parser("depends", help="change the dependencies of a draft/planned task")
|
|
106
|
+
p.add_argument("id")
|
|
107
|
+
p.add_argument("--set", help="comma-separated ids replacing the dependencies ('' clears)")
|
|
108
|
+
p.add_argument("--add", help="comma-separated ids to add")
|
|
109
|
+
p.add_argument("--remove", help="comma-separated ids to remove")
|
|
110
|
+
p.add_argument("--by", help=BY_HELP)
|
|
111
|
+
p.set_defaults(func=cmd_depends)
|
|
112
|
+
|
|
113
|
+
p = sub.add_parser("mcp", help="start the MCP stdio server (JSON-RPC 2.0, newline-delimited)")
|
|
114
|
+
p.set_defaults(func=_cmd_mcp)
|
|
115
|
+
|
|
116
|
+
p = sub.add_parser("config-snippet", help="print MCP configuration for a client")
|
|
117
|
+
p.add_argument("client", choices=list(clients.CLIENTS), help="; ".join(f"{k}: {v}" for k, v in clients.CLIENTS.items()))
|
|
118
|
+
p.add_argument("--from", dest="source", help="uvx source (path, git URL or version spec); default: PyPI")
|
|
119
|
+
p.add_argument("--root", help="absolute project root, written as TASKGATE_ROOT")
|
|
120
|
+
p.set_defaults(func=_cmd_snippet)
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
"""Program entry: dispatch commands, return the exit code."""
|
|
126
|
+
args = build_parser().parse_args(argv)
|
|
127
|
+
code: int = args.func(args)
|
|
128
|
+
return code
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run() -> None:
|
|
132
|
+
"""Console-script entry point."""
|
|
133
|
+
sys.exit(main())
|
taskgate/clients.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Ready-to-paste MCP server configuration for common clients (``taskgate config-snippet``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
CLIENTS = {
|
|
8
|
+
"claude": "Claude Code: .mcp.json in the project (or `claude mcp add`)",
|
|
9
|
+
"vscode": "VS Code (GitHub Copilot agent mode): .vscode/mcp.json",
|
|
10
|
+
"codex": "OpenAI Codex CLI: ~/.codex/config.toml",
|
|
11
|
+
"cursor": "Cursor: .cursor/mcp.json",
|
|
12
|
+
"gemini": "Gemini CLI: .gemini/settings.json",
|
|
13
|
+
"generic": "any client that takes a command and arguments",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def launch(source: str | None) -> tuple[str, list[str]]:
|
|
18
|
+
"""Command and arguments that start the stdio server through ``uvx``."""
|
|
19
|
+
args = ["--from", source, "taskgate", "mcp"] if source else ["taskgate", "mcp"]
|
|
20
|
+
return "uvx", args
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def snippet(client: str, *, source: str | None = None, root: str | None = None) -> str:
|
|
24
|
+
"""The configuration text for ``client``; raises ``ValueError`` for an unknown client."""
|
|
25
|
+
if client not in CLIENTS:
|
|
26
|
+
raise ValueError(f"unknown client {client!r}; choose one of {', '.join(CLIENTS)}")
|
|
27
|
+
command, args = launch(source)
|
|
28
|
+
env = {"TASKGATE_ROOT": root} if root else {}
|
|
29
|
+
if client == "codex":
|
|
30
|
+
lines = ["[mcp_servers.taskgate]", f"command = {json.dumps(command)}", f"args = {json.dumps(args)}"]
|
|
31
|
+
if env:
|
|
32
|
+
lines += ["", "[mcp_servers.taskgate.env]", f"TASKGATE_ROOT = {json.dumps(root)}"]
|
|
33
|
+
return "\n".join(lines) + "\n"
|
|
34
|
+
if client == "claude":
|
|
35
|
+
add = ["claude", "mcp", "add", "taskgate"]
|
|
36
|
+
if root:
|
|
37
|
+
add += ["-e", f"TASKGATE_ROOT={root}"]
|
|
38
|
+
add += ["--", command, *args]
|
|
39
|
+
entry: dict[str, object] = {"type": "stdio", "command": command, "args": args}
|
|
40
|
+
if env:
|
|
41
|
+
entry["env"] = env
|
|
42
|
+
text = json.dumps({"mcpServers": {"taskgate": entry}}, indent=2)
|
|
43
|
+
return f"# one command:\n{' '.join(add)}\n\n# or .mcp.json:\n{text}\n"
|
|
44
|
+
if client == "vscode":
|
|
45
|
+
entry = {"type": "stdio", "command": command, "args": args}
|
|
46
|
+
if env:
|
|
47
|
+
entry["env"] = env
|
|
48
|
+
return json.dumps({"servers": {"taskgate": entry}}, indent=2) + "\n"
|
|
49
|
+
entry = {"command": command, "args": args}
|
|
50
|
+
if env:
|
|
51
|
+
entry["env"] = env
|
|
52
|
+
return json.dumps({"mcpServers": {"taskgate": entry}}, indent=2) + "\n"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Helpers shared by the CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
EMPTY_REGISTRY: dict[str, Any] = {"version": 1, "tasks": []}
|
|
10
|
+
|
|
11
|
+
VERIFICATION_STATUSES = ("in_review", "done")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def identity(args: argparse.Namespace) -> str:
|
|
15
|
+
"""Declared caller identity: ``--by``, else ``TASKGATE_BY``, else ``human``."""
|
|
16
|
+
by: str | None = getattr(args, "by", None)
|
|
17
|
+
return by or os.environ.get("TASKGATE_BY", "human")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find(data: dict[str, Any], task_id: str) -> tuple[dict[str, Any], bool]:
|
|
21
|
+
"""The task with this id and whether it exists."""
|
|
22
|
+
for task in data.get("tasks", []):
|
|
23
|
+
if task.get("id") == task_id:
|
|
24
|
+
return task, True
|
|
25
|
+
return {}, False
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Task flow commands: next, list, claim, update, add."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .. import model, ordering, store
|
|
12
|
+
from ..config import Config
|
|
13
|
+
from .common import VERIFICATION_STATUSES, find, identity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_next(args: argparse.Namespace) -> int:
|
|
17
|
+
project = store.open_project()
|
|
18
|
+
data = store.load(project)
|
|
19
|
+
head = ordering.queue_head(data.get("tasks", []))
|
|
20
|
+
if head is None:
|
|
21
|
+
print("no open tasks (every task is done or cancelled)")
|
|
22
|
+
return 2
|
|
23
|
+
if head.get("status") != "planned":
|
|
24
|
+
print(f"nothing to claim: {head['id']} is next in order and is {head.get('status')!r}")
|
|
25
|
+
return 2
|
|
26
|
+
blockers = ordering.claim_blockers(data, head, project.config)
|
|
27
|
+
if blockers:
|
|
28
|
+
print(f"{head['id']} is next in order but cannot start:", file=sys.stderr)
|
|
29
|
+
for reason in blockers:
|
|
30
|
+
print(f" - {reason}", file=sys.stderr)
|
|
31
|
+
return 2
|
|
32
|
+
if args.owner and head.get("owner") not in (None, args.owner):
|
|
33
|
+
print(f"nothing to claim: {head['id']} is owned by {head['owner']!r}")
|
|
34
|
+
return 2
|
|
35
|
+
owner = head.get("owner") or "unowned"
|
|
36
|
+
print(f"{head['id']} [{owner}] {head['title']} // verify: {head.get('verification_cmd')}")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
41
|
+
project = store.open_project()
|
|
42
|
+
rows = store.load(project).get("tasks", [])
|
|
43
|
+
for t in rows:
|
|
44
|
+
if args.status and t.get("status") != args.status:
|
|
45
|
+
continue
|
|
46
|
+
info = f" // blocked: {t.get('blocked_reason')}" if t.get("status") == "blocked" else ""
|
|
47
|
+
if t.get("depends_on"):
|
|
48
|
+
info += f" <- {','.join(t['depends_on'])}"
|
|
49
|
+
print(f"{t['id']} {t.get('status'):12} {t.get('owner') or '-':12} {t.get('title')}{info}")
|
|
50
|
+
print(f"-- {len(rows)} task(s)")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_claim(args: argparse.Namespace) -> int:
|
|
55
|
+
project = store.open_project()
|
|
56
|
+
by = identity(args)
|
|
57
|
+
with store.locked(project):
|
|
58
|
+
data = store.load(project)
|
|
59
|
+
task, found = find(data, args.id)
|
|
60
|
+
if not found:
|
|
61
|
+
print(f"error: unknown task {args.id}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
current = task.get("status")
|
|
64
|
+
if current != "planned":
|
|
65
|
+
print(f"error: {args.id} is {current!r}, claim requires 'planned'", file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
blockers = ordering.claim_blockers(data, task, project.config)
|
|
68
|
+
if blockers:
|
|
69
|
+
print(f"error: {args.id} cannot start:", file=sys.stderr)
|
|
70
|
+
for reason in blockers:
|
|
71
|
+
print(f" - {reason}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
if task.get("owner") and task["owner"] != by and not args.force:
|
|
74
|
+
print(f"error: {args.id} already owned by {task['owner']!r} (--force to steal)", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
task.update({"status": "in_progress", "owner": by, "started": store.now()})
|
|
77
|
+
store.save(project, data)
|
|
78
|
+
store.append_log(project, "claim", args.id, by, {"status": "in_progress"})
|
|
79
|
+
print(f"{args.id} claimed by {by} -> in_progress")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_result(raw: str, flag: str) -> dict[str, Any] | None:
|
|
84
|
+
"""Parse a ``--*-result`` JSON object; ``None`` means the payload was refused."""
|
|
85
|
+
try:
|
|
86
|
+
result = json.loads(raw)
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
print(f"error: {flag} is not valid JSON: {exc}", file=sys.stderr)
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(result, dict):
|
|
91
|
+
print(f"error: {flag} must be a JSON object", file=sys.stderr)
|
|
92
|
+
return None
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_instant(value: Any) -> datetime | None:
|
|
97
|
+
"""Parse a stored timestamp as a timezone-aware instant (``Z`` or offset)."""
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
parsed = datetime.fromisoformat(value)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
return parsed if parsed.tzinfo is not None else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _review_problem(task: dict[str, Any], review: dict[str, Any], by: str, config: Config) -> str | None:
|
|
108
|
+
"""Check the review write preconditions, in the order the contract fixes them."""
|
|
109
|
+
reviewer = config.reviewer
|
|
110
|
+
if by != reviewer or review.get("by") != reviewer:
|
|
111
|
+
return f"review_result.by and caller identity must both be {reviewer!r}"
|
|
112
|
+
verification = task.get("verification_result")
|
|
113
|
+
if not isinstance(verification, dict) or not model.is_green(verification):
|
|
114
|
+
return "recording review_result requires a green verification_result"
|
|
115
|
+
verifier = verification.get("by")
|
|
116
|
+
if not isinstance(verifier, str) or not verifier.strip():
|
|
117
|
+
return "verification_result.by is required; record fresh verification before review"
|
|
118
|
+
if verifier == reviewer:
|
|
119
|
+
return "reviewer must differ from verification_result.by"
|
|
120
|
+
checks = review.get("checks")
|
|
121
|
+
if not isinstance(checks, list) or not checks or not all(isinstance(c, str) and c.strip() for c in checks):
|
|
122
|
+
return "review_result.checks must be a non-empty list of non-blank command strings"
|
|
123
|
+
verified_at = _parse_instant(verification.get("at"))
|
|
124
|
+
if verified_at is None:
|
|
125
|
+
return (
|
|
126
|
+
"verification_result.at must be a timezone-aware ISO-8601 datetime; "
|
|
127
|
+
"record fresh verification before review"
|
|
128
|
+
)
|
|
129
|
+
current = _parse_instant(store.now())
|
|
130
|
+
elapsed = None if current is None else (current - verified_at).total_seconds()
|
|
131
|
+
if elapsed is None or elapsed < config.review_delay_seconds:
|
|
132
|
+
return f"review_result requires at least {config.review_delay_seconds} seconds after verification_result.at"
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_update(args: argparse.Namespace) -> int:
|
|
137
|
+
project = store.open_project()
|
|
138
|
+
config = project.config
|
|
139
|
+
by = identity(args)
|
|
140
|
+
with store.locked(project):
|
|
141
|
+
data = store.load(project)
|
|
142
|
+
task, found = find(data, args.id)
|
|
143
|
+
if not found:
|
|
144
|
+
print(f"error: unknown task {args.id}", file=sys.stderr)
|
|
145
|
+
return 1
|
|
146
|
+
if args.verification_result is not None and args.review_result is not None:
|
|
147
|
+
print(
|
|
148
|
+
"error: verification_result and review_result must be recorded in separate update calls",
|
|
149
|
+
file=sys.stderr,
|
|
150
|
+
)
|
|
151
|
+
return 1
|
|
152
|
+
current = task.get("status") or "draft"
|
|
153
|
+
if current in model.TERMINAL and (
|
|
154
|
+
args.verification_result is not None or args.review_result is not None or args.status is not None
|
|
155
|
+
):
|
|
156
|
+
print("error: terminal tasks cannot receive result writes or be closed again", file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
verification: dict[str, Any] | None = None
|
|
159
|
+
if args.verification_result is not None:
|
|
160
|
+
verification = _parse_result(args.verification_result, "--verification-result")
|
|
161
|
+
if verification is None:
|
|
162
|
+
return 1
|
|
163
|
+
claimed_by = verification.get("by")
|
|
164
|
+
if claimed_by is not None and claimed_by != by:
|
|
165
|
+
print("error: verification_result.by must match the caller identity", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
verification["by"] = by
|
|
168
|
+
# tool-owned timestamp: a caller-supplied 'at' cannot backdate evidence
|
|
169
|
+
verification["at"] = store.now()
|
|
170
|
+
verification.setdefault("cmd", task.get("verification_cmd"))
|
|
171
|
+
review: dict[str, Any] | None = None
|
|
172
|
+
if args.review_result is not None:
|
|
173
|
+
review = _parse_result(args.review_result, "--review-result")
|
|
174
|
+
if review is None:
|
|
175
|
+
return 1
|
|
176
|
+
problem = _review_problem(task, review, by, config)
|
|
177
|
+
if problem is not None:
|
|
178
|
+
print(f"error: {problem}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
review["at"] = store.now()
|
|
181
|
+
if review is not None and current != "in_review":
|
|
182
|
+
print("error: review_result can only be recorded while the task is 'in_review'", file=sys.stderr)
|
|
183
|
+
return 1
|
|
184
|
+
if args.status == "done" and review is None and config.require_review:
|
|
185
|
+
print("error: --status done requires a fresh --review-result in the same update", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
detail: dict[str, Any] = {}
|
|
188
|
+
if verification is not None:
|
|
189
|
+
# a fresh verification voids the earlier approval
|
|
190
|
+
task.pop("review_result", None)
|
|
191
|
+
task["verification_result"] = verification
|
|
192
|
+
detail["verification_result"] = True
|
|
193
|
+
if args.status == "in_progress" and current != args.status:
|
|
194
|
+
task.pop("review_result", None)
|
|
195
|
+
if review is not None:
|
|
196
|
+
task["review_result"] = review
|
|
197
|
+
detail["review_result"] = True
|
|
198
|
+
if args.status is not None:
|
|
199
|
+
if current != args.status and not model.can_transition(current, args.status):
|
|
200
|
+
allowed = sorted(model.TRANSITIONS.get(current, frozenset()))
|
|
201
|
+
print(
|
|
202
|
+
f"error: {args.id}: {current!r} -> {args.status!r} not allowed (allowed: {allowed})",
|
|
203
|
+
file=sys.stderr,
|
|
204
|
+
)
|
|
205
|
+
return 1
|
|
206
|
+
if current == "planned" and args.status == "in_progress":
|
|
207
|
+
print(
|
|
208
|
+
f"error: {args.id}: start work with 'claim' "
|
|
209
|
+
"(it enforces order, dependencies and the phase gate)",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
return 1
|
|
213
|
+
if args.status == "blocked":
|
|
214
|
+
reason = args.reason or task.get("blocked_reason")
|
|
215
|
+
if not reason:
|
|
216
|
+
print("error: --status blocked requires --reason", file=sys.stderr)
|
|
217
|
+
return 1
|
|
218
|
+
task["blocked_reason"] = reason
|
|
219
|
+
detail["blocked_reason"] = reason
|
|
220
|
+
elif current == "blocked":
|
|
221
|
+
task.pop("blocked_reason", None)
|
|
222
|
+
detail["blocked_reason"] = None
|
|
223
|
+
if args.status == "in_progress" and not task.get("started"):
|
|
224
|
+
task["started"] = store.now()
|
|
225
|
+
if args.status in model.TERMINAL:
|
|
226
|
+
task["finished"] = store.now()
|
|
227
|
+
if args.status == "cancelled" and args.reason:
|
|
228
|
+
task.setdefault("notes", []).append(
|
|
229
|
+
{"at": store.now(), "by": by, "text": f"cancelled: {args.reason}"}
|
|
230
|
+
)
|
|
231
|
+
task["status"] = args.status
|
|
232
|
+
detail["status"] = args.status
|
|
233
|
+
if args.status in VERIFICATION_STATUSES and not model.is_green(task.get("verification_result")):
|
|
234
|
+
print(
|
|
235
|
+
f"error: --status {args.status} requires a green verification_result "
|
|
236
|
+
"(pass --verification-result '{\"ok\": true, ...}')",
|
|
237
|
+
file=sys.stderr,
|
|
238
|
+
)
|
|
239
|
+
return 1
|
|
240
|
+
if args.status == "done" and config.require_review and not model.is_review(
|
|
241
|
+
task.get("review_result"), config.reviewer
|
|
242
|
+
):
|
|
243
|
+
print(
|
|
244
|
+
"error: --status done requires a green review_result with the reviewer identity "
|
|
245
|
+
f"(pass --review-result '{{\"ok\": true, \"by\": \"{config.reviewer}\", \"checks\": [...]}}')",
|
|
246
|
+
file=sys.stderr,
|
|
247
|
+
)
|
|
248
|
+
return 1
|
|
249
|
+
store.save(project, data)
|
|
250
|
+
store.append_log(project, "update", args.id, by, detail or {"no-op": True})
|
|
251
|
+
print(f"{args.id} updated ({', '.join(f'{k}={v!r}' for k, v in detail.items()) or 'no-op'})")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
256
|
+
project = store.open_project()
|
|
257
|
+
config = project.config
|
|
258
|
+
by = identity(args)
|
|
259
|
+
if by not in config.planners:
|
|
260
|
+
print(
|
|
261
|
+
f"error: only {', '.join(config.planners)} may add tasks (got {by!r}); pass --by with a planner identity",
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
return 1
|
|
265
|
+
match = config.id_regex.fullmatch(args.id)
|
|
266
|
+
phase = args.phase
|
|
267
|
+
if phase is None:
|
|
268
|
+
phase = int(match.group("phase")) if match and "phase" in match.groupdict() and match.group("phase") else 0
|
|
269
|
+
task: dict[str, Any] = {
|
|
270
|
+
"id": args.id,
|
|
271
|
+
"title": args.title,
|
|
272
|
+
"phase": phase,
|
|
273
|
+
"status": args.status,
|
|
274
|
+
"verification_cmd": args.verification_cmd,
|
|
275
|
+
}
|
|
276
|
+
if args.spec:
|
|
277
|
+
task["spec_source"] = args.spec
|
|
278
|
+
if args.owner:
|
|
279
|
+
task["owner"] = args.owner
|
|
280
|
+
if args.depends:
|
|
281
|
+
task["depends_on"] = [d for d in args.depends.split(",") if d]
|
|
282
|
+
with store.locked(project):
|
|
283
|
+
data = store.load(project)
|
|
284
|
+
if find(data, args.id)[1]:
|
|
285
|
+
print(f"error: task {args.id} already exists", file=sys.stderr)
|
|
286
|
+
return 1
|
|
287
|
+
phase_now = ordering.current_phase(data["tasks"])
|
|
288
|
+
if phase < phase_now:
|
|
289
|
+
print(
|
|
290
|
+
f"error: phase {phase} is already closed (current phase is {phase_now}); "
|
|
291
|
+
f"add the task to phase {phase_now} or later",
|
|
292
|
+
file=sys.stderr,
|
|
293
|
+
)
|
|
294
|
+
return 1
|
|
295
|
+
# appended after the last task of its phase; 'move' places it elsewhere
|
|
296
|
+
position = ordering.insert_position(data["tasks"], phase)
|
|
297
|
+
data["tasks"] = [*data["tasks"][:position], task, *data["tasks"][position:]]
|
|
298
|
+
errors = model.validate(data, config)
|
|
299
|
+
if errors:
|
|
300
|
+
for error in errors:
|
|
301
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
302
|
+
return 1
|
|
303
|
+
task["created"] = store.now()
|
|
304
|
+
store.save(project, data)
|
|
305
|
+
store.append_log(project, "add", args.id, by, {"status": task["status"]})
|
|
306
|
+
print(f"{args.id} added ({task['status']})")
|
|
307
|
+
return 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Planning commands: move (execution order) and depends (dependencies)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .. import model, ordering, store
|
|
10
|
+
from ..config import Config
|
|
11
|
+
from .common import find, identity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _planner(args: argparse.Namespace, config: Config) -> str | None:
|
|
15
|
+
"""The caller identity when it may plan, otherwise ``None`` after printing the refusal."""
|
|
16
|
+
by = identity(args)
|
|
17
|
+
if by not in config.planners:
|
|
18
|
+
print(
|
|
19
|
+
f"error: only {', '.join(config.planners)} may change the plan (got {by!r}); "
|
|
20
|
+
f"pass --by with a planner identity",
|
|
21
|
+
file=sys.stderr,
|
|
22
|
+
)
|
|
23
|
+
return None
|
|
24
|
+
return by
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _valid(data: dict[str, Any], config: Config) -> bool:
|
|
28
|
+
errors = model.validate(data, config)
|
|
29
|
+
for error in errors:
|
|
30
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
31
|
+
return not errors
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_move(args: argparse.Namespace) -> int:
|
|
35
|
+
project = store.open_project()
|
|
36
|
+
by = _planner(args, project.config)
|
|
37
|
+
if by is None:
|
|
38
|
+
return 1
|
|
39
|
+
anchor = args.before or args.after
|
|
40
|
+
with store.locked(project):
|
|
41
|
+
data = store.load(project)
|
|
42
|
+
blockers = ordering.move_blockers(data.get("tasks", []), args.id, anchor, after=bool(args.after))
|
|
43
|
+
if blockers:
|
|
44
|
+
for reason in blockers:
|
|
45
|
+
print(f"error: {reason}", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
data["tasks"] = ordering.moved(data["tasks"], args.id, anchor, after=bool(args.after))
|
|
48
|
+
if not _valid(data, project.config):
|
|
49
|
+
return 1
|
|
50
|
+
place = "after" if args.after else "before"
|
|
51
|
+
store.save(project, data)
|
|
52
|
+
store.append_log(project, "move", args.id, by, {place: anchor})
|
|
53
|
+
print(f"{args.id} moved {place} {anchor}")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ids(raw: str | None) -> list[str]:
|
|
58
|
+
return [part.strip() for part in raw.split(",") if part.strip()] if raw else []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_depends(args: argparse.Namespace) -> int:
|
|
62
|
+
project = store.open_project()
|
|
63
|
+
by = _planner(args, project.config)
|
|
64
|
+
if by is None:
|
|
65
|
+
return 1
|
|
66
|
+
if args.set is None and not args.add and not args.remove:
|
|
67
|
+
print("error: pass --set, --add or --remove", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
if args.set is not None and (args.add or args.remove):
|
|
70
|
+
print("error: --set cannot be combined with --add or --remove", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
with store.locked(project):
|
|
73
|
+
data = store.load(project)
|
|
74
|
+
task, found = find(data, args.id)
|
|
75
|
+
if not found:
|
|
76
|
+
print(f"error: unknown task {args.id}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
if task.get("status") not in ordering.MOVABLE:
|
|
79
|
+
print(
|
|
80
|
+
f"error: {args.id} is {task.get('status')!r}; dependencies can only change "
|
|
81
|
+
"while a task is draft or planned",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
return 1
|
|
85
|
+
current = list(task.get("depends_on") or [])
|
|
86
|
+
wanted = _ids(args.set) if args.set is not None else [*current, *_ids(args.add)]
|
|
87
|
+
removed = set(_ids(args.remove))
|
|
88
|
+
result = list(dict.fromkeys(dep for dep in wanted if dep not in removed))
|
|
89
|
+
if args.id in result:
|
|
90
|
+
print(f"error: {args.id} cannot depend on itself", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
if result:
|
|
93
|
+
task["depends_on"] = result
|
|
94
|
+
else:
|
|
95
|
+
task.pop("depends_on", None)
|
|
96
|
+
if not _valid(data, project.config):
|
|
97
|
+
return 1
|
|
98
|
+
store.save(project, data)
|
|
99
|
+
store.append_log(project, "depends", args.id, by, {"depends_on": result})
|
|
100
|
+
print(f"{args.id} depends on {', '.join(result) if result else 'nothing'}")
|
|
101
|
+
return 0
|