COMPASS docs

Import a rule set

What the importer actually understands today

POST /import/claude-md is a deterministic, model-free scanner — not a full Markdown parser, and not aware of any particular file's structure. It tries two paths, in this order:

  1. The numbered-rule format first: a ## 0. ... section containing ### R<n> — Titel (P<n>) headings. If this path finds at least one rule, only these matches are used — regel_text is then the first imperative sentence of the first prose paragraph under that heading (metadata paragraphs like **SoT:** ... or **Erzwingungsgrad:** ... are skipped). An ### R<n> header whose title has no (P<n>) suffix (e.g. a retired-but-reserved number like R12) still produces a proposal, but is also flagged in unklar.
  2. Otherwise — the common case — the freeform fallback: only when path 1 finds nothing (no ## 0. section, or no ### R<n> heading inside it) does the same call look for exactly two free shapes:
  3. A heading (# through ####) followed by a prose paragraph: any sentence in that paragraph that starts with an imperative marker becomes a candidate.
  4. A bullet point or numbered list item whose text starts with an imperative marker.

The markers are fixed and case-insensitive — English: Always, Never, Do not, Don't, Use, Prefer, Must, Avoid; German: Immer, Nie, Niemals, Kein, Keine, Nutze, Verwende, Vermeide, Muss. Code blocks (```) and table rows (lines starting with |) are never a source.

Each proposal carries the original wording, not a rewrite: regel_text is exactly the matched sentence or bullet text (or, on the numbered-rule path, the first imperative sentence), with only Markdown formatting (**bold**, `code`, [links](url)) stripped. titel is the heading's own title on the numbered-rule path, and on the freeform path the same text as regel_text, shortened to its first 8 words for a readable Sache title; sache is a kebab-slug derived from that title, de-duplicated (-2, -3, …) if two proposals would otherwise collide.

Honest limits

  • The special-case handling for the numbered-rule format only applies inside a ## 0. ... section. ### R<n> — Titel headings outside such a section (or without the (P<n>) convention at all) are not picked up by that path — either the ## 0. wrapper is needed, or the freeform fallback kicks in, which knows no special case for the rule number: it ends up in the title whenever the prose sentence itself happens to start with an imperative marker. Neither the heading text nor a rule number is ever used as a proposal by itself — a heading with no marker-led sentence underneath it (freeform path) or no prose paragraph at all (numbered-rule path) produces nothing. On the freeform path, many Kernsatz paragraphs don't happen to open with a recognized marker word (they describe the rule instead of commanding it), and those do not become proposals. As of 2026-09-25 they are no longer silently skipped either: unklar names them by count (see below). Treat the proposal list as a partial catch on a file like that — how many places fell outside it is now in the response. (A separate, offline tool carries the same two-path logic — but it isn't reachable through the API.)
  • Marker matching on the freeform path is purely lexical, at the very start of the sentence or bullet — it doesn't know what the rest of the sentence says. "Use of tabs is discouraged in this repository." starts with Use, so it becomes a proposal even though it isn't phrased as an instruction to follow. Read every regel_text before applying it.
  • Prose without a marker at the start never becomes a rule, no matter how clearly it states a policy. Either rephrase the source with a leading marker, or write the Aussage by hand afterward (see Set and supersede a rule).
  • A bullet whose marker isn't at the start (e.g. "See the docs — you must always check them first") is not silently dropped: it's listed in unklar as a near-miss, so review that list before assuming the proposals are the whole story.

What unklar tells you — two kinds of entry

As of 2026-09-25 the list holds nothing back. It contains, in this order:

  1. Individual near-misses, one per line with its line number — a place that was almost a rule (the marker just wasn't at the start).
  2. One counted line per reason, with a total and the first example plus its line number, covering everything that was not read as a rule at all: quote blocks, tables, bullets with no marker anywhere, paragraphs with no heading above them, and sentences under a heading that don't open with a marker.

An example, measured against COMPASS' own CLAUDE.md (189 lines):

7 proposals
4 unklar:
  9 lines were not read as a rule: in quote blocks (> ...) …
  8 lines were not read as a rule: in tables …
  8 places were not read as a rule: bullets without an imperative marker …
  60 places were not read as a rule: sentences under a heading that don't
     start with an imperative marker …

Before, the same 7 proposals came with 0 unklar. Counted rather than listed one by one, because nobody reads 85 lines — if you need a specific place, the first line number per reason is there.

art: the same scan, a different write

art (form field, default claude-md) is claude-md | agents-md | cursor-rules. All three run through the identical scan above — only what apply later writes differs (see below). Use agents-md/cursor-rules when importing those files instead of a CLAUDE.md.

Step 1 — preview

curl -sS -X POST "$COMPASS_URL/import/claude-md" \
  -H "X-Compass-Key: $COMPASS_KEY_RO" \
  -F 'text=## Security

- Always encrypt data at rest.
- Never log a password in plain text.'

Nothing is written. The response:

{
  "neu": [
    {"sache": "always-encrypt-data-at-rest", "titel": "Always encrypt data at rest",
     "frage": "Was gilt für 'Always encrypt data at rest'?",
     "regel_text": "Always encrypt data at rest.", "quelle_zeile": 3},
    {"sache": "never-log-a-password-in-plain-text", "titel": "Never log a password in plain text",
     "frage": "Was gilt für 'Never log a password in plain text'?",
     "regel_text": "Never log a password in plain text.", "quelle_zeile": 4}
  ],
  "unveraendert": [],
  "weicht_ab": [],
  "unklar": [],
  "art": "claude-md"
}

Four groups, and they are the point of this step. The preview reads what you already have and says, for each proposal, how it relates to what already holds:

Group Meaning What apply does with it
neu no statement of this kind holds for that Sache yet create it
unveraendert the same wording already holds nothing — it is not written twice
weicht_ab a different wording holds the holding statement is superseded (ersetzt)
unklar the scanner was not confident nothing, shown only

Entries in unveraendert and weicht_ab also carry geltende with id, text, gilt_seit and quelle — you see before applying what exactly would be superseded.

A second import of the same file is therefore no longer the same worklist as the first.

Read unklar before applying anything — it names what the scanner saw but wasn't confident enough to turn into a proposal.

When the file is not a rule file at all

The file path (-F "file=@…") checks two things before parsing, and reads nothing if either fails:

Case Response
larger than 1 MB 422 with feld: "file", byte (the measured size) and grenze
not a text file 422 with feld: "file" and byte — the byte offset where it breaks (not a character index)

"Not a text file" means: either the byte at that offset is not valid UTF-8, or there is a NUL byte there (a UTF-16 file is valid UTF-8 in stretches and still not a text file).

Nothing is guessed: no second attempt with a different encoding, no truncation to the limit. A wrongly guessed encoding would write a word like "naïve" as "na?ve" into your rulebook, and a silently mangled rule costs more than a rejected file.

One limit, so it surprises nobody: sache is derived from the title. For bullet points the bullet is the title — rewording a bullet rule produces a new slug, so the proposal shows up as neu, not weicht_ab, and the old rule keeps holding. Only where the title is separate from the rule text (headings, ### R<n> — Title) does the comparison recognise a rewording.

Step 2 — review, then apply

POST /import/claude-md/apply writes both the Sache and its statement — a regel for art=claude-md, a fakt for art=agents-md/art=cursor-rules (see below). Pass the proposals straight through as items:

curl -sS -X POST "$COMPASS_URL/import/claude-md/apply" \
  -H "X-Compass-Key: $COMPASS_KEY_RW" -H "Content-Type: application/json" \
  -d '{"items": [
    {"sache": "always-encrypt-data-at-rest", "titel": "Always encrypt data at rest",
     "frage": "Was gilt für '"'"'Always encrypt data at rest'"'"'?",
     "regel_text": "Always encrypt data at rest.", "quelle_zeile": 3}
  ]}'

Or select by slug instead, sending the same original text so the preview can be regenerated and filtered server-side — useful when you don't want to copy the full proposal objects around:

curl -sS -X POST "$COMPASS_URL/import/claude-md/apply" \
  -H "X-Compass-Key: $COMPASS_KEY_RW" -H "Content-Type: application/json" \
  -d '{"auswahl": ["always-encrypt-data-at-rest"], "text": "## Security\n\n- Always encrypt data at rest.\n- Never log a password in plain text.\n"}'

items or (auswahl + text) is required — 422 without either, 422 if auswahl names a slug the regenerated preview doesn't contain.

The response:

{
  "geschrieben": [{"sache": "always-encrypt-data-at-rest", "aussage_id": 101}],
  "unveraendert": [],
  "abgeloest": [],
  "konflikt": [],
  "quelle_ref_widerspruch": []
}
  • The Sache is created (or reused unchanged if it already exists, idempotent on slug, same as POST /sachen).
  • The statement's quelle is "<Dateiname>:<Zeile>" — the filename comes from the request's quelle field, defaulting per art to CLAUDE.md / AGENTS.md / cursor-rules.mdc; quelle_ref is "import:<art>:<Dateiname>:<Zeile>".
  • If the same wording already holds, nothing is written — the slug moves into unveraendert. That covers both ways COMPASS notices: applying the same proposal again (idempotent on quelle_ref) and the same rule text arriving under a different origin.
  • If the wording differs, the holding statement is superseded (only for art=claude-md, i.e. a regel): the new statement points at the old one via ersetzt, and the entry appears in abgeloest: [{sache, aussage_id, loest_ab}]. Nothing is deleted — the old statement keeps its span of validity and stays visible in the history. The preview said this would happen, as weicht_ab.
  • The earlier behaviour (write nothing, report konflikt) held until 2026-09-24. konflikt remains for the race: if someone else writes a holding rule for the same Sache between preview and apply, it is reported rather than guessed. The same quelle_ref with a different regel_text than before writes nothing either; it's reported in quelle_ref_widerspruch: [{sache, quelle_ref, bestehend}] instead — never a silent overwrite.
  • If the Sache already has a holding rule under a different quelle_ref (e.g. another line of the same file, or an earlier manual POST /aussagen), it is not replaced: the entry goes to konflikt: [{sache, grund, geltende}], nothing written. This only applies to art=claude-md — only regel statements are subject to the one-current-rule invariant.

Row-level security applies as everywhere: every row belongs to the tenant of the presented key; a different tenant sees nothing, even with an identical sache slug (the primary key is (kunde_slug, slug)).

Importing AGENTS.md or Cursor rules

Same two calls, art=agents-md or art=cursor-rules:

curl -sS -X POST "$COMPASS_URL/import/claude-md" \
  -H "X-Compass-Key: $COMPASS_KEY_RO" \
  -F "file=@AGENTS.md" -F "art=agents-md"

curl -sS -X POST "$COMPASS_URL/import/claude-md/apply" \
  -H "X-Compass-Key: $COMPASS_KEY_RW" -H "Content-Type: application/json" \
  -d '{"art": "agents-md", "items": [ ...from the preview... ]}'

The statement written here is a fakt (herkunft=gesagt), not a regel — AGENTS.md and .cursor/rules/*.mdc both declare themselves subordinate to CLAUDE.md ("Warum fakt, nicht regel"), and a second regel on the same Sache would collide with the one-current-rule invariant and be rejected. This means an art=agents-md import next to an existing art=claude-md rule on the same Sache never conflicts — they're different Aussage kinds. For .cursor/rules/*.mdc, run both calls once per file.

Importing decisions instead of headings

If what you have is a log of dated decisions rather than a rule document, POST /import/decisions is the better fit: it takes {items: [{z, quote, context, decided_at, sache?, ersetzt_z?}]}, is idempotent on the z number, and reports any conflict in a widerspruch list rather than silently skipping or overwriting it. A decision whose sache is missing or unknown is still recorded, as an event rather than a statement, so nothing from the log is lost. This is a bruecke route — a bruecke-scoped key works here even though it can't read or write most of the rest of the API.

Assistant preview (status: prepared)

For text the deterministic scanner above does NOT catch (prose without an imperative marker, sentences that describe a rule instead of commanding it), there's a second, optional path: POST /assistent/vorschlaege {text, art?: claude-md|agents-md|cursor-rules|prosa}. It runs the same scanner as /import/claude-md first — anything the scanner already recognizes gets no model call — and only sends the remainder to a language model, if one is configured.

This path is prepared today, not enabled by default. The provider choice is deliberately open: Anthropic (a current Haiku model) OR a local, OpenAI-compatible model (e.g. Ollama) — the default is off. GET /assistent/status → {anbieter, bereit, grund, verbrauch_heute, deckel} shows whether any provider is active; without configuration bereit=false with a plain-text reason (e.g. "COMPASS_MODELL_ANBIETER ist 'aus' (Standard)"), and POST /assistent/vorschlaege then answers 503 — unless the scanner already covered everything, in which case the response stays 200 even with no provider configured.

Every model-produced suggestion additionally carries woertlich (only true when regel_text is actually a substring of the original text — checked server-side, never taken from the model unverified), an optional bereich, and an optional pruefweg: {modul, parameter} (only with one of the three modules that actually exist: github_ruleset/ci_status/konfig_wert). This path writes nothing either — adoption still goes through the same POST /import/claude-md/apply as above; a suggestion here is deliberately shaped to work as an items entry as-is (drop the extra fields first). A daily cap per customer (COMPASS_MODELL_DECKEL_AUFRUFE_TAG/COMPASS_MODELL_DECKEL_TOKEN_TAG, default 200 calls/400000 tokens) keeps an enabled provider from running over budget unnoticed — exceeded → 429. The REST reference, section "Assistent", shows the exact suggestion shape.

What this guide doesn't cover

There is no MCP tool for any import route — all four (/import/claude-md, /import/claude-md/apply, /import/decisions, /assistent/vorschlaege) are REST-only today.