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 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"