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 +19 -0
- tackit/cli.py +415 -0
- tackit/core.py +477 -0
- tackit/data/SKILL.md +75 -0
- tackit/db.py +132 -0
- tackit/errors.py +33 -0
- tackit/mcp_server.py +151 -0
- tackit/models.py +113 -0
- tackit/schema.py +112 -0
- tackit/setup_cmd.py +71 -0
- tackit/sync.py +364 -0
- tackit-0.1.0.dist-info/METADATA +83 -0
- tackit-0.1.0.dist-info/RECORD +16 -0
- tackit-0.1.0.dist-info/WHEEL +4 -0
- tackit-0.1.0.dist-info/entry_points.txt +2 -0
- tackit-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|