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_storage.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""SQLite storage layer for the task tracker.
|
|
2
|
+
|
|
3
|
+
Extracted from tasks.py to keep the monolith small and testable.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
|
+
import tasks_init # noqa: E402
|
|
17
|
+
|
|
18
|
+
# Re-use ROOT resolution from tasks_init
|
|
19
|
+
ROOT = tasks_init.resolve_root(Path(__file__).resolve().parent.parent)
|
|
20
|
+
DATA_FILE = ROOT / "tasks" / "tasks.db"
|
|
21
|
+
VIEW_DIR = ROOT / "tasks"
|
|
22
|
+
README_FILE = VIEW_DIR / "README.md"
|
|
23
|
+
VIEW_FILE = README_FILE
|
|
24
|
+
LOCK_FILE = ROOT / "tasks" / ".lock"
|
|
25
|
+
SNAPSHOT_FILE = ROOT / "tasks" / "tasks.snapshot.json"
|
|
26
|
+
RECOVERY_DIR = ROOT / "tasks" / "removed"
|
|
27
|
+
BACKUP_DIR = Path(os.environ.get("VIBE_TASKS_BACKUP_DIR", str(Path.home() / ".local" / "share" / "vibe-tasks")))
|
|
28
|
+
BACKUP_REMOTE = os.environ.get("VIBE_TASKS_BACKUP_REMOTE", "git@gitlab.com:prachwal/vibe-emulator-tasks.git")
|
|
29
|
+
|
|
30
|
+
FIELDS = ("id", "uid", "title", "description", "status", "reason", "assignee", "goal", "start", "end",
|
|
31
|
+
"created", "updated", "started", "finished")
|
|
32
|
+
|
|
33
|
+
LINK_KINDS = ("spec", "patch", "runner", "model", "agent", "commit", "branch", "fixes", "scope")
|
|
34
|
+
|
|
35
|
+
NODES_V2_DDL = """
|
|
36
|
+
CREATE TABLE nodes_new (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
uid TEXT NOT NULL DEFAULT '',
|
|
39
|
+
parent_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
|
|
40
|
+
position INTEGER NOT NULL,
|
|
41
|
+
level INTEGER NOT NULL CHECK (level BETWEEN 1 AND 3),
|
|
42
|
+
title TEXT NOT NULL,
|
|
43
|
+
description TEXT NOT NULL DEFAULT '',
|
|
44
|
+
status TEXT NOT NULL DEFAULT 'none' CHECK (status IN ('none','in_progress','blocked','done','cancelled','review')),
|
|
45
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
46
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
47
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
48
|
+
start TEXT NOT NULL DEFAULT '',
|
|
49
|
+
end TEXT NOT NULL DEFAULT '',
|
|
50
|
+
created TEXT NOT NULL,
|
|
51
|
+
updated TEXT NOT NULL,
|
|
52
|
+
started TEXT NOT NULL DEFAULT '',
|
|
53
|
+
finished TEXT NOT NULL DEFAULT '',
|
|
54
|
+
UNIQUE (parent_id, position)
|
|
55
|
+
);
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from tasks_model import * # noqa: E402, F401, F403
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def connect(create: bool = True) -> sqlite3.Connection:
|
|
62
|
+
"""Open the database; create from scratch if missing."""
|
|
63
|
+
if not DATA_FILE.exists():
|
|
64
|
+
if not create:
|
|
65
|
+
raise TaskError(f"no database at {DATA_FILE.parent.name}/{DATA_FILE.name}; run: python3 scripts/tasks_init.py")
|
|
66
|
+
import tasks_init
|
|
67
|
+
tasks_init.create_database(DATA_FILE, ROOT.name)
|
|
68
|
+
print(f"note: created a new empty task database at {DATA_FILE.parent.name}/{DATA_FILE.name}", file=__import__("sys").stderr)
|
|
69
|
+
conn = sqlite3.connect(DATA_FILE, isolation_level=None, timeout=30)
|
|
70
|
+
conn.row_factory = sqlite3.Row
|
|
71
|
+
migrate(conn)
|
|
72
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
73
|
+
return conn
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def migrate(conn: sqlite3.Connection) -> None:
|
|
77
|
+
"""Bring an existing database up to the current schema (idempotent, FK-safe)."""
|
|
78
|
+
row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='nodes'").fetchone()
|
|
79
|
+
if row is None:
|
|
80
|
+
return
|
|
81
|
+
sql = row[0] or ""
|
|
82
|
+
columns = [c[1] for c in conn.execute("PRAGMA table_info(nodes)")]
|
|
83
|
+
have_uid = "uid" in columns
|
|
84
|
+
needs_rebuild = "cancelled" not in sql or "review" not in sql
|
|
85
|
+
|
|
86
|
+
if needs_rebuild:
|
|
87
|
+
cols = ("id, uid, parent_id, position, level, title, description, status, reason, assignee, "
|
|
88
|
+
"goal, start, end, created, updated, started, finished")
|
|
89
|
+
select = cols if have_uid else cols.replace("uid, ", "'' AS uid, ")
|
|
90
|
+
conn.execute("PRAGMA foreign_keys = OFF")
|
|
91
|
+
try:
|
|
92
|
+
conn.execute("BEGIN")
|
|
93
|
+
conn.execute(NODES_V2_DDL)
|
|
94
|
+
conn.execute(f"INSERT INTO nodes_new ({cols}) SELECT {select} FROM nodes")
|
|
95
|
+
conn.execute("DROP TABLE nodes")
|
|
96
|
+
conn.execute("ALTER TABLE nodes_new RENAME TO nodes")
|
|
97
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)")
|
|
98
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_assignee ON nodes(assignee)")
|
|
99
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id, position)")
|
|
100
|
+
conn.execute("COMMIT")
|
|
101
|
+
except BaseException:
|
|
102
|
+
conn.execute("ROLLBACK")
|
|
103
|
+
raise
|
|
104
|
+
finally:
|
|
105
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
106
|
+
elif not have_uid:
|
|
107
|
+
conn.execute("ALTER TABLE nodes ADD COLUMN uid TEXT NOT NULL DEFAULT ''")
|
|
108
|
+
|
|
109
|
+
conn.execute("CREATE TABLE IF NOT EXISTS links ("
|
|
110
|
+
"node_uid TEXT NOT NULL, kind TEXT NOT NULL, value TEXT NOT NULL, created TEXT NOT NULL, "
|
|
111
|
+
"PRIMARY KEY (node_uid, kind, value))")
|
|
112
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_links_uid ON links(node_uid)")
|
|
113
|
+
conn.execute("CREATE TABLE IF NOT EXISTS events ("
|
|
114
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, actor TEXT NOT NULL, "
|
|
115
|
+
"action TEXT NOT NULL, node_uid TEXT NOT NULL DEFAULT '', field TEXT NOT NULL DEFAULT '', "
|
|
116
|
+
"old TEXT NOT NULL DEFAULT '', new TEXT NOT NULL DEFAULT '')")
|
|
117
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_node ON events(node_uid)")
|
|
118
|
+
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_uid ON nodes(uid) WHERE uid <> ''")
|
|
119
|
+
_backfill_uids(conn)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _backfill_uids(conn: sqlite3.Connection) -> None:
|
|
123
|
+
"""Give every node that has no uid a fresh unique one."""
|
|
124
|
+
rows = conn.execute("SELECT id FROM nodes WHERE uid = ''").fetchall()
|
|
125
|
+
if not rows:
|
|
126
|
+
return
|
|
127
|
+
existing = {r[0] for r in conn.execute("SELECT uid FROM nodes WHERE uid <> ''")}
|
|
128
|
+
conn.execute("BEGIN")
|
|
129
|
+
try:
|
|
130
|
+
for (node_id,) in rows:
|
|
131
|
+
uid = new_uid(conn)
|
|
132
|
+
while uid in existing:
|
|
133
|
+
uid = new_uid(conn)
|
|
134
|
+
existing.add(uid)
|
|
135
|
+
conn.execute("UPDATE nodes SET uid = ? WHERE id = ?", (uid, node_id))
|
|
136
|
+
conn.execute("COMMIT")
|
|
137
|
+
except BaseException:
|
|
138
|
+
conn.execute("ROLLBACK")
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load(conn: sqlite3.Connection | None = None) -> dict:
|
|
143
|
+
"""Build the in-memory tree from the database."""
|
|
144
|
+
if conn is None and not DATA_FILE.exists():
|
|
145
|
+
return {"version": 2, "project": ROOT.name, "sprints": []}
|
|
146
|
+
own = conn is None
|
|
147
|
+
conn = conn or connect()
|
|
148
|
+
try:
|
|
149
|
+
project = conn.execute("SELECT value FROM meta WHERE key = 'project'").fetchone()[0]
|
|
150
|
+
deps: dict[str, list[str]] = {}
|
|
151
|
+
for r in conn.execute("SELECT node_id, depends_on FROM deps ORDER BY depends_on"):
|
|
152
|
+
deps.setdefault(r["node_id"], []).append(r["depends_on"])
|
|
153
|
+
notes: dict[str, list[str]] = {}
|
|
154
|
+
for r in conn.execute("SELECT node_id, text FROM notes ORDER BY seq"):
|
|
155
|
+
notes.setdefault(r["node_id"], []).append(r["text"])
|
|
156
|
+
links: dict[str, list[dict]] = {}
|
|
157
|
+
for r in conn.execute("SELECT node_uid, kind, value, created FROM links ORDER BY created, kind, value"):
|
|
158
|
+
links.setdefault(r["node_uid"], []).append(
|
|
159
|
+
{"kind": r["kind"], "value": r["value"], "created": r["created"]})
|
|
160
|
+
by_id: dict[str, dict] = {}
|
|
161
|
+
sprints: list[dict] = []
|
|
162
|
+
for r in conn.execute("SELECT * FROM nodes ORDER BY level, parent_id, position"):
|
|
163
|
+
node = {k: r[k] for k in FIELDS}
|
|
164
|
+
node.update(depends_on=deps.get(r["id"], []), notes=notes.get(r["id"], []),
|
|
165
|
+
links=links.get(r["uid"], []), children=[])
|
|
166
|
+
by_id[r["id"]] = node
|
|
167
|
+
(by_id[r["parent_id"]]["children"] if r["parent_id"] else sprints).append(node)
|
|
168
|
+
return {"version": 2, "project": project, "sprints": sprints}
|
|
169
|
+
finally:
|
|
170
|
+
if own:
|
|
171
|
+
conn.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Locked:
|
|
175
|
+
"""Exclusive lock so parallel agents cannot interleave a change and its re-render."""
|
|
176
|
+
|
|
177
|
+
def __enter__(self):
|
|
178
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
self._fh = LOCK_FILE.open("w")
|
|
180
|
+
import fcntl
|
|
181
|
+
fcntl.flock(self._fh, fcntl.LOCK_EX)
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __exit__(self, *exc):
|
|
185
|
+
import fcntl
|
|
186
|
+
fcntl.flock(self._fh, fcntl.LOCK_UN)
|
|
187
|
+
self._fh.close()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _testing_guard(target: Path) -> None:
|
|
191
|
+
"""When VIBE_TASKS_TESTING=1, refuse to write outside a temp dir."""
|
|
192
|
+
import os
|
|
193
|
+
if os.environ.get("VIBE_TASKS_TESTING") != "1":
|
|
194
|
+
return
|
|
195
|
+
import tempfile
|
|
196
|
+
tmp_dir = Path(tempfile.gettempdir()).resolve()
|
|
197
|
+
try:
|
|
198
|
+
target.resolve().relative_to(tmp_dir)
|
|
199
|
+
except ValueError:
|
|
200
|
+
raise TaskError(f"refusing to write outside a temp dir while testing: {target}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _write_atomic(target: Path, text: str) -> None:
|
|
204
|
+
"""Write text to target atomically."""
|
|
205
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
fd, tmp = tempfile.mkstemp(prefix=target.name + ".", suffix=".tmp", dir=str(target.parent))
|
|
207
|
+
try:
|
|
208
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
209
|
+
handle.write(text)
|
|
210
|
+
handle.flush()
|
|
211
|
+
os.fsync(handle.fileno())
|
|
212
|
+
os.replace(tmp, target)
|
|
213
|
+
except BaseException:
|
|
214
|
+
try:
|
|
215
|
+
os.unlink(tmp)
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def write_view(data: dict) -> None:
|
|
222
|
+
"""Regenerate the tasks/ view (README + per-sprint files)."""
|
|
223
|
+
import sys
|
|
224
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
225
|
+
import tasks_render # noqa: E402
|
|
226
|
+
files = tasks_render.expected_view(data)
|
|
227
|
+
VIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
for path, text in files.items():
|
|
229
|
+
_testing_guard(path)
|
|
230
|
+
_write_atomic(path, text)
|
|
231
|
+
keep = {p.name for p in files}
|
|
232
|
+
for stale in VIEW_DIR.glob("sprint-*.md"):
|
|
233
|
+
if stale.name not in keep:
|
|
234
|
+
stale.unlink()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def snapshot_json(data: dict) -> str:
|
|
238
|
+
"""Deterministic JSON snapshot."""
|
|
239
|
+
return json.dumps(data, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def write_snapshot(data: dict) -> None:
|
|
243
|
+
"""Write the committed JSON snapshot file."""
|
|
244
|
+
_testing_guard(SNAPSHOT_FILE)
|
|
245
|
+
_write_atomic(SNAPSHOT_FILE, snapshot_json(data))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _log_events(conn: sqlite3.Connection, before: dict, after: dict) -> None:
|
|
249
|
+
"""Write one audit event per added/removed node and per changed field."""
|
|
250
|
+
import datetime as dt
|
|
251
|
+
when = dt.datetime.now().replace(microsecond=0).isoformat()
|
|
252
|
+
who = actor()
|
|
253
|
+
action = os.environ.get("CURRENT_ACTION", "") or "change"
|
|
254
|
+
for node_id in sorted(set(before) | set(after)):
|
|
255
|
+
old_row = before.get(node_id)
|
|
256
|
+
new_row = after.get(node_id)
|
|
257
|
+
uid = (new_row or old_row)["uid"]
|
|
258
|
+
if old_row is None:
|
|
259
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
260
|
+
(when, who, f"{action}:add", uid, "", "", new_row["title"]))
|
|
261
|
+
continue
|
|
262
|
+
if new_row is None:
|
|
263
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
264
|
+
(when, who, f"{action}:remove", uid, "", old_row["title"], ""))
|
|
265
|
+
continue
|
|
266
|
+
for field in ("title", "description", "status", "reason", "assignee", "goal", "start", "end"):
|
|
267
|
+
if old_row[field] != new_row[field]:
|
|
268
|
+
conn.execute("INSERT INTO events (ts,actor,action,node_uid,field,old,new) VALUES (?,?,?,?,?,?,?)",
|
|
269
|
+
(when, who, action, uid, field, old_row[field], new_row[field]))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def mutate(action) -> None:
|
|
273
|
+
"""Run action(conn) in one transaction, validate, commit, re-render."""
|
|
274
|
+
import fcntl
|
|
275
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
fh = LOCK_FILE.open("w")
|
|
277
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
278
|
+
try:
|
|
279
|
+
conn = connect()
|
|
280
|
+
try:
|
|
281
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
282
|
+
try:
|
|
283
|
+
before = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM nodes")}
|
|
284
|
+
message = action(conn)
|
|
285
|
+
after = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM nodes")}
|
|
286
|
+
_log_events(conn, before, after)
|
|
287
|
+
data = load(conn)
|
|
288
|
+
# validate
|
|
289
|
+
import tasks as _tasks_mod
|
|
290
|
+
_tasks_mod.validate(data)
|
|
291
|
+
except BaseException:
|
|
292
|
+
conn.execute("ROLLBACK")
|
|
293
|
+
raise
|
|
294
|
+
conn.execute("COMMIT")
|
|
295
|
+
finally:
|
|
296
|
+
conn.close()
|
|
297
|
+
write_view(data)
|
|
298
|
+
write_snapshot(data)
|
|
299
|
+
finally:
|
|
300
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
301
|
+
fh.close()
|
|
302
|
+
if message:
|
|
303
|
+
import sys
|
|
304
|
+
print(message)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Row helpers
|
|
308
|
+
def row(conn: sqlite3.Connection, node_id: str) -> sqlite3.Row:
|
|
309
|
+
found = conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone()
|
|
310
|
+
if found is None:
|
|
311
|
+
raise TaskError(f"no such id: {node_id}")
|
|
312
|
+
return found
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def row_by_key(conn: sqlite3.Connection, key: str) -> sqlite3.Row:
|
|
316
|
+
found = conn.execute("SELECT * FROM nodes WHERE id = ? OR uid = ?", (key, key)).fetchone()
|
|
317
|
+
if found is None:
|
|
318
|
+
raise TaskError(f"no such id: {key}")
|
|
319
|
+
return found
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def subtree_ids(conn: sqlite3.Connection, node_id: str) -> list[str]:
|
|
323
|
+
return [r[0] for r in conn.execute(
|
|
324
|
+
"SELECT id FROM nodes WHERE id = ? OR id LIKE ? ORDER BY level, position", (node_id, node_id + ".%"))]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def subtree_rows(conn: sqlite3.Connection, node_id: str) -> list[sqlite3.Row]:
|
|
328
|
+
return list(conn.execute(
|
|
329
|
+
"SELECT * FROM nodes WHERE id = ? OR id LIKE ? ORDER BY level, position", (node_id, node_id + ".%")))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def leaf_ids(conn: sqlite3.Connection, node_id: str) -> list[str]:
|
|
333
|
+
return [r[0] for r in conn.execute(
|
|
334
|
+
"SELECT id FROM nodes n WHERE (id = ? OR id LIKE ?) "
|
|
335
|
+
"AND NOT EXISTS (SELECT 1 FROM nodes c WHERE c.parent_id = n.id) ORDER BY level, position",
|
|
336
|
+
(node_id, node_id + ".%"))]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def parse_ids(text: str | None) -> list[str]:
|
|
340
|
+
return [x for x in (text or "").replace(" ", "").split(",") if x]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def set_deps(conn: sqlite3.Connection, node_id: str, deps: list[str]) -> None:
|
|
344
|
+
conn.execute("DELETE FROM deps WHERE node_id = ?", (node_id,))
|
|
345
|
+
for dep in dict.fromkeys(deps):
|
|
346
|
+
if conn.execute("SELECT 1 FROM nodes WHERE id = ?", (dep,)).fetchone() is None:
|
|
347
|
+
raise TaskError(f"{node_id}: depends on unknown id {dep}")
|
|
348
|
+
conn.execute("INSERT INTO deps VALUES (?, ?)", (node_id, dep))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def insert_node(conn: sqlite3.Connection, parent_id: str | None, title: str, **fields) -> str:
|
|
352
|
+
where, args = ("parent_id = ?", (parent_id,)) if parent_id else ("parent_id IS NULL", ())
|
|
353
|
+
position = conn.execute(f"SELECT COALESCE(MAX(position), 0) + 1 FROM nodes WHERE {where}", args).fetchone()[0]
|
|
354
|
+
node_id = f"{parent_id}.{position}" if parent_id else str(position)
|
|
355
|
+
uid = new_uid(conn)
|
|
356
|
+
values = {"description": "", "assignee": "", "goal": "", "start": "", "end": ""}
|
|
357
|
+
values.update({k: v for k, v in fields.items() if v is not None})
|
|
358
|
+
conn.execute(
|
|
359
|
+
"INSERT INTO nodes (id, uid, parent_id, position, level, title, description, assignee, goal, start, end, "
|
|
360
|
+
"created, updated) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
361
|
+
(node_id, uid, parent_id, position, depth(node_id), title, values["description"], values["assignee"],
|
|
362
|
+
values["goal"], values["start"], values["end"], today(), today()))
|
|
363
|
+
return node_id
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def set_leaf_status(conn: sqlite3.Connection, node_id: str, status: str, reason: str) -> None:
|
|
367
|
+
current = row(conn, node_id)
|
|
368
|
+
if status == "blocked" and not reason:
|
|
369
|
+
raise TaskError(f"{node_id}: use --reason to say what blocks it")
|
|
370
|
+
started = current["started"] or (today() if status == "in_progress" else "")
|
|
371
|
+
finished = today() if status == "done" else ("" if current["status"] == "done" else current["finished"])
|
|
372
|
+
conn.execute("UPDATE nodes SET status=?, reason=?, started=?, finished=?, updated=? WHERE id=?",
|
|
373
|
+
(status, reason if status == "blocked" else "", started, finished, today(), node_id))
|
scripts/tasks_watch.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Background watcher that keeps the task view (tasks/README.md + per-sprint files) and the snapshot in sync with tasks/tasks.db.
|
|
3
|
+
|
|
4
|
+
Polls the database file for changes (mtime + size) and regenerates the view and snapshot
|
|
5
|
+
when the DB has changed. Uses the same functions from tasks.py (render, snapshot_json,
|
|
6
|
+
write_view, write_snapshot, Locked) so the output is always identical to what
|
|
7
|
+
`scripts/tasks.py` produces.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/tasks_watch.py [--interval SECONDS] [--once]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--interval Poll interval in seconds (default: 2).
|
|
14
|
+
--once Run a single check and exit (useful for tests).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# Ensure we can import tasks from the same directory.
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
import tasks # noqa: E402
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_shutting_down = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _handle_signal(signum, frame):
|
|
35
|
+
global _shutting_down
|
|
36
|
+
_shutting_down = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
40
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _db_stat(data_file: Path) -> tuple[int, int] | None:
|
|
44
|
+
"""Return (mtime_ns, size) of the DB file, or None if it doesn't exist."""
|
|
45
|
+
try:
|
|
46
|
+
st = data_file.stat()
|
|
47
|
+
return (st.st_mtime_ns, st.st_size)
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _view_matches(expected: dict) -> bool:
|
|
53
|
+
"""True when every expected view file matches and no stale sprint file remains."""
|
|
54
|
+
for path, text in expected.items():
|
|
55
|
+
try:
|
|
56
|
+
if path.read_text(encoding="utf-8") != text:
|
|
57
|
+
return False
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return False
|
|
60
|
+
keep = {p.name for p in expected}
|
|
61
|
+
if tasks.VIEW_DIR.exists():
|
|
62
|
+
for stale in tasks.VIEW_DIR.glob("sprint-*.md"):
|
|
63
|
+
if stale.name not in keep:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _current_snapshot_content() -> str | None:
|
|
69
|
+
"""Return the current snapshot content, or None if missing."""
|
|
70
|
+
try:
|
|
71
|
+
return tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _refresh_once() -> bool:
|
|
77
|
+
"""Run one poll cycle. Return True if a refresh was performed."""
|
|
78
|
+
data_file = tasks.DATA_FILE
|
|
79
|
+
|
|
80
|
+
# Check DB existence
|
|
81
|
+
stat = _db_stat(data_file)
|
|
82
|
+
if stat is None:
|
|
83
|
+
print("watch: no database yet (tasks/tasks.db missing) — waiting...", file=sys.stderr)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# Try to load the DB
|
|
87
|
+
try:
|
|
88
|
+
data = tasks.load()
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
# Corrupted or locked DB — print once and wait for next tick
|
|
91
|
+
print(f"watch: cannot read database ({exc}) — will retry", file=sys.stderr)
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
# Validate
|
|
95
|
+
try:
|
|
96
|
+
tasks.validate(data)
|
|
97
|
+
except tasks.TaskError as exc:
|
|
98
|
+
print(f"watch: invalid database ({exc}) — will retry", file=sys.stderr)
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
# Compute expected content
|
|
102
|
+
expected_files = tasks.expected_view(data)
|
|
103
|
+
expected_snapshot = tasks.snapshot_json(data)
|
|
104
|
+
|
|
105
|
+
# Compare with current files — only write if content actually differs
|
|
106
|
+
view_changed = not _view_matches(expected_files)
|
|
107
|
+
snapshot_changed = False
|
|
108
|
+
|
|
109
|
+
current_snapshot = _current_snapshot_content()
|
|
110
|
+
snapshot_exists = tasks.SNAPSHOT_FILE.exists()
|
|
111
|
+
if not snapshot_exists or current_snapshot != expected_snapshot:
|
|
112
|
+
snapshot_changed = True
|
|
113
|
+
|
|
114
|
+
if not view_changed and not snapshot_changed:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Write under the same lock tasks.py uses
|
|
118
|
+
with tasks.Locked():
|
|
119
|
+
if view_changed:
|
|
120
|
+
tasks.write_view(data)
|
|
121
|
+
if snapshot_changed:
|
|
122
|
+
tasks.write_snapshot(data)
|
|
123
|
+
|
|
124
|
+
now = dt.datetime.now().strftime("%H:%M:%S")
|
|
125
|
+
parts = []
|
|
126
|
+
if view_changed:
|
|
127
|
+
parts.append("view")
|
|
128
|
+
if snapshot_changed:
|
|
129
|
+
parts.append("snapshot")
|
|
130
|
+
print(f"refreshed {', '.join(parts)} at {now}", file=sys.stderr)
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: list[str] | None = None) -> int:
|
|
135
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
136
|
+
parser.add_argument("--interval", type=float, default=2.0, help="poll interval in seconds")
|
|
137
|
+
parser.add_argument("--once", action="store_true", help="run one check and exit")
|
|
138
|
+
args = parser.parse_args(argv)
|
|
139
|
+
|
|
140
|
+
if args.once:
|
|
141
|
+
_refresh_once()
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
while not _shutting_down:
|
|
145
|
+
try:
|
|
146
|
+
_refresh_once()
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
print(f"watch: unexpected error ({exc}) — will retry", file=sys.stderr)
|
|
149
|
+
# Sleep in small increments so we respond to signals promptly
|
|
150
|
+
for _ in range(int(args.interval * 10)):
|
|
151
|
+
if _shutting_down:
|
|
152
|
+
break
|
|
153
|
+
time.sleep(0.1)
|
|
154
|
+
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|
scripts/test.sh
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
cd "$root"
|
|
6
|
+
|
|
7
|
+
# ── agent guidelines drift ───────────────────────────────────────────────
|
|
8
|
+
echo "=== agent guidelines sync ==="
|
|
9
|
+
python3 scripts/agents_sync.py --check
|
|
10
|
+
|
|
11
|
+
# ── .NET tests ───────────────────────────────────────────────────────────
|
|
12
|
+
echo "=== .NET tests ==="
|
|
13
|
+
# Strip --fast from args passed to dotnet (it's a test.sh-only flag)
|
|
14
|
+
dotnet_args=()
|
|
15
|
+
for arg in "$@"; do
|
|
16
|
+
[ "$arg" = "--fast" ] || dotnet_args+=("$arg")
|
|
17
|
+
done
|
|
18
|
+
dotnet test VibeEmulator.slnx "${dotnet_args[@]}"
|
|
19
|
+
|
|
20
|
+
# ── worktree.sh tests ────────────────────────────────────────────────────
|
|
21
|
+
echo ""
|
|
22
|
+
echo "=== worktree.sh tests ==="
|
|
23
|
+
bash scripts/test_worktree.sh
|
|
24
|
+
|
|
25
|
+
# ── spec-lint tests ──────────────────────────────────────────────────────
|
|
26
|
+
echo ""
|
|
27
|
+
echo "=== spec-lint tests ==="
|
|
28
|
+
bash scripts/test_spec_lint.sh
|
|
29
|
+
|
|
30
|
+
# ── runner tests ─────────────────────────────────────────────────────────
|
|
31
|
+
echo ""
|
|
32
|
+
echo "=== runner tests ==="
|
|
33
|
+
SPEC_LINT=off bash scripts/test_runners.sh
|
|
34
|
+
|
|
35
|
+
# ── python unit tests (tracker, MCP, watch, agent config) ────────────────
|
|
36
|
+
echo ""
|
|
37
|
+
if [ "${1:-}" = "--fast" ]; then
|
|
38
|
+
echo "=== python unit tests (fast) ==="
|
|
39
|
+
bash scripts/test_python_fast.sh
|
|
40
|
+
else
|
|
41
|
+
echo "=== python unit tests ==="
|
|
42
|
+
python3 -m unittest discover -s scripts -p "test_*.py"
|
|
43
|
+
fi
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Validation tests for the agent instruction configuration (task 5.5).
|
|
2
|
+
|
|
3
|
+
Every linked/imported instruction file must exist, each tool config must point to an existing file,
|
|
4
|
+
and the files must stay within their size budgets. Run:
|
|
5
|
+
`python3 -m unittest discover -s scripts -p "test_*.py"`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read(rel: str) -> str:
|
|
20
|
+
"""Return the UTF-8 text of a repo-relative file."""
|
|
21
|
+
return (ROOT / rel).read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LinkTests(unittest.TestCase):
|
|
25
|
+
def test_agents_md_links_resolve(self):
|
|
26
|
+
"""Every `agents/...md` path mentioned in AGENTS.md exists."""
|
|
27
|
+
refs = re.findall(r"`(agents/[A-Za-z0-9._/-]+\.md)`", read("AGENTS.md"))
|
|
28
|
+
self.assertTrue(refs, "AGENTS.md mentions no agents/ files")
|
|
29
|
+
for ref in sorted(set(refs)):
|
|
30
|
+
self.assertTrue((ROOT / ref).is_file(), f"AGENTS.md links missing file: {ref}")
|
|
31
|
+
|
|
32
|
+
def test_claude_md_imports_resolve(self):
|
|
33
|
+
"""Every @import in CLAUDE.md exists."""
|
|
34
|
+
imports = re.findall(r"^@(\S+)$", read("CLAUDE.md"), re.MULTILINE)
|
|
35
|
+
self.assertTrue(imports, "CLAUDE.md has no @imports")
|
|
36
|
+
for ref in imports:
|
|
37
|
+
self.assertTrue((ROOT / ref).is_file(), f"CLAUDE.md imports missing file: {ref}")
|
|
38
|
+
|
|
39
|
+
def test_common_files_exist(self):
|
|
40
|
+
"""The shared detail files the dispatcher relies on are present."""
|
|
41
|
+
for name in ("layout", "git-and-hooks", "task-tracking", "branching", "delegation-flow",
|
|
42
|
+
"mcp-and-permissions"):
|
|
43
|
+
self.assertTrue((ROOT / "agents" / "common" / f"{name}.md").is_file(), name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfigTests(unittest.TestCase):
|
|
47
|
+
def test_kilo_instructions_exist(self):
|
|
48
|
+
"""Every file in kilo.json instructions exists."""
|
|
49
|
+
for ref in json.loads(read("kilo.json"))["instructions"]:
|
|
50
|
+
self.assertTrue((ROOT / ref).is_file(), f"kilo.json instructions missing: {ref}")
|
|
51
|
+
|
|
52
|
+
def test_junie_guidelines_location_exists(self):
|
|
53
|
+
"""`guidelines-location` resolves, relative to .junie/, to the generated file."""
|
|
54
|
+
loc = json.loads(read(".junie/config.json"))["guidelines-location"]
|
|
55
|
+
target = (ROOT / ".junie" / loc).resolve()
|
|
56
|
+
self.assertTrue(target.is_file(), f"junie guidelines-location missing: {loc}")
|
|
57
|
+
self.assertTrue(target.name.endswith(".generated.md"), target.name)
|
|
58
|
+
|
|
59
|
+
def test_generated_guidelines_in_sync(self):
|
|
60
|
+
"""agents_sync.py --check passes (generated file matches its sources)."""
|
|
61
|
+
proc = subprocess.run([sys.executable, str(ROOT / "scripts" / "agents_sync.py"), "--check"],
|
|
62
|
+
capture_output=True, text=True)
|
|
63
|
+
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SizeTests(unittest.TestCase):
|
|
67
|
+
def test_agents_md_is_slim(self):
|
|
68
|
+
"""AGENTS.md stays a dispatcher (<= 60 lines, excluding auto-generated gitnexus section)."""
|
|
69
|
+
content = read("AGENTS.md")
|
|
70
|
+
lines = content.splitlines()
|
|
71
|
+
filtered = []
|
|
72
|
+
in_gitnexus = False
|
|
73
|
+
for line in lines:
|
|
74
|
+
if "<!-- gitnexus:start -->" in line:
|
|
75
|
+
in_gitnexus = True
|
|
76
|
+
continue
|
|
77
|
+
if "<!-- gitnexus:end -->" in line:
|
|
78
|
+
in_gitnexus = False
|
|
79
|
+
continue
|
|
80
|
+
if not in_gitnexus:
|
|
81
|
+
filtered.append(line)
|
|
82
|
+
self.assertLessEqual(len(filtered), 60, "AGENTS.md is too long")
|
|
83
|
+
|
|
84
|
+
def test_common_files_within_budget(self):
|
|
85
|
+
"""No shared detail file grows past 80 lines."""
|
|
86
|
+
for path in (ROOT / "agents" / "common").glob("*.md"):
|
|
87
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
88
|
+
self.assertLessEqual(len(lines), 80, f"{path.name} too long: {len(lines)} lines")
|
|
89
|
+
|
|
90
|
+
def test_run_md_per_tool(self):
|
|
91
|
+
"""Each runner has a RUN.md."""
|
|
92
|
+
for tool in ("junie", "kilo", "codex"):
|
|
93
|
+
self.assertTrue((ROOT / "agents" / tool / "RUN.md").is_file(), f"agents/{tool}/RUN.md")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
unittest.main()
|