Guidance for agents working in this repo. The full design lives under docs/design/
(GOALS, PARITY, ARCHITECTURE, ROADMAP, ADRs docs/design/decisions/). This file grows one
slice at a time (see ROADMAP "Definition of done — every slice").
The control plane makes no LLM calls. All LLM calls happen inside task containers.
- LLM-free packages:
core,taskservice,sessionservice,terminal,workflows. - The only LLM-bearing package is
container/(the agent runs there).
If you add a package that orchestrates or renders, keep it LLM-free.
src/panopticon/
core/ # domain models, state classes, the Workflow interface (the state
# machine: resolution, queries, start_task/apply_transition),
# store & artifact interfaces — pure, no I/O EXCEPT git.py (local
# branch/worktree ops; LLM-free, behind an injectable command-runner)
workflows/ # built-in Workflow subclasses (Spike seed; GithubPeerReviewed [formerly Parity]
# = cloude-cade lifecycle; GithubSelfReviewed = same, sans the peer-review state,
# the user self-reviews; both share the GithubForgeWorkflow base = gh tool/skills
# (gh is base-installed);
# Orchestrator = an agent that creates + pre-plans other tasks, `orchestrates=True`
# gating the create/list MCP tools to it, ready-to-approve via the spawn-task skill;
# SetupRepo = a `runner_type="shell"` workflow — no container, the session service runs
# its shell_script in a host tmux session (here: `claude setup-token`)) +
# Spec2119Human/Spec2119Auto/Spec2119AutoSol = spec-driven forge lifecycles with
# human-gated, automatic, and Sol-only-review variants; discovery.py = scan the
# package + an optional path for Workflow subclasses
# (the registry build_app runs on; drop a module in → registered, ADR 0004)
harnesses/ # agent-CLI harnesses (M3): the Harness interface + the registry (a literal
# claude/codex/pi mapping; outfitter.py is experimental and deliberately
# unregistered pending its upstream width-safe TUI header fix) +
# claude.py (the default: argv, .claude/commands rendering, turn-flip
# settings.json, MCP config, trust seeds) + codex.py (config.toml with MCP +
# Claude-Code-compatible Stop/UserPromptSubmit hooks wired to the SAME
# container/hook.py callback, ~/.agents/skills SKILL.md rendering, auth.json
# materialization: credential-dir symlink or api-key render, pinned-release
# image layer) + pi.py (earendil-works/pi: no MCP client — operations render as
# REST-curl skill instructions instead; ~/.agents/skills SKILL.md rendering
# reusing codex's write_skills; no Stop/UserPromptSubmit hook config, but a
# minimal TypeScript extension (rendered at bootstrap, loaded via
# `--extension <path>`) wired to the SAME container/hook.py contract via plain
# REST calls on pi's agent_settled/input events; auth.json symlink for a
# mounted credential dir, else pi reads a provider API key straight from the
# env; pinned Node.js + npm-installed image layer, no static binary). LLM-free:
# harnesses DESCRIBE and RENDER a CLI; only the container's launcher EXECUTES
# one. A task records its harness by name (Task.harness, default claude)
taskservice/ # control plane: TaskService, FastAPI REST API, the SQLAlchemy store
# adapter (in-memory or on-disk SQLite), filesystem artifact store, MCP
# server (mcp.py: operations=tools, artifacts=resources; FastMCP) mounted at /mcp
sessionservice/ # the runner: Runner ABC + StubRunner (in-process) + LocalRunner
# (real Docker+tmux via the CLIs) + ShellRunner (shell_runner.py = a workflow's
# shell_script in a host tmux session, no container — for `runner_type="shell"`
# workflows; the spawner routes on it, skipping the image + the clone unless the
# workflow opts in via clone_repo); images.py = ADR-0005 composed images
# (base→harness→workflow→repo); provisioner.py = host-side provisioning
# (ADR 0011: branch the per-task clone on slug, record it back); clones.py =
# per-repo clone cache; spawn.py = spawn-prep (clone --local the per-task
# checkout, mounted rw at /workspace); spawner.py = the spawn loop (claim an
# unclaimed task → spawn its container); prefill.py = the tmux input primitive:
# a persistent pipe-pane watch records ESC[?2004h from process startup, then a
# later delivery uses load-buffer → paste-buffer -p → optional Enter;
# stage_entry_wake.py = the nonblocking host-side consumer for pending agent-turn
# state entries (durable per-history-entry dedup + operator opt-out);
# daemon.py = the provision-only pull loop;
# host.py = the unified per-host daemon (spawn + provision each pass;
# `python -m panopticon.sessionservice.host`); `python -m panopticon.sessionservice`
# spawns one task
container/ # entrypoint (`python -m panopticon.container` = connect/register/slug/
# heartbeat liveness) + agent.py (`-m panopticon.container.agent` = the tmux
# pane's launcher: fetch the workflow surface, dispatch to the task's harness
# (bootstrap = pure file writes), then run its argv) + hook.py (the turn-flip
# callback BOTH harnesses' hooks invoke) — the ONLY LLM pkg (the launch)
docker/Dockerfile # base task-container image (ADR 0005 base layer): python + git + gh + bash +
# the panopticon package + the `claude` CLI the agent execs; runs as the
# unprivileged `panopticon` user. docker/entrypoint.sh = remap that user to the
# invoking host uid/gid (PANOPTICON_PUID/PGID) then drop via gosu
- Breaking releases require explicit user approval. Agents MUST NOT make or merge a
breaking change without first asking the user and receiving explicit approval. Agents MUST NOT
write a
type!:commit header orBREAKING CHANGE:footer, or merge a Release Please PR that proposes a major version, without that approval. Before merging any Release Please PR, inspect its proposed version; a major bump is a stop-and-ask. - The state machine is deterministic and clock-free. Timestamps are passed in by the caller (the task service stamps them); the workflow never reads the clock. Keep it that way.
- Identity vs. slug. A task's identity is its internal
id(generated by the task service). Theslugis a human label, nullable, initially chosen in the container via a hook (ARCHITECTURE.md §8.3); an operator may later rename that label from the dashboard. - All task-state mutations go through the task service, which enforces transitions via the workflow before persisting (the store is the single writer; ADR 0006).
- Interfaces vs. adapters. Control-plane interfaces (ABCs) live in
core—Store,ArtifactStore,Workflow; adapters live in the owning package. The execution-backendRunnerinterface lives insessionservice(notcore): runners pull work via REST, so it isn't a control-plane dependency. New backends implement an interface; they don't change callers. - Docker/tmux via the CLIs. The runner shells out to
docker/tmux(the interactive surface — container TTY in a tmux pane, operatortmux attach— is inherently CLI; the Python SDKs don't serve it) behind an injectable command-runner so it's unit-testable. - Long options when shelling out. Spell external-program flags in full (
docker run --detach --volume … --env …,docker rm --force,apt-get install --yes,grep --extended-regexp) — they're self-documenting and grep-able. This applies anywhere we emit a command: runner/CLI code, theMakefile, the baseDockerfile, composedimage_layers, and tests. Use a short flag only where the tool has no long form —tmux(single-letter options only),ssh -t,git -C/git worktree add -b,python -m, and the BSD userland on macOS hosts (rm -f— BSDrmhas no long options; long forms are safe in Dockerfiles/containers, which are always Linux). - No LLMs in tests. Automated tests never call a real LLM/agent. The agent launcher
(
container/agent.py) splits a deterministic, tested bootstrap (render skills, wire MCP) from the launch (realclaude), which is injected as a fake in tests and only runs for real inskipif-gated/live containers.
A Makefile wraps the uv commands (make help lists targets):
make sync # uv sync — venv + deps
make test # uv run pytest
make typecheck # uv run mypy --package panopticon (strict)
make lint # uv run ruff check --fix + ruff format (lint + auto-format)
make format # uv run ruff format
make check # lint + typecheck + test (what CI runs)
make serve # run the task service over HTTP (python -m panopticon.taskservice)
make dashboard # run the dashboard once (no attach loop)
make start # bring up everything: task service + session-service runner + dashboard supervisor
make build # docker build the base task-container image (panopticon-base)
make clean # remove the base + composed panopticon-* images
make migrate # alembic upgrade head (uses $PANOPTICON_DB; override DB=<url>)
make migrate-revision MSG="…" # autogenerate a migration from ORM schema changesSchema is managed by Alembic (src/panopticon/migrations/, src/panopticon/alembic.ini; ADR 0001 §3). The SQLAlchemy
adapter still create_alls a fresh/in-memory DB for zero-config dev + tests; Alembic owns
versioned evolution of any persistent DB (make migrate to apply, make migrate-revision after
changing the ORM rows — then commit the generated src/panopticon/migrations/versions/*.py). The two are guarded
against drift by tests/test_migrations.py; alembic stamp head aligns a dev DB that create_all
already bootstrapped.
make serve runs the control plane (python -m panopticon.taskservice — default on-disk
SQLite + filesystem artifacts + the built-in workflows; PANOPTICON_HOST/PORT/DB/ARTIFACTS
override). make start brings up the whole system on the dedicated panopticon tmux
server (-L panopticon): three background sessions — service (task service), runner
(python -m panopticon.sessionservice.host — the per-host session service: spawns a container per
new task and provisions each on slug, ADR 0008/0011), and dashboard — then runs the terminal
session supervisor (panopticon console, ADR 0009) in the terminal. End to end: create a task in
the dashboard → the runner claims + spawns its container → the agent plans and sets its slug → the
runner branches the per-task clone → the agent works; a down task (claimed, no container) is
respawned from the dashboard with R. The supervisor loop is unchanged — on t the dashboard
records the picked task to a switch-file and detaches (staying alive); the supervisor attaches
the terminal to that task's session, then re-attaches the same live dashboard on detach (C-b d).
The switch-file carries <host>\t<session> for remote tasks (M5.3) or plain <session> for local
ones; the supervisor parses it and passes host= to attach_command(), which wraps the tmux attach
with ssh -t <host> when set. Crucially the runner spawns task sessions on the same
-L panopticon socket, so t reaches them. Switching is always detach→attach (never
switch-client), so the same loop reaches a remote task over ssh at M5; s jumps to the
service session. The background sessions persist after q
(stop them with make stop, which stops the task containers and kills the -L panopticon server).
Spawning needs the base image — make build
first. make dashboard runs the dashboard once without the attach loop (talks to
PANOPTICON_SERVICE_URL).
Lint + format is Ruff (make lint / make format); the ruleset lives under [tool.ruff] in
pyproject.toml (a curated best-practices select, incl. F401 unused-import — the rule that keeps
stale imports from landing; ruff format owns line width, so E501 is off). make check runs it
read-only (ruff check + ruff format --check) before mypy + pytest.
CI (.github/workflows/ci.yml) runs uv sync, ruff (lint + format check), mypy, and pytest
on every PR (the same commands the Makefile wraps).
tests/harnesses/— the agent-CLI harness suite (M3): the registry (names, claude default, unknown rejection),test_claude.py(the Slice-6 argv/rendering expectations carried over verbatim — the seam extraction must not change what claude is launched with),test_codex.py(config.toml validated as real TOML incl. the hook wiring, SKILL.md rendering, the three auth paths incl. the credential-dir symlink, first-run vs explicit-session-id resume argv (the newest interactive rollout, scanned by session_meta — never--last, which a reviewer'scodex execrollout sharing the sameCODEX_HOMEcan poison, REQ-032), the pinned-release image layer), andtest_pi.py(settings.json'sdefaultProjectTrust, the workflow-overview file argv reads back via--append-system-prompt, the rendered turn-flip extension pinned verbatim and loaded via--extension, REST-curl operation instructions in place of an MCP tool call, SKILL.md rendering to the shared~/.agents/skills, the credential-dir symlink auth path (and that no api-key auth.json is ever rendered — pi reads the env directly), first-run vs--continueargv, the pinned Node+pi image layer). Extend when you touch a harness or add one.tests/test_workflow.py— the golden harness: every legal/illegal transition, turn derivation, responsibility gating, and workflow validation. Extend it when you touch the state machine.tests/test_migrations.py— the migration drift guard:alembic upgrade headon an empty DB must reflect the same schema asmetadata.create_all, the migrations round-trip (upgrade→downgrade→upgrade), and there's a single head. Regenerate the migration (make migrate-revision) when you change the ORM rows and this holds the line.tests/test_github_peer_reviewed.py— the golden spec for the GithubPeerReviewed workflow (formerlyparity; cloude-cade's lifecycle): the fullPLANNING→…→COMPLETEpath, the fg/bgadvanced_bypolicy, per-stage gating, going back to coding as an ungated free move (set_state), and drop. Extend it when you touch the github-peer-reviewed flow.tests/test_store.py— store contract tests run against in-memory and on-disk SQLite, proving the interface is backend-agnostic (and that rows/domain models stay in sync).tests/test_discovery.py— workflow discovery (Slice 8): the built-in package + an optional path are scanned forWorkflowsubclasses; a dropped-in module registers with no core change; underscored/non-workflow files are ignored; duplicate names are rejected.tests/test_git.py— local git ops: unit tests pin the emittedgitcommands and slug-gating forGitWorktreesand the per-task-clone opsGitClones(clone/branch/set-origin, ADR 0011); askipifintegration test creates a real worktree.tests/test_provisioner.py— host-side provisioning (ADR 0011): unit tests pin the emittedgit(branch the per-task clone + point origin at the forge) and the slug/already-branched gating (fakes), plus an end-to-end pass against the real task service over REST proving the branch + clone path are recorded and a second pass is a no-op (idempotent).tests/test_clones.py— the per-repo clone cache: unit tests pin the clone-on-first-use vs fetch-when-present decision (fakes); askipifintegration test clones a real local repo.tests/test_models.py— the pure container-status composition (compose_container_status): the truth table folding the session service's reportedLifecyclePhasewith registration presence + runner liveness into the displayedContainerStatus(queued/…/live/down/failed/ disconnected), order-of-precedence and all.tests/test_spawner.py— the spawn loop (ADR 0008): unit tests pinspawn_one(claim → spawn, skip terminal/claimed, skip on a 409 lost claim), the reported phase sequence (claiming → preparing → building → starting → awaiting, andfailedwith the error when a step raises), thereconciledown-detection (a claimed-by-us in-flight task whose container is gone → clear the phase → composesdown),healself-heal (a claimed-by-us non-terminal task whose tmux session is gone → respawn via the idempotent spawn path; skips healthy/unclaimed/terminal tasks; the crash-loop cap + survivor-window budget reset), and thespawnable_tasksfilter; an integration test claims + spawns against the real task service over REST (fake git/runner).tests/test_host.py— the unified per-host daemon (ADR 0008/0011): a unit test isolates a failing task and another pins that each pass alsoheals every task; an integration test drives spawn → set slug → provision against the real task service over REST (claimed + spawned, then branched, no re-spawn).tests/test_daemon.py— the observe-and-provision loop + its launch: unit tests drivetick/runwith fakes (branch watched tasks, skip a provisioned one, isolate a failing one, poll until a stop condition); integration tests over REST cover the loop (slug-set → branched → no-op), the unprovisioned-only watch-set, andrun_daemonprovisioning a slugged task.tests/test_mcp.py— the MCP server surface, exercised in-memory via the MCP client (create_connected_server_and_client_session) — tools mutate the task, the artifact resource reads back. No LLM, no HTTP (HTTP hosting is the runnable server, Slice 7a).tests/test_skeleton.py— the end-to-end walking skeleton (create → register → slug → transition → history) over the REST API, no Docker.tests/test_local_runner.py/tests/test_entrypoint.py— the runner's emitted docker/tmux commands (incl. the ADR 0011/workspacemount + the CLI's spawn-prep→spawn flow,is_running'sdocker psprobe +has_session'stmux list-sessionsprobe for self-heal) and the container entrypoint loop (fakes; no Docker/LLM), plus askipifdocker integration test.tests/test_spawn.py— spawn-prep (ADR 0011): unit tests pin theclone --localof the per-task checkout and the idempotency gate (skips when the checkout already exists).tests/test_prefill.py— the task-pane readiness/delivery primitive: unit tests pin the persistentpipe-panewatch installed before the agent starts and the laterload-buffer/paste-buffer -p/Enter delivery, including every best-effort give-up; askipif-gated real-tmux test proves a marker recorded at startup wakes an already-idle pane.tests/test_stage_entry_wake_sessionservice.pycovers asynchronous host-loop dispatch, per-entry dedup/re-entry, multi-entry ordering, skip conditions, and thePANOPTICON_NO_STAGE_ENTRY_WAKEopt-out.tests/test_provisioning_acceptance.py— Slice 7 acceptance (skipifno git): the host-side provisioning path with real git — clone --local the per-task checkout → set slug → the daemon branches it (panopticon/<slug>) + repoints origin → the task service records branch + clone path.tests/test_acceptance.py— Slice 2 acceptance (skipifno docker/tmux): builds the base image and a real container connects back to an in-process task service, registers, heartbeats, and loses liveness on kill. No LLM.tests/test_multi_workflow_acceptance.py— Slice 8 acceptance: over REST (viabuild_app), a path-discovered workflow is selectable with no core change, and GithubPeerReviewed + the free-form (spike) workflow run concurrently with workflow-specific skills. No Docker, no LLM.
- Ensemble — the collapsible group of governed tasks shown under a governor in the
dashboard. Pressing
Enteron a governing task collapses its children into a single dim summary row (▸ N child tasks — enter to expand); pressingEnteragain expands them, and the governor's▸/▾marker reflects that state. Pure display state — no change is made to the task service. The summary row's key uses the_ENSEMBLE_KEY_PREFIXsentinel, and Up/Down arrow keys skip it like the separator. - Task — a unit of work; identity is
id, label isslug. - Repo — a repository tasks operate on. Holds
env_file(a reference — a name relative to the secrets dir$PANOPTICON_CONFIG/secretsnaming an env-file of secrets, ADR 0007), never the values; the runner resolves it against its own host's secrets dir and injects it at launch (--env-file), so a task gets only its own repo's secrets and the value stays host-agnostic for remote runners. The env-file carries the container'sCLAUDE_CODE_OAUTH_TOKEN— a non-rotatingclaude setup-tokenthe operator adds (ADR 0012 retired the old per-repo OAuth creds volume +panopticon login; auth is now just this env var, read straight from the environment — seedocs/auth.md) — alongside anyANTHROPIC_API_KEY/GH_TOKEN. Also holdsimage_layer_file— a reference (a name under the task service's layers dir) to the repo's Dockerfile fragment (ADR 0005's repo tier), served over REST (GET /repos/{id}/image-layer) and composed by the runner onto base → workflow → repo for the task image (e.g. the repo'suv/maketoolchain) — andcapabilities, a JSON opt-in map for elevated container privileges (docker_in_docker→ the runner spawns--privilegedand the entrypoint starts a nested Docker daemon; a trust escalation, off by default).credential_dir(M3) is the directory-shaped sibling ofenv_file: a name under the secrets dir for a dir of credential files that rotate in place (a ChatGPT-subscriptionauth.json), mounted read-write and shared across the repo's task containers at/panopticon/credentials— deliberate cross-task sharing, because one account is one rotating token chain and every session must converge on the same copy (codex reloads the file before refreshing and writes through the harness's symlink). - Harness — the agent CLI a task container runs (M3), as a pluggable adapter
(
harnesses/): claude (default), codex, or pi. AHarnessdeclares its config dirname (where the per-task config volume mounts), image layer (the CLI's install, composed base → harness → workflow → repo), an auth check (missing_auth, naming the fix for its credentials), abootstrap(pure file writes rendering skills/operations/hooks/MCP/system-prompt), and the launchargv(first-run vs resume). Selection resolves atomic harness/model pairs: task-explicit → an optional workflow pair → the repo'sdefault_harness+ opaquedefault_model(model[:effort]) → the app default. A workflow declares both halves or neither, and all built-ins declare neither. An explicit task harness that differs from the winning pair drops that pair's model. The resolved opaque strings are recorded on the task, so later default changes never re-route it; model vocabulary belongs to the harness. Codex auth:CODEX_API_KEY/CODEX_ACCESS_TOKENin the env-file (no new mechanics), or a ChatGPT subscriptionauth.jsonin the repo'scredential_dir(see Repo) — seedocs/auth.md. pi has no MCP client at all (its own stated design), so its rendered advance/drop operations are REST-curl instructions rather than an MCP tool call. It also has no Stop/UserPromptSubmit hook config, but its extension API does — a minimal TypeScript extension, rendered at bootstrap and loaded via--extension, PUTs the turn onagent_settled/inputthe same waycontainer/hook.pydoes; its sharedauth.jsoncovers subscription + API-key auth the same credential-dir way. - Workflow — a
Workflowsubclass whose states are nestedStateclasses (declarative). It declaresinitial; states are discovered and their transitions (class refs or label strings) resolved + validated when the workflow is instantiated. The lifecycle is code, not hardcoded control flow. - State — a class (
Statenon-terminal, inherits aDroppedtransition; orTerminalState). Carries alabel(persisted inTask.state, shown on the dashboard),turn_on_enter,advanced_by,responsibilities, andtransitions. Built-ins:Complete,Dropped. - Actor — a party,
useroragent. A state declaresturn_on_enter(who holds the turn on entry; seedsTask.turn) andadvanced_by(who transitions out — the default isUSER). The two are orthogonal. - Operation — a named core verb for the declared, gated graph (ADR 0004's two-tier
commands):
advanceis the happy path — auto-derived as a state's single non-DROPPEDdeclared transition (gated by responsibilities) — anddrop(→DROPPED) is the universal escape. Those are the core operations (a workflow may declare more, but each must target a legal transition).advancestarts a new agentic turn, so it's invoked by an in-container agent skill (over REST/MCP); the dashboard drives onlydrop(x). - Free move / set state — moving a task to any state directly (
set_state/PUT …/state), bypassing the declared graph and the responsibility gate. A workflow'stransitionsdeclare only the intended path (whatadvancefollows); the user is never boxed in — but, being a transition, a free move runs through an agent skill (the user directs the agent), not the dashboard.force_transitionis the engine primitive (e.g. going back to coding is justset_state(ITERATING)— not a named operation). - Turn-flip / blocked — the live
Task.turnflips within a state viaPUT /tasks/{id}/turn(the agnostic agent↔user ball tracking). The contract for the in-container hooks: the agent's stop hook setsturn=user(unless a background task — arun_in_backgroundBash command or theMonitortool — is still running, in which case the turn stays on the agent, since the task's completion re-invokes the agent without a user-prompt event, so a flip touserwould never flip back); the user-prompt hook setsturn=agent. The claude wiring iscontainer/hooks.py(renders.claude/settings.json) +container/hook.py(the callback the events invoke), rendered by the agent launcher.Task.blocked(PUT …/blocked) is a deliberate "waiting" marker the agent sets (cloude-cade's:blocked:). A turn-to-agent write clearsblocked, because the user has addressed the task; every task state change clears the existingblockedbefore lifecycle effects run, because the state that raised it has ended. A lifecycle effect may raise a fresh block for the state being entered. A turn-to-user write preservesblocked, and the agent can explicitly setblockedagain after either automatic clear if it is still stuck. Claude's blockingUserPromptSubmitcommand hook runs before prompt processing; its floor is callback process startup plus the synchronous task-service write. Codex's blockingUserPromptSubmitcommand hook runs before prompt processing; its floor is callback process startup plus the synchronous task-service write. Pi'sinputevent runs before prompt processing, and its handler waits for the task-service write.Task.attentionis an orthogonal escalation-only marker for tasks the dashboard can prove are waiting on dependencies or governed children. It can restore ordinary user-turn attention inside that proven wait but can never suppress attention; the same turn-to-agent user-prompt mutation clears it. The user-prompt hook also nudges toward provisioning (ADR 0011 §3): while the task has no slug it prints theprovisionreminder, which claude adds to the agent's context (core/provisioning.py). - Skill — an agent-driven procedure exposed in the container (ADR 0004), on top of the core
operations. Declared CLI-agnostically as a
Skill(name, description, instructions)spec; the in-container harness renders it to the active CLI surface (container/skills.py→ claude.claude/commands/<name>.md; other CLIs in M3). Exposed over REST (GET /tasks/{id}/skills). The agnosticprovision(core/provisioning.py) andartifacts(core/artifact_skills.py) skills are exposed on every task: provision names the task and triggers branching, while artifacts explains how to publish durable reviewer-readable task documents for the dashboard'sahotkey. Workflow-specific skills (e.g. github-peer-reviewed's forge skills) follow them, and a workflow may define none. - Responsibility / Status — an agent obligation for a state. Entering a state seeds its
responsibilities onto that entry's history record, all
PENDING(a promise); the agent fulfils each one at a time (MET, orFAILEDwith a comment) — mutating that entry — and a later advance is gated on all being resolved. Agent-only. - Registration / liveness — a container's standing claim that it is working on a task.
- Container lifecycle / status — the session service (the runner) reports its spawn progress
as a
LifecyclePhase(claiming → preparing → building → starting → awaiting, orfailedwith a detail) viaPUT /tasks/{id}/lifecycle— ephemeral, like a registration, cleared on claim release/reclaim. The task service folds that phase with registration presence + runner liveness into oneContainerStatusonTaskOut.container_status(compose_container_status): queued/claiming/preparing/building/starting/awaiting/live/down/failed/disconnected (or–for terminal).live= an open container registration;down= claimed + runner live + no phase + no registration (the host daemon'sreconcileclears a stale phase when the container has vanished, viaLocalRunner.is_running);disconnected= claimed by a runner no longer inlive_runners. The dashboard only displays it — it does no live/dead/respawn computation of its own. Ephemeral changes bump the change-feed version so the dashboard's long-poll wakes on them. - Claim —
Task.claimed_by(a runner's id, nullable): which session service owns a task. A runner claims an unclaimed task (PUT …/claim, compare-and-set, 409 if another holds it) before spawning its container — the spawn gate so exactly one host runs it (ADR 0008). Release (DELETE …/claim) returns it to unclaimed for hand-off or respawn. Distinct from liveness: a claimed task whose container died is "claimed but down".TaskOut.runner_host(M5.3) is derived at query time fromclaimed_by→ the runner's registrationhostfield (set via--host/PANOPTICON_RUNNER_HOSTon the session service, passed as a?host=query param onGET /runners/{id}/live). Used by the terminal supervisor to ssh-attach to remote sessions. - Provisioning — the writable per-task clone + slug-named branch a task works in (ADR
0010/0011). Each task gets a self-contained
git clone --localat spawn, mounted at/workspace; on slug the session service branches whatever's there (checkout -b panopticon/<slug>) and pointsoriginat the forge. The host git happens on the session service (where the container runs), so it stays correct when the runner is remote; the task service only records the result —record_provisioning/PUT /tasks/{id}/provisioningwritesTask.branch/Task.clone(the clone path), slug-gated, a pure recorded-fact write touching no filesystem.Task.provisioned(computed: branch recorded) is what the provisioner and the daemon's watch-set gate on.core/git.pyGitClonesis the LLM-free primitive the session service drives (GitWorktreesremains for non-task local-git use). - Task service — the deterministic control plane (sole DB authority).
- Session service / runner — spawns task containers and observes durable pending stage-entry wakes. It installs each pane's readiness watch before starting the agent, then performs tmux injection asynchronously on the host so a missing pane cannot block spawn/heal/provision work.
- Terminal controller — the user-facing CLI/dashboard (Slice 3).
- Artifact — a durable, file-backed per-task document for user review, reachable via
REST/FS/MCP and opened from the dashboard with
a; GitHub URLs remain the substantial exception and belong in the task's external URL field opened withp. - Lifecycle hook — a deterministic
Workflowmethod the task service runs at a defined moment (currentlyon_transition, after a transition, before persistence). It may write artifacts or mutate the task's own record — no LLM, no clock. The seam; the built-in workflows don't override it yet (the github-peer-reviewed plan-accepted hook is claude-driven, Slice 6).
This repository enforces spec-driven testing with 2119.
When planning a feature, write or update a spec in specs/ first. New specs
use a lowercase kebab-case filename as their namespace, such as
specs/repo-picker.md, and bare numbered section headings such as
### 3: Selection. Allocate numbers only within that file; item 2 in that
section has canonical ID repo-picker.3.2. Legacy REQ-NNN-* specs and new
file-scoped specs coexist indefinitely.
Do not allocate a new REQ-NNN number. Numeric IDs are a global allocator
with no locking, and a duplicate document ID is not a textual conflict — so
parallel branches each lint green and the collision appears only after both
merge. git merge-tree shows nothing. That is not hypothetical here: REQ-050
was allocated three times by parallel branches and took a dedicated task to
repair (#235). The sibling repo hit the same failure at larger scale — three
IDs each double-allocated, main's gate at 34 violations, and 28 "stale reviews"
that were a permanent flip-flop no amount of re-review could clear.
You cannot check whether a number is free: the branch you would collide with is unmerged and invisible to you. A file-scoped namespace has no allocator, so the question never arises.
Rename before recording verdicts, or not at all. Renaming a spec changes its canonical IDs and invalidates every verdict recorded against it, so a numeric ID caught during drafting costs nothing and one caught after review costs the whole review round.
A branch based before this adoption may retain its already assigned legacy ID. A branch based on this adoption uses a file-scoped ID.
Every requirement has exactly one RFC 2119 keyword and states an
observable outcome, not an implementation mechanism. Run
npx --yes rfc2119@0.7.0 lint after editing specs. Before writing tests
against a new spec, dispatch a fresh-context reviewer to critique the draft
requirements themselves: outcome-stated, individually testable, one obligation
each. A flawed requirement steers the whole implementation wrong.
When implementing, every MUST/SHALL requirement needs at least one test.
A test file may import its spec with # 2119-spec: repo-picker; a later bare
annotation # 2119: 3.2 and the full annotation
# 2119: repo-picker.3.2 both resolve to repo-picker.3.2. Full canonical IDs
remain available for cross-spec and legacy references, for example
# 2119: REQ-001.2.3. The marker line must start with a comment leader. Write
tests that would genuinely fail if the requirement were violated — including
its negative space: what the requirement forbids needs a rejection test, not
just what it allows. A fresh-context reviewer judges each test's honesty;
tautological or over-mocked tests will be rejected. Renaming a file-scoped spec
changes its canonical IDs, invalidates its verdicts, and requires re-review.
Reviewer diversity: use reviewer models from different providers, routinely
or as periodic npx --yes rfc2119@0.7.0 review --audit sweeps — adversarial audits of
passing verdicts. Audit especially the challenging or high-consequence
requirements; a single model family shares blind spots.
Before finishing any task, run npx --yes rfc2119@0.7.0 check. It must exit
0. If it reports pending judgment reviews, run
npx --yes rfc2119@0.7.0 review --dispatch and
dispatch each instruction file in .2119/reviews/ to a fresh-context subagent
(never review your own work in the same context). CI runs the same check, so
skipping it locally only defers the failure.
Migration sequence: REQ-035 taskservice-auth, REQ-036 verified-reviewer-models, REQ-037 cross-host-migration, and REQ-038 snoozed-tasks-sort-bottom merge before this adoption without renumbering. Thereafter the branch-base rule above applies.
CI fetch assumption: rfc2119@0.7.0 contains runnable compiled JavaScript,
rfc2119@0.7.0 declares no install-time build, and its
rfc2119@0.7.0 runtime dependency closure declares no install-time build.
CI therefore disables dependency lifecycle scripts while fetching the gate. An
install-time build introduced by a future CLI or runtime dependency is a
gate-breaking change while lifecycle scripts remain disabled; lifecycle scripts
cannot be silently enabled to accommodate it. The workflow checks the runner's
documented npm 10 major before installing exact npm 10.9.3, so a runner-image
npm-major change fails visibly.