/** * Closed enum of view slot names. Mirror of * `spec/schemas/view-slots.schema.json#/$defs/SlotName`. */ type TSlotName = 'card.title.right' | 'card.subtitle.left' | 'card.footer.left' | 'card.footer.right' | 'graph.node.alert' | 'inspector.header.badge' | 'inspector.action.button' | 'inspector.surface.version' | 'inspector.surface.stability' | 'inspector.surface.tags' | 'inspector.surface.summary' | 'inspector.surface.auto-tag' | 'inspector.body.panel.breakdown' | 'inspector.body.panel.records' | 'inspector.body.panel.tree' | 'inspector.body.panel.key-values' | 'inspector.body.panel.link-list' | 'inspector.body.panel.markdown' | 'topbar.nav.start'; /** * Closed enum of input-type names for plugin settings. Mirror of * `spec/schemas/input-types.schema.json#/$defs/InputTypeName`. */ type TInputTypeName = 'string-list' | 'single-string' | 'boolean-flag' | 'integer' | 'number' | 'enum-pick' | 'enum-multipick' | 'path-glob' | 'regex' | 'secret' | 'key-value-list' | 'match-list'; /** * Closed severity palette aligned with PrimeNG `` / `` severities. Used by counter, tag, alert, and icon slots for color/contrast hints. The UI maps each severity to a theme-aware tint; plugins do not pick raw colors. */ type Severity$1 = 'info' | 'warn' | 'success' | 'danger'; /** * Single string, prefix-discriminated by the UI. Four valid shapes: (1) emoji, any value starting with a non-ASCII-letter codepoint renders as text; (2) PrimeIcons, `pi-foo` or `pi pi-foo` renders as ``; (3) FontAwesome explicit family, `fa-solid fa-foo` / `fa-regular fa-foo` / `fa-brands fa-foo` passes through as-is; (4) FontAwesome shorthand, `fa-foo` (no family token) defaults to `fa-solid fa-foo`. Bare class names without a `pi-` / `fa-` prefix are rejected at manifest load (invalid-manifest). Unknown PrimeIcons / FontAwesome names render no icon (silent fallback) plus a console warning. */ type IconString = string; interface SlotPayloadMap { 'card.title.right': IconMarkerPayload; 'card.subtitle.left': CounterPayload; 'card.footer.left': CounterPayload; 'card.footer.right': CounterPayload; 'graph.node.alert': AlertPayload; 'inspector.header.badge': BadgePayload; 'inspector.action.button': ActionButtonPayload; 'inspector.surface.version': SurfaceActionPayload; 'inspector.surface.stability': SurfaceActionPayload; 'inspector.surface.tags': SurfaceActionPayload; 'inspector.surface.summary': SurfaceActionPayload; 'inspector.surface.auto-tag': SurfaceActionPayload; 'inspector.body.panel.breakdown': BreakdownPayload; 'inspector.body.panel.records': RecordsPayload; 'inspector.body.panel.tree': TreeNode; 'inspector.body.panel.key-values': KeyValuesPayload; 'inspector.body.panel.link-list': LinkListPayload; 'inspector.body.panel.markdown': MarkdownPayload; 'topbar.nav.start': ScopeStatPayload; } /** * Single icon per node, small standalone marker rendered next to the card title. The manifest requires `icon`; the payload optionally overrides it per node and may add `severity` (color tint) and `tooltip`. No counts, no labels, for chip + number use a counter slot; for label + severity use a tag slot. 'Empty' for `emitWhenEmpty` is the absence of both payload `icon` and a manifest fallback (in practice never empty since the manifest icon is required). */ interface IconMarkerPayload { /** * Single string, prefix-discriminated by the UI. Four valid shapes: (1) emoji, any value starting with a non-ASCII-letter codepoint renders as text; (2) PrimeIcons, `pi-foo` or `pi pi-foo` renders as ``; (3) FontAwesome explicit family, `fa-solid fa-foo` / `fa-regular fa-foo` / `fa-brands fa-foo` passes through as-is; (4) FontAwesome shorthand, `fa-foo` (no family token) defaults to `fa-solid fa-foo`. Bare class names without a `pi-` / `fa-` prefix are rejected at manifest load (invalid-manifest). Unknown PrimeIcons / FontAwesome names render no icon (silent fallback) plus a console warning. */ icon?: string; severity?: Severity$1; tooltip?: string; } /** * Single icon + integer pair, modelled after the `.sm-gnode__stat` rows in the card footer. Manifest requires `icon` (enforced by `IViewContribution.allOf` for every counter slot); payload carries `value`, optionally `severity` and `tooltip`. The manifest `label` is metadata (docs / plugin-doctor / aria-label) and is NOT rendered inline. */ interface CounterPayload { /** * Single non-negative integer for the chip / badge. 'Empty' for `emitWhenEmpty` purposes is `value === 0`. */ value: number; tooltip?: string; /** * Closed severity palette aligned with PrimeNG `` / `` severities. Used by counter, tag, alert, and icon slots for color/contrast hints. The UI maps each severity to a theme-aware tint; plugins do not pick raw colors. */ severity?: 'info' | 'warn' | 'success' | 'danger'; } /** * Decoration on the graph node (corner badge / pin). At least one of `icon`, `severity`, `count` is required. 'Empty' for `emitWhenEmpty` is the absence of `icon` AND `count`. Hard cap 1 marker per node per plugin extension (slot config enforces). */ interface AlertPayload { icon?: IconString; severity?: Severity$1; /** * Optional badge count rendered next to the icon (1-99; 99+ collapses to '99+'). Omit for an icon-only marker. */ count?: number; tooltip?: string; } /** * Unified inspector header badge. At least one of `icon`, `label`, `count` is required; optional `severity` tint and `tooltip`. Multi-cardinality slot (priority order, modeled on card.footer.left); a plugin extension may emit several. 'Empty' for `emitWhenEmpty` is the absence of `icon` AND `label` AND `count`. Replaces the retired `_counter`/`_tag` header sub-slots: a counter-style badge sets `count` (+`icon`), a tag-style badge sets `label` (+`severity`), the stale clock sets `icon` + `tooltip`. */ interface BadgePayload { icon?: IconString; label?: string; count?: number; severity?: Severity$1; tooltip?: string; } /** * An action button rendered in the inspector's generic Actions section. The manifest declares only `{ slot: 'inspector.action.button' }`; the per-node payload carries the action id, label, and the dynamic `enabled` flag. Click dispatches the Action via POST /api/actions/:id. `emitWhenEmpty` does not apply (a button is always meaningful). An affordance that owns a NAMED UI surface (version chip, stability chip, tag row, ...) does not emit here: it emits on its dedicated `inspector.surface.*` slot (the former payload-level `surface` re-homing field is retired). */ interface ActionButtonPayload { /** * Qualified Action id `/` the click dispatches via POST /api/actions/:id. Resolved by the kernel registry; an unknown id makes the BFF answer 404. */ actionId: string; label: string; icon?: IconString; severity?: Severity$1; /** * Dynamic gate. The button is ALWAYS emitted (the persistence upsert refreshes the row each scan); `false` renders it disabled. e.g. `isStale` for the bump button. */ enabled: boolean; /** * Tooltip shown when `enabled` is false. */ disabledReason?: string; /** * Reserved (Step 2+). Static input merged into the dispatch body for parametrized actions that need no user prompt. */ input?: {}; prompt?: ActionPrompt; /** * Reserved. Require an extra confirm step before dispatch (destructive actions). */ confirm?: boolean; } /** * Reserved (Step 3+). Declares an input-type prompt the UI collects before dispatching (enum-pick for stability, single-string for tags). */ interface ActionPrompt { /** * Input-type id from the closed catalog. The UI renders the matching control before dispatch (`single-string`, `enum-pick` and `string-list` today; other types degrade to a graceful 'unsupported' notice). */ inputType: ('string-list' | 'single-string' | 'boolean-flag' | 'integer' | 'number' | 'enum-pick' | 'enum-multipick' | 'path-glob' | 'regex' | 'secret' | 'key-value-list' | 'match-list') & string; /** * Key under which the collected value is placed in the dispatch `input` body. */ paramKey: string; label: string; /** * Optional pre-filled value the UI seeds the control with before the user edits (e.g. a node's current tags for a `string-list` edit). String for scalar input-types, string array for list input-types. */ defaultValue?: string | string[]; /** * Choice list for `enum-pick` / `enum-multipick` input types. */ options?: { value: string; label: string; }[]; } /** * Shared payload of the five `inspector.surface.*` slots (the dedicated-surface family that replaced the retired `ActionButtonPayload.surface` re-homing field, decision 2026-07-23). A slot in this family is a LOGICAL surface: the UI decides where it echoes (the version surface renders as the header chip AND the card's vN label). Single-cardinality per node: when several contributions land on one surface slot, the UI uses the first by contribution priority order and `sm plugins doctor` warns. */ interface SurfaceActionPayload { /** * Qualified Action id `/` the surface dispatches (deterministic surfaces POST /api/actions/:id; probabilistic surfaces submit a job for this extension). The UI selects the surface by SLOT and dispatches this id; it never matches extension ids, so any plugin may claim the surface and disabling the claiming extension removes it (the projection stops). */ actionId: string; label: string; icon?: IconString; severity?: Severity$1; /** * Dynamic gate. The surface is ALWAYS emitted while the claiming extension is enabled; `false` renders it disabled (e.g. a stale sidecar disabling the bump chip). */ enabled: boolean; /** * Tooltip shown when `enabled` is false. */ disabledReason?: string; /** * Reserved. Static input merged into the dispatch body for parametrized actions that need no user prompt. */ input?: {}; prompt?: ActionPrompt1; /** * Reserved. Require an extra confirm step before dispatch (destructive actions). */ confirm?: boolean; } /** * Declares an input-type prompt the UI collects before dispatching (enum-pick for stability, single-string for tags). */ interface ActionPrompt1 { /** * Input-type id from the closed catalog. The UI renders the matching control before dispatch (`single-string`, `enum-pick` and `string-list` today; other types degrade to a graceful 'unsupported' notice). */ inputType: ('string-list' | 'single-string' | 'boolean-flag' | 'integer' | 'number' | 'enum-pick' | 'enum-multipick' | 'path-glob' | 'regex' | 'secret' | 'key-value-list' | 'match-list') & string; /** * Key under which the collected value is placed in the dispatch `input` body. */ paramKey: string; label: string; /** * Optional pre-filled value the UI seeds the control with before the user edits (e.g. a node's current tags for a `string-list` edit). String for scalar input-types, string array for list input-types. */ defaultValue?: string | string[]; /** * Choice list for `enum-pick` / `enum-multipick` input types. */ options?: { value: string; label: string; }[]; } interface BreakdownPayload { /** * Top-N labeled values rendered as a horizontal bar chart. Hard cap 20 bars (overflow rejected at validation, plugin should pre-truncate). 'Empty' for `emitWhenEmpty` is `bars.length === 0`. * * @maxItems 20 */ bars: { label: string; value: number; tooltip?: string; }[]; } interface RecordsPayload { /** * Column declarations (max 6). Each row's value at `key` is rendered under `label`. * * @minItems 1 * @maxItems 6 */ columns: { key: string; label: string; }[]; /** * Tabular rows (max 50). Cell values are scalar only (string ≤256 chars, number, boolean, or null). 'Empty' for `emitWhenEmpty` is `rows.length === 0`. * * @maxItems 50 */ rows: { [k: string]: string | number | boolean | null; }[]; } /** * Recursive tree rendered as an indented hierarchy. Hard caps: max depth 6, max 200 total nodes per tree (validator enforces). 'Empty' for `emitWhenEmpty` is the root having no `children`. */ interface TreeNode { label: string; marker?: IconString; tooltip?: string; children?: TreeNode1[]; } interface TreeNode1 { label: string; marker?: IconString; tooltip?: string; children?: TreeNode1[]; } interface KeyValuesPayload { /** * Flat key/value pairs (max 50). Renders as a definition list. 'Empty' for `emitWhenEmpty` is `pairs.length === 0`. * * @maxItems 50 */ pairs: { key: string; value: string | number | boolean | null; tooltip?: string; }[]; } interface LinkListPayload { /** * List of in-scope node paths (max 100). 'Empty' for `emitWhenEmpty` is `links.length === 0`. * * @maxItems 100 */ links: { /** * Node path within the scope. Resolved by the UI to a clickable link via `Router.navigate`, never rendered as a raw `[href]` (per the renderer attr-sanitization analyzer). */ path: string; label?: string; /** * Optional Provider kind id (informational). The UI may apply per-kind tinting from `kindRegistry`. */ kind?: string; }[]; } interface MarkdownPayload { /** * Markdown text rendered with a sanitized allow-list (paragraphs, headings up to H3, lists, inline code, fenced code, emphasis, strong, blockquote). HTML, scripts, embedded SVG, image tags, and link autodetection are stripped. Hard cap 4096 chars to keep render cost bounded. 'Empty' for `emitWhenEmpty` is `markdown.trim() === ''`. */ markdown: string; } /** * Single value summarizing the entire scope. Emitted ONCE per scan (not per node). The emit path is `ctx.emitScopeContribution(...)` on the analyzer context (extractors never see it), which is RESERVED in the spec but NOT YET IMPLEMENTED: today's IAnalyzerContext does not expose the callback, so a manifest declaring this slot loads clean but its emissions are deferred until the kernel adds it. See view-slots.md. */ interface ScopeStatPayload { /** * Either a non-negative integer or a short string. The UI renders it as a single chip in the topbar. */ value: number | string; label?: string; tooltip?: string; severity?: Severity$1; } /** * Payload type for a given slot. `ctx.emitContribution` infers this from the * declared contribution's `slot`, so the author gets a typed payload argument. */ type SlotPayload = SlotPayloadMap[S]; /** * Step 11.x, runtime view-contribution catalog types. * * Lives in its own module (rather than `kernel/index.ts`) so consumers * deep inside the kernel, `IAnalyzerContext`, the BFF route factories, * future Action contexts, can depend on the catalog shape without * dragging the whole kernel barrel and risking a cycle. * * Mirrors `annotation-catalog.ts` for the annotation contribution side * (Step 9.6.6). The two systems share the "plugin contributes data, * kernel exposes catalog, UI renders" pattern but never overlap in * storage or routing, see `architecture.md` §View contribution system * for the comparison table. * * **Closed catalog by design, generated from the spec.** Both `TSlotName` * and `TInputTypeName` are generated (in `view-catalog.generated.ts`) from * the closed `oneOf` const lists in `spec/schemas/view-slots.schema.json` * and `spec/schemas/input-types.schema.json`. Adding a member means editing * the spec and running `pnpm --filter @skill-map/cli view-catalog`; the * `view-catalog:check` drift guard fails the build if any mirror (this * kernel one, the CLI `slots-catalog.ts`, the UI `TSlotId` union) goes * stale. The closed-enum shape lets TypeScript surface unknown slots at * author time (in plugin authors' editors when their plugin imports * `@skill-map/cli`) AND lets the runtime exhaustively dispatch slot → * renderer in the UI without `default:` fallbacks. */ /** Closed severity palette aligned with PrimeNG `` / ``. */ type TSeverity = 'info' | 'warn' | 'success' | 'danger'; /** * Manifest-side declaration of a single view contribution. The plugin * author writes one of these per Record key in * `IExtensionBase.viewContributions[]`. * * Mirror of `view-slots.schema.json#/$defs/IViewContribution`. */ interface IViewContribution { /** * Required. Closed-catalog slot name. Unknown name rejects the * extension as `invalid-manifest` at load. The slot fixes both the * renderer and the payload shape; there is no separate "contract" * abstraction. */ slot: TSlotName; /** * Optional human-readable label. English-only per `AGENTS.md` * (`Externalized texts, not internationalized`). */ label?: string; /** Optional hover tooltip. English-only. */ tooltip?: string; /** * Optional emoji codepoint OR PrimeIcons class id (without the * `pi-` prefix). The UI discriminates: matches Unicode * `\p{Extended_Pictographic}` → emoji text, otherwise → PrimeIcon. * Required for counter slots and `card.title.right` (enforced by * the manifest-side conditional in `view-slots.schema.json`). */ icon?: string; /** * Optional empty placeholder text shown when the payload is empty * AND `emitWhenEmpty` is true. Falls back to a UI-supplied generic * 'No data.' string. English-only. */ emptyText?: string; /** * When false (default), the kernel drops emissions whose payload is * structurally empty so the slot stays silent. When true, the * renderer surfaces an empty placeholder. Per-slot definition of * "empty" lives in the slot's payload schema. */ emitWhenEmpty?: boolean; /** * Optional ordering hint (default 100). Slots configured with * `order: 'priority'` sort contributions ASC by this value, with * alphabetical tie-break by qualified id. The plugin uses this to * suggest where its contribution belongs relative to others sharing * the same slot, the slot has the final say. */ priority?: number; } /** * Single row of the runtime view-contribution catalog surfaced by * `kernel.getRegisteredViewContributions()`. One row per * `(pluginId × extensionId × contributionId)` tuple. Composed at boot * by `loadPluginRuntime` from every loaded extension's * `viewContributions` map. * * The qualified id is `//`, * matches the qualified id pattern used elsewhere in the kernel * (`/` for extensions; this adds the third * segment for per-contribution identity). */ interface IRegisteredViewContribution { pluginId: string; extensionId: string; contributionId: string; slot: TSlotName; /** Optional manifest-declared label (English-only). */ label?: string; tooltip?: string; icon?: string; emptyText?: string; emitWhenEmpty: boolean; /** Manifest-declared ordering hint (default 100). See `IViewContribution.priority`. */ priority?: number; /** * Inspector-only ordering hint, denormalised from the owning plugin's * `plugin.json` `order` field (default 100). Orders the per-plugin * inspector body sections. Same value on every contribution of a plugin. */ pluginOrder?: number; /** * Inspector-only ordering hint, denormalised from the owning extension's * `order` manifest field (default 100). Orders the bricks inside a * plugin's inspector section. Same value on every contribution of an * extension. */ extensionOrder?: number; } /** * Common fields on every setting declaration. The discriminated union * `TSettingDeclaration` extends one of these per `type` value. */ interface ISettingCommon { /** Required. Short human-readable label. English-only. */ label: string; /** Optional helper text shown below the control. English-only. */ description?: string; } interface ISetting_StringList extends ISettingCommon { type: 'string-list'; default?: string[]; min?: number; max?: number; itemMaxLength?: number; } interface ISetting_SingleString extends ISettingCommon { type: 'single-string'; default?: string; minLength?: number; maxLength?: number; /** Optional ECMAScript regex pattern (no flags). */ pattern?: string; } interface ISetting_BooleanFlag extends ISettingCommon { type: 'boolean-flag'; default?: boolean; } interface ISetting_Integer extends ISettingCommon { type: 'integer'; default?: number; min?: number; max?: number; step?: number; } interface ISetting_Number extends ISettingCommon { type: 'number'; default?: number; min?: number; max?: number; step?: number; } interface ISetting_EnumOption { value: string; label: string; } interface ISetting_EnumPick extends ISettingCommon { type: 'enum-pick'; options: ISetting_EnumOption[]; default?: string; } interface ISetting_EnumMultipick extends ISettingCommon { type: 'enum-multipick'; options: ISetting_EnumOption[]; default?: string[]; min?: number; max?: number; } interface ISetting_PathGlob extends ISettingCommon { type: 'path-glob'; default?: string; /** When true, accepts string[]; when false (default), single string. */ multiple?: boolean; } interface ISetting_Regex extends ISettingCommon { type: 'regex'; default?: string; /** Subset of `gimsuy`. Default `''`. */ flags?: string; } interface ISetting_Secret extends ISettingCommon { type: 'secret'; /** * Optional uppercase-ASCII identifier. When set in the process * environment, that value wins over any stored value (lets CI * inject without writing to disk). */ envVar?: string; } interface ISetting_KeyValueListEntry { key: string; value: string; } interface ISetting_KeyValueList extends ISettingCommon { type: 'key-value-list'; keyLabel?: string; valueLabel?: string; default?: ISetting_KeyValueListEntry[]; min?: number; max?: number; } interface ISetting_MatchListEntry { /** * Match kind: `literal` (exact equality with the candidate string, * case-sensitive), `regex` (ECMAScript body, no flags, unanchored * test), or `glob` (gitignore-style, matched with the same engine * as `.skillmapignore`). */ type: 'literal' | 'regex' | 'glob'; /** Single line, 1-256 chars, no ASCII control or DEL characters. */ value: string; } interface ISetting_MatchList extends ISettingCommon { type: 'match-list'; default?: ISetting_MatchListEntry[]; } /** * Discriminated union of every setting declaration shape. The plugin * author NEVER writes JSON Schema for settings, they pick one of * these `type` values and supply per-type parameters. * * Mirror of `input-types.schema.json#/$defs/ISettingDeclaration`. */ type TSettingDeclaration = ISetting_StringList | ISetting_SingleString | ISetting_BooleanFlag | ISetting_Integer | ISetting_Number | ISetting_EnumPick | ISetting_EnumMultipick | ISetting_PathGlob | ISetting_Regex | ISetting_Secret | ISetting_KeyValueList | ISetting_MatchList; /** * Runtime value type for a setting, derived from its declaration. The * kernel exposes settings to extractors as `Record` * via `ctx.settings.`; consumers that want narrow typing * narrow at the call site by reading `manifest.settings[id].type`. */ type TSettingValue = string | string[] | boolean | number | ISetting_KeyValueListEntry[] | ISetting_MatchListEntry[]; /** * Base extension shape shared by every kind. Mirrors * `spec/schemas/extensions/base.schema.json` at the TypeScript level. * * **Structure-as-truth**: the manifest authored on disk no longer carries * `id` or `kind`. Both fields are derived from the filesystem path * (`///index.ts`, parent folder dictates kind, leaf * folder dictates id). The loader strips/rejects any `id` / `kind` literals * a hand-written manifest carries and injects the derived values into the * runtime descriptor before it reaches the registry. The qualified registry * key is `/`; `pluginId` similarly comes from the plugin's * folder name and is injected at load time. */ /** * Lifecycle label an extension manifest MAY declare. Renders as a badge * next to the extension in `sm plugins list ` / `sm plugins show` and * the Settings plugins panel for the non-default values. * * Two values ALSO change behaviour: `experimental` (not ready yet) and * `deprecated` (on its way out) flip the extension's installed default * to DISABLED, so the extension does not load (does not run, does not * register) unless the operator opts in (`sm plugins enable * /`, the Settings toggle, or a `settings.json` / * `settings.local.json` override). The opt-in is a plain enable override, * once set it wins over the installed default exactly like any other * extension (so a deprecated extension can still be kept running during * a migration). The remaining values are presentation-only and default * to ENABLED: `beta` runs by default with a badge, `stable` (declared * or defaulted) runs with no badge. Missing == `stable` == enabled, no * badge. Mirrors * `spec/schemas/extensions/base.schema.json#/properties/stability`. * * Deliberately a superset of the node-level annotations enum (which has * no `beta`): this describes the maturity of the extension itself, not * of a scanned node. */ type TExtensionStability = 'experimental' | 'beta' | 'stable' | 'deprecated'; /** * Single declaration of an extension's optional sidecar annotation * contribution. The annotation key is the extension's id (the leaf folder * name); to contribute additional keys, split into additional extensions. * Mirrors `spec/schemas/extensions/base.schema.json#/properties/annotation`. */ interface IAnnotationContribution { /** Inline JSON Schema describing the value written under this key. */ schema: Record; /** * Conflict policy. `shared` (default): multiple plugins MAY write the * key; `exclusive`: only this plugin may. REQUIRED to be `'exclusive'` * when `location: 'root'`. */ ownership?: 'exclusive' | 'shared'; /** * Where the key lands. `namespaced` (default): under the plugin's * `:` block; `root`: top-level, alongside `for` / * `annotations` / `settings` / `audit`. Cross-plugin root-key * collisions on `exclusive` are a fatal startup error. */ location?: 'namespaced' | 'root'; } /** * Runtime extension descriptor as seen by the registry / orchestrator. * Authors writing a manifest on disk do NOT declare `id`, `kind`, or * `pluginId`; the loader injects all three from the filesystem layout. * * `version` is the runtime invariant: every loaded extension carries a * string. External plugins MUST declare it in their manifest (enforced * by AJV via `spec/schemas/extensions/base.schema.json#/required`). * Built-in extensions inside this repo OMIT `version` from the manifest * file (see `IBuiltInManifest` below) because the codegen at * `scripts/generate-built-ins.js` stamps the CLI version onto every * built-in at build time, alongside the `pluginId` stamp. */ interface IExtensionBase { /** * Short id, the leaf folder name of the extension. Injected by the * loader. Hand-authored manifests carrying an `id` literal are * rejected as `invalid-manifest`. */ id: string; /** * Owning plugin namespace, the plugin folder name. Composed with `id` * to produce the qualified registry key `/`. Injected * by the loader. */ pluginId: string; version: string; /** Required short description shown by `sm s list` / UI. */ description: string; /** * Optional lifecycle label (`experimental` / `beta` / `stable` / * `deprecated`). Missing == `stable`, no badge rendered. `experimental` * and `deprecated` additionally flip the installed default to disabled. * See `TExtensionStability` for the full semantics. */ stability?: TExtensionStability; /** * Optional installed-default override for the ENABLED axis, orthogonal * to `stability` (spec `base.schema.json#/properties/defaultEnabled`, * 2026-07-21): when declared it wins over the stability-derived * default, so a `stable` extension can ship as a deliberate opt-in * (`defaultEnabled: false`) without mislabeling its maturity. Explicit * operator overrides win over it as usual. */ defaultEnabled?: boolean; /** * HOST-RESERVED lock (spec `architecture.md` §Locked extensions, * decision 2026-07-23 replacing the hardcoded kernel lock-list so the * kernel stays plugin-agnostic). `true` = this extension may never be * disabled: the enabled-resolver returns `true` before consulting any * config layer, toggle surfaces reject writes (403 `locked`), and the * id is never trust-gated. BUILT-IN ONLY: deliberately absent from * `base.schema.json`, so an external plugin declaring it fails load * (`invalid-manifest`, `unevaluatedProperties: false`); only the * typed built-ins compiled into the CLI can carry it. Nothing * experimental is lockable (the built-ins codegen rejects the * combination). The host layers consume the flag through * `src/plugins/locked-built-ins.ts`, never by naming ids. */ locked?: boolean; /** * Optional inspector-only ordering hint (default 100). Inside the * owning plugin's inspector section, orders this extension's * `inspector.body.panel.*` bricks relative to its sibling extensions. * Never affects execution order. See `extensions/base.schema.json`. */ order?: number; /** * Optional opt-in single annotation contribution. Renamed from * `annotationContributions` (mapa) with the structure-as-truth * refactor; the key is the extension's id, so only the schema + * ownership + location triple lives in the manifest. */ annotation?: IAnnotationContribution; /** * Optional extension-scoped user settings. Moved here from * `plugin.json` with the structure-as-truth refactor: settings now * live with the extension that consumes them, exposed at runtime as * `ctx.settings.`. Settings are read once at extension * invocation; changing a setting requires `sm scan` to re-emit. */ settings?: Record; /** * Resolved values of the settings declared above, populated by the * orchestrator from project config + user overrides. Runtime-only, * never written to disk by authors. */ resolvedSettings?: Record; /** * Optional plugin-contributed view contributions. Renamed from * `viewContributions` with the structure-as-truth refactor. Each * entry maps a local contribution id (kebab-case, unique within the * extension) to an `IViewContribution` that picks a view slot by * name from the closed catalog. Declared by `extractor` and * `analyzer` kinds (emitted during scan / graph evaluation) and by * `action` kinds (emitted from the Action's scan-time `project()` * self-projection, see `IActionProjectionContext`). */ ui?: Record; /** Runtime-only, absolute path of the extension entry file. */ entry?: string; } /** * Extension registry, six kinds, first-class, loaded through a single API. * * The `IExtension` shape is aligned with `spec/schemas/extensions/base.schema.json`. * Kind-specific manifests (provider / extractor / analyzer / action / formatter / * hook) extend this base structurally; the registry stores the base view * and each kind's code carries its own fuller type where needed. * * **Spec § A.6, qualified ids.** Every extension is keyed in the registry * by `/` (e.g. `core/annotations`, `core/slash-command`, * `my-plugin/my-extractor`). `IExtension.id` carries the **short** id as authored; * `IExtension.pluginId` carries the namespace; the registry composes the * qualifier internally and exposes lookup APIs that operate on either form * (qualified for direct lookup, kind-scoped listing for enumeration). * * Boot invariant: `new Registry()` is empty. `registry.totalCount() === 0` * when the kernel boots with zero extensions. This is the data side of the * `kernel-empty-boot` conformance contract. */ type ExtensionKind = 'provider' | 'extractor' | 'analyzer' | 'action' | 'formatter' | 'hook'; declare const EXTENSION_KINDS: readonly ExtensionKind[]; interface IExtension { /** Short (unqualified) extension id, injected by the loader from the leaf folder name. */ id: string; /** Owning plugin namespace, injected by the loader from the plugin folder name. */ pluginId: string; kind: ExtensionKind; version: string; /** Required short description; surfaced in `sm s list` and the UI. */ description: string; /** * Optional lifecycle label (`IExtensionBase.stability`). Carried on the * registry view so the enabled-resolver can read it: `experimental` * flips an extension's installed default to disabled. Absent == stable. */ stability?: TExtensionStability; /** * Optional installed-default override (`IExtensionBase.defaultEnabled`, * spec `base.schema.json`): wins over the stability-derived default * when declared. Carried so registry consumers (help catalogs) apply * the same installed default as the runtime resolvers. */ defaultEnabled?: boolean; entry?: string; } /** * Compose the qualified registry key for an extension. Single source of * truth so callers don't reinvent the format and a future change (e.g. a * different separator) lands in one place. */ declare function qualifiedExtensionId(pluginId: string, id: string): string; declare class DuplicateExtensionError extends Error { constructor(kind: ExtensionKind, qualifiedId: string); } declare class Registry { #private; constructor(); register(ext: IExtension): void; /** * Lookup by qualified id (`/`). Returns `undefined` when * no extension of that kind is registered under the qualifier. */ get(kind: ExtensionKind, qualifiedId: string): IExtension | undefined; /** * Convenience wrapper that composes the qualified id for the caller. * Equivalent to `get(kind, qualifiedExtensionId(pluginId, id))`. */ find(kind: ExtensionKind, pluginId: string, id: string): IExtension | undefined; all(kind: ExtensionKind): IExtension[]; count(kind: ExtensionKind): number; totalCount(): number; } /** * Step 9.6.6, runtime annotation-contribution catalog types. * * Lives in its own module (rather than `kernel/index.ts`) so consumers * deep inside the kernel, `IAnalyzerContext`, the BFF route factories, * future Action contexts, can depend on the catalog shape without * dragging the whole kernel barrel and risking a cycle. */ /** * Single row of the runtime annotation-contribution catalog surfaced by * `kernel.getRegisteredAnnotationKeys()`. One row per (plugin × key) * tuple. Built-in catalog keys from `annotations.schema.json` are NOT * included, this catalog is plugin-only; the UI knows the built-in * catalog via the schema bundle. */ interface IRegisteredAnnotationKey { pluginId: string; key: string; location: 'namespaced' | 'root'; ownership: 'exclusive' | 'shared'; /** Inline JSON Schema as declared in the manifest (not the AJV compiled validator). */ schema: Record; } /** * Domain types, byte-aligned with `spec/schemas/{node,link,issue,scan-result}.schema.json`. * * The kernel is the reference consumer of the spec; these types are therefore * derived from the schemas, not invented. When a schema changes, this file * follows. Until automatic AJV-driven derivation lands, the mapping is * hand-maintained and the release gate is the conformance suite. * * --- Naming convention (kernel-wide) ------------------------------------- * * Five categories with distinct prefix rules; the rules are deliberate * even though they look mixed at first read: * * 1. **Domain types**, every shape that mirrors a `spec/schemas/*.json` * file: `Node`, `Link`, `Issue`, `ScanResult`, `ScanStats`, * `ExecutionRecord`, `HistoryStats`, …. **No prefix.** Names track * the spec verbatim because the spec is the source of truth. * Renaming any of these is a spec change. * * 2. **Hexagonal ports**, the abstract boundaries the kernel calls * out to (`StoragePort`, `ProgressEmitterPort`, * `FilesystemPort`, `PluginLoaderPort`). **`Port` suffix.** The * suffix calls out the architectural role and avoids name clashes * with the concrete adapter classes (`SqliteStorageAdapter` * implements `StoragePort`). * * 3. **Runtime extension contracts**, what a plugin author * implements: `IProvider`, `IExtractor`, `IAnalyzer`, `IFormatter`, * `IExtensionBase`. **`I` prefix.** The prefix flags "this is a * contract you supply, not a value the kernel hands you", same * reading as the rest of TypeScript's plugin ecosystems where a * shape is implementable. * * 4. **Internal interfaces**, option bags, result records, config * slices, anything declared as `interface` and passed across * function boundaries inside the kernel / CLI but not part of the * spec: `IPluginRuntime`, `IPruneResult`, `IMigrationFile`, * `IDbLocationOptions`. **`I` prefix.** The prefix matches * category 3 because both are "shapes that live in TypeScript * only, never in JSON". * * 5. **Internal type aliases**, anything declared as `type` (string- * literal unions, function types, mapped/derived types) that lives * only in TS: `TLogLevel`, `TLogMethodLevel`, `TProgressListener`, * `TLogFormatter`, `TActionWrite`, `TExecutionMode`, * `THookFilter`, `THookTrigger`, `TNodeChangeReason`, * `TPluginLoadStatus`, `TPluginStorage`, `TWatchEventKind`. **`T` * prefix.** Use this bucket when `interface` is the wrong shape * (a union, a callback signature, an `Exclude<…>` derivation). * * Edge cases worth knowing: * - The following category-4 names lack the `I` prefix because * they are part of the public kernel surface and renaming is a * breaking change for downstream consumers. The list is closed: * option bags / records: `RunScanOptions`, `RenameOp`; * TS-only exports from `kernel/index.ts` / `kernel/ports/*`: * `Kernel`, `ProgressEvent`, `LogRecord`, `NodeStat`. * New public option bags MUST still use `I*`; new public type * aliases MUST still use `T*`. Removing a name from this list is a * breaking change. * - `IDatabase` (SQLite schema) is category 4 but lives in * `adapters/sqlite/schema.ts`, not here. Same rule applies. * * If you find yourself wanting to add a new type and aren't sure which * bucket it falls in: ask "does this shape exist in the spec?". If * yes, no prefix and align the name with the schema. If no, `I` prefix * for `interface`, `T` prefix for `type` aliases. */ /** * The four node kinds the **built-in Claude Provider** declares, `skill`, * `agent`, `command`, `note`. **NOT** the kernel-wide kind type. * * `Node.kind` is `string`. An external Provider (Cursor, Obsidian, …) * MAY classify into its own kinds (e.g. `'cursorRule'`, `'daily'`); the * orchestrator, persistence layer, and AJV `node.schema.json` accept any * non-empty string. Per `spec/db-schema.md` § scan_nodes and * `node.schema.json#/properties/kind`, the contract is open-by-design * (matches `IProvider.kinds` "open by design" docstring). * * Step 9.5 dropped `hook` from the catalog: `.claude/hooks/*.md` is NOT * an Anthropic-defined node type, hooks live in `settings.json` or as * sub-objects of agent / skill frontmatter (see * https://code.claude.com/docs/en/hooks.md). Files at the old path * classify as `markdown` via the Provider's fallback. The fallback is * named after the *format* because the file is generic markdown with * no specific role; format-named kinds apply only as the generic * fallback, a file that matches a specific role (agent / command / * skill) classifies under that role, not under `markdown`. * * This alias survives because: * - claude-specific code legitimately wants to switch on the four * hard-coded values (filter widgets, kind-aware UI cards, the * `schema-violation` built-in rule that maps each kind to its * frontmatter schema); * - sorting helpers want a stable `KIND_ORDER` for the canonical * catalog; * - tests expect to enumerate the four kinds when seeding fixtures. * * For "any kind a Provider could declare", use plain `string`. Only use * `NodeKind` when the code is intentionally claude-catalog-specific. */ type NodeKind = 'skill' | 'agent' | 'command' | 'markdown'; type LinkKind = 'invokes' | 'references' | 'mentions' | 'points'; /** * Extractor's self-assessed confidence, normalized to `[0..1]`. Drives * UI edge opacity in the graph view (more confident = more opaque edge). * Migrated from the legacy `'high' | 'medium' | 'low'` string union to * a numeric range so callers can express finer granularity than three * buckets. The named tiers below (`ConfidenceTier`) preserve the * legacy buckets as constants for callers that prefer bucket-thinking. * * Reference scoring (guideline, not contract): * * `1.0` structured input (sidecar annotation) * `0.95` unambiguous syntax (`[text](file.md)`, `https://…`) * `0.85` strong signal with one inference (`@file.md`) * `0.5` genuine ambiguity (`@bare-handle`) * * Validation: the orchestrator's `validateLink` rejects values outside * `[0..1]` with an `extension.error` event, mirroring the LinkKind * enum check. Missing confidence defaults to `ConfidenceTier.MEDIUM`. */ type Confidence = number; /** * A single confidence-scoring operation a `score`-phase analyzer * contributes via `ctx.adjustConfidence(link, op)`. The orchestrator * folds every op on a link into the final `confidence` (see * `orchestrator/confidence-score.ts` for the algebra): * - `set` hard override (resolved → 1.0, reserved → 0.1) * - `delta` additive, may be negative (third-party heuristics) * - `ceil` upper cap, lowers only (broken → 0.25) * - `floor` lower bound, raises only */ type TConfidenceOp = { readonly kind: 'set'; readonly value: number; } | { readonly kind: 'delta'; readonly value: number; } | { readonly kind: 'ceil'; readonly value: number; } | { readonly kind: 'floor'; readonly value: number; }; type Severity = 'error' | 'warn' | 'info'; type Stability = 'experimental' | 'stable' | 'deprecated'; /** * Execution mode of an analytical extension. Mirrors the per-kind capability * matrix in `spec/architecture.md` §Execution modes: * * - `deterministic`, pure code, runs synchronously inside `sm scan` / * `sm check`. Same input → same output, every run. * - `probabilistic`, needs an LLM, dispatches only as a queued job * (`sm jobs submit`) an external agent processes via `sm jobs claim` + * `sm record`; never participates in scan-time pipelines. * * Extractor / Rule / Action declare it directly (default `deterministic` when * omitted in the manifest). Provider / Formatter are deterministic-only and * MUST NOT carry the field. */ type TExecutionMode = 'deterministic' | 'probabilistic'; interface TripleSplit { frontmatter: number; body: number; total: number; } interface LinkTrigger { originalTrigger: string; normalizedTrigger: string; } interface LinkLocation { line: number; column?: number; offset?: number; } /** * One syntactic site in the source node's body that contributed to a * `Link`. Multiple occurrences accumulate when the same edge is detected * by more than one extractor (e.g. `@./foo.md` from `at-directive` and * `[label](./foo.md)` from `markdown-link` both resolve to the same * target), or when the same extractor walks an extractor-internal * dedup boundary. Today the merged edge's `trigger` / `location` * mirror the FIRST occurrence; the array carries every site so the * `core/reference-redundant` analyzer can flag multi-form * references and rename operations can find every author surface. */ interface LinkOccurrence { /** * Extractor id that observed this occurrence. Matches an entry of * the parent `Link.sources[]` (extractor + occurrence are not 1:1, * the same extractor can produce multiple occurrences when the * intra-extractor dedup is relaxed in the future). */ extractor: string; /** * Original substring as it appeared in the body (`@./real-agent.md`, * `[deploy](./deploy.md)`, `/help`, `@team-lead`). Preserves author * casing and the leading sigil so the analyzer can surface it * verbatim in fix-up messages. */ originalTrigger: string; /** * Surface context of this occurrence, copied verbatim from the * originating `Signal.context` when the resolver materialises the * link. Absent for prose occurrences. The post-walk * `prune-unresolved-code-triggers` transform keys on it: an * unresolved `mentions` link whose EVERY occurrence carries a * code-region context (`'code-block'` / `'inline-code'`) is pruned * (see `spec/architecture.md` §Extractor · code-region triggers). */ context?: SignalContext | null; /** * Position of the occurrence in the body. Optional, an extractor * that does not track line numbers yet (legacy emit paths) omits * this field; the analyzer falls back to "unknown line" in messages. */ location?: LinkLocation | null; } /** * External URL referenced from a node's body. Populated by the * `core/external-url-counter` extractor and surfaced on the node so * the inspector can list every outgoing http(s) reference without * re-walking the body. Distinct from internal `Link` (which connects * nodes inside the graph), external refs are leaf metadata: no * counterparty node, no resolution. */ interface IExternalRef { /** Normalised URL (lowercased host, fragment stripped). */ url: string; /** 1-indexed line of the occurrence in the source body, when known. */ line?: number; /** Verbatim author substring (sigil-free; usually equals `url`). */ originalTrigger?: string; } interface Node { path: string; /** * Provider-declared category. Open string (matches * `node.schema.json#/properties/kind`): the built-in Claude Provider * emits one of `NodeKind`'s values, but external Providers MAY emit * their own. Code that intentionally switches on the claude catalog * narrows via `if (kind === 'skill' \| ... )`; everything else * accepts the open string and treats unknown values as opaque labels. */ kind: string; provider: string; bodyHash: string; frontmatterHash: string; bytes: TripleSplit; linksOutCount: number; linksInCount: number; externalRefsCount: number; /** * Distinct external URLs referenced from this node's body, in * extractor-order (first-seen wins, dedup is by normalised URL). * Empty / absent when the body has no http(s) URLs. The denormalised * `externalRefsCount` MUST equal `externalRefs.length` whenever * both are present. Surfaced via `/api/nodes` so the inspector can * list each URL without an extra round-trip. */ externalRefs?: IExternalRef[]; frontmatter?: Record; tokens?: TripleSplit; /** * File modification time (`mtime`) in Unix milliseconds, captured at * scan time from the on-disk `lstat`. Absent for virtual / derived * nodes (`virtual === true`, no backing file) and for nodes built by a * Provider `walk()` that does not stat its sources. Persisted to * `scan_nodes.modified_at_ms` and surfaced on `/api/nodes` / * `/api/scan` so the UI can show and sort a "last modified" column. * NOT content: never participates in `bodyHash` / `frontmatterHash`. */ modifiedAtMs?: number; /** * Step 9.6.2, sidecar denormalisation surface. Populated by the * orchestrator at scan time; absent when the orchestrator did not * inspect sidecars (legacy code paths) or when no sidecar accompanies * the node. Read by `annotation-stale` rule and the persistence layer. */ sidecar?: ISidecarOverlay | null; /** * Per-user "favorite" flag, decorated by the BFF on `/api/nodes` and * `/api/nodes/:pathB64` responses via in-memory `Set` lookup against * `state_node_favorites`. Absent on emissions that don't carry per-user * state (e.g. `sm export --json`); consumers that don't recognise the * field MUST treat the absence as "unknown" rather than "false", a * truthy `isFavorite` only ever lands when the BFF set it. */ isFavorite?: boolean; /** * When `true`, the node is synthetic / derived: it does not correspond * to a single file on disk. Reconstructed on every scan from the * file(s) listed in `derivedFrom`. Synthetic nodes use a non-filesystem * path scheme (e.g. `mcp://github`) so the identifier is stable and * visibly non-physical. See * [`node.schema.json`](../../spec/schemas/node.schema.json) for the * normative contract. Absent / `false` for ordinary filesystem-backed * entities. Stability: experimental. */ virtual?: boolean; /** * Paths of the source files this node was derived from. Required (and * only meaningful) when `virtual === true`. Drives invalidation: any * change to a listed source between scans propagates into the virtual * node's hashes. Empty / absent when the node is a regular filesystem * entity (the `path` itself is the source). */ derivedFrom?: string[]; } /** * Drift status of a co-located `.sm` sidecar relative to the live * node hashes. Mirrors `TSidecarStatus` on the SQLite schema. */ type SidecarStatus = 'fresh' | 'stale-body' | 'stale-frontmatter' | 'stale-both'; /** * Sidecar overlay attached to a `Node` after the orchestrator parses * `.sm`. `present === false` is the empty overlay (no * sidecar accompanies the node); the other fields are absent or null * in that case. When `present === true` and parse + validation * succeeded, `status` carries the drift state and `annotations` carries * the parsed (typed) `annotations:` block. */ interface ISidecarOverlay { present: boolean; status?: SidecarStatus | null; /** * Parsed `annotations:` block. Untyped object, schema lives in * `spec/schemas/annotations.schema.json`. Null when no sidecar or * the block is empty/absent. */ annotations?: Record | null; /** * R15 closure (2026-05-07), full parsed YAML root of the sidecar * (the entire `.sm` payload, mirroring `sidecar.schema.json`). Surfaced * so the UI inspector can render `for:`, `audit:`, `settings:`, and * `:` namespace blocks without re-reading the file. NULL * when no sidecar is present, or when the sidecar exists but failed * to parse / validate. The `annotations` field above stays, it * duplicates `root.annotations` intentionally so existing consumers * keep working unchanged. */ root?: Record | null; } interface Link { /** The originating node, the path of the file the extractor was reading * when it emitted this link. Singular, NOT to be confused with * `sources` (plural) below. */ source: string; target: string; kind: LinkKind; confidence: Confidence; /** Identifiers of the extractors / extensions that contributed evidence * for this link (one link can be confirmed by multiple extractors). * Plural; NOT the same as `source` (singular) above, which is the * originating node path. Naming is unfortunate but spec-frozen. */ sources: string[]; trigger?: LinkTrigger | null; location?: LinkLocation | null; /** * Every syntactic site in the source body that contributed to this * edge. Populated by extractors at emit time (one entry per emission) * and accumulated by `dedupeLinks` when two extractors converge on the * same `(source, target, kind, normalizedTrigger)` key. Empty / absent * for legacy emits or for synthetic links (frontmatter-driven * references, sidecar annotations) that have no body position. The * `core/reference-redundant` analyzer walks this array to * detect multi-form references to the same target from one body. */ occurrences?: LinkOccurrence[]; /** * Node path the link resolves to, when the post-walk * `liftResolvedLinkConfidence` transform succeeded in matching the * (trigger-style or path-style) target against the live graph. Equal * to `link.target` for path-style links that hit a node directly; * different from `link.target` for trigger-style links (a Claude * `@real-agent` mention resolves to `.claude/agents/real-agent.md`, * but `link.target` keeps the authored trigger). Absent when the * link is unresolved (broken). The BFF `/api/links?to=` uses * this field to surface incoming edges that reach the node by name, * not just by literal path. */ resolvedTarget?: string | null; raw?: string | null; } /** * Scope of a `Signal` within its originating node. Mirrors * `signal.schema.json#/properties/scope`. * * - `body` = markdown body or equivalent prose payload. * - `frontmatter` = parsed metadata block at the top of the file. * - `sidecar` = co-located `.sm` overlay. */ type SignalScope = 'body' | 'frontmatter' | 'sidecar'; /** * Surface context for a body-scope `Signal`. Mirrors * `signal.schema.json#/properties/context/enum`. Null when the signal is in * normal prose or when the context concept does not apply (frontmatter / * sidecar scopes). */ type SignalContext = 'code-block' | 'inline-code' | 'escaped'; /** * Byte-range location for a body-scope `Signal`. `start` is inclusive, * `end` is exclusive (one past the last char). `line` is the optional * 1-indexed line number containing `start`, populated by extractors * that already compute line tracking via `computeLineStarts` so the * resolver's materialised `Link` preserves `link.location.line` * without re-walking the body. */ interface SignalRange { start: number; end: number; line?: number; } /** * One alternative interpretation of a `Signal`. The resolver picks the * winning candidate per Signal and materialises it as a `Link`; the * rejected candidates remain on `IAnalyzerContext.signals` for * collision-detection and conflict-visualisation analyzers. * * `confidence` is numeric `[0..1]`, identical shape to the `Link`'s * `Confidence` type after the Phase 4 migration. No conversion needed * when the resolver materialises a winning candidate. */ interface SignalCandidate { extractorId: string; kind: LinkKind; target: string; /** `[0..1]`. Reference scoring guideline lives in `signal.schema.json`. */ confidence: number; rationale?: string; trigger?: LinkTrigger | null; } /** * Intermediate Representation (IR) emitted by extractors via * `ctx.emitSignal(signal)`. The kernel's resolver phase consumes * `Signal[]` and produces final `Link[]` per the active Provider's * `resolverRules`. Opt-in: extractors with unambiguous detections keep * using `ctx.emitLink(link)` directly. See * [`signal.schema.json`](../../spec/schemas/signal.schema.json) for the * normative contract. */ interface Signal { /** `node.path` of the originating node. */ source: string; scope: SignalScope; /** * Byte-range location within the source. Required for `scope: 'body'`, * optional otherwise. Powers collision detection between extractors * (overlapping ranges) and code-block awareness (the orchestrator can * mark ranges that fall inside code spans). */ range?: SignalRange | null; /** * Structured-data location for `frontmatter` / `sidecar` scopes. Each * entry is a step of the path: object keys are strings, array indices * are integers serialised as strings. Example: `['tools', '0']`. Null * for body scope or when the extractor does not track field locations. */ fieldPath?: string[] | null; /** Verbatim matched text (body) or stringified value (frontmatter / sidecar). */ raw: string; /** Surface context. Null when in normal prose or when not applicable. */ context?: SignalContext | null; /** One or more alternative interpretations. At least one. */ candidates: SignalCandidate[]; /** * Resolver outcome annotation, populated by `resolveSignals`. Absent on * raw extractor emissions (before the resolver runs). When * `outcome === 'materialised'`, `winnerIndex` points into `candidates[]` * of the candidate the resolver chose; a corresponding `Link` was added * to the graph. When `outcome === 'rejected'`, `rejectedBy` is set and * no Link materialised. Both materialised and rejected Signals remain on * `IAnalyzerContext.signals` so the `core/extractor-collision` analyzer * can surface losers as `warn` issues. Mirrors * `signal.schema.json#/properties/resolution`. */ resolution?: ISignalResolution; } /** * Why the resolver chose to materialise or reject a `Signal`. Populated by * `resolveSignals`; carries no meaning before that pass. */ interface ISignalResolution { outcome: 'materialised' | 'rejected'; /** Index into `Signal.candidates[]` of the winner. Set when `outcome === 'materialised'`. */ winnerIndex?: number; /** * Set when the Signal lost a cross-extractor range-overlap collision * against another Signal at the same `source`. Names the winning Signal * so an analyzer (or the operator drilling into the sidecar) can see WHO * won and WHY. */ rejectedBy?: { source: string; range: SignalRange; /** Qualified id (`/`) of the winning candidate's extractor. */ extractorId: string; reason: 'kind-priority' | 'higher-confidence' | 'longer-range' | 'earlier-declaration'; }; } interface IssueFix { summary?: string; autofixable?: boolean; } interface Issue { analyzerId: string; severity: Severity; nodeIds: string[]; message: string; linkIndices?: number[]; detail?: string | null; fix?: IssueFix | null; data?: Record; } interface ScanStats { /** * Files visited by the Provider walkers. With a single Provider this * matches `nodesCount`; with multiple Providers running on overlapping * roots it can diverge (each yielded `IRawNode` is one walked file). */ filesWalked: number; /** * Files walked but not classified by any Provider. Today every walked * file is classified by its Provider (the `claude` Provider falls back to * `'markdown'`), so this is always 0; the field will matter once * multiple Providers can claim the same file. */ filesSkipped: number; /** * Files skipped by the walker BEFORE reading because their on-disk * size exceeded `scan.maxFileSizeBytes`. Equals * `ScanResult.oversizedFiles.length`. Absent on synthetic fixtures / * loaders that predate the field; defaults to 0 when omitted. */ filesOversized?: number; nodesCount: number; linksCount: number; issuesCount: number; durationMs: number; } interface ScanScannedBy { name: string; version: string; specVersion: string; } type ExecutionKind = 'action'; type ExecutionStatus = 'completed' | 'failed' | 'cancelled'; type ExecutionFailureReason = 'runner-error' | 'report-invalid' | 'timeout' | 'abandoned' | 'job-file-missing' | 'user-failed'; type ExecutionRunner = 'agent' | 'in-process'; /** * One row of execution history (`state_executions`). Matches * `spec/schemas/execution-record.schema.json`. `nodeIds` is the camelCased * domain field name; storage flattens it to `node_ids_json`. */ interface ExecutionRecord { id: string; kind: ExecutionKind; extensionId: string; extensionVersion: string; nodeIds?: string[]; contentHash?: string | null; status: ExecutionStatus; failureReason?: ExecutionFailureReason | null; exitCode?: number | null; runner?: ExecutionRunner | null; startedAt: number; finishedAt: number; durationMs?: number | null; tokensIn?: number | null; tokensOut?: number | null; /** * Executing model's name as SELF-REPORTED by the recording agent * (`sm record --model `). Unverifiable by design, like the * token counts; null when undeclared (and for in-process * deterministic executions). Denormalized onto `state_findings.model` * / `state_summaries.model` at record time. */ model?: string | null; reportPath?: string | null; jobId?: string | null; } type JobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; type JobFailureReason = 'runner-error' | 'report-invalid' | 'timeout' | 'abandoned' | 'job-file-missing' | 'user-failed'; type JobRunner = 'agent' | 'in-process'; /** * Extension kind resolved at submit time and frozen onto the job row * (like the version), per `job.schema.json#/properties/extensionKind`. * `sm record` routes on it: an `analyzer` report is findings by * definition (`state_findings` write-through), an `action` report * follows the summaries / enrichments schema conventions. Narrower than * the registry-wide `ExtensionKind` on purpose: only these two kinds * are queue-eligible (probabilistic). */ type JobExtensionKind = 'action' | 'analyzer'; /** * One row of the job queue (`state_jobs`). Matches * `spec/schemas/job.schema.json`; the runtime instance of a probabilistic * extension (Action or finder Analyzer, the queue is kind-agnostic) * applied to one `Node`, moving through the `spec/job-lifecycle.md` state * machine exactly once. The rendered content is NOT on this shape, it * lives in `state_job_contents` keyed by `contentHash`. */ interface Job { /** `d-YYYYMMDD-HHMMSS-XXXX`, human-readable + sortable. */ id: string; extensionId: string; extensionVersion: string; /** Kind frozen at submit; the record path routes on it. */ extensionKind: JobExtensionKind; /** * Per-job auto-fix opt-in, frozen at submit like `extensionKind` * (`job.schema.json#/properties/autoFix`). When `true` on a finder job * (`extensionKind = 'analyzer'`), `sm record` chains the finder's fixers * on completion (`spec/job-lifecycle.md` §Auto-fix chain (per-job)). * `false` for Action jobs and by default. Persisted as 0/1 in SQLite. */ autoFix: boolean; /** * Finding-subset targeting for FIXER jobs * (`job.schema.json#/properties/findingIds`, frozen at submit): the * `state_findings` ids this job resolves. `null` = whole-node * targeting. Persisted as JSON in `state_jobs.finding_ids_json`. */ findingIds: readonly number[] | null; /** Target `node.path`. */ nodeId: string; contentHash: string; nonce: string; priority: number; status: JobStatus; failureReason?: JobFailureReason | null; runner?: JobRunner | null; /** * Optional TTL (seconds), resolved at submit from explicit operator * sources only (`--ttl` flag, `jobs.perExtensionTtl`, * `jobs.ttlSeconds`). `null` = the job never expires (the default); * the reaper skips it and `sm doctor`'s `jobs-overdue` check advises * instead. Frozen. */ ttlSeconds: number | null; createdAt: number; claimedAt?: number | null; finishedAt?: number | null; expiresAt?: number | null; submittedBy?: string | null; } interface HistoryStatsTotals { executionsCount: number; completedCount: number; failedCount: number; tokensIn: number; tokensOut: number; durationMsTotal: number; } interface HistoryStatsTokensPerExtension { extensionId: string; extensionVersion: string; executionsCount: number; tokensIn: number; tokensOut: number; durationMsMean: number | null; durationMsMedian: number | null; } interface HistoryStatsExecutionsPerPeriod { periodStart: string; periodUnit: 'day' | 'week' | 'month'; executionsCount: number; tokensIn: number; tokensOut: number; } interface HistoryStatsTopNode { nodePath: string; executionsCount: number; lastExecutedAt: number; } interface HistoryStatsPerExtensionRate { extensionId: string; rate: number; executionsCount: number; failedCount: number; } interface HistoryStatsErrorRates { global: number; perExtension: HistoryStatsPerExtensionRate[]; perFailureReason: Record; } /** * `sm history stats --json` payload, conforming to * `spec/schemas/history-stats.schema.json`. `elapsedMs` is the command's * own wall-clock per `cli-contract.md` §Elapsed time. */ interface HistoryStats { schemaVersion: 1; range: { since: string | null; until: string; }; totals: HistoryStatsTotals; tokensPerExtension: HistoryStatsTokensPerExtension[]; executionsPerPeriod: HistoryStatsExecutionsPerPeriod[]; topNodes: HistoryStatsTopNode[]; errorRates: HistoryStatsErrorRates; elapsedMs: number; } interface ScanResult { schemaVersion: 1; /** Unix milliseconds when the scan started. */ scannedAt: number; /** * Filesystem roots that were walked during this scan. Spec requires * `minItems: 1`, `runScan` throws if `roots: []` is supplied. */ roots: string[]; /** Provider ids that participated in classification. Empty if no Provider matched. */ providers: string[]; /** Implementation metadata. Populated by `runScan` for self-describing output. */ scannedBy?: ScanScannedBy; /** * Resolved offline tokenizer (encoder) that produced the per-node token * counts in this scan. One of the closed allow-list in * `project-config.schema.json#/properties/tokenizer` (`cl100k_base` * default, `o200k_base`). Mirrors `scan_meta.tokenizer`. Populated by * `runScan` from the resolved `RunScanOptions.tokenizer`; the * incremental path compares the persisted value against the resolved * one and force-recomputes counts when they differ. Absent on synthetic * fixtures / loaders that predate the field. */ tokenizer?: string; /** * Active provider LENS that produced this scan: the id of the gated * Provider whose grammar the corpus was read under (`activeProvider` * from the resolved config, `null` when none is resolvable). Mirrors * `scan_meta.active_provider`. Sibling of `tokenizer` above and read * for the same reason: the lens decides per-node classification and * gates provider-specific extractors, so the incremental path compares * the persisted value against the resolved one and re-classifies every * node when they differ. Absent on synthetic fixtures / loaders that * predate the field, which the comparison reads as a change. */ activeProvider?: string | null; /** * Effective walk ceiling for this scan (`--max-scan ` override on * `sm scan` / `sm watch` / `sm serve`, else `scan.maxScan` from * settings, default 5000). The scan walks, parses, analyzes, and * reference-validates the full corpus up to this number, so references * resolve across the whole project regardless of how many nodes the * map renders. Mirrors `scan_meta.scan_ceiling`. Absent on synthetic * fixtures that bypass the walker. */ scanCeiling?: number; /** * True when the walker reached `scanCeiling` and dropped files in * stable provider-walker order, false otherwise. Drives the CLI "scan * truncated" notice and the UI persistent banner pointing at the * `.skillmapignore` editor. Mirrors `scan_meta.scan_truncated`. Absent * on synthetic fixtures that bypass the walker. */ scanTruncated?: boolean; /** * Effective map render cap for this scan (`--max-nodes ` override, * else `scan.maxNodes` from settings, default 256). Does NOT bound the * scan (the full corpus up to `scanCeiling` is persisted and the * folders tree shows all of it); it only bounds the graph projection. * The UI projects the selected folder branch capped at this number and * raises an in-view banner when a branch exceeds it. Mirrors * `scan_meta.max_render_nodes`. Absent on synthetic fixtures. */ maxRenderNodes?: number; /** * Files the walker skipped because their on-disk size exceeded * `scan.maxFileSizeBytes` (default 1 MiB). Each entry is the * root-relative, forward-slash path (same form as `node.path`) plus * the byte size. Drives the CLI / serve terminal WARN and the UI * banner. Defaults to `[]`; absent on synthetic fixtures that bypass * the walker. */ oversizedFiles?: OversizedFile[]; nodes: Node[]; links: Link[]; issues: Issue[]; stats: ScanStats; } /** * One file the walker skipped for exceeding `scan.maxFileSizeBytes`. * Mirrors `scan-result.schema.json#/properties/oversizedFiles/items`. */ interface OversizedFile { /** Root-relative, forward-slash path (same form as `node.path`). */ path: string; /** On-disk size of the skipped file, in bytes. */ bytes: number; } /** * Plugin-surface types, hand-written to mirror * `spec/schemas/plugins-registry.schema.json#/$defs/PluginManifest` and the * extension-kind manifests under `spec/schemas/extensions/`. * * Per ROADMAP §DTO gap (review-pass decision): the proper emission of * typed DTOs from `@skill-map/spec` is deferred to a future iteration when a * third consumer (real providers / extractors / rules) forces a single * source of truth. Until then, both `ui/src/models/` and `src/kernel/types/` * hand-curate their own local mirror, the risk of drift is accepted at * this scale (17 schemas) and flagged in the roadmap. */ /** * Plugin storage declaration. Matches the `storage` block in the plugin * manifest schema: the shared `state_plugin_kvs` table (mode `kv`). * Absent = the plugin does not persist state at all. * * The optional output-schema declaration (spec § A.12, opt-in * correctness for plugin custom storage) is `schema`, a single relative * path validating the value written by `ctx.store.set(key, value)`. * Absent = permissive (status quo, no validation). Schema load failures * surface as `load-error`. `emitLink` and `enrichNode` keep their * universal kernel validation regardless of this field. */ type TPluginStorage = { mode: 'kv'; schema?: string; }; /** * Raw `plugin.json` shape after successful AJV validation. * * **Structure-as-truth**: the plugin id comes from the directory name * (`//plugin.json`); it is NOT a manifest field. The loader * rejects manifests carrying an `id` literal. Settings moved out of * `plugin.json` into each extension's own manifest with the same refactor. */ interface IPluginManifest { version: string; specCompat: string; /** * Required semver range against the kernel's view-slots + input-types * catalog version. Mismatch surfaces as `incompatible-catalog`. Promoted * from optional to required with the structure-as-truth refactor, * declaring compat is part of the plugin contract regardless of which * catalog surfaces it actually consumes. */ catalogCompat: string; /** Required short description shown in `sm plugins list` and the UI. */ description: string; /** * Optional inspector-only ordering hint (default 100). Sorts the * per-plugin sections in the inspector body. Never affects execution * order. See `plugins-registry.schema.json#/$defs/PluginManifest`. */ order?: number; storage?: TPluginStorage; author?: string; license?: string; homepage?: string; repository?: string; } /** * Failure mode produced by the loader when a plugin cannot be loaded. * Matches the three states named in spec §Plugin discovery / load. * * - `incompatible-spec`: manifest parsed fine but `semver.satisfies` failed * against the installed `@skill-map/spec` version. * - `invalid-manifest`: `plugin.json` missing, unparseable, failing AJV on * the base manifest schema, OR the exported extension shape failed its * kind-specific schema (per spec/architecture.md §Plugin discovery, * "AJV rejects unknown `slot` names with `invalid-manifest`"). * - `load-error`: manifest parsed but an extension module failed to import. */ /** * Possible outcomes after the loader sees a plugin.json. Mirrors the * `status` enum in `spec/schemas/plugins-registry.schema.json`. * * - `enabled` , manifest valid, specCompat satisfied, every * extension imported and validated. * - `disabled` , user-toggled off via `sm plugins disable` or * `settings.json#/plugins//enabled`. Manifest * is parsed and surfaced (so `sm plugins list` * shows it), but extensions are not imported. * - `incompatible-spec` , manifest parsed but `semver.satisfies` failed. * - `invalid-manifest` , `plugin.json` missing, unparseable, AJV-fails, * OR the directory name does not equal the * manifest id (a cheap structural rule that * rules out same-root collisions by construction: * a filesystem cannot contain two siblings with * the same name). * - `load-error` , manifest passed, an extension module failed. * - `id-collision` , two plugins reachable from different roots * (project + global, or any `--plugin-dir` * combination) declared the same `id`. Both * collided plugins receive this status; no * precedence rule applies. The user resolves * by renaming one of them and rerunning. */ type TPluginLoadStatus = 'enabled' | 'disabled' | 'incompatible-spec' | 'incompatible-catalog' | 'invalid-manifest' | 'load-error' | 'id-collision'; /** * An extension that exists on disk but whose module was deliberately * NOT imported: no trust grant, the plugin is disabled, or this * particular extension is disabled. * * It carries real metadata rather than a bare id because * `extension.json` is readable without executing anything, so an * operator can review a project-local plugin's full inventory BEFORE * granting it trust, which is exactly what the untrusted advisory tells * them to do. * * Deliberately NOT an `ILoadedExtension` with optional fields, and * deliberately not folded into `IDiscoveredPlugin.extensions`: it has no * `instance` and no `module`, so it is structurally incapable of * reaching the registry, the composer or the orchestrator. Membership in * `extensions` is the proof that an extension was allowed to execute; * nothing here can be mistaken for that. */ interface IUnloadedExtension { kind: ExtensionKind; id: string; pluginId: string; version: string; description: string; stability?: TExtensionStability; defaultEnabled?: boolean; /** What WOULD have been imported. Never imported. */ entryPath: string; reason: TUnloadedReason; } /** * Why an on-disk extension was not imported. Kept distinct so the CLI * can tell an operator whether to run `sm plugins trust` (a security * decision) or `sm plugins enable` (an operational one); conflating them * is how an operator learns to reflexively grant trust. */ type TUnloadedReason = 'plugin-untrusted' | 'plugin-disabled' | 'extension-disabled'; interface ILoadedExtension { kind: ExtensionKind; id: string; /** * Owning plugin namespace, `manifest.id` of the `plugin.json` that * declared this extension. Composed with `id` to form the qualified * registry key `/`. Per spec § A.6 the loader injects * this from the manifest; an extension that hand-declares a * mismatching `pluginId` is rejected as `invalid-manifest`. */ pluginId: string; version: string; /** * Short description, read from `extension.json`. Stamped here (and * merged onto `instance`) so consumers read a typed field instead of * shape-checking the module export. */ description: string; /** * Optional lifecycle label read from `extension.json`. Stamped here by * the loader so consumers (CLI list/show, BFF projection) read a typed * field instead of shape-checking `instance`. Absent when the file * does not declare it. */ stability?: TExtensionStability; /** * Optional installed-default override (spec * `extension-manifest.schema.json#/properties/defaultEnabled`): a * declared value wins over the stability-derived default when * resolving the enabled axis. */ defaultEnabled?: boolean; entryPath: string; /** Raw module namespace as returned by the dynamic `import()`. */ module: unknown; /** * Runtime extension instance ready for the registry / orchestrator, * the `default` export of `module` (or the module itself when no * default), shallow-cloned with `pluginId` injected per spec § A.6. * * The clone is essential: ESM caches the imported module, so two * plugins importing the same file would otherwise share a single * mutable instance and overwrite each other's `pluginId`. The loader * owns the clone so consumers (CLI, tests) never need to mutate. */ instance: unknown; } interface IDiscoveredPlugin { /** Absolute path to the plugin directory. */ path: string; /** Plugin id, populated from the manifest if it parsed, else a path hint. */ id: string; status: TPluginLoadStatus; /** Only present when status === 'enabled' or 'incompatible-spec'. */ manifest?: IPluginManifest; /** * Only present when status === 'enabled'. * * **Membership here is the proof that an extension was allowed to * execute**: it means the plugin was trusted, the plugin was enabled, * this extension was enabled, and only then was its module imported. * Everything discovered but not imported rides in * `unloadedExtensions` instead. Consumers that feed the registry, the * composer or the orchestrator read ONLY this field, which is what * makes "disabled code never runs" a structural property rather than a * convention every call site has to remember. */ extensions?: ILoadedExtension[]; /** * Extensions found on disk whose module was deliberately not imported * (untrusted plugin, disabled plugin, or disabled extension). * * `extensions` ∪ `unloadedExtensions` is the plugin's full declared * inventory. Present alongside `extensions` on an `enabled` plugin * (some of its extensions may be individually disabled) and alongside * a `disabled` status (where `extensions` is absent entirely). * * Populated from each extension's `extension.json`, so it costs no * code execution and stays available exactly when the operator most * needs it: reviewing a project-local plugin before trusting it. */ unloadedExtensions?: IUnloadedExtension[]; /** * Runtime-only, never persisted, never spec-modeled. * * Spec § A.12, opt-in JSON Schema validation for plugin custom storage. * Populated by the loader when `manifest.storage.schema` declares a * schema path the loader successfully read and AJV-compiled. Consumed * by the runtime store wrapper to validate `ctx.store.set(key, value)` * before persisting. * * Keyed by the sentinel `__kv__` for the single value-shape schema. * The map shape survives the runtime contract change if the store ever * grows multiple namespaces. * * Absent (`undefined`) when no schemas were declared OR when the load * surfaced a `load-error` (the discovered plugin keeps its failure * status; consumers must check `status === 'enabled'`). */ storageSchemas?: Record; /** Human-readable diagnostic shown by `sm plugins list/show`. */ reason?: string; /** * Runtime-only, never persisted, never spec-modeled. * * Set by the loader when a project-local disk plugin was discovered * (manifest parsed + surfaced) but its extension code was NOT imported * because the operator never granted local trust (no scope-lock * override enables the plugin or any of its extensions). The plugin * still rides as `status: 'disabled'` (extensions absent), this flag * lets the runtime distinguish "not yet trusted" from an explicit * `sm plugins disable`, so it can emit a one-time "found but not * loaded, run `sm plugins enable`" notice. See spec § Plugin trust. */ untrusted?: boolean; } /** * Runtime-only, a single AJV-compiled storage schema attached to a * loaded plugin. The schema path (relative to the plugin directory) is * preserved so error messages can name the offending file. `validate` * is the AJV `ValidateFunction` itself: it returns `true` on shape * match, otherwise `false` with `validate.errors` populated. Typed * loosely here (no `ajv/dist/2020.js` import) to keep the shared type * module free of Ajv at compile time; the runtime adapter narrows. */ interface IPluginStorageSchema { /** Plugin-relative path to the schema file (`storage.schemas[]` or `storage.schema`). */ schemaPath: string; /** AJV-compiled validator. `errors` is populated after a failed call. */ validate: ((row: unknown) => boolean) & { errors?: { instancePath: string; message?: string; keyword: string; }[] | null; }; } /** * Plugin store wrapper, runtime injection for `ctx.store`. * * One shape, mirroring the manifest's `storage` block documented in * `spec/plugin-kv-api.md`: the `KvStore`, a full four-method accessor * (`get` / `set` / `delete` / `list`) the spec declares as a MUST. * `set` AJV-validates `value` against the schema declared by * `manifest.storage.schema` (single value-shape) when present. Absent = * permissive. `get` / `list` never validate, they return what is * stored. * * The wrapper is storage-engine agnostic, it accepts a `persist` port * the caller supplies. The persistence side (SQLite, in-memory, mock) * is the caller's concern; this wrapper owns the plugin-facing * contract: key validation, JSON encoding / decoding, size ceilings, * the AJV gate, the typed error taxonomy, and the `nodePath ↔ nodeId` * sentinel translation. That separation lets the test suite exercise * the semantics without spinning up a real DB and lets the SQLite * adapter (`kernel/adapters/sqlite/plugin-kvs.ts`) plug in unchanged. * * Scoping is structural, not conventional: `pluginId` is captured when * the wrapper is built and the `persist` port handed in is already * bound to that same plugin (see `core/runtime/plugin-stores.ts`). * A plugin has no way to name another plugin's rows, matching * `spec/plugin-kv-api.md` § Scoping. * * Universal validation (`emitLink` against `link.schema.json`, * `enrichNode` against `node.schema.json`) is unaffected, it lives on * the orchestrator side and runs regardless of the plugin's * `outputSchema` opt-in. */ /** * Sentinel key under which the store's single value-shape schema lives * inside `IDiscoveredPlugin.storageSchemas`. The map shape * (`Record`) is kept rather than a bare * field so a future second namespace can join without reshaping the * discovered-plugin surface. */ declare const KV_SCHEMA_KEY = "__kv__"; /** * Internal `node_id` value standing in for "global scope" (no * `nodePath`). `spec/plugin-kv-api.md` § Scoping mandates a sentinel * empty string because the backing table's composite primary key * `(plugin_id, node_id, key)` cannot carry NULL. Omitted, `undefined` * and explicit `null` all normalise to this on the way in; on the way * out it surfaces as `IKvEntry.nodePath === null`. */ declare const KV_GLOBAL_NODE_ID = ""; /** Hard key ceiling, `spec/plugin-kv-api.md` § Key constraints. */ declare const KV_KEY_MAX_BYTES = 256; /** * Soft key ceiling. Above this the wrapper MAY warn (spec: "MAY log a * warning ... but MUST NOT reject below 256"), so crossing it is an * advisory, never a rejection. */ declare const KV_KEY_WARN_BYTES = 128; /** * Reference-implementation per-value ceiling (1 MiB). The spec leaves * the number to the implementation but requires a typed error rather * than silent truncation. */ declare const KV_VALUE_MAX_BYTES: number; /** * Aggregate storage ceiling per plugin, counted per wrapper instance * (one scan) as BYTES ACCEPTED BY `set`, not net bytes stored. * * Why 4 MiB: an extractor runs once per node, so a plugin on a * 5,000-node tree writing a 200-byte record per node lands around * 1 MB, comfortably clear. Reaching 4 MiB means the plugin is either * storing bulk content (which does not belong here) or looping. It is * 4x the single-value ceiling, so one legitimate large write cannot * trip it either. * * This is a HARD ceiling: the `set` that would cross it is rejected * with `KvBudgetExceededError` and nothing is persisted. An advisory * alone was the earlier design and it was the wrong call: the value of * a budget is that the database cannot grow without bound, and a * warning a plugin never reads bounds nothing. The scan itself is not * aborted, the extractor sees a typed rejection and decides, exactly * as it does for an oversized value. * * The budget is per plugin and per wrapper, so it resets each scan. It * therefore bounds the damage ONE scan can do, not the total a plugin * accumulates across many; the latter needs a stored running total, * which is a bigger change than this ceiling is worth. */ declare const KV_PLUGIN_MAX_TOTAL_BYTES: number; /** * How many distinct over-soft-limit keys one wrapper tracks before it * stops warning. Bounds BOTH the retained Set (a plugin generating a * unique long key per node would otherwise grow it for the whole scan) * and the advisory volume. The point of the advisory is "your key * naming is too long", which lands in the first few lines; the * hundredth repetition is noise the operator scrolls past. */ declare const KV_KEY_WARN_MAX_TRACKED = 20; /** * Display ceiling for plugin-controlled strings interpolated into an * error or advisory. Mirrors `PLUGIN_ID_DISPLAY_CAP` in * `core/runtime/plugin-runtime/warnings.ts`; a key is capped at 256 * BYTES on the accept path but a REJECTED key is unbounded, so the cap * has to live on the render path too. */ declare const KV_DISPLAY_CAP = 200; /** * One stored row as the plugin sees it. Mirrors the spec's * `KvEntry`: `value` is already JSON-decoded and `nodePath` is `null` * for globally-scoped rows. */ interface IKvEntry { key: string; value: unknown; nodePath: string | null; updatedAt: number; } /** * Per-call scope selector. `nodePath` omitted / `undefined` / `null` * all mean the global scope. */ interface IKvScopeOptions { nodePath?: string | null; } /** `list` selector: scope plus an optional key-prefix filter. */ interface IKvListOptions extends IKvScopeOptions { prefix?: string; } /** * One row as the persistence port speaks it: `nodeId` is the sentinel * form (`''` for global) and the value is still an encoded JSON string. * Translation to / from `IKvEntry` happens in the wrapper. */ interface IKvPersistedRow { nodeId: string; key: string; valueJson: string; updatedAt: number; } /** * Engine-agnostic persistence port, already bound to a * single `pluginId` by whoever constructs it. Every method may be sync * or async so an in-memory test double stays a plain object literal. * * Ordering is NOT required from `list`; the wrapper sorts by key ASC * so the spec's SHOULD holds for every backing engine. The SQLite * adapter still orders in SQL because the index makes it free. */ interface IKvStorePersist { get(nodeId: string, key: string): IKvPersistedRow | null | Promise; set(nodeId: string, key: string, valueJson: string, updatedAt: number): void | Promise; delete(nodeId: string, key: string): boolean | Promise; list(nodeId: string, prefix: string | undefined): readonly IKvPersistedRow[] | Promise; } /** * The plugin-facing `KvStore` from `spec/plugin-kv-api.md`. * * - `get` returns the decoded value or `null`; a missing row is not an * error. * - `set` upserts. It runs the AJV gate (when the plugin declared a * value schema), JSON-encodes, checks the size ceiling, then * forwards. Any rejection happens before persistence, so a failed * `set` leaves no row. * - `delete` returns `true` iff a row was removed. Idempotent. * - `list` returns the scope's entries, optionally filtered by key * prefix, ordered by key ASC. * * Every method is scoped to the wrapper's plugin and to the requested * `nodePath` (or the global sentinel). There is deliberately no * `transaction()`, the store is single-operation atomic by contract. */ interface IKvStoreWrapper { get(key: string, options?: IKvScopeOptions): Promise; set(key: string, value: T, options?: IKvScopeOptions): Promise; delete(key: string, options?: IKvScopeOptions): Promise; list(options?: IKvListOptions): Promise; } /** * Shape exposed to extractors via `ctx.store`. An alias rather than a * bare re-export of `IKvStoreWrapper`: consumers name the injected * surface by its role (the plugin store) while the wrapper interface * keeps naming the contract it implements. */ type TPluginStore = IKvStoreWrapper; /** Constructor bag for `makeKvStoreWrapper`. */ interface IKvStoreWrapperOptions { pluginId: string; schema: IPluginStorageSchema | undefined; persist: IKvStorePersist; /** * Optional advisory sink for the soft key-length limit. Called at * most once per distinct key per wrapper instance so a plugin * writing the same long key on every node does not flood the * operator's terminal. */ warn?: (message: string) => void; } declare function makeKvStoreWrapper(opts: IKvStoreWrapperOptions): IKvStoreWrapper; /** Constructor bag for `makePluginStore`. */ interface IMakePluginStoreOptions { plugin: IDiscoveredPlugin; persistKv?: IKvStorePersist; warn?: (message: string) => void; } /** * Convenience entry point: build the wrapper for a discovered plugin * that declared storage. Returns `undefined` when the plugin declared * no storage at all (the orchestrator omits `ctx.store` in that case, * per the existing contract), and when the caller supplied no * persistence to write through. */ declare function makePluginStore(opts: IMakePluginStoreOptions): TPluginStore | undefined; /** * Combination algebra for plugin-contributed link-confidence adjustments. * * Confidence ([0,1]) starts at the kernel's 1.0 baseline (seeded on every * link by `liftResolvedLinkConfidence`). `score`-phase analyzers then * contribute attributed operations via `ctx.adjustConfidence(link, op)`; * the orchestrator buffers them and folds all ops for a link into a final * value with `foldConfidence`. The kernel dogfoods this exact API through * two built-in score-phase detectors, each co-locating its penalty op * with the finding it reports: `core/name-reserved` * (reserved → `delta -0.9` → 0.1), `core/reference-broken` * (broken → `delta -0.75` → 0.25). A clean-resolved or untouched link folds * to `clamp(base)` and keeps the 1.0 baseline. * * The fold is deterministic and order-independent across the four * buckets (set / delta / floor / ceil are each commutative): * 1. base = the extractor-emitted confidence. * 2. `set`: a hard override. When more than one `set` lands on a link * the LAST in the caller's canonical order wins (the caller pre- * sorts ops by `(pluginId, extensionId)` so the winner is stable); * a single `set` simply replaces the base. * 3. `delta`: additive (may be negative), summed. * 4. `floor`: raise to at least the value (`max`). * 5. `ceil`: lower to at most the value (`min`), today's broken cap. * Applied AFTER floor so a cap dominates a floor/ceil collision. * 6. clamp to [0,1] ONCE at the end, so opposing deltas round-trip * (e.g. `-0.4` then `+0.4` returns to base, never clipped midway). * * A link no scorer touches folds to `clamp(base)` (the kernel's 1.0 * baseline), identical to a clean-resolved link. With only the built-in * detectors active a link is at most reserved OR broken (mutually * exclusive), so it gets at most one penalty delta and folds to 0.1 / 0.5 * respectively; a clean link keeps the 1.0 base. Third-party scorers layer * additional ops on top, summed deterministically before the single clamp. */ /** * One attributed adjustment, buffered by the orchestrator as a scorer * calls `adjustConfidence`. `link` is held by object identity (the same * link objects flow through the post-walk pipeline). Persisted to * `scan_link_scores` for the "why is this link at X?" audit trail. */ interface IConfidenceAdjustment { readonly link: Link; readonly pluginId: string; readonly extensionId: string; readonly op: TConfidenceOp; } /** * Origin lane of a `state_findings` row (`spec/db-schema.md` * §state_findings). `extension` = one entry of a finder Analyzer's * validated `findings[]` array; `kernel` = a safety row the record path * synthesized from a probabilistic report's `safety` block under one of * the reserved type slugs. */ type TFindingOrigin = 'extension' | 'kernel'; /** * The lifecycle STATE a finding moved into (`spec/db-schema.md` * §state_findings, "Finding lifecycle state"). `fixed` = resolved; * `human-decision` = a fixer proposed but the choice is the author's * (renamed from the earlier `declined`, which read as a dead-end when it is * the most action-demanding state). * * A lifecycle state, NOT a verdict: `fixed` means "resolved", not "verified * gone". It hides from the default `sm findings` view but the row persists * and stays re-checkable (only the finder re-judging the current body * deletes or reopens it). `human-decision` stays VISIBLE: its note is the * fixer's PROPOSAL, the author's TODO. `null` = open. */ type TFindingResolution = 'fixed' | 'human-decision' | 'dismissed'; /** * WHO decided a `fixed` finding (`state_findings.resolution_actor`, * `spec/db-schema.md` §state_findings). One rule: **any user interaction * makes it `human`; only a fully autonomous fix with zero user interaction * is `fixer`.** So an unattended processing run that applies a clear-cut fix is * `fixer`; an interactive processing run where the operator approved the edit, chose * among options, or a `sm findings resolve` is `human`. `null` on a * `human-decision` (undecided) or open row. */ type TResolutionActor = 'human' | 'fixer'; /** * Row-level filter for `port.scans.findNodes(...)` (driven by * `sm list`'s flags). All fields are optional, an empty filter * returns every node sorted by `path` asc. */ interface INodeFilter { /** Restrict to a single node kind. Open string (matches `Node.kind`). */ kind?: string; /** * When `true`, keep only nodes whose path is referenced by at least * one `scan_issues.nodeIds` array. */ hasIssues?: boolean; /** * Sort column. The adapter validates against its own whitelist and * rejects anything else with an Error (the CLI's own usage-error * exit is the right place to surface a bad `--sort-by`; the port * defends in depth). */ sortBy?: string; /** `'asc'` or `'desc'`. Defaults to the adapter's per-column convention. */ sortDirection?: 'asc' | 'desc'; /** Cap the result. Positive integer; absent → no limit. */ limit?: number; } /** * Bundled fetch for `port.scans.findNode(path)`, one node and * everything `sm show ` displays alongside it. Every field is * computed from `scan_*` zone reads only; per-domain data (history, * jobs, plugin enrichments) ships through other namespaces. */ interface INodeBundle { node: Node; linksOut: Link[]; linksIn: Link[]; issues: Issue[]; } /** * A stored per-node summary row (`state_summaries`), as returned by * `port.summaries.forNode(nodeId)`. `report` is the parsed `summary_json` * (the validated summarizer report); `bodyHashAtGeneration` lets a reader * (`sm show`) flag the summary `(stale)` by comparing against the node's * current `scan_nodes.body_hash`. */ interface ISummaryRecord { nodeId: string; kind: string; summarizerActionId: string; summarizerVersion: string; bodyHashAtGeneration: string; generatedAt: number; /** Recording agent's self-reported model; `null` when undeclared. */ model: string | null; report: Record; } /** * Write intent handed to `port.jobs.recordTerminal(execution, summary?)` * when the recorded Action's report schema is a per-node summary schema * (`summaryKindOfReportSchema`, see `kernel/jobs/summary-schema.ts`). Carries only the * caller-known fields; the adapter reads the target node's live `kind` * and `body_hash` from `scan_nodes` inside the record transaction (and * skips the upsert when the node is absent). `summaryJson` is the * serialized validated report. */ interface ISummaryWriteIntent { summarizerActionId: string; summarizerVersion: string; generatedAt: number; /** Agent-self-reported `--model`; `null` when undeclared. */ model: TReportedModel; summaryJson: string; } /** * One fresh `state_findings` row the record path composes BEFORE the * node-derived fields are known. `bodyHashAtGeneration` is stamped by the * adapter from the live `scan_nodes.body_hash` inside the record * transaction; `extensionId` / `extensionVersion` / `generatedAt` / * `jobId` travel on the enclosing `IFindingsWriteIntent`. */ interface IFindingRowInput { origin: TFindingOrigin; type: string; severity: Severity; message: string; detail: string | null; confidence: number; } /** Recording agent's self-reported model id; `null` when undeclared. */ type TReportedModel = string | null; /** * Write intent handed to `port.jobs.recordTerminal(execution, summary?, * findings?)` when the recorded job is a probabilistic extension whose * `completed` report produces `state_findings` rows: the finder lane * (`origin: 'extension'`, Analyzers only) plus the kernel safety lane * (`origin: 'kernel'`, any probabilistic report whose `safety` block flags * trouble). The adapter DELETEs every existing row for * `(nodeId, extensionId)` (both origins) then inserts `rows`, in the SAME * transaction as the `state_executions` insert + job transition; an empty * `rows` array is a clean verdict that erases the prior judgment. The * whole write is skipped (previous rows kept) when the target node has * disappeared from `scan_nodes` (`spec/db-schema.md` §state_findings). */ interface IFindingsWriteIntent { extensionId: string; extensionVersion: string; generatedAt: number; jobId: string | null; /** * Agent-self-reported `--model` of the recording callback, stamped * onto EVERY row of the intent (both lanes); `null` when undeclared. */ model: TReportedModel; rows: IFindingRowInput[]; } /** * A stored `state_findings` row as returned by `port.findings.list(...)`, * camelCase mirror of the SQL columns plus the derived `stale` boolean * (`bodyHashAtGeneration` differs from the node's live * `scan_nodes.body_hash`, or the node is gone from the scan entirely). */ interface IFindingRecord { id: number; nodeId: string; extensionId: string; extensionVersion: string; origin: TFindingOrigin; type: string; severity: Severity; message: string; detail: string | null; confidence: number; /** Recording agent's self-reported model; `null` when undeclared. */ model: string | null; /** * The lifecycle state this finding moved into; `null` (open) until a * fixer or the operator resolves it. `fixed` hides from the default * `sm findings` view (re-checkable, not deleted); `human-decision` stays * visible with the fixer's PROPOSAL (the author's TODO) in * `resolutionNote` (`spec/db-schema.md` §state_findings). */ resolution: TFindingResolution | null; /** * WHO decided a `fixed` finding (`human` / `fixer`); `null` for a * `human-decision` (undecided) or open row (`spec/db-schema.md` * §state_findings). */ resolutionActor: TResolutionActor | null; /** The one-line reason, verbatim (agent-supplied: sanitize at render). */ resolutionNote: string | null; /** * The fixer's qualified extension id (agent-adjacent: sanitize at * render); `null` for a purely human resolution (`sm findings resolve`). */ resolutionBy: string | null; resolutionAt: number | null; bodyHashAtGeneration: string; generatedAt: number; jobId: string | null; stale: boolean; } /** * Discriminated outcome of `port.findings.resolveByHuman(id, note, nowMs)`, * the operator marking a finding `fixed` themselves (`sm findings resolve`, * `spec/cli-contract.md`): * - `resolved`, an OPEN or `human-decision` row moved to `fixed` / * `human` (the updated `finding` rides along for the `--json` echo). * - `already-fixed`, the row is already `fixed` (the verb exits 2). * - `not-found`, no `state_findings` row carries that id (exit 5). */ type TFindingResolveOutcome = { kind: 'resolved'; finding: IFindingRecord; } | { kind: 'already-fixed'; } | { kind: 'not-found'; }; /** * Outcome of `port.findings.dismissByHuman(id, note, nowMs)`, the * ROW-grain dismissal (`sm findings dismiss `, the tray's X; * 2026-07-22): `dismissed` carries the updated row; `already-dismissed` * exits 2; `not-found` exits 5. */ type TFindingRowDismissOutcome = { kind: 'dismissed'; finding: IFindingRecord; } | { kind: 'already-dismissed'; } | { kind: 'not-found'; }; /** * Outcome of `port.findings.reopen(id, nowMs)` (`sm findings reopen`): * `reopened` carries the updated row; `already-open` exits 2; * `not-found` exits 5. */ type TFindingReopenOutcome = { kind: 'reopened'; finding: IFindingRecord; } | { kind: 'already-open'; } | { kind: 'not-found'; }; /** * One entry of a fixer report's `resolved[]`, narrowed from the * AJV-validated payload (`spec/job-lifecycle.md` §Findings injection for * fixers, "The resolution"): the `id` the fixer echoed back from the * injected findings section, the `state` it moved the finding into * (`fixed` = it edited the file to resolve it, `human-decision` = it did * not; the fix needs the author's choice and the `note` is the fixer's * PROPOSAL), the deciding actor `by` (`fixer` = zero user interaction, * `human` = any user interaction was involved), and its one-line `note`. * * `by` is stamped onto `resolution_actor` and is meaningful ONLY on a * `fixed` entry (`null` on a `human-decision` one, where the actor is * undecided). */ interface IFindingResolutionEntry { id: number; state: TFindingResolution; by: TResolutionActor | null; note: string; } /** * Write intent handed to `port.jobs.recordTerminal(execution, summary, * findings, resolutions)` when the recorded job's extension is a FIXER (a * probabilistic Action declaring `precondition.analyzerIds`) and its * report validated. The adapter stamps each entry onto the finding its * `id` names, inside the SAME transaction as the execution insert + job * transition. * * Every entry is SKIPPED silently when its `id` no longer exists, when * the row's node is not the job's target node, or when the row's * `extension_id` is outside `analyzerIds`: a missing id is a benign race * (the finder re-ran between submit and record, so the resolution is * moot), and the node / analyzer guards are the defensive scope, a fixer * can NEVER stamp a finding outside its own (`spec/db-schema.md` * §state_findings). */ interface IFindingResolutionIntent { /** The fixer's qualified extension id, stamped as `resolution_by`. */ resolvedBy: string; /** * The fixer's declared `precondition.analyzerIds`: a finding is only * stampable when its `extension_id` matches one * (`matchesQualifiedExtensionFilter` semantics, qualified or bare). */ analyzerIds: readonly string[]; /** Stamped as `resolution_at` on every entry that lands. */ resolvedAt: number; entries: readonly IFindingResolutionEntry[]; } /** * Row-level filter for `port.findings.list(...)` (driven by * `sm findings`' flags and `sm show`'s per-node section). All fields * optional; an empty filter returns every non-stale row. */ interface IFindingsListFilter { /** Restrict to rows whose `node_id` equals the path. */ nodeId?: string; /** * Qualified (`/`) or bare extension ids; a row matches * when its stored qualified `extension_id` matches any entry * (`matchesQualifiedExtensionFilter` semantics, mirroring * `sm check --analyzers`). Empty / absent = every extension. */ extensionIds?: readonly string[]; /** Restrict to rows whose `type` slug equals the value. */ type?: string; /** MINIMUM severity: `warn` keeps `warn` + `error`, drops `info`. */ minSeverity?: Severity; /** Keep rows whose `generated_at` >= the value (Unix ms). */ sinceMs?: number; /** Keep rows whose `confidence` >= the value. */ minConfidence?: number; /** * When `true`, stale rows are INCLUDED (each flagged via the derived * `stale` boolean). Default `false`: stale rows are excluded, matching * `sm findings`' default read (`spec/cli-contract.md` §sm findings). */ includeStale?: boolean; } /** * A stored per-node enrichment state row (`state_enrichments`), as * returned by `port.enrichments.listStateForNode(nodeId)` / * `listStaleStateCandidates()`. `providerId` carries the enriching * Action's qualified id (e.g. `github/enrichment`); `data` is the * parsed `data_json` (the validated enrichment report). Model A of the * enrichment split: Model B (Extractor outputs) lives in * `node_enrichments` behind the transactional-only `upsertMany`, do not * conflate the two. */ interface IStateEnrichmentRecord { nodeId: string; providerId: string; data: Record; verified: boolean | null; fetchedAt: number; staleAfter: number | null; } /** * Upsert payload for one `state_enrichments` row * (`port.enrichments.upsertState` / the transactional * `tx.enrichments.upsertState`). `dataJson` is the already-serialized * validated report; `verified` is lifted from the report by the caller * (`null` when the report carries no boolean verdict); `staleAfter` is * `null` in v1 (no declared refresh policy, body-hash drift is the only * staleness signal, `spec/db-schema.md` §state_enrichments). */ interface IStateEnrichmentUpsert { nodeId: string; providerId: string; dataJson: string; verified: boolean | null; fetchedAt: number; staleAfter: number | null; } /** * Output of `port.scans.countRows()`. Used by `sm scan` to decide * whether the persist would wipe a populated DB (the "refusing to * wipe" guard) and by `sm db status` for the human summary. */ interface INodeCounts { nodes: number; links: number; issues: number; } /** * Lightweight per-node projection for the BFF `/api/folders` endpoint. * Carries only the cheap scalar columns the SPA folders tree needs * (`path`, `kind`, the two link counts, total tokens, mtime), never the * full `Node` (no frontmatter, body, links, signals, contributions). * Pushed straight from `scan_nodes` so a 50K corpus does not hydrate the * whole `ScanResult` into memory. */ interface ILiteNode { path: string; kind: string; linksInCount: number; linksOutCount: number; tokensTotal: number | null; modifiedAtMs: number | null; /** * The persisted `scan_nodes.sidecar_status`, null when there is no * parseable sidecar. Lets the folders rail flag staleness corpus-wide, * sibling of the issue counts. */ sidecarStatus: string | null; } /** * Per-node issue incidence counts by severity, output of * `port.scans.issueCountsByPath()`. One entry per node that has at least * one error- or warn-severity issue whose `nodeIds` array includes the * path; nodes with no error / warn issues are absent from the map. The * `info` severity is intentionally ignored (the SPA badges only error / * warn). Counts are issue incidence (one per matching issue), the same * semantics the UI's `countIssuesByPath` rolls up per node. */ interface IIssueIncidenceCount { error: number; warn: number; } /** * Per-node count of UNRESOLVED, non-stale probabilistic findings by * severity, output of `port.findings.countUnresolvedByPath(paths)`. * "Unresolved" = NOT `fixed` (so `resolution IS NULL` open rows AND * `human-decision` proposals awaiting the author both count), non-stale, * matching the `sm findings` default view (`findings-view.ts` * `isFindingShown`) so the card chip and the inspector agree * (`spec/view-slots.md` §card.footer.right). Only `warn` / `error` * are tallied (`info` is not surfaced on the card, mirroring * `IIssueIncidenceCount`); nodes with no such finding are * absent from the map (the caller defaults them to `{ warn: 0, error: * 0 }`). Backs the BFF read-time fold that sums a node's findings into * `core/issue-counter`'s aggregate severity chips. */ interface IFindingSeverityCount { warn: number; error: number; } /** * Input of `port.scans.loadBranch(...)`: the map scope overrides * (`spec/cli-contract.md` §Map scope overrides). `include` / `exclude` * carry the non-root override paths; the root override rides * `rootExcluded` (the path `''` never appears in the arrays). A node's * effective state is the override of its NEAREST ancestor (self * included); no matching override = included. */ interface IBranchScope { include: string[]; exclude: string[]; rootExcluded: boolean; } /** * Output of `port.scans.loadBranch(...)`, the override-scoped + capped * graph projection the BFF `/api/branch` endpoint returns. `nodes` is * the first `LIMIT` nodes of the scoped set (nearest-ancestor override * evaluation over `IBranchScope`), ordered by the SENIORITY FILL rule * (spec §Map scope overrides): root excluded with two or more includes * ranks rows by the first include (in `IBranchScope.include` order) * that admits them, then path; every other shape is plain stable path * order; `links` carries only edges whose source AND RESOLVED target * (`resolvedTarget`, else the raw `target` for path-style links) are * both in that node set, so a trigger-style `invokes` / `mentions` edge * that resolves to a rendered node is kept and a genuinely-broken link * is dropped; `issues` carries only those whose `nodeIds` intersect it. * `total` is the count of scoped nodes BEFORE the cap (so the route can * compute `truncated`), post-override by construction. `paths` echoes * the (de-duped, request-ordered) include overrides; the whole-corpus * case echoes `[]`. */ interface IBranchProjection { nodes: Node[]; links: Link[]; issues: Issue[]; total: number; paths: string[]; } /** * Lightweight option bag for `port.scans.persist`. Mirrors the optional * inputs of the `persistScanResult(db, result, inputs)` free function * (`IPersistScanInputs` in `kernel/adapters/sqlite/scan-persistence.ts`), * so the adapter implementation is a one-line delegation; the named-bag * shape lets new optional inputs land without breaking callers. */ interface IPersistOptions { renameOps?: RenameOp[]; extractorRuns?: IExtractorRunRecord[]; enrichments?: IEnrichmentRecord[]; contributions?: IContributionRecord[]; /** * "off-shape visible" follow-up, per-scan records of view * contributions REJECTED at emit time (undeclared ref, or payload * failed the slot's AJV schema). Plain REPLACE-ALL into * `scan_contribution_errors` (delete all, then insert), the same * posture as `scan_issues`. Empty / absent wipes the table (a clean * scan clears any stale rows). Surfaced by `sm plugins doctor`. */ contributionErrors?: IContributionErrorRecord[]; /** * Per-op confidence-attribution audit trail for `scan_link_scores`. * One entry per attributed `ctx.adjustConfidence(link, op)` call a * `score`-phase analyzer buffered this scan; the orchestrator already * folded them into `link.confidence`, so these rows are the attribution * (which plugin / extension / op moved a given link, plus the folded * `result_confidence`). Plain REPLACE-ALL into `scan_link_scores` * (delete all, then insert), the same posture as `scan_issues`. Empty / * absent wipes the table (a scan whose scorers touched nothing clears * any stale rows). */ linkScores?: IConfidenceAdjustment[]; /** * Phase 3 / View contribution system, active runtime catalog of * registered view contributions, keyed by qualified id * `//`. Passed to the * `scan_contributions` upsert so the catalog sweep can drop rows * belonging to plugins / extensions that are no longer in the * catalog (uninstalled plugins, disabled plugins, removed * contributions). Empty / absent set = no catalog sweep (legacy * behaviour, leaves disabled-plugin rows stale per design F24 * pre-fix). */ registeredContributionKeys?: ReadonlySet; /** * Phase 3 / View contribution system, set of `(plugin, extension, * node)` tuples where the extension actually RAN against that node * in this scan. Format: `//` (no * contribution-id segment, the sweep operates at the (plugin, * extension, node) level and inspects the buffer to decide which * contribution-ids survive). * * Membership rules: * - Extractor + cache miss: tuple INCLUDED (extract() ran). * - Extractor + cache hit: tuple OMITTED (extract() skipped, prior * rows must be preserved). * - Rule, every node in `ctx.nodes`: tuple INCLUDED (rules always * run and see the full graph). * * Drives the per-tuple sweep documented in `spec/architecture.md` * §View contribution system → Persistence (sweep #3): rows whose * `(plugin_id, extension_id, node_path)` is in this set but whose * `(plugin_id, extension_id, node_path, contribution_id)` is NOT in * the buffer get DELETEd before the upsert. Catches the "extractor * used to emit, now does not" case (e.g. body change removes the * trigger). Empty / absent set = no per-tuple sweep (legacy * callers preserve the pre-fix behaviour where stale rows linger). */ freshlyRunTuples?: ReadonlySet; } /** * Issue row as the storage layer sees it, paired with its DB-assigned * id so `port.issues.deleteById(id)` can target it inside a * transaction. The runtime `Issue` shape (per `issue.schema.json`) does * not carry `id` because the spec models issues as ephemeral findings * scoped to a scan; the DB does need the synthetic id to update / delete * a single row. */ interface IIssueRow { id: number; issue: Issue; } /** * Filter + pagination shape for `port.issues.list(...)`, driven by the * BFF's `/api/issues` route. Every field is optional, an empty filter * returns every issue ordered by `id` ASC (insertion order, stable * across pages so `offset` / `limit` paging is deterministic). * * The three semantic filters mirror `/api/issues`'s query params: * * - `severities`, narrowed list of `Severity` values. Empty / absent * matches every severity. * - `analyzerIds`, accepts qualified (`/`) AND short * (``) forms; the suffix-match semantics live in * `matchesAnalyzerFilter`. Each entry generates two SQL clauses * (`= ?` and `LIKE '%/' || ?`) ORed together so the filter remains * a single SQL pass with parameterised values, no string * interpolation. Empty / absent matches every analyzer id. * - `nodePath`, keeps issues whose `nodeIds` JSON array contains the * given path (correlated EXISTS over `json_each`). Absent / null * skips the filter. * - `nodePaths`, multi-node variant of `nodePath`: keeps issues * whose `nodeIds` JSON array intersects the given set (correlated * EXISTS over `json_each` with an `IN(...)` predicate). Used by * the linked-nodes panel to fetch issues for the focused node + * its neighbours in one round-trip instead of pulling the whole * table. Empty array matches zero rows; absent skips the filter. * Combines with `nodePath` (intersection); when both are set, the * `nodePath` predicate is AND-ed with `nodePaths`. * * Pagination is mandatory; the route layer fills the defaults via * `parsePagination`. `total` in `IIssueListResult` reports the total * MATCHING the filters (not just the page slice) so the SPA can * surface a correct page-count without a second round-trip. */ interface IIssueListFilter { /** * Severity tokens to match. Typed as open `string` (not the * `Severity` union) so an unknown value from a URL query string * surfaces as a zero-match SQL query, not a kernel validation * error. The adapter parameterises each entry into the `IN(...)` * clause; unrecognised severities simply match no rows. */ severities?: readonly string[]; analyzerIds?: readonly string[]; nodePath?: string | null; nodePaths?: readonly string[]; offset: number; limit: number; } /** * Output of `port.issues.list(...)`. `items` is the page slice (length * ≤ `filter.limit`); `total` is the count of rows matching the filters * before pagination was applied. */ interface IIssueListResult { items: Issue[]; total: number; } /** * Output of `port.jobs.claim(...)`, the identity a runner needs after an * atomic claim (spec/job-lifecycle.md §Atomic claim). `contentHash` lets * the caller fetch the rendered content; `nonce` is the sole credential a * later `sm record` presents. `null` from `claim` means the queue was * empty (or nothing matched the filter). */ interface IJobClaim { id: string; nonce: string; contentHash: string; } /** * Discriminated outcome of the two operator-driven terminal transitions, * `port.jobs.cancel(id, nowMs)` and `port.jobs.fail(id, nowMs)`. Shared * because both share the same guard shape: * - `cancelled`, a `queued` / `running` job was moved to the terminal * `cancelled` state (returned only by `cancel`). * - `failed`, a `queued` / `running` job was moved to `failed` / * `user-failed` (returned only by `fail`). * - `already-terminal`, the job is already `completed` / `failed` / * `cancelled` (spec rejects the re-transition with exit 2). * - `not-found`, no `state_jobs` row carries that id (exit 5). */ type TJobTransitionOutcome = 'cancelled' | 'failed' | 'already-terminal' | 'not-found'; /** Output of `port.jobs.pruneTerminal` / `listTerminalCandidates`. */ interface IPruneResult { /** How many `state_jobs` rows were deleted (or would be, in dry-run). */ deletedCount: number; /** * How many orphaned `state_job_contents` rows were collected in the * same transaction (content blobs referenced by zero surviving * `state_jobs` rows). Always `0` for the `listTerminalCandidates` * dry-run preview; the live `pruneTerminal` returns the real count. */ prunedContents: number; } /** Output of `port.jobs.integrityCounts` (the `sm doctor` job checks). */ interface IJobsIntegrityCounts { /** * `state_jobs` rows whose `content_hash` has no `state_job_contents` * row. DB-corruption signal (`job-file-missing` at claim time); * healthy DBs report `0`. */ missingContent: number; /** * `state_job_contents` rows referenced by zero `state_jobs` rows. * Retention leftovers; `sm jobs prune` collects them. */ contentStragglers: number; } /** Output of `port.migrations.quickCheck` (the `sm doctor` DB check). */ interface IQuickCheckResult { /** True when `PRAGMA quick_check` returned the single row `ok`. */ ok: boolean; /** First reported corruption line when not ok, else `null`. */ detail: string | null; } /** * Content row inserted into `state_job_contents` at submit time via * `INSERT OR IGNORE`. Keyed by `contentHash`; a second submit of the same * hash is a no-op (the blob is stored once, refcounted by reference). */ interface IJobContentInput { contentHash: string; content: string; createdAt: number; } /** * The `state_jobs` row values a submit provides. Lifecycle-null columns * (`failureReason` / `runner` / `claimedAt` / `finishedAt` / `expiresAt`) * are filled by the adapter; the caller supplies only the frozen-at-submit * fields. `status` is `queued` for every real submit but stays typed for * reuse. */ interface IJobSubmitRow { id: string; extensionId: string; extensionVersion: string; /** * Extension kind resolved by the submit target resolution and frozen * onto the row (`spec/db-schema.md` §state_jobs); `sm record` routes * on it instead of re-resolving the extension by id. */ extensionKind: JobExtensionKind; /** * Per-job auto-fix opt-in, frozen at submit (`state_jobs.auto_fix`). * Optional like `submittedBy`: the column carries a SQL `DEFAULT 0`, so a * caller that omits it lands `false`. Only ever `true` on a finder submit * flagged `--auto-fix` (`spec/job-lifecycle.md` §Auto-fix chain (per-job)). */ autoFix?: boolean; /** * Finding-subset targeting for FIXER jobs, frozen at submit * (`spec/job-lifecycle.md` §Findings injection for fixers · * Finding-subset targeting): the `state_findings` ids this job * resolves. Absent/undefined = whole-node targeting (the column * stores NULL). Meaningless on non-fixer jobs. */ findingIds?: readonly number[]; nodeId: string; contentHash: string; nonce: string; priority: number; status: JobStatus; /** Optional operator-armed TTL; `null` = never expires (the default). */ ttlSeconds: number | null; createdAt: number; submittedBy?: string | null; } /** * Outcome of `port.jobs.submitFixer(...)`, the atomic fixer supersede submit * (`spec/job-lifecycle.md` §Findings injection for fixers · Supersede). A * fixer submit that finds an ACTIVE job for the same `(extensionId, nodeId)` * pair resolves the collision in ONE transaction: * - `created`, the new queued job landed; `supersededIds` are the stale * queued siblings (a DIFFERENT `contentHash`: the finding set or the body * changed since they were queued) cancelled in the SAME transaction * (empty when there was nothing to supersede). * - `duplicate`, an IDENTICAL queued request already exists (same * `contentHash`); nothing was written, `existingId` names it (exit 3). * - `running-conflict`, a RUNNING job holds the pair (an agent claimed it); * it is never superseded, nothing was written, `runningId` names it * (exit 3). Supersede applies to fixer submits only; non-fixer jobs keep * the plain duplicate detection on `submit(...)`. */ type TFixerSubmitOutcome = { outcome: 'created'; jobId: string; supersededIds: string[]; } | { outcome: 'duplicate'; existingId: string; } | { outcome: 'running-conflict'; runningId: string; }; /** * Filter for `port.jobs.list(...)` (drives `sm jobs list`). All optional; * an empty filter returns every job, newest first. `extensionId` matches * the stored (qualified) id exactly OR by bare-id suffix, mirroring the * analyzer-filter semantics so `--extension skill-summarizer` finds * `core/skill-summarizer`. */ interface IJobListFilter { status?: JobStatus; extensionId?: string; nodeId?: string; } /** Filter shape for `port.history.list`. All fields optional. */ interface IListExecutionsFilter { /** Restrict to executions whose `nodeIds` array contains this path. */ nodePath?: string; /** Exact match on `extension_id`. */ extensionId?: string; /** Subset of {`completed`,`failed`,`cancelled`}. */ statuses?: ExecutionStatus[]; /** Lower bound (inclusive) on `started_at`. Unix ms. */ sinceMs?: number; /** Upper bound (exclusive) on `started_at`. Unix ms. */ untilMs?: number; /** Cap result count. No default. */ limit?: number; } /** Window shape for `port.history.aggregateStats`. */ interface IHistoryStatsRange { /** Inclusive lower bound. `null` = all-time. */ sinceMs: number | null; /** Exclusive upper bound. */ untilMs: number; } /** Period bucket granularity for `port.history.aggregateStats`. */ type THistoryStatsPeriod = 'day' | 'week' | 'month'; /** * Output of `port.transaction(tx => tx.history.migrateNodeFks(from, to))`. * Lists how many rows in each `state_*` table were repointed plus any * composite-PK collisions that forced a drop instead of an update. */ /** * One entry of a node's recent-activity ring as persisted in * `state_activity_stats.recent_json` (`spec/db-schema.md` * §state_activity_stats). The BFF accumulator owns the shape and its * caps; the kernel stores it opaquely, so `kind` stays a plain string. */ interface IActivityRecentRow { at: number; owner?: string; detail?: string; caller?: string; target?: string; kind?: string; } /** One `state_activity_stats` row, JSON columns already decoded. */ interface IActivityStatsRow { nodePath: string; count: number; firstSeenAt: number; lastStartAt: number; lastOwner: string | null; owners: readonly string[]; recent: readonly IActivityRecentRow[]; toolUses: number; tokens: number; summarizedRuns: number; } /** One `state_activity_pairs` row (spec §state_activity_pairs). */ interface IActivityPairRow { parent: string; childNodePath: string; count: number; lastStartAt: number; } interface IMigrateNodeFksReport { jobs: number; executions: number; summaries: number; findings: number; enrichments: number; pluginKvs: number; nodeFavorites: number; /** `state_activity_stats` rows moved (0 or 1). */ activityStats: number; /** `state_activity_pairs` rows repointed (either side). */ activityPairs: number; /** * Collisions encountered when migrating any of the keyed-by-node * `state_*` tables because a row already existed at the destination * PK. The pre-existing rows are preserved, the migrating rows are * dropped (deleted from `fromPath` without a corresponding INSERT). * One entry per dropped row, with the affected PK fields included * for diagnostic output. `state_node_favorites` has no composite key * so its `keys` is the empty object. */ collisions: Array<{ table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs' | 'state_node_favorites' | 'state_activity_stats' | 'state_activity_pairs'; fromPath: string; toPath: string; keys: Record; }>; } /** Discovered kernel migration file (one of `NNN_snake_case.sql`). */ interface IMigrationFile { version: number; description: string; filePath: string; } /** A row from the `config_schema_versions` ledger for the kernel scope. */ interface IMigrationRecord { scope: string; ownerId: string; version: number; description: string; appliedAt: number; } /** `port.migrations.plan` output: applied vs pending. */ interface IMigrationPlan { applied: IMigrationRecord[]; pending: IMigrationFile[]; } /** Apply-time options for `port.migrations.apply`. */ interface IApplyOptions { backup?: boolean; dryRun?: boolean; to?: number; } /** Result of `port.migrations.apply`. */ interface IApplyResult { applied: IMigrationFile[]; backupPath: string | null; } /** * Single contribution row as returned to callers of the * `contributions` namespace on `StoragePort`. The payload is * `unknown` because the slot space is open at the type layer (catalog * evolution is a kernel + spec concern); narrow at the call site by * reading `slot`. * * Lives next to the port (not under `adapters/sqlite/`) so non-SQLite * implementations of `StoragePort` (in-memory test harness, future * Postgres adapter) can satisfy the port contract without importing * from the SQLite adapter. The SQLite adapter re-exports this type * for backwards compatibility with callers that still import from * the adapter path. */ interface IPersistedContribution { pluginId: string; extensionId: string; nodePath: string; contributionId: string; slot: string; payload: unknown; emittedAt: number; } /** * `scan_contributions` adapter, replace-all writer used by * `persistScanResult`, plus read helpers consumed by the BFF * (`/api/contributions/...`). * * One row per `(plugin_id, extension_id, node_path, contribution_id)` * tuple. See `spec/architecture.md` § View contribution system → * Persistence and `migrations/001_initial.sql` § View contribution * layer for the normative shape. * * Replace-all semantics mirror the rest of the `scan_*` zone: every * scan is a fresh snapshot, so prior rows are deleted before insert. * Wrapped in the same transaction `persistScanResult` opens. * * The rename heuristic does NOT need to migrate `node_path` here, * because of replace-all, every contribution is re-emitted on the new * path automatically. Keeping the rename path lighter than `state_*` * (which IS rename-migrated because state survives across scans). */ /** * In-memory contribution record buffered during scan and flushed to * `scan_contributions` by `persistScanResult`. One entry per accepted * `ctx.emitContribution(id, payload)` call. Payload validation against * the slot's payload schema happens at emit time (orchestrator); * by the time records reach this adapter they are wire-shape clean. */ interface IContributionRecord { pluginId: string; extensionId: string; nodePath: string; contributionId: string; /** * Closed enum value mirroring `view-slots.schema.json#/$defs/SlotName`. * Persisted as TEXT (no SQL CHECK by design, see migration comment). */ slot: string; /** Already-validated payload. Serialised via `JSON.stringify` at write. */ payload: unknown; emittedAt: number; } /** * In-memory record of a view contribution REJECTED at emit time, * buffered during scan and flushed to `scan_contribution_errors` by * `persistScanResult`. The "off-shape visible" follow-up to the * ephemeral `extension.error` event (kind `contribution-rejected`): * the orchestrator still fires the event, AND pushes one of these so * the rejection survives the scan and surfaces in `sm plugins doctor`. * * Two rejection shapes share the record: * - `undeclared-contribution-ref`, the `ref` passed to * `ctx.emitContribution` was not one of the extension's declared * `viewContributions` objects. `contributionId` / `slot` absent. * - AJV failure, the payload failed the slot's payload schema. * `reason` is the AJV error string; `contributionId` / `slot` name * the resolved target. */ interface IContributionErrorRecord { pluginId: string; extensionId: string; nodePath: string; /** `undeclared-contribution-ref` literal, or the AJV error string. */ reason: string; /** Rendered diagnostic (mirrors the `extension.error` event message). */ message: string; /** Absent for the `undeclared-contribution-ref` shape. */ contributionId?: string; /** Absent for the `undeclared-contribution-ref` shape. */ slot?: string; emittedAt: number; } /** * `loadScanResult`, driving inverse of `persistScanResult`. Reads the * `scan_*` tables and reconstructs a `ScanResult` shape so the * orchestrator can run an incremental scan (`sm scan --changed`) on * top of a prior snapshot. * * The reconstruction is faithful for everything that was actually * persisted: nodes (with triple-split bytes / tokens, denormalised * counts, JSON frontmatter), internal links (with regrouped * `trigger` / `location`, parsed `sources[]`), and issues * (with parsed `nodeIds` / `linkIndices` / `fix` / `data`). * * **Documented omission**: external pseudo-links (those whose target is * an `http://` / `https://` URL emitted by the external-url-counter * extractor) are NEVER persisted to `scan_links`, only their per-node * count survives in `scan_nodes.external_refs_count`. Therefore the * `result.links` returned by `loadScanResult` contains only internal * graph links, and `node.externalRefsCount` is the authoritative count * carried over from the prior scan. The orchestrator's incremental path * preserves that count for "unchanged" nodes and re-derives it for * new / modified nodes from a fresh extractor pass. * * Meta envelope: the `scan_meta` table persists `roots` / * `scannedAt` / `scannedBy` / `tokenizer` / `providers` / * `stats.filesWalked` / `stats.filesSkipped` / `stats.filesOversized` / * `stats.durationMs` / `oversizedFiles`. When the row exists, those fields come back * authoritatively. When it does not (DB * freshly migrated but never scanned, or a legacy DB never * re-persisted), the loader degrades to a synthetic envelope: * * - `scannedAt` ← max(`scan_nodes.scanned_at`); falls back to `Date.now()` * for empty snapshots so the field stays a positive integer. * - `roots` ← `['.']` to satisfy spec's `minItems: 1`. NOT * load-bearing: the orchestrator's incremental path only reads * `nodes` / `links` / `issues` from the prior; it never reuses the * prior `roots`. * - `providers` ← `[]`. * - `stats` ← zeros for `filesWalked` / `filesSkipped` / * `durationMs`; the three count fields derive from row counts. * * Both branches keep `nodesCount` / `linksCount` / `issuesCount` derived * from `COUNT(*)` of the loaded rows, never persisted, always recomputed. */ /** * Spec § A.9, load the fine-grained Extractor cache as a per-node map * from qualified extractor id (`/`) to the run-time * hashes the extractor recorded on its last run. Empty map is the * default when the table is empty (fresh DB, never-scanned scope, or * every extractor has been uninstalled since the last scan). * * Returned shape: `Map>`. * The inner value carries the body hash AND the sidecar-annotations * hash so the orchestrator can apply the widened cache key (both must * match for a cache hit). */ interface IPriorExtractorRun { bodyHash: string; sidecarAnnotationsHash: string; /** * SHA-256 of the extractor's canonical-form resolved settings at run * time. Third leg of the cache key: the orchestrator compares it * against the live per-extractor hash, so a settings change re-runs * the pair instead of reusing outputs computed under old settings. */ settingsHash: string; } /** * `.skillmapignore` parser + filter facade. Wraps `ignore` (kaelzhang) * with the project-local layering: bundled defaults → `config.ignore` * (from `.skill-map/settings.json`) → `.skillmapignore` file content. * * Why a wrapper instead of exposing `ignore` directly: * * 1. Single-source defaults, `src/config/defaults/skillmapignore` is * the canonical default list, loaded once at module init (or at * explicit build time, depending on bundling). The runtime never * re-reads it per scan. * 2. Stable interface, Providers and the orchestrator depend on a * minimal `IIgnoreFilter` shape, so the underlying library can be * swapped without touching every consumer. * 3. Path normalization, every consumer passes the path RELATIVE to * the scan root (POSIX separators); the wrapper guarantees that * contract before delegating to `ignore`. */ interface IIgnoreFilter { /** * Returns `true` when `relativePath` should be skipped. The caller * MUST pass paths relative to the scan root, with POSIX separators * (forward slashes), no leading `/`. Directories MAY be passed with * or without trailing `/`; the wrapper does not require it. */ ignores(relativePath: string): boolean; } /** * Diagnostic surfaced by a parser when the raw input was structurally * malformed (e.g. YAML parse error). The parser MUST still return a * usable `{ frontmatter, frontmatterRaw, body }` triple (defaults are * fine) so the scan keeps making progress; this carries the message * the orchestrator translates into a kernel `Issue` with severity * `warn` (and `error` under `--strict`). * * Pure data: parsers never log or throw; they describe the failure * here and let the orchestrator decide how to surface it. */ interface IParseIssue { /** * Stable tag describing the failure class. Emitters today are * `frontmatter-yaml` (YAML parse error) and `toml` (TOML parse * error), both reporting `'frontmatter-parse-error'`; the set may * grow as new parsers land. */ code: string; /** * Human-readable message, sanitised. Never includes the raw input * (a hostile YAML could embed multi-line garbage); only the * parser-error string is interpolated. */ message: string; } /** * Config-side MCP dialect parsers. Turns a vendor's raw MCP config file into * canonical `IMcpServerDescriptor[]`, so the config-side discovery (the * `mcpConfig` Provider capability, `spec/architecture.md` §Provider · MCP config * discovery) never reimplements a grammar: a Provider declares WHERE its config * lives and in which dialect, the kernel reads the file and calls this. * * Kept out of the dependency-free `mcp.ts` (identity primitives) because these * pull the TOML parser. Both files together are the `kernel/util/mcp` family the * spec names as the single owner of every MCP grammar. * * Tolerant by contract: a malformed file, a missing server map, or a junk entry * yields fewer descriptors (or none), never a throw. A scan must not abort * because a hand-edited `settings.json` has a trailing comma. */ /** * The closed set of MCP config grammars the kernel knows. Each wraps a * `{ : }` map; they differ only in file format, so * the reader tolerates any conventional top-level key regardless of dialect * (`mcpServers` for Claude, `mcp_servers` for Codex TOML, `mcp` for OpenCode's * `opencode.json`). The per-server value shape is likewise unified: an OpenCode * `{ type: "remote" | "local", url, enabled }` entry reads through the same * `type` / `url` path a Claude `{ type: "http", url }` entry does. */ type TMcpConfigDialect = 'json-mcp-servers' | 'toml-mcp-servers'; /** * Provider runtime contract. Walks filesystem roots and emits raw node * records; classification maps path conventions to a node kind. * * Distinct from the **hexagonal-architecture** 'adapter' * (`StoragePort.adapter`, etc.). A `Provider` is an extension kind authored * by plugins to declare a platform's universe (the catalog of kinds it * emits, the per-kind frontmatter schema, the filesystem directory it * owns); a hexagonal adapter is an internal implementation of a port. * Both can coexist without confusion because they live in different * namespaces. * * `walk()` is an async iterator so large scopes don't buffer in memory. * Each yielded `IRawNode` carries the full parsed frontmatter + body plus * the path relative to the scan root; the kernel computes hashes, bytes, * and tokens on top. * * **Structure-as-truth**: each plugin carries at most one Provider, declared * as `/provider.ts`. The kinds catalog lives as folders under * `/kinds//`; each kind folder contains `schema.json` * (the frontmatter JSON Schema) and `kind.json` (UI metadata). The loader * discovers each entry by walking the directory and populates the runtime * `kinds` map below. The manifest itself NO LONGER carries a `kinds` map * or a `defaultRefreshAction` field (the UI's Refresh button consumer was * retired alongside it; the replacement TBD). */ interface IRawNode { /** Path relative to the scan root that produced this node. */ path: string; /** Raw markdown body (everything after the frontmatter fence). */ body: string; /** Raw frontmatter text (between `---` fences). Empty string when absent. */ frontmatterRaw: string; /** Parsed frontmatter, or `{}` when absent / unparseable. */ frontmatter: Record; /** * `true` when the parser recognised a DECLARED frontmatter block, even * an empty one (`---`, blank line, `---`). Disambiguates * `frontmatterRaw: ''` so the orchestrator can run the per-kind AJV * validation on a declared-but-empty block instead of treating it as * "no frontmatter". Optional: a Provider with a custom `walk()` that * never sets it falls back to the historic `frontmatterRaw.length > 0` * discriminator in `node-build`. */ frontmatterDeclared?: boolean; /** * Number of file lines preceding the first `body` line (frontmatter * block, fences included), parser-owned (see * `IParsedFile.bodyLineOffset`). The orchestrator adds it to * body-relative line tracking so persisted `link.location.line` and * finding `L` prefixes are FILE-absolute, matching the author's * editor. Omitted (`0`) when the body is the whole file, when a * `bodyField` swap makes a file-absolute line undefined, or by custom * `walk()` Providers that don't track it. */ bodyLineOffset?: number; /** * File modification time (`mtime`) in Unix milliseconds, captured by * the kernel walker from the same `lstat` that guards the read (zero * extra syscalls). Threaded onto the persisted `Node` as * `modifiedAtMs`. Optional: a Provider that ships its own `walk()` and * does not stat its sources MAY omit it; virtual / derived nodes carry * no file and never set it. */ modifiedAtMs?: number; /** * Parser diagnostics (audit L1). Populated by the walker when the * parser surfaced `IParseIssue` entries (e.g. malformed YAML). * Carried through `processRawNode` and converted into warn-level * kernel `Issue` rows inside `buildFreshNodeAndValidateFrontmatter`. * Empty / undefined on the happy path. */ parseIssues?: readonly IParseIssue[]; /** * Incremental-walk fast path. `true` when the walker matched this * file's on-disk `mtime` against the prior scan snapshot (via * `IProviderWalkOptions.priorMtimes`) and SKIPPED reading + parsing the * body, the dominant per-file cost. For such a record `body` / * `frontmatter` / `frontmatterRaw` are empty placeholders: the * orchestrator reuses the prior node verbatim and reads the body * (through `reread`) ONLY when a sidecar change forces re-extraction. * Absent / `false` means a normal record whose body was read eagerly. */ unchanged?: boolean; /** * Present only on an `unchanged` record: a lazy reader that performs * the deferred `readFile` + parse and returns the body / frontmatter * the walker skipped. The orchestrator calls it only when it must * actually re-extract (a sidecar edit on an otherwise-unchanged file). * Keeps the read + parse logic in the walker (single source) rather * than duplicating it in the orchestrator. */ reread?: () => Promise>; } /** * Runtime descriptor of one Provider kind, populated by the loader from * the structure under `/kinds//`. The loader reads * `schema.json` from the kind folder, parses it once, attaches the path * (for diagnostics) and the parsed object (for AJV registration), and * reads `kind.json` for the UI metadata. The runtime descriptor lives in * memory; no field in this shape comes from the Provider manifest itself * since the structure-as-truth refactor. */ interface IProviderKind { /** * Path to the kind's frontmatter JSON Schema, relative to the Provider's * package directory. Always `kinds//schema.json` under the new * layout. Kept on the descriptor for diagnostics (file references in * error messages, doctor reports). */ schema: string; /** * Loaded JSON Schema document for the kind. The kernel registers this * with AJV at scan boot and validates each node's frontmatter against * it. The schema MUST extend the spec's * `frontmatter/base.schema.json` via `allOf` + `$ref` to base's `$id`; * the loader registers base into the same AJV instance so cross-package * `$ref`-by-`$id` resolves transparently. */ schemaJson: unknown; /** * Presentation metadata the UI consumes to render nodes of this kind * (palette swatches, list tags, graph nodes, filter chips). Read from * `kinds//kind.json#/ui`. Required so the UI never has to * invent visuals for a Provider-declared kind. */ ui: IProviderKindUi; /** * Priority-ordered list of identifier sources the post-walk resolver * uses to derive this kind's canonical name(s). Each entry contributes * one normalized name to the name index built by * `liftResolvedLinkConfidence`; multiple sources accumulate (e.g. a * skill with `name: foo` AND dirname `foo` yields one entry, a skill * with `name: bar` and dirname `foo` yields two). * * Defaults to `[]` (no name-resolvable). Source semantics: * * - `'frontmatter.name'`, read `node.frontmatter.name`. Required-name * kinds (`agent`, `command`, `skill` per their schemas) typically * declare this first. * - `'filename-basename'`, `basename(path)` without the extension. * For Claude/OpenAI agents and commands the filename IS the * invocation handle when `name:` is absent. * - `'dirname'`, `basename(dirname(path))`. Anthropic skills + * agent-skills (open standard, also adopted by Antigravity) * resolve to the directory between the skills root and the * SKILL.md (e.g. `.claude/skills/foo/SKILL.md` → `foo`). * * Compare with `IProvider.resolution` (which declares which target * kinds resolve which link.kind): `identifiers` is a per-kind detail * about WHERE the name lives; `resolution` is a per-provider strict * matrix about WHICH kinds count as resolution for a given link.kind. */ identifiers?: TIdentifierSource[]; /** * Severity of the `core/name-mismatch` issue emitted when a node's * NORMALISED `frontmatter.name` diverges from a declared path-derived * identifier (`filename-basename` / `dirname`), giving the node two * live names in the resolution index. Absent = no diagnostic. External * `kind.json` files declare `identifiers` / `identifierMismatch` * directly (both are optional keys on `provider-kind.schema.json`), so * a drop-in Provider reaches the same name-resolution lane a built-in * gets from this TypeScript field. `'warn'` when the kind's standard * REQUIRES agreement (the * open-standard skill kind mandates name == parent dirname); `'info'` * when the runtime documents the divergence as a legal override yet * the dual identity is still worth surfacing. Normative wording: * `spec/architecture.md` §Provider · kind identifiers · Identifier * agreement. */ identifierMismatch?: 'warn' | 'info'; } /** * Sources the post-walk confidence-lift transform consults to derive a * node's canonical name. Closed set: extending it is a spec + kernel * change. Order is meaningful inside `IProviderKind.identifiers`, the * resolver visits sources in declaration order, but the resulting index * is presence-based so multiple matches collapse. */ type TIdentifierSource = 'frontmatter.name' | 'filename-basename' | 'dirname'; /** * Presentation contract for one Provider kind. The Provider declares * intent (label + base color, optional dark variant + emoji + icon); * the UI derives `bg`/`fg` tints per theme via a deterministic helper * and reads the registry from the `kindRegistry` field embedded in REST * envelopes. Single source of truth for what a kind looks like, the * UI never hardcodes presentation for a built-in kind. */ interface IProviderKindUi { /** * Plural human-readable label for groups of this kind (e.g. `'Skills'`, * `'Agents'`, `'Cursor Rules'`). Used in filter dropdowns, palette * tooltips, and any list grouping. */ label: string; /** * Base hex color (`#RRGGBB`) for the light theme. The UI derives `bg` * and `fg` tints from this value at runtime via a deterministic * helper. Declaring one base value (instead of three) keeps the * manifest small and centralises accessibility-driven contrast in the * UI. */ color: string; /** * Optional dark-theme variant of `color`. When absent, the UI falls * back to `color`. Declared explicitly because a luminosity flip * rarely matches the brand intent for kinds that should stand out in * dark mode. */ colorDark?: string; /** * Optional decorative emoji used as a fallback when `icon` is absent * or fails to render. Length-bound so the UI can lay it out * predictably alongside text. */ emoji?: string; /** * Optional discriminated icon descriptor. The UI prefers `icon` over * `emoji`; when both are absent, the UI falls back to the first * letter of `label` colored with `color`. */ icon?: TProviderKindIcon; } /** * Discriminated icon contract. `pi` references a PrimeIcons identifier * (e.g. `'pi-cog'`); `svg` carries raw SVG path data the UI wraps in a * `` element tinted with * `currentColor`. The discriminator (`kind`) keeps the UI dispatch * exhaustive without string-sniffing the payload. */ type TProviderKindIcon = { kind: 'pi'; id: string; } | { kind: 'svg'; path: string; }; /** * Presentation contract for the Provider's OWN identity, distinct from * its per-kind visuals (`IProviderKindUi`). Drives the active-lens * dropdown label, the topbar lens chip, and the per-node provider chip * on cards. Reaches the UI via the `providerRegistry` field embedded in * REST envelopes (sibling of `kindRegistry`). Unlike kind colors * (normalised across Providers so every `agent` paints the same), * Provider colors are deliberately distinct so the chip tells the user * at a glance which platform a node came from. Mirrors * `spec/schemas/extensions/provider.schema.json#/properties/ui`. */ interface IProviderUi { /** * Human-readable Provider name shown in the lens dropdown, the topbar * lens chip, and the per-node provider chip. Vendor lenses use a * possessive `'s ` form (`"Anthropic's Claude"`, * `"OpenAI's Codex"`, `"Google's Antigravity"`); the vendor-neutral open * standard uses a `'Standard: '` prefix (`'Standard: Agent skills'`). * The non-gated `'Markdown'` base keeps a label for internal lookups but * is never a selectable lens. */ label: string; /** Base hex color (`#RRGGBB`) for the light-theme provider chip. */ color: string; /** Optional dark-theme variant of `color`. Falls back to `color`. */ colorDark?: string; /** Optional decorative emoji fallback when `icon` is absent. */ emoji?: string; /** Optional discriminated icon descriptor (preferred over `emoji`). */ icon?: TProviderKindIcon; /** * When `true`, the UI does NOT paint this Provider's chip on node * cards. Reserved for the universal `markdown` fallback (carried by * the majority of nodes, so badging every generic `.md` would be * noise). The Provider still appears in the lens dropdown and the * topbar lens chip; only the per-card badge is suppressed. */ hideChip?: boolean; /** * Single glyph this lens's runtime uses to invoke a skill / command, * surfaced as the `invokes` edge-kind glyph (and its tooltip example) * in the link-kind palette so the operator recognises the source * syntax instantly. `/` for the slash-invoking lenses (`claude` * commands + skills, `antigravity` skills + workflows), `$` for * `codex` (skills are `$skill`; `/` is reserved for Codex's own * built-in commands). Omitted for lenses with no `/`/`$` invocation * channel (the open-standard `agent-skills`, where skills activate by * `description`, and the non-lens `markdown` base): under those no * `invokes` edge arises, so the palette never paints the glyph. * Projected into `providerRegistry` and joined client-side against * the active lens. */ invocationSigil?: string; } /** * Auto-detection markers for the active-provider lens. The lens resolver * checks each marker path (relative to the scope root) and, when present, * suggests this Provider as a candidate lens. Replaces the former * hardcoded detection table: the detectable set now derives from the * registered Providers. Mirrors * `spec/schemas/extensions/provider.schema.json#/properties/detect`. */ interface IProviderDetect { /** * Paths relative to the scope root whose existence signals this * Provider's presence (e.g. `['.claude']`, `['.codex', 'AGENTS.md']`). * A directory or a file both count; existence is the only test. */ markers: string[]; /** * When `true`, this Provider is the open-standard FALLBACK lens: its * markers produce a detection candidate ONLY when no non-fallback * (vendor) Provider matched under the same scope. Reserved for * `agent-skills`, whose `.agents/` marker is the shared skill home that * vendor lenses (`codex`, `antigravity`) also populate; without this flag * a `.codex/` + `.agents/` project would falsely read as an ambiguous * `codex` vs `agent-skills` pair. Vendor Providers omit it (default * `false`) so two vendor markers still surface a real ambiguous prompt. * Mirrors `provider.schema.json#/properties/detect/properties/fallback`. */ fallback?: boolean; /** * Provider ids whose detection candidate this Provider ABSORBS when both * matched under the same scope root: a one-way "I read that runtime's * territory too" relation. `opencode` declares `['claude']` because * OpenCode's Claude-compat reads `.claude/skills/` + `CLAUDE.md`, so a * `.claude/` directory is expected inside an OpenCode project and is not * evidence Claude Code is in use, while Claude Code never reads * `.opencode/`. Applied after the `fallback` rule, so it only ever * collapses a would-be ambiguous prompt into an unambiguous auto-detect; * a mutual pair keeps the ambiguity rather than tie-breaking arbitrarily. * Mirrors `provider.schema.json#/properties/detect/properties/subsumes`. */ subsumes?: string[]; } /** * Authoring targets for verbs that MATERIALISE files into this * Provider's on-disk territory (today only `sm tutorial`). The WRITE * counterpart to `detect` (which READS markers to suggest a lens) and * `classify` (which READS paths during a scan). Mirrors * `spec/schemas/extensions/provider.schema.json#/properties/scaffold`. */ interface IProviderScaffold { /** * Directory (relative to the scope root) under which a materialising * verb writes a skill folder, e.g. `.claude/skills` for Claude, * `.agents/skills` for the open standard. The verb appends * `//SKILL.md`. Relative, no leading slash, no `..` * traversal; the consuming verb joins it onto the cwd. */ skillDir: string; /** * Optional directory the materialising verb creates so the active-lens * resolver picks THIS Provider when its `skillDir` is shared with another * lens. The open `.agents/skills` territory is read by several lenses * (`agent-skills`, `antigravity`, `codex`); a Provider whose skillDir is * that shared territory but whose lens needs a distinct marker (Codex's * `.codex`) declares it here, and `sm tutorial --for ` drops the marker * alongside the skill. Omitted when the skillDir's parent IS the marker. */ marker?: string; /** * Display-only hints naming the agents that consume this scaffold * territory AND share its tutorial track, rendered in parentheses next to * the Provider label in the `sm tutorial` destination prompt. Purely * presentational: NOT matched by `--for` (only registered Provider ids * are) and has no runtime effect. */ aka?: readonly string[]; /** * Qualified id of the Provider that OWNS this `skillDir` when the * territory is shared. Declared CONSUMER-side (like the `COMMONS_KINDS` * composition it mirrors): `antigravity` and `opencode` both READ the * open `.agents/skills` territory that `agent-skills` owns, so they name * it here instead of duplicating ownership. * * It splits the two questions `scaffold` answers. Verbs that offer a * DESTINATION CHOICE (`sm tutorial`) list owners only, so one territory * stays one row; per-lens probes that ask "does THIS lens support / have * the skill?" (`sm agent install / status`, `GET /api/agent/install`, the * Quick Start row) resolve a sharing lens normally, because a skill * materialised there IS discovered by its runtime. Omitted when the * Provider owns its `skillDir`. */ sharedWith?: string; } /** * Optional MCP config-discovery capability (see `spec/architecture.md` * §Provider · MCP config discovery). Declares WHERE this Provider's MCP server * config lives and in which dialect; the kernel reads + parses each source once * per scan (shared `kernel/util/mcp-config`) and materialises one virtual * `mcp://` node per declared server. The Provider owns the filesystem * territory; the parsing stays in core so a new vendor onboards by naming a file * + dialect. Mirrors * `spec/schemas/extensions/provider.schema.json#/properties/mcpConfig`. */ interface IProviderMcpConfig { /** One or more config files to read for declared MCP servers. */ readonly sources: readonly IProviderMcpConfigSource[]; } interface IProviderMcpConfigSource { /** * Config file path, relative to the scope root (e.g. `.mcp.json`, * `.codex/config.toml`). Project-local; a home-scoped source would extend the * documented `os.homedir()` allowlist and is not supported here yet. */ readonly path: string; /** Which config grammar the file uses. */ readonly dialect: TMcpConfigDialect; } /** * Optional MCP REGISTRATION recipe (see `spec/architecture.md` §Provider · MCP * registration), the write-side mirror of `mcpConfig`: how an operator declares * skill-map's OWN MCP server to this Provider's runtime. Travels verbatim in the * BFF `providerRegistry` so the UI's Copy affordance is driven by the registered * Provider set instead of a client-side catalog; a Provider that declares * nothing falls back to the bare endpoint URL. `{{url}}` is the only * placeholder, substituted by the consumer with the live MCP endpoint. Mirrors * `spec/schemas/extensions/provider.schema.json#/properties/mcpRegister`. */ type TProviderMcpRegister = { /** The runtime ships an `mcp` CLI verb: registration is one shell line. */ readonly kind: 'command'; readonly command: { /** Shell command carrying `{{url}}` at least once. */ readonly template: string; }; readonly config?: never; } | { /** No `mcp` verb: registration means saving a JSON document. */ readonly kind: 'config'; readonly config: { /** * Where the document goes, shown as the paste hint. Display only: * skill-map never writes it, which is why a `~/` target is legitimate * here and leaves the never-read-$HOME invariant untouched. */ readonly target: string; /** A COMPLETE config document; `{{url}}` substituted at any depth. */ readonly document: Readonly>; }; readonly command?: never; }; /** * Phase of one live-activity signal. `start` lights the resolved node; * `end` is emitted only for units whose provider runtime has a native * terminal event (a Claude subagent's matching `SubagentStop`). Units * with no native end (a Claude skill) simply never emit `end`; the UI * owns span decay (TTL). Mirrors `spec/provider-activity.md` §WS event. */ type TActivityPhase = 'start' | 'end'; /** * Spawn-relation block riding an activity signal (see * `spec/provider-activity.md` §The `provider.activity` capability and * §WS event: `agent.spawn`). Produced by the spawning tool call's * events; the BFF turns each block into ONE stateless `agent.spawn` * frame, resolving `childKind`/`childName` through the same * identifiers contract name signals use. The Provider names the * relation; it does NOT resolve nodes. */ interface IActivitySpawnRelation { /** * Opaque per-spawn correlation id: the RAW spawning tool-call id, * never a synthetic owner key (`spawn:`), nothing downstream * parses owner strings. */ spawnId: string; /** * `start` at the spawn call; `handoff` when an async child's own * owner id becomes known; `end` when the spawn completed with no * live child (sync spawns, or a completion arriving after the child * already stopped). */ phase: 'start' | 'handoff' | 'end'; /** * Owner key of the spawning context (an agent id, or the sessionized * main key). Opaque to consumers; the structural discriminator for a * session parent is the ABSENT `parentNodePath` on the resolved * frame, never the owner string. */ parentOwner: string; /** Child unit kind as the runtime named it (`agent` today). */ childKind?: string; /** Child unit name as the runtime named it (resolved downstream). */ childName?: string; /** The child context's own owner id, known from `handoff` on. */ childOwner?: string; /** * Parent -> child conversation half, carried on `start`. NEVER rides * the WS; retained only under the capture gate * (`spec/provider-activity.md` §Conversation capture). */ prompt?: string; /** * Child -> parent conversation half, carried on a sync `end` (the * runtime returned the child's final report as a string). Same * capture-gate custody as `prompt`. */ response?: string; /** * Aggregate execution summary of the completed child run, when the * runtime reports one (Claude: sync completions only). METADATA * (plain numbers), so unlike `prompt` / `response` it rides outside * the capture gate's content rules, feeding per-node aggregates and * retained records alike. */ execution?: IActivitySpawnExecution; } /** * Aggregate execution summary of one completed child run, as reported * by the runtime's completion payload. Every field optional: providers * extract defensively and omit what the payload does not carry. */ interface IActivitySpawnExecution { /** Total wall-clock of the child run, milliseconds. */ durationMs?: number; /** Total tokens consumed by the child run. */ tokens?: number; /** Total tool invocations the child run made. */ toolUses?: number; } /** * One node-attributable signal derived from a single raw provider hook * payload by `IProviderActivityAdapter.mapEvent`. The Provider names the * unit in ONE of two forms (see `spec/provider-activity.md`); it does * NOT resolve nodes: * * - **By name** (`kind` + `name`): the BFF resolves `(kind, name)` * against the scanned node set through the same `kinds[*].identifiers` * contract link resolution uses. * - **By path** (`path`, scope-relative, forward-slash): used when the * runtime reports a FILE rather than a named unit (a markdown read via * the provider's file-read tool). Path signals match the scanned node * with that exact `path` ACROSS providers and kinds, the path already * identifies one node unambiguously. When `path` is present, `kind` / * `name` are ignored. * * Signals that resolve to no scanned node are dropped either way. * * A third, RELATION-ONLY form carries `spawn` + `owner` + `phase` with * NO `kind`/`name`/`path`: a spawn happening in a context that is not * itself a node (the main session spawning a subagent). There is no * parent node to claim, but the relation still matters; the resolver * emits one `agent.spawn` frame and no `node.activity` event. */ interface IActivitySignal { /** Node kind the unit belongs to (`skill`, `agent`, `command`, ...). Required unless `path` is set. */ kind?: string; /** Raw unit name as the runtime reported it (normalised by the resolver). Required unless `path` is set. */ name?: string; /** * Optional finer-grained label for WHAT this signal represents beneath the * node itself, e.g. the specific MCP tool invoked (`notion-create-pages`) on * an `mcp://` node. Metadata only: it rides `node.activity` to the UI * (glow label + the per-node recent history) and is stored in the recent * ring; it is NEVER used for resolution. Absent when the runtime reports no * finer detail. */ detail?: string; /** * Scope-relative node path (forward-slash). When present, resolution * is a direct `node.path` match and `kind` / `name` are ignored. */ path?: string; /** * Adapter-declared access class for a PATH signal (spec * `provider-activity.md` field list, 2026-08-17): `'write'` when the * vendor tool wrote / edited the file (Claude `Write` / `Edit`, * opencode `write` / `edit`, Codex `apply_patch`, Antigravity * `write_to_file` / `replace_file_content`, ...). Omit for reads; the resolver * defaults any unstamped non-`mcp://` path signal to `"read"` and * derives `"mcp"` from the path prefix regardless. Ignored on NAME * signals (a unit's own execution carries no access class). */ access?: 'read' | 'write' | 'shell'; /** Signal phase, see `TActivityPhase`. */ phase: TActivityPhase; /** * Opaque identifier of the executing context (`'main'`, an agent id, * a session id, provider-dependent). Consumers treat it as a grouping * key only; absent when the runtime reports none. */ owner?: string; /** * Opaque session identifier the executing context belongs to. Groups * every owner (the main context AND the subagents it spawned) under one * session so a `sessionScope` end can release them together. Rides the * frame; consumers build an `owner -> session` map from the signals * that carry both. Absent when the runtime reports no session id. */ session?: string; /** * Only meaningful on `phase: 'end'`: `true` when the signal marks the * end of a WHOLE SESSION (a runtime's turn ended), releasing EVERY * owner grouped under `session`. The safety net for runtimes that drop * a subagent's own `ownerScope` end (Codex, live-verified 2026-07-24: * a subagent that itself spawns a nested worker never gets its * `SubagentStop`, so only the main-context `Stop` unwinds it). Node-less * like the owner-release form; `session` is REQUIRED for it to mean * anything. */ sessionScope?: boolean; /** * Only meaningful on `phase: 'end'`: `true` when the signal marks the * end of the OWNER'S WHOLE EXECUTION CONTEXT (a subagent terminating), * not just of the named node. Consumers release every claim held by * that `owner`, so the units the context lit along the way (the * skills it invoked, the markdowns it read) go dark with it instead * of waiting out their decay. * * OWNER-RELEASE form: an ownerScope end MAY omit `kind`/`name`/`path` * entirely when the runtime reports a context end with no node to * hang it on (Antigravity's `Stop`: conversations are not nodes). * The resolver forwards it as a node-less release instead of * resolving; `owner` is REQUIRED for the form to mean anything. */ ownerScope?: boolean; /** * Only meaningful on `phase: 'end'` with an `owner`: `true` when the * owner's TURN completed (a `napping` runtime's main context reporting * a real turn boundary, e.g. Claude's main `Stop`). A sync spawn call * cannot outlive its caller's turn, so consumers release every * relation that owner PARENTS whose child identity never materialized * (no `childOwner`), the shape an interrupted or failed spawn call * leaves behind. Async relations and the owner's node claims are * untouched (NOT an `ownerScope` release). Node-less like the * owner-release form. `spec/provider-activity.md` §WS event: * `node.activity`. */ turnEnd?: boolean; /** * Only meaningful on `phase: 'start'`: `true` for LIFECYCLE claims * (an agent's own span, a parent held lit by a running child), which * get a much longer decay window than momentary usage claims. Sticky * claims are meant to end via `ownerScope` ends; the long window is a * safety net against a crashed runtime that never sends one. */ sticky?: boolean; /** * Only meaningful on `phase: 'start'`: `true` for CUSTODY claims (a * parent held lit through a spawn). Keep-alive starts light and * refresh nodes exactly like any other start but are EXCLUDED from * execution counting (`spec/provider-activity.md` §Execution stats): * custody is not an execution of the named unit. */ keepAlive?: boolean; /** * Spawn-relation block riding the signal produced by the spawning * tool call. On a node-carrying signal the resolved node becomes the * frame's `parentNodePath`; combined with NO `kind`/`name`/`path` it * forms the RELATION-ONLY signal (see the interface docstring). */ spawn?: IActivitySpawnRelation; /** * Only meaningful on `phase: 'end'` BOUNDARY signals: the ending * context's final message, as the runtime reported it on its stop * event (Claude: `last_assistant_message`). CONTENT, not metadata: * it never rides the WS; the BFF hands it to the conversation store * only under the capture gate, where it completes the response half * of async spawns by matching the record's `childOwner`. Stop events * fire on pause too; consumers overwrite, so the terminal message * wins. */ report?: string; } /** * Declarative install descriptor consumed by `sm activity install * `: where the provider's PROJECT-LOCAL hook config lives and * which install shape applies. Discriminated on `kind` so each shape * carries ONLY the fields that parameterize it, mirroring the schema's * per-kind gate in * `extensions/provider.schema.json#/properties/activity/properties/install` * (a `plugin-file` descriptor with wiring knobs is invalid there too). */ type TActivityInstall = IActivityInstallJsonHooks | IActivityInstallPluginFile; /** Fields shared by every install shape. */ interface IActivityInstallBase { /** * Path of the provider's hook config file (`json-hooks`) or the plugin * file to write (`plugin-file`), relative to the scope root. No leading * slash, no `..` traversal; the consuming verb joins it onto the cwd. */ configPath: string; } /** * `json-hooks`: merge hook entries that spawn the activity bridge * command into a JSON settings/hooks file. */ interface IActivityInstallJsonHooks extends IActivityInstallBase { kind: 'json-hooks'; /** * The provider lifecycle events to wire the bridge into, with an * optional per-event matcher in the provider runtime's own matcher * grammar. Only the events `mapEvent` actually consumes belong here, * every wired event spawns one bridge process at runtime, so a tight * list keeps the overhead proportional to the signal. */ events?: readonly IActivityInstallEvent[]; /** * NAMED-GROUP document shape (Antigravity's `.agents/hooks.json`): * the top-level group key skill-map owns in the hook document. * Claude / Codex nest the event map under the vendor's fixed `hooks` * key (operator entries coexist inside, marker-filtered); * Antigravity's document maps GROUP NAMES to event maps, so skill-map * writes its entries under its own group and uninstall removes exactly * that group. Omitted = the conventional `hooks` container. The inner * per-event shape is identical either way. */ group?: string; /** * Working directory the provider runtime spawns hook commands with, * which decides how the bridge command's SCRIPT PATH is written into * the config. `'scope-root'` (default, Claude / Codex): the runtime * spawns at the project root, so the plain scope-relative bridge path * resolves. `'config-dir'` (Antigravity, live-verified 2026-07-04): * the runtime spawns at the hook config's OWN directory, so the * installer prefixes the relative hops from `dirname(configPath)` * back to the root (e.g. `node ../.skill-map/activity/bridge.js`). * The bridge itself derives its scope root from its installed * location, never from the spawn cwd, so this only affects command * path resolution. */ commandCwd?: 'scope-root' | 'config-dir'; /** * Name of an environment variable the runtime sets to the PROJECT ROOT * when it spawns a hook command, if it provides one (Claude Code: * `CLAUDE_PROJECT_DIR`, per its changelog "Hooks: Added * CLAUDE_PROJECT_DIR env var for hook commands"). When declared, the * installer anchors the bridge path on it and `commandCwd` is ignored, * because the path is then absolute at spawn time. * * Prefer this over the cwd-relative form wherever the runtime offers * it. The relative form assumes the hook is spawned at the project * root, and that assumption is not stable within a single session: an * agent that changes directory while working (the Bash tool's cwd * persists between calls) makes every later hook resolve against the * subdirectory, so ingestion stops with a `MODULE_NOT_FOUND` naming a * path the operator never wrote. * * An absolute literal would fix the cwd problem and break a worse one: * these hook configs are routinely committed, so a baked * `/home//...` breaks every teammate. The variable keeps the * config portable AND cwd-immune, which is why it is the right shape * rather than merely a convenient one. */ projectDirEnvVar?: string; } /** * `plugin-file`: write an in-process plugin file that POSTs to the * ingest route directly (no spawn). Carries NO wiring knobs: the * hook-registration half is the adapter's `pluginHooksSource` (code, * never manifest data). */ interface IActivityInstallPluginFile extends IActivityInstallBase { kind: 'plugin-file'; } /** One provider hook event to wire the bridge into (`json-hooks` installs). */ interface IActivityInstallEvent { /** Provider runtime event name, verbatim (e.g. `PreToolUse`). */ event: string; /** * Optional matcher in the provider's own grammar (e.g. a Claude tool * regex `^(Skill|Agent)$`). Omitted = the event's match-all form. */ matcher?: string; /** * Marks the event as OPT-IN (spec `provider.schema.json`): the * install renders it only when the matching operator choice is on * (`'shell'` -> the project-local `activity.shellCapture` key, set by * `sm activity install --shell`; provider-activity.md, Capture level * rung 5). Omitted = always rendered. */ optIn?: 'shell'; /** * Entry shape the runtime expects for THIS event's array. `'wrapped'` * (default): the `{ matcher?, hooks: [{ type, command }] }` group * every tool event uses. `'flat'`: a bare `{ type, command }` command * entry, the shape Antigravity's lifecycle events (PreInvocation / * PostInvocation / Stop) take (its parser rejects the wrapped form * there). Matchers do not apply to flat entries. */ entryShape?: 'wrapped' | 'flat'; } /** * Optional live-activity capability (see `spec/provider-activity.md`). * Declared by Providers whose runtime exposes a hook system that reports * skill / agent / command invocations in real time. Like `scaffold`, a * provider-owned capability sub-object, NOT a new extension kind. This * surface is UNRELATED to skill-map's internal `hook` extension kind * (scan lifecycle); provider activity consumes an EXTERNAL event source. */ interface IProviderActivityAdapter { /** Declarative install descriptor, the manifest (JSON) half. */ install: TActivityInstall; /** * How the runtime holds custody while a spawned child runs, which * decides what an OWNER-SCOPED END means for the spawns that owner * PARENTS (see `spec/provider-activity.md` §Spawn custody). * * - `napping` (default, Claude's shape): the parent may idle while * its child works, so its owner-scoped end is ambiguous and counts * as a pause while it still parents a live spawn. * - `blocking` (OpenCode's shape): the parent blocks inside the spawn * call and cannot report idle mid-spawn, so an owner-scoped end * from it is TERMINAL and releases the spawns it parents too. * * The resolver projects it onto the wire as `terminal: true` on the * owner-release frame. Without it, a spawn whose completion never * arrives (a refused or crashed call, e.g. OpenCode refusing a nested * `task`) stays drawn until the client's decay sweep. */ spawnCustody?: 'blocking' | 'napping'; /** * Runtime half (TypeScript-only, never in the manifest JSON, mirroring * `classify()` / `walk()`): turn ONE raw provider hook payload into * zero or more activity signals, or `null` to disclaim the event. * MUST be pure and total over arbitrary input (the payload arrives * from an external process verbatim); throwing is treated as a * disclaim by the caller. */ mapEvent(raw: unknown): IActivitySignal[] | null; /** * Second runtime half, REQUIRED when `install.kind === 'plugin-file'` * (the install engine refuses to render without it) and meaningless * otherwise: the hook-registration source spliced into the generated * in-process plugin. The engine's template * (`core/activity/plugin-template.ts`) owns the ENVELOPE (header * marker, `serve.json` discovery, scope + loopback + token checks, * fetch timeout, never-throw); this source is the body of the * plugin's returned hooks map, one * `'': async (...) => { await forward('', {...}); },` * entry per hook `mapEvent` consumes, including any wiring-level * filters that keep high-frequency host traffic from ever leaving * the process. Payload knowledge exactly like `mapEvent`: it lives * with the Provider, never in the manifest and never in core. * * The source MAY parameterize a wiring-level filter on the * `{{SHELL_ON}}` placeholder (`core/activity/plugin-template.ts`), * the plugin-file dialect of a `json-hooks` descriptor's * `optIn: 'shell'` event (spec provider-activity.md §Capture level * rung 5): the install render resolves it to the stored opt-in, and * its presence marks the provider shell-capable * (`providerOwnsShellOptIn`). */ pluginHooksSource?: string; } interface IProvider extends IExtensionBase { /** Discriminant injected by the loader from the folder structure. */ kind: 'provider'; /** * Presentation metadata for the Provider's own identity (lens dropdown * label, topbar lens chip, per-node provider chip). Required so the UI * never hardcodes a closed provider list: it reads every registered * Provider's identity from the `providerRegistry` envelope field. * Distinct from `kinds[*].ui` (per-kind node visuals). * * Named `presentation`, NOT `ui`: the base `IExtensionBase.ui` field is * the view-contributions map (`Record`, * declared only by `extractor` / `analyzer` kinds). Providers leave that * inherited field undefined and carry their identity here instead. */ presentation: IProviderUi; /** * Optional auto-detection markers for the active-provider lens. When * present, the lens resolver auto-suggests this Provider if any marker * path exists under the scope root. Absent means the Provider is never * auto-suggested (it can still be selected manually). */ detect?: IProviderDetect; /** * Optional authoring targets for materialising verbs (`sm tutorial`). * When present, the Provider is offered as a destination for newly * generated content (a skill folder dropped under `scaffold.skillDir`). * Absent means a materialising verb never offers this Provider, e.g. * `codex` until Codex skills land, `antigravity` (skills live under * the open-standard `agent-skills` territory), `core/markdown` (owns * no authoring convention). */ scaffold?: IProviderScaffold; /** * Optional live-activity capability (see `spec/provider-activity.md` * and `IProviderActivityAdapter`). Present only on Providers whose * runtime exposes a hookable event system (claude today; codex / * antigravity / opencode are additive follow-ups). Absent means * `sm activity install` never offers this Provider and the ingest * route drops events tagged with its id. */ activity?: IProviderActivityAdapter; /** * Optional MCP config-discovery capability (see `IProviderMcpConfig` and * `spec/architecture.md` §Provider · MCP config discovery). When present, the * kernel reads the declared config file(s) each scan and materialises the * declared MCP servers as virtual `mcp://` nodes (config-side * canonical over the consumer-side `core/mcp-tools` emission). Absent means * this Provider surfaces MCP usage only from the consumer side. */ mcpConfig?: IProviderMcpConfig; /** * Optional MCP REGISTRATION recipe (see `TProviderMcpRegister` and * `spec/architecture.md` §Provider · MCP registration): how an operator * declares skill-map's own MCP server to this Provider's runtime, either as a * shell command or as a config document to save. Projected verbatim into the * BFF `providerRegistry`; absent means the UI copies the bare endpoint URL. */ mcpRegister?: TProviderMcpRegister; /** * Catalog of node kinds this Provider emits. Populated by the loader * from the `/kinds//` directory layout: each subfolder * becomes one entry, with `schema.json` parsed into `schemaJson` and * `kind.json#/ui` projected into `ui`. Authors do NOT write this map by * hand any more, it is a runtime descriptor only. * * The string keys are typed loosely (`string`) rather than `NodeKind` * because the value space is open by design: a future Cursor Provider * could declare `rule`, an Obsidian Provider could declare `daily`. */ kinds: Record; /** * Optional path globs the Provider claims. Enforcement-grade since * structure-as-truth: a Provider declaring `roots` only receives files * matching at least one glob; a Provider without `roots` acts as a * fallback for files unmatched by every other Provider's roots. Two * Providers whose `roots` both match the same file produce a * `provider-ambiguous` issue and the file stays unclassified. Mirrors * `extensions/provider.schema.json#/properties/roots`. */ roots?: string[]; /** * Optional auxiliary JSON Schemas this Provider's per-kind schemas * `$ref` by `$id`. Registered with AJV via `addSchema` BEFORE the * per-kind schemas compile, so cross-file `$ref` resolution succeeds. * * Use case: when several kinds share a common base (e.g. Anthropic's * merged skill / command frontmatter, both extend a shared * `skill-base.schema.json`), the Provider declares the base here so * `skill.schema.json` and `command.schema.json` can `$ref` it without * duplicating fields. * * Runtime-only, does NOT appear in the spec's `provider.schema.json` * manifest. Manifest-validated schemas remain the per-kind ones in * `kinds[].schema`; auxiliary schemas are an implementation * concern of how the runtime composes those. */ schemas?: unknown[]; /** * Declarative file-discovery config consumed by the kernel walker. * When present, the kernel walks every root, includes files whose * extension matches `extensions`, parses each with the parser id * registered in the kernel-internal registry, and yields `IRawNode` * records the same shape `walk()` would. * * When neither `read` nor `walk` is declared, `resolveProviderWalk` * applies the default `{ extensions: ['.md'], parser: 'frontmatter-yaml' }` * so the most common Provider shape needs zero configuration. * * Either a SINGLE rule (the common case) or an ARRAY of rules. A * Provider that reads several file families with different parsers * declares an array, and `resolveProviderWalk` runs one walk pass per * rule (each filtering by its own `extensions`). The codex provider * uses this to read `.toml` sub-agents (`parser: 'toml'`, * `bodyField: 'developer_instructions'`) AND `.md` open-standard skills * (`parser: 'frontmatter-yaml'`) declaratively, without an escape-hatch * `walk()`. Rules SHOULD use disjoint extensions; overlaps are * tolerated because the orchestrator's first-wins `claimedPaths` dedup * drops a path already claimed on an earlier pass. * * Precedence: when both `walk()` (runtime field) and `read` are * declared, `walk()` wins, `read` is ignored. The escape-hatch * relationship is intentional: most Providers should use `read` (single * or multi-rule); Providers with genuinely non-standard discovery * requirements (custom file naming, dynamic ignore logic) implement * `walk()` directly and accept the duplication of audit-cleared defences. * * Built-in parsers: `'frontmatter-yaml'` (markdown with `--- … ---` * YAML frontmatter; pollution-strip + JSON_SCHEMA-pinned), `'plain'` * (entire body, empty frontmatter), `'toml'` (whole-file TOML as * structured frontmatter). The set is closed; user plugins cannot * register their own. */ read?: IProviderReadConfig | IProviderReadConfig[]; /** * Walk the given roots and yield every node the Provider recognises. * Non-matching files are silently skipped. Unreadable files produce * a diagnostic via the emitter but do not abort the walk. * * `options.ignoreFilter`, when supplied, the Provider MUST * skip every directory and file whose path-relative-to-root the * filter reports as ignored. Providers MAY also keep their own * hard-coded skip list (e.g. `.git`) as a defensive measure, but the * filter is the canonical source of user intent. * * Optional. When omitted, the Provider MUST declare `read` (or rely * on the default config). The orchestrator never calls `walk()` * directly, it goes through `resolveProviderWalk(provider)` which * picks `walk` over `read`. */ walk?(roots: string[], options?: IProviderWalkOptions): AsyncIterable; /** * Given a path and its parsed frontmatter, decide the node kind, or * `null` to disclaim the file. The classifier is called after walk() * yields; with multiple Providers active, every Provider walks every * file matching its `read.extensions`, so each Provider MUST disclaim * paths it does not recognise. Returning the same path's kind from * two Providers fires the spec's `provider-ambiguous` issue and the * orchestrator drops the duplicate. * * Convention: a Provider's classify returns one of its own `kinds` * map keys for paths in its territory (`.claude/`, `.codex/`, * `.agents/skills/`, etc.) and `null` elsewhere. External Providers * (Cursor, Obsidian, …) follow the same rule: claim what's yours, * disclaim everything else. The orchestrator does not validate the * kind against `NodeKind`. */ classify(path: string, frontmatter: Record): string | null; /** * Strict resolution matrix consumed by the post-walk confidence-lift * transform: maps a `link.kind` (emitted by an Extractor in this * Provider's plugin, e.g. `'mentions'`, `'invokes'`) to the set of * target `node.kind` values that count as a valid resolution. * * Used to decide whether to bump a link's confidence to 1.0 when its * normalized trigger matches some node's identifier (see * `IProviderKind.identifiers`). A link whose name resolves to a node * whose kind is NOT in `resolution[link.kind]` stays at its * extractor-emitted confidence, the name exists but does not resolve * AS THIS link.kind. Example: in `claude`, `invokes` resolves to * `['command', 'skill']`, so a `/foo` slash matching an `agent` named * `foo` does not bump (agents are mentioned with `@`, not invoked * with `/`). * * The lookup uses the Provider id attached to the link's SOURCE node * (i.e. who wrote the trigger). A link sourced from a markdown body * outside any Provider's territory falls under `core/markdown`'s * empty rules, no bump applies via the name path (the path-match rule * still does). * * Default `undefined` ≡ empty map ≡ no link.kind bumps under this * Provider's name index. Path matches (`link.target === node.path`) * are unaffected, those always bump regardless of `resolution`. * * Distinct from the spec's `IProvider.resolverRules` (referenced in * §Resolver phase): `resolverRules` rank candidates inside the Signal * IR (Phase 3+, not wired today); `resolution` is the post-walk * confidence-lift contract, which runs against the merged Link graph. */ resolution?: Record; /** * Lens gating flag. When `true`, this Provider is a LENS: its * `classify()` only runs (and the walker only iterates its territory) * if `provider.id === activeProvider` (the project's active lens), and * it is offered as a selectable lens (the BFF projects `isLens: true` * from this flag). When `false` or omitted (default), the Provider is a * non-gated universal BASE and classifies unconditionally. * * Vendor providers (`claude`, `codex`, `antigravity`) and the * open-standard `agent-skills` provider MUST set this `true`: the actual * runtimes never read each other's on-disk formats (Claude Code does not * consume `.codex/`; Codex CLI does not consume `.claude/`), and offering * every file to every provider fabricates cross-vendor graph edges the * runtimes themselves reject. * * Only the markdown fallback `core/markdown` (and any future * format-based fallback) keeps this `false`: the single non-gated base, * consumed by every lens and run on every scan. It is the substrate, NOT * a selectable lens (`isLens: false`). * * There is no unlensed state: a project with no vendor marker resolves * to the open-standard `agent-skills` default lens, under which the * open-standard classifier plus the universal base run. The resolver * never yields a null lens. * * Default `undefined` ≡ `false` ≡ universal. The field affects * classification ONLY; extractors continue to filter via their own * `precondition.provider` allowlist and are unaffected by this flag. */ gatedByActiveLens?: boolean; /** * Reserved invocation names this Provider's runtime owns for each * kind. Maps a `node.kind` to the set of normalised names the runtime * uses for its built-in invocables (e.g. `claude` reserves * `['help', 'clear', 'init', …]` under `command` because typing * `/help` in the Claude CLI runs the built-in help screen, not a * user-authored `.claude/commands/help.md`). * * Two consumers share the catalog: * * 1. The `core/name-reserved` analyzer scans every user node and * emits a `warn` issue when the node's normalised identifiers * (per `IProviderKind.identifiers`) intersect the reserved list * for its provider + kind. The user file is silently shadowed * by the runtime, the analyzer surfaces it so the operator can * rename. * 2. The post-walk confidence-lift transform downgrades any link * that resolves to a reserved node (by path OR by name) to a * very low confidence floor (today `0.1`) instead of bumping * to `1.0`. The graph then reflects "this edge exists in disk * but the runtime ignores the target". * * Default `undefined` ≡ empty map ≡ no reserved names. Reserved * lookup normalises both sides via the §Extractor · trigger * normalization pipeline (lowercase, NFD, separator unification), * so a literal `Init-Project` in the manifest still matches a user * `name: init project` or filename `Init-Project.md`. * * The set is intentionally per-kind, not global: a name reserved for * commands (`/help`) may legitimately appear as a skill (a "help" * skill that triggers via something other than the command channel). * Providers MUST scope each entry to the kind the runtime actually * consumes. */ reservedNames?: Record; /** * Per-Provider ranking hints consumed by the Signal IR **resolver * phase** (kernel `resolveSignals`). Drives intra-Signal candidate * selection AND cross-Signal range-overlap tiebreaks. * * Optional, when absent the resolver uses the default tiebreak chain: * `confidence` DESC → `range` length DESC → extractor declaration * order. Most Providers do not need to declare this; the default chain * is correct unless the Provider has a kind-specific preference (e.g. * "treat `invokes` edges as more important than `mentions` of the * same range"). * * Distinct from the `resolution` field above: `resolverRules` ranks * candidates INSIDE the Signal IR (the candidate that becomes a Link * in the first place); `resolution` ranks Links AFTER they exist * (confidence lift on already-emitted edges). The two surfaces share * no mechanism and intentionally do not compose. */ resolverRules?: IResolverRules; } /** * Per-Provider Signal IR resolver ranking hints. Mirrors * `extensions/provider.schema.json#/properties/resolverRules`. */ interface IResolverRules { /** * When present, the resolver ranks candidates whose `kind` appears * earlier in this array ABOVE candidates whose `kind` appears later. * Candidates whose `kind` is absent from the array drop to the end * (after every listed kind). Ties inside the same `kindPriority` * bucket fall through to the `confidence` → range-length → declaration * order tiebreaks. * * Example: a Provider that wants `invokes` edges to win against * `mentions` / `references` of the same byte range declares * `['invokes', 'references', 'mentions']`. */ kindPriority?: readonly LinkKind[]; } /** * Per-invocation options the orchestrator threads into a Provider walk * (and through `resolveProviderWalk` into the kernel walker). All * optional, so a bare `provider.walk(roots)` keeps working. * * - `ignoreFilter`, the composed `.skillmapignore` + config.ignore + * bundled-defaults filter. * - `maxFileSizeBytes` / `onOversizedFile`, mirror of * `scan.maxFileSizeBytes` and the collector that records skipped * files into `ScanResult.oversizedFiles`. A Provider that ships its * own `walk()` SHOULD forward both into `walkContent` (or apply the * same size guard) so oversized files stay skipped + reported * regardless of which discovery path runs. */ interface IProviderWalkOptions { ignoreFilter?: IIgnoreFilter; maxFileSizeBytes?: number; /** * Mirror of `scan.followExternalSymlinks` (default `false`). Forwarded * to the kernel walker so a symlink whose target escapes the scan roots * is refused unless the operator opted in. A Provider shipping its own * `walk()` SHOULD forward it (or apply the same containment) so the * gate holds regardless of the discovery path. Absent → contained. */ followExternalSymlinks?: boolean; onOversizedFile?: (info: { path: string; bytes: number; }) => void; /** * Incremental-walk hint: prior-scan file mtimes keyed by root-relative * path (the same form as `IRawNode.path`). When supplied, the kernel * walker compares each file's on-disk `mtime` against this map and, on * a match, yields a lightweight `unchanged` record WITHOUT reading or * parsing the body (the dominant cost on a re-scan). The orchestrator * builds this from the prior snapshot only when cache reuse is on and * the tokenizer is unchanged; absent means "read every file" (the * full-scan default). A Provider shipping its own `walk()` MAY honour * it for the same speedup but is not required to. */ priorMtimes?: ReadonlyMap; /** * Scoped-walk hint for the watcher's incremental path: an explicit * list of ABSOLUTE file paths to read instead of traversing the * roots. When supplied, the kernel walker skips traversal entirely and * reads ONLY these paths (those matching the provider's `extensions`, * existing on disk, passing the size guard), yielding a normal * `IRawNode` per match. Built by the orchestrator from chokidar's * changed-path list; absent means "traverse the roots" (the full-scan * default). A Provider shipping its own `walk()` MAY honour it for the * same speedup but is not required to. */ scopedPaths?: readonly string[]; /** * Per-pass directory containment memo for the scoped read (audit H4). * The orchestrator allocates ONE and hands the same instance to every * active provider's scoped walk, so the containment `realpath`s are * paid once per directory instead of once per provider per file. A * Provider shipping its own `walk()` may ignore it. */ scopedContainmentCache?: Map; } /** * Declarative read config a Provider declares via `IProvider.read`. * Mirrors `extensions/provider.schema.json#/properties/read` at the * TypeScript level. Built-in parser ids: `'frontmatter-yaml'`, `'plain'`. */ interface IProviderReadConfig { /** * File extensions the walker yields. Strings include the leading dot * (e.g. `'.md'`, `'.mdc'`, `'.toml'`). Match is suffix-based; the * comparison is case-sensitive. */ extensions: string[]; /** * Parser id from the kernel-internal registry. Built-ins: * `'frontmatter-yaml'`, `'plain'`. Unknown ids surface as * `UnknownParserError` from the walker; the orchestrator translates * the error into a Provider issue with status `invalid-manifest`. */ parser: string; /** * Name of a parsed-frontmatter field that carries the node's markdown * body. When set, the walker feeds `frontmatter[bodyField]` (when it is * a string) to every downstream consumer as the node `body` instead of * the parser's own `body` output: the body hash, byte counts, and every * body-scoped extractor (markdown-link, at-directive, slash, ...) then * see this prose. For formats where the prompt lives inside structured * frontmatter rather than after a fence: OpenAI Codex sub-agents are * pure TOML (`read.parser: 'toml'`) whose markdown prompt is the * triple-quoted `developer_instructions` field, so the codex provider * declares `bodyField: 'developer_instructions'`. When the field is * absent or not a string, * the parser's own `body` is used unchanged (the default for `.md` * providers). The field stays in `frontmatter` too, so frontmatter-scoped * extractors (e.g. `core/mcp-tools` reading `tools`) are unaffected. */ bodyField?: string; } /** * `ctx.log`, the diagnostic channel handed to every extension. * * Why this exists as a wrapper instead of exposing the kernel `log` * singleton directly: a plugin already runs arbitrary in-process code * once it clears the import gate (trust + enable), so handing it a * logger grants NO new capability. What the wrapper buys is the three * properties a raw `console.log` inside a plugin does not have: * * 1. **Channel discipline.** The kernel logger writes to stderr. A * plugin reaching for `console.log` lands on STDOUT and corrupts * every `--json` payload (`spec/cli-contract.md` §Machine-readable * output rules). Routing through here makes the safe channel the * easy one. * 2. **Terminal safety.** `Logger` writes its message verbatim, so an * extension-authored string carries ANSI escapes and C0 controls * straight to the operator's terminal. Every message crossing this * boundary goes through `sanitizeForTerminal` first, the same * defence already applied to extension-sourced ids and failure * reasons elsewhere in the kernel. * 3. **Attribution.** Each line is prefixed with the qualified * extension id, so operator output names its author and a plugin * cannot forge a line that reads as kernel output. * * Level semantics are the port's (`kernel/ports/logger.ts`): the CLI * boots at `warn`, so extension `info` / `debug` / `trace` stay silent * until the operator asks for them with `--log` / `--log-level`. A chatty extension costs nothing in normal runs. * * Deliberately NARROWER than `LoggerPort`: no `context` bag (its values * would need the same sanitisation and buy an extension nothing a * formatted message does not), no `setLevel` (the operator owns the * level, not the plugin), no `level()` read. * * Secrets are the author's responsibility, not the kernel's: an * extension holding a token can log it, exactly as it could before this * channel existed. `spec/plugin-author-guide.md` carries the warning. */ /** * The logging surface an extension sees on `ctx.log`. One method per * level, message-only. */ interface IExtensionLogger { trace(message: string): void; debug(message: string): void; info(message: string): void; warn(message: string): void; error(message: string): void; /** * True when a message at `level` would actually be emitted. For HOT * LOOPS only (per node, per link): the argument to `log.trace(...)` is * evaluated before anything can drop it, so an unguarded template * inside a loop over the graph is built on every scan even at the * default level. Guard those: * * if (ctx.log.enabled('trace')) ctx.log.trace(`…${x}…`); * * A one-shot line never needs this. */ enabled(level: 'trace' | 'debug' | 'info' | 'warn' | 'error'): boolean; } /** * Build the `ctx.log` an extension receives. * * `qualifiedId` is the `/` id (see * `qualifiedExtensionId`); it is sanitised too, since a disk plugin * authors its own manifest ids. */ declare function makeExtensionLogger(qualifiedId: string): IExtensionLogger; /** * Extractor runtime contract. Consumes a single node (frontmatter + body) * and emits its output through context-supplied callbacks rather than a * return value. Extractors run in isolation: they MUST NOT read other * nodes, the graph, or the DB. Cross-node reasoning lives in Analyzers. * * Extractors are deterministic-only. They run synchronously inside the * scan loop; LLM-driven enrichment of a node is an Action concern, not * an Extractor concern. The Extractor context therefore exposes no LLM * access, see spec `architecture.md` §Execution modes. * * **Structure-as-truth**: the extension's `id` and `kind` come from the * filesystem (`/extractors//index.ts`); the manifest does NOT * declare them. The `emitsLinkKinds` allowlist was retired with the same * refactor: the global closed enum of link kinds is the contract, and an * extractor emitting an off-enum kind keeps surfacing `extension.error`. * Confidence is per-emit (no manifest-level default). */ /** * Payload accepted by `IExtractorCallbacks.emitNode`. A loose subset of * `Node` because the kernel fills the rest from the emission context: * * - `bodyHash`, `frontmatterHash` are computed from `derivedFrom` (the * hash of the sources concatenated in declared order, so the * virtual node's hashes drift when any source changes). * - `bytes`, `linksOutCount`, `linksInCount`, `externalRefsCount` * default to zero counts on emission; the orchestrator's * post-extraction recompute pass fills them in once links resolve. * * The emitter MUST supply `path` (canonical id), `kind` (registered in * a Provider's catalog), `derivedFrom` (one or more existing-node paths * the virtual node is derived from), and SHOULD supply `frontmatter` * with the metadata the UI / analyzers will surface. */ interface IEmittedNode { /** Synthetic identifier. Use a non-filesystem scheme (`mcp://`, etc). */ path: string; /** Kind declared in some Provider's `kinds` catalog. */ kind: string; /** Required for virtual nodes: paths of the source(s). */ derivedFrom: string[]; /** Always true on this surface; the kernel mirrors it to `Node.virtual`. */ virtual: true; /** Provider id the node is attributed to (e.g. `'claude'`). */ provider: string; /** Optional structured metadata the UI / analyzers read. */ frontmatter?: Record; } /** * Output callbacks supplied by the kernel on the extractor context. */ interface IExtractorCallbacks { /** * Emit a single Link. Validated against the global closed enum of * link kinds (`invokes`, `references`, `mentions`, `points`) before * insertion; off-enum kinds drop silently with an `extension.error` * event. */ emitLink(link: Link): void; /** * Emit a multi-candidate `Signal` for the kernel's resolver phase to * collapse into a single Link (or reject). Use this instead of * `emitLink` when the detection carries genuine ambiguity (multiple * plausible kinds / targets), needs byte-range awareness for * collision detection, or needs numeric confidence with * sub-tier granularity. Unambiguous detectors should keep using * `emitLink` directly. See * [`signal.schema.json`](../../../spec/schemas/signal.schema.json) for the * normative contract. Validated against the same closed kind enum; * off-spec Signals (no candidates, off-enum kind, confidence outside * `[0..1]`) drop silently with an `extension.error` event. */ emitSignal(signal: Signal): void; /** * Phase 5, emit a synthetic / virtual `Node` derived from the * scanning context (frontmatter, sidecar, config). Used by the * `core/mcp-tools` extractor to materialise an `mcp://` node * out of a `tools: [mcp____*]` frontmatter entry, and by the * future Cursor / Codex MCP-config extractors that walk * `.cursor/mcp.json` / `~/.codex/config.toml`. The kernel * deduplicates by `node.path` against the walker's nodes AND across * extractor emissions: the FIRST emission of a given path wins, * subsequent emissions are silently ignored (idempotent semantics so * N skills referencing the same MCP collapse into one node). Emitted * nodes carry `virtual: true` and `derivedFrom: [...]` per * [`node.schema.json`](../../../spec/schemas/node.schema.json). */ emitNode(node: IEmittedNode): void; /** * Merge canonical, kernel-curated properties onto the current node's * enrichment layer. The author-supplied frontmatter stays untouched * (Decision #109 in `ROADMAP.md`). */ enrichNode(partial: Partial): void; /** * Emit a per-node view contribution. Pass the contribution object you * declared in the manifest's `ui` map BY REFERENCE, e.g. * `const facts = { slot: '...' } satisfies IViewContribution; ui: { facts };` * then `ctx.emitContribution(facts, payload)`. The kernel recovers the * contribution id + slot by object identity, then validates `payload` * against the slot's payload schema in * `spec/schemas/view-slots.schema.json#/$defs/payloads/`. `payload` * is typed from `ref.slot` (`SlotPayload`), so the wrong shape * is a compile error; an undeclared `ref` (a spread copy / inline literal) * or an off-shape payload drops at runtime with a loud `extension.error`. */ emitContribution(ref: C, payload: SlotPayload): void; } interface IExtractorContext extends IExtractorCallbacks { node: Node; body: string; frontmatter: Record; /** * Resolved values of the extension's declared `settings`, populated * from project config + user overrides. Empty object when no settings * are declared. */ settings: Record; /** * Plugin-scoped persistence. Optional because not every plugin declares * a `storage.mode` in `plugin.json`. See `spec/plugin-kv-api.md`. */ store?: unknown; /** * Diagnostic channel, stderr-bound, sanitised and prefixed with the * qualified extension id. Silent below `warn` until the operator * raises the level (`--log` / `--log-level`). NEVER write to stdout from * an extension: it corrupts every `--json` payload. See * `kernel/util/extension-logger.ts`. */ log: IExtensionLogger; } /** * Optional declarative filter shared with Analyzer and Action. The kernel * applies a single matcher: every declared sub-filter must hold for the * extension to be invoked on the candidate node. */ interface IExtensionPrecondition { /** * Qualified node kinds the extension accepts, written as * `/` (e.g. `claude/agent`). Unknown * qualified kinds load OK but surface a `precondition-kind-unknown` * warning in `sm plugins doctor`. */ kind?: string[]; /** Provider ids whose nodes the extension accepts. */ provider?: string[]; } interface IExtractor extends IExtensionBase { /** Discriminant injected by the loader from the folder structure. */ kind: 'extractor'; /** Which slice of the node the orchestrator feeds. Defaults to `both`. */ scope?: 'frontmatter' | 'body' | 'both'; /** * Optional precondition that gates `extract()` invocation. Replaces * the old `applicableKinds` field; same shape used by Analyzer and * Action so the kernel ships a single matcher. */ precondition?: IExtensionPrecondition; /** * Extractor entry point. Returns nothing; output flows through * `ctx.emitLink`, `ctx.enrichNode`, `ctx.emitContribution`, `ctx.store`. */ extract(ctx: IExtractorContext): void | Promise; } /** * Per-node observed EXECUTIONS folded from the journal (spec §Session * journal · Consumption): how many times a node ran as a UNIT across the * recordings. A unit run is a `start` frame naming the node with NO * resource access (`keepAlive` custody heartbeats do not count; a sticky * agent span counts once per claim, i.e. once per spawn). This is the * dead-design detector's VOLUME gate: absence of an observed pair means * nothing until the would-be source demonstrably executed. */ interface IObservedExecution { /** Scan-relative path of the node observed running. */ path: string; count: number; /** Distinct recordings the node ran in. */ sessions: number; /** Unix-ms of the latest run. */ lastSeenAt: number; } /** * One observed `(source, target)` relation folded from the journal. * `sessions` counts the DISTINCT recordings the pair appeared in, so the * analyzer's message can say "across N sessions" honestly; `count` totals * the individual observations. */ interface IObservedRelation { /** Scan-relative path of the node observed doing the invoking / spawning. */ source: string; /** Scan-relative path of the invoked / spawned node (`mcp://…` for invokes). */ target: string; relation: 'invokes' | 'spawns' | 'reads'; count: number; sessions: number; /** Unix-ms of the latest observation. */ lastSeenAt: number; } /** * The executions half of the fold: per-node unit-run counts plus the * ACTIVE-session denominator, the distinct recorded sessions that * produced at least one unit run (spec §Consumption: a recording where * nothing executed proves nothing, so "never ran" claims count against * active sessions, not raw files). */ interface IObservedExecutions { /** Per-node unit-run counts keyed by node path. */ byPath: ReadonlyMap; /** Distinct recorded sessions with at least one unit run. */ activeSessions: number; } /** * Analyzer runtime contract. Runs against the whole graph after every * Provider and extractor has completed; emits issues and MAY project * findings into the UI via view contributions. Deterministic analyzers * are pure (same graph in → same issues out) and run synchronously * inside `sm scan` / `sm check`. Probabilistic analyzers dispatch only * as queued jobs an external agent processes (`sm jobs claim` + * `sm record`), they never participate in scan-time pipelines. Mode is * declared in the manifest (default `deterministic`). */ /** * Step 9.6.2, orphan sidecar entry surfaced to analyzers. A `.sm` file * whose sibling `.md` does not exist on disk; the `annotation-orphan` * built-in analyzer emits one warning per entry. Other analyzers that * care about orphan sidecars MAY consume the list too. */ interface IAnalyzerOrphanSidecar { /** Relative path (POSIX-separated) of the orphan `.sm`. */ relativePath: string; /** Absolute path of the missing `.md` the sidecar was anchored to. */ expectedMdPath: string; } /** * One node's claim on a normalised name in the collision index, tagged * with the identifier source that produced it. `source` decides the * `core/name-collision` tier: `error` when two or more distinct paths * claim via `'frontmatter.name'`, `warn` for a mixed bucket (a declared * name colliding with another node's filename / dirname handle). Shared * shape between the orchestrator precompute (`collectNameCollisions`) * and the analyzer context. */ interface INameClaim { readonly path: string; readonly kind: string; readonly source: TIdentifierSource; } /** * One node whose declared `frontmatter.name` diverges from a declared * path-derived identifier, computed by `collectNameMismatches` and * projected by `core/name-mismatch`. `severity` is resolved at * precompute time from the kind's `identifierMismatch` knob because the * projector has no access to the kind registry. Both name fields carry * the RAW (pre-normalization) values so the issue message shows what * the author actually wrote. */ interface INameMismatch { readonly path: string; readonly kind: string; readonly severity: 'warn' | 'info'; readonly declaredName: string; readonly derivedName: string; readonly derivedSource: Exclude; } interface IAnalyzerContext { nodes: Node[]; links: Link[]; /** * Resolved values of the analyzer's declared `settings`, populated * from project config + user overrides. Empty object when no settings * are declared. */ settings: Record; /** * Step 9.6.2, orphaned sidecars discovered during the scan walk. * Empty when sidecar discovery did not run (legacy callers) or * when no orphans exist. */ orphanSidecars?: IAnalyzerOrphanSidecar[]; /** * Step 9.6.6, raw parsed sidecar root keyed by `node.path`. Populated * by the orchestrator alongside the public `Node.sidecar` overlay so * analyzers that inspect plugin namespaces (e.g. the built-in * `core/annotation-field-unknown` Analyzer) can walk the full tree without * re-reading the file from disk. Absent (or `undefined` per node) * when no sidecar accompanies the node, or when the sidecar failed * to parse. Treat as read-only. */ sidecarRoots?: ReadonlyMap>; /** * Step 9.6.6, runtime catalog of plugin-contributed annotation keys, * as exposed by `kernel.getRegisteredAnnotationKeys()`. Threaded * through so analyzers can reason about the registered-vs-unknown * split without reaching back into the kernel. Empty array when no * plugin declares contributions; absent for legacy callers (older * runScan sites that never wired the catalog through). */ annotationContributions?: readonly IRegisteredAnnotationKey[]; /** * Step 11.x, runtime catalog of plugin-contributed view contributions, * as exposed by `kernel.getRegisteredViewContributions()`. Threaded * through so analyzers can reason about emissions without reaching * back into the kernel; a generic context surface for cross-cutting * checks (no built-in consumes it today; the former * `core/contribution-orphan` stub was deleted 2026-07-22). Slot * catalog drift detection is NOT a scan concern, it lives at load * time and surfaces via `sm plugins doctor`. Empty array when no extension * declares view contributions; absent for legacy callers (older * runScan sites that never wired the catalog through). */ viewContributions?: readonly IRegisteredViewContribution[]; /** * Issues emitted by analyzers that already ran in the current pass. * Lets a late-phase analyzer (`core/issue-counter`) compute * cross-analyzer aggregates (per-node severity totals) without * scanning the persisted DB. The orchestrator threads the live * accumulator on every call so any analyzer can opt-in; only the * aggregator reads it today, the rest treat it as inert. * * Treat as read-only, the accumulator is shared with downstream * analyzers and a mutation here would corrupt their view of the * scan. Absent (or empty) on legacy callers that never wired it. */ accumulatedIssues?: readonly Issue[]; /** * Diagnostic channel, stderr-bound, sanitised and prefixed with the * qualified analyzer id. Silent below `warn` until the operator * raises the level (`--log` / `--log-level`). NEVER write to stdout from * an extension: it corrupts every `--json` payload. See * `kernel/util/extension-logger.ts`. */ log: IExtensionLogger; /** * Set of absolute file paths the operator has opted into for * link-validation purposes via `scan.referencePaths`. The driving * adapter walks each configured path before the scan and collects * every existing file's absolute path here. Files in this set are * NOT indexed as graph nodes, the only consumer is * `core/reference-broken`, which suppresses its `warn` issue when a * path-style link target falls into the set. Absent / empty when * the operator left `scan.referencePaths` empty or when the * adapter does not maintain the side index. Treat as read-only. */ referenceablePaths?: ReadonlySet; /** * Paths of nodes whose normalised identifier(s) intersect a * `reservedNames[kind]` catalog under self scope (the node's own * Provider, e.g. `.claude/commands/help.md` shadowed by Claude's * built-in `/help`) or lens scope (the active lens lending its catalog * to the universal `agent-skills` skill nodes it consumes, e.g. * `.agents/skills/goal/SKILL.md` shadowed by Antigravity's `/goal`). * The set is computed once per scan by the orchestrator (mirroring the * same set threaded to the post-walk confidence-lift transform), so * analyzers consume it without re-deriving every node's * identifiers. The single consumer today is `core/name-reserved`, * which projects one warn issue per entry; future analyzers MAY * read the set for cross-rule cohesion (e.g. an action that * suggests rename targets). Absent for legacy callers (older * `runScan` sites that never wired the field through). */ reservedNodePaths?: ReadonlySet; /** * Links the post-walk lift judged genuinely broken: target matches no * node `path` AND the stripped trigger matches no entry in the cross- * kind name index (`spec/architecture.md` §Provider · resolution * rules). Computed once per scan by the orchestrator from the same * `deriveNodeIdentifiers`-backed index the confidence-lift transform * uses, so a link that resolves only via a filename / dirname * identifier is NOT in the set. Membership is by object identity (the * orchestrator threads the SAME link objects). The single consumer is * `core/reference-broken`, which projects one issue per member (after * its `referenceablePaths` escape hatch). Absent for legacy callers * that never wired the field through, the rule then emits nothing. */ brokenLinks?: ReadonlySet; /** * Names claimed by two or more distinct nodes, keyed by the normalised * name. Only kinds that declare `frontmatter.name` among their * `identifiers` participate (plain `core/markdown` and filename-only * kinds never contribute claims), and every bucket holds at least one * `frontmatter.name`-sourced claim (path-only buckets are dropped at * collection). Names that normalise to the same value (e.g. `Deploy` / * `deploy`) collide, mirroring how the resolver keys on the normalised * identifier. Computed once per scan by the orchestrator from the same * kind registry the resolver uses, so analyzers project it without * re-deriving (the `brokenLinks` / `reservedNodePaths` * precompute-and-project pattern). The single consumer is * `core/name-collision`, which emits `error` when two or more claims * are declared names and `warn` for mixed buckets. Absent for legacy * callers that never wired the field through. */ nameCollisions?: ReadonlyMap; /** * Nodes whose declared `frontmatter.name` diverges from a declared * path-derived identifier (filename stem / parent dirname), giving the * node two live names in the resolution index. Computed once per scan * by the orchestrator (`collectNameMismatches`) from the per-kind * `identifierMismatch` knob; severity travels in each entry. The * single consumer is `core/name-mismatch`. Absent for legacy callers * that never wired the field through. */ nameMismatches?: readonly INameMismatch[]; /** * Observed runtime relations folded from the session journal * (`spec/provider-activity.md` §Session journal), keyed * `source\x00target`. Computed by the DRIVING adapter before the scan * (`readSessionJournal` + `foldObservedActivity` from * `kernel/session-journal`) and threaded through * `RunScanOptions.observedRelations`, the same precompute-and-project * pattern as `referenceablePaths`. The single consumer today is * `core/observed-link-missing`, which flags observed pairs no declared * link covers. Absent when the journal directory is empty or the * caller never wired the field; the analyzer then emits nothing. * Treat as read-only. */ observedRelations?: ReadonlyMap; /** * Observed EXECUTIONS from the same journal fold * (`foldObservedActivity(...).executions`): per-node unit-run counts * (`byPath`, custody heartbeats excluded) plus the ACTIVE-session * denominator (`activeSessions`, distinct recorded sessions with at * least one unit run). Consumers: `core/observed-link-dead` * (per-source volume gate) and `core/observed-node-dead` (node-grain * "never ran" against the denominator). Absent when the journal is * empty. Treat as read-only. */ observedExecutions?: IObservedExecutions; /** * Absolute path of the scan's project root (cwd of the invocation). * Threaded into the analyzer pass so an analyzer that needs to * resolve a relative `link.target` to an absolute filesystem path * (today only `core/reference-broken`, when consulting * `referenceablePaths`) does not have to derive it from * `nodes[0].path` heuristics. Absent for legacy callers (older * `runScan` sites that never wired the field through). Always an * absolute path when present. */ cwd?: string; /** * Signals emitted by extractors during the scan, before the resolver * collapsed them into `links`. Populated when at least one extractor * opted into the Signal IR path (`ctx.emitSignal` in * `IExtractorCallbacks`). Empty / absent when every extractor used * `emitLink` directly (legacy and unambiguous paths). Treat as * read-only. Analyzers consume this for collision detection * (overlapping `range` from different extractors), fragmentation * detection, and conflict-visualisation; the resolved `links` remain * the source of truth for graph-level analyses. */ signals?: readonly Signal[]; /** * Emit a per-node view contribution declared in this analyzer's * manifest `viewContributions` map. Sync, void return; the * orchestrator validates the payload against the slot's schema at * call time and silently drops invalid emissions with a logged * `extension.error` event (parallel to * `IExtractorCallbacks.emitContribution`). * * Unlike Extractor's emit (which binds `nodePath` from `ctx.node.path` * implicitly because Extractors run per-node), Analyzer's `evaluate()` * sees the full graph at once. The analyzer walks `ctx.nodes` itself * and MUST supply the target node path explicitly per emission. * * Pass the contribution object declared in the manifest `ui` map BY * REFERENCE (same model as the Extractor emit). `payload` is typed from * `ref.slot`. An undeclared `ref` (a spread copy / inline literal) or an * off-shape payload drops with a loud `extension.error`. The kernel routes * accepted contributions to the same persistence pipeline as Extractor * emissions (`scan_contributions`). */ emitContribution(nodePath: string, ref: C, payload: SlotPayload): void; /** * Contribute a confidence adjustment to a link. Usable ONLY from a * `score`-phase analyzer; the orchestrator records it attributed to * the calling extension (`pluginId` / `extensionId`, like * `emitContribution`) and folds every op on a link into the final * `link.confidence` before the `detect` phase. `link` must be one of * `ctx.links` (matched by object identity). Present ONLY in the * `score` phase (absent for `detect` / `aggregate` and legacy * callers), mirroring the other orchestrator-injected ctx fields. */ adjustConfidence?(link: Link, op: TConfidenceOp): void; } interface IAnalyzer extends IExtensionBase { /** Discriminant injected by the loader from the folder structure. */ kind: 'analyzer'; /** * Execution mode. Optional in the manifest with a default of * `deterministic`. `probabilistic` analyzers (finders) run only as * queued jobs and ship files-by-convention (`prompt.md` + * `report.schema.json` extending the canonical findings envelope) * instead of `evaluate()`, mirroring the probabilistic Action shape. */ mode?: TExecutionMode; /** * Best-effort ADVISORY estimate of wall-clock duration in seconds when * `mode=probabilistic`, same contract as * `IAction.probExpectedDurationSeconds`: it does NOT arm or compute * expiry (Decision #139), it feeds the `jobs-overdue` doctor check and * display surfaces. Required by the schema's conditional for * probabilistic analyzers; ignored otherwise. */ probExpectedDurationSeconds?: number; /** * Optional declarative precondition. Same shape used by Extractor and * Action. The analyzer is invoked only when the graph contains at * least one node matching every declared sub-filter. * * The reverse relationship (which Actions resolve this analyzer's * findings) is now declared on the Action side via * `precondition.analyzerIds` (Modelo B). The old * `recommendedActions` field was retired with the structure-as-truth * refactor; the UI matches against Action manifests when surfacing * "Resolve this issue" affordances. */ precondition?: IExtensionPrecondition; /** * Execution phase. Drives the order the orchestrator schedules * analyzers in: * * - `'score'`, runs strictly BEFORE every `detect`-phase analyzer. * The ONLY phase permitted to write: it adjusts link confidence * via `ctx.adjustConfidence(link, op)`. The orchestrator folds * every score-phase op into `link.confidence` before the read- * only `detect` phase runs, so the `detect` analyzers see the * final value. The kernel seeds the 1.0 confidence baseline on * every link, then dogfoods this phase via two built-in score-phase * detectors (`core/name-reserved`, `core/reference-broken`), each * co-locating its penalty `delta` with the finding it reports. * - `'detect'` (default), the main pass. Walks nodes / links and * emits its own findings. Most analyzers live here. Read-only. * - `'aggregate'`, runs strictly AFTER every `detect`-phase * analyzer has finished. The orchestrator passes the full * issue accumulator on `ctx.accumulatedIssues`, so an * aggregator can compute cross-analyzer summaries (per-node * severity totals, etc.) without re-reading the persisted DB. * Aggregators emit contributions; emitting issues is allowed * but uncommon. Read-only. * * Phase scheduling is the clean alternative to ordering analyzers by * hand in the built-ins registry: filesystem-sorted generators can * keep their alphabetical output, the orchestrator applies the phase * sort (`score` < `detect` < `aggregate`) at run-time. */ phase?: 'score' | 'detect' | 'aggregate'; /** * Inlined prompt template for a BUILT-IN probabilistic analyzer. * Populated by the built-ins codegen (`scripts/generate-built-ins.js`) * from the analyzer's sibling `prompt.md` at build time; the built-in * equivalent of the on-disk `prompt.md` a user plugin resolves from * its source directory (mirror of `IAction.promptTemplate`). Absent on * on-disk plugins and on deterministic analyzers. */ promptTemplate?: string; /** * Inlined report schema for a BUILT-IN probabilistic analyzer. * Populated by the built-ins codegen from the analyzer's sibling * `report.schema.json` (parsed to an object at build time; MUST extend * the canonical findings envelope). Mirror of `IAction.reportSchema`. * Absent on on-disk plugins and on deterministic analyzers. */ reportSchema?: Record; /** * Deterministic evaluation entry point. Conditional per mode, the * mirror of `IAction.invoke`: a `deterministic` analyzer implements it * (the orchestrator invokes it during `sm scan` / `sm check`); a * `probabilistic` analyzer has NO `evaluate()`, its judgment is the * queued prompt an external agent processes and records into * `state_findings`. The orchestrator excludes probabilistic analyzers * from every scan-time phase, so a declared `evaluate` on one is * never invoked (tolerated silently at load, same posture as a * probabilistic Action declaring `invoke`). */ evaluate?(ctx: IAnalyzerContext): Issue[] | Promise; } /** * Action runtime contract. The fourth plugin kind (spec § A.4 + * `spec/schemas/extensions/action.schema.json`). * * Actions operate on one or more nodes in one of two execution modes: * * - `deterministic` (default), code runs in-process; the action computes * the report synchronously and returns it. No job, no handover. * - `probabilistic`, the kernel renders `/prompt.md` + preamble * into a queued job; an external agent claims it (`sm jobs claim`), * runs it against an LLM, and `sm record` closes the job by * validating the report against `/report.schema.json`. * * **Structure-as-truth file conventions**: every Action carries * `/report.schema.json` (the JSON Schema for the report, MUST * extend `report-base.schema.json`). Probabilistic Actions additionally * carry `/prompt.md` (the prompt template). The loader resolves * both by convention; missing or mis-placed files surface as `load-error`. * The `reportSchemaRef` / `promptTemplateRef` manifest fields were retired * with the same refactor. * * **Built-in inlined siblings**: an on-disk plugin has a source directory * at runtime, so the kernel reads `prompt.md` / `report.schema.json` off * disk. A BUILT-IN Action bundles into `src/plugins/built-ins.ts` as a plain * manifest object with no source directory, so the built-ins codegen * (`scripts/generate-built-ins.js`) reads those sibling files at build time * and inlines their content onto the manifest as `promptTemplate` (the * `prompt.md` text) and `reportSchema` (the parsed `report.schema.json`). * These two fields are the built-in equivalent of the on-disk files; they * are absent on on-disk plugins. * * **`prob*` prefix convention**: manifest fields that only apply when * `mode=probabilistic` start with `prob`. Today only * `probExpectedDurationSeconds` follows this convention. * * **Deferred runtime invocation**: the dispatcher (`Action.invoke(input, ctx)` * for deterministic; the `sm jobs claim` + `sm record` handover for * probabilistic) lands fully with the job subsystem (Decision #114 in * `ROADMAP.md`). The kernel today still validates manifests and surfaces * the precondition gating to the UI; the runtime entry point stays * optional until the job subsystem ships. */ type TActionWrite = { kind: 'sidecar'; path: string; changes: Record; }; /** * The IO capabilities an Action manifest may declare via `IAction.io` * (mirrors `spec/schemas/extensions/action.schema.json#/properties/io`). * Today the union has a single member (`'network'`): `invoke()` is pure * by contract, and an Action that MUST reach the network (the * provenance verifier `github/enrichment`) declares it here, which * (a) relaxes the purity rule for exactly that capability, (b) injects * `ctx.fetch` into its invocation context, and (c) subjects execution * to the project-local policy `allowNetworkActions` (default * `false`). Declared-network Actions execute only via `sm enrich`, * never inside `sm scan` and never as queued jobs. */ type TActionIoKind = 'network'; /** * The discriminant kinds an Action may emit through `IActionResult.writes`. * Today the union has a single member (`'sidecar'`); the alias keeps the * manifest `writes` capability (`IAction.writes`) in lock-step with the * runtime write union so a new write kind only has to be added in one place. */ type TActionWriteKind = TActionWrite['kind']; interface IActionResult { report: TReport; writes?: TActionWrite[]; } interface IActionContext { node: Node; nodeAbsolutePath: string; invoker: string; now: () => Date; /** * Resolved values of the Action's declared `settings`. Empty when no * settings are declared on the manifest. */ settings: Record; /** * Injected network entry point, present ONLY when the Action's * manifest declares `io: ['network']` (the single sanctioned * carve-out from the extension-purity rule, see * `spec/architecture.md` §Extension purity). Implementations MUST * route every remote call through it and never touch a global * `fetch`: the injection is what lets the dispatcher (`sm enrich`) * enforce the `allowNetworkActions` policy and lets tests substitute * a fake transport. Absent on every other Action's context. */ fetch?: typeof globalThis.fetch; /** * Diagnostic channel, stderr-bound, sanitised and prefixed with the * qualified action id. Silent below `warn` until the operator raises * the level (`--log` / `--log-level`). NEVER write to stdout from an * extension: it corrupts every `--json` payload. See * `kernel/util/extension-logger.ts`. */ log: IExtensionLogger; } /** * Read-only graph context handed to an Action's scan-time `project()` * method. Mirrors the Analyzer emit path (`IAnalyzerContext`): the * Action sees the full merged graph (`nodes` + `links`) and emits its * own per-node view contributions via `emitContribution`, supplying the * target node path explicitly because, like the Analyzer, it walks the * whole graph rather than running per-node. * * The contribution is declared in the Action's manifest `ui` map and * passed BY REFERENCE (same object-identity model as Extractor / * Analyzer emit). The orchestrator validates the payload against the * slot's schema at call time, dropping invalid emissions with an * `extension.error` event. * * `project()` is strictly DETERMINISTIC and side-effect-free: no writes, * no runner, no IO. It runs during the scan's contribution phase on * EVERY scan, exactly like an Analyzer's emit path, so its cost is the * same per-scan cost as today's projector analyzers. Even an Action * whose `invoke` is `mode: 'probabilistic'` MUST keep `project()` * deterministic, only `invoke` may be probabilistic. */ interface IActionProjectionContext { readonly nodes: readonly Node[]; readonly links: readonly Link[]; emitContribution(nodePath: string, ref: IViewContribution, payload: unknown): void; } /** * Declarative filter applied by `--all` fan-out, UI button gating, and * `sm actions show`. Same shape used by Extractor and Analyzer so the * kernel ships a single matcher; the `analyzerIds` field is unique to * Action and powers Modelo B (Action declares which Analyzer findings * it resolves; replaces the deprecated `Analyzer.recommendedActions`). */ interface IActionPrecondition { /** * Qualified node kinds this action accepts, written as * `/` (e.g. `claude/agent`). Unknown * qualified kinds load OK but surface a `precondition-kind-unknown` * warning in `sm plugins doctor`. */ kind?: string[]; /** Provider ids whose nodes this action accepts. */ provider?: string[]; /** * Qualified analyzer ids whose findings this action resolves * (`/` or `/:` when the * analyzer emits sub-typed issues). The UI matches against this list * to surface "Resolve this issue" affordances. Dangling references * warn via `recommended-action-missing` in `sm plugins doctor` but * do NOT block load. */ analyzerIds?: string[]; /** * Frontmatter-gap gate: the action applies ONLY to nodes whose * frontmatter is missing at least one of the listed fields (no * frontmatter block, absent field, or empty-string value; a * non-string value counts as present). Evaluated by the same shared * matcher as `kind` / `provider` (`nodeMatchesPrecondition`), so it * gates the BFF launcher classification and the `--all` fan-out * alike. E.g. `core/ai-frontmatter-action` declares * `['name', 'description']` so its standalone launcher renders only * while the file is missing one of them. */ frontmatterMissing?: string[]; } interface IAction extends IExtensionBase { /** Discriminant injected by the loader from the folder structure. */ kind: 'action'; /** * Execution mode. Optional with default `deterministic` since the * structure-as-truth refactor. */ mode?: TExecutionMode; /** * Best-effort ADVISORY estimate of wall-clock duration in seconds when * `mode=probabilistic`. Does NOT arm or compute expiry (Decision #139: * TTL is opt-in operator policy); it feeds the `jobs-overdue` doctor * check and display surfaces. Required by the schema's conditional for * probabilistic Actions; ignored otherwise. Renamed from * `expectedDurationSeconds` with the `prob*` prefix convention. */ probExpectedDurationSeconds?: number; /** * Declares that this probabilistic Action operates on NO node * (`spec/job-lifecycle.md` §Submit · Nodeless submit). Its prompt asks * nothing about project content, so a submit skips target resolution * AND the on-disk read + drift verification, enqueues against the * synthetic id `sm://`, and renders without a * `` block. * * Narrow by design: it exists for SYSTEM probes whose subject is the * agent, not a file (`core/ai-ping-action`, the liveness probe, is the * only declarer). Binding such a probe to "the first real node" made it * fail whenever that node had been deleted since the last scan, and made * it unrunnable against an empty corpus. NOT an escape hatch for Actions * that do read content. Ignored unless `mode=probabilistic`. */ probNodeless?: boolean; /** * Declared persistent-write capability. Mirrors the `kind`s this * Action's `invoke()` may return in `IActionResult.writes`. Today the * only kind is `'sidecar'` (the Action creates / modifies a `.sm` * annotation sidecar). An Action that returns a sidecar write MUST * declare `['sidecar']` here: the manifest declaration is what * consumers gate on WITHOUT invoking the action, so the * `allowSidecarWriters: false` project policy can drop every * sidecar-writer from the scan composer (its `inspector.action.button` * never projects) and the sidecar store can refuse the write. Absent = * the Action performs no persistent writes (read-only / report-only). */ writes?: TActionWriteKind[]; /** * Optional declarative filter; absent → applies to every node. */ precondition?: IActionPrecondition; /** * Inlined prompt template for a BUILT-IN probabilistic Action. Populated * by the built-ins codegen (`scripts/generate-built-ins.js`) from the * Action's sibling `prompt.md` at build time; it is the built-in * equivalent of the on-disk `prompt.md` a user plugin resolves from its * source directory. Absent on on-disk plugins (which read `prompt.md` * from disk) and on deterministic Actions (which ship no prompt). */ promptTemplate?: string; /** * Inlined report schema for a BUILT-IN probabilistic Action. Populated by * the built-ins codegen from the Action's sibling `report.schema.json` * (parsed to an object at build time); the built-in equivalent of the * on-disk `report.schema.json` a user plugin resolves from its source * directory. Absent on on-disk plugins. */ reportSchema?: Record; /** * Declared IO capability (mirrors `TActionIoKind`). Absent = fully * pure `invoke()`. `['network']` = the Action's `invoke()` reaches * the network through the injected `ctx.fetch` (never a global) and * is refused at execution while the project-local policy * `allowNetworkActions` (default `false`) is off. The manifest * declaration is what dispatchers gate on WITHOUT invoking the * action, the same posture as `writes`. */ io?: TActionIoKind[]; /** * Deterministic invocation entry point. Optional on the runtime * contract until the job subsystem ships; Actions that ship for the * future probabilistic runner / record path leave it absent. * Implementations MUST stay pure (no IO inside `invoke()`) unless the * manifest declares the matching `io` capability (today only * `'network'`, routed through the injected `ctx.fetch`); the kernel * materialises any returned `writes` after the call. The return MAY * be a Promise: a declared-network Action is inherently async, so * every dispatcher `await`s the result (a plain value awaits to * itself, sync Actions stay unchanged). */ invoke?: (input: TInput, ctx: IActionContext) => IActionResult | Promise>; /** * Optional scan-time self-projection. When present, the orchestrator * calls it during the contribution phase (right after the analyzer * pass) with read-only graph access, and the Action emits its OWN * `inspector.action.button` (or any declared `ui` contribution) per * node. This replaces the former "projector analyzer" pattern: the * button now lives with the Action that dispatches it, not in a * sibling Analyzer. * * MUST be deterministic and side-effect-free (no writes, no runner, * no IO), exactly like an Analyzer's emit path. The button declares * its own qualified id as `actionId` in the payload. Actions that ship * for the future probabilistic runner / record path leave it absent; * an Action MAY declare both `project` and `invoke` (advertiser + * executor), or only one. */ project?(ctx: IActionProjectionContext): void; } /** * Formatter runtime contract. Turns the (nodes, links, issues) graph into * a textual representation for `sm graph --format `. * * **Structure-as-truth**: the format id comes from the formatter's folder * name (`/formatters//index.ts`); it is injected by the * loader into `id` and surfaced here as `formatId` for the existing CLI * lookup (`formatters.find((f) => f.formatId === flag)`). Manifests carrying * a `formatId` literal are rejected as `invalid-manifest`. * * All formatters accept the `--filter` expression; opting out is no longer * supported (the old `supportsFilter` field was retired). */ interface IFormatterContext { nodes: Node[]; links: Link[]; issues: Issue[]; /** * Resolved values of the formatter's declared `settings`, populated * from project config + user overrides. Empty object when no settings * are declared. */ settings: Record; /** * Full persisted scan, when the caller has it on hand. Optional so * formatters that only consume (nodes, links, issues) keep working * unchanged; formatters whose output mirrors a `ScanResult` envelope * (today: the built-in `json` formatter) read this to project the * canonical document verbatim. */ scanResult?: ScanResult; } interface IFormatter extends IExtensionBase { /** Discriminant injected by the loader from the folder structure. */ kind: 'formatter'; /** * Format identifier consumed by `sm graph --format `. Injected * by the loader from the formatter folder name. Surfaced as a top-level * field (rather than reusing `id`) so the existing CLI lookup keeps its * domain-specific name. */ formatId: string; /** * MIME-like hint surfaced when streaming over HTTP. Advisory; default * `'text/plain'`. */ contentType?: string; /** Serialize the graph into a string. Deterministic-only. */ format(ctx: IFormatterContext): string; } /** * Hook runtime contract. The sixth plugin kind (spec § A.11). * * Hooks subscribe declaratively to a curated set of kernel lifecycle * events and react to them. Reaction-only by design: a hook cannot * mutate the pipeline, block emission, or alter outputs. Use cases * are notification (Slack on `job.completed`), integration glue (CI * webhook on `job.failed`), and bookkeeping (per-extractor metrics). * * The hookable trigger set is INTENTIONALLY SMALL, nine events. Seven * are pipeline-driven (emitted from inside `runScan`); two * (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving * binary before / after the verb runs, fire-and-forget so * `process.exit` is never blocked). The full `ProgressEmitterPort` * catalog (per-node `scan.progress`, `model.delta`, `run.*`, internal * job lifecycle) is deliberately not hookable: too verbose for a * reactive surface, internal to the runner, or covered elsewhere. * Declaring a trigger outside the curated set yields * `invalid-manifest` at load time. * * **Deterministic-only since the structure-as-truth refactor**: the * `mode` field was removed from the manifest. `on(ctx)` runs in-process * during the dispatch of the matching event, synchronously between the * event's emission and the next pipeline step. Errors are caught by * the dispatcher, logged via `extension.error`, and never block the * main flow. To react to a lifecycle event with an LLM call, write a * deterministic Hook that enqueues a probabilistic Action via * `ctx.queue('/', payload)`. * * Curated trigger set (per spec § A.11): * * 0. `boot` , once per CLI process, before verb routing. * 1. `scan.started` , pre-scan setup (one per scan). * 2. `scan.completed` , post-scan reaction (one per scan). * 3. `extractor.completed` , aggregated per-Extractor outputs. * 4. `analyzer.completed` , aggregated per-Rule outputs. * 5. `action.completed` , Action executed on a node. * 6. `job.completed` , most common trigger. * 7. `job.failed` , alerts, retry triggers. * 8. `shutdown` , once per CLI process, after the verb's * exit code resolves and before * `process.exit`. * * Nine triggers, matching `extensions/hook.schema.json` exactly. A tenth, * `job.spawning`, used to sit between `action.completed` and * `job.completed`: it meant "pre-spawn of the runner subprocess", and the * pull-only decision (2026-07-13) removed the subprocess it named. It * outlived that removal here without ever being dispatched, so a plugin * could declare it, pass validation, and never fire. It was removed * rather than implemented, because there is no longer a spawn to hook. */ /** * The nine hookable lifecycle events. Mirrors the `triggers[]` enum in * `spec/schemas/extensions/hook.schema.json`. Seven are pipeline-driven * (emitted from inside `runScan`); two (`boot`, `shutdown`) are * CLI-process-driven (emitted by the driving binary before / after the * verb runs). Anything outside this set is rejected at load time as * `invalid-manifest`. */ type THookTrigger = 'boot' | 'scan.started' | 'scan.completed' | 'extractor.completed' | 'analyzer.completed' | 'action.completed' | 'job.completed' | 'job.failed' | 'shutdown'; /** * Frozen list mirror of `THookTrigger` for runtime introspection. The * loader validates `manifest.triggers[]` against this set; the * dispatcher iterates it in order when fanning an event out to * subscribed hooks. `boot` first / `shutdown` last so a debug log of * the array reads in lifecycle order. */ declare const HOOK_TRIGGERS: readonly THookTrigger[]; /** * Context the dispatcher hands to `Hook.on()`. The shape is intentionally * narrow: a hook reacts to an event, it does not steer the pipeline. * * The `event` carries the raw `ProgressEvent` envelope (type, timestamp, * runId/jobId when applicable, data). Optional `node` / `extractorId` * / `analyzerId` / `actionId` are extracted from the event payload by the * dispatcher when present so authors don't have to walk `event.data`. * * Probabilistic hooks additionally receive `runner` for LLM dispatch. * Deterministic hooks SHOULD ignore the field. */ interface IHookContext { /** * Resolved values of the hook's declared `settings`, populated from * project config + user overrides. Empty object when no settings are * declared. */ settings: Record; /** The raw event the dispatcher matched. */ event: { type: THookTrigger; timestamp: string; runId?: string; jobId?: string; data?: unknown; }; /** * Convenience extraction of the node payload when the event is * node-scoped (`action.completed`). Undefined for run-scoped or * scan-scoped events. */ node?: Node; /** * Diagnostic channel, stderr-bound, sanitised and prefixed with the * qualified hook id. Silent below `warn` until the operator raises the * level (`--log` / `--log-level`). NEVER write to stdout from an * extension: it corrupts every `--json` payload. See * `kernel/util/extension-logger.ts`. */ log: IExtensionLogger; /** * Set on `extractor.completed` events. Qualified extension id of the * Extractor whose work the event aggregates. */ extractorId?: string; /** * Set on `analyzer.completed` events. Qualified extension id of the Rule. */ analyzerId?: string; /** * Set on `action.completed` events. Qualified extension id of the * Action that just ran. */ actionId?: string; /** * Set on `job.*` events: the report payload for `job.completed`, * the failure record for `job.failed`. */ jobResult?: unknown; /** * Enqueue a probabilistic Action as a deferred job. The Hook stays * deterministic; LLM dispatch happens via the job subsystem the * Action drives. Available when the job subsystem is wired in; * placeholder `undefined` for legacy callers. */ queue?: (actionId: string, payload: unknown) => void; /** * Projection of the loaded Action catalog, supplied by the driver that * wires `ctx.queue` (the record path, on `job.*` dispatch). Lets a hook * resolve the INVERSE of Modelo B (the fixer Actions a finder's findings * feed, `spec/architecture.md` §Analyzer ↔ Action relationship) without * importing the registry: a chain hook (e.g. a drop-in subscribing * `job.completed`) filters this list by `analyzerIds` and `ctx.queue`s * each match. Absent for drivers * that do not queue (they never dispatch a hook that needs it). */ actions?: readonly IHookActionInfo[]; } /** * Minimal per-Action projection the dispatcher hands a hook via * `IHookContext.actions`: the qualified id plus the Action's declared * `precondition.analyzerIds` (Modelo B, the finders whose findings it * resolves; empty for a non-fixer Action). Deliberately narrower than * `IAction` so a hook never reaches the full runtime object. */ interface IHookActionInfo { /** Qualified extension id (`/`). */ id: string; /** The Action's `precondition.analyzerIds`; empty for a non-fixer. */ analyzerIds: readonly string[]; } /** * Optional declarative filter applied by the dispatcher BEFORE * invoking `on(ctx)`. Keys are payload field paths (top-level only in * v0.x); values are the literal expected match. The dispatcher walks * `event.data` for the field and short-circuits the invocation if the * value disagrees. * * Cross-field validation against declared `triggers` is best-effort * at load time: when none of the declared triggers carries a given * filter field, the loader surfaces `invalid-manifest`. The current * impl performs the basic enum check but defers full payload-shape * cross-validation to a follow-up, the dispatcher is permissive at * runtime (an unknown field never matches → the hook simply never * fires for that event, which is a correct interpretation of "filter * by a field that doesn't exist"). */ type THookFilter = Record; interface IHook extends IExtensionBase { /** Discriminant injected by the loader from the folder structure. */ kind: 'hook'; /** * Subset of the curated lifecycle trigger set this hook subscribes * to. MUST be non-empty; every entry MUST be a member of * `HOOK_TRIGGERS`. The loader validates both invariants and surfaces * `invalid-manifest` on violation. */ triggers: THookTrigger[]; /** * Optional declarative filter. Absent → invoke on every dispatched * event of every declared trigger. */ filter?: THookFilter; /** * Hook entry point. Returns nothing; reactions are side effects. * Errors are caught by the dispatcher (logged as `extension.error`, * surfaced via `hook.failed` meta-event) and NEVER block the main * pipeline, a buggy hook degrades gracefully. */ on(ctx: IHookContext): void | Promise; } /** * `ProgressEmitterPort`, emits progress events during long operations. * * Shape-only today. The full event catalog (`run.started`, * `job.claimed`, `model.delta`, etc.) is normative in * `spec/job-events.md`; this port carries an open `data` payload so * adapters can emit any documented event without type churn. */ interface ProgressEvent { type: string; /** * Job-event envelopes (`spec/job-events.md`) carry Unix milliseconds * (number, normative in the ndjson stream). The experimental scan / * extension families still emit ISO strings; they unify on numbers * when promoted to stable. */ timestamp: number | string; runId?: string; /** Null on run-level events (`run.*`), per the envelope contract. */ jobId?: string | null; data?: unknown; } type TProgressListener = (event: ProgressEvent) => void; interface ProgressEmitterPort { emit(event: ProgressEvent): void; subscribe(listener: TProgressListener): () => void; } /** * Per-node extractor invocation: build a fresh `IExtractorContext` for * each extractor, validate every emitted link / contribution against * the declared catalog, fold enrichment partials into per-`(node, * extractor)` records, and surface emit-time drops as * `extension.error` events. * * Also hosts the post-walk recompute helpers that re-derive * `linksOutCount` / `linksInCount` / `externalRefsCount` on every node * from the final merged link buffer, plus the `IExtractorRunRecord` * and `IEnrichmentRecord` types those records eventually persist as. */ /** * Spec § A.9, runs to persist into `scan_extractor_runs`. One entry * per `(nodePath, qualifiedExtractorId)` pair the orchestrator decided * "this extractor is current for this body". Includes both freshly-run * pairs (extractor invoked this scan) and reused pairs (cached node, the * extractor's prior run still applies to the same body hash). Excludes * obsolete pairs, extractors that ran in the prior but are no longer * registered, so a replace-all persist drops them automatically. */ interface IExtractorRunRecord { nodePath: string; extractorId: string; bodyHashAtRun: string; ranAt: number; /** * sha256 of the canonical-form sidecar annotations the Extractor saw * at run time. Always populated (an absent sidecar canonicalises to * `{}` so the hash is stable). Used unconditionally by the cache * decision alongside `bodyHashAtRun`: a sidecar-only edit invalidates * the cached run for every applicable Extractor on that node. */ sidecarAnnotationsHashAtRun: string; /** * sha256 of the canonical-form resolved settings the Extractor saw at * run time (`ctx.settings`: committed keys + project-local secrets + * env overrides; an extension without settings canonicalises to `{}`). * Third leg of the cache key: a settings change re-runs the pair on * the next scan, so the incremental default never serves outputs * computed under superseded settings. */ settingsHashAtRun: string; } /** * Spec § A.8, universal enrichment layer. * * One entry per `(nodePath, qualifiedExtractorId)` pair an Extractor * produced via `ctx.enrichNode(...)` during the walk. Attribution is * preserved per-Extractor (rather than merged client-side as B.1 did) * so the persistence layer can: * * - upsert a single row per pair (stable PRIMARY KEY conflict on * re-extract); * - feed `mergeNodeWithEnrichments` with `enrichedAt`-sorted partials * for last-write-wins per field at read time. * * `value` is the cumulative merge across every `enrichNode` call that * Extractor made for this node within this scan, multiple * `ctx.enrichNode({...})` calls inside one `extract(ctx)` invocation * fold into a single row, but two different Extractors hitting the * same node yield two distinct rows. * * `isProbabilistic` is reserved: Extractors are deterministic-only, so * every record produced by the orchestrator sets it to `false`. The * field is kept on the record (and the row in `node_enrichments`) so a * future Action-issued enrichment can populate it without reshaping * the persistence contract, see spec `architecture.md` * §Extractor · enrichment layer. */ interface IEnrichmentRecord { nodePath: string; extractorId: string; bodyHashAtEnrichment: string; value: Partial; enrichedAt: number; isProbabilistic: boolean; } /** * Run a set of extractors against a single node, collecting their link * emissions and node-enrichment partials. Each extractor is invoked * exactly once with a fresh `IExtractorContext`. Caller decides what * to do with the returned arrays (push into per-scan buffers, write to * a focused refresh result, etc.). * * Exported so `cli/commands/refresh.ts` can reuse the same wiring it * needs for re-running a single extractor against a single node, the * pre-extraction code in `refresh.ts` was hand-duplicating this loop * (audit item V4). * * Within this call, multiple `enrichNode(partial)` calls from the same * extractor against the same node fold into one record (last-write-wins * per field), same contract as the in-scan path. */ declare function runExtractorsForNode(opts: { extractors: IExtractor[]; node: Node; body: string; frontmatter: Record; bodyHash: string; emitter: ProgressEmitterPort; /** * File lines preceding the first body line (`IRawNode.bodyLineOffset`). * Extractors track lines against the body they receive; the orchestrator * adds this offset to every body-scoped Signal's `range.line` at emit * time so persisted lines are FILE-absolute (frontmatter counted). * Omitted / `0` when the body is the whole file or no absolute mapping * exists (`bodyField` providers), lines stay body-relative then. */ bodyLineOffset?: number; /** * Spec § A.12, per-plugin `ctx.store` wrappers keyed by `pluginId`. * The map's lookup is per-extractor inside the loop, so callers that * don't track plugin storage can omit it; the resulting `ctx.store` * stays `undefined` (the existing contract). */ pluginStores?: ReadonlyMap; }): Promise<{ internalLinks: Link[]; externalLinks: Link[]; enrichments: IEnrichmentRecord[]; contributions: IContributionRecord[]; contributionErrors: IContributionErrorRecord[]; signals: Signal[]; virtualNodes: Node[]; }>; /** * Rename + orphan classification per `spec/db-schema.md` §Rename * detection. Pure: takes the prior `ScanResult` and the current node * set, mutates the supplied `issues` array in place, and returns the * `RenameOp[]` the persistence layer must apply inside the same tx as * the scan zone replace-all. */ /** * Confidence-tagged plan to repoint `state_*` references from one node * path to another. Emitted by the rename heuristic during `runScan` and * consumed by `persistScanResult` so the FK migration runs inside the * same transaction as the scan zone replace-all. */ interface RenameOp { from: string; to: string; /** * Rename-heuristic confidence as a numeric tier. Body-hash matches * use `ConfidenceTier.HIGH` (`0.9`); frontmatter-hash matches use * `ConfidenceTier.MEDIUM` (`0.6`). Consumers that surface the tier * as a string (e.g. issue analyzerId `auto-rename-`) call * `renameTierLabel(confidence)` to recover the legacy label. */ confidence: number; } /** * Pure rename / orphan classification per `spec/db-schema.md` §Rename * detection. Mutates `issues` in place, caller passes the in-progress * issue list; returns the `RenameOp[]` for the persistence layer to * apply inside its tx. * * Pipeline (1-to-1: a `newPath` claimed by one stage cannot be reused * by another): * * 1. **High-confidence**: pair each `deletedPath` with a `newPath` * that has the same `bodyHash`. No issue, no prompt. * 2. **Medium-confidence (1:1)**: of the remaining deletions, pair * each with the *unique* unclaimed `newPath` that shares its * `frontmatterHash`. Emits `auto-rename-medium` (severity warn) * with `data: { from, to, confidence: ConfidenceTier.MEDIUM }`. * 3. **Ambiguous (N:1)**: when a single `newPath` has more than one * remaining frontmatter-matching candidate, emit ONE * `auto-rename-ambiguous` issue per `newPath`, listing all * candidates in `data.candidates`. NO migration. * 4. **Orphan**: every `deletedPath` left after steps 1-3 yields one * `orphan` issue (severity info) with `data: { path: }`. * * Determinism: `deletedPaths` and `newPaths` are iterated in lex-asc * order so the same input always produces the same matches, * required for reproducible tests and conformance fixtures (the spec * does not prescribe an order, but stability is the obvious contract). * * `silenced` (optional): predicate that returns true when a path * disappeared from the current scan because the project's * `.skillmapignore` (or any other ignore source) started excluding * it, not because the file was actually deleted from disk. The * orphan flagger uses it to skip the info-severity issue for those * paths: silencing a node intentionally is not the same as losing * one without a rename match. Callers that don't pass it preserve * the previous behaviour (treat every disappearance as an orphan). */ declare function detectRenamesAndOrphans(prior: ScanResult, current: Node[], issues: Issue[], silenced?: (path: string) => boolean): RenameOp[]; /** * Scan orchestrator, runs the Provider → extractor → analyzer pipeline across * every registered extension and emits `ProgressEmitterPort` events in * canonical order. The callable extension set is injected via * `RunScanOptions.extensions`, the Registry holds manifest metadata, the * callable set holds the runtime instances the orchestrator actually * invokes. Separating the two lets `sm plugins` and `sm help` introspect * the graph without loading code. * * With zero registered extensions (or a callable set that carries none) * the pipeline still produces a valid zero-filled `ScanResult`, the * kernel-empty-boot invariant. * * Roots are validated up front: each entry of `RunScanOptions.roots` * must exist on disk as a directory. The first failure throws a clear * `Error` naming the offending path. This guards every caller (CLI, * server) against silently producing a zero-filled * `ScanResult` when a Provider walks a non-existent path, the bug * that wiped a populated DB via `sm scan -- --dry-run` (clipanion's * `--` made `--dry-run` a positional root that did not exist). * * Incremental scans: when `priorSnapshot` is supplied, the * orchestrator walks the filesystem, hashes each file, and reuses the * prior node + its prior-extracted internal links whenever both * `bodyHash` and `frontmatterHash` match. New / modified files run * through the full extractor pipeline (including the external-url-counter * which produces ephemeral pseudo-links). Rules ALWAYS run over the * fully merged graph, issue state can change even for an unchanged node * (e.g. a previously broken `references` link now resolves because a new * node was added). For unchanged nodes the prior `externalRefsCount` is * preserved as-is (the external pseudo-links were never persisted, so * they cannot be reconstructed; the count survived in the node row). * * Extractor output model (B.1, post-rename from Detector): extractors * return `void` and emit through three callbacks injected on the context: * - `ctx.emitLink(link)` → orchestrator validates the kind against * the spec's closed enum, then partitions into internal / external * buckets. * - `ctx.enrichNode(partial)` → orchestrator records ONE enrichment * entry per `(node, extractor)` so attribution survives into the DB. * Persisted into `node_enrichments` (A.8). The author-supplied * frontmatter on `node.frontmatter` stays immutable from any Extractor * , the enrichment layer is the only writable surface, and rules / * formatters consume it via `mergeNodeWithEnrichments`. * - `ctx.store` → the plugin's own KV namespace (spec § A.12). * Wired by the driving adapter via `RunScanOptions.pluginStores`, * which the orchestrator looks up per-extractor by `pluginId` and * attaches to the context. The orchestrator never inspects what * plugins write through it; the wrapper handles AJV validation * when the manifest declared an output schema. */ interface IScanExtensions { providers: IProvider[]; extractors: IExtractor[]; analyzers: IAnalyzer[]; /** * Optional hooks (spec § A.11). When supplied, the orchestrator's * lifecycle dispatcher invokes deterministic hooks subscribed to one * of the eight hookable triggers in canonical order with the matching * event payload. Absent → no hooks fire (the scan still emits its * lifecycle events to `ProgressEmitterPort` for observability). * Probabilistic hooks are loaded but skipped here with a stderr * advisory until the job subsystem ships once the job subsystem ships. */ hooks?: IHook[]; /** * Optional enabled actions. When supplied, the orchestrator runs the * action-projection pass right after the analyzer pass: every action * carrying a scan-time `project()` self-projection emits its own view * contributions (e.g. `inspector.action.button`) onto the merged * graph. Actions without `project` (only `invoke`) ride along inert. * Absent → no projection pass runs (the gate is the composed enabled * set, so a disabled / experimental action never reaches here). */ actions?: IAction[]; } interface RunScanOptions { /** * Filesystem roots to walk. Spec requires `minItems: 1`; passing an * empty array makes `runScan` throw before any work happens. */ roots: string[]; emitter?: ProgressEmitterPort; /** Runtime extension instances. Absent → empty pipeline. */ extensions?: IScanExtensions; /** * Step 9.6.6, runtime catalog of plugin-contributed annotation keys * (the same shape `kernel.getRegisteredAnnotationKeys()` returns). * Threaded into the rule pass so `core/annotation-field-unknown` can * legitimise registered plugin namespaces / root keys without * re-walking the manifests. Absent → empty catalog (every plugin * key is treated as unknown). Built-in catalog from * `annotations.schema.json` is NOT included, that is hard-coded * inside the rule. */ annotationContributions?: readonly IRegisteredAnnotationKey[]; /** * Runtime catalog of plugin-contributed view contributions (the same * shape `kernel.getRegisteredViewContributions()` returns). Threaded * into the rule pass so: * - `core/contribution-orphan` can introspect the catalog * (read-only) and join it with the live node set to flag * dangling emissions. Slot catalog drift is NOT a scan concern, * it lives at load time and surfaces via `sm plugins doctor` * (the kernel rejects unknown slots as `invalid-manifest` first, * doctor catches the catalog-version-skew tail). * - The orchestrator's per-rule emit closure can look up each * declared `(contributionId → slot)` pairing for AJV * payload validation. * Absent → empty catalog. Rules that emit contributions silently * drop emissions when the catalog has no entry for the rule's * declared contributionId. */ viewContributions?: readonly IRegisteredViewContribution[]; /** * Compute per-node token counts (frontmatter / body / total) using the * encoder named by `tokenizer` (default `cl100k_base`). Defaults to * true. Set false to skip tokenization; `node.tokens` is left undefined * (spec-valid: the field is optional). */ tokenize?: boolean; /** * Offline tokenizer (encoder) used to build the per-node token counts. * Closed allow-list mirroring `project-config.schema.json#/properties/tokenizer`: * `cl100k_base` (default) or `o200k_base`. Threaded from `cfg.tokenizer` * by the driving adapters (scan-runner, watcher). Absent → `cl100k_base`. * The orchestrator guards the override layer: any value that is neither * allow-list member falls back to `cl100k_base` (the AJV enum on the * config schema already guarantees this for the config layers, the * guard covers out-of-band callers and the `override` layer). The * resolved value is carried onto `ScanResult.tokenizer` so the * persistence layer can record which encoder produced the counts and * the incremental path can detect an encoder switch. */ tokenizer?: string; /** * Prior snapshot for two purposes (decoupled by design): * * 1. **Rename heuristic** (`spec/db-schema.md` §Rename detection): * always evaluated when `priorSnapshot` is supplied. The * heuristic compares prior vs current node paths and emits * high / medium / ambiguous / orphan classifications. This * runs on EVERY `sm scan` (with or without `--changed`) so * reorganising files always preserves history, never silently. * * 2. **Cache reuse** (`sm scan --changed`): only kicks in when * `enableCache: true` is also passed. With the flag set, nodes * whose `path` exists in the prior with both `bodyHash` and * `frontmatterHash` matching the freshly-computed hashes are * reused as-is (their internal links and `externalRefsCount` * survive); only new / modified nodes run through extractors. * Rules always re-run over the merged graph. * * Pass `null` (or omit) for a fresh scan with no rename detection. */ priorSnapshot?: ScanResult | null; /** * Reuse unchanged nodes from `priorSnapshot` instead of re-running * extractors over them. Defaults to `false` so a plain `sm scan` * always re-walks deterministically. `sm scan --changed` flips this * to `true` for the perf win on unchanged files. * * Has no effect without `priorSnapshot`; setting it to `true` with * a null prior is a no-op (every file is "new"). */ enableCache?: boolean; /** * Filter that decides which paths the Providers skip. Composed by the * caller (typically the CLI) from bundled defaults + `config.ignore` * + `.skillmapignore`. Providers that omit this option fall back to * their own defensive defaults (just enough to keep `.git` / * `node_modules` out). */ ignoreFilter?: IIgnoreFilter; /** * Promote frontmatter-validation findings from `warn` to `error`. * Defaults to false. The CLI surfaces this via `--strict` on `sm scan` * and the `scan.strict` config key. When false, the orchestrator * still emits a `frontmatter-invalid` issue per malformed file but * leaves the severity at `warn` so a clean scan exits 0; when true, * the same finding becomes `error` and the scan exits 1. */ strict?: boolean; /** * Spec § A.9, fine-grained Extractor cache breadcrumbs from the * prior scan. Shape: `Map>`. * Loaded from the `scan_extractor_runs` table by the CLI before * invoking `runScan`; absent / empty for a fresh DB or an out-of-band * caller that does not maintain a cache. Decoupled from `priorSnapshot` * because the runs live in a sibling table and are useful only when * `enableCache` is also set. * * Cache decision per `(node, extractor)`: * - body+frontmatter hashes match the prior node AND every currently- * registered extractor that applies to this kind has a matching * row → full skip, all prior outbound links reused. * - some applicable extractor lacks a matching row (newly registered, * or its prior run targeted a different body hash or sidecar * annotations hash) → run only the missing extractors, drop prior * links whose `sources` map to any missing extractor or to an * extractor that is no longer registered. */ priorExtractorRuns?: Map>; /** * Spec § A.12, per-plugin storage wrappers exposed to extractors via * `ctx.store`. Keyed by `pluginId`; absent / missing entry leaves * `ctx.store` undefined for that extractor (the existing contract). * * The kernel does not construct these, the driving adapter (CLI, * future server) builds them with `makePluginStore` from * `kernel/adapters/plugin-store.js` and threads them through. This * keeps the orchestrator persistence-agnostic (the wrapper supplies * its own persist callback) and lets tests inject a captured-call * mock without spinning up a DB. */ pluginStores?: ReadonlyMap; /** * Side set of absolute file paths the operator opted into for * link-validation purposes via `scan.referencePaths`. Threaded * through to `IAnalyzerContext.referenceablePaths` so the built-in * `core/reference-broken` rule can suppress its `warn` for path-style * links whose target lands in the set. Files are NOT walked by * the kernel, the driving adapter populates the set before * calling `runScan`. Absent / empty when the operator left * `scan.referencePaths` unconfigured. */ referenceablePaths?: ReadonlySet; /** * Observed runtime relations folded from the session journal * (`spec/provider-activity.md` §Session journal). The DRIVING adapter * computes the map before the scan (`readSessionJournal` + * `foldObservedActivity` from `kernel/session-journal`, anchored at * `defaultProjectSessionsDir(cwd)`) and threads it here, the same * precompute pattern as `referenceablePaths`; the orchestrator only * projects it onto `IAnalyzerContext.observedRelations` for the * `core/observed-link-missing` analyzer. Absent / empty when the * journal directory holds nothing (the common case), the analyzer * then emits nothing. */ observedRelations?: ReadonlyMap; /** * Observed per-node execution counts from the same journal fold * (`foldObservedActivity(...).executions`), keyed by node path. The * orchestrator only projects them onto * `IAnalyzerContext.observedExecutions`; the single consumer is the * `core/observed-link-dead` volume gate. Absent / empty when * the journal holds nothing. */ observedExecutions?: IObservedExecutions; /** * Absolute path of the scan's cwd / project root. Threaded onto * `IAnalyzerContext.cwd` so rules that need to resolve a relative * `link.target` to an absolute filesystem path can do so without * heuristics, and used by the orchestrator to anchor the link-target * existence probe (`link-target-probe.ts`, the on-disk clause of the * genuinely-broken definition). Absent for callers that don't track * a cwd concept (out-of-band tests, embedders); the probe then stays * off and broken verdicts degrade to the two in-graph clauses. */ cwd?: string; /** * Active provider lens for this scan, gating provider-specific * extractors against both the node's provider AND the lens (per * `spec/architecture.md` §Universal extractors and per-provider * extractors). Three interpretations: * * - `string`: explicit lens. Provider-specific extractors run only * when their declared `precondition.provider` includes BOTH this * value AND the node's provider. * - `null`: "no lens" for bare callers. Provider-specific extractors * are unconditionally skipped, the same shape as the universal * markdown lens. Production never reaches this: the resolver in * `core/runtime` always yields a concrete lens (a vendor id, or * the markdown id when no marker is present). * - `undefined`: kernel auto-detects from `options.roots[0]` using * filesystem markers (`.claude/`, `.codex/`, `AGENTS.md`). * Convenient default for out-of-band callers * (integration tests, embedders) that don't thread a settings * reader. Production callers (scan-runner) resolve upstream and * pass a concrete lens string explicitly, never `undefined`. */ activeProvider?: string | null; /** * Walk-intake ceiling (mirror of `scan.maxScan` in settings, default * 5000). Threaded through to `walkAndExtract` so the ceiling can fire * (dropping extra files in stable order) and so `ScanResult.scanCeiling` * / `ScanResult.scanTruncated` are populated. Absent → the orchestrator * falls back to 5000 (the design default), keeping out-of-band callers * and synthetic fixtures safe. */ scanCeiling?: number; /** * Per-invocation override of the walk ceiling (when `--max-scan ` * was passed). `null` (or absent) means no override; the configured * ceiling applies. Bidirectional: any positive integer replaces the * ceiling for the duration of this scan. */ overrideScanCeiling?: number | null; /** * Map render cap (mirror of `scan.maxNodes` in settings, default 256). * Pure metadata: it does NOT bound the walk. Threaded through to * `walkAndExtract` only so `ScanResult.maxRenderNodes` is populated and * the UI knows how many nodes to project onto the canvas. Absent → the * orchestrator falls back to 256. */ maxRenderNodes?: number; /** * Per-invocation override of the render cap (when `--max-nodes ` * was passed). `null` (or absent) means no override; the configured * render cap applies. Bidirectional. Never bounds the walk. */ overrideMaxRenderNodes?: number | null; /** * Mirror of `scan.maxFileSizeBytes` (default 1 MiB). Threaded into * `walkAndExtract` so the walker skips any file larger than this * BEFORE reading it; skipped files surface in * `ScanResult.oversizedFiles` and `stats.filesOversized`. Absent → no * size limit (out-of-band callers and synthetic fixtures stay safe). */ maxFileSizeBytes?: number; /** * Mirror of `scan.followExternalSymlinks` (default `false`). Threaded * into `walkAndExtract` so the walker refuses a symlink whose target * escapes the scan roots unless the operator opted in. Absent → the * safe contained default. */ followExternalSymlinks?: boolean; /** * Watcher-only incremental fast path (pure perf, identical output to a * full scan). When supplied AND a prior snapshot exists AND * `enableCache` is on AND the tokenizer is unchanged, the orchestrator * does NOT traverse the directory tree. Instead it: * * - re-reads + re-extracts ONLY the files in `changed` (scoped read, * no `readdir`), and * - injects every other prior node as an `unchanged` record through * the SAME cache machinery the mtime-gate uses (`applyFullCacheHit`), * reusing its node + links + extractor runs verbatim, and * - drops the files in `removed` (the rename / orphan heuristic over * prior-vs-merged handles the disappearance + any rename). * * Paths are root-relative POSIX (the same form as `node.path`). A * sidecar (`.sm`) path in either set is mapped to its `.md` node so a * sidecar edit re-processes the node. The downstream analysis phases * (resolver, post-walk transforms, analyzers, broken-ref, * name-collision) always run over the fully-merged graph, so global * validation stays correct (a changed file's new link to an unchanged * file resolves; an unchanged file's link to a removed file breaks). * * `filesWalked` reflects only the scoped reads (far fewer than the * corpus), `scanTruncated` stays `false` (the ceiling never fires on a * scoped read). Absent (boot, `sm scan`, `sm scan --changed`, * meta-file change) falls back to the full-traversal + mtime-gate path, * byte-identical to today. Whether the fast path was honoured or fell * back is surfaced on the `scan.started` event as `mode: 'changed' | * 'full'` (`spec/job-events.md` §scan.started). */ incrementalChangedPaths?: { changed: ReadonlySet; removed: ReadonlySet; }; } /** * Same as `runScan` but also returns the rename heuristic's `RenameOp[]` * the high- and medium-confidence renames the persistence layer must * apply to `state_*` rows inside the same tx as the scan zone replace- * all (per `spec/db-schema.md` §Rename detection). Most callers want * `runScan` (which returns just `ScanResult`); the CLI's `sm scan` * uses this variant so it can hand the ops off to `persistScanResult`. * * Also returns `extractorRuns`, the Spec § A.9 fine-grained cache * breadcrumbs the CLI persists into `scan_extractor_runs` so the next * incremental scan can decide per-(node, extractor) whether re-running * is required. */ declare function runScanWithRenames(_kernel: Kernel, options: RunScanOptions): Promise<{ result: ScanResult; renameOps: RenameOp[]; extractorRuns: IExtractorRunRecord[]; enrichments: IEnrichmentRecord[]; contributions: IContributionRecord[]; contributionErrors: IContributionErrorRecord[]; linkScores: IConfidenceAdjustment[]; freshlyRunTuples: ReadonlySet; }>; declare function runScan(_kernel: Kernel, options: RunScanOptions): Promise; /** * Node-construction helpers: hash a body, canonicalise frontmatter / * sidecar annotations, resolve the sidecar overlay for a given relative * path, and produce a fresh `Node` (validating its frontmatter on the * way out). Also hosts `mergeNodeWithEnrichments` + `IPersistedEnrichment` * the read-time merge of author frontmatter with the A.8 enrichment * layer. */ /** * Spec § A.8, produce the merged read-time view of a Node. * * Rules / `sm check` / `sm export` consume `node.frontmatter` directly * (deterministic CI-safe baseline, author intent, byte-stable). UI / future * rules that opt into enrichment context call this helper to merge the * author frontmatter with the live enrichment layer. * * Algorithm: * * 1. Filter `enrichments` down to rows targeting this node AND not * flagged `stale`. With Extractors deterministic-only no row is * stale-flagged in this revision; the filter is preserved for the * future Action-issued enrichment revision (queued LLM jobs whose * output must survive body changes), where stale visibility * belongs to the UI layer next to the value. * 2. Sort the survivors by `enrichedAt` ASC so iteration order is * "oldest first". This makes the spread merge below * last-write-wins per field, the freshest Extractor's value * stomps the older one for any conflicting key. * 3. Spread-merge each row's `value` over `node.frontmatter`. The * author's keys are the base; enrichment keys overlay them. * * The returned object is a fresh shallow copy, mutating it does not * touch the caller's node. The original `node.frontmatter` reference * remains accessible via `node.frontmatter` for callers that want the * pristine author baseline. * * @param node Node to merge against; `node.frontmatter` is the base. * @param enrichments Per-(node, extractor) enrichment records, typically * loaded via `loadNodeEnrichments(db, node.path)` or * pre-filtered to this node by the caller. * @param opts.includeStale When true, include rows flagged stale. Defaults * to false (the safe, CI-deterministic default). * UIs that want to display "stale (last value: …)" * pass `true` and consult `enrichment.stale` * on the source rows. */ declare function mergeNodeWithEnrichments(node: Node, enrichments: IPersistedEnrichment[], opts?: { includeStale?: boolean; }): Record; /** * A persisted enrichment row, post-load. Mirrors the DB row shape * but with `value` already deserialised from JSON and `stale` / * `isProbabilistic` already decoded from `0 | 1`. Surfaced via * `loadNodeEnrichments` (driven adapter) and consumed by * `mergeNodeWithEnrichments` and the `sm enrich` command. */ interface IPersistedEnrichment { nodePath: string; extractorId: string; bodyHashAtEnrichment: string; value: Partial; stale: boolean; enrichedAt: number; isProbabilistic: boolean; } /** * In-memory `ProgressEmitterPort` adapter. No network, no DB, just a * synchronous fan-out to registered listeners. Used by the default scan * orchestrator; the WebSocket-backed emitter that streams to * the Web UI lands. */ declare class InMemoryProgressEmitter implements ProgressEmitterPort { #private; emit(event: ProgressEvent): void; subscribe(listener: TProgressListener): () => void; } /** * Typed errors raised by the Mode A KV store wrapper * (`kernel/adapters/plugin-store.ts`). The class names are normative: * `spec/plugin-kv-api.md` § Errors declares the exact four an * implementation MUST expose, so plugin code can branch on * `err instanceof KvKeyInvalidError` instead of string-matching a * message. * * They are plain `Error` subclasses (same shape as * `kernel/jobs/errors.ts`) so a caller that does not care about the * taxonomy still gets a readable message. Backend detail never reaches * the message text: a SQL string / file path only rides in * `KvOperationFailedError.cause`, per the spec's "errors MUST NOT leak * backend-specific details" analyzer. * * Naming note: these are runtime classes, not TS-only shapes, so the * `I*` / `T*` prefixes from `context/kernel.md` § Type naming do not * apply; the spec fixes the names verbatim. */ /** Key is empty, not a string, or above the 256-byte ceiling. */ declare class KvKeyInvalidError extends Error { readonly key: unknown; constructor(message: string, key: unknown); } /** * The `nodePath` scope selector is unusable: an empty string (reserved * as the internal global sentinel, see `KV_GLOBAL_NODE_ID`) or a * non-string value. * * ADDITIVE to the four classes named in `spec/plugin-kv-api.md` * § Errors. The spec's Stability section explicitly allows this * ("adding a new error class is a minor bump"), and the alternative, * overloading `KvKeyInvalidError`, would tell a plugin author to fix * their KEY when the problem is their SCOPE. The spec table should * gain a row for it; flagged rather than silently diverged. */ declare class KvNodePathInvalidError extends Error { readonly nodePath: unknown; constructor(message: string, nodePath: unknown); } /** * Value cannot be JSON-encoded: cyclic, `undefined`, a function, a * `bigint`, or a nested member of any of those. Raised BEFORE the * value reaches persistence, so a rejected write leaves no row behind. */ declare class KvValueNotSerializableError extends Error { readonly key: string; constructor(message: string, key: string, cause?: unknown); } /** * The write would push the plugin past its per-scan storage budget. * * Distinct from `KvValueTooLargeError`, which is about ONE value being * too big: this one fires when many individually-legal writes add up. * A plugin looping over 5,000 nodes hits this and never that, so * collapsing them would tell the author to shrink a value that is * already within its limit. */ declare class KvBudgetExceededError extends Error { readonly key: string; /** Bytes the plugin would have written this scan, including this value. */ readonly wouldTotalBytes: number; /** The ceiling that was crossed. */ readonly budgetBytes: number; constructor(message: string, key: string, wouldTotalBytes: number, budgetBytes: number); } /** Encoded value exceeds the reference implementation's 1 MiB ceiling. */ declare class KvValueTooLargeError extends Error { readonly key: string; readonly bytes: number; constructor(message: string, key: string, bytes: number); } /** * Unexpected backend failure (DB full, IO error, corrupt stored JSON). * The underlying error rides in `cause`; the message stays * backend-agnostic. */ declare class KvOperationFailedError extends Error { readonly operation: string; constructor(message: string, operation: string, cause?: unknown); } /** * File watcher for `sm watch` / `sm scan --watch`. * * Two backends behind one small `IFsWatcher` interface: * * - `createParcelWatcher` (`@parcel/watcher`) is the PRIMARY scan * watcher. A single native inotify instance scales to huge trees * without the `EMFILE` exhaustion chokidar hits via per-directory * `fs.watch`. * - `createChokidarWatcher` (`chokidar`) backs the META-watcher (config * files at `depth: 0`), which parcel cannot express (no depth limit). * * The interface buys two things: * * 1. The CLI / BFF are impl-agnostic, the backend swap (and a future * selectable backend) doesn't ripple into them. * 2. Debouncing, batching, and ignore-filter integration live in one * place (`createDebouncedBatcher` + `normalizeIgnoreFilter`), shared * by both wrappers. The caller just gets `onBatch(paths)` callbacks * and decides whether to re-scan. * * The watcher does NOT call into the orchestrator itself. That decision * is deliberate: the CLI owns the scan-and-persist pipeline (`runScan`, * `persistScanResult`, optional rebuild of the ignore filter when * `.skillmapignore` itself changes). Pulling that into the watcher * would couple the kernel module to `SqliteStorageAdapter`, which the * Server wouldn't want. Keep this module side-effect free * apart from filesystem subscription. * * Ignore filter integration: the supplied `IIgnoreFilter` is consulted * via chokidar's `ignored` predicate, which receives an absolute path. * We re-derive the path RELATIVE to the closest matching root before * passing it through `IIgnoreFilter.ignores`. This mirrors what the * scan walker does (`extensions/providers/claude/index.ts`) so both code * paths agree on what "ignored" means. */ type TWatchEventKind = 'add' | 'change' | 'unlink'; interface IWatchEvent { kind: TWatchEventKind; /** Absolute path. */ absolutePath: string; } interface IWatchBatch { /** Events that arrived inside the debounce window, in arrival order. */ events: IWatchEvent[]; /** Convenience: deduplicated absolute paths across the batch. */ paths: string[]; } interface IFsWatcher { /** Resolves once chokidar has finished its initial directory scan and is ready to emit. */ ready: Promise; /** Tear down the watcher. Resolves after chokidar releases handles. */ close: () => Promise; } interface ICreateFsWatcherOptions { /** Roots to watch. Resolved relative to `cwd` if relative paths are passed. */ roots: string[]; /** Working directory used to resolve relative roots and the ignore-filter root. */ cwd: string; /** Debounce window in milliseconds. `0` triggers `onBatch` synchronously per event. */ debounceMs: number; /** * Optional ignore filter, same instance the scan walker uses. * * Two shapes are accepted: * * - **`IIgnoreFilter`** (the static one), captured by reference at * construction. Use this when the filter never changes for the * lifetime of the watcher (the typical CLI `sm watch` flow). * * - **`() => IIgnoreFilter | undefined`** (a getter), re-evaluated * on EVERY chokidar `ignored` predicate call. Use this when the * filter can change at runtime, e.g. the BFF rebuilds it after * a `.skillmapignore` or `.skill-map/settings.json` edit and * wants chokidar to immediately respect the new patterns without * tearing down and rebuilding the watcher. A getter that returns * `undefined` disables ignore filtering for that call. */ ignoreFilter?: IIgnoreFilter | (() => IIgnoreFilter | undefined) | undefined; /** * Maximum directory traversal depth. `undefined` (default) walks the * tree recursively without bound; `0` limits the watch to the * literal `roots` entries (no descent), which is the right setting * when watching a directory only to catch changes to specific * top-level files (see `subscribeMeta` in `core/watcher/runtime.ts`). * Forwarded verbatim to chokidar's `depth` option. */ depth?: number; /** * Extension gate. When set, chokidar holds a watch on (and fires events * for) only FILES whose name ends with one of these suffixes (e.g. * `['.md', '.toml', '.sm']`). Directories always pass so the tree is * still traversed to reach matching files. Omitted ⇒ no gate (every * non-ignored file is watched, the legacy behaviour). Applied BEFORE * the ignore filter. The suffixes mirror the scan walker's provider * `read.extensions` (plus the `.sm` sidecar) so the watcher reacts only * to the file types a scan would actually open. NOT passed to the * meta-watcher, which targets specific config files by path instead. */ watchedExtensions?: readonly string[] | undefined; /** * Whether the project root `.gitignore` lines feed the PARCEL backend's * coarse native prune (`buildParcelIgnore`). Default `false`, mirroring * `scan.respectGitignore`: when off, parcel keeps watching git-ignored * dirs so nothing the operator asked to index is silently pruned at the * OS level. Chokidar ignores this option entirely, its authoritative * filter is the live `ignoreFilter` getter. Only meaningful on parcel. */ respectGitignore?: boolean | undefined; /** * Mirrors `scan.followExternalSymlinks` (default `false`). When off, * the CHOKIDAR backend refuses to arm a watch on any path whose * realpath escapes every root, the same realpath-containment gate * the walker applies (audit M1, and this backend's own finding on * 2026-08-01). * * chokidar dereferences symlinks by default, so before this existed * a committed `docs/x -> ~/` made `sm watch --watch-backend chokidar` * arm inotify watches across the operator's entire home directory: * no content leaked (the walker's read-side gate still refused it) * but the watch itself escaped containment, which exhausts inotify * and turns out-of-tree edits into an activity oracle. * * Expressed as a containment gate rather than chokidar's own * `followSymlinks: false` on purpose. The blunt flag would also stop * following symlinks whose target stays INSIDE the tree, and live * updates behind an internal symlinked directory are the entire * reason this backend is selectable over parcel. Parcel does not * follow symlinks at all, so it needs no gate. */ followExternalSymlinks?: boolean | undefined; /** Called once per debounced batch. Awaited; concurrent batches are serialised. */ onBatch: (batch: IWatchBatch) => void | Promise; /** * Called when the underlying watcher surfaces an error. The watcher * stays open, callers decide whether to log, keep going, or close. */ onError?: (err: Error) => void; } /** * Construct a chokidar-backed watcher. Subscribes immediately; the * returned `ready` promise resolves once chokidar's initial directory * walk completes, at which point only NEW events fire `onBatch`. * * The initial directory walk is deliberately silent, we set * `ignoreInitial: true`. The CLI runs a one-shot scan before flipping * the watcher on, so re-emitting an `add` for every existing file * would be redundant churn. * * Used for the meta-watcher (config files at `depth: 0`); the primary * scan watcher uses `createParcelWatcher` to avoid chokidar's `EMFILE` * exhaustion on huge trees. */ declare function createChokidarWatcher(opts: ICreateFsWatcherOptions): IFsWatcher; /** * Construct a `@parcel/watcher`-backed watcher (the primary scan watcher). * Parcel uses a single native inotify instance (managed in C++) rather * than one `fs.watch` per directory, so it does not exhaust inotify * instances / file descriptors on huge trees the way chokidar does (the * `EMFILE` failure), and arms the tree far faster. Same `IFsWatcher` * contract as the chokidar wrapper. * * Differences from chokidar, all handled here: * - parcel `subscribe` takes ONE directory, so we subscribe per root. * - `ready` resolves once every subscription is armed; parcel only * reports post-subscription changes (no initial events), matching * chokidar's `ignoreInitial: true`. * - parcel's `ignore` is a STATIC glob/path list, so the extension gate * and the (live) ignore filter run per-event in JS via `accept`, * preserving runtime filter swaps. The static `ignore` we pass is a * coarse prune (bundled-default dirs + raw `.gitignore` / * `.skillmapignore` lines) so parcel never even watches `node_modules` * and friends, which is the actual scale win. * - `depth` is not supported by parcel and is ignored (only the * meta-watcher uses `depth: 0`, and that stays on chokidar). * * NOTE: parcel's symlink support is weak/undocumented, so live updates * behind a symlinked directory may not fire on this backend; a full scan * still indexes them (the walker follows a symlink whose real target * stays inside a scan root, and refuses one that escapes unless * `scan.followExternalSymlinks` is set). Selecting chokidar via * `--watch-backend chokidar` restores live symlink watching under the * same containment rule. */ declare function createParcelWatcher(opts: ICreateFsWatcherOptions): IFsWatcher; /** * Scan delta, pure comparison of two `ScanResult` snapshots. Drives * `sm scan --compare-with ` and is the single place the kernel * knows how to identify "the same" entity across two scans. * * **Identity contract** (mirrors decisions made at earlier sub-steps): * * - **Node**: `node.path`. The path is the only field stable across * edits, every other Node field is content-derived (hashes, counts, * denormalised frontmatter). Two nodes with the same path are the * "same" node; differences are reported as a `changed` entry with * a reason narrowing what diverged. * * - **Link**: `(source, target, kind, normalizedTrigger ?? '')`. This * mirrors the link-kind-conflict rule and `sm show` aggregation, * two links with identical endpoints, kind, and (optional) trigger * are the same link, even if emitted by different extractors. The * `sources[]` union and confidence are NOT part of identity; they * are presentation facets that can churn without making the link * "different" for delta purposes. * * - **Issue**: `(analyzerId, sorted nodeIds, message)`. Mirrors * `spec/job-events.md` §issue.*, same key → same issue, even when * `data` / `severity` / `linkIndices` shift. A meaningful change in * `message` (or a different set of node ids) is a different issue. * This is the same key future job events will use; keep it aligned * so consumers can reuse logic. * * No "changed" bucket for links / issues, identity already captures * everything that matters there. Nodes get a "changed" bucket because * the path stays stable while the body / frontmatter rewrite, and that * change is meaningful (formatters, summarisers, downstream consumers * all care about it). * * Pure: no IO, no DB, no FS. Safe to run in-memory inside `sm scan` * without polluting the persisted snapshot. */ type TNodeChangeReason = 'body' | 'frontmatter' | 'both'; interface INodeChange { before: Node; after: Node; /** * Which hash diverged. `'body'` means body rewritten, frontmatter * untouched; `'frontmatter'` means metadata rewritten, body * untouched; `'both'` means both rewritten in the same edit. */ reason: TNodeChangeReason; } interface IScanDelta { /** Path the current scan was compared against (echoed for the report header). */ comparedWith: string; nodes: { added: Node[]; removed: Node[]; changed: INodeChange[]; }; links: { added: Link[]; removed: Link[]; }; issues: { added: Issue[]; removed: Issue[]; }; } declare function computeScanDelta(prior: ScanResult, current: ScanResult, comparedWith: string): IScanDelta; /** * `true` iff every bucket is empty. Callers use this to decide the * exit code (`0` clean, `1` non-empty delta). */ declare function isEmptyDelta(delta: IScanDelta): boolean; /** * Export query, minimal filter language for `sm export ` (Step 8.3). * * Spec contract: `spec/cli-contract.md` line 190 says "Query syntax is * implementation-defined pre-1.0". This module defines the v0.5.0 syntax. * * **Grammar** (BNF-ish, intentionally tiny): * * query := token (WS+ token)* * token := key "=" value-list * key := "kind" | "has" | "path" * value-list := value ("," value)* * value := non-comma, non-whitespace string * * Tokens AND together; values within one token OR. An empty / whitespace-only * query is valid and matches every node ("export everything"). * * **Filters**: * * - `kind=skill` / `kind=skill,agent`, node kind whitelist. * - `has=issues`, node must appear in some issue's `nodeIds`. (Future * expansion: `has=findings` / `has=summary` once Step 10 / 11 land. * Unknown values are a parse error today; we'll ratchet up the * accepted set additively.) * - `path=foo/*` / `path=.claude/agents/**`, POSIX glob over `node.path`. * Supports `*` (any chars except `/`) and `**` (any chars including `/`). * * **Subset semantics** (`applyExportQuery`): * * - Nodes pass when every specified filter matches (AND across keys, * OR within values). * - Links survive only when BOTH endpoints (`source` + `target`) belong * to the filtered node set. A subset that includes "edges out to * unfiltered nodes" would be confusing, the user asked for a focused * subgraph, not its boundary. External-URL pseudo-links are already * stripped by the orchestrator and never reach this layer. * - Issues survive when ANY of the issue's `nodeIds` is in the filtered * set. Issues span multiple nodes (e.g. `trigger-collision` over two * advertisers); dropping an issue when one of its nodes is outside * would hide cross-cutting problems the user is investigating. * * Pure: no IO, no DB, no FS. */ interface IExportQuery { /** Original query string echoed back so consumers can render the header. */ raw: string; /** * Whitelist of node kinds (`node.kind` is open string, built-in * Claude catalog `skill` / `agent` / `command` / `hook` / `note`, * plus whatever external Providers declare). The query parser does * not validate values against a closed enum; an unknown kind simply * yields zero matches at filter time. */ kinds?: string[]; hasIssues?: boolean; pathGlobs?: string[]; } interface IExportSubset { query: IExportQuery; nodes: Node[]; links: Link[]; issues: Issue[]; } declare class ExportQueryError extends Error { constructor(message: string); } declare function parseExportQuery(raw: string): IExportQuery; declare function applyExportQuery(scan: { nodes: Node[]; links: Link[]; issues: Issue[]; }, query: IExportQuery): IExportSubset; /** * `scan_node_tags` adapter, tags · single-source persistence layer. * * One row per `(node_path, tag)` pair. Projected at persist time from * the node's `sidecar.annotations.tags` (the only tag source). * * Belongs to the `scan_*` family, replaced wholesale per scan. * Cached nodes' tag rows are projected from the cached * `node.sidecar.annotations.tags` (already in memory at persist time), * so the rebuild is cheap regardless of cache hit / miss. See * `spec/db-schema.md` § scan_node_tags for the normative shape and * replace-all semantics. */ /** * In-memory tag record buffered during scan and flushed to * `scan_node_tags` by `persistScanResult`. One entry per * `(node_path, tag)` pair projected from a node's sidecar annotations * tags (`sidecar.annotations.tags`). */ interface ITagRecord { nodePath: string; tag: string; } /** * `state_plugin_kvs` adapter, Mode A plugin key/value persistence. * * Backs the plugin-facing `KvStore` accessor documented in * `spec/plugin-kv-api.md` § Mode A. The plugin never reaches this * module: `kernel/adapters/plugin-store.ts` owns the plugin contract * (key validation, JSON encoding, the typed error taxonomy, the * `nodePath ↔ node_id` sentinel) and talks to a plugin-bound * `IKvStorePersist` port; `core/runtime/plugin-stores.ts` binds that * port to these functions. Everything here speaks raw storage terms: * an already-encoded `valueJson` string and the sentinel `nodeId` * (`''` for the global scope, never NULL, because the primary key is * `(plugin_id, node_id, key)`). * * Zone `state_`: rows survive `sm scan` truncation and `sm db reset` * (which drops only `scan_*`). See `spec/db-schema.md` § * `state_plugin_kvs` and `spec/plugin-kv-api.md` § Backup and * retention. * * `pluginId` is a mandatory argument on every function. There is no * cross-plugin read: the composite key is always fully qualified, so * a plugin's accessor structurally cannot address another plugin's * rows. */ /** One `state_plugin_kvs` row, storage-shaped (value still encoded). */ interface IPluginKvRow { pluginId: string; /** Sentinel scope: `''` is global, anything else is a node path. */ nodeId: string; key: string; valueJson: string; updatedAt: number; } /** Fully-qualified addressing for a single row. */ interface IPluginKvScope { pluginId: string; nodeId: string; key: string; } /** Scope + optional key-prefix filter for a `list`. */ interface IPluginKvListQuery { pluginId: string; nodeId: string; prefix?: string; } /** * Row builders for the `state_findings` write-through * (`spec/db-schema.md` §state_findings, `spec/job-lifecycle.md` §Record). * The record path composes an `IFindingsWriteIntent` out of a validated * `completed` probabilistic report through two lanes: * * - **Finder lane** (`extensionFindingRows`, `origin = 'extension'`): * one row per entry of a probabilistic Analyzer's `findings[]` array. * Per-row `confidence` is the finding's own value when present, else * the report-level `confidence`. * - **Safety lane** (`kernelSafetyRows`, `origin = 'kernel'`): for * EVERY probabilistic report (Action or Analyzer) whose `safety` * block flags trouble, synthesized rows under the RESERVED type * slugs, message from the kernel catalog * (`kernel/i18n/findings.texts.ts`), `safety.injectionDetails` * folded into `detail` when present. * * `findReservedFindingTypes` backs the record-time rejection: extensions * MUST NOT emit the reserved slugs themselves; a `findings[]` entry that * does fails the job as `report-invalid` (spec: implementations SHOULD * reject). * * Inputs are the ALREADY-VALIDATED report (AJV against the extension's * own `report.schema.json`, which extends the canonical envelopes), so * the narrowing here is defensive, not a validation layer. */ /** * One active suppression entry (`annotations.suppressions`, * `spec/schemas/annotations.schema.json`) as the read-time lens matches * it: the qualified (or bare) finder `extension` it silences, and an * optional `type` slug that narrows it (absent = every type from that * extension). */ interface ISuppressionMatch { extension: string; type?: string; } /** * A suppression entry with its operator-facing `note` kept: the display * shape `sm findings suppressions` lists and `sm findings undismiss` * echoes (`note` never affects matching). */ interface ISuppressionEntry extends ISuppressionMatch { note?: string; } /** * Pure helpers for the "update available" notification feature. * * Three responsibilities: * - `fetchLatestVersion` , query `https://registry.npmjs.org//latest` * with `AbortController` + timeout. Throws on * non-200 / parse failure / abort. * - `compareVersions` , semver compare (-1 / 0 / 1). Pre-1.0 aware: * treats prereleases via the standard rules * (release > prerelease at the same triple). * - `isOutdated` , sugar over `compareVersions` for the common * "is `latest` strictly greater than `current`" * check the banner runs against. * * Pure kernel module, NO `process.env` reads, NO Node globals beyond the * built-in `fetch` / `AbortController` (Node 22+). Every env / settings * lookup happens in `src/cli/util/update-check-banner.ts`, the CLI-side * adapter that owns side effects. * * The shared cache type (`IUpdateCheckCache`) is used by the storage * helpers under `kernel/storage/update-check.ts` and by the BFF's * `GET /api/update-status` projection. A second type * (`IUpdateStatus`) shapes the BFF response, it merges `current` * (from `VERSION`) into the cache so the UI can render without a * second lookup. Both stay flat, no nested objects, so JSON * serialization is trivial. */ interface IUpdateCheckCache { latestVersion: string; /** Epoch ms, when the registry was last successfully probed. */ checkedAt: number; /** Epoch ms, when the banner was last printed; null = never shown yet. */ shownAt: number | null; } /** * Subset of `StoragePort` exposed inside a `transaction(fn)` callback. * Lifecycle methods are intentionally omitted, a transaction that * tries to `init()` the adapter mid-flight is a category error. * * Every callable in the subset MUST run on the same underlying * transaction handle the adapter opened for the callback. Adapters * are responsible for that wiring; consumers only see the namespace * surfaces. */ interface ITransactionalStorage { scans: { persist(result: ScanResult, opts?: IPersistOptions): Promise; }; issues: { deleteById(id: number): Promise; insert(issue: Issue): Promise; }; enrichments: { /** * Upsert a batch of fresh enrichment records produced by an * extractor pass (Model B, `node_enrichments`). Composite PK is * `(nodePath, extractorId)`; conflict → replace. Every row lands * with `stale = 0` (the caller just refreshed it; ROADMAP §B.10, * staleness is computed downstream when the body hash changes * again). */ upsertMany(records: IEnrichmentRecord[]): Promise; /** * Upsert one `state_enrichments` row (Model A, the enrichment * write-through `sm enrich` lands for an enricher Action). * Composite PK is `(nodeId, providerId)`; conflict → replace. * Transactional variant so the state row and its `state_executions` * sibling land atomically (mirror of the summaries fold inside * `jobs.recordTerminal`). */ upsertState(row: IStateEnrichmentUpsert): Promise; }; history: { /** * Repoint every `state_*` reference from `fromPath` to `toPath`. * Atomic across the four state tables; the report flags any * composite-PK collisions so callers can diagnose them. * `sm orphans reconcile` / `undo-rename` and the scan-time * rename heuristic are the canonical consumers. */ migrateNodeFks(from: string, to: string): Promise; /** * Append a single `state_executions` row inside the transaction. * `sm enrich` pairs it with `enrichments.upsertState` so an * in-process enricher execution and its state row commit together. */ insertExecution(record: ExecutionRecord): Promise; }; } interface StoragePort { init(): Promise; close(): Promise; scans: { /** * Persist a fresh `ScanResult` (replace-all on the scan zone). * Called by `sm scan` after the orchestrator returns. The renames / * extractor-runs / enrichments side bags ride along inside the * same transaction, the call is atomic from the caller's view. */ persist(result: ScanResult, opts?: IPersistOptions): Promise; /** * Hydrate the persisted `ScanResult`. Returns the snapshot the * scan zone holds today (including external-Provider kinds, * `node.kind` is open string per `node.schema.json`). */ load(): Promise; /** * Metadata-only `ScanResult`: every scalar field plus real * `COUNT(*)` stats, but empty `nodes` / `links` / `issues` arrays. * Reads only the single `scan_meta` row plus the counts, never the * node / link / issue tables, so the BFF `GET /api/scan?meta=1` boot * fetch stays cheap on a large corpus. The SPA pairs it with * `/api/folders` (tree) and `/api/branch` (map). */ loadMeta(): Promise; /** * Spec § A.9, fine-grained extractor-runs cache breadcrumbs. * Returns `Map>`. * Inner value carries `bodyHash` AND `sidecarAnnotationsHash`; both * participate in the cache hit condition for every Extractor. */ loadExtractorRuns(): Promise>>; /** Universal enrichment layer, every persisted `(node, extractor)` pair. */ loadNodeEnrichments(): Promise; /** * Row counts for `scan_nodes` / `scan_links` / `scan_issues`. * Used by `sm scan`'s "refusing to wipe a populated DB" guard. */ countRows(): Promise; /** Row-level filter for `sm list`. Open `kind` (matches `Node.kind`). */ findNodes(filter: INodeFilter): Promise; /** * Bundled fetch for `sm show `. Returns `null` if the node * is not in the persisted scan. */ findNode(path: string): Promise; /** * Lightweight full-corpus node list `{ path, kind }[]`, ordered by * `path` ASC. Backs the BFF `/api/folders` endpoint: the SPA folders * tree renders the whole scanned corpus (up to `scan.maxScan`) * without hydrating the full `ScanResult`. Pushes the projection to * SQL (`SELECT path, kind`), never loads the rest of the row. */ listLiteNodes(): Promise; /** * Distinct `scan_nodes.provider` values in the persisted scan. * Backs `sm doctor`'s providers-matched-nothing check. */ distinctNodeProviders(): Promise; /** * Per-node issue incidence counts by severity, keyed by node path. * Expands every `scan_issues.node_ids_json` array with SQLite * `json_each` and groups by `(value, severity)` so the count is * computed in SQL, not by loading every issue into memory. Only * error / warn severities are tallied (the SPA badges ignore * `info`); nodes with no error / warn issue are absent from the * map. Backs the `errorCount` / `warnCount` fields on `/api/folders`. */ issueCountsByPath(): Promise>; /** * Effective map-render cap recorded by the latest scan * (`scan_meta.max_render_nodes`). Returns the design default (256) * when no `scan_meta` row exists (DB freshly migrated / never * scanned). Backs the `/api/branch` cap default + clamp ceiling. */ effectiveMaxRenderNodes(): Promise; /** * Override-scoped, capped graph projection for the BFF `/api/branch` * endpoint (`spec/cli-contract.md` §Map scope overrides). A node is * in the branch when its NEAREST matching override (longest of the * scope's include/exclude paths that equals the node's path or * prefixes it, the root riding `rootExcluded`) is an include, or no * override matches at all (default include). The degenerate scopes * keep the historical semantics: `{include: [], exclude: [], * rootExcluded: false}` selects the whole corpus with no WHERE; * `{include: P, exclude: [], rootExcluded: true}` is the old * prefix-union over P. Identical paths are de-duped defensively. * `nodes` is the first `limit` scoped nodes in stable path order * (`ORDER BY path LIMIT`); `links` carries only edges whose source * AND resolved target are both in `nodes`; `issues` carries only * those whose `nodeIds` intersect `nodes`. `total` is the count of * scoped nodes BEFORE the cap (so the route can compute * `truncated`); `paths` echoes the de-duped includes. All scoping + * capping happens in SQL so a 50K corpus never hydrates into memory; * the fully-excluded scope (`rootExcluded` with no includes) short- * circuits to an empty projection without querying. */ loadBranch(scope: IBranchScope, limit: number): Promise; /** * Refresh ONE node's denormalized `scan_nodes.annotations_json` * mirror from its just-written `.sm` annotations, the write-through * half of `sm findings dismiss` / `undismiss` (`spec/db-schema.md` * §state_findings, read-time suppression lens). The sidecar stays the * source of truth; `sm scan` remains the wholesale refresher (a * hand-edited `.sm` reconciles at the next scan). `null` clears the * column; a path not in the scan is a no-op. */ refreshAnnotations(path: string, annotations: Record | null): Promise; }; /** * Phase 3 / View contribution system, read access to * `scan_contributions`, plus the targeted purge used by * `sm plugins disable` to clear stale rows immediately at toggle time. * Bulk writes still happen exclusively via * `scans.persist({ contributions })` (replace-all semantics). */ contributions: { /** Every contribution row for a single node. Stable order. */ listForNode(nodePath: string): Promise; /** * Bulk variant for the BFF nodes-list route. Returns rows for * every path in `paths`, sorted `nodePath` ASC, then qualified-id * ASC. Empty `paths` returns `[]` without a query. */ listForPaths(paths: readonly string[]): Promise; /** * Lookup by qualified id + path. Used by * `GET /api/contributions/:pluginId/:contributionId?path=...`. */ lookup(pluginId: string, contributionId: string, nodePath: string, extensionId?: string): Promise; /** * Drop rows for a plugin (optionally narrowed to a single * extension within the plugin). Returns the number of deleted * rows. Called by `sm plugins disable` so the UI stops rendering * the disabled plugin's chips before the next scan. */ purgeByPlugin(pluginId: string, extensionId?: string): Promise; /** * "off-shape visible" follow-up, every view contribution the last * scan REJECTED at emit time (undeclared ref, or payload failed the * slot's AJV schema), ordered by `(pluginId, extensionId, nodePath, * emittedAt)` ASC. Consumed by `sm plugins doctor` to surface * runtime contribution rejections per plugin (and later the BFF). */ listAllErrors(): Promise; }; /** * Read-only access to `scan_node_tags`. Writes happen exclusively * via `scans.persist({...})` (the persistence layer projects from * `node.sidecar.annotations.tags`, the only tag source); this * namespace is read-only. */ tags: { /** Every tag row for a single node, ordered by tag name. */ listForNode(nodePath: string): Promise; /** * Bulk variant for the BFF nodes-list route. Returns rows for every * path in `paths`, sorted `tag` ASC. Empty `paths` returns `[]` * without a query. */ listForPaths(paths: readonly string[]): Promise; /** * Find every node carrying `tag` in its `.sm` sidecar * (`annotations.tags`). Drives `sm list --tag `. */ findNodes(tag: string): Promise; }; issues: { /** Every issue from the latest scan, in insertion order. */ listAll(): Promise; /** * Paginated, filtered issue read. Drives `/api/issues` (the BFF * route used to call `listAll()` and filter in JS, which loaded * every persisted issue into memory before paging; the audit * L6 fix pushes both filtering AND pagination into SQL). * * `total` in the result is the count matching the filters BEFORE * pagination is applied; `items` is the page slice (length ≤ * `filter.limit`). Order is `id` ASC (insertion order, stable * across pages so the route's `offset` / `limit` is deterministic). * * Empty filters match every row (the route still passes * `offset` + `limit` so pagination always applies). See * `IIssueListFilter` for the per-field semantics. */ list(filter: IIssueListFilter): Promise; /** * Issue rows whose runtime `Issue` shape passes `predicate`. * `port.issues.findActive((i) => i.analyzerId === 'orphan')` is the * canonical use; `sm orphans` consumes this. The returned shape * carries the DB-assigned `id` so a follow-up * `transaction(tx => tx.issues.deleteById(row.id))` can target * a specific row. */ findActive(predicate: (issue: Issue) => boolean): Promise; /** * Delete every persisted issue row matching an operator's issue * suppression (`spec/db-schema.md` §scan_issues): `analyzer` * (qualified or short, `matchesAnalyzerFilter` semantics against * the stored SHORT `analyzer_id`), the verbatim `data.target` * value (exact, case-sensitive), and membership of `nodePath` in * the row's `nodeIds`. Called by the `sm issues dismiss` surfaces * AFTER the sidecar write so reads agree without waiting for a * rescan; the delete converges regenerable machine state toward * what the next scan (whose analyzer consults the suppression at * emission time) produces anyway. Returns the deleted row count. */ deleteForSuppression(nodePath: string, analyzer: string, value: string): Promise; }; /** * Read access to `state_enrichments` (Model A, the per-node * enrichment write-through an enricher Action lands via `sm enrich`, * `spec/db-schema.md` §state_enrichments). The mutation surfaces stay * transactional-only on `ITransactionalStorage`: the Model B batch * (`upsertMany`, `node_enrichments`) rides inside the refresh * extractor persist, and the Model A upsert (`upsertState`) commits * atomically with its `state_executions` sibling. This top-level * namespace is read-only by design. */ enrichments: { /** * Every `state_enrichments` row for a node, ordered by * `providerId` ASC. `providerId` carries the enriching Action's * qualified id (e.g. `github/enrichment`). */ listStateForNode(nodeId: string): Promise; /** * The stale candidate set for `sm enrich --stale` (v1 staleness: * `data_json.localBodyHash` differs from the node's current * `scan_nodes.body_hash`, or a non-null `stale_after` has passed; * rows whose node vanished from the scan are excluded). Computed * SQL-side, see `adapters/sqlite/enrichments.ts`. */ listStaleStateCandidates(nowMs: number): Promise; }; jobs: { /** * Submit a job: `INSERT OR IGNORE` the rendered content into * `state_job_contents` then insert the `state_jobs` lifecycle row * (`status = 'queued'`), both in ONE transaction (content row first). * Returns the inserted job id. The `state_jobs` insert may throw a * UNIQUE-constraint error from `ix_state_jobs_extension_node_hash` when * a matching queued/running job already exists (the hard duplicate * backstop); the CLI maps that to exit 3. */ submit(row: IJobSubmitRow, content: IJobContentInput): Promise; /** * Atomic FIXER supersede submit (`spec/job-lifecycle.md` §Findings * injection for fixers · Supersede). In ONE transaction it finds any * ACTIVE job for the `(extensionId, nodeId)` pair and resolves the * collision: a running job refuses (`running-conflict`, never superseded); * an identical queued request refuses (`duplicate`); otherwise it CANCELS * every stale queued sibling (a different `contentHash`) and enqueues the * new job (`created`, `supersededIds` naming the cancelled rows). Only the * fixer submit path uses this; every other submit goes through * `submit(...)` + the plain `findActiveDuplicate` pre-check. */ submitFixer(row: IJobSubmitRow, content: IJobContentInput): Promise; /** * Duplicate pre-check: id of any `queued`/`running` job matching * `(extensionId, extensionVersion, nodeId, contentHash)`, else `null`. * The soft gate `sm jobs submit` runs before insert (skipped by * `--force`). */ findActiveDuplicate(extensionId: string, extensionVersion: string, nodeId: string, contentHash: string): Promise; /** Filtered job list for `sm jobs list`, newest-first. */ list(filter: IJobListFilter): Promise; /** Full job by id for `sm jobs show`, or `null` when absent. */ get(id: string): Promise; /** * Rendered content blob for a job's `contentHash` (from * `state_job_contents`), or `null` when the content row is absent (the * DB-corruption-only `job-file-missing` state). Powers `sm jobs preview`. */ getContent(contentHash: string): Promise; /** * Atomic claim (`spec/job-lifecycle.md` §Atomic claim): a single * `UPDATE ... RETURNING` that transitions the highest-priority, oldest * queued job to `running`, stamping `claimedAt` / `runner` / * `expiresAt = claimedAt + ttlSeconds × 1000`. Returns the claimed * `{ id, nonce, contentHash }`, or `null` when the queue is empty (or * nothing matches `filter`, an `extensionId` restriction). The statement's * second `AND status='queued'` is the mandatory race guard, two racers * selecting the same id yield exactly one winning UPDATE. `sm jobs claim` * exposes this to external agents (`runner='agent'`). */ claim(runner: JobRunner, nowMs: number, filter?: string): Promise; /** * Cancel a single job (`spec/job-lifecycle.md` §Cancellation): a * `queued` / `running` job moves to the terminal `cancelled` state * (`finishedAt = nowMs`, no `failureReason`; `cancelled` is a distinct * state, NOT a `failed` sub-reason). Returns `cancelled`, * `already-terminal` (job in a terminal state, the verb exits 2), or * `not-found` (exit 5). Does NOT interrupt any subprocess. */ cancel(id: string, nowMs: number): Promise; /** * Cancel every `queued` / `running` job in one statement; returns the * ids transitioned to the terminal `cancelled` state (mirroring * `reapExpired`: the caller derives the count from the length and * feeds the per-job `job.cancelled` live push, * `spec/job-events.md` §Transport). Powers `sm jobs cancel --all`. */ cancelAllActive(nowMs: number): Promise; /** * Fail a single job (`spec/job-lifecycle.md` §Fail), the symmetric * counterpart to `cancel`: a `queued` / `running` job moves to `failed` * with `failureReason = user-failed` (`finishedAt = nowMs`). Returns * `failed`, `already-terminal` (exit 2), or `not-found` (exit 5). Does * NOT interrupt any subprocess. */ fail(id: string, nowMs: number): Promise; /** * Fail every `queued` / `running` job in one statement; returns the * ids transitioned to `failed` / `user-failed` (mirroring * `reapExpired`, see `cancelAllActive`). Powers `sm jobs fail --all`. */ failAllActive(nowMs: number): Promise; /** * Counts per lifecycle status (`queued` / `running` / `completed` / * `failed` / `cancelled`), every key present. Backs `sm jobs status` * with no id. */ countByStatus(): Promise>; /** * Read-only integrity counts for `sm doctor`: jobs whose content * row is missing (corruption) and content rows referenced by zero * jobs (retention leftovers `sm jobs prune` collects). */ integrityCounts(): Promise; /** * Auto-reap (`spec/job-lifecycle.md` §Reap procedure): transition every * `running` job whose `expiresAt < nowMs` to `failed` / `abandoned` * with `finishedAt = nowMs`; returns the reaped job ids (a live event * transport MAY surface them, `spec/job-events.md` §Ordering; the CLI * claim verb ignores them silently). Invoked at the start of every * `sm jobs claim`, before the claim statement; no standalone verb. */ reapExpired(nowMs: number): Promise; /** * Retention GC, in one transaction: delete `state_jobs` rows in * terminal `status` whose `finishedAt` is older than `cutoffMs` * (Unix ms), then collect orphaned `state_job_contents` rows (every * content blob referenced by zero surviving `state_jobs` rows). * Returns the deleted job count plus the collected content-row count. * Caller computes `cutoffMs` from the configured retention. Job * content is DB-only (`state_job_contents`); there is no on-disk * `.skill-map/jobs/*.md` artifact to unlink. */ pruneTerminal(status: 'completed' | 'failed' | 'cancelled', cutoffMs: number): Promise; /** * Read-only preview of `pruneTerminal` (no DELETE). Powers `sm jobs * prune --dry-run` so the output reports how many rows the live mode * would delete. `prunedContents` is `0` in the preview (see the * adapter note). */ listTerminalCandidates(status: 'completed' | 'failed' | 'cancelled', cutoffMs: number): Promise; /** * Record callback (`spec/job-lifecycle.md` §Record): append the terminal * `state_executions` row AND transition the `running` job to its * terminal state (`completed` / `failed`), atomically in one * transaction. The `ExecutionRecord` carries the target `jobId`, the * final `status`, the `failureReason` (`report-invalid` / * `runner-error` / null), and `finishedAt`; the report payload rides * inline on `reportPath` (mapped to the `report_json` column). Backs * `sm record`. * * When `summary` is supplied (the recorded Action's report schema is * a per-node summary schema, only ever on the `completed` path), the validated * report is ALSO upserted into `state_summaries` inside the same * transaction, keyed by `(node_id, summarizer_action_id)`. The upsert * reads the node's live `kind` + `body_hash` from `scan_nodes` and is * skipped when the node no longer exists (deleted / renamed since * submit); the execution row + job transition still land * (`spec/job-lifecycle.md` §Record). * * When `findings` is supplied (the recorded job's extension is * probabilistic and its `completed` report produced finder / safety * rows, possibly zero), the pair's `state_findings` rows are REPLACED * inside the same transaction (both origins deleted, fresh rows * inserted stamped with the node's live `body_hash`); an empty intent * is a clean verdict that erases the prior judgment. Same skip rule * as summaries when the node has disappeared * (`spec/db-schema.md` §state_findings). * * When `resolutions` is supplied (the recorded job's extension is a * FIXER: an Action declaring `precondition.analyzerIds`), the lifecycle * `state` each entry of its report's `resolved[]` declares is stamped * onto the finding its `id` names, in the same transaction. A `fixed` * state hides the row from the default view but never deletes it; only * the finder re-judging closes a finding. Entries naming an unknown id, * a finding on another node, or a finder outside the fixer's * `analyzerIds` are skipped SILENTLY (benign race / defensive scope). */ recordTerminal(execution: ExecutionRecord, summary?: ISummaryWriteIntent, findings?: IFindingsWriteIntent, resolutions?: IFindingResolutionIntent): Promise; }; /** * Read access to `state_findings`, the probabilistic findings a finder * Analyzer (plus the kernel safety lane) lands via `sm record`. Writes * happen inside the `jobs.recordTerminal(execution, summary, findings)` * transaction (folded into the record callback, never a standalone * write); this namespace is read-only. */ findings: { /** * Filtered read with the derived `stale` flag * (`body_hash_at_generation` vs the node's live `scan_nodes.body_hash`; * rows for nodes gone from the scan count as stale). Stale rows are * excluded unless `filter.includeStale` is set. Backs `sm findings` * and `sm show`'s Findings section. */ list(filter?: IFindingsListFilter): Promise; /** * Batch count of each node's FRESH OPEN findings by severity * (`resolution IS NULL`, non-stale; `warn` / `error` only, `info` * dropped, mirroring issues), keyed by node path. Both origins * (finder-lane + kernel safety-lane) count. One SQL GROUP BY over * `paths`; empty `paths` returns an empty map without a query; nodes * with no open warn/error finding are absent (the caller defaults to * `{ warn: 0, error: 0 }`). Backs the BFF read-time fold that sums a * node's findings into `core/issue-counter`'s aggregate severity chips * (`spec/view-slots.md` §card.footer.right); the sum is a read-time UI * decoration, `sm scan --json` carries only the deterministic * component. */ countUnresolvedByPath(paths: readonly string[]): Promise>; /** * Count the STALE rows (body-hash drift, or the node gone from * `scan_nodes`); the `sm findings prune` dry-run / confirmation * count. */ countStale(): Promise; /** * Delete every STALE row (`sm findings prune`); fresh rows are never * touched. Returns the deleted row count. */ pruneStale(): Promise; /** * `sm findings resolve `: mark an OPEN or `human-decision` finding * `fixed` by the OPERATOR themselves (`resolution = 'fixed'`, * `resolution_actor = 'human'`, `resolution_by = NULL`, the optional * `note`, `resolution_at = nowMs`). Refuses a row already `fixed` * (`already-fixed`, exit 2); an unknown id is `not-found` (exit 5). It * records a human decision, NOT a verification (only re-running the * finder verifies). Returns the updated row for the `--json` echo. */ resolveByHuman(id: number, note: string | null, nowMs: number): Promise; /** * `sm findings dismiss ` (ROW grain, 2026-07-22): mark the row * `dismissed` by the operator (`resolution = 'dismissed'`, actor * `human`; no sidecar, no consent). Hides under the dismissed * bucket, dies with the row when the finder re-judges. Refuses an * already-dismissed row; the durable class suppression is the * separate `--class` / silence-type path. */ dismissByHuman(id: number, note: string | null, nowMs: number): Promise; /** * `sm findings reopen `: clear ANY resolution (`dismissed` / * `fixed` / `human-decision`) back to open. Refuses an already-open * row. Class suppressions are untouched (`sm findings undismiss`). */ reopen(id: number, nowMs: number): Promise; /** * Read one finding by id with the derived `stale` flag. `null` when no * row carries the id. Backs `sm findings dismiss `, which loads the * target (to read its `extension_id` / `type` / `node_id` / `origin`) * before writing the durable sidecar suppression. */ get(id: number): Promise; /** * Active suppression entries per node path, read from the * write-through `scan_nodes.annotations_json` mirror * (`spec/db-schema.md` §state_findings, read-time suppression lens): * the `.sm` sidecar is the source of truth, dismiss / undismiss * refresh the column for the touched node, `sm scan` refreshes it * wholesale. Backs the findings view's `dismissed` bucket, the card * counters, and `sm findings suppressions`; ZERO file reads. `paths` * narrows; absent reads every node. Nodes without suppressions are * absent from the map. */ suppressionsByPath(paths?: readonly string[]): Promise>; /** * Count the rows `clear(nodeId?)` would delete (fresh included, all * origins); the `sm findings clear` dry-run / confirmation count. * `nodeId` narrows to one node, absent counts the whole table. */ countClearable(nodeId?: string): Promise; /** * `sm findings clear` (`spec/cli-contract.md` §sm findings clear): * wholesale delete of `state_findings` rows, FRESH included, all * origins (finder judgments AND kernel safety rows; a delete cannot * silence future warnings, unlike a suppression, so the safety lane is * deletable here while `sm findings dismiss` refuses it). `nodeId` * narrows to one node, absent clears the whole table. A reset, not a * suppression: a finder re-run re-judges. Returns the deleted count. */ clear(nodeId?: string): Promise; /** * Hard-delete ONE row by id, the per-row twin of `clear` behind * `DELETE /api/nodes/:pathB64/findings/:id` (the inspector's delete X * on a revealed dismissed / fixed row). Same all-origins rationale as * `clear`; leaves `annotations.suppressions` untouched. Returns * whether a row was deleted (false = unknown id). */ removeById(id: number): Promise; }; /** * Read access to `state_summaries`, the per-node semantic summaries a * summarizer Action (one whose report schema extends a * `summaries/` schema) lands via `sm record`. Writes happen inside the * `jobs.recordTerminal(execution, summary)` transaction (folded into the * record callback, never a standalone write); this namespace is * read-only. */ summaries: { /** * Every stored summary for a node, ordered by `summarizerActionId` * ASC. Backs `sm show `'s Summary section: the caller flags each * `(stale)` by comparing `bodyHashAtGeneration` against the node's * current `scan_nodes.body_hash`. */ forNode(nodeId: string): Promise; /** * Hard-delete the node's stored summaries: with `summarizerActionId` * only that action's row, without it every summary the node has * (`DELETE /api/nodes/:pathB64/summary`, the inspector's delete X). * A regenerable machine judgment, so no ceremony; returns the * deleted count (0 = nothing matched). */ remove(nodeId: string, summarizerActionId?: string): Promise; }; /** * Generic key/value preferences keyed by a stable string. Backs the * `config_preferences` table, one row per `key`, `value_json` is a * single JSON blob the caller serialises. Keys with the `_kernel.` * prefix are reserved for kernel-managed entries (today: the * update-check cache); user-set preferences land under unprefixed * keys when those ship. * * Read-only by design at the port level, the only writer is the * CLI's post-run hook (`cli/util/update-check-banner.ts`), which * reaches the persistence helpers directly. The port surfaces the * read so the BFF's `GET /api/update-status` projection can stay * inside the abstract contract. */ preferences: { /** * Load the update-check cache row. Returns `null` when the row * is absent, malformed JSON, or fails the shape guard. Never * throws, read failures degrade silently because the banner is * a non-essential surface. */ loadUpdateCheckCache(): Promise; /** * Upsert the update-check cache row. Always overwrites the * existing JSON blob in place. `updated_at` tracks wall-clock * now, separate from the embedded `checkedAt` field, which * the caller controls. */ saveUpdateCheckCache(cache: IUpdateCheckCache): Promise; }; favorites: { /** * Mark `path` as favorited. Idempotent, a second call refreshes * `favoritedAt` but does not error. The path is FK-semantic to * `scan_nodes.path`; the route layer is responsible for confirming * the path exists in the live scan before calling. */ set(path: string): Promise; /** Drop the favorite row for `path`. Idempotent, no-op when absent. */ unset(path: string): Promise; /** * Load every favorited path as a `Set` ready for `O(1)` * membership checks. Used by the BFF's `/api/nodes` decorator, * one query per request, no SQL JOIN against `scan_nodes`. */ listPaths(): Promise>; }; /** * Runtime execution-stats checkpoint (`state_activity_stats` / * `state_activity_pairs`, `spec/db-schema.md` §state_activity_stats): * the persisted half of the BFF's in-memory accumulator * (`spec/provider-activity.md` §Execution stats). Rows are opaque to * the kernel, the BFF projects its state in and out; the port only * stores, loads, deletes and (via `history.migrateNodeFks`) migrates. */ activity: { /** Every persisted node row + pair row (the boot hydration read). */ loadAll(): Promise<{ nodes: IActivityStatsRow[]; pairs: IActivityPairRow[]; }>; /** Insert-or-replace node rows (the debounced checkpoint write). */ upsertNodes(rows: readonly IActivityStatsRow[]): Promise; /** Insert-or-replace pair rows (same debounce). */ upsertPairs(rows: readonly IActivityPairRow[]): Promise; /** * Drop the node's row plus every pair row naming it on either side * (the Activity clear-all, `spec/provider-activity.md` §DELETE * /api/activity/node). Idempotent. */ deleteNode(nodePath: string): Promise; }; /** * Mode A plugin storage (`state_plugin_kvs`), the engine-level half * of the `KvStore` accessor specified in `spec/plugin-kv-api.md`. * * This namespace is NOT what a plugin calls. `ctx.store` is the * plugin-facing wrapper built by * `kernel/adapters/plugin-store.ts:makeKvStoreWrapper`; the wrapper * owns key validation, JSON encoding, size ceilings, the typed error * taxonomy and the `nodePath ↔ nodeId` sentinel, and delegates here * through a `pluginId`-bound port (`core/runtime/plugin-stores.ts`). * Everything below therefore speaks storage terms: encoded * `valueJson`, sentinel `nodeId` (`''` = global scope, never NULL). * * Every method takes a fully-qualified `pluginId`, which is what * makes cross-plugin reads structurally impossible rather than * merely discouraged. */ pluginKvs: { /** One row, or `null` when absent. */ get(scope: IPluginKvScope): Promise; /** Upsert one row against the `(pluginId, nodeId, key)` primary key. */ set(row: IPluginKvRow): Promise; /** Delete one row. `true` iff a row was removed. Idempotent. */ delete(scope: IPluginKvScope): Promise; /** One scope's rows, key ASC, optionally narrowed by key prefix. */ list(query: IPluginKvListQuery): Promise; /** * Drop every row a plugin owns; returns the deleted count. Not * wired to a verb today (`sm plugins disable` keeps plugin * storage on purpose), reserved for the future * `sm plugins forget `. */ purgeByPlugin(pluginId: string): Promise; }; history: { /** List `state_executions` rows (paginated by filter). */ list(filter: IListExecutionsFilter): Promise; /** * Distinct node paths holding at least one `state_executions` row * (any status). Feeds the activity summary's `runNodes` list * (`spec/provider-activity.md` §GET /api/activity/summary): the * boot-scoped counters reset on restart, the DB history does not, * so Activity visibility needs this persistent signal. */ nodesWithRuns(): Promise; /** * Append a single `state_executions` row (the table is append-only * apart from the targeted `deleteForNode` clear below). The * primitive history write the port previously lacked; `sm record` * transitions atomically through `jobs.recordTerminal`, while a * standalone in-process action with no job row uses this directly. */ insertExecution(record: ExecutionRecord): Promise; /** * Delete every `state_executions` row whose node list contains * `nodePath` (the same JSON1 containment the `list` filter applies, * so the delete removes exactly the rows a per-node listing shows). * Returns the deleted-row count. The single targeted history * delete: the Activity clear-all * (`spec/provider-activity.md` §DELETE /api/activity/node). */ deleteForNode(nodePath: string): Promise; /** * Aggregate counters / period buckets / top-nodes / error rates * over `state_executions`. Body matches the spec * `history-stats.schema.json` shape minus `range`/`elapsedMs` * (the verb fills those in around the call). */ aggregateStats(range: IHistoryStatsRange, period: THistoryStatsPeriod, topN: number): Promise & { rangeMs: { sinceMs: number | null; untilMs: number; }; }>; }; migrations: { /** Enumerate kernel migration files bundled with this build. */ discover(): IMigrationFile[]; /** * Compute the apply / pending plan against the current `config_ * schema_versions` ledger. Read-only; safe under `--dry-run`. */ plan(files?: IMigrationFile[]): IMigrationPlan; /** * Apply pending migrations in order. Each runs inside its own * `BEGIN/COMMIT` (per `kernel/adapters/sqlite/migrations.ts`); a * partial failure rolls back to the prior state. Returns the * applied list + backup path (when `backup: true`). */ apply(options?: IApplyOptions, files?: IMigrationFile[]): IApplyResult; /** * WAL-checkpoint + atomic file copy of the DB to `destPath`. * Caller composes the path. Returns the destination on success, * or `null` for in-memory DBs (no file to copy). */ writeBackup(destPath: string): string | null; /** * Read `PRAGMA user_version` from the underlying DB. The migrations * runner keeps that pragma in sync with the latest applied kernel * migration, so this is the canonical "current schema version" * read for `sm version --json`'s `dbSchema` field. Returns `null` * on engine quirks (non-numeric / null pragma). */ currentSchemaVersion(): number | null; /** * Run `PRAGMA quick_check` against the DB file (`sm doctor`'s * integrity probe). `ok: true` when SQLite reports the single `ok` * row; otherwise the first corruption line lands in `detail`. */ quickCheck(): IQuickCheckResult; }; /** * Open a transaction. The callback receives a transactional subset * of the port; the adapter commits on resolution and rolls back on * rejection. `sm orphans reconcile / undo-rename` and `sm enrich` * are the canonical consumers. */ transaction(fn: (tx: ITransactionalStorage) => Promise): Promise; } /** * `FilesystemPort`, walks roots, reads nodes, writes job files. * * Shape-only. The real adapter ships with the scan end-to-end pipeline. */ interface NodeStat { path: string; sizeBytes: number; mtimeMs: number; } interface IWalkOptions { ignore?: string[]; } interface FilesystemPort { walk(roots: string[], options?: IWalkOptions): AsyncIterable; readNode(path: string): Promise; stat(path: string): Promise; writeJobFile(path: string, content: string): Promise; ensureDir(path: string): Promise; } /** * `PluginLoaderPort`, discovers plugin directories and loads their * extensions. The shape mirrors what the concrete loader actually * exposes (see `kernel/adapters/plugin-loader.ts`); the port exists so * the CLI consumes the abstract contract via `createPluginLoader(...)` * instead of `new PluginLoader(...)` and so the concrete adapter is * structurally pinned to the port (`implements PluginLoaderPort` makes * any drift a compile error). * * Domain types (`IPluginManifest`, `ILoadedExtension`, `IDiscoveredPlugin`, * `TPluginStorage`, `TPluginLoadStatus`) live in * `kernel/types/plugin.ts` because they are spec-mirroring DTOs, not * port-shape types. The port re-exports them for callers that import * from the ports barrel. */ interface PluginLoaderPort { /** * Synchronously enumerate every directory containing a `plugin.json` * across the configured search paths. Non-existent paths are skipped. */ discoverPaths(): string[]; /** * Discover every plugin, attempt to load each, then apply the * cross-root id-collision pass. Never throws, failures are reported * via `IDiscoveredPlugin.status`. */ discoverAndLoadAll(): Promise; /** * Load a single plugin from its directory. Never throws, failure is * reported via the returned `status`. */ loadOne(pluginPath: string): Promise; } /** * `LoggerPort`, structured logging port for the kernel. * * The kernel must NOT write to stdout/stderr directly. Anything that * would historically have been a `console.log` / `console.error` goes * through this port; the adapter (CLI, server, test harness) decides * format, level filter, and destination. * * Levels follow the conventional ordering, lowest = most verbose: * * trace < debug < info < warn < error < silent * * `silent` is a sentinel for filtering only, it never appears as a * `LogRecord.level`. Setting an adapter to `silent` disables every * method. */ type TLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'; type TLogMethodLevel = Exclude; declare const LOG_LEVELS: readonly TLogLevel[]; declare function logLevelRank(level: TLogLevel): number; declare function isLogLevel(value: unknown): value is TLogLevel; /** * Parse a string into a `TLogLevel`. Returns `null` for invalid input * (incl. `undefined` / `null` / empty). Case-insensitive; trims * whitespace. */ declare function parseLogLevel(value: string | undefined | null): TLogLevel | null; interface LogRecord { level: TLogMethodLevel; /** ISO 8601 timestamp produced at the moment the log call was made. */ timestamp: string; message: string; /** Optional structured context. Caller-owned; serialization is up to the formatter. */ context?: Record; } interface LoggerPort { trace(message: string, context?: Record): void; debug(message: string, context?: Record): void; info(message: string, context?: Record): void; warn(message: string, context?: Record): void; error(message: string, context?: Record): void; /** * Current threshold, so a caller can SKIP building a message it is * about to throw away. The level check inside an adapter happens * after the argument is evaluated, so a `log.trace(\`…\${x}…\`)` in a * per-node loop pays for its template on every scan even at the * default `warn`. Hot paths guard with `logEnabled()` * (`kernel/util/logger.ts`), which reads this. * * OPTIONAL so an existing adapter stays valid. An adapter that omits * it is treated as "cannot tell" and `logEnabled()` answers `false`, * i.e. it opts out of hot-path diagnostics rather than paying for * strings nobody may read. One-shot lines never need the guard and * are unaffected. */ level?(): TLogLevel; } /** * No-op `LoggerPort`. Default when the kernel is invoked without a * logger (tests, embedded usage). Equivalent in spirit to * `InMemoryProgressEmitter`: callers that don't care get a working * implementation that does nothing. * * Every method is intentionally empty, that IS the contract of this * class. We disable `no-empty-function` for the whole file because * adding `// eslint-disable-next-line` to each method would be noise. */ declare class SilentLogger implements LoggerPort { trace(): void; debug(): void; info(): void; warn(): void; error(): void; /** Nothing is ever emitted, so `logEnabled()` short-circuits every * hot-path diagnostic instead of building strings for a sink that * discards them. */ level(): TLogLevel; } /** * The `ctx.log` counterpart: an extension logger that discards * everything. For callers that compose an extension context with no * operator watching (unit tests, in-memory harnesses), so the required * `log` field never has to be faked inline. Lives here rather than * beside `makeExtensionLogger` to reuse this file's documented * `no-empty-function` exemption instead of opening a second one. */ declare const SILENT_EXTENSION_LOGGER: IExtensionLogger; /** * Module-level singleton `LoggerPort`. The kernel emits warnings / * info / debug through `log.*`; the active implementation defaults to * `SilentLogger` (no output) and is swapped by the driving adapter at * boot time via `configureLogger(...)`. * * Why a singleton (vs. per-call injection): * - Logging crosses every layer; threading a `logger` argument * through every kernel function costs a lot of plumbing for a * side-channel concern. * - The active impl is a pointer; the exported `log` is a stable * proxy. Imports made before `configureLogger` runs still see the * new impl on every call, no "captured stale logger" bugs. * * Tradeoffs accepted: * - Tests must call `resetLogger()` (or replace the active impl) in * teardown to avoid cross-test bleed. * - Concurrent scans share the same logger; per-scan logging requires * reintroducing an explicit `logger` argument on the call path. */ /** Stable proxy. Methods always delegate to the current `active` impl. */ declare const log: LoggerPort; /** Install a logger as the active implementation. Idempotent. */ declare function configureLogger(impl: LoggerPort): void; /** Restore the default `SilentLogger`. Call from test teardown. */ declare function resetLogger(): void; /** Inspect the active logger. Test-only, production code uses `log`. */ declare function getActiveLogger(): LoggerPort; /** * Hook lifecycle dispatcher (spec § A.11). Indexes the supplied hooks * by trigger and fans the matching event out to every subscribed * deterministic hook in registration order. Probabilistic hooks are * skipped here with a stderr advisory; they will dispatch via the job * subsystem once it ships (Decision #114). * * Filter handling: when the hook declares a `filter` map, the dispatcher * walks `event.data` for each declared key and short-circuits the * invocation when any value disagrees. Top-level fields only in v0.x * (deep-path matching is deferred until a real use case justifies the * complexity). * * Error policy: a hook that throws is caught here, logged through a * synthetic `extension.error` event with kind `hook-error`, and the * caller continues. A buggy hook MUST NOT block the main pipeline (or * the CLI exit path), that would invert the design intent (hooks * REACT to events, they never steer them). * * The module lives under `kernel/extensions/` (alongside the `IHook` * contract itself) so two callers share it: `runScan` for the eight * pipeline-driven triggers (`scan.*`, `extractor.completed`, * `analyzer.completed`, `action.completed`, `job.*`) and the CLI entry * for the two CLI-process-driven triggers (`boot`, `shutdown`). * Pulling the dispatcher out of the orchestrator keeps both consumers * symmetric, same indexing, same filter semantics, same error * policy. */ /** * Optional runtime capabilities the DRIVER supplies so a hook can react * beyond pure observation. Only the record-path dispatch wires these today * (for chain hooks subscribing `job.completed`); the scan / boot dispatchers omit * them and the fields stay `undefined` on `IHookContext`. * * - `queue`, enqueue a probabilistic Action as a deferred job. Attached * to `ctx.queue`. The driver owns the async lifecycle (it collects the * requests and drains them while its DB handle is still open), so the * hook-facing signature stays fire-and-forget `void`. * - `actions`, the loaded-Action projection (`IHookActionInfo[]`). * Attached to `ctx.actions` so a hook can resolve the inverse of * Modelo B without importing the registry. */ interface IHookDispatchCapabilities { queue?: (actionId: string, payload: unknown) => void; actions?: readonly IHookActionInfo[]; } interface IHookDispatcher { /** * Fan the event out to every hook subscribed to `trigger`. Awaits each * hook's `on(ctx)` in registration order. Errors are caught and * logged via `extension.error`; they never propagate. */ dispatch(trigger: THookTrigger, event: ProgressEvent): Promise; } /** * Build a dispatcher over the given hooks. Empty `hooks` returns a * cheap no-op shape so the call sites can dispatch unconditionally. * `capabilities` (optional) supplies the driver-provided `queue` / `actions` * that `buildHookContext` threads onto each `IHookContext`. */ declare function makeHookDispatcher(hooks: IHook[], emitter: ProgressEmitterPort, capabilities?: IHookDispatchCapabilities): IHookDispatcher; /** Construct a `ProgressEvent` envelope. Mirrors the orchestrator helper. */ declare function makeEvent(type: string, data: unknown): ProgressEvent; /** * Kernel entry point. `createKernel()` returns a shell with an empty registry * and no bound ports. Driving adapters (CLI, Server) are expected to * wire adapters before invoking use cases. */ interface Kernel { registry: Registry; /** * Step 9.6.6, read-only catalog of plugin-contributed annotation * keys, keyed by `(pluginId, key)`. Populated at plugin-load time; * pure read with no side effects. Built-in catalog (from * `annotations.schema.json`) is NOT included here. */ getRegisteredAnnotationKeys: () => readonly IRegisteredAnnotationKey[]; /** * Internal, replace the frozen catalog. Called once by the * plugin runtime composer after every plugin has loaded; consumers * MUST treat the resulting array as immutable. */ setRegisteredAnnotationKeys: (entries: readonly IRegisteredAnnotationKey[]) => void; /** * Step 11.x, read-only catalog of plugin-contributed view * contributions, keyed by `(pluginId, extensionId, contributionId)`. * Populated at plugin-load time; pure read with no side effects. * Mirror of `getRegisteredAnnotationKeys` for the view contribution * surface (see `architecture.md` §View contribution system → * Runtime catalog). */ getRegisteredViewContributions: () => readonly IRegisteredViewContribution[]; /** * Internal, replace the frozen view-contribution catalog. Called * once by the plugin runtime composer after every plugin has loaded; * consumers MUST treat the resulting array as immutable. */ setRegisteredViewContributions: (entries: readonly IRegisteredViewContribution[]) => void; } declare function createKernel(): Kernel; export { type Confidence, DuplicateExtensionError, EXTENSION_KINDS, type ExecutionFailureReason, type ExecutionKind, type ExecutionRecord, type ExecutionRunner, type ExecutionStatus, ExportQueryError, type ExtensionKind, type FilesystemPort, HOOK_TRIGGERS, type HistoryStats, type HistoryStatsErrorRates, type HistoryStatsExecutionsPerPeriod, type HistoryStatsPerExtensionRate, type HistoryStatsTokensPerExtension, type HistoryStatsTopNode, type HistoryStatsTotals, type IAction, type IActionContext, type IActionPrecondition, type IActionResult, type IAnalyzer, type IAnalyzerContext, type IAnnotationContribution, type ICreateFsWatcherOptions, type IDiscoveredPlugin, type IEnrichmentRecord, type IExportQuery, type IExportSubset, type IExtension, type IExtensionBase, type IExtensionLogger, type IExternalRef, type IExtractor, type IExtractorCallbacks, type IExtractorContext, type IExtractorRunRecord, type IFormatter, type IFormatterContext, type IFsWatcher, type IHook, type IHookContext, type IHookDispatcher, type IIssueRow, type IKvEntry, type IKvListOptions, type IKvPersistedRow, type IKvScopeOptions, type IKvStorePersist, type IKvStoreWrapper, type IKvStoreWrapperOptions, type ILoadedExtension, type INodeBundle, type INodeChange, type INodeCounts, type INodeFilter, type IPersistOptions, type IPersistedEnrichment, type IPluginManifest, type IPluginStorageSchema, type IProvider, type IRawNode, type IRegisteredAnnotationKey, type IRegisteredViewContribution, type IScanDelta, type ITransactionalStorage, type IViewContribution, type IWalkOptions, type IWatchBatch, type IWatchEvent, InMemoryProgressEmitter, type Issue, type IssueFix, KV_DISPLAY_CAP, KV_GLOBAL_NODE_ID, KV_KEY_MAX_BYTES, KV_KEY_WARN_BYTES, KV_KEY_WARN_MAX_TRACKED, KV_PLUGIN_MAX_TOTAL_BYTES, KV_SCHEMA_KEY, KV_VALUE_MAX_BYTES, type Kernel, KvBudgetExceededError, KvKeyInvalidError, KvNodePathInvalidError, KvOperationFailedError, KvValueNotSerializableError, KvValueTooLargeError, LOG_LEVELS, type Link, type LinkKind, type LinkLocation, type LinkOccurrence, type LinkTrigger, type LogRecord, type LoggerPort, type Node, type NodeKind, type NodeStat, type PluginLoaderPort, type ProgressEmitterPort, type ProgressEvent, Registry, type RenameOp, type RunScanOptions, SILENT_EXTENSION_LOGGER, type ScanResult, type ScanScannedBy, type ScanStats, type Severity, SilentLogger, type SlotPayload, type SlotPayloadMap, type Stability, type StoragePort, type TActionWrite, type TExecutionMode, type THookFilter, type THookTrigger, type TInputTypeName, type TLogLevel, type TLogMethodLevel, type TNodeChangeReason, type TPluginLoadStatus, type TPluginStorage, type TPluginStore, type TProgressListener, type TSettingDeclaration, type TSettingValue, type TSeverity, type TSlotName, type TWatchEventKind, type TripleSplit, applyExportQuery, computeScanDelta, configureLogger, createChokidarWatcher, createKernel, createParcelWatcher, detectRenamesAndOrphans, getActiveLogger, isEmptyDelta, isLogLevel, log, logLevelRank, makeEvent, makeExtensionLogger, makeHookDispatcher, makeKvStoreWrapper, makePluginStore, mergeNodeWithEnrichments, parseExportQuery, parseLogLevel, qualifiedExtensionId, resetLogger, runExtractorsForNode, runScan, runScanWithRenames };