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 ADDED
@@ -0,0 +1,3 @@
1
+ """taskgate: a gated task registry with an MCP server."""
2
+
3
+ __version__ = "0.1.0"
taskgate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m taskgate``."""
2
+
3
+ from .cli import run
4
+
5
+ run()
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