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.py
ADDED
|
@@ -0,0 +1,1813 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task tracker: sprints > tasks > subtasks, stored in a SQLite database (tasks/tasks.db).
|
|
3
|
+
|
|
4
|
+
Every command that changes the data runs in one transaction, validates the result (rolling back
|
|
5
|
+
on error) and re-renders the tasks/ view, so the Markdown view is never stale. Standard library only.
|
|
6
|
+
`export-json` / `import-json` give a diffable backup and a way to recover from a binary merge conflict.
|
|
7
|
+
|
|
8
|
+
Statuses (leaf nodes are set by hand; parents are derived, never stored):
|
|
9
|
+
none (brak) | in_progress (w trakcie) | blocked (bloker) | done (gotowe)
|
|
10
|
+
|
|
11
|
+
Cascade up (task from subtasks, sprint from tasks):
|
|
12
|
+
any child blocked -> blocked
|
|
13
|
+
all children done -> done
|
|
14
|
+
any child in progress or done -> in_progress
|
|
15
|
+
otherwise -> none
|
|
16
|
+
|
|
17
|
+
Usage: scripts/tasks.py <command> --help
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import fcntl
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import secrets
|
|
28
|
+
import sqlite3
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
35
|
+
import tasks_init # noqa: E402
|
|
36
|
+
|
|
37
|
+
# Agents work in disposable worktrees (<main>/tmp/**); the tracker always lives in the main checkout.
|
|
38
|
+
ROOT = tasks_init.resolve_root(Path(__file__).resolve().parent.parent)
|
|
39
|
+
DATA_FILE = ROOT / "tasks" / "tasks.db"
|
|
40
|
+
VIEW_DIR = ROOT / "tasks" # generated Markdown view directory (git-ignored)
|
|
41
|
+
README_FILE = VIEW_DIR / "README.md" # index: overall + current sprint + links to the per-sprint files
|
|
42
|
+
VIEW_FILE = README_FILE # the primary generated file (alias for the README index)
|
|
43
|
+
LOCK_FILE = ROOT / "tasks" / ".lock"
|
|
44
|
+
SNAPSHOT_FILE = ROOT / "tasks" / "tasks.snapshot.json"
|
|
45
|
+
RECOVERY_DIR = ROOT / "tasks" / "removed"
|
|
46
|
+
# Backup clone (outside the project) with history; nothing pushes on its own (explicit --push only).
|
|
47
|
+
BACKUP_DIR = Path(os.environ.get("VIBE_TASKS_BACKUP_DIR", str(Path.home() / ".local" / "share" / "vibe-tasks")))
|
|
48
|
+
BACKUP_REMOTE = os.environ.get("VIBE_TASKS_BACKUP_REMOTE", "git@gitlab.com:prachwal/vibe-emulator-tasks.git")
|
|
49
|
+
|
|
50
|
+
STATUSES = ("none", "in_progress", "blocked", "done", "cancelled", "review")
|
|
51
|
+
ALIASES = {
|
|
52
|
+
"none": "none", "brak": "none", "todo": "none", "open": "none",
|
|
53
|
+
"in_progress": "in_progress", "in-progress": "in_progress", "w-trakcie": "in_progress",
|
|
54
|
+
"w_trakcie": "in_progress", "doing": "in_progress", "wip": "in_progress", "start": "in_progress",
|
|
55
|
+
"blocked": "blocked", "bloker": "blocked", "block": "blocked",
|
|
56
|
+
"done": "done", "gotowe": "done", "finished": "done",
|
|
57
|
+
"cancelled": "cancelled", "canceled": "cancelled", "anulowane": "cancelled", "anulowany": "cancelled",
|
|
58
|
+
"review": "review", "w-przeglądzie": "review", "w-przegladzie": "review", "przeglad": "review",
|
|
59
|
+
}
|
|
60
|
+
BADGE = {"none": "⬜ none", "in_progress": "🔄 in progress", "blocked": "⛔ blocked",
|
|
61
|
+
"done": "✅ done", "cancelled": "🚫 cancelled", "review": "🔎 review"}
|
|
62
|
+
MAX_DEPTH = 3 # sprint > task > subtask
|
|
63
|
+
|
|
64
|
+
# `nodes` rebuilt with the 'cancelled' status; used by migrate() for databases created before it.
|
|
65
|
+
NODES_V2_DDL = """
|
|
66
|
+
CREATE TABLE nodes_new (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
uid TEXT NOT NULL DEFAULT '',
|
|
69
|
+
parent_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
|
|
70
|
+
position INTEGER NOT NULL,
|
|
71
|
+
level INTEGER NOT NULL CHECK (level BETWEEN 1 AND 3),
|
|
72
|
+
title TEXT NOT NULL,
|
|
73
|
+
description TEXT NOT NULL DEFAULT '',
|
|
74
|
+
status TEXT NOT NULL DEFAULT 'none' CHECK (status IN ('none','in_progress','blocked','done','cancelled','review')),
|
|
75
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
76
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
77
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
78
|
+
start TEXT NOT NULL DEFAULT '',
|
|
79
|
+
end TEXT NOT NULL DEFAULT '',
|
|
80
|
+
created TEXT NOT NULL,
|
|
81
|
+
updated TEXT NOT NULL,
|
|
82
|
+
started TEXT NOT NULL DEFAULT '',
|
|
83
|
+
finished TEXT NOT NULL DEFAULT '',
|
|
84
|
+
UNIQUE (parent_id, position)
|
|
85
|
+
);
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaskError(Exception):
|
|
90
|
+
"""A user-facing error (bad id, invalid transition, invalid data)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- model
|
|
94
|
+
|
|
95
|
+
def today() -> str:
|
|
96
|
+
return dt.date.today().isoformat()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def now_iso() -> str:
|
|
100
|
+
"""Local timestamp (second precision) used for audit events."""
|
|
101
|
+
return dt.datetime.now().replace(microsecond=0).isoformat() # noqa: DTZ005 - local by design
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def actor() -> str:
|
|
105
|
+
"""Whoever is making the change: $VIBE_ACTOR, else $USER, else 'unknown'."""
|
|
106
|
+
return os.environ.get("VIBE_ACTOR") or os.environ.get("USER") or "unknown"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
CURRENT_ACTION = "" # the CLI command currently running, recorded in the audit log
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def new_uid(conn: sqlite3.Connection | None = None) -> str:
|
|
113
|
+
"""A fresh immutable uid (`T-` + 4 base36 chars), unique among the nodes already stored."""
|
|
114
|
+
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
115
|
+
existing: set[str] = set()
|
|
116
|
+
if conn is not None:
|
|
117
|
+
existing = {r[0] for r in conn.execute("SELECT uid FROM nodes WHERE uid <> ''")}
|
|
118
|
+
while True:
|
|
119
|
+
uid = "T-" + "".join(secrets.choice(alphabet) for _ in range(4))
|
|
120
|
+
if uid not in existing:
|
|
121
|
+
return uid
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def new_node(node_id: str, title: str, **fields) -> dict:
|
|
125
|
+
node = {
|
|
126
|
+
"id": node_id, "uid": "", "title": title, "description": "", "status": "none", "reason": "",
|
|
127
|
+
"assignee": "", "depends_on": [], "notes": [], "links": [], "created": today(), "updated": today(),
|
|
128
|
+
"started": "", "finished": "", "children": [],
|
|
129
|
+
}
|
|
130
|
+
node.update({k: v for k, v in fields.items() if v is not None})
|
|
131
|
+
return node
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def empty_data(project: str = "Project") -> dict:
|
|
135
|
+
return {"version": 1, "project": project, "sprints": []}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def walk(nodes):
|
|
139
|
+
"""Yield every node depth-first in document order."""
|
|
140
|
+
for node in nodes:
|
|
141
|
+
yield node
|
|
142
|
+
yield from walk(node["children"])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def find(data: dict, node_id: str) -> dict:
|
|
146
|
+
for node in walk(data["sprints"]):
|
|
147
|
+
if node["id"] == node_id or node.get("uid") == node_id:
|
|
148
|
+
return node
|
|
149
|
+
raise TaskError(f"no such id: {node_id}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parent_of(data: dict, node_id: str) -> dict | None:
|
|
153
|
+
for node in walk(data["sprints"]):
|
|
154
|
+
if any(c["id"] == node_id for c in node["children"]):
|
|
155
|
+
return node
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def depth(node_id: str) -> int:
|
|
160
|
+
return node_id.count(".") + 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def effective(node: dict) -> str:
|
|
164
|
+
"""Status of a node: stored for leaves, derived from children otherwise (cascade up).
|
|
165
|
+
|
|
166
|
+
Cancelled children are terminal: all done or cancelled -> done, all cancelled -> cancelled, and
|
|
167
|
+
cancelled leaves never block a parent from completing.
|
|
168
|
+
"""
|
|
169
|
+
if not node["children"]:
|
|
170
|
+
return node["status"]
|
|
171
|
+
states = [effective(c) for c in node["children"]]
|
|
172
|
+
if "blocked" in states:
|
|
173
|
+
return "blocked"
|
|
174
|
+
if all(s == "done" for s in states):
|
|
175
|
+
return "done"
|
|
176
|
+
if all(s == "cancelled" for s in states):
|
|
177
|
+
return "cancelled"
|
|
178
|
+
if all(s in ("done", "cancelled") for s in states):
|
|
179
|
+
return "done"
|
|
180
|
+
if any(s in ("in_progress", "done", "review") for s in states):
|
|
181
|
+
return "in_progress"
|
|
182
|
+
return "none"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def leaves(node: dict):
|
|
186
|
+
if not node["children"]:
|
|
187
|
+
yield node
|
|
188
|
+
for child in node["children"]:
|
|
189
|
+
yield from leaves(child)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def progress(node: dict) -> tuple[int, int]:
|
|
193
|
+
"""(done, actionable) leaves; cancelled leaves are excluded from the denominator."""
|
|
194
|
+
all_leaves = [n for n in leaves(node) if n["status"] != "cancelled"]
|
|
195
|
+
return sum(1 for n in all_leaves if n["status"] == "done"), len(all_leaves)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def next_child_id(parent: dict | None, data: dict) -> str:
|
|
199
|
+
siblings = parent["children"] if parent else data["sprints"]
|
|
200
|
+
top = 0
|
|
201
|
+
for s in siblings:
|
|
202
|
+
top = max(top, int(s["id"].rsplit(".", 1)[-1]))
|
|
203
|
+
return f"{parent['id']}.{top + 1}" if parent else str(top + 1)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def normalize_status(value: str) -> str:
|
|
207
|
+
try:
|
|
208
|
+
return ALIASES[value.strip().lower()]
|
|
209
|
+
except KeyError:
|
|
210
|
+
raise TaskError(f"unknown status '{value}' (use: none/brak, in_progress/w-trakcie, blocked/bloker, done/gotowe)")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def validate(data: dict) -> None:
|
|
214
|
+
seen: set[str] = set()
|
|
215
|
+
uids: set[str] = set()
|
|
216
|
+
ids = {n["id"] for n in walk(data["sprints"])}
|
|
217
|
+
for node in walk(data["sprints"]):
|
|
218
|
+
nid = node["id"]
|
|
219
|
+
if nid in seen:
|
|
220
|
+
raise TaskError(f"duplicate id {nid}")
|
|
221
|
+
seen.add(nid)
|
|
222
|
+
uid = node.get("uid", "")
|
|
223
|
+
if not uid:
|
|
224
|
+
raise TaskError(f"{nid}: missing uid")
|
|
225
|
+
if uid in uids:
|
|
226
|
+
raise TaskError(f"duplicate uid {uid}")
|
|
227
|
+
uids.add(uid)
|
|
228
|
+
for link in node.get("links", []):
|
|
229
|
+
if link["kind"] not in LINK_KINDS:
|
|
230
|
+
raise TaskError(f"{nid}: unknown link kind {link['kind']}")
|
|
231
|
+
if depth(nid) > MAX_DEPTH:
|
|
232
|
+
raise TaskError(f"{nid}: nesting deeper than sprint > task > subtask is not allowed")
|
|
233
|
+
if node["status"] not in STATUSES:
|
|
234
|
+
raise TaskError(f"{nid}: invalid status {node['status']}")
|
|
235
|
+
if not node["children"] and node["status"] == "blocked" and not node["reason"]:
|
|
236
|
+
raise TaskError(f"{nid}: a blocked item needs a reason")
|
|
237
|
+
for dep in node["depends_on"]:
|
|
238
|
+
if dep not in ids:
|
|
239
|
+
raise TaskError(f"{nid}: depends on unknown id {dep}")
|
|
240
|
+
if dep == nid or dep.startswith(nid + ".") or nid.startswith(dep + "."):
|
|
241
|
+
raise TaskError(f"{nid}: cannot depend on its own ancestor/descendant {dep}")
|
|
242
|
+
# dependency cycles
|
|
243
|
+
graph = {n["id"]: n["depends_on"] for n in walk(data["sprints"])}
|
|
244
|
+
state: dict[str, int] = {}
|
|
245
|
+
|
|
246
|
+
def visit(n: str, path: list[str]) -> None:
|
|
247
|
+
if state.get(n) == 2:
|
|
248
|
+
return
|
|
249
|
+
if state.get(n) == 1:
|
|
250
|
+
raise TaskError("dependency cycle: " + " -> ".join(path + [n]))
|
|
251
|
+
state[n] = 1
|
|
252
|
+
for dep in graph[n]:
|
|
253
|
+
visit(dep, path + [n])
|
|
254
|
+
state[n] = 2
|
|
255
|
+
|
|
256
|
+
for n in graph:
|
|
257
|
+
visit(n, [])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# --------------------------------------------------------------------------- storage
|
|
261
|
+
|
|
262
|
+
FIELDS = ("id", "uid", "title", "description", "status", "reason", "assignee", "goal", "start", "end",
|
|
263
|
+
"created", "updated", "started", "finished")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
LINK_KINDS = ("spec", "patch", "runner", "model", "agent", "commit", "branch", "fixes", "scope")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def connect(create: bool = True) -> sqlite3.Connection:
|
|
270
|
+
"""Open the database; when the file is missing, create it from scratch (see tasks_init.py)."""
|
|
271
|
+
if not DATA_FILE.exists():
|
|
272
|
+
if not create:
|
|
273
|
+
raise TaskError(f"no database at {DATA_FILE.parent.name}/{DATA_FILE.name}; run: python3 scripts/tasks_init.py")
|
|
274
|
+
tasks_init.create_database(DATA_FILE, ROOT.name)
|
|
275
|
+
print(f"note: created a new empty task database at {DATA_FILE.parent.name}/{DATA_FILE.name}", file=sys.stderr)
|
|
276
|
+
conn = sqlite3.connect(DATA_FILE, isolation_level=None, timeout=30)
|
|
277
|
+
conn.row_factory = sqlite3.Row
|
|
278
|
+
migrate(conn)
|
|
279
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
280
|
+
return conn
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def migrate(conn: sqlite3.Connection) -> None:
|
|
284
|
+
"""Bring an existing database up to the current schema (idempotent, FK-safe).
|
|
285
|
+
|
|
286
|
+
Adds the immutable `uid` column (backfilling it) and the `links` table, and rebuilds `nodes`
|
|
287
|
+
when its CHECK predates the 'cancelled'/'review' statuses. Dependencies and notes are untouched
|
|
288
|
+
and re-resolve to the rebuilt table by name.
|
|
289
|
+
"""
|
|
290
|
+
row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='nodes'").fetchone()
|
|
291
|
+
if row is None:
|
|
292
|
+
return
|
|
293
|
+
sql = row[0] or ""
|
|
294
|
+
columns = [c[1] for c in conn.execute("PRAGMA table_info(nodes)")]
|
|
295
|
+
have_uid = "uid" in columns
|
|
296
|
+
needs_rebuild = "cancelled" not in sql or "review" not in sql
|
|
297
|
+
|
|
298
|
+
if needs_rebuild:
|
|
299
|
+
cols = ("id, uid, parent_id, position, level, title, description, status, reason, assignee, "
|
|
300
|
+
"goal, start, end, created, updated, started, finished")
|
|
301
|
+
select = cols if have_uid else cols.replace("uid, ", "'' AS uid, ")
|
|
302
|
+
conn.execute("PRAGMA foreign_keys = OFF")
|
|
303
|
+
try:
|
|
304
|
+
conn.execute("BEGIN")
|
|
305
|
+
conn.execute(NODES_V2_DDL)
|
|
306
|
+
conn.execute(f"INSERT INTO nodes_new ({cols}) SELECT {select} FROM nodes")
|
|
307
|
+
conn.execute("DROP TABLE nodes")
|
|
308
|
+
conn.execute("ALTER TABLE nodes_new RENAME TO nodes")
|
|
309
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)")
|
|
310
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_assignee ON nodes(assignee)")
|
|
311
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id, position)")
|
|
312
|
+
conn.execute("COMMIT")
|
|
313
|
+
except BaseException:
|
|
314
|
+
conn.execute("ROLLBACK")
|
|
315
|
+
raise
|
|
316
|
+
finally:
|
|
317
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
318
|
+
elif not have_uid:
|
|
319
|
+
conn.execute("ALTER TABLE nodes ADD COLUMN uid TEXT NOT NULL DEFAULT ''")
|
|
320
|
+
|
|
321
|
+
conn.execute("CREATE TABLE IF NOT EXISTS links ("
|
|
322
|
+
"node_uid TEXT NOT NULL, kind TEXT NOT NULL, value TEXT NOT NULL, created TEXT NOT NULL, "
|
|
323
|
+
"PRIMARY KEY (node_uid, kind, value))")
|
|
324
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_links_uid ON links(node_uid)")
|
|
325
|
+
conn.execute("CREATE TABLE IF NOT EXISTS events ("
|
|
326
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, actor TEXT NOT NULL, "
|
|
327
|
+
"action TEXT NOT NULL, node_uid TEXT NOT NULL DEFAULT '', field TEXT NOT NULL DEFAULT '', "
|
|
328
|
+
"old TEXT NOT NULL DEFAULT '', new TEXT NOT NULL DEFAULT '')")
|
|
329
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_node ON events(node_uid)")
|
|
330
|
+
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_uid ON nodes(uid) WHERE uid <> ''")
|
|
331
|
+
_backfill_uids(conn)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _backfill_uids(conn: sqlite3.Connection) -> None:
|
|
335
|
+
"""Give every node that has no uid a fresh unique one."""
|
|
336
|
+
rows = conn.execute("SELECT id FROM nodes WHERE uid = ''").fetchall()
|
|
337
|
+
if not rows:
|
|
338
|
+
return
|
|
339
|
+
existing = {r[0] for r in conn.execute("SELECT uid FROM nodes WHERE uid <> ''")}
|
|
340
|
+
conn.execute("BEGIN")
|
|
341
|
+
try:
|
|
342
|
+
for (node_id,) in rows:
|
|
343
|
+
uid = new_uid(conn)
|
|
344
|
+
while uid in existing:
|
|
345
|
+
uid = new_uid(conn)
|
|
346
|
+
existing.add(uid)
|
|
347
|
+
conn.execute("UPDATE nodes SET uid = ? WHERE id = ?", (uid, node_id))
|
|
348
|
+
conn.execute("COMMIT")
|
|
349
|
+
except BaseException:
|
|
350
|
+
conn.execute("ROLLBACK")
|
|
351
|
+
raise
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load(conn: sqlite3.Connection | None = None) -> dict:
|
|
355
|
+
"""Build the in-memory tree (same shape the renderer and validator use) from the database."""
|
|
356
|
+
if conn is None and not DATA_FILE.exists():
|
|
357
|
+
return {"version": 2, "project": ROOT.name, "sprints": []} # reading never creates the database
|
|
358
|
+
own = conn is None
|
|
359
|
+
conn = conn or connect()
|
|
360
|
+
try:
|
|
361
|
+
project = conn.execute("SELECT value FROM meta WHERE key = 'project'").fetchone()[0]
|
|
362
|
+
deps: dict[str, list[str]] = {}
|
|
363
|
+
for r in conn.execute("SELECT node_id, depends_on FROM deps ORDER BY depends_on"):
|
|
364
|
+
deps.setdefault(r["node_id"], []).append(r["depends_on"])
|
|
365
|
+
notes: dict[str, list[str]] = {}
|
|
366
|
+
for r in conn.execute("SELECT node_id, text FROM notes ORDER BY seq"):
|
|
367
|
+
notes.setdefault(r["node_id"], []).append(r["text"])
|
|
368
|
+
links: dict[str, list[dict]] = {}
|
|
369
|
+
for r in conn.execute("SELECT node_uid, kind, value, created FROM links ORDER BY created, kind, value"):
|
|
370
|
+
links.setdefault(r["node_uid"], []).append(
|
|
371
|
+
{"kind": r["kind"], "value": r["value"], "created": r["created"]})
|
|
372
|
+
by_id: dict[str, dict] = {}
|
|
373
|
+
sprints: list[dict] = []
|
|
374
|
+
for r in conn.execute("SELECT * FROM nodes ORDER BY level, parent_id, position"):
|
|
375
|
+
node = {k: r[k] for k in FIELDS}
|
|
376
|
+
node.update(depends_on=deps.get(r["id"], []), notes=notes.get(r["id"], []),
|
|
377
|
+
links=links.get(r["uid"], []), children=[])
|
|
378
|
+
by_id[r["id"]] = node
|
|
379
|
+
(by_id[r["parent_id"]]["children"] if r["parent_id"] else sprints).append(node)
|
|
380
|
+
return {"version": 2, "project": project, "sprints": sprints}
|
|
381
|
+
finally:
|
|
382
|
+
if own:
|
|
383
|
+
conn.close()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Locked:
|
|
387
|
+
"""Exclusive lock so parallel agents cannot interleave a change and its re-render."""
|
|
388
|
+
|
|
389
|
+
def __enter__(self):
|
|
390
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
self._fh = LOCK_FILE.open("w")
|
|
392
|
+
fcntl.flock(self._fh, fcntl.LOCK_EX)
|
|
393
|
+
return self
|
|
394
|
+
|
|
395
|
+
def __exit__(self, *exc):
|
|
396
|
+
fcntl.flock(self._fh, fcntl.LOCK_UN)
|
|
397
|
+
self._fh.close()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --------------------------------------------------------------------------- rendering
|
|
401
|
+
|
|
402
|
+
def counts(nodes) -> dict:
|
|
403
|
+
result = {s: 0 for s in STATUSES}
|
|
404
|
+
for n in nodes:
|
|
405
|
+
result[n["status"]] += 1
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def bar(done: int, total: int, width: int = 10) -> str:
|
|
410
|
+
filled = round(width * done / total) if total else 0
|
|
411
|
+
return "█" * filled + "░" * (width - filled)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def render_node(node: dict, indent: int, lines: list[str]) -> None:
|
|
415
|
+
status = effective(node)
|
|
416
|
+
box = "x" if status == "done" else " "
|
|
417
|
+
parts = [f"{' ' * indent}- [{box}] **{node['id']}** {node['title']} — {BADGE[status]}"]
|
|
418
|
+
if node["children"]:
|
|
419
|
+
d, t = progress(node)
|
|
420
|
+
parts.append(f"({d}/{t})")
|
|
421
|
+
if node["assignee"]:
|
|
422
|
+
parts.append(f"· @{node['assignee']}")
|
|
423
|
+
if node["depends_on"]:
|
|
424
|
+
parts.append("· after " + ", ".join(node["depends_on"]))
|
|
425
|
+
kinds = " · ".join(sorted({link["kind"] for link in node.get("links", [])}))
|
|
426
|
+
if kinds:
|
|
427
|
+
parts.append(f"· [{kinds}]")
|
|
428
|
+
lines.append(" ".join(parts))
|
|
429
|
+
pad = " " * (indent + 1)
|
|
430
|
+
if status == "blocked" and node["reason"]:
|
|
431
|
+
lines.append(f"{pad}- ⛔ **Blocked:** {node['reason']}")
|
|
432
|
+
if node["description"]:
|
|
433
|
+
for text in node["description"].splitlines():
|
|
434
|
+
stripped = text.strip()
|
|
435
|
+
if not stripped:
|
|
436
|
+
continue
|
|
437
|
+
# Never double-prefix a line that is already a list item, and never emit a lone "-".
|
|
438
|
+
lines.append(f"{pad}{text}" if stripped.startswith(("- ", "* ")) else f"{pad}- {text}")
|
|
439
|
+
for note in node["notes"]:
|
|
440
|
+
lines.append(f"{pad}- 📝 {note}")
|
|
441
|
+
for child in node["children"]:
|
|
442
|
+
render_node(child, indent + 1, lines)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def slugify(text: str) -> str:
|
|
446
|
+
"""Lowercase kebab slug for a sprint file name; falls back to 'sprint'."""
|
|
447
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
448
|
+
return slug or "sprint"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def sprint_view_path(sprint: dict) -> Path:
|
|
452
|
+
"""Path of the per-sprint detail file inside the view directory."""
|
|
453
|
+
return VIEW_DIR / f"sprint-{sprint['id']}-{slugify(sprint['title'])}.md"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _sprint_body(sprint: dict) -> list[str]:
|
|
457
|
+
"""Body shared by the README's current sprint and the per-sprint file."""
|
|
458
|
+
d, t = progress(sprint)
|
|
459
|
+
lines: list[str] = []
|
|
460
|
+
if sprint.get("goal"):
|
|
461
|
+
lines += [f"**Goal:** {sprint['goal']}", ""]
|
|
462
|
+
lines += [f"Progress: {d}/{t} `{bar(d, t)}`", ""]
|
|
463
|
+
for note in sprint["notes"]:
|
|
464
|
+
lines.append(f"📝 {note}")
|
|
465
|
+
if sprint["notes"]:
|
|
466
|
+
lines.append("")
|
|
467
|
+
for task in sprint["children"]:
|
|
468
|
+
render_node(task, 0, lines)
|
|
469
|
+
lines.append("")
|
|
470
|
+
return lines
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def render_sprint_file(sprint: dict) -> str:
|
|
474
|
+
"""The per-sprint detail file (`sprint-<n>-<slug>.md`)."""
|
|
475
|
+
lines = [f"# Sprint {sprint['id']} — {sprint['title']} {BADGE[effective(sprint)]}", ""]
|
|
476
|
+
lines += _sprint_body(sprint)
|
|
477
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def render_readme(data: dict) -> str:
|
|
481
|
+
"""The view index: overall + the current sprint inline + links to the per-sprint files."""
|
|
482
|
+
sprints = data["sprints"]
|
|
483
|
+
all_leaves = [leaf for s in sprints for leaf in leaves(s)]
|
|
484
|
+
c = counts(all_leaves)
|
|
485
|
+
actionable = [leaf for leaf in all_leaves if leaf["status"] != "cancelled"]
|
|
486
|
+
done, total = c["done"], len(actionable)
|
|
487
|
+
pct = round(100 * done / total) if total else 0
|
|
488
|
+
lines = [
|
|
489
|
+
f"# Tasks — {data['project']}",
|
|
490
|
+
"",
|
|
491
|
+
"> Generated from `tasks/tasks.db` by `scripts/tasks.py` after every change. Do not edit by hand.",
|
|
492
|
+
"> Per-sprint detail lives in the `sprint-*.md` files next to this README.",
|
|
493
|
+
"",
|
|
494
|
+
f"**Overall:** {done}/{total} done ({pct}%) `{bar(done, total)}` · "
|
|
495
|
+
f"🔄 {c['in_progress']} in progress · ⛔ {c['blocked']} blocked · "
|
|
496
|
+
f"🚫 {c['cancelled']} cancelled · 🔎 {c['review']} review · ⬜ {c['none']} not started",
|
|
497
|
+
"",
|
|
498
|
+
"Status legend: ⬜ none (brak) · 🔄 in progress (w trakcie) · ⛔ blocked (bloker) · ✅ done (gotowe) · "
|
|
499
|
+
"🚫 cancelled (anulowane). Parent status is derived from its children: any blocked → blocked, "
|
|
500
|
+
"all done or cancelled → done, any started or finished → in progress, otherwise none.",
|
|
501
|
+
"",
|
|
502
|
+
]
|
|
503
|
+
if sprints:
|
|
504
|
+
lines += ["## Sprints", ""]
|
|
505
|
+
for s in sprints:
|
|
506
|
+
d, t = progress(s)
|
|
507
|
+
lines.append(f"- [Sprint {s['id']} — {s['title']}]({sprint_view_path(s).name}) — "
|
|
508
|
+
f"{BADGE[effective(s)]} `{bar(d, t)}` {d}/{t}")
|
|
509
|
+
lines.append("")
|
|
510
|
+
blockers = [n for s in sprints for n in leaves(s) if n["status"] == "blocked"]
|
|
511
|
+
if blockers:
|
|
512
|
+
lines += ["## ⛔ Blockers", ""]
|
|
513
|
+
lines += [f"- **{n['id']}** {n['title']} — {n['reason']}" for n in blockers]
|
|
514
|
+
lines.append("")
|
|
515
|
+
if not sprints:
|
|
516
|
+
lines += ["_No sprints yet. Start with `scripts/tasks.py add-sprint \"Title\"`._", ""]
|
|
517
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def render(data: dict) -> str:
|
|
521
|
+
"""The main generated file: the README index (single-document entry point)."""
|
|
522
|
+
return render_readme(data)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def expected_view(data: dict) -> dict:
|
|
526
|
+
"""{path: text} for every generated view file (the README index + one file per sprint)."""
|
|
527
|
+
files = {README_FILE: render_readme(data)}
|
|
528
|
+
for s in data["sprints"]:
|
|
529
|
+
files[sprint_view_path(s)] = render_sprint_file(s)
|
|
530
|
+
return files
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _testing_guard(target: Path) -> None:
|
|
534
|
+
"""When VIBE_TASKS_TESTING=1, refuse to write a file that is not under a system temp dir.
|
|
535
|
+
|
|
536
|
+
This is a safety net so a missed setUp redirect in a test class cannot overwrite the
|
|
537
|
+
real tracker files in the main checkout.
|
|
538
|
+
"""
|
|
539
|
+
if os.environ.get("VIBE_TASKS_TESTING") != "1":
|
|
540
|
+
return
|
|
541
|
+
import tempfile as _tf
|
|
542
|
+
tmp_dir = Path(_tf.gettempdir()).resolve()
|
|
543
|
+
try:
|
|
544
|
+
target.resolve().relative_to(tmp_dir)
|
|
545
|
+
except ValueError:
|
|
546
|
+
raise TaskError(f"refusing to write outside a temp dir while testing: {target}")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _write_atomic(target: Path, text: str) -> None:
|
|
550
|
+
"""Write text to target atomically: a temp file in the same directory, then os.replace.
|
|
551
|
+
|
|
552
|
+
A reader (or the tasks_watch poller) never sees a truncated or half-written file, and a crash
|
|
553
|
+
mid-write leaves the previous content intact. Standard library only.
|
|
554
|
+
"""
|
|
555
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
fd, tmp = tempfile.mkstemp(prefix=target.name + ".", suffix=".tmp", dir=str(target.parent))
|
|
557
|
+
try:
|
|
558
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
559
|
+
handle.write(text)
|
|
560
|
+
handle.flush()
|
|
561
|
+
os.fsync(handle.fileno())
|
|
562
|
+
os.replace(tmp, target)
|
|
563
|
+
except BaseException:
|
|
564
|
+
try:
|
|
565
|
+
os.unlink(tmp)
|
|
566
|
+
except OSError:
|
|
567
|
+
pass
|
|
568
|
+
raise
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def write_view(data: dict) -> None:
|
|
572
|
+
files = expected_view(data)
|
|
573
|
+
VIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
for path, text in files.items():
|
|
575
|
+
_testing_guard(path)
|
|
576
|
+
_write_atomic(path, text)
|
|
577
|
+
keep = {p.name for p in files}
|
|
578
|
+
for stale in VIEW_DIR.glob("sprint-*.md"):
|
|
579
|
+
if stale.name not in keep:
|
|
580
|
+
stale.unlink()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def snapshot_json(data: dict) -> str:
|
|
584
|
+
"""Deterministic JSON snapshot: sorted keys, 2-space indent, trailing newline, no timestamps."""
|
|
585
|
+
return json.dumps(data, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def write_snapshot(data: dict) -> None:
|
|
589
|
+
"""Write the committed JSON snapshot file (tasks/tasks.snapshot.json)."""
|
|
590
|
+
_testing_guard(SNAPSHOT_FILE)
|
|
591
|
+
_write_atomic(SNAPSHOT_FILE, snapshot_json(data))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --------------------------------------------------------------------------- commands
|
|
595
|
+
|
|
596
|
+
def _log_events(conn: sqlite3.Connection, before: dict, after: dict) -> None:
|
|
597
|
+
"""Write one audit event per added/removed node and per changed field (inside the transaction)."""
|
|
598
|
+
when = now_iso()
|
|
599
|
+
who = actor()
|
|
600
|
+
action = CURRENT_ACTION or "change"
|
|
601
|
+
for node_id in sorted(set(before) | set(after)):
|
|
602
|
+
old_row = before.get(node_id)
|
|
603
|
+
new_row = after.get(node_id)
|
|
604
|
+
uid = (new_row or old_row)["uid"]
|
|
605
|
+
if old_row is None:
|
|
606
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
607
|
+
(when, who, f"{action}:add", uid, "", "", new_row["title"]))
|
|
608
|
+
continue
|
|
609
|
+
if new_row is None:
|
|
610
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
611
|
+
(when, who, f"{action}:remove", uid, "", old_row["title"], ""))
|
|
612
|
+
continue
|
|
613
|
+
for field in ("title", "description", "status", "reason", "assignee", "goal", "start", "end"):
|
|
614
|
+
if old_row[field] != new_row[field]:
|
|
615
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
616
|
+
(when, who, action, uid, field, old_row[field], new_row[field]))
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def mutate(action):
|
|
620
|
+
"""Run action(conn) in one transaction, validate the result, then commit and re-render.
|
|
621
|
+
Both the tasks/ view and the JSON snapshot are written after a successful commit; on rollback nothing is written."""
|
|
622
|
+
with Locked():
|
|
623
|
+
conn = connect()
|
|
624
|
+
try:
|
|
625
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
626
|
+
try:
|
|
627
|
+
before = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM nodes")}
|
|
628
|
+
message = action(conn)
|
|
629
|
+
after = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM nodes")}
|
|
630
|
+
_log_events(conn, before, after)
|
|
631
|
+
data = load(conn)
|
|
632
|
+
validate(data)
|
|
633
|
+
except BaseException:
|
|
634
|
+
conn.execute("ROLLBACK")
|
|
635
|
+
raise
|
|
636
|
+
conn.execute("COMMIT")
|
|
637
|
+
finally:
|
|
638
|
+
conn.close()
|
|
639
|
+
write_view(data)
|
|
640
|
+
write_snapshot(data)
|
|
641
|
+
try:
|
|
642
|
+
_backup_commit(data, f"{CURRENT_ACTION or 'change'}: auto")
|
|
643
|
+
except Exception as exc: # noqa: BLE001 - the backup must never break a change
|
|
644
|
+
print(f"warning: backup failed: {exc}", file=sys.stderr)
|
|
645
|
+
if message:
|
|
646
|
+
print(message)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def row(conn: sqlite3.Connection, node_id: str) -> sqlite3.Row:
|
|
650
|
+
found = conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone()
|
|
651
|
+
if found is None:
|
|
652
|
+
raise TaskError(f"no such id: {node_id}")
|
|
653
|
+
return found
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def row_by_key(conn: sqlite3.Connection, key: str) -> sqlite3.Row:
|
|
657
|
+
"""Look a node up by its display id or its immutable uid."""
|
|
658
|
+
found = conn.execute("SELECT * FROM nodes WHERE id = ? OR uid = ?", (key, key)).fetchone()
|
|
659
|
+
if found is None:
|
|
660
|
+
raise TaskError(f"no such id: {key}")
|
|
661
|
+
return found
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def subtree_ids(conn: sqlite3.Connection, node_id: str) -> list[str]:
|
|
665
|
+
return [r[0] for r in conn.execute(
|
|
666
|
+
"SELECT id FROM nodes WHERE id = ? OR id LIKE ? ORDER BY level, position", (node_id, node_id + ".%"))]
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def subtree_rows(conn: sqlite3.Connection, node_id: str) -> list[sqlite3.Row]:
|
|
670
|
+
"""Return all rows in the subtree (including the root) ordered by level, position."""
|
|
671
|
+
return list(conn.execute(
|
|
672
|
+
"SELECT * FROM nodes WHERE id = ? OR id LIKE ? ORDER BY level, position", (node_id, node_id + ".%")))
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def leaf_ids(conn: sqlite3.Connection, node_id: str) -> list[str]:
|
|
676
|
+
return [r[0] for r in conn.execute(
|
|
677
|
+
"SELECT id FROM nodes n WHERE (id = ? OR id LIKE ?) "
|
|
678
|
+
"AND NOT EXISTS (SELECT 1 FROM nodes c WHERE c.parent_id = n.id) ORDER BY level, position",
|
|
679
|
+
(node_id, node_id + ".%"))]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def parse_ids(text: str | None) -> list[str]:
|
|
683
|
+
return [x for x in (text or "").replace(" ", "").split(",") if x]
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def set_deps(conn: sqlite3.Connection, node_id: str, deps: list[str]) -> None:
|
|
687
|
+
conn.execute("DELETE FROM deps WHERE node_id = ?", (node_id,))
|
|
688
|
+
for dep in dict.fromkeys(deps):
|
|
689
|
+
if conn.execute("SELECT 1 FROM nodes WHERE id = ?", (dep,)).fetchone() is None:
|
|
690
|
+
raise TaskError(f"{node_id}: depends on unknown id {dep}")
|
|
691
|
+
conn.execute("INSERT INTO deps VALUES (?, ?)", (node_id, dep))
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def insert_node(conn: sqlite3.Connection, parent_id: str | None, title: str, **fields) -> str:
|
|
695
|
+
where, args = ("parent_id = ?", (parent_id,)) if parent_id else ("parent_id IS NULL", ())
|
|
696
|
+
position = conn.execute(f"SELECT COALESCE(MAX(position), 0) + 1 FROM nodes WHERE {where}", args).fetchone()[0]
|
|
697
|
+
node_id = f"{parent_id}.{position}" if parent_id else str(position)
|
|
698
|
+
uid = new_uid(conn)
|
|
699
|
+
values = {"description": "", "assignee": "", "goal": "", "start": "", "end": ""}
|
|
700
|
+
values.update({k: v for k, v in fields.items() if v is not None})
|
|
701
|
+
conn.execute(
|
|
702
|
+
"INSERT INTO nodes (id, uid, parent_id, position, level, title, description, assignee, goal, start, end, "
|
|
703
|
+
"created, updated) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
704
|
+
(node_id, uid, parent_id, position, depth(node_id), title, values["description"], values["assignee"],
|
|
705
|
+
values["goal"], values["start"], values["end"], today(), today()))
|
|
706
|
+
return node_id
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def set_leaf_status(conn: sqlite3.Connection, node_id: str, status: str, reason: str) -> None:
|
|
710
|
+
current = row(conn, node_id)
|
|
711
|
+
if status == "blocked" and not reason:
|
|
712
|
+
raise TaskError(f"{node_id}: use --reason to say what blocks it")
|
|
713
|
+
started = current["started"] or (today() if status == "in_progress" else "")
|
|
714
|
+
finished = today() if status == "done" else ("" if current["status"] == "done" else current["finished"])
|
|
715
|
+
conn.execute("UPDATE nodes SET status=?, reason=?, started=?, finished=?, updated=? WHERE id=?",
|
|
716
|
+
(status, reason if status == "blocked" else "", started, finished, today(), node_id))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def cmd_add_sprint(args):
|
|
720
|
+
def action(conn):
|
|
721
|
+
node_id = insert_node(conn, None, args.title, goal=args.goal, start=args.start, end=args.end)
|
|
722
|
+
return f"added sprint {node_id}: {args.title}"
|
|
723
|
+
mutate(action)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def cmd_add(args):
|
|
727
|
+
def action(conn):
|
|
728
|
+
parent = row(conn, args.parent)
|
|
729
|
+
if parent["level"] >= MAX_DEPTH:
|
|
730
|
+
raise TaskError(f"{args.parent} is already a subtask; subtasks cannot have children")
|
|
731
|
+
has_children = conn.execute("SELECT 1 FROM nodes WHERE parent_id = ?", (args.parent,)).fetchone()
|
|
732
|
+
if not has_children and parent["status"] != "none":
|
|
733
|
+
# a leaf becoming a parent: its status is derived from its children from now on
|
|
734
|
+
conn.execute("UPDATE nodes SET status='none', reason='', started='', finished='' WHERE id=?",
|
|
735
|
+
(args.parent,))
|
|
736
|
+
node_id = insert_node(conn, args.parent, args.title, description=args.desc, assignee=args.assignee)
|
|
737
|
+
set_deps(conn, node_id, parse_ids(args.after))
|
|
738
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), args.parent))
|
|
739
|
+
return f"added {node_id}: {args.title}"
|
|
740
|
+
mutate(action)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def apply_status(conn: sqlite3.Connection, node_id: str, status: str, reason: str, cascade_down: bool) -> str:
|
|
744
|
+
row(conn, node_id)
|
|
745
|
+
targets = leaf_ids(conn, node_id)
|
|
746
|
+
if targets != [node_id] and not cascade_down:
|
|
747
|
+
raise TaskError(f"{node_id} has subtasks; its status is derived. Change a subtask, or pass --all "
|
|
748
|
+
"to apply the status to every item below it.")
|
|
749
|
+
for target in targets:
|
|
750
|
+
set_leaf_status(conn, target, status, reason)
|
|
751
|
+
return f"{node_id}: set {BADGE[status]} on {len(targets)} item(s)"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def cmd_status(args):
|
|
755
|
+
positionals = args.positionals
|
|
756
|
+
if len(positionals) < 2:
|
|
757
|
+
raise TaskError("usage: status <id> <status> [--reason R] [--all] (or: status <id1> <id2> ... <status>)")
|
|
758
|
+
status = normalize_status(positionals[-1])
|
|
759
|
+
ids = positionals[:-1]
|
|
760
|
+
def action(conn):
|
|
761
|
+
msgs = []
|
|
762
|
+
for nid in ids:
|
|
763
|
+
msgs.append(apply_status(conn, nid, status, args.reason or "", args.all))
|
|
764
|
+
return "\n".join(msgs)
|
|
765
|
+
mutate(action)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _get_ids(args) -> list[str]:
|
|
769
|
+
"""Extract ids list from args, supporting both 'ids' (nargs='+') and 'id' (single)."""
|
|
770
|
+
if hasattr(args, "ids") and args.ids:
|
|
771
|
+
return args.ids
|
|
772
|
+
if hasattr(args, "id") and args.id:
|
|
773
|
+
return [args.id]
|
|
774
|
+
raise TaskError("no id provided")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def shortcut(status: str):
|
|
778
|
+
def run(args):
|
|
779
|
+
reason = getattr(args, "reason", "") or ""
|
|
780
|
+
cascade = getattr(args, "all", False)
|
|
781
|
+
ids = getattr(args, "ids", None) or [args.id]
|
|
782
|
+
def action(conn):
|
|
783
|
+
msgs = []
|
|
784
|
+
for nid in ids:
|
|
785
|
+
msgs.append(apply_status(conn, nid, status, reason, cascade))
|
|
786
|
+
return "\n".join(msgs)
|
|
787
|
+
mutate(action)
|
|
788
|
+
return run
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def cmd_edit(args):
|
|
792
|
+
ids = getattr(args, "ids", None) or [args.id]
|
|
793
|
+
# --title with multiple ids is rejected
|
|
794
|
+
if len(ids) > 1 and args.title is not None:
|
|
795
|
+
raise TaskError("--title cannot be used with multiple ids")
|
|
796
|
+
def action(conn):
|
|
797
|
+
for nid in ids:
|
|
798
|
+
node = row(conn, nid)
|
|
799
|
+
sprint_only = {"goal": args.goal, "start": args.start, "end": args.end}
|
|
800
|
+
changes = {"title": args.title, "description": args.desc, "assignee": args.assignee, **sprint_only}
|
|
801
|
+
for key, value in changes.items():
|
|
802
|
+
if value is None:
|
|
803
|
+
continue
|
|
804
|
+
if key in sprint_only and node["level"] != 1:
|
|
805
|
+
raise TaskError(f"--{key} applies to sprints only")
|
|
806
|
+
conn.execute(f'UPDATE nodes SET "{key}" = ? WHERE id = ?', (value, nid))
|
|
807
|
+
if args.after is not None:
|
|
808
|
+
set_deps(conn, nid, parse_ids(args.after))
|
|
809
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), nid))
|
|
810
|
+
if len(ids) == 1:
|
|
811
|
+
return f"edited {ids[0]}"
|
|
812
|
+
return f"edited {len(ids)} item(s): {', '.join(ids)}"
|
|
813
|
+
mutate(action)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def cmd_note(args):
|
|
817
|
+
def action(conn):
|
|
818
|
+
row(conn, args.id)
|
|
819
|
+
conn.execute("INSERT INTO notes (node_id, text) VALUES (?, ?)", (args.id, f"{today()}: {args.text}"))
|
|
820
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), args.id))
|
|
821
|
+
return f"noted on {args.id}"
|
|
822
|
+
mutate(action)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def cmd_link(args):
|
|
826
|
+
if args.kind not in LINK_KINDS:
|
|
827
|
+
raise TaskError(f"unknown link kind '{args.kind}' (use: {', '.join(LINK_KINDS)})")
|
|
828
|
+
|
|
829
|
+
def action(conn):
|
|
830
|
+
node = row_by_key(conn, args.id)
|
|
831
|
+
conn.execute("INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
|
|
832
|
+
(node["uid"], args.kind, args.value, today()))
|
|
833
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
834
|
+
(now_iso(), actor(), "link", node["uid"], args.kind, "", args.value))
|
|
835
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), node["id"]))
|
|
836
|
+
return f"linked {node['id']} {args.kind}={args.value}"
|
|
837
|
+
mutate(action)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def cmd_unlink(args):
|
|
841
|
+
def action(conn):
|
|
842
|
+
node = row_by_key(conn, args.id)
|
|
843
|
+
conn.execute("DELETE FROM links WHERE node_uid=? AND kind=? AND value=?",
|
|
844
|
+
(node["uid"], args.kind, args.value))
|
|
845
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
846
|
+
(now_iso(), actor(), "unlink", node["uid"], args.kind, args.value, ""))
|
|
847
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), node["id"]))
|
|
848
|
+
return f"unlinked {node['id']} {args.kind}={args.value}"
|
|
849
|
+
mutate(action)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _dedupe(ids: list[str]) -> list[str]:
|
|
853
|
+
"""Keep the first occurrence of each id, preserving order."""
|
|
854
|
+
seen: list[str] = []
|
|
855
|
+
for nid in ids:
|
|
856
|
+
if nid not in seen:
|
|
857
|
+
seen.append(nid)
|
|
858
|
+
return seen
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _effective_roots(ids: list[str]) -> list[str]:
|
|
862
|
+
"""Drop ids already covered by another requested id (an ancestor's subtree includes it)."""
|
|
863
|
+
return [nid for nid in ids if not any(nid.startswith(other + ".") for other in ids if other != nid)]
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _rm_totals(data: dict, ids: list[str]) -> tuple[int, int]:
|
|
867
|
+
"""Total number of nodes and notes across all subtrees being deleted."""
|
|
868
|
+
all_ids = {n["id"] for i in ids for n in walk([find(data, i)])}
|
|
869
|
+
notes = sum(len(n["notes"]) for n in walk(data["sprints"]) if n["id"] in all_ids)
|
|
870
|
+
return len(all_ids), notes
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _rm_preview(data: dict, ids: list[str]) -> str:
|
|
874
|
+
"""Build the preview text (printed to stderr): one block per id, then totals and a warning."""
|
|
875
|
+
all_ids = {n["id"] for i in ids for n in walk([find(data, i)])}
|
|
876
|
+
outside = sorted(n["id"] for n in walk(data["sprints"])
|
|
877
|
+
if n["id"] not in all_ids and any(d in all_ids for d in n["depends_on"]))
|
|
878
|
+
lines = []
|
|
879
|
+
for i in ids:
|
|
880
|
+
node = find(data, i)
|
|
881
|
+
sub_ids = {n["id"] for n in walk([node])}
|
|
882
|
+
deps_on_me = sorted(o for o in outside if any(d in sub_ids for d in find(data, o)["depends_on"]))
|
|
883
|
+
parts = [f"{i} «{node['title']}»", f"status={effective(node)}"]
|
|
884
|
+
if node["assignee"]:
|
|
885
|
+
parts.append(f"assignee={node['assignee']}")
|
|
886
|
+
parts.append(f"subtasks={len(sub_ids) - 1}")
|
|
887
|
+
parts.append(f"notes={len(node['notes'])}")
|
|
888
|
+
if deps_on_me:
|
|
889
|
+
parts.append("depends-on-me=" + ",".join(deps_on_me))
|
|
890
|
+
lines.append(" ".join(parts))
|
|
891
|
+
items, notes = _rm_totals(data, ids)
|
|
892
|
+
lines.append(f"{items} items, {notes} notes will be deleted")
|
|
893
|
+
risky = any(effective(find(data, i)) in ("in_progress", "done") for i in ids) \
|
|
894
|
+
or notes > 0 or bool(outside)
|
|
895
|
+
if risky:
|
|
896
|
+
lines.append("warning: some items are in progress or done, have notes, or are depended on from outside")
|
|
897
|
+
return "\n".join(lines) + "\n"
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _rm_question(data: dict, ids: list[str]) -> str:
|
|
901
|
+
"""The confirmation question: type the id (single), or the word `delete` (several ids)."""
|
|
902
|
+
items, notes = _rm_totals(data, ids)
|
|
903
|
+
if len(ids) == 1:
|
|
904
|
+
node = find(data, ids[0])
|
|
905
|
+
return (f"Delete {ids[0]} «{node['title']}» and everything below it ({items} items, {notes} notes)? "
|
|
906
|
+
f"This cannot be undone from the database (a recovery file is written). "
|
|
907
|
+
f"Type the id \"{ids[0]}\" to confirm, or press Enter to cancel:\n")
|
|
908
|
+
return (f"Delete {', '.join(ids)} and everything below them ({items} items, {notes} notes)? "
|
|
909
|
+
f"This cannot be undone from the database (a recovery file is written). "
|
|
910
|
+
f"Type \"delete\" to confirm, or press Enter to cancel:\n")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _confirmed(ids: list[str], data: dict, answer: str) -> bool:
|
|
914
|
+
"""Decide whether the typed answer confirms the deletion.
|
|
915
|
+
|
|
916
|
+
Several ids always require the word `delete`. A single id requires the id itself, except
|
|
917
|
+
plain empty leaves (no children, status none, no notes) which also accept `y`/`yes`.
|
|
918
|
+
"""
|
|
919
|
+
if len(ids) > 1:
|
|
920
|
+
return answer.strip().lower() == "delete"
|
|
921
|
+
node = find(data, ids[0])
|
|
922
|
+
plain = not node["children"] and node["status"] == "none" and not node["notes"]
|
|
923
|
+
if answer.strip() == ids[0]:
|
|
924
|
+
return True
|
|
925
|
+
return plain and answer.strip().lower() in ("y", "yes")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _tty() -> bool:
|
|
929
|
+
"""True when stdin is interactive, so we may ask for confirmation."""
|
|
930
|
+
return sys.stdin.isatty()
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _ask(prompt: str) -> str:
|
|
934
|
+
"""Print the prompt to stderr and read one answer line from stdin (stdout stays pipeable)."""
|
|
935
|
+
sys.stderr.write(prompt)
|
|
936
|
+
sys.stderr.flush()
|
|
937
|
+
return input()
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _recovery_path(ids: list[str]) -> Path:
|
|
941
|
+
"""Unique recovery file path: tasks/removed/<YYYYMMDD-HHMMSS>-<ids>.json."""
|
|
942
|
+
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
943
|
+
base = f"{stamp}-{'-'.join(ids)}"
|
|
944
|
+
path = RECOVERY_DIR / f"{base}.json"
|
|
945
|
+
n = 2
|
|
946
|
+
while path.exists():
|
|
947
|
+
path = RECOVERY_DIR / f"{base}-{n}.json"
|
|
948
|
+
n += 1
|
|
949
|
+
return path
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _write_recovery(current: dict, ids: list[str], roots: list[str]) -> Path:
|
|
953
|
+
"""Write the export of the deleted subtrees (same node shape as export-json, plus each
|
|
954
|
+
subtree's original parent). Written inside the lock right before the delete; a failure
|
|
955
|
+
aborts the deletion."""
|
|
956
|
+
entries = []
|
|
957
|
+
for rid in roots:
|
|
958
|
+
node = find(current, rid)
|
|
959
|
+
parent = parent_of(current, rid)
|
|
960
|
+
entries.append({"parent": parent["id"] if parent else None,
|
|
961
|
+
"node": json.loads(json.dumps(node))})
|
|
962
|
+
text = json.dumps({"version": 2, "project": current["project"], "removed": entries},
|
|
963
|
+
sort_keys=True, indent=2, ensure_ascii=False) + "\n"
|
|
964
|
+
path = _recovery_path(ids)
|
|
965
|
+
_testing_guard(path)
|
|
966
|
+
try:
|
|
967
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
968
|
+
path.write_text(text, encoding="utf-8")
|
|
969
|
+
except OSError as exc:
|
|
970
|
+
raise TaskError(f"could not write recovery file {path}: {exc}")
|
|
971
|
+
return path
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def cmd_rm(args):
|
|
975
|
+
ids = _dedupe(args.ids)
|
|
976
|
+
data = load()
|
|
977
|
+
for nid in ids:
|
|
978
|
+
find(data, nid) # every id must exist before anything is printed
|
|
979
|
+
sys.stderr.write(_rm_preview(data, ids))
|
|
980
|
+
if args.dry_run:
|
|
981
|
+
return 0
|
|
982
|
+
if not args.yes:
|
|
983
|
+
if _tty():
|
|
984
|
+
answer = _ask(_rm_question(data, ids))
|
|
985
|
+
if not _confirmed(ids, data, answer):
|
|
986
|
+
print("cancelled", file=sys.stderr)
|
|
987
|
+
return 1
|
|
988
|
+
else:
|
|
989
|
+
print("refusing to delete without confirmation: pass --yes (see --dry-run for the preview)",
|
|
990
|
+
file=sys.stderr)
|
|
991
|
+
return 2
|
|
992
|
+
|
|
993
|
+
def action(conn):
|
|
994
|
+
current = load(conn)
|
|
995
|
+
roots = _effective_roots(ids)
|
|
996
|
+
all_ids = {r for rid in roots for r in subtree_ids(conn, rid)}
|
|
997
|
+
outside = [n for n in walk(current["sprints"])
|
|
998
|
+
if n["id"] not in all_ids and any(d in all_ids for d in n["depends_on"])]
|
|
999
|
+
if outside:
|
|
1000
|
+
dep = next(d for d in outside[0]["depends_on"] if d in all_ids)
|
|
1001
|
+
raise TaskError(f"{outside[0]['id']} depends on {dep}; remove the dependency first")
|
|
1002
|
+
_write_recovery(current, ids, roots)
|
|
1003
|
+
msgs = []
|
|
1004
|
+
for rid in roots:
|
|
1005
|
+
row(conn, rid)
|
|
1006
|
+
count = len(subtree_ids(conn, rid)) - 1
|
|
1007
|
+
conn.execute("DELETE FROM nodes WHERE id = ?", (rid,)) # children, deps, notes cascade
|
|
1008
|
+
msgs.append(f"removed {rid} and {count} item(s) below it")
|
|
1009
|
+
return "\n".join(msgs)
|
|
1010
|
+
|
|
1011
|
+
mutate(action)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _subtree_depth(node: dict) -> int:
|
|
1015
|
+
"""Deepest depth (1-based) inside a saved subtree, relative to the document."""
|
|
1016
|
+
return max([depth(node["id"])] + [_subtree_depth(c) for c in node.get("children", [])])
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _restore_node(conn: sqlite3.Connection, parent_id: str | None, node: dict, id_map: dict[str, str]) -> str:
|
|
1020
|
+
"""Insert one saved node with a fresh id under parent_id, recursing into children."""
|
|
1021
|
+
where, args = ("parent_id = ?", (parent_id,)) if parent_id else ("parent_id IS NULL", ())
|
|
1022
|
+
position = conn.execute(f"SELECT COALESCE(MAX(position), 0) + 1 FROM nodes WHERE {where}", args).fetchone()[0]
|
|
1023
|
+
new_id = f"{parent_id}.{position}" if parent_id else str(position)
|
|
1024
|
+
conn.execute(
|
|
1025
|
+
"INSERT INTO nodes (id, uid, parent_id, position, level, title, description, status, reason, assignee, "
|
|
1026
|
+
"goal, start, end, created, updated, started, finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
1027
|
+
(new_id, new_uid(conn), parent_id, position, depth(new_id), node["title"], node.get("description", ""),
|
|
1028
|
+
node.get("status", "none"), node.get("reason", ""), node.get("assignee", ""), node.get("goal", ""),
|
|
1029
|
+
node.get("start", ""), node.get("end", ""), node.get("created", today()), node.get("updated", today()),
|
|
1030
|
+
node.get("started", ""), node.get("finished", "")))
|
|
1031
|
+
id_map[node["id"]] = new_id
|
|
1032
|
+
for child in node.get("children", []):
|
|
1033
|
+
_restore_node(conn, new_id, child, id_map)
|
|
1034
|
+
return new_id
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def cmd_restore_removed(args):
|
|
1038
|
+
path = Path(args.file)
|
|
1039
|
+
try:
|
|
1040
|
+
recovery = json.loads(path.read_text(encoding="utf-8"))
|
|
1041
|
+
except (OSError, ValueError) as exc:
|
|
1042
|
+
raise TaskError(f"could not read recovery file {path}: {exc}")
|
|
1043
|
+
entries = recovery.get("removed")
|
|
1044
|
+
if not isinstance(entries, list) or not entries:
|
|
1045
|
+
raise TaskError(f"{path}: not a recovery file (missing 'removed' entries)")
|
|
1046
|
+
|
|
1047
|
+
def action(conn):
|
|
1048
|
+
id_map: dict[str, str] = {}
|
|
1049
|
+
for entry in entries:
|
|
1050
|
+
node = entry["node"]
|
|
1051
|
+
if not isinstance(node, dict) or "id" not in node or "title" not in node:
|
|
1052
|
+
raise TaskError(f"{path}: malformed recovery entry (node missing id/title)")
|
|
1053
|
+
parent_id = args.parent if args.parent is not None else entry.get("parent")
|
|
1054
|
+
if depth(node["id"]) == 1 and parent_id is not None:
|
|
1055
|
+
raise TaskError(f"{node['id']}: a sprint cannot be restored under {parent_id}")
|
|
1056
|
+
if parent_id is not None:
|
|
1057
|
+
if conn.execute("SELECT 1 FROM nodes WHERE id = ?", (parent_id,)).fetchone() is None:
|
|
1058
|
+
raise TaskError(f"original parent {parent_id} no longer exists; pass --parent to restore elsewhere")
|
|
1059
|
+
parent_row = row(conn, parent_id)
|
|
1060
|
+
if parent_row["level"] + 1 + (_subtree_depth(node) - depth(node["id"])) > MAX_DEPTH:
|
|
1061
|
+
raise TaskError(f"{node['id']}: too deep to restore under {parent_id}")
|
|
1062
|
+
_restore_node(conn, parent_id, node, id_map)
|
|
1063
|
+
skipped: list[tuple[str, str]] = []
|
|
1064
|
+
for entry in entries:
|
|
1065
|
+
for node in walk([entry["node"]]):
|
|
1066
|
+
new_id = id_map[node["id"]]
|
|
1067
|
+
for dep in node.get("depends_on", []):
|
|
1068
|
+
if dep in id_map:
|
|
1069
|
+
conn.execute("INSERT INTO deps VALUES (?, ?)", (new_id, id_map[dep]))
|
|
1070
|
+
elif conn.execute("SELECT 1 FROM nodes WHERE id = ?", (dep,)).fetchone():
|
|
1071
|
+
conn.execute("INSERT INTO deps VALUES (?, ?)", (new_id, dep))
|
|
1072
|
+
else:
|
|
1073
|
+
skipped.append((new_id, dep))
|
|
1074
|
+
for text in node.get("notes", []):
|
|
1075
|
+
conn.execute("INSERT INTO notes (node_id, text) VALUES (?, ?)", (new_id, text))
|
|
1076
|
+
for entry in entries:
|
|
1077
|
+
parent_id = args.parent if args.parent is not None else entry.get("parent")
|
|
1078
|
+
if parent_id is not None:
|
|
1079
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), parent_id))
|
|
1080
|
+
lines = []
|
|
1081
|
+
for entry in entries:
|
|
1082
|
+
for node in walk([entry["node"]]):
|
|
1083
|
+
lines.append(f"restored {node['id']} -> {id_map[node['id']]}")
|
|
1084
|
+
for new_id, dep in skipped:
|
|
1085
|
+
lines.append(f"skipped dependency: {new_id} -> {dep} (id does not exist)")
|
|
1086
|
+
return "\n".join(lines)
|
|
1087
|
+
|
|
1088
|
+
mutate(action)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _rewrite_id(old_id: str, new_id: str, suffix: str) -> str:
|
|
1092
|
+
"""Replace the old_id prefix with new_id in a full hierarchical id.
|
|
1093
|
+
suffix is the part after old_id, e.g. '.1' for '1.1.1' when old_id='1.1'."""
|
|
1094
|
+
# Strip leading dot from suffix to avoid double dots
|
|
1095
|
+
clean = suffix.lstrip(".")
|
|
1096
|
+
if clean:
|
|
1097
|
+
return f"{new_id}.{clean}"
|
|
1098
|
+
return new_id
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def cmd_mv(args):
|
|
1102
|
+
def action(conn):
|
|
1103
|
+
src = row(conn, args.id)
|
|
1104
|
+
dst = row(conn, args.parent)
|
|
1105
|
+
|
|
1106
|
+
# Cannot move a sprint (level 1)
|
|
1107
|
+
if src["level"] == 1:
|
|
1108
|
+
raise TaskError(f"{args.id}: cannot move a sprint")
|
|
1109
|
+
|
|
1110
|
+
# Cannot move deeper than MAX_DEPTH
|
|
1111
|
+
if dst["level"] >= MAX_DEPTH:
|
|
1112
|
+
raise TaskError(f"{args.parent} is already a subtask; cannot move under it")
|
|
1113
|
+
|
|
1114
|
+
# Check depth: a task with subtasks cannot move under another task (would exceed MAX_DEPTH)
|
|
1115
|
+
src_has_subtasks = conn.execute(
|
|
1116
|
+
"SELECT 1 FROM nodes WHERE parent_id = ?", (args.id,)).fetchone()
|
|
1117
|
+
if src_has_subtasks and dst["level"] == 2:
|
|
1118
|
+
raise TaskError(f"{args.id} has subtasks; cannot move under task {args.parent} (would exceed max depth)")
|
|
1119
|
+
|
|
1120
|
+
# Cannot move into own subtree
|
|
1121
|
+
if args.parent.startswith(args.id + "."):
|
|
1122
|
+
raise TaskError(f"{args.id}: cannot move into its own subtree {args.parent}")
|
|
1123
|
+
|
|
1124
|
+
# Display ids encode the position (1.2 is the 2nd child), so reordering within one parent would
|
|
1125
|
+
# need a renumbering of the siblings and their subtrees. It used to delete the node (cascading
|
|
1126
|
+
# to its subtree) and re-insert only the root. Refuse instead of losing data.
|
|
1127
|
+
if src["parent_id"] == dst["id"]:
|
|
1128
|
+
raise TaskError(f"{args.id}: already under {dst['id']}; reordering siblings is not supported "
|
|
1129
|
+
"(ids encode the position) - move it to another parent instead")
|
|
1130
|
+
|
|
1131
|
+
# Gather the full subtree
|
|
1132
|
+
old_ids = subtree_ids(conn, args.id)
|
|
1133
|
+
old_id_set = set(old_ids)
|
|
1134
|
+
|
|
1135
|
+
new_parent_id = dst["id"]
|
|
1136
|
+
|
|
1137
|
+
# --- Cross-parent move: renumber IDs ---
|
|
1138
|
+
# Determine target position
|
|
1139
|
+
where_clause, where_args = ("parent_id = ?", (new_parent_id,))
|
|
1140
|
+
if args.pos is not None:
|
|
1141
|
+
pos = args.pos
|
|
1142
|
+
else:
|
|
1143
|
+
pos = conn.execute(
|
|
1144
|
+
f"SELECT COALESCE(MAX(position), 0) + 1 FROM nodes WHERE {where_clause}",
|
|
1145
|
+
where_args).fetchone()[0]
|
|
1146
|
+
|
|
1147
|
+
new_root_id = f"{new_parent_id}.{pos}" if new_parent_id else str(pos)
|
|
1148
|
+
old_root_id = args.id
|
|
1149
|
+
|
|
1150
|
+
# Build mapping: old_id -> new_id for every node in the subtree
|
|
1151
|
+
id_map: dict[str, str] = {}
|
|
1152
|
+
rows = subtree_rows(conn, args.id)
|
|
1153
|
+
for r in rows:
|
|
1154
|
+
rid = r["id"]
|
|
1155
|
+
suffix = rid[len(old_root_id):]
|
|
1156
|
+
new_id = _rewrite_id(old_root_id, new_root_id, suffix)
|
|
1157
|
+
if depth(new_id) > MAX_DEPTH:
|
|
1158
|
+
raise TaskError(f"{rid}: new id {new_id} would exceed max depth")
|
|
1159
|
+
id_map[rid] = new_id
|
|
1160
|
+
|
|
1161
|
+
# Check new ids don't collide with existing nodes (excluding ids being moved)
|
|
1162
|
+
new_ids = set(id_map.values())
|
|
1163
|
+
for nid in new_ids:
|
|
1164
|
+
if nid in old_id_set:
|
|
1165
|
+
continue
|
|
1166
|
+
existing = conn.execute("SELECT 1 FROM nodes WHERE id = ?", (nid,)).fetchone()
|
|
1167
|
+
if existing:
|
|
1168
|
+
raise TaskError(f"cannot move {args.id}: id {nid} already exists")
|
|
1169
|
+
|
|
1170
|
+
# --- Phase 0: Save deps and notes referencing subtree ids ---
|
|
1171
|
+
# CASCADE DELETE will remove these when we delete old nodes.
|
|
1172
|
+
saved_deps: list[tuple[str, str]] = []
|
|
1173
|
+
for r in conn.execute("SELECT node_id, depends_on FROM deps "
|
|
1174
|
+
"WHERE node_id IN ({}) OR depends_on IN ({})".format(
|
|
1175
|
+
",".join("?" * len(old_id_set)),
|
|
1176
|
+
",".join("?" * len(old_id_set))),
|
|
1177
|
+
old_ids + old_ids):
|
|
1178
|
+
saved_deps.append((r["node_id"], r["depends_on"]))
|
|
1179
|
+
|
|
1180
|
+
saved_notes: list[tuple[str, str]] = []
|
|
1181
|
+
for r in conn.execute("SELECT node_id, text, seq FROM notes WHERE node_id IN ({})".format(
|
|
1182
|
+
",".join("?" * len(old_id_set))), old_ids):
|
|
1183
|
+
saved_notes.append((r["node_id"], r["text"], r["seq"]))
|
|
1184
|
+
|
|
1185
|
+
# --- Phase 1: Delete old nodes (deepest-first to avoid FK violations) ---
|
|
1186
|
+
delete_order = sorted(rows, key=lambda r: r["level"], reverse=True)
|
|
1187
|
+
for r in delete_order:
|
|
1188
|
+
conn.execute("DELETE FROM nodes WHERE id = ?", (r["id"],))
|
|
1189
|
+
|
|
1190
|
+
# --- Phase 2: Insert new nodes (shallowest-first so parents exist first) ---
|
|
1191
|
+
insert_order = sorted(rows, key=lambda r: r["level"])
|
|
1192
|
+
for r in insert_order:
|
|
1193
|
+
old_id = r["id"]
|
|
1194
|
+
new_id = id_map[old_id]
|
|
1195
|
+
old_parent = r["parent_id"]
|
|
1196
|
+
|
|
1197
|
+
if old_parent is None:
|
|
1198
|
+
new_parent = new_parent_id
|
|
1199
|
+
elif old_parent in id_map:
|
|
1200
|
+
new_parent = id_map[old_parent]
|
|
1201
|
+
else:
|
|
1202
|
+
new_parent = old_parent
|
|
1203
|
+
|
|
1204
|
+
new_pos = r["position"]
|
|
1205
|
+
if old_id == args.id:
|
|
1206
|
+
new_pos = pos
|
|
1207
|
+
new_parent = new_parent_id
|
|
1208
|
+
|
|
1209
|
+
conn.execute(
|
|
1210
|
+
"INSERT INTO nodes (id, uid, parent_id, position, level, title, description, status, reason, "
|
|
1211
|
+
"assignee, goal, start, end, created, updated, started, finished) "
|
|
1212
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
1213
|
+
(new_id, r["uid"] or new_uid(conn), new_parent, new_pos, r["level"], r["title"], r["description"],
|
|
1214
|
+
r["status"], r["reason"], r["assignee"], r["goal"], r["start"], r["end"],
|
|
1215
|
+
r["created"], today(), r["started"], r["finished"]))
|
|
1216
|
+
|
|
1217
|
+
# --- Phase 3: Restore deps and notes with rewritten ids ---
|
|
1218
|
+
for node_id, depends_on in saved_deps:
|
|
1219
|
+
new_node = id_map.get(node_id, node_id)
|
|
1220
|
+
new_dep = id_map.get(depends_on, depends_on)
|
|
1221
|
+
conn.execute("INSERT INTO deps VALUES (?, ?)", (new_node, new_dep))
|
|
1222
|
+
|
|
1223
|
+
for node_id, text, seq in saved_notes:
|
|
1224
|
+
new_node = id_map.get(node_id, node_id)
|
|
1225
|
+
conn.execute("INSERT INTO notes (seq, node_id, text) VALUES (?, ?, ?)",
|
|
1226
|
+
(seq, new_node, text))
|
|
1227
|
+
|
|
1228
|
+
# Update the destination parent's timestamp
|
|
1229
|
+
conn.execute("UPDATE nodes SET updated=? WHERE id=?", (today(), new_parent_id))
|
|
1230
|
+
|
|
1231
|
+
# Build output
|
|
1232
|
+
lines = [f"moved {args.id} -> {new_root_id} (uid {src['uid']})"]
|
|
1233
|
+
if len(id_map) > 1:
|
|
1234
|
+
lines.append("id changes:")
|
|
1235
|
+
for old_id, new_id in sorted(id_map.items()):
|
|
1236
|
+
if old_id != args.id:
|
|
1237
|
+
lines.append(f" {old_id} -> {new_id}")
|
|
1238
|
+
return "\n".join(lines)
|
|
1239
|
+
|
|
1240
|
+
mutate(action)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def ready(data: dict, node: dict) -> bool:
|
|
1244
|
+
return all(effective(find(data, d)) == "done" for d in node["depends_on"])
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def cmd_next(args):
|
|
1248
|
+
data = load()
|
|
1249
|
+
shown = 0
|
|
1250
|
+
for sprint in data["sprints"]:
|
|
1251
|
+
if effective(sprint) == "done":
|
|
1252
|
+
continue
|
|
1253
|
+
for leaf in leaves(sprint):
|
|
1254
|
+
if leaf["status"] in ("none", "in_progress") and ready(data, leaf):
|
|
1255
|
+
if args.assignee and leaf["assignee"] != args.assignee:
|
|
1256
|
+
continue
|
|
1257
|
+
print(f"{leaf['id']} {leaf['title']} [{BADGE[leaf['status']]}]"
|
|
1258
|
+
f"{' @' + leaf['assignee'] if leaf['assignee'] else ''}")
|
|
1259
|
+
if leaf["description"] and args.count == 1:
|
|
1260
|
+
print(leaf["description"])
|
|
1261
|
+
shown += 1
|
|
1262
|
+
if shown >= args.count:
|
|
1263
|
+
return
|
|
1264
|
+
if not shown:
|
|
1265
|
+
print("nothing ready")
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def cmd_list(args):
|
|
1269
|
+
data = load()
|
|
1270
|
+
wanted = normalize_status(args.status) if args.status else None
|
|
1271
|
+
needle = (args.text or "").lower()
|
|
1272
|
+
if args.id:
|
|
1273
|
+
roots = [find(data, args.id)]
|
|
1274
|
+
else:
|
|
1275
|
+
roots = [s for s in data["sprints"] if not args.sprint or s["id"] == args.sprint]
|
|
1276
|
+
for root_node in roots:
|
|
1277
|
+
for node in walk([root_node]):
|
|
1278
|
+
status = effective(node)
|
|
1279
|
+
if wanted and status != wanted:
|
|
1280
|
+
continue
|
|
1281
|
+
if args.assignee and node["assignee"] != args.assignee:
|
|
1282
|
+
continue
|
|
1283
|
+
if needle and needle not in (node["title"] + " " + node["description"]).lower():
|
|
1284
|
+
continue
|
|
1285
|
+
if args.leaves and node["children"]:
|
|
1286
|
+
continue
|
|
1287
|
+
pad = " " * (depth(node["id"]) - 1)
|
|
1288
|
+
print(f"{pad}{node['id']:<8} {BADGE[status]:<16} {node['title']}")
|
|
1289
|
+
if args.desc and node["description"]:
|
|
1290
|
+
for text in node["description"].splitlines():
|
|
1291
|
+
stripped = text.strip()
|
|
1292
|
+
if not stripped:
|
|
1293
|
+
continue
|
|
1294
|
+
body = text if stripped.startswith(("- ", "* ")) else f"- {text}"
|
|
1295
|
+
print(f"{pad} {body}")
|
|
1296
|
+
if args.notes:
|
|
1297
|
+
for note in node["notes"]:
|
|
1298
|
+
print(f"{pad} - 📝 {note}")
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def cmd_overview(args):
|
|
1302
|
+
"""All sprints in one call: derived status, counts per status and the goal."""
|
|
1303
|
+
data = load()
|
|
1304
|
+
print(f"{data['project']} — {len(data['sprints'])} sprint(s)")
|
|
1305
|
+
for sprint in data["sprints"]:
|
|
1306
|
+
leaves_s = list(leaves(sprint))
|
|
1307
|
+
c = counts(leaves_s)
|
|
1308
|
+
goal = f" — {sprint['goal']}" if sprint.get("goal") else ""
|
|
1309
|
+
print(f"{sprint['id']} {BADGE[effective(sprint)]} {sprint['title']}{goal}")
|
|
1310
|
+
print(f" done {c['done']}/{len(leaves_s)} · in progress {c['in_progress']} · review {c['review']}"
|
|
1311
|
+
f" · blocked {c['blocked']} · none {c['none']} · cancelled {c['cancelled']}")
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def cmd_sprint_table(args):
|
|
1315
|
+
"""One compact table of a sprint's tasks (status, assignee, progress)."""
|
|
1316
|
+
data = load()
|
|
1317
|
+
sprint = find(data, args.id)
|
|
1318
|
+
if depth(sprint["id"]) != 1:
|
|
1319
|
+
raise TaskError(f"{args.id} is not a sprint")
|
|
1320
|
+
print(f"Sprint {sprint['id']} — {sprint['title']} {BADGE[effective(sprint)]}")
|
|
1321
|
+
print("")
|
|
1322
|
+
print("| id | status | assignee | progress | title |")
|
|
1323
|
+
print("| --- | --- | --- | --- | --- |")
|
|
1324
|
+
for task in sprint["children"]:
|
|
1325
|
+
d, t = progress(task)
|
|
1326
|
+
prog = f"{d}/{t}" if task["children"] else ""
|
|
1327
|
+
print(f"| {task['id']} | {BADGE[effective(task)]} | {task['assignee']} | {prog} | {task['title']} |")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def cmd_stats(args):
|
|
1331
|
+
data = load()
|
|
1332
|
+
all_leaves = [leaf for s in data["sprints"] for leaf in leaves(s)]
|
|
1333
|
+
c = counts(all_leaves)
|
|
1334
|
+
print(f"items: {sum(1 for _ in walk(data['sprints']))} leaves: {len(all_leaves)} "
|
|
1335
|
+
f"done: {c['done']} in progress: {c['in_progress']} blocked: {c['blocked']} "
|
|
1336
|
+
f"none: {c['none']} cancelled: {c['cancelled']}")
|
|
1337
|
+
by_owner: dict[str, int] = {}
|
|
1338
|
+
for leaf in all_leaves:
|
|
1339
|
+
if leaf["status"] in ("done", "cancelled"):
|
|
1340
|
+
continue
|
|
1341
|
+
by_owner[leaf["assignee"] or "(unassigned)"] = by_owner.get(leaf["assignee"] or "(unassigned)", 0) + 1
|
|
1342
|
+
for owner, number in sorted(by_owner.items()):
|
|
1343
|
+
print(f" open for {owner}: {number}")
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def cmd_show(args):
|
|
1347
|
+
data = load()
|
|
1348
|
+
for node_id in args.id:
|
|
1349
|
+
node = find(data, node_id)
|
|
1350
|
+
status = effective(node)
|
|
1351
|
+
if args.brief:
|
|
1352
|
+
d, t = progress(node)
|
|
1353
|
+
empty, total = d, t
|
|
1354
|
+
extra = f" ({empty}/{total})" if node["children"] else ""
|
|
1355
|
+
owner = f" @{node['assignee']}" if node["assignee"] else ""
|
|
1356
|
+
deps = f" after {', '.join(node['depends_on'])}" if node["depends_on"] else ""
|
|
1357
|
+
goal = f" goal: {node['goal']}" if node.get("goal") else ""
|
|
1358
|
+
kinds = " · ".join(sorted({link["kind"] for link in node.get("links", [])}))
|
|
1359
|
+
links = f" [{kinds}]" if kinds else ""
|
|
1360
|
+
print(f"{node['id']} {BADGE[status]}{extra} {node['title']}{owner}{deps}{goal}{links}")
|
|
1361
|
+
else:
|
|
1362
|
+
print(json.dumps({**node, "children": [c["id"] for c in node["children"]],
|
|
1363
|
+
"effective_status": status}, indent=2, ensure_ascii=False))
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def cmd_log(args):
|
|
1367
|
+
if not DATA_FILE.exists():
|
|
1368
|
+
print("tasks: no database; nothing to log")
|
|
1369
|
+
return
|
|
1370
|
+
data = load()
|
|
1371
|
+
by_uid = {n["uid"]: n["id"] for n in walk(data["sprints"])}
|
|
1372
|
+
uid_filter = find(data, args.id)["uid"] if args.id else None
|
|
1373
|
+
conn = connect(create=False)
|
|
1374
|
+
try:
|
|
1375
|
+
rows = list(conn.execute("SELECT * FROM events ORDER BY id"))
|
|
1376
|
+
finally:
|
|
1377
|
+
conn.close()
|
|
1378
|
+
shown = 0
|
|
1379
|
+
for r in rows:
|
|
1380
|
+
if uid_filter and r["node_uid"] != uid_filter:
|
|
1381
|
+
continue
|
|
1382
|
+
if args.since and r["ts"] < args.since:
|
|
1383
|
+
continue
|
|
1384
|
+
disp = by_uid.get(r["node_uid"], r["node_uid"])
|
|
1385
|
+
change = f"{r['field']}: {r['old']} -> {r['new']}" if r["field"] else (r["new"] or r["old"])
|
|
1386
|
+
print(f"{r['ts']} {r['actor']} {r['action']} {disp} {change}")
|
|
1387
|
+
shown += 1
|
|
1388
|
+
if not shown:
|
|
1389
|
+
print("no events")
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _scopes_overlap(first: str, second: str) -> bool:
|
|
1393
|
+
"""True when two scope paths are equal or one is a directory prefix of the other."""
|
|
1394
|
+
one, two = first.rstrip("/") + "/", second.rstrip("/") + "/"
|
|
1395
|
+
return one.startswith(two) or two.startswith(one)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def cmd_plan_batch(args):
|
|
1399
|
+
"""Greedily prefer the least-conflicted ready tasks in the first unfinished sprint.
|
|
1400
|
+
|
|
1401
|
+
Ready means every dependency is done, so the chosen tasks never depend on each other. Scopes overlap
|
|
1402
|
+
when equal or nested (src/a vs src/a/b). Ready tasks without a scope are skipped with a stderr warning.
|
|
1403
|
+
Output remains in id order; selection breaks ties by id."""
|
|
1404
|
+
data = load()
|
|
1405
|
+
ready_tasks: list[dict] = []
|
|
1406
|
+
for sprint in data["sprints"]:
|
|
1407
|
+
if effective(sprint) == "done":
|
|
1408
|
+
continue
|
|
1409
|
+
ready_tasks = [leaf for leaf in leaves(sprint) if leaf["status"] == "none" and ready(data, leaf)]
|
|
1410
|
+
break
|
|
1411
|
+
scoped_tasks: list[tuple[dict, list[str]]] = []
|
|
1412
|
+
for task in ready_tasks:
|
|
1413
|
+
scopes = [link["value"] for link in task.get("links", []) if link["kind"] == "scope"]
|
|
1414
|
+
if not scopes:
|
|
1415
|
+
print(f"warning: {task['id']} skipped: no scope link", file=sys.stderr)
|
|
1416
|
+
continue
|
|
1417
|
+
scoped_tasks.append((task, scopes))
|
|
1418
|
+
|
|
1419
|
+
conflicts = [set() for _ in scoped_tasks]
|
|
1420
|
+
for first in range(len(scoped_tasks)):
|
|
1421
|
+
for second in range(first + 1, len(scoped_tasks)):
|
|
1422
|
+
first_scopes = scoped_tasks[first][1]
|
|
1423
|
+
second_scopes = scoped_tasks[second][1]
|
|
1424
|
+
if any(_scopes_overlap(a, b) for a in first_scopes for b in second_scopes):
|
|
1425
|
+
conflicts[first].add(second)
|
|
1426
|
+
conflicts[second].add(first)
|
|
1427
|
+
|
|
1428
|
+
remaining = set(range(len(scoped_tasks)))
|
|
1429
|
+
chosen = set()
|
|
1430
|
+
while remaining:
|
|
1431
|
+
candidate = min(remaining, key=lambda index: (len(conflicts[index] & remaining), index))
|
|
1432
|
+
chosen.add(candidate)
|
|
1433
|
+
remaining.remove(candidate)
|
|
1434
|
+
remaining.difference_update(conflicts[candidate])
|
|
1435
|
+
if args.max and len(chosen) >= args.max:
|
|
1436
|
+
break
|
|
1437
|
+
for index in sorted(chosen):
|
|
1438
|
+
task = scoped_tasks[index][0]
|
|
1439
|
+
print(f"{task['id']} {task['title']}")
|
|
1440
|
+
if not chosen:
|
|
1441
|
+
print("no disjoint ready tasks")
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def cmd_brief(args):
|
|
1445
|
+
"""Print the context bundle a spec author/agent needs for one item."""
|
|
1446
|
+
data = load()
|
|
1447
|
+
node = find(data, args.id)
|
|
1448
|
+
lines = [f"# {node['id']} {node['title']} — {BADGE[effective(node)]}"]
|
|
1449
|
+
if node["assignee"]:
|
|
1450
|
+
lines.append(f"Assignee: @{node['assignee']}")
|
|
1451
|
+
if node["description"]:
|
|
1452
|
+
lines += ["", node["description"]]
|
|
1453
|
+
if node["depends_on"]:
|
|
1454
|
+
lines += ["", "## Depends on"]
|
|
1455
|
+
for dep_id in node["depends_on"]:
|
|
1456
|
+
dep = find(data, dep_id)
|
|
1457
|
+
commits = [link["value"] for link in dep.get("links", []) if link["kind"] == "commit"]
|
|
1458
|
+
suffix = f" — commit {commits[-1]}" if commits else ""
|
|
1459
|
+
lines.append(f"- {dep['id']} {dep['title']} ({BADGE[effective(dep)]}){suffix}")
|
|
1460
|
+
specs = [link["value"] for link in node.get("links", []) if link["kind"] == "spec"]
|
|
1461
|
+
if specs:
|
|
1462
|
+
lines += ["", f"Spec: {specs[-1]}"]
|
|
1463
|
+
scopes = sorted({link["value"] for link in node.get("links", []) if link["kind"] == "scope"})
|
|
1464
|
+
if scopes:
|
|
1465
|
+
lines += ["", "Files: " + ", ".join(scopes)]
|
|
1466
|
+
if node["notes"]:
|
|
1467
|
+
lines += ["", "## Notes"] + [f"- {note}" for note in node["notes"]]
|
|
1468
|
+
print("\n".join(lines))
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def cmd_render(args):
|
|
1472
|
+
connect(create=False).close() # refuse to render from a database that does not exist
|
|
1473
|
+
with Locked():
|
|
1474
|
+
data = load()
|
|
1475
|
+
validate(data)
|
|
1476
|
+
write_view(data)
|
|
1477
|
+
count = len(data["sprints"])
|
|
1478
|
+
print(f"rendered {VIEW_DIR.name}/README.md + {count} sprint file(s)")
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def cmd_check(args):
|
|
1482
|
+
if not DATA_FILE.exists():
|
|
1483
|
+
print("tasks: no database (tasks/tasks.db is git-ignored and not created yet); nothing to check")
|
|
1484
|
+
return
|
|
1485
|
+
data = load()
|
|
1486
|
+
validate(data)
|
|
1487
|
+
expected = expected_view(data)
|
|
1488
|
+
if not README_FILE.exists():
|
|
1489
|
+
# the view is git-ignored; if missing, regenerate the whole directory silently.
|
|
1490
|
+
write_view(data)
|
|
1491
|
+
regenerated_view = True
|
|
1492
|
+
elif any((not path.exists()) or path.read_text(encoding="utf-8") != text
|
|
1493
|
+
for path, text in expected.items()):
|
|
1494
|
+
raise TaskError("the task view is out of date; run: python3 scripts/tasks.py render")
|
|
1495
|
+
else:
|
|
1496
|
+
regenerated_view = False
|
|
1497
|
+
if not SNAPSHOT_FILE.exists():
|
|
1498
|
+
raise TaskError("tasks/tasks.snapshot.json is missing; run: python3 scripts/tasks.py snapshot")
|
|
1499
|
+
if SNAPSHOT_FILE.read_text(encoding="utf-8") != snapshot_json(data):
|
|
1500
|
+
raise TaskError("tasks/tasks.snapshot.json is out of date; run: python3 scripts/tasks.py snapshot")
|
|
1501
|
+
if regenerated_view:
|
|
1502
|
+
print("tasks OK (view regenerated)")
|
|
1503
|
+
else:
|
|
1504
|
+
print("tasks OK (valid, view and snapshot in sync)")
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def cmd_export_json(args):
|
|
1508
|
+
text = json.dumps(load(), indent=2, ensure_ascii=False) + "\n"
|
|
1509
|
+
if args.file:
|
|
1510
|
+
Path(args.file).write_text(text, encoding="utf-8")
|
|
1511
|
+
print(f"exported to {args.file}")
|
|
1512
|
+
else:
|
|
1513
|
+
sys.stdout.write(text)
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def cmd_snapshot(args):
|
|
1517
|
+
"""Write the JSON snapshot explicitly; no-op otherwise."""
|
|
1518
|
+
if not DATA_FILE.exists():
|
|
1519
|
+
raise TaskError("no database; nothing to snapshot")
|
|
1520
|
+
data = load()
|
|
1521
|
+
validate(data)
|
|
1522
|
+
write_snapshot(data)
|
|
1523
|
+
print(f"wrote {SNAPSHOT_FILE}")
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _replace_from_source(source: dict, label: str) -> None:
|
|
1527
|
+
"""Replace the whole database with `source` (an export dict); shared by import-json and restore."""
|
|
1528
|
+
|
|
1529
|
+
def insert(conn, node, parent_id):
|
|
1530
|
+
position = int(node["id"].rsplit(".", 1)[-1])
|
|
1531
|
+
uid = node.get("uid") or new_uid(conn)
|
|
1532
|
+
node["uid"] = uid
|
|
1533
|
+
conn.execute(
|
|
1534
|
+
"INSERT INTO nodes (id, uid, parent_id, position, level, title, description, status, reason, assignee, "
|
|
1535
|
+
"goal, start, end, created, updated, started, finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
1536
|
+
(node["id"], uid, parent_id, position, depth(node["id"]), node["title"], node.get("description", ""),
|
|
1537
|
+
node.get("status", "none"), node.get("reason", ""), node.get("assignee", ""), node.get("goal", ""),
|
|
1538
|
+
node.get("start", ""), node.get("end", ""), node.get("created", today()), node.get("updated", today()),
|
|
1539
|
+
node.get("started", ""), node.get("finished", "")))
|
|
1540
|
+
for child in node.get("children", []):
|
|
1541
|
+
insert(conn, child, node["id"])
|
|
1542
|
+
|
|
1543
|
+
def action(conn):
|
|
1544
|
+
conn.execute("DELETE FROM nodes")
|
|
1545
|
+
conn.execute("DELETE FROM notes")
|
|
1546
|
+
conn.execute("DELETE FROM deps")
|
|
1547
|
+
conn.execute("DELETE FROM links")
|
|
1548
|
+
conn.execute("INSERT OR REPLACE INTO meta VALUES ('project', ?)", (source.get("project", ROOT.name),))
|
|
1549
|
+
for sprint in source["sprints"]:
|
|
1550
|
+
insert(conn, sprint, None)
|
|
1551
|
+
for node in walk(source["sprints"]):
|
|
1552
|
+
for dep in node.get("depends_on", []):
|
|
1553
|
+
conn.execute("INSERT INTO deps VALUES (?, ?)", (node["id"], dep))
|
|
1554
|
+
for text in node.get("notes", []):
|
|
1555
|
+
conn.execute("INSERT INTO notes (node_id, text) VALUES (?, ?)", (node["id"], text))
|
|
1556
|
+
for link in node.get("links", []):
|
|
1557
|
+
conn.execute("INSERT OR IGNORE INTO links (node_uid, kind, value, created) VALUES (?,?,?,?)",
|
|
1558
|
+
(node["uid"], link["kind"], link["value"], link.get("created", today())))
|
|
1559
|
+
return f"imported {sum(1 for _ in walk(source['sprints']))} item(s) from {label}"
|
|
1560
|
+
mutate(action)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def cmd_import_json(args):
|
|
1564
|
+
"""Replace the whole database with the contents of a JSON export (also migrates the old tasks.json)."""
|
|
1565
|
+
source = json.loads(Path(args.file).read_text(encoding="utf-8"))
|
|
1566
|
+
_replace_from_source(source, args.file)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _backup_files(data: dict) -> dict[str, str]:
|
|
1570
|
+
"""The files stored in the backup clone: the JSON snapshot plus the rendered view."""
|
|
1571
|
+
files = {"snapshot.json": snapshot_json(data)}
|
|
1572
|
+
for path, text in expected_view(data).items():
|
|
1573
|
+
files[path.name] = text
|
|
1574
|
+
return files
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def _backup_git(*git_args: str, quiet: bool = False) -> subprocess.CompletedProcess:
|
|
1578
|
+
"""Run git in the backup clone; a failure is reported on stderr (never raised, never silent)."""
|
|
1579
|
+
result = subprocess.run(["git", "-C", str(BACKUP_DIR), *git_args], check=False, capture_output=True, text=True)
|
|
1580
|
+
if result.returncode != 0 and not quiet:
|
|
1581
|
+
print(f"warning: backup: git {git_args[0]} failed: {result.stderr.strip()}", file=sys.stderr)
|
|
1582
|
+
return result
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _backup_commit(data: dict, message: str, push: bool = False) -> bool:
|
|
1586
|
+
"""Write the snapshot + view into BACKUP_DIR and make a LOCAL commit there (no push unless asked).
|
|
1587
|
+
|
|
1588
|
+
No-op when BACKUP_DIR is not a git repository yet, so nothing is written or pushed on its own.
|
|
1589
|
+
Git failures are reported on stderr as warnings."""
|
|
1590
|
+
if not (BACKUP_DIR / ".git").exists():
|
|
1591
|
+
return False
|
|
1592
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1593
|
+
for name, text in _backup_files(data).items():
|
|
1594
|
+
(BACKUP_DIR / name).write_text(text, encoding="utf-8")
|
|
1595
|
+
_backup_git("add", "-A")
|
|
1596
|
+
if _backup_git("diff", "--cached", "--quiet", quiet=True).returncode != 0:
|
|
1597
|
+
_backup_git("-c", "user.name=planner", "-c", "user.email=planner@localhost", "commit", "-q", "-m", message)
|
|
1598
|
+
if push:
|
|
1599
|
+
_backup_git("push", "-q", "origin", "HEAD")
|
|
1600
|
+
return True
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def cmd_backup(args):
|
|
1604
|
+
"""Write the snapshot + view into the backup clone and commit locally; --push pushes it."""
|
|
1605
|
+
if not DATA_FILE.exists():
|
|
1606
|
+
raise TaskError("no database; nothing to back up")
|
|
1607
|
+
data = load()
|
|
1608
|
+
if not (BACKUP_DIR / ".git").exists():
|
|
1609
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1610
|
+
if _backup_git("init", "-q").returncode != 0:
|
|
1611
|
+
raise TaskError(f"cannot initialise the backup clone in {BACKUP_DIR}")
|
|
1612
|
+
if _backup_git("remote", "add", "origin", BACKUP_REMOTE).returncode != 0:
|
|
1613
|
+
raise TaskError(f"cannot add the backup remote {BACKUP_REMOTE}")
|
|
1614
|
+
message = args.message or f"{CURRENT_ACTION or 'backup'}: {now_iso()}"
|
|
1615
|
+
_backup_commit(data, message, push=args.push)
|
|
1616
|
+
print(f"backed up to {BACKUP_DIR}{' (pushed)' if args.push else ''}")
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def cmd_restore(args):
|
|
1620
|
+
"""Replace the database from the backup clone's snapshot (optionally pulling first)."""
|
|
1621
|
+
if args.pull:
|
|
1622
|
+
result = _backup_git("pull", "-q", "--ff-only")
|
|
1623
|
+
if result.returncode != 0:
|
|
1624
|
+
raise TaskError("backup pull failed; refusing to restore from a potentially stale snapshot")
|
|
1625
|
+
snapshot = BACKUP_DIR / "snapshot.json"
|
|
1626
|
+
if not snapshot.exists():
|
|
1627
|
+
raise TaskError(f"no backup snapshot at {snapshot}")
|
|
1628
|
+
_replace_from_source(json.loads(snapshot.read_text(encoding="utf-8")), str(snapshot))
|
|
1629
|
+
print(f"restored from {snapshot}")
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
# --------------------------------------------------------------------------- CLI
|
|
1633
|
+
|
|
1634
|
+
def cmd_import_plan(args):
|
|
1635
|
+
"""Atomically import a plan by run id (delegates to tasks_plan_import)."""
|
|
1636
|
+
import tasks_plan_import
|
|
1637
|
+
tasks_plan_import.cmd_import_plan(args)
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1641
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1642
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1643
|
+
|
|
1644
|
+
p = sub.add_parser("add-sprint", help="create a sprint")
|
|
1645
|
+
p.add_argument("title")
|
|
1646
|
+
p.add_argument("--goal")
|
|
1647
|
+
p.add_argument("--start", help="YYYY-MM-DD")
|
|
1648
|
+
p.add_argument("--end", help="YYYY-MM-DD")
|
|
1649
|
+
p.set_defaults(func=cmd_add_sprint)
|
|
1650
|
+
|
|
1651
|
+
p = sub.add_parser("add", help="add a task under a sprint id, or a subtask under a task id")
|
|
1652
|
+
p.add_argument("parent", help="sprint id (1) or task id (1.2)")
|
|
1653
|
+
p.add_argument("title")
|
|
1654
|
+
p.add_argument("-d", "--desc", help="description / acceptance criteria")
|
|
1655
|
+
p.add_argument("-a", "--assignee", help="who does it, e.g. junie, kilo, claude")
|
|
1656
|
+
p.add_argument("--after", help="comma separated ids that must be done first")
|
|
1657
|
+
p.set_defaults(func=cmd_add)
|
|
1658
|
+
|
|
1659
|
+
p = sub.add_parser("status", help="set a status (leaf items; --all cascades down)", add_help=False)
|
|
1660
|
+
p.add_argument("positionals", nargs="*", help="ids followed by status, e.g. 1.1 1.2 done")
|
|
1661
|
+
p.add_argument("--reason", help="required for blocked")
|
|
1662
|
+
p.add_argument("--all", action="store_true", help="apply to every leaf below a parent")
|
|
1663
|
+
p.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS,
|
|
1664
|
+
help="show this help message and exit")
|
|
1665
|
+
p.set_defaults(func=cmd_status)
|
|
1666
|
+
|
|
1667
|
+
for name, status, extra in (("start", "in_progress", False), ("done", "done", False),
|
|
1668
|
+
("reset", "none", False), ("block", "blocked", True)):
|
|
1669
|
+
p = sub.add_parser(name, help=f"shortcut for: status <id> {status}")
|
|
1670
|
+
p.add_argument("ids", nargs="+", help="one or more item ids")
|
|
1671
|
+
if extra:
|
|
1672
|
+
p.add_argument("--reason", required=True)
|
|
1673
|
+
p.add_argument("--all", action="store_true")
|
|
1674
|
+
p.set_defaults(func=shortcut(status))
|
|
1675
|
+
|
|
1676
|
+
p = sub.add_parser("edit", help="change title, description, assignee, dependencies; sprint goal/dates")
|
|
1677
|
+
p.add_argument("ids", nargs="+", help="one or more item ids")
|
|
1678
|
+
p.add_argument("--title")
|
|
1679
|
+
p.add_argument("-d", "--desc")
|
|
1680
|
+
p.add_argument("-a", "--assignee")
|
|
1681
|
+
p.add_argument("--after", help="comma separated ids (replaces the list; empty string clears)")
|
|
1682
|
+
p.add_argument("--goal")
|
|
1683
|
+
p.add_argument("--start")
|
|
1684
|
+
p.add_argument("--end")
|
|
1685
|
+
p.set_defaults(func=cmd_edit)
|
|
1686
|
+
|
|
1687
|
+
p = sub.add_parser("mv", help="move an item to another parent (renumbers the subtree)")
|
|
1688
|
+
p.add_argument("id", help="item id to move")
|
|
1689
|
+
p.add_argument("parent", help="new parent id")
|
|
1690
|
+
p.add_argument("--pos", type=int, help="1-based position (default: append last)")
|
|
1691
|
+
p.set_defaults(func=cmd_mv)
|
|
1692
|
+
|
|
1693
|
+
p = sub.add_parser("note", help="append a dated note")
|
|
1694
|
+
p.add_argument("id")
|
|
1695
|
+
p.add_argument("text")
|
|
1696
|
+
p.set_defaults(func=cmd_note)
|
|
1697
|
+
|
|
1698
|
+
p = sub.add_parser("link", help="attach a structured link (spec/patch/commit/branch/runner/model/...)")
|
|
1699
|
+
p.add_argument("id", help="display id or uid")
|
|
1700
|
+
p.add_argument("kind", help="one of: " + ", ".join(LINK_KINDS))
|
|
1701
|
+
p.add_argument("value")
|
|
1702
|
+
p.set_defaults(func=cmd_link)
|
|
1703
|
+
|
|
1704
|
+
p = sub.add_parser("unlink", help="remove a structured link")
|
|
1705
|
+
p.add_argument("id", help="display id or uid")
|
|
1706
|
+
p.add_argument("kind")
|
|
1707
|
+
p.add_argument("value")
|
|
1708
|
+
p.set_defaults(func=cmd_unlink)
|
|
1709
|
+
|
|
1710
|
+
p = sub.add_parser("rm", help="remove item(s) and everything below them (asks for confirmation)")
|
|
1711
|
+
p.add_argument("ids", nargs="+", help="one or more item ids")
|
|
1712
|
+
p.add_argument("--yes", action="store_true",
|
|
1713
|
+
help="skip the confirmation question (the preview and the recovery file still happen)")
|
|
1714
|
+
p.add_argument("--dry-run", action="store_true", help="show the preview only; change nothing")
|
|
1715
|
+
p.set_defaults(func=cmd_rm)
|
|
1716
|
+
|
|
1717
|
+
p = sub.add_parser("restore-removed", help="restore a recovery file written by rm (fresh ids)")
|
|
1718
|
+
p.add_argument("file", help="path to a tasks/removed/*.json recovery file")
|
|
1719
|
+
p.add_argument("--parent", help="restore under this id instead of the original parent")
|
|
1720
|
+
p.set_defaults(func=cmd_restore_removed)
|
|
1721
|
+
|
|
1722
|
+
p = sub.add_parser("next", help="first ready, unfinished leaf in the first unfinished sprint")
|
|
1723
|
+
p.add_argument("--assignee")
|
|
1724
|
+
p.add_argument("-n", "--count", type=int, default=1, help="show up to N ready items")
|
|
1725
|
+
p.set_defaults(func=cmd_next)
|
|
1726
|
+
|
|
1727
|
+
p = sub.add_parser("list", help="print the tree (or one subtree with a positional id)")
|
|
1728
|
+
p.add_argument("id", nargs="?", help="only this item and everything below it")
|
|
1729
|
+
p.add_argument("--status")
|
|
1730
|
+
p.add_argument("--sprint")
|
|
1731
|
+
p.add_argument("--assignee")
|
|
1732
|
+
p.add_argument("--text", help="case-insensitive match in title/description")
|
|
1733
|
+
p.add_argument("--leaves", action="store_true", help="only items without children")
|
|
1734
|
+
p.add_argument("--desc", action="store_true", help="also print each item's description")
|
|
1735
|
+
p.add_argument("--notes", action="store_true", help="also print each item's notes")
|
|
1736
|
+
p.set_defaults(func=cmd_list)
|
|
1737
|
+
|
|
1738
|
+
p = sub.add_parser("stats", help="counts per status and open work per assignee")
|
|
1739
|
+
p.set_defaults(func=cmd_stats)
|
|
1740
|
+
|
|
1741
|
+
p = sub.add_parser("sprint-table", help="one table of a sprint's tasks (status, assignee, progress)")
|
|
1742
|
+
p.add_argument("id", help="sprint id")
|
|
1743
|
+
p.set_defaults(func=cmd_sprint_table)
|
|
1744
|
+
|
|
1745
|
+
p = sub.add_parser("overview", help="all sprints in one call: status, counts per status, goal")
|
|
1746
|
+
p.set_defaults(func=cmd_overview)
|
|
1747
|
+
|
|
1748
|
+
p = sub.add_parser("show", help="print one or more items as JSON (or a one-line summary with --brief)")
|
|
1749
|
+
p.add_argument("id", nargs="+")
|
|
1750
|
+
p.add_argument("--brief", action="store_true", help="one line per item instead of JSON")
|
|
1751
|
+
p.set_defaults(func=cmd_show)
|
|
1752
|
+
|
|
1753
|
+
p = sub.add_parser("log", help="print the audit log (optionally for one item, since a timestamp)")
|
|
1754
|
+
p.add_argument("id", nargs="?", help="display id or uid")
|
|
1755
|
+
p.add_argument("--since", help="only events at or after this timestamp")
|
|
1756
|
+
p.set_defaults(func=cmd_log)
|
|
1757
|
+
|
|
1758
|
+
p = sub.add_parser("brief", help="context bundle for one item (description, deps with commits, spec, notes)")
|
|
1759
|
+
p.add_argument("id", help="display id or uid")
|
|
1760
|
+
p.set_defaults(func=cmd_brief)
|
|
1761
|
+
|
|
1762
|
+
p = sub.add_parser("plan-batch", help="largest set of ready tasks with disjoint scope links")
|
|
1763
|
+
p.add_argument("--max", type=int, help="cap the batch size")
|
|
1764
|
+
p.set_defaults(func=cmd_plan_batch)
|
|
1765
|
+
|
|
1766
|
+
p = sub.add_parser("render", help="regenerate the tasks/ view (README + one file per sprint)")
|
|
1767
|
+
p.set_defaults(func=cmd_render)
|
|
1768
|
+
|
|
1769
|
+
p = sub.add_parser("export-json", help="dump the database as JSON (stdout or --file)")
|
|
1770
|
+
p.add_argument("--file")
|
|
1771
|
+
p.set_defaults(func=cmd_export_json)
|
|
1772
|
+
|
|
1773
|
+
p = sub.add_parser("import-json", help="REPLACE the database with a JSON export / the old tasks.json")
|
|
1774
|
+
p.add_argument("file")
|
|
1775
|
+
p.set_defaults(func=cmd_import_json)
|
|
1776
|
+
|
|
1777
|
+
p = sub.add_parser("check", help="validate the database and that the tasks/ view is in sync (used by pre-push)")
|
|
1778
|
+
p.set_defaults(func=cmd_check)
|
|
1779
|
+
|
|
1780
|
+
p = sub.add_parser("snapshot", help="write the committed JSON snapshot (tasks/tasks.snapshot.json)")
|
|
1781
|
+
p.set_defaults(func=cmd_snapshot)
|
|
1782
|
+
|
|
1783
|
+
p = sub.add_parser("backup", help="write the snapshot + view into the backup clone and commit locally")
|
|
1784
|
+
p.add_argument("--push", action="store_true", help="also push to the backup remote")
|
|
1785
|
+
p.add_argument("--message", help="commit message")
|
|
1786
|
+
p.set_defaults(func=cmd_backup)
|
|
1787
|
+
|
|
1788
|
+
p = sub.add_parser("restore", help="replace the database from the backup clone's snapshot")
|
|
1789
|
+
p.add_argument("--pull", action="store_true", help="pull the backup clone first")
|
|
1790
|
+
p.set_defaults(func=cmd_restore)
|
|
1791
|
+
|
|
1792
|
+
p = sub.add_parser("import-plan", help="atomically import a validated plan by run id (automation)")
|
|
1793
|
+
p.add_argument("plan", help="path to the plan JSON file")
|
|
1794
|
+
p.add_argument("--run-id", required=True, help="unique run identity for replay detection")
|
|
1795
|
+
p.set_defaults(func=cmd_import_plan)
|
|
1796
|
+
|
|
1797
|
+
return parser
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1801
|
+
args = build_parser().parse_args(argv)
|
|
1802
|
+
global CURRENT_ACTION
|
|
1803
|
+
CURRENT_ACTION = getattr(args, "command", "") or ""
|
|
1804
|
+
try:
|
|
1805
|
+
code = args.func(args)
|
|
1806
|
+
except TaskError as error:
|
|
1807
|
+
print(f"error: {error}", file=sys.stderr)
|
|
1808
|
+
return 1
|
|
1809
|
+
return code or 0
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
if __name__ == "__main__":
|
|
1813
|
+
sys.exit(main())
|