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
@@ -0,0 +1,269 @@
1
+ """Atomic plan import for the automated sprint lifecycle (Sprint 12, task 12.2).
2
+
3
+ Provides an idempotent, single-transaction import primitive owned by tasks.py.
4
+ No separate database writer or schema migration is introduced.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+ import tasks # noqa: E402
16
+
17
+
18
+ def canonical_fingerprint(plan: dict) -> str:
19
+ """Deterministic SHA-256 fingerprint of a plan (sorted keys)."""
20
+ return hashlib.sha256(
21
+ json.dumps(plan, sort_keys=True, ensure_ascii=False).encode()
22
+ ).hexdigest()
23
+
24
+
25
+ def _validate_spec(spec_text: str, spec_path: Path) -> None:
26
+ """Write spec to disk and lint it (lint must be enabled)."""
27
+ spec_path.parent.mkdir(parents=True, exist_ok=True)
28
+ spec_path.write_text(spec_text, encoding="utf-8")
29
+ lint = Path(__file__).resolve().parent / "spec-lint.sh"
30
+ if not lint.exists():
31
+ return
32
+ env = dict(os.environ) if False else None
33
+ import os
34
+ env = {k: v for k, v in os.environ.items() if k != "SPEC_LINT"}
35
+ r = subprocess.run(
36
+ ["bash", str(lint), str(spec_path)],
37
+ capture_output=True, text=True, env=env,
38
+ )
39
+ if r.returncode != 0:
40
+ raise tasks.TaskError(f"spec lint failed for {spec_path}: {r.stderr or r.stdout}")
41
+
42
+
43
+ def _find_existing_run(conn, run_id: str):
44
+ """Return (sprint_id, fingerprint) if run_id was already imported, else (None, None)."""
45
+ row = conn.execute(
46
+ "SELECT node_uid FROM links WHERE kind='agent' AND value=?",
47
+ (f"run-id:{run_id}",),
48
+ ).fetchone()
49
+ if row is None:
50
+ return None, None
51
+ sprint_row = conn.execute(
52
+ "SELECT id FROM nodes WHERE uid=?", (row[0],)
53
+ ).fetchone()
54
+ if sprint_row is None:
55
+ return None, None
56
+ fp_row = conn.execute(
57
+ "SELECT value FROM links WHERE node_uid=? AND kind='agent' AND value LIKE 'fingerprint:%'",
58
+ (row[0],),
59
+ ).fetchone()
60
+ fp = fp_row[0].replace("fingerprint:", "", 1) if fp_row else None
61
+ return sprint_row[0], fp
62
+
63
+
64
+ def _reconstruct_mapping(conn, sprint_id: str) -> dict:
65
+ """Rebuild task-key -> {id, uid} mapping from stored links."""
66
+ result = {}
67
+ for row in conn.execute(
68
+ "SELECT id, uid FROM nodes WHERE parent_id=?", (sprint_id,)
69
+ ):
70
+ tid, tuid = row
71
+ kr = conn.execute(
72
+ "SELECT value FROM links WHERE node_uid=? AND kind='agent' AND value LIKE 'task-key:%'",
73
+ (tuid,),
74
+ ).fetchone()
75
+ if kr:
76
+ key = kr[0].replace("task-key:", "", 1)
77
+ result[key] = {"id": tid, "uid": tuid}
78
+ return result
79
+
80
+
81
+ def _repair_materialization(plan: dict, root: Path) -> None:
82
+ """Write any missing spec files (no DB changes)."""
83
+ spec_dir = root / "tmp" / "tasks"
84
+ spec_dir.mkdir(parents=True, exist_ok=True)
85
+ for t in plan.get("tasks", []):
86
+ spec_name = t.get("spec_name", "")
87
+ if spec_name and t.get("spec"):
88
+ sp = spec_dir / f"{spec_name}.md"
89
+ if not sp.exists():
90
+ sp.write_text(t["spec"], encoding="utf-8")
91
+
92
+
93
+ def import_plan(conn, plan: dict, run_id: str, root: Path) -> dict:
94
+ """Import *plan* under *run_id*. Must be called inside a mutate transaction.
95
+
96
+ Returns a receipt dict. Raises TaskError on validation or conflict.
97
+ """
98
+ fingerprint = canonical_fingerprint(plan)
99
+
100
+ # --- replay detection ---
101
+ existing_sid, existing_fp = _find_existing_run(conn, run_id)
102
+ if existing_sid is not None:
103
+ if existing_fp != fingerprint:
104
+ raise tasks.TaskError(
105
+ f"run-id {run_id!r} reused with different content"
106
+ )
107
+ # repair materialization without reinserting
108
+ _repair_materialization(plan, root)
109
+ # re-render view + snapshot
110
+ data = tasks.load(conn)
111
+ tasks.write_view(data)
112
+ tasks.write_snapshot(data)
113
+ return {
114
+ "sprint_id": existing_sid,
115
+ "sprint_uid": conn.execute(
116
+ "SELECT uid FROM nodes WHERE id=?", (existing_sid,)
117
+ ).fetchone()[0],
118
+ "task_map": _reconstruct_mapping(conn, existing_sid),
119
+ "replay": True,
120
+ }
121
+
122
+ # --- validate plan structure ---
123
+ sprint_info = plan.get("sprint", {})
124
+ if not sprint_info.get("slug") or not sprint_info.get("title"):
125
+ raise tasks.TaskError("plan missing sprint slug/title")
126
+ tasks_list = plan.get("tasks", [])
127
+ if not tasks_list:
128
+ raise tasks.TaskError("plan has no tasks")
129
+
130
+ # --- validate + write specs before mutation ---
131
+ spec_dir = root / "tmp" / "tasks"
132
+ spec_dir.mkdir(parents=True, exist_ok=True)
133
+ for t in tasks_list:
134
+ spec_name = t.get("spec_name", "")
135
+ spec_text = t.get("spec", "")
136
+ if spec_name and spec_text:
137
+ _validate_spec(spec_text, spec_dir / f"{spec_name}.md")
138
+
139
+ # --- create sprint ---
140
+ sprint_id = tasks.insert_node(
141
+ conn, None, sprint_info["title"],
142
+ goal=sprint_info.get("goal", ""),
143
+ start=sprint_info.get("start", ""),
144
+ end=sprint_info.get("end", ""),
145
+ )
146
+ sprint_uid = conn.execute(
147
+ "SELECT uid FROM nodes WHERE id=?", (sprint_id,)
148
+ ).fetchone()[0]
149
+
150
+ # --- store run-id and fingerprint as agent links ---
151
+ conn.execute(
152
+ "INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
153
+ (sprint_uid, "agent", f"run-id:{run_id}", tasks.today()),
154
+ )
155
+ conn.execute(
156
+ "INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
157
+ (sprint_uid, "agent", f"fingerprint:{fingerprint}", tasks.today()),
158
+ )
159
+
160
+ # --- create tasks ---
161
+ task_map = {}
162
+ key_to_id: dict = {}
163
+ for t in tasks_list:
164
+ key = t["key"]
165
+ task_id = tasks.insert_node(
166
+ conn, sprint_id, t["title"],
167
+ description=t.get("description", ""),
168
+ assignee=t.get("assignee", ""),
169
+ )
170
+ task_uid = conn.execute(
171
+ "SELECT uid FROM nodes WHERE id=?", (task_id,)
172
+ ).fetchone()[0]
173
+ key_to_id[key] = task_id
174
+ conn.execute(
175
+ "INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
176
+ (task_uid, "agent", f"task-key:{key}", tasks.today()),
177
+ )
178
+ # spec link
179
+ spec_name = t.get("spec_name", "")
180
+ if spec_name:
181
+ conn.execute(
182
+ "INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
183
+ (task_uid, "spec", f"tmp/tasks/{spec_name}.md", tasks.today()),
184
+ )
185
+ # scope links
186
+ for scope in t.get("scope", []):
187
+ conn.execute(
188
+ "INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
189
+ (task_uid, "scope", scope, tasks.today()),
190
+ )
191
+ task_map[key] = {"id": task_id, "uid": task_uid}
192
+
193
+ # --- set dependencies ---
194
+ for t in tasks_list:
195
+ key = t["key"]
196
+ deps = t.get("depends_on", [])
197
+ dep_ids = [key_to_id[d] for d in deps if d in key_to_id]
198
+ if dep_ids:
199
+ tasks.set_deps(conn, key_to_id[key], dep_ids)
200
+
201
+ return {
202
+ "sprint_id": sprint_id,
203
+ "sprint_uid": sprint_uid,
204
+ "task_map": task_map,
205
+ "replay": False,
206
+ }
207
+
208
+
209
+ def cmd_import_plan(args) -> None:
210
+ """CLI entry point: tasks.py import-plan PLAN --run-id NAME"""
211
+ plan_path = Path(args.plan)
212
+ if not plan_path.exists():
213
+ raise tasks.TaskError(f"plan file not found: {plan_path}")
214
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
215
+ run_id = args.run_id
216
+ root = tasks.ROOT
217
+
218
+ # read-only replay check
219
+ conn = tasks.connect()
220
+ try:
221
+ existing_sid, existing_fp = _find_existing_run(conn, run_id)
222
+ finally:
223
+ conn.close()
224
+
225
+ if existing_sid is not None:
226
+ fingerprint = canonical_fingerprint(plan)
227
+ if existing_fp != fingerprint:
228
+ raise tasks.TaskError(f"run-id {run_id!r} reused with different content")
229
+ # repair materialization
230
+ _repair_materialization(plan, root)
231
+ conn = tasks.connect()
232
+ try:
233
+ data = tasks.load(conn)
234
+ tasks.write_view(data)
235
+ tasks.write_snapshot(data)
236
+ mapping = _reconstruct_mapping(conn, existing_sid)
237
+ sprint_uid = conn.execute(
238
+ "SELECT uid FROM nodes WHERE id=?", (existing_sid,)
239
+ ).fetchone()[0]
240
+ finally:
241
+ conn.close()
242
+ receipt = {
243
+ "sprint_id": existing_sid,
244
+ "sprint_uid": sprint_uid,
245
+ "task_map": mapping,
246
+ "replay": True,
247
+ }
248
+ print(json.dumps(receipt, indent=2))
249
+ return
250
+
251
+ # validate specs before mutation (lint enabled)
252
+ spec_dir = root / "tmp" / "tasks"
253
+ spec_dir.mkdir(parents=True, exist_ok=True)
254
+ for t in plan.get("tasks", []):
255
+ sn = t.get("spec_name", "")
256
+ st = t.get("spec", "")
257
+ if sn and st:
258
+ _validate_spec(st, spec_dir / f"{sn}.md")
259
+
260
+ # mutate
261
+ receipt_holder = {}
262
+
263
+ def action(conn):
264
+ r = import_plan(conn, plan, run_id, root)
265
+ receipt_holder.update(r)
266
+ return ""
267
+
268
+ tasks.mutate(action)
269
+ print(json.dumps(receipt_holder, indent=2))
@@ -0,0 +1,148 @@
1
+ """Markdown rendering for the task tracker view.
2
+
3
+ Extracted from tasks.py to keep the monolith small and testable.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ # Import from tasks_model and tasks_storage
12
+ import sys as _sys
13
+ _sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ from tasks_model import * # noqa: E402, F401, F403
15
+ from tasks_storage import * # noqa: E402, F401, F403
16
+
17
+
18
+ def counts(nodes) -> dict[str, int]:
19
+ """Count nodes by status."""
20
+ result = {s: 0 for s in STATUSES}
21
+ for n in nodes:
22
+ result[n["status"]] += 1
23
+ return result
24
+
25
+
26
+ def bar(done: int, total: int, width: int = 10) -> str:
27
+ """Render a progress bar."""
28
+ filled = round(width * done / total) if total else 0
29
+ return "█" * filled + "░" * (width - filled)
30
+
31
+
32
+ def render_node(node: dict, indent: int, lines: list[str]) -> None:
33
+ """Render a single node (and children) into lines."""
34
+ status = effective(node)
35
+ box = "x" if status == "done" else " "
36
+ parts = [f"{' ' * indent}- [{box}] **{node['id']}** {node['title']} — {BADGE[status]}"]
37
+ if node["children"]:
38
+ d, t = progress(node)
39
+ parts.append(f"({d}/{t})")
40
+ if node["assignee"]:
41
+ parts.append(f"· @{node['assignee']}")
42
+ if node["depends_on"]:
43
+ parts.append("· after " + ", ".join(node["depends_on"]))
44
+ kinds = " · ".join(sorted({link["kind"] for link in node.get("links", [])}))
45
+ if kinds:
46
+ parts.append(f"· [{kinds}]")
47
+ lines.append(" ".join(parts))
48
+ pad = " " * (indent + 1)
49
+ if status == "blocked" and node["reason"]:
50
+ lines.append(f"{pad}- ⛔ **Blocked:** {node['reason']}")
51
+ if node["description"]:
52
+ for text in node["description"].splitlines():
53
+ stripped = text.strip()
54
+ if not stripped:
55
+ continue
56
+ lines.append(f"{pad}{text}" if stripped.startswith(("- ", "* ")) else f"{pad}- {text}")
57
+ for note in node["notes"]:
58
+ lines.append(f"{pad}- 📝 {note}")
59
+ for child in node["children"]:
60
+ render_node(child, indent + 1, lines)
61
+
62
+
63
+ def slugify(text: str) -> str:
64
+ """Lowercase kebab slug for a sprint file name; falls back to 'sprint'."""
65
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
66
+ return slug or "sprint"
67
+
68
+
69
+ def sprint_view_path(sprint: dict) -> Path:
70
+ """Path of the per-sprint detail file inside the view directory."""
71
+ return VIEW_DIR / f"sprint-{sprint['id']}-{slugify(sprint['title'])}.md"
72
+
73
+
74
+ def _sprint_body(sprint: dict) -> list[str]:
75
+ """Body shared by the README's current sprint and the per-sprint file."""
76
+ d, t = progress(sprint)
77
+ lines: list[str] = []
78
+ if sprint.get("goal"):
79
+ lines += [f"**Goal:** {sprint['goal']}", ""]
80
+ lines += [f"Progress: {d}/{t} `{bar(d, t)}`", ""]
81
+ for note in sprint["notes"]:
82
+ lines.append(f"📝 {note}")
83
+ if sprint["notes"]:
84
+ lines.append("")
85
+ for task in sprint["children"]:
86
+ render_node(task, 0, lines)
87
+ lines.append("")
88
+ return lines
89
+
90
+
91
+ def render_sprint_file(sprint: dict) -> str:
92
+ """The per-sprint detail file (`sprint-<n>-<slug>.md`)."""
93
+ lines = [f"# Sprint {sprint['id']} — {sprint['title']} {BADGE[effective(sprint)]}", ""]
94
+ lines += _sprint_body(sprint)
95
+ return "\n".join(lines).rstrip() + "\n"
96
+
97
+
98
+ def render_readme(data: dict) -> str:
99
+ """The view index: overall + the current sprint inline + links to the per-sprint files."""
100
+ sprints = data["sprints"]
101
+ all_leaves = [leaf for s in sprints for leaf in leaves(s)]
102
+ c = counts(all_leaves)
103
+ actionable = [leaf for leaf in all_leaves if leaf["status"] != "cancelled"]
104
+ done, total = c["done"], len(actionable)
105
+ pct = round(100 * done / total) if total else 0
106
+ lines = [
107
+ f"# Tasks — {data['project']}",
108
+ "",
109
+ "> Generated from `tasks/tasks.db` by `scripts/tasks.py` after every change. Do not edit by hand.",
110
+ "> Per-sprint detail lives in the `sprint-*.md` files next to this README.",
111
+ "",
112
+ f"**Overall:** {done}/{total} done ({pct}%) `{bar(done, total)}` · "
113
+ f"🔄 {c['in_progress']} in progress · ⛔ {c['blocked']} blocked · "
114
+ f"🚫 {c['cancelled']} cancelled · 🔎 {c['review']} review · ⬜ {c['none']} not started",
115
+ "",
116
+ "Status legend: ⬜ none (brak) · 🔄 in progress (w trakcie) · ⛔ blocked (bloker) · ✅ done (gotowe) · "
117
+ "🚫 cancelled (anulowane). Parent status is derived from its children: any blocked → blocked, "
118
+ "all done or cancelled → done, any started or finished → in progress, otherwise none.",
119
+ "",
120
+ ]
121
+ if sprints:
122
+ lines += ["## Sprints", ""]
123
+ for s in sprints:
124
+ d, t = progress(s)
125
+ lines.append(f"- [Sprint {s['id']} — {s['title']}]({sprint_view_path(s).name}) — "
126
+ f"{BADGE[effective(s)]} `{bar(d, t)}` {d}/{t}")
127
+ lines.append("")
128
+ blockers = [n for s in sprints for n in leaves(s) if n["status"] == "blocked"]
129
+ if blockers:
130
+ lines += ["## ⛔ Blockers", ""]
131
+ lines += [f"- **{n['id']}** {n['title']} — {n['reason']}" for n in blockers]
132
+ lines.append("")
133
+ if not sprints:
134
+ lines += ["_No sprints yet. Start with `scripts/tasks.py add-sprint \"Title\"`._", ""]
135
+ return "\n".join(lines).rstrip() + "\n"
136
+
137
+
138
+ def render(data: dict) -> str:
139
+ """The main generated file: the README index (single-document entry point)."""
140
+ return render_readme(data)
141
+
142
+
143
+ def expected_view(data: dict) -> dict:
144
+ """{path: text} for every generated view file (the README index + one file per sprint)."""
145
+ files = {README_FILE: render_readme(data)}
146
+ for s in data["sprints"]:
147
+ files[sprint_view_path(s)] = render_sprint_file(s)
148
+ return files