tasks-toolkit 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/agents_sync.py +61 -0
  3. scripts/apply-plan.sh +51 -0
  4. scripts/build.sh +7 -0
  5. scripts/codex-task.sh +198 -0
  6. scripts/install-hooks.sh +9 -0
  7. scripts/junie-status.sh +52 -0
  8. scripts/junie-task.sh +183 -0
  9. scripts/kilo-task.sh +226 -0
  10. scripts/needs-dotnet-checks.sh +24 -0
  11. scripts/plan-sprint.sh +92 -0
  12. scripts/run-task.sh +79 -0
  13. scripts/run.sh +7 -0
  14. scripts/run_stats.py +141 -0
  15. scripts/run_summary.py +94 -0
  16. scripts/spec-lint.sh +139 -0
  17. scripts/sprint-auto.sh +79 -0
  18. scripts/sprint_auto_execute.py +160 -0
  19. scripts/sprint_auto_preflight.py +309 -0
  20. scripts/sprint_auto_prepare.py +271 -0
  21. scripts/sprint_auto_resume.py +153 -0
  22. scripts/sprint_auto_state.py +191 -0
  23. scripts/sprint_plan.py +238 -0
  24. scripts/task-report.sh +45 -0
  25. scripts/tasks.py +1813 -0
  26. scripts/tasks_cli.py +14 -0
  27. scripts/tasks_commands.py +11 -0
  28. scripts/tasks_init.py +140 -0
  29. scripts/tasks_mcp.py +309 -0
  30. scripts/tasks_model.py +173 -0
  31. scripts/tasks_plan_import.py +269 -0
  32. scripts/tasks_render.py +148 -0
  33. scripts/tasks_storage.py +373 -0
  34. scripts/tasks_watch.py +159 -0
  35. scripts/test.sh +43 -0
  36. scripts/test_agent_config.py +97 -0
  37. scripts/test_plan_sprint.sh +172 -0
  38. scripts/test_python_fast.sh +26 -0
  39. scripts/test_python_lint.sh +22 -0
  40. scripts/test_python_slow.sh +14 -0
  41. scripts/test_run_stats.py +64 -0
  42. scripts/test_runners.sh +588 -0
  43. scripts/test_spec_lint.sh +438 -0
  44. scripts/test_sprint_auto.py +50 -0
  45. scripts/test_sprint_auto_execute.py +112 -0
  46. scripts/test_sprint_auto_preflight.py +143 -0
  47. scripts/test_sprint_auto_prepare.py +150 -0
  48. scripts/test_sprint_auto_resume.py +104 -0
  49. scripts/test_sprint_auto_state.py +157 -0
  50. scripts/test_tasks.py +1678 -0
  51. scripts/test_tasks_mcp.py +233 -0
  52. scripts/test_tasks_plan_import.py +129 -0
  53. scripts/test_tasks_storage.py +67 -0
  54. scripts/test_tasks_watch.py +239 -0
  55. scripts/test_worktree.sh +2112 -0
  56. scripts/worktree.sh +992 -0
  57. scripts/worktree_mr.py +133 -0
  58. scripts/worktree_path.py +70 -0
  59. scripts/worktree_sprint.py +70 -0
  60. tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
  61. tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
  62. tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
  63. tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
  64. tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
  65. tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
@@ -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()