Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
# Editor / Obsidian vault metadata
.obsidian/

# Python
__pycache__/
*.py[cod]
.venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
*.egg-info/
dist/
build/
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# panopticon

Orchestrate multiple coding agents across isolated tasks and **configurable workflows**.

A ground-up rewrite of the [cloude-cade](https://github.com/tildesrc/cloude-cade)
prototype. The design lives on the [`design-docs`](../../tree/design-docs) branch
(goals, parity analysis, architecture, roadmap, and ADRs).

## Architecture in one paragraph

A deterministic control plane (the **task service**) owns task state and drives
per-workflow state machines; a per-machine **runner** spawns task containers and host
tmux sessions; a **terminal controller** runs the dashboard. **All LLM calls happen
inside task containers** — the control plane, runner, and dashboard never call a model.
See the `design-docs` branch for the full picture.

## Status

Early development. Building Milestone 1 in vertical slices (see the roadmap). This slice
lands the core contracts:

- `panopticon.core` — domain models, the workflow port (`Workflow` ABC), and the
deterministic lifecycle engine (state machine, turn tracking, responsibility gating).
- `panopticon.workflows.FreeFormWorkflow` — the minimal seed workflow.

## Development

```sh
uv sync # create the venv and install dev deps
uv run pytest # run tests
uv run mypy -p panopticon # type-check
```
30 changes: 30 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[project]
name = "panopticon"
version = "0.0.1"
description = "Orchestrate multiple coding agents across isolated tasks and configurable workflows."
requires-python = ">=3.11"
dependencies = []

[dependency-groups]
dev = [
"pytest>=8",
"mypy>=1.11",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/panopticon"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"

[tool.mypy]
python_version = "3.11"
strict = true
mypy_path = "src"
namespace_packages = true
explicit_package_bases = true
3 changes: 3 additions & 0 deletions src/panopticon/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""panopticon — keep an eye on your agents."""

__version__ = "0.0.1"
45 changes: 45 additions & 0 deletions src/panopticon/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Core domain: models, state classes, and the workflow interface (the state machine).

Nothing in this package performs I/O or calls an LLM (the determinism invariant — all LLM
calls happen inside task containers).
"""

from panopticon.core.models import (
Actor,
HistoryEntry,
Repo,
Responsibility,
Status,
Task,
)
from panopticon.core.state import (
BaseState,
Complete,
Dropped,
State,
TerminalState,
)
from panopticon.core.workflow import (
IllegalTransition,
InvalidWorkflow,
ResponsibilitiesNotMet,
Workflow,
)

__all__ = [
"Actor",
"BaseState",
"Complete",
"Dropped",
"HistoryEntry",
"IllegalTransition",
"InvalidWorkflow",
"Repo",
"ResponsibilitiesNotMet",
"Responsibility",
"State",
"Status",
"Task",
"TerminalState",
"Workflow",
]
141 changes: 141 additions & 0 deletions src/panopticon/core/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Core domain models — pure data, no I/O, no LLM.

These types are the vocabulary the whole system shares. Most are plain records; the exception
is :class:`Task`, which carries behavior over **its own record** — fulfilling the
responsibilities it promised on entry and reporting which remain outstanding. The *rules of
the state machine* (which transitions are legal, what each state means) live in
:class:`panopticon.core.workflow.Workflow`, and the state classes live in
:mod:`panopticon.core.state`.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum


class Actor(str, Enum):
"""A party that can act on a task: the user or the agent.

The same two parties answer every "who?" in the model — who holds the turn
(``Task.turn``, ``State.turn_on_enter``) and who transitions out of a state
(``State.advanced_by``).
"""

USER = "user"
AGENT = "agent"


class Status(str, Enum):
"""Resolution status of a single responsibility."""

PENDING = "pending" # not yet resolved — blocks handing the turn back
MET = "met"
FAILED = "failed" # could not be satisfied; requires a comment


@dataclass(frozen=True)
class Responsibility:
"""An agent obligation for a state.

The workflow supplies these as *definitions* (``status`` ``PENDING``, no comment). The
agent resolves each to ``MET`` or ``FAILED`` before handing the turn back; a ``FAILED``
responsibility must carry a ``comment`` explaining why. They are agent-only — user
actions drive transitions directly rather than being modelled as responsibilities.
"""

key: str
description: str
status: Status = Status.PENDING
comment: str | None = None

def resolve(self, status: Status, comment: str | None = None) -> Responsibility:
"""Return a resolved copy carrying the definition's ``key``/``description``."""
return Responsibility(
key=self.key, description=self.description, status=status, comment=comment
)


@dataclass
class Repo:
Comment thread
tildesrc marked this conversation as resolved.
"""A repository tasks operate on. Owns secret references (added in a later slice)."""

id: str
name: str
git_url: str
default_base: str = "main"


@dataclass(frozen=True)
class HistoryEntry:
"""One entry in a task's log — recorded when the task *enters* ``to_state``.

Timestamps are passed in by the caller (the task service stamps them); the core
never reads the clock, which keeps the state machine deterministic and testable.

On entry, ``responsibilities`` is seeded with the destination state's obligations, all
``PENDING`` — a promise to fulfil them before leaving. The agent then resolves them **one
at a time**, which replaces entries in this list in place; that is the *only* mutable part
of an otherwise append-only, frozen record (the transition facts never change).
"""

at: str # ISO-8601 timestamp, supplied by the caller
from_state: str | None
to_state: str
trigger: str | None = None # what triggered the transition (e.g. "start", "advance")
note: str | None = None
responsibilities: list[Responsibility] = field(default_factory=list)


@dataclass
class Task:
"""A unit of work. Identity is the internal ``id``; ``slug`` is a human label set later.

A task carries behavior over **its own record** — fulfilling the responsibilities it
promised on entering its current state, and reporting which remain outstanding. It knows
nothing of the state machine's rules; those live in
:class:`~panopticon.core.workflow.Workflow`, which drives the task across states.
"""

id: str
repo_id: str
workflow: str
state: str
turn: Actor
slug: str | None = None
history: list[HistoryEntry] = field(default_factory=list)

@property
def current_entry(self) -> HistoryEntry:
"""The latest history entry — the one recorded on entering the current state."""
return self.history[-1]

def record_responsibility(
self, *, key: str, status: Status, comment: str | None = None
) -> None:
"""Fulfil one responsibility promised on entering the current state, in place.

Resolves the matching promise on :attr:`current_entry`. ``status`` must be ``MET`` or
``FAILED`` (the latter requires a ``comment``). Raises :class:`ValueError` for an
unknown key, a ``PENDING`` status, or a ``FAILED`` without a comment.
"""
if status is Status.PENDING:
raise ValueError("record a responsibility as MET or FAILED, not PENDING")
if status is Status.FAILED and not (comment and comment.strip()):
raise ValueError(f"FAILED responsibility {key!r} requires a comment")
promised = self.current_entry.responsibilities
for i, definition in enumerate(promised):
if definition.key == key:
promised[i] = definition.resolve(status, comment)
return
raise ValueError(f"no responsibility {key!r} promised in state {self.state!r}")

@property
def outstanding_responsibilities(self) -> list[Responsibility]:
Comment thread
tildesrc marked this conversation as resolved.
"""Promises on the current entry still unresolved (``PENDING``).

An empty result means the turn may be handed back and the task may advance. A
``FAILED`` promise counts as resolved — :meth:`record_responsibility` already requires
its comment, so it never lingers here.
"""
return [r for r in self.current_entry.responsibilities if r.status is Status.PENDING]
75 changes: 75 additions & 0 deletions src/panopticon/core/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Workflow states, expressed as classes (declarative, à la an ORM model).

A state is a **class** used as a type-level identity (never instantiated). Subclass:

* :class:`State` — a non-terminal state. It carries an inherited transition to
:class:`Dropped`, so every task is always droppable; concrete states add their own
``transitions`` (which accumulate with the inherited ``Dropped`` across the hierarchy).
* :class:`TerminalState` — a terminal state (no outgoing transitions).

``transitions`` entries are other state **classes** or their ``label`` **strings**; strings
are resolved to classes when the owning :class:`~panopticon.core.workflow.Workflow` is built
(forward references — e.g. cycles — must use strings, as with an ORM relationship).

Each state declares two orthogonal, immutable facts:

* ``turn_on_enter`` — who holds the turn *on entry* (distinct from ``Task.turn``, the live
holder, which may differ later within the state);
* ``advanced_by`` — who moves the task *out* of the state (the user, or the agent once
satisfied). These are independent: e.g. a plan state is left by the user (approval) yet
the next state may begin on the agent's turn.
"""

from __future__ import annotations

from abc import ABC
from typing import ClassVar

from panopticon.core.models import Actor, Responsibility


class BaseState(ABC):
"""Common base for workflow states — use :class:`State` or :class:`TerminalState`."""

#: Stable identifier — persisted in ``Task.state`` and shown on the dashboard.
label: ClassVar[str]
#: Who holds the turn upon entering this state.
turn_on_enter: ClassVar[Actor]
#: The agent's obligations while in this state (empty = ungated).
responsibilities: ClassVar[tuple[Responsibility, ...]] = ()


class TerminalState(BaseState):
"""A terminal state: the task is finished here; no outgoing transitions.

The turn returns to the user once a task is terminal.
"""

turn_on_enter: ClassVar[Actor] = Actor.USER


class Complete(TerminalState):
"""Built-in terminal state for successfully finished tasks."""

label: ClassVar[str] = "COMPLETE"


class Dropped(TerminalState):
"""Built-in terminal state for abandoned tasks. Reachable from every non-terminal state."""

label: ClassVar[str] = "DROPPED"


class State(BaseState):
"""A non-terminal state.

Concrete states set ``label``, optionally override ``turn_on_enter``/``advanced_by``, and
add their own ``transitions``. The inherited transition to :class:`Dropped` makes every
task droppable without each state having to declare it. Defaults suit the common case — the
agent acts first on entry, then the user reviews and advances — so a state where the
agent advances itself once satisfied overrides ``advanced_by = Actor.AGENT``.
"""

turn_on_enter: ClassVar[Actor] = Actor.AGENT
advanced_by: ClassVar[Actor] = Actor.USER
transitions: ClassVar[tuple[type[BaseState] | str, ...]] = (Dropped,)
Loading
Loading