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/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
|