tackboard-mcp 0.8.0__tar.gz

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.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.3
2
+ Name: tackboard-mcp
3
+ Version: 0.8.0
4
+ Summary: Optional MCP adapter for the tackboard CLI — exposes board tools to MCP clients
5
+ License: MIT
6
+ Requires-Dist: tackboard>=0.8.0,<0.9
7
+ Requires-Dist: mcp>=2.3,<3
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # tackboard-mcp — an optional MCP adapter
12
+
13
+ A small [Model Context Protocol](https://modelcontextprotocol.io) server that
14
+ exposes one `tack` project board to an MCP client (an IDE assistant, a chat
15
+ client, another agent runtime).
16
+
17
+ **This package is optional and outside the core.** The `tackboard` package —
18
+ the CLI, the database, the JSON contracts — stays dependency-free and does not
19
+ import anything here. If you only want the board, install `tackboard`; if your
20
+ agent runtime speaks MCP and you would rather it called tools than assembled
21
+ shell commands, install this beside it.
22
+
23
+ ## What it is, precisely
24
+
25
+ The adapter contains **no domain logic**. Every tool shells out to the `tack`
26
+ CLI with `--json` and returns that document unchanged, so a client can never
27
+ see a claim, a transition or a validation rule that differs from the board. A
28
+ non-zero exit is raised as an error whose payload is the CLI's own error
29
+ document (`{"schema_version":1,"error":{...}}`).
30
+
31
+ What it adds is discovery and a typed surface: seven named tools with described
32
+ arguments, instead of a CLI string to compose.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ uv tool install tackboard-mcp # or: pip install tackboard-mcp
38
+ ```
39
+
40
+ This installs the adapter and pulls `tackboard` with it, so the `tack` CLI
41
+ comes along in the same environment. The MCP framework and the core are this
42
+ package's dependencies, and they are declared here — never in the core.
43
+
44
+ `TACK_CMD` remains available for unusual environments where the adapter and the
45
+ CLI live in separate environments; set it to the command that invokes `tack`
46
+ (for example `TACK_CMD="uv run tack"`).
47
+
48
+ ## Configure
49
+
50
+ The server reads three environment variables:
51
+
52
+ | Variable | Meaning |
53
+ |---|---|
54
+ | `TACKBOARD_DB` | the board file to operate on (same variable the CLI reads) |
55
+ | `TACKBOARD_AGENT` | the actor every mutation is attributed to |
56
+ | `TACK_CMD` | overrides the CLI command prefix; defaults to the installed core module, or `tack` when the core is not importable |
57
+
58
+ ```bash
59
+ TACKBOARD_DB=~/.local/state/board.sqlite3 TACKBOARD_AGENT=assistant tackboard-mcp
60
+ ```
61
+
62
+ The process speaks MCP over stdio. Register it with your client the way that
63
+ client documents stdio servers:
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "tackboard": {
69
+ "command": "tackboard-mcp",
70
+ "env": {
71
+ "TACKBOARD_DB": "/path/to/board.sqlite3",
72
+ "TACKBOARD_AGENT": "assistant"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ## Tools
80
+
81
+ | Tool | Arguments | Returns |
82
+ |---|---|---|
83
+ | `next_claim` | `stage?` (`work`/`review`) | the `next --claim` document; `task` is `null` when nothing is eligible |
84
+ | `show` | `task_id` | the `show` document: the task and its notes |
85
+ | `note` | `task_id`, `text`, `refs?` (`KIND:VALUE`) | the `note` document with the appended event |
86
+ | `complete` | `task_id` | the mutation document; completing unblocks dependents |
87
+ | `release` | `task_id`, `reason?` | the mutation document; a reason is the audited force path |
88
+ | `create` | `title`, `description?`, `priority?`, `assignee?`, `labels?`, `depends_on?`, `status?` | the created task document |
89
+ | `board_brief` | — | the counts-only board document |
90
+
91
+ Every tool declares all four MCP annotation hints as explicit booleans. The two
92
+ read tools (`board_brief`, `show`) are read-only and repeatable; every mutating
93
+ tool writes only the one local board and touches no external system
94
+ (`openWorldHint` false); nothing on this surface deletes data
95
+ (`destructiveHint` false); and `complete` is idempotent while `next_claim`,
96
+ `note`, `release` and `create` are not.
97
+
98
+ Each tool returns the CLI's JSON document unchanged. Exit codes are not
99
+ translated into a second vocabulary: a failed call raises with the CLI's
100
+ `error.code`, `exit_code` and `details` intact.
101
+
102
+ ## Tests
103
+
104
+ ```bash
105
+ uv run pytest packages/tackboard-mcp/tests
106
+ ```
107
+
108
+ The tool layer is tested without the framework: argument mapping against a fake
109
+ transport, plus a scratch board driven through the real CLI. The binding tests
110
+ (`test_server.py`) run only when `mcp` is installed — including a real client
111
+ handshake over stdio — and skip otherwise, so the core suite never needs the
112
+ framework.
113
+
114
+ ## Limits
115
+
116
+ - One board per process: the database is fixed at start-up.
117
+ - The web view and the schema commands are not exposed; they are not tool-shaped.
118
+ - `--force` is reachable only through `release`'s `reason`, deliberately: the
119
+ other force cases are operator decisions, not assistant ones.
@@ -0,0 +1,109 @@
1
+ # tackboard-mcp — an optional MCP adapter
2
+
3
+ A small [Model Context Protocol](https://modelcontextprotocol.io) server that
4
+ exposes one `tack` project board to an MCP client (an IDE assistant, a chat
5
+ client, another agent runtime).
6
+
7
+ **This package is optional and outside the core.** The `tackboard` package —
8
+ the CLI, the database, the JSON contracts — stays dependency-free and does not
9
+ import anything here. If you only want the board, install `tackboard`; if your
10
+ agent runtime speaks MCP and you would rather it called tools than assembled
11
+ shell commands, install this beside it.
12
+
13
+ ## What it is, precisely
14
+
15
+ The adapter contains **no domain logic**. Every tool shells out to the `tack`
16
+ CLI with `--json` and returns that document unchanged, so a client can never
17
+ see a claim, a transition or a validation rule that differs from the board. A
18
+ non-zero exit is raised as an error whose payload is the CLI's own error
19
+ document (`{"schema_version":1,"error":{...}}`).
20
+
21
+ What it adds is discovery and a typed surface: seven named tools with described
22
+ arguments, instead of a CLI string to compose.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv tool install tackboard-mcp # or: pip install tackboard-mcp
28
+ ```
29
+
30
+ This installs the adapter and pulls `tackboard` with it, so the `tack` CLI
31
+ comes along in the same environment. The MCP framework and the core are this
32
+ package's dependencies, and they are declared here — never in the core.
33
+
34
+ `TACK_CMD` remains available for unusual environments where the adapter and the
35
+ CLI live in separate environments; set it to the command that invokes `tack`
36
+ (for example `TACK_CMD="uv run tack"`).
37
+
38
+ ## Configure
39
+
40
+ The server reads three environment variables:
41
+
42
+ | Variable | Meaning |
43
+ |---|---|
44
+ | `TACKBOARD_DB` | the board file to operate on (same variable the CLI reads) |
45
+ | `TACKBOARD_AGENT` | the actor every mutation is attributed to |
46
+ | `TACK_CMD` | overrides the CLI command prefix; defaults to the installed core module, or `tack` when the core is not importable |
47
+
48
+ ```bash
49
+ TACKBOARD_DB=~/.local/state/board.sqlite3 TACKBOARD_AGENT=assistant tackboard-mcp
50
+ ```
51
+
52
+ The process speaks MCP over stdio. Register it with your client the way that
53
+ client documents stdio servers:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "tackboard": {
59
+ "command": "tackboard-mcp",
60
+ "env": {
61
+ "TACKBOARD_DB": "/path/to/board.sqlite3",
62
+ "TACKBOARD_AGENT": "assistant"
63
+ }
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Tools
70
+
71
+ | Tool | Arguments | Returns |
72
+ |---|---|---|
73
+ | `next_claim` | `stage?` (`work`/`review`) | the `next --claim` document; `task` is `null` when nothing is eligible |
74
+ | `show` | `task_id` | the `show` document: the task and its notes |
75
+ | `note` | `task_id`, `text`, `refs?` (`KIND:VALUE`) | the `note` document with the appended event |
76
+ | `complete` | `task_id` | the mutation document; completing unblocks dependents |
77
+ | `release` | `task_id`, `reason?` | the mutation document; a reason is the audited force path |
78
+ | `create` | `title`, `description?`, `priority?`, `assignee?`, `labels?`, `depends_on?`, `status?` | the created task document |
79
+ | `board_brief` | — | the counts-only board document |
80
+
81
+ Every tool declares all four MCP annotation hints as explicit booleans. The two
82
+ read tools (`board_brief`, `show`) are read-only and repeatable; every mutating
83
+ tool writes only the one local board and touches no external system
84
+ (`openWorldHint` false); nothing on this surface deletes data
85
+ (`destructiveHint` false); and `complete` is idempotent while `next_claim`,
86
+ `note`, `release` and `create` are not.
87
+
88
+ Each tool returns the CLI's JSON document unchanged. Exit codes are not
89
+ translated into a second vocabulary: a failed call raises with the CLI's
90
+ `error.code`, `exit_code` and `details` intact.
91
+
92
+ ## Tests
93
+
94
+ ```bash
95
+ uv run pytest packages/tackboard-mcp/tests
96
+ ```
97
+
98
+ The tool layer is tested without the framework: argument mapping against a fake
99
+ transport, plus a scratch board driven through the real CLI. The binding tests
100
+ (`test_server.py`) run only when `mcp` is installed — including a real client
101
+ handshake over stdio — and skip otherwise, so the core suite never needs the
102
+ framework.
103
+
104
+ ## Limits
105
+
106
+ - One board per process: the database is fixed at start-up.
107
+ - The web view and the schema commands are not exposed; they are not tool-shaped.
108
+ - `--force` is reachable only through `release`'s `reason`, deliberately: the
109
+ other force cases are operator decisions, not assistant ones.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "tackboard-mcp"
3
+ version = "0.8.0"
4
+ description = "Optional MCP adapter for the tackboard CLI — exposes board tools to MCP clients"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "tackboard>=0.8.0,<0.9",
9
+ "mcp>=2.3,<3",
10
+ ]
11
+
12
+ [project.license]
13
+ text = "MIT"
14
+
15
+ [project.scripts]
16
+ tackboard-mcp = "tackboard_mcp.server:main"
17
+
18
+ [tool.uv.sources.tackboard]
19
+ workspace = true
20
+
21
+ [tool.uv.build-backend]
22
+ module-name = "tackboard_mcp"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.7,<0.13"]
26
+ build-backend = "uv_build"
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "tackboard-mcp"
3
+ version = "0.8.0"
4
+ description = "Optional MCP adapter for the tackboard CLI — exposes board tools to MCP clients"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ dependencies = ["tackboard>=0.8.0,<0.9", "mcp>=2.3,<3"]
9
+
10
+ [tool.uv.sources]
11
+ tackboard = { workspace = true }
12
+
13
+ [project.scripts]
14
+ tackboard-mcp = "tackboard_mcp.server:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.7,<0.13"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.uv.build-backend]
21
+ module-name = "tackboard_mcp"
@@ -0,0 +1,14 @@
1
+ """Optional MCP adapter for the ``tack`` board CLI.
2
+
3
+ The tool layer (:mod:`tackboard_mcp.tools`) has no MCP dependency and can be
4
+ used directly; :mod:`tackboard_mcp.server` is the thin MCP binding and imports
5
+ the framework only when it is asked to build a server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .tools import Document, TackCli, TackCommandError
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = ["Document", "TackCli", "TackCommandError", "__version__"]
@@ -0,0 +1,166 @@
1
+ """The MCP binding: one tool per board operation, over the CLI.
2
+
3
+ This module is the only place the MCP framework is imported, and only inside
4
+ :func:`build_server` — so importing :mod:`tackboard_mcp` or its tool layer never
5
+ requires the framework, and the core ``tackboard`` package never depends on it.
6
+
7
+ Every tool returns the CLI's JSON document unchanged. A failed call raises, and
8
+ the framework turns that into a tool error whose text is the CLI's own message.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .tools import TackCli
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - typing only, the framework is optional
18
+ from mcp.server.mcpserver import MCPServer
19
+
20
+ INSTRUCTIONS = """\
21
+ This server exposes one project's tackboard through the `tack` CLI.
22
+
23
+ Read the board before acting: board_brief for counts, then next_claim to take
24
+ work atomically. A claim is a promise to every other agent — complete the task
25
+ when the work is done, and leave a note with evidence (commit ids, paths, urls)
26
+ at every handoff. `show` returns the full task including notes. `release`
27
+ without a reason gives up a claim you hold; with a reason it is the audited
28
+ force path, so use it only to recover a dead agent's claim.
29
+ """
30
+
31
+
32
+ def _server_class() -> type[MCPServer]:
33
+ """The framework's server class, under either release line's name.
34
+
35
+ MCP 2.x renamed ``mcp.server.fastmcp.FastMCP`` to
36
+ ``mcp.server.mcpserver.MCPServer``; the decorator surface this adapter uses
37
+ is the same, so it supports both rather than pinning a client's toolchain.
38
+ """
39
+ try:
40
+ from mcp.server.mcpserver import MCPServer as _MCPServer
41
+ except ImportError: # pragma: no cover - 1.x
42
+ from mcp.server.fastmcp import FastMCP as _MCPServer # type: ignore[attr-defined,no-redef]
43
+ return _MCPServer
44
+
45
+
46
+ def build_server(cli: TackCli) -> MCPServer:
47
+ """Build the MCP server for ``cli``'s board.
48
+
49
+ Imported lazily: the MCP framework lives only in this package's metadata,
50
+ so a checkout without it can still use the tool layer and its tests.
51
+ """
52
+ from mcp.types import ToolAnnotations
53
+
54
+ server = _server_class()("tackboard", instructions=INSTRUCTIONS)
55
+
56
+ @server.tool(
57
+ annotations=ToolAnnotations(
58
+ read_only_hint=False,
59
+ destructive_hint=False,
60
+ idempotent_hint=False,
61
+ open_world_hint=False,
62
+ )
63
+ )
64
+ def next_claim(stage: str | None = None) -> dict[str, Any]:
65
+ """Claim and return the next eligible task. `stage` may be "review"."""
66
+ return cli.next_claim(stage)
67
+
68
+ @server.tool(
69
+ annotations=ToolAnnotations(
70
+ read_only_hint=True,
71
+ destructive_hint=False,
72
+ idempotent_hint=True,
73
+ open_world_hint=False,
74
+ )
75
+ )
76
+ def show(task_id: str) -> dict[str, Any]:
77
+ """Read one task in full, including its notes."""
78
+ return cli.show(task_id)
79
+
80
+ @server.tool(
81
+ annotations=ToolAnnotations(
82
+ read_only_hint=False,
83
+ destructive_hint=False,
84
+ idempotent_hint=False,
85
+ open_world_hint=False,
86
+ )
87
+ )
88
+ def note(task_id: str, text: str, refs: list[str] | None = None) -> dict[str, Any]:
89
+ """Append a handoff note; refs are KIND:VALUE strings (commit/url/path/task)."""
90
+ return cli.note(task_id, text, tuple(refs or ()))
91
+
92
+ @server.tool(
93
+ annotations=ToolAnnotations(
94
+ read_only_hint=False,
95
+ destructive_hint=False,
96
+ idempotent_hint=True,
97
+ open_world_hint=False,
98
+ )
99
+ )
100
+ def complete(task_id: str) -> dict[str, Any]:
101
+ """Finish a task, unblocking its dependents."""
102
+ return cli.complete(task_id)
103
+
104
+ @server.tool(
105
+ annotations=ToolAnnotations(
106
+ read_only_hint=False,
107
+ destructive_hint=False,
108
+ idempotent_hint=False,
109
+ open_world_hint=False,
110
+ )
111
+ )
112
+ def release(task_id: str, reason: str | None = None) -> dict[str, Any]:
113
+ """Release a claim; a reason makes it the audited force path."""
114
+ return cli.release(task_id, reason)
115
+
116
+ @server.tool(
117
+ annotations=ToolAnnotations(
118
+ read_only_hint=False,
119
+ destructive_hint=False,
120
+ idempotent_hint=False,
121
+ open_world_hint=False,
122
+ )
123
+ )
124
+ def create(
125
+ title: str,
126
+ description: str | None = None,
127
+ priority: str | None = None,
128
+ assignee: str | None = None,
129
+ labels: list[str] | None = None,
130
+ depends_on: list[str] | None = None,
131
+ status: str | None = None,
132
+ ) -> dict[str, Any]:
133
+ """Create a task, optionally with labels, assignment and prerequisite ids."""
134
+ return cli.create(
135
+ title,
136
+ description=description,
137
+ priority=priority,
138
+ assignee=assignee,
139
+ labels=tuple(labels or ()),
140
+ depends_on=tuple(depends_on or ()),
141
+ status=status,
142
+ )
143
+
144
+ @server.tool(
145
+ annotations=ToolAnnotations(
146
+ read_only_hint=True,
147
+ destructive_hint=False,
148
+ idempotent_hint=True,
149
+ open_world_hint=False,
150
+ )
151
+ )
152
+ def board_brief() -> dict[str, Any]:
153
+ """The board's counts only: the cheapest look at what is on the board."""
154
+ return cli.board_brief()
155
+
156
+ return server
157
+
158
+
159
+ def main() -> int:
160
+ """Start the server over stdio, configured from the environment."""
161
+ build_server(TackCli.from_environment()).run()
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__": # pragma: no cover - entry point
166
+ raise SystemExit(main())
@@ -0,0 +1,220 @@
1
+ """The tool layer: one function per board operation, each a ``tack`` call.
2
+
3
+ There is deliberately no domain logic here. Every tool shells out to the CLI
4
+ with ``--json`` and returns that JSON document unchanged, so the adapter can
5
+ never disagree with the board: the CLI is the only implementation of claims,
6
+ transitions, validation and exit codes. A non-zero exit becomes a
7
+ ``TackCommandError`` carrying the CLI's own error document, so an MCP client
8
+ sees exactly what an agent at a terminal would see.
9
+
10
+ The runner is injectable (``runner=``) so tests can exercise argument building
11
+ without a board; production always spawns the real executable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from collections.abc import Callable, Iterator, Mapping, Sequence
21
+ from contextlib import contextmanager
22
+ from dataclasses import dataclass
23
+ from importlib.util import find_spec
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ #: A runner takes the full argv and stdin text and returns
28
+ #: ``(returncode, stdout, stderr)``. The default spawns a process.
29
+ Runner = Callable[[Sequence[str], str | None], tuple[int, str, str]]
30
+
31
+ #: A ``tack --json`` document.
32
+ Document = dict[str, Any]
33
+
34
+
35
+ class TackCommandError(RuntimeError):
36
+ """A ``tack`` call failed; carries the CLI's error document when it sent one."""
37
+
38
+ def __init__(
39
+ self,
40
+ argv: Sequence[str],
41
+ returncode: int,
42
+ message: str,
43
+ document: Document | None = None,
44
+ ) -> None:
45
+ super().__init__(f"tack exited {returncode}: {' '.join(argv)}")
46
+ self.argv = tuple(argv)
47
+ self.returncode = returncode
48
+ self.stderr = message
49
+ self.document = document
50
+
51
+
52
+ def _subprocess_runner(argv: Sequence[str], stdin: str | None) -> tuple[int, str, str]:
53
+ completed = subprocess.run(
54
+ list(argv), input=stdin, capture_output=True, text=True, check=False
55
+ )
56
+ return completed.returncode, completed.stdout, completed.stderr
57
+
58
+
59
+ def _parse(argv: Sequence[str], returncode: int, text: str) -> Document:
60
+ try:
61
+ document = json.loads(text)
62
+ except json.JSONDecodeError as exc:
63
+ raise TackCommandError(argv, returncode, f"stdout is not JSON: {text[:200]!r}") from exc
64
+ if not isinstance(document, dict):
65
+ raise TackCommandError(
66
+ argv, returncode, f"stdout is not a JSON object: {text[:200]!r}"
67
+ )
68
+ return document
69
+
70
+
71
+ @contextmanager
72
+ def _environment(actor: str | None) -> Iterator[None]:
73
+ """Set ``TACKBOARD_AGENT`` for one call and restore the process state after."""
74
+ saved = os.environ.get("TACKBOARD_AGENT")
75
+ if actor is None:
76
+ os.environ.pop("TACKBOARD_AGENT", None)
77
+ else:
78
+ os.environ["TACKBOARD_AGENT"] = actor
79
+ try:
80
+ yield
81
+ finally:
82
+ if saved is None:
83
+ os.environ.pop("TACKBOARD_AGENT", None)
84
+ else:
85
+ os.environ["TACKBOARD_AGENT"] = saved
86
+
87
+
88
+ def _default_command() -> tuple[str, ...]:
89
+ """Prefer the installed core module, falling back to the console script.
90
+
91
+ The adapter depends on ``tackboard``, so the core is importable from the
92
+ same environment in the normal case: running ``sys.executable -m tackboard``
93
+ avoids depending on ``tack`` being on ``PATH``. When the adapter lives in a
94
+ different environment from the CLI, ``tackboard`` is not importable and the
95
+ historical ``tack`` lookup remains the fallback. ``TACK_CMD`` still takes
96
+ first precedence in ``from_environment``.
97
+ """
98
+ if find_spec("tackboard") is not None:
99
+ return (sys.executable, "-m", "tackboard")
100
+ return ("tack",)
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class TackCli:
105
+ """A configured ``tack`` invocation: command prefix, board and actor.
106
+
107
+ ``command`` is the executable plus any fixed prefix, e.g. ``("tack",)`` or
108
+ ``(sys.executable, "-m", "tackboard")``. ``db`` and ``actor`` become
109
+ ``--db`` and ``TACKBOARD_AGENT`` on every call.
110
+ """
111
+
112
+ command: tuple[str, ...] = ("tack",)
113
+ db: Path | None = None
114
+ actor: str | None = None
115
+ runner: Runner = _subprocess_runner
116
+
117
+ @classmethod
118
+ def from_environment(
119
+ cls, environ: Mapping[str, str] | None = None, runner: Runner | None = None
120
+ ) -> TackCli:
121
+ """Build from ``TACK_CMD``, ``TACKBOARD_DB`` and ``TACKBOARD_AGENT``."""
122
+ env = os.environ if environ is None else environ
123
+ command = tuple(env.get("TACK_CMD", "").split()) or _default_command()
124
+ db_text = env.get("TACKBOARD_DB")
125
+ return cls(
126
+ command=command,
127
+ db=Path(db_text) if db_text else None,
128
+ actor=env.get("TACKBOARD_AGENT"),
129
+ runner=runner or _subprocess_runner,
130
+ )
131
+
132
+ def _argv(self, *argv: str) -> list[str]:
133
+ taken = [*self.command, *argv, "--json"]
134
+ if self.db is not None:
135
+ taken.extend(["--db", str(self.db)])
136
+ return taken
137
+
138
+ def run(self, *argv: str, stdin: str | None = None) -> Document:
139
+ """Run one ``tack`` command and return its JSON document unchanged."""
140
+ full = self._argv(*argv)
141
+ with _environment(self.actor):
142
+ returncode, stdout, stderr = self.runner(full, stdin)
143
+ text = stdout.strip()
144
+ if returncode != 0:
145
+ document = None
146
+ if text:
147
+ try:
148
+ parsed = json.loads(text)
149
+ except json.JSONDecodeError:
150
+ parsed = None
151
+ document = parsed if isinstance(parsed, dict) else None
152
+ raise TackCommandError(full, returncode, stderr.strip() or text, document)
153
+ if not text:
154
+ raise TackCommandError(full, returncode, "tack printed no JSON document")
155
+ return _parse(full, returncode, text)
156
+
157
+ # -- the board operations ------------------------------------------------
158
+
159
+ def next_claim(self, stage: str | None = None) -> Document:
160
+ """Claim and return the next eligible task (``task`` may be ``null``)."""
161
+ argv: list[str] = ["next", "--claim"]
162
+ if stage is not None:
163
+ argv.extend(["--stage", stage])
164
+ return self.run(*argv)
165
+
166
+ def show(self, task_id: str) -> Document:
167
+ """One task in full, with its notes."""
168
+ return self.run("show", task_id)
169
+
170
+ def note(self, task_id: str, text: str, refs: Sequence[str] = ()) -> Document:
171
+ """Append a handoff note; ``refs`` are ``KIND:VALUE`` strings."""
172
+ argv = ["note", task_id, text]
173
+ for ref in refs:
174
+ argv.extend(["--ref", ref])
175
+ return self.run(*argv)
176
+
177
+ def complete(self, task_id: str) -> Document:
178
+ """Finish a task; its dependents become eligible."""
179
+ return self.run("complete", task_id)
180
+
181
+ def release(self, task_id: str, reason: str | None = None) -> Document:
182
+ """Release a claim; a reason forces (audited) rather than plain release."""
183
+ argv = ["release", task_id]
184
+ if reason is not None:
185
+ argv.extend(["--force", "--reason", reason])
186
+ return self.run(*argv)
187
+
188
+ def create(
189
+ self,
190
+ title: str,
191
+ *,
192
+ description: str | None = None,
193
+ priority: str | None = None,
194
+ assignee: str | None = None,
195
+ labels: Sequence[str] = (),
196
+ depends_on: Sequence[str] = (),
197
+ status: str | None = None,
198
+ ) -> Document:
199
+ """Create a task, optionally with its dependency edges."""
200
+ argv = ["create", title]
201
+ if description is not None:
202
+ argv.extend(["--description", description])
203
+ if priority is not None:
204
+ argv.extend(["--priority", priority])
205
+ if assignee is not None:
206
+ argv.extend(["--assignee", assignee])
207
+ if status is not None:
208
+ argv.extend(["--status", status])
209
+ for label in labels:
210
+ argv.extend(["--label", label])
211
+ for prerequisite in depends_on:
212
+ argv.extend(["--depends-on", prerequisite])
213
+ return self.run(*argv)
214
+
215
+ def board_brief(self) -> Document:
216
+ """The counts-only board document: the cheapest look at a board."""
217
+ return self.run("board", "--brief")
218
+
219
+
220
+ __all__ = ["Document", "Runner", "TackCli", "TackCommandError"]