feat: OpenMesh 基础平台与 MD/PDF 转换技能
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
2026-09-13 23:41:04 +08:00
commit 6f402ffcee
638 changed files with 154534 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
"""Agent teams substrate. Two append-only stores, one record discipline:
the board log (space-scoped — a board lives and dies with its team) and the
journal store (case-keyed — knowledge that outlives boards and teams)."""
from .journal import JournalStore
from .model import (
Actor,
AuthorityError,
BoardError,
BoardNotFoundError,
ChainError,
ItemState,
Role,
)
from .store import TeamStore
from .tools import board_tools, journal_tools
__all__ = [
"Actor",
"AuthorityError",
"BoardError",
"BoardNotFoundError",
"ChainError",
"ItemState",
"JournalStore",
"Role",
"TeamStore",
"board_tools",
"journal_tools",
]
# BoardDialect / LocalDialect / RemoteDialect live in .dialect, BoardTokens in
# .tokens — imported directly by their consumers (CLI, MCP server, `/v1/board`)
# to keep this package root light for the common in-app path.

View File

@@ -0,0 +1,114 @@
"""Content-addressed attachments for board items — screenshots first.
Review artifacts don't belong in the repo (they aren't source, and they die with
checkouts) and don't belong in the board log (events carry refs, never blobs — no
megabytes under the hash chain). They live here: files named by their sha256 in the
state dir, bridged into the board as a normal comment event carrying an
`attachment://<hash>.<ext>#<name>` ref.
Content addressing buys three things: dedupe for free (the same screenshot attached
twice stores once), immutability by construction (the ref can never dangle onto
changed bytes), and location independence — on a hosted board the same ref resolves
to object storage instead of this directory.
Scope is images-only and ~10MB to start; the allowlist is the policy choke point
when that widens.
"""
from __future__ import annotations
import hashlib
import re
from pathlib import Path
from typing import Optional
from .model import BoardError, BoardNotFoundError
ATTACHMENT_SCHEME = "attachment://"
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
# Extension → mime for the types we accept. Sniffed magic must agree with the
# claimed extension — a .png that isn't a PNG is refused, not renamed.
_IMAGE_TYPES = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
}
_MAGIC = {
"png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff",
"jpeg": b"\xff\xd8\xff",
"gif": b"GIF8",
"webp": b"RIFF", # RIFF….WEBP — checked with the fourcc below
}
_STORED_NAME = re.compile(r"[0-9a-f]{64}\.[a-z0-9]{1,5}")
class AttachmentStore:
def __init__(self, root: str | Path) -> None:
self.root = Path(root).expanduser()
def put(self, data: bytes, filename: str) -> str:
"""Store one attachment; returns its `attachment://` ref. Idempotent —
identical bytes land on the same file."""
ext = _validate(data, filename)
stored = f"{hashlib.sha256(data).hexdigest()}.{ext}"
self.root.mkdir(parents=True, exist_ok=True)
target = self.root / stored
if not target.exists():
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_bytes(data)
tmp.replace(target)
safe_name = Path(filename).name.replace("#", "_")
return f"{ATTACHMENT_SCHEME}{stored}#{safe_name}"
def path_for(self, stored: str) -> Path:
"""Resolve a stored name (`<sha256>.<ext>`) to its file. The strict name
check is the traversal guard — nothing else reaches the filesystem."""
stored = validate_stored_name(stored)
path = self.root / stored
if not path.exists():
raise BoardNotFoundError("attachment not found")
return path
def mime_for(self, stored: str) -> str:
return _IMAGE_TYPES.get(stored.rsplit(".", 1)[-1], "application/octet-stream")
def stored_name(ref: str) -> Optional[str]:
"""`attachment://<hash>.<ext>#<name>` → `<hash>.<ext>`; None for other refs."""
if not ref.startswith(ATTACHMENT_SCHEME):
return None
return ref[len(ATTACHMENT_SCHEME):].split("#", 1)[0]
def validate_stored_name(stored: str) -> str:
"""Return one normalized stored name, rejecting malformed input."""
stored = stored.strip()
if not _STORED_NAME.fullmatch(stored):
raise BoardError(f"not an attachment name: {stored!r}")
return stored
def _validate(data: bytes, filename: str) -> str:
if not data:
raise BoardError("attachment is empty")
if len(data) > MAX_ATTACHMENT_BYTES:
raise BoardError(
f"attachment exceeds {MAX_ATTACHMENT_BYTES // (1024 * 1024)}MB"
)
ext = Path(filename).suffix.lstrip(".").lower()
if ext not in _IMAGE_TYPES:
raise BoardError(
f"unsupported attachment type .{ext or '?'} — images only for now"
f" ({', '.join(sorted(set(_IMAGE_TYPES)))})"
)
if not data.startswith(_MAGIC[ext]) or (
ext == "webp" and data[8:12] != b"WEBP"
):
raise BoardError(f"file content does not look like .{ext}")
return "jpg" if ext == "jpeg" else ext

215
coworker/teams/chat.py Normal file
View File

@@ -0,0 +1,215 @@
"""The chat store — group chat as its own abstraction (eighth pass, 2026-08-16).
A GROUP is `{group_id, name, members[]}` plus an append-only message log and
per-member unread cursors. One group per team in v1 (created at the staffing gate
when chat is enabled), but nothing here knows about boards or teams — groups can
later serve non-team chats and the external-chat dialect.
Wake semantics live in the read side: an agent post is "for" exactly its @mentioned
members; a USER post is for every member ([User] outranks — posting to the channel
is rare and deliberate). Un-mentioned agent chatter wakes nobody, which is what
keeps chat an exception channel structurally.
"""
from __future__ import annotations
import json
import re
import sqlite3
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from .model import BoardError
class ChatStore:
def __init__(self, db_path: str | Path) -> None:
self.db_path = str(db_path)
if self.db_path != ":memory:":
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS chat_groups (
group_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
members TEXT NOT NULL,
created_ts TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
ts TEXT NOT NULL,
author TEXT NOT NULL,
author_role TEXT NOT NULL,
text TEXT NOT NULL,
mentions TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_chat_group ON chat_messages (group_id, seq);
CREATE TABLE IF NOT EXISTS chat_cursors (
cursor_key TEXT PRIMARY KEY,
read_seq INTEGER NOT NULL
);
""")
self._conn.commit()
# ---------------------------------------------------------------------- groups
def create_group(self, name: str, members: list[dict[str, Any]]) -> dict[str, Any]:
"""`members`: [{name, persona, role}] — `name` is the member's handle
(@mention target). The user participates implicitly and is not a member row."""
handles = [str(m.get("name", "")).strip() for m in members]
if not name.strip():
raise BoardError("group name is required")
if not all(handles) or len(set(handles)) != len(handles):
raise BoardError("every member needs a unique name")
group = {
"group_id": uuid.uuid4().hex[:12],
"name": name.strip(),
"members": [
{
"name": str(m.get("name")),
"persona": str(m.get("persona", "")),
"role": str(m.get("role", "worker")),
}
for m in members
],
"created_ts": datetime.now(timezone.utc).isoformat(),
}
with self._lock:
self._conn.execute(
"INSERT INTO chat_groups (group_id, name, members, created_ts)"
" VALUES (?, ?, ?, ?)",
(
group["group_id"],
group["name"],
json.dumps(group["members"]),
group["created_ts"],
),
)
self._conn.commit()
return group
def get_group(self, group_id: str) -> Optional[dict[str, Any]]:
with self._lock:
row = self._conn.execute(
"SELECT * FROM chat_groups WHERE group_id = ?", (group_id,)
).fetchone()
if row is None:
return None
group = dict(row)
group["members"] = json.loads(group.pop("members") or "[]")
return group
# -------------------------------------------------------------------- messages
def post(
self, group_id: str, author: str, text: str, *, author_role: str = "worker"
) -> dict[str, Any]:
"""Append one message. Mentions are parsed against member handles —
`@name` anywhere in the text — so tagging needs no separate parameter."""
group = self.get_group(group_id)
if group is None:
raise BoardError(f"no chat group '{group_id}'")
if not (text or "").strip():
raise BoardError("message text is required")
handles = {m["name"] for m in group["members"]}
mentions = sorted(
{
m.group(1)
for m in re.finditer(r"@([\w.-]+)", text)
if m.group(1) in handles
}
)
message = {
"group_id": group_id,
"ts": datetime.now(timezone.utc).isoformat(),
"author": author,
"author_role": author_role,
"text": text,
"mentions": mentions,
}
with self._lock:
cursor = self._conn.execute(
"INSERT INTO chat_messages"
" (group_id, ts, author, author_role, text, mentions)"
" VALUES (?, ?, ?, ?, ?, ?)",
(
group_id,
message["ts"],
author,
author_role,
text,
json.dumps(mentions),
),
)
self._conn.commit()
return {**message, "seq": cursor.lastrowid}
def messages(
self, group_id: str, *, since_seq: int = 0, limit: int = 200
) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM chat_messages WHERE group_id = ? AND seq > ?"
" ORDER BY seq LIMIT ?",
(group_id, since_seq, max(1, min(int(limit or 200), 2000))),
).fetchall()
return [_row_to_message(row) for row in rows]
# ------------------------------------------------------- unread / wake reads
def unread_for(self, group_id: str, member: str) -> list[dict[str, Any]]:
"""Messages this member should be WOKEN for: posts that @mention it, plus
every user post. Its own posts never count."""
out = []
for message in self.messages(group_id, since_seq=self._cursor(group_id, member)):
if message["author"] == member:
continue
if member in message["mentions"] or message["author_role"] == "user":
out.append(message)
return out
def unread_count(self, group_id: str, member: str) -> int:
"""Plain unread count (all messages since the member's cursor) — drives the
sidebar badge for the USER, whose 'member' key is "user"."""
with self._lock:
row = self._conn.execute(
"SELECT COUNT(*) AS n FROM chat_messages WHERE group_id = ?"
" AND seq > ? AND author != ?",
(group_id, self._cursor(group_id, member), member),
).fetchone()
return int(row["n"])
def consume(self, group_id: str, member: str, upto_seq: int) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO chat_cursors (cursor_key, read_seq) VALUES (?, ?)"
" ON CONFLICT(cursor_key) DO UPDATE SET read_seq ="
" MAX(read_seq, ?)",
(f"{group_id}:{member}", int(upto_seq), int(upto_seq)),
)
self._conn.commit()
def close(self) -> None:
self._conn.close()
def _cursor(self, group_id: str, member: str) -> int:
row = self._conn.execute(
"SELECT read_seq FROM chat_cursors WHERE cursor_key = ?",
(f"{group_id}:{member}",),
).fetchone()
return int(row["read_seq"]) if row else 0
def _row_to_message(row: sqlite3.Row) -> dict[str, Any]:
message = dict(row)
try:
message["mentions"] = json.loads(message.get("mentions") or "[]")
except json.JSONDecodeError:
message["mentions"] = []
return message

504
coworker/teams/cli.py Normal file
View File

@@ -0,0 +1,504 @@
"""`ocw` — the board and journal from any shell, for any harness.
The board is an open surface (OPE-100): the same role-scoped verbs the in-app
agents get, usable by an external agent CLI, a script, or a human. Point it at a
running OpenWorker server (same machine or remote) or straight at a state dir.
Backing resolution, in order:
1. `--url` + `--token` (or OCW_BOARD_URL / OCW_BOARD_TOKEN) — a remote board.
2. `--db DIR` — direct SQLite in that state dir (headless; you are the only writer).
3. A running local server, discovered via its sidecar token files — the CLI mints
itself a local user token on first use. This is preferred over direct SQLite
whenever a server is up: two processes must never write one board file.
4. Direct SQLite on the default state dir (nothing else is running).
`ocw board mcp` serves the same surface as an MCP server on stdio — the way to
hand a board to an external coding agent: point the agent's MCP config at
`ocw board mcp --url … --token … --space …` and ask it to claim a work item.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Optional
from .model import BoardError, space_for_workspace
from .store import CLAIM_POLICIES
_STATES = ("open", "in_progress", "blocked", "review", "done", "canceled")
def main(argv: Optional[list[str]] = None) -> int:
parser = _parser()
args = parser.parse_args(argv)
if not getattr(args, "cmd", None):
parser.print_help()
return 2
try:
return args.func(args)
except BoardError as error:
print(f"error: {error}", file=sys.stderr)
return 1
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ocw", description="OpenWorker team board + journal CLI."
)
sub = parser.add_subparsers(dest="group")
board = sub.add_parser("board", help="work-item board verbs")
board_sub = board.add_subparsers(dest="cmd")
def cmd(name: str, func, help: str, parent=board_sub):
p = parent.add_parser(name, help=help)
_backing_args(p)
p.set_defaults(func=func, cmd=name)
return p
p = cmd("list", _cmd_list, "list items")
p.add_argument("--state", choices=_STATES, default="")
p.add_argument("--assignee", default="")
p.add_argument("--mine", action="store_true", help="only items assigned to me")
p = cmd("show", _cmd_show, "one item, with comments")
p.add_argument("id", type=int)
p = cmd("create", _cmd_create, "file a new item (open, unassigned)")
p.add_argument("title")
p.add_argument("--criteria", required=True, help="acceptance criteria")
p.add_argument("--description", default="")
p.add_argument("--parent", type=int, default=None)
p.add_argument("--case", default="")
p = cmd("claim", _cmd_claim, "claim an open, unassigned item for yourself")
p.add_argument("id", type=int)
p = cmd("move", _cmd_move, "transition an item")
p.add_argument("id", type=int)
p.add_argument("to", choices=_STATES[1:] + ("open",))
p.add_argument("--comment", default="")
p.add_argument("--ref", action="append", default=[], dest="refs")
p = cmd("comment", _cmd_comment, "comment on an item")
p.add_argument("id", type=int)
p.add_argument("body")
p.add_argument("--ref", action="append", default=[], dest="refs")
p = cmd("assign", _cmd_assign, "assign an item (lead/user)")
p.add_argument("id", type=int)
p.add_argument("assignee")
p = cmd("attach", _cmd_attach, "attach a screenshot/image to an item")
p.add_argument("id", type=int)
p.add_argument("file", help="image file (png/jpg/gif/webp, ≤10MB)")
p.add_argument("--caption", default="")
p = cmd("attachment", _cmd_attachment, "download an attachment by ref or name")
p.add_argument("ref", help="attachment:// ref or <sha256>.<ext> name")
p.add_argument("-o", "--out", default="", help="output path (default: basename)")
p = cmd("link", _cmd_link, "link two items")
p.add_argument("src", type=int)
p.add_argument("kind", choices=("parent", "blocks"))
p.add_argument("dst", type=int)
p = cmd("policy", _cmd_policy, "show or set the board's claim policy")
p.add_argument("--claims", choices=CLAIM_POLICIES, default="")
p = cmd("pending", _cmd_pending, "my unconsumed deliveries (assignments etc.)")
p.add_argument("--consume", action="store_true", help="advance my cursor")
p.add_argument("--limit", type=int, default=50)
cmd("spaces", _cmd_spaces, "list known board spaces")
# `token` manages the serving machine's registry file directly — it takes no
# backing/identity flags of its own (minting is what CREATES identities).
p = board_sub.add_parser(
"token", help="mint/list/revoke board join tokens (serving machine)"
)
p.add_argument("action", choices=("mint", "list", "revoke"))
p.add_argument("--actor", default="", help="callname the token binds (mint)")
p.add_argument(
"--role", choices=("worker", "lead", "user"), default="worker"
)
p.add_argument("--label", default="", help="what this token is for (mint)")
p.add_argument("--prefix", default="", help="token prefix to revoke")
p.add_argument("--db", default="", help="state dir holding the registry")
p.add_argument("--json", action="store_true")
p.set_defaults(func=_cmd_token, cmd="token")
p = cmd("mcp", _cmd_mcp, "serve this board over MCP on stdio")
journal = sub.add_parser("journal", help="journal case verbs")
journal_sub = journal.add_subparsers(dest="cmd")
p = cmd("cases", _cmd_cases, "cases I can read", parent=journal_sub)
p = cmd("read", _cmd_read, "read a case (filtered)", parent=journal_sub)
p.add_argument("case")
p.add_argument("--item", type=int, default=None)
p.add_argument("--author", default="")
p.add_argument("--kind", default="")
p.add_argument("--entity", default="")
p.add_argument("--raw", action="store_true", dest="include_raw")
p.add_argument("--limit", type=int, default=50)
p = cmd("append", _cmd_append, "append an entry to a case", parent=journal_sub)
p.add_argument("case")
p.add_argument("body")
p.add_argument(
"--kind",
choices=("finding", "evidence", "decision", "note", "raw"),
default="note",
)
p.add_argument("--item", type=int, default=None)
p.add_argument("--entity", action="append", default=[], dest="entities")
p.add_argument("--ref", action="append", default=[], dest="refs")
return parser
def _backing_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--url", default=os.environ.get("OCW_BOARD_URL", ""))
p.add_argument("--token", default=os.environ.get("OCW_BOARD_TOKEN", ""))
p.add_argument("--db", default="", help="state dir for direct (headless) access")
p.add_argument("--actor", dest="local_actor", default="user")
p.add_argument("--role", dest="local_role", default="user")
p.add_argument(
"--space",
default=os.environ.get("OCW_BOARD_SPACE", ""),
help="board space (default: this directory's workspace)",
)
p.add_argument("--json", action="store_true", help="machine-readable output")
# ------------------------------------------------------------------ backing
def _space(args) -> str:
return args.space or space_for_workspace(Path.cwd())
def _dialect(args):
from .dialect import RemoteDialect, local_dialect
if args.url:
if not args.token:
raise BoardError("--token (or OCW_BOARD_TOKEN) is required with --url")
return RemoteDialect(args.url, args.token)
if args.db:
return local_dialect(args.db, actor=args.local_actor, role=args.local_role)
server = _discover_server()
if server is not None:
return RemoteDialect(server, _local_cli_token())
from ..secrets import state_dir
return local_dialect(state_dir(), actor=args.local_actor, role=args.local_role)
def _discover_server() -> Optional[str]:
"""A running local server, found via its per-port sidecar token files."""
import httpx
from ..secrets import state_dir
ports = []
try:
for path in state_dir().glob("sidecar-*.token"):
try:
ports.append(int(path.stem.split("-")[1]))
except (IndexError, ValueError):
continue
except OSError:
return None
for port in sorted(ports, reverse=True):
url = f"http://127.0.0.1:{port}"
try:
if httpx.get(f"{url}/v1/health", timeout=1.5).status_code == 200:
return url
except httpx.HTTPError:
continue
return None
def _local_cli_token() -> str:
"""The CLI's own user token against the local server. Minted once into the
shared registry; the plaintext is cached user-only in the state dir — the
user's own credential on the user's own machine, same pattern as the sidecar
token file."""
from ..secrets import state_dir, write_private_text
from .tokens import BoardTokens
cache = state_dir() / "ocw-cli.token"
tokens = BoardTokens(state_dir() / "board-tokens.json")
try:
cached = cache.read_text().strip()
if cached and tokens.resolve(cached) is not None:
return cached
except OSError:
pass
token = tokens.mint("user", "user", label="local ocw CLI")
write_private_text(cache, token + "\n")
return token
# ------------------------------------------------------------------ board cmds
def _cmd_list(args) -> int:
dialect = _dialect(args)
assignee = args.assignee or (dialect.whoami()["actor"] if args.mine else "")
items = dialect.list_items(
_space(args), state=args.state or None, assignee=assignee or None
)
if args.json:
print(json.dumps(items, indent=2))
return 0
if not items:
print("no items")
return 0
for item in items:
who = f" @{item['assignee']}" if item["assignee"] else ""
print(f"#{item['id']:<4} {item['state']:<12}{who:<14} {item['title']}")
return 0
def _cmd_show(args) -> int:
item = _dialect(args).get_item(_space(args), args.id)
if args.json:
print(json.dumps(item, indent=2))
return 0
print(f"#{item['id']} {item['title']} [{item['state']}]")
if item["assignee"]:
print(f"assignee: {item['assignee']}")
print(f"created by: {item['creator']}")
if item["description"]:
print(f"\n{item['description']}")
print(f"\nDone when: {item['criteria']}")
if item.get("refs"):
print("refs: " + ", ".join(item["refs"]))
for link in item.get("links") or []:
print(f"link: {link['kind']} #{link['item']}")
for comment in item.get("comments") or []:
print(f"\n[{comment['ts']}] {comment['author']}: {comment['body']}")
return 0
def _cmd_create(args) -> int:
item = _dialect(args).create_item(
_space(args),
title=args.title,
criteria=args.criteria,
description=args.description,
parent=args.parent,
case=args.case or None,
)
print(json.dumps(item, indent=2) if args.json else f"created #{item['id']}")
return 0
def _cmd_claim(args) -> int:
item = _dialect(args).claim(_space(args), args.id)
print(
json.dumps(item, indent=2)
if args.json
else f"claimed #{item['id']} — it's yours; move it to in_progress when you start"
)
return 0
def _cmd_move(args) -> int:
item = _dialect(args).transition(
_space(args), args.id, args.to, comment=args.comment, refs=args.refs
)
print(json.dumps(item, indent=2) if args.json else f"#{item['id']}{item['state']}")
return 0
def _cmd_comment(args) -> int:
_dialect(args).comment(_space(args), args.id, args.body, refs=args.refs)
print("ok" if not args.json else json.dumps({"ok": True}))
return 0
def _cmd_assign(args) -> int:
item = _dialect(args).assign(_space(args), args.id, args.assignee)
print(
json.dumps(item, indent=2)
if args.json
else f"#{item['id']} → @{item['assignee']}"
)
return 0
def _cmd_attach(args) -> int:
source = Path(args.file).expanduser()
if not source.is_file():
print(f"error: no such file: {source}", file=sys.stderr)
return 1
result = _dialect(args).attach(
_space(args), args.id, source.read_bytes(), source.name, caption=args.caption
)
ref = result.get("ref") or next(
(r for r in (result.get("payload") or {}).get("refs", [])), ""
)
print(json.dumps(result, indent=2) if args.json else f"attached → {ref}")
return 0
def _cmd_attachment(args) -> int:
from .attachments import stored_name
stored = stored_name(args.ref) or args.ref
data, _mime = _dialect(args).attachment(_space(args), stored)
out = Path(args.out) if args.out else Path(
args.ref.rsplit("#", 1)[-1] if "#" in args.ref else stored
)
out.write_bytes(data)
print(str(out))
return 0
def _cmd_link(args) -> int:
_dialect(args).link(_space(args), args.src, args.kind, args.dst)
print("ok" if not args.json else json.dumps({"ok": True}))
return 0
def _cmd_policy(args) -> int:
dialect = _dialect(args)
policy = (
dialect.set_policy(_space(args), claims=args.claims)
if args.claims
else dialect.policy(_space(args))
)
print(json.dumps(policy) if args.json else f"claims: {policy['claims']}")
return 0
def _cmd_pending(args) -> int:
dialect = _dialect(args)
events = dialect.pending(_space(args), limit=args.limit)
if args.json:
print(json.dumps(events, indent=2))
else:
for event in events:
print(f"[{event['seq']}] {event['kind']} #{event.get('item_id')}"
f" from {event['actor']}: {json.dumps(event['payload'])}")
if not events:
print("nothing pending")
if args.consume and events:
dialect.consume(_space(args), events[-1]["seq"])
return 0
def _cmd_spaces(args) -> int:
spaces = _dialect(args).spaces()
print(json.dumps(spaces) if args.json else "\n".join(spaces) or "no spaces")
return 0
def _cmd_token(args) -> int:
from ..secrets import state_dir
from .tokens import BoardTokens
tokens = BoardTokens(
(Path(args.db).expanduser() if args.db else state_dir()) / "board-tokens.json"
)
if args.action == "mint":
if not args.actor:
print("error: --actor is required to mint", file=sys.stderr)
return 1
token = tokens.mint(args.actor, args.role, label=args.label)
print(token)
print(
f"# binds actor '{args.actor}' as {args.role}; shown once — store it"
" in the client's config (OCW_BOARD_TOKEN)",
file=sys.stderr,
)
return 0
if args.action == "revoke":
removed = tokens.revoke(args.prefix)
print(f"revoked {removed} token(s)")
return 0
entries = tokens.entries()
if args.json:
print(json.dumps(entries, indent=2))
return 0
for entry in entries:
label = f" ({entry['label']})" if entry["label"] else ""
print(f"{entry['prefix']}{entry['actor']:<16} {entry['role']:<8}{label}")
if not entries:
print("no tokens")
return 0
def _cmd_mcp(args) -> int:
from .mcp_server import serve
serve(_dialect(args), space=_space(args))
return 0
# ------------------------------------------------------------------ journal cmds
def _cmd_cases(args) -> int:
cases = _dialect(args).journal_overview()
if args.json:
print(json.dumps(cases, indent=2))
return 0
for case in cases:
print(
f"{case.get('case', '?'):<28} {case.get('entries', 0)} entries"
+ (f" (last {case['last_ts']})" if case.get("last_ts") else "")
)
if not cases:
print("no cases")
return 0
def _cmd_read(args) -> int:
entries = _dialect(args).journal_read(
args.case,
item=args.item,
author=args.author or None,
kind=args.kind or None,
entity=args.entity or None,
include_raw=args.include_raw,
limit=args.limit,
)
if args.json:
print(json.dumps(entries, indent=2))
return 0
for entry in entries:
print(f"[{entry['ts']}] {entry['author']} {entry['kind']}:"
f" {entry.get('body') or ''}")
if not entries:
print("no entries")
return 0
def _cmd_append(args) -> int:
_dialect(args).journal_append(
args.case,
args.body,
kind=args.kind,
space=_space(args),
item=args.item,
entities=args.entities,
refs=args.refs,
)
print("ok" if not args.json else json.dumps({"ok": True}))
return 0
if __name__ == "__main__":
sys.exit(main())

560
coworker/teams/dialect.py Normal file
View File

@@ -0,0 +1,560 @@
"""The BoardDialect seam — where "a board" stops meaning "our SQLite file".
A dialect is where the board of record LIVES, seen from a client's chair:
- LocalDialect: this machine's TeamStore/JournalStore, direct SQLite. For the
standalone/headless case where the caller is the only writer.
- RemoteDialect: one wire protocol (the `/v1/board` HTTP API) to a board served
elsewhere — the running OpenWorker sidecar on this machine, a teammate's machine,
or a hosted board service later. Identity rides the token; the server binds it to
an actor+role and the store enforces authority, so a remote client is safe by
construction.
External trackers (Jira/Linear) are deliberately NOT dialects: making a pre-LLM
tracker the board of record means contorting our state machine and delivery cursors
onto its API. They join as MIRRORS instead — one more subscriber with a cursor over
the append-only event log, replaying events outward (decided 2026-08-16). The board
stays the abstraction and the source of truth.
Every front door — the `team-board` MCP server, the `ocw` CLI, remote OpenWorker
instances — bottoms out in this one verb surface. Dialect instances are
identity-bound: one actor per instance, matching the one-identity-per-process shape
of an external harness.
Cross-process write safety: the store's hash-chain append is read-head-then-write
under an in-process lock, so two processes must never write one SQLite file
directly. Rule: when a server is up, clients go remote; LocalDialect is for the
headless case where this process is the only writer.
"""
from __future__ import annotations
from typing import Any, Optional, Protocol
from .journal import JournalStore
from .model import Actor, BoardError, Role
from .store import TeamStore
class BoardDialect(Protocol):
"""The verb surface a board client sees, identity already bound."""
def whoami(self) -> dict[str, Any]: ...
def spaces(self) -> list[str]: ...
def list_items(
self,
space: str,
*,
state: Optional[str] = None,
assignee: Optional[str] = None,
) -> list[dict[str, Any]]: ...
def get_item(self, space: str, item_id: int) -> dict[str, Any]: ...
def create_item(
self,
space: str,
*,
title: str,
criteria: str,
description: str = "",
parent: Optional[int] = None,
case: Optional[str] = None,
) -> dict[str, Any]: ...
def transition(
self,
space: str,
item_id: int,
to: str,
*,
comment: str = "",
refs: Optional[list[str]] = None,
) -> dict[str, Any]: ...
def comment(
self,
space: str,
item_id: int,
body: str,
*,
refs: Optional[list[str]] = None,
) -> dict[str, Any]: ...
def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]: ...
def claim(self, space: str, item_id: int) -> dict[str, Any]: ...
def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]: ...
def attach(
self,
space: str,
item_id: int,
data: bytes,
filename: str,
*,
caption: str = "",
) -> dict[str, Any]: ...
def attachment(self, space: str, stored: str) -> tuple[bytes, str]:
"""Read a blob referenced by an actor-visible item in ``space``."""
...
def policy(self, space: str) -> dict[str, Any]: ...
def set_policy(self, space: str, *, claims: str) -> dict[str, Any]: ...
def pending(self, space: str, *, limit: int = 200) -> list[dict[str, Any]]: ...
def consume(self, space: str, upto_seq: int) -> None: ...
def journal_append(
self,
case: str,
body: str,
*,
kind: str = "note",
space: Optional[str] = None,
item: Optional[int] = None,
entities: Optional[list[str]] = None,
refs: Optional[list[str]] = None,
) -> dict[str, Any]: ...
def journal_read(
self,
case: str,
*,
item: Optional[int] = None,
author: Optional[str] = None,
kind: Optional[str] = None,
entity: Optional[str] = None,
include_raw: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]: ...
def journal_overview(self) -> list[dict[str, Any]]: ...
class LocalDialect:
"""Direct store access, one bound identity. The headless/standalone backing."""
def __init__(
self,
store: TeamStore,
journal: Optional[JournalStore],
actor: Actor,
*,
attachments: Any = None,
) -> None:
self.store = store
self.journal = journal
self.actor = actor
self.attachments = attachments
def whoami(self) -> dict[str, Any]:
return {"actor": self.actor.id, "role": self.actor.role.value}
def spaces(self) -> list[str]:
return self.store.spaces()
def list_items(
self,
space: str,
*,
state: Optional[str] = None,
assignee: Optional[str] = None,
) -> list[dict[str, Any]]:
return self.store.list_items(space, self.actor, state=state, assignee=assignee)
def get_item(self, space: str, item_id: int) -> dict[str, Any]:
return self.store.get_item(space, item_id, actor=self.actor)
def create_item(
self,
space: str,
*,
title: str,
criteria: str,
description: str = "",
parent: Optional[int] = None,
case: Optional[str] = None,
) -> dict[str, Any]:
return self.store.create_item(
space,
self.actor,
title=title,
criteria=criteria,
description=description,
parent=parent,
case=case,
)
def transition(
self,
space: str,
item_id: int,
to: str,
*,
comment: str = "",
refs: Optional[list[str]] = None,
) -> dict[str, Any]:
return self.store.transition(
space, self.actor, item_id, to, comment=comment, refs=refs
)
def comment(
self,
space: str,
item_id: int,
body: str,
*,
refs: Optional[list[str]] = None,
) -> dict[str, Any]:
return self.store.comment(space, self.actor, item_id, body, refs=refs)
def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]:
return self.store.assign(space, self.actor, item_id, assignee)
def claim(self, space: str, item_id: int) -> dict[str, Any]:
return self.store.claim(space, self.actor, item_id)
def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]:
return self.store.link(space, self.actor, src, kind, dst)
def attach(
self,
space: str,
item_id: int,
data: bytes,
filename: str,
*,
caption: str = "",
) -> dict[str, Any]:
# Attach = store blob + an attributed attachment-comment event. Comment
# authority IS attach authority (workers attach on their slice only).
if self.attachments is None:
raise BoardError("no attachment store is attached to this board")
ref = self.attachments.put(data, filename)
return self.store.attach_ref(
space,
self.actor,
item_id,
caption or f"attached {filename}",
ref,
)
def attachment(self, space: str, stored: str) -> tuple[bytes, str]:
if self.attachments is None:
raise BoardError("no attachment store is attached to this board")
self.store.require_attachment_access(space, self.actor, stored)
path = self.attachments.path_for(stored)
return path.read_bytes(), self.attachments.mime_for(stored)
def policy(self, space: str) -> dict[str, Any]:
return self.store.policy(space)
def set_policy(self, space: str, *, claims: str) -> dict[str, Any]:
return self.store.set_policy(space, self.actor, claims=claims)
def pending(self, space: str, *, limit: int = 200) -> list[dict[str, Any]]:
return self.store.feed_for(space, self.actor.id, limit=limit)
def consume(self, space: str, upto_seq: int) -> None:
self.store.consume_feed(space, self.actor.id, int(upto_seq))
def journal_append(
self,
case: str,
body: str,
*,
kind: str = "note",
space: Optional[str] = None,
item: Optional[int] = None,
entities: Optional[list[str]] = None,
refs: Optional[list[str]] = None,
) -> dict[str, Any]:
self._need_journal()
return self.journal.append(
self.actor,
case,
body,
kind=kind,
space=space,
item=item,
entities=entities,
refs=refs,
)
def journal_read(
self,
case: str,
*,
item: Optional[int] = None,
author: Optional[str] = None,
kind: Optional[str] = None,
entity: Optional[str] = None,
include_raw: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
self._need_journal()
return self.journal.read(
self.actor,
case,
item=item,
author=author,
kind=kind,
entity=entity,
include_raw=include_raw,
limit=limit,
)
def journal_overview(self) -> list[dict[str, Any]]:
self._need_journal()
return self.journal.overview(self.actor)
def _need_journal(self) -> None:
if self.journal is None:
raise BoardError("no journal store is attached to this board")
class RemoteDialect:
"""The `/v1/board` HTTP client. `base_url` is an OpenWorker sidecar or a hosted
board service; the Bearer token carries identity — the server resolves it to an
actor+role, so this client never states who it is, it proves it."""
def __init__(
self, base_url: str, token: str, *, client: Any = None, timeout: float = 30.0
) -> None:
import httpx
self.base_url = base_url.rstrip("/")
self._client = client or httpx.Client(
base_url=self.base_url,
timeout=timeout,
)
self._client.headers["Authorization"] = f"Bearer {token}"
# -- plumbing --------------------------------------------------------------
def _get(self, path: str, params: Optional[dict] = None) -> Any:
response = self._client.get(
path, params={k: v for k, v in (params or {}).items() if v is not None}
)
return self._unwrap(response)
def _post(self, path: str, body: dict) -> Any:
response = self._client.post(
path, json={k: v for k, v in body.items() if v is not None}
)
return self._unwrap(response)
@staticmethod
def _unwrap(response: Any) -> Any:
if response.status_code == 401:
raise BoardError("board token was not accepted (401) — mint one with"
" `ocw board token` on the serving machine")
try:
data = response.json()
except ValueError:
data = {}
if response.status_code >= 400:
raise BoardError(
str(data.get("error") or data.get("detail") or response.text)
)
return data
# -- verbs -----------------------------------------------------------------
def whoami(self) -> dict[str, Any]:
return self._get("/v1/board/whoami")
def spaces(self) -> list[str]:
return self._get("/v1/board/spaces")["spaces"]
def list_items(
self,
space: str,
*,
state: Optional[str] = None,
assignee: Optional[str] = None,
) -> list[dict[str, Any]]:
return self._get(
"/v1/board/items",
{"space": space, "state": state, "assignee": assignee},
)["items"]
def get_item(self, space: str, item_id: int) -> dict[str, Any]:
return self._get("/v1/board/item", {"space": space, "id": item_id})
def create_item(
self,
space: str,
*,
title: str,
criteria: str,
description: str = "",
parent: Optional[int] = None,
case: Optional[str] = None,
) -> dict[str, Any]:
return self._post(
"/v1/board/items",
{
"space": space,
"title": title,
"criteria": criteria,
"description": description,
"parent": parent,
"case": case,
},
)
def transition(
self,
space: str,
item_id: int,
to: str,
*,
comment: str = "",
refs: Optional[list[str]] = None,
) -> dict[str, Any]:
return self._post(
"/v1/board/items/transition",
{
"space": space,
"id": item_id,
"to": to,
"comment": comment,
"refs": refs or [],
},
)
def comment(
self,
space: str,
item_id: int,
body: str,
*,
refs: Optional[list[str]] = None,
) -> dict[str, Any]:
return self._post(
"/v1/board/items/comment",
{"space": space, "id": item_id, "body": body, "refs": refs or []},
)
def assign(self, space: str, item_id: int, assignee: str) -> dict[str, Any]:
return self._post(
"/v1/board/items/assign",
{"space": space, "id": item_id, "assignee": assignee},
)
def claim(self, space: str, item_id: int) -> dict[str, Any]:
return self._post("/v1/board/items/claim", {"space": space, "id": item_id})
def link(self, space: str, src: int, kind: str, dst: int) -> dict[str, Any]:
return self._post(
"/v1/board/link", {"space": space, "src": src, "kind": kind, "dst": dst}
)
def attach(
self,
space: str,
item_id: int,
data: bytes,
filename: str,
*,
caption: str = "",
) -> dict[str, Any]:
import base64
return self._post(
"/v1/board/items/attach",
{
"space": space,
"id": item_id,
"filename": filename,
"caption": caption,
"data_b64": base64.b64encode(data).decode("ascii"),
},
)
def attachment(self, space: str, stored: str) -> tuple[bytes, str]:
response = self._client.get(
"/v1/board/attachment", params={"space": space, "name": stored}
)
if response.status_code >= 400:
self._unwrap(response) # raises with the server's message
return response.content, response.headers.get(
"content-type", "application/octet-stream"
)
def policy(self, space: str) -> dict[str, Any]:
return self._get("/v1/board/policy", {"space": space})
def set_policy(self, space: str, *, claims: str) -> dict[str, Any]:
return self._post("/v1/board/policy", {"space": space, "claims": claims})
def pending(self, space: str, *, limit: int = 200) -> list[dict[str, Any]]:
return self._get("/v1/board/pending", {"space": space, "limit": limit})[
"events"
]
def consume(self, space: str, upto_seq: int) -> None:
self._post("/v1/board/consume", {"space": space, "upto_seq": int(upto_seq)})
def journal_append(
self,
case: str,
body: str,
*,
kind: str = "note",
space: Optional[str] = None,
item: Optional[int] = None,
entities: Optional[list[str]] = None,
refs: Optional[list[str]] = None,
) -> dict[str, Any]:
return self._post(
"/v1/board/journal",
{
"case": case,
"body": body,
"kind": kind,
"space": space,
"item": item,
"entities": entities or [],
"refs": refs or [],
},
)
def journal_read(
self,
case: str,
*,
item: Optional[int] = None,
author: Optional[str] = None,
kind: Optional[str] = None,
entity: Optional[str] = None,
include_raw: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
return self._get(
"/v1/board/journal",
{
"case": case,
"item": item,
"author": author,
"kind": kind,
"entity": entity,
"include_raw": "1" if include_raw else None,
"limit": limit,
},
)["entries"]
def journal_overview(self) -> list[dict[str, Any]]:
return self._get("/v1/board/journal/cases")["cases"]
def close(self) -> None:
self._client.close()
def local_dialect(
db_dir, *, actor: str = "user", role: str = "user"
) -> LocalDialect:
"""Open the state dir's stores directly as one bound identity — the headless
backing for the CLI and MCP server when no OpenWorker server is running."""
from pathlib import Path
from .attachments import AttachmentStore
base = Path(db_dir).expanduser()
journal = JournalStore(base / "journal.db")
store = TeamStore(base / "teams.db", journal=journal)
return LocalDialect(
store,
journal,
Actor(id=actor, role=Role(role)),
attachments=AttachmentStore(base / "attachments"),
)

434
coworker/teams/journal.py Normal file
View File

@@ -0,0 +1,434 @@
"""The journal store — case-keyed knowledge that outlives boards and teams.
Split from the board log on purpose (decided 2026-08-16): a board is a team-scoped
artifact and can be archived with its team, but a journal case follows the
INVESTIGATION — it may span two boards, survive a team, or belong to an Ops case no
board ever references. So cases live in their own store, hash-chained per case, with
their own grant table. What stays unified with the board is the record shape and the
discipline: attributed, timestamped, append-only, taint-flagged — the policy/audit
choke point is the API layer, not table co-location.
Access model: the user is never gated. Everyone else needs a grant on the case:
- creating a case (first append) grants its creator;
- assignment feeds grants automatically (assign an item carrying a case → the
assignee gains it; reassignment moves it) — "sharing rides assignment";
- explicit grants cover cross-team sharing.
Backing is SQLite for now (same as everything else in the state dir); the store is
deliberately small enough to swap the backing later without touching the verb
surface. Retrieval order stays: filters (here) → entity index → vectors as a
derived index.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from .model import (
JOURNAL_BODY_LIMIT,
JOURNAL_KINDS,
Actor,
AuthorityError,
BoardError,
ChainError,
Role,
)
from .store import GENESIS, _canonical, _hash
_HASHED_FIELDS = (
"ts",
"case_id",
"kind",
"actor",
"actor_role",
"space",
"item_id",
"payload",
"taint",
"prev_hash",
)
class JournalStore:
def __init__(self, db_path: str | Path) -> None:
self.db_path = str(db_path)
if self.db_path != ":memory:":
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS journal_entries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
case_id TEXT NOT NULL,
kind TEXT NOT NULL,
actor TEXT NOT NULL,
actor_role TEXT NOT NULL,
persona TEXT DEFAULT '',
model TEXT DEFAULT '',
session_id TEXT DEFAULT '',
space TEXT,
item_id INTEGER,
payload TEXT NOT NULL,
taint INTEGER NOT NULL DEFAULT 0,
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_journal_case
ON journal_entries (case_id, seq);
CREATE INDEX IF NOT EXISTS idx_journal_item
ON journal_entries (case_id, space, item_id, seq);
CREATE TABLE IF NOT EXISTS journal_grants (
case_id TEXT NOT NULL,
principal TEXT NOT NULL,
source TEXT NOT NULL,
space TEXT DEFAULT '',
item_id INTEGER,
UNIQUE (case_id, principal, source, space, item_id)
);
CREATE TABLE IF NOT EXISTS journal_meta (
case_id TEXT PRIMARY KEY,
head_hash TEXT NOT NULL,
created_ts TEXT NOT NULL
);
""")
self._conn.commit()
# ---------------------------------------------------------------------- verbs
def append(
self,
actor: Actor,
case: str,
body: str,
*,
kind: str = "note",
space: Optional[str] = None,
item: Optional[int] = None,
entities: Optional[list[str]] = None,
refs: Optional[list[str]] = None,
taint: bool = False,
) -> dict[str, Any]:
if not (case or "").strip():
raise BoardError("case is required")
if not (body or "").strip():
raise BoardError("entry body is required")
if kind not in JOURNAL_KINDS:
raise BoardError(f"unknown entry kind: {kind} (use one of {JOURNAL_KINDS})")
if len(body) > JOURNAL_BODY_LIMIT:
raise BoardError(
f"entry body over {JOURNAL_BODY_LIMIT} chars — save the full"
" capture to a file and journal an excerpt that references it"
)
with self._lock:
exists = self._case_exists(case)
if exists:
self._check_access(actor, case)
ts = datetime.now(timezone.utc).isoformat()
prev = self._head_hash(case)
record = {
"ts": ts,
"case_id": case,
"kind": kind,
"actor": actor.id,
"actor_role": actor.role.value,
"space": space,
"item_id": item,
"payload": _canonical(
{
"body": body,
"entities": sorted(set(entities or [])),
"refs": [str(ref) for ref in refs or []],
}
),
"taint": 1 if taint else 0,
"prev_hash": prev,
}
record["hash"] = _hash(record, fields=_HASHED_FIELDS)
try:
cursor = self._conn.execute(
"""
INSERT INTO journal_entries
(ts, case_id, kind, actor, actor_role, persona, model,
session_id, space, item_id, payload, taint, prev_hash, hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ts,
case,
kind,
actor.id,
actor.role.value,
actor.persona,
actor.model,
actor.session_id,
space,
item,
record["payload"],
record["taint"],
prev,
record["hash"],
),
)
if not exists:
self._conn.execute(
"INSERT INTO journal_meta (case_id, head_hash, created_ts)"
" VALUES (?, ?, ?)",
(case, record["hash"], ts),
)
# A new case belongs to whoever opened it.
self._grant_locked(case, actor.id, source="creator")
else:
self._conn.execute(
"UPDATE journal_meta SET head_hash = ? WHERE case_id = ?",
(record["hash"], case),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
return {**record, "seq": cursor.lastrowid}
def read(
self,
actor: Actor,
case: str,
*,
item: Optional[int] = None,
author: Optional[str] = None,
kind: Optional[str] = None,
entity: Optional[str] = None,
since_seq: int = 0,
include_raw: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Filtered read. `raw` captures are skipped unless asked for (by
`kind="raw"` or `include_raw`) so dumps never bury the signal entries."""
with self._lock:
self._check_access(actor, case)
where = ["case_id = ?", "seq > ?"]
params: list[Any] = [case, since_seq]
if item is not None:
where.append("item_id = ?")
params.append(item)
if author:
where.append("actor = ?")
params.append(author)
if kind:
if kind not in JOURNAL_KINDS:
raise BoardError(f"unknown entry kind: {kind}")
where.append("kind = ?")
params.append(kind)
elif not include_raw:
where.append("kind != 'raw'")
rows = self._conn.execute(
"SELECT * FROM journal_entries WHERE "
+ " AND ".join(where)
+ " ORDER BY seq",
params,
).fetchall()
out = []
cap = max(1, min(int(limit or 100), 1000))
for row in rows:
entry = _row_to_entry(row)
if entity and entity not in entry["entities"]:
continue
out.append(entry)
if len(out) >= cap:
break
return out
def overview(self, actor: Actor) -> list[dict[str, Any]]:
"""Case list with entry counts and last activity — the rail's summary view."""
visible = self.cases(actor)
if not visible:
return []
with self._lock:
rows = self._conn.execute(
"SELECT case_id, COUNT(*) AS entries, MAX(ts) AS last_ts"
" FROM journal_entries GROUP BY case_id"
).fetchall()
counts = {row["case_id"]: dict(row) for row in rows}
return [
{
"case": case,
"entries": counts.get(case, {}).get("entries", 0),
"last_ts": counts.get(case, {}).get("last_ts") or "",
}
for case in visible
]
def cases(self, actor: Actor) -> list[str]:
"""Cases visible to this actor (all of them for the user)."""
with self._lock:
if actor.role == Role.USER:
rows = self._conn.execute(
"SELECT case_id FROM journal_meta ORDER BY case_id"
).fetchall()
else:
rows = self._conn.execute(
"SELECT DISTINCT case_id FROM journal_grants WHERE principal = ?"
" ORDER BY case_id",
(actor.id,),
).fetchall()
return [row["case_id"] for row in rows]
# ---------------------------------------------------------------------- grants
def grant(self, actor: Actor, case: str, principal: str) -> None:
"""Explicit cross-team sharing. The user may grant any case; a lead may
grant cases it holds. Workers never grant — evidence flows up, access
flows down."""
if actor.role == Role.WORKER or actor.role == Role.SYSTEM:
raise AuthorityError("only the user or a lead may grant a case")
with self._lock:
if not self._case_exists(case):
raise BoardError(f"no case '{case}'")
if actor.role == Role.LEAD:
self._check_access(actor, case)
self._grant_locked(case, principal, source="grant")
self._conn.commit()
def revoke(self, actor: Actor, case: str, principal: str) -> None:
if actor.role == Role.WORKER or actor.role == Role.SYSTEM:
raise AuthorityError("only the user or a lead may revoke a case grant")
with self._lock:
if actor.role == Role.LEAD:
self._check_access(actor, case)
self._conn.execute(
"DELETE FROM journal_grants WHERE case_id = ? AND principal = ?"
" AND source = 'grant'",
(case, principal),
)
self._conn.commit()
def ensure_case(self, case: str, creator: str) -> None:
"""Create a case (empty, chain at genesis) if it doesn't exist, granting
its creator. Called by the board when an item attaches a case ref — so
the case belongs to whoever attached it, not to whichever assignee
happens to journal first. Standalone cases (no board) are still created
by their first append."""
if not (case or "").strip():
return
with self._lock:
if not self._case_exists(case):
self._conn.execute(
"INSERT INTO journal_meta (case_id, head_hash, created_ts)"
" VALUES (?, ?, ?)",
(case, GENESIS, datetime.now(timezone.utc).isoformat()),
)
self._grant_locked(case, creator, source="creator")
self._conn.commit()
def sync_assignment(
self,
case: str,
*,
space: str,
item_id: int,
assignee: str,
previous: str = "",
) -> None:
"""Called by the board on assign: access rides assignment. The previous
assignee loses the grant THIS item carried (grants from its other items
or explicit shares survive)."""
if not case:
return
with self._lock:
if previous:
self._conn.execute(
"DELETE FROM journal_grants WHERE case_id = ? AND principal = ?"
" AND source = 'assignment' AND space = ? AND item_id = ?",
(case, previous, space, item_id),
)
self._grant_locked(
case, assignee, source="assignment", space=space, item_id=item_id
)
self._conn.commit()
# ----------------------------------------------------------------- integrity
def verify_chain(self, case: str) -> int:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM journal_entries WHERE case_id = ? ORDER BY seq",
(case,),
).fetchall()
prev = GENESIS
for row in rows:
record = {key: row[key] for key in _HASHED_FIELDS}
if row["prev_hash"] != prev:
raise ChainError(f"entry {row['seq']}: chain linkage broken")
if _hash(record, fields=_HASHED_FIELDS) != row["hash"]:
raise ChainError(f"entry {row['seq']}: content does not match hash")
prev = row["hash"]
# Tail truncation is invisible to the chain itself; the stored head sees it.
if rows and prev != self._head_hash(case):
raise ChainError("case log ends before the recorded head — tail deleted")
return len(rows)
def close(self) -> None:
self._conn.close()
# ------------------------------------------------------------------ internals
def _check_access(self, actor: Actor, case: str) -> None:
if actor.role == Role.USER:
return
row = self._conn.execute(
"SELECT 1 FROM journal_grants WHERE case_id = ? AND principal = ?"
" LIMIT 1",
(case, actor.id),
).fetchone()
if row is None:
raise AuthorityError(f"{actor.id} has no grant on case '{case}'")
def _grant_locked(
self,
case: str,
principal: str,
*,
source: str,
space: str = "",
item_id: Optional[int] = None,
) -> None:
self._conn.execute(
"INSERT OR IGNORE INTO journal_grants"
" (case_id, principal, source, space, item_id) VALUES (?, ?, ?, ?, ?)",
(case, principal, source, space, item_id),
)
def _case_exists(self, case: str) -> bool:
return (
self._conn.execute(
"SELECT 1 FROM journal_meta WHERE case_id = ?", (case,)
).fetchone()
is not None
)
def _head_hash(self, case: str) -> str:
row = self._conn.execute(
"SELECT head_hash FROM journal_meta WHERE case_id = ?", (case,)
).fetchone()
return row["head_hash"] if row else GENESIS
def _row_to_entry(row: sqlite3.Row) -> dict[str, Any]:
entry = dict(row)
try:
payload = json.loads(entry.pop("payload") or "{}")
except json.JSONDecodeError:
payload = {}
entry["body"] = payload.get("body")
entry["entities"] = payload.get("entities") or []
entry["refs"] = payload.get("refs") or []
entry["author"] = entry.pop("actor")
entry["role"] = entry.pop("actor_role")
entry["item"] = entry.pop("item_id")
return entry

View File

@@ -0,0 +1,211 @@
"""`team-board` — the board and journal as an MCP server on stdio.
The way an external coding agent joins a team: its MCP config runs
`ocw board mcp --url … --token … --space …` (or `--db …` headless), it sees the
role-scoped board tools, and the user asks it to claim an item and work. Identity
and authority never live here: the dialect is already bound to one actor (token or
local flags), and every write is judged by the store/server — this file is a thin
adapter, safe to hand to any harness.
Tool results are JSON — raw data for the agent, not prose.
"""
from __future__ import annotations
from typing import Any, Optional
from .model import BoardError
def build(dialect, *, space: str):
"""Assemble the FastMCP server for one dialect+space. Split from serve() so
tests can inspect the registered tool set without a transport."""
from mcp.server.fastmcp import FastMCP
who = dialect.whoami()
role = who.get("role", "worker")
mcp = FastMCP(
"team-board",
instructions=(
f"A shared team work board (you are '{who.get('actor')}', role"
f" {role}) plus the team journal. Items carry acceptance criteria —"
" what gets verified before they can be done. Typical worker loop:"
" board_list → board_claim an open item → board_move to in_progress →"
" work, journal_append findings as you go → board_move to review with"
" a hand-off comment and refs. Never mark items done — done is the"
" verdict after review."
),
)
def _safe(func, *args, **kwargs) -> Any:
try:
return func(*args, **kwargs)
except (BoardError, ValueError) as error:
return {"error": str(error)}
@mcp.tool()
def board_list(state: str = "", assignee: str = "") -> Any:
"""List work items on the board, optionally filtered by state
(open/in_progress/blocked/review/done/canceled) or assignee."""
return _safe(
dialect.list_items, space, state=state or None, assignee=assignee or None
)
@mcp.tool()
def board_show(item: int) -> Any:
"""One work item in full: description, acceptance criteria, refs, links,
and every comment."""
return _safe(dialect.get_item, space, item)
@mcp.tool()
def board_create(
title: str,
criteria: str,
description: str = "",
parent: Optional[int] = None,
case: str = "",
) -> Any:
"""File a new work item (open, unassigned — work starts when it is
assigned or claimed). `criteria` is the acceptance criteria — what gets
verified before the item can be done; required."""
return _safe(
dialect.create_item,
space,
title=title,
criteria=criteria,
description=description,
parent=parent,
case=case or None,
)
@mcp.tool()
def board_claim(item: int) -> Any:
"""Claim an open, unassigned item for yourself. First claim wins; the
item becomes your assignment. Only claim work you can start on now."""
return _safe(dialect.claim, space, item)
@mcp.tool()
def board_move(item: int, to: str, comment: str = "", refs: list[str] = []) -> Any:
"""Move a work item: in_progress when you start, blocked with the blocker
as `comment`, review with a hand-off comment and artifact refs (branch,
PR, file:line) when finished."""
return _safe(
dialect.transition, space, item, to, comment=comment, refs=list(refs or [])
)
@mcp.tool()
def board_comment(item: int, body: str, refs: list[str] = []) -> Any:
"""Comment on a work item — durable and attributed; answers that matter
belong here. `refs` attach artifact pointers."""
return _safe(dialect.comment, space, item, body, refs=list(refs or []))
@mcp.tool()
def board_attach(item: int, path: str, caption: str = "") -> Any:
"""Attach a screenshot or image (png/jpg/gif/webp, ≤10MB) from a local
file to a work item — so the lead/reviewer can SEE what you did. Give it
a caption saying what the image shows. Great with review hand-offs."""
from pathlib import Path as _Path
source = _Path(path).expanduser()
if not source.is_file():
return {"error": f"no such file: {path}"}
return _safe(
dialect.attach,
space,
item,
source.read_bytes(),
source.name,
caption=caption,
)
@mcp.tool()
def board_pending() -> Any:
"""Your unconsumed feed: every event on items assigned to you or filed
by you — assignments, send-backs with feedback, comments from the lead
or user, cancellations. Check at the start of a work session and before
finishing; acknowledge with board_consume."""
return _safe(dialect.pending, space)
@mcp.tool()
def board_consume(upto_seq: int) -> Any:
"""Acknowledge feed events up to a sequence number (from board_pending),
so they are not re-delivered."""
return _safe(lambda: (dialect.consume(space, upto_seq), {"ok": True})[1])
if role in ("lead", "user"):
@mcp.tool()
def board_assign(item: int, assignee: str) -> Any:
"""Assign a work item to a worker (or to yourself to reserve it)."""
return _safe(dialect.assign, space, item, assignee)
@mcp.tool()
def board_link(src: int, kind: str, dst: int) -> Any:
"""Link two items: `parent` (dst becomes src's parent) or `blocks`
(src blocks dst)."""
return _safe(dialect.link, space, src, kind, dst)
@mcp.tool()
def board_policy(claims: str = "") -> Any:
"""Show the board's claim policy, or set it: `open` (workers may
self-claim open items) or `lead-only`."""
if claims:
return _safe(dialect.set_policy, space, claims=claims)
return _safe(dialect.policy, space)
@mcp.tool()
def journal_append(
case: str,
body: str,
kind: str = "note",
item: Optional[int] = None,
entities: list[str] = [],
refs: list[str] = [],
) -> Any:
"""Append to a journal case as you work: kind is finding, evidence,
decision, note, or raw (a capture excerpt referencing a file).
`entities` are the concrete things it is about (paths, resources, ids)."""
return _safe(
dialect.journal_append,
case,
body,
kind=kind,
space=space,
item=item,
entities=list(entities or []),
refs=list(refs or []),
)
@mcp.tool()
def journal_read(
case: str,
item: Optional[int] = None,
author: str = "",
kind: str = "",
entity: str = "",
include_raw: bool = False,
limit: int = 50,
) -> Any:
"""Read a journal case, filtered by item, author, entry kind, or entity.
Prefer narrow reads; raw captures are skipped unless asked."""
return _safe(
dialect.journal_read,
case,
item=item,
author=author or None,
kind=kind or None,
entity=entity or None,
include_raw=include_raw,
limit=limit,
)
@mcp.tool()
def journal_cases() -> Any:
"""The journal cases you can read, with entry counts."""
return _safe(dialect.journal_overview)
return mcp
def serve(dialect, *, space: str) -> None:
build(dialect, space=space).run("stdio")

97
coworker/teams/model.py Normal file
View File

@@ -0,0 +1,97 @@
"""Work-item model for agent teams — states, actors, links, errors.
The board is not a database of record: it is a projection of the append-only team
event log (see teams.store). These are the shapes the projection folds into, and the
rules the verbs enforce. Deliberately minimal — no sprints, estimates, priorities, or
custom fields; anyone needing those graduates to a real tracker via connectors.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ItemState(str, Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
REVIEW = "review"
DONE = "done"
CANCELED = "canceled"
# Legal edges of the state machine. There is NO draft/proposed state (decided
# 2026-08-16): a plan proposal lives in the conversation (plan-approval flow) and
# the board only ever contains accepted work — items are created `open`, and the
# control point for work starting is ASSIGNMENT (a granted, revocable authority),
# not a per-item approval. review→done stays the verification gate; canceled→open
# is reopen.
EDGES: dict[ItemState, set[ItemState]] = {
ItemState.OPEN: {ItemState.IN_PROGRESS, ItemState.CANCELED},
ItemState.IN_PROGRESS: {ItemState.BLOCKED, ItemState.REVIEW, ItemState.CANCELED},
ItemState.BLOCKED: {ItemState.IN_PROGRESS, ItemState.CANCELED},
ItemState.REVIEW: {ItemState.DONE, ItemState.IN_PROGRESS, ItemState.CANCELED},
ItemState.DONE: set(),
ItemState.CANCELED: {ItemState.OPEN},
}
# Targets a worker may move its OWN item to. Workers never approve, never close:
# done is the lead's verdict at review, cancel is a lead/user board decision.
WORKER_TARGETS = {ItemState.IN_PROGRESS, ItemState.BLOCKED, ItemState.REVIEW}
class Role(str, Enum):
USER = "user"
LEAD = "lead"
WORKER = "worker"
SYSTEM = "system"
@dataclass(frozen=True)
class Actor:
"""Who is speaking to the board. `id` is the agent instance id ("user" for the
human); role decides verb authority — the capability firebreak in data form."""
id: str
role: Role
persona: str = ""
model: str = ""
session_id: str = ""
LINK_KINDS = ("parent", "blocks") # link(src, "parent", dst): dst is src's parent
# link(src, "blocks", dst): src blocks dst
# `note` is any observation — the journal is not only for investigations. `raw` is
# a capture (log excerpt, command output); reads skip raw unless asked, and large
# payloads belong in a file the entry references.
JOURNAL_KINDS = ("finding", "evidence", "decision", "note", "raw")
# An entry body is an excerpt/summary, never a blob: oversized payloads make every
# read (and replay) drag. Full captures live as files the entry points at.
JOURNAL_BODY_LIMIT = 16_000
def space_for_workspace(workspace: str | Path) -> str:
"""Spaces are keyed to the project/workspace (boards are views over a space).
The resolved path is the one unambiguous local key; a display name is its
basename."""
return str(Path(workspace).expanduser().resolve())
class BoardError(Exception):
"""A verb call the board refuses — illegal transition, missing item, bad input."""
class BoardNotFoundError(BoardError):
"""A requested board object is missing or is not visible to the actor."""
class AuthorityError(BoardError):
"""The actor's role does not permit this verb on this item."""
class ChainError(Exception):
"""Hash-chain verification failed — the log was modified out of band."""

140
coworker/teams/registry.py Normal file
View File

@@ -0,0 +1,140 @@
"""Team registry — which sessions form a team: one lead, its workers, their board.
A team is created at the staffing gate ("Create team & start"): worker sessions are
PRE-SPAWNED as durable state on disk (spawn ≠ first turn — an unassigned worker costs
zero tokens; its first model turn fires when the first assignment lands). The registry
is the roster the wake plumbing walks each tick, and the tie that scopes staleness
digests by role membership.
"""
from __future__ import annotations
import json
import threading
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@dataclass
class TeamWorker:
actor: str # the lead-given NAME — board actor id, assignee handle, @mention target
persona: str
session_id: str
model: str = ""
reason: str = "" # why the lead staffed it — surfaces in teammates' rosters
@dataclass
class Team:
team_id: str
space: str
lead_session: str
lead_actor: str
workers: list[TeamWorker] = field(default_factory=list)
chat_enabled: bool = False
chat_group: str = "" # ChatStore group_id when chat is enabled
paused: bool = False # budget/user pause: the wake gate skips a paused team
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
# Rolling budget gate: automatic wakes this hour (reset when the hour rolls).
wake_hour: str = ""
wakes_this_hour: int = 0
class TeamRegistry:
def __init__(self, path: Optional[str | Path] = None) -> None:
self.path = Path(path) if path else None
self._lock = threading.Lock()
self._teams: dict[str, Team] = {}
if self.path and self.path.is_file():
for raw in json.loads(self.path.read_text(encoding="utf-8")).get(
"teams", []
):
workers = [TeamWorker(**w) for w in raw.pop("workers", [])]
team = Team(**{**raw, "workers": []})
team.workers = workers
self._teams[team.team_id] = team
def _save(self) -> None:
if not self.path:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps(
{"teams": [asdict(t) for t in self._teams.values()]}, indent=2
),
encoding="utf-8",
)
def create(
self,
*,
space: str,
lead_session: str,
lead_actor: str,
workers: list[TeamWorker],
chat_enabled: bool = False,
chat_group: str = "",
) -> Team:
team = Team(
team_id=uuid.uuid4().hex[:12],
space=space,
lead_session=lead_session,
lead_actor=lead_actor,
workers=workers,
chat_enabled=chat_enabled,
chat_group=chat_group,
)
with self._lock:
self._teams[team.team_id] = team
self._save()
return team
def all(self) -> list[Team]:
return list(self._teams.values())
def get(self, team_id: str) -> Optional[Team]:
return self._teams.get(team_id)
def for_lead_session(self, session_id: str) -> Optional[Team]:
for team in self._teams.values():
if team.lead_session == session_id:
return team
return None
def for_worker_session(self, session_id: str) -> Optional[tuple[Team, TeamWorker]]:
for team in self._teams.values():
for worker in team.workers:
if worker.session_id == session_id:
return team, worker
return None
def set_paused(self, team_id: str, paused: bool) -> None:
with self._lock:
team = self._teams.get(team_id)
if team is not None:
team.paused = paused
self._save()
def count_wake(self, team_id: str, *, cap: int) -> bool:
"""The budget gate at the wake gate: count one automatic wake against the
team's rolling hour; False = over cap (the caller skips the wake and the
team reads as paused-for-budget until the hour rolls). A runaway loop
stops BETWEEN turns, never mid-flight."""
hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
with self._lock:
team = self._teams.get(team_id)
if team is None:
return False
if team.wake_hour != hour:
team.wake_hour, team.wakes_this_hour = hour, 0
if team.wakes_this_hour >= cap:
self._save()
return False
team.wakes_this_hour += 1
self._save()
return True

1290
coworker/teams/store.py Normal file

File diff suppressed because it is too large Load Diff

97
coworker/teams/tokens.py Normal file
View File

@@ -0,0 +1,97 @@
"""Board join tokens — identity for external board clients.
A token binds an ACTOR and a ROLE server-side: an external harness (another agent
CLI, a headless OpenWorker, the `ocw` CLI from a second machine) presents the token
and the server resolves who it is — the client never states its own identity, and a
worker token cannot claim to be the lead. Authority then falls to the store, same
as for in-app agents: the token is identity, the store is the gate.
Storage is hash-only (sha256): the plaintext is shown once at mint and never
persisted, so the registry file leaking doesn't leak the credentials. Revocation is
per-token, keyed by the display prefix.
"""
from __future__ import annotations
import hashlib
import json
import secrets
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from .model import Actor, Role
_TOKEN_PREFIX = "owb_" # OpenWorker board — greppable in configs, meaningless to guess
class BoardTokens:
def __init__(self, path: str | Path) -> None:
self.path = Path(path).expanduser()
self._lock = threading.Lock()
def mint(self, actor: str, role: str = "worker", *, label: str = "") -> str:
"""Create a token for one actor identity; returns the plaintext ONCE."""
actor = (actor or "").strip()
if not actor:
raise ValueError("actor is required")
Role(role) # validate early — a bad role should fail at mint, not at use
token = _TOKEN_PREFIX + secrets.token_urlsafe(32)
with self._lock:
entries = self._load()
entries[_digest(token)] = {
"actor": actor,
"role": role,
"label": label,
"prefix": token[:12],
"created_ts": datetime.now(timezone.utc).isoformat(),
}
self._save(entries)
return token
def resolve(self, token: str) -> Optional[Actor]:
if not token:
return None
with self._lock:
entry = self._load().get(_digest(token))
if entry is None:
return None
return Actor(id=entry["actor"], role=Role(entry["role"]))
def entries(self) -> list[dict[str, Any]]:
with self._lock:
return sorted(self._load().values(), key=lambda e: e["created_ts"])
def revoke(self, prefix: str) -> int:
"""Revoke every token whose display prefix matches; returns the count."""
prefix = (prefix or "").strip()
if not prefix:
return 0
with self._lock:
entries = self._load()
keep = {
key: entry
for key, entry in entries.items()
if not entry["prefix"].startswith(prefix)
}
removed = len(entries) - len(keep)
if removed:
self._save(keep)
return removed
def _load(self) -> dict[str, dict[str, Any]]:
try:
return json.loads(self.path.read_text())
except (OSError, ValueError):
return {}
def _save(self, entries: dict[str, dict[str, Any]]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(entries, indent=2))
tmp.replace(self.path)
def _digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()

400
coworker/teams/tools.py Normal file
View File

@@ -0,0 +1,400 @@
"""Board and journal verbs as agent tools.
The verbs are generic on purpose (the connector-dialect play): the local TeamStore is
the default backing, and a Jira/Linear-backed dialect can implement the same tool
surface later. Registration is gated by the persona's `team:` trait — a lead gets the
full set, a worker gets the worker set, solo personas get none of this.
The engine decides `taint` (whether this agent touched untrusted content this
session) and passes it at construction — the model never self-reports provenance.
"""
from __future__ import annotations
from typing import Callable, Optional
import aisuite as ai
from .journal import JournalStore
from .model import Actor, BoardError, Role
from .store import TeamStore
LEAD_VERBS = ("create_item", "list_items", "transition", "comment", "assign", "link")
# Workers file items too (a bug spotted in passing, a follow-up) — new items land
# `open` and unassigned; nothing runs until the item is assigned. `claim` is
# self-assignment: on an open-claims board (the default) a worker may pick up an
# open, unassigned item — the store arbitrates races, the lead supervises by
# exception (every claim lands in its feed; reassign/cancel revokes).
WORKER_VERBS = ("create_item", "list_items", "transition", "comment", "claim")
JOURNAL_VERBS = ("journal_append", "journal_read")
# Explicit schema: the auto-generator's normalizer strips every `title` key to drop
# pydantic metadata, which also deletes a PARAMETER named `title` from properties.
# Registered via `__coworker_schema__` (same escape hatch as todo_write).
_CREATE_ITEM_SCHEMA = {
"type": "function",
"function": {
"name": "create_item",
"description": (
"Create a work item (open, unassigned — work starts when it is"
" assigned). `criteria` is the acceptance criteria — what gets verified"
" before the item can be done; required. `parent` links it under"
" another item; `case` names its journal case (children inherit the"
" parent's case by default)."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"criteria": {"type": "string"},
"description": {"type": "string"},
"parent": {"type": "integer"},
"case": {"type": "string"},
},
"required": ["title", "criteria"],
},
},
}
def board_tools(
store: TeamStore,
*,
space: str,
actor: Actor,
taint: Callable[[], bool] = lambda: False,
attachments=None,
) -> list:
"""The board verbs for one agent, pre-bound to its space and identity.
Authority is enforced twice on purpose: the returned set is role-filtered
(a worker never even sees `assign`), and the store re-checks every call —
the tool layer is convenience, the store is the gate.
"""
def create_item(
title: str,
criteria: str,
description: str = "",
parent: Optional[int] = None,
case: str = "",
) -> dict:
"""Create a work item (open, unassigned — work starts when it is
assigned). `criteria` is the acceptance criteria — what gets verified
before the item can be done; required. `parent` links it under another
item; `case` names its journal case (children inherit the parent's case
by default)."""
return _call(
store.create_item,
space,
actor,
title=title,
criteria=criteria,
description=description,
parent=parent,
case=case or None,
)
def list_items(state: str = "", assignee: str = "") -> dict:
"""List work items on the board, optionally filtered by state
(open/in_progress/blocked/review/done/canceled) or assignee."""
try:
return {"items": store.list_items(space, actor, state=state or None, assignee=assignee or None)}
except (BoardError, ValueError) as error:
return {"error": str(error)}
def transition(
item: int, to: str, comment: str = "", refs: Optional[list] = None
) -> dict:
"""Move a work item to a new state. Workers move their own item to
in_progress, blocked, or review (attach the blocker or a hand-off summary
as `comment`, and artifact pointers — branch, report, session — as
`refs`); done requires review verification first."""
return _call(
store.transition,
space,
actor,
item,
to,
comment=comment,
refs=[str(ref) for ref in refs or []],
taint=taint(),
)
def comment(item: int, body: str, refs: Optional[list] = None) -> dict:
"""Add a comment to a work item. Comments are durable and attributed —
answers that matter belong here, not in chat. `refs` attach artifact
pointers (branch, PR, report, file:line) to the item."""
return _call(
store.comment,
space,
actor,
item,
body,
refs=[str(ref) for ref in refs or []],
taint=taint(),
)
def claim(item: int) -> dict:
"""Claim an open, unassigned work item for yourself. First claim wins;
the item becomes your assignment. Only claim work you can start on —
the lead sees every claim and can reassign."""
return _call(store.claim, space, actor, item)
def assign(item: int, assignee: str) -> dict:
"""Assign a work item to a worker coworker. The item itself becomes the
worker's assignment — write the description and criteria accordingly."""
return _call(store.assign, space, actor, item, assignee)
def link(src: int, kind: str, dst: int) -> dict:
"""Link two work items: kind `parent` (dst becomes src's parent) or
`blocks` (src blocks dst)."""
return _call(store.link, space, actor, src, kind, dst)
def attach_image(item: int, path: str, caption: str = "") -> dict:
"""Attach a screenshot or image file (png/jpg/gif/webp, ≤10MB) to a work
item so the lead/reviewer can SEE what you did — pair it with your review
hand-off. `caption` says what the image shows."""
from pathlib import Path as _Path
source = _Path(path).expanduser()
if not source.is_file():
return {"error": f"no such file: {path}"}
try:
ref = attachments.put(source.read_bytes(), source.name)
except (BoardError, ValueError) as error:
return {"error": str(error)}
return _call(
store.attach_ref,
space,
actor,
item,
caption or f"attached {source.name}",
ref,
taint=taint(),
)
verbs = LEAD_VERBS if actor.role in (Role.USER, Role.LEAD) else WORKER_VERBS
if attachments is not None:
verbs = verbs + ("attach_image",)
local = locals()
out = []
for name in verbs:
wrapped = _wrap(local[name])
if name == "create_item":
wrapped.__coworker_schema__ = _CREATE_ITEM_SCHEMA
out.append(wrapped)
return out
def journal_tools(
journal: "JournalStore",
*,
actor: Actor,
space: str = "",
taint: Callable[[], bool] = lambda: False,
) -> list:
def journal_append(
case: str,
body: str,
kind: str = "note",
item: Optional[int] = None,
entities: Optional[list] = None,
refs: Optional[list] = None,
) -> dict:
"""Append an entry to a journal case as you work: kind is finding,
evidence, decision, note (any observation), or raw (a capture like a log
excerpt — for large captures, save the full output to a file and journal
an excerpt that references it). `entities` are the concrete things it is
about (file paths, resource names, CVE ids) — they power later recall;
`refs` are pointers (file:line, commit, url)."""
return _call(
journal.append,
actor,
case,
body,
kind=kind,
space=space or None,
item=item,
entities=[str(entity) for entity in entities or []],
refs=[str(ref) for ref in refs or []],
taint=taint(),
)
def journal_read(
case: str,
item: Optional[int] = None,
author: str = "",
kind: str = "",
entity: str = "",
include_raw: bool = False,
limit: int = 50,
) -> dict:
"""Read a journal case, filtered: by item, author, entry kind, or entity.
Prefer narrow filtered reads over pulling the whole case. Raw captures
are skipped unless you pass include_raw or kind="raw"."""
try:
return {
"entries": journal.read(
actor,
case,
item=item,
author=author or None,
kind=kind or None,
entity=entity or None,
include_raw=include_raw,
limit=limit,
)
}
except (BoardError, ValueError) as error:
return {"error": str(error)}
local = locals()
return [_wrap(local[name]) for name in JOURNAL_VERBS]
# The staffing gate's schema carrier. Like propose_plan, the real handling lives in
# the TurnEngine (it needs the out-of-band approval round-trip): it emits
# TEAM_PROPOSED and waits; approval PRE-SPAWNS the worker sessions and returns the
# roster (actor ids) to the lead. This body only runs when no approver is wired.
_PROPOSE_TEAM_SCHEMA = {
"type": "function",
"function": {
"name": "propose_team",
"description": (
"Propose the worker coworkers you need for this board. Give EACH member"
" a short unique callname (`name`, e.g. 'nia', 'webb', 'checks') — it"
" becomes their handle for assignment and @mentions, and lets you staff"
" two of the same coworker. The user sees the roster and approves it;"
" approval creates the worker sessions and returns the handles. Only"
" team-capable worker coworkers may be proposed."
),
"parameters": {
"type": "object",
"properties": {
"members": {
"type": "array",
"items": {
"type": "object",
"properties": {
"persona": {"type": "string"},
"name": {"type": "string"},
"model": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["persona", "name"],
},
},
"enable_chat": {"type": "boolean"},
"note": {"type": "string"},
},
"required": ["members"],
},
},
}
# The decomposition gate's schema carrier — the board-flavored sibling of
# propose_plan, usable in ANY permission mode (proposing costs nothing; the board
# only ever holds accepted work). The engine intercepts it; approval creates the
# items and returns their ids.
_PROPOSE_ITEMS_SCHEMA = {
"type": "function",
"function": {
"name": "propose_work_items",
"description": (
"Present your decomposition to the user as proposed WORK ITEMS for the"
" team board. Approval creates them on the board (ids come back in the"
" result); rejection returns feedback to revise. Each item needs a"
" title and acceptance criteria — what gets verified before it can be"
" done. This is not propose_plan: it carries no implementation steps"
" and works in any mode — it is how a lead plans and coordinates via"
" the board."
),
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"criteria": {"type": "string"},
"description": {"type": "string"},
"case": {"type": "string"},
},
"required": ["title", "criteria"],
},
},
"note": {"type": "string"},
},
"required": ["items"],
},
},
}
def propose_work_items_tool() -> object:
def propose_work_items(items: Optional[list] = None, note: str = "") -> dict:
"""Present proposed work items ({title, criteria, description?, case?})
for the user's approval; approval creates them on the board."""
return {
"approved": False,
"error": "item proposals aren't available in this surface",
}
wrapped = ai.tool(
propose_work_items,
metadata=ai.ToolMetadata(
category="team",
risk_level="low",
capabilities=["team"],
),
)
wrapped.__coworker_schema__ = _PROPOSE_ITEMS_SCHEMA
return wrapped
def propose_team_tool() -> object:
def propose_team(
members: Optional[list] = None, enable_chat: bool = False, note: str = ""
) -> dict:
"""Propose the worker roster for this board (the staffing gate). Each member
is {persona, model?, reason?}. The user approves; approval creates the
worker sessions and returns their actor ids for assignment."""
return {
"approved": False,
"error": "team staffing isn't available in this surface",
}
wrapped = ai.tool(
propose_team,
metadata=ai.ToolMetadata(
category="team",
risk_level="medium",
capabilities=["team"],
),
)
wrapped.__coworker_schema__ = _PROPOSE_TEAM_SCHEMA
return wrapped
def _call(func, *args, **kwargs) -> dict:
try:
result = func(*args, **kwargs)
return result if isinstance(result, dict) else {"ok": True}
except (BoardError, ValueError) as error:
return {"error": str(error)}
def _wrap(func):
risk = "medium" if func.__name__ == "assign" else "low"
return ai.tool(
func,
metadata=ai.ToolMetadata(
category="team",
risk_level=risk,
capabilities=["team"],
),
)