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.
- taskops/__init__.py +39 -0
- taskops/_clock.py +32 -0
- taskops/_errors.py +160 -0
- taskops/_ids.py +63 -0
- taskops/_types.py +106 -0
- taskops/_version.py +7 -0
- taskops/assets/GUIDE.md +220 -0
- taskops/contracts/__init__.py +96 -0
- taskops/contracts/_fields.py +113 -0
- taskops/contracts/actor.py +30 -0
- taskops/contracts/board.py +142 -0
- taskops/contracts/commit.py +36 -0
- taskops/contracts/day.py +150 -0
- taskops/contracts/dep.py +23 -0
- taskops/contracts/event.py +58 -0
- taskops/contracts/gitstate.py +45 -0
- taskops/contracts/index.py +37 -0
- taskops/contracts/lease.py +43 -0
- taskops/contracts/log.py +59 -0
- taskops/contracts/remote.py +48 -0
- taskops/contracts/results.py +84 -0
- taskops/contracts/task.py +94 -0
- taskops/contracts/tools.py +110 -0
- taskops/contracts/wire.py +60 -0
- taskops/engine/__init__.py +34 -0
- taskops/engine/_blocks.py +48 -0
- taskops/engine/_briefs.py +92 -0
- taskops/engine/_chunks.py +66 -0
- taskops/engine/_closed.py +65 -0
- taskops/engine/_entries.py +126 -0
- taskops/engine/_events.py +55 -0
- taskops/engine/_opened.py +51 -0
- taskops/engine/_process.py +80 -0
- taskops/engine/_prompts.py +98 -0
- taskops/engine/_stream.py +129 -0
- taskops/engine/activity.py +94 -0
- taskops/engine/bus.py +42 -0
- taskops/engine/commitline.py +110 -0
- taskops/engine/day.py +142 -0
- taskops/engine/diffstat.py +63 -0
- taskops/engine/gitio.py +108 -0
- taskops/engine/gitstate.py +96 -0
- taskops/engine/history.py +76 -0
- taskops/engine/identity.py +84 -0
- taskops/engine/log.py +68 -0
- taskops/engine/machine.py +160 -0
- taskops/engine/narrate.py +91 -0
- taskops/engine/project.py +57 -0
- taskops/engine/replay.py +142 -0
- taskops/engine/reports.py +67 -0
- taskops/engine/scheduler.py +135 -0
- taskops/engine/transcript.py +127 -0
- taskops/engine/wire.py +92 -0
- taskops/engine/worker.py +115 -0
- taskops/py.typed +0 -0
- taskops/render/__init__.py +40 -0
- taskops/render/_closed_days.py +64 -0
- taskops/render/_dossier.py +68 -0
- taskops/render/_opened.py +64 -0
- taskops/render/_sections.py +51 -0
- taskops/render/_tasklist.py +72 -0
- taskops/render/_text.py +74 -0
- taskops/render/_verbatim.py +65 -0
- taskops/render/ansi.py +92 -0
- taskops/render/board.py +55 -0
- taskops/render/day.py +102 -0
- taskops/render/dispatch.py +104 -0
- taskops/render/inbox.py +27 -0
- taskops/render/log.py +47 -0
- taskops/render/recover.py +64 -0
- taskops/render/report.py +51 -0
- taskops/render/reports.py +57 -0
- taskops/render/results.py +96 -0
- taskops/render/session.py +75 -0
- taskops/render/task.py +88 -0
- taskops/render/tasklist.py +65 -0
- taskops/storage/__init__.py +36 -0
- taskops/storage/_ddl.py +78 -0
- taskops/storage/_delivered.py +51 -0
- taskops/storage/_deps.py +69 -0
- taskops/storage/_events.py +127 -0
- taskops/storage/_leases.py +108 -0
- taskops/storage/_rows.py +90 -0
- taskops/storage/_tasks.py +124 -0
- taskops/storage/locate.py +76 -0
- taskops/storage/schema.py +66 -0
- taskops/storage/store.py +120 -0
- taskops/storage/sync.py +139 -0
- taskops/transports/__init__.py +6 -0
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +3 -0
- taskops/transports/cli/commands/_digest.py +64 -0
- taskops/transports/cli/commands/_serve_init.py +65 -0
- taskops/transports/cli/commands/_shared.py +50 -0
- taskops/transports/cli/commands/_tasks_args.py +112 -0
- taskops/transports/cli/commands/_window.py +45 -0
- taskops/transports/cli/commands/ask.py +27 -0
- taskops/transports/cli/commands/dispatch.py +43 -0
- taskops/transports/cli/commands/init.py +48 -0
- taskops/transports/cli/commands/log.py +22 -0
- taskops/transports/cli/commands/plan.py +43 -0
- taskops/transports/cli/commands/pushpull.py +53 -0
- taskops/transports/cli/commands/recover.py +30 -0
- taskops/transports/cli/commands/remote.py +56 -0
- taskops/transports/cli/commands/report.py +82 -0
- taskops/transports/cli/commands/run_.py +60 -0
- taskops/transports/cli/commands/serve.py +74 -0
- taskops/transports/cli/commands/sync.py +22 -0
- taskops/transports/cli/commands/tasks.py +79 -0
- taskops/transports/cli/commands/ui.py +72 -0
- taskops/transports/cli/commands/update.py +25 -0
- taskops/transports/cli/main.py +85 -0
- taskops/transports/hooks/__init__.py +15 -0
- taskops/transports/hooks/__main__.py +63 -0
- taskops/transports/hooks/_args.py +28 -0
- taskops/transports/hooks/claude.py +78 -0
- taskops/transports/hooks/commit.py +61 -0
- taskops/transports/hooks/events.py +107 -0
- taskops/transports/hooks/record.py +55 -0
- taskops/transports/http/__init__.py +21 -0
- taskops/transports/http/_handler.py +132 -0
- taskops/transports/http/_wire.py +79 -0
- taskops/transports/http/_wsframes.py +116 -0
- taskops/transports/http/agentapi.py +69 -0
- taskops/transports/http/api.py +122 -0
- taskops/transports/http/exchange.py +80 -0
- taskops/transports/http/live.py +160 -0
- taskops/transports/http/policy.py +110 -0
- taskops/transports/http/projects.py +116 -0
- taskops/transports/http/reports.py +75 -0
- taskops/transports/http/router.py +90 -0
- taskops/transports/http/server.py +50 -0
- taskops/transports/http/static.py +86 -0
- taskops/transports/http/ui/app.js +59 -0
- taskops/transports/http/ui/index.html +23 -0
- taskops/transports/http/ui/style.css +1 -0
- taskops/transports/http/websocket.py +60 -0
- taskops/transports/mcp/__init__.py +19 -0
- taskops/transports/mcp/__main__.py +7 -0
- taskops/transports/mcp/_descriptions.py +102 -0
- taskops/transports/mcp/_reads.py +82 -0
- taskops/transports/mcp/_writes.py +72 -0
- taskops/transports/mcp/answers.py +44 -0
- taskops/transports/mcp/arguments.py +104 -0
- taskops/transports/mcp/dispatch.py +47 -0
- taskops/transports/mcp/protocol.py +83 -0
- taskops/transports/mcp/schema.py +50 -0
- taskops/transports/mcp/server.py +49 -0
- taskops/transports/mcp/tools.py +66 -0
- taskops/usecases/__init__.py +56 -0
- taskops/usecases/_entry.py +84 -0
- taskops/usecases/_facts.py +49 -0
- taskops/usecases/_freeing.py +93 -0
- taskops/usecases/_gitignore.py +94 -0
- taskops/usecases/_mirroring.py +57 -0
- taskops/usecases/_narrating.py +77 -0
- taskops/usecases/_project.py +67 -0
- taskops/usecases/_range.py +102 -0
- taskops/usecases/_reasons.py +54 -0
- taskops/usecases/_remotefile.py +62 -0
- taskops/usecases/_reportsync.py +108 -0
- taskops/usecases/_routing.py +91 -0
- taskops/usecases/_wireclient.py +141 -0
- taskops/usecases/ask.py +41 -0
- taskops/usecases/claim.py +104 -0
- taskops/usecases/dispatch.py +160 -0
- taskops/usecases/dossier.py +155 -0
- taskops/usecases/edit.py +71 -0
- taskops/usecases/exchange.py +86 -0
- taskops/usecases/feed.py +138 -0
- taskops/usecases/guard.py +122 -0
- taskops/usecases/hooks.py +127 -0
- taskops/usecases/index.py +62 -0
- taskops/usecases/ingest.py +60 -0
- taskops/usecases/log.py +134 -0
- taskops/usecases/narration.py +91 -0
- taskops/usecases/plan.py +128 -0
- taskops/usecases/pushpull.py +119 -0
- taskops/usecases/recover.py +84 -0
- taskops/usecases/remote.py +78 -0
- taskops/usecases/report.py +87 -0
- taskops/usecases/reportfile.py +106 -0
- taskops/usecases/session.py +134 -0
- taskops/usecases/setup.py +92 -0
- taskops/usecases/sync.py +78 -0
- taskops/usecases/update.py +123 -0
- taskops/usecases/view.py +112 -0
- taskops_cli-0.2.0.dist-info/METADATA +291 -0
- taskops_cli-0.2.0.dist-info/RECORD +193 -0
- taskops_cli-0.2.0.dist-info/WHEEL +5 -0
- taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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)}
|
taskops/engine/replay.py
ADDED
|
@@ -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)"
|