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,91 @@
1
+ """Asking Claude to read the day's dossier and write what it means.
2
+
3
+ The ONE place in taskops that calls a model, and it is deliberately the cheapest possible shape:
4
+ a subprocess running the `claude` binary the developer is already logged into. No SDK, no API
5
+ key, no dependency — the package still installs with nothing, and the narration is paid for by
6
+ the subscription rather than by the token.
7
+
8
+ **The model only ever sees the DOSSIER.** Not the transcripts, not the diffs — the facts the
9
+ report already assembled from the log. That is what keeps a narration cheap, reproducible enough
10
+ to argue with, and unable to leak a conversation into a committed file.
11
+
12
+ This module decides how a long dossier is CUT and STITCHED. What one reading costs, and how the
13
+ text reaches the caller while it is still being written, lives in `_stream`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Callable, Optional
19
+
20
+ from .._errors import NarrationFailed
21
+ from ._chunks import CHUNK_CHARS, slices
22
+ from ._prompts import CHUNK_PROMPT, PROMPT, STITCH_PROMPT
23
+ from ._stream import TIMEOUT, stream_narration
24
+
25
+ __all__ = ["narrate", "stream_narration", "PROMPT", "TIMEOUT", "CHUNK_CHARS"]
26
+
27
+ OnPass = Optional[Callable[[int, int], None]]
28
+ """Told `(this pass, how many)` as each reading starts. A whole-project dossier takes several
29
+ minutes per pass, and a caller that cannot say which one is running has nothing to show but a
30
+ cursor — which is how this looked like a hang."""
31
+
32
+ OnText = Optional[Callable[[str], None]]
33
+ """Told every delta as it arrives. The engine does not print: it hands the fragment to whoever
34
+ asked, and the transport decides whether that is a terminal, a socket, or nothing."""
35
+
36
+
37
+ def narrate(dossier: str, *, model: str = "", timeout: float = TIMEOUT,
38
+ on_pass: OnPass = None, on_text: OnText = None) -> str:
39
+ """The prose for one dossier. Raises `NarrationFailed` with what to do about it.
40
+
41
+ ONE reading when the dossier fits (`_chunks.CHUNK_CHARS`), otherwise one reading per slice
42
+ and a final pass that stitches them. The long path costs N+1 calls and is taken on purpose:
43
+ trimming the prompt instead would produce a report that silently forgets the cards that
44
+ happened to sort last, and nothing on the page would say so.
45
+
46
+ Both callbacks are optional and default to nothing, so every existing caller keeps working
47
+ and a narration nobody is watching costs exactly what it did before.
48
+ """
49
+ parts = slices(dossier)
50
+ total = 1 if len(parts) == 1 else len(parts) + 1
51
+ ask = _reader(model, timeout, total, on_pass, on_text)
52
+ if total == 1:
53
+ return ask(1, PROMPT + dossier)
54
+ told = [ask(i, f"{CHUNK_PROMPT}(slice {i} of {len(parts)})\n\n{part}")
55
+ for i, part in enumerate(parts, start=1)]
56
+ return ask(total, STITCH_PROMPT + "\n\n---\n\n".join(told))
57
+
58
+
59
+ def _reader(model: str, timeout: float, total: int,
60
+ on_pass: OnPass, on_text: OnText) -> Callable[[int, str], str]:
61
+ """One pass of the narration: announced, streamed to the watcher, returned whole.
62
+
63
+ A closure rather than five arguments threaded through every call site — the three ways a
64
+ dossier is read (single, slice, stitch) differ only in their prompt, and that is the one
65
+ thing the returned function takes.
66
+ """
67
+ def ask(n: int, prompt: str) -> str:
68
+ if on_pass:
69
+ on_pass(n, total)
70
+ written: list[str] = []
71
+ for delta in stream_narration(prompt, model=model, timeout=timeout):
72
+ written.append(delta)
73
+ if on_text:
74
+ on_text(delta)
75
+ return _said("".join(written))
76
+
77
+ return ask
78
+
79
+
80
+ def _said(text: str) -> str:
81
+ """The prose, never an empty string.
82
+
83
+ A CLI that is installed but not logged in can finish cleanly having emitted no text at all,
84
+ and a report whose narration is a blank section reads as a taskops bug rather than a login
85
+ problem.
86
+ """
87
+ said = text.strip()
88
+ if not said:
89
+ raise NarrationFailed("claude answered with nothing — check `claude` runs and is "
90
+ "logged in (`claude -p hello`)")
91
+ return said
@@ -0,0 +1,57 @@
1
+ """The state of the WORK: the board, and the three numbers that summarise it.
2
+
3
+ Nothing here is stored. That is the property worth protecting — a new column or a
4
+ different count is a rendering change with no migration, and any number on screen
5
+ can be traced back to the rows that produced it.
6
+
7
+ Who is doing the work lives in `activity`: that reads events and leases, this reads
8
+ tasks and dependencies, and keeping them apart means a change to how liveness is
9
+ judged cannot break how a board is drawn.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .._clock import now
15
+ from .._types import CLOSED_STATUSES, OPEN_STATUSES, STATUSES, WORKING_STATUSES
16
+ from ..contracts import Board, Card, Column
17
+ from ..storage import Store
18
+
19
+ __all__ = ["board", "counts"]
20
+
21
+
22
+ def board(store: Store) -> Board:
23
+ """Every task, in columns, with the counts that make a card readable.
24
+
25
+ The aggregates come from ONE query each (`count_by_task`, `live`) rather than
26
+ three per task: that difference is what separates a board that is fine at fifty
27
+ tasks from one that stalls at five hundred.
28
+ """
29
+ tasks = store.tasks.all()
30
+ commits = store.events.count_by_task("commit")
31
+ live = {lease["task"]: lease for lease in store.leases.live(now())}
32
+ cards = [Card(task=task, lease=live.get(task["id"]),
33
+ blocked_by=len(store.deps.open_blockers_of(task["id"])),
34
+ blocks=len(store.deps.dependents_of(task["id"])),
35
+ commits=commits.get(task["id"], 0))
36
+ for task in tasks]
37
+ columns = [Column(status=status,
38
+ cards=[c for c in cards if c["task"]["status"] == status])
39
+ for status in STATUSES]
40
+ return Board(repo=str(store.root), columns=columns,
41
+ ready=sum(1 for t in tasks if t["status"] == "ready"),
42
+ total=len(tasks))
43
+
44
+
45
+ def counts(store: Store) -> dict[str, int]:
46
+ """Ready / working / blocked, and the open-closed split.
47
+
48
+ `next` reports these when it has nothing to hand over, so "nothing ready" always
49
+ arrives with a diagnosis: everything blocked reads differently from everything
50
+ claimed, and only one of the two is worth waking a human for.
51
+ """
52
+ statuses = [task["status"] for task in store.tasks.all()]
53
+ return {"ready": statuses.count("ready"),
54
+ "working": sum(1 for s in statuses if s in WORKING_STATUSES),
55
+ "blocked": sum(1 for s in statuses if s in ("blocked", "backlog")),
56
+ "open": sum(1 for s in statuses if s in OPEN_STATUSES),
57
+ "closed": sum(1 for s in statuses if s in CLOSED_STATUSES)}
@@ -0,0 +1,142 @@
1
+ """Events -> tasks and dependencies. What makes the log the source of truth rather than a diary.
2
+
3
+ Without this, importing another developer's log gave you their EVENTS and an empty board — which is
4
+ exactly what happened when the usage guide was followed end to end, and what the sync test missed by
5
+ asserting on the log file instead of on the board.
6
+
7
+ It lives in `engine` because every line is a decision: which events describe state, what to do when
8
+ two machines disagree, and what to ignore. `storage.sync` moves bytes; this decides what they mean.
9
+
10
+ **Additive and idempotent.** Replaying the same event twice must change nothing, because a `git pull`
11
+ can deliver the same log a second time and `taskops sync` is safe to run in a loop. Nothing here
12
+ deletes: an event that arrives for a task this machine has never seen creates it, and an event that
13
+ contradicts newer local state loses.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .._types import EDITABLE_FIELDS, Status
19
+ from ..contracts import Event, Task
20
+ from ..storage import Store
21
+
22
+ __all__ = ["apply", "REPLAYED"]
23
+
24
+ REPLAYED = ("created", "blocked", "status", "done", "edited")
25
+ """The kinds that describe STATE. Everything else — comments, commits, activity — is history: worth
26
+ keeping and rendering, but it does not tell you what a task IS. Listing them positively rather than
27
+ skipping a blocklist means a new kind is inert here until somebody decides it should not be."""
28
+
29
+
30
+ def apply(store: Store, events: list[Event]) -> int:
31
+ """Materialise state from events. Returns how many actually changed something."""
32
+ changed = 0
33
+ for event in events:
34
+ if event["kind"] == "created":
35
+ changed += int(_create(store, event))
36
+ elif event["kind"] == "blocked":
37
+ changed += int(_block(store, event))
38
+ elif event["kind"] in ("status", "done"):
39
+ changed += int(_status(store, event))
40
+ elif event["kind"] == "edited":
41
+ changed += int(_edited(store, event))
42
+ return changed
43
+
44
+
45
+ def _edited(store: Store, event: Event) -> bool:
46
+ """A rewritten title, spec or priority — newer-wins, exactly like `_status`.
47
+
48
+ The SAME arbitrator (`event["ts"]` against `task["updated"]`) rather than a per-field
49
+ clock: one `updated` column is what the row has, and a second timestamp per field would
50
+ be a schema for a case — two people editing two different fields of one card within the
51
+ same sync window — that a shared task list barely produces. What it costs is that the
52
+ older edit loses even when it touched another field; both are in the log, so it is
53
+ recoverable, which is the trade `_status` already makes.
54
+ """
55
+ task = store.tasks.get(event["task"])
56
+ field = event["body"].get("field")
57
+ value = event["body"].get("to")
58
+ if task is None or not isinstance(field, str) or field not in EDITABLE_FIELDS:
59
+ return False
60
+ if not isinstance(value, int if field == "priority" else str) or isinstance(value, bool):
61
+ return False
62
+ if event["ts"] <= task["updated"] or task[field] == value: # type: ignore[literal-required]
63
+ return False
64
+ store.tasks.set_field(event["task"], field, value, when=event["ts"])
65
+ return True
66
+
67
+
68
+ def _create(store: Store, event: Event) -> bool:
69
+ """A task this machine has not seen. Existing ones are left ALONE.
70
+
71
+ Not upserted: a `created` event is a statement about the past, and re-applying it would undo
72
+ every local edit made since — a teammate's clone would keep resetting a spec somebody improved.
73
+ """
74
+ if store.tasks.get(event["task"]) is not None:
75
+ return False
76
+ body = event["body"]
77
+ store.tasks.insert(Task(
78
+ id=event["task"], title=str(body.get("title", "(untitled)")),
79
+ spec=str(body.get("spec", "")), status="backlog",
80
+ priority=_int(body.get("priority"), 2), parent=_optional(body.get("parent")),
81
+ labels=_strings(body.get("labels")), files=_strings(body.get("files")),
82
+ created_by=event["actor"], assignee=str(body.get("assignee", "")),
83
+ created=event["ts"], updated=event["ts"]))
84
+ return True
85
+
86
+
87
+ def _block(store: Store, event: Event) -> bool:
88
+ """A dependency edge. Idempotent at the table (`INSERT OR IGNORE`), so this only reports.
89
+
90
+ The blocker may not exist here yet — events arrive in file order, and a plan's edges can precede
91
+ the tasks they point at when two logs merge. The edge is added anyway: `deps` has no foreign key
92
+ for exactly this reason, and `open_blockers_of` joins on `tasks`, so an edge to an unknown task
93
+ simply does not block until that task shows up.
94
+ """
95
+ blocker = str(event["body"].get("on", ""))
96
+ if not blocker or blocker == event["task"]:
97
+ return False
98
+ store.deps.add(blocker, event["task"])
99
+ return True
100
+
101
+
102
+ def _status(store: Store, event: Event) -> bool:
103
+ """A status change, applied only if it is NEWER than what this machine has.
104
+
105
+ `updated` is the arbitrator, and it is a wall clock from another machine — so a badly skewed
106
+ clock can win an argument it should have lost. That is the accepted cost of having no server:
107
+ both edits are in the log either way, so the outcome is always recoverable, and the alternative
108
+ (a vector clock per task) is a great deal of machinery for a case that is already rare.
109
+
110
+ A lease is never replayed. Leases are live local state about a process on one machine, and
111
+ importing one would mean claiming a task on behalf of an agent that is not running here.
112
+ """
113
+ task = store.tasks.get(event["task"])
114
+ target = event["body"].get("to")
115
+ if task is None or not isinstance(target, str) or target not in _STATUSES:
116
+ return False
117
+ if event["ts"] <= task["updated"]:
118
+ return False
119
+ store.tasks.set_status(event["task"], _as_status(target), when=event["ts"])
120
+ return True
121
+
122
+
123
+ _STATUSES = frozenset({"backlog", "ready", "claimed", "in_progress", "blocked", "review",
124
+ "done", "cancelled"})
125
+
126
+
127
+ def _as_status(value: str) -> Status:
128
+ return value # type: ignore[return-value]
129
+
130
+
131
+ def _int(value: object, fallback: int) -> int:
132
+ return value if isinstance(value, int) and not isinstance(value, bool) else fallback
133
+
134
+
135
+ def _optional(value: object) -> str | None:
136
+ return str(value) if isinstance(value, str) and value.strip() else None
137
+
138
+
139
+ def _strings(value: object) -> list[str]:
140
+ if not isinstance(value, list):
141
+ return []
142
+ return [str(item) for item in value] # type: ignore[misc]
@@ -0,0 +1,67 @@
1
+ """The fingerprint a written daily report carries, and what makes one STALE.
2
+
3
+ A dossier on disk is a claim about a day, and the day keeps happening after it is written.
4
+ The header line is what lets a reader tell the difference between "this is the day" and
5
+ "this was the day as of event 812" — without it, a report generated at noon and read at
6
+ midnight is indistinguishable from a complete one, which is the failure mode that makes
7
+ generated documents untrustworthy.
8
+
9
+ `max_seq` and not a timestamp: seq is the log's own local order, so "did anything land
10
+ after this was written" is an integer comparison with no clock skew in it. The `generated`
11
+ field is for the human, and nothing reads it back.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+
18
+ from .._types import LOCAL_ONLY_KINDS
19
+ from ..storage import Store
20
+ from .day import MAX_EVENTS, window
21
+
22
+ __all__ = ["stamp", "stamped_seq", "missing_events", "NO_STAMP"]
23
+
24
+ _PREFIX = "<!-- taskops:report"
25
+
26
+ _FIELD = "max_seq="
27
+
28
+ NO_STAMP = -1
29
+ """What `stamped_seq` answers for a file nobody generated — a hand-written report, or one
30
+ from before this existed. Staleness is then UNKNOWN, and reported as not stale rather than
31
+ as stale: nagging about a file taskops did not write is noise nobody can act on."""
32
+
33
+
34
+ def stamp(label: str, max_seq: int, at: float) -> str:
35
+ """The header line. Machine-readable, and an HTML comment so it renders as nothing."""
36
+ return (f"{_PREFIX} date={label} {_FIELD}{max_seq} "
37
+ f"generated={time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(at))} -->")
38
+
39
+
40
+ def stamped_seq(text: str) -> int:
41
+ """The `max_seq` a written report was generated at, or `NO_STAMP`.
42
+
43
+ Read off the FIRST line only. A stamp further down is not a stamp — it is prose that
44
+ happens to quote one, and trusting it would let a narration re-date its own report.
45
+ """
46
+ first = text.lstrip().splitlines()[0] if text.strip() else ""
47
+ if not first.startswith(_PREFIX):
48
+ return NO_STAMP
49
+ for field in first.split():
50
+ if field.startswith(_FIELD) and field[len(_FIELD):].isdigit():
51
+ return int(field[len(_FIELD):])
52
+ return NO_STAMP
53
+
54
+
55
+ def missing_events(store: Store, from_date: str, to_date: str, since_seq: int) -> int:
56
+ """How many of the window's events landed AFTER the report was generated.
57
+
58
+ Scoped to the report's OWN window, because the fingerprint is a global `max_seq`: work on
59
+ another day would otherwise mark every past report stale forever. Heartbeats are
60
+ excluded for the same reason the dossier excludes them — a live agent would make every
61
+ report of today stale within a minute, and the word would stop meaning anything.
62
+ """
63
+ if since_seq <= NO_STAMP:
64
+ return 0
65
+ start, end = window(from_date)[0], window(to_date)[1]
66
+ return sum(1 for event in store.events.after_seq(since_seq, limit=MAX_EVENTS)
67
+ if start <= event["ts"] < end and event["kind"] not in LOCAL_ONLY_KINDS)
@@ -0,0 +1,135 @@
1
+ """Choosing what an agent does next, and claiming it without a race.
2
+
3
+ Three jobs, in the order they matter:
4
+
5
+ **Unblock.** `ready` is a stored status, so something has to move a task there when
6
+ its last dependency closes. `unblock` is that one writer — nothing else in the
7
+ package may set `ready` — which is what keeps the column from drifting away from the
8
+ dependency graph.
9
+
10
+ **Score.** Priority first, then the anti-collision term: a task whose `files`
11
+ overlap what a LIVE agent is editing sorts last. Two agents in one file is a merge
12
+ conflict that both of them will spend context on, and the cheapest place to prevent
13
+ it is here, before either has started.
14
+
15
+ **Claim.** One INSERT on a primary key, under a transaction that took the write lock
16
+ before it read. See `_leases` for why that ordering is the whole story.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .._clock import LEASE_TTL, now
22
+ from .._ids import slugify
23
+ from .._types import OPEN_STATUSES, WORKING_STATUSES, Status
24
+ from ..contracts import Lease, Task
25
+ from ..storage import Store
26
+
27
+ __all__ = ["unblock", "ready_tasks", "score", "claim", "branch_for", "sweep_dead"]
28
+
29
+ _COLLISION_PENALTY = 100
30
+ """Bigger than any priority band, so a file collision always outranks urgency.
31
+
32
+ Deliberate: an urgent task handed to a second agent in the same file is not urgent
33
+ work, it is two agents about to undo each other. The penalty defers it, never
34
+ hides it — nothing else is pickable and it comes back to the top.
35
+ """
36
+
37
+
38
+ def sweep_dead(store: Store, *, at: float | None = None) -> list[str]:
39
+ """Expire silent leases and walk their tasks back to `ready`. Returns those ids.
40
+
41
+ The crash-recovery path, and it runs at the START of every claim rather than on a
42
+ timer: a daemon would be another process to keep alive, and the moment anyone
43
+ asks for work is exactly when a stuck task matters.
44
+ """
45
+ when = now() if at is None else at
46
+ freed: list[str] = []
47
+ for task_id in store.leases.sweep(when):
48
+ task = store.tasks.get(task_id)
49
+ if task is not None and task["status"] in WORKING_STATUSES:
50
+ store.tasks.set_status(task_id, "ready", when=when)
51
+ freed.append(task_id)
52
+ return freed
53
+
54
+
55
+ def unblock(store: Store, *, at: float | None = None) -> list[str]:
56
+ """Promote every `backlog` task whose dependencies are all closed. Returns ids.
57
+
58
+ Also demotes: a `ready` task that gained a dependency goes back to `backlog`, so
59
+ a mid-flight discovery cannot leave pickable work that is not actually pickable.
60
+ """
61
+ when = now() if at is None else at
62
+ changed: list[str] = []
63
+ for task in store.tasks.with_status(("backlog", "ready")):
64
+ blocked = bool(store.deps.open_blockers_of(task["id"]))
65
+ target: Status = "backlog" if blocked else "ready"
66
+ if task["status"] != target:
67
+ store.tasks.set_status(task["id"], target, when=when)
68
+ if target == "ready":
69
+ changed.append(task["id"])
70
+ return changed
71
+
72
+
73
+ def ready_tasks(store: Store, *, labels: tuple[str, ...] = (),
74
+ actor: str = "") -> list[Task]:
75
+ """Pickable work for this actor, best first. Call `unblock` before this, or it lies.
76
+
77
+ Assignment FILTERS, it does not merely sort: a card assigned to somebody else is not offered at
78
+ all, and the caller's own assigned cards come before the open pool. Without the filter,
79
+ "assigned" would be a label any agent could ignore, and dispatch could not promise a worker that
80
+ the card it was launched for is still there when it asks.
81
+ """
82
+ pool = [t for t in store.tasks.with_status(("ready",))
83
+ if not t["assignee"] or t["assignee"] == actor]
84
+ if labels:
85
+ wanted = set(labels)
86
+ pool = [t for t in pool if wanted & set(t["labels"])]
87
+ busy = _busy_files(store)
88
+ return sorted(pool, key=lambda t: (0 if t["assignee"] == actor and actor else 1,
89
+ score(t, busy), t["created"]))
90
+
91
+
92
+ def _busy_files(store: Store) -> set[str]:
93
+ """Files that a live lease is plausibly editing right now."""
94
+ out: set[str] = set()
95
+ for lease in store.leases.live(now()):
96
+ task = store.tasks.get(lease["task"])
97
+ if task is not None:
98
+ out |= set(task["files"])
99
+ return out
100
+
101
+
102
+ def score(task: Task, busy_files: set[str]) -> int:
103
+ """Lower is better. Priority, plus a penalty for touching occupied files."""
104
+ collides = bool(busy_files & set(task["files"]))
105
+ return task["priority"] + (_COLLISION_PENALTY if collides else 0)
106
+
107
+
108
+ def branch_for(task: Task) -> str:
109
+ """`tk/<id>/<slug>` — the shape the commit guard matches on.
110
+
111
+ Composed here rather than left to the agent because the guard's regex and this
112
+ string are one contract, and an agent inventing `feature/tk-4f2a` would be
113
+ denied its own commits with no clue why.
114
+ """
115
+ return f"tk/{task['id']}/{slugify(task['title'])}"
116
+
117
+
118
+ def claim(store: Store, task: Task, *, actor: str, session: str = "",
119
+ at: float | None = None, ttl: float = LEASE_TTL) -> Lease | None:
120
+ """Take the lease, or None if somebody got there first.
121
+
122
+ None rather than an exception: losing a race is the NORMAL outcome when twenty
123
+ agents ask for work at once, and the caller's response is to try the next task,
124
+ not to handle an error.
125
+ """
126
+ when = now() if at is None else at
127
+ lease = Lease(task=task["id"], actor=actor, session=session, branch="",
128
+ acquired=when, expires=when + ttl)
129
+ return lease if store.leases.acquire(lease) else None
130
+
131
+
132
+ def open_children(store: Store, task_id: str) -> int:
133
+ """How many subtasks are still open — the fact the `done` guard needs."""
134
+ return sum(1 for child in store.tasks.children(task_id)
135
+ if child["status"] in OPEN_STATUSES)
@@ -0,0 +1,127 @@
1
+ """Finding and reading a Claude Code session transcript. Every line here was verified on disk.
2
+
3
+ **`$CLAUDE_CONFIG_DIR` is honoured, and that is not a nicety.** On the machine this was written on it
4
+ is `~/.claude-jp`, so a hardcoded `~/.claude` found a different installation's projects and reported
5
+ that the workers had left no transcript at all. They had; it was in the other home.
6
+
7
+ **A transcript is located by DIRECTORY, not by searching for a session id.** Claude Code names the
8
+ directory after the working directory with every separator turned into a dash, so a dispatched worker
9
+ running in `<repo>/.taskops/trees/tk-856f45` gets a directory of its own — one per CARD. That makes the
10
+ lookup a path computation rather than a scan, and it works retroactively on transcripts written before
11
+ taskops knew it would ever read them.
12
+
13
+ **`gitBranch` verifies what the path suggests.** Every entry carries it, so an entry can be confirmed
14
+ to belong to the card's branch rather than assumed. That is what makes reading the main project's
15
+ directory safe too, where many cards' sessions are mixed together.
16
+
17
+ The format is NOT documented as stable — the Agent SDK offers `get_session_messages()` for this, which
18
+ is a supported contract — so everything here degrades to "nothing found" rather than raising, and an
19
+ entry shape this version does not recognise is passed through as `other` instead of dropped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ from pathlib import Path
27
+ from typing import Any, Iterator, cast
28
+
29
+ __all__ = ["home", "slug_for", "directories_for", "read_entries", "ENV_HOME"]
30
+
31
+ ENV_HOME = "CLAUDE_CONFIG_DIR"
32
+
33
+ _MAX_TEXT = 4000
34
+ """Characters kept per entry. A single assistant turn can run to tens of thousands, and a viewer
35
+ needs the shape of the conversation more than the whole of one message."""
36
+
37
+
38
+ def home() -> Path:
39
+ """Claude Code's config directory. `$CLAUDE_CONFIG_DIR` wins, else `~/.claude`."""
40
+ forced = os.environ.get(ENV_HOME, "").strip()
41
+ return Path(forced).expanduser() if forced else Path.home() / ".claude"
42
+
43
+
44
+ def slug_for(path: Path) -> str:
45
+ """A working directory -> the transcript directory name Claude Code uses.
46
+
47
+ Every separator becomes a dash, including the leading one, so `/Users/b/x` becomes `-Users-b-x`
48
+ and a worktree at `/Users/b/x/.taskops/trees/tk-1` becomes `-Users-b-x--taskops-trees-tk-1` — the
49
+ doubled dash is the dot of `.taskops`, which is where the encoding stops being guessable and starts
50
+ being something to verify. It was.
51
+ """
52
+ return str(path.resolve()).replace("/", "-").replace(".", "-")
53
+
54
+
55
+ def directories_for(root: Path, tree: Path) -> list[Path]:
56
+ """Where this card's transcripts could be: its worktree's directory, then the project's.
57
+
58
+ The worktree's comes FIRST and is per-card, so it needs no filtering. The project's is shared by
59
+ every session anybody ran in the repository, which is why callers filter it by branch.
60
+ """
61
+ base = home() / "projects"
62
+ found = [base / slug_for(tree), base / slug_for(root)]
63
+ return [path for path in found if path.is_dir()]
64
+
65
+
66
+ def read_entries(directory: Path, *, branch: str = "",
67
+ sessions: tuple[str, ...] = ()) -> Iterator[dict[str, Any]]:
68
+ """Every raw entry in every transcript in `directory`, oldest file first.
69
+
70
+ Two filters, and the second is what rescues the ordinary case. `branch` matches the entry's own
71
+ `gitBranch`, which is how the shared project directory can be read without mixing in other cards'
72
+ conversations — but it only works for an agent that made a branch. A person who claimed a card and
73
+ worked on `main` produces entries the branch filter throws away, so `sessions` names transcripts
74
+ directly: a file whose stem is a recorded session id belongs to the card by record, and its entries
75
+ are taken whatever branch they were on.
76
+
77
+ Both empty means take everything, which is correct for a per-card worktree directory.
78
+ """
79
+ for path in sorted(directory.glob("*.jsonl"), key=_mtime):
80
+ named = path.stem in sessions
81
+ for entry in _lines(path):
82
+ if branch and not named and entry.get("gitBranch") not in (branch, None):
83
+ continue
84
+ yield entry
85
+
86
+
87
+ def _mtime(path: Path) -> float:
88
+ try:
89
+ return path.stat().st_mtime
90
+ except OSError:
91
+ return 0.0
92
+
93
+
94
+ def _lines(path: Path) -> Iterator[dict[str, Any]]:
95
+ """Parsed lines, skipping anything unreadable.
96
+
97
+ A malformed line is skipped rather than fatal, for the same reason it is in the event log: this
98
+ file is written by whatever Claude Code version was installed, and one bad line must not make a
99
+ conversation unreadable.
100
+ """
101
+ try:
102
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
103
+ for line in handle:
104
+ parsed = _parse(line)
105
+ if parsed is not None:
106
+ yield parsed
107
+ except OSError:
108
+ return
109
+
110
+
111
+ def _parse(line: str) -> dict[str, Any] | None:
112
+ raw = line.strip()
113
+ if not raw:
114
+ return None
115
+ try:
116
+ found: Any = json.loads(raw)
117
+ except json.JSONDecodeError:
118
+ return None
119
+ return cast("dict[str, Any]", found) if isinstance(found, dict) else None
120
+
121
+
122
+ def clip(text: str) -> str:
123
+ """One entry's text, bounded. Says it was cut rather than trailing off."""
124
+ flat = text.strip()
125
+ if len(flat) <= _MAX_TEXT:
126
+ return flat
127
+ return flat[:_MAX_TEXT] + f"\n… (+{len(flat) - _MAX_TEXT} more characters)"