taskops-cli 0.2.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 (193) hide show
  1. taskops/__init__.py +39 -0
  2. taskops/_clock.py +32 -0
  3. taskops/_errors.py +160 -0
  4. taskops/_ids.py +63 -0
  5. taskops/_types.py +106 -0
  6. taskops/_version.py +7 -0
  7. taskops/assets/GUIDE.md +220 -0
  8. taskops/contracts/__init__.py +96 -0
  9. taskops/contracts/_fields.py +113 -0
  10. taskops/contracts/actor.py +30 -0
  11. taskops/contracts/board.py +142 -0
  12. taskops/contracts/commit.py +36 -0
  13. taskops/contracts/day.py +150 -0
  14. taskops/contracts/dep.py +23 -0
  15. taskops/contracts/event.py +58 -0
  16. taskops/contracts/gitstate.py +45 -0
  17. taskops/contracts/index.py +37 -0
  18. taskops/contracts/lease.py +43 -0
  19. taskops/contracts/log.py +59 -0
  20. taskops/contracts/remote.py +48 -0
  21. taskops/contracts/results.py +84 -0
  22. taskops/contracts/task.py +94 -0
  23. taskops/contracts/tools.py +110 -0
  24. taskops/contracts/wire.py +60 -0
  25. taskops/engine/__init__.py +34 -0
  26. taskops/engine/_blocks.py +48 -0
  27. taskops/engine/_briefs.py +92 -0
  28. taskops/engine/_chunks.py +66 -0
  29. taskops/engine/_closed.py +65 -0
  30. taskops/engine/_entries.py +126 -0
  31. taskops/engine/_events.py +55 -0
  32. taskops/engine/_opened.py +51 -0
  33. taskops/engine/_process.py +80 -0
  34. taskops/engine/_prompts.py +98 -0
  35. taskops/engine/_stream.py +129 -0
  36. taskops/engine/activity.py +94 -0
  37. taskops/engine/bus.py +42 -0
  38. taskops/engine/commitline.py +110 -0
  39. taskops/engine/day.py +142 -0
  40. taskops/engine/diffstat.py +63 -0
  41. taskops/engine/gitio.py +108 -0
  42. taskops/engine/gitstate.py +96 -0
  43. taskops/engine/history.py +76 -0
  44. taskops/engine/identity.py +84 -0
  45. taskops/engine/log.py +68 -0
  46. taskops/engine/machine.py +160 -0
  47. taskops/engine/narrate.py +91 -0
  48. taskops/engine/project.py +57 -0
  49. taskops/engine/replay.py +142 -0
  50. taskops/engine/reports.py +67 -0
  51. taskops/engine/scheduler.py +135 -0
  52. taskops/engine/transcript.py +127 -0
  53. taskops/engine/wire.py +92 -0
  54. taskops/engine/worker.py +115 -0
  55. taskops/py.typed +0 -0
  56. taskops/render/__init__.py +40 -0
  57. taskops/render/_closed_days.py +64 -0
  58. taskops/render/_dossier.py +68 -0
  59. taskops/render/_opened.py +64 -0
  60. taskops/render/_sections.py +51 -0
  61. taskops/render/_tasklist.py +72 -0
  62. taskops/render/_text.py +74 -0
  63. taskops/render/_verbatim.py +65 -0
  64. taskops/render/ansi.py +92 -0
  65. taskops/render/board.py +55 -0
  66. taskops/render/day.py +102 -0
  67. taskops/render/dispatch.py +104 -0
  68. taskops/render/inbox.py +27 -0
  69. taskops/render/log.py +47 -0
  70. taskops/render/recover.py +64 -0
  71. taskops/render/report.py +51 -0
  72. taskops/render/reports.py +57 -0
  73. taskops/render/results.py +96 -0
  74. taskops/render/session.py +75 -0
  75. taskops/render/task.py +88 -0
  76. taskops/render/tasklist.py +65 -0
  77. taskops/storage/__init__.py +36 -0
  78. taskops/storage/_ddl.py +78 -0
  79. taskops/storage/_delivered.py +51 -0
  80. taskops/storage/_deps.py +69 -0
  81. taskops/storage/_events.py +127 -0
  82. taskops/storage/_leases.py +108 -0
  83. taskops/storage/_rows.py +90 -0
  84. taskops/storage/_tasks.py +124 -0
  85. taskops/storage/locate.py +76 -0
  86. taskops/storage/schema.py +66 -0
  87. taskops/storage/store.py +120 -0
  88. taskops/storage/sync.py +139 -0
  89. taskops/transports/__init__.py +6 -0
  90. taskops/transports/cli/__init__.py +0 -0
  91. taskops/transports/cli/commands/__init__.py +3 -0
  92. taskops/transports/cli/commands/_digest.py +64 -0
  93. taskops/transports/cli/commands/_serve_init.py +65 -0
  94. taskops/transports/cli/commands/_shared.py +50 -0
  95. taskops/transports/cli/commands/_tasks_args.py +112 -0
  96. taskops/transports/cli/commands/_window.py +45 -0
  97. taskops/transports/cli/commands/ask.py +27 -0
  98. taskops/transports/cli/commands/dispatch.py +43 -0
  99. taskops/transports/cli/commands/init.py +48 -0
  100. taskops/transports/cli/commands/log.py +22 -0
  101. taskops/transports/cli/commands/plan.py +43 -0
  102. taskops/transports/cli/commands/pushpull.py +53 -0
  103. taskops/transports/cli/commands/recover.py +30 -0
  104. taskops/transports/cli/commands/remote.py +56 -0
  105. taskops/transports/cli/commands/report.py +82 -0
  106. taskops/transports/cli/commands/run_.py +60 -0
  107. taskops/transports/cli/commands/serve.py +74 -0
  108. taskops/transports/cli/commands/sync.py +22 -0
  109. taskops/transports/cli/commands/tasks.py +79 -0
  110. taskops/transports/cli/commands/ui.py +72 -0
  111. taskops/transports/cli/commands/update.py +25 -0
  112. taskops/transports/cli/main.py +85 -0
  113. taskops/transports/hooks/__init__.py +15 -0
  114. taskops/transports/hooks/__main__.py +63 -0
  115. taskops/transports/hooks/_args.py +28 -0
  116. taskops/transports/hooks/claude.py +78 -0
  117. taskops/transports/hooks/commit.py +61 -0
  118. taskops/transports/hooks/events.py +107 -0
  119. taskops/transports/hooks/record.py +55 -0
  120. taskops/transports/http/__init__.py +21 -0
  121. taskops/transports/http/_handler.py +132 -0
  122. taskops/transports/http/_wire.py +79 -0
  123. taskops/transports/http/_wsframes.py +116 -0
  124. taskops/transports/http/agentapi.py +69 -0
  125. taskops/transports/http/api.py +122 -0
  126. taskops/transports/http/exchange.py +80 -0
  127. taskops/transports/http/live.py +160 -0
  128. taskops/transports/http/policy.py +110 -0
  129. taskops/transports/http/projects.py +116 -0
  130. taskops/transports/http/reports.py +75 -0
  131. taskops/transports/http/router.py +90 -0
  132. taskops/transports/http/server.py +50 -0
  133. taskops/transports/http/static.py +86 -0
  134. taskops/transports/http/ui/app.js +59 -0
  135. taskops/transports/http/ui/index.html +23 -0
  136. taskops/transports/http/ui/style.css +1 -0
  137. taskops/transports/http/websocket.py +60 -0
  138. taskops/transports/mcp/__init__.py +19 -0
  139. taskops/transports/mcp/__main__.py +7 -0
  140. taskops/transports/mcp/_descriptions.py +102 -0
  141. taskops/transports/mcp/_reads.py +82 -0
  142. taskops/transports/mcp/_writes.py +72 -0
  143. taskops/transports/mcp/answers.py +44 -0
  144. taskops/transports/mcp/arguments.py +104 -0
  145. taskops/transports/mcp/dispatch.py +47 -0
  146. taskops/transports/mcp/protocol.py +83 -0
  147. taskops/transports/mcp/schema.py +50 -0
  148. taskops/transports/mcp/server.py +49 -0
  149. taskops/transports/mcp/tools.py +66 -0
  150. taskops/usecases/__init__.py +56 -0
  151. taskops/usecases/_entry.py +84 -0
  152. taskops/usecases/_facts.py +49 -0
  153. taskops/usecases/_freeing.py +93 -0
  154. taskops/usecases/_gitignore.py +94 -0
  155. taskops/usecases/_mirroring.py +57 -0
  156. taskops/usecases/_narrating.py +77 -0
  157. taskops/usecases/_project.py +67 -0
  158. taskops/usecases/_range.py +102 -0
  159. taskops/usecases/_reasons.py +54 -0
  160. taskops/usecases/_remotefile.py +62 -0
  161. taskops/usecases/_reportsync.py +108 -0
  162. taskops/usecases/_routing.py +91 -0
  163. taskops/usecases/_wireclient.py +141 -0
  164. taskops/usecases/ask.py +41 -0
  165. taskops/usecases/claim.py +104 -0
  166. taskops/usecases/dispatch.py +160 -0
  167. taskops/usecases/dossier.py +155 -0
  168. taskops/usecases/edit.py +71 -0
  169. taskops/usecases/exchange.py +86 -0
  170. taskops/usecases/feed.py +138 -0
  171. taskops/usecases/guard.py +122 -0
  172. taskops/usecases/hooks.py +127 -0
  173. taskops/usecases/index.py +62 -0
  174. taskops/usecases/ingest.py +60 -0
  175. taskops/usecases/log.py +134 -0
  176. taskops/usecases/narration.py +91 -0
  177. taskops/usecases/plan.py +128 -0
  178. taskops/usecases/pushpull.py +119 -0
  179. taskops/usecases/recover.py +84 -0
  180. taskops/usecases/remote.py +78 -0
  181. taskops/usecases/report.py +87 -0
  182. taskops/usecases/reportfile.py +106 -0
  183. taskops/usecases/session.py +134 -0
  184. taskops/usecases/setup.py +92 -0
  185. taskops/usecases/sync.py +78 -0
  186. taskops/usecases/update.py +123 -0
  187. taskops/usecases/view.py +112 -0
  188. taskops_cli-0.2.0.dist-info/METADATA +291 -0
  189. taskops_cli-0.2.0.dist-info/RECORD +193 -0
  190. taskops_cli-0.2.0.dist-info/WHEEL +5 -0
  191. taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
  192. taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  193. taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,120 @@
1
+ """The database: one SQLite file per repository, and the ONLY package with SQL.
2
+
3
+ `tests/architecture` enforces that. A query written anywhere else is a second place
4
+ that knows the column order, and it is always the one nobody updates when the
5
+ schema moves.
6
+
7
+ `Store` owns the connection and the schema; each table is its own object
8
+ (`store.tasks`, `store.leases`, …). Flat methods would make this file know every
9
+ table; this way it knows only the connection.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from functools import cached_property
16
+ from pathlib import Path
17
+ from types import TracebackType
18
+
19
+ from . import schema
20
+ from ._delivered import DeliveredTable
21
+ from ._deps import DepTable
22
+ from ._events import EventTable
23
+ from ._leases import LeaseTable
24
+ from ._tasks import TaskTable
25
+ from .locate import DB_FILE
26
+
27
+ __all__ = ["Store", "BUSY_TIMEOUT"]
28
+
29
+ BUSY_TIMEOUT = 15.0
30
+ """Seconds a connection waits for a write lock before giving up.
31
+
32
+ Under WAL a reader never waits, so this bounds writer-versus-writer only. Fifteen
33
+ is generous against the writes taskops actually makes — a claim and an append, both
34
+ sub-millisecond — and it is deliberately SHORTER than a lease TTL: an agent that
35
+ cannot get the lock inside fifteen seconds should be told, not left hanging past
36
+ the point where its own claim expires underneath it.
37
+ """
38
+
39
+
40
+ class Store:
41
+ def __init__(self, root: Path, *, check_same_thread: bool = True) -> None:
42
+ # check_same_thread=False lets the studio read from its request threads;
43
+ # that server serialises its own writes, so it stays safe.
44
+ self.root = Path(root)
45
+ db_path = self.root / DB_FILE
46
+ db_path.parent.mkdir(parents=True, exist_ok=True)
47
+ self.db = sqlite3.connect(db_path, check_same_thread=check_same_thread,
48
+ timeout=BUSY_TIMEOUT,
49
+ isolation_level="DEFERRED")
50
+ self.db.row_factory = sqlite3.Row
51
+ schema.apply(self.db)
52
+
53
+ # ---- tables (cached: one object per store, built on first touch)
54
+
55
+ @cached_property
56
+ def tasks(self) -> TaskTable:
57
+ return TaskTable(self.db)
58
+
59
+ @cached_property
60
+ def deps(self) -> DepTable:
61
+ """The DAG. Queried from both ends; see `_deps` for the direction."""
62
+ return DepTable(self.db)
63
+
64
+ @cached_property
65
+ def leases(self) -> LeaseTable:
66
+ """Claims with a deadline — the concurrency story. Writes go under
67
+ `claiming()`, never bare, or the expiry sweep can interleave."""
68
+ return LeaseTable(self.db)
69
+
70
+ @cached_property
71
+ def events(self) -> EventTable:
72
+ return EventTable(self.db)
73
+
74
+ @cached_property
75
+ def delivered(self) -> DeliveredTable:
76
+ return DeliveredTable(self.db)
77
+
78
+ # ---- lifecycle
79
+
80
+ def claiming(self) -> None:
81
+ """Take the write lock NOW, before this call reads or writes anything.
82
+
83
+ A claim is sweep-then-decide-then-insert, and under a DEFERRED transaction the lock
84
+ is only taken at the insert — so two agents can both read the same dead lease as
85
+ sweepable and both then try to claim. BEGIN IMMEDIATE closes that window.
86
+
87
+ MUST be the first statement of the call. sqlite3 opens a transaction implicitly on
88
+ the first write, and `BEGIN IMMEDIATE` inside one raises "cannot start a transaction
89
+ within a transaction" — which is exactly how this was found, by an end-to-end test
90
+ where a heartbeat wrote first. Tolerating that case with `if not in_transaction`
91
+ would have been worse: the lock would silently not be taken, and the race this
92
+ exists to prevent would be back with nothing to show for it.
93
+ """
94
+ self.db.execute("BEGIN IMMEDIATE")
95
+
96
+ def commit(self) -> None:
97
+ self.db.commit()
98
+
99
+ def close(self) -> None:
100
+ self.db.close()
101
+
102
+ def __enter__(self) -> "Store":
103
+ return self
104
+
105
+ def __exit__(
106
+ self,
107
+ exc_type: type[BaseException] | None,
108
+ exc: BaseException | None,
109
+ tb: TracebackType | None,
110
+ ) -> None:
111
+ """Clean exit commits; a raised exception rolls back. Always closes.
112
+
113
+ sqlite3 opens a transaction on the first write and `close()` does NOT
114
+ commit it, so a block that only closed would discard everything it wrote —
115
+ a claim that reported success and left no lease.
116
+ """
117
+ try:
118
+ self.db.rollback() if exc_type else self.db.commit()
119
+ finally:
120
+ self.close()
@@ -0,0 +1,139 @@
1
+ """The committed log ↔ the local cache. Multi-developer sync with no server.
2
+
3
+ `.taskops/events.jsonl` is append-only text, one event per line, and it is
4
+ COMMITTED. That is the whole mechanism: two developers' agents converge by `git
5
+ pull`, because appending to different ends of a file is the one edit git merges
6
+ without help, and content-hash ids make importing the same event twice a no-op.
7
+
8
+ What this module deliberately does NOT do is decide what an event MEANS. It moves bytes; turning
9
+ events into tasks and dependencies is `engine.replay`, which is also where the one genuine conflict
10
+ — two machines changing a task's status — is settled by timestamp.
11
+
12
+ There are no conflicts in the LOG to resolve: events are facts about the past, so the union of two
13
+ logs is the correct log.
14
+
15
+ `activity` never leaves the machine (`LOCAL_ONLY_KINDS`): it is a per-tool-call
16
+ heartbeat, and replicating it would add thousands of lines a day to a file whose
17
+ whole value is that a human can read its diff.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Any, Iterator, cast
25
+
26
+ from .._types import LOCAL_ONLY_KINDS
27
+ from ..contracts import Event
28
+ from ._rows import as_dict
29
+ from .locate import LOG_FILE
30
+ from .store import Store
31
+
32
+ __all__ = ["export_events", "import_events", "all_events", "read_log", "event_from"]
33
+
34
+
35
+ def export_events(store: Store, *, limit: int = 1000) -> int:
36
+ """Append this machine's new events to the log. Returns how many were written.
37
+
38
+ The file is opened per call and closed immediately: a long-lived handle would
39
+ keep a deleted inode alive after a `git checkout` swapped the file, and every
40
+ write after that would go somewhere nobody can read.
41
+ """
42
+ fresh = store.events.unexported(limit=limit)
43
+ shared = [e for e in fresh if e["kind"] not in LOCAL_ONLY_KINDS]
44
+ if shared:
45
+ _append(store.root / LOG_FILE, shared)
46
+ if fresh:
47
+ # Local-only kinds are marked too, or every export re-scans every activity
48
+ # event this machine has ever written.
49
+ store.events.mark_exported([e["id"] for e in fresh])
50
+ return len(shared)
51
+
52
+
53
+ def _append(path: Path, events: list[Event]) -> None:
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ with path.open("a", encoding="utf-8") as handle:
56
+ for event in events:
57
+ handle.write(json.dumps(event, sort_keys=True,
58
+ separators=(",", ":")) + "\n")
59
+
60
+
61
+ def import_events(store: Store) -> list[Event]:
62
+ """Load the log into the cache. Returns the events that were NEW here.
63
+
64
+ The events themselves and not a count, because the caller has to do something with them:
65
+ materialising tasks from them is `engine.replay`'s job, and it must see exactly the new ones.
66
+ Returning a number was the earlier signature, and it is how the board stayed empty after a
67
+ teammate's `git pull` — the events arrived and nothing turned them into tasks.
68
+
69
+ Reads the whole file every time. At tens of thousands of events that is a few milliseconds, and
70
+ the alternative — a byte offset into a file `git pull` rewrites from the middle — is a cursor
71
+ that silently skips a merge.
72
+ """
73
+ fresh: list[Event] = []
74
+ for event in read_log(store.root):
75
+ if store.events.append(event, exported=True):
76
+ fresh.append(event)
77
+ return fresh
78
+
79
+
80
+ def read_log(root: Path) -> Iterator[Event]:
81
+ """Every well-formed event in the log, in file order.
82
+
83
+ A malformed line is SKIPPED, not fatal: this file is merged by git and written
84
+ by however many taskops versions a team runs, and one bad line must not make
85
+ the project unreadable. Nothing is lost silently — the line is still in git.
86
+ """
87
+ path = root / LOG_FILE
88
+ if not path.is_file():
89
+ return
90
+ with path.open("r", encoding="utf-8") as handle:
91
+ for line in handle:
92
+ event = _parse(line)
93
+ if event is not None:
94
+ yield event
95
+
96
+
97
+ def _parse(line: str) -> Event | None:
98
+ """One log line -> an Event, or None."""
99
+ raw = line.strip()
100
+ if not raw:
101
+ return None
102
+ try:
103
+ parsed: Any = json.loads(raw)
104
+ except json.JSONDecodeError:
105
+ return None
106
+ return event_from(parsed)
107
+
108
+
109
+ def event_from(parsed: Any) -> Event | None:
110
+ """One decoded JSON value -> an Event, or None when it cannot be one.
111
+
112
+ Every field is coerced rather than trusted. The producer is another machine's
113
+ taskops, possibly a newer one, and `json.loads` returns Any — so without this
114
+ an unexpected type would flow into the database and fail later at a render.
115
+
116
+ Split from `_parse` because the committed log is no longer the only way a foreign
117
+ event arrives: `POST /api/sync` receives them already decoded. Two coercions in two
118
+ modules is how the two paths would start disagreeing about what a valid event IS —
119
+ and since the id is the content, disagreeing means forking history.
120
+ """
121
+ if not isinstance(parsed, dict):
122
+ return None
123
+ fields = cast("dict[str, Any]", parsed)
124
+ if not fields.get("id") or not fields.get("kind"):
125
+ return None
126
+ return Event(id=str(fields["id"]), task=str(fields.get("task", "")),
127
+ actor=str(fields.get("actor", "")), kind=fields["kind"],
128
+ body=as_dict(json.dumps(fields.get("body", {}))),
129
+ ts=float(fields.get("ts", 0.0)))
130
+
131
+
132
+ def all_events(root: Path) -> list[Event]:
133
+ """The whole log, for a caller that has to re-apply state rather than fill gaps.
134
+
135
+ Separate from `import_events` because they answer different questions: that one says "what is
136
+ new here", this one says "what does the log say" — and a cache being rebuilt from a deleted
137
+ database needs the second, since by then nothing is new.
138
+ """
139
+ return list(read_log(root))
@@ -0,0 +1,6 @@
1
+ """Layer 6 — the surfaces. Thin by construction: CLI, MCP and HTTP all call the same
2
+ use cases, and `tests/architecture` forbids any of them from importing storage or engine
3
+ directly. That rule is what stops a fourth place where a decision lives.
4
+ """
5
+
6
+ from __future__ import annotations
File without changes
@@ -0,0 +1,3 @@
1
+ """The CLI commands. One module per verb, each registering its own flags."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,64 @@
1
+ """`report … --digest` in a terminal: the narration as it is being written.
2
+
3
+ Its own module because it is the only command that takes MINUTES, and the only one that writes
4
+ to stdout before it returns. Everything else here renders a finished answer and hands back a
5
+ string for `main` to print.
6
+
7
+ The user's report was "esto nunca terminó": `report all --digest` on a real project narrates a
8
+ whole-project dossier in several passes, and with the output captured the terminal showed
9
+ nothing at all for a quarter of an hour. Nothing was broken — nothing was VISIBLE. So this
10
+ prints a header before the first pass, names each pass as it starts, and renders the prose live.
11
+
12
+ The FILE is untouched by any of it: `digest` writes the joined text, and no escape sequence ever
13
+ reaches disk.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import IO
22
+
23
+ from ...._clock import now
24
+ from ....render import Ink
25
+ from ....usecases import Selector, digest
26
+
27
+ __all__ = ["stream_digest"]
28
+
29
+
30
+ def stream_digest(where: Path, sel: Selector, *, kind: str, model: str = "",
31
+ force: bool = False, out: IO[str] | None = None) -> str:
32
+ """Narrate the window, showing it happen. Returns the line `main` prints at the end."""
33
+ to = out or sys.stdout
34
+ ink = Ink(colour=_colour(to))
35
+ started = now()
36
+
37
+ def show(text: str) -> None:
38
+ for line in ink.feed(text):
39
+ print(line, file=to)
40
+ to.flush()
41
+
42
+ def announce(n: int, total: int) -> None:
43
+ for line in ink.flush():
44
+ print(line, file=to)
45
+ print(f"\n▸ narrating {n}/{total} …" if total > 1 else "", file=to)
46
+ to.flush()
47
+
48
+ print(f"▸ reading the {kind} dossier of {where} — this calls Claude and takes minutes",
49
+ file=to)
50
+ to.flush()
51
+ path = digest(where, sel, model=model, force=force, on_pass=announce, on_text=show)
52
+ for line in ink.flush():
53
+ print(line, file=to)
54
+ return f"\nnarrated {path} in {now() - started:.0f}s"
55
+
56
+
57
+ def _colour(to: IO[str]) -> bool:
58
+ """Whether to style at all — decided HERE and not in the renderer, which is pure.
59
+
60
+ A pipe, a file and a CI log get the markdown verbatim, which is byte for byte what this
61
+ command emitted before it streamed. `NO_COLOR` is honoured because a person who set it
62
+ means every tool, not the ones that remembered.
63
+ """
64
+ return bool(getattr(to, "isatty", bool)()) and not os.environ.get("NO_COLOR")
@@ -0,0 +1,65 @@
1
+ """Creating one project on a server, and minting the secret that is its only door.
2
+
3
+ Split from `serve` because the two halves have nothing in common but a name: that one opens a
4
+ socket, this one writes two things to disk and prints a URL. Keeping them apart is also what
5
+ keeps the token handling in a file small enough to read in one sitting — which is the correct
6
+ size for the only code in taskops that mints a credential.
7
+
8
+ The model is the one that has already been proven in `bgist`: the token is minted BY THE BOX,
9
+ lands in a `0600` file, is printed exactly once, and never touches git or a log. Nothing here
10
+ can recover it — a lost token is re-minted, not looked up — because a tool that can print a
11
+ secret twice is a tool whose transcript is a secret.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import secrets
17
+ from pathlib import Path
18
+
19
+ from ...._errors import TaskopsError
20
+ from ....transports.http.projects import NAME, TOKEN_FILE
21
+ from ....usecases import init as setup
22
+
23
+ __all__ = ["create"]
24
+
25
+ TOKEN_BYTES = 16
26
+ """128 bits from `secrets`, hex-encoded. Long enough that the 404 on a wrong name is the only
27
+ enumeration surface, and short enough to paste."""
28
+
29
+
30
+ def create(root: Path, project: str) -> str:
31
+ """Make `<root>/<project>/`, init a taskops store in it, mint the token. Idempotent
32
+ EXCEPT for the token, which is minted once and never reprinted."""
33
+ if not NAME.match(project):
34
+ raise TaskopsError(f"'{project}' cannot name a project — use [a-z0-9-], "
35
+ f"1 to 40 characters, because the name is also a URL segment")
36
+ home = root / project
37
+ home.mkdir(parents=True, exist_ok=True)
38
+ # No git hooks: a server directory is a store of boards, not a working tree, and there is
39
+ # no repository here whose commits a hook could bind to.
40
+ setup(home, install_git_hooks=False)
41
+ token = _mint(home / TOKEN_FILE)
42
+ return _describe(home, project, token)
43
+
44
+
45
+ def _mint(path: Path) -> str:
46
+ """Write the secret `0600`, or leave an existing one alone and return "".
47
+
48
+ `touch(mode=0o600)` BEFORE the write, so the file never exists — not even for the
49
+ microsecond between create and chmod — with the umask's permissions.
50
+ """
51
+ if path.is_file():
52
+ return ""
53
+ path.touch(mode=0o600)
54
+ token = secrets.token_hex(TOKEN_BYTES)
55
+ path.write_text(token + "\n", encoding="utf-8")
56
+ return token
57
+
58
+
59
+ def _describe(home: Path, project: str, token: str) -> str:
60
+ if not token:
61
+ return (f"{project} already exists at {home}\n"
62
+ f"its token is in {home / TOKEN_FILE} — it is never printed twice")
63
+ return (f"created {project} at {home}\n"
64
+ f"token (shown ONCE, kept in {home / TOKEN_FILE}):\n\n {token}\n\n"
65
+ f"open the board at http://<host>/{project}/?token={token}")
@@ -0,0 +1,50 @@
1
+ """What every command's parser repeats. One place, so the flags cannot drift.
2
+
3
+ `--repo` defaults to the cwd because the callers are hooks: git runs them from inside the
4
+ repository, and Claude Code runs them from the project directory, so requiring the path
5
+ would make every hook line longer for no information.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ __all__ = ["add_target", "add_actor", "add_identity", "repo_of"]
14
+
15
+
16
+ def add_target(parser: argparse.ArgumentParser, *, inherit: bool = False) -> None:
17
+ parser.add_argument("--repo", default=_default(".", inherit),
18
+ help="path in the repository (default: the current directory)")
19
+
20
+
21
+ def add_actor(parser: argparse.ArgumentParser, *, inherit: bool = False) -> None:
22
+ parser.add_argument("--actor", default=_default("", inherit),
23
+ help="who is calling: agent:<dev>/<name> or dev:<name>")
24
+
25
+
26
+ def _default(value: str, inherit: bool) -> object:
27
+ """`SUPPRESS` when a PARENT parser already carries this flag.
28
+
29
+ argparse writes a subparser's defaults into the namespace the parent already filled, so
30
+ a group whose subcommands re-declare `--repo` would silently reset it: `taskops tasks
31
+ --repo /x list` would look in the current directory instead. `SUPPRESS` is the only way
32
+ to say "set nothing when it was not given", which leaves the parent's value standing.
33
+ """
34
+ return argparse.SUPPRESS if inherit else value
35
+
36
+
37
+ def add_identity(parser: argparse.ArgumentParser) -> None:
38
+ """`--actor` and `--session`, for the callers that know who they are.
39
+
40
+ Both optional: an actor resolves from $TASKOPS_ACTOR or git, and a session id only
41
+ exists when Claude Code passed one. A hook that has them should say so, because that is
42
+ what links the work to a transcript on the live board.
43
+ """
44
+ parser.add_argument("--actor", default="",
45
+ help="who is calling: agent:<dev>/<name> or dev:<name>")
46
+ parser.add_argument("--session", default="", help="the Claude Code session id")
47
+
48
+
49
+ def repo_of(args: argparse.Namespace) -> Path:
50
+ return Path(str(args.repo))
@@ -0,0 +1,112 @@
1
+ """The subcommands of `taskops tasks`: the names, their flags, and which `run` each reaches.
2
+
3
+ Split from `tasks.py` for the file budget, and the seam is the honest one: that module holds
4
+ the two behaviours this group adds (the compact list, creating one card), this one holds the
5
+ wiring. Most subcommands point straight at a `run` that already exists, which is the whole
6
+ point of the group — `taskops tasks done` and `taskops update --status done` are one code
7
+ path, so they cannot start answering differently.
8
+
9
+ Every subparser inherits `--repo` and `--actor` rather than owning them, so both orders work:
10
+ `taskops tasks --repo /x list` and `taskops tasks list --repo /x`. See `_shared._default`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from typing import Callable
17
+
18
+ from . import ask as ask_cmd
19
+ from . import log as log_cmd
20
+ from . import plan as plan_cmd
21
+ from . import update as update_cmd
22
+ from ._shared import add_actor, add_target
23
+
24
+ __all__ = ["add_subcommands", "add_list_flags"]
25
+
26
+ Runner = Callable[[argparse.Namespace], str]
27
+
28
+
29
+ def add_list_flags(parser: argparse.ArgumentParser) -> None:
30
+ """What the list shows. On BOTH `taskops tasks` and `taskops tasks list`, because the
31
+ bare group name IS the list — a flag that worked on only one of the two spellings would
32
+ be a flag whose absence looks like the feature is missing.
33
+
34
+ `--status` takes any string rather than argparse `choices` so the refusal can be the
35
+ package's own sentence; see `tasks.run_list`.
36
+ """
37
+ parser.add_argument("--all", action="store_true",
38
+ help="show closed tasks too, after the open ones")
39
+ parser.add_argument("--status", default=None, metavar="<status>",
40
+ help="show only tasks in this status")
41
+
42
+
43
+ def add_subcommands(parent: argparse.ArgumentParser, *, listing: Runner,
44
+ adding: Runner, editing: Runner) -> None:
45
+ sub = parent.add_subparsers(dest="subcommand", metavar="<subcommand>")
46
+ add_list_flags(_flags(sub.add_parser("list", help="one line per open task"), listing))
47
+
48
+ _edit_flags(_flags(sub.add_parser("edit", help="rewrite a task's title, spec or priority"),
49
+ editing))
50
+
51
+ show = _flags(sub.add_parser("show", help="read one task in full"), ask_cmd.run)
52
+ show.add_argument("what", metavar="task", help="the task id")
53
+
54
+ _add_flags(_flags(sub.add_parser("add", help="create one task"), adding))
55
+
56
+ from_json = _flags(sub.add_parser("plan", help="create tasks from JSON (a file, or -)"),
57
+ plan_cmd.run)
58
+ from_json.add_argument("source", help="path to a JSON array of tasks, or - for stdin")
59
+
60
+ _close(sub, "done", "finish a task", "done")
61
+ _close(sub, "release", "hand a task back, unfinished", "released")
62
+
63
+ entries = _flags(sub.add_parser("log", help="the agent's conversation for a card"),
64
+ log_cmd.run)
65
+ entries.add_argument("task", help="the task id")
66
+ entries.add_argument("--limit", type=int, default=0,
67
+ help="entries to keep, newest last (default 400)")
68
+
69
+ found = _flags(sub.add_parser("search", help="search titles and specs"), ask_cmd.run)
70
+ found.add_argument("what", metavar="text", help="what to look for")
71
+
72
+
73
+ def _flags(parser: argparse.ArgumentParser, run: Runner) -> argparse.ArgumentParser:
74
+ add_target(parser, inherit=True)
75
+ add_actor(parser, inherit=True)
76
+ parser.set_defaults(run=run)
77
+ return parser
78
+
79
+
80
+ def _add_flags(parser: argparse.ArgumentParser) -> None:
81
+ """One card's worth of `plan`'s JSON, as flags. Anything with a graph in it stays JSON —
82
+ `after` here takes ids only, because an index refers to a batch this command cannot have."""
83
+ parser.add_argument("title", help="what the task is")
84
+ parser.add_argument("--spec", default="", help="the brief: what done looks like")
85
+ parser.add_argument("--after", default="", help="comma-separated ids this waits on")
86
+ parser.add_argument("--files", default="", help="comma-separated edit surface")
87
+ parser.add_argument("--priority", type=int, default=None, help="0 urgent … 3 whenever")
88
+ parser.add_argument("--label", default="", dest="labels", help="comma-separated labels")
89
+
90
+
91
+ def _edit_flags(parser: argparse.ArgumentParser) -> None:
92
+ """The three fields a card can be corrected in. All default to `None` — "not passed" —
93
+ so that `--spec ""` clears a brief instead of being indistinguishable from not saying it.
94
+ Requiring at least one is the use case's job, not argparse's: the CLI is one of three
95
+ surfaces, and a rule only argparse knows is a rule the other two do not have."""
96
+ parser.add_argument("task", help="the task id")
97
+ parser.add_argument("--title", default=None, help="what the task is")
98
+ parser.add_argument("--spec", default=None, help="the brief: what done looks like")
99
+ parser.add_argument("--priority", type=int, default=None, help="0 urgent … 3 whenever")
100
+
101
+
102
+ def _close(sub: "argparse._SubParsersAction[argparse.ArgumentParser]", name: str,
103
+ help_text: str, status: str) -> None:
104
+ """`done` and `release` are `update` with the status already chosen — the two transitions
105
+ a person makes by hand, spelled as the actions they are instead of a flag value to recall.
106
+ The rest of `update`'s namespace is defaulted here, because its `run` reads the whole of it."""
107
+ parser = _flags(sub.add_parser(name, help=help_text), update_cmd.run)
108
+ parser.add_argument("task", help="the task id")
109
+ parser.add_argument("-m", "--comment", default="", help="what happened, for the thread")
110
+ parser.add_argument("--no-code", action="store_true", dest="no_code",
111
+ help="this task legitimately produces no commit")
112
+ parser.set_defaults(status=status, mentions="", blocked_on="")
@@ -0,0 +1,45 @@
1
+ """Which days the caller asked for — argparse's flags turned into ONE `Selector`.
2
+
3
+ Its own module because the refusals are the interesting part and `report.py` already owns the
4
+ flag table and the three ways of emitting a dossier. Nothing here reads a repository: it maps
5
+ what was typed onto what `usecases` resolves, and says no to combinations that would otherwise
6
+ be settled by the order of a chain of ifs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from ...._errors import BadRequest
14
+ from ....usecases import Selector
15
+
16
+ __all__ = ["selector"]
17
+
18
+
19
+ def selector(args: argparse.Namespace) -> Selector:
20
+ """The window, refusing a MIX of ways of asking for it.
21
+
22
+ `report day --last 7d` is somebody who means one of the two and would not notice which
23
+ one won — so neither does taskops. The flags are named after the kind that owns them, and
24
+ borrowing another kind's flag is an error rather than a silently ignored preference.
25
+ """
26
+ if args.kind != "range" and (args.last or args.from_date or args.to):
27
+ raise BadRequest(f"--last, --from and --to belong to `report range` — "
28
+ f"`report {args.kind}` takes {_instead(str(args.kind))}")
29
+ if args.kind == "all":
30
+ return Selector(whole=True)
31
+ if args.kind != "range":
32
+ return Selector(date=str(args.date))
33
+ if not (args.last or args.from_date):
34
+ raise BadRequest("`report range` needs a window — --last 7d (also 2w, 1m), or "
35
+ "--from YYYY-MM-DD [--to YYYY-MM-DD]")
36
+ if args.last and args.from_date:
37
+ raise BadRequest("--last and --from are two ways of naming the same start — pick "
38
+ "one, or the report covers a window nobody asked for")
39
+ return Selector(date=str(args.from_date), to=str(args.to), last=str(args.last))
40
+
41
+
42
+ def _instead(kind: str) -> str:
43
+ """What the kind the caller named DOES accept. Naming the alternative is the difference
44
+ between an error somebody fixes and one they retry with the same flag."""
45
+ return "no window at all — it covers everything" if kind == "all" else "--date"
@@ -0,0 +1,27 @@
1
+ """Read one task in full, or search when you have no id.
2
+
3
+ No parser of its own any more: `taskops ask` was a command for agents, and agents have
4
+ `taskops_ask` over MCP with a better contract. The function stayed exactly where it was,
5
+ because `taskops tasks show` and `taskops tasks search` reach THIS — two implementations of
6
+ "read a task" is how the CLI and the MCP start disagreeing about what a task looks like.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from ....render import render_search, render_view
14
+ from ....usecases import ask as read
15
+ from ....usecases import search
16
+ from ._shared import repo_of
17
+
18
+ __all__ = ["run"]
19
+
20
+
21
+ def run(args: argparse.Namespace) -> str:
22
+ """A bare argument, because a human types `taskops ask tk-4f2a` far more often than a
23
+ query — and an id is recognisable on sight, so guessing which one it is costs nothing."""
24
+ what = str(args.what)
25
+ if what.startswith("tk-"):
26
+ return render_view(read(repo_of(args), what, actor=args.actor))
27
+ return render_search(search(repo_of(args), what), what)