Skip to content

Search your past work

Systematic can search the durable outputs that its own workflows write: the learnings in docs/solutions/, the plans in docs/plans/, the brainstorms in docs/brainstorms/ and the ideation documents in docs/ideation/. You type a symptom, an error message, a module name or a topic. You get ranked hits with the path, title, kind, a snippet, tags and status. Agents use the same search, so the learnings-researcher finds a past fix, or a direction you already rejected, before it greps.

Search needs no setup. It ranks documents with a lexical method (BM25F) that runs in the plugin itself. It makes no network call, downloads no model and writes no cache.

The four kinds cover what Systematic itself writes. Decisions often live somewhere else: an RFC folder, a wiki, a set of runbooks or a Deciduous decision graph. You can opt in to search those too, with kind: external. Nothing outside the four kinds is read until you set that up. See External sources.

Terminal window
npx @fro.bot/systematic search "tests fail only on retry"

Search has four kinds. Each kind is one directory directly under the project root:

KindDirectoryWhat it holds
solutiondocs/solutions/Learnings that ce:compound writes
plandocs/plans/Plans that ce:plan writes
brainstormdocs/brainstorms/Requirements documents that ce:brainstorm writes
ideationdocs/ideation/Ideation documents that ce:ideate writes

Search reads the files from disk on every call and keeps no copy. A learning that ce:compound wrote a moment ago is searchable at once. It reads the files whether git tracks them or ignores them.

It indexes every .md file under a kind’s directory, with these exceptions:

  • README.md files.
  • critical-patterns.md, in docs/solutions/ only.
  • Anything under an _archived/ directory.
  • Symlinks. Search does not follow them. If a kind’s directory itself resolves to a place outside the project root, search loads nothing from it and prints a warning.

Some projects list docs/brainstorms/ in .gitignore. Search still reads it in the checkout where the files exist. A linked worktree has only the tracked files, so in a worktree of such a project the brainstorm kind reports none: the directory is not there. Plans, solutions and ideation documents appear as usual. Search the primary checkout to include brainstorms. A project that keeps its brainstorms in git, or has no docs/brainstorms/ at all, needs no setup.

The limits are:

LimitValue
Size of one file256 KB (a larger file is skipped with a warning)
Number of files2,000
Directory depth below a kind’s directory6
Text loaded in one call16 MB

The file, depth and text limits apply to each kind separately, so a search of all can read up to four times as much. When a limit skips files, the result carries one warning that names the directory and counts them.

Search reads these frontmatter fields: title, topic, tags, module, component, problem_type, symptoms, applies_when, status, superseded_by and stale_reason. The title of a hit is title, else topic, else the file name. A file with no frontmatter is indexed as body text, and its file name is its title. A file with invalid frontmatter is skipped. The result carries one warning per kind that counts the skipped files by reason.

Search matches whole terms. Text is lowercased and split into runs of the ASCII letters and digits a-z and 0-9. A run joined by _ or - also counts as one term, so foo_bar matches foo_bar, foo and bar. There is no stemming, so timeout does not match timeouts. A letter outside ASCII ends a word. Matches in the title, symptoms and tags weigh more than matches in the body. A query is cut to its first 64 distinct terms, and the result says so.

A solution prints active, stale or superseded, as it always has. A plan, brainstorm or ideation document prints the status it records, exactly as written (completed, abandoned, deferred and so on), or unspecified when it records none. Search never maps those values to another word. A status label longer than 40 characters is cut.

Only two statuses do anything: superseded and stale. For plans, brainstorms and ideation documents, search compares them without regard to case or surrounding spaces. For solutions the comparison is exact.

A document can say it was replaced. Search follows that link so you read the current doc.

  • Superseded. If a matching doc has status: superseded and a superseded_by path in the same kind, the hit shows the successor instead. The successor takes the best rank among itself and the docs it replaces. The hit carries a Supersedes: line that lists every replaced path.
  • Successor in another kind. A plan can be superseded by a solution, or a brainstorm by a plan. Search does not hop between kinds. The hit stays on the last document of its own kind and carries a Successor: line with the path of the document that replaced it. That line is information, not a warning. Search does not check that the file exists, because the other kind’s directory may be absent from this checkout. Read the path before you rely on it.
  • Successor unresolved. Search follows at most 5 links. If a same-kind successor is missing, loops back, or lies past the fifth link, or if the link is not a path into any kind’s directory, search does not drop the old doc. It returns the superseded doc at its own rank with the line Caution: successor unresolved. Treat that doc as out of date and look for a newer one.
  • Stale. A doc with status: stale is returned with a line that starts Caution: marked stale, followed by its stale_reason when it has one.

Search needs to know which project it is in. The project root is the nearest ancestor of the start directory that holds a .git entry. A .git file counts, so a linked worktree works. If no ancestor holds one, the start directory is the root. Search then reads the kind directories under <root>/docs/.

The start directory depends on the surface:

SurfaceStart directory
OpenCode v1The directory of the session, as OpenCode reports it for each call
OpenCode v2The location directory of the session. If the host provides none, the tool says there is no project directory and searches nothing.
PiThe working directory of each call. In a delegate, that is the delegate session’s own directory.
systematic search and systematic-search--dir <path>, else the current directory

In a monorepo the root is the repository root, not the package you are in. The project config file is read from the same root, so a session in packages/app/ reads the project file at the repository root and not one inside packages/app/. See Config File Locations for the file names and the order of the config layers.

HarnessNameNotes
OpenCode v1 and v2systematic_search toolOffered to the model like any other tool.
Pisystematic_search toolAlso on the default read-only allowlist of systematic_delegate personas. A persona with an explicit tools: list gets it only if the list names systematic_search.
Claude Codesystematic-search commandA bin in the plugin’s bin/ directory, run from a shell. Claude Code has no systematic_search tool.
Any terminalsystematic searchUse npx @fro.bot/systematic search … if the package is not installed globally.

The tool takes these arguments. The command takes the same three as flags, plus --dir.

ArgumentRequiredMeaning
queryyesWhat to look for, in plain words. At most 2,000 characters.
kindnosolution, plan, brainstorm, ideation, all, or external. The default is solution.
limitnoHow many hits to return, an integer from 1 to 20. The default is 8. With kind: external it applies to each source instead, and at most 5 hits per source are returned.

Leaving kind out searches solutions only, so existing calls return what they always did. all is not a fifth kind. It searches the four kinds in one ranking, and each hit names its kind. external is not a kind either, and it never combines with another kind or with all: it searches only the external sources you allowed, and every source is ranked on its own. A call with any other kind never returns an external record. An unknown kind and an out-of-range limit are rejected with a fixed error on every surface. On the command line they exit with status 2. A reply is at most 16 KB, and a cut reply ends with a marker line that says so. With all, that one cap covers the whole reply. A reply for external has its own cap of 12 KB and its own marker.

Terminal window
systematic search flaky retry --limit 5
systematic search --kind plan "search lifecycle"
systematic search --kind all "did we reject semantic search"
systematic search --dir ../other-repo "connection pool exhausted"
systematic search -- "--watch crashes" # a query that starts with a dash

The first line of a reply says what was searched. A single-kind search ends with the directory, as in Corpus: docs/plans.. A search of all ends with one token per kind: its document count, none when the directory does not exist, or empty when it exists but holds no indexable document. For example:

Search "search lifecycle": 232 docs, 8 hits. Corpus: solution 128, plan 102, brainstorm none, ideation 2.

When every searched directory is missing, the reply says corpus: none. When at least one exists and none holds a document, it says corpus: empty.

The learnings-researcher agent takes a scope on the first line of its prompt, scope: solutions or scope: past-work. Without that line it searches solutions only, as before.

  • solutions searches docs/solutions/ once. ce:review and ce:compound use it, and so does the overlap check that ce:compound runs.
  • past-work searches all four kinds with one search per kind (solution, plan, brainstorm and ideation, five hits each), so long plans do not crowd the other kinds out of the top results. It never searches all. It then makes one more call, kind: external with limit: 3, which returns a fixed one-line reply and adds nothing when you have set up no external source. It groups what it finds by kind and recorded status: completed work, rejected, deferred or superseded directions, proposals that were never finished, and solutions. External hits go in a separate External records group, at most four of them, and each item names its source, its path and either its recorded status or its Deciduous node_type. The researcher never infers a status for an external record. It never greps, globs or reads an external source’s folders, and it never retries a failed external call. Every item carries its path and status, and nothing proposed is described as shipped. Its reply says once whether it used search or fell back to grep.

solutions scope, the overlap check in ce:compound and ce:review never call external.

Four skills dispatch the researcher with scope: past-work: ce:plan, ce:ideate, ce:brainstorm (at Standard and Deep scope; at Lightweight scope it calls search directly with kind: all) and deepen-plan. When a result shows a rejected, deferred, superseded or completed item that overlaps the new work, the skill cites it by path and status and says how the new work differs. It never blocks the work. A cite names the path and the status and does not quote text from a file that git ignores.

Exit statuses: 0 for results, no hits and a disabled search; 1 for a failure; 2 for a usage error.

An external source is a folder you point search at. It holds either Markdown files (an RFC folder, a wiki, runbooks) or the sync folder of a Deciduous decision graph. A source is opt in. With none set up, kind: external returns one fixed line and reads nothing.

Three rules decide what gets read:

  1. A repository proposes. A project’s own config may list sources that live inside its own checkout. A proposal alone reads nothing.
  2. You allow. A project’s proposals are read only after you add that project to search.allow_project_sources in your user config.
  3. Your own roots are unrestricted. A source you write in your user config may point at any folder on your machine. You wrote it, so it needs no allow step. It still has to name the projects it applies to.

Put this in ~/.config/opencode/systematic.jsonc (or in the file inside OPENCODE_CONFIG_DIR). Absolute roots and projects paths belong to one machine, so you can put them in systematic.local.jsonc beside either file and keep the main file in your dotfiles (see Configure search). A user source needs a projects list with at least one project root. A user source without one is dropped with a warning, so a source can never apply to every project by accident.

{
"search": {
"sources": [
{
"name": "runbooks",
"format": "markdown",
"roots": ["~/wiki/runbooks", "/srv/team/runbooks"],
"projects": ["~/src/github.com/me/app"]
},
{
"name": "decisions",
"format": "deciduous",
"roots": ["~/src/github.com/me/app/.deciduous/sync"],
"projects": ["~/src/github.com/me/app"]
}
]
}
}
  • name is 1 to 40 letters, digits, - or _. It is case-sensitive, and it labels the source in every reply.
  • format is markdown or deciduous.
  • roots holds 1 to 8 folders. In user config each root is an absolute path or starts with ~. A relative root is rejected.
  • projects holds the main repository roots the source applies to, written as absolute or ~ paths. A linked worktree counts as its main checkout (see Allow a project).

A repository can ship its own list in .systematic.jsonc (or .json) at the repository root, or in .opencode/systematic.jsonc (or .json). When both exist, the root file is used and the .opencode/ file is ignored, with a warning that names both paths. The two are never merged. A project may set sources and nothing else under search.

{
"search": {
"sources": [
{ "name": "rfcs", "format": "markdown", "roots": ["docs/rfcs"] },
{ "name": "graph", "format": "deciduous", "roots": [".deciduous/sync"] }
]
}
}

A project root is a relative folder inside the checkout, and the rules are strict:

  • It may not be ., may not be absolute, and may not contain ...
  • It may not contain any dot segment, such as .slim/clonedeps. There is one exception: a deciduous source may use a folder under .deciduous/, such as .deciduous/sync. .deciduous alone, .deciduous/.cache and a markdown source under .deciduous/ are all rejected.
  • A root that is itself a symlink, that has a symlink anywhere between the checkout and the root (such as docs linking to a hidden folder, with the root docs/private), or that resolves outside the checkout, drops the source with a warning. User roots are not held to this rule.
  • A project entry cannot set projects. That field is for user config.

A bad entry drops only itself. Its valid siblings still load, and the reply carries a warning that names the source and never a value.

A project’s proposals are read only when its main repository root is in search.allow_project_sources, which lives in your user config:

{
"search": {
"allow_project_sources": ["~/src/github.com/me/app"]
}
}

Entries are absolute or ~ paths. Matching is exact, after both sides are resolved to real paths. Systematic combines the lists in all the user-level files, local files included. Neither a project config nor anything inside a repository can add to this list.

If you skip this step, kind: external says so and reads nothing:

External sources for "retry cache": this project proposes 2 external sources, but it is not in search.allow_project_sources, so nothing was read. To allow them, add "/Users/me/src/github.com/me/app" to search.allow_project_sources in your user config.

The reply never repeats a source name or root that the project wrote. It prints the main-root path only after it has verified the project’s identity. If it cannot verify it, the reply says “Its identity could not be verified, so it cannot be allowed.” and prints no path.

Identity is the main root. Systematic reads the .git entry and works out the main repository root without running git. A linked worktree matches its main checkout, so one allow entry covers .worktrees/feature-x too. A submodule or a nested repository is its own project. These never verify, and so never match: a worktree of a bare repository or of a repository with a separate git directory, a repository whose config sets core.worktree, a .git that is a symlink, and a .git file that does not agree with the repository it names.

User sources use the same rule through projects: a source applies to a checkout when one of its projects entries matches that checkout’s main root. A user source that has the same name as a project source and applies to the current project replaces the project source.

A markdown source indexes every .md file under each root, to a depth of 6.

  • The title is the frontmatter title, else the first level-1 heading (outside a code fence), else the file name.
  • The status is the frontmatter status string exactly as written, and a hit with none prints no status. Search never infers a status, and it does not follow superseded_by links in an external source.
  • Nothing is excluded by name: a README.md is searchable, and so is a folder called _archived.
  • Symlinks are skipped at every depth. A file with invalid frontmatter is skipped, and the reply counts it in one warning.
  • A hit path is <source>/<path inside a root>. The source name comes first, so the path is not relative to the root as it stands. To find the file, strip the source name; what is left is a path inside one of the source’s roots, which the section header lists. The reply does not say which root a hit came from. If two roots of one source provide the same path, the first root wins, so with several roots the file is under the first listed root that has it.

A deciduous source reads the sync folder of a Deciduous graph. The root is the folder that holds nodes/ and edges/, and each holds one <id>.json file per node or edge. Ids may use letters, digits, - and _, up to 64 characters. A file with any other name is skipped, and no file is ever opened by an id read from its contents.

  • Only nodes of type decision, option, outcome and goal become records. action and observation nodes never do. A node with a deleted_at value is skipped.
  • The title is title, else the first line of description, else the change_id. A hit prints node_type decision (or option, outcome, goal) where a Markdown hit prints its status.
  • An edge is not a record. Its rationale text becomes searchable text on the nodes it links, as long as the other end of the edge is a live node. Each edge adds up to 300 characters, each node takes up to 20 edges and 2 KB of rationale, and deleted nodes and edges add nothing.
  • A hit path is <source>/nodes/<id>.json. Strip the source name to get nodes/<id>.json, a path inside one of the source’s roots. The reply does not say which root, so with several roots it is the first listed root that has the file.
  • A missing nodes/ folder makes the source empty with a warning. A missing edges/ folder gives records without rationale, with a warning.
  • A nodes/ folder that exists but cannot be listed makes the source unreadable, the same state a Markdown root gets when it cannot be read. An edges/ folder that exists but cannot be listed adds the warning could not read an edges/ folder; records load without its rationale, and the records still load. With several roots, the source is unreadable only when no root had a nodes/ folder that could be listed.
  • The scan visits the nodes/ folder of every root, in root order, and then the edges/ folder of every root, sharing one limit of 8,000 files. A large edges/ folder in the first root cannot use up the limit before a later root’s nodes are scanned.

A store often holds files that only add noise: an activity log in a wiki, or sandbox copies of your docs that a test tool leaves behind. Add an exclude list to a source to skip them. Both user and project sources accept it:

{
"name": "wiki",
"format": "markdown",
"roots": ["~/wiki"],
"projects": ["~/src/github.com/me/app"],
"exclude": ["log.md", ".stryker-tmp", "drafts/*.md", "archive/**/old"]
}

A pattern is tested against the path of a file or folder relative to the root, written with / between segments. The rules are a small subset of .gitignore:

  • No / in the pattern. It matches a file or folder with that name at any depth, so log.md skips log.md and notes/log.md.
  • A / inside the pattern. It is anchored at the root and tested against the whole relative path. drafts/*.md skips drafts/a.md, but not drafts/sub/a.md and not notes/drafts/a.md.
  • A trailing / is ignored. drafts/ means drafts, which matches a file or folder of that name at any depth. To pin a folder to the root, write drafts/**.
  • * matches any run of characters inside one name, a leading dot included. ? matches one character. Neither crosses a /.
  • ** as a whole segment matches zero or more folders. archive/**/old matches archive/old and archive/2024/old. archive/** matches archive and everything below it. ** inside a segment, as in a**b, is invalid.
  • Matching is case-sensitive.

A matched folder is never entered, so everything below it is skipped too. A skipped file is never opened and counts toward no file or byte limit. The names in a folder still count against the 32,000 directory entries a source may examine, so an exclude cannot hide a huge folder from that limit.

The pattern is tested against the same path on every surface. For a deciduous source that path is nodes/<id>.json or edges/<id>.json, so nodes/dec-* skips matching nodes, edges skips every edge, and nodes skips every node and leaves the source empty. A Deciduous folder that your patterns remove whole raises no warning.

A source takes at most 64 patterns of at most 256 characters. These are not supported, and a pattern that uses one is dropped:

  • An empty pattern.
  • Negation with !.
  • Character classes with [ and ].
  • A leading / or ./.
  • An empty segment (a//b) or a . or .. segment.
  • A backslash.

An invalid pattern drops alone. The source and its other patterns still load. The warning names the position (exclude[2]) and the broken rule, and never repeats the pattern. Patterns past the 64th are dropped with one warning that names exclude[64] and counts them. An exclude that is not an array drops the whole source.

In user config, a source in systematic.local.jsonc with the same name as a source in systematic.jsonc replaces that source whole, including its exclude list. The lists do not combine.

LimitValue
Active sources in one call8. User sources come before project sources, and the extra ones are dropped with a warning that names them.
Roots per source8
Size of one external file1 MB. A larger file is skipped with a warning, including a file that grew past the limit after search first saw it. Search reads at most one byte more than the limit. (The four built-in kinds keep their 256 KB limit.)
Bytes read in one call, all sources together32 MB. Before any file is read, the file sizes of a source are added up, and a source that does not fit what is left is dropped whole. Later, smaller sources are still tried. Each read is limited to what the budget and the 16 MB source limit leave, so a file that grew after it was sized is rejected, not indexed, and nothing after it is read. The budget is charged the bytes actually read.
Files per source2,000 Markdown files, or 2,000 Deciduous records out of up to 8,000 files scanned
Directory entries examined per source32,000 in total, across every folder and every root of one external source. The source stops at the limit with one warning that names the source. Built-in kinds list a folder whole.
Text per source16 MB
Hits per sourceAt most 5, even when limit asks for more. limit applies to each source, not to the whole reply.
Reply size12 KB, shared evenly by the sources that have hits
Project config file1 MB. A larger file is not loaded as project config. The plugins fail at startup and name the file. systematic search and systematic-search report it and carry on.
Patterns in one source’s exclude64, each at most 256 characters

A source that goes past its share of the 12 KB drops whole hits from the end and prints +N more hits not shown. If the reply is cut anyway, it ends with [Systematic: search output truncated at 12 KB].

Every external reply starts with External sources for "<query>":. It never contains the header tokens of a built-in search (Corpus:, corpus: none, corpus: empty or Search "). Every expected state below is a normal reply with exit status 0, not an error. An empty query, an unknown kind and an out-of-range limit are still rejected as usual, with status 2 on the command line.

With hits, each active source gets its own section. Sections are sorted by source name, and each header shows the source’s origin (project or user) and its roots, with your home directory shortened to ~:

External sources for "retry cache": 3 sources, 3 hits.
graph (project): ~/src/github.com/me/app/.deciduous/sync
1. graph/nodes/dec-cache.json
Title: Keep the retry cache | node_type decision
Snippet: Keep the retry cache
rfcs (project): ~/src/github.com/me/app/docs/rfcs
1. rfcs/cache.md
Title: Use a write-through cache | Status: accepted
Snippet: We chose a write-through cache because reads dominate and retries must stay idempotent.
runbooks (user): ~/wiki/runbooks
1. runbooks/runbook.md
Title: Retry runbook
Snippet: # Retry runbook When retries overlap, the cache can serve a stale entry. Flush the cache first.

Scores are not comparable across sources, so each source is ranked on its own and no source outranks another. The fixed lines are:

SituationReply after the prefix
No .git at or above the start directoryno project: there is no .git directory at or above the start directory, so there are no external sources to search. This is checked before the query and limit.
The project proposes sources and is not allowed, and nothing else is activeThe not-allowed reply above.
The project proposes sources and is not allowed, but a source of yours is activeThe normal sections for your sources, plus a line such as Project sources: 1 proposed source not read: this project is not in search.allow_project_sources. followed by the same advice.
No source is activeno external sources are active for this project.
A source has no matchThe section stays, with No matching records.
A source’s folder does not existmissing: root not found. A missing folder is quiet, and the other sources still load.
A source has no readable recordempty: no readable records.
A source could not be readunreadable: root could not be read. This includes a Deciduous source whose nodes/ folder exists but cannot be listed.
A source was not readdropped: see the warnings. The warning names the source and says why (a bad root, or no room in the 32 MB budget).
The project’s own config file could not be readProject sources: the project config could not be read, so it proposes none.

A gitignored folder does not exist in a fresh worktree, so a source that points at one shows as missing there. The usual Notes: and Warnings: lines apply. Warnings that explain a dropped source come first.

External search reads fresh on every call:

  • The start directory and the project it belongs to, including its identity.
  • All the user-level config files, systematic.local included, so edits to allow_project_sources and to your user sources take effect on the next call.
  • The project config file, from the repository root of the start directory.
  • The record files themselves. Nothing is cached.

A few things are fixed when the harness starts: search.enabled, whether the systematic_search tool exists and how it is described, and the config unreadable note recorded at startup, which built-in searches keep printing. In a host plugin, an external call decides that note from its own read of the user config and ignores the startup note, so a repair clears the note on the next external call without a restart. The systematic search and systematic-search commands read everything on every run.

A project config never fails plugin setup. This holds on OpenCode v2, where it is tested. If it holds an invalid search.sources, Systematic drops the bad entries, warns, and loads everything else. If the file cannot be parsed, is larger than 1 MB, or fails validation anywhere else, Systematic skips the whole file, warns once with the file name and the reason, and loads with the other layers. A user-level file that cannot be parsed, a local file included, still fails setup, and the error names the file. External search cannot rescue a plugin that never started: if setup failed, there is no tool to call.

The per-call reader and systematic-search still honor the valid, allowed search.sources of a project file whose other fields made the plugin skip it. They validate the search block on its own, so an agent search and a plugin load can see different parts of the same file.

These are accepted trade-offs. Read them before you allow a project.

  • Every worktree of an allowed project contributes. A worktree reads its own copy of the project file (.systematic.jsonc or .opencode/systematic.jsonc) and its own Markdown. That includes a worktree you made to review someone else’s pull request. Allowing a project covers whatever any of its branches proposes. Roots still pass the rules above, so a branch cannot reach outside its checkout, but it can point at any folder inside it.
  • A stale allow entry trusts whatever repository is cloned to that path later. The entry is an exact path. Remove entries for paths you no longer use.
  • Plugin hosts read the standard config directories. On OpenCode v1, OpenCode v2 and Pi, external search reads ~/.config/opencode and OPENCODE_CONFIG_DIR. The OpenCode v2 plugin has no config-directory override for this read.
  • No redaction. An external source is text you chose to expose. Text in a hit can contain instructions, and Systematic does not strip anything from it. Point it only at stores you trust.

External sources read local files and send nothing anywhere. Search opens no network connection, runs no command and writes nothing. The text of a hit goes where any tool result goes: to the agent that called search, and so to your model provider. Systematic does not redact secrets from an excerpt, and an agent can copy that text into a plan. Keep secrets out of any folder you add as a source. See also the privacy page.

Search reads its settings from your user-level config. Systematic reads them from these files, in this order, and merges them:

  1. ~/.config/opencode/systematic.jsonc, or systematic.json if there is no .jsonc.
  2. systematic.local.jsonc (or .json) in the same directory. This is the local overlay: a file for the settings that belong to this machine, so the main file can live in your dotfiles. It needs no main file beside it.
  3. The same file name as 1 inside the directory named by OPENCODE_CONFIG_DIR, if that variable is set and not blank.
  4. systematic.local.jsonc (or .json) in that directory.

A later file overrides an earlier one. enabled takes the last file that sets it, allow_project_sources combines the lists in all the files, and sources merge by name with the later file replacing the earlier entry whole. A local file may set every field the file beside it may set.

If OPENCODE_CONFIG_DIR is the same directory as ~/.config/opencode (spelled the same way or through a symlink), each file is read once and applies at both positions. No warning is shown.

Each file gets its own warning that names it, so a broken systematic.local.jsonc is named and its sibling is not. The valid files still apply, and search results carry the config unreadable note.

A project config may set search.sources and nothing else. search.enabled and search.allow_project_sources belong to you. If the project file (.systematic.jsonc or .json at the repository root, or .opencode/systematic.jsonc or .json) sets either one, Systematic ignores that field and prints a warning that names it. This is on purpose. Otherwise a repository you clone could switch search off, or allow its own sources. There is no project-level .local file.

The systematic-search command in Claude Code reads the user-level files. From project config it reads one thing, and only for --kind external: the sources the project proposes. It does not read project config for anything else, so it cannot print the warning about ignored user-only fields that systematic search prints. If it cannot read the project file, including a project file over 1 MB, it prints one warning on stderr and treats the project as proposing no sources.

The command also validates only the search block of each file. An invalid field elsewhere in a config file is invisible to it, and it keeps using that file’s valid search block. systematic search and the plugins check the whole file. A file that fails there is named in an error (the plugins fail at startup, for a user-level file) or in a warning (a skipped project file; systematic search prints it on stderr), and systematic search still uses the valid search blocks.

Pi loads config once, from the working directory of the Pi process when it starts. That load fixes search.enabled, the registration of the tool and the config unreadable note, so restart Pi after you change them. Each systematic_search call then starts from the directory of that call instead, which in a delegate is the delegate session’s own directory. It reads the allow list, your sources and the project file again from that directory’s repository root. The two agree when Pi starts and searches in the same directory.

systematic config show prints a Search section for the current project, and systematic config show --json carries the same data under search. It runs no search and reads no document. It shows:

  • Whether search is enabled, the allow list and whether this project is on it.
  • Each external source with where it was declared (user, user.local, custom, custom.local or project), its name, format, roots, exclude patterns and warnings.
  • A status for each source: active; not allowed (a project proposal without an allow entry); over source cap (more than 8 are active); dropped invalid (an entry that failed its checks, with the reason); root rejected (a project root that is a symlink, passes through one or resolves outside the checkout, so a search drops the source; the reason says which); or missing root (a root that does not exist).
  • The four built-in directories, each present, empty, missing or unreadable.

A source dropped for being invalid reports the origin user whichever user-level file declared it. A user source mapped only to other projects is left out. An allowed project proposal replaced by a same-named user source is not listed either; a proposal that is not allowed is always listed. The section lists the name and roots of a proposal that is not allowed. The tool and the command replies still never do.

config show never prints the text of a local file. A source defined in one appears with its resolved name and roots.

An unknown field makes the search block invalid.

FieldTypeDefaultMeaning
search.enabledbooleantruefalse removes search. See Turn search off. User config only.
search.allow_project_sourcesarray of pathsnoneMain repository roots whose project config may propose sources. See Allow a project. User config only.
search.sourcesarray of sourcesnoneThe external sources. See External sources. A project may set this field.
search.sources[].namestringnoneRequired. 1 to 40 letters, digits, - or _.
search.sources[].formatmarkdown or deciduousnoneRequired.
search.sources[].rootsarray of pathsnoneRequired, 1 to 8 folders. Relative in a project, absolute or ~ in user config.
search.sources[].projectsarray of pathsnoneRequired in user config, where it needs at least one entry. Not allowed in a project.
search.sources[].excludearray of patternsnoneFiles and folders to skip under each root. See Exclude files and folders. A project may set this field.

A field or an entry that fails its check is dropped alone. enabled: false survives a typo in sources, and one bad source leaves its valid siblings in place. Systematic prints one warning that names the field or the source and never its value, and loads the rest of the file. If a dropped setting changes what search does (a bad enabled, a bad allow entry, a sources that is not an array, an unknown field), results carry the note config unreadable. A bad allow entry allows nothing. A source entry that is dropped on its own sets no note. The systematic-search command does the same. It also treats a user-level file it cannot parse as unreadable. Search never stops the plugin from loading.

Set search.enabled to false:

{ "search": { "enabled": false } }

This removes systematic_search from OpenCode v1, OpenCode v2 and Pi, and from Pi delegates, with no note. systematic search and systematic-search print that search is disabled and exit with status 0. The learnings-researcher then falls back to grep. In past-work scope it greps each of the four directories that exists. It never greps external source folders, so with search off there are no external records.

OpenCode and Pi read config once when they start. Restart the harness after you change search.enabled, in any of the user-level files. Changes to allow_project_sources, to your user sources and to a project’s sources need no restart for kind: external, because that call reads them again every time (see What is read when). The systematic search command and the systematic-search command read config on every run, so they need no restart.

Notes appear on the Notes: line of a result, separated by semicolons. They use fixed wording.

NoteWhat it means
config unreadableA search block or a user-level config file could not be read. The warning on stderr, or in the host log, names the field.
query cut to its first 64 distinct termsThe query had more than 64 distinct terms. The extra terms were ignored.

Both notes apply to external replies too. config unreadable there also covers a bad allow entry and an unreadable user-level file, and in a host plugin an external reply takes it from that call’s own read of the config, so it disappears once the config is repaired.

A result can also carry up to five warnings from the corpus loader, followed by +N more. Warnings about a directory that cannot be read, or about a limit that was reached, come first and name the directory. After them comes one line per kind that counts its skipped files by reason, such as docs/plans: skipped 2 files (1 invalid frontmatter, 1 larger than 262144 bytes).

The @fro.bot/systematic package declares engines.node as >=20.11. This is the first Node release with import.meta.dirname, which the CLI uses.

The systematic-search command in the Claude Code plugin is a single bundled file with no extension. Node must detect that it is an ES module. Node turns that detection on by default from 20.19 and 22.7. On an older Node the command may fail to start. Update Node, or run systematic search instead.

  • It does not use embeddings or a semantic index. It matches terms, so a learning that describes the same problem in other words can be missed.
  • It does not send anything off your machine. The text of a hit reaches your model provider when an agent reads it, as any tool result does.
  • It searches the four kinds above and the external sources you opted in to, and nothing else. Todos, review artifacts and instruction files such as AGENTS.md are not indexed.
  • It does not read an external source through a command, a network service or an MCP server, and it never writes to one.
  • It does not weigh one kind against another. In a search of all, a long plan can outrank a short solution on common words. Search a single kind when you want one.