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.
- scripts/__init__.py +0 -0
- scripts/agents_sync.py +61 -0
- scripts/apply-plan.sh +51 -0
- scripts/build.sh +7 -0
- scripts/codex-task.sh +198 -0
- scripts/install-hooks.sh +9 -0
- scripts/junie-status.sh +52 -0
- scripts/junie-task.sh +183 -0
- scripts/kilo-task.sh +226 -0
- scripts/needs-dotnet-checks.sh +24 -0
- scripts/plan-sprint.sh +92 -0
- scripts/run-task.sh +79 -0
- scripts/run.sh +7 -0
- scripts/run_stats.py +141 -0
- scripts/run_summary.py +94 -0
- scripts/spec-lint.sh +139 -0
- scripts/sprint-auto.sh +79 -0
- scripts/sprint_auto_execute.py +160 -0
- scripts/sprint_auto_preflight.py +309 -0
- scripts/sprint_auto_prepare.py +271 -0
- scripts/sprint_auto_resume.py +153 -0
- scripts/sprint_auto_state.py +191 -0
- scripts/sprint_plan.py +238 -0
- scripts/task-report.sh +45 -0
- scripts/tasks.py +1813 -0
- scripts/tasks_cli.py +14 -0
- scripts/tasks_commands.py +11 -0
- scripts/tasks_init.py +140 -0
- scripts/tasks_mcp.py +309 -0
- scripts/tasks_model.py +173 -0
- scripts/tasks_plan_import.py +269 -0
- scripts/tasks_render.py +148 -0
- scripts/tasks_storage.py +373 -0
- scripts/tasks_watch.py +159 -0
- scripts/test.sh +43 -0
- scripts/test_agent_config.py +97 -0
- scripts/test_plan_sprint.sh +172 -0
- scripts/test_python_fast.sh +26 -0
- scripts/test_python_lint.sh +22 -0
- scripts/test_python_slow.sh +14 -0
- scripts/test_run_stats.py +64 -0
- scripts/test_runners.sh +588 -0
- scripts/test_spec_lint.sh +438 -0
- scripts/test_sprint_auto.py +50 -0
- scripts/test_sprint_auto_execute.py +112 -0
- scripts/test_sprint_auto_preflight.py +143 -0
- scripts/test_sprint_auto_prepare.py +150 -0
- scripts/test_sprint_auto_resume.py +104 -0
- scripts/test_sprint_auto_state.py +157 -0
- scripts/test_tasks.py +1678 -0
- scripts/test_tasks_mcp.py +233 -0
- scripts/test_tasks_plan_import.py +129 -0
- scripts/test_tasks_storage.py +67 -0
- scripts/test_tasks_watch.py +239 -0
- scripts/test_worktree.sh +2112 -0
- scripts/worktree.sh +992 -0
- scripts/worktree_mr.py +133 -0
- scripts/worktree_path.py +70 -0
- scripts/worktree_sprint.py +70 -0
- tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
- tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
- tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
- tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
- tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
- 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
|