{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skill-map.ai/spec/v1/project-config.schema.json",
  "title": "ProjectConfig",
  "description": "Shape of `.skill-map/settings.json` (and its `.skill-map/settings.local.json` partner) inside a scope. Loaded by the layered config hierarchy (library defaults → user → user-local → project → project-local → env/flags) and deep-merged per key. All fields optional; defaults apply when absent. camelCase keys throughout, consistent with the rest of the spec.",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "schemaVersion": {
      "type": "integer",
      "const": 1,
      "description": "Config file shape version. Bumped on breaking changes to this schema."
    },
    "tokenizer": {
      "type": "string",
      "enum": ["cl100k_base", "o200k_base"],
      "description": "Closed allow-list of the offline tokenizer used to compute per-node token counts during scan. Exactly two encodings are supported: `cl100k_base` (the default, the modern OpenAI tokenizer used by GPT-4 / GPT-3.5) and `o200k_base` (the GPT-4o family tokenizer). When absent the default `cl100k_base` applies. An out-of-set value is dropped with a warning by the config loader's AJV `enum` check and the merged value falls back to the default. The resolved encoder is persisted in `scan_meta.tokenizer` so consumers know which encoder produced the numbers. Changing this invalidates prior per-node counts on the next scan (the incremental cache path force-recomputes token counts when the persisted encoder differs from the resolved one)."
    },
    "activeProvider": {
      "type": "string",
      "description": "The active provider lens for this project. Exactly one provider id (from the registered providers) sees the project at any time. All extractors, classifiers, and resolution rules belonging to other providers are skipped during scan. Changing this triggers an atomic drop of the `scan_*` DB zone followed by a fresh scan under the new lens; `state_*` and `config_*` zones survive the switch. When absent on a fresh project, the kernel auto-detects from filesystem markers (presence of `.claude/`, `.codex/`, `.agents/`, `.cursor/`, etc.) and prompts via the CLI / UI when several markers match; when no vendor marker is present the lens resolves to the open-standard `agent-skills` view (the universal default lens) without persisting it, so a vendor marker added later still auto-detects. The field stays optional on disk (a fresh project may omit it); the runtime always resolves it to a concrete lens, never null. Google's Antigravity CLI has no vendor-specific marker and is selected manually. Stability: experimental."
    },
    "activeProviderMarkers": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Internal-state snapshot, NOT normally hand-edited. The set of provider ids whose filesystem markers were present on disk at the moment `activeProvider` was set (whether by auto-detect, the interactive prompt, or `sm config set activeProvider <id>`). On every subsequent scan the runtime re-detects markers and compares against this snapshot; when the diff is non-empty (new markers appeared, or recorded ones disappeared) it emits ONE soft warning before the scan and continues with the cached lens. The warn is informational and never blocks the scan. Absent on legacy projects, the runtime backfills the snapshot lazily on the next scan without warning. Stability: experimental."
    },
    "roots": {
      "type": "array",
      "description": "Directories (relative to the config file) to scan. Defaults to the scope root.",
      "items": { "type": "string" }
    },
    "ignore": {
      "type": "array",
      "description": "Glob patterns to exclude, in addition to `.skillmapignore` (and `.gitignore`, only when `scan.respectGitignore` is enabled).",
      "items": { "type": "string" }
    },
    "server": {
      "type": "object",
      "additionalProperties": false,
      "description": "Bind address for `sm serve` (and the bare `sm` invocation that routes to it), resolved through the normal config layering so a project can pin its port once instead of passing flags per invocation. Precedence at boot: the `--port` / `--host` flags (the `override` layer) win over these keys, which win over the built-in defaults (4242 / 127.0.0.1). The RESOLVED values are what `serve.json` publishes (see `serve-info.schema.json`). The loopback-only rule still applies at boot regardless of which layer supplied the host: a non-loopback host rejects with exit 2 (multi-host serve is deferred pre-1.0).",
      "properties": {
        "port": {
          "type": "integer",
          "minimum": 1,
          "maximum": 65535,
          "description": "Listening port for `sm serve`. Default 4242. Ephemeral OS-assigned binding (`--port 0`) stays flag-only; this key pins a concrete port for the project."
        },
        "host": {
          "type": "string",
          "minLength": 1,
          "description": "Listening host for `sm serve`. Default `127.0.0.1`. Must be a loopback name (`127.0.0.1`, `::1`, `localhost`); any other value is rejected at boot."
        }
      }
    },
    "mcp": {
      "type": "object",
      "additionalProperties": false,
      "description": "Model Context Protocol surface (see mcp-server.md). Holds the opt-in read-only server toggle today; extends additively as further MCP preferences land. The consumer / observer side (mapping the MCP servers a project USES) is not configured here; that is the per-extension enable of the `core/mcp-*` extractors under `plugins`.",
      "properties": {
        "server": {
          "type": "object",
          "additionalProperties": false,
          "description": "skill-map's own MCP server, exposed over Streamable HTTP at `/mcp` on the running `sm serve`. Off by default. When on it exposes the read map tools/resources plus the queue + findings-lifecycle tools over one endpoint.",
          "properties": {
            "enabled": {
              "type": "boolean",
              "description": "Whether `sm serve` mounts the MCP server at `/mcp`. Default `false` (opt-in). When on, the server exposes both the read-only map tools/resources AND the queue + findings-lifecycle tools (submit/claim/record/cancel/fail jobs, resolve/dismiss/reopen/undismiss findings), so an MCP host can query the map and drive the queue over one endpoint. Resolved through the normal config layering; the `--mcp` / `--no-mcp` flags on `sm serve` override it. Because the endpoint mounts at serve boot, flipping this while a server runs takes effect only on the next `sm serve` restart. The surface is loopback-only and unauthenticated, so enabling it grants queue + findings control to any local process (same trust boundary as the REST mutating routes). **Project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`), stripped with a warning from the committed `project` layer: exposing a local server is a per-operator decision that must not travel via the shared repo, so the Settings > Project toggle and `PATCH /api/project-preferences` write it to `settings.local.json`.",
              "$comment": "Project-local only; a per-operator server-enable, never committed."
            }
          }
        }
      }
    },
    "skillActions": {
      "type": "object",
      "additionalProperties": false,
      "description": "Skill-actions surface (see skill-actions.md). Holds the offering toggle today; extends additively as further skill-action preferences land.",
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "Whether the skill-actions catalog is OFFERED (skill-actions.md §Settings). Default `false` (opt-in: installing a skill does not surface it until the operator turns the offering on). When `false`, `GET /api/nodes/:pathB64/prob-extensions` emits an empty `skills` bucket and a `skill:` submit refuses not-found; discovery still runs at boot (membership is boot-frozen either way), only the offering is gated. Read fresh on every request, so flipping it takes effect immediately, no watcher or server restart. **Project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`), stripped with a warning from the committed `project` layer: the catalog itself is per-machine state under `.skill-map/`, so whether to offer it is a per-operator decision; the Settings > Project toggle and `PATCH /api/project-preferences` write it to `settings.local.json`. No confirm gate (it neither expands disk access nor trusts code).",
          "$comment": "Project-local only; a per-operator offering toggle, never committed."
        }
      }
    },
    "scan": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "tokenize": { "type": "boolean", "description": "Whether to compute token counts. Default true." },
        "strict": { "type": "boolean", "description": "Promote frontmatter warnings to errors. Default false." },
        "maxFileSizeBytes": {
          "type": "integer",
          "minimum": 1,
          "description": "Files larger than this are skipped before they are read and surfaced at WARN level. Default 1048576 (1 MiB). Protects against scanning accidental binary drops or generated artefacts. Every skipped file is reported in `ScanResult.oversizedFiles` (root-relative path + byte size), counted in `ScanResult.stats.filesOversized`, printed as a terminal warning on `sm scan` / `sm watch` / `sm serve`, and raised as a UI banner. Trim the offending paths via `.skillmapignore` or raise this limit to include them."
        },
        "maxScan": {
          "type": "integer",
          "minimum": 1,
          "description": "Scan corpus ceiling: hard cap on the number of files the scan accepts after `.skillmapignore` filtering, before extractors run. Default 5000. The scan walks, parses, analyzes, and reference-validates every file up to this ceiling; that full corpus is what link resolution checks against, so references resolve across the whole project (a large monorepo) regardless of how many nodes the map renders. When the walker reaches the ceiling, additional files are dropped in stable provider-walker order and the scan is marked truncated in `scan_meta` (the UI raises a persistent banner pointing at the `.skillmapignore` editor in Settings → Project). Override per invocation with `--max-scan N` on `sm scan` / `sm watch` / `sm serve`, bidirectional (raises OR lowers the ceiling, the flag is a full override of this setting). Distinct from `maxNodes`, which bounds only the graph render, not the scan."
        },
        "maxNodes": {
          "type": "integer",
          "minimum": 1,
          "description": "Map render cap: the maximum number of nodes the graph view projects onto the canvas at once. Default 256. This does NOT bound the scan; the full corpus is walked and reference-validated up to `maxScan`, and the folders tree shows all of it. `maxNodes` only limits the Foblex graph projection so a large project stays readable. When a selected folder branch exceeds this cap, the map renders the branch's first N nodes (stable order) and raises an in-view banner inviting the user to pick a narrower sub-folder. Override per invocation with `--max-nodes N` on `sm scan` / `sm watch` / `sm serve`; the value persists in `scan_meta` and is read by the UI when the map renders. Headless `sm scan` records but does not act on it (it renders nothing)."
        },
        "watch": {
          "type": "object",
          "additionalProperties": false,
          "description": "File-watcher knobs for `sm watch` and `sm scan --watch`. The watcher subscribes to the same roots `sm scan` walks, applies the `.skillmapignore` filter, and triggers an incremental scan after each batch.",
          "properties": {
            "debounceMs": {
              "type": "integer",
              "minimum": 0,
              "description": "Milliseconds to wait after the last filesystem event before triggering an incremental scan. Groups bursts (editor saves, branch switches, package installs) into a single scan pass. Default 300. Set to 0 to disable debouncing, every filesystem event triggers a scan immediately."
            },
            "backend": {
              "type": "string",
              "enum": ["chokidar", "parcel"],
              "description": "Which backend the PRIMARY scan watcher uses. `chokidar` (default) uses one `fs.watch` per directory and observes changes behind followed symlinks, so live edits inside a symlinked directory refresh the map. `parcel` uses `@parcel/watcher` (a single native inotify instance that scales to huge trees without the `EMFILE: too many open files` failure) but does NOT observe changes behind a symlinked directory: the initial walk still follows the link, but subsequent live edits under it do not fire an incremental scan. Override per invocation with `--watch-backend <chokidar|parcel>` on `sm serve` / `sm watch` / `sm scan --watch` (the flag wins over this setting). The meta-watcher (the config files `.skillmapignore` / `.gitignore` / `.skill-map/settings.json`) is always chokidar."
            }
          }
        },
        "referencePaths": {
          "type": "array",
          "items": { "type": "string" },
          "description": "**Privacy-sensitive, project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`) when entries point outside the project, opens read-only disk access for link validation only. Default `[]`. Directories walked in parallel by the scan to collect existing absolute paths into a side set; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses. Files under these paths are NOT parsed and NOT indexed as nodes, the only effect is suppressing `broken-ref` warnings for targets that exist on disk outside the scan. Reference impl gates writes that introduce out-of-project paths behind `--yes` (CLI) and a confirm dialog (UI). **Stripped with a warning when found in the committed `project` layer**, paths are inherently per-machine and must not travel via the shared repo."
        },
        "followExternalSymlinks": {
          "type": "boolean",
          "description": "Whether the walker dereferences a symbolic link whose real target lands OUTSIDE the scan roots. Default `false` (contained). While the walker always follows a symlink whose target stays inside a scan root, an escaping link (a file link like `notes.md -> ~/.ssh/id_rsa`, or a directory link like `docs/mirror -> ~/`) is by default refused and skipped, so a cloned, hostile repository cannot use a committed symlink to read arbitrary local files into `skill-map.db` or drive a filesystem-traversal denial of service. Set to `true` to restore following escaping links wherever they point (cycle detection still prevents loops); doing so is an explicit statement that you trust every symlink target reachable from your project tree, so use it only on a tree whose links you authored. This is a per-machine trust decision, so it is **project-local only** (stripped with a warning from the committed `project` layer, like `scan.referencePaths`) and, because turning it on expands the disk-read surface, **privacy-sensitive**: `sm config set scan.followExternalSymlinks true` and the Settings UI toggle both require explicit confirmation (`--yes` on the CLI, a confirm dialog in the UI); turning it off needs no confirmation. It never opens `$HOME`: it only governs whether an already-authored on-disk link is followed.",
          "$comment": "Project-local only; privacy-sensitive."
        },
        "respectGitignore": {
          "type": "boolean",
          "description": "Whether the project root `.gitignore` participates in the scan's ignore stack. Default `false`: out of the box skill-map does NOT read `.gitignore`, so a file git ignores (build output, vendored docs) is still indexed unless the bundled defaults, `config.ignore`, or `.skillmapignore` exclude it. Set to `true` to fold `.gitignore` in as a layer between the bundled defaults and `config.ignore` (full order: bundled defaults -> `.gitignore` -> `config.ignore` -> `.skillmapignore`), so the map skips whatever git already skips; a later `.skillmapignore` layer can still `!`-re-include a git-ignored path. Only the root `.gitignore` is read (not nested git-style files). This is a **committed team-shared policy** (persisted in `settings.json`, travels with the repo like `allowSidecarWriters`), NOT project-local and NOT privacy-sensitive: it never reads outside the project root, so `sm config set scan.respectGitignore true` and the Settings UI toggle need no confirmation. The one-shot scan and the live watcher honour it identically; flipping it in `settings.json` while `sm serve` runs is picked up by the meta-watcher (and a UI toggle restarts the watcher)."
        }
      }
    },
    "plugins": {
      "type": "object",
      "description": "Per-plugin overrides. Keys are plugin ids. Carries the enable/disable toggle and, under `extensions.<extId>.settings`, the operator-supplied values for the settings each extension declares in its manifest. Absent = installed defaults (enabled, declared setting defaults).",
      "additionalProperties": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "enabled": { "type": "boolean" },
          "extensions": {
            "type": "object",
            "description": "Per-extension overrides, keyed by extension id (the leaf folder name, NOT the qualified `<plugin>/<ext>` id, the plugin is already the parent key). Carries the per-extension `enabled` toggle (operational) and the operator-supplied `settings` values.",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "enabled": {
                  "type": "boolean",
                  "description": "Per-extension operational on/off, resolved over the plugin-level `enabled` and the extension's installed default (`false` for `experimental` / `deprecated`, `true` otherwise). Shareable: lands in `settings.json` (team baseline) or `settings.local.json` (per-checkout override) via the normal config layering. This is the OPERATIONAL axis only; it does NOT grant import trust for a project-local plugin (that is the separate per-plugin trust axis, the scope lock written by `sm plugins trust`)."
                },
                "settings": {
                  "type": "object",
                  "description": "Operator-supplied values for the extension's declared settings, keyed by settingId. Values are intentionally NOT validated by this schema: the kernel's settings resolver validates each value against the per-type value schema of the input-type the manifest declares (`input-types.schema.json#/$defs/ISettingDeclaration`), since this schema cannot know which type a given settingId picked. A non-`secret` setting lands here in `settings.json` (team-shared) or `settings.local.json` (per-checkout override) via the normal config layering; a `secret` setting uses the SAME key path but the kernel forces it into the project-local `settings.local.json` (gitignored), never the committed `settings.json`, and NOT encrypted: the protection is that the value never travels via the shared repo (see `input-types.schema.json#/$defs/Setting_Secret`). Exception: the `github/enrichment` base-URL overrides (`plugins.github.extensions.enrichment.settings.apiBaseUrl` / `.rawBaseUrl`) are `PROJECT_LOCAL_ONLY_KEYS` members, honoured from `settings.local.json` only and stripped from the committed layer with a warning, because the extension token rides the Authorization header to whatever host `apiBaseUrl` names (see `cli-contract.md` section Project-local-only config).",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "activity": {
      "type": "object",
      "additionalProperties": false,
      "description": "Live-activity preferences (see provider-activity.md). Holds the conversation-capture consent gate and the session-journal toggle today; extends additively as further activity preferences land.",
      "properties": {
        "shellCapture": {
          "type": "boolean",
          "description": "Install-side opt-in for the capture ladder's `shell` rung (provider-activity.md, Capture level): when true, `sm activity install claude` renders the extra `PreToolUse` Bash hook and the `shell` selector position unlocks. Default false; command lines are operator content, so this is never on by default. Project-LOCAL (settings.local.json); written by the install command's --shell / --no-shell flags and retired by `sm activity uninstall` of the provider that owns the opt-in event."
        },
        "captureLevel": {
          "type": "string",
          "enum": ["executions", "reads", "writes", "mcp", "shell"],
          "description": "Cumulative capture-level ladder (provider-activity.md, Capture level): how much runtime activity the serve ingest keeps, filtering resolved frames above the level before stats, journal and broadcast. Default `mcp` (the historical full surface). A project-LOCAL operational knob (settings.local.json), movable live via POST /api/activity/capture-level; `shell` is reserved (no capture yet)."
        },
        "captureConversations": {
          "type": "boolean",
          "description": "Privacy-sensitive, project-local only. Consent gate for retaining inter-agent spawn conversation content (the spawn prompt, the sync completion response) in the serve process's in-memory, bounded store (provider-activity.md, Conversation capture). Default false. Turning it off clears the store immediately; content never persists, never rides the WS, and dies with the process. Written by POST /api/activity/capture behind the server-enforced confirm gate; stripped with a warning if found in the committed `project` layer (consent is per-operator, not team-shared)."
        },
        "journal": {
          "type": "object",
          "additionalProperties": false,
          "description": "Session-journal preferences (provider-activity.md, Session journal): the per-session files of resolved, content-free activity frames the serve process persists under `.skill-map/sessions/`.",
          "properties": {
            "enabled": {
              "type": "boolean",
              "description": "Master switch of the session journal (`.skill-map/sessions/`). Default true. Read once at serve boot. Capture itself is a GESTURE, never ambient (decision 2026-08-16): frames land only while the operator records (`POST /api/activity/sessions/recording`, driven by the UI's Record session control); this key decides whether that recording toggle can engage at all for the boot (off = the toggle refuses and nothing is ever written). A NORMAL project-config key, committable in the shared `project` layer: the journal is content-free by construction (no prompts, no arguments, no file contents), so this is a team preference, NOT consent-shaped like `activity.captureConversations`. Existing session files are left untouched either way (delete them via the UI's delete-recording gesture, or by hand)."
            },
            "maxFiles": {
              "type": "integer",
              "minimum": 1,
              "description": "Retention ceiling for `.skill-map/sessions/`: keep at most this many recording files, pruning oldest first at boot and at each finalization. Default 50. This bounds the EVIDENCE WINDOW the observed-* volume gates count against, so keep it at or above the largest `min-active-sessions` in use or that gate becomes unreachable."
            },
            "maxTotalBytes": {
              "type": "integer",
              "minimum": 65536,
              "description": "Retention ceiling for `.skill-map/sessions/` in total bytes across all recording files, pruning oldest first. Default 20971520 (20 MiB). Applied together with `maxFiles`; whichever bound is exceeded first evicts."
            }
          }
        }
      }
    },
    "jobs": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "ttlSeconds": {
          "type": "integer",
          "minimum": 1,
          "description": "Global opt-in TTL policy (seconds): when set, EVERY submitted job arms this expiry unless a higher-precedence source overrides it (`jobs.perExtensionTtl`, or the `--ttl` flag, including the `--ttl 0` disarm). UNSET by default: jobs never expire (Decision #139; interactive processing runs may hold a claim for hours). The retired `graceMultiplier` / `minimumTtlSeconds` keys died with the estimate-driven formula."
        },
        "claimWaitSeconds": {
          "type": "integer",
          "minimum": 1,
          "description": "Default poll cadence (seconds) for a blocking `sm jobs claim --wait`: how often a resident worker re-reaps and re-claims while the queue is empty (job-lifecycle.md §Atomic claim · Blocking claim). The `--interval` flag overrides it per invocation; absent both, the CLI default is 2. Bounds pickup latency only, the claim stays atomic."
        },
        "perExtensionTtl": {
          "type": "object",
          "description": "Per-extension TTL overrides (seconds). Keys are qualified ids of queued probabilistic extensions (Action or Analyzer). Renamed from `perActionTtl` when Analyzers joined the queue.",
          "additionalProperties": { "type": "integer", "minimum": 1 }
        },
        "perExtensionPriority": {
          "type": "object",
          "description": "Per-extension priority overrides. Keys are qualified ids of queued probabilistic extensions (Action or Analyzer), values are integers (higher runs first, negatives allowed). Frozen on `state_jobs.priority` at submit time. Overrides `defaultPriority` from the extension manifest; `--priority` on `sm jobs submit` overrides both. Renamed from `perActionPriority` when Analyzers joined the queue.",
          "additionalProperties": { "type": "integer" }
        },
        "retention": {
          "type": "object",
          "additionalProperties": false,
          "description": "Garbage-collection policy for `state_jobs` rows and their job files. `sm jobs prune` reads this; no implicit pruning during normal verbs.",
          "properties": {
            "completed": {
              "type": ["integer", "null"],
              "minimum": 1,
              "description": "Seconds after `finishedAt` before a `completed` job is eligible for pruning. `null` = never auto-prune. Default 2592000 (30 days)."
            },
            "failed": {
              "type": ["integer", "null"],
              "minimum": 1,
              "description": "Seconds after `finishedAt` before a `failed` job is eligible for pruning. `null` = never auto-prune. Default `null`, failed jobs are kept for post-mortem."
            },
            "cancelled": {
              "type": ["integer", "null"],
              "minimum": 1,
              "description": "Seconds after `finishedAt` before a `cancelled` job is eligible for pruning. `null` = never auto-prune. Default 2592000 (30 days), mirroring `completed`: a cancellation is a routine terminal state with no failure to post-mortem, so it is prunable on the same schedule."
            }
          }
        }
      }
    },
    "logLevel": {
      "type": "string",
      "description": "**Project-local only** (per `PROJECT_LOCAL_ONLY_KEYS`). Standing log level for this checkout, one of `trace` / `debug` / `info` / `warn` / `error` / `silent`. LOWEST precedence above the built-in `warn` default: `-v` / `-vv` / `-vvv`, `--log-level`, and `SKILL_MAP_LOG_LEVEL` all win, so a standing preference never fights a one-off invocation. **Stripped with a warning when found in the committed `project` layer**: whoever is debugging wants the noise, their team does not, and a committed `trace` would become everybody's console. Deliberately NOT constrained by an `enum` here: an unrecognised value warns on stderr and falls through to the default, which keeps a typo from disabling logging or invalidating the surrounding file. Read directly at process boot (before the layer system runs), so it only takes effect for invocations made inside this project.",
      "default": "warn"
    },
    "allowEditSmFiles": {
      "type": "boolean",
      "description": "**Project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`). Grants this project permission to create / modify `.sm` annotation sidecars next to source files. Default `false`. The first time a verb or BFF route attempts a `.sm` write while this is `false`, the kernel raises `EConsentRequiredError`. The CLI surfaces it as an interactive `confirm()` prompt (or `--yes` bypass); the BFF returns 412 `confirm-required` so the UI can open a `ConfirmationService` dialog. On accept the flag is persisted to `<cwd>/.skill-map/settings.local.json` (gitignored, per-checkout) and never asked again. On decline the operation aborts WITHOUT persisting the rejection, the next attempt re-asks. **Stripped with a warning when found in the committed `project` layer** (`<cwd>/.skill-map/settings.json`), each developer consents independently."
    },
    "allowNetworkActions": {
      "type": "boolean",
      "description": "Privacy-sensitive, project-local only. Default `false`. When `false`, every Action whose manifest declares `io: ['network']` (the built-in `github/enrichment`, plus any external action) is refused at execution time (`sm enrich` reports it skipped with a directed advisory) and its manifest still loads (visible in `sm actions list`, never executed). Opt-in by design: a cloned repo must not be able to make skill-map fetch remote content without the operator's explicit consent, which is why the key is honoured from `settings.local.json` (behind a scope-lock grant) and stripped with a warning from the committed `project` layer.",
      "default": false
    },
    "allowSidecarWriters": {
      "type": "boolean",
      "description": "**Project policy, team-shared** (committed in `<cwd>/.skill-map/settings.json`, NOT project-local). Default `true`. When `false`, every extension whose manifest declares `writes: ['sidecar']` (the built-in `core/node-bump`, `core/node-set-tags`, `core/node-set-stability`, plus any external action) is dropped from the scan composer so its `inspector.action.button` never renders, and the sidecar store refuses the write with `ESidecarWritersForbiddenError`. This is a HARD gate that wins over the per-machine `allowEditSmFiles` consent: a developer cannot re-enable sidecar writes locally, and `--yes` does not bypass it. Reads of existing `.sm` sidecars (annotation orphan fields) are unaffected, the policy governs writes / generation only. Unlike `allowEditSmFiles` this key is meant to travel via the shared repo, so it is NOT stripped from the committed `project` layer."
    },
    "tutorialReminderStep": {
      "type": "integer",
      "minimum": 0,
      "maximum": 2,
      "description": "**Project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`). UI preference: which topbar reminder message the web UI shows to a first-time user, advanced one step at a time by its dismiss button. `0` (default/absent): the Quick Start nudge. `1`: the `sm tutorial` nudge. `2`: fully dismissed, the reminder never shows again on this checkout. Persisted to `<cwd>/.skill-map/settings.local.json` (gitignored, per-checkout); reset with `sm config reset tutorialReminderStep`. Stripped with a warning if found in the committed `project` layer (the dismissal sequence is per-developer, not a team-shared decision)."
    },
    "ui": {
      "type": "object",
      "additionalProperties": false,
      "description": "Web-UI preferences persisted per checkout. Every key is **project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`): per-developer rendering choices, stripped with a warning from the committed `project` layer. Written by the Settings > Project toggles through `PATCH /api/project-preferences`; no confirm gate (nothing here expands disk access or trusts code). The live pair previously lived in the browser's localStorage; moving them here makes the choice follow the checkout instead of the browser profile.",
      "properties": {
        "liveUpdates": {
          "type": "boolean",
          "description": "Whether the web UI keeps the map in sync with `sm serve` (scan refreshes, live events, node activity). Default `true`. When `false` the SPA opens no live channel at all and the map works through plain HTTP reads (manual refresh)."
        },
        "realtimeActivity": {
          "type": "boolean",
          "description": "Whether real-time node activity (the executing glow driven by provider activity frames, see provider-activity.md) lights up the map. Default `true`. Subordinate to `liveUpdates`: with live updates off no activity frames arrive at all; with live updates on, turning this off silences just the activity lighting."
        },
        "showRuntimeAgents": {
          "type": "boolean",
          "description": "Whether the map renders ephemeral agent capsules for runtime sub-agents that match no scanned node (provider-activity.md, unresolved spawn children: a vendor's built-in explorer/planner with no file on disk). Default `true`. When `false` those spawns stay invisible (resolved-node spawn edges and session anchors are unaffected). Subordinate to `realtimeActivity`."
        },
        "changeSpark": {
          "type": "boolean",
          "description": "Whether the map flashes a node once (a short single-color spark) when the live watcher detects its file changed on disk, i.e. a `scan.progress` with `cached: false` inside a scan whose `scan.started` reported `mode: 'changed'` (job-events.md §Scan events). Default `true`. Subordinate to `liveUpdates` only (no live channel, no scan frames); independent of `realtimeActivity`. Suppressed while the node shows real-time agent activity or shortly after it, so an agent-driven write surfaces through the activity glow, never a double flash."
        },
        "confirmIgnore": {
          "type": "boolean",
          "description": "Whether the web UI asks for confirmation before appending a pattern to the project-root `.skillmapignore` from the Ignore buttons on the files rail's rows and the inspector header. Default `true` (ask). Set to `false` by the confirm dialog's don't-ask-again checkbox; per checkout in `settings.local.json`, reset with `sm config reset ui.confirmIgnore`. Positive naming on purpose: every `ui.*` key defaults to `true`."
        },
        "confirmViewSwitch": {
          "type": "boolean",
          "description": "Whether the web UI asks for confirmation (Save / Discard / Cancel) before switching away from a map view that has unsaved changes (see map-views.md). Default `true` (ask). Set to `false` by the dialog's don't-ask-again checkbox; per checkout in `settings.local.json`, reset with `sm config reset ui.confirmViewSwitch`."
        }
      }
    }
  }
}
