tasks-toolkit 0.1.3__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.
Files changed (65) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/agents_sync.py +61 -0
  3. scripts/apply-plan.sh +51 -0
  4. scripts/build.sh +7 -0
  5. scripts/codex-task.sh +198 -0
  6. scripts/install-hooks.sh +9 -0
  7. scripts/junie-status.sh +52 -0
  8. scripts/junie-task.sh +183 -0
  9. scripts/kilo-task.sh +226 -0
  10. scripts/needs-dotnet-checks.sh +24 -0
  11. scripts/plan-sprint.sh +92 -0
  12. scripts/run-task.sh +79 -0
  13. scripts/run.sh +7 -0
  14. scripts/run_stats.py +141 -0
  15. scripts/run_summary.py +94 -0
  16. scripts/spec-lint.sh +139 -0
  17. scripts/sprint-auto.sh +79 -0
  18. scripts/sprint_auto_execute.py +160 -0
  19. scripts/sprint_auto_preflight.py +309 -0
  20. scripts/sprint_auto_prepare.py +271 -0
  21. scripts/sprint_auto_resume.py +153 -0
  22. scripts/sprint_auto_state.py +191 -0
  23. scripts/sprint_plan.py +238 -0
  24. scripts/task-report.sh +45 -0
  25. scripts/tasks.py +1813 -0
  26. scripts/tasks_cli.py +14 -0
  27. scripts/tasks_commands.py +11 -0
  28. scripts/tasks_init.py +140 -0
  29. scripts/tasks_mcp.py +309 -0
  30. scripts/tasks_model.py +173 -0
  31. scripts/tasks_plan_import.py +269 -0
  32. scripts/tasks_render.py +148 -0
  33. scripts/tasks_storage.py +373 -0
  34. scripts/tasks_watch.py +159 -0
  35. scripts/test.sh +43 -0
  36. scripts/test_agent_config.py +97 -0
  37. scripts/test_plan_sprint.sh +172 -0
  38. scripts/test_python_fast.sh +26 -0
  39. scripts/test_python_lint.sh +22 -0
  40. scripts/test_python_slow.sh +14 -0
  41. scripts/test_run_stats.py +64 -0
  42. scripts/test_runners.sh +588 -0
  43. scripts/test_spec_lint.sh +438 -0
  44. scripts/test_sprint_auto.py +50 -0
  45. scripts/test_sprint_auto_execute.py +112 -0
  46. scripts/test_sprint_auto_preflight.py +143 -0
  47. scripts/test_sprint_auto_prepare.py +150 -0
  48. scripts/test_sprint_auto_resume.py +104 -0
  49. scripts/test_sprint_auto_state.py +157 -0
  50. scripts/test_tasks.py +1678 -0
  51. scripts/test_tasks_mcp.py +233 -0
  52. scripts/test_tasks_plan_import.py +129 -0
  53. scripts/test_tasks_storage.py +67 -0
  54. scripts/test_tasks_watch.py +239 -0
  55. scripts/test_worktree.sh +2112 -0
  56. scripts/worktree.sh +992 -0
  57. scripts/worktree_mr.py +133 -0
  58. scripts/worktree_path.py +70 -0
  59. scripts/worktree_sprint.py +70 -0
  60. tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
  61. tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
  62. tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
  63. tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
  64. tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
  65. tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
scripts/tasks_cli.py ADDED
@@ -0,0 +1,14 @@
1
+ """CLI parser and entry point for the task tracker.
2
+
3
+ Re-exports from tasks.py for backward compatibility.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ from tasks import build_parser, main # noqa: E402, F401
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main())
@@ -0,0 +1,11 @@
1
+ """Command handlers for the task tracker.
2
+
3
+ Re-exports from tasks.py for backward compatibility.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ from tasks import * # noqa: E402, F401, F403
scripts/tasks_init.py ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Creates the task database from scratch. tasks/tasks.db is git-ignored; this script is what makes
3
+ a fresh clone usable. scripts/tasks.py calls create_database() automatically when the file is missing.
4
+
5
+ python3 scripts/tasks_init.py [--path tasks/tasks.db] [--project NAME] [--force]
6
+
7
+ Refuses to touch an existing database unless --force (which deletes it first). Standard library only.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sqlite3
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ def resolve_root(start: Path, use_git: bool = True) -> Path:
20
+ """Project root that owns the task database, even when running inside a git worktree.
21
+
22
+ Agents work in worktrees under <main>/tmp/ (any depth); the tracker must always use the main
23
+ checkout. Order: $VIBE_TASKS_ROOT, git's common dir (works for any worktree location), the
24
+ nearest ancestor named `tmp` that sits in a checkout with a `.git`, then `start` itself.
25
+ """
26
+ start = Path(start).resolve()
27
+ override = os.environ.get("VIBE_TASKS_ROOT")
28
+ if override:
29
+ return Path(override).resolve()
30
+ if use_git:
31
+ try:
32
+ common = subprocess.run(
33
+ ["git", "-C", str(start), "rev-parse", "--path-format=absolute", "--git-common-dir"],
34
+ capture_output=True, text=True, timeout=10, check=True).stdout.strip()
35
+ if common and Path(common).name == ".git":
36
+ return Path(common).resolve().parent
37
+ except (OSError, subprocess.SubprocessError):
38
+ pass
39
+ for ancestor in start.parents:
40
+ if ancestor.name == "tmp" and (ancestor.parent / ".git").exists():
41
+ return ancestor.parent
42
+ return start
43
+
44
+
45
+ ROOT = resolve_root(Path(__file__).resolve().parent.parent)
46
+ DEFAULT_PATH = ROOT / "tasks" / "tasks.db"
47
+
48
+ SCHEMA = """
49
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
50
+ CREATE TABLE IF NOT EXISTS nodes (
51
+ id TEXT PRIMARY KEY,
52
+ uid TEXT NOT NULL DEFAULT '',
53
+ parent_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
54
+ position INTEGER NOT NULL,
55
+ level INTEGER NOT NULL CHECK (level BETWEEN 1 AND 3),
56
+ title TEXT NOT NULL,
57
+ description TEXT NOT NULL DEFAULT '',
58
+ status TEXT NOT NULL DEFAULT 'none' CHECK (status IN ('none','in_progress','blocked','done','cancelled','review')),
59
+ reason TEXT NOT NULL DEFAULT '',
60
+ assignee TEXT NOT NULL DEFAULT '',
61
+ goal TEXT NOT NULL DEFAULT '',
62
+ start TEXT NOT NULL DEFAULT '',
63
+ end TEXT NOT NULL DEFAULT '',
64
+ created TEXT NOT NULL,
65
+ updated TEXT NOT NULL,
66
+ started TEXT NOT NULL DEFAULT '',
67
+ finished TEXT NOT NULL DEFAULT '',
68
+ UNIQUE (parent_id, position)
69
+ );
70
+ CREATE TABLE IF NOT EXISTS deps (
71
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
72
+ depends_on TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
73
+ PRIMARY KEY (node_id, depends_on)
74
+ );
75
+ CREATE TABLE IF NOT EXISTS notes (
76
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
78
+ text TEXT NOT NULL
79
+ );
80
+ CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
81
+ CREATE INDEX IF NOT EXISTS idx_nodes_assignee ON nodes(assignee);
82
+ CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id, position);
83
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_uid ON nodes(uid) WHERE uid <> '';
84
+ CREATE TABLE IF NOT EXISTS links (
85
+ node_uid TEXT NOT NULL,
86
+ kind TEXT NOT NULL,
87
+ value TEXT NOT NULL,
88
+ created TEXT NOT NULL,
89
+ PRIMARY KEY (node_uid, kind, value)
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_links_uid ON links(node_uid);
92
+ CREATE TABLE IF NOT EXISTS events (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ ts TEXT NOT NULL,
95
+ actor TEXT NOT NULL,
96
+ action TEXT NOT NULL,
97
+ node_uid TEXT NOT NULL DEFAULT '',
98
+ field TEXT NOT NULL DEFAULT '',
99
+ old TEXT NOT NULL DEFAULT '',
100
+ new TEXT NOT NULL DEFAULT ''
101
+ );
102
+ CREATE INDEX IF NOT EXISTS idx_events_node ON events(node_uid);
103
+ """
104
+
105
+
106
+ def create_database(path: Path, project: str) -> None:
107
+ """Create a new, empty database file with the full schema. Raises FileExistsError if it exists."""
108
+ path = Path(path)
109
+ if path.exists():
110
+ raise FileExistsError(f"{path} already exists")
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ conn = sqlite3.connect(path)
113
+ try:
114
+ conn.executescript(SCHEMA)
115
+ conn.execute("INSERT INTO meta VALUES ('project', ?)", (project,))
116
+ conn.execute("INSERT INTO meta VALUES ('schema_version', '1')")
117
+ conn.commit()
118
+ finally:
119
+ conn.close()
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
124
+ parser.add_argument("--path", type=Path, default=DEFAULT_PATH)
125
+ parser.add_argument("--project", default=ROOT.name)
126
+ parser.add_argument("--force", action="store_true", help="delete an existing database first (data is lost)")
127
+ args = parser.parse_args(argv)
128
+ if args.path.exists():
129
+ if not args.force:
130
+ print(f"error: {args.path} already exists (use --force to recreate it; all tasks are lost)",
131
+ file=sys.stderr)
132
+ return 1
133
+ args.path.unlink()
134
+ create_database(args.path, args.project)
135
+ print(f"created empty task database: {args.path}")
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())
scripts/tasks_mcp.py ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env python3
2
+ """MCP server (stdio) exposing the task tracker (scripts/tasks.py) as tools.
3
+
4
+ Speaks newline-delimited JSON-RPC 2.0 (MCP). Standard library only. Every tool is a thin wrapper
5
+ around a tasks.py command, so the CLI and the MCP interface share one implementation and one
6
+ database; each change still re-renders the tasks/ view.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+ import tasks # noqa: E402
18
+
19
+ SERVER = {"name": "vibe-tasks", "version": "1.0.0"}
20
+ SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
21
+
22
+
23
+ def schema(properties: dict, required: list[str] | None = None) -> dict:
24
+ return {"type": "object", "properties": properties, "required": required or [], "additionalProperties": False}
25
+
26
+
27
+ def text(description: str) -> dict:
28
+ return {"type": "string", "description": description}
29
+
30
+
31
+ ID = text("Item id: sprint '1', task '1.2', subtask '1.2.3'")
32
+ STATUS = {"type": "string", "enum": ["none", "in_progress", "blocked", "done", "cancelled", "review"],
33
+ "description": "none (brak), in_progress (w trakcie), blocked (bloker, needs reason), "
34
+ "done (gotowe), cancelled (anulowane), review (w przeglądzie)"}
35
+
36
+
37
+ def flag(name, value, out): # optional "--name value"
38
+ if value not in (None, ""):
39
+ out += [name, str(value)]
40
+
41
+
42
+ # name -> (description, input schema, annotations, argv builder)
43
+ TOOLS: dict[str, tuple] = {
44
+ "tasks_list": (
45
+ "List sprints/tasks/subtasks as an indented tree with their (derived) status.",
46
+ schema({"id": text("Only this item and everything below it"),
47
+ "status": STATUS, "sprint": text("Only this sprint id"), "assignee": text("Only this assignee"),
48
+ "text": text("Case-insensitive match in title/description"),
49
+ "leaves": {"type": "boolean", "description": "Only items without children"},
50
+ "desc": {"type": "boolean", "description": "Also print each item's description"},
51
+ "notes": {"type": "boolean", "description": "Also print each item's notes"}}),
52
+ {"readOnlyHint": True},
53
+ lambda a: ["list"] + ([str(a["id"])] if a.get("id") else [])
54
+ + _opts(a, status="--status", sprint="--sprint", assignee="--assignee", text="--text")
55
+ + (["--leaves"] if a.get("leaves") else []) + (["--desc"] if a.get("desc") else [])
56
+ + (["--notes"] if a.get("notes") else [])),
57
+ "tasks_show": (
58
+ "Show one or more items (description, assignee, dependencies, notes, derived status); brief=true prints one line per item.",
59
+ schema({"ids": {"type": "array", "items": ID, "minItems": 1},
60
+ "brief": {"type": "boolean", "description": "One line per item instead of JSON"}}, ["ids"]),
61
+ {"readOnlyHint": True},
62
+ lambda a: ["show", *[str(x) for x in a["ids"]]] + (["--brief"] if a.get("brief") else [])),
63
+ "tasks_next": (
64
+ "Next ready, unfinished leaf items (dependencies met) in the first unfinished sprint.",
65
+ schema({"assignee": text("Only items assigned to this agent"),
66
+ "count": {"type": "integer", "minimum": 1, "maximum": 50, "description": "How many (default 1)"}}),
67
+ {"readOnlyHint": True},
68
+ lambda a: ["next"] + (["--assignee", a["assignee"]] if a.get("assignee") else [])
69
+ + (["-n", str(a["count"])] if a.get("count") else [])),
70
+ "tasks_stats": (
71
+ "Counts per status and open work per assignee.", schema({}), {"readOnlyHint": True}, lambda a: ["stats"]),
72
+ "tasks_sprint": (
73
+ "One table of a sprint's tasks (status, assignee, progress) in a single call.",
74
+ schema({"id": text("Sprint id")}, ["id"]), {"readOnlyHint": True},
75
+ lambda a: ["sprint-table", a["id"]]),
76
+ "tasks_overview": (
77
+ "All sprints in one call: derived status, counts per status and the goal.",
78
+ schema({}), {"readOnlyHint": True}, lambda a: ["overview"]),
79
+ "tasks_add_sprint": (
80
+ "Create a sprint.",
81
+ schema({"title": text("Sprint title"), "goal": text("Sprint goal"), "start": text("YYYY-MM-DD"),
82
+ "end": text("YYYY-MM-DD")}, ["title"]),
83
+ {"readOnlyHint": False, "destructiveHint": False},
84
+ lambda a: ["add-sprint", a["title"]] + _opts(a, goal="--goal", start="--start", end="--end")),
85
+ "tasks_add": (
86
+ "Add a task under a sprint id, or a subtask under a task id (max depth: sprint > task > subtask).",
87
+ schema({"parent": text("Sprint id (e.g. '2') or task id (e.g. '2.1')"), "title": text("Title"),
88
+ "description": text("Description / acceptance criteria"),
89
+ "assignee": text("Who does it: junie, kilo, claude, ..."),
90
+ "after": text("Comma separated ids that must be done first")}, ["parent", "title"]),
91
+ {"readOnlyHint": False, "destructiveHint": False},
92
+ lambda a: ["add", a["parent"], a["title"]] + _opts(a, description="--desc", assignee="--assignee",
93
+ after="--after")),
94
+ "tasks_set_status": (
95
+ "Set a status on a leaf item. Parent statuses are derived (any blocked -> blocked, all done -> done, "
96
+ "anything started/finished -> in progress). Blocking requires a reason. cascade_down applies the "
97
+ "status to every leaf below a task or sprint. Pass either id or ids (exactly one).",
98
+ schema({"id": ID, "ids": {"type": "array", "items": {"type": "string"},
99
+ "description": "Multiple item ids (alternative to id)"},
100
+ "status": STATUS, "reason": text("Required when status is blocked"),
101
+ "cascade_down": {"type": "boolean", "description": "Apply to all leaves below a parent"}},
102
+ []),
103
+ {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True},
104
+ lambda a: _build_status_argv(a)),
105
+ "tasks_edit": (
106
+ "Edit title, description, assignee, dependencies (--after replaces the list; empty clears) and, for "
107
+ "sprints, goal/start/end. Pass either id or ids (exactly one). --title with multiple ids is rejected.",
108
+ schema({"id": ID, "ids": {"type": "array", "items": {"type": "string"},
109
+ "description": "Multiple item ids (alternative to id)"},
110
+ "title": text("New title"), "description": text("New description"),
111
+ "assignee": text("New assignee"), "after": text("Comma separated ids; empty string clears"),
112
+ "goal": text("Sprint only"), "start": text("Sprint only, YYYY-MM-DD"),
113
+ "end": text("Sprint only, YYYY-MM-DD")}, []),
114
+ {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True},
115
+ lambda a: _build_edit_argv(a)),
116
+ "tasks_move": (
117
+ "Move an item to another parent (renumbers the subtree). Errors: unknown ids, moving a sprint, "
118
+ "moving into own subtree, depth > MAX_DEPTH, same parent without --pos.",
119
+ schema({"id": ID, "new_parent": text("New parent id"),
120
+ "pos": {"type": "integer", "minimum": 1, "description": "1-based position (default: append last)"}},
121
+ ["id", "new_parent"]),
122
+ {"readOnlyHint": False, "destructiveHint": False},
123
+ lambda a: ["mv", a["id"], a["new_parent"]] + (["--pos", str(a["pos"])] if a.get("pos") else [])),
124
+ "tasks_note": (
125
+ "Append a dated note to an item (progress, decisions, pointers to patches).",
126
+ schema({"id": ID, "text": text("Note text")}, ["id", "text"]),
127
+ {"readOnlyHint": False, "destructiveHint": False}, lambda a: ["note", a["id"], a["text"]]),
128
+ "tasks_remove": (
129
+ "Delete an item and everything below it (cascades to notes and dependencies). Refused while other "
130
+ "items depend on it. Two-step: call without confirm for a preview, then again with confirm=true.",
131
+ schema({"id": ID, "confirm": {"type": "boolean",
132
+ "description": "Omit (or false) for a preview; set true to actually delete."}}, ["id"]),
133
+ {"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True},
134
+ lambda a: ["rm", a["id"], "--yes"] if a.get("confirm") else ["rm", a["id"], "--dry-run"]),
135
+ "tasks_link": (
136
+ "Attach a structured link (spec, patch, runner, model, agent, commit, branch, fixes, scope) to an item.",
137
+ schema({"id": ID, "kind": {"type": "string",
138
+ "enum": ["spec", "patch", "runner", "model", "agent", "commit", "branch", "fixes", "scope"],
139
+ "description": "Link kind"}, "value": text("Link value (path, sha, name, ...)")}, ["id", "kind", "value"]),
140
+ {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True},
141
+ lambda a: ["link", a["id"], a["kind"], a["value"]]),
142
+ "tasks_unlink": (
143
+ "Remove a structured link from an item.",
144
+ schema({"id": ID, "kind": {"type": "string",
145
+ "enum": ["spec", "patch", "runner", "model", "agent", "commit", "branch", "fixes", "scope"],
146
+ "description": "Link kind"}, "value": text("Link value to remove")}, ["id", "kind", "value"]),
147
+ {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True},
148
+ lambda a: ["unlink", a["id"], a["kind"], a["value"]]),
149
+ "tasks_mv": (
150
+ "Move an item to another parent (renumbers the subtree).",
151
+ schema({"id": ID, "new_parent": text("New parent id"),
152
+ "pos": {"type": "integer", "minimum": 1,
153
+ "description": "1-based position (default: append last)"}}, ["id", "new_parent"]),
154
+ {"readOnlyHint": False, "destructiveHint": False},
155
+ lambda a: ["mv", a["id"], a["new_parent"]] + (["--pos", str(a["pos"])] if a.get("pos") else [])),
156
+ "tasks_brief": (
157
+ "Context bundle for one item: description, dependencies with merge commits, spec link, notes, "
158
+ "and the list of files the dependencies changed.",
159
+ schema({"id": ID}, ["id"]), {"readOnlyHint": True},
160
+ lambda a: ["brief", a["id"]]),
161
+ "tasks_log": (
162
+ "Print the audit log (optionally for one item, optionally since a timestamp).",
163
+ schema({"id": ID, "since": text("ISO timestamp (e.g. 2026-10-17)")}), {"readOnlyHint": True},
164
+ lambda a: (["log", a["id"]] if a.get("id") else ["log"])
165
+ + (["--since", a["since"]] if a.get("since") else [])),
166
+ }
167
+
168
+
169
+ def _opts(args: dict, keep_empty: tuple = (), **mapping) -> list[str]:
170
+ out: list[str] = []
171
+ for key, option in mapping.items():
172
+ if key == "keep_empty":
173
+ continue
174
+ value = args.get(key)
175
+ if value is None or (value == "" and key not in keep_empty):
176
+ continue
177
+ out += [option, str(value)]
178
+ return out
179
+
180
+
181
+ def _resolve_ids(args: dict) -> list[str]:
182
+ """Return the list of ids from either 'id' or 'ids' (exactly one must be present)."""
183
+ has_id = "id" in args and args["id"] not in (None, "")
184
+ has_ids = "ids" in args and args["ids"] is not None
185
+ if has_id and not has_ids:
186
+ return [args["id"]]
187
+ if has_ids and not has_id:
188
+ return args["ids"]
189
+ if has_id and has_ids:
190
+ raise ValueError("pass either id or ids, not both")
191
+ raise ValueError("pass either id or ids")
192
+
193
+
194
+ def _build_status_argv(args: dict) -> list[str]:
195
+ ids = _resolve_ids(args)
196
+ result = ["status"] + ids + [args["status"]]
197
+ result += _opts(args, reason="--reason")
198
+ if args.get("cascade_down"):
199
+ result += ["--all"]
200
+ return result
201
+
202
+
203
+ def _build_edit_argv(args: dict) -> list[str]:
204
+ ids = _resolve_ids(args)
205
+ result = ["edit"] + ids
206
+ result += _opts(args, title="--title", description="--desc", assignee="--assignee",
207
+ goal="--goal", start="--start", end="--end", after="--after",
208
+ keep_empty=("after",))
209
+ return result
210
+
211
+
212
+ def run_tool(name: str, arguments: dict) -> tuple[str, bool]:
213
+ """Run one tool; returns (text, is_error)."""
214
+ if name == "tasks_view":
215
+ data = tasks.load()
216
+ return tasks.render(data), False
217
+ if name not in TOOLS:
218
+ return f"unknown tool: {name}", True
219
+ schema_def = TOOLS[name][1]
220
+ for key in schema_def["required"]:
221
+ if key not in arguments:
222
+ return f"missing required argument: {key}", True
223
+ unknown = set(arguments) - set(schema_def["properties"])
224
+ if unknown:
225
+ return f"unknown argument(s): {', '.join(sorted(unknown))}", True
226
+ if name == "tasks_remove":
227
+ rm_out, rm_err = io.StringIO(), io.StringIO()
228
+ try:
229
+ with contextlib.redirect_stdout(rm_out), contextlib.redirect_stderr(rm_err):
230
+ code = tasks.main(TOOLS[name][3](arguments))
231
+ except Exception as exc: # noqa: BLE001 - report instead of killing the server
232
+ return f"internal error: {exc}", True
233
+ text = (rm_out.getvalue() + rm_err.getvalue()).strip()
234
+ if code != 0:
235
+ return text or "remove failed", True
236
+ if not arguments.get("confirm"):
237
+ return (text + "\n\nPreview only — nothing was deleted. "
238
+ "Call tasks_remove again with confirm=true to delete.").strip(), False
239
+ return text or "ok", False
240
+ out, err = io.StringIO(), io.StringIO()
241
+ try:
242
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
243
+ code = tasks.main(TOOLS[name][3](arguments))
244
+ except SystemExit as exit_: # argparse errors
245
+ return (err.getvalue() or f"invalid arguments (exit {exit_.code})").strip(), True
246
+ except Exception as exc: # noqa: BLE001 - report instead of killing the server
247
+ return f"internal error: {exc}", True
248
+ if code != 0:
249
+ return (err.getvalue() or out.getvalue()).strip(), True
250
+ return out.getvalue().strip() or "ok", False
251
+
252
+
253
+ TOOLS_VIEW = {"tasks_view": (
254
+ "Return the rendered task view README (overall + current sprint + links to the per-sprint files).", schema({}), {"readOnlyHint": True})}
255
+
256
+
257
+ def tool_list() -> list[dict]:
258
+ listed = [{"name": n, "description": d, "inputSchema": s, "annotations": a} for n, (d, s, a, _) in TOOLS.items()]
259
+ listed += [{"name": n, "description": d, "inputSchema": s, "annotations": a} for n, (d, s, a) in TOOLS_VIEW.items()]
260
+ return listed
261
+
262
+
263
+ def handle(message: dict) -> dict | None:
264
+ """Handle one JSON-RPC message; returns the response, or None for notifications."""
265
+ method, msg_id, params = message.get("method"), message.get("id"), message.get("params") or {}
266
+ if msg_id is None: # notification (e.g. notifications/initialized)
267
+ return None
268
+
269
+ def ok(result):
270
+ return {"jsonrpc": "2.0", "id": msg_id, "result": result}
271
+
272
+ def fail(code, text_):
273
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": text_}}
274
+
275
+ if method == "initialize":
276
+ asked = params.get("protocolVersion")
277
+ return ok({"protocolVersion": asked if asked in SUPPORTED_PROTOCOLS else SUPPORTED_PROTOCOLS[0],
278
+ "capabilities": {"tools": {"listChanged": False}}, "serverInfo": SERVER,
279
+ "instructions": "Task tracker: sprints > tasks > subtasks with derived statuses. "
280
+ "Use tasks_next to pick work, tasks_set_status to update it."})
281
+ if method == "ping":
282
+ return ok({})
283
+ if method == "tools/list":
284
+ return ok({"tools": tool_list()})
285
+ if method == "tools/call":
286
+ content, is_error = run_tool(params.get("name", ""), params.get("arguments") or {})
287
+ return ok({"content": [{"type": "text", "text": content}], "isError": is_error})
288
+ return fail(-32601, f"method not found: {method}")
289
+
290
+
291
+ def main() -> int:
292
+ for line in sys.stdin:
293
+ line = line.strip()
294
+ if not line:
295
+ continue
296
+ try:
297
+ message = json.loads(line)
298
+ except ValueError:
299
+ print(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}),
300
+ flush=True)
301
+ continue
302
+ response = handle(message)
303
+ if response is not None:
304
+ print(json.dumps(response, ensure_ascii=False), flush=True)
305
+ return 0
306
+
307
+
308
+ if __name__ == "__main__":
309
+ sys.exit(main())
scripts/tasks_model.py ADDED
@@ -0,0 +1,173 @@
1
+ """Core data model for the task tracker.
2
+
3
+ Extracted from tasks.py to keep the monolith small and testable.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ import secrets
9
+ import sqlite3
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ STATUSES = ("none", "in_progress", "blocked", "done", "cancelled", "review")
14
+
15
+ ALIASES: dict[str, str] = {
16
+ "none": "none", "brak": "none", "todo": "none", "open": "none",
17
+ "in_progress": "in_progress", "in-progress": "in_progress", "w-trakcie": "in_progress",
18
+ "w_trakcie": "in_progress", "doing": "in_progress", "wip": "in_progress", "start": "in_progress",
19
+ "blocked": "blocked", "bloker": "blocked", "block": "blocked",
20
+ "done": "done", "gotowe": "done", "finished": "done",
21
+ "cancelled": "cancelled", "canceled": "cancelled", "anulowane": "cancelled", "anulowany": "cancelled",
22
+ "review": "review", "w-przeglądzie": "review", "w-przegladzie": "review", "przeglad": "review",
23
+ }
24
+
25
+ BADGE: dict[str, str] = {
26
+ "none": "⬜ none", "in_progress": "🔄 in progress", "blocked": "⛔ blocked",
27
+ "done": "✅ done", "cancelled": "🚫 cancelled", "review": "🔎 review",
28
+ }
29
+
30
+ MAX_DEPTH = 3
31
+
32
+
33
+ class TaskError(Exception):
34
+ """A user-facing error (bad id, invalid transition, invalid data)."""
35
+
36
+ pass
37
+
38
+
39
+ def today() -> str:
40
+ """Return today's date as ISO string."""
41
+ return dt.date.today().isoformat()
42
+
43
+
44
+ def now_iso() -> str:
45
+ """Local timestamp (second precision) used for audit events."""
46
+ return dt.datetime.now().replace(microsecond=0).isoformat()
47
+
48
+
49
+ def actor() -> str:
50
+ """Whoever is making the change: $VIBE_ACTOR, else $USER, else 'unknown'."""
51
+ import os
52
+ return os.environ.get("VIBE_ACTOR") or os.environ.get("USER") or "unknown"
53
+
54
+
55
+ def new_uid(conn: sqlite3.Connection | None = None) -> str:
56
+ """A fresh immutable uid ('T-' + 4 base36 chars), unique among stored nodes."""
57
+ alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
58
+ existing: set[str] = set()
59
+ if conn is not None:
60
+ existing = {r[0] for r in conn.execute("SELECT uid FROM nodes WHERE uid <> ''")}
61
+ while True:
62
+ uid = "T-" + "".join(secrets.choice(alphabet) for _ in range(4))
63
+ if uid not in existing:
64
+ return uid
65
+
66
+
67
+ def new_node(node_id: str, title: str, **fields: Any) -> dict[str, Any]:
68
+ """Create a new node dict with defaults."""
69
+ node: dict[str, Any] = {
70
+ "id": node_id, "uid": "", "title": title, "description": "", "status": "none",
71
+ "reason": "", "assignee": "", "depends_on": [], "notes": [], "links": [],
72
+ "created": today(), "updated": today(), "started": "", "finished": "", "children": [],
73
+ }
74
+ node.update({k: v for k, v in fields.items() if v is not None})
75
+ return node
76
+
77
+
78
+ def empty_data(project: str = "Project") -> dict[str, Any]:
79
+ """Return an empty tracker data structure."""
80
+ return {"version": 1, "project": project, "sprints": []}
81
+
82
+
83
+ def walk(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
84
+ """Yield every node depth-first in document order."""
85
+ result: list[dict[str, Any]] = []
86
+ for node in nodes:
87
+ result.append(node)
88
+ result.extend(walk(node["children"]))
89
+ return result
90
+
91
+
92
+ def find(data: dict[str, Any], node_id: str) -> dict[str, Any]:
93
+ """Find a node by id or uid."""
94
+ for node in walk(data["sprints"]):
95
+ if node["id"] == node_id or node.get("uid") == node_id:
96
+ return node
97
+ raise TaskError(f"no such id: {node_id}")
98
+
99
+
100
+ def parent_of(data: dict[str, Any], node_id: str) -> dict[str, Any] | None:
101
+ """Find the parent of a node."""
102
+ for node in walk(data["sprints"]):
103
+ if any(c["id"] == node_id for c in node["children"]):
104
+ return node
105
+ return None
106
+
107
+
108
+ def depth(node_id: str) -> int:
109
+ """Return the depth of a node id (1 for sprint, 2 for task, 3 for subtask)."""
110
+ return node_id.count(".") + 1
111
+
112
+
113
+ def effective(node: dict[str, Any]) -> str:
114
+ """Status of a node: stored for leaves, derived from children otherwise."""
115
+ if not node["children"]:
116
+ return node["status"]
117
+ states = [effective(c) for c in node["children"]]
118
+ if "blocked" in states:
119
+ return "blocked"
120
+ if all(s == "done" for s in states):
121
+ return "done"
122
+ if all(s == "cancelled" for s in states):
123
+ return "cancelled"
124
+ if all(s in ("done", "cancelled") for s in states):
125
+ return "done"
126
+ if any(s in ("in_progress", "done", "review") for s in states):
127
+ return "in_progress"
128
+ return "none"
129
+
130
+
131
+ def leaves(node: dict[str, Any]) -> list[dict[str, Any]]:
132
+ """Yield all leaf nodes under a node."""
133
+ result: list[dict[str, Any]] = []
134
+ if not node["children"]:
135
+ result.append(node)
136
+ for child in node["children"]:
137
+ result.extend(leaves(child))
138
+ return result
139
+
140
+
141
+ def progress(node: dict[str, Any]) -> tuple[int, int]:
142
+ """Return (done, actionable) leaf count."""
143
+ all_leaves = [n for n in leaves(node) if n["status"] != "cancelled"]
144
+ return sum(1 for n in all_leaves if n["status"] == "done"), len(all_leaves)
145
+
146
+
147
+ def next_child_id(parent: dict[str, Any] | None, data: dict[str, Any]) -> str:
148
+ """Compute the next child id for a parent."""
149
+ siblings = parent["children"] if parent else data["sprints"]
150
+ top = 0
151
+ for s in siblings:
152
+ top = max(top, int(s["id"].rsplit(".", 1)[-1]))
153
+ return f"{parent['id']}.{top + 1}" if parent else str(top + 1)
154
+
155
+
156
+ def normalize_status(value: str) -> str:
157
+ """Normalize a status string to canonical form."""
158
+ try:
159
+ return ALIASES[value.strip().lower()]
160
+ except KeyError:
161
+ raise TaskError(f"unknown status '{value}' (use: none/brak, in_progress/w-trakcie, blocked/bloker, done/gotowe)")
162
+
163
+
164
+ def ready(data: dict[str, Any], node: dict[str, Any]) -> bool:
165
+ """Check if a node is ready (all dependencies done)."""
166
+ for dep in node.get("depends_on", []):
167
+ try:
168
+ dep_node = find(data, dep)
169
+ if effective(dep_node) != "done":
170
+ return False
171
+ except TaskError:
172
+ return False
173
+ return True