@skill-map/spec 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli-contract.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CLI contract
2
2
 
3
- Normative description of the `sm` CLI surface: verbs, flags, exit codes, machine-readable output. Any conforming implementation MUST expose a CLI binary that satisfies this contract. The binary name (`sm`) and long alias (`skill-map`) are normative.
3
+ Normative description of the `sm` CLI surface: verbs, flags, exit codes, machine-readable output. Any conforming implementation MUST expose a CLI binary satisfying this contract. The binary name (`sm`) and long alias (`skill-map`) are normative.
4
4
 
5
5
  ---
6
6
 
@@ -9,7 +9,7 @@ Normative description of the `sm` CLI surface: verbs, flags, exit codes, machine
9
9
  - Primary: `sm`.
10
10
  - Long alias: `skill-map`. MUST resolve to the same binary. A symlink, shim, or alias in `bin` field of `package.json` is acceptable.
11
11
  - Help invocation: `sm --help` and `sm -h` MUST print top-level help and exit with code 0.
12
- - Bare invocation: `sm` with no arguments starts the Web UI server (equivalent to `sm serve`) when a `.skill-map/` project is initialized in the current working directory. When no project is found in the cwd, it MUST print a one-line hint to stderr pointing the user to `sm init` and `sm --help`, then exit with code `2`.
12
+ - Bare invocation: `sm` with no arguments starts the Web UI server (equivalent to `sm serve`) when a `.skill-map/` project is initialized in the cwd. With no project in the cwd, it MUST print a one-line hint to stderr pointing at `sm init` and `sm --help`, then exit `2`.
13
13
 
14
14
  ---
15
15
 
@@ -41,39 +41,36 @@ CLI flag wins over env var. Env var wins over config file.
41
41
  Every `sm` verb operates on the **project scope** (`<cwd>/.skill-map/`).
42
42
  There is no opt-in global scope, no `-g/--global` flag, no
43
43
  `SKILL_MAP_SCOPE` env var. Skill-map MUST NOT read anything from
44
- `$HOME` by default. The only mechanism the user has to extend the
45
- scan beyond the project root is the `scan.extraFolders` setting (see
46
- §Scan), which lives in `<cwd>/.skill-map/settings.local.json` and is
47
- written under an explicit privacy gate (`sm config set --yes` or the
48
- Settings UI confirm dialog). Plugins load from
49
- `<cwd>/.skill-map/plugins/` by default; an arbitrary external
50
- location MAY be loaded via the `--plugin-dir <path>` escape hatch on
51
- the `sm plugins …` verb family, again user-explicit per invocation.
44
+ `$HOME` by default. The only way to extend the scan beyond the project
45
+ root is the `scan.extraFolders` setting (see §Scan), in
46
+ `<cwd>/.skill-map/settings.local.json`, written under an explicit
47
+ privacy gate (`sm config set --yes` or the Settings UI confirm dialog).
48
+ Plugins load from `<cwd>/.skill-map/plugins/` by default; an arbitrary
49
+ external location MAY be loaded via the `--plugin-dir <path>` escape
50
+ hatch on the `sm plugins …` verb family, user-explicit per invocation.
52
51
 
53
52
  ### User-settings file (narrow, documented exception)
54
53
 
55
54
  Genuinely per-user, per-machine preferences live in a **single file**
56
55
  at `~/.skill-map/settings.json`, validated against
57
56
  [`user-settings.schema.json`](./schemas/user-settings.schema.json).
58
- The file holds preferences that have no project meaning (the update-
59
- check toggle + its throttle bookkeeping, and the telemetry consent
60
- flag today; future locale, theme, etc.). Constraints:
57
+ It holds preferences with no project meaning (today: the update-check
58
+ toggle + its throttle bookkeeping, and the telemetry consent flag;
59
+ future locale, theme). Constraints:
61
60
 
62
61
  - **One file, no `.local` partner**: values here are already
63
62
  per-machine, so the project / project-local split has no meaning.
64
63
  - **NOT part of the config layer system**: the project config loader
65
64
  (`defaults` → `project` → `project-local` → `override`) MUST NOT
66
- read or merge this file. Modules that own a user-scope feature
67
- read it directly through a dedicated helper.
65
+ read or merge this file. Modules owning a user-scope feature read it
66
+ directly through a dedicated helper.
68
67
  - **Narrow scope**: implementations SHOULD keep the key set small
69
- (only preferences whose value is meaningless inside a project).
70
- Anything that belongs to a project goes in
71
- `<cwd>/.skill-map/settings.json` instead.
68
+ (only preferences meaningless inside a project). Anything project-
69
+ scoped goes in `<cwd>/.skill-map/settings.json` instead.
72
70
  - **Closed list of writers**: a single user-settings store module
73
71
  (`src/cli/util/user-settings-store.ts` in the reference impl) is the
74
- only reader / writer of the file. Every user-scope feature (the
75
- update-check toggle, the telemetry consent flag) goes through it
76
- rather than opening new home access points.
72
+ only reader / writer. Every user-scope feature (update-check toggle,
73
+ telemetry consent) goes through it, not new home access points.
77
74
 
78
75
  Everything else under `$HOME` MUST NOT be touched.
79
76
 
@@ -86,35 +83,35 @@ skill-map sends nothing off the machine by default. Opt-in, anonymous
86
83
  - **Default OFF.** The `telemetry.errorsEnabled` flag in
87
84
  `~/.skill-map/settings.json` is absent until the operator decides. Absent
88
85
  or `false` means no telemetry SDK is loaded and nothing is sent, on every
89
- surface (CLI, BFF, UI), with zero added latency.
86
+ surface (CLI, BFF, UI), zero added latency.
90
87
  - **Consent prompt (interactive terminals only, second eligible run).** The
91
88
  CLI MAY show a one-time consent prompt (yes (default) / no / details),
92
89
  but NOT on the operator's first eligible run: that run only records
93
90
  `telemetry.firstRunAt` and stays silent so the prompt does not stack on
94
91
  the first-`sm scan` provider-lens prompt. The next eligible run shows it,
95
- persists the choice, and stamps `telemetry.promptedAt` (so it is never
96
- shown again). When stdout is not a TTY (CI, pipes), nothing is asked or
92
+ persists the choice, and stamps `telemetry.promptedAt` (never shown
93
+ again). When stdout is not a TTY (CI, pipes), nothing is asked or
97
94
  recorded and the state stays OFF.
98
95
  - **Kill switch.** `SKILL_MAP_TELEMETRY=0` forces OFF everywhere regardless
99
96
  of the persisted flag. There is no env value that forces ON.
100
- - **No `sm config` key.** The flag is per-machine, so it lives in the
101
- user-settings file, not in project config. `sm config` writes project-local
102
- settings only and MUST NOT surface this key. Consent is changed after the
103
- first run through the Settings UI (persisted via the BFF), mirroring how
104
- the update-check toggle works. A future `sm telemetry` verb
105
- family MAY expose CLI status / toggling; it is not part of this level.
97
+ - **No `sm config` key.** Per-machine, so it lives in the user-settings
98
+ file, not project config. `sm config` writes project-local settings only
99
+ and MUST NOT surface this key. Consent is changed after the first run
100
+ through the Settings UI (persisted via the BFF), mirroring the update-
101
+ check toggle. A future `sm telemetry` verb family MAY expose CLI status /
102
+ toggling; not part of this level.
106
103
 
107
104
  ### Active provider lens
108
105
 
109
- The project sees its filesystem through exactly one **active provider lens** at any time. The lens is persisted as `activeProvider` in `<cwd>/.skill-map/settings.json` (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider) and the [`architecture.md` §Active Provider Lens](./architecture.md#active-provider-lens) section for the full architectural rationale).
106
+ The project sees its filesystem through exactly one **active provider lens** at any time, persisted as `activeProvider` in `<cwd>/.skill-map/settings.json` (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider) and [`architecture.md` §Active Provider Lens](./architecture.md#active-provider-lens) for the architectural rationale).
110
107
 
111
108
  CLI surfaces:
112
109
 
113
- - **Auto-detect on first scan**: when `activeProvider` is absent, `sm scan` and `sm watch` run a filesystem heuristic driven by each Provider's manifest `detect.markers` (e.g. `.claude/` → `claude`, `.codex/` or root `AGENTS.md` → `openai`, `.agents/` → `agent-skills`). The marker set is provider-owned, not a central hardcoded table, so the detectable set derives from the registered Providers. On unambiguous match, the result is persisted to `settings.json` and the scan proceeds; on no match, the CLI exits non-zero with a "no provider detected, set `activeProvider` in settings or install a provider plugin" message. On ambiguous match (multiple providers detected), the CLI prompts the user interactively (or fails with exit code 2 under `--yes` if no default is configured). Google's Antigravity CLI does not declare a vendor-specific marker (it adopted the open-standard `.agents/` layout, which auto-detects as the universal `agent-skills` lens); the `antigravity` lens is set manually via `sm config set activeProvider antigravity`.
114
- - **Manual override**: `sm config set activeProvider <id>` switches the lens. The verb drops the `scan_*` zone atomically (see [`db-schema.md`](./db-schema.md#zones)) and triggers an immediate rescan under the new lens. `state_*` and `config_*` zones survive.
115
- - **No per-scan flag**: there is no `sm scan --provider=<id>` flag. The lens is a project-level decision, not a per-invocation parameter. The drop+rescan cost makes per-invocation switching the wrong default UX.
110
+ - **Auto-detect on first scan**: when `activeProvider` is absent, `sm scan` and `sm watch` run a filesystem heuristic driven by each Provider's manifest `detect.markers` (e.g. `.claude/` → `claude`, `.codex/` or root `AGENTS.md` → `openai`, `.agents/` → `agent-skills`). The marker set is provider-owned, not hardcoded. On unambiguous match, the result is persisted to `settings.json` and the scan proceeds; on no match, the CLI exits non-zero with "no provider detected, set `activeProvider` in settings or install a provider plugin"; on ambiguous match (multiple detected), it prompts interactively (or fails with exit code 2 under `--yes` if no default is configured). Google's Antigravity CLI declares no vendor-specific marker (it adopted the open-standard `.agents/` layout, auto-detected as `agent-skills`); the `antigravity` lens is set manually via `sm config set activeProvider antigravity`.
111
+ - **Manual override**: `sm config set activeProvider <id>` switches the lens, drops the `scan_*` zone atomically (see [`db-schema.md`](./db-schema.md#zones)), and triggers an immediate rescan under the new lens. `state_*` and `config_*` zones survive.
112
+ - **No per-scan flag**: there is no `sm scan --provider=<id>` flag. The lens is a project-level decision; the drop+rescan cost makes per-invocation switching the wrong default UX.
116
113
 
117
- **UI lens-selection surface.** `GET /api/active-provider` returns `{ activeProvider, detected, source, selectable }`. `selectable` is the set of registered-Provider ids that are enabled right now, resolved against the live per-extension enabled resolver (`config_plugins` layered over `settings.json#/plugins`, the same resolution `GET /api/plugins` applies), so it is the subset of `providerRegistry` eligible to become the lens. A Provider the operator disabled is dropped from `selectable` but stays in `providerRegistry` (the static boot catalog keeps it so already-scanned nodes still render their chip / icon). The SPA's active-lens dropdown lists every `providerRegistry` Provider and renders those absent from `selectable` as disabled (greyed, not selectable), so a disabled Provider can never be picked as the lens. This mirrors the scan-time contract that a lens pointing at a disabled Provider runs none of its extractors (the runtime soft-warns on that drift); the dropdown closes the loop on the write side by refusing to create the drift in the first place.
114
+ **UI lens-selection surface.** `GET /api/active-provider` returns `{ activeProvider, detected, source, selectable }`. `selectable` is the set of registered-Provider ids enabled right now, resolved against the live per-extension enabled resolver (`config_plugins` layered over `settings.json#/plugins`, same as `GET /api/plugins`), the subset of `providerRegistry` eligible to become the lens. A disabled Provider is dropped from `selectable` but stays in `providerRegistry` (the static boot catalog keeps it so already-scanned nodes still render their chip / icon). The SPA's active-lens dropdown lists every `providerRegistry` Provider and renders those absent from `selectable` disabled (greyed, not selectable), so a disabled Provider can never be picked. This mirrors the scan-time contract that a lens pointing at a disabled Provider runs none of its extractors (the runtime soft-warns on that drift); the dropdown closes the loop by refusing to create the drift.
118
115
 
119
116
  ---
120
117
 
@@ -130,7 +127,7 @@ CLI surfaces:
130
127
 
131
128
  For those verbs, `--all` means "apply to every eligible target matching the verb's preconditions" and is mutually exclusive with a positional target or `-n <path>` on the same invocation.
132
129
 
133
- Implementations MUST NOT silently accept `--all` on unrelated verbs. Unsupported `--all` usage is an operational error (exit `2`), the same as any other unknown or invalid flag.
130
+ Implementations MUST NOT silently accept `--all` on unrelated verbs. Unsupported `--all` usage is an operational error (exit `2`), like any other invalid flag.
134
131
 
135
132
  ---
136
133
 
@@ -155,13 +152,13 @@ Codes 6–15 are reserved. Codes ≥ 16 are free for verb-specific use.
155
152
 
156
153
  A verb that exposes `-n` / `--dry-run` MUST honour the following contract:
157
154
 
158
- - **No observable side effects.** The command MUST NOT mutate the database, the filesystem, the config, the network, or spawn external processes. Read-only operations needed to compute the preview (e.g. loading the prior `ScanResult`, reading existing config files, listing FS entries) ARE permitted.
159
- - **No auto-provisioning.** A dry-run MUST NOT create directories, schema files, or DBs that would not exist after the command. If the operation would create a `.skill-map/` scope, dry-run only previews the creation; the directory must NOT appear on disk.
160
- - **Output mirrors the live mode**, same shape, same fields, same `--json` schema, except that human-readable output explicitly indicates the dry-run state ("would persist …", "would create …", "would delete …", or a clear "(dry-run)" suffix) and machine-readable output sets a top-level `dryRun: true` field where applicable.
161
- - **Exit codes mirror the live mode.** Same exit code table; the dry-run posture does not introduce new codes. A dry-run that surfaces an error severity (e.g. "scan would emit an error-severity issue") still exits `1`; a dry-run that fails to read the input still exits `2`.
162
- - **Dry-run MUST NOT depend on `--yes` / `--force`.** Verbs that offer interactive confirmation for destructive operations MUST allow `--dry-run` to bypass the prompt entirely (no confirmation needed when nothing is being destroyed).
155
+ - **No observable side effects.** The command MUST NOT mutate the database, filesystem, config, network, or spawn external processes. Read-only operations needed to compute the preview (e.g. loading the prior `ScanResult`, reading config files, listing FS entries) ARE permitted.
156
+ - **No auto-provisioning.** A dry-run MUST NOT create directories, schema files, or DBs that would not exist after the command. If the operation would create a `.skill-map/` scope, dry-run only previews it; the directory must NOT appear on disk.
157
+ - **Output mirrors the live mode**, same shape, fields, and `--json` schema, except human-readable output indicates the dry-run state ("would persist …", "would create …", "would delete …", or a "(dry-run)" suffix) and machine-readable output sets a top-level `dryRun: true` field where applicable.
158
+ - **Exit codes mirror the live mode.** Same table; dry-run introduces no new codes. A dry-run surfacing an error severity (e.g. "scan would emit an error-severity issue") still exits `1`; one that fails to read the input still exits `2`.
159
+ - **Dry-run MUST NOT depend on `--yes` / `--force`.** Verbs offering interactive confirmation for destructive operations MUST allow `--dry-run` to bypass the prompt entirely (nothing being destroyed needs no confirmation).
163
160
 
164
- Dry-run is **per-verb opt-in**. The flag is not global; verbs that do not declare it MUST reject `--dry-run` as an unknown option (exit `2`), the same as any other unknown flag. The verb catalog below names every verb that exposes the flag and what its preview looks like.
161
+ Dry-run is **per-verb opt-in**; not global. Verbs that do not declare it MUST reject `--dry-run` as an unknown option (exit `2`), like any other unknown flag. The verb catalog below names every verb that exposes the flag and its preview.
165
162
 
166
163
  ---
167
164
 
@@ -184,25 +181,25 @@ Exit: 0 on success, 2 on failure.
184
181
 
185
182
  #### `sm tutorial`
186
183
 
187
- Materialize the interactive tester tutorial as a skill folder under the chosen agent's on-disk territory. Companion to the `sm-tutorial` skill: a tester drops into an empty directory, runs `sm tutorial` to seed the skill, then opens their agent there and triggers it by speaking one of its trigger phrases (the agent auto-discovers `<skillDir>/sm-tutorial/SKILL.md` on boot). The skill is a single "book" of parts and chapters: a first-time tester walks the live-UI prologue, then picks further parts (extend skill-map with plugins/settings/view-slots, the CLI in depth) from an in-skill menu. The verb takes **no positional argument**.
184
+ Materialize the interactive tester tutorial as a skill folder under the chosen agent's on-disk territory. Companion to the `sm-tutorial` skill: a tester drops into an empty directory, runs `sm tutorial` to seed the skill, opens their agent there and triggers it via one of its trigger phrases (the agent auto-discovers `<skillDir>/sm-tutorial/SKILL.md` on boot). The skill is a single "book" of parts: a tester walks the live-UI prologue, then picks further parts (extend skill-map with plugins/settings/view-slots, the CLI in depth) from an in-skill menu. The verb takes **no positional argument**.
188
185
 
189
- The destination directory is the selected Provider's `scaffold.skillDir` (e.g. `.claude/skills` for Claude, `.agents/skills` for the open standard adopted by Antigravity); the verb writes `<cwd>/<skillDir>/sm-tutorial/`. Provider selection:
186
+ The destination is the selected Provider's `scaffold.skillDir` (e.g. `.claude/skills` for Claude, `.agents/skills` for the open standard adopted by Antigravity); the verb writes `<cwd>/<skillDir>/sm-tutorial/`. Provider selection:
190
187
 
191
- - `--for <provider-id>` selects the destination Provider explicitly (e.g. `--for claude`, `--for agent-skills`). The id MUST be a registered Provider that declares `scaffold.skillDir`; any other value is a usage error.
192
- - Without `--for`, the default Provider is the first scaffold-capable Provider in catalog order (Claude). The verb requires an empty cwd (see below), so there is no marker to detect: provider auto-detection does not apply.
193
- - Without `--for`, on an interactive stdin the verb prompts with a numbered list of the Providers that declare `scaffold.skillDir`, marking the default option (Claude); an empty answer accepts it. Each option shows the Provider label plus any `scaffold.aka` agents in parentheses (e.g. the open standard lists Antigravity and OpenAI Codex). The `aka` strings are display-only and are NOT accepted by `--for`.
194
- - Without `--for`, on a non-interactive stdin (pipes, CI) the verb selects the default Provider without prompting, so the verb stays scriptable.
188
+ - `--for <provider-id>` selects the Provider explicitly (e.g. `--for claude`, `--for agent-skills`). The id MUST be a registered Provider declaring `scaffold.skillDir`; any other value is a usage error.
189
+ - Without `--for`, the default is the first scaffold-capable Provider in catalog order (Claude). The verb requires an empty cwd (see below), so there is no marker to detect: provider auto-detection does not apply.
190
+ - Without `--for`, on interactive stdin the verb prompts with a numbered list of Providers declaring `scaffold.skillDir`, marking the default (Claude); an empty answer accepts it. Each option shows the Provider label plus any `scaffold.aka` agents in parentheses (e.g. the open standard lists Antigravity and OpenAI Codex). The `aka` strings are display-only, NOT accepted by `--for`.
191
+ - Without `--for`, on non-interactive stdin (pipes, CI) the verb selects the default without prompting, staying scriptable.
195
192
 
196
193
  Behaviour:
197
194
 
198
195
  - Writes the full skill folder (`SKILL.md` plus its `references/` sub-folder) under the resolved `<skillDir>/sm-tutorial/`.
199
- - Content is the canonical skill shipped with the implementation. The `SKILL.md` payload is host-agnostic; only the destination directory varies per Provider. Any conforming implementation MUST embed equivalent tutorial sources (the prose itself is informative; what is normative is that the verb produces a readable skill folder a compatible agent can consume).
200
- - Requires the cwd to be empty (a directory listing including dotfiles returns nothing). The tutorial seeds a self-contained scenario and the skill later lays its fixtures and `.skill-map/` directly in the cwd, so the tester can delete the whole directory afterwards without losing prior work; that guarantee only holds when the directory started empty. A non-empty cwd is refused (exit 2) unless `--force` is passed.
201
- - Does NOT require an initialized project and never reads or writes `.skill-map/`. It is a pre-bootstrap helper: Provider selection reads the built-in Provider catalog directly, not project config.
196
+ - Content is the canonical skill shipped with the implementation. The `SKILL.md` payload is host-agnostic; only the destination varies per Provider. Any conforming implementation MUST embed equivalent tutorial sources (the prose is informative; what is normative is that the verb produces a readable skill folder a compatible agent can consume).
197
+ - Requires the cwd to be empty (a listing including dotfiles returns nothing). The tutorial seeds a self-contained scenario and the skill later lays its fixtures and `.skill-map/` directly in the cwd, so the tester can delete the whole directory afterwards without losing prior work; that guarantee only holds when the directory started empty. A non-empty cwd is refused (exit 2) unless `--force`.
198
+ - Does NOT require an initialized project and never reads or writes `.skill-map/`. A pre-bootstrap helper: Provider selection reads the built-in Provider catalog directly, not project config.
202
199
 
203
200
  Flags: `--for <provider-id>` (destination Provider, skips the prompt); `--force` (proceed even when the cwd is not empty, overwriting any existing target folder, without prompting).
204
201
 
205
- Exit: `0` on success; `2` if the cwd is not empty and `--force` was not passed (operational error, refusing to seed the tutorial into a directory that already holds content); `2` if an unexpected positional argument is passed (the verb takes no positional; e.g. the removed `master` variant, the advanced walkthrough is now a part inside the single skill, reached from its menu); `2` if `--for` names a Provider that does not exist or declares no `scaffold.skillDir`; `2` on any I/O failure.
202
+ Exit: `0` on success; `2` if the cwd is not empty and `--force` was not passed; `2` if an unexpected positional argument is passed (the verb takes no positional; e.g. the removed `master` variant, the advanced walkthrough is now a part inside the single skill, reached from its menu); `2` if `--for` names a Provider that does not exist or declares no `scaffold.skillDir`; `2` on any I/O failure.
206
203
 
207
204
  #### `sm version`
208
205
 
@@ -236,8 +233,8 @@ Exit: 0 if all green, 1 if warnings, 2 if any `error`-level problem.
236
233
 
237
234
  Self-describing introspection.
238
235
 
239
- - `human` (default): pretty terminal output. With no argument: the compact overview of every verb grouped by category. With a verb (`sm help scan`, `sm scan --help`): that verb's detail view. With a **command namespace** (a prefix that owns subcommands but is not itself runnable, e.g. `sm help plugins`, `sm plugins --help`, `sm plugins slots --help`): a namespace overview, header line, USAGE, optional DESCRIPTION, then a COMMANDS list of the subcommands. An argument that is neither a verb nor a namespace exits `5` with an unknown-verb message.
240
- - `md`: canonical markdown for documentation sites. Implementations MUST NOT hand-maintain equivalent markdown; it is generated on demand from this output. With a verb or namespace argument, the output is scoped to that verb (or the namespace's subcommands).
236
+ - `human` (default): pretty terminal output. No argument: compact overview of every verb grouped by category. With a verb (`sm help scan`, `sm scan --help`): that verb's detail view. With a **command namespace** (a prefix owning subcommands but not itself runnable, e.g. `sm help plugins`, `sm plugins --help`, `sm plugins slots --help`): a namespace overview, header line, USAGE, optional DESCRIPTION, then a COMMANDS list of the subcommands. An argument that is neither verb nor namespace exits `5` with an unknown-verb message.
237
+ - `md`: canonical markdown for documentation sites. Implementations MUST NOT hand-maintain equivalent markdown; it is generated on demand from this output. With a verb or namespace argument, output is scoped to that verb (or the namespace's subcommands).
241
238
  - `json`: structured surface dump. Shape:
242
239
 
243
240
  ```json
@@ -277,18 +274,18 @@ Keys are dot-paths (`jobs.minimumTtlSeconds`, `scan.tokenize`). Unknown keys →
277
274
 
278
275
  Keys whose value opens disk access OUTSIDE the project root (today: `scan.extraFolders`, `scan.referencePaths`) are gated behind `--yes` so the user never expands the scan surface by accident. The analyzer:
279
276
 
280
- - `sm config set <privacy-key> <value>` (without `--yes`), when the new value would expand the surface (adding paths to `extraFolders` / `referencePaths` that resolve outside the project root), exits with code `2` and prints the full list of paths the change would expose to stderr, suggesting `--yes` to confirm.
281
- - `sm config set <privacy-key> <value> --yes`, proceeds with the write and prints the same list as a confirmation receipt.
277
+ - `sm config set <privacy-key> <value>` (without `--yes`), when the new value would expand the surface (adding `extraFolders` / `referencePaths` paths resolving outside the project root), exits with code `2` and prints the full list of exposed paths to stderr, suggesting `--yes` to confirm.
278
+ - `sm config set <privacy-key> <value> --yes`, proceeds and prints the same list as a confirmation receipt.
282
279
  - Writes that NARROW the surface (removing paths) do not require `--yes`.
283
280
 
284
- The Settings UI's Project section enforces the same analyzer via a confirm dialog that enumerates the paths.
281
+ The Settings UI's Project section enforces the same analyzer via a confirm dialog enumerating the paths.
285
282
 
286
283
  #### Project-local-only config
287
284
 
288
285
  The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `PROJECT_LOCAL_ONLY_KEYS` (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality)). The values are per-user-per-project and MUST NOT travel via the committed repo:
289
286
 
290
287
  - `sm config set` writes them to `<cwd>/.skill-map/settings.local.json` (gitignored).
291
- - The loader strips them (with a warning) when found in the committed `project` layer (`settings.json`). An older install that wrote one of these keys to `settings.json` keeps validating against the schema, but the value is ignored at read time and `sm config show --source` surfaces the warning.
288
+ - The loader strips them (with a warning) when found in the committed `project` layer (`settings.json`). An older install that wrote one to `settings.json` keeps validating against the schema, but the value is ignored at read time and `sm config show --source` surfaces the warning.
292
289
 
293
290
  ---
294
291
 
@@ -302,27 +299,27 @@ The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `P
302
299
  | `sm scan --watch` | Long-running: watch the roots and trigger an incremental scan after each debounced batch of filesystem events. Alias of `sm watch`. |
303
300
  | `sm scan compare-with <dump> [roots...]` | Delta report: run a fresh scan in memory and compare against the saved `ScanResult` dump at `<dump>`. Read-only, does not modify the DB. Exit `0` on empty delta, `1` on any drift, `2` on operational error (missing or malformed dump, schema violation). |
304
301
  | `sm watch [roots...]` | Long-running watcher. Same semantics as `sm scan --watch`, exposed as a top-level verb because the watcher is a loop, not a one-shot scan. |
305
- | `sm refresh <node.path>` | Re-run Extractors against a single node and upsert their outputs into the universal enrichment layer (`node_enrichments`, see [`db-schema.md`](./db-schema.md#node_enrichments)). Extractors are deterministic-only, they run synchronously and persist. Exit `0` on success, `2` on failure, `5` if the node is not in the persisted scan. `--json` emits the report shape declared by [`refresh-report.schema.json`](./schemas/refresh-report.schema.json): `{ ok: true, kind: 'refresh.report', refreshed, nodes[], elapsedMs }`. Error envelope per §Error envelope: `not-found` (missing node), `db-missing` (absent DB), `internal` (read / persist failure). |
306
- | `sm refresh --stale` | Batch form of `sm refresh <node>`, refreshes every node carrying at least one stale enrichment row. With Extractors deterministic-only, the stale set is empty in this revision (Extractor writes never set `stale = 1`) so `--stale` always exits `0` with a "nothing to do" advisory. The verb is preserved for the future Action-prob enrichment revision (see [`architecture.md` §Extractor · enrichment layer](./architecture.md#extractor--enrichment-layer)) where queued LLM jobs will populate stale rows. `--json` emits the same envelope as the single-node form ([`refresh-report.schema.json`](./schemas/refresh-report.schema.json)); an empty stale set yields `{ ok: true, kind: 'refresh.report', refreshed: 0, nodes: [], elapsedMs }`. |
302
+ | `sm refresh <node.path>` | Re-run Extractors against a single node and upsert their outputs into the universal enrichment layer (`node_enrichments`, see [`db-schema.md`](./db-schema.md#node_enrichments)). Extractors are deterministic-only: run synchronously and persist. Exit `0` on success, `2` on failure, `5` if the node is not in the persisted scan. `--json` emits the report shape declared by [`refresh-report.schema.json`](./schemas/refresh-report.schema.json): `{ ok: true, kind: 'refresh.report', refreshed, nodes[], elapsedMs }`. Error envelope per §Error envelope: `not-found` (missing node), `db-missing` (absent DB), `internal` (read / persist failure). |
303
+ | `sm refresh --stale` | Batch form of `sm refresh <node>`, refreshes every node carrying at least one stale enrichment row. With Extractors deterministic-only, the stale set is empty in this revision (Extractor writes never set `stale = 1`) so `--stale` always exits `0` with a "nothing to do" advisory. Preserved for the future Action-prob enrichment revision (see [`architecture.md` §Extractor · enrichment layer](./architecture.md#extractor--enrichment-layer)) where queued LLM jobs populate stale rows. `--json` emits the same envelope as the single-node form ([`refresh-report.schema.json`](./schemas/refresh-report.schema.json)); an empty stale set yields `{ ok: true, kind: 'refresh.report', refreshed: 0, nodes: [], elapsedMs }`. |
307
304
 
308
305
  `--json` output conforms to `schemas/scan-result.schema.json`. `sm watch` (and `sm scan --watch`) emit one ScanResult per batch, under `--json` this is an `ndjson` stream of ScanResult documents.
309
306
 
310
307
  **Effective roots** (one-shot `sm scan`):
311
308
 
312
- - `sm scan [roots...]`: when positional roots are given, they ARE the effective roots (verbatim). When omitted: the effective roots are `[cwd]` plus the appended set below.
313
- - `scan.extraFolders[]` (project-local config) is appended verbatim (entries starting with `~` resolve against the user home; relative entries resolve against the project root). This is the ONLY way to extend the scan beyond the project: there is no implicit `$HOME` walk, no opt-in global scope, and Providers cannot opt their own directory in. See §Scope is always project-local at the top of this file for the broader principle.
309
+ - `sm scan [roots...]`: positional roots, when given, ARE the effective roots (verbatim). When omitted: `[cwd]` plus the appended set below.
310
+ - `scan.extraFolders[]` (project-local config) is appended verbatim (entries starting with `~` resolve against the user home; relative entries against the project root). This is the ONLY way to extend the scan beyond the project: no implicit `$HOME` walk, no opt-in global scope, and Providers cannot opt their own directory in. See §Scope is always project-local at the top of this file.
314
311
 
315
- **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. Files there are NOT parsed and NOT indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses.
312
+ **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. These files are NOT parsed or indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses.
316
313
 
317
- The watcher subscribes to the same roots that `sm scan` walks and respects `.skillmapignore` plus `config.ignore` exactly as the one-shot scan does. Filesystem events are grouped using `scan.watch.debounceMs` (default 300ms) before the watcher re-runs the incremental scan and persists. `SIGINT` / `SIGTERM` close the watcher cleanly. Exit code on clean shutdown is 0.
314
+ The watcher subscribes to the same roots `sm scan` walks and respects `.skillmapignore` plus `config.ignore` exactly as the one-shot scan does. Filesystem events are grouped using `scan.watch.debounceMs` (default 300ms) before the watcher re-runs the incremental scan and persists. `SIGINT` / `SIGTERM` close the watcher cleanly. Exit code on clean shutdown is 0.
318
315
 
319
- **Node cap** (`--max-nodes <N>`): on `sm scan` and `sm watch` (alias `sm scan --watch`), a hard cap on the number of files the walker accepts after `.skillmapignore` filtering, before extractors run. Default comes from `scan.maxNodes` (default 256). The flag is a full override of the setting and is **bidirectional**: it can raise the cap (`--max-nodes 1000` on a 312-file repo) or lower it (`--max-nodes 100` cuts deeper than the default). When the walker reaches the cap, additional files are dropped in stable provider-walker order and the scan is marked oversized in `scan_meta` (columns `recommended_node_limit` and `override_max_nodes`), the resulting `ScanResult` envelope carries `recommendedNodeLimit` and `overrideMaxNodes` so the UI raises a persistent banner pointing at the `.skillmapignore` editor in Settings → Project. The CLI prints a human-mode notice naming both escapes: edit `.skillmapignore` (preferred, trims permanently) or re-run with `--max-nodes <N>` (force, graph quality may degrade past the recommended limit). `sm refresh` operates on a single already-classified node, so the cap does not apply there. Validation: integer ≥ 1, anything else exits `2` operational.
316
+ **Node cap** (`--max-nodes <N>`): on `sm scan` and `sm watch` (alias `sm scan --watch`), a hard cap on files the walker accepts after `.skillmapignore` filtering, before extractors run. Default from `scan.maxNodes` (default 256). The flag fully overrides the setting and is **bidirectional**: it can raise the cap (`--max-nodes 1000` on a 312-file repo) or lower it (`--max-nodes 100`). At the cap, additional files are dropped in stable provider-walker order and the scan is marked oversized in `scan_meta` (columns `recommended_node_limit` and `override_max_nodes`); the `ScanResult` envelope carries `recommendedNodeLimit` and `overrideMaxNodes` so the UI raises a persistent banner pointing at the `.skillmapignore` editor in Settings → Project. The CLI prints a human-mode notice naming both escapes: edit `.skillmapignore` (preferred, trims permanently) or re-run with `--max-nodes <N>` (force, graph quality may degrade past the recommended limit). `sm refresh` operates on a single already-classified node, so the cap does not apply there. Validation: integer ≥ 1, else exits `2` operational.
320
317
 
321
- **File-size skip** (`scan.maxFileSizeBytes`, default 1 MiB): the walker checks each candidate file's on-disk size before reading it and skips any file larger than the limit. The skip happens at the source (the file is never read, parsed, or indexed as a node), so an accidental binary drop or generated artefact cannot poison the graph. Every skipped file is reported in the `ScanResult` envelope as `oversizedFiles` (each entry the root-relative, forward-slash path plus the byte size) and counted in `stats.filesOversized`. When at least one file is skipped, `sm scan`, `sm watch` (per batch), and `sm serve` (initial scan and every batch) print a **WARN**-level terminal notice listing the skipped files with a human-readable size, plus a hint pointing at `scan.maxFileSizeBytes` and `.skillmapignore`; the UI raises a matching banner. Unlike the node cap, the limit is config-only (no per-invocation flag).
318
+ **File-size skip** (`scan.maxFileSizeBytes`, default 1 MiB): the walker checks each candidate file's on-disk size before reading and skips any larger than the limit. The skip happens at the source (never read, parsed, or indexed), so an accidental binary or generated artefact cannot poison the graph. Every skipped file is reported in the `ScanResult` envelope as `oversizedFiles` (each entry the root-relative, forward-slash path plus byte size) and counted in `stats.filesOversized`. When at least one file is skipped, `sm scan`, `sm watch` (per batch), and `sm serve` (initial scan and every batch) print a **WARN** terminal notice listing the skipped files with a human-readable size, plus a hint pointing at `scan.maxFileSizeBytes` and `.skillmapignore`; the UI raises a matching banner. Unlike the node cap, the limit is config-only (no per-invocation flag).
322
319
 
323
- **Schema-drift rebuild (pre-1.0)**: before persisting, `sm scan`, `sm watch`, and `sm serve` (before it starts listening) detect schema drift on two axes: the recorded `scan_meta.scanned_by_version` against the running CLI (a minor or major difference is drift; patch-level is compatible), AND the recorded `scan_meta.schema_fingerprint` against the fingerprint recomputed from the bundled migration DDL (any mismatch, or a NULL stored value from a pre-fingerprint DB, is drift). The fingerprint axis catches an inline `001_initial.sql` column add within the same `major.minor` that the version axis cannot see. When either axis trips, the local cache predates a schema change, so the DB is deleted and rebuilt from scratch by this run (`.sm` sidecars are untouched, they are the source of truth). On an interactive terminal the rebuild is confirmed first (`sm scan` rebuilds on the next persist; `sm serve` prompts before booting and aborts with a nonzero exit if declined); `--yes` (and every non-interactive caller: piped stdin, CI, the BFF scan route, the watcher) rebuilds without prompting. Declining aborts (exit `2`) without deleting anything. A DB that was never scanned (no `scan_meta` row) is not drift. Read-only verbs keep the advisory (warn on an older DB or a fingerprint mismatch, refuse on a newer or different-major DB) instead of rebuilding. See [`db-schema.md` §Schema drift (pre-1.0)](./db-schema.md#schema-drift-pre-10).
320
+ **Schema-drift rebuild (pre-1.0)**: before persisting, `sm scan`, `sm watch`, and `sm serve` (before listening) detect schema drift on two axes: recorded `scan_meta.scanned_by_version` against the running CLI (a minor or major difference is drift; patch-level is compatible), AND recorded `scan_meta.schema_fingerprint` against the fingerprint recomputed from the bundled migration DDL (any mismatch, or a NULL stored value from a pre-fingerprint DB, is drift). The fingerprint axis catches an inline `001_initial.sql` column add within the same `major.minor` the version axis cannot see. When either trips, the local cache predates a schema change, so the DB is deleted and rebuilt from scratch by this run (`.sm` sidecars untouched, the source of truth). On an interactive terminal the rebuild is confirmed first (`sm scan` rebuilds on the next persist; `sm serve` prompts before booting, aborts with a nonzero exit if declined); `--yes` (and every non-interactive caller: piped stdin, CI, the BFF scan route, the watcher) rebuilds without prompting. Declining aborts (exit `2`) without deleting anything. A DB never scanned (no `scan_meta` row) is not drift. Read-only verbs keep the advisory (warn on an older DB or fingerprint mismatch, refuse on a newer or different-major DB) instead of rebuilding. See [`db-schema.md` §Schema drift (pre-1.0)](./db-schema.md#schema-drift-pre-10).
324
321
 
325
- Exit: 0 on clean (or clean watcher shutdown), 1 if error-severity issues exist (one-shot scan only, the watcher does not flip exit code based on per-batch issues), 2 on operational error.
322
+ Exit: 0 on clean (or clean watcher shutdown), 1 if error-severity issues exist (one-shot scan only; the watcher does not flip exit code on per-batch issues), 2 on operational error.
326
323
 
327
324
  ---
328
325
 
@@ -331,14 +328,14 @@ Exit: 0 on clean (or clean watcher shutdown), 1 if error-severity issues exist (
331
328
  | Command | Purpose |
332
329
  |---|---|
333
330
  | `sm list [--kind <k>] [--issue] [--sort-by ...] [--limit N]` | Tabular listing. `--json` emits an array conforming to `node.schema.json`. |
334
- | `sm show <node.path>` | Node detail: weight (tokens triple-split), frontmatter, links in/out, issues, findings, summary. `--json` emits a detail object with the raw link rows. Pretty output groups identical-shape links (same endpoint, kind, normalized trigger) onto one line and lists the union of extractor ids in a `sources:` field; the section header reports both the raw row count and the unique-after-grouping count, e.g. `Links out (12, 9 unique)`. Storage keeps one row per extractor (`scan_links` is unchanged), the grouping is purely a read-time presentation choice. |
335
- | `sm check [-n <node.path>] [--analyzers <ids>] [--include-prob] [--async]` | Print all current issues. Equivalent to `sm scan --json \| jq '.issues'` but faster (reads from DB). `-n` restricts to issues whose `nodeIds` include the path; `--analyzers <ids>` accepts a comma-separated list of qualified or short analyzer ids and restricts the issue read accordingly. Default behaviour is deterministic-only (CI-safe, status quo). `--include-prob` is the opt-in flag for probabilistic Analyzer dispatch (spec § A.7): the verb loads the plugin runtime, finds Analyzers with `mode === 'probabilistic'` (filtered by `--analyzers` if set), and emits a stderr advisory naming the analyzer ids. Full prob dispatch requires the job subsystem (Step 10); until then `--include-prob` is a stub, prob analyzers never produce issues, never alter the exit code, and `--async` (reserved companion: returns job ids without waiting once jobs land) is a no-op the advisory simply mentions. The flag does NOT extend to `sm scan` or `sm list`. |
331
+ | `sm show <node.path>` | Node detail: weight (tokens triple-split), frontmatter, links in/out, issues, findings, summary. `--json` emits a detail object with the raw link rows. Pretty output groups identical-shape links (same endpoint, kind, normalized trigger) onto one line and lists the union of extractor ids in a `sources:` field; the section header reports both the raw row count and the unique-after-grouping count, e.g. `Links out (12, 9 unique)`. Storage keeps one row per extractor (`scan_links` unchanged); grouping is purely read-time presentation. |
332
+ | `sm check [-n <node.path>] [--analyzers <ids>] [--include-prob] [--async]` | Print all current issues. Equivalent to `sm scan --json \| jq '.issues'` but faster (reads from DB). `-n` restricts to issues whose `nodeIds` include the path; `--analyzers <ids>` accepts a comma-separated list of qualified or short analyzer ids and restricts the issue read. Default is deterministic-only (CI-safe). `--include-prob` is the opt-in flag for probabilistic Analyzer dispatch (spec § A.7): the verb loads the plugin runtime, finds Analyzers with `mode === 'probabilistic'` (filtered by `--analyzers` if set), and emits a stderr advisory naming the analyzer ids. Full prob dispatch requires the job subsystem (Step 10); until then `--include-prob` is a stub, prob analyzers never produce issues or alter the exit code, and `--async` (reserved companion: returns job ids without waiting once jobs land) is a no-op the advisory mentions. The flag does NOT extend to `sm scan` or `sm list`. |
336
333
  | `sm findings [--kind ...] [--since ...] [--threshold <n>]` | Probabilistic findings (injection, stale summaries, low confidence). `--json` emits an array of finding objects. |
337
334
  | `sm graph [--format ascii\|mermaid\|dot\|json]` | Render the full graph via the named formatter. `--format json` is the built-in JSON formatter: stringifies the persisted `ScanResult` ([`scan-result.schema.json`](./schemas/scan-result.schema.json)), byte-equivalent to `sm scan --json` modulo whitespace. The global `--json` flag is ignored on `sm graph` (formats are picked via `--format`, never via the global flag). |
338
335
  | `sm export <query> --format json\|md\|mermaid` | Filtered export. Query syntax is implementation-defined pre-1.0. |
339
336
  | `sm orphans` | History rows whose target node is missing. |
340
337
  | `sm orphans reconcile <orphan.path> --to <new.path>` | Migrate history rows from the old path to the new one after a rename. Use case: the scan's rename heuristic missed a match (semantic-only rename, body rewrite) and the user wants to stitch history manually. |
341
- | `sm orphans undo-rename <new.path> [--from <old.path>] [--force]` | Reverse a medium- or ambiguous-confidence auto-rename. Requires an active `auto-rename-medium` or `auto-rename-ambiguous` issue on `<new.path>`. For `auto-rename-medium`, omit `--from`, the previous path is read from `issue.data_json`. For `auto-rename-ambiguous`, `--from <old.path>` is REQUIRED to pick one of the candidates listed in `data_json.candidates`. Migrates `state_*` FKs back and resolves the issue; the previous path becomes an `orphan` (its file no longer exists in FS). Destructive; prompts for confirmation unless `--force`. Exit `5` if no active auto-rename issue targets `<new.path>`, or if `--from` references a path not in `data_json.candidates`. |
338
+ | `sm orphans undo-rename <new.path> [--from <old.path>] [--force]` | Reverse a medium- or ambiguous-confidence auto-rename. Requires an active `auto-rename-medium` or `auto-rename-ambiguous` issue on `<new.path>`. For `auto-rename-medium`, omit `--from` (the previous path is read from `issue.data_json`). For `auto-rename-ambiguous`, `--from <old.path>` is REQUIRED to pick one of the candidates in `data_json.candidates`. Migrates `state_*` FKs back and resolves the issue; the previous path becomes an `orphan` (its file no longer exists in FS). Destructive; prompts for confirmation unless `--force`. Exit `5` if no active auto-rename issue targets `<new.path>`, or if `--from` references a path not in `data_json.candidates`. |
342
339
 
343
340
  ---
344
341
 
@@ -353,20 +350,20 @@ Actions are not invoked via `sm actions`; invocation is via `sm job submit` (see
353
350
 
354
351
  #### Sidecar bump (Step 9.6.4)
355
352
 
356
- The built-in deterministic `core/node-bump` Action is the canonical write channel for `<basename>.sm` annotation sidecars; the verbs below are its CLI surface plus a few sidecar-management helpers. The `bump` verb stays top-level (high frequency, ROADMAP-named); the administrative helpers live under the `sm sidecar` sub-namespace to avoid colliding with the existing `sm refresh` (which targets the enrichment layer, not sidecars). All sidecar-touching verbs are deterministic, they invoke `core/node-bump` (or the `FilesystemSidecarStore` directly) in-process and never queue jobs.
353
+ The built-in deterministic `core/node-bump` Action is the canonical write channel for `<basename>.sm` annotation sidecars; the verbs below are its CLI surface plus a few sidecar-management helpers. The `bump` verb stays top-level (high frequency, ROADMAP-named); the administrative helpers live under the `sm sidecar` sub-namespace to avoid colliding with `sm refresh` (which targets the enrichment layer, not sidecars). All sidecar-touching verbs are deterministic: they invoke `core/node-bump` (or `FilesystemSidecarStore` directly) in-process and never queue jobs.
357
354
 
358
355
  | Command | Purpose |
359
356
  |---|---|
360
- | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/node-bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy`; on first creation also `audit.createdAt` + `audit.createdBy`). The `by` fields carry the Git author name (`git config user.name`) when the project is a Git repository, otherwise the channel literal `'cli'`. Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes in this project, see §`.sm` write consent below. |
361
- | `sm bump --pending [--staged] [--force] [--yes]` | Batch bump. Walks every node whose sidecar overlay reports drift in `node.path` ASC order and bumps each. `--staged` runs `git add <sidecar-path>` after each successful bump so the new content lands in the same commit; `git add` failure degrades to a stderr warning, the batch keeps running. Empty stale set → exit `0` with a "nothing to do" advisory. `--json` envelope: `{ bumped, refused, skipped, errors[], elapsedMs }`. Exit `0` on a clean run; `1` when at least one per-node error landed in `errors[]`. **Git error matrix for `--staged`**: not inside a git repo (no `.git/` parent of `cwd`) → exit `5`; `git` binary not on PATH (spawn ENOENT) → exit `2`. Both checks run BEFORE any sidecar write so a misconfigured environment never produces partial state. `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
362
- | `sm sidecar refresh <node.path> [--yes]` | Hash-only update on the sidecar. Refreshes `for.{bodyHash, frontmatterHash}` to match the live node WITHOUT bumping `annotations.version` and WITHOUT touching the audit block. Useful when the user knows a body change is editorial-only and doesn't want to spend a version increment. Distinct from the top-level `sm refresh` (which targets the enrichment layer at Step A.8), different storage, different concept; the sub-namespace prefix prevents the collision. Exit `5` if the node has no sidecar or is not in the persisted scan. No-op on a fresh node (informational stderr, exit `0`). `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
363
- | `sm sidecar prune [--dry-run] [--yes]` | Delete orphan `.sm` files (sidecars whose accompanying `<basename>.md` does not exist on disk). Destructive, without `--dry-run` prompts for interactive confirmation listing every file to be deleted (per the §Dry-run analyzer for destructive verbs). `--yes` (alias `--force`) bypasses the destructive-confirmation prompt for non-interactive callers (CI, the pre-commit hook, scripts), NOT to be confused with the `.sm` write-consent gate, which does not apply to prune (delete is not a write). With `--dry-run` reports what would be deleted without touching disk and never prompts. Different domain from `sm orphans`, that verb operates on the node graph (rename heuristic); this one operates on the filesystem layer. `--json` envelope: `{ deleted, wouldDelete, errors, items[], elapsedMs }`. Exit `1` when delete failures landed in `errors`. |
357
+ | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/node-bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force`; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy`; on first creation also `audit.createdAt` + `audit.createdBy`). The `by` fields carry the Git author name (`git config user.name`) when the project is a Git repository, else the channel literal `'cli'`. Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
358
+ | `sm bump --pending [--staged] [--force] [--yes]` | Batch bump. Walks every node whose sidecar overlay reports drift in `node.path` ASC order and bumps each. `--staged` runs `git add <sidecar-path>` after each successful bump so the content lands in the same commit; `git add` failure degrades to a stderr warning, the batch keeps running. Empty stale set → exit `0` with a "nothing to do" advisory. `--json` envelope: `{ bumped, refused, skipped, errors[], elapsedMs }`. Exit `0` on a clean run; `1` when at least one per-node error landed in `errors[]`. **Git error matrix for `--staged`**: not inside a git repo (no `.git/` parent of `cwd`) → exit `5`; `git` binary not on PATH (spawn ENOENT) → exit `2`. Both checks run BEFORE any sidecar write so a misconfigured environment never produces partial state. `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
359
+ | `sm sidecar refresh <node.path> [--yes]` | Hash-only update on the sidecar. Refreshes `for.{bodyHash, frontmatterHash}` to match the live node WITHOUT bumping `annotations.version` or touching the audit block. Useful when a body change is editorial-only and the user doesn't want a version increment. Distinct from top-level `sm refresh` (enrichment layer, Step A.8): different storage, different concept; the sub-namespace prefix prevents the collision. Exit `5` if the node has no sidecar or is not in the persisted scan. No-op on a fresh node (informational stderr, exit `0`). `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
360
+ | `sm sidecar prune [--dry-run] [--yes]` | Delete orphan `.sm` files (sidecars whose `<basename>.md` does not exist on disk). Destructive; without `--dry-run` prompts for interactive confirmation listing every file to be deleted (per the §Dry-run analyzer for destructive verbs). `--yes` (alias `--force`) bypasses the destructive-confirmation prompt for non-interactive callers (CI, the pre-commit hook, scripts), NOT the `.sm` write-consent gate (delete is not a write). With `--dry-run` reports what would be deleted without touching disk and never prompts. Different domain from `sm orphans` (node graph, rename heuristic); this operates on the filesystem layer. `--json` envelope: `{ deleted, wouldDelete, errors, items[], elapsedMs }`. Exit `1` when delete failures landed in `errors`. |
364
361
  | `sm sidecar annotate <node.path> [--force] [--yes]` | Pure scaffolding. Writes a minimal `.sm` next to the `.md` with the `identity:` block populated and an empty `annotations: {}` block, ready for editing. Refuses if the file exists; `--force` overwrites. The optional legacy-frontmatter migration helper (`--from-frontmatter`) is deferred, no released consumer demands it. `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
365
- | `sm hooks install pre-commit-bump [--dry-run]` | Install (or chain into) a git pre-commit hook that runs `sm bump --pending --staged` so any staged drift in `.sm` sidecars auto-bumps before the commit lands. Idempotent: re-running detects the skill-map marker and no-ops. When the repo already has a custom `pre-commit`, the verb appends the skill-map block to the existing file rather than replacing it. `--dry-run` prints the planned content with `--- target: <path> ---` markers and writes nothing. Exit `5` if no `.git/` parent is found at or above `cwd`; exit `2` on write failures or unknown hook flavours. |
362
+ | `sm hooks install pre-commit-bump [--dry-run]` | Install (or chain into) a git pre-commit hook that runs `sm bump --pending --staged` so staged drift in `.sm` sidecars auto-bumps before the commit lands. Idempotent: re-running detects the skill-map marker and no-ops. When the repo already has a custom `pre-commit`, the verb appends the skill-map block rather than replacing it. `--dry-run` prints the planned content with `--- target: <path> ---` markers and writes nothing. Exit `5` if no `.git/` parent is found at or above `cwd`; exit `2` on write failures or unknown hook flavours. |
366
363
 
367
- **`.sm` round-trip contract.** The `bump` verb, `sm sidecar refresh`, and `sm sidecar annotate` write through `FilesystemSidecarStore`, which re-serialises the merged result via `js-yaml` `dump` with `sortKeys: true`. **`.sm` files are managed artifacts; comments and key order are not preserved on round-trip.** Author commentary belongs in the markdown body or in a separate documentation file, not inside `.sm`. The integrity guarantee is that the merged YAML always validates against `sidecar.schema.json` + `annotations.schema.json` and that the file is written atomically (`.tmp + rename`).
364
+ **`.sm` round-trip contract.** The `bump` verb, `sm sidecar refresh`, and `sm sidecar annotate` write through `FilesystemSidecarStore`, which re-serialises the merged result via `js-yaml` `dump` with `sortKeys: true`. **`.sm` files are managed artifacts; comments and key order are not preserved on round-trip.** Author commentary belongs in the markdown body or a separate doc, not inside `.sm`. The integrity guarantee: the merged YAML always validates against `sidecar.schema.json` + `annotations.schema.json` and is written atomically (`.tmp + rename`).
368
365
 
369
- Concretely, a hand-edited sidecar like this:
366
+ A hand-edited sidecar like this:
370
367
 
371
368
  ```yaml
372
369
  identity:
@@ -404,13 +401,13 @@ identity:
404
401
  path: agents/reviewer.md
405
402
  ```
406
403
 
407
- Comments dropped, keys re-sorted alphabetically. **`.sm` files cannot preserve free-form commentary across bumps, narrative documentation lives in the `.md` body, which is never touched.** The `sm sidecar annotate` scaffold prints a banner reminding the author of this contract on first creation; that banner itself is dropped on the first bump.
404
+ Comments dropped, keys re-sorted alphabetically. The `sm sidecar annotate` scaffold prints a banner reminding the author of this contract on first creation; that banner itself is dropped on the first bump.
408
405
 
409
- Tracked as **R6** in the §Step 9.6 review queue: open by design, defer the `js-yaml` → `yaml` (eemeli) swap that would preserve comments + key order until a user complaint surfaces. The swap is a small piece of work (one new dep, one Document-aware merge helper); the bias is to ship simple now and add fidelity when there is concrete demand.
406
+ Tracked as **R6** in the §Step 9.6 review queue: open by design, defer the `js-yaml` → `yaml` (eemeli) swap that would preserve comments + key order until a user complaint surfaces.
410
407
 
411
408
  ##### BFF endpoint, `POST /api/sidecar/bump` (Step 9.6.5, BFF half)
412
409
 
413
- The Hono BFF exposes the single-node bump flow over REST so the UI can drive the same Action / Store the CLI uses. Behaviour mirrors `sm bump <node.path> [--force]` 1:1: same `core/node-bump` Action, same `FilesystemSidecarStore`, same fresh-vs-stale refusal semantics. The only differences from the CLI verb are the invoker channel fallback (`'ui'` vs `'cli'`, used only when no Git author resolves) and the wire shape. This supersedes Decision A5 of 9.6.4 (which left the invoker a literal): both routes now stamp the Git `user.name` when the project is a Git repository, falling back to the channel literal otherwise. Batch (`--pending`) is intentionally CLI-only at 9.6.5, surfacing it over REST needs a job-style progress channel and lands later.
410
+ The Hono BFF exposes the single-node bump flow over REST so the UI can drive the same Action / Store the CLI uses. Behaviour mirrors `sm bump <node.path> [--force]` 1:1: same `core/node-bump` Action, `FilesystemSidecarStore`, fresh-vs-stale refusal semantics. The only differences are the invoker channel fallback (`'ui'` vs `'cli'`, used only when no Git author resolves) and the wire shape. Supersedes Decision A5 of 9.6.4 (which left the invoker a literal): both routes now stamp the Git `user.name` when the project is a Git repository, else the channel literal. Batch (`--pending`) is intentionally CLI-only at 9.6.5; surfacing it over REST needs a job-style progress channel and lands later.
414
411
 
415
412
  | Field | Value |
416
413
  |---|---|
@@ -421,7 +418,7 @@ The Hono BFF exposes the single-node bump flow over REST so the UI can drive the
421
418
  | 412 envelope | `{ "ok": false, "error": { "code": "confirm-required", "message": <string>, "details": { "key": "allowEditSmFiles" } } }`. Returned when `allowEditSmFiles` is `false` and `confirm !== true`. The UI catches this and opens a ConfirmDialog; on accept it retries the POST with `{ ..., "confirm": true }`, the kernel then persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` and performs the bump. See §`.sm` write consent below. |
422
419
  | 404 envelope | Standard `'not-found'` envelope. Returned when the DB is missing OR `nodePath` is not in the persisted scan. |
423
420
  | 400 envelope | Standard `'bad-query'` envelope. Body must be a JSON object with `nodePath` (non-empty string); `force` / `confirm` (when present) must be booleans. |
424
- | 200 force-on-fresh | Per the Action spec, `force: true` on a fresh node is a silent no-op, the response carries the existing `version` (read off the sidecar overlay) and `status: 'fresh'`. **No WS broadcast** is emitted in this case (decision: no-op = no event; nothing changed on disk, sending `sidecar.bumped` would tell every connected UI to refresh state that hasn't moved). |
421
+ | 200 force-on-fresh | Per the Action spec, `force: true` on a fresh node is a silent no-op; the response carries the existing `version` (read off the sidecar overlay) and `status: 'fresh'`. **No WS broadcast** in this case (no-op = no event; nothing changed on disk, sending `sidecar.bumped` would tell every connected UI to refresh state that hasn't moved). |
425
422
 
426
423
  **WS event, `sidecar.bumped`** (Step 9.6.5; canonical envelope shape locked in 9.6.7 / R9). After every successful bump that materialises a write, the BFF broadcasts a `sidecar.bumped` event over `/ws` so all connected clients refresh in lockstep. The event uses the canonical `IWsEventEnvelope` wire shape (matches every other kernel→broadcaster bridge, `scan.*`, `watcher.*`, etc.):
427
424
 
@@ -443,34 +440,34 @@ Emission analyzers:
443
440
  - **NOT** emitted on a force-on-fresh no-op 200 (nothing changed on disk).
444
441
  - **NOT** emitted on 409 / 404 / 400 (no write happened).
445
442
 
446
- The `type` value is a normative addition to the event-type registry, if a future spec section catalogues every WS event type, `sidecar.bumped` joins `scan.started` / `scan.completed` / `watcher.error` / `emitter.error` there.
443
+ The `type` value is a normative addition to the event-type registry; if a future spec section catalogues every WS event type, `sidecar.bumped` joins `scan.started` / `scan.completed` / `watcher.error` / `emitter.error` there.
447
444
 
448
445
  ##### BFF endpoint, `GET /api/annotations/registered` (Step 9.6.6, BFF half)
449
446
 
450
- Read-only catalog of plugin-contributed annotation keys. The endpoint is a pure projection of `kernel.getRegisteredAnnotationKeys()`, populated once by `registerEnabledExtensions` at server boot, frozen, surfaced unchanged. Built-in catalog keys (from `annotations.schema.json`) are NOT included; the UI knows the built-in set via the bundled spec. The endpoint exists so a future UI autocomplete can offer plugin-namespaced and root-exclusive contributions the UI can't otherwise discover at runtime.
447
+ Read-only catalog of plugin-contributed annotation keys. A pure projection of `kernel.getRegisteredAnnotationKeys()`, populated once by `registerEnabledExtensions` at server boot, frozen, surfaced unchanged. Built-in catalog keys (from `annotations.schema.json`) are NOT included; the UI knows the built-in set via the bundled spec. The endpoint exists so a future UI autocomplete can offer plugin-namespaced and root-exclusive contributions the UI can't otherwise discover at runtime.
451
448
 
452
449
  | Field | Value |
453
450
  |---|---|
454
451
  | Method + path | `GET /api/annotations/registered` |
455
452
  | Request | None, no query params, no body, no auth (matches `/api/plugins`, `/api/config`). |
456
453
  | 200 envelope | `{ "schemaVersion": "1", "kind": "annotations.registered", "items": IRegisteredAnnotationKey[], "counts": { "total": <int> } }`. The `kind` value is part of the canonical `rest-envelope.schema.json#/properties/kind/enum` and validates under the catalog `oneOf` variant (`items` + `counts.total` only, no `filters` / `kindRegistry` / `returned`, the catalog ships in its entirety on every response and does not paginate). |
457
- | Item shape | `IRegisteredAnnotationKey` per `src/kernel/types/annotation-catalog.ts`: `{ pluginId: string, key: string, location: 'namespaced' \| 'root', ownership: 'exclusive' \| 'shared', schema: Record<string, unknown> }`. The inline JSON Schema as declared in the contributing plugin's manifest (NOT the AJV-compiled validator). |
458
- | Invariants | Read-only, no side effects, never throws after kernel boot. The catalog is small (typically 0–50 entries); no pagination, no filters, no caching headers. Mutating the returned `items` array does not affect subsequent calls, the kernel's view is frozen. |
459
- | Empty case | When the kernel was booted with no plugin contributions (or `--no-plugins`): `{ "items": [], "counts": { "total": 0 } }`. |
460
- | Refresh policy | Same as the rest of the BFF's plugin surface, discovery happens once at `sm serve` boot. An operator that installs a new plugin restarts the server (matches the watcher's "loaded ONCE at boot" contract). |
454
+ | Item shape | `IRegisteredAnnotationKey` per `src/kernel/types/annotation-catalog.ts`: `{ pluginId: string, key: string, location: 'namespaced' \| 'root', ownership: 'exclusive' \| 'shared', schema: Record<string, unknown> }`. The inline JSON Schema declared in the contributing plugin's manifest (NOT the AJV-compiled validator). |
455
+ | Invariants | Read-only, no side effects, never throws after kernel boot. Catalog small (typically 0–50 entries); no pagination, filters, or caching headers. Mutating the returned `items` array does not affect subsequent calls; the kernel's view is frozen. |
456
+ | Empty case | Booted with no plugin contributions (or `--no-plugins`): `{ "items": [], "counts": { "total": 0 } }`. |
457
+ | Refresh policy | Discovery happens once at `sm serve` boot. Installing a new plugin requires a server restart (matches the watcher's "loaded ONCE at boot" contract). |
461
458
 
462
459
  ##### `.sm` write consent
463
460
 
464
461
  Every verb in this section that writes `.sm` (the `bump` table rows, `sm sidecar refresh`, `sm sidecar annotate`, and the BFF's `POST /api/sidecar/bump`) consults the `allowEditSmFiles` setting (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality) and §Annotation system · Write consent). Behaviour:
465
462
 
466
- - **`allowEditSmFiles === true`**, the verb proceeds silently. No prompt, no flag mutation, identical to the pre-consent behaviour.
467
- - **`allowEditSmFiles === false` and the operator passes `--yes` (CLI) or `{ "confirm": true }` (BFF body)**, the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (gitignored) and proceeds with the write. The flag flip is durable; the next invocation will not re-ask.
463
+ - **`allowEditSmFiles === true`**, the verb proceeds silently. No prompt, no flag mutation, identical to pre-consent behaviour.
464
+ - **`allowEditSmFiles === false` and the operator passes `--yes` (CLI) or `{ "confirm": true }` (BFF body)**, the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (gitignored) and proceeds. The flag flip is durable; the next invocation won't re-ask.
468
465
  - **`allowEditSmFiles === false` and the operator did NOT confirm**:
469
- - **CLI on a TTY**, the verb prints a one-paragraph explanation of what `.sm` files are and where they will land, then runs an interactive `confirm()` prompt. Accept proceeds (same effect as `--yes`); decline aborts the verb without persisting the rejection (exit `2`, the verb's reported `errors[]` carries one entry with code `confirm-required`). The next invocation re-asks; declining is never "remembered".
466
+ - **CLI on a TTY**, the verb prints a one-paragraph explanation of what `.sm` files are and where they land, then runs an interactive `confirm()` prompt. Accept proceeds (same as `--yes`); decline aborts without persisting the rejection (exit `2`, the verb's `errors[]` carries one entry with code `confirm-required`). The next invocation re-asks; declining is never "remembered".
470
467
  - **CLI without a TTY** (CI, piped stdin, agent harness), the verb exits `2` immediately with a stderr message: `consent required: pass --yes to allow .sm sidecars in this project (writes to .skill-map/settings.local.json, gitignored)`.
471
- - **BFF**, the route returns 412 `confirm-required` (envelope shown in the bump-endpoint table above). The UI catches the code and opens a `ConfirmationService.confirm({ ... })` dialog; on accept it retries the original request with `{ "confirm": true }`; on reject the action is silently abandoned (no toast spam, the user opted out).
468
+ - **BFF**, the route returns 412 `confirm-required` (envelope in the bump-endpoint table above). The UI catches the code and opens a `ConfirmationService.confirm({ ... })` dialog; on accept it retries with `{ "confirm": true }`; on reject the action is silently abandoned (no toast spam, the user opted out).
472
469
 
473
- `sm sidecar prune --yes` is unaffected: `--yes` on `prune` bypasses the destructive-delete confirmation prompt (the verb does not write `.sm`; it deletes orphans). The two flags share a spelling but address orthogonal concerns.
470
+ `sm sidecar prune --yes` is unaffected: `--yes` on `prune` bypasses the destructive-delete confirmation prompt (the verb deletes orphans, does not write `.sm`). Same spelling, orthogonal concerns.
474
471
 
475
472
  ---
476
473
 
@@ -510,7 +507,7 @@ sm record --id <job.id> --nonce <n> --status completed \
510
507
  --model <name>
511
508
  ```
512
509
 
513
- Closes a running job with success. `--report` accepts either a filesystem path the kernel reads, or `-` to read the JSON payload from stdin. The kernel stores the parsed JSON inline on `state_executions.report_json`; the path / stdin source is ingestion-only and not retained.
510
+ Closes a running job with success. `--report` accepts a filesystem path the kernel reads, or `-` to read the JSON payload from stdin. The kernel stores the parsed JSON inline on `state_executions.report_json`; the path / stdin source is ingestion-only, not retained.
514
511
 
515
512
  ```
516
513
  sm record --id <job.id> --nonce <n> --status failed --error "..."
@@ -541,7 +538,7 @@ Authentication: the nonce is the sole credential. An implementation MUST reject
541
538
  | `sm plugins show <plugin>/<ext>` | Single-extension detail (Kind / Version / Stability / Description / Preconditions / Entry; `--json` emits the single extension object). Accepts only a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. |
542
539
  | `sm plugins enable <id>... \| --all` | Toggle on. Persists in `config_plugins`. Accepts one or more ids; batches are all-or-nothing (any unknown / mismatched id aborts before any write) and repeated ids are deduped. `--all` applies to every discovered plugin. |
543
540
  | `sm plugins disable <id>... \| --all` | Toggle off; does not delete the plugin directory. Eagerly purges each id's rows from `scan_contributions` so its UI chips disappear before the next scan (plugin-managed state in `state_plugin_kvs` / dedicated tables is preserved, see `plugin-kv-api.md`). Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
544
- | `sm plugins config <plugin>/<ext> [<settingId> [<value>]] [--reset]` | Read or write the operator-supplied values for an extension's declared `settings`. No `settingId`: table of each declared setting with its effective value and the layer that set it (`--json` emits the resolved set). With `<settingId> <value>`: coerce the shell string to the setting's input-type, validate, then write it under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` (a normal setting lands in `settings.json`; a `secret`-typed one is forced into `settings.local.json`, gitignored, never committed); prints a "re-scan to apply" reminder. `--reset` drops the override back to the manifest default. Requires a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. `secret` values are redacted as `<redacted>` in output. |
541
+ | `sm plugins config <plugin>/<ext> [<settingId> [<value>]] [--reset]` | Read or write the operator-supplied values for an extension's declared `settings`. No `settingId`: table of each declared setting with its effective value and the layer that set it (`--json` emits the resolved set). With `<settingId> <value>`: coerce the shell string to the setting's input-type, validate, then write under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` (a normal setting lands in `settings.json`; a `secret`-typed one is forced into `settings.local.json`, gitignored, never committed); prints a "re-scan to apply" reminder. `--reset` drops the override back to the manifest default. Requires a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. `secret` values are redacted as `<redacted>` in output. |
545
542
  | `sm plugins doctor` | Revalidate all plugins against current spec version; update `status` fields. `--json` emits the report shape declared by [`plugins-doctor.schema.json`](./schemas/plugins-doctor.schema.json): `{ ok: true, kind: 'plugins.doctor', counts, issues[], warnings[], elapsedMs }`. |
546
543
 
547
544
  ---
@@ -562,7 +559,7 @@ See `db-schema.md` for the table catalog.
562
559
  | `sm db dump [--tables ...]` | SQL dump. |
563
560
  | `sm db migrate [--dry-run \| --status \| --to <n> \| --kernel-only \| --plugin <id> \| --no-backup]` | Migration controls. |
564
561
 
565
- Destructive verbs (`reset --state`, `reset --hard`, `restore`) require interactive confirmation unless `--yes` (non-interactive mode for scripts) or `--force` (alias, kept for backward compatibility) is passed. `sm db reset` without a modifier is non-destructive and never prompts. **`--dry-run` short-circuits the confirmation prompt entirely** (per §Dry-run analyzer: dry-run MUST NOT depend on `--yes` / `--force`).
562
+ Destructive verbs (`reset --state`, `reset --hard`, `restore`) require interactive confirmation unless `--yes` (non-interactive mode for scripts) or `--force` (alias, kept for backward compatibility) is passed. `sm db reset` without a modifier is non-destructive and never prompts. **`--dry-run` short-circuits the confirmation prompt** (per §Dry-run analyzer: dry-run MUST NOT depend on `--yes` / `--force`).
566
563
 
567
564
  ---
568
565
 
@@ -570,56 +567,56 @@ Destructive verbs (`reset --state`, `reset --hard`, `restore`) require interacti
570
567
 
571
568
  | Command | Purpose |
572
569
  |---|---|
573
- | `sm serve [--port N] [--host ...] [--db <path>] [--no-built-ins] [--no-plugins] [--open\|--no-open] [--dev-cors] [--ui-dist <path>] [--no-ui] [--no-watcher]` | Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener. Default port 4242, default host 127.0.0.1 (loopback-only through v0.6.0; multi-host deferred, see §Server). The watcher is on by default (Decision #121: a server with stale DB is a footgun); pass `--no-watcher` for CI / read-only deployments. `--no-ui` skips the SPA bundle (dev workflow alongside the Angular dev server); see §Server flags. |
570
+ | `sm serve [--port N] [--host ...] [--db <path>] [--no-built-ins] [--no-plugins] [--open\|--no-open] [--dev-cors] [--ui-dist <path>] [--no-ui] [--no-watcher]` | Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener. Default port 4242, default host 127.0.0.1 (loopback-only through v0.6.0; multi-host deferred, see §Server). Watcher on by default (Decision #121: a server with stale DB is a footgun); pass `--no-watcher` for CI / read-only deployments. `--no-ui` skips the SPA bundle (dev workflow alongside the Angular dev server); see §Server flags. |
574
571
 
575
572
  #### Server
576
573
 
577
574
  *(Stability: experimental, locks at v0.6.0.)*
578
575
 
579
- The reference implementation ships a Hono BFF rooted at `src/server/`. One Node process serves the Angular SPA, the REST API under `/api/*`, and the WebSocket at `/ws`, single-port mandate, no proxy. Loopback-only assumption through v0.6.0: no per-connection auth on `/ws`; combining `--dev-cors` with a non-loopback `--host` is rejected (exit 2).
576
+ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node process serves the Angular SPA, the REST API under `/api/*`, and the WebSocket at `/ws`, single-port, no proxy. Loopback-only through v0.6.0: no per-connection auth on `/ws`; combining `--dev-cors` with a non-loopback `--host` is rejected (exit 2).
580
577
 
581
- **Host + Origin gate.** Every request runs through a first-stage middleware before any route handler. Two invariants are enforced, with the canonical error envelope `403` + `{ ok: false, error: { code: 'host-not-allowed' | 'origin-not-allowed', message: <terse>, details: null } }` on violation. The gate stays opaque to probes (no per-request state leaked in `details`); the discriminator lives in `error.code` and matches the canonical envelope shared by every other `/api/*` error.
578
+ **Host + Origin gate.** Every request runs through a first-stage middleware before any route handler. Two invariants are enforced, with the canonical error envelope `403` + `{ ok: false, error: { code: 'host-not-allowed' | 'origin-not-allowed', message: <terse>, details: null } }` on violation. The gate stays opaque to probes (no per-request state in `details`); the discriminator lives in `error.code` and matches the canonical envelope shared by every other `/api/*` error.
582
579
 
583
- 1. **`Host` header hostname**, must be a loopback name (`127.0.0.1`, `localhost`, `::1`); the port half is ignored. Closes the DNS-rebinding lane where a malicious page in the operator's own browser resolves an attacker-controlled hostname to 127.0.0.1 and the server would otherwise accept the request. The hostname is what DNS rebinding flips; port pinning adds no extra defence and would break ephemeral test ports and operator-overridden ports. Missing `Host` (legacy HTTP/1.0) is tolerated.
584
- 2. **`Origin` header hostname**, enforced only on `/api/*` and `/ws`. Missing / empty / `null` (sandboxed or `file://`) is accepted; otherwise the origin's hostname must be loopback and its scheme `http` / `https`. Cross-origin attacker domains, non-HTTP schemes (`file://`), and malformed origins are rejected. Same port-agnostic posture as the Host gate, so a Vite dev UI on a different loopback port passes without `--dev-cors`. Static-asset requests (e.g. `/`, `/index.html`) skip the Origin check because they carry no Origin in normal navigation and the bundle is the public surface.
580
+ 1. **`Host` header hostname**, must be a loopback name (`127.0.0.1`, `localhost`, `::1`); the port half is ignored. Closes the DNS-rebinding lane where a malicious page in the operator's own browser resolves an attacker-controlled hostname to 127.0.0.1 and the server would otherwise accept. The hostname is what DNS rebinding flips; port pinning adds no defence and would break ephemeral test ports and operator-overridden ports. Missing `Host` (legacy HTTP/1.0) is tolerated.
581
+ 2. **`Origin` header hostname**, enforced only on `/api/*` and `/ws`. Missing / empty / `null` (sandboxed or `file://`) is accepted; otherwise the origin's hostname must be loopback and its scheme `http` / `https`. Cross-origin attacker domains, non-HTTP schemes (`file://`), and malformed origins are rejected. Same port-agnostic posture as the Host gate, so a Vite dev UI on a different loopback port passes without `--dev-cors`. Static-asset requests (e.g. `/`, `/index.html`) skip the Origin check: they carry no Origin in normal navigation and the bundle is the public surface.
585
582
 
586
- **Boot resilience**: `sm serve` boots even when the project DB is missing. `/api/health` reports `db: 'missing'` so the SPA can render an empty-state CTA instead of failing the connection. Explicit `--db <path>` that doesn't exist is the exception, that exits 5 (NotFound) per `§Exit codes`.
583
+ **Boot resilience**: `sm serve` boots even when the project DB is missing. `/api/health` reports `db: 'missing'` so the SPA renders an empty-state CTA instead of failing the connection. Explicit `--db <path>` that doesn't exist is the exception, exits 5 (NotFound) per `§Exit codes`.
587
584
 
588
585
  **Boot output**: after the listener binds, `sm serve` writes a startup banner to **stderr**. Stdout is reserved for `--json` payloads on other verbs and stays empty here. The banner shape depends on `isTTY(stderr)` and the standard color toggles (`NO_COLOR`, `FORCE_COLOR`, `--no-color`):
589
586
 
590
- - **TTY + color**: an ASCII-art figlet logo split into a violet upper half and a green lower half, a dim version line right-aligned under the logo, then a dim-labelled data block (`Server <url>`, `Path <cwd>`, `DB <path>`) and the `Press Ctrl+C to stop.` hint. The `Path` row shows the cwd the verb is running from; when it sits under the user's home, the prefix is replaced with `~` for legibility. The URL value is rendered in green with an underline. Implementations MAY choose any figlet-style rendering and any palette consistent with the violet-upper / green-lower split; the reference impl uses xterm 256-color codes (`\x1b[38;5;141m` violet, `\x1b[38;5;42m` green) and does NOT degrade to 16-color terminals, users on legacy terminals MUST set `NO_COLOR`.
587
+ - **TTY + color**: an ASCII-art figlet logo split into a violet upper half and a green lower half, a dim version line right-aligned under the logo, then a dim-labelled data block (`Server <url>`, `Path <cwd>`, `DB <path>`) and the `Press Ctrl+C to stop.` hint. The `Path` row shows the running cwd; when under the user's home, the prefix is replaced with `~`. The URL value is green with an underline. Implementations MAY choose any figlet-style rendering and palette consistent with the violet-upper / green-lower split; the reference impl uses xterm 256-color codes (`\x1b[38;5;141m` violet, `\x1b[38;5;42m` green) and does NOT degrade to 16-color terminals, users on legacy terminals MUST set `NO_COLOR`.
591
588
  - **TTY + `NO_COLOR` (or `--no-color`)**: same figlet block + version + data block, with zero ANSI escapes.
592
- - **Non-TTY (pipes / redirects)**: banner suppressed; the verb emits two flat lines, `sm serve: listening on http://<host>:<port> (db=<path>)` followed by `sm serve: opening <url>/ in your browser. Press Ctrl+C to stop.` (or `sm serve: visit <url>/ ...` under `--no-open`). This shape is **stable**; tooling that scrapes those lines (CI capture, `tee log.txt`) MUST keep working across releases.
589
+ - **Non-TTY (pipes / redirects)**: banner suppressed; the verb emits two flat lines, `sm serve: listening on http://<host>:<port> (db=<path>)` then `sm serve: opening <url>/ in your browser. Press Ctrl+C to stop.` (or `sm serve: visit <url>/ ...` under `--no-open`). This shape is **stable**; tooling scraping those lines (CI capture, `tee log.txt`) MUST keep working across releases.
593
590
 
594
591
  **Endpoints (v14.2 surface)**:
595
592
 
596
593
  | Path | Status | Shape |
597
594
  |---|---|---|
598
- | `GET /api/health` | implemented | `{ ok: true, schemaVersion, specVersion, implVersion, db: 'present'\|'missing', cwd: string, dbPath: string }`. `cwd` is the absolute project root the BFF resolves against (`runtimeContext.cwd`); `dbPath` is the absolute project DB path (`IServerOptions.dbPath`). Both are surfaced so the SPA's About panel can show "you are looking at <project>" + the DB location without a second endpoint. |
595
+ | `GET /api/health` | implemented | `{ ok: true, schemaVersion, specVersion, implVersion, db: 'present'\|'missing', cwd: string, dbPath: string }`. `cwd` is the absolute project root the BFF resolves against (`runtimeContext.cwd`); `dbPath` is the absolute project DB path (`IServerOptions.dbPath`). Both surfaced so the SPA's About panel can show "you are looking at <project>" + the DB location without a second endpoint. |
599
596
  | `GET /api/scan` | implemented | latest persisted `ScanResult` (1:1 with `scan-result.schema.json`; byte-equal to `sm scan --json` modulo whitespace). DB absent → empty `ScanResult` shape (zero `nodes` / `links` / `issues`). |
600
597
  | `GET /api/scan?fresh=1` | implemented | runs an in-memory scan and returns the produced `ScanResult` without persistence. Rejects with `bad-query` (400) when the server was started with `--no-built-ins` or `--no-plugins` (would yield empty / partial results). |
601
- | `POST /api/scan` | implemented | Run a fresh scan **and persist it** through the same `runScanWithRenames` + `persistScanResult` pipeline the watcher uses. Body is empty (`{}` or no body). Response: the persisted `ScanResult` inline (same shape as `GET /api/scan`). Side effects: broadcasts `scan.started` then `scan.completed` over `/ws` so other connected clients can refresh, the per-batch sequence is identical to a watcher-driven batch. **Concurrency**: only one scan may run at a time across the whole BFF process. A POST that arrives while a watcher batch is in flight (or while another POST is in flight) is rejected with `409 scan-busy` so the caller can decide whether to retry. **Pipeline gate**: rejected with `400 bad-query` when the server was started with `--no-built-ins` or `--no-plugins` (a partial pipeline would persist a misleading DB the next watcher boot would have to reconcile). **DB gate**: rejected with `500 db-missing` when the project DB file is absent, the read-side `/api/scan` degrades to the empty shape, but a write path cannot, so it fails fast. |
598
+ | `POST /api/scan` | implemented | Run a fresh scan **and persist it** through the same `runScanWithRenames` + `persistScanResult` pipeline the watcher uses. Body empty (`{}` or none). Response: the persisted `ScanResult` inline (same shape as `GET /api/scan`). Side effects: broadcasts `scan.started` then `scan.completed` over `/ws` so other clients refresh; the per-batch sequence is identical to a watcher-driven batch. **Concurrency**: only one scan may run at a time across the whole BFF process. A POST arriving while a watcher batch (or another POST) is in flight is rejected with `409 scan-busy` so the caller can retry. **Pipeline gate**: rejected with `400 bad-query` when the server started with `--no-built-ins` or `--no-plugins` (a partial pipeline would persist a misleading DB the next watcher boot must reconcile). **DB gate**: rejected with `500 db-missing` when the project DB is absent; the read-side `/api/scan` degrades to the empty shape, but a write path cannot, so it fails fast. |
602
599
  | `GET /api/nodes?kind=&hasIssues=&path=&limit=&offset=` | implemented | `RestEnvelope` (`kind: 'nodes'`), paginated, filtered list. Filters share the `kind=` / `has=issues` / `path=<glob>` grammar with `sm export`. `hasIssues=false` is a server-side post-filter (not representable in the kernel grammar). Pagination defaults `offset=0`, `limit=100`; max `limit=1000`. |
603
- | `GET /api/nodes/:pathB64[?include=body]` | implemented | Single-node detail envelope: `{ schemaVersion, kind: 'node', item: Node, links: { incoming: Link[], outgoing: Link[] }, issues: Issue[] }`. `:pathB64` is base64url (RFC 4648 §5, no padding) of `node.path`. Missing node or malformed `pathB64` → 404 `not-found`. **`?include=body`** (Step 14.5.a), opt-in flag that adds `item.body: string \| null` to the response. The body is read from disk on demand at request time (the kernel persists `bodyHash` only). `null` indicates the source file was missing / unreadable when the request landed (the watcher will re-emit `scan.completed` when it catches up). Without the flag, `item.body` is `undefined` and the handler does not touch the filesystem. |
600
+ | `GET /api/nodes/:pathB64[?include=body]` | implemented | Single-node detail envelope: `{ schemaVersion, kind: 'node', item: Node, links: { incoming: Link[], outgoing: Link[] }, issues: Issue[] }`. `:pathB64` is base64url (RFC 4648 §5, no padding) of `node.path`. Missing node or malformed `pathB64` → 404 `not-found`. **`?include=body`** (Step 14.5.a), opt-in flag adding `item.body: string \| null` to the response. The body is read from disk on demand at request time (the kernel persists `bodyHash` only). `null` means the source file was missing / unreadable when the request landed (the watcher re-emits `scan.completed` on catch-up). Without the flag, `item.body` is `undefined` and the handler does not touch the filesystem. |
604
601
  | `GET /api/links?kind=&from=&to=` | implemented | `RestEnvelope` (`kind: 'links'`), list of links. Filters: `kind` (CSV whitelist of `link.kind`), `from` (exact match on `link.source`), `to` (exact match on `link.target`). No pagination at v14.2. |
605
602
  | `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`), list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
606
603
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
607
604
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
608
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row itself has no granular toggle axis; the row's `status` aggregates the children (`'enabled'` when at least one extension is enabled, `'disabled'` otherwise). The `description` field on the plugin item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when the manifest does not declare it; missing means `stable`. The SPA badges only the non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default, where the installed default is `false` for `experimental` and `deprecated` extensions and `true` otherwise), so an experimental or deprecated extension reads `enabled: false` until the operator turns it on. The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, every extension of the plugin was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
609
- | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists one `config_plugins` row per child extension (`<plugin>/<ext>`) inside a single transaction; locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
610
- | `PATCH /api/plugins/:pluginId/extensions/:extensionId` | implemented | Canonical per-extension toggle. Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:pluginId` or `:extensionId`). 404 `not-found` when the plugin id is unknown or the extension id does not belong to that plugin. **Lock**, rejected with 403 `locked` when either the plugin id or the qualified `pluginId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle (aggregate) macro form (including the `startsAsDisabled` exception). The SPA's buffered Settings modal posts here for every per-row flip. |
611
- | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing, any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: bare plugin ids expand to their child qualified ids before persistence; `IConfigPluginsPort.set` is called per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state, the SPA replaces its modal state from this envelope. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
605
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row has no granular toggle axis; its `status` aggregates the children (`'enabled'` when at least one extension is enabled, else `'disabled'`). The `description` carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when undeclared; missing means `stable`. The SPA badges only non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders descriptions as muted secondary text and indexes them for substring search alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default, where the default is `false` for `experimental` and `deprecated` extensions and `true` otherwise). The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` returns `403 locked`. Omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, i.e. every extension was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling requires `sm serve` restart (the rest of the toggle pipeline applies live). Omitted when false. |
606
+ | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists one `config_plugins` row per child extension (`<plugin>/<ext>`) in a single transaction; locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true`) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to load. The SPA surfaces this per-row. The endpoint does NOT broadcast a WS event today. |
607
+ | `PATCH /api/plugins/:pluginId/extensions/:extensionId` | implemented | Canonical per-extension toggle. Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:pluginId` or `:extensionId`). 404 `not-found` when the plugin id is unknown or the extension id does not belong to that plugin. **Lock**, rejected with 403 `locked` when either the plugin id or the qualified `pluginId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle macro form (including the `startsAsDisabled` exception). The SPA's buffered Settings modal posts here for every per-row flip. |
608
+ | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` is a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing; any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, DB untouched. Valid batches apply in **one SQLite transaction**: bare plugin ids expand to child qualified ids before persistence; `IConfigPluginsPort.set` is called per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state; the SPA replaces its modal state from it. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant lets the SPA stage edits in a buffered modal and ship the final delta atomically. |
612
609
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
613
610
  | `GET /ws` | implemented (v14.4.a) | accepts WebSocket upgrade and registers the client with the BFF broadcaster. Server-push only, the server fans `scan.*` (and forthcoming `issue.*`) events to every connected client. See **WebSocket protocol** below. |
614
611
  | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
615
612
 
616
613
  List endpoints conform to [`schemas/api/rest-envelope.schema.json`](schemas/api/rest-envelope.schema.json). The `/api/scan` and `/api/health` responses carry their underlying `ScanResult` / `IHealthResponse` shapes directly (no envelope wrap). The `/api/graph` response carries the formatter's native textual output.
617
614
 
618
- **`kindRegistry` envelope field.** Every payload-bearing variant of the REST envelope (`nodes` / `links` / `issues` / `plugins` lists, the `node` single, the `config` value envelope) embeds a required `kindRegistry: { [kindName]: { providerId, label, color, colorDark?, emoji?, icon? } }` field. Sentinel envelopes (`health`, `scan`, `graph`) are exempt, they carry no payload at the wire level. The BFF assembles the registry once at boot from EVERY built-in Provider's `kinds[*].ui` block (regardless of the boot-time enabled verdict, their module code is statically imported by `built-ins.ts` and always in memory) PLUS every drop-in user Provider that loaded successfully at boot (see [`architecture.md` §Provider · `ui` presentation](architecture.md#provider--ui-presentation)). The registry is then attached to every applicable response. Built-ins are listed unconditionally because a user re-enabling one mid-session expects its kinds to render on the next scan; the runtime enabled/disabled axis is enforced at SCAN-TIME by `composeScanExtensions` reading the fresh resolver, not by hiding kinds from the registry. Drop-ins that loaded as `disabled` carry `startsAsDisabled: true` on `GET /api/plugins` and need `sm serve` restart to register, their module code was never imported. The UI consumes `kindRegistry` directly to render kind palettes, list rows, and inspector headers, built-in and user-plugin kinds render identically. A kind appearing in a response payload (e.g. `node.kind`) without a matching `kindRegistry` entry is a contract violation; the kernel rejects Providers without a `ui` block at load time so the registry is always complete for whatever kinds appear in the response.
615
+ **`kindRegistry` envelope field.** Every payload-bearing variant of the REST envelope (`nodes` / `links` / `issues` / `plugins` lists, the `node` single, the `config` value envelope) embeds a required `kindRegistry: { [kindName]: { providerId, label, color, colorDark?, emoji?, icon? } }` field. Sentinel envelopes (`health`, `scan`, `graph`) are exempt, carrying no wire-level payload. The BFF assembles the registry once at boot from EVERY built-in Provider's `kinds[*].ui` block (regardless of boot-time enabled verdict; their module code is statically imported by `built-ins.ts` and always in memory) PLUS every drop-in user Provider that loaded successfully at boot (see [`architecture.md` §Provider · `ui` presentation](architecture.md#provider--ui-presentation)), then attaches it to every applicable response. Built-ins are listed unconditionally (a user re-enabling one mid-session expects its kinds to render on the next scan); the runtime enabled/disabled axis is enforced at SCAN-TIME by `composeScanExtensions` reading the fresh resolver, not by hiding kinds. Drop-ins that loaded as `disabled` carry `startsAsDisabled: true` on `GET /api/plugins` and need `sm serve` restart to register (module code never imported). The UI consumes `kindRegistry` directly to render kind palettes, list rows, and inspector headers; built-in and user-plugin kinds render identically. A kind in a payload (e.g. `node.kind`) without a matching `kindRegistry` entry is a contract violation; the kernel rejects Providers without a `ui` block at load time so the registry is always complete.
619
616
 
620
- **`providerRegistry` envelope field.** The same payload-bearing envelopes also embed a required `providerRegistry: { [providerId]: { label, color, colorDark?, emoji?, icon?, hideChip? } }` field (sibling of `kindRegistry`). Sentinel envelopes (`health`, `scan`, `graph`), action-result envelopes (`sidecar.bumped`), and the catalog envelopes (`annotations.registered`, `contributions.registered`) are exempt. Same boot-time assembly discipline as `kindRegistry`: assembled once from EVERY built-in Provider's top-level `presentation` block (regardless of enabled verdict) PLUS every drop-in user Provider that loaded at boot. The UI consumes `providerRegistry` to render the active-lens dropdown, the topbar lens chip, and the per-node provider chip from the real registered-Provider set, never a hardcoded list; `hideChip: true` (the universal `markdown` fallback) suppresses only the per-card chip. This is the static boot catalog of Provider identity; the dynamic active lens (current value + filesystem-detected candidates + the enabled `selectable` set) is served separately by `GET /api/active-provider`.
617
+ **`providerRegistry` envelope field.** The same payload-bearing envelopes embed a required `providerRegistry: { [providerId]: { label, color, colorDark?, emoji?, icon?, hideChip? } }` field (sibling of `kindRegistry`). Sentinel envelopes (`health`, `scan`, `graph`), action-result envelopes (`sidecar.bumped`), and catalog envelopes (`annotations.registered`, `contributions.registered`) are exempt. Same boot-time assembly as `kindRegistry`, from EVERY built-in Provider's top-level `presentation` block PLUS every drop-in user Provider loaded at boot. The UI consumes `providerRegistry` to render the active-lens dropdown, topbar lens chip, and per-node provider chip from the real registered-Provider set, never a hardcoded list; `hideChip: true` (the universal `markdown` fallback) suppresses only the per-card chip. The static boot catalog of Provider identity; the dynamic active lens (current value + filesystem-detected candidates + the enabled `selectable` set) is served separately by `GET /api/active-provider`.
621
618
 
622
- **`contributionsRegistry` envelope field.** Same payload-bearing envelopes also embed `contributionsRegistry: { "<pluginId>/<extensionId>/<contributionId>": { pluginId, extensionId, contributionId, slot, label?, tooltip?, icon?, emptyText?, emitWhenEmpty } }`. Same boot-time assembly discipline as `kindRegistry`: ALL built-in declarations are listed regardless of enabled state (so re-enabling a built-in mid-session renders correctly on the next scan), plus drop-in user plugins that loaded at boot. The `slot` value comes from the closed catalog in `spec/schemas/view-slots.schema.json`. A view contribution emitted by an extension whose qualified id is missing from the registry is dropped by the UI's slot host (mirrors the kindRegistry contract, `startsAsDisabled` drop-ins illustrate the absence path).
619
+ **`contributionsRegistry` envelope field.** Same payload-bearing envelopes also embed `contributionsRegistry: { "<pluginId>/<extensionId>/<contributionId>": { pluginId, extensionId, contributionId, slot, label?, tooltip?, icon?, emptyText?, emitWhenEmpty } }`. Same boot-time assembly as `kindRegistry`, ALL built-in declarations plus drop-in user plugins loaded at boot. The `slot` value comes from the closed catalog in `spec/schemas/view-slots.schema.json`. A view contribution emitted by an extension whose qualified id is missing from the registry is dropped by the UI's slot host (mirrors the kindRegistry contract; `startsAsDisabled` drop-ins illustrate the absence path).
623
620
 
624
621
  **Error envelope** (mirrors `§Machine-readable output analyzers`):
625
622
 
@@ -638,16 +635,16 @@ HTTP status mapping: `400` → `bad-query`, `403` → `locked` (`PATCH /api/plug
638
635
 
639
636
  Error code sources at v14.2:
640
637
 
641
- - `not-found` (404), unknown `/api/*` path; missing node on `/api/nodes/:pathB64`; malformed `pathB64` (treated as "no such node" so the client UX is uniform).
638
+ - `not-found` (404), unknown `/api/*` path; missing node on `/api/nodes/:pathB64`; malformed `pathB64` (treated as "no such node" for uniform client UX).
642
639
  - `bad-query` (400), `ExportQueryError` from `parseExportQuery`; pagination beyond `limit ≤ 1000`; non-integer / negative `limit` / `offset`; unknown formatter on `/api/graph`; `?fresh=1` when the server started with `--no-built-ins` or `--no-plugins`.
643
- - `internal` (500), uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
644
- - `db-missing` (500), emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:pluginId/extensions/:extensionId`, `PATCH /api/plugins`) when the project DB is absent. Read-side routes uniformly degrade to the empty shape (`/api/scan`) or zero items (list endpoints) so they do not emit this code; mutation endpoints cannot persist without a DB so they fail fast instead of silently dropping the write.
645
- - `not-found` (404) on `PATCH /api/plugins/:id`, unknown plugin id (no built-in plugin, no discovered drop-in matches). The qualified-id form returns the same code when either segment misses. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first offending id; the batch is rejected before any DB write.
646
- - `bad-query` (400) on `PATCH /api/plugins/:id`, malformed body (missing `enabled`, wrong type), or `:id` contains a slash (the qualified-id sibling is `PATCH /api/plugins/:pluginId/extensions/:extensionId`). The qualified-id sibling returns 404 `not-found` for an unknown plugin or extension id. The bulk `PATCH /api/plugins` returns 400 for malformed `changes` array or missing/typeless `enabled`, with `error.details.id` set to the first offending entry's id.
647
- - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling, the target plugin id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). The list is hardcoded, host-only, and not user-editable; `GET /api/plugins` mirrors the same analyzer by stamping `locked: true` on the affected items. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first locked entry; the batch is rejected before any DB write.
648
- - `bad-query` (400) on `POST /api/scan`, the server was started with `--no-built-ins` or `--no-plugins` (partial pipeline would persist a misleading DB).
649
- - `scan-busy` (409) on `POST /api/scan`, another scan (a watcher batch or another POST) is already in flight. Retry once the in-flight scan resolves; the WS `scan.completed` envelope is the unambiguous "now safe" signal.
650
- - `host-not-allowed` / `origin-not-allowed` (403) on every endpoint: first-stage loopback gate rejected the request because the `Host` or `Origin` header hostname is not loopback (`127.0.0.1`, `localhost`, `::1`). Closes DNS rebinding (Host) and cross-origin abuse (Origin). The gate is always-on; the envelope `details` is `null` so the response is opaque to probes.
640
+ - `internal` (500), uncaught error during a request (e.g. config-load failure, DB corruption via `loadScanResult`).
641
+ - `db-missing` (500), emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:pluginId/extensions/:extensionId`, `PATCH /api/plugins`) when the project DB is absent. Read-side routes degrade to the empty shape (`/api/scan`) or zero items (list endpoints) instead; mutation endpoints cannot persist without a DB so they fail fast.
642
+ - `not-found` (404) on `PATCH /api/plugins/:id`, unknown plugin id (no built-in, no discovered drop-in). The qualified-id form returns the same when either segment misses. The bulk `PATCH /api/plugins` returns the same with `error.details.id` set to the first offending id; the batch is rejected before any DB write.
643
+ - `bad-query` (400) on `PATCH /api/plugins/:id`, malformed body (missing `enabled`, wrong type), or `:id` contains a slash (the qualified-id sibling is `PATCH /api/plugins/:pluginId/extensions/:extensionId`). The qualified-id sibling returns 404 `not-found` for an unknown plugin or extension id. The bulk `PATCH /api/plugins` returns 400 for a malformed `changes` array or missing/typeless `enabled`, with `error.details.id` set to the first offending entry's id.
644
+ - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling, the target plugin id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). Hardcoded, host-only, not user-editable; `GET /api/plugins` mirrors it by stamping `locked: true` on the affected items. The bulk `PATCH /api/plugins` returns the same with `error.details.id` set to the first locked entry; rejected before any DB write.
645
+ - `bad-query` (400) on `POST /api/scan`, the server started with `--no-built-ins` or `--no-plugins` (partial pipeline would persist a misleading DB).
646
+ - `scan-busy` (409) on `POST /api/scan`, another scan (a watcher batch or another POST) is already in flight. Retry once it resolves; the WS `scan.completed` envelope is the unambiguous "now safe" signal.
647
+ - `host-not-allowed` / `origin-not-allowed` (403) on every endpoint: first-stage loopback gate rejected the request because the `Host` or `Origin` header hostname is not loopback (`127.0.0.1`, `localhost`, `::1`). Closes DNS rebinding (Host) and cross-origin abuse (Origin). Always-on; the envelope `details` is `null` so the response is opaque to probes.
651
648
 
652
649
  **Flag surface**:
653
650
 
@@ -661,32 +658,32 @@ Error code sources at v14.2:
661
658
  | `--open` / `--no-open` | `--open` | Auto-open the SPA in the user's default browser after listen. |
662
659
  | `--dev-cors` | off | Enable permissive CORS for the Angular dev-server proxy workflow. Loopback-only when set. |
663
660
  | `--ui-dist <path>` | auto | Override the UI bundle directory. Hidden flag, used by the demo build pipeline + tests; everyday users never need it. Mutually exclusive with `--no-ui` (rejected with exit 2). |
664
- | `--no-ui` | off | Skip serving the Angular SPA bundle. The root `/` (and any SPA fallback) responds with an inline dev-mode placeholder pointing the user at `npm run ui:dev` + `http://localhost:4200/`. Intended for local development alongside the Angular dev server with HMR; pairs with `--no-open` (default `--open` plus `--no-ui` would auto-open the placeholder, so a non-fatal stderr warning is emitted in that combination). Mutually exclusive with `--ui-dist <path>` (rejected with exit 2). The server keeps `/api/*` and `/ws` fully functional; only the static SPA is suppressed. |
665
- | `--no-watcher` | off | Disable the chokidar-fed scan-and-broadcast loop. Use only for CI / read-only deployments, without the watcher, `/ws` stays open but no `scan.*` events ever fire. Combining with `--no-built-ins` is rejected (the watcher cannot run with an empty pipeline; would persist empty scans on every batch). |
661
+ | `--no-ui` | off | Skip serving the Angular SPA bundle. The root `/` (and any SPA fallback) responds with an inline dev-mode placeholder pointing at `npm run ui:dev` + `http://localhost:4200/`. For local development alongside the Angular dev server with HMR; pairs with `--no-open` (default `--open` plus `--no-ui` would auto-open the placeholder, so a non-fatal stderr warning is emitted then). Mutually exclusive with `--ui-dist <path>` (rejected with exit 2). `/api/*` and `/ws` stay fully functional; only the static SPA is suppressed. |
662
+ | `--no-watcher` | off | Disable the chokidar-fed scan-and-broadcast loop. CI / read-only deployments only; without the watcher, `/ws` stays open but no `scan.*` events fire. Combining with `--no-built-ins` is rejected (the watcher cannot run with an empty pipeline; would persist empty scans on every batch). |
666
663
 
667
664
  **WebSocket protocol** *(Stability: experimental, locks at v0.6.0)*:
668
665
 
669
- The `/ws` endpoint is the live-events channel for the SPA. Clients connect once at bootstrap, the server pushes events as they happen, and the SPA reconciles its in-memory store against the deltas. The wire envelope and `scan.*` payload shapes are normative in [`job-events.md`](./job-events.md), the BFF emits them verbatim.
666
+ The `/ws` endpoint is the live-events channel for the SPA. Clients connect once at bootstrap, the server pushes events as they happen, and the SPA reconciles its in-memory store against the deltas. The wire envelope and `scan.*` payload shapes are normative in [`job-events.md`](./job-events.md); the BFF emits them verbatim.
670
667
 
671
- - **Wire format**: each event is a single WebSocket text frame carrying one JSON object that conforms to `job-events.md` §Common envelope (`type`, `timestamp`, `runId?`, `jobId? | null`, `data`).
668
+ - **Wire format**: each event is a single WebSocket text frame carrying one JSON object conforming to `job-events.md` §Common envelope (`type`, `timestamp`, `runId?`, `jobId? | null`, `data`).
672
669
  - **Event catalog at v14.4.a**:
673
670
  - `scan.started` (per `job-events.md` §Scan events line 325).
674
- - `scan.progress` (per `job-events.md` line 345, emitted by the kernel orchestrator at every node; throttling deferred to a follow-up).
671
+ - `scan.progress` (per `job-events.md` line 345, emitted by the kernel orchestrator at every node; throttling deferred).
675
672
  - `scan.completed` (per `job-events.md` line 363).
676
673
  - `extractor.completed` (per `job-events.md` line 384) and `analyzer.completed` (per `job-events.md` line 404) ride along as side effects of the same emitter bridge.
677
674
  - `extension.error` (kernel-internal, emitted when an extension violates its declared contract; the BFF forwards verbatim).
678
675
  - `watcher.started` and `watcher.error`, BFF-internal advisories. Non-normative; consumers MUST ignore unknown event types per the forward-compatibility analyzer.
679
- - **Deferred to a follow-up**: `issue.added` / `issue.resolved` (per `job-events.md` §Issue events line 446) and `scan.failed`. The 14.4.a surface fans out only the events the kernel emitter already produces; the diff-based issue events and a dedicated batch-failure event require additional plumbing inside the BFF watcher loop.
676
+ - **Deferred**: `issue.added` / `issue.resolved` (per `job-events.md` §Issue events line 446) and `scan.failed`. The 14.4.a surface fans out only events the kernel emitter already produces; diff-based issue events and a dedicated batch-failure event need more plumbing inside the BFF watcher loop.
680
677
  - **Connection lifecycle**:
681
- 1. Client opens `ws://<host>:<port>/ws`. The server completes the WS handshake and registers the underlying socket with the broadcaster.
682
- 2. Server pushes events. The client sends no application frames, `onMessage` is intentionally not registered for app data (transport-level pong frames are answered by the browser automatically, see **Keep-alive** below). A future client-initiated subscribe / filter request lands in a follow-up.
678
+ 1. Client opens `ws://<host>:<port>/ws`. The server completes the handshake and registers the socket with the broadcaster.
679
+ 2. Server pushes events. The client sends no application frames; `onMessage` is intentionally not registered for app data (transport-level pong frames are answered by the browser automatically, see **Keep-alive** below). A future client-initiated subscribe / filter request lands in a follow-up.
683
680
  3. Server has NO state push on connect (no replay of last events). The client SHOULD poll `/api/scan` once on connect to seed initial state, then rely on `/ws` for deltas.
684
681
  4. On normal disconnect: client closes with code 1000 ('normal closure') or 1001 ('going away'). The broadcaster unregisters silently.
685
682
  5. On server shutdown (SIGINT / SIGTERM): the broadcaster sends close code 1001 + reason `'server shutdown'` to every client, then closes the http listener.
686
- 6. **Backpressure**: if a client's outbound buffer (`bufferedAmount`) exceeds an implementation-defined threshold (the reference impl uses 4 MiB), the broadcaster closes that client with code 1009 ('message too big') and unregisters it. Clients SHOULD reconnect after backpressure eviction with a fresh `/api/scan` poll.
687
- 7. **Reconnect responsibility**: the server does NOT reconnect on the client's behalf and does NOT replay missed events on reconnect. The client SHOULD treat `/ws` as a best-effort delta channel and re-seed via `/api/scan` whenever the connection drops. To avoid a re-seed storm against a flapping endpoint, the client SHOULD reset its reconnect backoff only after a connection has stayed open long enough to be considered stable; a socket that opens and then drops before that window counts as a failed attempt, so the backoff keeps escalating and eventually surfaces a non-fatal 'connection lost' state instead of reconnecting (and re-seeding) in a tight loop.
688
- - **Keep-alive (heartbeat)**: the server sends an RFC 6455 ping control frame to every connected client on a fixed interval (implementation-defined; the reference impl uses 30s). The browser answers each ping with an automatic pong, transparent to the page's JS `WebSocket` API, so the connection never sits idle and an intermediary that drops idle sockets (the Angular dev-server proxy under `pnpm dev`, a hosted nginx / load balancer) leaves it alone. The same exchange is dead-peer detection: a client that has not ponged since the previous interval is terminated server-side (it vanished without a close frame, e.g. host sleep or a NAT timeout). This complements the `bufferedAmount` backpressure eviction, which only fires when there is an event to send, so a silent half-open socket on an idle workspace is still reaped. No application frame or envelope is involved; this is purely transport keep-alive and does not appear in the event catalog above.
689
- - **Loopback-only assumption (Decision #119)**: no per-connection authentication on `/ws` through v0.6.0. The transport security boundary is the `--host` flag (defaults to `127.0.0.1`); the server rejects `--dev-cors` combined with a non-loopback `--host` precisely because that combination would expose `/ws` over the network without auth. Multi-host serve and an auth model re-open post-v0.6.0.
683
+ 6. **Backpressure**: if a client's outbound buffer (`bufferedAmount`) exceeds an implementation-defined threshold (reference impl: 4 MiB), the broadcaster closes that client with code 1009 ('message too big') and unregisters it. Clients SHOULD reconnect after backpressure eviction with a fresh `/api/scan` poll.
684
+ 7. **Reconnect responsibility**: the server does NOT reconnect on the client's behalf or replay missed events. The client SHOULD treat `/ws` as a best-effort delta channel and re-seed via `/api/scan` whenever the connection drops. To avoid a re-seed storm against a flapping endpoint, the client SHOULD reset its reconnect backoff only after a connection stays open long enough to be considered stable; a socket that opens then drops before that window counts as a failed attempt, so the backoff keeps escalating and eventually surfaces a non-fatal 'connection lost' state instead of reconnecting in a tight loop.
685
+ - **Keep-alive (heartbeat)**: the server sends an RFC 6455 ping control frame to every connected client on a fixed interval (implementation-defined; reference impl: 30s). The browser answers each ping with an automatic pong, transparent to the page's JS `WebSocket` API, so the connection never sits idle and an intermediary that drops idle sockets (the Angular dev-server proxy under `pnpm dev`, a hosted nginx / load balancer) leaves it alone. The same exchange is dead-peer detection: a client that has not ponged since the previous interval is terminated server-side (it vanished without a close frame, e.g. host sleep or NAT timeout). This complements the `bufferedAmount` backpressure eviction (which only fires when there is an event to send), so a silent half-open socket on an idle workspace is still reaped. Purely transport keep-alive, absent from the event catalog above.
686
+ - **Loopback-only assumption (Decision #119)**: no per-connection authentication on `/ws` through v0.6.0. The transport security boundary is the `--host` flag (defaults to `127.0.0.1`); the server rejects `--dev-cors` combined with a non-loopback `--host` precisely because that would expose `/ws` over the network without auth. Multi-host serve and an auth model re-open post-v0.6.0.
690
687
 
691
688
  **Graceful shutdown**: SIGINT / SIGTERM trigger a graceful close; the verb returns exit 0 on clean shutdown. Bind failure (port in use, EACCES) returns exit 2. The shutdown sequence drains the in-flight watcher batch (if any), closes every WS client with code 1001, then closes the http listener.
692
689
 
@@ -697,7 +694,7 @@ The `/ws` endpoint is the live-events channel for the SPA. Clients connect once
697
694
  - `sm help --format json`, structured CLI surface dump.
698
695
  - `sm help --format md`, canonical markdown, generated on demand (not a committed artifact).
699
696
 
700
- These two formats are NORMATIVE: any change to verbs, flags, or exit codes MUST reflect in `--format json` output immediately. Third-party consumers rely on this.
697
+ These two formats are NORMATIVE: any change to verbs, flags, or exit codes MUST reflect in `--format json` output immediately. Third-party consumers rely on it.
701
698
 
702
699
  ### Conformance
703
700
 
@@ -705,7 +702,7 @@ These two formats are NORMATIVE: any change to verbs, flags, or exit codes MUST
705
702
  |---|---|
706
703
  | `sm conformance run [--scope spec\|provider:<id>\|all]` | Run the conformance suite. `--scope spec` runs only the kernel-agnostic cases bundled with `@skill-map/spec` (default fixture: `preamble-v1.txt`, case: `kernel-empty-boot`). `--scope provider:<id>` runs only the named built-in Provider's suite (today: `provider:claude`). `--scope all` (default) runs every visible scope in registry order. Exit 0 on a clean sweep; exit 1 if any case failed; exit 2 on a configuration error (unknown scope, missing binary). `--json` emits the report shape declared by [`conformance-result.schema.json`](./schemas/conformance-result.schema.json): `{ ok: true, kind: 'conformance.result', totals, scopes[], elapsedMs }`. Error envelope per §Error envelope: `bad-query` (unknown scope), `internal` (missing binary). |
707
704
 
708
- Per-Provider conformance suites live next to the Provider's manifest under `<plugin-dir>/conformance/{cases,fixtures}/`. The verb discovers them by walking the built-in Provider directory (and, post-job-subsystem, the plugin loader's discovery output). External consumers, alt-impl authors, Provider authors validating their own work, drive the same suite via this verb without reaching into bespoke scripts.
705
+ Per-Provider conformance suites live next to the Provider's manifest under `<plugin-dir>/conformance/{cases,fixtures}/`. The verb discovers them by walking the built-in Provider directory (and, post-job-subsystem, the plugin loader's discovery output). External consumers, alt-impl authors, and Provider authors validating their own work drive the same suite via this verb without bespoke scripts.
709
706
 
710
707
  ---
711
708
 
@@ -735,13 +732,13 @@ When `--json` is set:
735
732
 
736
733
  ## Elapsed time
737
734
 
738
- Every verb that does non-trivial work MUST report its own wall-clock duration. Coverage is broad on purpose, operators and agents need to notice regressions without instrumenting the host.
735
+ Every verb that does non-trivial work MUST report its own wall-clock duration. Coverage is broad on purpose: operators and agents need to notice regressions without instrumenting the host.
739
736
 
740
737
  ### Scope
741
738
 
742
739
  **In scope**: any verb that walks the filesystem, hits the DB, spawns a subprocess, or renders a report. Examples: `sm scan`, `sm check`, `sm list`, `sm show`, `sm findings`, `sm history`, `sm history stats`, `sm graph`, `sm export`, `sm job submit`, `sm job run`, `sm job claim`, `sm job preview`, `sm record`, `sm doctor`, `sm db backup`, `sm db restore`, `sm db dump`, `sm db migrate`, `sm plugins list`, `sm plugins doctor`, `sm init`, `sm conformance run`.
743
740
 
744
- **Exempt**: informational verbs that return in well under a millisecond and would clutter the output, `sm --version`, `sm --help`, `sm version`, `sm help`, `sm config get`, `sm config list`, `sm config show`.
741
+ **Exempt**: informational verbs that return well under a millisecond and would clutter output, `sm --version`, `sm --help`, `sm version`, `sm help`, `sm config get`, `sm config list`, `sm config show`.
745
742
 
746
743
  ### Pretty output (TTY)
747
744
 
@@ -761,7 +758,7 @@ When the verb's `--json` output is a top-level **object**, the schema includes a
761
758
 
762
759
  When the verb's `--json` output is a top-level **array** or an **ndjson stream**, the schema does NOT include `elapsedMs` (there is no object to attach it to). Stderr is the sole carrier of the timing line.
763
760
 
764
- Schemas that already express the command's wall-clock under a nested field (e.g. `scan-result.schema.json` → `stats.durationMs`) MUST treat that field as the elapsed time of the scan command itself. Adding a top-level `elapsedMs` to those schemas for redundancy is a minor bump and MAY happen later for consistency; until then, consumers read the nested field.
761
+ Schemas that already express the command's wall-clock under a nested field (e.g. `scan-result.schema.json` → `stats.durationMs`) MUST treat that field as the elapsed time of the scan command itself. Adding a top-level `elapsedMs` to those schemas for redundancy is a minor bump and MAY happen later; until then, consumers read the nested field.
765
762
 
766
763
  ### Implementations
767
764