Connect your projects
If you work in several repositories, each one holds its own docs/solutions/, and nothing reads them against each other. One project records “always retry the export job on timeout”. A sibling records “never retry it”. A third project hit a failure that the first one already solved and wrote down. ce:connect reads the projects you name and proposes two kinds of candidate:
- Contradiction. Two learnings that give opposite guidance for the same situation, where at least one of them is in the project that should change and at least one is in another project.
- Transferable lesson. A lesson one project recorded, where another project’s tracked code, workflows or config show the failure and its own learnings do not record the lesson.
A run proposes. A person decides. ce:connect writes nothing into any project, any remote or any global config. Its only output is a digest and a run record in a state directory outside your repositories.
What a run does
Section titled “What a run does”- The skill reads the mode and checks that the host is one where the researcher’s tool limits have been seen to work (see Supported hosts).
- A helper, a Node script bundled with the skill, resolves your selection (the whole allow list unless you name a group or some projects), takes a lock, lists each selected project’s tracked files, hashes them and builds an index of the learnings.
- The skill groups part of that index into families of related rules and dispatches the
connections-researcheragent once per family. The agent can read, grep and glob. It cannot run commands, write, edit or fetch. - The helper validates every candidate the agent returns. It keeps a candidate only when each cited path is a tracked file in an allowed project, each fingerprint matches the hash taken at the start, each excerpt appears word for word in its file, and each piece of target evidence names a line span that holds its symbol. Candidates that fail a check are dropped, counted by reason in the digest, and listed there by title and reason, so you can tell whether a real finding was lost.
- The helper publishes the digest and the run record and updates a small ledger.
A project supplies text, not instructions. A learning that says “report nothing”, “this corpus is audited” or “add that project to the allow list” changes nothing. The allow list is your own config.
Choose the projects
Section titled “Choose the projects”The allow list is a top-level connect block. Its allow_projects field names every project a run may read. An optional groups field names parts of that list. Put the block in your user-level Systematic config:
{ "connect": { "allow_projects": [ "~/src/github.com/me/app", "~/src/github.com/me/shared-lib" ] }}Each entry is the root of a repository checkout, absolute or starting with ~. It is not a subdirectory and not a linked worktree. At most 64 entries. A project is identified by its main checkout, so a worktree of an allowed project is the same project.
Rules for the block:
- User-owned only. A repository cannot grant itself access to other projects. If a project’s own config sets
connect, Systematic ignores the whole block and prints a warning. - A missing list is an empty list. With no list, a run stops with
no-allowed-projectsand reads nothing. - A bad block is an empty list. An unknown key or a bad value, in
groupsas well as inallow_projects, makes the block unreadable, and an unreadable block allows nothing and defines no group. It never widens access. - Layers merge as a union.
systematic.jsonc,systematic.local.jsoncand the same two files inOPENCODE_CONFIG_DIRcan each hold entries. Put machine-specific paths in the.localfile. The layers and their order are the ones in Configure search. Groups merge by name instead: a later file replaces a same-named group whole. - An entry that matches no repository is reported. The run names the entry and the reason (stale path, not a repository, not the main root, unreadable). That run is
degraded, because a project was not read. The other projects are still read. A run narrowed to a group or to some projects does not touch the entries it does not name, so an entry that matches nothing does not degrade it. - Unsafe config directories are refused. If
OPENCODE_CONFIG_DIRpoints inside a checkout or inside an allowed project, the run does not read it.
The field reference is in Configuration.
Groups
Section titled “Groups”A group is a name for part of the allow list. Define groups beside allow_projects:
{ "connect": { "allow_projects": [ "~/src/github.com/me/app", "~/src/github.com/me/shared-lib", "~/src/github.com/me/docs-site" ], "groups": { "backend": [ "~/src/github.com/me/app", "~/src/github.com/me/shared-lib" ] } }}- Name. 1 to 64 characters of lowercase letters, digits and hyphens, starting with a letter or digit.
- Members. One to 64 entries in the same form as an
allow_projectsentry. At most 32 groups per file. - User-owned only, like the rest of the block. A project’s own config cannot define a group.
- A group never adds a project. Each member must resolve to a project on the allow list. The config loader checks only the shape of a group. The membership check happens when a run selects the group, and a member that is not on the allow list stops that run.
Pick projects for one run
Section titled “Pick projects for one run”For a one-off narrower run, name the projects instead of defining a group. Each entry is a project’s directory name or its 12-character project key. A directory name shared by two allowed projects is refused, and the refusal lists their keys so you can name one of them.
Selection words
Section titled “Selection words”| Words | What the run reads |
|---|---|
| None | The whole allow list. |
group:backend | The members of that group. |
projects:app,shared-lib | The two named projects. |
| The same word twice or more | That word, counted once. |
No word ever reads more than the allow list. A word that would is refused.
A bad selection stops the run before it takes the lock, in interactive and headless mode alike. Nothing is written, and the run ends Connect failed with the reason on the line before it. It never falls back to the whole allow list, because you asked for a narrower run.
| Reason | Cause |
|---|---|
conflicting-selection | A group: word together with a projects: word, two different group: words, or two different projects: words. projects:a,b and projects:b,a are different words. |
unknown-group | No group of that name is in your config. |
unknown-project | No allowed project has that directory name or key. |
ambiguous-project | Two allowed projects share that directory name. The refusal lists their keys. |
group-member-not-allowed | A member of the group is not on the allow list. The refusal names the group and the member, and says why when the member did not resolve to a repository. |
invalid-request | A word is malformed: an empty value, an empty entry between commas, more than 32 words, or a word over 2,048 characters. |
Run it
Section titled “Run it”In an interactive session:
/ce:connectThe skill names the host it found, lists the projects it will read, what the run was narrowed to and any allow-list entries that did not match, and states the limits (see Limits). On a host without a recorded deny it warns that the tool limits are unverified and asks you before it dispatches. Each family is dispatched one at a time. At the end the skill prints a summary, the digest path and a candidate line for each candidate, and offers to record your decisions.
Run a group or a few projects
Section titled “Run a group or a few projects”/ce:connect group:backend/ce:connect projects:app,shared-lib/ce:connect group:backend mode:headlessSelection words go anywhere in the arguments. Like mode:, they are read from the skill arguments, or from the text that started the run on a host that passes no arguments. They are never read from a file in a project. The skill passes the words to the helper unchanged, and the helper resolves them, removes identical repeats and decides conflicts, so the result is the same on every host.
The researcher is given only the selected projects, so a contradiction with a project outside the selection is not found by that run. A selection is a narrower read, not a sandbox: the researcher’s read tools are still not path-scoped (see Limits).
Headless mode
Section titled “Headless mode”/ce:connect mode:headlessHeadless mode asks no question and offers nothing after the digest. It does not wait for input. It also refuses a host and version with no recorded deny, where interactive mode would ask for consent. Anything unclear becomes a stop with a reason.
Some hosts pass no arguments to a skill. On those hosts the skill reads mode: tokens from the text that started the run. It never reads one from a file in a project. mode:interactive and no token both mean interactive. Two different mode tokens, or an unknown mode: token, stop the run before any command.
The four final lines
Section titled “The four final lines”Every run ends with exactly one of these lines as its last line. The reason and the digest path come on the lines just before it. Nothing follows it.
| Final line | Meaning |
|---|---|
Connect complete | The slice of rules offered to this run was fully covered, every selected project was read and nothing failed. A run that found nothing is still complete; its digest says “no candidate in searched scope”. A fetch: whose candidates were all checked and none refused is complete too. |
Connect degraded | Something was not read: a project was missing, a bound on the file inventory or on a batch was reached, a family did not finish, a file a candidate cited changed or vanished during the run (evidence-changed, unresolved-source), or an offered rule was not covered. The digest names what. A degraded run is never proof that nothing is there. A fetch: that refused at least one candidate is degraded. |
Connect busy | Another run holds the lock, or its holder cannot be verified. Nothing was written and nothing was dispatched. |
Connect failed | Any other stop: a bad mode token, a refused selection, a bad or unusable fetch:, the helper missing, a host without a recorded deny in headless mode, consent declined, no allowed projects, a malformed batch or a helper error. Nothing was published. |
A scheduler that reads the last line of the output can branch on these four.
Headless runs on OpenCode need a host permission
Section titled “Headless runs on OpenCode need a host permission”A run reads and writes its work files in the state directory, and the researcher reads your allowed projects. All of that is outside the project the session runs in. OpenCode asks before it touches a path outside the project, and a run with nobody to answer is refused. On OpenCode 1.19.5, a headless run that read a work file in the state directory was refused as an external_directory request. The refusal ended the whole session: no final line, no digest, and the lock stayed held until the next run reclaimed it.
Before the first headless run, allow the state directory in OpenCode’s own opencode.json. This is the host’s config, not Systematic’s. Use the absolute path of the state directory (see Where the output goes):
{ "permission": { "external_directory": { "<state directory>/*": "allow" } } }With that line, a headless run on OpenCode 1.19.5 ran the whole pipeline and ended Connect complete. The researcher’s own definition already allows reading outside the project, so the allowed projects need no entry. Systematic never writes this line for you.
Sweep rotation and progress
Section titled “Sweep rotation and progress”A run covers a slice of your learnings, not all of them. The helper offers at most 6 families of 8 rules each. It offers first the rules that were never covered, or whose learning changed since they were covered, and then the least recently covered ones. Inside each group it takes the projects in turn, so a slice spans projects. The order depends only on the index and on what committed runs covered, so running ce:connect again sweeps the whole index and then starts over with the oldest.
The researcher still searches every project the run reads for evidence. A rule that is not in this run’s slice can be the other half of a contradiction with one that is.
The digest header and the summary carry one progress line:
Sweep: covered 48 of 61 rules (current hashes); this run covered 48; about 1 run left in the sweep.A rule counts as covered only while its learning still has the hash it had when it was covered. A run that could not read a project in full does not forget what earlier runs covered there: the coverage records of that project stay until it is read in full again, and a rule whose file could not be read stays as it was. Only a learning that is gone from a project the run read in full is forgotten. When the sweep is finished, the line says so and says the next run starts again with the least recently covered rules. Rules outside the slice are not a reason to degrade a run.
Coverage is recorded only for a run that committed. An aborted or crashed run records none.
Each selection has its own progress
Section titled “Each selection has its own progress”A run over a group or some projects indexes only the selected projects, and its progress line counts only those rules. Coverage is kept apart for each selection:
- A run with no selection keeps the coverage it always had. A group run never counts toward it, so a group run that covered a rule does not stop a later whole-list run from offering that rule.
- A group or a pick is identified by the set of projects it selects. The group’s name is not part of the identity: two groups with the same members share one sweep, and a group that gains or loses a member starts a fresh sweep.
- A selection of the whole list is still a selection. It does not count as coverage for a run with no selection.
- A narrowed run never forgets what it did not read. The coverage of unselected projects, and of other selections, stays as it was.
The ledger holds one shared cap of 20,000 coverage records across all selections. When it is full, the oldest records that the current run did not index go first, whichever selection they belong to.
Where the output goes
Section titled “Where the output goes”The state directory is $XDG_STATE_HOME/systematic/connect, or ~/.local/state/systematic/connect when XDG_STATE_HOME is not set. Directories are mode 0700 and files are 0600. The helper refuses a state directory that sits inside a git checkout, is a symlink, or is inside an allowed project. Keep the path free of whitespace: when XDG_STATE_HOME contains whitespace, the summary prints only the candidate id, and the run record path cannot be passed to ce:compound as one word.
<state directory>/ lock/holder.json the held lock (only while a run is active) runs/<run id>/ digest.md the human-readable digest run.json the run record and its candidate block ledger.json the minimal ledger- The digest has three sections: candidates, connections already recorded (the target already has a learning citing the same source), and stale citations. Each candidate shows its sources with paths, fingerprints and excerpts, any counterevidence, an overlap check, and a next step. Free text is shown as quoted, untrusted evidence. Its header carries a
Selection:line that names the whole allow list, the group, or the picked projects, with the selection’s id. - The run record (
run.json) holds the candidate block thatce:compoundreads when you promote a lesson. It names the selection too, and records each project’srepo(the lowercaseowner/repoof itsoriginremote) and the commit its HEAD names, so the record can be matched to a checkout on another machine. The helper reads both from.gitfiles, runs no git command for them and opens no connection. Only GitHub remotes count, credentials in a URL are dropped, and a project with no GitHuboriginrecords neither. - Stale citations are a deterministic check of which
path:linecitations in your learnings still hold. A citation ispassed,not-in-span(the symbol is not in the cited lines, with a flag when it is elsewhere in the file) orunchecked(with a reason). It reports which citations still hold. It does not promise that code has not moved. The check stops at a cap per project (500 citations). A project that reaches it is named under “Not read or not covered” in the digest and the run record keeps the counts, but the run is not markeddegradedfor that alone. - Retention. Run directories older than 30 days are pruned at the start of a run. The ledger is never pruned.
- Publishing is one rename. A run is built in
runs/<run id>.tmp/and renamed toruns/<run id>/. The ledger is updated after the rename. If a crash falls between the two, the digest exists but its candidates are not in the ledger, and the next run proposes them once more. If the ledger cannot be written after the rename, or the new ledger would pass its 16 MB limit, the old ledger stays as it was, and the run isdegradedwith the reasonledger-write-failed. The committed digest and run record are rewritten to say so. The next run also removes leftover.tmpdirectories.
Promote a candidate
Section titled “Promote a candidate”Only a person promotes a candidate. ce:connect never writes a learning. Every candidate in the summary that can be promoted comes with a line to paste. A lesson gets a line of two tokens for ce:compound, shown below. A contradiction gets a Review in <project>: /ce:compound-refresh path:<path> line that names the target project’s own learning, because ce:compound refuses contradictions. A candidate whose next step is investigate has neither: the summary says to investigate it first, and there is nothing to paste yet.
candidate:<run record path> candidate-id:<id>Copy that line unchanged.
A contradiction
Section titled “A contradiction”Run ce:compound-refresh from the project that should change, with the exact path of the learning to review. The summary prints this line for you:
/ce:compound-refresh path:docs/solutions/best-practices/export-job-retry-on-timeout-2026-02-11.mdRepeat path: for each learning. An exact path scope reviews those documents and nothing else. It shows what it will touch first and runs once per project. It refuses mode:autofix: a human reviews every change.
A transferable lesson
Section titled “A transferable lesson”Run ce:compound from the target project, with the candidate line:
/ce:compound candidate:<run record path> candidate-id:<id>ce:compound treats the run record as data. It prints Source project: and Target project: lines first, then re-verifies every fingerprint and excerpt with its own tools and checks that the target evidence still holds. A stale fingerprint stops it and tells you to run ce:connect again. It then writes the learning in the target project’s own words and shows the full diff. It writes after you approve the diff, and not before.
A promoted learning never copies another project’s text. It cites each source as <project>:<path>@<fingerprint> in a ## Provenance section in the body.
Bring a scheduled run’s candidates here
Section titled “Bring a scheduled run’s candidates here”A lesson found on another machine cannot be promoted from its own record: the paths in it belong to a runner that is gone. fetch:<run>.<attempt> brings the candidates of a run of Systematic’s ce:connect workflow to your machine and re-checks them against your own checkouts, so you can promote them with the usual line. The workflow is described in Scheduled runs.
/ce:connect fetch:<run>.<attempt><run> is the GitHub run id and <attempt> is the attempt number. Each is a positive integer, digits only. A scheduled report prints this form for each candidate, with the attempt that found it, for example fetch:43525490720.1. A bare fetch:<run> still works and reads the newest attempt of that run. A fetch: word stands alone: combined with group: or projects: it stops with conflicting-selection, and it takes no mode:, because a fetch dispatches no researcher, asks nothing and observes no host.
The fetch needs the GitHub CLI (gh), logged in with a token that can read the Actions runs and artifacts of marcusrbrown/systematic. The skill runs gh with your own credentials, as explicit commands. The helper never touches the network. In order:
- Check the run before downloading anything. Its workflow must be
.github/workflows/connect.yamland its event must bescheduleorworkflow_dispatch. A run of another workflow, or one started by a pull request, is refused whatever its artifacts hold. - Pick the artifact named
connect-run-<run>-<attempt>, and download it into a fresh temporary directory that is removed afterwards. - Import. The helper reads only
run.jsonfrom that directory (a plain file of at most 2 MiB; never a symlink), as untrusted input.
The import maps each project in the record to exactly one allowed local checkout by its origin remote (owner/repo). It then recomputes every candidate id and rule id from your local roots, never trusting a recorded one, and re-verifies each cited file’s fingerprint and each excerpt against your files. It inserts the accepted candidates into your local ledger as a local run, so the usual promotion lines come out unchanged, and prints them. It never changes coverage, and it writes no learning.
A candidate is refused, and named, when its evidence does not hold on your machine. The fixed reasons include:
| Reason | Meaning |
|---|---|
file-changed | A cited file differs from the file the run read. The refusal names the file. |
repo-not-allowed | No allowed checkout has the recorded repository as its origin. |
repo-ambiguous | Two allowed checkouts do. The refusal lists their keys. |
project-without-repo | The run recorded no GitHub remote for a project of the candidate. |
file-not-tracked, file-symlink, file-vanished | A cited file is not tracked locally, is a symlink, or is gone. |
path-rejected | A cited path is absolute, climbs out of its project, or is not under docs/solutions, docs/plans or docs/brainstorms. |
candidate-id-mismatch, rule-id-mismatch | The record was changed after it was written. |
check-failed | A local check failed, such as an excerpt that is not in its file. |
A refused candidate is not proof the lesson is wrong. A later scheduled run proposes it again while its evidence holds.
Your checkout may be at a different commit than the one the run read. That is accepted when every check passes, and the summary shows both commits for each project. A candidate you already decided locally is skipped and named. A rerun of the same fetch writes nothing new and prints the lines of the candidates that are still open.
If the fetch stops before the import, the skill prints one of these reasons: not-a-connect-run, unsupported-run-event, run-not-found, gh-not-authenticated, artifact-list-failed, artifact-not-found, artifact-expired or download-failed. If node or the helper file is missing, or the import prints no answer, it prints helper-unavailable. In every case it ends with Connect failed. An artifact is kept for a limited time. When it has expired, tick Rejected on the candidate in the run’s report, or wait for a later run to propose it again.
Settle a candidate on a report
Section titled “Settle a candidate on a report”A scheduled run’s report lists every candidate that is still open, oldest first, each with a fetch line for the run that found it and two boxes: Promoted and Rejected. Tick Promoted after you promoted the candidate locally, or Rejected to dismiss it.
The next run reads the ticks back with the helper’s apply-ticks operation and records each as a decision with the reason settled in report #<n>, where <n> is the report’s issue number. A settled candidate leaves the list. Only the generated lines count:
- Only the boxes the report generated. The operation parses one delimited section of the body, outside any code fence, and each box carries the candidate id in a marker. A
- [x]that appears inside a candidate’s quoted text records nothing. - Only candidates the report listed. Each body names the list of ids it rendered. A tick for an id outside that list, or for one the ledger no longer knows, is reported as unresolved. It is never fatal.
- One decision per candidate. Ticking both boxes is a conflict and is skipped. A tick already recorded with the same status and reason is skipped, so reading the same body twice changes nothing.
The ledger a scheduled run keeps is separate from your local ledger, and nothing flows between them. Promoting locally through fetch does not settle the candidate on the report; tick Promoted for that. The workflow in Scheduled runs calls the helper’s apply-ticks and render-report operations, which read these ticks and write the report body. A local run never calls either operation.
Record the decision
Section titled “Record the decision”After you promote or turn down a candidate, tell the ledger. In an interactive run the skill offers to do this for you. A candidate is candidate, promoted or rejected, and promoted and rejected carry a reason. “Context-specific, no write needed” is rejected with that reason. If a learning was written but the ledger update failed, ce:compound prints the candidate id and destination so you can record the decision. A later run also reports learnings that carry a provenance marker with no recorded decision.
The ledger suppresses a candidate whose sources and target evidence have not changed, whatever its status. A changed source reopens it as “sources changed”; changed target evidence reopens it as “target changed”.
Manage the ledger
Section titled “Manage the ledger”The ledger holds identities, source paths, fingerprints, statuses, reasons and rule coverage. It holds no excerpts, and it is never evidence. The helper has three ledger operations. The skill runs them as node "$SKILL_DIR/scripts/connect.mjs" ledger <op>, where SKILL_DIR is the skill’s directory.
| Operation | What it does |
|---|---|
ledger show | Lists entries, including open candidates whose digest was pruned, 500 at a time. The answer has total and nextOffset; pass { "offset": <n> }, or ledger show --offset <n> on the command line, to read on until nextOffset is null. The answer also has a coverage block with the number of covered rules for the whole-list sweep and for each selection, labelled group:<name> or projects:<a>,<b> while the run that last covered it is kept. |
ledger clear | Removes the ledger and its coverage, for every selection. The next run proposes everything again and every sweep starts over. |
ledger unlock | Removes a lock that no run holds. Use it only when you know no other run is active. |
The ledger file is at version 2. A version 1 ledger is converted by the first run that writes it: its coverage becomes the whole-list sweep, and its entries are unchanged. A ledger with any other version cannot be read and is never replaced. A helper that predates version 2 reads a version 2 ledger as unreadable and overwrites nothing, so update the skill and its helper together.
A lock held by a process that has died is reclaimed by the next run without any help. ledger unlock is for a run that ended without releasing its lock while its host process is still alive. The skill never runs ledger unlock for a busy lock, and never in headless mode. The one exception is a failed validate whose reply does not confirm that the lock was released: the skill then unlocks the lock its own run took before it stops.
Scheduled runs
Section titled “Scheduled runs”Systematic’s repository runs one headless ce:connect slice a day over public repositories, and posts what it found as a GitHub issue. The workflow is .github/workflows/connect.yaml. It starts every day at 05:41 UTC, and you can start it by hand with workflow_dispatch. Both triggers run only on the repository’s default branch; on any other branch every job is skipped.
The run is headless, so it asks nothing and decides nothing. It proposes candidates. You settle them by ticking boxes on the report (Settle a candidate on a report) or by fetching them to your machine (Bring a scheduled run’s candidates here).
Set it up
Section titled “Set it up”The workflow reads five repository variables and two repository secrets.
| Name | Kind | What it holds |
|---|---|---|
FRO_BOT_CONNECT_PROJECTS | Variable | The projects to read, as owner/repo entries separated by whitespace or commas. Case is folded, duplicates are dropped, and a list must hold between 1 and 16 entries. An entry’s checkout name, owner__repo, may be at most 100 characters, because the workflow reads the repository back from that name. A longer entry stops the run. |
FRO_BOT_CONNECT_MODEL | Variable | Required. The researcher’s model, as provider/model. |
FRO_BOT_CONNECT_VARIANT | Variable | Optional. A model variant for the researcher. |
FRO_BOT_CONNECT_STEP_TIMEOUT | Variable | Optional. The minutes the agent step may run, as a whole number below the run job’s 50-minute timeout. The default is 40. It bounds the agent step so a hung run still posts its report. |
FRO_BOT_MODEL | Variable | The model of the parent agent that runs the skill, as provider/model. It is the same variable the Fro Bot workflow uses. The workflow does not check it: if it is unset, the action falls back to its own default model. |
OPENCODE_AUTH_JSON | Secret | The model provider’s credential. |
OPENCODE_CONFIG | Secret | The OpenCode configuration the action needs next to the credential. The credential alone fails provider authentication, so both are required. |
The two secrets reach only the step that runs the agent, as the action’s auth-json and opencode-config inputs. No other job or step references them.
A bad variable stops the run in its first job, before anything is cloned or posted, and the workflow ends red. That covers a missing or malformed model, an entry that is not owner/repo, an empty list, a list over 16 and a step timeout that is not a whole number of minutes below 50.
Only public repositories are read. The first job asks the GitHub API for each repository’s visibility and treats an error as not public. One repository that is not public stops the whole run. The render job checks again before it saves the cache or uploads anything, and the last job checks again right before it writes, so a repository made private during the run is caught too. Both rechecks cover every repository the cached state names, shown or not: the repositories behind all stored candidates and every retained run record, including ones that have left the list. The first job runs the same check before it uploads the restored cache, and a repository that is no longer public fails the run with nothing uploaded, cached or posted. A repository that cannot be named fails the run too. To recover, delete the connect cache with gh cache list --key connect-v1- --limit 100 --json id --jq '.[].id' | xargs -n 1 gh cache delete. The next run then starts with an empty ledger. Each repository is cloned without credentials, at its default branch, with its history cut to the latest commit.
The four jobs split the work by what each may touch:
- Prepare has read-only access. It checks the variables and the repositories, restores the ledger cache, and records the ticks left on the newest open report.
- Run has a read-only token. It is the only job that runs the agent.
- Render has no permissions and no secrets. It renders the report from what the run left, rechecks visibility, saves the ledger cache and checks that the save left a cache behind.
- Post is the only job that can write, and it can write only issues. It writes the report and closes the reports it replaces.
Only the run job checks out Systematic’s repository, and it takes .github/actions alone: the shared agent action that the Fro Bot workflow runs too. The checkout lands in .fro-bot-actions, keeps no credential and leaves the connect layout alone. No job runs a script from the repository. The jobs pass files to each other as artifacts, and an artifact carries only the connect files on an allow list: the ledger, the stores behind the report, the invocation record, and each run’s run.json and digest.md. These artifacts are kept for one day.
Pin the Systematic version
Section titled “Pin the Systematic version”The workflow installs one exact release of @fro.bot/systematic. The same version feeds the action’s systematic-version input and the helper that renders the report and reads the ticks, so the skill that wrote the state and the helper that reads it are the same release. Bump it by editing SYSTEMATIC_VERSION at the top of the workflow. Do it with each Systematic release that changes the format of the ledger or the run records.
The fro-bot/agent action is pinned by commit in one place, .github/actions/fro-bot-agent/action.yml, which this workflow and the Fro Bot workflow both run, and its pin follows Renovate. A headless run refuses a host and version that HOST_SUPPORT_TABLE does not list (Supported hosts): the run ends Connect failed with the reason unsupported-host, and the workflow turns red.
Read the report
Section titled “Read the report”Each UTC date has one report, titled ce:connect report — YYYY-MM-DD (UTC). A second run on the same date edits that report in place and opens no second issue. When a new date’s report is posted, each older open report gets a comment, Superseded by #<n>., and is closed. The workflow finds a report by its exact title and by its author, github-actions[bot], which is the login the job token posts as. An issue that only looks like a report, such as one with the same title written by another account, is ignored.
The report lists the candidates that are still open, oldest first, as many as fit under the size cap. Each candidate has:
- the sources and the target evidence, shown as quoted text,
- a
fetch:<run>.<attempt>line for the run and attempt that found it, to bring it to your machine, and - two boxes, Promoted and Rejected.
Tick one box per candidate. Ticking both is a conflict, and neither is recorded. The next run reads the ticks from the newest open report. Ticks on a closed report are ignored, so settle a candidate before the next report supersedes the report that lists it. A box ticked on any line the report did not generate records nothing.
A box you tick while a run is going is kept. Before the last job edits or closes a report, it reads that report again and ticks the same boxes in the new body, for the candidates the new body still lists. It reads only the generated boxes inside the report’s decision section and never inside a code fence. The next run then records those ticks. A tick for a candidate the new body no longer lists is dropped.
The report also says how the run ended: Connect complete, Connect degraded, Connect busy, Connect failed, or no final line. A degraded or busy run is posted and the workflow stays green. A failed run, or one that began and never reported an outcome, is posted and the workflow then turns red.
The agent step has its own timeout, FRO_BOT_CONNECT_STEP_TIMEOUT (40 minutes by default), shorter than the run job’s. A hung run is cut off at that point and the steps after it still run: the run record is read, handed on and posted, and the report says the run ended with no final line. The job’s own timeout is only a backstop. When a job is cancelled or times out, every step after the agent is skipped and nothing is posted.
A failure before the report can be rendered turns the workflow red and posts nothing. This covers an agent step that stopped before ce:connect began, a run record that does not belong to this run, projects that differ from the allow list the workflow wrote, a store that cannot be read, and a lock that is held. The next run starts from the last good state and reads the same open report again.
What the cache holds
Section titled “What the cache holds”The ledger lives in a GitHub Actions cache, because no other job or run keeps files between runs. The cache holds:
ledger.json, the candidates and their decisions,- the store of candidate text that the report is rendered from,
- the manifests of the last 14 reports, which say which ticks still count, and
run.jsonanddigest.mdfor each run.
It never holds the lock, OpenCode’s own state, the configuration directory or a credential. Every run saves under a new key and restores the newest one. The save is checked: the render job looks the cache up by its key afterwards, because the save step reports success even when it saved nothing. If the cache cannot be saved, or the lookup finds nothing, the report says so and the workflow ends red, because the next run may list settled candidates again.
GitHub evicts caches that go unused for seven days or push a repository over its cache limit. After an eviction, the first run starts with an empty ledger. It restarts the sweep from the beginning, forgets earlier decisions, and may list a settled candidate again. Its report opens with a notice that no cache was restored, and a tick for a candidate the ledger no longer knows is reported as unresolved.
Fetch a candidate
Section titled “Fetch a candidate”The run saves its run record as an artifact named connect-run-<run>-<attempt>, kept for 30 days. It holds the run record and nothing else. fetch:<run>.<attempt> reads that attempt’s artifact and no other (Bring a scheduled run’s candidates here). After a workflow rerun, each attempt keeps its own artifact, and a candidate carried over from an earlier attempt still names that attempt. If the named artifact is missing, fetch reports artifact-not-found, and once it has expired, artifact-expired. It never reads another attempt in its place. Tick Rejected on the report, or wait for a later run to propose the candidate again.
fetch: reads runs of marcusrbrown/systematic only. A copy of the workflow in another repository posts its own reports and ticks, but its runs cannot be fetched.
What the credential can reach
Section titled “What the credential can reach”This is a documented exposure, and it is accepted.
The agent step can read the model provider’s credential and the job’s token on the runner. The parent agent has shell and network access. A prompt injection hidden in the text of a public repository could try to make it leak either one. Nothing screens the report or the artifacts for secret values, because a keyword or entropy screen gives false assurance and would not stop an agent that has its own shell and network.
The mitigations narrow what a leak is worth. None of them stops the leak:
- Only public repositories are read.
- The run job’s token is scoped to the job, read-only, and expires when the job ends.
- No job has a write path to any repository. The one unattended write is the report issue.
- The credential reaches only the action step.
- Artifacts between jobs carry only the connect files on the allow list.
- The report is inert: model text is fenced and neutralised, and a quoted checkbox records nothing.
If that exposure is more than you accept, do not enable the workflow, or use a provider credential that you can scope and revoke.
Supported hosts
Section titled “Supported hosts”A host counts as supported only from the lowest version where a deny was observed on a real host: the researcher is dispatched, it tries shell, write, edit and web fetch, each is refused, and its read succeeds. Definition tests are regression pins and are not counted as proof. The proofs use a scripted model, so they show that the host enforces the limits. They do not show how a live model behaves.
| Host | Status | How the limits are enforced |
|---|---|---|
| OpenCode v1 | Supported | The agent’s permission: denies bash, edit, webfetch, websearch and task. A blanket allow in your host config does not lift them. |
| OpenCode v2 | Supported | The same denies, plus Systematic’s own tool guard (below). |
| Pi | Supported | The agent’s tools allowlist (Read, Grep, Glob) reaches the delegate session as read, grep and find. |
| Claude Code | Supported | The agent’s tools allowlist (Read, Grep, Glob) is what the host enforces. In a manual run with a live model, the session’s tools were exactly those three. The agent also declares disallowedTools, but upstream reports say the host does not always enforce that for plugin subagents, so the allowlist is what counts. |
Each row is the lowest version where the limits were observed. That version and any newer release of the same host and major version are supported, so a host update does not need a new Systematic release. The versions are listed in HOST_SUPPORT_TABLE in src/lib/connect-config.ts. A release where the limits are later seen to fail goes on a known-bad list in the same file and is refused with its own reason.
The Claude Code row rests on one manual run, not on a scripted test: one live model, one version. In the run that dispatched the researcher through the Agent tool, the subagent reported only those three tools and called none of the others, which is its own report. The host’s statement comes from the second run, a session started as the researcher, whose tool list held only Read, Grep and Glob. Read paths are not scoped, and whether a dispatched subagent can reach its parent session’s MCP tools was not tested.
A version below the lowest observed one is unsupported. So is a prerelease (2.2.8-rc.1), a higher major version than the row names (OpenCode v1 and v2 are separate hosts, and a new major version has to be proved on its own), a host that is not in the table and a release on the known-bad list. A newer release is supported on the strength of the observation at the floor, so it has not been seen to hold on that release itself. In interactive mode an unsupported pair gets a warning that the no-write guarantee cannot be given, and the run goes on only if you agree. In headless mode it is refused with unsupported-host. A release on the known-bad list is the exception: its limits were seen to fail, so it is refused with unsupported-host in interactive mode too, and agreeing does not run it. A host whose version the skill cannot read counts as unsupported.
Another definition of the agent keeps only its model. If another plugin also defines connections-researcher, or on OpenCode v1 your own opencode.json or a project config does, Systematic still applies its own read-only definition: its prompt, permission denies and every other field replace the other entry’s, and nothing from the other entry’s permissions or tools is merged in. Only the other entry’s model (and its variant) is kept, so you can route the researcher’s model from elsewhere, and Systematic logs one warning that names the agent. A model fallback chain defined this way works for the model only; it cannot give the agent more tools. This holds on OpenCode v1 and v2. On v2 an agent definition in your own OpenCode config is applied after every plugin, so it is not replaced; the tool guard below is what keeps the researcher read-only in that case.
On OpenCode v2, Systematic’s tool guard does the enforcing. A session-level allow rule overrides an agent’s own deny on v2, and Code Mode reaches host tools that no frontmatter key can name. So Systematic registers an execute.before hook that refuses every call from connections-researcher outside a fixed list: read, grep, glob, skill, systematic_search and systematic_skill. It refuses a call made directly and a call made from inside Code Mode, and the rest of the session keeps running. The hook is registered before any agent is installed. If it cannot be registered, Systematic logs one warning and does not install connections-researcher at all, so the agent is never callable without its allowlist.
Pi caps a delegate at 20 turns. A family that does not finish inside the cap is marked unfinished and degrades the run. It never silently contributes nothing.
Limits
Section titled “Limits”Read these before you add a project.
- Read tools are not path-scoped. The researcher cannot run commands, write or fetch. Its read, grep and glob tools can still open any file your user can, not only the allowed projects. The allow list decides which projects are indexed and which paths the helper validates, and a group or a pick narrows that further. Neither is a sandbox.
- Session MCP tools are not denied. Tools from MCP servers connected to your session stay available to the researcher. Check what is connected before a run.
- A digest can carry content from those reads. A validated excerpt must come from a tracked file in an allowed project. The digest’s other free text is written by the researcher after unscoped reads, and nothing screens it for secrets. Treat a digest like the learnings it quotes, keep the state directory private, and read it before you share it.
- Everything read is evidence. Candidates are checked against the files, but a candidate is a proposal, and a wrong one is possible. A run that found nothing, or that is
degraded, does not show that nothing is there. - The title and the summary are not checked against evidence. The researcher writes them. The helper checks excerpts, fingerprints and paths, but not these two fields. Reads are not scoped to a path, so text from outside the allowed projects can reach the chat reply and the run records through them. Read every candidate before you promote it.
- Coverage is self-reported. The helper records a rule as covered from the researcher’s own report of what it searched. A run steered by a hostile learning can mark rules covered without searching them.
ledger clearresets coverage. - Some paths cannot be cited. A file whose path contains a single quote, a dollar sign or a backtick is skipped, and so is a project whose root has one. The digest names the reason under “Not read or not covered”.
- A fetched record is untrusted. It comes from a runner you do not control, and only the helper opens it. The helper re-verifies fingerprints and excerpts against your files, but the title and summary were written by a researcher on that runner and are not checked against evidence. Read every candidate before you promote it.
- A scheduled run’s agent can reach its credential. The agent step can read the provider credential and the job’s token, and nothing screens the report or the artifacts for secret values. The mitigations narrow the damage and do not prevent a leak. See What the credential can reach.
- A report’s ticks are only as good as the report. A tick settles a candidate by id. Nothing checks that you promoted it. The ledger of a scheduled run is separate from yours.
- Promoted learnings never copy another project’s text. They are written in the target project’s own words and cite their sources by project, path and fingerprint.
- Only a human promotes. Nothing in
ce:connectwrites a learning, edits a project, or records a decision without you.
Privacy
Section titled “Privacy”The helper opens no connection at all. Apart from fetch: (below) and the scheduled workflow, ce:connect makes no network call of its own and sends nothing to anyone but your model provider. Everything a run reads and writes is on your machine. The text the researcher reads, and the digest, reach your model provider like any other tool result.
The one command that reaches GitHub is fetch:<run>, and only when you type it. The skill runs gh with your own credentials to read one run and download one artifact of marcusrbrown/systematic. The requests carry the run id and your gh token. Nothing from your projects is uploaded. See Privacy.