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
taskops/engine/gitio.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Reading git, and the two conventions that bind a commit to a task.
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
branch: tk/<task-id>/<slug>
|
|
5
|
+
commit trailer: Task: tk-4f2a9c
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
Both, not either. The branch is what a human sees in `git branch` and what the guard
|
|
9
|
+
can check BEFORE a commit exists; the trailer is what survives a squash, a rebase and
|
|
10
|
+
a cherry-pick onto main, where the branch name is gone. A system with only the branch
|
|
11
|
+
loses every association the moment the branch is deleted — which is the normal end of
|
|
12
|
+
a branch's life.
|
|
13
|
+
|
|
14
|
+
Every function here degrades rather than raises. This code runs inside git hooks and
|
|
15
|
+
in repositories that are sometimes not repositories at all, and a coordination tool
|
|
16
|
+
that breaks `git commit` because it could not parse something is worse than one that
|
|
17
|
+
records nothing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .._ids import TASK_PREFIX
|
|
27
|
+
|
|
28
|
+
__all__ = ["TRAILER", "task_of_branch", "task_of_message", "add_trailer",
|
|
29
|
+
"current_branch", "head_sha", "commit_message", "changed_files"]
|
|
30
|
+
|
|
31
|
+
TRAILER = "Task"
|
|
32
|
+
|
|
33
|
+
_BRANCH = re.compile(rf"^tk/({re.escape(TASK_PREFIX)}[0-9a-f]+)/")
|
|
34
|
+
_TRAILER_LINE = re.compile(rf"^{TRAILER}:\s*({re.escape(TASK_PREFIX)}[0-9a-f]+)\s*$",
|
|
35
|
+
re.MULTILINE | re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def task_of_branch(branch: str) -> str:
|
|
39
|
+
"""The task a branch belongs to, or "". Anchored, so `feat/tk-1/x` is not a match:
|
|
40
|
+
a near-miss must read as unbound rather than bind to the wrong task."""
|
|
41
|
+
found = _BRANCH.match(branch.strip())
|
|
42
|
+
return found.group(1) if found else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def task_of_message(message: str) -> str:
|
|
46
|
+
"""The task a commit message names in its trailer, or ""."""
|
|
47
|
+
found = _TRAILER_LINE.search(message)
|
|
48
|
+
return found.group(1) if found else ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_trailer(message: str, task_id: str) -> str:
|
|
52
|
+
"""Append `Task: <id>` unless the message already carries one.
|
|
53
|
+
|
|
54
|
+
Idempotent, because the guard runs on every commit and an agent that wrote the
|
|
55
|
+
trailer by hand should not get two. A trailer for a DIFFERENT task is left
|
|
56
|
+
alone — the guard rejects that case rather than silently rewriting what the
|
|
57
|
+
author said.
|
|
58
|
+
"""
|
|
59
|
+
if task_of_message(message):
|
|
60
|
+
return message
|
|
61
|
+
body = message.rstrip("\n")
|
|
62
|
+
separator = "\n\n" if "\n" not in body else "\n"
|
|
63
|
+
return f"{body}{separator}{TRAILER}: {task_id}\n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def current_branch(root: Path) -> str:
|
|
67
|
+
"""The checked-out branch, or "" if there is not one.
|
|
68
|
+
|
|
69
|
+
`symbolic-ref` and NOT `rev-parse --abbrev-ref`, for two reasons that both bit:
|
|
70
|
+
|
|
71
|
+
- On a repository with no commits yet, HEAD is unborn and `rev-parse` FAILS — so the
|
|
72
|
+
branch read as "" and the guard told an agent that `tk/tk-ea4f26/…`, which it was
|
|
73
|
+
standing on, "is not a task branch". Found by hand-driving the CLI in a fresh repo,
|
|
74
|
+
because every test had made an initial commit first.
|
|
75
|
+
- On a detached HEAD, `rev-parse --abbrev-ref` returns the literal string "HEAD", which
|
|
76
|
+
is indistinguishable from a branch actually named HEAD. `symbolic-ref` fails instead,
|
|
77
|
+
and failing is the honest answer: there is no branch.
|
|
78
|
+
"""
|
|
79
|
+
return _git(root, "symbolic-ref", "--short", "HEAD")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def head_sha(root: Path) -> str:
|
|
83
|
+
return _git(root, "rev-parse", "HEAD")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def commit_message(root: Path, sha: str = "HEAD") -> str:
|
|
87
|
+
return _git(root, "log", "-1", "--format=%B", sha)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def changed_files(root: Path, sha: str = "HEAD") -> list[str]:
|
|
91
|
+
"""Paths a commit touched. Empty for the very first commit, which has no parent
|
|
92
|
+
to diff against — reported as nothing rather than as a failure."""
|
|
93
|
+
out = _git(root, "diff-tree", "--no-commit-id", "--name-only", "-r", sha)
|
|
94
|
+
return [line for line in out.splitlines() if line]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _git(root: Path, *args: str) -> str:
|
|
98
|
+
"""One git command's stdout, or "" for any failure at all.
|
|
99
|
+
|
|
100
|
+
Never raises, for the reason in the module docstring. `check=False` plus a
|
|
101
|
+
timeout: a hook that hangs on a git subprocess hangs the developer's commit.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
done = subprocess.run(["git", *args], cwd=root, capture_output=True,
|
|
105
|
+
text=True, timeout=10, check=False)
|
|
106
|
+
except (OSError, subprocess.SubprocessError):
|
|
107
|
+
return ""
|
|
108
|
+
return done.stdout.strip() if done.returncode == 0 else ""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Reading how far a branch is from its remote. Cheap, batched, and never raising.
|
|
2
|
+
|
|
3
|
+
The question a board has to answer is "can anybody else see this work yet", and git already knows.
|
|
4
|
+
One `for-each-ref` returns every local branch with its upstream and its ahead/behind counts, which
|
|
5
|
+
is why this is one subprocess for a whole board rather than one per card.
|
|
6
|
+
|
|
7
|
+
Same rule as the rest of `gitio`: every function degrades to a useless-but-honest answer instead of
|
|
8
|
+
raising. This runs inside a board refresh and inside hooks, and a coordination tool that breaks
|
|
9
|
+
because a branch was deleted mid-read is worse than one that reports `exists: False`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..contracts import BranchState
|
|
19
|
+
|
|
20
|
+
__all__ = ["branch_states", "branch_state", "unknown", "porcelain"]
|
|
21
|
+
|
|
22
|
+
_TRACK = re.compile(r"ahead (\d+)|behind (\d+)")
|
|
23
|
+
_SEP = "\x1f" # unit separator: cannot appear in a ref name, unlike anything printable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def unknown(branch: str) -> BranchState:
|
|
27
|
+
"""The answer for a branch this repository does not have.
|
|
28
|
+
|
|
29
|
+
Its own constructor because the honest empty value is easy to get wrong: `exists=False` with
|
|
30
|
+
`pushed=False` reads as "unpushed work", and the truth is "no idea, not here".
|
|
31
|
+
"""
|
|
32
|
+
return BranchState(branch=branch, upstream="", ahead=0, behind=0, pushed=False,
|
|
33
|
+
exists=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def branch_states(root: Path) -> dict[str, BranchState]:
|
|
37
|
+
"""Every local branch, keyed by name. ONE subprocess for a whole board."""
|
|
38
|
+
fields = f"%(refname:short){_SEP}%(upstream:short){_SEP}%(upstream:track)"
|
|
39
|
+
out = _git(root, "for-each-ref", f"--format={fields}", "refs/heads")
|
|
40
|
+
states: dict[str, BranchState] = {}
|
|
41
|
+
for line in out.splitlines():
|
|
42
|
+
parts = line.split(_SEP)
|
|
43
|
+
if len(parts) != 3 or not parts[0]:
|
|
44
|
+
continue
|
|
45
|
+
states[parts[0]] = _state(parts[0], parts[1], parts[2])
|
|
46
|
+
return states
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def branch_state(root: Path, branch: str) -> BranchState:
|
|
50
|
+
"""One branch. Convenience over `branch_states` for a single-task read."""
|
|
51
|
+
if not branch:
|
|
52
|
+
return unknown(branch)
|
|
53
|
+
return branch_states(root).get(branch, unknown(branch))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _state(branch: str, upstream: str, track: str) -> BranchState:
|
|
57
|
+
"""`[ahead 3, behind 1]` -> the numbers.
|
|
58
|
+
|
|
59
|
+
A branch with no upstream reports `ahead=0`, and that is NOT "nothing to push": git has nothing
|
|
60
|
+
to compare against, so the count is unknown while the work is definitely unpushed. `pushed`
|
|
61
|
+
therefore requires an upstream, which is what keeps a never-pushed branch from reading as clean.
|
|
62
|
+
"""
|
|
63
|
+
ahead, behind = 0, 0
|
|
64
|
+
for match in _TRACK.finditer(track):
|
|
65
|
+
if match.group(1):
|
|
66
|
+
ahead = int(match.group(1))
|
|
67
|
+
elif match.group(2):
|
|
68
|
+
behind = int(match.group(2))
|
|
69
|
+
return BranchState(branch=branch, upstream=upstream, ahead=ahead, behind=behind,
|
|
70
|
+
pushed=bool(upstream) and ahead == 0, exists=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def porcelain(tree: Path) -> list[str]:
|
|
74
|
+
"""The paths `git status` reports as changed or untracked in `tree`, or [] if there is no tree.
|
|
75
|
+
|
|
76
|
+
Used by recovery to find what a dead worker never committed. Untracked files count — that is
|
|
77
|
+
usually exactly what a killed agent leaves, since it writes before it commits.
|
|
78
|
+
"""
|
|
79
|
+
if not tree.is_dir():
|
|
80
|
+
return []
|
|
81
|
+
out = _git(tree, "status", "--porcelain")
|
|
82
|
+
return [line[3:].strip() for line in out.splitlines() if len(line) > 3]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _git(root: Path, *args: str) -> str:
|
|
86
|
+
"""One git command's stdout, or "" for any failure.
|
|
87
|
+
|
|
88
|
+
Never raises, for the reason in the module docstring. `check=False` plus a timeout, because a
|
|
89
|
+
board refresh must not hang on a repository whose index is locked by somebody's rebase.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
done = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True,
|
|
93
|
+
timeout=10, check=False)
|
|
94
|
+
except (OSError, subprocess.SubprocessError):
|
|
95
|
+
return ""
|
|
96
|
+
return done.stdout.strip() if done.returncode == 0 else ""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The log read as a HISTORY: what happened here, in order, and who did it.
|
|
2
|
+
|
|
3
|
+
Its own module rather than a third projection in `activity.py`, because it answers a different
|
|
4
|
+
question. That one is about the workers as they are right now — who holds what, is it alive — and
|
|
5
|
+
this one is about the record they leave behind, which outlives every session that wrote it.
|
|
6
|
+
|
|
7
|
+
Nothing is stored for it. Every fact it shows is already in `events.jsonl`, which is why it could be
|
|
8
|
+
added without a migration and why it cannot drift from what actually happened.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..contracts import Activity, ActorRoll, Event
|
|
14
|
+
from ..storage import Store
|
|
15
|
+
from .activity import tasks_of
|
|
16
|
+
|
|
17
|
+
__all__ = ["activity", "rolls", "MAX_EVENTS"]
|
|
18
|
+
|
|
19
|
+
MAX_EVENTS = 600
|
|
20
|
+
"""How much timeline one read returns. A window wide enough to be interesting on an old project is
|
|
21
|
+
also wide enough to be tens of thousands of events, and nobody scrolls that — so it is bounded, and
|
|
22
|
+
the answer SAYS it was bounded rather than quietly showing a slice as if it were everything."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def activity(store: Store, *, since: float, limit: int = MAX_EVENTS) -> Activity:
|
|
26
|
+
"""The timeline, plus a roll-up per actor, from one pass over the same events.
|
|
27
|
+
|
|
28
|
+
One projection and not two: a per-actor summary computed over a different window than the
|
|
29
|
+
timeline beside it would summarise something the reader cannot see, and it would cost a second
|
|
30
|
+
scan of the same rows to be wrong.
|
|
31
|
+
|
|
32
|
+
The questions this answers — when did this card really move, who has ever touched this area,
|
|
33
|
+
what did that agent do while it held the lease — are answerable from the log and from nothing
|
|
34
|
+
else, because a task row keeps only where things landed.
|
|
35
|
+
"""
|
|
36
|
+
# One extra row is the whole truncation check: asking for `limit + 1` and getting it means
|
|
37
|
+
# there was more, without a second COUNT over the same window.
|
|
38
|
+
found = store.events.newest_since(since, limit=limit + 1)
|
|
39
|
+
kept = found[-limit:] if len(found) > limit else found
|
|
40
|
+
return Activity(repo=str(store.root), since=since, events=list(reversed(kept)),
|
|
41
|
+
titles={task["id"]: task["title"]
|
|
42
|
+
for task in tasks_of(store, [e["task"] for e in kept])},
|
|
43
|
+
actors=rolls(kept), kinds=sorted({e["kind"] for e in kept}),
|
|
44
|
+
truncated=len(found) > limit)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rolls(events: list[Event]) -> list[ActorRoll]:
|
|
48
|
+
"""Per actor, busiest first. Public because `day` needs the same roll-up over its own
|
|
49
|
+
window — two projections summarising actors two different ways is exactly how a board
|
|
50
|
+
starts disagreeing with itself about who did what.
|
|
51
|
+
|
|
52
|
+
Counted from the events rather than from leases, which is what makes an agent that finished an
|
|
53
|
+
hour ago still appear: the lease is gone, the work is not. That is the whole reason this replaced
|
|
54
|
+
a live fleet panel — "who is free" stopped being a question when agents became disposable.
|
|
55
|
+
"""
|
|
56
|
+
seen: dict[str, list[Event]] = {}
|
|
57
|
+
for event in events:
|
|
58
|
+
seen.setdefault(event["actor"], []).append(event)
|
|
59
|
+
out = [_roll(actor, theirs) for actor, theirs in seen.items()]
|
|
60
|
+
return sorted(out, key=lambda roll: (-roll["tasks"], -roll["commits"], roll["actor"]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _roll(actor: str, events: list[Event]) -> ActorRoll:
|
|
64
|
+
"""Tasks, not events: an actor that commented forty times on one card has done less than one
|
|
65
|
+
that closed four, and counting events would rank them the other way round."""
|
|
66
|
+
stamps = [event["ts"] for event in events]
|
|
67
|
+
kinds = [event["kind"] for event in events]
|
|
68
|
+
return ActorRoll(actor=actor, tasks=len({event["task"] for event in events}),
|
|
69
|
+
commits=kinds.count("commit"),
|
|
70
|
+
comments=kinds.count("comment") + kinds.count("message"),
|
|
71
|
+
# A close is its OWN kind — `update` writes `done` rather than `status` when
|
|
72
|
+
# the target is done, precisely so it can be found without reading bodies.
|
|
73
|
+
# This is the only place the fact lives: the task row shows the state, never
|
|
74
|
+
# who moved it there.
|
|
75
|
+
done=kinds.count("done"),
|
|
76
|
+
first_seen=min(stamps), last_seen=max(stamps))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Who is calling — resolved once, from the least surprising source available.
|
|
2
|
+
|
|
3
|
+
An agent rarely knows its own name, and asking it to invent one produces a fleet of
|
|
4
|
+
actors called `agent:claude/agent`. So the resolution order goes from most explicit
|
|
5
|
+
to most inferable, and every step is something a human configured on purpose:
|
|
6
|
+
|
|
7
|
+
1. what the caller passed (a fleet launcher naming its workers)
|
|
8
|
+
2. `$TASKOPS_ACTOR` (the plugin exports it per session)
|
|
9
|
+
3. the git identity, turned into `dev:<local-part>`
|
|
10
|
+
|
|
11
|
+
The last one is why a developer who never configures anything still gets attributed
|
|
12
|
+
work rather than an `unknown`. It is also why `git config user.email` being wrong
|
|
13
|
+
shows up here: the actor on the board is the identity the commits will carry.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .._errors import BadRequest
|
|
23
|
+
from ..contracts import Actor
|
|
24
|
+
|
|
25
|
+
__all__ = ["parse", "resolve", "ENV_ACTOR", "ENV_SESSION"]
|
|
26
|
+
|
|
27
|
+
ENV_ACTOR = "TASKOPS_ACTOR"
|
|
28
|
+
ENV_SESSION = "TASKOPS_SESSION"
|
|
29
|
+
|
|
30
|
+
_FALLBACK = "dev:unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse(actor_id: str) -> Actor:
|
|
34
|
+
"""`agent:berna/one` -> its parts. Raises on a shape nothing can attribute.
|
|
35
|
+
|
|
36
|
+
Strict rather than lenient, unlike most readers in this package: an actor id is
|
|
37
|
+
a JOIN KEY — it addresses an inbox and appears in every event — so accepting a
|
|
38
|
+
malformed one creates a second identity for the same person that nobody notices
|
|
39
|
+
until half their work is filed under it.
|
|
40
|
+
"""
|
|
41
|
+
kind, _, rest = actor_id.strip().partition(":")
|
|
42
|
+
if kind == "dev" and rest and "/" not in rest:
|
|
43
|
+
return Actor(id=f"dev:{rest}", kind="dev", dev=rest)
|
|
44
|
+
if kind == "agent" and "/" in rest:
|
|
45
|
+
dev, _, name = rest.partition("/")
|
|
46
|
+
if dev and name:
|
|
47
|
+
return Actor(id=f"agent:{dev}/{name}", kind="agent", dev=dev)
|
|
48
|
+
raise BadRequest(f"`{actor_id}` is not an actor id — use `dev:<name>` or "
|
|
49
|
+
f"`agent:<dev>/<name>`")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve(root: Path, asked: str = "") -> Actor:
|
|
53
|
+
"""The caller's identity: what it said, else the environment, else git."""
|
|
54
|
+
for candidate in (asked, os.environ.get(ENV_ACTOR, "")):
|
|
55
|
+
if candidate.strip():
|
|
56
|
+
return parse(candidate)
|
|
57
|
+
return parse(_from_git(root))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _from_git(root: Path) -> str:
|
|
61
|
+
"""`dev:<local-part of user.email>`, or a named fallback.
|
|
62
|
+
|
|
63
|
+
Falling back rather than raising: a git identity is missing in a fresh
|
|
64
|
+
container and in a repository nobody configured, and refusing to work there
|
|
65
|
+
would make taskops unusable in exactly the throwaway environments agents run
|
|
66
|
+
in. The fallback is VISIBLE on the board, which is the nudge.
|
|
67
|
+
"""
|
|
68
|
+
email = _git_config(root, "user.email")
|
|
69
|
+
local = email.partition("@")[0].strip()
|
|
70
|
+
return f"dev:{local}" if local else _FALLBACK
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _git_config(root: Path, key: str) -> str:
|
|
74
|
+
"""One git config value, or "" if git is absent, broken or silent.
|
|
75
|
+
|
|
76
|
+
Never raises: this is called during identity resolution, which happens on the
|
|
77
|
+
way into every single call. A missing git must degrade, not take the tool down.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
done = subprocess.run(["git", "config", "--get", key], cwd=root,
|
|
81
|
+
capture_output=True, text=True, timeout=5, check=False)
|
|
82
|
+
except (OSError, subprocess.SubprocessError):
|
|
83
|
+
return ""
|
|
84
|
+
return done.stdout.strip() if done.returncode == 0 else ""
|
taskops/engine/log.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Recording that something happened. The one door into the event log.
|
|
2
|
+
|
|
3
|
+
Every fact enters through `record`, which means three things happen together and
|
|
4
|
+
cannot drift apart: the row is written, the id is derived from the content, and the
|
|
5
|
+
live subscribers are told. A use case that wrote the table directly would be an
|
|
6
|
+
event nobody sees on the board and nobody can replicate.
|
|
7
|
+
|
|
8
|
+
Nothing here decides ANYTHING. `record` does not check a lease or a transition — by
|
|
9
|
+
the time it is called, `machine` has already allowed the move. That separation is
|
|
10
|
+
what lets the ingest path record a commit somebody made by hand outside any claim.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .._clock import now
|
|
18
|
+
from .._ids import event_id
|
|
19
|
+
from .._types import EventKind
|
|
20
|
+
from ..contracts import Event
|
|
21
|
+
from ..storage import Store
|
|
22
|
+
from .bus import BUS
|
|
23
|
+
|
|
24
|
+
__all__ = ["record", "build"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build(*, task: str, actor: str, kind: EventKind, body: dict[str, Any] | None = None,
|
|
28
|
+
ts: float | None = None) -> Event:
|
|
29
|
+
"""An event with its id derived from its content.
|
|
30
|
+
|
|
31
|
+
Split from `record` so a caller can construct one without a database — which is
|
|
32
|
+
what the relay does when it forwards, and what the tests do when they need an
|
|
33
|
+
event whose id they can predict.
|
|
34
|
+
"""
|
|
35
|
+
when = now() if ts is None else ts
|
|
36
|
+
payload = body or {}
|
|
37
|
+
return Event(id=event_id(task=task, actor=actor, kind=kind, body=payload, ts=when),
|
|
38
|
+
task=task, actor=actor, kind=kind, body=payload, ts=when)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def record(store: Store, *, task: str, actor: str, kind: EventKind,
|
|
42
|
+
body: dict[str, Any] | None = None, ts: float | None = None) -> Event:
|
|
43
|
+
"""Write it, then announce it. Returns the event either way.
|
|
44
|
+
|
|
45
|
+
The event is returned even when it was already present, because the caller's
|
|
46
|
+
next step is usually to report what it recorded and a None here would make every
|
|
47
|
+
call site handle a case that is not an error.
|
|
48
|
+
"""
|
|
49
|
+
event = build(task=task, actor=actor, kind=kind, body=body, ts=ts)
|
|
50
|
+
if store.events.append(event):
|
|
51
|
+
BUS.publish(event)
|
|
52
|
+
return event
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def relay(store: Store, event: Event) -> bool:
|
|
56
|
+
"""Accept an event that came from elsewhere, verbatim. True if it was new.
|
|
57
|
+
|
|
58
|
+
The id is NOT recomputed. A foreign event's id is its identity, and rebuilding
|
|
59
|
+
it here would silently fork history the moment a newer taskops adds a body field
|
|
60
|
+
this version does not serialize identically.
|
|
61
|
+
|
|
62
|
+
That means a bad actor could forge an id, which is worth being explicit about:
|
|
63
|
+
the trust boundary is the git remote and the relay's token, not this function.
|
|
64
|
+
"""
|
|
65
|
+
accepted = store.events.append(event, exported=True)
|
|
66
|
+
if accepted:
|
|
67
|
+
BUS.publish(event)
|
|
68
|
+
return accepted
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The state machine, and the ONLY place a status move is decided.
|
|
2
|
+
|
|
3
|
+
Two tables and no `if status ==` anywhere else in the package — `tests/architecture`
|
|
4
|
+
enforces the second half. A transition table plus one convenient status check
|
|
5
|
+
somewhere is two state machines, and the convenient one is always the one that
|
|
6
|
+
forgot the guard.
|
|
7
|
+
|
|
8
|
+
Guards are pure functions of `Facts`, not of a database. That is what makes the
|
|
9
|
+
rules testable from literals — "a claimed task cannot be closed without a commit"
|
|
10
|
+
is three lines here, not a fixture with a repository in it — and it means the use
|
|
11
|
+
case that ASSEMBLES the facts is the only thing that needs a store.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from .._errors import GuardFailed, IllegalTransition
|
|
20
|
+
from .._types import Status
|
|
21
|
+
from ..contracts import Task
|
|
22
|
+
|
|
23
|
+
__all__ = ["Facts", "TRANSITIONS", "check_move", "allowed_from"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Facts:
|
|
28
|
+
"""Everything the guards are allowed to know. Assembled by `usecases.update`."""
|
|
29
|
+
|
|
30
|
+
task: Task
|
|
31
|
+
actor: str
|
|
32
|
+
has_live_lease: bool
|
|
33
|
+
commits: int
|
|
34
|
+
"""Commits bound to this task — the evidence `done` demands."""
|
|
35
|
+
|
|
36
|
+
open_children: int
|
|
37
|
+
no_code: bool
|
|
38
|
+
"""The caller's explicit declaration that this task produces no commit."""
|
|
39
|
+
|
|
40
|
+
justification: str
|
|
41
|
+
"""The comment accompanying `no_code`. A claim with no reasoning is a bypass."""
|
|
42
|
+
|
|
43
|
+
unpushed: int
|
|
44
|
+
"""Commits on this branch no remote has. Recorded on close, never blocking — see `_closing`."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Guard = Callable[[Facts], str | None]
|
|
48
|
+
"""None means allowed; a string is the reason, written for the agent that is stuck."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _needs_lease(facts: Facts) -> str | None:
|
|
52
|
+
if facts.has_live_lease:
|
|
53
|
+
return None
|
|
54
|
+
return (f"{facts.actor} holds no live lease on {facts.task['id']} — claim it "
|
|
55
|
+
f"with taskops_next before working on it")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _needs_evidence(facts: Facts) -> str | None:
|
|
59
|
+
"""`done` requires a commit, or an argued exemption.
|
|
60
|
+
|
|
61
|
+
THE guard that makes the board trustworthy. Without it "done" means "an agent
|
|
62
|
+
said so", which is exactly the failure a human is trying to avoid by reading a
|
|
63
|
+
board instead of the diff. The exemption exists because research and decisions
|
|
64
|
+
are real work — but it has to be declared and reasoned, so that a review can
|
|
65
|
+
see which closures had no code and why.
|
|
66
|
+
"""
|
|
67
|
+
if facts.commits > 0:
|
|
68
|
+
return None
|
|
69
|
+
if facts.no_code and facts.justification.strip():
|
|
70
|
+
return None
|
|
71
|
+
if facts.no_code:
|
|
72
|
+
return ("no_code needs a `comment` saying what was produced instead — "
|
|
73
|
+
"an unexplained exemption is indistinguishable from a shortcut")
|
|
74
|
+
# "on the task's branch" and not "the guard adds the trailer", which was the earlier wording:
|
|
75
|
+
# the trailer is only injected inside Claude Code, where a hook can rewrite the command. From a
|
|
76
|
+
# terminal the BRANCH is what binds the commit, so naming the branch is the advice that is true
|
|
77
|
+
# in both places.
|
|
78
|
+
return (f"{facts.task['id']} has no commit bound to it. Commit your work on the "
|
|
79
|
+
f"task's branch, or pass no_code with a comment if this task legitimately "
|
|
80
|
+
f"produced none")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _needs_children_closed(facts: Facts) -> str | None:
|
|
84
|
+
if facts.open_children == 0:
|
|
85
|
+
return None
|
|
86
|
+
return (f"{facts.task['id']} still has {facts.open_children} open subtask(s) — "
|
|
87
|
+
f"an epic is done when its children are")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _closing(facts: Facts) -> str | None:
|
|
91
|
+
"""Both closing rules, in the order a caller can act on them.
|
|
92
|
+
|
|
93
|
+
Children first: being told to write a commit for an epic whose subtasks are
|
|
94
|
+
unfinished sends the agent to do the wrong work.
|
|
95
|
+
|
|
96
|
+
`unpushed` is deliberately NOT a rule here. It is recorded on the `done` event and shown on the
|
|
97
|
+
board instead, because pushing is not always the closer's job — a task can finish on a branch
|
|
98
|
+
somebody else lands — and a repository with no remote at all would otherwise be unable to close
|
|
99
|
+
anything, which is the most common way taskops is first tried.
|
|
100
|
+
"""
|
|
101
|
+
return _needs_children_closed(facts) or _needs_evidence(facts)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
TRANSITIONS: dict[Status, dict[Status, Guard | None]] = {
|
|
105
|
+
"backlog": {"ready": None, "cancelled": None},
|
|
106
|
+
"ready": {"claimed": _needs_lease, "backlog": None, "cancelled": None},
|
|
107
|
+
"claimed": {"in_progress": _needs_lease, "review": _needs_lease, "done": _closing,
|
|
108
|
+
"ready": None, "blocked": _needs_lease, "cancelled": None},
|
|
109
|
+
"in_progress": {"review": _needs_lease, "done": _closing, "blocked": _needs_lease,
|
|
110
|
+
"ready": None, "cancelled": None},
|
|
111
|
+
"blocked": {"ready": None, "backlog": None, "in_progress": _needs_lease,
|
|
112
|
+
"cancelled": None},
|
|
113
|
+
"review": {"done": _closing, "in_progress": _needs_lease, "blocked": None,
|
|
114
|
+
"cancelled": None},
|
|
115
|
+
"done": {},
|
|
116
|
+
"cancelled": {"backlog": None},
|
|
117
|
+
}
|
|
118
|
+
"""Who may go where.
|
|
119
|
+
|
|
120
|
+
`claimed → done` exists, so `in_progress` is never MANDATORY. Requiring the
|
|
121
|
+
intermediate step would add a call to the lifecycle of every task in the project in
|
|
122
|
+
exchange for no information the commit does not already carry — and it is the GUARD,
|
|
123
|
+
not the path taken to reach it, that protects the board. An agent that wants its work
|
|
124
|
+
shown in flight sets `in_progress`; one that claimed, coded, committed and closed has
|
|
125
|
+
done nothing wrong. (The missing arrow was found by the end-to-end test, which is the
|
|
126
|
+
kind of bureaucracy only a full run reveals.)
|
|
127
|
+
|
|
128
|
+
`done` is TERMINAL on purpose: reopening would make the log say a task was finished
|
|
129
|
+
twice, and the honest record of "we shipped it and it was wrong" is a new task that
|
|
130
|
+
references the old one.
|
|
131
|
+
|
|
132
|
+
`→ ready` from a working status is the RELEASE path, and it is deliberately
|
|
133
|
+
unguarded: an agent that is out of context or out of depth must always be able to
|
|
134
|
+
hand work back, and a guard there would make the alternative — abandoning it until
|
|
135
|
+
the lease lapses — the easier move.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def allowed_from(status: Status) -> tuple[Status, ...]:
|
|
140
|
+
"""Where a task in this status can go. Used in the error message, so a
|
|
141
|
+
rejected caller learns the shape of the machine instead of guessing."""
|
|
142
|
+
return tuple(TRANSITIONS.get(status, {}))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_move(facts: Facts, new: Status) -> None:
|
|
146
|
+
"""Raise unless this move is legal AND earned. Silent on success.
|
|
147
|
+
|
|
148
|
+
Two error types, because they are two different conversations: the arrow does
|
|
149
|
+
not exist (nothing to do about it), versus the arrow exists and the work is not
|
|
150
|
+
there yet (do the work).
|
|
151
|
+
"""
|
|
152
|
+
old: Status = facts.task["status"]
|
|
153
|
+
outgoing = TRANSITIONS.get(old, {})
|
|
154
|
+
if new not in outgoing:
|
|
155
|
+
raise IllegalTransition.between(task=facts.task["id"], old=old, new=new,
|
|
156
|
+
allowed=allowed_from(old))
|
|
157
|
+
guard = outgoing[new]
|
|
158
|
+
refusal = guard(facts) if guard else None
|
|
159
|
+
if refusal:
|
|
160
|
+
raise GuardFailed(refusal)
|