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/usecases/edit.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""`taskops tasks edit` — rewriting the card itself: its title, its spec, its priority.
|
|
2
|
+
|
|
3
|
+
Until this existed, a spec was whatever the planner typed at 9am and nothing could correct
|
|
4
|
+
it: an agent that read a brief which had since turned out to be wrong had no door back, and
|
|
5
|
+
the only workaround was cancelling the card and planning a new one, which threw away its
|
|
6
|
+
thread and its commits.
|
|
7
|
+
|
|
8
|
+
Separate from `update` on purpose. `update` moves a card through the state machine and talks
|
|
9
|
+
about the WORK; this rewrites what the work IS. They record different events, they answer to
|
|
10
|
+
different guards, and folding them together would put a spec rewrite behind the same lease
|
|
11
|
+
and transition checks that exist to protect a status.
|
|
12
|
+
|
|
13
|
+
**One event per field**, never one `edited` carrying three. Replay applies each event on its
|
|
14
|
+
own merits, so a body holding three fields would make the newer-wins rule all-or-nothing —
|
|
15
|
+
two people editing different fields of the same card would clobber each other instead of
|
|
16
|
+
converging on both edits.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .._clock import now
|
|
24
|
+
from .._errors import BadRequest
|
|
25
|
+
from .._types import CLOSED_STATUSES
|
|
26
|
+
from ..contracts import EditResult, Task
|
|
27
|
+
from ..engine import record
|
|
28
|
+
from ..storage import Store
|
|
29
|
+
from ._project import caller, heartbeat, project
|
|
30
|
+
|
|
31
|
+
__all__ = ["edit"]
|
|
32
|
+
|
|
33
|
+
CLOSED_REFUSAL = "closed cards are history — open a new card referencing it"
|
|
34
|
+
"""Why a done or cancelled card is frozen. The log is what a standup, a report and a
|
|
35
|
+
teammate's clone read back; letting somebody rewrite the spec of work already delivered
|
|
36
|
+
would rewrite the record of what was delivered, which is the one thing this system exists
|
|
37
|
+
to keep honest."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def edit(start: Path | str, task_id: str, *, title: str | None = None,
|
|
41
|
+
spec: str | None = None, priority: int | None = None,
|
|
42
|
+
actor: str = "") -> EditResult:
|
|
43
|
+
"""Apply whatever fields were passed, recording one `edited` event for each."""
|
|
44
|
+
asked = {"title": title, "spec": spec, "priority": priority}
|
|
45
|
+
wanted = {field: value for field, value in asked.items() if value is not None}
|
|
46
|
+
if not wanted:
|
|
47
|
+
raise BadRequest("nothing to edit — pass a `title`, a `spec` or a `priority`")
|
|
48
|
+
with project(start) as store:
|
|
49
|
+
who = caller(store, actor)["id"]
|
|
50
|
+
heartbeat(store, who)
|
|
51
|
+
task = store.tasks.need(task_id)
|
|
52
|
+
if task["status"] in CLOSED_STATUSES:
|
|
53
|
+
raise BadRequest(CLOSED_REFUSAL)
|
|
54
|
+
changed = [f for f, value in wanted.items() if _apply(store, task, who, f, value)]
|
|
55
|
+
return EditResult(task=store.tasks.need(task_id), changed=changed)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _apply(store: Store, task: Task, who: str, field: str, value: object) -> bool:
|
|
59
|
+
"""Write one field, and say so in the log. A no-op edit records NOTHING.
|
|
60
|
+
|
|
61
|
+
An event whose `from` equals its `to` is a lie about the card's history and, worse,
|
|
62
|
+
bumps `updated` — which is the arbitrator replay uses, so a redundant edit on one
|
|
63
|
+
machine would win an argument against a real edit on another.
|
|
64
|
+
"""
|
|
65
|
+
before = task[field] # type: ignore[literal-required]
|
|
66
|
+
if before == value:
|
|
67
|
+
return False
|
|
68
|
+
store.tasks.set_field(task["id"], field, value, when=now())
|
|
69
|
+
record(store, task=task["id"], actor=who, kind="edited",
|
|
70
|
+
body={"field": field, "from": before, "to": value})
|
|
71
|
+
return True
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Events over HTTP: the half of remote sync that a server performs.
|
|
2
|
+
|
|
3
|
+
The git path (`storage.sync`) and this one move the SAME facts and rely on the same property —
|
|
4
|
+
ids are content hashes, so importing an event twice is a primary-key no-op. Nothing here
|
|
5
|
+
resolves a conflict because there is none to resolve: events are facts about the past, and the
|
|
6
|
+
union of two logs is the correct log.
|
|
7
|
+
|
|
8
|
+
**Seq is local, and that is the whole cursor design.** A puller's `after` is a number in the
|
|
9
|
+
SERVER's sequence, so a client keeps one cursor PER remote and may never mix two. Nothing on
|
|
10
|
+
the wire carries a seq inside an event for exactly that reason (`EventTable.page_after`).
|
|
11
|
+
|
|
12
|
+
**Local-only kinds are filtered in BOTH directions.** Outbound because `activity` is a
|
|
13
|
+
per-tool-call heartbeat and replicating it would add thousands of rows a day for nothing;
|
|
14
|
+
inbound because a server does not trust a client to have remembered the rule.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .._errors import BadRequest
|
|
23
|
+
from .._types import LOCAL_ONLY_KINDS
|
|
24
|
+
from ..contracts import Event
|
|
25
|
+
from ..engine import relay, replay, unblock
|
|
26
|
+
from ..storage import event_from
|
|
27
|
+
from ._project import project
|
|
28
|
+
|
|
29
|
+
__all__ = ["accept_events", "pull_events", "MAX_BATCH", "MAX_PAGE"]
|
|
30
|
+
|
|
31
|
+
MAX_BATCH = 500
|
|
32
|
+
"""Events per POST. A cap and not a stream: the body is read into memory before anything looks
|
|
33
|
+
at it, so the limit has to bind before the allocation does. A client with more sends again."""
|
|
34
|
+
|
|
35
|
+
MAX_PAGE = 500
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def accept_events(start: Path | str, raw: list[Any]) -> dict[str, int]:
|
|
39
|
+
"""Relay a foreign batch. Returns how many were NEW here, and this server's cursor.
|
|
40
|
+
|
|
41
|
+
`accepted` is the idempotency signal the client logs: pushing the same batch twice answers
|
|
42
|
+
0 the second time, which is how a person reading the output can tell a retry from a
|
|
43
|
+
duplicate import.
|
|
44
|
+
|
|
45
|
+
The whole batch is coerced BEFORE anything is written, so a malformed event at index 40
|
|
46
|
+
cannot leave 39 relayed and the caller unsure how far it got.
|
|
47
|
+
|
|
48
|
+
Accepted events are MATERIALISED — `replay` then `unblock` — before this returns. Without
|
|
49
|
+
that, a push landed its events and the server's board stayed empty: the exact bug the git
|
|
50
|
+
path hit once and `replay.py` tells the story of. The smoke that caught it here asked the
|
|
51
|
+
server's board for the pushed card and got eight empty columns.
|
|
52
|
+
"""
|
|
53
|
+
if len(raw) > MAX_BATCH:
|
|
54
|
+
raise BadRequest(f"{len(raw)} events in one push — send at most {MAX_BATCH} per "
|
|
55
|
+
f"request and repeat until the cursor stops moving")
|
|
56
|
+
events = [_coerce(item, index) for index, item in enumerate(raw)]
|
|
57
|
+
with project(start) as store:
|
|
58
|
+
fresh = [e for e in events if e is not None and relay(store, e)]
|
|
59
|
+
if fresh:
|
|
60
|
+
replay.apply(store, fresh)
|
|
61
|
+
unblock(store)
|
|
62
|
+
return {"accepted": len(fresh), "max_seq": store.events.max_seq()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pull_events(start: Path | str, *, after: int = 0, limit: int = MAX_PAGE) -> dict[str, Any]:
|
|
66
|
+
"""One page of this server's log after a cursor, plus where the cursor now stands.
|
|
67
|
+
|
|
68
|
+
`more` reports that the PAGE was full, not that events are certainly waiting — the cheap
|
|
69
|
+
answer, and the client's loop stops one request later either way.
|
|
70
|
+
"""
|
|
71
|
+
size = max(1, min(limit, MAX_PAGE))
|
|
72
|
+
with project(start) as store:
|
|
73
|
+
page, cursor = store.events.page_after(after, limit=size)
|
|
74
|
+
shared = [e for e in page if e["kind"] not in LOCAL_ONLY_KINDS]
|
|
75
|
+
return {"events": shared, "max_seq": cursor, "more": len(page) == size}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _coerce(item: Any, index: int) -> Event | None:
|
|
79
|
+
"""A wire object -> an event to relay, or None for one to drop. Raises on nonsense.
|
|
80
|
+
|
|
81
|
+
The id is NOT recomputed — see `engine.log.relay` for why rebuilding it forks history.
|
|
82
|
+
"""
|
|
83
|
+
event = event_from(item)
|
|
84
|
+
if event is None:
|
|
85
|
+
raise BadRequest(f"events[{index}] is not an event — it needs at least `id` and `kind`")
|
|
86
|
+
return None if event["kind"] in LOCAL_ONLY_KINDS else event
|
taskops/usecases/feed.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Following the event log as it grows — what a live board (or a terminal) tails.
|
|
2
|
+
|
|
3
|
+
This is a use case rather than something the HTTP layer does itself, and the architecture test
|
|
4
|
+
is what said so: the feed needs a bus subscription and a cursor read, both of which are engine
|
|
5
|
+
and storage. Putting it here means the transport frames SSE and nothing else, and it means the
|
|
6
|
+
same feed can be tailed from the CLI without a second implementation.
|
|
7
|
+
|
|
8
|
+
**Why it polls a cursor rather than only listening to the bus.** Every writer is a different
|
|
9
|
+
process — MCP is stdio, so each editor session launches its own server, and each git hook is a
|
|
10
|
+
fresh interpreter. An in-process bus cannot see any of them. So the source of truth is
|
|
11
|
+
`events.after_seq(cursor)`, an indexed integer scan, and the bus subscription is layered on top
|
|
12
|
+
purely so the studio's OWN writes (a human's comment) appear instantly rather than on the next
|
|
13
|
+
tick. Both paths end at the same cursor read, so an event can never be delivered twice.
|
|
14
|
+
|
|
15
|
+
**And why it also carries things that are NOT events.** A narration delta is not a fact worth
|
|
16
|
+
storing — see `contracts.wire` — so it never reaches the database and there is no cursor that
|
|
17
|
+
could produce it. It rides the same generator because a browser has ONE socket: the alternative
|
|
18
|
+
is a second stream, a second subscription and a second lifetime to leak. A wire message is
|
|
19
|
+
yielded straight through, and the consequence is documented rather than hidden: it CANNOT be
|
|
20
|
+
recovered. A browser that reconnects missed whatever passed while it was away, and that is fine,
|
|
21
|
+
because the file on disk is the durable copy and the socket is only the window onto it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import queue
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Iterator
|
|
29
|
+
|
|
30
|
+
from ..contracts import Event, WireMessage
|
|
31
|
+
from ..engine import BUS, WIRE, is_wire
|
|
32
|
+
from ..storage import Store, resolve_root
|
|
33
|
+
|
|
34
|
+
__all__ = ["follow", "TICK", "is_wire"]
|
|
35
|
+
|
|
36
|
+
TICK = 0.5
|
|
37
|
+
"""Seconds between cursor polls. Under the threshold where a board feels polled rather than
|
|
38
|
+
live, and two queries a second against an indexed integer column is nothing."""
|
|
39
|
+
|
|
40
|
+
_SIGNAL_DEPTH = 256
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def follow(start: Path | str, after: int = -1, *,
|
|
44
|
+
tick: float = TICK) -> Iterator[Event | WireMessage | None]:
|
|
45
|
+
"""Yield every event as it lands, every wire message as it is published, and `None` on
|
|
46
|
+
each quiet tick.
|
|
47
|
+
|
|
48
|
+
The `None` is not a placeholder — it is how a consumer gets control back on a schedule
|
|
49
|
+
without this module knowing what it wants to do with it. The studio emits an SSE keepalive;
|
|
50
|
+
a terminal tailer redraws a clock; a test stops. Without it, a silent hour would be an hour
|
|
51
|
+
with no way to notice the connection died.
|
|
52
|
+
|
|
53
|
+
`after < 0` means "from now": a board that opened is asking what happens NEXT, and replaying
|
|
54
|
+
the entire history into it would be a spike of thousands of frames it has no use for.
|
|
55
|
+
|
|
56
|
+
**Wire messages are filtered by ROOT, and that filter lives HERE.** `WIRE` is process-global,
|
|
57
|
+
so a server holding many projects open (`transports.http.projects`) broadcasts every narration
|
|
58
|
+
delta to every board — which is prose from one project on another project's screen, and that
|
|
59
|
+
prose names its strategies and its data. Events cannot leak: they come from each project's own
|
|
60
|
+
sqlite, through the cursor read below. A wire message has no such gate, so this generator
|
|
61
|
+
supplies one: it already resolved its own root, and it compares against the root the publisher
|
|
62
|
+
stamped with the same `resolve_root`.
|
|
63
|
+
|
|
64
|
+
It belongs to this function and not to a transport for one reason each: `live.py` sees frames
|
|
65
|
+
but would have to be told which project it is framing for (and every future consumer would
|
|
66
|
+
have to be told again), while `projects.py` mounts whole routers and never sees an individual
|
|
67
|
+
frame at all. One filter, on the only object that knows both the root and the message.
|
|
68
|
+
|
|
69
|
+
A message with NO root is DROPPED, not broadcast. It is the only safe default: an unlabelled
|
|
70
|
+
frame is exactly the pre-fix message that leaked, and delivering it everywhere to be
|
|
71
|
+
compatible with an older publisher would preserve the bug this closes. The cost of dropping
|
|
72
|
+
is a few seconds of missing animation while a mixed-version server is restarted; the cost of
|
|
73
|
+
delivering is one project's prose on another's screen.
|
|
74
|
+
"""
|
|
75
|
+
root = resolve_root(start)
|
|
76
|
+
origin = str(root)
|
|
77
|
+
signal: "queue.Queue[WireMessage | None]" = queue.Queue(maxsize=_SIGNAL_DEPTH)
|
|
78
|
+
# An event enqueues `None` — the SIGNAL to read the cursor, because the row is the payload.
|
|
79
|
+
# A wire message enqueues ITSELF, because there is no row and never will be.
|
|
80
|
+
cancels = (BUS.subscribe(lambda _event: _offer(signal, None)),
|
|
81
|
+
WIRE.subscribe(lambda message: _offer(signal, message)))
|
|
82
|
+
store = Store(root, check_same_thread=False)
|
|
83
|
+
try:
|
|
84
|
+
cursor = store.events.max_seq() if after < 0 else after
|
|
85
|
+
while True:
|
|
86
|
+
messages = [m for m in _drain(signal, tick) if m.get("root") == origin]
|
|
87
|
+
yield from messages
|
|
88
|
+
fresh = store.events.after_seq(cursor)
|
|
89
|
+
yield from fresh
|
|
90
|
+
if fresh:
|
|
91
|
+
cursor = store.events.max_seq()
|
|
92
|
+
elif not messages:
|
|
93
|
+
# Only when NOTHING was delivered. A narration streaming at fifty deltas a second
|
|
94
|
+
# would otherwise emit fifty keepalive ticks a second and burn the stream's
|
|
95
|
+
# lifetime (`live.MAX_TICKS`) in the middle of the thing somebody is watching.
|
|
96
|
+
yield None
|
|
97
|
+
finally:
|
|
98
|
+
# All of them, always. A browser tab that closes leaves this generator un-advanced, and
|
|
99
|
+
# without the release every closed tab would strand a subscriber holding a sqlite
|
|
100
|
+
# connection — which on a board somebody leaves open all day IS the failure mode.
|
|
101
|
+
for cancel in cancels:
|
|
102
|
+
cancel()
|
|
103
|
+
store.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _drain(signal: "queue.Queue[WireMessage | None]", tick: float) -> list[WireMessage]:
|
|
107
|
+
"""Block up to one tick, then take everything that piled up. Returns the wire messages.
|
|
108
|
+
|
|
109
|
+
The `None`s are DISCARDED on purpose: they are a wake-up signal, and the cursor read that
|
|
110
|
+
follows is what decides which events to send. Treating them as the payload would give one
|
|
111
|
+
event two delivery paths and a duplicate whenever both fired. Wire messages are the opposite
|
|
112
|
+
— they ARE the payload, since nothing else in this process knows they happened.
|
|
113
|
+
"""
|
|
114
|
+
out: list[WireMessage] = []
|
|
115
|
+
try:
|
|
116
|
+
first = signal.get(timeout=tick)
|
|
117
|
+
except queue.Empty:
|
|
118
|
+
return out
|
|
119
|
+
if first is not None:
|
|
120
|
+
out.append(first)
|
|
121
|
+
while not signal.empty():
|
|
122
|
+
item = signal.get_nowait()
|
|
123
|
+
if item is not None:
|
|
124
|
+
out.append(item)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _offer(signal: "queue.Queue[WireMessage | None]", item: WireMessage | None) -> None:
|
|
129
|
+
"""Never block a WRITER. A full queue means a consumer is behind, and the cursor poll will
|
|
130
|
+
catch it up anyway — so dropping the signal costs half a second, where blocking here would
|
|
131
|
+
stall the use case that just recorded the event.
|
|
132
|
+
|
|
133
|
+
A dropped WIRE message is a lost fragment of prose and nothing more: the file on disk is
|
|
134
|
+
being written in parallel, so the reader loses a moment of the animation, never the text."""
|
|
135
|
+
try:
|
|
136
|
+
signal.put_nowait(item)
|
|
137
|
+
except queue.Full:
|
|
138
|
+
return
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The commit guard — where git-binding stops being a convention.
|
|
2
|
+
|
|
3
|
+
Called from a Claude Code `PreToolUse` hook on `git commit`. It answers one question:
|
|
4
|
+
may this commit happen, and with what message. The hook turns a refusal into a DENY
|
|
5
|
+
that the agent reads before the commit runs, so a task-less commit never exists rather
|
|
6
|
+
than being reported afterwards.
|
|
7
|
+
|
|
8
|
+
Why enforce at all, when a `post-commit` hook could just record whatever happens: a
|
|
9
|
+
commit with no task is a commit nobody can attribute at review time, and the moment
|
|
10
|
+
one is allowed the board stops being a complete picture of what changed. The refusal is
|
|
11
|
+
also cheap to satisfy — the fix is one `taskops_next` call, and the message says so.
|
|
12
|
+
|
|
13
|
+
What it deliberately does NOT do is judge the CONTENT. Whether the diff matches the
|
|
14
|
+
spec is a question for a model, and putting a model in the path of every commit would
|
|
15
|
+
add seconds and a failure mode to the most common action in the repository.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .._clock import now
|
|
23
|
+
from ..engine import commitline, gitio
|
|
24
|
+
from ..storage import Store
|
|
25
|
+
from ._project import caller, heartbeat, project
|
|
26
|
+
|
|
27
|
+
__all__ = ["Verdict", "check_commit", "check_command"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Verdict:
|
|
31
|
+
"""Allowed or not, the message the commit should carry, and the command to run.
|
|
32
|
+
|
|
33
|
+
A class rather than a tuple because a caller reads all four fields, and a bare
|
|
34
|
+
`(bool, str, str, str)` at a call site is where two of them get swapped.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, *, allowed: bool, reason: str = "", message: str = "",
|
|
38
|
+
task: str = "", command: str = "") -> None:
|
|
39
|
+
self.allowed = allowed
|
|
40
|
+
self.reason = reason
|
|
41
|
+
self.message = message
|
|
42
|
+
self.task = task
|
|
43
|
+
self.command = command
|
|
44
|
+
"""The rewritten shell command, or "" if there is nothing to change.
|
|
45
|
+
|
|
46
|
+
Set only by `check_command`. It exists so the hook transport can hand back an
|
|
47
|
+
`updatedInput` without knowing anything about git commit syntax — deciding what a
|
|
48
|
+
command becomes is a decision, and decisions do not live in a transport.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_command(start: Path | str, command: str, *, actor: str = "") -> Verdict | None:
|
|
53
|
+
"""Judge a whole `git commit` shell command. None if it is not one.
|
|
54
|
+
|
|
55
|
+
The Claude Code matcher can only filter on the TOOL name, so the hook sees every Bash
|
|
56
|
+
call and something has to recognise a commit. That something is here rather than in the
|
|
57
|
+
transport, along with the rewrite: the transport passes through what it received and gets
|
|
58
|
+
back what to do.
|
|
59
|
+
"""
|
|
60
|
+
if not commitline.is_commit(command):
|
|
61
|
+
return None
|
|
62
|
+
verdict = check_commit(start, commitline.message_of(command), actor=actor)
|
|
63
|
+
if verdict.allowed and verdict.message:
|
|
64
|
+
verdict.command = commitline.with_message(command, verdict.message)
|
|
65
|
+
return verdict
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_commit(start: Path | str, message: str, *, actor: str = "") -> Verdict:
|
|
69
|
+
"""May this commit run, and what should its message say.
|
|
70
|
+
|
|
71
|
+
The task is taken from the BRANCH, not from the caller's claims. An agent holding
|
|
72
|
+
three leases and committing on one branch means exactly one of them, and asking it
|
|
73
|
+
to also state which invites the one answer that is wrong.
|
|
74
|
+
"""
|
|
75
|
+
with project(start) as store:
|
|
76
|
+
who = caller(store, actor)["id"]
|
|
77
|
+
heartbeat(store, who)
|
|
78
|
+
branch = gitio.current_branch(store.root)
|
|
79
|
+
return _judge(store, who, branch, message)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _judge(store: Store, who: str, branch: str, message: str) -> Verdict:
|
|
83
|
+
on_branch = gitio.task_of_branch(branch)
|
|
84
|
+
stated = gitio.task_of_message(message)
|
|
85
|
+
if not on_branch:
|
|
86
|
+
return _no_branch(store, who, branch, message, stated)
|
|
87
|
+
if stated and stated != on_branch:
|
|
88
|
+
return Verdict(allowed=False, task=on_branch,
|
|
89
|
+
reason=f"the message says {stated} but the branch is for "
|
|
90
|
+
f"{on_branch} — fix whichever is wrong, do not guess")
|
|
91
|
+
if not store.leases.held_by(on_branch, who, now()):
|
|
92
|
+
return Verdict(allowed=False, task=on_branch,
|
|
93
|
+
reason=f"{who} holds no live lease on {on_branch}. Claim it "
|
|
94
|
+
f"(taskops_next task={on_branch}) before committing — "
|
|
95
|
+
f"another agent may have taken it while you worked")
|
|
96
|
+
return Verdict(allowed=True, task=on_branch,
|
|
97
|
+
message=gitio.add_trailer(message, on_branch))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _no_branch(store: Store, who: str, branch: str, message: str,
|
|
101
|
+
stated: str) -> Verdict:
|
|
102
|
+
"""Not on a `tk/...` branch. The one case with a genuinely useful suggestion.
|
|
103
|
+
|
|
104
|
+
A trailer alone is accepted: a developer cherry-picking onto main, or committing a
|
|
105
|
+
fix straight to a trunk-based repository, is doing something legitimate — and the
|
|
106
|
+
trailer is what actually binds the commit, since a branch name does not survive a
|
|
107
|
+
squash. Only the case with NEITHER is refused, and then the reason names the branch
|
|
108
|
+
to switch to, because "claim a task first" without the command is a puzzle.
|
|
109
|
+
"""
|
|
110
|
+
if stated and store.tasks.get(stated) is not None:
|
|
111
|
+
return Verdict(allowed=True, task=stated, message=message)
|
|
112
|
+
held = store.leases.of_actor(who, now())
|
|
113
|
+
if not held:
|
|
114
|
+
return Verdict(allowed=False,
|
|
115
|
+
reason=f"`{branch or 'HEAD'}` is not a task branch and {who} "
|
|
116
|
+
f"holds no task. Run taskops_next, then commit on the "
|
|
117
|
+
f"branch it names")
|
|
118
|
+
suggestions = ", ".join(f"tk/{lease['task']}/…" for lease in held[:3])
|
|
119
|
+
return Verdict(allowed=False,
|
|
120
|
+
reason=f"`{branch}` is not a task branch. You hold "
|
|
121
|
+
f"{len(held)} task(s) — switch to one of {suggestions} "
|
|
122
|
+
f"(taskops_ask gives the exact branch name)")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Installing the git hooks, without destroying the ones already there.
|
|
2
|
+
|
|
3
|
+
Three decisions carry this file.
|
|
4
|
+
|
|
5
|
+
**The command is the WIRING transport, not the developer's CLI.** `taskops` is what a person
|
|
6
|
+
types; `python -m taskops.transports.hooks` is what git runs. They were the same binary until
|
|
7
|
+
the separation the project declares turned out to be cosmetic, and a shared door is a door
|
|
8
|
+
whose surface is decided by the wrong audience.
|
|
9
|
+
|
|
10
|
+
**The interpreter is absolute, not `taskops` on PATH.** Git hooks run with whatever
|
|
11
|
+
environment git had, and that is routinely not the shell's: a virtualenv that is not active,
|
|
12
|
+
a GUI client with a minimal PATH, a `git commit` from an editor. A bare `taskops` there
|
|
13
|
+
resolves to nothing and the hook does nothing — silently, because every line ends in
|
|
14
|
+
`|| true`. Embedding `sys.executable -m taskops...` binds the hook to the interpreter that
|
|
15
|
+
installed it, which is the one that definitely has taskops in it. (Found by the end-to-end
|
|
16
|
+
test, where `git commit` could not see the venv that pytest was running from.)
|
|
17
|
+
|
|
18
|
+
**Existing hooks are CHAINED, never overwritten.** A repository's `post-commit` may already
|
|
19
|
+
run something somebody depends on, so an existing script is kept and our line is appended
|
|
20
|
+
under a marker — which is also how a re-install finds the line it wrote last time and
|
|
21
|
+
REWRITES it. That refresh is what repairs a repository initialised before the wiring moved
|
|
22
|
+
out of the CLI: its hook names a command that no longer exists, and `|| true` means it says
|
|
23
|
+
nothing about it. `taskops init` again is the whole repair.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import stat
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
__all__ = ["install_hooks", "HOOKS", "MARKER", "runner"]
|
|
33
|
+
|
|
34
|
+
MARKER = "# >>> taskops >>>"
|
|
35
|
+
|
|
36
|
+
HOOKS: dict[str, str] = {
|
|
37
|
+
"post-commit": "ingest commit HEAD",
|
|
38
|
+
"post-checkout": "ingest branch",
|
|
39
|
+
"post-merge": "sync",
|
|
40
|
+
}
|
|
41
|
+
"""Why these three, and why nothing else:
|
|
42
|
+
|
|
43
|
+
- **post-commit** catches every commit, including the ones the PreToolUse guard never saw —
|
|
44
|
+
a human's terminal commit, a `--no-verify`, a rebase landing on a task branch.
|
|
45
|
+
- **post-checkout** records the branch on the lease, so the board can show where an agent is
|
|
46
|
+
working without asking it to report that separately.
|
|
47
|
+
- **post-merge** imports what a `git pull` just brought in. That is the moment another
|
|
48
|
+
developer's events become visible, and the only automatic sync point that matters.
|
|
49
|
+
|
|
50
|
+
Notably NOT `pre-commit`: refusing a commit is the guard's job, and the guard runs inside
|
|
51
|
+
Claude Code where a refusal reaches the agent as text it can act on. A `pre-commit` refusal
|
|
52
|
+
reaches a human as a failed command with no context, and an agent as an error it will try to
|
|
53
|
+
work around.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def runner() -> str:
|
|
58
|
+
"""The command prefix a hook uses: this interpreter, running the wiring transport.
|
|
59
|
+
|
|
60
|
+
Renaming this is the change that breaks in TOTAL silence — every hook line ends in
|
|
61
|
+
`|| true`, so a module that no longer exists just stops binding commits to cards and
|
|
62
|
+
nothing anywhere says so. `tests/e2e/test_hook_wiring.py` commits in a real repository and
|
|
63
|
+
asserts the binding happened; that test is the only thing standing between a rename and a
|
|
64
|
+
board that quietly loses its commits.
|
|
65
|
+
"""
|
|
66
|
+
return f"{sys.executable} -m taskops.transports.hooks"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install_hooks(root: Path) -> tuple[list[str], list[str]]:
|
|
70
|
+
"""Install into `.git/hooks`. Returns (installed, skipped-with-reason).
|
|
71
|
+
|
|
72
|
+
Skipping rather than raising: this runs inside `init`, and a directory that is not a git
|
|
73
|
+
repository yet is an ordinary state — `git init` then `taskops init` again is the fix, and
|
|
74
|
+
the report says so.
|
|
75
|
+
"""
|
|
76
|
+
hooks_dir = root / ".git" / "hooks"
|
|
77
|
+
if not hooks_dir.is_dir():
|
|
78
|
+
return [], [f"{root}/.git/hooks does not exist — not a git repository yet"]
|
|
79
|
+
installed: list[str] = []
|
|
80
|
+
skipped: list[str] = []
|
|
81
|
+
for name, verb in HOOKS.items():
|
|
82
|
+
problem = _install_one(hooks_dir / name, f"{runner()} {verb} >/dev/null 2>&1 || true")
|
|
83
|
+
(skipped if problem else installed).append(f"{name}: {problem}" if problem else name)
|
|
84
|
+
return installed, skipped
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _install_one(path: Path, command: str) -> str:
|
|
88
|
+
""""" on success, else the reason. Appends under the marker if a hook already exists."""
|
|
89
|
+
try:
|
|
90
|
+
if path.is_file() and MARKER in path.read_text(encoding="utf-8"):
|
|
91
|
+
return "" if _refresh(path, command) else "already installed"
|
|
92
|
+
if path.is_file():
|
|
93
|
+
_append(path, command)
|
|
94
|
+
else:
|
|
95
|
+
path.write_text(f"#!/bin/sh\n{MARKER}\n{command}\n", encoding="utf-8")
|
|
96
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
|
|
97
|
+
except OSError as err:
|
|
98
|
+
return str(err)
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _refresh(path: Path, command: str) -> bool:
|
|
103
|
+
"""Rewrite OUR line when it no longer says what it should. True if anything changed.
|
|
104
|
+
|
|
105
|
+
Everything above the marker is the repository's own script and is kept byte for byte;
|
|
106
|
+
everything below it is ours, and ours is the only part that is allowed to move. Without
|
|
107
|
+
this, `init` on an already-initialised repository reports "already installed" over a hook
|
|
108
|
+
line pointing at a module that was renamed — the silent failure this file warns about,
|
|
109
|
+
reached through the command that is supposed to be the repair.
|
|
110
|
+
"""
|
|
111
|
+
current = path.read_text(encoding="utf-8")
|
|
112
|
+
head, _, ours = current.partition(MARKER)
|
|
113
|
+
if ours.strip() == command:
|
|
114
|
+
return False
|
|
115
|
+
path.write_text(f"{head}{MARKER}\n{command}\n", encoding="utf-8")
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _append(path: Path, command: str) -> None:
|
|
120
|
+
"""Add our line to an existing hook, keeping what was there.
|
|
121
|
+
|
|
122
|
+
Appended rather than prepended: the existing script is what the repository's owner put
|
|
123
|
+
there, and it gets to decide the exit code. Our line cannot fail anyway — it ends in
|
|
124
|
+
`|| true`.
|
|
125
|
+
"""
|
|
126
|
+
current = path.read_text(encoding="utf-8").rstrip("\n")
|
|
127
|
+
path.write_text(f"{current}\n\n{MARKER}\n{command}\n", encoding="utf-8")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""What has been written up — the listing behind the Reports view.
|
|
2
|
+
|
|
3
|
+
The one read here that does NOT go through `day_report`: this answers "which reports exist",
|
|
4
|
+
which is a question about the directory, not about the log. It reads each file only far enough
|
|
5
|
+
to answer whether it is stale and whether anybody narrated it, and never returns a body.
|
|
6
|
+
|
|
7
|
+
Today is listed even when its file is missing, because the screen's main verb is generating it
|
|
8
|
+
and a list that shows nothing on a fresh repository would offer nothing to press.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .._clock import now
|
|
16
|
+
from ..contracts import ReportEntry
|
|
17
|
+
from ..engine import date_of, missing_events, stamped_seq
|
|
18
|
+
from ..render import is_pending
|
|
19
|
+
from ..storage import REPORTS_DIR, Store
|
|
20
|
+
from ._project import project
|
|
21
|
+
from ._range import is_day
|
|
22
|
+
from .dossier import report_path
|
|
23
|
+
|
|
24
|
+
__all__ = ["report_index"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def report_index(start: Path | str) -> list[ReportEntry]:
|
|
28
|
+
"""Every report on disk, newest first, with today at the top whether or not it exists."""
|
|
29
|
+
with project(start) as store:
|
|
30
|
+
today = date_of(now())
|
|
31
|
+
directory = store.root / REPORTS_DIR
|
|
32
|
+
found = sorted(directory.glob("*.md"), key=lambda path: path.stem, reverse=True) \
|
|
33
|
+
if directory.is_dir() else []
|
|
34
|
+
entries = [_entry(store, path) for path in found]
|
|
35
|
+
if not any(entry["label"] == today for entry in entries):
|
|
36
|
+
entries.insert(0, ReportEntry(label=today, path=str(report_path(store.root, today)),
|
|
37
|
+
exists=False, stale=False, missing_events=0,
|
|
38
|
+
has_narration=False, bytes=0))
|
|
39
|
+
return entries
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _entry(store: Store, path: Path) -> ReportEntry:
|
|
43
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
44
|
+
behind = _behind(store, path.stem, text)
|
|
45
|
+
return ReportEntry(label=path.stem, path=str(path), exists=True, stale=behind > 0,
|
|
46
|
+
missing_events=behind, has_narration=not is_pending(text),
|
|
47
|
+
bytes=len(text.encode("utf-8")))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _behind(store: Store, label: str, text: str) -> int:
|
|
51
|
+
"""Staleness is only meaningful for a SINGLE day.
|
|
52
|
+
|
|
53
|
+
`missing_events` counts inside one calendar window, so a report named for a range — or for
|
|
54
|
+
`all` — has no window to count against. The label is opaque by contract: anything that
|
|
55
|
+
assumed it parses as a date would raise the first time somebody writes a weekly report.
|
|
56
|
+
"""
|
|
57
|
+
if not is_day(label):
|
|
58
|
+
return 0
|
|
59
|
+
# Both ends are the same date: a day IS the one-day window, which is exactly why the
|
|
60
|
+
# range work could generalise `missing_events` without this call meaning something else.
|
|
61
|
+
return missing_events(store, label, label, stamped_seq(text))
|
|
62
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Recording what git actually did. Called from a `post-commit` hook.
|
|
2
|
+
|
|
3
|
+
This is the half of git-binding that does not need permission. The guard runs before a
|
|
4
|
+
commit and can refuse; this runs after one and only records — which is why it also
|
|
5
|
+
catches every commit the guard never saw: a human's `git commit` in a terminal, a
|
|
6
|
+
`--no-verify`, a merge, a rebase that rewrote history onto a task branch.
|
|
7
|
+
|
|
8
|
+
Recording is unconditional and idempotent. A commit whose task cannot be determined is
|
|
9
|
+
skipped rather than attributed to a guess, because a wrong attribution is worse than a
|
|
10
|
+
missing one: the board would show evidence for a task that has none, and the `done`
|
|
11
|
+
guard would let it close.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..contracts import Event
|
|
19
|
+
from ..engine import gitio, record
|
|
20
|
+
from ._project import caller, project
|
|
21
|
+
|
|
22
|
+
__all__ = ["ingest_commit"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ingest_commit(start: Path | str, sha: str = "HEAD", *, actor: str = "") -> Event | None:
|
|
26
|
+
"""Bind one commit to its task. None if there is no task to bind it to.
|
|
27
|
+
|
|
28
|
+
The task comes from the trailer FIRST and the branch second. That order matters
|
|
29
|
+
after a rebase: the branch is whatever is checked out now, the trailer is what the
|
|
30
|
+
author wrote, and when they disagree the author is right.
|
|
31
|
+
"""
|
|
32
|
+
with project(start) as store:
|
|
33
|
+
resolved = gitio.head_sha(store.root) if sha == "HEAD" else sha
|
|
34
|
+
message = gitio.commit_message(store.root, resolved)
|
|
35
|
+
task = gitio.task_of_message(message) or \
|
|
36
|
+
gitio.task_of_branch(gitio.current_branch(store.root))
|
|
37
|
+
if not task or store.tasks.get(task) is None:
|
|
38
|
+
return None
|
|
39
|
+
return record(store, task=task, actor=caller(store, actor)["id"],
|
|
40
|
+
kind="commit",
|
|
41
|
+
body={"sha": resolved, "subject": message.splitlines()[0],
|
|
42
|
+
"files": gitio.changed_files(store.root, resolved)})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ingest_branch(start: Path | str, branch: str = "", *, actor: str = "") -> Event | None:
|
|
46
|
+
"""Note that a task's branch exists, and record it on the lease.
|
|
47
|
+
|
|
48
|
+
Called from `post-checkout`. The lease carries the branch so the live board can show
|
|
49
|
+
where an agent is working, and so a later `taskops_ask` can name the branch instead
|
|
50
|
+
of asking the agent to remember it.
|
|
51
|
+
"""
|
|
52
|
+
with project(start) as project_store:
|
|
53
|
+
name = branch or gitio.current_branch(project_store.root)
|
|
54
|
+
task = gitio.task_of_branch(name)
|
|
55
|
+
if not task or project_store.tasks.get(task) is None:
|
|
56
|
+
return None
|
|
57
|
+
project_store.leases.set_branch(task_id=task, branch=name)
|
|
58
|
+
return record(project_store, task=task,
|
|
59
|
+
actor=caller(project_store, actor)["id"],
|
|
60
|
+
kind="branch", body={"branch": name})
|