taskproof 0.3.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.
Files changed (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/errors.py ADDED
@@ -0,0 +1,55 @@
1
+ """Exit codes and the error hierarchy.
2
+
3
+ The exit codes are part of the public contract: callers (agents, scripts, humans)
4
+ must be able to distinguish "the agent failed" from "the agent succeeded but
5
+ verification failed" without parsing stdout.
6
+
7
+ Keep this table in sync with docs/DESIGN.md.
8
+ """
9
+
10
+ EXIT_OK = 0
11
+ EXIT_REGISTRY = 2
12
+ EXIT_USAGE = 64
13
+ EXIT_ADAPTER = 70
14
+ EXIT_VERIFY = 71
15
+ EXIT_CONCURRENCY = 75
16
+
17
+
18
+ class TaskproofError(Exception):
19
+ """Base class. Carries the process exit code this failure maps to."""
20
+
21
+ exit_code = 1
22
+
23
+ def __init__(self, message, *, hint=None):
24
+ super().__init__(message)
25
+ self.hint = hint
26
+
27
+
28
+ class UsageError(TaskproofError):
29
+ """Bad invocation — wrong flags, unreadable input. -> 64"""
30
+
31
+ exit_code = EXIT_USAGE
32
+
33
+
34
+ class RegistryError(TaskproofError):
35
+ """Project not found, registry malformed, missing acceptance command. -> 2"""
36
+
37
+ exit_code = EXIT_REGISTRY
38
+
39
+
40
+ class AdapterError(TaskproofError):
41
+ """The agent CLI failed to run, or its result could not be parsed. -> 70"""
42
+
43
+ exit_code = EXIT_ADAPTER
44
+
45
+
46
+ class VerifyError(TaskproofError):
47
+ """Acceptance command or artifact self-check failed. -> 71"""
48
+
49
+ exit_code = EXIT_VERIFY
50
+
51
+
52
+ class ConcurrencyError(TaskproofError):
53
+ """Same-group serialisation or the global cap blocked this dispatch. -> 75"""
54
+
55
+ exit_code = EXIT_CONCURRENCY
taskproof/ledger.py ADDED
@@ -0,0 +1,185 @@
1
+ """Append-only audit stream.
2
+
3
+ WRITE-ONLY WITH RESPECT TO LOGIC. Nothing in this package decides anything by
4
+ reading these files. The database is the query surface; this is the audit trail
5
+ and the "read it once tomorrow morning" surface.
6
+
7
+ Layout (workspace = ~/.taskproof by default):
8
+
9
+ events-2026-10.jsonl current month
10
+ events-2026-09.jsonl.gz rotated + compressed
11
+
12
+ Rotation is driven by `gc` (see `rotate`), not by a background daemon.
13
+ """
14
+
15
+ import gzip
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ from typing import Iterator, Optional
21
+
22
+ from .storage import now_iso
23
+
24
+ EVENT_PREFIX = "events-"
25
+
26
+
27
+ def month_key(iso_ts: Optional[str] = None) -> str:
28
+ """`2026-10` for the given timestamp (or now)."""
29
+ ts = iso_ts or now_iso()
30
+ return ts[:7]
31
+
32
+
33
+ def stream_path(audit_dir: str, month: Optional[str] = None) -> str:
34
+ return os.path.join(audit_dir, f"{EVENT_PREFIX}{month or month_key()}.jsonl")
35
+
36
+
37
+ def append(audit_dir: str, event: dict) -> str:
38
+ """Append one JSON object as a single line. Returns the file written.
39
+
40
+ Must be atomic enough that a concurrent reader never sees a partial line:
41
+ one `write()` of a complete line, opened in append mode.
42
+ """
43
+ if audit_dir:
44
+ ensure_dir(audit_dir)
45
+ path = stream_path(audit_dir, month_key(event.get("ts")))
46
+ line = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
47
+ with open(path, "a", encoding="utf-8") as fh:
48
+ fh.write(line) # one write() of a complete line
49
+ fh.flush()
50
+ os.fsync(fh.fileno())
51
+ return path
52
+
53
+
54
+ def rotate(audit_dir: str, *, keep_months: int = 6, compress: bool = True) -> list:
55
+ """Compress rotated months, prune beyond `keep_months`.
56
+
57
+ Returns the list of paths touched, so `gc` can report what it did.
58
+ NEVER deletes the current month. Never touches a file it did not create.
59
+
60
+ A "month we own" is a file whose name matches ``events-<YYYY>-<MM>.jsonl``
61
+ (optionally ``.gz``). Anything else in the directory — a workspace's other
62
+ ``*.jsonl``, a stray lock file — is invisible to this function.
63
+
64
+ Retention is deliberately coarse: files are per-month, but a month file is
65
+ pruned once its calendar year is at least ``keep_months`` years behind the
66
+ current one, so the newest ``keep_months`` years of months are kept.
67
+ """
68
+ touched: list = []
69
+ months = _months_on_disk(audit_dir)
70
+ if not months:
71
+ return touched
72
+ current = month_key()
73
+ current_year = int(current[:4])
74
+ for month in sorted(months):
75
+ if month == current:
76
+ # The live month is never compressed and never deleted.
77
+ continue
78
+ files = months[month]
79
+ plain = files.get("plain")
80
+ gz = files.get("gz")
81
+ if plain is not None and compress:
82
+ if gz is None:
83
+ gz = _gzip_copy(plain)
84
+ os.remove(plain)
85
+ touched.append(plain)
86
+ touched.append(gz)
87
+ if current_year - int(month[:4]) >= keep_months:
88
+ # Beyond the window: drop whatever representation survives.
89
+ for stale in (plain, gz):
90
+ if stale and os.path.exists(stale):
91
+ os.remove(stale)
92
+ touched.append(stale)
93
+ return touched
94
+
95
+
96
+ def iter_events(audit_dir: str, *, task_id: Optional[str] = None) -> Iterator[dict]:
97
+ """Read events back, oldest first. Transparently reads `.gz` months.
98
+
99
+ This exists for `taskproof log` and for human/audit use. No decision logic
100
+ may depend on it.
101
+
102
+ Only files this module created are read (same pattern as :func:`rotate`).
103
+ A torn final line — a process killed mid-append — is skipped, and a
104
+ truncated gzip stream stops that month rather than raising.
105
+ """
106
+ months = _months_on_disk(audit_dir)
107
+ for month in sorted(months):
108
+ files = months[month]
109
+ # Prefer the compressed copy: if a crash left both behind, the `.gz` is
110
+ # the completed rotation and the `.jsonl` is a leftover duplicate.
111
+ path = files.get("gz") or files.get("plain")
112
+ if not path:
113
+ continue
114
+ for event in _iter_jsonl_tolerant(path):
115
+ if task_id is not None and event.get("task_id") != task_id:
116
+ continue
117
+ yield event
118
+
119
+
120
+ _MONTH_PATTERN = re.compile(r"^events-(\d{4})-(\d{2})\.jsonl(\.gz)?$")
121
+
122
+
123
+ def _months_on_disk(audit_dir: str) -> dict:
124
+ """``{month: {"plain": path, "gz": path}}`` for files THIS module owns."""
125
+ found: dict = {}
126
+ try:
127
+ names = os.listdir(audit_dir)
128
+ except OSError:
129
+ return found
130
+ for name in names:
131
+ m = _MONTH_PATTERN.match(name)
132
+ if m is None:
133
+ continue
134
+ path = os.path.join(audit_dir, name)
135
+ if not os.path.isfile(path):
136
+ continue
137
+ kind = "gz" if name.endswith(".gz") else "plain"
138
+ found.setdefault(f"{m.group(1)}-{m.group(2)}", {})[kind] = path
139
+ return found
140
+
141
+
142
+ def _gzip_copy(plain_path: str) -> str:
143
+ """Gzip ``plain_path`` in place to ``<plain_path>.gz``; return the new path."""
144
+ gz_path = plain_path + ".gz"
145
+ tmp = gz_path + ".tmp"
146
+ with open(plain_path, "rb") as src, gzip.open(tmp, "wb") as dst:
147
+ shutil.copyfileobj(src, dst)
148
+ os.replace(tmp, gz_path) # atomic: readers never see a half-written .gz
149
+ return gz_path
150
+
151
+
152
+ def _iter_jsonl_tolerant(path: str) -> Iterator[dict]:
153
+ """``_read_jsonl`` that also survives a truncated gzip stream."""
154
+ try:
155
+ for event in _read_jsonl(path):
156
+ yield event
157
+ except (OSError, EOFError):
158
+ # gzip.BadGzipFile is an OSError; a torn plain line is already skipped
159
+ # inside `_read_jsonl`. Either way an audit read must not explode.
160
+ return
161
+
162
+
163
+ def _read_jsonl(path: str) -> Iterator[dict]:
164
+ opener = gzip.open if path.endswith(".gz") else open
165
+ with opener(path, "rt", encoding="utf-8") as fh:
166
+ for line in fh:
167
+ line = line.strip()
168
+ if not line:
169
+ continue
170
+ try:
171
+ yield json.loads(line)
172
+ except json.JSONDecodeError:
173
+ # A torn line must never break an audit read.
174
+ continue
175
+
176
+
177
+ def ensure_dir(path: str) -> str:
178
+ os.makedirs(path, exist_ok=True)
179
+ return path
180
+
181
+
182
+ def copy_for_export(audit_dir: str, dest: str) -> str:
183
+ """Bundle the audit dir for sharing (used by `board --out` snapshots)."""
184
+ shutil.make_archive(dest.rstrip("/"), "gztar", audit_dir)
185
+ return dest + ".tar.gz"
taskproof/models.py ADDED
@@ -0,0 +1,155 @@
1
+ """Plain data carriers.
2
+
3
+ Deliberately dependency-free dataclasses (no ORM, no pydantic) — the package
4
+ promises standard library only.
5
+ """
6
+
7
+ from dataclasses import dataclass, field, asdict
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ # Task lifecycle. Terminal states: DONE, FAILED, BLOCKED, TIMEOUT, CANCELLED.
11
+ STATUS_RUNNING = "running"
12
+ STATUS_VERIFYING = "verifying"
13
+ STATUS_DONE = "done"
14
+ STATUS_FAILED = "failed"
15
+ STATUS_BLOCKED = "blocked"
16
+ STATUS_TIMEOUT = "timeout"
17
+ STATUS_CANCELLED = "cancelled"
18
+
19
+ TERMINAL_STATUSES = (
20
+ STATUS_DONE,
21
+ STATUS_FAILED,
22
+ STATUS_BLOCKED,
23
+ STATUS_TIMEOUT,
24
+ STATUS_CANCELLED,
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class Project:
30
+ """A repository / filter unit: where the work happens and what to call it.
31
+
32
+ A project is *not* dispatchable on its own — it is the shared identity and
33
+ default path that one or more :class:`Taskgroup` lanes hang off. Renaming a
34
+ project (or adding a lane) never splits its history.
35
+ """
36
+
37
+ id: str
38
+ path: str
39
+ aliases: List[str] = field(default_factory=list)
40
+
41
+
42
+ @dataclass
43
+ class Taskgroup:
44
+ """One lane ("道"): the unit a task is dispatched onto.
45
+
46
+ It owns the acceptance command, the forbidden-path rules and the structured
47
+ result contract; ``path`` defaults to the owning project's path but may
48
+ point anywhere (an independent clone, a subdirectory, ...).
49
+ """
50
+
51
+ id: str
52
+ project: str
53
+ path: str
54
+ aliases: List[str] = field(default_factory=list)
55
+ verify: Optional[str] = None
56
+ verify_kind: str = "none" # check | build | none
57
+ forbidden_paths: List[str] = field(default_factory=list)
58
+ auto_registered: bool = False
59
+ #: Structured-result contract: "default" (package-shipped schema), "none"
60
+ #: (fall back to free text, never silent), or an absolute path to a custom
61
+ #: JSON Schema. See `registry.result_schema_path`.
62
+ result_schema: str = "default"
63
+ #: Long-lived workspace policy. ``"none"`` (the default) runs in-place;
64
+ #: ``"worktree"`` asks taskproof to lazily create one persistent git
65
+ #: worktree for this lane. It is a per-lane opt-in, never a global default.
66
+ workspace: str = "none"
67
+ #: Relative paths to symlink from the main checkout into a lane workspace
68
+ #: (for example ``desktop/node_modules``). Empty means the dispatcher may
69
+ #: probe for package-manager dependency directories.
70
+ link: List[str] = field(default_factory=list)
71
+ #: Concurrency lock name. Empty means this lane's own id; loading fills the
72
+ #: default in so callers never need to special-case an unset value.
73
+ group: str = ""
74
+ #: Per-lane hard timeout in seconds. ``None`` (the default) means "no lane
75
+ #: budget": the effective timeout then falls through to ``[defaults]
76
+ #: timeout`` and finally the built-in fallback. A ``--timeout`` on the
77
+ #: command line overrides it for that one run. See `registry.resolve_timeout`.
78
+ timeout: Optional[int] = None
79
+ #: Per-lane no-progress watchdog budget in seconds (card 83). ``0`` — the
80
+ #: shipped default — disables it; a positive value stops a card whose log
81
+ #: AND working tree have both been quiet for that long. ``None`` means the
82
+ #: lane did not name one, so the chain falls through to the built-in default
83
+ #: (also off). A ``--idle-timeout`` on the command line overrides it for that
84
+ #: one run. See `registry.resolve_idle_timeout`.
85
+ idle_timeout: Optional[int] = None
86
+
87
+ def __post_init__(self):
88
+ if not isinstance(self.group, str) or not self.group.strip():
89
+ self.group = self.id
90
+
91
+
92
+ @dataclass
93
+ class Task:
94
+ """One dispatched unit of work. Mirrors the `tasks` table."""
95
+
96
+ id: str
97
+ project: str
98
+ group: str
99
+ brief: str
100
+ status: str = STATUS_RUNNING
101
+ adapter: str = "codex"
102
+ model: Optional[str] = None
103
+ reasoning: Optional[str] = None
104
+ attempt: int = 1
105
+ exit_code: Optional[int] = None
106
+ pid: Optional[int] = None
107
+ #: Process-group id that locates the whole adapter tree (`_run_adapter`
108
+ #: spawns it with ``start_new_session=True``, so this is the adapter's own
109
+ #: session/group, not the dispatching process' group). `cancel` signals
110
+ #: exactly this group.
111
+ pgid: Optional[int] = None
112
+ workdir: Optional[str] = None
113
+ result_path: Optional[str] = None
114
+ verify_cmd: Optional[str] = None
115
+ verify_exit: Optional[int] = None
116
+ files_changed: Optional[int] = None
117
+ created_at: Optional[str] = None
118
+ started_at: Optional[str] = None
119
+ finished_at: Optional[str] = None
120
+
121
+ def to_row(self) -> Dict[str, Any]:
122
+ return asdict(self)
123
+
124
+
125
+ @dataclass
126
+ class Claim:
127
+ """A held concurrency slot. `scope` is either `group:<name>` or `global`."""
128
+
129
+ scope: str
130
+ task_id: str
131
+ pid: Optional[int]
132
+ claimed_at: str
133
+ expires_at: str
134
+
135
+
136
+ @dataclass
137
+ class AdapterResult:
138
+ """Normalised outcome of one adapter invocation.
139
+
140
+ `summary` is the agent's own claim — it is displayed but NEVER treated as
141
+ proof of success. Only the independently run acceptance command decides.
142
+ """
143
+
144
+ exit_code: int
145
+ summary: str = ""
146
+ payload: Optional[Dict[str, Any]] = None
147
+ files_changed: Optional[int] = None
148
+ raw_stdout: str = ""
149
+ raw_stderr: str = ""
150
+ parse_degraded: bool = False
151
+ result_path: Optional[str] = None
152
+
153
+ @property
154
+ def ok(self) -> bool:
155
+ return self.exit_code == 0