Autonomous Dependabot/Security PR manager for a set of GitHub repositories. It merges green dependency PRs that are within policy, batch-cuts a single patch release once the queue settles, and reports to chat — safe by construction.
twiki is an autonomous actor with merge and release authority across many repos, and its LLM reads untrusted third-party changelog text. The architecture makes that safe structurally, not by instruction:
facts ──▶ LLM advisor (NO write tools) ──▶ typed JSON plan ──▶ executor
re-checks every gate
shadow: log │ enforce: act
- The LLM never touches GitHub. It only emits a plan (
merge/holdper PR,release/waitper repo). Only the deterministic executor calls the API. - Gates are code, re-validated at execution time — never merge if CI isn't
green, never auto-merge a major, never release if
mainis red, only act on allowlisted repos. The plan can only narrow outcomes. - Prompt-injection is contained. A poisoned changelog can at worst push the
advisor toward
hold(more conservative); it can never manufacture a merge or release. (Seetest/injection.test.ts.)
- Auto-merge: patch + minor bumps when green (minor configurable per repo). Majors are never auto-merged — they're flagged for a human; security majors are flagged urgently.
- Batch release ("settled"): cut one patch release for a repo only when
(a) no open Dependabot PR remains that policy would merge, (b)
mainis green, and (c) there are merged-but-unreleased dependency commits. A stuck red PR doesn't block — it's reported. - Release mechanism: the executor pushes the next
vX.Y.Z+1tag; each repo's own tag-triggered workflow builds/publishes. Repos without one are reported as merge-only. - Stateless: every tick re-derives truth from GitHub, so skipped or repeated runs self-heal.
Copy repos.example.yaml to repos.yaml and list your repos (see that file for per-repo autoMergeMinor, mergeOnly, defaultBranch and versionSources overrides).
defaultBranch is what the repository calls its default branch.
Absent means main, so only a repository on something else needs the line.
Write the branch name, not a ref: master, never refs/heads/master.
The declaration is what the read side will sort workflow runs by, and against the wrong name a failed build on the real default branch would read as a build on just another branch.
tricorder doctor compares the declaration against what GitHub reports and names any disagreement.
versionSources is where the repository's tree carries its version: a file, and a pattern with exactly one capture group.
Before pushing a tag, twiki reads those files at the exact commit it is about to tag and compares what they say against the version it computed.
A disagreement blocks the release, writes nothing, and says in the digest which file said what.
So does a declared file that is not there at that commit, a pattern that finds no version, and a pattern that matches in more than one place, because none of those identifies a version either.
twiki never edits the tree: a human lands the bump, and the next tick releases.
Until that bump lands, a settled repository reports the same block on every tick.
That repetition is the check working, not a fault: the repository is ready to release and its tree says otherwise, and that stays true until somebody changes it.
A repository with no tag yet computes v0.0.1 for its first release, so a declared source has to say 0.0.1 before that release will go out.
Absent means the tree carries no version, which is a real answer and not an opt-out.
A repository that declares nothing releases exactly as it did before, and no file is read for it.
| Variable | Purpose | Default |
|---|---|---|
TWIKI_CONFIG |
Path to the config file | repos.yaml |
TWIKI_MODE |
shadow or enforce (overrides config) |
from config |
TWIKI_ONCE |
Run a single tick and exit (for external cron) | unset (polls) |
TWIKI_POLL_MINUTES |
Poll interval when not ONCE |
60 |
TWIKI_CI_REMEDIATION |
CI remediation (on/off); off keeps diagnostics, no writes |
on |
TWIKI_MAX_CI_ATTEMPTS |
Max workflow attempts before twiki stops re-running (1-based; 2 = one re-run) |
2 |
TWIKI_GITHUB_APP_ID |
GitHub App ID | — (required) |
TWIKI_GITHUB_APP_PRIVATE_KEY |
App private key (PEM, inline) | — |
TWIKI_GITHUB_APP_PRIVATE_KEY_PATH |
App private key file path | — |
ANTHROPIC_API_KEY |
Advisor LLM key (read by the SDK) | — (required) |
TWIKI_MODEL |
Advisor model | claude-sonnet-4-6 |
TWIKI_SLACK_WEBHOOK_URL |
Slack incoming webhook | — |
TWIKI_DISCORD_WEBHOOK_URL |
Discord webhook | — |
TWIKI_MATRIX_HOMESERVER |
Matrix homeserver base URL (Client-Server API) | — |
TWIKI_MATRIX_TOKEN |
Matrix access token | — |
TWIKI_MATRIX_ROOM |
Matrix room ID, e.g. !abc:example.org |
— |
TWIKI_AUDIT_PATH |
Append-only JSONL audit log | audit.jsonl |
TWIKI_STATE_DIR |
Where the notifier remembers the last digest it sent, so a restart does not re-send it. Unset keeps a dotfile in the working directory, which in a container is a writable layer that dies on every recreate. | unset |
The chat target is chosen by precedence: Slack (if its webhook URL is set),
then Discord, then Matrix (when all three TWIKI_MATRIX_* vars are
set), otherwise the digest is printed to stdout.
twiki authenticates as a GitHub App so merges/tags show as twiki[bot],
tokens are short-lived, and the blast radius is scoped per install.
- Create a GitHub App (org → Settings → Developer settings → GitHub Apps).
- Grant these repository permissions:
- Contents: Read & write (push tags, read workflow files, compare commits)
- Pull requests: Read & write (merge, post
@dependabot rebase) - Actions: Read & write (re-run failed CI jobs — CI remediation)
- Checks: Read-only
- Commit statuses: Read-only
- Metadata: Read-only
- No webhook is needed (twiki polls).
- Generate a private key; store it as a secret and point
TWIKI_GITHUB_APP_PRIVATE_KEY[_PATH]at it. SetTWIKI_GITHUB_APP_ID. - Install the App on exactly the repos in your allowlist.
make install # install dependencies
make browsers # once per machine: the Chromium the browser project renders with
make verify # lint + typecheck + tests + browser project (CI gate)
make run # build and start (polls)
TWIKI_ONCE=1 make run # single tick (e.g. external cron)New here? The Quick Start walks through a shadow-mode run with copy-paste examples for Slack, Discord, and Matrix.
- Deploy in shadow mode (
mode: shadow). twiki posts what it would merge/release and writes nothing. - Watch the digests across several daily cycles; confirm they match your judgment.
- Flip to enforce (
mode: enforceorTWIKI_MODE=enforce). Rollback is instant: flip back toshadow.
A second product in this repository, sharing core/ and github/ but nothing
else: twiki notifies and merges, gitricorder collects and shows. It is a
separate entrypoint (dist/tricorder.js) with two roles from one image.
tricorder collect # writes: migrates the schema, then collects (see below)
tricorder web # reads: serves the dashboard, read-only, never migrates
tricorder doctor # checks the App setup; reads GitHub, writes nothingDependency-update pull requests are collected for the bot actors named under bots: in repos.yaml (see repos.example.yaml).
There is no default: AD-19 forbids a bot login in source, so an absent list disables the lane and the collector says so at startup.
A PR inherits the risk of what it fixes: when its package matches an open alert, the alert's KEV, EPSS and severity rank the PR; otherwise it is a plain update ranked by bump size.
Every other open pull request — the ones a human is waiting on — is collected by a second lane that excludes exactly those actors, and shows under Pull requests rather than Dependencies.
One pull request reaches one of the two and never both.
That lane runs whatever bots: says: with no actor configured, nothing is a bot and every open pull request is a human one.
Its only ranking term is whether its checks are stuck, read from the workflow runs the Actions lane already retains — no checks API call is made.
web serves two pages on the loopback bind: / lists every watched repository with its alert count and freshness, and /queue is one cross-repository list ordered by the ranking chain, each row saying why it ranks where it does.
The ordering is a local policy, not SSVC or any published standard: a broken default branch, then CISA KEV listing, then EPSS, then severity, then update size, then whether GitHub could prepare the update.
A failed or hung workflow run on a repository's default branch leads the chain, because nothing ships from a red main whatever else is open.
gitricorder uses its own App, separate from twiki's. This is the point, not an inconvenience: twiki's App can merge, tag and re-run, and the dashboard must hold no such capability. Sharing one credential would hand the read side the ability to write.
Create a new GitHub App and grant read-only on exactly these, nothing more:
| Permission (UI) | API name | Why |
|---|---|---|
| Metadata | metadata |
mandatory for any App |
| Dependabot alerts | vulnerability_alerts |
the security lane, and the only endpoint carrying EPSS |
| Code scanning alerts | security_events |
security sweep |
| Secret scanning alerts | secret_scanning_alerts |
security sweep |
| Actions | actions |
workflow run status |
| Pull requests | pull_requests |
dependency-update PRs, open human PRs and the review queue |
| Issues | issues |
untriaged issues |
Grant no write permission at all, and subscribe to no webhook events: nothing in gitricorder listens.
Install it on every account holding a watched repository, then point these at
it and run tricorder doctor:
| Variable | Meaning |
|---|---|
TRICORDER_GITHUB_APP_ID |
App ID |
TRICORDER_GITHUB_APP_PRIVATE_KEY |
private key (PEM, inline) |
TRICORDER_GITHUB_APP_PRIVATE_KEY_PATH |
or a path to it |
doctor exits non-zero and says why if the App holds any write permission, if
it is missing any read above, if a watched repository is not visible to its
installation, if a watched repository has no installation at all, or if a
watched repository's defaultBranch is not the branch GitHub reports.
The branch mismatch is reported whether or not the repository declared the field.
An undeclared main is exactly the assumption that goes stale when a repository is renamed.
The branch rides the installation listing doctor already pages, so the check costs no extra request and still writes nothing.
The missing-read check matters as much as the write check: an App scoped to
metadata alone holds no write permission, passes every other test, and then
collects nothing forever. The API names are listed because a permission GitHub
renames should read as a name mismatch rather than as "you did not grant
something you did grant", so doctor prints what GitHub actually reported
alongside what it expected.
collect migrates the schema, then runs each lane on its own cadence: Dependabot alerts every 15 minutes, workflow runs hourly, coverage daily, and the CISA KEV catalogue daily.
Each is judged for freshness on its own cadence, so an hourly lane is not read as stale on a fifteen-minute budget.
KEV is the one non-GitHub request the system makes.
The catalogue is collected and stored; the ranked queue that consumes it is not built yet, so nothing renders it today.
Due-ness is read from the store rather than from memory, so a restart neither re-sweeps everything nor waits a full cadence before doing anything.
Set TRICORDER_ONCE to run a single cycle and exit, for cron.
| Variable | Meaning | Default |
|---|---|---|
TRICORDER_DB |
SQLite database path. Relative paths land in the working directory, which in a container dies with it. | tricorder.db |
TRICORDER_HOST |
Bind address for web. |
127.0.0.1 |
TRICORDER_PORT |
Port for web. Decimal, 1-65535; 0 is rejected. |
8787 |
TWIKI_CONFIG |
Watched repositories. gitricorder shares twiki's repos.yaml: it is the entire universe for both. |
repos.yaml |
TRICORDER_RETENTION_DAYS |
Days of observation history to keep. Unset keeps everything. | unset |
TRICORDER_RUN_RETENTION_DAYS |
Days of collection-run history to keep. Unset keeps everything. | unset |
TRICORDER_ONCE |
Run one collection cycle and exit, instead of looping. | unset (loops) |
TRICORDER_TICK_SECONDS |
How often collect wakes to look for due lanes. |
60 |
TRICORDER_EPSS_BANDS |
Ranking thresholds, comma-separated and strictly descending, e.g. 0.5,0.1,0.01. Changes the order; nothing can reorder the chain itself. A malformed value refuses to start. |
0.5,0.1,0.01 |
TRICORDER_NOW_EPSS |
The EPSS probability at or above which an item, and so its repository, is now. Must equal one of the configured bands, so changing TRICORDER_EPSS_BANDS away from 0.1 requires setting this too. A mismatch refuses to start. |
0.1 |
TRICORDER_REVIEW_BUDGET_DAYS |
Days a review request may wait before its repository is at least soon, measured from the pull request's creation. A positive integer; anything else refuses to start. |
3 |
TRICORDER_BASE_URL |
Where the dashboard is reachable from outside, used to build absolute links for notifications. Optional. https only, no query or fragment; a path prefix is kept. Anything else refuses to start, for both roles. |
unset |
TRICORDER_KEV_URL |
Where to fetch the CISA KEV catalogue. Point it at a mirror or proxy in an egress-restricted deployment. | CISA's public feed |
TRICORDER_VERBOSE |
Print Octokit's own request logging. Off by default because the coverage lane expects a 403 or 404 per repository per security feature that is switched off or has analysed nothing, and those would otherwise look like errors on a healthy run. | unset |
Both retention windows are off by default and a malformed value refuses to start rather than falling back to a default, because silently ignoring a typo in the one setting that deletes data is not a recoverable mistake.
Keeping observation history is cheap: the log appends only when a value actually changes, so a quiet estate adds very little. Run history is the asymmetric case, since collection_run gains a row per lane per installation per cycle whether or not anything happened. Nothing reads it beyond the latest run per key, and that row is never trimmed, so a lane that stopped months ago still appears on the collection-health view.
The default bind is loopback, and there is no code path that defaults to
0.0.0.0. There is no authentication in front of the dashboard, so binding
it anywhere else exposes every collected alert to anything that can reach the
port. Doing so logs a warning; put your own authenticated proxy in front.
The web process opens the database read-only and refuses to start if the
schema is a version it was not built against, rather than serving misread rows.
Upgrading means restarting both roles, not just the collector.
The collector reaches api.github.com and, for the KEV lane only, www.cisa.gov over HTTPS.
The CISA request carries no credentials and downloads roughly 1.5 MB once a day.
TRICORDER_KEV_URL repoints it; there is no way to disable the lane, and a failed fetch leaves every KEV answer unknown rather than "not listed".
Both products run from one image, selected by command (AD-13). compose.yml
runs all three: twiki on the image's default command, plus gitricorder's
tricorder-collect and tricorder-web.
Three files must exist before the first make up, because Docker creates a
missing bind-mount source as an empty directory rather than failing: the
container then gets a directory where it expected a file, and dies with
EISDIR instead of saying what is missing.
repos.yaml— copyrepos.example.yaml- twiki's App private key, at
TWIKI_KEY_PATH - gitricorder's App private key, at
TRICORDER_KEY_PATH
Those two *_KEY_PATH variables are host paths, read only by compose.yml
so it knows what to mount. The variables the application reads are
TWIKI_GITHUB_APP_PRIVATE_KEY_PATH and TRICORDER_GITHUB_APP_PRIVATE_KEY_PATH,
which are container paths that compose.yml pins to /secrets/*.pem.
Setting those two in .env does nothing under compose: they are declared under
environment, which beats env_file. Set the *_KEY_PATH pair and leave the
long names alone. Running the binary directly rather than through compose, it
is the reverse - the long names are the real ones and *_KEY_PATH is unused.
Both keys must be readable by uid 65532, the image's non-root runtime user.
A key stored the usual way (chmod 600, owned by you) is unreadable inside the
container on Linux and both services crash-loop with EACCES. Docker Desktop
and OrbStack on macOS mask this, so it will not show up until you deploy on a
Linux host.
cp .env.example .env # then fill in the two App IDs and key paths
make up # preflight-checks the image, then starts
make logs # follow
make ps # what is running
make down # stopThe dashboard is then at http://127.0.0.1:8787.
make up checks first that the image can actually run the roles compose asks of it.
It probes the resolved image for dist/tricorder.js and judges on capability, not on which tag you picked, so any image that can do the job is accepted.
An image that cannot fails with Cannot find module, which says nothing about images or releases; the preflight says it instead and tells you how to fix it.
Published image tags:
| tag | what it is |
|---|---|
:latest |
the most recently pushed stable release |
:X.Y |
the most recently pushed patch of that minor line |
:X.Y.Z |
one exact release, immutable |
:rc |
the current state of main, overwritten on every merge |
"Most recently pushed", not "highest": there is no guard, so pushing a stable tag lower than an existing one moves both floating tags backward.
RELEASING.md covers why and what to do instead.
There is no floating major tag (:X), and main publishes no versioned tag.
For an immutable reference, pin the digest a tag resolves to.
:latest and :rc both move, so make up pulls before starting rather than trusting a local copy.
:0still exists and is frozen at0.0.5. It was published before:Xwas retired, and nothing writes it any more. It could not be deleted: GHCR removes a whole version rather than a single tag, and that version also carries:0.0.5, a real release. Do not pull:0— it will silently serve old code forever. Use:latest.
Four things the compose file encodes, each of which is easy to get wrong:
- The dashboard is published on
127.0.0.1only. There is no authentication in front of it, so a bare8787:8787would publish on every host interface and expose every collected alert to anything that can reach the port. If you need more than loopback, put your own authenticated proxy in front of it. - The container binds
0.0.0.0internally while the host mapping is loopback-scoped. The isolation is the mapping, not the in-container bind: a container listening only on its own127.0.0.1cannot receive a published port at all, because the mapping arrives from outside its network namespace. - The data volume is writable for both read-side roles, including
web. See the constraints below; a:romount fails withSQLITE_CANTOPEN. - twiki has no data mount and no published port. It gained nothing when gitricorder arrived, and keeping it that way is deliberate.
Under compose, web logs a warning on every start:
WARNING: binding 0.0.0.0, not loopback. There is no UI authentication;
anything that can reach this port can read every collected alert.
That is expected here, and correct from where the process stands: it binds
every interface inside its own network namespace and cannot see that the host
mapping in front of it is scoped to 127.0.0.1. Check the mapping rather than
the warning - make ps, or docker compose port tricorder-web 8787, should
show 127.0.0.1. The warning still earns its place: it is the one that fires
if somebody widens that mapping.
The two Apps are separate (AD-21), so .env carries two App IDs and two key
paths. Both keys and repos.yaml are mounted read-only and are gitignored;
none of them is ever baked into the image.
Both roles open one SQLite file in WAL mode. Measured across two containers on one host bind mount: no corruption, no lock contention, and snapshot isolation holds.
Three constraints follow from how WAL works, and each is easy to get wrong:
- Do not mount the data volume read-only, not even for
web. WAL keeps its shared memory in a-shmfile beside the database, so SQLite must be able to write the directory even when the connection isreadOnly. A:romount fails withSQLITE_CANTOPEN. The read-only guarantee comes from the handle, which SQLite enforces, not from the filesystem. - Never copy the database file while the collector is running.
A copy taken mid-write carries an uncheckpointed WAL, and on its own that file cannot be opened by a read-only handle at all:
webfails withSQLITE_CANTOPENuntil some write open repairs it. Restoring<db>and<db>-walwithout-shmfails the same way. A cleanly stopped collector checkpoints on close, and the single file it leaves behind restores fine on its own. Stop the collector or useVACUUM INTObefore taking a backup. - Start the collector before the web process on a cold start. Any write open repairs a database left in the state above, and the collector's startup migration is that write open.
An ordinary crash needs no special handling.
A collector killed mid-transaction leaves -wal and -shm on disk, and with those present web opens the database and reads it without the collector running at all.
The database must be on a local filesystem.
WAL requires processes to share memory through that -shm file and mmap, which NFS and SMB do not provide reliably.
This was not tested here, and a network volume should be treated as unsupported.
src/
index.ts entrypoint: env, config load, dependency wiring (twiki)
tricorder.ts entrypoint: role dispatch, store open, server (gitricorder)
core/ shared domain, a closed leaf (see "Module boundaries" below)
config.ts allowlist + per-repo policy (strict YAML schema)
semver.ts bump classification + next-patch tag (pure)
types.ts domain types
github/ read/write ports, App auth, Octokit adapter
port.ts GitHubRepoReadPort, GitHubAccountReadPort, GitHubReadPort,
GitHubWritePort, GitHubPort
auth.ts GitHub App auth + installation token cache
octokit-adapter.ts the one adapter, implements both halves
twiki/ the write side: everything that decides or mutates
gates.ts deterministic safety gates + "settled" predicate (pure)
plan.ts the advisor's typed output contract (zod + JSON schema)
advisor.ts LLM advisor, one output tool, no write tools
facts.ts stateless per-tick fact gathering
executor.ts the ONLY component that mutates GitHub; re-validates gates
report.ts per-run chat digest
notify.ts Slack/Discord/Matrix delivery + de-dup
audit.ts append-only JSONL audit log
result.ts per-run result types
run.ts orchestrate one tick
scheduler.ts single tick, or a tick every interval
tricorder/ the read side: collect into a store, serve a page
store/ append-only observations + materialised projection (node:sqlite)
collect/ one lane per source; dependabot-alerts.ts is the first
web/ freshness policy, view model, JSX components, routes, server
test/ pure-logic suites + ports + injection + shadow e2e (fakes, no network)
scripts/ preflight-image (make up gate), matrix-smoke
src/core/ holds domain logic that more than one entrypoint may need: domain
types, pure functions and the config schema. It is a closed leaf: files in
src/core/ may import node: builtins, third-party packages, and other files
inside src/core/. Nothing else.
The rule exists because a second read-only entrypoint is being added alongside
the write path. Anything both sides need moves down into core/; neither side
imports the other. If you are unsure where a new module belongs, ask whether
both entrypoints would need it. If only one would, it does not go in core/.
src/twiki/ is the write side. src/github/ splits its port along two axes.
The first is mutation. GitHubReadPort has no mutating method and GitHubWritePort has only mutating ones, so a consumer given the read port cannot merge, tag or re-run anything: the type has no such member. test/ports.test.ts pins that with @ts-expect-error, so a write method leaking onto the read port fails typecheck rather than review.
The second is what a call names. GitHubRepoReadPort names a repository and resolves its installation from that repository, so it works whoever owns it. GitHubAccountReadPort names an account, which takes an installation resolved by account plus the account's kind, because a GitHub App on a user account has no org-level endpoint. GitHubReadPort is their union, which gitricorder's collector consumes.
GitHubPort is therefore NOT both halves of everything: it is GitHubRepoReadPort plus GitHubWritePort. twiki acts on the repositories its allowlist names and never enumerates an account, so extending the account-scoped half would advertise six methods createGitHubFromEnv cannot honour, and did until that was split. test/write-port.test.ts pins this boundary the same way, with @ts-expect-error per method.
See CONTRIBUTING.md. Two things are non-negotiable and both are enforced: every commit needs a DCO sign-off (git commit -s) from a human identity, and AI-assisted commits carry an Assisted-by: trailer.
Releases are documented in RELEASING.md. Vulnerabilities go through SECURITY.md — privately, never a public issue.
MIT. See LICENSE.