tractable 1.0.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.
- tractable/__init__.py +64 -0
- tractable/cli.py +395 -0
- tractable/errors.py +33 -0
- tractable/git_backend.py +977 -0
- tractable/git_store.py +834 -0
- tractable/mcp/README.md +57 -0
- tractable/mcp/__init__.py +19 -0
- tractable/mcp/__main__.py +10 -0
- tractable/mcp/identity.py +138 -0
- tractable/mcp/instructions.py +143 -0
- tractable/mcp/resources.py +114 -0
- tractable/mcp/server.py +202 -0
- tractable/mcp/target.py +63 -0
- tractable/mcp/tools.py +572 -0
- tractable/migrate.py +280 -0
- tractable/service.py +1737 -0
- tractable/store.py +2970 -0
- tractable-1.0.0.dist-info/METADATA +306 -0
- tractable-1.0.0.dist-info/RECORD +22 -0
- tractable-1.0.0.dist-info/WHEEL +4 -0
- tractable-1.0.0.dist-info/entry_points.txt +3 -0
- tractable-1.0.0.dist-info/licenses/LICENSE +21 -0
tractable/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Public API for the JSON/Git TODO tracker."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from . import store
|
|
6
|
+
from .errors import (
|
|
7
|
+
E_ACTIVE_CLAIMS,
|
|
8
|
+
E_BATCH_HEAD,
|
|
9
|
+
E_BATCH_OWNER,
|
|
10
|
+
E_CLAIM_STALE,
|
|
11
|
+
E_CONFLICT,
|
|
12
|
+
E_CURSOR_STALE,
|
|
13
|
+
E_MULTIPLE_CLAIMS,
|
|
14
|
+
E_NOTHING_READY,
|
|
15
|
+
E_NO_PRINCIPAL,
|
|
16
|
+
E_OFFLINE,
|
|
17
|
+
E_OPEN_DEFERRALS,
|
|
18
|
+
E_OUTPUT_TRUNCATED,
|
|
19
|
+
E_OVERSIZED,
|
|
20
|
+
E_SCHEMA,
|
|
21
|
+
E_STATE,
|
|
22
|
+
E_UNKNOWN,
|
|
23
|
+
TractableError,
|
|
24
|
+
TodoError,
|
|
25
|
+
)
|
|
26
|
+
from .git_backend import ReconcileResult, StateRef, bootstrap, history, mutate, new_op_id, read, reconcile
|
|
27
|
+
from .service import TrackerService, count_tokens
|
|
28
|
+
|
|
29
|
+
TOOL_VERSION = version("tractable")
|
|
30
|
+
BATCH_CAPABILITY = store.batch_capability(TOOL_VERSION)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"E_ACTIVE_CLAIMS",
|
|
34
|
+
"E_BATCH_HEAD",
|
|
35
|
+
"E_BATCH_OWNER",
|
|
36
|
+
"E_CLAIM_STALE",
|
|
37
|
+
"E_CONFLICT",
|
|
38
|
+
"E_CURSOR_STALE",
|
|
39
|
+
"E_MULTIPLE_CLAIMS",
|
|
40
|
+
"E_NOTHING_READY",
|
|
41
|
+
"E_NO_PRINCIPAL",
|
|
42
|
+
"E_OFFLINE",
|
|
43
|
+
"E_OPEN_DEFERRALS",
|
|
44
|
+
"E_OUTPUT_TRUNCATED",
|
|
45
|
+
"E_OVERSIZED",
|
|
46
|
+
"E_SCHEMA",
|
|
47
|
+
"E_STATE",
|
|
48
|
+
"E_UNKNOWN",
|
|
49
|
+
"BATCH_CAPABILITY",
|
|
50
|
+
"TOOL_VERSION",
|
|
51
|
+
"ReconcileResult",
|
|
52
|
+
"StateRef",
|
|
53
|
+
"TractableError",
|
|
54
|
+
"TodoError",
|
|
55
|
+
"TrackerService",
|
|
56
|
+
"bootstrap",
|
|
57
|
+
"count_tokens",
|
|
58
|
+
"history",
|
|
59
|
+
"mutate",
|
|
60
|
+
"new_op_id",
|
|
61
|
+
"read",
|
|
62
|
+
"reconcile",
|
|
63
|
+
"store",
|
|
64
|
+
]
|
tractable/cli.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Minimal human/CI command line for the JSON/Git TODO tracker.
|
|
2
|
+
|
|
3
|
+
Bootstrap, validation, migration, and recovery live here. Agent planning
|
|
4
|
+
verbs (create/take/finish/...) are MCP-only by design: there is no
|
|
5
|
+
duplicate agent CLI. ``list``/``show``/``batch show`` are read-only aids.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from . import TOOL_VERSION, git_backend
|
|
20
|
+
from . import store as S
|
|
21
|
+
from .errors import TractableError
|
|
22
|
+
from .migrate import migrate_file
|
|
23
|
+
from .service import TrackerService
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_ERROR = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _emit(payload: Any) -> None:
|
|
30
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fail(message: str) -> int:
|
|
34
|
+
print(f"error: {message}", file=sys.stderr)
|
|
35
|
+
return EXIT_ERROR
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ref_from(args: argparse.Namespace) -> tuple[git_backend.StateRef, Path | None]:
|
|
39
|
+
try:
|
|
40
|
+
return git_backend.resolve_ref(
|
|
41
|
+
remote=args.state_remote,
|
|
42
|
+
branch=args.state_branch,
|
|
43
|
+
config=args.config,
|
|
44
|
+
repo_root=args.repo_root,
|
|
45
|
+
)
|
|
46
|
+
except TractableError as exc:
|
|
47
|
+
raise exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _worker(args: argparse.Namespace) -> str:
|
|
51
|
+
return args.actor or os.environ.get("TRACTABLE_ACTOR") or "human"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _session_id(args: argparse.Namespace) -> str:
|
|
55
|
+
"""Resolve the CLI session: flag, environment, or an ephemeral ID.
|
|
56
|
+
|
|
57
|
+
Mutations from one CLI invocation belong to one session. An explicit
|
|
58
|
+
``--session`` / ``TRACTABLE_SESSION`` value lets a caller resume it;
|
|
59
|
+
otherwise a fresh per-invocation ID leaves no unattributed gaps in
|
|
60
|
+
session history.
|
|
61
|
+
"""
|
|
62
|
+
explicit = getattr(args, "session", None) or os.environ.get("TRACTABLE_SESSION")
|
|
63
|
+
if explicit and str(explicit).strip():
|
|
64
|
+
# Fail closed: a forged session would land in commit trailers.
|
|
65
|
+
return S.validate_session_id(str(explicit).strip())
|
|
66
|
+
return uuid4().hex
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cache(args: argparse.Namespace) -> Path:
|
|
70
|
+
if getattr(args, "cache_dir", None):
|
|
71
|
+
return Path(args.cache_dir).expanduser()
|
|
72
|
+
return git_backend.default_cache_dir()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _preflight_write_config(args: argparse.Namespace, ref: git_backend.StateRef) -> Path | None:
|
|
76
|
+
"""Decide the config write before any remote mutation, so a config
|
|
77
|
+
refusal can never strand a freshly created branch."""
|
|
78
|
+
if not args.write_config:
|
|
79
|
+
return None
|
|
80
|
+
root = Path(args.repo_root).expanduser().resolve() if args.repo_root else Path.cwd().resolve()
|
|
81
|
+
parent = root / git_backend.CONFIG_DIRNAME
|
|
82
|
+
# A non-directory at the config location (or an unwritable tree) must
|
|
83
|
+
# refuse here, before the branch is created — never strand state.
|
|
84
|
+
if parent.exists() and not parent.is_dir():
|
|
85
|
+
raise TractableError(f"cannot write config: {parent} exists and is not a directory")
|
|
86
|
+
path = parent / git_backend.CONFIG_FILENAME
|
|
87
|
+
payload = {"state_remote": ref.remote, "state_branch": ref.branch}
|
|
88
|
+
if path.is_file():
|
|
89
|
+
try:
|
|
90
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
91
|
+
except ValueError:
|
|
92
|
+
existing = None
|
|
93
|
+
if existing != payload:
|
|
94
|
+
raise TractableError(f"refusing to overwrite differing config at {path}")
|
|
95
|
+
return None
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_bootstrap(args: argparse.Namespace) -> int:
|
|
100
|
+
try:
|
|
101
|
+
ref, _ = _ref_from(args)
|
|
102
|
+
config_path = _preflight_write_config(args, ref)
|
|
103
|
+
except TractableError as exc:
|
|
104
|
+
# Bootstrap may be the very first command: allow explicit remote
|
|
105
|
+
# without a config file even when discovery finds nothing.
|
|
106
|
+
return _fail(str(exc))
|
|
107
|
+
try:
|
|
108
|
+
sha = git_backend.bootstrap(ref, worker=_worker(args), session=_session_id(args))
|
|
109
|
+
except TractableError as exc:
|
|
110
|
+
return _fail(str(exc))
|
|
111
|
+
if config_path is not None:
|
|
112
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
config_path.write_text(
|
|
114
|
+
json.dumps({"state_remote": ref.remote, "state_branch": ref.branch}, indent=2, sort_keys=True) + "\n",
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
_emit({"rev": sha, "remote": ref.display_remote, "branch": ref.branch})
|
|
118
|
+
return EXIT_OK
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
122
|
+
try:
|
|
123
|
+
ref, _ = _ref_from(args)
|
|
124
|
+
outcome = git_backend.read(ref, _cache(args))
|
|
125
|
+
except TractableError as exc:
|
|
126
|
+
return _fail(str(exc))
|
|
127
|
+
now = datetime.now(timezone.utc)
|
|
128
|
+
by_status: dict[str, int] = {}
|
|
129
|
+
live = 0
|
|
130
|
+
for entry in outcome.snapshot.index["items"].values():
|
|
131
|
+
by_status[entry["status"]] = by_status.get(entry["status"], 0) + 1
|
|
132
|
+
if S.claim_is_live(entry.get("claim"), now):
|
|
133
|
+
live += 1
|
|
134
|
+
_emit({
|
|
135
|
+
"rev": outcome.rev,
|
|
136
|
+
"stale": outcome.stale,
|
|
137
|
+
"items": len(outcome.snapshot.index["items"]),
|
|
138
|
+
"by_status": by_status,
|
|
139
|
+
"live_claims": live,
|
|
140
|
+
})
|
|
141
|
+
# Authoritative validation must inspect authoritative state. A stale
|
|
142
|
+
# cache passes only behind an explicit flag, so a CI gate cannot go
|
|
143
|
+
# green on a cached revision while the remote is down.
|
|
144
|
+
if (git_backend._ns_dir(_cache(args), ref) / "revs").is_dir():
|
|
145
|
+
print("note: a legacy snapshot cache remains; run `tractable cache prune` to reclaim it", file=sys.stderr)
|
|
146
|
+
failure = git_backend._store(ref, _cache(args)).ns / "maintenance.error"
|
|
147
|
+
if failure.is_file():
|
|
148
|
+
print(f"note: cache maintenance failed: {failure.read_text(encoding='utf-8').strip()}", file=sys.stderr)
|
|
149
|
+
if outcome.stale and not args.allow_stale:
|
|
150
|
+
print("error: served a stale cached revision; remote unreachable (retry, or pass --allow-stale)", file=sys.stderr)
|
|
151
|
+
return EXIT_ERROR
|
|
152
|
+
return EXIT_OK
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmd_migrate(args: argparse.Namespace) -> int:
|
|
156
|
+
try:
|
|
157
|
+
ref, _ = _ref_from(args)
|
|
158
|
+
report = migrate_file(
|
|
159
|
+
args.from_export,
|
|
160
|
+
ref,
|
|
161
|
+
worker=_worker(args),
|
|
162
|
+
dry_run=args.dry_run,
|
|
163
|
+
backup_dir=args.backup_dir,
|
|
164
|
+
session=_session_id(args),
|
|
165
|
+
cache_dir=_cache(args),
|
|
166
|
+
)
|
|
167
|
+
except TractableError as exc:
|
|
168
|
+
return _fail(str(exc))
|
|
169
|
+
_emit(report)
|
|
170
|
+
return EXIT_OK
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_recover(args: argparse.Namespace) -> int:
|
|
174
|
+
try:
|
|
175
|
+
ref, _ = _ref_from(args)
|
|
176
|
+
except TractableError as exc:
|
|
177
|
+
return _fail(str(exc))
|
|
178
|
+
try:
|
|
179
|
+
if args.op_id:
|
|
180
|
+
try:
|
|
181
|
+
result = git_backend.reconcile(ref, args.op_id, cache_dir=_cache(args))
|
|
182
|
+
except TractableError as exc:
|
|
183
|
+
return _fail(str(exc))
|
|
184
|
+
if result.state == "applied":
|
|
185
|
+
_emit({"op_id": args.op_id, "rev": result.sha, "applied": True})
|
|
186
|
+
return EXIT_OK
|
|
187
|
+
if result.state == "absent":
|
|
188
|
+
_emit({
|
|
189
|
+
"op_id": args.op_id,
|
|
190
|
+
"applied": False,
|
|
191
|
+
"checked_rev": result.sha,
|
|
192
|
+
"note": "not present at the inspected tip; re-apply only deliberately",
|
|
193
|
+
})
|
|
194
|
+
return EXIT_OK
|
|
195
|
+
return _fail(f"reconciliation unknown: {result.detail}; retry later with --op-id {args.op_id}")
|
|
196
|
+
if args.restore_rev:
|
|
197
|
+
outcome = git_backend.restore_rev(
|
|
198
|
+
ref, args.restore_rev, _worker(args), session=_session_id(args), cache_dir=_cache(args),
|
|
199
|
+
)
|
|
200
|
+
if not outcome.ok:
|
|
201
|
+
return _fail(outcome.error or "restore failed")
|
|
202
|
+
_emit({"restored_to": args.restore_rev, "rev": outcome.sha})
|
|
203
|
+
return EXIT_OK
|
|
204
|
+
_emit({"history": git_backend.history(ref, limit=args.limit, cache_dir=_cache(args))})
|
|
205
|
+
return EXIT_OK
|
|
206
|
+
except TractableError as exc:
|
|
207
|
+
return _fail(str(exc))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_session_id(args: argparse.Namespace) -> str | None:
|
|
211
|
+
"""Best-effort session for read-only commands.
|
|
212
|
+
|
|
213
|
+
Reads never mutate, so a broken session configuration must not block
|
|
214
|
+
them: fall back to unattributed rather than failing. Mutation paths
|
|
215
|
+
keep the fail-closed :func:`_session_id`.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
return _session_id(args)
|
|
219
|
+
except TractableError:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
224
|
+
try:
|
|
225
|
+
ref, _ = _ref_from(args)
|
|
226
|
+
svc = TrackerService(
|
|
227
|
+
ref=ref, cache_dir=_cache(args), worker="cli",
|
|
228
|
+
session_id=_read_session_id(args), client_name="cli",
|
|
229
|
+
)
|
|
230
|
+
env = svc.list_items(
|
|
231
|
+
status=args.status, priority=args.priority, text=args.text,
|
|
232
|
+
ready_only=args.ready, limit=args.limit, cursor=args.cursor,
|
|
233
|
+
)
|
|
234
|
+
except TractableError as exc:
|
|
235
|
+
return _fail(str(exc))
|
|
236
|
+
_emit(env)
|
|
237
|
+
return EXIT_OK if env.get("ok") else EXIT_ERROR
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
241
|
+
try:
|
|
242
|
+
ref, _ = _ref_from(args)
|
|
243
|
+
svc = TrackerService(
|
|
244
|
+
ref=ref, cache_dir=_cache(args), worker="cli",
|
|
245
|
+
session_id=_read_session_id(args), client_name="cli",
|
|
246
|
+
)
|
|
247
|
+
env = svc.show_item(args.id, field=args.field, offset=args.offset, budget=args.budget, rev=args.rev)
|
|
248
|
+
except TractableError as exc:
|
|
249
|
+
return _fail(str(exc))
|
|
250
|
+
_emit(env)
|
|
251
|
+
return EXIT_OK if env.get("ok") else EXIT_ERROR
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_cache_prune(args: argparse.Namespace) -> int:
|
|
255
|
+
from .git_store import prune_cache
|
|
256
|
+
|
|
257
|
+
cache = _cache(args)
|
|
258
|
+
aliases: dict[str, str] = {}
|
|
259
|
+
try:
|
|
260
|
+
ref, _ = _ref_from(args)
|
|
261
|
+
except TractableError:
|
|
262
|
+
ref = None
|
|
263
|
+
if ref is not None:
|
|
264
|
+
# A local remote's store is keyed by its resolved path, so its legacy
|
|
265
|
+
# snapshots may sit under a namespace that no store will ever claim.
|
|
266
|
+
legacy, current = git_backend.namespace(ref), git_backend._store(ref, cache).ns.name
|
|
267
|
+
if legacy != current:
|
|
268
|
+
aliases[legacy] = current
|
|
269
|
+
report = prune_cache(cache, older_than_days=args.older_than, dry_run=args.dry_run, aliases=aliases)
|
|
270
|
+
_emit(report)
|
|
271
|
+
return EXIT_OK
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def cmd_batch_show(args: argparse.Namespace) -> int:
|
|
275
|
+
try:
|
|
276
|
+
ref, _ = _ref_from(args)
|
|
277
|
+
svc = TrackerService(
|
|
278
|
+
ref=ref, cache_dir=_cache(args), worker="cli",
|
|
279
|
+
session_id=_read_session_id(args), client_name="cli",
|
|
280
|
+
)
|
|
281
|
+
env = svc.show_batch(args.batch_id, field=args.field, offset=args.offset, budget=args.budget, rev=args.rev)
|
|
282
|
+
except TractableError as exc:
|
|
283
|
+
return _fail(str(exc))
|
|
284
|
+
_emit(env)
|
|
285
|
+
return EXIT_OK if env.get("ok") else EXIT_ERROR
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _add_target_options(parser: argparse.ArgumentParser) -> None:
|
|
289
|
+
parser.add_argument("--config", help="path to a .tractable/config.json (overrides discovery)")
|
|
290
|
+
parser.add_argument("--state-remote", help="Git URL or path of the state remote")
|
|
291
|
+
parser.add_argument("--state-branch", help="state branch (default: todo-state)")
|
|
292
|
+
parser.add_argument("--cache-dir", help="local state cache (default: TRACTABLE_CACHE_DIR or ~/.cache/tractable-state)")
|
|
293
|
+
parser.add_argument("--repo-root", help="project root for config discovery (default: cwd)")
|
|
294
|
+
parser.add_argument("--actor", help="worker identity for mutations (default: TRACTABLE_ACTOR or 'human')")
|
|
295
|
+
parser.add_argument("--session", help="session identity for mutations (default: TRACTABLE_SESSION or a fresh ID)")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
299
|
+
parser = argparse.ArgumentParser(
|
|
300
|
+
prog="tractable",
|
|
301
|
+
description="Minimal human/CI CLI for the JSON/Git TODO tracker.",
|
|
302
|
+
)
|
|
303
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {TOOL_VERSION}")
|
|
304
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
305
|
+
|
|
306
|
+
bootstrap = sub.add_parser("bootstrap", help="create the state branch with empty state")
|
|
307
|
+
_add_target_options(bootstrap)
|
|
308
|
+
bootstrap.add_argument("--write-config", action="store_true", help="write .tractable/config.json in the repo root")
|
|
309
|
+
bootstrap.set_defaults(func=cmd_bootstrap)
|
|
310
|
+
|
|
311
|
+
validate = sub.add_parser("validate", help="validate the accepted tip and report counts")
|
|
312
|
+
_add_target_options(validate)
|
|
313
|
+
validate.add_argument(
|
|
314
|
+
"--allow-stale",
|
|
315
|
+
action="store_true",
|
|
316
|
+
help="accept a cached revision when the remote is unreachable (default: fail)",
|
|
317
|
+
)
|
|
318
|
+
validate.set_defaults(func=cmd_validate)
|
|
319
|
+
|
|
320
|
+
migrate = sub.add_parser("migrate", help="migrate a v2 lossless export envelope onto a fresh state branch")
|
|
321
|
+
_add_target_options(migrate)
|
|
322
|
+
migrate.add_argument("--from-export", required=True, help="path to the export JSON file (read-only)")
|
|
323
|
+
migrate.add_argument("--dry-run", action="store_true", help="report the mapping without publishing")
|
|
324
|
+
migrate.add_argument("--backup-dir", help="directory receiving a backup copy of the input")
|
|
325
|
+
migrate.set_defaults(func=cmd_migrate)
|
|
326
|
+
|
|
327
|
+
recover = sub.add_parser("recover", help="reconcile an operation ID, restore a revision, or show history")
|
|
328
|
+
_add_target_options(recover)
|
|
329
|
+
group = recover.add_mutually_exclusive_group()
|
|
330
|
+
group.add_argument("--op-id", help="look up the accepted commit for an operation ID")
|
|
331
|
+
group.add_argument("--restore-rev", help="append-only restore of a prior revision (new commit)")
|
|
332
|
+
recover.add_argument("--limit", type=int, default=20, help="history entries (default: 20)")
|
|
333
|
+
recover.set_defaults(func=cmd_recover)
|
|
334
|
+
|
|
335
|
+
lst = sub.add_parser("list", help="list tasks as brief rows (read-only)")
|
|
336
|
+
_add_target_options(lst)
|
|
337
|
+
lst.add_argument("--status", default=None)
|
|
338
|
+
lst.add_argument("--priority", default=None)
|
|
339
|
+
lst.add_argument("--text", default=None)
|
|
340
|
+
lst.add_argument("--ready", action="store_true")
|
|
341
|
+
lst.add_argument("--limit", type=int, default=5)
|
|
342
|
+
lst.add_argument("--cursor", default=None)
|
|
343
|
+
lst.set_defaults(func=cmd_list)
|
|
344
|
+
|
|
345
|
+
show = sub.add_parser("show", help="show one task (read-only)")
|
|
346
|
+
_add_target_options(show)
|
|
347
|
+
show.add_argument("id")
|
|
348
|
+
show.add_argument("--field", default=None)
|
|
349
|
+
show.add_argument("--offset", type=int, default=0)
|
|
350
|
+
show.add_argument("--budget", type=int, default=6000)
|
|
351
|
+
show.add_argument("--rev", default=None)
|
|
352
|
+
show.set_defaults(func=cmd_show)
|
|
353
|
+
|
|
354
|
+
batch = sub.add_parser("batch", help="batch registry reads (read-only)")
|
|
355
|
+
batch_sub = batch.add_subparsers(dest="batch_command", required=True)
|
|
356
|
+
batch_show = batch_sub.add_parser("show", help="show one batch record or section (read-only)")
|
|
357
|
+
_add_target_options(batch_show)
|
|
358
|
+
batch_show.add_argument("batch_id")
|
|
359
|
+
batch_show.add_argument("--field", default=None)
|
|
360
|
+
batch_show.add_argument("--offset", type=int, default=0)
|
|
361
|
+
batch_show.add_argument("--budget", type=int, default=6000)
|
|
362
|
+
batch_show.add_argument("--rev", default=None)
|
|
363
|
+
batch_show.set_defaults(func=cmd_batch_show)
|
|
364
|
+
|
|
365
|
+
cache = sub.add_parser("cache", help="manage the local state cache")
|
|
366
|
+
cache_sub = cache.add_subparsers(dest="cache_command", required=True)
|
|
367
|
+
prune = cache_sub.add_parser(
|
|
368
|
+
"prune",
|
|
369
|
+
help="remove legacy snapshot folders and unused namespaces, then repack each store",
|
|
370
|
+
)
|
|
371
|
+
prune.add_argument("--cache-dir", help="local state cache (default: TRACTABLE_CACHE_DIR or ~/.cache/tractable-state)")
|
|
372
|
+
prune.add_argument("--config", help="also clear this project's legacy cache (path to .tractable/config.json)")
|
|
373
|
+
prune.add_argument("--state-remote", help="also clear this state remote's legacy cache")
|
|
374
|
+
prune.add_argument("--state-branch", help="state branch for --state-remote (default: todo-state)")
|
|
375
|
+
prune.add_argument("--repo-root", help="project root for config discovery (default: cwd)")
|
|
376
|
+
prune.add_argument(
|
|
377
|
+
"--older-than", type=float, default=30.0, metavar="DAYS",
|
|
378
|
+
help="remove namespaces unused for this many days (default: 30, minimum: 1)",
|
|
379
|
+
)
|
|
380
|
+
prune.add_argument("--dry-run", action="store_true", help="list what would be removed without removing it")
|
|
381
|
+
prune.set_defaults(func=cmd_cache_prune)
|
|
382
|
+
|
|
383
|
+
return parser
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def main(argv: list[str] | None = None) -> int:
|
|
387
|
+
args = build_parser().parse_args(argv)
|
|
388
|
+
try:
|
|
389
|
+
return args.func(args)
|
|
390
|
+
except TractableError as exc:
|
|
391
|
+
return _fail(str(exc))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__": # pragma: no cover
|
|
395
|
+
sys.exit(main())
|
tractable/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Stable error types exposed by the tractable public API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TractableError(Exception):
|
|
5
|
+
"""Base class for expected tracker errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, *, code: str | None = None, recovery: list[str] | None = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
self.recovery = list(recovery or [])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TodoError(TractableError):
|
|
14
|
+
"""Raised when a TODO lifecycle or validation rule is violated."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
E_CLAIM_STALE = "E_CLAIM_STALE"
|
|
18
|
+
E_MULTIPLE_CLAIMS = "E_MULTIPLE_CLAIMS"
|
|
19
|
+
E_NOTHING_READY = "E_NOTHING_READY"
|
|
20
|
+
E_SCHEMA = "E_SCHEMA"
|
|
21
|
+
E_STATE = "E_STATE"
|
|
22
|
+
E_CONFLICT = "E_CONFLICT"
|
|
23
|
+
E_UNKNOWN = "E_UNKNOWN"
|
|
24
|
+
E_OFFLINE = "E_OFFLINE"
|
|
25
|
+
E_CURSOR_STALE = "E_CURSOR_STALE"
|
|
26
|
+
E_ACTIVE_CLAIMS = "E_ACTIVE_CLAIMS"
|
|
27
|
+
E_OVERSIZED = "E_OVERSIZED"
|
|
28
|
+
E_OUTPUT_TRUNCATED = "E_OUTPUT_TRUNCATED"
|
|
29
|
+
E_NO_PRINCIPAL = "E_NO_PRINCIPAL"
|
|
30
|
+
E_FINAL_EVIDENCE = "E_FINAL_EVIDENCE"
|
|
31
|
+
E_OPEN_DEFERRALS = "E_OPEN_DEFERRALS"
|
|
32
|
+
E_BATCH_OWNER = "E_BATCH_OWNER"
|
|
33
|
+
E_BATCH_HEAD = "E_BATCH_HEAD"
|