thinkpol: the actor contract
Read this to enroll or write an actor.
In the book the Thought Police do not deliberate; they enforce
decisions already taken. thinkpol is the actor role, defined by the
.publish contract rather than by any particular executable. This repo
enrolls a deterministic reference actor (ordinary Go: it never composes
a word, posts the draft verbatim); an agentic actor is equally welcome:
your own agent, your persona and tone, its MCP credentials, acting on
the approved draft. The enrolled actor declares its semantics: verbatim
(the draft is the post) or interpretive (the agent may adapt the text,
and must then record what it actually posted in the published marker's
section body, per the Queue contract). Exactly one actor
is enrolled at a time; two consumers of the same approvals would race
into double-posting.
Why a separate binary
Publishing needs zero judgment: parse the approval, extract the draft, post it, stamp the record. Keeping it inside the speakwrite agent made the least deterministic component the only one with outward reach, and its "never post without approval" rule was a sentence in a prompt. As a separate binary it is structure: the agent has nothing to post with, and the one component that can post is ordinary Go, unit-tested line by line.
Responsibilities, and what each one requires
The system is five single-responsibility components joined only by files. Each is replaceable without touching the others.
| Role | Component | Interface it honors | Requires |
|---|---|---|---|
| produce | minitrue | writes records into tube/ (Queue contract) |
claude CLI + gh; Slack/Linear MCP, degrading without |
| queue | recdep | plain files and renames (Queue contract) | a filesystem |
| view | telescreen | reads records, renames between drawers | nothing else |
| draft | speakwrite | consumes .intent, appends dictated/draft markers |
claude CLI + gh for research; Slack/Linear MCP optional |
| act | thinkpol | consumes .publish, posts, appends published, renames to upsub |
per-publisher credentials (see the publisher table); no claude |
The model is optional at every role except draft. A deterministic producer plus telescreen plus thinkpol is a complete, LLM-free system; speakwrite is the one place judgment lives, and dictation is how the human injects it.
Agnosticism
Two axes, both resolved at the edges rather than in the core:
- Source-agnostic. A record carries a source tag, a URL, content, and
markers; nothing else about Slack, GitHub, or Linear leaks into the
queue, the view, or the flow. The three places that know sources are
declared tables, not logic: the producer's watches (prose in its
skill), the action map (a data table in the TUI), and the publisher
table (
internal/publish, a dispatch table from URL shape to posting call; the TUI's p gate and the actor both read it). Adding a source touches those three tables and nothing else. - Technology-agnostic. Every seam is a file format, so every component is swappable per role: the producer can be an agent, a deterministic poller, or a webhook receiver; the draft role can be filled by any agent runtime that reads intents and appends markers; the actor can be this Go binary, a shell script, or anything that honors the approval semantics. The contract files (the Queue contract, Design: speakwrite, this one) are the only coupling.
Configuration
Environment details live outside the code, split by shape.
~/.config/telescreen.yaml holds the per-component choices, the agent
binary, instructions, MCP tool allowlists, and the action map
included. Env files (~/.config/minitrue.env,
~/.config/speakwrite.env, ~/.config/thinkpol.env) hold identity
and secrets, and act as the fallback layer for the agent keys.
Systemd enrollment chooses implementations: swapping a component means
enabling a different unit. The subcommand defaults reproduce a plain
claude setup, so an absent config is a working one.
Procedure
Triggered by a systemd path unit on recdep/intents/*.publish
(trigger-bounded like the others). For each approval, oldest first:
- Parse the
entry <absolute path>line. Resolve the record: the recorded path, else search the four drawers for the basename, else remove the approval and log the orphan. - Extract the last draft marker. No draft, or a discarded marker after it: remove the approval, log why, touch nothing.
- Dispatch on the URL through the publisher table below. A URL no publisher matches: remove the approval, log why.
- On success: append
--- published <ISO-8601 time> <comment URL>to the record (contract newline discipline), rename the record toupsub/unless it already sits inupsub/orfiles/, remove the approval, log one line. - On failure: leave the record untouched (the draft tag survives, the human can re-approve), remove the approval so nothing retries silently, log the error.
Logs append to recdep/publish.log, one line per approval, so a
disappeared approval is always explained somewhere.
The publisher table
internal/publish declares the table; the TUI's p gate and the actor
both read it, so the view offers exactly what the actor can do. The
table is extensible by configuration: thinkpol.publishers in
telescreen.yaml routes URL prefixes to a named publisher, disables
one, or defines an exec backend (a command getting the record URL as
an argument and the draft on stdin) for custom targets. Rules apply in
order, first match wins; the built-in matching below is the fallback
when no rule matches. The obligations are unchanged: whatever takes
the URL posts the draft, returns the permalink, and honors the
procedure above.
| Publisher | URL shape | Posts via | Requires |
|---|---|---|---|
| github-pr | github.com/<owner>/<repo>/pull/<n> |
gh pr comment <n> --repo <o/r> --body-file - |
gh, authenticated |
| slack-thread | https://<workspace>.slack.com/archives/<CHANNEL>/p<digits> |
Slack Web API chat.postMessage |
SLACK_TOKEN, a user token with chat:write |
| linear-issue | https://linear.app/<workspace>/issue/<KEY>-<n> |
Linear GraphQL commentCreate (issue id resolved by the issue query, which accepts the KEY-<n> identifier) |
LINEAR_API_KEY |
The Slack thread timestamp comes from the URL's thread_ts query when
present, else from the p<digits> segment itself (a dot before the
last six digits), so a bare message URL replies to that message as the
thread root. SLACK_TOKEN is a user token on purpose: it posts as the
human, which matches drafts written in first person.
The service unit loads the tokens from ~/.config/thinkpol.env
(EnvironmentFile=-, so a missing file is fine, gh alone still works).
The file holds secrets; chmod 600 it.