tackit 0.1.0__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.
tackit/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """tackit - a deterministic task + dependency tracker for coding agents.
2
+
3
+ Authoritative design lives in ``docs/plan/`` (local, gitignored):
4
+ ``design.md`` (slices D1-D18) and ``schema.md`` (tables S1-S6). Code in this
5
+ package is tagged with those ids so any line can be traced back to the slice or
6
+ table it implements -- grep ``D12`` or ``S3`` to find the relevant code.
7
+
8
+ Module -> design map:
9
+ errors.py fail-loud exception hierarchy (D2, D14)
10
+ models.py D2 typed validation boundary (Pydantic models)
11
+ schema.py S1-S6 SQLite DDL + FTS5 triggers
12
+ db.py D1 persistent WAL store + path discovery
13
+ core.py D3-D17 operations (the single determinism home)
14
+ sync.py D18 git-text serialization + safe DB<->SQL sync
15
+ cli.py CLI adapter (thin) + `tackit setup`/`mcp` entry points
16
+ mcp_server.py MCP stdio adapter (thin; schema auto-generated from type hints)
17
+ """
18
+
19
+ __version__ = "0.1.0"
tackit/cli.py ADDED
@@ -0,0 +1,415 @@
1
+ """Interface - CLI (design.md "Interface - CLI").
2
+
3
+ A thin adapter over :mod:`tackit.core` exposing the same operation surface as a
4
+ command line, for debugging / scripting / agent-fallback (the agent's default
5
+ door is MCP). No logic lives here. Output is verbose natural-language-with-ids by
6
+ default, ``--json`` for structured parsing; every mutating op prints its
7
+ obligations inline (same payload as the MCP results). The command<->slice mapping
8
+ matches the table in design.md.
9
+
10
+ This module is also the single ``[project.scripts]`` entry point: ``tackit mcp``
11
+ launches the stdio MCP server (design.md "Installation"); ``tackit setup`` emits
12
+ the post-install steps (agent-driven install).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import sync
23
+ from .core import Core
24
+ from .db import init_store, require_store
25
+ from .errors import TackitError
26
+
27
+
28
+ # --- human-readable formatters (──> CLI default output) ---------------------
29
+
30
+ def _flags(status: str, stale: bool) -> str:
31
+ return f"{status}, STALE" if stale else status
32
+
33
+
34
+ def _fmt_task(t) -> str:
35
+ return f"T{t.id} [{_flags(t.status, t.stale)}] {t.name}"
36
+
37
+
38
+ def _fmt_neighbors(label: str, neighbors) -> list[str]:
39
+ if not neighbors:
40
+ return [f" {label}: (none)"]
41
+ return [f" {label}:"] + [
42
+ f" - T{n.id} [{_flags(n.status, n.stale)}] {n.name}" for n in neighbors
43
+ ]
44
+
45
+
46
+ def _fmt_slice(s) -> str:
47
+ lines = [_fmt_task(s.task)]
48
+ if s.task.description.strip():
49
+ lines.append(f" {s.task.description.strip()}")
50
+ lines.append(f" labels: {', '.join(s.labels) if s.labels else '(none)'}")
51
+ lines += _fmt_neighbors("depends on", s.dependencies)
52
+ lines += _fmt_neighbors("depended on by", s.dependents)
53
+ return "\n".join(lines)
54
+
55
+
56
+ def _emit(obj_text: str, json_obj, as_json: bool) -> None:
57
+ if as_json:
58
+ print(json.dumps(json_obj, default=str, indent=2))
59
+ else:
60
+ print(obj_text)
61
+
62
+
63
+ def _dump(model):
64
+ return model.model_dump(mode="json")
65
+
66
+
67
+ # --- command handlers -------------------------------------------------------
68
+
69
+ def _cmd_init(args) -> int:
70
+ store = init_store(Path.cwd())
71
+ _emit(
72
+ f"Initialized tackit store at {store.dir} "
73
+ f"(db gitignored; tackit.sql is the committed source of truth).",
74
+ {"root": str(store.root), "dir": str(store.dir)},
75
+ args.json,
76
+ )
77
+ return 0
78
+
79
+
80
+ def _cmd_add(args) -> int:
81
+ core = Core.open()
82
+ try:
83
+ task = core.add(args.name, description=args.desc or "", labels=args.label, deps=args.dep)
84
+ _emit("created " + _fmt_slice(core.show(task.id)), _dump(core.show(task.id)), args.json)
85
+ finally:
86
+ core.close_conn()
87
+ return 0
88
+
89
+
90
+ def _cmd_show(args) -> int:
91
+ core = Core.open()
92
+ try:
93
+ s = core.show(args.id)
94
+ _emit(_fmt_slice(s), _dump(s), args.json)
95
+ finally:
96
+ core.close_conn()
97
+ return 0
98
+
99
+
100
+ def _cmd_search(args) -> int:
101
+ core = Core.open()
102
+ try:
103
+ hits = core.search(args.terms)
104
+ text = (
105
+ "\n".join(f"T{h.id} ({h.score:+.3f}) {h.name}" for h in hits)
106
+ if hits
107
+ else "(no matches)"
108
+ )
109
+ _emit(text, [_dump(h) for h in hits], args.json)
110
+ finally:
111
+ core.close_conn()
112
+ return 0
113
+
114
+
115
+ def _cmd_edit(args) -> int:
116
+ core = Core.open()
117
+ try:
118
+ result = core.edit(args.id, name=args.name, description=args.desc)
119
+ text = ["edited " + _fmt_task(result.task)]
120
+ if result.newly_stale:
121
+ text.append(" ⚠ now STALE (review/reconcile these dependents):")
122
+ text += [f" - T{n.id} {n.name}" for n in result.newly_stale]
123
+ else:
124
+ text.append(" no dependents to review.")
125
+ _emit("\n".join(text), _dump(result), args.json)
126
+ finally:
127
+ core.close_conn()
128
+ return 0
129
+
130
+
131
+ def _cmd_close(args) -> int:
132
+ core = Core.open()
133
+ try:
134
+ result = core.close(args.id)
135
+ text = ["closed " + _fmt_task(result.task), " review obligations (one hop):"]
136
+ text += _fmt_neighbors("depends on", result.dependencies)
137
+ text += _fmt_neighbors("depended on by", result.dependents)
138
+ _emit("\n".join(text), _dump(result), args.json)
139
+ finally:
140
+ core.close_conn()
141
+ return 0
142
+
143
+
144
+ def _cmd_reopen(args) -> int:
145
+ core = Core.open()
146
+ try:
147
+ t = core.reopen(args.id)
148
+ _emit("reopened " + _fmt_task(t), _dump(t), args.json)
149
+ finally:
150
+ core.close_conn()
151
+ return 0
152
+
153
+
154
+ def _cmd_reconcile(args) -> int:
155
+ core = Core.open()
156
+ try:
157
+ t = core.reconcile(args.id)
158
+ _emit("reconciled (stale cleared) " + _fmt_task(t), _dump(t), args.json)
159
+ finally:
160
+ core.close_conn()
161
+ return 0
162
+
163
+
164
+ def _cmd_dep(args) -> int:
165
+ core = Core.open()
166
+ try:
167
+ if args.dep_action == "add":
168
+ s = core.dep_add(args.from_task, args.to_task)
169
+ verb = "added"
170
+ else:
171
+ s = core.dep_rm(args.from_task, args.to_task)
172
+ verb = "removed"
173
+ _emit(
174
+ f"{verb} edge T{args.from_task} depends_on T{args.to_task}\n" + _fmt_slice(s),
175
+ _dump(s),
176
+ args.json,
177
+ )
178
+ finally:
179
+ core.close_conn()
180
+ return 0
181
+
182
+
183
+ def _cmd_label(args) -> int:
184
+ core = Core.open()
185
+ try:
186
+ if args.label_action == "add":
187
+ t = core.label_add(args.id, args.label)
188
+ verb = "added"
189
+ else:
190
+ t = core.label_rm(args.id, args.label)
191
+ verb = "removed"
192
+ _emit(f"{verb} label '{args.label}' on " + _fmt_task(t), _dump(t), args.json)
193
+ finally:
194
+ core.close_conn()
195
+ return 0
196
+
197
+
198
+ def _cmd_ls(args) -> int:
199
+ core = Core.open()
200
+ try:
201
+ stale = True if args.stale else None
202
+ tasks = core.ls(status=args.status, label=args.label, stale=stale)
203
+ text = "\n".join(_fmt_task(t) for t in tasks) if tasks else "(no matching tasks)"
204
+ _emit(text, [_dump(t) for t in tasks], args.json)
205
+ finally:
206
+ core.close_conn()
207
+ return 0
208
+
209
+
210
+ def _cmd_stale(args) -> int:
211
+ core = Core.open()
212
+ try:
213
+ tasks = core.stale_worklist()
214
+ if tasks:
215
+ text = "stale worklist (reconcile each, then it's empty):\n" + "\n".join(
216
+ _fmt_task(t) for t in tasks
217
+ )
218
+ else:
219
+ text = "stale worklist empty — reconciliation complete."
220
+ _emit(text, [_dump(t) for t in tasks], args.json)
221
+ finally:
222
+ core.close_conn()
223
+ return 0
224
+
225
+
226
+ def _cmd_render(args) -> int:
227
+ core = Core.open()
228
+ try:
229
+ md = core.render(args.label)
230
+ _emit(md, {"label": args.label, "markdown": md}, args.json)
231
+ finally:
232
+ core.close_conn()
233
+ return 0
234
+
235
+
236
+ def _cmd_history(args) -> int:
237
+ core = Core.open()
238
+ try:
239
+ rows = core.history(args.id)
240
+ text = "\n".join(
241
+ f" {r.changed_at} {r.from_status or '(new)'} -> {r.to_status}" for r in rows
242
+ )
243
+ _emit(f"status history of T{args.id}:\n{text}", [_dump(r) for r in rows], args.json)
244
+ finally:
245
+ core.close_conn()
246
+ return 0
247
+
248
+
249
+ # --- D18 sync-management commands (bypass auto startup_sync) -----------------
250
+
251
+ def _cmd_status(args) -> int:
252
+ info = sync.status(require_store())
253
+ _emit(json.dumps(info, indent=2, default=str), info, args.json)
254
+ return 0
255
+
256
+
257
+ def _cmd_export(args) -> int:
258
+ store = require_store()
259
+ v = sync.export(store)
260
+ _emit(f"exported db -> {store.sql_path} (version {v}).", {"version": v}, args.json)
261
+ return 0
262
+
263
+
264
+ def _cmd_import(args) -> int:
265
+ store = require_store()
266
+ msg = sync.import_sql(store, force=args.force)
267
+ _emit(msg, {"message": msg}, args.json)
268
+ return 0
269
+
270
+
271
+ def _cmd_restore(args) -> int:
272
+ store = require_store()
273
+ backups = sync.list_backups(store)
274
+ if args.list or not args.backup:
275
+ if not backups:
276
+ _emit("(no backups)", [], args.json)
277
+ return 0
278
+ text = "\n".join(f"[{i}] {p.name}" for i, p in enumerate(backups))
279
+ _emit("available backups:\n" + text, [p.name for p in backups], args.json)
280
+ return 0
281
+ # --backup accepts an index into the list or a filename
282
+ chosen = None
283
+ if args.backup.isdigit() and int(args.backup) < len(backups):
284
+ chosen = backups[int(args.backup)]
285
+ else:
286
+ for p in backups:
287
+ if p.name == args.backup:
288
+ chosen = p
289
+ break
290
+ if chosen is None:
291
+ _emit(f"no such backup: {args.backup}", {"error": "not found"}, args.json)
292
+ return 1
293
+ msg = sync.restore(store, chosen)
294
+ _emit(msg, {"message": msg}, args.json)
295
+ return 0
296
+
297
+
298
+ def _cmd_setup(args) -> int:
299
+ from .setup_cmd import render_setup
300
+
301
+ print(render_setup(Path.cwd()))
302
+ return 0
303
+
304
+
305
+ def _cmd_mcp(args) -> int:
306
+ from .mcp_server import run
307
+
308
+ run() # blocks serving stdio
309
+ return 0
310
+
311
+
312
+ # --- argument parser (self-documenting via --help, design.md) ----------------
313
+
314
+ def build_parser() -> argparse.ArgumentParser:
315
+ common = argparse.ArgumentParser(add_help=False)
316
+ common.add_argument("--json", action="store_true", help="emit structured JSON output")
317
+
318
+ p = argparse.ArgumentParser(
319
+ prog="tackit",
320
+ description="Deterministic task + dependency tracker for coding agents. "
321
+ "Each command maps to a design slice (D#); see docs/plan/design.md.",
322
+ parents=[common],
323
+ )
324
+ sub = p.add_subparsers(dest="command", required=True)
325
+
326
+ def add(name, handler, help_text, parents=(common,)):
327
+ sp = sub.add_parser(name, help=help_text, parents=list(parents))
328
+ sp.set_defaults(func=handler)
329
+ return sp
330
+
331
+ add("init", _cmd_init, "create DB/schema + gitignore the .db (D1)")
332
+
333
+ sp = add("add", _cmd_add, "create a task (D3)")
334
+ sp.add_argument("name")
335
+ sp.add_argument("--desc", default="", help="task description/body")
336
+ sp.add_argument("--label", action="append", default=[], help="attach a label (repeatable, D4)")
337
+ sp.add_argument("--dep", action="append", type=int, default=[], help="depends_on this id (repeatable, D5)")
338
+
339
+ sp = add("search", _cmd_search, "ranked FTS keyword search -> ids (D17)")
340
+ sp.add_argument("terms")
341
+
342
+ sp = add("show", _cmd_show, "slice fetch: task + deps + dependents + labels (D9)")
343
+ sp.add_argument("id", type=int)
344
+
345
+ sp = add("edit", _cmd_edit, "change a task -> stale its dependents (D13/D10)")
346
+ sp.add_argument("id", type=int)
347
+ sp.add_argument("--name")
348
+ sp.add_argument("--desc")
349
+
350
+ sp = add("close", _cmd_close, "close (refused if stale) + print neighbors (D12/D14)")
351
+ sp.add_argument("id", type=int)
352
+
353
+ sp = add("reopen", _cmd_reopen, "closed -> open, logged (D7/D8)")
354
+ sp.add_argument("id", type=int)
355
+
356
+ sp = add("reconcile", _cmd_reconcile, "clear stale without changing (reviewed-OK, D11)")
357
+ sp.add_argument("id", type=int)
358
+
359
+ sp = add("dep", _cmd_dep, "add/remove a depends_on edge (D5)")
360
+ dep_sub = sp.add_subparsers(dest="dep_action", required=True)
361
+ for act in ("add", "rm"):
362
+ dsp = dep_sub.add_parser(act, parents=[common])
363
+ dsp.add_argument("from_task", type=int, metavar="A")
364
+ dsp.add_argument("to_task", type=int, metavar="B")
365
+ dsp.set_defaults(func=_cmd_dep)
366
+
367
+ sp = add("label", _cmd_label, "tag/untag a task (D4)")
368
+ label_sub = sp.add_subparsers(dest="label_action", required=True)
369
+ for act in ("add", "rm"):
370
+ lsp = label_sub.add_parser(act, parents=[common])
371
+ lsp.add_argument("id", type=int)
372
+ lsp.add_argument("label")
373
+ lsp.set_defaults(func=_cmd_label)
374
+
375
+ sp = add("ls", _cmd_ls, "query/board: filter by status/label/stale (D15)")
376
+ sp.add_argument("--status", choices=["open", "closed"])
377
+ sp.add_argument("--label")
378
+ sp.add_argument("--stale", action="store_true", help="only stale tasks")
379
+
380
+ add("stale", _cmd_stale, "reconciliation worklist: all stale tasks (D11)")
381
+
382
+ sp = add("render", _cmd_render, "narrative render of a label -> markdown (D16)")
383
+ sp.add_argument("--label", required=True)
384
+
385
+ sp = add("history", _cmd_history, "status transition history of a task (D8)")
386
+ sp.add_argument("id", type=int)
387
+
388
+ add("status", _cmd_status, "db version vs tackit.sql + sync verdict (D18)")
389
+ add("export", _cmd_export, "force-dump .db -> tackit.sql (D18)")
390
+
391
+ sp = add("import", _cmd_import, "adopt tackit.sql (backup + rebuild .db) (D18)")
392
+ sp.add_argument("--force", action="store_true", help="adopt even if local db is newer")
393
+
394
+ sp = add("restore", _cmd_restore, "restore .db from a rotating backup (D18)")
395
+ sp.add_argument("--list", action="store_true", help="list available backups")
396
+ sp.add_argument("--backup", help="backup index or filename to restore")
397
+
398
+ add("setup", _cmd_setup, "emit post-install steps (agent-driven install)")
399
+ add("mcp", _cmd_mcp, "launch the stdio MCP server (agent's primary door)")
400
+ return p
401
+
402
+
403
+ def main(argv=None) -> int:
404
+ parser = build_parser()
405
+ args = parser.parse_args(argv)
406
+ try:
407
+ return args.func(args)
408
+ except TackitError as exc:
409
+ # design.md "Fail loud": surface the refusal cleanly, non-zero exit.
410
+ print(f"tackit: {exc}", file=sys.stderr)
411
+ return 1
412
+
413
+
414
+ if __name__ == "__main__":
415
+ sys.exit(main())