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
|
@@ -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))
|
scripts/tasks_render.py
ADDED
|
@@ -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
|