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.
- tackit-0.1.0/.claude/settings.local.json +8 -0
- tackit-0.1.0/.gitignore +19 -0
- tackit-0.1.0/LICENSE +21 -0
- tackit-0.1.0/PKG-INFO +83 -0
- tackit-0.1.0/README.md +64 -0
- tackit-0.1.0/pyproject.toml +46 -0
- tackit-0.1.0/src/tackit/__init__.py +19 -0
- tackit-0.1.0/src/tackit/cli.py +415 -0
- tackit-0.1.0/src/tackit/core.py +477 -0
- tackit-0.1.0/src/tackit/data/SKILL.md +75 -0
- tackit-0.1.0/src/tackit/db.py +132 -0
- tackit-0.1.0/src/tackit/errors.py +33 -0
- tackit-0.1.0/src/tackit/mcp_server.py +151 -0
- tackit-0.1.0/src/tackit/models.py +113 -0
- tackit-0.1.0/src/tackit/schema.py +112 -0
- tackit-0.1.0/src/tackit/setup_cmd.py +71 -0
- tackit-0.1.0/src/tackit/sync.py +364 -0
- tackit-0.1.0/tests/test_tackit.py +281 -0
tackit-0.1.0/.gitignore
ADDED
|
@@ -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())
|