tackit 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read(//home/reedv/work/pinky/**)",
5
+ "Read(//home/reedv/.claude/projects/**)"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,19 @@
1
+ # Design/planning docs are kept LOCAL only — they reference private source
2
+ # projects (used as case-study material) and must not ship in this public repo.
3
+ docs/
4
+
5
+ # Python build / env artifacts
6
+ .venv/
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ build/
11
+ dist/
12
+ .pytest_cache/
13
+
14
+ # A tackit store dogfooded inside this repo (the binary db is local; if we ever
15
+ # track our own plan in tackit, tackit.sql would be committed and the db ignored).
16
+ .tackit/tackit.db
17
+ .tackit/tackit.db-wal
18
+ .tackit/tackit.db-shm
19
+ .tackit/backups/
tackit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 reedvoid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tackit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: tackit
3
+ Version: 0.1.0
4
+ Summary: A deterministic task + dependency tracker for coding agents (SQLite + Pydantic, MCP + CLI).
5
+ Project-URL: Homepage, https://github.com/reedvoid/tackit
6
+ Author: reedvoid
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: coding-agents,dependency-graph,mcp,sqlite,task-tracker
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: mcp<2,>=1.27.1
17
+ Requires-Dist: pydantic<3,>=2.13.4
18
+ Description-Content-Type: text/markdown
19
+
20
+ # tackit
21
+
22
+ A deterministic task + dependency tracker **for coding agents**. One local SQLite
23
+ file is the single source of truth for a project's build plan — its tasks, their
24
+ dependencies, and their reconciliation state. An agent fetches small *slices* on
25
+ demand instead of re-reading monolithic plan documents, so project truth survives
26
+ across sessions and context-window compaction, and a change to one task can be
27
+ traced to everything that depends on it.
28
+
29
+ Strict Pydantic validation at the boundary (malformed data is refused, not stored),
30
+ a manual dirty-propagation discipline (editing a task marks its dependents *stale*;
31
+ a stale task can't be closed until reconciled), and full-text search over tasks via
32
+ SQLite FTS5. Exposed two ways over one core: an **MCP server** (the agent's primary
33
+ door) and a **CLI** (debugging / scripting / fallback).
34
+
35
+ > **Status: alpha (0.1.0).** The data model, interfaces, and sync design are
36
+ > settled and implemented; expect rough edges.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install tackit && tackit setup
42
+ ```
43
+
44
+ `tackit setup` doesn't touch your config — it *emits* the post-install steps with
45
+ contextualized paths (the MCP registration snippet, where to drop the bundled
46
+ `SKILL.md`, and `tackit init`) for the driving agent to carry out.
47
+
48
+ ## Quickstart (CLI)
49
+
50
+ ```bash
51
+ tackit init # create .tackit/ in this project
52
+ tackit add "parse FTS5 query" --label search # create a task (D3)
53
+ tackit add "rank search results" --dep 1 # task 2 depends_on task 1
54
+ tackit search "fts" # ranked keyword search (D17)
55
+ tackit show 2 # slice: task + deps + dependents
56
+ tackit edit 1 --desc "tokenized MATCH" # edits stale task 1's dependents
57
+ tackit stale # the reconciliation worklist
58
+ tackit reconcile 2 # reviewed-OK; clear stale
59
+ tackit close 2 # refused while stale (D14)
60
+ tackit ls --status open # query/board (D15)
61
+ tackit --help # full, self-documenting surface
62
+ ```
63
+
64
+ The store lives at `.tackit/tackit.db` (binary, gitignored). Its git-canonical form
65
+ is a deterministic SQL text dump, `.tackit/tackit.sql`, re-written on every mutation
66
+ and committed — so diffs and merges are reviewable text, never a binary blob. Sync
67
+ between the two is automatic; `tackit status` / `export` / `import` / `restore`
68
+ exist only for the divergence cases the auto-sync deliberately refuses to guess at.
69
+
70
+ ## MCP
71
+
72
+ ```bash
73
+ tackit mcp # serve the stdio MCP server (the agent's primary door)
74
+ ```
75
+
76
+ Tool names are the bare verbs (`add`, `show`, `search`, `edit`, `close`,
77
+ `reconcile`, `dep_add`, …); their input schemas are generated from the Python type
78
+ hints, so they can't drift from the real interface. Each mutating tool returns the
79
+ agent's review obligations in its result.
80
+
81
+ ## License
82
+
83
+ MIT
tackit-0.1.0/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # tackit
2
+
3
+ A deterministic task + dependency tracker **for coding agents**. One local SQLite
4
+ file is the single source of truth for a project's build plan — its tasks, their
5
+ dependencies, and their reconciliation state. An agent fetches small *slices* on
6
+ demand instead of re-reading monolithic plan documents, so project truth survives
7
+ across sessions and context-window compaction, and a change to one task can be
8
+ traced to everything that depends on it.
9
+
10
+ Strict Pydantic validation at the boundary (malformed data is refused, not stored),
11
+ a manual dirty-propagation discipline (editing a task marks its dependents *stale*;
12
+ a stale task can't be closed until reconciled), and full-text search over tasks via
13
+ SQLite FTS5. Exposed two ways over one core: an **MCP server** (the agent's primary
14
+ door) and a **CLI** (debugging / scripting / fallback).
15
+
16
+ > **Status: alpha (0.1.0).** The data model, interfaces, and sync design are
17
+ > settled and implemented; expect rough edges.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install tackit && tackit setup
23
+ ```
24
+
25
+ `tackit setup` doesn't touch your config — it *emits* the post-install steps with
26
+ contextualized paths (the MCP registration snippet, where to drop the bundled
27
+ `SKILL.md`, and `tackit init`) for the driving agent to carry out.
28
+
29
+ ## Quickstart (CLI)
30
+
31
+ ```bash
32
+ tackit init # create .tackit/ in this project
33
+ tackit add "parse FTS5 query" --label search # create a task (D3)
34
+ tackit add "rank search results" --dep 1 # task 2 depends_on task 1
35
+ tackit search "fts" # ranked keyword search (D17)
36
+ tackit show 2 # slice: task + deps + dependents
37
+ tackit edit 1 --desc "tokenized MATCH" # edits stale task 1's dependents
38
+ tackit stale # the reconciliation worklist
39
+ tackit reconcile 2 # reviewed-OK; clear stale
40
+ tackit close 2 # refused while stale (D14)
41
+ tackit ls --status open # query/board (D15)
42
+ tackit --help # full, self-documenting surface
43
+ ```
44
+
45
+ The store lives at `.tackit/tackit.db` (binary, gitignored). Its git-canonical form
46
+ is a deterministic SQL text dump, `.tackit/tackit.sql`, re-written on every mutation
47
+ and committed — so diffs and merges are reviewable text, never a binary blob. Sync
48
+ between the two is automatic; `tackit status` / `export` / `import` / `restore`
49
+ exist only for the divergence cases the auto-sync deliberately refuses to guess at.
50
+
51
+ ## MCP
52
+
53
+ ```bash
54
+ tackit mcp # serve the stdio MCP server (the agent's primary door)
55
+ ```
56
+
57
+ Tool names are the bare verbs (`add`, `show`, `search`, `edit`, `close`,
58
+ `reconcile`, `dep_add`, …); their input schemas are generated from the Python type
59
+ hints, so they can't drift from the real interface. Each mutating tool returns the
60
+ agent's review obligations in its result.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tackit"
7
+ version = "0.1.0"
8
+ description = "A deterministic task + dependency tracker for coding agents (SQLite + Pydantic, MCP + CLI)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "reedvoid" }]
12
+ license = "MIT"
13
+ license-files = ["LICEN[CS]E*"]
14
+ keywords = ["task-tracker", "coding-agents", "mcp", "sqlite", "dependency-graph"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Software Development",
21
+ ]
22
+ # Resolved 2026-05-30 (most-recent-stable-published->=2-weeks-ago rule):
23
+ # pydantic 2.13.4 (2026-05-06), mcp 1.27.1 (2026-05-08).
24
+ # mcp 1.27.2 (2026-05-29) was skipped as too fresh; floor at the known-good versions.
25
+ dependencies = [
26
+ "pydantic>=2.13.4,<3",
27
+ "mcp>=1.27.1,<2",
28
+ ]
29
+
30
+ [project.scripts]
31
+ # One console entry point. With no args / a CLI verb -> CLI (design.md "Interface
32
+ # - CLI"). With the `mcp` subcommand -> the stdio MCP server (design.md "Interface
33
+ # - MCP"); the same entry point launches both, per the Installation section.
34
+ tackit = "tackit.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/reedvoid/tackit"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/tackit"]
41
+ # Ship the cross-agent SKILL.md inside the package so `tackit setup` can locate and
42
+ # copy it (design.md "Interface - Skill"; skill.md).
43
+ artifacts = ["src/tackit/data/SKILL.md"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -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"
@@ -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())