The config file — signposts.yaml

Everything Signposts needs lives in one file at your repo root. A handful of sections, each with a single consumer, so nothing is wired in two places.

Section Holds Read by
project Stack identity — a name and description. the CLI
config Engine runtime settings — currently drift_tokens (the sign re-injection cadence). the sign injector
signs Your sign notes, grouped by namespace. the sign injector
rules Your rule blocks, grouped by namespace. the engine
settings Optional. Host-permission entries a pack carries, per namespace — merged into .claude/settings.json on install. the CLI (install)
packs The installed packs + what each owns, so refresh / uninstall --pack can track them. the CLI
install What to copy, which deps to add, how to arm it. the CLI

Grouped by namespace

Both signs: and rules: are grouped by namespace — the pack each belongs to. core is the built-in pack; local is your own ad-hoc stuff; a name like neon is a coherent set you might share. A namespace is what install and propagate move as one unit — its signs and rules together.

A worked example

project:
  name: my-core
  description: My personal dev guardrails. Reused on every repo; irrelevant rules sleep.

config:
  drift_tokens: 200000          # re-inject a sign after ~this many tokens of drift

signs:
  local:
    - id: db-area
      globs: ["src/db/**"]
      text: |-
        Queries go through the repository layer, never inline in a route.

rules:
  local:
    - id: no-edit-generated
      use: core/protected-path
      deny: ["**/*.generated.ts", "vendor/**"]
      message: "Generated / vendored — change the source, then regenerate."
  imports:
    - id: boundaries           # a tool-gate is heavy → commit-only, so set when:
      use: core/tool-gate
      run: "npx depcruise src --config .dependency-cruiser.cjs"
      carries: [".dependency-cruiser.cjs"]
      when: [commit]

packs:
  - "@signposts/core"          # the baked-in pack
install:
  activate: ["npm install"]    # arms the gate (lefthook writes .git/hooks/*)

How a rule reads

Each entry names a script with use: and carries that script’s config inline. The whole entry is handed to the script verbatim.

  • use: core/<name> — a core script; or <namespace>/<name> for your own.
  • on: — the glob(s) it applies to (some scripts use deny: or run: instead).
  • when: — defaults to [edit, commit]; omit it unless you need to override (a tool-gate that’s [commit] only).
  • message: — the reason, fed back to the agent on a block.

Code patterns are files, not inline. ast-grep rules stay as rules/ast-grep/*.yml (so the engine and signposts test use the same files); the core/ast-grep runner executes them in-process. See core/ast-grep. Everything else is a normal rules: entry.

Permissions a pack carries — settings: and the packs: ledger

A pack can carry host permissions the engine can’t enforce (denying a read, an MCP tool call) via an optional settings: block — install merges them into .claude/settings.json. And packs: records what each install owns, so refresh and uninstall --pack know exactly what to touch. A plain string entry is still valid; install writes the richer object form:

settings:
  neon:
    permissions:
      deny: ["Read(./.env.keys)"]      # enforced by the host, delivered by the pack

packs:
  - "@signposts/core"                  # legacy string — fine
  - source: "github:you/hub#main"      # object form, written by install
    namespaces: [neon]
    settings: { deny: ["Read(./.env.keys)"] }   # the ledger uninstall reverses
    installed: "2026-07-06"

See Packs & distribution for the install / refresh / uninstall flow.