taskops-cli 0.2.0__py3-none-any.whl → 0.3.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 +11 -24
- taskops/_clock.py +21 -20
- taskops/_errors.py +30 -135
- taskops/_ids.py +55 -47
- taskops/_json.py +48 -0
- taskops/_locate.py +106 -0
- taskops/_version.py +2 -5
- taskops/_wire.py +77 -0
- taskops/board.py +196 -0
- taskops/cli/__init__.py +7 -0
- taskops/cli/__main__.py +7 -0
- taskops/cli/admin.py +111 -0
- taskops/cli/claude.py +196 -0
- taskops/cli/commands.py +173 -0
- taskops/cli/enrol.py +45 -0
- taskops/cli/main.py +165 -0
- taskops/cli/operate.py +178 -0
- taskops/cli/push.py +195 -0
- taskops/cli/remote.py +141 -0
- taskops/cli/serving.py +157 -0
- taskops/cli/watch.py +51 -0
- taskops/cli/wording.py +93 -0
- taskops/core/__init__.py +3 -0
- taskops/core/actors.py +77 -0
- taskops/core/challenge.py +98 -0
- taskops/core/chapters.py +68 -0
- taskops/core/event.py +101 -0
- taskops/core/graph.py +142 -0
- taskops/core/hours.py +123 -0
- taskops/core/machine.py +135 -0
- taskops/core/mentions.py +88 -0
- taskops/core/replay.py +146 -0
- taskops/core/review.py +66 -0
- taskops/core/scope.py +113 -0
- taskops/core/seams.py +127 -0
- taskops/core/types.py +196 -0
- taskops/gitwork/__init__.py +3 -0
- taskops/gitwork/bind.py +150 -0
- taskops/gitwork/catchup.py +59 -0
- taskops/gitwork/claudefiles.py +97 -0
- taskops/gitwork/diff.py +154 -0
- taskops/gitwork/install.py +125 -0
- taskops/gitwork/landing.py +104 -0
- taskops/gitwork/patch.py +64 -0
- taskops/gitwork/remote.py +147 -0
- taskops/gitwork/run.py +102 -0
- taskops/gitwork/sig.py +109 -0
- taskops/gitwork/trailer.py +60 -0
- taskops/gitwork/trees.py +114 -0
- taskops/http/__init__.py +3 -0
- taskops/http/admin.py +153 -0
- taskops/http/auth.py +85 -0
- taskops/http/feed.py +178 -0
- taskops/http/gitdoor.py +103 -0
- taskops/http/grants.py +69 -0
- taskops/http/handler.py +164 -0
- taskops/http/ingest.py +181 -0
- taskops/http/login.py +155 -0
- taskops/http/mounts.py +170 -0
- taskops/http/rpc.py +141 -0
- taskops/http/scoped.py +40 -0
- taskops/http/server.py +53 -0
- taskops/http/static.py +96 -0
- taskops/http/upstream.py +141 -0
- taskops/http/watcher.py +76 -0
- taskops/identity.py +112 -0
- taskops/mcp/__init__.py +3 -0
- taskops/{transports/mcp → mcp}/__main__.py +1 -1
- taskops/mcp/before.py +149 -0
- taskops/mcp/boards.py +62 -0
- taskops/mcp/boardview.py +152 -0
- taskops/mcp/brief.py +105 -0
- taskops/mcp/dossier.py +162 -0
- taskops/mcp/fields.py +79 -0
- taskops/mcp/gitmoves.py +191 -0
- taskops/mcp/hello.py +76 -0
- taskops/mcp/integrate.py +161 -0
- taskops/mcp/render.py +88 -0
- taskops/mcp/schema.py +166 -0
- taskops/mcp/server.py +143 -0
- taskops/mcp/thread.py +40 -0
- taskops/mcp/tools.py +167 -0
- taskops/session.py +109 -0
- taskops/store/__init__.py +3 -0
- taskops/store/cache.py +164 -0
- taskops/store/creds.py +165 -0
- taskops/store/live.py +193 -0
- taskops/store/log.py +77 -0
- taskops/store/pubkeys.py +61 -0
- taskops/store/reviews.py +94 -0
- taskops/store/server.py +185 -0
- taskops/store/stores.py +110 -0
- taskops/ui/app.js +60 -0
- taskops/ui/index.html +20 -0
- taskops/ui/style.css +1 -0
- taskops/verbs/__init__.py +154 -0
- taskops/verbs/_args.py +97 -0
- taskops/verbs/_cards.py +59 -0
- taskops/verbs/_context.py +189 -0
- taskops/verbs/_facts.py +163 -0
- taskops/verbs/_mentions.py +80 -0
- taskops/verbs/_rows.py +46 -0
- taskops/verbs/_waiting.py +57 -0
- taskops/verbs/assign.py +115 -0
- taskops/verbs/card.py +54 -0
- taskops/verbs/events.py +60 -0
- taskops/verbs/plan.py +154 -0
- taskops/verbs/project.py +109 -0
- taskops/verbs/pulse.py +165 -0
- taskops/verbs/record.py +99 -0
- taskops/verbs/report.py +120 -0
- taskops/verbs/review.py +88 -0
- taskops/verbs/take.py +106 -0
- taskops/verbs/update.py +193 -0
- taskops_cli-0.3.0.dist-info/METADATA +239 -0
- taskops_cli-0.3.0.dist-info/RECORD +119 -0
- {taskops_cli-0.2.0.dist-info → taskops_cli-0.3.0.dist-info}/WHEEL +1 -2
- taskops_cli-0.3.0.dist-info/entry_points.txt +2 -0
- taskops/_types.py +0 -106
- taskops/assets/GUIDE.md +0 -220
- taskops/contracts/__init__.py +0 -96
- taskops/contracts/_fields.py +0 -113
- taskops/contracts/actor.py +0 -30
- taskops/contracts/board.py +0 -142
- taskops/contracts/commit.py +0 -36
- taskops/contracts/day.py +0 -150
- taskops/contracts/dep.py +0 -23
- taskops/contracts/event.py +0 -58
- taskops/contracts/gitstate.py +0 -45
- taskops/contracts/index.py +0 -37
- taskops/contracts/lease.py +0 -43
- taskops/contracts/log.py +0 -59
- taskops/contracts/remote.py +0 -48
- taskops/contracts/results.py +0 -84
- taskops/contracts/task.py +0 -94
- taskops/contracts/tools.py +0 -110
- taskops/contracts/wire.py +0 -60
- taskops/engine/__init__.py +0 -34
- taskops/engine/_blocks.py +0 -48
- taskops/engine/_briefs.py +0 -92
- taskops/engine/_chunks.py +0 -66
- taskops/engine/_closed.py +0 -65
- taskops/engine/_entries.py +0 -126
- taskops/engine/_events.py +0 -55
- taskops/engine/_opened.py +0 -51
- taskops/engine/_process.py +0 -80
- taskops/engine/_prompts.py +0 -98
- taskops/engine/_stream.py +0 -129
- taskops/engine/activity.py +0 -94
- taskops/engine/bus.py +0 -42
- taskops/engine/commitline.py +0 -110
- taskops/engine/day.py +0 -142
- taskops/engine/diffstat.py +0 -63
- taskops/engine/gitio.py +0 -108
- taskops/engine/gitstate.py +0 -96
- taskops/engine/history.py +0 -76
- taskops/engine/identity.py +0 -84
- taskops/engine/log.py +0 -68
- taskops/engine/machine.py +0 -160
- taskops/engine/narrate.py +0 -91
- taskops/engine/project.py +0 -57
- taskops/engine/replay.py +0 -142
- taskops/engine/reports.py +0 -67
- taskops/engine/scheduler.py +0 -135
- taskops/engine/transcript.py +0 -127
- taskops/engine/wire.py +0 -92
- taskops/engine/worker.py +0 -115
- taskops/render/__init__.py +0 -40
- taskops/render/_closed_days.py +0 -64
- taskops/render/_dossier.py +0 -68
- taskops/render/_opened.py +0 -64
- taskops/render/_sections.py +0 -51
- taskops/render/_tasklist.py +0 -72
- taskops/render/_text.py +0 -74
- taskops/render/_verbatim.py +0 -65
- taskops/render/ansi.py +0 -92
- taskops/render/board.py +0 -55
- taskops/render/day.py +0 -102
- taskops/render/dispatch.py +0 -104
- taskops/render/inbox.py +0 -27
- taskops/render/log.py +0 -47
- taskops/render/recover.py +0 -64
- taskops/render/report.py +0 -51
- taskops/render/reports.py +0 -57
- taskops/render/results.py +0 -96
- taskops/render/session.py +0 -75
- taskops/render/task.py +0 -88
- taskops/render/tasklist.py +0 -65
- taskops/storage/__init__.py +0 -36
- taskops/storage/_ddl.py +0 -78
- taskops/storage/_delivered.py +0 -51
- taskops/storage/_deps.py +0 -69
- taskops/storage/_events.py +0 -127
- taskops/storage/_leases.py +0 -108
- taskops/storage/_rows.py +0 -90
- taskops/storage/_tasks.py +0 -124
- taskops/storage/locate.py +0 -76
- taskops/storage/schema.py +0 -66
- taskops/storage/store.py +0 -120
- taskops/storage/sync.py +0 -139
- taskops/transports/__init__.py +0 -6
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +0 -3
- taskops/transports/cli/commands/_digest.py +0 -64
- taskops/transports/cli/commands/_serve_init.py +0 -65
- taskops/transports/cli/commands/_shared.py +0 -50
- taskops/transports/cli/commands/_tasks_args.py +0 -112
- taskops/transports/cli/commands/_window.py +0 -45
- taskops/transports/cli/commands/ask.py +0 -27
- taskops/transports/cli/commands/dispatch.py +0 -43
- taskops/transports/cli/commands/init.py +0 -48
- taskops/transports/cli/commands/log.py +0 -22
- taskops/transports/cli/commands/plan.py +0 -43
- taskops/transports/cli/commands/pushpull.py +0 -53
- taskops/transports/cli/commands/recover.py +0 -30
- taskops/transports/cli/commands/remote.py +0 -56
- taskops/transports/cli/commands/report.py +0 -82
- taskops/transports/cli/commands/run_.py +0 -60
- taskops/transports/cli/commands/serve.py +0 -74
- taskops/transports/cli/commands/sync.py +0 -22
- taskops/transports/cli/commands/tasks.py +0 -79
- taskops/transports/cli/commands/ui.py +0 -72
- taskops/transports/cli/commands/update.py +0 -25
- taskops/transports/cli/main.py +0 -85
- taskops/transports/hooks/__init__.py +0 -15
- taskops/transports/hooks/__main__.py +0 -63
- taskops/transports/hooks/_args.py +0 -28
- taskops/transports/hooks/claude.py +0 -78
- taskops/transports/hooks/commit.py +0 -61
- taskops/transports/hooks/events.py +0 -107
- taskops/transports/hooks/record.py +0 -55
- taskops/transports/http/__init__.py +0 -21
- taskops/transports/http/_handler.py +0 -132
- taskops/transports/http/_wire.py +0 -79
- taskops/transports/http/_wsframes.py +0 -116
- taskops/transports/http/agentapi.py +0 -69
- taskops/transports/http/api.py +0 -122
- taskops/transports/http/exchange.py +0 -80
- taskops/transports/http/live.py +0 -160
- taskops/transports/http/policy.py +0 -110
- taskops/transports/http/projects.py +0 -116
- taskops/transports/http/reports.py +0 -75
- taskops/transports/http/router.py +0 -90
- taskops/transports/http/server.py +0 -50
- taskops/transports/http/static.py +0 -86
- taskops/transports/http/ui/app.js +0 -59
- taskops/transports/http/ui/index.html +0 -23
- taskops/transports/http/ui/style.css +0 -1
- taskops/transports/http/websocket.py +0 -60
- taskops/transports/mcp/__init__.py +0 -19
- taskops/transports/mcp/_descriptions.py +0 -102
- taskops/transports/mcp/_reads.py +0 -82
- taskops/transports/mcp/_writes.py +0 -72
- taskops/transports/mcp/answers.py +0 -44
- taskops/transports/mcp/arguments.py +0 -104
- taskops/transports/mcp/dispatch.py +0 -47
- taskops/transports/mcp/protocol.py +0 -83
- taskops/transports/mcp/schema.py +0 -50
- taskops/transports/mcp/server.py +0 -49
- taskops/transports/mcp/tools.py +0 -66
- taskops/usecases/__init__.py +0 -56
- taskops/usecases/_entry.py +0 -84
- taskops/usecases/_facts.py +0 -49
- taskops/usecases/_freeing.py +0 -93
- taskops/usecases/_gitignore.py +0 -94
- taskops/usecases/_mirroring.py +0 -57
- taskops/usecases/_narrating.py +0 -77
- taskops/usecases/_project.py +0 -67
- taskops/usecases/_range.py +0 -102
- taskops/usecases/_reasons.py +0 -54
- taskops/usecases/_remotefile.py +0 -62
- taskops/usecases/_reportsync.py +0 -108
- taskops/usecases/_routing.py +0 -91
- taskops/usecases/_wireclient.py +0 -141
- taskops/usecases/ask.py +0 -41
- taskops/usecases/claim.py +0 -104
- taskops/usecases/dispatch.py +0 -160
- taskops/usecases/dossier.py +0 -155
- taskops/usecases/edit.py +0 -71
- taskops/usecases/exchange.py +0 -86
- taskops/usecases/feed.py +0 -138
- taskops/usecases/guard.py +0 -122
- taskops/usecases/hooks.py +0 -127
- taskops/usecases/index.py +0 -62
- taskops/usecases/ingest.py +0 -60
- taskops/usecases/log.py +0 -134
- taskops/usecases/narration.py +0 -91
- taskops/usecases/plan.py +0 -128
- taskops/usecases/pushpull.py +0 -119
- taskops/usecases/recover.py +0 -84
- taskops/usecases/remote.py +0 -78
- taskops/usecases/report.py +0 -87
- taskops/usecases/reportfile.py +0 -106
- taskops/usecases/session.py +0 -134
- taskops/usecases/setup.py +0 -92
- taskops/usecases/sync.py +0 -78
- taskops/usecases/update.py +0 -123
- taskops/usecases/view.py +0 -112
- taskops_cli-0.2.0.dist-info/METADATA +0 -291
- taskops_cli-0.2.0.dist-info/RECORD +0 -193
- taskops_cli-0.2.0.dist-info/entry_points.txt +0 -2
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +0 -21
- taskops_cli-0.2.0.dist-info/top_level.txt +0 -1
taskops/__init__.py
CHANGED
|
@@ -1,39 +1,26 @@
|
|
|
1
|
-
"""taskops —
|
|
1
|
+
"""taskops — a shared work board for teams of coding agents.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
The error types are exported eagerly because a caller has to be able to write
|
|
8
|
-
`except taskops.LeaseHeld` before it has opened anything. Everything else stays
|
|
9
|
-
behind its own module so that importing this package costs a dict lookup — the
|
|
10
|
-
MCP host imports it to list tools long before it asks for any work.
|
|
3
|
+
The public surface is deliberately tiny: open a board, call a verb, handle one
|
|
4
|
+
error tree. Everything else (MCP tools, the HTTP server, the git hooks) is a
|
|
5
|
+
transport built on top of exactly this.
|
|
11
6
|
"""
|
|
12
7
|
|
|
13
8
|
from __future__ import annotations
|
|
14
9
|
|
|
15
10
|
from ._errors import (
|
|
16
|
-
|
|
11
|
+
Refused,
|
|
12
|
+
NotFound,
|
|
17
13
|
BadRequest,
|
|
18
|
-
|
|
19
|
-
IllegalTransition,
|
|
20
|
-
LeaseHeld,
|
|
21
|
-
NoLease,
|
|
22
|
-
NoSuchTask,
|
|
23
|
-
NotInitialized,
|
|
14
|
+
Unreachable,
|
|
24
15
|
TaskopsError,
|
|
25
16
|
)
|
|
26
17
|
from ._version import __version__
|
|
27
18
|
|
|
28
19
|
__all__ = [
|
|
29
|
-
"__version__",
|
|
30
20
|
"TaskopsError",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"LeaseHeld",
|
|
35
|
-
"NoLease",
|
|
36
|
-
"GuardFailed",
|
|
21
|
+
"Refused",
|
|
22
|
+
"NotFound",
|
|
23
|
+
"Unreachable",
|
|
37
24
|
"BadRequest",
|
|
38
|
-
"
|
|
25
|
+
"__version__",
|
|
39
26
|
]
|
taskops/_clock.py
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The only module that reads the clock.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
`tests/test_architecture.py` pins this: `time.time`, `datetime.now`,
|
|
4
|
+
`time.monotonic`, `localtime` and `strftime` may not appear anywhere else
|
|
5
|
+
(except `core/hours.py`, which does calendar arithmetic and says so). v1 let a
|
|
6
|
+
stray `strftime` through and a report cut days in two timezones at once.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
machines and survive a restart, which a monotonic clock cannot do. The cost is
|
|
10
|
-
that a badly skewed clock skews a TTL — bounded, and visible in the studio,
|
|
11
|
-
where a lease from the future is obvious.
|
|
8
|
+
Tests freeze time through `set_now`; nothing else may.
|
|
12
9
|
"""
|
|
13
10
|
|
|
14
11
|
from __future__ import annotations
|
|
15
12
|
|
|
16
13
|
import time
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
_frozen: float | None = None
|
|
19
16
|
|
|
20
|
-
LEASE_TTL = 900.0
|
|
21
|
-
"""Seconds a claim survives without a heartbeat. Every taskops call an agent
|
|
22
|
-
makes renews it, so this bounds how long a task stays stuck after a CRASH —
|
|
23
|
-
not how long a legitimately slow task may run."""
|
|
24
17
|
|
|
25
|
-
|
|
26
|
-
"""
|
|
27
|
-
|
|
18
|
+
def now() -> float:
|
|
19
|
+
"""Wall-clock seconds. The single source of 'when'."""
|
|
20
|
+
return _frozen if _frozen is not None else time.time()
|
|
28
21
|
|
|
29
22
|
|
|
30
|
-
def
|
|
31
|
-
"""
|
|
32
|
-
|
|
23
|
+
def datestamp(when: float) -> str:
|
|
24
|
+
"""`YYYY-MM-DD`, local. Here and not at the call site for the reason above:
|
|
25
|
+
the archive a `board push` leaves behind is named after a day, and a day is
|
|
26
|
+
a calendar fact — the one kind of formatting this module exists to own."""
|
|
27
|
+
return time.strftime("%Y-%m-%d", time.localtime(when))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_now(value: float | None) -> None:
|
|
31
|
+
"""Freeze (or unfreeze) the clock. Tests only — never called by the package."""
|
|
32
|
+
global _frozen
|
|
33
|
+
_frozen = value
|
taskops/_errors.py
CHANGED
|
@@ -1,160 +1,55 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
"""The one error tree.
|
|
2
|
+
|
|
3
|
+
Every exception that escapes this package descends from `TaskopsError`. A
|
|
4
|
+
foreign exception is converted at the boundary that raises it (`raise X from
|
|
5
|
+
err`) — a caller never sees `sqlite3.OperationalError` or
|
|
6
|
+
`json.JSONDecodeError`. Each class carries a stable `code` because that code
|
|
7
|
+
crosses the wire in the RPC envelope, so the string lives here and nowhere
|
|
8
|
+
else.
|
|
9
|
+
|
|
10
|
+
`Refused` is the interesting one: a rule said no, and **its message contains
|
|
11
|
+
the call that fixes it, verbatim**. That was the best habit of v1 and it is
|
|
12
|
+
kept word for word.
|
|
8
13
|
"""
|
|
9
14
|
|
|
10
15
|
from __future__ import annotations
|
|
11
16
|
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
__all__ = [
|
|
15
|
-
"TaskopsError", "NotInitialized", "NoSuchTask", "IllegalTransition", "LeaseHeld", "NoLease",
|
|
16
|
-
"GuardFailed", "BadRequest", "AlreadyWritten", "AlreadyNarrating", "NarrationFailed",
|
|
17
|
-
"ReportConflict", "Unreachable",
|
|
18
|
-
]
|
|
19
|
-
|
|
20
17
|
|
|
21
18
|
class TaskopsError(Exception):
|
|
22
|
-
"""Root
|
|
19
|
+
"""Root of everything this package raises."""
|
|
23
20
|
|
|
24
21
|
code = "error"
|
|
25
|
-
http_status = 500
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class NotInitialized(TaskopsError, FileNotFoundError):
|
|
29
|
-
"""No `.taskops/` at or above the given path."""
|
|
30
|
-
|
|
31
|
-
code = "not_initialized"
|
|
32
|
-
http_status = 404
|
|
33
|
-
|
|
34
|
-
@classmethod
|
|
35
|
-
def at(cls, path: str | Path) -> "NotInitialized":
|
|
36
|
-
return cls(f"no taskops project at or above {path} — run `taskops init` in the "
|
|
37
|
-
f"repository root")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class NoSuchTask(TaskopsError, KeyError):
|
|
41
|
-
"""A task id nobody created — usually a hallucinated or a stale one."""
|
|
42
|
-
|
|
43
|
-
code = "no_such_task"
|
|
44
|
-
http_status = 404
|
|
45
|
-
|
|
46
|
-
@classmethod
|
|
47
|
-
def named(cls, task: str) -> "NoSuchTask":
|
|
48
|
-
return cls(f"no task {task} — list what exists with `taskops report board`")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class IllegalTransition(TaskopsError, ValueError):
|
|
52
|
-
"""A status move the machine does not allow."""
|
|
53
|
-
|
|
54
|
-
code = "illegal_transition"
|
|
55
|
-
http_status = 409
|
|
56
|
-
|
|
57
|
-
@classmethod
|
|
58
|
-
def between(
|
|
59
|
-
cls, *, task: str, old: str, new: str, allowed: tuple[str, ...]
|
|
60
|
-
) -> "IllegalTransition":
|
|
61
|
-
legal = ", ".join(allowed) if allowed else "nothing — it is terminal"
|
|
62
|
-
return cls(f"{task} is {old} and cannot go to {new}; from {old} it can go to {legal}")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class LeaseHeld(TaskopsError, RuntimeError):
|
|
66
|
-
"""Somebody else is on it and their lease has not expired."""
|
|
67
22
|
|
|
68
|
-
code = "lease_held"
|
|
69
|
-
http_status = 409
|
|
70
23
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return cls(f"{task} is claimed by {actor} for another {seconds}s — "
|
|
74
|
-
f"pick another task, or message them on it")
|
|
24
|
+
class Refused(TaskopsError):
|
|
25
|
+
"""A rule said no. The message must name the way out."""
|
|
75
26
|
|
|
27
|
+
code = "refused"
|
|
76
28
|
|
|
77
|
-
class NoLease(TaskopsError, RuntimeError):
|
|
78
|
-
"""Working on a task nobody claimed. Its own type, not a GuardFailed,
|
|
79
|
-
because it has ONE fix the agent can apply unaided — which the message is."""
|
|
80
29
|
|
|
81
|
-
|
|
82
|
-
|
|
30
|
+
class NotFound(TaskopsError):
|
|
31
|
+
"""A card, milestone, board or event that does not exist."""
|
|
83
32
|
|
|
84
|
-
|
|
85
|
-
def on(cls, *, task: str, actor: str) -> "NoLease":
|
|
86
|
-
return cls(f"{actor} holds no live lease on {task} — claim it with "
|
|
87
|
-
f"taskops_next, or `taskops claim {task}`")
|
|
33
|
+
code = "not_found"
|
|
88
34
|
|
|
89
35
|
|
|
90
|
-
class
|
|
91
|
-
"""
|
|
92
|
-
from IllegalTransition: that one is "the arrow does not exist", this one is
|
|
93
|
-
"you have not earned it yet" — and only this one is fixable by doing work."""
|
|
36
|
+
class Unreachable(TaskopsError):
|
|
37
|
+
"""The remote board did not answer. Never silently degrades to local."""
|
|
94
38
|
|
|
95
|
-
code = "
|
|
96
|
-
http_status = 400
|
|
39
|
+
code = "unreachable"
|
|
97
40
|
|
|
98
41
|
|
|
99
|
-
class BadRequest(TaskopsError
|
|
100
|
-
"""
|
|
42
|
+
class BadRequest(TaskopsError):
|
|
43
|
+
"""Malformed input: bad actor grammar, unknown verb, wrong argument shape."""
|
|
101
44
|
|
|
102
45
|
code = "bad_request"
|
|
103
|
-
http_status = 400
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class AlreadyWritten(TaskopsError, FileExistsError):
|
|
107
|
-
"""A generated file that exists and would be OVERWRITTEN. 409, never 500.
|
|
108
|
-
|
|
109
|
-
A written report is something somebody may have already read, cited, or narrated by
|
|
110
|
-
hand; silently regenerating it would rewrite history under them. Refusing and naming
|
|
111
|
-
`--force` leaves the choice with the person who knows whether the old one mattered.
|
|
112
|
-
"""
|
|
113
|
-
|
|
114
|
-
code = "already_written"
|
|
115
|
-
http_status = 409
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class AlreadyNarrating(TaskopsError, RuntimeError):
|
|
119
|
-
"""A narration of that report is already running IN THIS PROCESS. 409.
|
|
120
|
-
|
|
121
|
-
Two models writing the same file is not a slow path, it is corruption: both hold the
|
|
122
|
-
dossier they read at the start and each rewrites the whole file when it flushes, so
|
|
123
|
-
whichever finishes last silently erases the other. Refusing the second is the only
|
|
124
|
-
outcome that leaves a readable report — and the first one is still streaming, so the
|
|
125
|
-
person who clicked twice is already looking at what they asked for.
|
|
126
|
-
"""
|
|
127
46
|
|
|
128
|
-
code = "already_narrating"
|
|
129
|
-
http_status = 409
|
|
130
47
|
|
|
48
|
+
CODES: dict[str, type[TaskopsError]] = {
|
|
49
|
+
cls.code: cls for cls in (Refused, NotFound, Unreachable, BadRequest, TaskopsError)
|
|
50
|
+
}
|
|
131
51
|
|
|
132
|
-
class NarrationFailed(TaskopsError, RuntimeError):
|
|
133
|
-
"""The `claude` CLI could not write the narration. 502: an upstream did not answer.
|
|
134
52
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
facts, only the prose.
|
|
139
|
-
"""
|
|
140
|
-
|
|
141
|
-
code = "narration_failed"
|
|
142
|
-
http_status = 502
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
class ReportConflict(TaskopsError, FileExistsError):
|
|
146
|
-
"""A DIFFERENT narration of one report, not a newer one — `ours`/`theirs` are the stamps."""
|
|
147
|
-
|
|
148
|
-
code = "report_conflict"
|
|
149
|
-
http_status = 409
|
|
150
|
-
ours = -1
|
|
151
|
-
theirs = -1
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
class Unreachable(TaskopsError, ConnectionError):
|
|
155
|
-
"""The server did not answer at all — no status, no body. 502, and never a reason to
|
|
156
|
-
write locally instead: that is how two machines end up holding one card. Its own type
|
|
157
|
-
so `usecases._routing` can catch exactly it and say which URL went quiet."""
|
|
158
|
-
|
|
159
|
-
code = "unreachable"
|
|
160
|
-
http_status = 502
|
|
53
|
+
def from_code(code: str, message: str) -> TaskopsError:
|
|
54
|
+
"""Rebuild an error carried over the wire. Unknown code → the root type."""
|
|
55
|
+
return CODES.get(code, TaskopsError)(message)
|
taskops/_ids.py
CHANGED
|
@@ -1,63 +1,71 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Identifiers.
|
|
2
2
|
|
|
3
|
-
Two
|
|
3
|
+
Two shapes, both deliberate:
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
hash, importing it twice is a primary-key no-op instead of a duplicate comment
|
|
13
|
-
in somebody's inbox. It also makes the log verifiable — an event whose id does
|
|
14
|
-
not match its content was edited after the fact.
|
|
5
|
+
* a card id is **random** (`tk-` + 6 hex). It is a name, not a hash: it must
|
|
6
|
+
stay stable while the card is edited, and it is what the git branch and the
|
|
7
|
+
commit trailer are made of.
|
|
8
|
+
* an event id is the **sha256 of its canonical form, 32 hex**. Content-hash
|
|
9
|
+
ids are what make the log idempotent (`INSERT OR IGNORE` and replaying twice
|
|
10
|
+
is free). v1 truncated to 16 hex — 64 bits — and a collision there is a
|
|
11
|
+
silently dropped event, so 128 bits it is.
|
|
15
12
|
"""
|
|
16
13
|
|
|
17
14
|
from __future__ import annotations
|
|
18
15
|
|
|
19
|
-
import hashlib
|
|
20
|
-
import json
|
|
21
16
|
import secrets
|
|
22
|
-
from
|
|
23
|
-
|
|
24
|
-
__all__ = ["new_task_id", "event_id", "slugify", "TASK_PREFIX"]
|
|
17
|
+
from hashlib import sha256
|
|
25
18
|
|
|
26
19
|
TASK_PREFIX = "tk-"
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
MILESTONE_PREFIX = "ms-"
|
|
21
|
+
EVENT_ID_LEN = 32
|
|
29
22
|
|
|
30
23
|
|
|
31
24
|
def new_task_id() -> str:
|
|
32
|
-
"""
|
|
33
|
-
return TASK_PREFIX + secrets.token_hex(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
25
|
+
"""`tk-` + 6 random hex. Collision-checked by the caller's INSERT."""
|
|
26
|
+
return TASK_PREFIX + secrets.token_hex(3)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def new_milestone_id() -> str:
|
|
30
|
+
return MILESTONE_PREFIX + secrets.token_hex(3)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_task_id(value: str) -> bool:
|
|
34
|
+
return _is(value, TASK_PREFIX)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_milestone_id(value: str) -> bool:
|
|
38
|
+
return _is(value, MILESTONE_PREFIX)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is(value: str, prefix: str) -> bool:
|
|
42
|
+
body = value[len(prefix) :]
|
|
43
|
+
return (
|
|
44
|
+
value.startswith(prefix) and len(body) == 6 and all(c in "0123456789abcdef" for c in body)
|
|
48
45
|
)
|
|
49
|
-
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:_EVENT_CHARS]
|
|
50
46
|
|
|
51
47
|
|
|
52
|
-
def
|
|
53
|
-
"""
|
|
48
|
+
def digest(canonical: str) -> str:
|
|
49
|
+
"""sha256 of an already-canonical string, truncated to 128 bits."""
|
|
50
|
+
return sha256(canonical.encode("utf-8")).hexdigest()[:EVENT_ID_LEN]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def new_token() -> str:
|
|
54
|
+
"""A credential or invite secret. Only the sha256 of this is ever stored.
|
|
55
|
+
|
|
56
|
+
**Never starts with `-`.** `token_urlsafe` draws from `A-Za-z0-9_-`, so
|
|
57
|
+
roughly one token in 64 begins with a hyphen — and every one of those is a
|
|
58
|
+
token that cannot be passed to the CLI: `taskops join x --invite -Ab9…`
|
|
59
|
+
makes argparse read the secret as an option and refuse with *expected one
|
|
60
|
+
argument*. It fails for the user who was unlucky, on a value they cannot
|
|
61
|
+
influence, with a message about the flag rather than the value.
|
|
62
|
+
|
|
63
|
+
Fixed HERE, at the one place a secret is minted, rather than by quoting or
|
|
64
|
+
`--invite=` at each call site: the token also travels in URLs, briefs and
|
|
65
|
+
shell snippets nobody controls. Found by CI — the runner drew a leading
|
|
66
|
+
hyphen on the first run this suite ever had off this laptop.
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
kept = [c.lower() if c.isalnum() else "-" for c in text.strip()]
|
|
60
|
-
slug = "".join(kept).strip("-")
|
|
61
|
-
while "--" in slug:
|
|
62
|
-
slug = slug.replace("--", "-")
|
|
63
|
-
return slug[:limit].strip("-") or "task"
|
|
68
|
+
Rerolling (not stripping) keeps every token the full 24 bytes of entropy."""
|
|
69
|
+
while (token := secrets.token_urlsafe(24)).startswith("-"):
|
|
70
|
+
pass
|
|
71
|
+
return token
|
taskops/_json.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Foreign JSON becomes a typed mapping HERE, or it becomes nothing.
|
|
2
|
+
|
|
3
|
+
Every boundary that reads JSON somebody else wrote — a config file, an HTTP
|
|
4
|
+
envelope, an MCP argument object — goes through `as_object`. One place to be
|
|
5
|
+
lenient, everywhere else typed. v1 spread this coercion over fifteen call
|
|
6
|
+
sites and one of them read `claim="false"` as True.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_object(value: object) -> dict[str, Any]:
|
|
15
|
+
"""A JSON object as `dict[str, Any]`. Anything else is an empty mapping."""
|
|
16
|
+
if not isinstance(value, dict):
|
|
17
|
+
return {}
|
|
18
|
+
return {str(k): v for k, v in cast("dict[Any, Any]", value).items()}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def as_rows(value: object) -> list[dict[str, Any]]:
|
|
22
|
+
"""A JSON array of objects. A non-object entry becomes an empty mapping."""
|
|
23
|
+
if not isinstance(value, list):
|
|
24
|
+
return []
|
|
25
|
+
return [as_object(x) for x in cast("list[object]", value)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def as_strings(value: object) -> list[str]:
|
|
29
|
+
if not isinstance(value, list):
|
|
30
|
+
return []
|
|
31
|
+
return [x for x in cast("list[object]", value) if isinstance(x, str)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def text(value: object, default: str = "") -> str:
|
|
35
|
+
"""A string field that may be missing or of the wrong type."""
|
|
36
|
+
return value if isinstance(value, str) else default
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def query(url: str) -> dict[str, str]:
|
|
40
|
+
"""`…?token=abc&invite=xyz` → the parameters, without a urllib import at
|
|
41
|
+
every call site. Anything malformed is simply absent."""
|
|
42
|
+
_, _, tail = url.partition("?")
|
|
43
|
+
found: dict[str, str] = {}
|
|
44
|
+
for part in tail.split("&"):
|
|
45
|
+
key, sep, value = part.partition("=")
|
|
46
|
+
if sep and key:
|
|
47
|
+
found[key] = value
|
|
48
|
+
return found
|
taskops/_locate.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Where the board is, and what its config says. Nothing here opens one.
|
|
2
|
+
|
|
3
|
+
Split out of `board.py` along the seam that file's own history names: v1 kept
|
|
4
|
+
this as `storage/locate.py`, and the two questions really are separate — *which
|
|
5
|
+
directory is this project's* is answered from the filesystem alone, while *how
|
|
6
|
+
do I talk to its board* needs the stores, the verbs and the network.
|
|
7
|
+
|
|
8
|
+
Two files make a project, and only `init`/`join` ever write them:
|
|
9
|
+
|
|
10
|
+
.taskops/board.json {"url": …} committed — the address travels with the code
|
|
11
|
+
.taskops/remote.json {"token", "token_expires", "login"} 0600, gitignored —
|
|
12
|
+
the secret never travels, and with a `login` block the
|
|
13
|
+
token is a SESSION this machine re-mints (`session.py`)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from ._json import as_object
|
|
24
|
+
|
|
25
|
+
DIR = ".taskops"
|
|
26
|
+
|
|
27
|
+
# "A board lives here." Written by BOTH `init` and `join`, so it is the one
|
|
28
|
+
# marker every project has and nothing else creates.
|
|
29
|
+
ADDRESS = "board.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_project(candidate: Path) -> bool:
|
|
33
|
+
"""A directory is a project when `.taskops/board.json` is in it.
|
|
34
|
+
|
|
35
|
+
The FILE, not the directory, and the distinction is not pedantry: v1 kept
|
|
36
|
+
its sessions in `~/.taskops/`, so that directory exists on every machine
|
|
37
|
+
that ever ran it — and matching the directory alone made the HOME DIRECTORY
|
|
38
|
+
a project: `taskops init` in a fresh repo under it walked up, adopted HOME,
|
|
39
|
+
and wrote the board, both git hooks, `.mcp.json` and the Claude settings
|
|
40
|
+
there instead of in the repo. v1 hit and fixed this the same way
|
|
41
|
+
(`storage/locate.py`); v2 shipped the bare check and reproduced it.
|
|
42
|
+
"""
|
|
43
|
+
return (candidate / DIR / ADDRESS).is_file()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_root(start: Path) -> Path:
|
|
47
|
+
"""The nearest project, else the git root, else `start`.
|
|
48
|
+
|
|
49
|
+
A project wins over `.git` deliberately: a worker's worktree lives at
|
|
50
|
+
`<repo>/.taskops/trees/tk-…` and has a `.git` file of its own. Answering
|
|
51
|
+
"the worktree" there would look for the credential in the wrong place and
|
|
52
|
+
silently fall back to a local board — one of v1's split-brain routes. The
|
|
53
|
+
walk finds `<repo>` anyway, because that is where the address file is.
|
|
54
|
+
"""
|
|
55
|
+
here = start.resolve()
|
|
56
|
+
chain = [here, *here.parents]
|
|
57
|
+
for candidate in chain:
|
|
58
|
+
if is_project(candidate):
|
|
59
|
+
return candidate
|
|
60
|
+
for candidate in chain:
|
|
61
|
+
if (candidate / ".git").exists():
|
|
62
|
+
return candidate
|
|
63
|
+
return here
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_config(root: Path) -> dict[str, Any]:
|
|
67
|
+
"""board.json (committed) merged with remote.json (secret). Missing is fine."""
|
|
68
|
+
out: dict[str, Any] = {}
|
|
69
|
+
for name in (ADDRESS, "remote.json"):
|
|
70
|
+
path = root / DIR / name
|
|
71
|
+
if not path.exists():
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
continue # a broken config means "not configured", never a crash on read
|
|
77
|
+
out.update(as_object(data))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_remote(root: Path, fields: dict[str, Any]) -> None:
|
|
82
|
+
"""Merge `fields` into remote.json, keeping every other key it holds, 0600.
|
|
83
|
+
|
|
84
|
+
The WRITER of the secret half, beside its reader, because the shape of that
|
|
85
|
+
file is one fact: a session refresh (`session.remember`), the key that minted
|
|
86
|
+
it (`session.cache_login`) and the host and board this checkout operates
|
|
87
|
+
(`cli/remote.py`) each write ONE block and must not eat the others'.
|
|
88
|
+
|
|
89
|
+
`login` is therefore MERGED FIELD BY FIELD and never replaced whole. Three
|
|
90
|
+
writers own three fields of it — `remote add` the host, a sign-in the
|
|
91
|
+
principal and the key, `board create` the board's name — and a plain
|
|
92
|
+
top-level update would have the sign-in silently drop the recorded name, so
|
|
93
|
+
the bare `board push` after it would go back to guessing the directory."""
|
|
94
|
+
path = root / DIR / "remote.json"
|
|
95
|
+
body: dict[str, Any] = {}
|
|
96
|
+
if path.exists():
|
|
97
|
+
try:
|
|
98
|
+
body = as_object(json.loads(path.read_text(encoding="utf-8")))
|
|
99
|
+
except (OSError, ValueError):
|
|
100
|
+
body = {}
|
|
101
|
+
if "login" in fields:
|
|
102
|
+
fields = {**fields, "login": {**as_object(body.get("login")), **as_object(fields["login"])}}
|
|
103
|
+
body.update(fields)
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
|
|
106
|
+
os.chmod(path, 0o600)
|
taskops/_version.py
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
"""The single source of the version. `pyproject.toml` reads this attribute."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
__version__ = "0.2.0"
|
|
3
|
+
__title__ = "taskops"
|
|
4
|
+
__version__ = "0.3.0" # single source of truth; pyproject reads it dynamically
|