Skip to content
palhatlemPublic

About

A config-driven workbench for a doctorate: what is due, what formally gates it, what state each paper is in — plus a panel for your own agent terminal. Self-hosted, AGPL-3.0.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

Repository files navigation

officina

A self-hosted, config-driven workbench for a doctorate.

A PhD is not a project plan. It is several clocks that disagree, a handful of formal gates whose consequences are categorical rather than gradual, a few papers in different states, a training component, and a pile of obligations nobody reminds you about until they are late. officina puts that on one page:

  • what is due, as a short list you can actually read;
  • what gates it — the administrative obligations whose failure mode is not delay but deletion, rejection or a closed window;
  • what state each paper is in, as a state machine rather than a word count;
  • where you left off — documents, commits, calendar, inbox; and
  • a panel for your own agent or terminal, if you run one.

It reads YAML you write and files you already have. There is no database, no account, no telemetry, no build step, no CDN and no external requests other than the one optional source you must switch on yourself. It renders server side and it works offline.

officina holds no opinion about your discipline, your institution or your language. Everything specific to you lives in four config files.

officina — the front page

Quick start

Requires Docker with the Compose plugin. Nothing else.

git clone https://github.com/YOUR-USERNAME/officina.git
cd officina
cp .env.example .env

Create your config from the examples:

mkdir -p data
for f in config/*.example.yml; do
  cp "$f" "data/$(basename "${f%.example.yml}").yml"
done

Edit .env and point the four path variables at your own files — your writing workspace, a calendar export, a task export, a drop folder — and set OFFICINA_UID/OFFICINA_GID to your own (id -u, id -g). Then edit data/frame.yml: it is the only file you must fill in, and it is heavily commented.

If you do not have some of those files yet, create empty stand-ins so the bind mounts resolve, and disable the matching sources in data/sources.yml:

mkdir -p ro/workspace ro/handoff
printf '{}' > ro/calendar.json
printf '{}' > ro/tasks.json
printf '{}' > ro/status.json

Then:

docker compose up -d --build

officina is now on http://127.0.0.1:3013. Read Security posture before you expose it anywhere.

Configuration

Four files, mounted read-write at /data inside the container. Each has an annotated .example.yml in config/ — those files are the real documentation; this section is only the map.

File What it holds
frame.yml The doctorate itself: candidate, clocks, gaps, gates, outputs, fieldwork, coursework, milestones, dissemination, meetings, privacy. The only required file.
sources.yml Where data comes from. One block per source, dispatched by type.
rules.yml What the front page nags about, and how loudly.
launchers.yml A short strip of links to the places you work.

Config is re-read from disk about every 30 seconds. Edit a file, reload the page, see the change — no restart. A missing or malformed file degrades the section that needs it and leaves the rest of the page standing.

Some things worth knowing about frame.yml:

  • Clocks may disagree, and that is the point. Your contract, your admission and your project plan can all end on different days. Mark exactly one clock authoritative: true; it drives the big countdown, and the others are shown quietly beside it rather than reconciled away.
  • Gates are kept apart from progress because their consequences are different in kind. Every gate carries a one-sentence consequence. Write what actually happens, not what the status is.
  • Papers have states, not percentages. You define the vocabulary in outputs.states; the engine only cares about position in that list.
  • legacy_plan_end records what the original plan said. Keeping it makes the drift visible instead of quietly forgotten.

Source types

sources.yml names types, never products. Every collector returns {"ok": true, ...} or {"ok": false, "error": "..."} and never raises, so a missing file or an unreadable mount costs you one panel, not the page.

Type Does Key settings
json_file Reads a JSON document and reshapes it via a map block into the canonical fields the templates use. Your JSON keeps its own shape. path, map, max_age_minutes
latex_tree Walks a manuscript tree: documents, word counts parsed from the source itself, and PDFs that are older than their source. No TeX installation required. path, index_file, build_subdir
git Recent commit activity for a working tree, via read-only plumbing commands. path
dir_count Counts files waiting in a folder. Counts only — no file names. path, label
link Not a collector: a labelled destination rendered as a panel, with an optional standing notice. Used for the agent/terminal pane. path, label, notice
zotero_api The only source that touches the network, and it is off unless you set both environment variables. Otherwise it reports not_configured. library_id_env, api_key_env

Container paths (/ro/...) are what sources.yml refers to; the host side of each mount comes from .env. To drop a source, set enabled: false and remove its line from docker-compose.yml.

Adding a rule

A rule inspects the frame and, if it matches, contributes one line to the "Now" list. Rules are data — adding one never requires touching code.

- id: ethics-expiry
  match:
    collection: gates
    status_in: [not_sent]
    due_within_days: 120
  text: "{title}"
  why: "{consequence}"
  urgent_if:
    severity_in: [critical]
    or_due_within_days: 60
  priority: 95

match selects a collection and requires all its conditions. text and why are templates whose {placeholders} are filled from the matched item. Lines are sorted by urgency, then priority, then date, and truncated to now.max_items.

Two conventions worth keeping:

  1. why states a consequence, not a status. "The legal basis expires and the material must be deleted" earns a place on the front page; "not yet submitted" does not.
  2. Keep max_items small. A list you can read at a glance is the whole idea. A list of twenty is wallpaper.

The full list of match keys, urgency keys and placeholders is in config/rules.example.yml.

Languages

No user-facing string is hardcoded. The application's own strings live in locales/*.yml as flat key/value pairs and are looked up by key.

Set OFFICINA_LOCALE in .env to any file in locales/ (en, nb, ...). Missing keys fall back to English per key, so a partial translation is useful immediately and never renders a blank.

To add a language, copy locales/en.yml, translate the values, keep the keys, and set OFFICINA_LOCALE to the new filename. Pull requests welcome.

Your own config is a separate matter: the strings in rules.yml, frame.yml and launchers.yml are yours, and you write them in whatever language you think in.

Privacy model

officina is designed to sit next to material it must not read. The rules below are enforced by the engine, not merely recommended.

privacy.exclude_paths — a list of globs in frame.yml. Every collector that touches the filesystem calls a single guard before opening anything; a matching file is never opened. Not indexed, not word-counted, not listed, not read to check whether it changed. Add patterns generously — an extra pattern costs nothing.

privacy.aggregate_only — collections named here expose counts and dates only. Individual records are never rendered even if a source supplies them. fieldwork is in this list by default: officina will tell you that three interviews of a target of ten are done, and will not tell you anything about any of them. Where no safe machine-readable source exists, the honest answer is to type the number in yourself; that is what manual: true means.

Gaps are never explained. The gap schema deliberately has no reason field, and with never_explain_gaps: true the renderer drops one even if you added it. Leave, illness, a stopped clock — a gap is shown as a gap. Your dashboard does not need to know why, and neither does anyone reading over your shoulder.

No egress. There are no analytics, no error reporting, no fonts, no scripts and no stylesheets from anywhere but this repository. The only outbound request officina can make is the Zotero source, which is disabled until you provide credentials.

Security posture

officina has no authentication of its own, and it links to a terminal. Treat it as a private-network application.

  • The published port is bound to 127.0.0.1 by default. Keep it that way.
  • If you want it on more than one device, put a reverse proxy with real authentication in front of it — forward-auth, SSO, mTLS, a VPN or a Tailscale-style overlay. Do not simply widen the port binding.
  • The container runs as an unprivileged user with no-new-privileges, and every data source is mounted read-only. Only /data is writable.
  • Serve it over TLS if it leaves the host.
  • Your config is not secret from the application but it is personal: data/, .env and config/*.yml are gitignored. Keep them that way.

Development

pip install fastapi "uvicorn[standard]" jinja2 pyyaml
OFFICINA_LOCALE=en uvicorn app.main:app --reload --port 8080

The application expects config at /data; bind-mount or symlink your config directory there when running outside Docker.

Endpoint Purpose
GET / The page.
GET /api/state The same state object, as JSON. Useful for debugging a source or driving something else.
GET /healthz {"ok": true}.

Layout: app/config.py (config loading and the privacy guard), app/sources.py (one collector per source type), app/engine.py (pure functions over the frame, no I/O), app/main.py (routes and state assembly), plus templates/, static/ and locales/.

Every section of the state is independently fallible: each is wrapped, and a failure records {"ok": false, "error": ...} for that section alone. A broken source must never produce a 500.

Licence

Copyright (C) 2026 officina contributors.

officina is free software: you may redistribute it and modify it under the terms of the GNU Affero General Public License, version 3 or (at your option) any later version, as published by the Free Software Foundation. See LICENSE for the full text.

It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

The AGPL is deliberate: if you run a modified officina as a network service for other people, they are entitled to your modifications.

Write API

officina reads config, and the same config can be edited without a shell — by the browser, or by an agent on your private network. Every write goes through app/store.py, which is atomic (temp file + os.replace), snapshots the previous version into data/.history/ (last 20), and preserves the comments you wrote in the file.

GET    /api/config/{frame|sources|rules|launchers}
PATCH  /api/config/{name}          {"path": "fieldwork.completed", "value": 4}
POST   /api/config/{name}/append   {"path": "milestones", "value": {...}}
GET    /api/notes                  list note ids
GET    /api/notes/{id}
PUT    /api/notes/{id}             {"markdown": "..."}

path is dotted and may index lists: outputs.items.2.state.

One thing is immutable: frame.privacy. The exclusion globs, the aggregate-only list and the never-explain-gaps flag are the guarantees the rest of the code enforces. A client that could rewrite them could switch off every protection by setting a value, so the API refuses — change them by editing the file yourself, deliberately.

There is no authentication on these routes. officina expects to sit behind a reverse proxy that authenticates, on a private network. Do not publish the port.

About

A config-driven workbench for a doctorate: what is due, what formally gates it, what state each paper is in — plus a panel for your own agent terminal. Self-hosted, AGPL-3.0.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages