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/sprint_plan.py ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """Sprint plan tooling for the Kilo planner agent (see knowledge/local-planner-design.md).
3
+
4
+ sprint_plan.py extract <raw.txt> <plan.json> pull the JSON plan out of the model's answer
5
+ sprint_plan.py validate <plan.json> [--no-lint] [--specs-written] schema, flow rules and spec-lint; errors on stderr, exit 4
6
+ sprint_plan.py specs <plan.json> write each task's spec to tmp/tasks/<spec_name>.md
7
+ sprint_plan.py preview <plan.json> human-readable summary
8
+ sprint_plan.py apply <plan.json> import through tasks.py (add-sprint, add, link spec|scope)
9
+
10
+ Exit codes: 0 ok, 1 usage/IO, 4 invalid plan. Only `apply` writes the tracker, and only through tasks.py.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).resolve().parent.parent
21
+ SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
22
+ SPEC_NAME = re.compile(r"^[a-z0-9][a-z0-9-]*$")
23
+ KEY = re.compile(r"^[A-Za-z0-9_-]+$")
24
+ CLEAN_PATH = re.compile(r"^[A-Za-z0-9._/-]+$")
25
+ FORBIDDEN = (".git", ".githooks", ".claude", ".junie", ".kilo", "tmp", "kilo.json", ".mcp.json",
26
+ "tasks/tasks.db", "tasks/.lock")
27
+ MAX_TASKS = 12
28
+
29
+
30
+ class PlanError(Exception):
31
+ """Raised for an unusable plan or model answer."""
32
+
33
+
34
+ def extract_plan(text: str) -> dict:
35
+ """Return the plan object: the first fenced json block, else the outermost {...}."""
36
+ fence = re.search(r"```json\s*(\{.*?\})\s*```", text, re.S)
37
+ candidate = fence.group(1) if fence else None
38
+ if candidate is None:
39
+ start, end = text.find("{"), text.rfind("}")
40
+ if start < 0 or end <= start:
41
+ raise PlanError("no JSON object found in the model answer")
42
+ candidate = text[start:end + 1]
43
+ try:
44
+ plan = json.loads(candidate)
45
+ except json.JSONDecodeError as exc:
46
+ raise PlanError(f"the plan is not valid JSON: {exc}") from exc
47
+ if not isinstance(plan, dict):
48
+ raise PlanError("the plan must be a JSON object")
49
+ return plan
50
+
51
+
52
+ def _path_problem(path: object) -> str | None:
53
+ """Why a scope/path is not allowed, or None."""
54
+ if not isinstance(path, str) or not path:
55
+ return "must be a non-empty string"
56
+ if not CLEAN_PATH.match(path) or path.startswith("/") or ".." in path.split("/"):
57
+ return "must be a clean repo-relative path ([A-Za-z0-9._/-], no '..')"
58
+ if any(seg.startswith("-") or not seg for seg in path.split("/")):
59
+ return "has an empty segment or one starting with '-'"
60
+ for bad in FORBIDDEN:
61
+ if path == bad or path.startswith(bad + "/"):
62
+ return f"touches the forbidden path {bad}"
63
+ return None
64
+
65
+
66
+ def validate_plan(plan: dict, lint: bool = True, specs_written: bool = False) -> list[str]:
67
+ """Return every violation of the flow rules (empty list = valid).
68
+
69
+ With specs_written (apply time) the spec files must already exist; otherwise (plan time) they must not."""
70
+ errors: list[str] = []
71
+ sprint = plan.get("sprint")
72
+ if not isinstance(sprint, dict):
73
+ return ["sprint: missing object"]
74
+ if not isinstance(sprint.get("slug"), str) or not SLUG.match(sprint["slug"]):
75
+ errors.append("sprint.slug: lowercase words joined by single hyphens")
76
+ for field in ("title", "goal"):
77
+ if not isinstance(sprint.get(field), str) or not sprint[field].strip():
78
+ errors.append(f"sprint.{field}: required")
79
+ tasks = plan.get("tasks")
80
+ if not isinstance(tasks, list) or not tasks:
81
+ return errors + ["tasks: a non-empty list is required"]
82
+ if len(tasks) > MAX_TASKS:
83
+ errors.append(f"tasks: at most {MAX_TASKS}, got {len(tasks)}")
84
+ seen_keys: set[str] = set()
85
+ seen_specs: set[str] = set()
86
+ for index, task in enumerate(tasks, 1):
87
+ label = f"task #{index}"
88
+ if not isinstance(task, dict):
89
+ errors.append(f"{label}: must be an object")
90
+ continue
91
+ key = task.get("key")
92
+ if not isinstance(key, str) or not KEY.match(key):
93
+ errors.append(f"{label}: key must match {KEY.pattern}")
94
+ elif key in seen_keys:
95
+ errors.append(f"{label}: duplicate key '{key}'")
96
+ label = f"task '{key}'"
97
+ title = task.get("title")
98
+ if not isinstance(title, str) or not title.strip() or title.endswith(".") or re.match(r"^\w+(\(.*\))?:", title):
99
+ errors.append(f"{label}: title required, no trailing period, no conventional-commit prefix")
100
+ name = task.get("spec_name")
101
+ if not isinstance(name, str) or not SPEC_NAME.match(name):
102
+ errors.append(f"{label}: spec_name must match {SPEC_NAME.pattern}")
103
+ elif name in seen_specs:
104
+ errors.append(f"{label}: duplicate spec_name '{name}'")
105
+ elif (ROOT / "tmp" / "tasks" / f"{name}.md").exists() != specs_written:
106
+ errors.append(f"{label}: tmp/tasks/{name}.md " + ("is missing" if specs_written else "already exists; pick another spec_name"))
107
+ if not isinstance(task.get("spec"), str) or not task["spec"].strip():
108
+ errors.append(f"{label}: spec text required")
109
+ scope = task.get("scope")
110
+ if not isinstance(scope, list) or not scope:
111
+ errors.append(f"{label}: at least one scope path is required")
112
+ else:
113
+ errors += [f"{label}: scope '{p}' {why}" for p in scope if (why := _path_problem(p))]
114
+ deps = task.get("depends_on", [])
115
+ if not isinstance(deps, list) or any(d not in seen_keys for d in deps):
116
+ errors.append(f"{label}: depends_on must list earlier task keys")
117
+ if isinstance(key, str):
118
+ seen_keys.add(key)
119
+ if isinstance(name, str):
120
+ seen_specs.add(name)
121
+ if lint and not errors:
122
+ errors += _lint_specs(tasks)
123
+ return errors
124
+
125
+
126
+ def _lint_specs(tasks: list[dict]) -> list[str]:
127
+ """Run scripts/spec-lint.sh on every spec (written to a temp dir, not to tmp/tasks)."""
128
+ import tempfile
129
+ problems: list[str] = []
130
+ with tempfile.TemporaryDirectory() as tmp:
131
+ for task in tasks:
132
+ path = Path(tmp) / f"{task['spec_name']}.md"
133
+ path.write_text(task["spec"].rstrip() + "\n", encoding="utf-8")
134
+ result = subprocess.run(["bash", str(ROOT / "scripts" / "spec-lint.sh"), str(path)],
135
+ capture_output=True, text=True, check=False)
136
+ if result.returncode != 0:
137
+ problems += [f"task '{task['key']}': {line}" for line in result.stderr.strip().splitlines()]
138
+ return problems
139
+
140
+
141
+ def write_specs(plan: dict) -> list[Path]:
142
+ """Write every task's spec to tmp/tasks/<spec_name>.md."""
143
+ out = ROOT / "tmp" / "tasks"
144
+ out.mkdir(parents=True, exist_ok=True)
145
+ paths = []
146
+ for task in plan["tasks"]:
147
+ path = out / f"{task['spec_name']}.md"
148
+ path.write_text(task["spec"].rstrip() + "\n", encoding="utf-8")
149
+ paths.append(path)
150
+ return paths
151
+
152
+
153
+ def preview(plan: dict) -> str:
154
+ """Summary shown before the human confirms."""
155
+ sprint = plan["sprint"]
156
+ lines = [f"Sprint {sprint['slug']}: {sprint['title']}", f"Goal: {sprint['goal']}", ""]
157
+ for task in plan["tasks"]:
158
+ deps = f" after {','.join(task['depends_on'])}" if task.get("depends_on") else ""
159
+ lines.append(f" [{task['key']}] {task['title']}{deps}")
160
+ lines.append(f" scope: {', '.join(task['scope'])} spec: tmp/tasks/{task['spec_name']}.md")
161
+ return "\n".join(lines)
162
+
163
+
164
+ def _tasks_py(*args: str) -> str:
165
+ result = subprocess.run([sys.executable, str(ROOT / "scripts" / "tasks.py"), *args], capture_output=True,
166
+ text=True, check=False)
167
+ if result.returncode != 0:
168
+ raise PlanError(f"tasks.py {args[0]} failed: {result.stderr.strip()}")
169
+ return result.stdout
170
+
171
+
172
+ def apply_plan(plan: dict) -> dict[str, str]:
173
+ """Create the sprint and its tasks through tasks.py; returns {task key: tracker id}."""
174
+ sprint = plan["sprint"]
175
+ out = _tasks_py("add-sprint", sprint["title"], "--goal", sprint["goal"])
176
+ match = re.search(r"added sprint (\d+)", out)
177
+ if not match:
178
+ raise PlanError(f"cannot read the sprint id from: {out.strip()}")
179
+ sprint_id = match.group(1)
180
+ ids: dict[str, str] = {}
181
+ for task in plan["tasks"]:
182
+ args = ["add", sprint_id, task["title"]]
183
+ if task.get("description"):
184
+ args += ["-d", task["description"]]
185
+ if task.get("depends_on"):
186
+ args += ["--after", ",".join(ids[k] for k in task["depends_on"])]
187
+ match = re.search(r"added (\d+(?:\.\d+)+)", _tasks_py(*args))
188
+ if not match:
189
+ raise PlanError(f"cannot read the id of task '{task['key']}'")
190
+ ids[task["key"]] = match.group(1)
191
+ _tasks_py("link", ids[task["key"]], "spec", f"tmp/tasks/{task['spec_name']}.md")
192
+ for path in task["scope"]:
193
+ _tasks_py("link", ids[task["key"]], "scope", path)
194
+ return ids
195
+
196
+
197
+ def main(argv: list[str]) -> int:
198
+ """CLI entry point."""
199
+ if len(argv) < 3:
200
+ print(__doc__, file=sys.stderr)
201
+ return 1
202
+ command, target = argv[1], Path(argv[2])
203
+ try:
204
+ if command == "extract":
205
+ if len(argv) < 4:
206
+ print("usage: sprint_plan.py extract <raw.txt> <plan.json>", file=sys.stderr)
207
+ return 1
208
+ plan = extract_plan(target.read_text(encoding="utf-8"))
209
+ Path(argv[3]).write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
210
+ return 0
211
+ plan = json.loads(target.read_text(encoding="utf-8"))
212
+ if command == "validate":
213
+ errors = validate_plan(plan, lint="--no-lint" not in argv, specs_written="--specs-written" in argv)
214
+ for error in errors:
215
+ print(f"plan: {error}", file=sys.stderr)
216
+ return 4 if errors else 0
217
+ if command == "specs":
218
+ for path in write_specs(plan):
219
+ print(path.relative_to(ROOT))
220
+ elif command == "preview":
221
+ print(preview(plan))
222
+ elif command == "apply":
223
+ for key, task_id in apply_plan(plan).items():
224
+ print(f"{key} -> {task_id}")
225
+ else:
226
+ print(f"unknown command '{command}'", file=sys.stderr)
227
+ return 1
228
+ except PlanError as exc:
229
+ print(f"plan: {exc}", file=sys.stderr)
230
+ return 4
231
+ except (OSError, json.JSONDecodeError) as exc:
232
+ print(f"error: {exc}", file=sys.stderr)
233
+ return 1
234
+ return 0
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main(sys.argv))
scripts/task-report.sh ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Reports delegation progress to the task tracker (see AGENTS.md "Delegation flow").
3
+ # Used by junie-task.sh and kilo-task.sh through their --task-id flag.
4
+ #
5
+ # scripts/task-report.sh start <id> mark the task in_progress
6
+ # scripts/task-report.sh note <id> "<text>" append a dated note, leaving the status unchanged
7
+ # scripts/task-report.sh close <id> green|red [reason]
8
+ # green -> done; red -> blocked with [reason]
9
+ #
10
+ # An empty <id> is a silent no-op. Any tracker failure only warns on stderr and never
11
+ # changes the caller's exit code, so reporting can never mask the runner's own result.
12
+ set -euo pipefail
13
+
14
+ action="${1:-}"
15
+ task_id="${2:-}"
16
+ text="${3:-}"
17
+
18
+ [ -n "$task_id" ] || exit 0
19
+
20
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ tracker="$here/tasks.py"
22
+
23
+ warn() { printf 'task-report: %s\n' "$*" >&2; }
24
+
25
+ # run <args...>: call the tracker; on failure warn (never fail). Already-in-progress start
26
+ # and unknown ids are just failures here, which is exactly what the runner must tolerate.
27
+ run() {
28
+ local output rc=0
29
+ output="$(python3 "$tracker" "$@" 2>&1)" || rc=$?
30
+ [ "$rc" -eq 0 ] && return 0
31
+ warn "tasks.py $* failed (exit $rc): ${output:-no output}"
32
+ }
33
+
34
+ case "$action" in
35
+ start) run start "$task_id" ;;
36
+ note) run note "$task_id" "$text" ;;
37
+ close)
38
+ case "${3:-}" in
39
+ green) run done "$task_id" ;;
40
+ red) run block "$task_id" --reason "${4:-run failed}" ;;
41
+ *) warn "close needs a verdict: green|red" ;;
42
+ esac
43
+ ;;
44
+ *) warn "unknown action '${action}' (use: start|note|close)" ;;
45
+ esac