@skill-map/cli 0.8.0 → 0.10.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/README.md +8 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12951 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/defaults/skill-mapignore +27 -0
- package/dist/conformance/index.d.ts +82 -0
- package/dist/conformance/index.js +357 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1706 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +2531 -0
- package/dist/kernel/index.js +1706 -0
- package/dist/kernel/index.js.map +1 -0
- package/dist/migrations/001_initial.sql +267 -0
- package/package.json +7 -2
|
@@ -0,0 +1,2531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain types — byte-aligned with `spec/schemas/{node,link,issue,scan-result}.schema.json`.
|
|
3
|
+
*
|
|
4
|
+
* The kernel is the reference consumer of the spec; these types are therefore
|
|
5
|
+
* derived from the schemas, not invented. When a schema changes, this file
|
|
6
|
+
* follows. Until automatic AJV-driven derivation lands, the mapping is
|
|
7
|
+
* hand-maintained and the release gate is the conformance suite.
|
|
8
|
+
*
|
|
9
|
+
* --- Naming convention (kernel-wide) -------------------------------------
|
|
10
|
+
*
|
|
11
|
+
* Four categories with distinct prefix rules; the rules are deliberate
|
|
12
|
+
* even though they look mixed at first read:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Domain types** — every shape that mirrors a `spec/schemas/*.json`
|
|
15
|
+
* file: `Node`, `Link`, `Issue`, `ScanResult`, `ScanStats`,
|
|
16
|
+
* `ExecutionRecord`, `HistoryStats`, …. **No prefix.** Names track
|
|
17
|
+
* the spec verbatim because the spec is the source of truth.
|
|
18
|
+
* Renaming any of these is a spec change.
|
|
19
|
+
*
|
|
20
|
+
* 2. **Hexagonal ports** — the abstract boundaries the kernel calls
|
|
21
|
+
* out to (`StoragePort`, `RunnerPort`, `ProgressEmitterPort`,
|
|
22
|
+
* `FilesystemPort`, `PluginLoaderPort`). **`Port` suffix.** The
|
|
23
|
+
* suffix calls out the architectural role and avoids name clashes
|
|
24
|
+
* with the concrete adapter classes (`SqliteStorageAdapter`
|
|
25
|
+
* implements `StoragePort`).
|
|
26
|
+
*
|
|
27
|
+
* 3. **Runtime extension contracts** — what a plugin author
|
|
28
|
+
* implements: `IProvider`, `IExtractor`, `IRule`, `IFormatter`,
|
|
29
|
+
* `IExtensionBase`. **`I` prefix.** The prefix flags "this is a
|
|
30
|
+
* contract you supply, not a value the kernel hands you" — same
|
|
31
|
+
* reading as the rest of TypeScript's plugin ecosystems where a
|
|
32
|
+
* shape is implementable.
|
|
33
|
+
*
|
|
34
|
+
* 4. **Internal shapes** — option bags, result records, config
|
|
35
|
+
* slices, anything passed across function boundaries inside the
|
|
36
|
+
* kernel / CLI but not part of the spec: `IRunScanOptions` (well,
|
|
37
|
+
* `RunScanOptions` — see below), `IPluginRuntimeBundle`,
|
|
38
|
+
* `IPruneResult`, `IMigrationFile`, `IDbLocationOptions`. **`I`
|
|
39
|
+
* prefix.** The prefix matches category 3 because both are
|
|
40
|
+
* "shapes that live in TypeScript only, never in JSON".
|
|
41
|
+
*
|
|
42
|
+
* Edge cases worth knowing:
|
|
43
|
+
* - The following category-4 names lack the `I` prefix because
|
|
44
|
+
* they are part of the public kernel surface and renaming is a
|
|
45
|
+
* breaking change for downstream consumers. The list is closed:
|
|
46
|
+
* option bags / records: `RunScanOptions`, `RenameOp`;
|
|
47
|
+
* TS-only exports from `kernel/index.ts` / `kernel/ports/*`:
|
|
48
|
+
* `Kernel`, `ProgressEvent`, `LogRecord`, `NodeStat`.
|
|
49
|
+
* New public option bags and TS-only exports MUST still use
|
|
50
|
+
* `I*`; removing a name from this list is a breaking change.
|
|
51
|
+
* - `IDatabase` (SQLite schema) is category 4 but lives in
|
|
52
|
+
* `adapters/sqlite/schema.ts`, not here. Same rule applies.
|
|
53
|
+
*
|
|
54
|
+
* If you find yourself wanting to add a new type and aren't sure which
|
|
55
|
+
* bucket it falls in: ask "does this shape exist in the spec?". If
|
|
56
|
+
* yes, no prefix and align the name with the schema. If no, `I`
|
|
57
|
+
* prefix.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* The five node kinds the **built-in Claude Provider** declares — `skill`,
|
|
61
|
+
* `agent`, `command`, `hook`, `note`. **NOT** the kernel-wide kind type.
|
|
62
|
+
*
|
|
63
|
+
* `Node.kind` is `string`. An external Provider (Cursor, Obsidian, …)
|
|
64
|
+
* MAY classify into its own kinds (e.g. `'cursorRule'`, `'daily'`); the
|
|
65
|
+
* orchestrator, persistence layer, and AJV `node.schema.json` accept any
|
|
66
|
+
* non-empty string. Per `spec/db-schema.md` § scan_nodes and
|
|
67
|
+
* `node.schema.json#/properties/kind`, the contract is open-by-design
|
|
68
|
+
* (matches `IProvider.kinds` "open by design" docstring).
|
|
69
|
+
*
|
|
70
|
+
* This alias survives because:
|
|
71
|
+
* - claude-specific code legitimately wants to switch on the five
|
|
72
|
+
* hard-coded values (filter widgets, kind-aware UI cards, the
|
|
73
|
+
* `validate-all` built-in rule that maps each kind to its
|
|
74
|
+
* frontmatter schema);
|
|
75
|
+
* - sorting helpers want a stable `KIND_ORDER` for the canonical
|
|
76
|
+
* catalog;
|
|
77
|
+
* - tests expect to enumerate the five kinds when seeding fixtures.
|
|
78
|
+
*
|
|
79
|
+
* For "any kind a Provider could declare", use plain `string`. Only use
|
|
80
|
+
* `NodeKind` when the code is intentionally claude-catalog-specific.
|
|
81
|
+
*/
|
|
82
|
+
type NodeKind = 'skill' | 'agent' | 'command' | 'hook' | 'note';
|
|
83
|
+
type LinkKind = 'invokes' | 'references' | 'mentions' | 'supersedes';
|
|
84
|
+
type Confidence = 'high' | 'medium' | 'low';
|
|
85
|
+
type Severity = 'error' | 'warn' | 'info';
|
|
86
|
+
type Stability = 'experimental' | 'stable' | 'deprecated';
|
|
87
|
+
/**
|
|
88
|
+
* Execution mode of an analytical extension. Mirrors the per-kind capability
|
|
89
|
+
* matrix in `spec/architecture.md` §Execution modes:
|
|
90
|
+
*
|
|
91
|
+
* - `deterministic` — pure code, runs synchronously inside `sm scan` /
|
|
92
|
+
* `sm check`. Same input → same output, every run.
|
|
93
|
+
* - `probabilistic` — calls an LLM through `RunnerPort`, dispatches only
|
|
94
|
+
* as a queued job (`sm job submit <kind>:<id>`); never participates in
|
|
95
|
+
* scan-time pipelines.
|
|
96
|
+
*
|
|
97
|
+
* Extractor / Rule / Action declare it directly (default `deterministic` when
|
|
98
|
+
* omitted in the manifest). Provider / Formatter are deterministic-only and
|
|
99
|
+
* MUST NOT carry the field.
|
|
100
|
+
*/
|
|
101
|
+
type TExecutionMode = 'deterministic' | 'probabilistic';
|
|
102
|
+
interface TripleSplit {
|
|
103
|
+
frontmatter: number;
|
|
104
|
+
body: number;
|
|
105
|
+
total: number;
|
|
106
|
+
}
|
|
107
|
+
interface LinkTrigger {
|
|
108
|
+
originalTrigger: string;
|
|
109
|
+
normalizedTrigger: string;
|
|
110
|
+
}
|
|
111
|
+
interface LinkLocation {
|
|
112
|
+
line: number;
|
|
113
|
+
column?: number;
|
|
114
|
+
offset?: number;
|
|
115
|
+
}
|
|
116
|
+
interface Node {
|
|
117
|
+
path: string;
|
|
118
|
+
/**
|
|
119
|
+
* Provider-declared category. Open string (matches
|
|
120
|
+
* `node.schema.json#/properties/kind`): the built-in Claude Provider
|
|
121
|
+
* emits one of `NodeKind`'s values, but external Providers MAY emit
|
|
122
|
+
* their own. Code that intentionally switches on the claude catalog
|
|
123
|
+
* narrows via `if (kind === 'skill' \| ... )`; everything else
|
|
124
|
+
* accepts the open string and treats unknown values as opaque labels.
|
|
125
|
+
*/
|
|
126
|
+
kind: string;
|
|
127
|
+
provider: string;
|
|
128
|
+
bodyHash: string;
|
|
129
|
+
frontmatterHash: string;
|
|
130
|
+
bytes: TripleSplit;
|
|
131
|
+
linksOutCount: number;
|
|
132
|
+
linksInCount: number;
|
|
133
|
+
externalRefsCount: number;
|
|
134
|
+
title?: string | null;
|
|
135
|
+
description?: string | null;
|
|
136
|
+
stability?: Stability | null;
|
|
137
|
+
version?: string | null;
|
|
138
|
+
author?: string | null;
|
|
139
|
+
frontmatter?: Record<string, unknown>;
|
|
140
|
+
tokens?: TripleSplit;
|
|
141
|
+
}
|
|
142
|
+
interface Link {
|
|
143
|
+
/** The originating node — the path of the file the extractor was reading
|
|
144
|
+
* when it emitted this link. Singular, NOT to be confused with
|
|
145
|
+
* `sources` (plural) below. */
|
|
146
|
+
source: string;
|
|
147
|
+
target: string;
|
|
148
|
+
kind: LinkKind;
|
|
149
|
+
confidence: Confidence;
|
|
150
|
+
/** Identifiers of the extractors / extensions that contributed evidence
|
|
151
|
+
* for this link (one link can be confirmed by multiple extractors).
|
|
152
|
+
* Plural; NOT the same as `source` (singular) above, which is the
|
|
153
|
+
* originating node path. Naming is unfortunate but spec-frozen. */
|
|
154
|
+
sources: string[];
|
|
155
|
+
trigger?: LinkTrigger | null;
|
|
156
|
+
location?: LinkLocation | null;
|
|
157
|
+
raw?: string | null;
|
|
158
|
+
}
|
|
159
|
+
interface IssueFix {
|
|
160
|
+
summary?: string;
|
|
161
|
+
autofixable?: boolean;
|
|
162
|
+
}
|
|
163
|
+
interface Issue {
|
|
164
|
+
ruleId: string;
|
|
165
|
+
severity: Severity;
|
|
166
|
+
nodeIds: string[];
|
|
167
|
+
message: string;
|
|
168
|
+
linkIndices?: number[];
|
|
169
|
+
detail?: string | null;
|
|
170
|
+
fix?: IssueFix | null;
|
|
171
|
+
data?: Record<string, unknown>;
|
|
172
|
+
}
|
|
173
|
+
interface ScanStats {
|
|
174
|
+
/**
|
|
175
|
+
* Files visited by the Provider walkers. With a single Provider this
|
|
176
|
+
* matches `nodesCount`; with multiple Providers running on overlapping
|
|
177
|
+
* roots it can diverge (each yielded `IRawNode` is one walked file).
|
|
178
|
+
*/
|
|
179
|
+
filesWalked: number;
|
|
180
|
+
/**
|
|
181
|
+
* Files walked but not classified by any Provider. Today every walked
|
|
182
|
+
* file is classified by its Provider (the `claude` Provider falls back to
|
|
183
|
+
* `'note'`), so this is always 0; the field will matter once multiple
|
|
184
|
+
* Providers can claim the same file.
|
|
185
|
+
*/
|
|
186
|
+
filesSkipped: number;
|
|
187
|
+
nodesCount: number;
|
|
188
|
+
linksCount: number;
|
|
189
|
+
issuesCount: number;
|
|
190
|
+
durationMs: number;
|
|
191
|
+
}
|
|
192
|
+
interface ScanScannedBy {
|
|
193
|
+
name: string;
|
|
194
|
+
version: string;
|
|
195
|
+
specVersion: string;
|
|
196
|
+
}
|
|
197
|
+
type ExecutionKind = 'action';
|
|
198
|
+
type ExecutionStatus = 'completed' | 'failed' | 'cancelled';
|
|
199
|
+
type ExecutionFailureReason = 'runner-error' | 'report-invalid' | 'timeout' | 'abandoned' | 'job-file-missing' | 'user-cancelled';
|
|
200
|
+
type ExecutionRunner = 'cli' | 'skill' | 'in-process';
|
|
201
|
+
/**
|
|
202
|
+
* One row of execution history (`state_executions`). Matches
|
|
203
|
+
* `spec/schemas/execution-record.schema.json`. `nodeIds` is the camelCased
|
|
204
|
+
* domain field name; storage flattens it to `node_ids_json`.
|
|
205
|
+
*/
|
|
206
|
+
interface ExecutionRecord {
|
|
207
|
+
id: string;
|
|
208
|
+
kind: ExecutionKind;
|
|
209
|
+
extensionId: string;
|
|
210
|
+
extensionVersion: string;
|
|
211
|
+
nodeIds?: string[];
|
|
212
|
+
contentHash?: string | null;
|
|
213
|
+
status: ExecutionStatus;
|
|
214
|
+
failureReason?: ExecutionFailureReason | null;
|
|
215
|
+
exitCode?: number | null;
|
|
216
|
+
runner?: ExecutionRunner | null;
|
|
217
|
+
startedAt: number;
|
|
218
|
+
finishedAt: number;
|
|
219
|
+
durationMs?: number | null;
|
|
220
|
+
tokensIn?: number | null;
|
|
221
|
+
tokensOut?: number | null;
|
|
222
|
+
reportPath?: string | null;
|
|
223
|
+
jobId?: string | null;
|
|
224
|
+
}
|
|
225
|
+
interface HistoryStatsTotals {
|
|
226
|
+
executionsCount: number;
|
|
227
|
+
completedCount: number;
|
|
228
|
+
failedCount: number;
|
|
229
|
+
tokensIn: number;
|
|
230
|
+
tokensOut: number;
|
|
231
|
+
durationMsTotal: number;
|
|
232
|
+
}
|
|
233
|
+
interface HistoryStatsTokensPerAction {
|
|
234
|
+
actionId: string;
|
|
235
|
+
actionVersion: string;
|
|
236
|
+
executionsCount: number;
|
|
237
|
+
tokensIn: number;
|
|
238
|
+
tokensOut: number;
|
|
239
|
+
durationMsMean: number | null;
|
|
240
|
+
durationMsMedian: number | null;
|
|
241
|
+
}
|
|
242
|
+
interface HistoryStatsExecutionsPerPeriod {
|
|
243
|
+
periodStart: string;
|
|
244
|
+
periodUnit: 'day' | 'week' | 'month';
|
|
245
|
+
executionsCount: number;
|
|
246
|
+
tokensIn: number;
|
|
247
|
+
tokensOut: number;
|
|
248
|
+
}
|
|
249
|
+
interface HistoryStatsTopNode {
|
|
250
|
+
nodePath: string;
|
|
251
|
+
executionsCount: number;
|
|
252
|
+
lastExecutedAt: number;
|
|
253
|
+
}
|
|
254
|
+
interface HistoryStatsPerActionRate {
|
|
255
|
+
actionId: string;
|
|
256
|
+
rate: number;
|
|
257
|
+
executionsCount: number;
|
|
258
|
+
failedCount: number;
|
|
259
|
+
}
|
|
260
|
+
interface HistoryStatsErrorRates {
|
|
261
|
+
global: number;
|
|
262
|
+
perAction: HistoryStatsPerActionRate[];
|
|
263
|
+
perFailureReason: Record<ExecutionFailureReason, number>;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* `sm history stats --json` payload, conforming to
|
|
267
|
+
* `spec/schemas/history-stats.schema.json`. `elapsedMs` is the command's
|
|
268
|
+
* own wall-clock per `cli-contract.md` §Elapsed time.
|
|
269
|
+
*/
|
|
270
|
+
interface HistoryStats {
|
|
271
|
+
schemaVersion: 1;
|
|
272
|
+
range: {
|
|
273
|
+
since: string | null;
|
|
274
|
+
until: string;
|
|
275
|
+
};
|
|
276
|
+
totals: HistoryStatsTotals;
|
|
277
|
+
tokensPerAction: HistoryStatsTokensPerAction[];
|
|
278
|
+
executionsPerPeriod: HistoryStatsExecutionsPerPeriod[];
|
|
279
|
+
topNodes: HistoryStatsTopNode[];
|
|
280
|
+
errorRates: HistoryStatsErrorRates;
|
|
281
|
+
elapsedMs: number;
|
|
282
|
+
}
|
|
283
|
+
interface ScanResult {
|
|
284
|
+
schemaVersion: 1;
|
|
285
|
+
/** Unix milliseconds when the scan started. */
|
|
286
|
+
scannedAt: number;
|
|
287
|
+
/** Scan scope. `project` walks the cwd repo; `global` walks user-level skill dirs. */
|
|
288
|
+
scope: 'project' | 'global';
|
|
289
|
+
/**
|
|
290
|
+
* Filesystem roots that were walked during this scan. Spec requires
|
|
291
|
+
* `minItems: 1` — `runScan` throws if `roots: []` is supplied.
|
|
292
|
+
*/
|
|
293
|
+
roots: string[];
|
|
294
|
+
/** Provider ids that participated in classification. Empty if no Provider matched. */
|
|
295
|
+
providers: string[];
|
|
296
|
+
/** Implementation metadata. Populated by `runScan` for self-describing output. */
|
|
297
|
+
scannedBy?: ScanScannedBy;
|
|
298
|
+
nodes: Node[];
|
|
299
|
+
links: Link[];
|
|
300
|
+
issues: Issue[];
|
|
301
|
+
stats: ScanStats;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Extension registry — six kinds, first-class, loaded through a single API.
|
|
306
|
+
*
|
|
307
|
+
* The `Extension` shape is aligned with `spec/schemas/extensions/base.schema.json`.
|
|
308
|
+
* Kind-specific manifests (provider / extractor / rule / action / formatter /
|
|
309
|
+
* hook) extend this base structurally; the registry stores the base view
|
|
310
|
+
* and each kind's code carries its own fuller type where needed.
|
|
311
|
+
*
|
|
312
|
+
* **Spec § A.6 — qualified ids.** Every extension is keyed in the registry
|
|
313
|
+
* by `<pluginId>/<id>` (e.g. `core/frontmatter`, `claude/slash`,
|
|
314
|
+
* `hello-world/greet`). `Extension.id` carries the **short** id as authored;
|
|
315
|
+
* `Extension.pluginId` carries the namespace; the registry composes the
|
|
316
|
+
* qualifier internally and exposes lookup APIs that operate on either form
|
|
317
|
+
* (qualified for direct lookup, kind-scoped listing for enumeration).
|
|
318
|
+
*
|
|
319
|
+
* Boot invariant: `new Registry()` is empty. `registry.totalCount() === 0`
|
|
320
|
+
* when the kernel boots with zero extensions. This is the data side of the
|
|
321
|
+
* `kernel-empty-boot` conformance contract.
|
|
322
|
+
*/
|
|
323
|
+
|
|
324
|
+
type ExtensionKind = 'provider' | 'extractor' | 'rule' | 'action' | 'formatter' | 'hook';
|
|
325
|
+
declare const EXTENSION_KINDS: readonly ExtensionKind[];
|
|
326
|
+
interface Extension {
|
|
327
|
+
/** Short (unqualified) extension id as declared in the manifest. */
|
|
328
|
+
id: string;
|
|
329
|
+
/** Owning plugin namespace. Composed with `id` to form the qualified key. */
|
|
330
|
+
pluginId: string;
|
|
331
|
+
kind: ExtensionKind;
|
|
332
|
+
version: string;
|
|
333
|
+
description?: string;
|
|
334
|
+
stability?: Stability;
|
|
335
|
+
preconditions?: string[];
|
|
336
|
+
entry?: string;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Compose the qualified registry key for an extension. Single source of
|
|
340
|
+
* truth so callers don't reinvent the format and a future change (e.g. a
|
|
341
|
+
* different separator) lands in one place.
|
|
342
|
+
*/
|
|
343
|
+
declare function qualifiedExtensionId(pluginId: string, id: string): string;
|
|
344
|
+
declare class DuplicateExtensionError extends Error {
|
|
345
|
+
constructor(kind: ExtensionKind, qualifiedId: string);
|
|
346
|
+
}
|
|
347
|
+
declare class Registry {
|
|
348
|
+
#private;
|
|
349
|
+
constructor();
|
|
350
|
+
register(ext: Extension): void;
|
|
351
|
+
/**
|
|
352
|
+
* Lookup by qualified id (`<pluginId>/<id>`). Returns `undefined` when
|
|
353
|
+
* no extension of that kind is registered under the qualifier.
|
|
354
|
+
*/
|
|
355
|
+
get(kind: ExtensionKind, qualifiedId: string): Extension | undefined;
|
|
356
|
+
/**
|
|
357
|
+
* Convenience wrapper that composes the qualified id for the caller.
|
|
358
|
+
* Equivalent to `get(kind, qualifiedExtensionId(pluginId, id))`.
|
|
359
|
+
*/
|
|
360
|
+
find(kind: ExtensionKind, pluginId: string, id: string): Extension | undefined;
|
|
361
|
+
all(kind: ExtensionKind): Extension[];
|
|
362
|
+
count(kind: ExtensionKind): number;
|
|
363
|
+
totalCount(): number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `.skill-mapignore` parser + filter facade. Wraps `ignore` (kaelzhang)
|
|
368
|
+
* with the project-local layering: bundled defaults → `config.ignore`
|
|
369
|
+
* (from `.skill-map/settings.json`) → `.skill-mapignore` file content.
|
|
370
|
+
*
|
|
371
|
+
* Why a wrapper instead of exposing `ignore` directly:
|
|
372
|
+
*
|
|
373
|
+
* 1. Single-source defaults — `src/config/defaults/skill-mapignore` is
|
|
374
|
+
* the canonical default list, loaded once at module init (or at
|
|
375
|
+
* explicit build time, depending on bundling). The runtime never
|
|
376
|
+
* re-reads it per scan.
|
|
377
|
+
* 2. Stable interface — Providers and the orchestrator depend on a
|
|
378
|
+
* minimal `IIgnoreFilter` shape, so the underlying library can be
|
|
379
|
+
* swapped without touching every consumer.
|
|
380
|
+
* 3. Path normalization — every consumer passes the path RELATIVE to
|
|
381
|
+
* the scan root (POSIX separators); the wrapper guarantees that
|
|
382
|
+
* contract before delegating to `ignore`.
|
|
383
|
+
*/
|
|
384
|
+
interface IIgnoreFilter {
|
|
385
|
+
/**
|
|
386
|
+
* Returns `true` when `relativePath` should be skipped. The caller
|
|
387
|
+
* MUST pass paths relative to the scan root, with POSIX separators
|
|
388
|
+
* (forward slashes), no leading `/`. Directories MAY be passed with
|
|
389
|
+
* or without trailing `/`; the wrapper does not require it.
|
|
390
|
+
*/
|
|
391
|
+
ignores(relativePath: string): boolean;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* `ProgressEmitterPort` — emits progress events during long operations.
|
|
396
|
+
*
|
|
397
|
+
* Shape-only today. The full event catalog (`run.started`,
|
|
398
|
+
* `job.claimed`, `model.delta`, etc.) is normative in
|
|
399
|
+
* `spec/job-events.md`; this port carries an open `data` payload so
|
|
400
|
+
* adapters can emit any documented event without type churn.
|
|
401
|
+
*/
|
|
402
|
+
interface ProgressEvent {
|
|
403
|
+
type: string;
|
|
404
|
+
timestamp: string;
|
|
405
|
+
runId?: string;
|
|
406
|
+
jobId?: string;
|
|
407
|
+
data?: unknown;
|
|
408
|
+
}
|
|
409
|
+
type ProgressListener = (event: ProgressEvent) => void;
|
|
410
|
+
interface ProgressEmitterPort {
|
|
411
|
+
emit(event: ProgressEvent): void;
|
|
412
|
+
subscribe(listener: ProgressListener): () => void;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Plugin-surface types, hand-written to mirror
|
|
417
|
+
* `spec/schemas/plugins-registry.schema.json#/$defs/PluginManifest` and the
|
|
418
|
+
* extension-kind manifests under `spec/schemas/extensions/`.
|
|
419
|
+
*
|
|
420
|
+
* Per ROADMAP §DTO gap (review-pass decision): the proper emission of
|
|
421
|
+
* typed DTOs from `@skill-map/spec` is deferred to a future iteration when a
|
|
422
|
+
* third consumer (real providers / extractors / rules) forces a single
|
|
423
|
+
* source of truth. Until then, both `ui/src/models/` and `src/kernel/types/`
|
|
424
|
+
* hand-curate their own local mirror — the risk of drift is accepted at
|
|
425
|
+
* this scale (17 schemas) and flagged in the roadmap.
|
|
426
|
+
*/
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Plugin storage mode. Matches the `oneOf` in the plugin manifest schema:
|
|
430
|
+
* either shared `state_plugin_kvs` (mode `kv`) or dedicated plugin-owned
|
|
431
|
+
* tables with explicit migrations (mode `dedicated`). Absent = the plugin
|
|
432
|
+
* does not persist state at all.
|
|
433
|
+
*
|
|
434
|
+
* Optional output-schema declarations (spec § A.12 — opt-in correctness
|
|
435
|
+
* for plugin custom storage):
|
|
436
|
+
* - Mode `kv` → `schema` (single relative path). Validates the value
|
|
437
|
+
* written by `ctx.store.set(key, value)`.
|
|
438
|
+
* - Mode `dedicated` → `schemas` (per-table relative paths). Validates
|
|
439
|
+
* each row written by `ctx.store.write(table, row)` whose table has
|
|
440
|
+
* a declared schema; tables absent from the map accept any shape.
|
|
441
|
+
*
|
|
442
|
+
* Absent in both cases = permissive (status quo, no validation). Schema
|
|
443
|
+
* load failures surface as `load-error`. `emitLink` and `enrichNode`
|
|
444
|
+
* keep their universal kernel validation regardless of these fields.
|
|
445
|
+
*/
|
|
446
|
+
type TPluginStorage = {
|
|
447
|
+
mode: 'kv';
|
|
448
|
+
schema?: string;
|
|
449
|
+
} | {
|
|
450
|
+
mode: 'dedicated';
|
|
451
|
+
tables: string[];
|
|
452
|
+
migrations: string[];
|
|
453
|
+
schemas?: Record<string, string>;
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* Toggle granularity for a plugin / built-in bundle.
|
|
457
|
+
*
|
|
458
|
+
* - `'bundle'` — the plugin id is the only enable/disable key. The whole
|
|
459
|
+
* bundle of extensions follows the toggle; the user cannot
|
|
460
|
+
* enable some extensions of the bundle and disable others.
|
|
461
|
+
* Default for plugins (and for the built-in `claude`
|
|
462
|
+
* bundle, where the provider and its kind-aware extractors
|
|
463
|
+
* form a coherent provider).
|
|
464
|
+
* - `'extension'` — each extension is independently toggle-able under its
|
|
465
|
+
* qualified id `<plugin-id>/<extension-id>`. Used for
|
|
466
|
+
* the built-in `core` bundle (every kernel built-in
|
|
467
|
+
* rule / formatter is removable per spec
|
|
468
|
+
* "no extension is privileged"). Plugin authors opt in
|
|
469
|
+
* only when the plugin ships several orthogonal
|
|
470
|
+
* capabilities a user might reasonably want piecemeal.
|
|
471
|
+
*/
|
|
472
|
+
type TGranularity = 'bundle' | 'extension';
|
|
473
|
+
/** Raw `plugin.json` shape after successful AJV validation. */
|
|
474
|
+
interface IPluginManifest {
|
|
475
|
+
id: string;
|
|
476
|
+
version: string;
|
|
477
|
+
specCompat: string;
|
|
478
|
+
extensions: string[];
|
|
479
|
+
description?: string;
|
|
480
|
+
storage?: TPluginStorage;
|
|
481
|
+
/**
|
|
482
|
+
* Toggle granularity for this plugin. Default `'bundle'`. See
|
|
483
|
+
* `TGranularity` for the trade-off; in practice 95% of plugins want
|
|
484
|
+
* the default.
|
|
485
|
+
*/
|
|
486
|
+
granularity?: TGranularity;
|
|
487
|
+
author?: string;
|
|
488
|
+
license?: string;
|
|
489
|
+
homepage?: string;
|
|
490
|
+
repository?: string;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Failure mode produced by the loader when a plugin cannot be loaded.
|
|
494
|
+
* Matches the three states named in spec §Plugin discovery / load.
|
|
495
|
+
*
|
|
496
|
+
* - `incompatible-spec`: manifest parsed fine but `semver.satisfies` failed
|
|
497
|
+
* against the installed `@skill-map/spec` version.
|
|
498
|
+
* - `invalid-manifest`: `plugin.json` missing, unparseable, or failing AJV.
|
|
499
|
+
* - `load-error`: manifest passed but an extension module failed to import
|
|
500
|
+
* or the imported manifest failed its extension-kind schema.
|
|
501
|
+
*/
|
|
502
|
+
/**
|
|
503
|
+
* Possible outcomes after the loader sees a plugin.json. Mirrors the
|
|
504
|
+
* `status` enum in `spec/schemas/plugins-registry.schema.json`.
|
|
505
|
+
*
|
|
506
|
+
* - `enabled` — manifest valid, specCompat satisfied, every
|
|
507
|
+
* extension imported and validated.
|
|
508
|
+
* - `disabled` — user-toggled off via `sm plugins disable` or
|
|
509
|
+
* `settings.json#/plugins/<id>/enabled`. Manifest
|
|
510
|
+
* is parsed and surfaced (so `sm plugins list`
|
|
511
|
+
* shows it), but extensions are not imported.
|
|
512
|
+
* - `incompatible-spec` — manifest parsed but `semver.satisfies` failed.
|
|
513
|
+
* - `invalid-manifest` — `plugin.json` missing, unparseable, AJV-fails,
|
|
514
|
+
* OR the directory name does not equal the
|
|
515
|
+
* manifest id (a cheap structural rule that
|
|
516
|
+
* rules out same-root collisions by construction:
|
|
517
|
+
* a filesystem cannot contain two siblings with
|
|
518
|
+
* the same name).
|
|
519
|
+
* - `load-error` — manifest passed, an extension module failed.
|
|
520
|
+
* - `id-collision` — two plugins reachable from different roots
|
|
521
|
+
* (project + global, or any `--plugin-dir`
|
|
522
|
+
* combination) declared the same `id`. Both
|
|
523
|
+
* collided plugins receive this status; no
|
|
524
|
+
* precedence rule applies. The user resolves
|
|
525
|
+
* by renaming one of them and rerunning.
|
|
526
|
+
*/
|
|
527
|
+
type TPluginLoadStatus = 'enabled' | 'disabled' | 'incompatible-spec' | 'invalid-manifest' | 'load-error' | 'id-collision';
|
|
528
|
+
interface ILoadedExtension {
|
|
529
|
+
kind: ExtensionKind;
|
|
530
|
+
id: string;
|
|
531
|
+
/**
|
|
532
|
+
* Owning plugin namespace — `manifest.id` of the `plugin.json` that
|
|
533
|
+
* declared this extension. Composed with `id` to form the qualified
|
|
534
|
+
* registry key `<pluginId>/<id>`. Per spec § A.6 the loader injects
|
|
535
|
+
* this from the manifest; an extension that hand-declares a
|
|
536
|
+
* mismatching `pluginId` is rejected as `invalid-manifest`.
|
|
537
|
+
*/
|
|
538
|
+
pluginId: string;
|
|
539
|
+
version: string;
|
|
540
|
+
entryPath: string;
|
|
541
|
+
/** Raw module namespace as returned by the dynamic `import()`. */
|
|
542
|
+
module: unknown;
|
|
543
|
+
/**
|
|
544
|
+
* Runtime extension instance ready for the registry / orchestrator —
|
|
545
|
+
* the `default` export of `module` (or the module itself when no
|
|
546
|
+
* default), shallow-cloned with `pluginId` injected per spec § A.6.
|
|
547
|
+
*
|
|
548
|
+
* The clone is essential: ESM caches the imported module, so two
|
|
549
|
+
* plugins importing the same file would otherwise share a single
|
|
550
|
+
* mutable instance and overwrite each other's `pluginId`. The loader
|
|
551
|
+
* owns the clone so consumers (CLI, tests) never need to mutate.
|
|
552
|
+
*/
|
|
553
|
+
instance: unknown;
|
|
554
|
+
}
|
|
555
|
+
interface IDiscoveredPlugin {
|
|
556
|
+
/** Absolute path to the plugin directory. */
|
|
557
|
+
path: string;
|
|
558
|
+
/** Plugin id — populated from the manifest if it parsed, else a path hint. */
|
|
559
|
+
id: string;
|
|
560
|
+
status: TPluginLoadStatus;
|
|
561
|
+
/** Only present when status === 'enabled' or 'incompatible-spec'. */
|
|
562
|
+
manifest?: IPluginManifest;
|
|
563
|
+
/** Only present when status === 'enabled'. */
|
|
564
|
+
extensions?: ILoadedExtension[];
|
|
565
|
+
/**
|
|
566
|
+
* Resolved granularity for this plugin. Always populated from
|
|
567
|
+
* `manifest.granularity` (default `'bundle'`) when the manifest parsed;
|
|
568
|
+
* absent for `invalid-manifest` paths where the manifest never validated.
|
|
569
|
+
*/
|
|
570
|
+
granularity?: TGranularity;
|
|
571
|
+
/**
|
|
572
|
+
* Runtime-only — never persisted, never spec-modeled.
|
|
573
|
+
*
|
|
574
|
+
* Spec § A.12 — opt-in JSON Schema validation for plugin custom storage.
|
|
575
|
+
* Populated by the loader when `manifest.storage.schemas` (Mode B) or
|
|
576
|
+
* `manifest.storage.schema` (Mode A) declares schema paths the loader
|
|
577
|
+
* successfully read and AJV-compiled. Consumed by the runtime store
|
|
578
|
+
* wrapper to validate `ctx.store.write(table, row)` (Mode B) and
|
|
579
|
+
* `ctx.store.set(key, value)` (Mode A) before persisting.
|
|
580
|
+
*
|
|
581
|
+
* Mode B layout — keyed by logical table name (without the
|
|
582
|
+
* `plugin_<normalizedId>_` prefix), matching the manifest's `schemas`
|
|
583
|
+
* map. Tables not present in the map accept any shape (permissive).
|
|
584
|
+
*
|
|
585
|
+
* Mode A layout — uses the sentinel key `__kv__` for the single
|
|
586
|
+
* value-shape schema. The sentinel survives the runtime contract change
|
|
587
|
+
* if Mode A ever grows multiple namespaces.
|
|
588
|
+
*
|
|
589
|
+
* Absent (`undefined`) when no schemas were declared OR when the load
|
|
590
|
+
* surfaced a `load-error` (the discovered plugin keeps its failure
|
|
591
|
+
* status; consumers must check `status === 'enabled'`).
|
|
592
|
+
*/
|
|
593
|
+
storageSchemas?: Record<string, IPluginStorageSchema>;
|
|
594
|
+
/** Human-readable diagnostic shown by `sm plugins list/show`. */
|
|
595
|
+
reason?: string;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Runtime-only — a single AJV-compiled storage schema attached to a
|
|
599
|
+
* loaded plugin. The schema path (relative to the plugin directory) is
|
|
600
|
+
* preserved so error messages can name the offending file. `validate`
|
|
601
|
+
* is the AJV `ValidateFunction` itself: it returns `true` on shape
|
|
602
|
+
* match, otherwise `false` with `validate.errors` populated. Typed
|
|
603
|
+
* loosely here (no `ajv/dist/2020.js` import) to keep the shared type
|
|
604
|
+
* module free of Ajv at compile time; the runtime adapter narrows.
|
|
605
|
+
*/
|
|
606
|
+
interface IPluginStorageSchema {
|
|
607
|
+
/** Plugin-relative path to the schema file (`storage.schemas[<table>]` or `storage.schema`). */
|
|
608
|
+
schemaPath: string;
|
|
609
|
+
/** AJV-compiled validator. `errors` is populated after a failed call. */
|
|
610
|
+
validate: ((row: unknown) => boolean) & {
|
|
611
|
+
errors?: {
|
|
612
|
+
instancePath: string;
|
|
613
|
+
message?: string;
|
|
614
|
+
keyword: string;
|
|
615
|
+
}[] | null;
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Plugin store wrappers — runtime injection for `ctx.store` per spec
|
|
621
|
+
* § A.12 (opt-in `outputSchema` for plugin custom storage).
|
|
622
|
+
*
|
|
623
|
+
* Two shapes, mirroring the manifest's storage modes documented in
|
|
624
|
+
* `spec/plugin-kv-api.md`:
|
|
625
|
+
*
|
|
626
|
+
* - Mode A — `KvStore.set(key, value)`. AJV-validates `value` against
|
|
627
|
+
* the schema declared by `manifest.storage.schema` (single
|
|
628
|
+
* value-shape) when present. Absent = permissive.
|
|
629
|
+
* - Mode B — `DedicatedStore.write(table, row)`. AJV-validates `row`
|
|
630
|
+
* against the per-table schema declared in `manifest.storage.schemas`
|
|
631
|
+
* when present. Tables absent from the map accept any shape.
|
|
632
|
+
*
|
|
633
|
+
* Both wrappers are storage-engine agnostic — they accept a `persist`
|
|
634
|
+
* callback the caller supplies. The persistence side (SQLite, in-memory,
|
|
635
|
+
* mock) is the caller's concern; this wrapper's only job is the
|
|
636
|
+
* AJV gate. That separation lets the test suite exercise the validator
|
|
637
|
+
* without spinning up a real DB and lets the kernel adapter (future
|
|
638
|
+
* `state_plugin_kvs` writer / dedicated-table writer) plug in
|
|
639
|
+
* unchanged.
|
|
640
|
+
*
|
|
641
|
+
* Universal validation (`emitLink` against `link.schema.json`,
|
|
642
|
+
* `enrichNode` against `node.schema.json`) is unaffected — it lives on
|
|
643
|
+
* the orchestrator side and runs regardless of the plugin's
|
|
644
|
+
* `outputSchema` opt-in.
|
|
645
|
+
*/
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Sentinel key under which Mode A stores its single value-shape schema
|
|
649
|
+
* inside `IDiscoveredPlugin.storageSchemas`. The sentinel keeps the
|
|
650
|
+
* shared `Record<string, IPluginStorageSchema>` map a single-typed
|
|
651
|
+
* surface across both modes; consumers look up by sentinel for KV and
|
|
652
|
+
* by table name for dedicated.
|
|
653
|
+
*/
|
|
654
|
+
declare const KV_SCHEMA_KEY = "__kv__";
|
|
655
|
+
interface IKvStorePersist {
|
|
656
|
+
(key: string, value: unknown): void | Promise<void>;
|
|
657
|
+
}
|
|
658
|
+
interface IDedicatedStorePersist {
|
|
659
|
+
(table: string, row: unknown): void | Promise<void>;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Mode A wrapper. `set(key, value)` AJV-validates `value` against the
|
|
663
|
+
* Mode A schema (sentinel key `__kv__`) when declared, then forwards
|
|
664
|
+
* to `persist`. Validation failure throws with a message naming the
|
|
665
|
+
* schema path and AJV errors; persistence is skipped on failure.
|
|
666
|
+
*
|
|
667
|
+
* `pluginId` is captured for diagnostics (the throw message names the
|
|
668
|
+
* plugin). The wrapper does NOT itself scope by plugin id — that is
|
|
669
|
+
* the persistence layer's job (the spec's `state_plugin_kvs` PK includes
|
|
670
|
+
* `pluginId` and the kernel-side adapter prepends it before write).
|
|
671
|
+
*/
|
|
672
|
+
interface IKvStoreWrapper {
|
|
673
|
+
set(key: string, value: unknown): Promise<void>;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Union shape exposed to extractors via `ctx.store`. Spec § A.12 — Mode A
|
|
677
|
+
* (`kv`) returns a `set(key, value)` surface; Mode B (`dedicated`) returns
|
|
678
|
+
* `write(table, row)`. Plugin authors narrow at the call site based on
|
|
679
|
+
* the storage mode declared in their `plugin.json`.
|
|
680
|
+
*/
|
|
681
|
+
type IPluginStore = IKvStoreWrapper | IDedicatedStoreWrapper;
|
|
682
|
+
declare function makeKvStoreWrapper(opts: {
|
|
683
|
+
pluginId: string;
|
|
684
|
+
schema: IPluginStorageSchema | undefined;
|
|
685
|
+
persist: IKvStorePersist;
|
|
686
|
+
}): IKvStoreWrapper;
|
|
687
|
+
/**
|
|
688
|
+
* Mode B wrapper. `write(table, row)` AJV-validates `row` against
|
|
689
|
+
* `storageSchemas[table]` when declared, then forwards to `persist`.
|
|
690
|
+
* Tables absent from the map are permissive — the wrapper forwards
|
|
691
|
+
* straight to `persist` without validation.
|
|
692
|
+
*
|
|
693
|
+
* The wrapper accepts the full `storageSchemas` map (rather than a
|
|
694
|
+
* single schema) so a plugin author can declare schemas for some
|
|
695
|
+
* tables and leave others permissive in the same map without the
|
|
696
|
+
* caller having to lookup-then-narrow.
|
|
697
|
+
*/
|
|
698
|
+
interface IDedicatedStoreWrapper {
|
|
699
|
+
write(table: string, row: unknown): Promise<void>;
|
|
700
|
+
}
|
|
701
|
+
declare function makeDedicatedStoreWrapper(opts: {
|
|
702
|
+
pluginId: string;
|
|
703
|
+
schemas: Record<string, IPluginStorageSchema> | undefined;
|
|
704
|
+
persist: IDedicatedStorePersist;
|
|
705
|
+
}): IDedicatedStoreWrapper;
|
|
706
|
+
/**
|
|
707
|
+
* Convenience entry point: build whichever wrapper matches the
|
|
708
|
+
* discovered plugin's storage mode. Returns `undefined` when the
|
|
709
|
+
* plugin declared no storage at all (the orchestrator omits
|
|
710
|
+
* `ctx.store` in that case, per the existing contract). Mode A
|
|
711
|
+
* extracts the sentinel-keyed schema; Mode B forwards the full map.
|
|
712
|
+
*/
|
|
713
|
+
declare function makePluginStore(opts: {
|
|
714
|
+
plugin: IDiscoveredPlugin;
|
|
715
|
+
persistKv?: IKvStorePersist;
|
|
716
|
+
persistDedicated?: IDedicatedStorePersist;
|
|
717
|
+
}): IPluginStore | undefined;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Base manifest shape shared by every extension kind. Mirrors
|
|
721
|
+
* `spec/schemas/extensions/base.schema.json` at the TypeScript level.
|
|
722
|
+
*
|
|
723
|
+
* Spec § A.6 — every extension is identified in the registry by the
|
|
724
|
+
* qualified id `<pluginId>/<id>`. The `pluginId` field is required at the
|
|
725
|
+
* runtime / TS level: built-ins declare it directly in
|
|
726
|
+
* `src/extensions/built-ins.ts`; user plugins have it injected by the
|
|
727
|
+
* `PluginLoader` from `plugin.json#/id` before the extension reaches the
|
|
728
|
+
* registry. A plugin author who hand-codes a `pluginId` that disagrees
|
|
729
|
+
* with the manifest's `id` is rejected as `invalid-manifest`.
|
|
730
|
+
*
|
|
731
|
+
* The JSON Schema deliberately does NOT model `pluginId` — the qualifier
|
|
732
|
+
* is a runtime concern composed by the loader, not a manifest field
|
|
733
|
+
* authors are expected to set. Stripping it before AJV validation in
|
|
734
|
+
* the loader keeps the spec contract clean ("authors declare only the
|
|
735
|
+
* short id").
|
|
736
|
+
*/
|
|
737
|
+
|
|
738
|
+
interface IExtensionBase {
|
|
739
|
+
id: string;
|
|
740
|
+
/**
|
|
741
|
+
* Owning plugin namespace. Composed with `id` to produce the
|
|
742
|
+
* qualified registry key `<pluginId>/<id>`. Built-ins declare this
|
|
743
|
+
* directly; user plugins have it injected by the `PluginLoader`
|
|
744
|
+
* from `plugin.json#/id`.
|
|
745
|
+
*/
|
|
746
|
+
pluginId: string;
|
|
747
|
+
version: string;
|
|
748
|
+
description?: string;
|
|
749
|
+
stability?: Stability;
|
|
750
|
+
preconditions?: string[];
|
|
751
|
+
entry?: string;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Provider runtime contract. Walks filesystem roots and emits raw node
|
|
756
|
+
* records; classification maps path conventions to a node kind.
|
|
757
|
+
*
|
|
758
|
+
* Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`,
|
|
759
|
+
* `StoragePort.adapter`, etc.). A `Provider` is an extension kind authored
|
|
760
|
+
* by plugins to declare a platform's universe (the catalog of kinds it
|
|
761
|
+
* emits, the per-kind frontmatter schema, the filesystem directory it
|
|
762
|
+
* owns); a hexagonal adapter is an internal implementation of a port.
|
|
763
|
+
* Both can coexist without confusion because they live in different
|
|
764
|
+
* namespaces.
|
|
765
|
+
*
|
|
766
|
+
* `walk()` is an async iterator so large scopes don't buffer in memory.
|
|
767
|
+
* Each yielded `IRawNode` carries the full parsed frontmatter + body plus
|
|
768
|
+
* the path relative to the scan root; the kernel computes hashes, bytes,
|
|
769
|
+
* and tokens on top.
|
|
770
|
+
*
|
|
771
|
+
* **Spec 0.8.0**. Per-kind frontmatter schemas relocated from the spec
|
|
772
|
+
* to the Provider that owns them. The flat
|
|
773
|
+
* `defaultRefreshAction` map collapsed into the new `kinds` map: every
|
|
774
|
+
* kind the Provider emits gets one entry that declares both its schema
|
|
775
|
+
* and its refresh action. Spec keeps only `frontmatter/base.schema.json`
|
|
776
|
+
* (universal); per-kind schemas live with the Provider.
|
|
777
|
+
*/
|
|
778
|
+
|
|
779
|
+
interface IRawNode {
|
|
780
|
+
/** Path relative to the scan root that produced this node. */
|
|
781
|
+
path: string;
|
|
782
|
+
/** Raw markdown body (everything after the frontmatter fence). */
|
|
783
|
+
body: string;
|
|
784
|
+
/** Raw frontmatter text (between `---` fences). Empty string when absent. */
|
|
785
|
+
frontmatterRaw: string;
|
|
786
|
+
/** Parsed frontmatter, or `{}` when absent / unparseable. */
|
|
787
|
+
frontmatter: Record<string, unknown>;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* One entry in a Provider's `kinds` map. Declares both the per-kind
|
|
791
|
+
* frontmatter schema (path relative to the Provider's package dir, plus
|
|
792
|
+
* the loaded JSON object the kernel passes to AJV) and the qualified
|
|
793
|
+
* default refresh action id the UI dispatches for nodes of this kind.
|
|
794
|
+
*
|
|
795
|
+
* The split between `schema` (manifest-level path) and `schemaJson`
|
|
796
|
+
* (runtime-loaded JSON) keeps the manifest shape spec-conformant while
|
|
797
|
+
* letting the runtime instance carry the parsed schema without a second
|
|
798
|
+
* filesystem read at scan time. Built-in Providers populate `schemaJson`
|
|
799
|
+
* via `import schema from './schemas/skill.schema.json' with { type: 'json' }`;
|
|
800
|
+
* user-plugin Providers loaded by `PluginLoader` will have it filled in
|
|
801
|
+
* by the loader after manifest validation.
|
|
802
|
+
*/
|
|
803
|
+
interface IProviderKind {
|
|
804
|
+
/**
|
|
805
|
+
* Path to the kind's frontmatter JSON Schema, relative to the
|
|
806
|
+
* Provider's package directory. Mirrors the spec field of the same
|
|
807
|
+
* name in `extensions/provider.schema.json#/properties/kinds/.../schema`.
|
|
808
|
+
*/
|
|
809
|
+
schema: string;
|
|
810
|
+
/**
|
|
811
|
+
* Loaded JSON Schema document for the kind. The kernel registers this
|
|
812
|
+
* with AJV at scan boot and validates each node's frontmatter against
|
|
813
|
+
* it. The schema MUST extend the spec's
|
|
814
|
+
* `frontmatter/base.schema.json` via `allOf` + `$ref` to base's
|
|
815
|
+
* `$id`; the loader registers base into the same AJV instance so
|
|
816
|
+
* cross-package `$ref`-by-`$id` resolves transparently.
|
|
817
|
+
*
|
|
818
|
+
* `unknown` rather than a stronger type because AJV consumes any JSON
|
|
819
|
+
* Schema object; tightening to a concrete shape would require mirroring
|
|
820
|
+
* the JSON Schema vocabulary in TypeScript.
|
|
821
|
+
*/
|
|
822
|
+
schemaJson: unknown;
|
|
823
|
+
/**
|
|
824
|
+
* Qualified action id (`<plugin-id>/<action-id>`) the probabilistic-
|
|
825
|
+
* refresh UI dispatches for nodes of this kind. The kernel resolves
|
|
826
|
+
* the id against its qualified action registry; a dangling reference
|
|
827
|
+
* disables the Provider with status `invalid-manifest`.
|
|
828
|
+
*/
|
|
829
|
+
defaultRefreshAction: string;
|
|
830
|
+
}
|
|
831
|
+
interface IProvider extends IExtensionBase {
|
|
832
|
+
kind: 'provider';
|
|
833
|
+
/**
|
|
834
|
+
* Filesystem directory (relative to user home or project root) where this
|
|
835
|
+
* Provider's content lives. Required. Examples: `'~/.claude'` for the
|
|
836
|
+
* Claude Provider, `'~/.cursor'` for a hypothetical Cursor Provider.
|
|
837
|
+
* The kernel walks this directory during boot/scan to discover nodes;
|
|
838
|
+
* `sm doctor` validates the directory exists and emits a non-blocking
|
|
839
|
+
* warning when it does not.
|
|
840
|
+
*/
|
|
841
|
+
explorationDir: string;
|
|
842
|
+
/**
|
|
843
|
+
* Catalog of node kinds this Provider emits. Keyed by kind name. Every
|
|
844
|
+
* kind the Provider can `classify()` MUST have an entry; an entry is
|
|
845
|
+
* the union of the kind's frontmatter schema and its default refresh
|
|
846
|
+
* action.
|
|
847
|
+
*
|
|
848
|
+
* The string keys are typed loosely (`string`) rather than `NodeKind`
|
|
849
|
+
* because the value space is open by design: a future Cursor Provider
|
|
850
|
+
* could declare `rule`, an Obsidian Provider could declare `daily`.
|
|
851
|
+
* The kernel's hard-coded `NodeKind` union represents the kinds the
|
|
852
|
+
* built-in Claude Provider emits; it is NOT the kernel-wide kind type
|
|
853
|
+
* (see `kernel/types.ts:NodeKind` docstring). `Node.kind`, the AJV
|
|
854
|
+
* `node.schema.json` validator, and the SQLite `scan_nodes.kind`
|
|
855
|
+
* column all accept any non-empty string an enabled Provider returns.
|
|
856
|
+
*/
|
|
857
|
+
kinds: Record<string, IProviderKind>;
|
|
858
|
+
/**
|
|
859
|
+
* Walk the given roots and yield every node the Provider recognises.
|
|
860
|
+
* Non-matching files are silently skipped. Unreadable files produce
|
|
861
|
+
* a diagnostic via the emitter but do not abort the walk.
|
|
862
|
+
*
|
|
863
|
+
* `options.ignoreFilter` — when supplied, the Provider MUST
|
|
864
|
+
* skip every directory and file whose path-relative-to-root the
|
|
865
|
+
* filter reports as ignored. Providers MAY also keep their own
|
|
866
|
+
* hard-coded skip list (e.g. `.git`) as a defensive measure, but the
|
|
867
|
+
* filter is the canonical source of user intent.
|
|
868
|
+
*/
|
|
869
|
+
walk(roots: string[], options?: {
|
|
870
|
+
ignoreFilter?: IIgnoreFilter;
|
|
871
|
+
}): AsyncIterable<IRawNode>;
|
|
872
|
+
/**
|
|
873
|
+
* Given a path and its parsed frontmatter, decide the node kind. The
|
|
874
|
+
* classifier is called after walk() yields — Providers MAY embed the
|
|
875
|
+
* logic inside walk itself, but exposing it lets the kernel rebuild
|
|
876
|
+
* classification during partial scans without re-walking.
|
|
877
|
+
*
|
|
878
|
+
* Returns an open `string`. The returned value MUST be a key of the
|
|
879
|
+
* Provider's own `kinds` catalog; the orchestrator does not validate
|
|
880
|
+
* the kind against `NodeKind`. External Providers (Cursor, Obsidian,
|
|
881
|
+
* …) freely return their own kinds (e.g. `'cursorRule'`, `'daily'`).
|
|
882
|
+
*/
|
|
883
|
+
classify(path: string, frontmatter: Record<string, unknown>): string;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Extractor runtime contract. Consumes a single node (frontmatter + body)
|
|
888
|
+
* and emits its output through three context-supplied callbacks rather than
|
|
889
|
+
* a return value. Extractors run in isolation: they MUST NOT read other
|
|
890
|
+
* nodes, the graph, or the DB. Cross-node reasoning lives in rules.
|
|
891
|
+
*
|
|
892
|
+
* Output channels (all on the context):
|
|
893
|
+
*
|
|
894
|
+
* - `ctx.emitLink(link)` — persist a link in the kernel's `links` table.
|
|
895
|
+
* Validated against `emitsLinkKinds` before insertion; an off-contract
|
|
896
|
+
* kind drops the link and surfaces an `extension.error` event.
|
|
897
|
+
* - `ctx.enrichNode(partial)` — merge canonical, kernel-curated properties
|
|
898
|
+
* onto the node. Strictly separate from the author-supplied frontmatter
|
|
899
|
+
* (the latter remains immutable and survives verbatim). Persistence and
|
|
900
|
+
* stale-tracking are spec'd in § A.8.
|
|
901
|
+
* - `ctx.store` — plugin-scoped persistence. Present only when the
|
|
902
|
+
* plugin declares `storage.mode` in `plugin.json`; shape depends on the
|
|
903
|
+
* mode (`KvStore` for mode A, scoped `Database` for mode B). See
|
|
904
|
+
* `plugin-kv-api.md` for the contract.
|
|
905
|
+
* - `ctx.runner` — `RunnerPort` injection for `probabilistic` extractors.
|
|
906
|
+
* `undefined` for the default `deterministic` mode.
|
|
907
|
+
*
|
|
908
|
+
* The manifest's `scope` field tells the orchestrator which parts to feed:
|
|
909
|
+
* `frontmatter` extractors receive an empty string for body and vice versa.
|
|
910
|
+
*
|
|
911
|
+
* Renamed from `Detector` in spec 0.8.x. The previous `detect(ctx) → Link[]`
|
|
912
|
+
* signature is gone; everything now flows through `extract(ctx) → void`
|
|
913
|
+
* and the callbacks above.
|
|
914
|
+
*/
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Output callbacks supplied by the kernel on the extractor context.
|
|
918
|
+
* Split out so plugin authors can name the callback shape if they
|
|
919
|
+
* want to mock it in unit tests without depending on the wider
|
|
920
|
+
* `IExtractorContext`.
|
|
921
|
+
*/
|
|
922
|
+
interface IExtractorCallbacks {
|
|
923
|
+
/**
|
|
924
|
+
* Emit a single Link. The orchestrator validates the link against the
|
|
925
|
+
* extractor's declared `emitsLinkKinds` before inserting it; off-contract
|
|
926
|
+
* links are silently dropped with an `extension.error` event.
|
|
927
|
+
*/
|
|
928
|
+
emitLink(link: Link): void;
|
|
929
|
+
/**
|
|
930
|
+
* Merge canonical, kernel-curated properties onto the current node's
|
|
931
|
+
* enrichment layer. The author-supplied frontmatter stays untouched
|
|
932
|
+
* (Decision #109 in `ROADMAP.md`). Persistence and stale-tracking
|
|
933
|
+
* semantics live in spec § A.8; the orchestrator already buffers the
|
|
934
|
+
* partials and `persistScanResult` upserts them.
|
|
935
|
+
*/
|
|
936
|
+
enrichNode(partial: Partial<Node>): void;
|
|
937
|
+
}
|
|
938
|
+
interface IExtractorContext extends IExtractorCallbacks {
|
|
939
|
+
node: Node;
|
|
940
|
+
body: string;
|
|
941
|
+
frontmatter: Record<string, unknown>;
|
|
942
|
+
/**
|
|
943
|
+
* Plugin-scoped persistence. Optional because not every plugin declares
|
|
944
|
+
* a `storage.mode` in `plugin.json`. Shape: `KvStoreWrapper` for mode A
|
|
945
|
+
* (`set(key, value)`), `DedicatedStoreWrapper` for mode B
|
|
946
|
+
* (`write(table, row)`). See `spec/plugin-kv-api.md`.
|
|
947
|
+
*
|
|
948
|
+
* Typed as `unknown` so this contract module stays free of any
|
|
949
|
+
* adapter-side imports — the concrete `IPluginStore` lives in
|
|
950
|
+
* `kernel/adapters/plugin-store.js`. Plugin authors narrow at the
|
|
951
|
+
* call site based on the storage mode declared in their manifest.
|
|
952
|
+
* The orchestrator looks up the wrapper per-extractor in
|
|
953
|
+
* `RunScanOptions.pluginStores` (keyed by `pluginId`) and attaches
|
|
954
|
+
* it here.
|
|
955
|
+
*/
|
|
956
|
+
store?: unknown;
|
|
957
|
+
/**
|
|
958
|
+
* `RunnerPort` injection for `probabilistic` extractors. `undefined`
|
|
959
|
+
* for `deterministic` mode (the default). The kernel rejects
|
|
960
|
+
* probabilistic extractors that try to register scan-time hooks at
|
|
961
|
+
* load time.
|
|
962
|
+
*/
|
|
963
|
+
runner?: unknown;
|
|
964
|
+
}
|
|
965
|
+
interface IExtractor extends IExtensionBase {
|
|
966
|
+
kind: 'extractor';
|
|
967
|
+
/**
|
|
968
|
+
* Execution mode. Optional in the manifest with a default of
|
|
969
|
+
* `deterministic` per `spec/schemas/extensions/extractor.schema.json`.
|
|
970
|
+
* `probabilistic` extractors invoke an LLM through the kernel's
|
|
971
|
+
* `RunnerPort` and never participate in scan-time pipelines —
|
|
972
|
+
* they dispatch only as queued jobs.
|
|
973
|
+
*/
|
|
974
|
+
mode?: TExecutionMode;
|
|
975
|
+
emitsLinkKinds: LinkKind[];
|
|
976
|
+
defaultConfidence: Confidence;
|
|
977
|
+
scope: 'frontmatter' | 'body' | 'both';
|
|
978
|
+
/**
|
|
979
|
+
* Optional opt-in filter on `node.kind`. When declared, the orchestrator
|
|
980
|
+
* skips invocation of `extract()` for any node whose `kind` is NOT in
|
|
981
|
+
* this list — fail-fast, before context construction, so a
|
|
982
|
+
* probabilistic extractor wastes zero LLM cost on inapplicable nodes
|
|
983
|
+
* and a deterministic extractor wastes zero CPU.
|
|
984
|
+
*
|
|
985
|
+
* Absent (`undefined`) is the default: the extractor applies to every
|
|
986
|
+
* kind. There are no wildcards — the absence of the field already
|
|
987
|
+
* encodes "every kind". An empty array (`[]`) is rejected at load
|
|
988
|
+
* time by AJV (`minItems: 1` in the schema).
|
|
989
|
+
*
|
|
990
|
+
* Unknown kinds (no installed Provider declares them) do NOT block
|
|
991
|
+
* the load: the extractor keeps `loaded` status and `sm plugins doctor`
|
|
992
|
+
* surfaces a warning. The Provider that declares the kind may arrive
|
|
993
|
+
* later (e.g. a user installs the corresponding plugin).
|
|
994
|
+
*
|
|
995
|
+
* Spec: `spec/schemas/extensions/extractor.schema.json#/properties/applicableKinds`.
|
|
996
|
+
*/
|
|
997
|
+
applicableKinds?: string[];
|
|
998
|
+
/**
|
|
999
|
+
* Extractor entry point. Returns nothing; output flows through
|
|
1000
|
+
* `ctx.emitLink`, `ctx.enrichNode`, and `ctx.store`.
|
|
1001
|
+
*/
|
|
1002
|
+
extract(ctx: IExtractorContext): void | Promise<void>;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Rule runtime contract. Runs against the whole graph after every Provider
|
|
1007
|
+
* and extractor has completed; emits issues. Deterministic rules are pure
|
|
1008
|
+
* (same graph in → same issues out) and run synchronously inside `sm scan`
|
|
1009
|
+
* / `sm check`. Probabilistic rules invoke an LLM through the kernel's
|
|
1010
|
+
* `RunnerPort` and dispatch only as queued jobs — they never participate
|
|
1011
|
+
* in scan-time pipelines. Mode is declared in the manifest (default
|
|
1012
|
+
* `deterministic`).
|
|
1013
|
+
*/
|
|
1014
|
+
|
|
1015
|
+
interface IRuleContext {
|
|
1016
|
+
nodes: Node[];
|
|
1017
|
+
links: Link[];
|
|
1018
|
+
}
|
|
1019
|
+
interface IRule extends IExtensionBase {
|
|
1020
|
+
kind: 'rule';
|
|
1021
|
+
/**
|
|
1022
|
+
* Execution mode. Optional in the manifest with a default of
|
|
1023
|
+
* `deterministic` per `spec/schemas/extensions/rule.schema.json`.
|
|
1024
|
+
*/
|
|
1025
|
+
mode?: TExecutionMode;
|
|
1026
|
+
evaluate(ctx: IRuleContext): Issue[] | Promise<Issue[]>;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Action runtime contract. The fourth plugin kind (spec § A.4 +
|
|
1031
|
+
* `spec/schemas/extensions/action.schema.json`).
|
|
1032
|
+
*
|
|
1033
|
+
* Actions operate on one or more nodes in one of two execution modes:
|
|
1034
|
+
*
|
|
1035
|
+
* - `deterministic` — code runs in-process; the action computes the
|
|
1036
|
+
* report synchronously and returns it. No job file, no runner.
|
|
1037
|
+
* - `probabilistic` — the kernel renders a prompt + preamble into a
|
|
1038
|
+
* job file; a runner executes it via `RunnerPort` against an LLM;
|
|
1039
|
+
* `sm record` closes the job and validates the report against
|
|
1040
|
+
* `reportSchemaRef`.
|
|
1041
|
+
*
|
|
1042
|
+
* **Deferred runtime invocation.** The dispatcher (`Action.run(ctx)` for
|
|
1043
|
+
* deterministic; the `RunnerPort` + `sm record` round-trip for
|
|
1044
|
+
* probabilistic) lands with the job subsystem (Decision #114 in
|
|
1045
|
+
* `ROADMAP.md`). Today the loader still validates `kind: 'action'`
|
|
1046
|
+
* manifests against `extension-action.schema.json` and the registry
|
|
1047
|
+
* holds them — `sm actions show` and the precondition gating UI consume
|
|
1048
|
+
* the manifest data. The runtime entry point is intentionally absent
|
|
1049
|
+
* from `IAction` so plugin authors don't ship a method the kernel will
|
|
1050
|
+
* not call until the job subsystem is in place; when it ships, the
|
|
1051
|
+
* method shape will land here without breaking the manifest contract.
|
|
1052
|
+
*
|
|
1053
|
+
* Mirrors `extensions/action.schema.json`:
|
|
1054
|
+
*
|
|
1055
|
+
* - `mode` (required) — discriminator between the two modes.
|
|
1056
|
+
* - `reportSchemaRef` (required) — JSON Schema reference the report
|
|
1057
|
+
* MUST validate against. MUST extend `report-base.schema.json`.
|
|
1058
|
+
* - `promptTemplateRef` — REQUIRED when `mode: 'probabilistic'`,
|
|
1059
|
+
* FORBIDDEN when `mode: 'deterministic'`. The schema's conditional
|
|
1060
|
+
* `allOf` enforces both directions; the runtime contract simply
|
|
1061
|
+
* surfaces the field as optional and lets the loader catch shape
|
|
1062
|
+
* violations at AJV time.
|
|
1063
|
+
* - `expectedDurationSeconds` — REQUIRED for probabilistic (drives
|
|
1064
|
+
* TTL); advisory for deterministic.
|
|
1065
|
+
* - `precondition` — declarative filter consumed by `--all` fan-out,
|
|
1066
|
+
* UI button gating, `sm actions show`.
|
|
1067
|
+
* - `expectedTools` — hint to Skill / CLI runners about expected
|
|
1068
|
+
* tools (no normative enforcement in v0).
|
|
1069
|
+
* - `fanOutPolicy` — `'per-node'` (default) vs `'batch'`.
|
|
1070
|
+
*/
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Declarative filter applied by `--all` fan-out, UI button gating, and
|
|
1074
|
+
* `sm actions show`. All fields optional — an empty precondition matches
|
|
1075
|
+
* every node.
|
|
1076
|
+
*/
|
|
1077
|
+
interface IActionPrecondition {
|
|
1078
|
+
/**
|
|
1079
|
+
* Node kinds this action accepts. Open-by-design (matches
|
|
1080
|
+
* `node.schema.json#/properties/kind`): an action declared with
|
|
1081
|
+
* `kind: ['cursorRule']` is valid as long as some Provider classifies
|
|
1082
|
+
* into `cursorRule`. Omitted → any kind.
|
|
1083
|
+
*/
|
|
1084
|
+
kind?: string[];
|
|
1085
|
+
/** Provider ids whose nodes this action accepts. Omitted → any Provider. */
|
|
1086
|
+
provider?: string[];
|
|
1087
|
+
/** Node stability filter. */
|
|
1088
|
+
stability?: Array<'experimental' | 'stable' | 'deprecated'>;
|
|
1089
|
+
/**
|
|
1090
|
+
* Free-form precondition strings the kernel forwards to the action for
|
|
1091
|
+
* runtime evaluation (example: `frontmatter.metadata.source != null`).
|
|
1092
|
+
*/
|
|
1093
|
+
custom?: string[];
|
|
1094
|
+
}
|
|
1095
|
+
interface IAction extends IExtensionBase {
|
|
1096
|
+
kind: 'action';
|
|
1097
|
+
/**
|
|
1098
|
+
* Execution mode discriminator. Required per
|
|
1099
|
+
* `extensions/action.schema.json`.
|
|
1100
|
+
*/
|
|
1101
|
+
mode: TExecutionMode;
|
|
1102
|
+
/**
|
|
1103
|
+
* Reference to the JSON Schema the report MUST validate against. MUST
|
|
1104
|
+
* extend `report-base.schema.json` (directly or transitively).
|
|
1105
|
+
* Validation failure → job transitions to `failed` with reason
|
|
1106
|
+
* `report-invalid`.
|
|
1107
|
+
*/
|
|
1108
|
+
reportSchemaRef: string;
|
|
1109
|
+
/**
|
|
1110
|
+
* Best-effort estimate of wall-clock duration in seconds. Drives TTL
|
|
1111
|
+
* (`ttl = max(expectedDurationSeconds × graceMultiplier,
|
|
1112
|
+
* minimumTtlSeconds)`). Required for `probabilistic`; advisory for
|
|
1113
|
+
* `deterministic`.
|
|
1114
|
+
*/
|
|
1115
|
+
expectedDurationSeconds?: number;
|
|
1116
|
+
/**
|
|
1117
|
+
* Path (relative to the extension file) to the prompt template the
|
|
1118
|
+
* kernel renders at `sm job submit`. REQUIRED when `mode:
|
|
1119
|
+
* 'probabilistic'`; FORBIDDEN when `mode: 'deterministic'`. The
|
|
1120
|
+
* conditional shape is enforced by AJV at load time; the runtime
|
|
1121
|
+
* contract carries the field as optional so both modes share one
|
|
1122
|
+
* interface.
|
|
1123
|
+
*/
|
|
1124
|
+
promptTemplateRef?: string;
|
|
1125
|
+
/**
|
|
1126
|
+
* Optional declarative filter; absent → applies to every node.
|
|
1127
|
+
*/
|
|
1128
|
+
precondition?: IActionPrecondition;
|
|
1129
|
+
/**
|
|
1130
|
+
* Hint to Skill / CLI runners about what tools the rendered prompt
|
|
1131
|
+
* expects access to (`Bash`, `Read`, `WebSearch`, …). No normative
|
|
1132
|
+
* enforcement in v0.
|
|
1133
|
+
*/
|
|
1134
|
+
expectedTools?: string[];
|
|
1135
|
+
/**
|
|
1136
|
+
* `'per-node'` (default): `sm job submit --all` produces one job per
|
|
1137
|
+
* matching node. `'batch'`: one job whose prompt template receives the
|
|
1138
|
+
* full list. Batch actions tend to hit context limits; use sparingly.
|
|
1139
|
+
*/
|
|
1140
|
+
fanOutPolicy?: 'per-node' | 'batch';
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Formatter runtime contract. Turns the (nodes, links, issues) graph into
|
|
1145
|
+
* a textual representation for `sm graph --format <name>`.
|
|
1146
|
+
*
|
|
1147
|
+
* Two adjacent names live on the same instance:
|
|
1148
|
+
*
|
|
1149
|
+
* - `formatId: string` — the manifest field consumed by the
|
|
1150
|
+
* `--format <name>` CLI flag. The kernel's lookup is
|
|
1151
|
+
* `formatters.find((f) => f.formatId === flag)`.
|
|
1152
|
+
* - `format(ctx) → string` — the runtime method. Receives the full
|
|
1153
|
+
* graph and returns the serialized output. Output MUST be
|
|
1154
|
+
* byte-deterministic for the same input (the snapshot-test suite
|
|
1155
|
+
* relies on this).
|
|
1156
|
+
*
|
|
1157
|
+
* The split (`formatId` vs `format`) is deliberate: it keeps the method
|
|
1158
|
+
* named after the kind (`Formatter.format()` reads naturally) while the
|
|
1159
|
+
* field carries the identifier the user types on the command line.
|
|
1160
|
+
*/
|
|
1161
|
+
|
|
1162
|
+
interface IFormatterContext {
|
|
1163
|
+
nodes: Node[];
|
|
1164
|
+
links: Link[];
|
|
1165
|
+
issues: Issue[];
|
|
1166
|
+
}
|
|
1167
|
+
interface IFormatter extends IExtensionBase {
|
|
1168
|
+
kind: 'formatter';
|
|
1169
|
+
/** Format identifier consumed by `sm graph --format <name>`. */
|
|
1170
|
+
formatId: string;
|
|
1171
|
+
/** Serialize the graph into a string. Deterministic-only. */
|
|
1172
|
+
format(ctx: IFormatterContext): string;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Hook runtime contract. The sixth plugin kind (spec § A.11).
|
|
1177
|
+
*
|
|
1178
|
+
* Hooks subscribe declaratively to a curated set of kernel lifecycle
|
|
1179
|
+
* events and react to them. Reaction-only by design: a hook cannot
|
|
1180
|
+
* mutate the pipeline, block emission, or alter outputs. Use cases
|
|
1181
|
+
* are notification (Slack on `job.completed`), integration glue (CI
|
|
1182
|
+
* webhook on `job.failed`), and bookkeeping (per-extractor metrics).
|
|
1183
|
+
*
|
|
1184
|
+
* The hookable trigger set is INTENTIONALLY SMALL — eight events. The
|
|
1185
|
+
* full `ProgressEmitterPort` catalog (per-node `scan.progress`,
|
|
1186
|
+
* `model.delta`, `run.*`, internal job lifecycle) is deliberately not
|
|
1187
|
+
* hookable: too verbose for a reactive surface, internal to the runner,
|
|
1188
|
+
* or covered elsewhere. Declaring a trigger outside the curated set
|
|
1189
|
+
* yields `invalid-manifest` at load time.
|
|
1190
|
+
*
|
|
1191
|
+
* Dual-mode (declared in manifest):
|
|
1192
|
+
*
|
|
1193
|
+
* - `deterministic` (default): `on(ctx)` runs in-process during the
|
|
1194
|
+
* dispatch of the matching event, synchronously between the
|
|
1195
|
+
* event's emission and the next pipeline step. Errors are caught
|
|
1196
|
+
* by the dispatcher, logged via `extension.error`, and never
|
|
1197
|
+
* block the main flow.
|
|
1198
|
+
* - `probabilistic`: the hook is enqueued as a job. Until the job
|
|
1199
|
+
* subsystem ships, probabilistic hooks load but skip dispatch
|
|
1200
|
+
* with a stderr advisory (Decision #114 in `ROADMAP.md`).
|
|
1201
|
+
*
|
|
1202
|
+
* Curated trigger set (per spec § A.11):
|
|
1203
|
+
*
|
|
1204
|
+
* 1. `scan.started` — pre-scan setup (one per scan).
|
|
1205
|
+
* 2. `scan.completed` — post-scan reaction (one per scan).
|
|
1206
|
+
* 3. `extractor.completed` — aggregated per-Extractor outputs.
|
|
1207
|
+
* 4. `rule.completed` — aggregated per-Rule outputs.
|
|
1208
|
+
* 5. `action.completed` — Action executed on a node.
|
|
1209
|
+
* 6. `job.spawning` — pre-spawn of runner subprocess.
|
|
1210
|
+
* 7. `job.completed` — most common trigger.
|
|
1211
|
+
* 8. `job.failed` — alerts, retry triggers.
|
|
1212
|
+
*/
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* The eight hookable lifecycle events. Mirrors the `triggers[]` enum in
|
|
1216
|
+
* `spec/schemas/extensions/hook.schema.json`. Anything outside this set
|
|
1217
|
+
* is rejected at load time as `invalid-manifest`.
|
|
1218
|
+
*/
|
|
1219
|
+
type THookTrigger = 'scan.started' | 'scan.completed' | 'extractor.completed' | 'rule.completed' | 'action.completed' | 'job.spawning' | 'job.completed' | 'job.failed';
|
|
1220
|
+
/**
|
|
1221
|
+
* Frozen list mirror of `THookTrigger` for runtime introspection. The
|
|
1222
|
+
* loader validates `manifest.triggers[]` against this set; the
|
|
1223
|
+
* orchestrator's dispatcher iterates it in order when fanning an event
|
|
1224
|
+
* out to subscribed hooks.
|
|
1225
|
+
*/
|
|
1226
|
+
declare const HOOK_TRIGGERS: readonly THookTrigger[];
|
|
1227
|
+
/**
|
|
1228
|
+
* Context the dispatcher hands to `Hook.on()`. The shape is intentionally
|
|
1229
|
+
* narrow: a hook reacts to an event, it does not steer the pipeline.
|
|
1230
|
+
*
|
|
1231
|
+
* The `event` carries the raw `ProgressEvent` envelope (type, timestamp,
|
|
1232
|
+
* runId/jobId when applicable, data). Optional `node` / `extractorId`
|
|
1233
|
+
* / `ruleId` / `actionId` are extracted from the event payload by the
|
|
1234
|
+
* dispatcher when present so authors don't have to walk `event.data`.
|
|
1235
|
+
*
|
|
1236
|
+
* Probabilistic hooks additionally receive `runner` for LLM dispatch.
|
|
1237
|
+
* Deterministic hooks SHOULD ignore the field.
|
|
1238
|
+
*/
|
|
1239
|
+
interface IHookContext {
|
|
1240
|
+
/** The raw event the dispatcher matched. */
|
|
1241
|
+
event: {
|
|
1242
|
+
type: THookTrigger;
|
|
1243
|
+
timestamp: string;
|
|
1244
|
+
runId?: string;
|
|
1245
|
+
jobId?: string;
|
|
1246
|
+
data?: unknown;
|
|
1247
|
+
};
|
|
1248
|
+
/**
|
|
1249
|
+
* Convenience extraction of the node payload when the event is
|
|
1250
|
+
* node-scoped (`action.completed`). Undefined for run-scoped or
|
|
1251
|
+
* scan-scoped events.
|
|
1252
|
+
*/
|
|
1253
|
+
node?: Node;
|
|
1254
|
+
/**
|
|
1255
|
+
* Set on `extractor.completed` events. Qualified extension id of the
|
|
1256
|
+
* Extractor whose work the event aggregates.
|
|
1257
|
+
*/
|
|
1258
|
+
extractorId?: string;
|
|
1259
|
+
/**
|
|
1260
|
+
* Set on `rule.completed` events. Qualified extension id of the Rule.
|
|
1261
|
+
*/
|
|
1262
|
+
ruleId?: string;
|
|
1263
|
+
/**
|
|
1264
|
+
* Set on `action.completed` events. Qualified extension id of the
|
|
1265
|
+
* Action that just ran.
|
|
1266
|
+
*/
|
|
1267
|
+
actionId?: string;
|
|
1268
|
+
/**
|
|
1269
|
+
* Set on `job.*` events once the job subsystem lands. Carries the
|
|
1270
|
+
* report payload for `job.completed`, the failure record for
|
|
1271
|
+
* `job.failed`, and the spawn metadata for `job.spawning`.
|
|
1272
|
+
*/
|
|
1273
|
+
jobResult?: unknown;
|
|
1274
|
+
/**
|
|
1275
|
+
* `RunnerPort` injection for `probabilistic` hooks. `undefined` for
|
|
1276
|
+
* `deterministic` mode (the default). Probabilistic hooks land with
|
|
1277
|
+
* the job subsystem; the field is reserved here so the runtime
|
|
1278
|
+
* contract is forward-compatible without a major bump.
|
|
1279
|
+
*/
|
|
1280
|
+
runner?: unknown;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Optional declarative filter applied by the dispatcher BEFORE
|
|
1284
|
+
* invoking `on(ctx)`. Keys are payload field paths (top-level only in
|
|
1285
|
+
* v0.x); values are the literal expected match. The dispatcher walks
|
|
1286
|
+
* `event.data` for the field and short-circuits the invocation if the
|
|
1287
|
+
* value disagrees.
|
|
1288
|
+
*
|
|
1289
|
+
* Cross-field validation against declared `triggers` is best-effort
|
|
1290
|
+
* at load time: when none of the declared triggers carries a given
|
|
1291
|
+
* filter field, the loader surfaces `invalid-manifest`. The current
|
|
1292
|
+
* impl performs the basic enum check but defers full payload-shape
|
|
1293
|
+
* cross-validation to a follow-up — the dispatcher is permissive at
|
|
1294
|
+
* runtime (an unknown field never matches → the hook simply never
|
|
1295
|
+
* fires for that event, which is a correct interpretation of "filter
|
|
1296
|
+
* by a field that doesn't exist").
|
|
1297
|
+
*/
|
|
1298
|
+
type THookFilter = Record<string, string | number | boolean>;
|
|
1299
|
+
interface IHook extends IExtensionBase {
|
|
1300
|
+
kind: 'hook';
|
|
1301
|
+
/**
|
|
1302
|
+
* Execution mode. Optional in the manifest with a default of
|
|
1303
|
+
* `deterministic` per `spec/schemas/extensions/hook.schema.json`.
|
|
1304
|
+
* Probabilistic hooks load but skip dispatch with a stderr advisory
|
|
1305
|
+
* until the job subsystem ships (Decision #114).
|
|
1306
|
+
*/
|
|
1307
|
+
mode?: TExecutionMode;
|
|
1308
|
+
/**
|
|
1309
|
+
* Subset of the curated lifecycle trigger set this hook subscribes
|
|
1310
|
+
* to. MUST be non-empty; every entry MUST be a member of
|
|
1311
|
+
* `HOOK_TRIGGERS`. The loader validates both invariants and surfaces
|
|
1312
|
+
* `invalid-manifest` on violation.
|
|
1313
|
+
*/
|
|
1314
|
+
triggers: THookTrigger[];
|
|
1315
|
+
/**
|
|
1316
|
+
* Optional declarative filter. Absent → invoke on every dispatched
|
|
1317
|
+
* event of every declared trigger.
|
|
1318
|
+
*/
|
|
1319
|
+
filter?: THookFilter;
|
|
1320
|
+
/**
|
|
1321
|
+
* Hook entry point. Returns nothing; reactions are side effects.
|
|
1322
|
+
* Errors are caught by the dispatcher (logged as `extension.error`,
|
|
1323
|
+
* surfaced via `hook.failed` meta-event) and NEVER block the main
|
|
1324
|
+
* pipeline — a buggy hook degrades gracefully.
|
|
1325
|
+
*/
|
|
1326
|
+
on(ctx: IHookContext): void | Promise<void>;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Scan orchestrator — runs the Provider → extractor → rule pipeline across
|
|
1331
|
+
* every registered extension and emits `ProgressEmitterPort` events in
|
|
1332
|
+
* canonical order. The callable extension set is injected via
|
|
1333
|
+
* `RunScanOptions.extensions` — the Registry holds manifest metadata, the
|
|
1334
|
+
* callable set holds the runtime instances the orchestrator actually
|
|
1335
|
+
* invokes. Separating the two lets `sm plugins` and `sm help` introspect
|
|
1336
|
+
* the graph without loading code.
|
|
1337
|
+
*
|
|
1338
|
+
* With zero registered extensions (or a callable set that carries none)
|
|
1339
|
+
* the pipeline still produces a valid zero-filled `ScanResult` — the
|
|
1340
|
+
* kernel-empty-boot invariant.
|
|
1341
|
+
*
|
|
1342
|
+
* Roots are validated up front: each entry of `RunScanOptions.roots`
|
|
1343
|
+
* must exist on disk as a directory. The first failure throws a clear
|
|
1344
|
+
* `Error` naming the offending path. This guards every caller (CLI,
|
|
1345
|
+
* server, skill-agent) against silently producing a zero-filled
|
|
1346
|
+
* `ScanResult` when a Provider walks a non-existent path — the bug
|
|
1347
|
+
* that wiped a populated DB via `sm scan -- --dry-run` (clipanion's
|
|
1348
|
+
* `--` made `--dry-run` a positional root that did not exist).
|
|
1349
|
+
*
|
|
1350
|
+
* Incremental scans: when `priorSnapshot` is supplied, the
|
|
1351
|
+
* orchestrator walks the filesystem, hashes each file, and reuses the
|
|
1352
|
+
* prior node + its prior-extracted internal links whenever both
|
|
1353
|
+
* `bodyHash` and `frontmatterHash` match. New / modified files run
|
|
1354
|
+
* through the full extractor pipeline (including the external-url-counter
|
|
1355
|
+
* which produces ephemeral pseudo-links). Rules ALWAYS run over the
|
|
1356
|
+
* fully merged graph — issue state can change even for an unchanged node
|
|
1357
|
+
* (e.g. a previously broken `references` link now resolves because a new
|
|
1358
|
+
* node was added). For unchanged nodes the prior `externalRefsCount` is
|
|
1359
|
+
* preserved as-is (the external pseudo-links were never persisted, so
|
|
1360
|
+
* they cannot be reconstructed; the count survived in the node row).
|
|
1361
|
+
*
|
|
1362
|
+
* Extractor output model (B.1, post-rename from Detector): extractors
|
|
1363
|
+
* return `void` and emit through three callbacks injected on the context:
|
|
1364
|
+
* - `ctx.emitLink(link)` → orchestrator validates against
|
|
1365
|
+
* `emitsLinkKinds` then partitions into internal / external buckets.
|
|
1366
|
+
* - `ctx.enrichNode(partial)` → orchestrator records ONE enrichment
|
|
1367
|
+
* entry per `(node, extractor)` so attribution survives into the DB.
|
|
1368
|
+
* Persisted into `node_enrichments` (A.8). The author-supplied
|
|
1369
|
+
* frontmatter on `node.frontmatter` stays immutable from any Extractor
|
|
1370
|
+
* — the enrichment layer is the only writable surface, and rules /
|
|
1371
|
+
* formatters consume it via `mergeNodeWithEnrichments`.
|
|
1372
|
+
* - `ctx.store` → plugin's own KV / dedicated tables (spec § A.12).
|
|
1373
|
+
* Wired by the driving adapter via `RunScanOptions.pluginStores`,
|
|
1374
|
+
* which the orchestrator looks up per-extractor by `pluginId` and
|
|
1375
|
+
* attaches to the context. The orchestrator never inspects what
|
|
1376
|
+
* plugins write through it; the wrapper handles AJV validation
|
|
1377
|
+
* when the manifest declared an output schema.
|
|
1378
|
+
*/
|
|
1379
|
+
|
|
1380
|
+
interface IScanExtensions {
|
|
1381
|
+
providers: IProvider[];
|
|
1382
|
+
extractors: IExtractor[];
|
|
1383
|
+
rules: IRule[];
|
|
1384
|
+
/**
|
|
1385
|
+
* Optional hooks (spec § A.11). When supplied, the orchestrator's
|
|
1386
|
+
* lifecycle dispatcher invokes deterministic hooks subscribed to one
|
|
1387
|
+
* of the eight hookable triggers in canonical order with the matching
|
|
1388
|
+
* event payload. Absent → no hooks fire (the scan still emits its
|
|
1389
|
+
* lifecycle events to `ProgressEmitterPort` for observability).
|
|
1390
|
+
* Probabilistic hooks are loaded but skipped here with a stderr
|
|
1391
|
+
* advisory until the job subsystem ships once the job subsystem ships.
|
|
1392
|
+
*/
|
|
1393
|
+
hooks?: IHook[];
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Confidence-tagged plan to repoint `state_*` references from one node
|
|
1397
|
+
* path to another. Emitted by the rename heuristic during `runScan` and
|
|
1398
|
+
* consumed by `persistScanResult` so the FK migration runs inside the
|
|
1399
|
+
* same transaction as the scan zone replace-all.
|
|
1400
|
+
*/
|
|
1401
|
+
interface RenameOp {
|
|
1402
|
+
from: string;
|
|
1403
|
+
to: string;
|
|
1404
|
+
confidence: 'high' | 'medium';
|
|
1405
|
+
}
|
|
1406
|
+
interface RunScanOptions {
|
|
1407
|
+
/**
|
|
1408
|
+
* Filesystem roots to walk. Spec requires `minItems: 1`; passing an
|
|
1409
|
+
* empty array makes `runScan` throw before any work happens.
|
|
1410
|
+
*/
|
|
1411
|
+
roots: string[];
|
|
1412
|
+
emitter?: ProgressEmitterPort;
|
|
1413
|
+
/** Runtime extension instances. Absent → empty pipeline. */
|
|
1414
|
+
extensions?: IScanExtensions;
|
|
1415
|
+
/**
|
|
1416
|
+
* Scan scope. Defaults to `'project'`. The CLI flag wiring lands in
|
|
1417
|
+
* the config layer wiring; `runScan` already accepts the override
|
|
1418
|
+
* so plugins / tests can opt into `'global'` today.
|
|
1419
|
+
*/
|
|
1420
|
+
scope?: 'project' | 'global';
|
|
1421
|
+
/**
|
|
1422
|
+
* Compute per-node token counts (frontmatter / body / total) using the
|
|
1423
|
+
* cl100k_base BPE (the modern OpenAI tokenizer used by GPT-4 / GPT-3.5).
|
|
1424
|
+
* Defaults to true. Set false to skip tokenization; `node.tokens` is
|
|
1425
|
+
* left undefined (spec-valid: the field is optional).
|
|
1426
|
+
*/
|
|
1427
|
+
tokenize?: boolean;
|
|
1428
|
+
/**
|
|
1429
|
+
* Prior snapshot for two purposes (decoupled by design):
|
|
1430
|
+
*
|
|
1431
|
+
* 1. **Rename heuristic** (`spec/db-schema.md` §Rename detection):
|
|
1432
|
+
* always evaluated when `priorSnapshot` is supplied. The
|
|
1433
|
+
* heuristic compares prior vs current node paths and emits
|
|
1434
|
+
* high / medium / ambiguous / orphan classifications. This
|
|
1435
|
+
* runs on EVERY `sm scan` (with or without `--changed`) so
|
|
1436
|
+
* reorganising files always preserves history, never silently.
|
|
1437
|
+
*
|
|
1438
|
+
* 2. **Cache reuse** (`sm scan --changed`): only kicks in when
|
|
1439
|
+
* `enableCache: true` is also passed. With the flag set, nodes
|
|
1440
|
+
* whose `path` exists in the prior with both `bodyHash` and
|
|
1441
|
+
* `frontmatterHash` matching the freshly-computed hashes are
|
|
1442
|
+
* reused as-is (their internal links and `externalRefsCount`
|
|
1443
|
+
* survive); only new / modified nodes run through extractors.
|
|
1444
|
+
* Rules always re-run over the merged graph.
|
|
1445
|
+
*
|
|
1446
|
+
* Pass `null` (or omit) for a fresh scan with no rename detection.
|
|
1447
|
+
*/
|
|
1448
|
+
priorSnapshot?: ScanResult | null;
|
|
1449
|
+
/**
|
|
1450
|
+
* Reuse unchanged nodes from `priorSnapshot` instead of re-running
|
|
1451
|
+
* extractors over them. Defaults to `false` so a plain `sm scan`
|
|
1452
|
+
* always re-walks deterministically. `sm scan --changed` flips this
|
|
1453
|
+
* to `true` for the perf win on unchanged files.
|
|
1454
|
+
*
|
|
1455
|
+
* Has no effect without `priorSnapshot`; setting it to `true` with
|
|
1456
|
+
* a null prior is a no-op (every file is "new").
|
|
1457
|
+
*/
|
|
1458
|
+
enableCache?: boolean;
|
|
1459
|
+
/**
|
|
1460
|
+
* Filter that decides which paths the Providers skip. Composed by the
|
|
1461
|
+
* caller (typically the CLI) from bundled defaults + `config.ignore`
|
|
1462
|
+
* + `.skill-mapignore`. Providers that omit this option fall back to
|
|
1463
|
+
* their own defensive defaults (just enough to keep `.git` /
|
|
1464
|
+
* `node_modules` out).
|
|
1465
|
+
*/
|
|
1466
|
+
ignoreFilter?: IIgnoreFilter;
|
|
1467
|
+
/**
|
|
1468
|
+
* Promote frontmatter-validation findings from `warn` to `error`.
|
|
1469
|
+
* Defaults to false. The CLI surfaces this via `--strict` on `sm scan`
|
|
1470
|
+
* and the `scan.strict` config key. When false, the orchestrator
|
|
1471
|
+
* still emits a `frontmatter-invalid` issue per malformed file but
|
|
1472
|
+
* leaves the severity at `warn` so a clean scan exits 0; when true,
|
|
1473
|
+
* the same finding becomes `error` and the scan exits 1.
|
|
1474
|
+
*/
|
|
1475
|
+
strict?: boolean;
|
|
1476
|
+
/**
|
|
1477
|
+
* Spec § A.9 — fine-grained Extractor cache breadcrumbs from the
|
|
1478
|
+
* prior scan. Shape: `Map<nodePath, Map<qualifiedExtractorId, bodyHashAtRun>>`.
|
|
1479
|
+
* Loaded from the `scan_extractor_runs` table by the CLI before
|
|
1480
|
+
* invoking `runScan`; absent / empty for a fresh DB or an out-of-band
|
|
1481
|
+
* caller that does not maintain a cache. Decoupled from `priorSnapshot`
|
|
1482
|
+
* because the runs live in a sibling table and are useful only when
|
|
1483
|
+
* `enableCache` is also set.
|
|
1484
|
+
*
|
|
1485
|
+
* Cache decision per `(node, extractor)`:
|
|
1486
|
+
* - body+frontmatter hashes match the prior node AND every currently-
|
|
1487
|
+
* registered extractor that applies to this kind has a matching
|
|
1488
|
+
* row → full skip, all prior outbound links reused.
|
|
1489
|
+
* - some applicable extractor lacks a matching row (newly registered,
|
|
1490
|
+
* or its prior run targeted a different body hash) → run only the
|
|
1491
|
+
* missing extractors, drop prior links whose `sources` map to any
|
|
1492
|
+
* missing extractor or to an extractor that is no longer registered.
|
|
1493
|
+
*/
|
|
1494
|
+
priorExtractorRuns?: Map<string, Map<string, string>>;
|
|
1495
|
+
/**
|
|
1496
|
+
* Spec § A.12 — per-plugin storage wrappers exposed to extractors via
|
|
1497
|
+
* `ctx.store`. Keyed by `pluginId`; absent / missing entry leaves
|
|
1498
|
+
* `ctx.store` undefined for that extractor (the existing contract).
|
|
1499
|
+
*
|
|
1500
|
+
* The kernel does not construct these — the driving adapter (CLI,
|
|
1501
|
+
* future server) builds them with `makePluginStore` from
|
|
1502
|
+
* `kernel/adapters/plugin-store.js` and threads them through. This
|
|
1503
|
+
* keeps the orchestrator persistence-agnostic (the wrapper supplies
|
|
1504
|
+
* its own persist callback) and lets tests inject a captured-call
|
|
1505
|
+
* mock without spinning up a DB.
|
|
1506
|
+
*/
|
|
1507
|
+
pluginStores?: ReadonlyMap<string, IPluginStore>;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Spec § A.9 — runs to persist into `scan_extractor_runs`. One entry
|
|
1511
|
+
* per `(nodePath, qualifiedExtractorId)` pair the orchestrator decided
|
|
1512
|
+
* "this extractor is current for this body". Includes both freshly-run
|
|
1513
|
+
* pairs (extractor invoked this scan) and reused pairs (cached node, the
|
|
1514
|
+
* extractor's prior run still applies to the same body hash). Excludes
|
|
1515
|
+
* obsolete pairs — extractors that ran in the prior but are no longer
|
|
1516
|
+
* registered — so a replace-all persist drops them automatically.
|
|
1517
|
+
*/
|
|
1518
|
+
interface IExtractorRunRecord {
|
|
1519
|
+
nodePath: string;
|
|
1520
|
+
extractorId: string;
|
|
1521
|
+
bodyHashAtRun: string;
|
|
1522
|
+
ranAt: number;
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Spec § A.8 — universal enrichment layer.
|
|
1526
|
+
*
|
|
1527
|
+
* One entry per `(nodePath, qualifiedExtractorId)` pair an Extractor
|
|
1528
|
+
* produced via `ctx.enrichNode(...)` during the walk. Attribution is
|
|
1529
|
+
* preserved per-Extractor (rather than merged client-side as B.1 did)
|
|
1530
|
+
* so the persistence layer can:
|
|
1531
|
+
*
|
|
1532
|
+
* - upsert a single row per pair (stable PRIMARY KEY conflict on
|
|
1533
|
+
* re-extract);
|
|
1534
|
+
* - flag probabilistic rows `stale = 1` when the body changes between
|
|
1535
|
+
* scans (preserving the prior LLM cost);
|
|
1536
|
+
* - feed `mergeNodeWithEnrichments` with `enrichedAt`-sorted partials
|
|
1537
|
+
* for last-write-wins per field at read time.
|
|
1538
|
+
*
|
|
1539
|
+
* `value` is the cumulative merge across every `enrichNode` call that
|
|
1540
|
+
* Extractor made for this node within this scan — multiple
|
|
1541
|
+
* `ctx.enrichNode({...})` calls inside one `extract(ctx)` invocation
|
|
1542
|
+
* fold into a single row, but two different Extractors hitting the
|
|
1543
|
+
* same node yield two distinct rows.
|
|
1544
|
+
*
|
|
1545
|
+
* `isProbabilistic` is denormalised so the persistence layer's stale
|
|
1546
|
+
* flag query stays a single-table read; recomputing from the live
|
|
1547
|
+
* registry would force every read-path to thread the runtime extension
|
|
1548
|
+
* set through.
|
|
1549
|
+
*/
|
|
1550
|
+
interface IEnrichmentRecord {
|
|
1551
|
+
nodePath: string;
|
|
1552
|
+
extractorId: string;
|
|
1553
|
+
bodyHashAtEnrichment: string;
|
|
1554
|
+
value: Partial<Node>;
|
|
1555
|
+
enrichedAt: number;
|
|
1556
|
+
isProbabilistic: boolean;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Same as `runScan` but also returns the rename heuristic's `RenameOp[]`
|
|
1560
|
+
* — the high- and medium-confidence renames the persistence layer must
|
|
1561
|
+
* apply to `state_*` rows inside the same tx as the scan zone replace-
|
|
1562
|
+
* all (per `spec/db-schema.md` §Rename detection). Most callers want
|
|
1563
|
+
* `runScan` (which returns just `ScanResult`); the CLI's `sm scan`
|
|
1564
|
+
* uses this variant so it can hand the ops off to `persistScanResult`.
|
|
1565
|
+
*
|
|
1566
|
+
* Also returns `extractorRuns` — the Spec § A.9 fine-grained cache
|
|
1567
|
+
* breadcrumbs the CLI persists into `scan_extractor_runs` so the next
|
|
1568
|
+
* incremental scan can decide per-(node, extractor) whether re-running
|
|
1569
|
+
* is required.
|
|
1570
|
+
*/
|
|
1571
|
+
declare function runScanWithRenames(_kernel: Kernel, options: RunScanOptions): Promise<{
|
|
1572
|
+
result: ScanResult;
|
|
1573
|
+
renameOps: RenameOp[];
|
|
1574
|
+
extractorRuns: IExtractorRunRecord[];
|
|
1575
|
+
enrichments: IEnrichmentRecord[];
|
|
1576
|
+
}>;
|
|
1577
|
+
declare function runScan(_kernel: Kernel, options: RunScanOptions): Promise<ScanResult>;
|
|
1578
|
+
/**
|
|
1579
|
+
* Run a set of extractors against a single node, collecting their link
|
|
1580
|
+
* emissions and node-enrichment partials. Each extractor is invoked
|
|
1581
|
+
* exactly once with a fresh `IExtractorContext`. Caller decides what
|
|
1582
|
+
* to do with the returned arrays (push into per-scan buffers, write to
|
|
1583
|
+
* a focused refresh result, etc.).
|
|
1584
|
+
*
|
|
1585
|
+
* Exported so `cli/commands/refresh.ts` can reuse the same wiring it
|
|
1586
|
+
* needs for re-running a single extractor against a single node — the
|
|
1587
|
+
* pre-extraction code in `refresh.ts` was hand-duplicating this loop
|
|
1588
|
+
* (audit item V4).
|
|
1589
|
+
*
|
|
1590
|
+
* Within this call, multiple `enrichNode(partial)` calls from the same
|
|
1591
|
+
* extractor against the same node fold into one record (last-write-wins
|
|
1592
|
+
* per field) — same contract as the in-scan path.
|
|
1593
|
+
*/
|
|
1594
|
+
declare function runExtractorsForNode(opts: {
|
|
1595
|
+
extractors: IExtractor[];
|
|
1596
|
+
node: Node;
|
|
1597
|
+
body: string;
|
|
1598
|
+
frontmatter: Record<string, unknown>;
|
|
1599
|
+
bodyHash: string;
|
|
1600
|
+
emitter: ProgressEmitterPort;
|
|
1601
|
+
/**
|
|
1602
|
+
* Spec § A.12 — per-plugin `ctx.store` wrappers keyed by `pluginId`.
|
|
1603
|
+
* The map's lookup is per-extractor inside the loop, so callers that
|
|
1604
|
+
* don't track plugin storage can omit it; the resulting `ctx.store`
|
|
1605
|
+
* stays `undefined` (the existing contract).
|
|
1606
|
+
*/
|
|
1607
|
+
pluginStores?: ReadonlyMap<string, IPluginStore>;
|
|
1608
|
+
}): Promise<{
|
|
1609
|
+
internalLinks: Link[];
|
|
1610
|
+
externalLinks: Link[];
|
|
1611
|
+
enrichments: IEnrichmentRecord[];
|
|
1612
|
+
}>;
|
|
1613
|
+
/**
|
|
1614
|
+
* Pure rename / orphan classification per `spec/db-schema.md` §Rename
|
|
1615
|
+
* detection. Mutates `issues` in place — caller passes the in-progress
|
|
1616
|
+
* issue list; returns the `RenameOp[]` for the persistence layer to
|
|
1617
|
+
* apply inside its tx.
|
|
1618
|
+
*
|
|
1619
|
+
* Pipeline (1-to-1: a `newPath` claimed by one stage cannot be reused
|
|
1620
|
+
* by another):
|
|
1621
|
+
*
|
|
1622
|
+
* 1. **High-confidence**: pair each `deletedPath` with a `newPath`
|
|
1623
|
+
* that has the same `bodyHash`. No issue, no prompt.
|
|
1624
|
+
* 2. **Medium-confidence (1:1)**: of the remaining deletions, pair
|
|
1625
|
+
* each with the *unique* unclaimed `newPath` that shares its
|
|
1626
|
+
* `frontmatterHash`. Emits `auto-rename-medium` (severity warn)
|
|
1627
|
+
* with `data: { from, to, confidence: 'medium' }`.
|
|
1628
|
+
* 3. **Ambiguous (N:1)**: when a single `newPath` has more than one
|
|
1629
|
+
* remaining frontmatter-matching candidate, emit ONE
|
|
1630
|
+
* `auto-rename-ambiguous` issue per `newPath`, listing all
|
|
1631
|
+
* candidates in `data.candidates`. NO migration.
|
|
1632
|
+
* 4. **Orphan**: every `deletedPath` left after steps 1-3 yields one
|
|
1633
|
+
* `orphan` issue (severity info) with `data: { path: <deletedPath> }`.
|
|
1634
|
+
*
|
|
1635
|
+
* Determinism: `deletedPaths` and `newPaths` are iterated in lex-asc
|
|
1636
|
+
* order so the same input always produces the same matches —
|
|
1637
|
+
* required for reproducible tests and conformance fixtures (the spec
|
|
1638
|
+
* does not prescribe an order, but stability is the obvious contract).
|
|
1639
|
+
*/
|
|
1640
|
+
declare function detectRenamesAndOrphans(prior: ScanResult, current: Node[], issues: Issue[]): RenameOp[];
|
|
1641
|
+
/**
|
|
1642
|
+
* Spec § A.8 — produce the merged read-time view of a Node.
|
|
1643
|
+
*
|
|
1644
|
+
* Rules / `sm check` / `sm export` consume `node.frontmatter` directly
|
|
1645
|
+
* (deterministic CI-safe baseline — author intent, byte-stable). UI / future
|
|
1646
|
+
* rules that opt into enrichment context call this helper to merge the
|
|
1647
|
+
* author frontmatter with the live enrichment layer.
|
|
1648
|
+
*
|
|
1649
|
+
* Algorithm:
|
|
1650
|
+
*
|
|
1651
|
+
* 1. Filter `enrichments` down to rows targeting this node AND not
|
|
1652
|
+
* flagged `stale`. Stale rows (probabilistic enrichments whose
|
|
1653
|
+
* body changed since their last run) are excluded by default —
|
|
1654
|
+
* stale visibility belongs to the UI layer where the marker is
|
|
1655
|
+
* shown next to the value.
|
|
1656
|
+
* 2. Sort the survivors by `enrichedAt` ASC so iteration order is
|
|
1657
|
+
* "oldest first". This makes the spread merge below
|
|
1658
|
+
* last-write-wins per field — the freshest Extractor's value
|
|
1659
|
+
* pisar the older one for any conflicting key.
|
|
1660
|
+
* 3. Spread-merge each row's `value` over `node.frontmatter`. The
|
|
1661
|
+
* author's keys are the base; enrichment keys overlay them.
|
|
1662
|
+
*
|
|
1663
|
+
* The returned object is a fresh shallow copy — mutating it does not
|
|
1664
|
+
* touch the caller's node. The original `node.frontmatter` reference
|
|
1665
|
+
* remains accessible via `node.frontmatter` for callers that want the
|
|
1666
|
+
* pristine author baseline.
|
|
1667
|
+
*
|
|
1668
|
+
* @param node Node to merge against; `node.frontmatter` is the base.
|
|
1669
|
+
* @param enrichments Per-(node, extractor) enrichment records — typically
|
|
1670
|
+
* loaded via `loadNodeEnrichments(db, node.path)` or
|
|
1671
|
+
* pre-filtered to this node by the caller.
|
|
1672
|
+
* @param opts.includeStale When true, include rows flagged stale. Defaults
|
|
1673
|
+
* to false (the safe, CI-deterministic default).
|
|
1674
|
+
* UIs that want to display "stale (last value: …)"
|
|
1675
|
+
* pass `true` and consult `enrichment.stale`
|
|
1676
|
+
* on the source rows.
|
|
1677
|
+
*/
|
|
1678
|
+
declare function mergeNodeWithEnrichments(node: Node, enrichments: IPersistedEnrichment[], opts?: {
|
|
1679
|
+
includeStale?: boolean;
|
|
1680
|
+
}): Record<string, unknown>;
|
|
1681
|
+
/**
|
|
1682
|
+
* A persisted enrichment row, post-load. Mirrors the DB row shape
|
|
1683
|
+
* but with `value` already deserialised from JSON and `stale` /
|
|
1684
|
+
* `isProbabilistic` already decoded from `0 | 1`. Surfaced via
|
|
1685
|
+
* `loadNodeEnrichments` (driven adapter) and consumed by
|
|
1686
|
+
* `mergeNodeWithEnrichments` and the `sm refresh` command.
|
|
1687
|
+
*/
|
|
1688
|
+
interface IPersistedEnrichment {
|
|
1689
|
+
nodePath: string;
|
|
1690
|
+
extractorId: string;
|
|
1691
|
+
bodyHashAtEnrichment: string;
|
|
1692
|
+
value: Partial<Node>;
|
|
1693
|
+
stale: boolean;
|
|
1694
|
+
enrichedAt: number;
|
|
1695
|
+
isProbabilistic: boolean;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* In-memory `ProgressEmitterPort` adapter. No network, no DB — just a
|
|
1700
|
+
* synchronous fan-out to registered listeners. Used by the default scan
|
|
1701
|
+
* orchestrator; the WebSocket-backed emitter that streams to
|
|
1702
|
+
* the Web UI lands.
|
|
1703
|
+
*/
|
|
1704
|
+
|
|
1705
|
+
declare class InMemoryProgressEmitter implements ProgressEmitterPort {
|
|
1706
|
+
#private;
|
|
1707
|
+
emit(event: ProgressEvent): void;
|
|
1708
|
+
subscribe(listener: ProgressListener): () => void;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* File watcher for `sm watch` / `sm scan --watch`.
|
|
1713
|
+
*
|
|
1714
|
+
* Wraps `chokidar` behind a small `IFsWatcher` interface so:
|
|
1715
|
+
*
|
|
1716
|
+
* 1. The CLI command is impl-agnostic — swapping chokidar for a
|
|
1717
|
+
* different watcher later (Java? Rust port? a future `WatchPort`?)
|
|
1718
|
+
* doesn't ripple into the command.
|
|
1719
|
+
* 2. Debouncing, batching, and ignore-filter integration live in one
|
|
1720
|
+
* place. The CLI just gets `onBatch(paths)` callbacks and decides
|
|
1721
|
+
* whether to re-scan.
|
|
1722
|
+
*
|
|
1723
|
+
* The watcher does NOT call into the orchestrator itself. That decision
|
|
1724
|
+
* is deliberate: the CLI owns the scan-and-persist pipeline (`runScan`,
|
|
1725
|
+
* `persistScanResult`, optional rebuild of the ignore filter when
|
|
1726
|
+
* `.skill-mapignore` itself changes). Pulling that into the watcher
|
|
1727
|
+
* would couple the kernel module to `SqliteStorageAdapter`, which the
|
|
1728
|
+
* Server wouldn't want. Keep this module side-effect free
|
|
1729
|
+
* apart from filesystem subscription.
|
|
1730
|
+
*
|
|
1731
|
+
* Ignore filter integration: the supplied `IIgnoreFilter` is consulted
|
|
1732
|
+
* via chokidar's `ignored` predicate, which receives an absolute path.
|
|
1733
|
+
* We re-derive the path RELATIVE to the closest matching root before
|
|
1734
|
+
* passing it through `IIgnoreFilter.ignores`. This mirrors what the
|
|
1735
|
+
* scan walker does (`extensions/providers/claude/index.ts`) so both code
|
|
1736
|
+
* paths agree on what "ignored" means.
|
|
1737
|
+
*/
|
|
1738
|
+
|
|
1739
|
+
type TWatchEventKind = 'add' | 'change' | 'unlink';
|
|
1740
|
+
interface IWatchEvent {
|
|
1741
|
+
kind: TWatchEventKind;
|
|
1742
|
+
/** Absolute path. */
|
|
1743
|
+
absolutePath: string;
|
|
1744
|
+
}
|
|
1745
|
+
interface IWatchBatch {
|
|
1746
|
+
/** Events that arrived inside the debounce window, in arrival order. */
|
|
1747
|
+
events: IWatchEvent[];
|
|
1748
|
+
/** Convenience: deduplicated absolute paths across the batch. */
|
|
1749
|
+
paths: string[];
|
|
1750
|
+
}
|
|
1751
|
+
interface IFsWatcher {
|
|
1752
|
+
/** Resolves once chokidar has finished its initial directory scan and is ready to emit. */
|
|
1753
|
+
ready: Promise<void>;
|
|
1754
|
+
/** Tear down the watcher. Resolves after chokidar releases handles. */
|
|
1755
|
+
close: () => Promise<void>;
|
|
1756
|
+
}
|
|
1757
|
+
interface ICreateFsWatcherOptions {
|
|
1758
|
+
/** Roots to watch. Resolved relative to `cwd` if relative paths are passed. */
|
|
1759
|
+
roots: string[];
|
|
1760
|
+
/** Working directory used to resolve relative roots and the ignore-filter root. */
|
|
1761
|
+
cwd: string;
|
|
1762
|
+
/** Debounce window in milliseconds. `0` triggers `onBatch` synchronously per event. */
|
|
1763
|
+
debounceMs: number;
|
|
1764
|
+
/** Optional ignore filter — same instance the scan walker uses. */
|
|
1765
|
+
ignoreFilter?: IIgnoreFilter | undefined;
|
|
1766
|
+
/** Called once per debounced batch. Awaited; concurrent batches are serialised. */
|
|
1767
|
+
onBatch: (batch: IWatchBatch) => void | Promise<void>;
|
|
1768
|
+
/**
|
|
1769
|
+
* Called when the underlying watcher surfaces an error. The watcher
|
|
1770
|
+
* stays open — callers decide whether to log, keep going, or close.
|
|
1771
|
+
*/
|
|
1772
|
+
onError?: (err: Error) => void;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Construct a chokidar-backed watcher. Subscribes immediately; the
|
|
1776
|
+
* returned `ready` promise resolves once chokidar's initial directory
|
|
1777
|
+
* walk completes, at which point only NEW events fire `onBatch`.
|
|
1778
|
+
*
|
|
1779
|
+
* The initial directory walk is deliberately silent — we set
|
|
1780
|
+
* `ignoreInitial: true`. The CLI runs a one-shot scan before flipping
|
|
1781
|
+
* the watcher on, so re-emitting an `add` for every existing file
|
|
1782
|
+
* would be redundant churn.
|
|
1783
|
+
*/
|
|
1784
|
+
declare function createChokidarWatcher(opts: ICreateFsWatcherOptions): IFsWatcher;
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Scan delta — pure comparison of two `ScanResult` snapshots. Drives
|
|
1788
|
+
* `sm scan --compare-with <path>` and is the single place the kernel
|
|
1789
|
+
* knows how to identify "the same" entity across two scans.
|
|
1790
|
+
*
|
|
1791
|
+
* **Identity contract** (mirrors decisions made at earlier sub-steps):
|
|
1792
|
+
*
|
|
1793
|
+
* - **Node**: `node.path`. The path is the only field stable across
|
|
1794
|
+
* edits — every other Node field is content-derived (hashes, counts,
|
|
1795
|
+
* denormalised frontmatter). Two nodes with the same path are the
|
|
1796
|
+
* "same" node; differences are reported as a `changed` entry with
|
|
1797
|
+
* a reason narrowing what diverged.
|
|
1798
|
+
*
|
|
1799
|
+
* - **Link**: `(source, target, kind, normalizedTrigger ?? '')`. This
|
|
1800
|
+
* mirrors the link-conflict rule and `sm show` aggregation —
|
|
1801
|
+
* two links with identical endpoints, kind, and (optional) trigger
|
|
1802
|
+
* are the same link, even if emitted by different extractors. The
|
|
1803
|
+
* `sources[]` union and confidence are NOT part of identity; they
|
|
1804
|
+
* are presentation facets that can churn without making the link
|
|
1805
|
+
* "different" for delta purposes.
|
|
1806
|
+
*
|
|
1807
|
+
* - **Issue**: `(ruleId, sorted nodeIds, message)`. Mirrors
|
|
1808
|
+
* `spec/job-events.md` §issue.* — same key → same issue, even when
|
|
1809
|
+
* `data` / `severity` / `linkIndices` shift. A meaningful change in
|
|
1810
|
+
* `message` (or a different set of node ids) is a different issue.
|
|
1811
|
+
* This is the same key future job events will use; keep it aligned
|
|
1812
|
+
* so consumers can reuse logic.
|
|
1813
|
+
*
|
|
1814
|
+
* No "changed" bucket for links / issues — identity already captures
|
|
1815
|
+
* everything that matters there. Nodes get a "changed" bucket because
|
|
1816
|
+
* the path stays stable while the body / frontmatter rewrite, and that
|
|
1817
|
+
* change is meaningful (formatters, summarisers, downstream consumers
|
|
1818
|
+
* all care about it).
|
|
1819
|
+
*
|
|
1820
|
+
* Pure: no IO, no DB, no FS. Safe to run in-memory inside `sm scan`
|
|
1821
|
+
* without polluting the persisted snapshot.
|
|
1822
|
+
*/
|
|
1823
|
+
|
|
1824
|
+
type TNodeChangeReason = 'body' | 'frontmatter' | 'both';
|
|
1825
|
+
interface INodeChange {
|
|
1826
|
+
before: Node;
|
|
1827
|
+
after: Node;
|
|
1828
|
+
/**
|
|
1829
|
+
* Which hash diverged. `'body'` means body rewritten, frontmatter
|
|
1830
|
+
* untouched; `'frontmatter'` means metadata rewritten, body
|
|
1831
|
+
* untouched; `'both'` means both rewritten in the same edit.
|
|
1832
|
+
*/
|
|
1833
|
+
reason: TNodeChangeReason;
|
|
1834
|
+
}
|
|
1835
|
+
interface IScanDelta {
|
|
1836
|
+
/** Path the current scan was compared against (echoed for the report header). */
|
|
1837
|
+
comparedWith: string;
|
|
1838
|
+
nodes: {
|
|
1839
|
+
added: Node[];
|
|
1840
|
+
removed: Node[];
|
|
1841
|
+
changed: INodeChange[];
|
|
1842
|
+
};
|
|
1843
|
+
links: {
|
|
1844
|
+
added: Link[];
|
|
1845
|
+
removed: Link[];
|
|
1846
|
+
};
|
|
1847
|
+
issues: {
|
|
1848
|
+
added: Issue[];
|
|
1849
|
+
removed: Issue[];
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
declare function computeScanDelta(prior: ScanResult, current: ScanResult, comparedWith: string): IScanDelta;
|
|
1853
|
+
/**
|
|
1854
|
+
* `true` iff every bucket is empty. Callers use this to decide the
|
|
1855
|
+
* exit code (`0` clean, `1` non-empty delta).
|
|
1856
|
+
*/
|
|
1857
|
+
declare function isEmptyDelta(delta: IScanDelta): boolean;
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Export query — minimal filter language for `sm export <query>` (Step 8.3).
|
|
1861
|
+
*
|
|
1862
|
+
* Spec contract: `spec/cli-contract.md` line 190 says "Query syntax is
|
|
1863
|
+
* implementation-defined pre-1.0". This module defines the v0.5.0 syntax.
|
|
1864
|
+
*
|
|
1865
|
+
* **Grammar** (BNF-ish, intentionally tiny):
|
|
1866
|
+
*
|
|
1867
|
+
* query := token (WS+ token)*
|
|
1868
|
+
* token := key "=" value-list
|
|
1869
|
+
* key := "kind" | "has" | "path"
|
|
1870
|
+
* value-list := value ("," value)*
|
|
1871
|
+
* value := non-comma, non-whitespace string
|
|
1872
|
+
*
|
|
1873
|
+
* Tokens AND together; values within one token OR. An empty / whitespace-only
|
|
1874
|
+
* query is valid and matches every node ("export everything").
|
|
1875
|
+
*
|
|
1876
|
+
* **Filters**:
|
|
1877
|
+
*
|
|
1878
|
+
* - `kind=skill` / `kind=skill,agent` — node kind whitelist.
|
|
1879
|
+
* - `has=issues` — node must appear in some issue's `nodeIds`. (Future
|
|
1880
|
+
* expansion: `has=findings` / `has=summary` once Step 10 / 11 land.
|
|
1881
|
+
* Unknown values are a parse error today; we'll ratchet up the
|
|
1882
|
+
* accepted set additively.)
|
|
1883
|
+
* - `path=foo/*` / `path=.claude/agents/**` — POSIX glob over `node.path`.
|
|
1884
|
+
* Supports `*` (any chars except `/`) and `**` (any chars including `/`).
|
|
1885
|
+
*
|
|
1886
|
+
* **Subset semantics** (`applyExportQuery`):
|
|
1887
|
+
*
|
|
1888
|
+
* - Nodes pass when every specified filter matches (AND across keys,
|
|
1889
|
+
* OR within values).
|
|
1890
|
+
* - Links survive only when BOTH endpoints (`source` + `target`) belong
|
|
1891
|
+
* to the filtered node set. A subset that includes "edges out to
|
|
1892
|
+
* unfiltered nodes" would be confusing — the user asked for a focused
|
|
1893
|
+
* subgraph, not its boundary. External-URL pseudo-links are already
|
|
1894
|
+
* stripped by the orchestrator and never reach this layer.
|
|
1895
|
+
* - Issues survive when ANY of the issue's `nodeIds` is in the filtered
|
|
1896
|
+
* set. Issues span multiple nodes (e.g. `trigger-collision` over two
|
|
1897
|
+
* advertisers); dropping an issue when one of its nodes is outside
|
|
1898
|
+
* would hide cross-cutting problems the user is investigating.
|
|
1899
|
+
*
|
|
1900
|
+
* Pure: no IO, no DB, no FS.
|
|
1901
|
+
*/
|
|
1902
|
+
|
|
1903
|
+
interface IExportQuery {
|
|
1904
|
+
/** Original query string echoed back so consumers can render the header. */
|
|
1905
|
+
raw: string;
|
|
1906
|
+
/**
|
|
1907
|
+
* Whitelist of node kinds (`node.kind` is open string — built-in
|
|
1908
|
+
* Claude catalog `skill` / `agent` / `command` / `hook` / `note`,
|
|
1909
|
+
* plus whatever external Providers declare). The query parser does
|
|
1910
|
+
* not validate values against a closed enum; an unknown kind simply
|
|
1911
|
+
* yields zero matches at filter time.
|
|
1912
|
+
*/
|
|
1913
|
+
kinds?: string[];
|
|
1914
|
+
hasIssues?: boolean;
|
|
1915
|
+
pathGlobs?: string[];
|
|
1916
|
+
}
|
|
1917
|
+
interface IExportSubset {
|
|
1918
|
+
query: IExportQuery;
|
|
1919
|
+
nodes: Node[];
|
|
1920
|
+
links: Link[];
|
|
1921
|
+
issues: Issue[];
|
|
1922
|
+
}
|
|
1923
|
+
declare class ExportQueryError extends Error {
|
|
1924
|
+
constructor(message: string);
|
|
1925
|
+
}
|
|
1926
|
+
declare function parseExportQuery(raw: string): IExportQuery;
|
|
1927
|
+
declare function applyExportQuery(scan: {
|
|
1928
|
+
nodes: Node[];
|
|
1929
|
+
links: Link[];
|
|
1930
|
+
issues: Issue[];
|
|
1931
|
+
}, query: IExportQuery): IExportSubset;
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* `PluginLoaderPort` — discovers plugin directories and loads their
|
|
1935
|
+
* extensions. The shape mirrors what the concrete loader actually
|
|
1936
|
+
* exposes (see `kernel/adapters/plugin-loader.ts`); the port exists so
|
|
1937
|
+
* the CLI consumes the abstract contract via `createPluginLoader(...)`
|
|
1938
|
+
* instead of `new PluginLoader(...)` and so the concrete adapter is
|
|
1939
|
+
* structurally pinned to the port (`implements PluginLoaderPort` makes
|
|
1940
|
+
* any drift a compile error).
|
|
1941
|
+
*
|
|
1942
|
+
* Domain types (`IPluginManifest`, `ILoadedExtension`, `IDiscoveredPlugin`,
|
|
1943
|
+
* `TPluginStorage`, `TPluginLoadStatus`, `TGranularity`) live in
|
|
1944
|
+
* `kernel/types/plugin.ts` because they are spec-mirroring DTOs, not
|
|
1945
|
+
* port-shape types. The port re-exports them for callers that import
|
|
1946
|
+
* from the ports barrel.
|
|
1947
|
+
*/
|
|
1948
|
+
|
|
1949
|
+
interface PluginLoaderPort {
|
|
1950
|
+
/**
|
|
1951
|
+
* Synchronously enumerate every directory containing a `plugin.json`
|
|
1952
|
+
* across the configured search paths. Non-existent paths are skipped.
|
|
1953
|
+
*/
|
|
1954
|
+
discoverPaths(): string[];
|
|
1955
|
+
/**
|
|
1956
|
+
* Discover every plugin, attempt to load each, then apply the
|
|
1957
|
+
* cross-root id-collision pass. Never throws — failures are reported
|
|
1958
|
+
* via `IDiscoveredPlugin.status`.
|
|
1959
|
+
*/
|
|
1960
|
+
discoverAndLoadAll(): Promise<IDiscoveredPlugin[]>;
|
|
1961
|
+
/**
|
|
1962
|
+
* Load a single plugin from its directory. Never throws — failure is
|
|
1963
|
+
* reported via the returned `status`.
|
|
1964
|
+
*/
|
|
1965
|
+
loadOne(pluginPath: string): Promise<IDiscoveredPlugin>;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* Row-level filter for `port.scans.findNodes(...)` (driven by
|
|
1970
|
+
* `sm list`'s flags). All fields are optional — an empty filter
|
|
1971
|
+
* returns every node sorted by `path` asc.
|
|
1972
|
+
*/
|
|
1973
|
+
interface INodeFilter {
|
|
1974
|
+
/** Restrict to a single node kind. Open string (matches `Node.kind`). */
|
|
1975
|
+
kind?: string;
|
|
1976
|
+
/**
|
|
1977
|
+
* When `true`, keep only nodes whose path is referenced by at least
|
|
1978
|
+
* one `scan_issues.nodeIds` array.
|
|
1979
|
+
*/
|
|
1980
|
+
hasIssues?: boolean;
|
|
1981
|
+
/**
|
|
1982
|
+
* Sort column. The adapter validates against its own whitelist and
|
|
1983
|
+
* rejects anything else with an Error (the CLI's own usage-error
|
|
1984
|
+
* exit is the right place to surface a bad `--sort-by`; the port
|
|
1985
|
+
* defends in depth).
|
|
1986
|
+
*/
|
|
1987
|
+
sortBy?: string;
|
|
1988
|
+
/** `'asc'` or `'desc'`. Defaults to the adapter's per-column convention. */
|
|
1989
|
+
sortDirection?: 'asc' | 'desc';
|
|
1990
|
+
/** Cap the result. Positive integer; absent → no limit. */
|
|
1991
|
+
limit?: number;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Bundled fetch for `port.scans.findNode(path)` — one node and
|
|
1995
|
+
* everything `sm show <path>` displays alongside it. Every field is
|
|
1996
|
+
* computed from `scan_*` zone reads only; per-domain data (history,
|
|
1997
|
+
* jobs, plugin enrichments) ships through other namespaces.
|
|
1998
|
+
*/
|
|
1999
|
+
interface INodeBundle {
|
|
2000
|
+
node: Node;
|
|
2001
|
+
linksOut: Link[];
|
|
2002
|
+
linksIn: Link[];
|
|
2003
|
+
issues: Issue[];
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Output of `port.scans.countRows()`. Used by `sm scan` to decide
|
|
2007
|
+
* whether the persist would wipe a populated DB (the "refusing to
|
|
2008
|
+
* wipe" guard) and by `sm db status` for the human summary.
|
|
2009
|
+
*/
|
|
2010
|
+
interface INodeCounts {
|
|
2011
|
+
nodes: number;
|
|
2012
|
+
links: number;
|
|
2013
|
+
issues: number;
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Lightweight option bag for `port.scans.persist`. Mirrors the trailing
|
|
2017
|
+
* arguments of the legacy `persistScanResult(db, result, renameOps,
|
|
2018
|
+
* extractorRuns, enrichments)` free function so the adapter
|
|
2019
|
+
* implementation is a one-line delegation today; the named-bag shape
|
|
2020
|
+
* tomorrow lets new optional inputs land without breaking callers.
|
|
2021
|
+
*/
|
|
2022
|
+
interface IPersistOptions {
|
|
2023
|
+
renameOps?: RenameOp[];
|
|
2024
|
+
extractorRuns?: IExtractorRunRecord[];
|
|
2025
|
+
enrichments?: IEnrichmentRecord[];
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Issue row as the storage layer sees it — paired with its DB-assigned
|
|
2029
|
+
* id so `port.issues.deleteById(id)` can target it inside a
|
|
2030
|
+
* transaction. The runtime `Issue` shape (per `issue.schema.json`) does
|
|
2031
|
+
* not carry `id` because the spec models issues as ephemeral findings
|
|
2032
|
+
* scoped to a scan; the DB does need the synthetic id to update / delete
|
|
2033
|
+
* a single row.
|
|
2034
|
+
*/
|
|
2035
|
+
interface IIssueRow {
|
|
2036
|
+
id: number;
|
|
2037
|
+
issue: Issue;
|
|
2038
|
+
}
|
|
2039
|
+
/** Output of `port.jobs.pruneTerminal` / `listTerminalCandidates`. */
|
|
2040
|
+
interface IPruneResult {
|
|
2041
|
+
/** How many `state_jobs` rows were deleted (or would be, in dry-run). */
|
|
2042
|
+
deletedCount: number;
|
|
2043
|
+
/** Job-file paths from the affected rows; the CLI unlinks these from disk. `null` `filePath` rows contribute nothing here. */
|
|
2044
|
+
filePaths: string[];
|
|
2045
|
+
}
|
|
2046
|
+
/** Filter shape for `port.history.list`. All fields optional. */
|
|
2047
|
+
interface IListExecutionsFilter {
|
|
2048
|
+
/** Restrict to executions whose `nodeIds` array contains this path. */
|
|
2049
|
+
nodePath?: string;
|
|
2050
|
+
/** Exact match on `extension_id`. */
|
|
2051
|
+
actionId?: string;
|
|
2052
|
+
/** Subset of {`completed`,`failed`,`cancelled`}. */
|
|
2053
|
+
statuses?: ExecutionStatus[];
|
|
2054
|
+
/** Lower bound (inclusive) on `started_at`. Unix ms. */
|
|
2055
|
+
sinceMs?: number;
|
|
2056
|
+
/** Upper bound (exclusive) on `started_at`. Unix ms. */
|
|
2057
|
+
untilMs?: number;
|
|
2058
|
+
/** Cap result count. No default. */
|
|
2059
|
+
limit?: number;
|
|
2060
|
+
}
|
|
2061
|
+
/** Window shape for `port.history.aggregateStats`. */
|
|
2062
|
+
interface IHistoryStatsRange {
|
|
2063
|
+
/** Inclusive lower bound. `null` = all-time. */
|
|
2064
|
+
sinceMs: number | null;
|
|
2065
|
+
/** Exclusive upper bound. */
|
|
2066
|
+
untilMs: number;
|
|
2067
|
+
}
|
|
2068
|
+
/** Period bucket granularity for `port.history.aggregateStats`. */
|
|
2069
|
+
type THistoryStatsPeriod = 'day' | 'week' | 'month';
|
|
2070
|
+
/**
|
|
2071
|
+
* Output of `port.transaction(tx => tx.history.migrateNodeFks(from, to))`.
|
|
2072
|
+
* Lists how many rows in each `state_*` table were repointed plus any
|
|
2073
|
+
* composite-PK collisions that forced a drop instead of an update.
|
|
2074
|
+
*/
|
|
2075
|
+
interface IMigrateNodeFksReport {
|
|
2076
|
+
jobs: number;
|
|
2077
|
+
executions: number;
|
|
2078
|
+
summaries: number;
|
|
2079
|
+
enrichments: number;
|
|
2080
|
+
pluginKvs: number;
|
|
2081
|
+
/**
|
|
2082
|
+
* Composite-PK collisions encountered when migrating
|
|
2083
|
+
* `state_summaries` / `state_enrichments` / `state_plugin_kvs` because
|
|
2084
|
+
* a row already existed at the destination PK. The pre-existing rows
|
|
2085
|
+
* are preserved — the migrating rows are dropped (deleted from
|
|
2086
|
+
* `fromPath` without a corresponding INSERT). One entry per dropped
|
|
2087
|
+
* row, with the affected PK fields included for diagnostic output.
|
|
2088
|
+
*/
|
|
2089
|
+
collisions: Array<{
|
|
2090
|
+
table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs';
|
|
2091
|
+
fromPath: string;
|
|
2092
|
+
toPath: string;
|
|
2093
|
+
keys: Record<string, string>;
|
|
2094
|
+
}>;
|
|
2095
|
+
}
|
|
2096
|
+
/** A single `config_plugins` override row as the kernel sees it. */
|
|
2097
|
+
interface IPluginConfigRow {
|
|
2098
|
+
pluginId: string;
|
|
2099
|
+
enabled: boolean;
|
|
2100
|
+
configJson: string | null;
|
|
2101
|
+
updatedAt: number;
|
|
2102
|
+
}
|
|
2103
|
+
/** Discovered kernel migration file (one of `NNN_snake_case.sql`). */
|
|
2104
|
+
interface IMigrationFile {
|
|
2105
|
+
version: number;
|
|
2106
|
+
description: string;
|
|
2107
|
+
filePath: string;
|
|
2108
|
+
}
|
|
2109
|
+
/** A row from the `config_schema_versions` ledger for the kernel scope. */
|
|
2110
|
+
interface IMigrationRecord {
|
|
2111
|
+
scope: string;
|
|
2112
|
+
ownerId: string;
|
|
2113
|
+
version: number;
|
|
2114
|
+
description: string;
|
|
2115
|
+
appliedAt: number;
|
|
2116
|
+
}
|
|
2117
|
+
/** `port.migrations.plan` output: applied vs pending. */
|
|
2118
|
+
interface IMigrationPlan {
|
|
2119
|
+
applied: IMigrationRecord[];
|
|
2120
|
+
pending: IMigrationFile[];
|
|
2121
|
+
}
|
|
2122
|
+
/** Apply-time options for `port.migrations.apply`. */
|
|
2123
|
+
interface IApplyOptions {
|
|
2124
|
+
backup?: boolean;
|
|
2125
|
+
dryRun?: boolean;
|
|
2126
|
+
to?: number;
|
|
2127
|
+
}
|
|
2128
|
+
/** Result of `port.migrations.apply`. */
|
|
2129
|
+
interface IApplyResult {
|
|
2130
|
+
applied: IMigrationFile[];
|
|
2131
|
+
backupPath: string | null;
|
|
2132
|
+
}
|
|
2133
|
+
/** Discovered plugin migration file. Same `NNN_snake_case.sql` convention. */
|
|
2134
|
+
interface IPluginMigrationFile {
|
|
2135
|
+
version: number;
|
|
2136
|
+
description: string;
|
|
2137
|
+
filePath: string;
|
|
2138
|
+
}
|
|
2139
|
+
/** A row from the `config_schema_versions` ledger for a single plugin. */
|
|
2140
|
+
interface IPluginMigrationRecord {
|
|
2141
|
+
version: number;
|
|
2142
|
+
description: string;
|
|
2143
|
+
appliedAt: number;
|
|
2144
|
+
}
|
|
2145
|
+
/** `port.pluginMigrations.plan` output for a single plugin. */
|
|
2146
|
+
interface IPluginMigrationPlan {
|
|
2147
|
+
pluginId: string;
|
|
2148
|
+
applied: IPluginMigrationRecord[];
|
|
2149
|
+
pending: IPluginMigrationFile[];
|
|
2150
|
+
}
|
|
2151
|
+
/** Apply-time options for `port.pluginMigrations.apply`. */
|
|
2152
|
+
interface IPluginApplyOptions {
|
|
2153
|
+
/** No actual writes; surfaces what would run. Default false. */
|
|
2154
|
+
dryRun?: boolean;
|
|
2155
|
+
}
|
|
2156
|
+
/** Result of `port.pluginMigrations.apply`. */
|
|
2157
|
+
interface IPluginApplyResult {
|
|
2158
|
+
pluginId: string;
|
|
2159
|
+
applied: IPluginMigrationFile[];
|
|
2160
|
+
/** Catalog intrusions caught by Layer 3 (post-apply sweep). Empty when clean. */
|
|
2161
|
+
intrusions: string[];
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* `StoragePort` — the kernel's persistence boundary. Driving adapters
|
|
2166
|
+
* (CLI, future server, in-memory test harness) consume this surface
|
|
2167
|
+
* exclusively; nothing in `cli/**` should reach into the SQLite
|
|
2168
|
+
* adapter's internal helpers (free functions on
|
|
2169
|
+
* `kernel/adapters/sqlite/*`) directly. Phase F of the
|
|
2170
|
+
* storage-port-promotion refactor finishes that hardening; A-E grow
|
|
2171
|
+
* the port enough that the CLI has somewhere to land.
|
|
2172
|
+
*
|
|
2173
|
+
* The port is namespaced by domain (`scans`, `issues`, `enrichments`,
|
|
2174
|
+
* etc.) — explicitly NOT a generic `port.query<T>(sql)`. Each
|
|
2175
|
+
* namespace's methods name an operation the kernel cares about; the
|
|
2176
|
+
* adapter translates to its persistence engine's idioms.
|
|
2177
|
+
*
|
|
2178
|
+
* Phase A lands the **scans / issues / enrichments / transaction**
|
|
2179
|
+
* namespaces — the core scan pipeline. The remaining namespaces
|
|
2180
|
+
* (history / jobs / pluginConfig / migrations / pluginMigrations)
|
|
2181
|
+
* arrive in subsequent phases. The port shape declared here is the
|
|
2182
|
+
* Phase A subset; later phases extend it without reshaping what
|
|
2183
|
+
* lands today.
|
|
2184
|
+
*/
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Subset of `StoragePort` exposed inside a `transaction(fn)` callback.
|
|
2188
|
+
* Lifecycle methods are intentionally omitted — a transaction that
|
|
2189
|
+
* tries to `init()` the adapter mid-flight is a category error.
|
|
2190
|
+
*
|
|
2191
|
+
* Every callable in the subset MUST run on the same underlying
|
|
2192
|
+
* transaction handle the adapter opened for the callback. Adapters
|
|
2193
|
+
* are responsible for that wiring; consumers only see the namespace
|
|
2194
|
+
* surfaces.
|
|
2195
|
+
*/
|
|
2196
|
+
interface ITransactionalStorage {
|
|
2197
|
+
scans: {
|
|
2198
|
+
persist(result: ScanResult, opts?: IPersistOptions): Promise<void>;
|
|
2199
|
+
};
|
|
2200
|
+
issues: {
|
|
2201
|
+
deleteById(id: number): Promise<void>;
|
|
2202
|
+
insert(issue: Issue): Promise<void>;
|
|
2203
|
+
};
|
|
2204
|
+
enrichments: {
|
|
2205
|
+
/**
|
|
2206
|
+
* Upsert a batch of fresh enrichment records produced by an
|
|
2207
|
+
* extractor pass. Composite PK is `(nodePath, extractorId)`;
|
|
2208
|
+
* conflict → replace. Every row lands with `stale = 0` (the
|
|
2209
|
+
* caller just refreshed it; ROADMAP §B.10 — staleness is
|
|
2210
|
+
* computed downstream when the body hash changes again).
|
|
2211
|
+
*/
|
|
2212
|
+
upsertMany(records: IEnrichmentRecord[]): Promise<void>;
|
|
2213
|
+
};
|
|
2214
|
+
history: {
|
|
2215
|
+
/**
|
|
2216
|
+
* Repoint every `state_*` reference from `fromPath` to `toPath`.
|
|
2217
|
+
* Atomic across the four state tables; the report flags any
|
|
2218
|
+
* composite-PK collisions so callers can diagnose them.
|
|
2219
|
+
* `sm orphans reconcile` / `undo-rename` and the scan-time
|
|
2220
|
+
* rename heuristic are the canonical consumers.
|
|
2221
|
+
*/
|
|
2222
|
+
migrateNodeFks(from: string, to: string): Promise<IMigrateNodeFksReport>;
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
interface StoragePort {
|
|
2226
|
+
init(): Promise<void>;
|
|
2227
|
+
close(): Promise<void>;
|
|
2228
|
+
scans: {
|
|
2229
|
+
/**
|
|
2230
|
+
* Persist a fresh `ScanResult` (replace-all on the scan zone).
|
|
2231
|
+
* Called by `sm scan` after the orchestrator returns. The renames /
|
|
2232
|
+
* extractor-runs / enrichments side bags ride along inside the
|
|
2233
|
+
* same transaction — the call is atomic from the caller's view.
|
|
2234
|
+
*/
|
|
2235
|
+
persist(result: ScanResult, opts?: IPersistOptions): Promise<void>;
|
|
2236
|
+
/**
|
|
2237
|
+
* Hydrate the persisted `ScanResult`. Returns the snapshot the
|
|
2238
|
+
* scan zone holds today (including external-Provider kinds —
|
|
2239
|
+
* `node.kind` is open string per `node.schema.json`).
|
|
2240
|
+
*/
|
|
2241
|
+
load(): Promise<ScanResult>;
|
|
2242
|
+
/**
|
|
2243
|
+
* Spec § A.9 — fine-grained extractor-runs cache breadcrumbs.
|
|
2244
|
+
* Returns `Map<nodePath, Map<qualifiedExtractorId, bodyHashAtRun>>`.
|
|
2245
|
+
*/
|
|
2246
|
+
loadExtractorRuns(): Promise<Map<string, Map<string, string>>>;
|
|
2247
|
+
/** Universal enrichment layer — every persisted `(node, extractor)` pair. */
|
|
2248
|
+
loadNodeEnrichments(): Promise<IPersistedEnrichment[]>;
|
|
2249
|
+
/**
|
|
2250
|
+
* Row counts for `scan_nodes` / `scan_links` / `scan_issues`.
|
|
2251
|
+
* Used by `sm scan`'s "refusing to wipe a populated DB" guard.
|
|
2252
|
+
*/
|
|
2253
|
+
countRows(): Promise<INodeCounts>;
|
|
2254
|
+
/** Row-level filter for `sm list`. Open `kind` (matches `Node.kind`). */
|
|
2255
|
+
findNodes(filter: INodeFilter): Promise<Node[]>;
|
|
2256
|
+
/**
|
|
2257
|
+
* Bundled fetch for `sm show <path>`. Returns `null` if the node
|
|
2258
|
+
* is not in the persisted scan.
|
|
2259
|
+
*/
|
|
2260
|
+
findNode(path: string): Promise<INodeBundle | null>;
|
|
2261
|
+
};
|
|
2262
|
+
issues: {
|
|
2263
|
+
/** Every issue from the latest scan, in insertion order. */
|
|
2264
|
+
listAll(): Promise<Issue[]>;
|
|
2265
|
+
/**
|
|
2266
|
+
* Issue rows whose runtime `Issue` shape passes `predicate`.
|
|
2267
|
+
* `port.issues.findActive((i) => i.ruleId === 'orphan')` is the
|
|
2268
|
+
* canonical use; `sm orphans` consumes this. The returned shape
|
|
2269
|
+
* carries the DB-assigned `id` so a follow-up
|
|
2270
|
+
* `transaction(tx => tx.issues.deleteById(row.id))` can target
|
|
2271
|
+
* a specific row.
|
|
2272
|
+
*/
|
|
2273
|
+
findActive(predicate: (issue: Issue) => boolean): Promise<IIssueRow[]>;
|
|
2274
|
+
};
|
|
2275
|
+
pluginConfig: {
|
|
2276
|
+
/**
|
|
2277
|
+
* Upsert the per-plugin enabled override into `config_plugins`.
|
|
2278
|
+
* Caller is `sm plugins enable / disable`.
|
|
2279
|
+
*/
|
|
2280
|
+
set(pluginId: string, enabled: boolean): Promise<void>;
|
|
2281
|
+
/** Read a single override; `undefined` when no row exists. */
|
|
2282
|
+
get(pluginId: string): Promise<boolean | undefined>;
|
|
2283
|
+
/** Every override row, sorted by `pluginId` for stable rendering. */
|
|
2284
|
+
list(): Promise<IPluginConfigRow[]>;
|
|
2285
|
+
/** Drop a single override row (no-op when absent). */
|
|
2286
|
+
delete(pluginId: string): Promise<void>;
|
|
2287
|
+
/**
|
|
2288
|
+
* Load every override into a map for quick lookup by id. Used by
|
|
2289
|
+
* `loadPluginRuntime` to layer the DB overrides over the
|
|
2290
|
+
* `settings.json` defaults at scan boot.
|
|
2291
|
+
*/
|
|
2292
|
+
loadOverrideMap(): Promise<Map<string, boolean>>;
|
|
2293
|
+
};
|
|
2294
|
+
jobs: {
|
|
2295
|
+
/**
|
|
2296
|
+
* Delete `state_jobs` rows in terminal `status` whose `finishedAt`
|
|
2297
|
+
* is older than `cutoffMs` (Unix ms). Returns the deleted count
|
|
2298
|
+
* plus every non-null `filePath` from the deleted rows so the
|
|
2299
|
+
* caller can unlink the on-disk MD files. Caller computes
|
|
2300
|
+
* `cutoffMs` from the configured retention.
|
|
2301
|
+
*/
|
|
2302
|
+
pruneTerminal(status: 'completed' | 'failed', cutoffMs: number): Promise<IPruneResult>;
|
|
2303
|
+
/**
|
|
2304
|
+
* Same SELECT side as `pruneTerminal` but without the DELETE.
|
|
2305
|
+
* Powers `sm job prune --dry-run` previews so the dry-run output
|
|
2306
|
+
* names exactly the rows the live mode would delete.
|
|
2307
|
+
*/
|
|
2308
|
+
listTerminalCandidates(status: 'completed' | 'failed', cutoffMs: number): Promise<IPruneResult>;
|
|
2309
|
+
/**
|
|
2310
|
+
* Read every `state_jobs.filePath` currently set, normalized through
|
|
2311
|
+
* `path.resolve()`. The CLI's `sm job prune --orphan-files` flow
|
|
2312
|
+
* pairs this set with `kernel/jobs/orphan-files.ts:findOrphanJobFiles`
|
|
2313
|
+
* (which walks the directory) to compute the MD files on disk that
|
|
2314
|
+
* no row references — keeps the storage layer FS-free.
|
|
2315
|
+
*/
|
|
2316
|
+
listReferencedFilePaths(): Promise<Set<string>>;
|
|
2317
|
+
};
|
|
2318
|
+
history: {
|
|
2319
|
+
/** List `state_executions` rows (paginated by filter). */
|
|
2320
|
+
list(filter: IListExecutionsFilter): Promise<ExecutionRecord[]>;
|
|
2321
|
+
/**
|
|
2322
|
+
* Aggregate counters / period buckets / top-nodes / error rates
|
|
2323
|
+
* over `state_executions`. Body matches the spec
|
|
2324
|
+
* `history-stats.schema.json` shape minus `range`/`elapsedMs`
|
|
2325
|
+
* (the verb fills those in around the call).
|
|
2326
|
+
*/
|
|
2327
|
+
aggregateStats(range: IHistoryStatsRange, period: THistoryStatsPeriod, topN: number): Promise<Omit<HistoryStats, 'elapsedMs' | 'range'> & {
|
|
2328
|
+
rangeMs: {
|
|
2329
|
+
sinceMs: number | null;
|
|
2330
|
+
untilMs: number;
|
|
2331
|
+
};
|
|
2332
|
+
}>;
|
|
2333
|
+
};
|
|
2334
|
+
migrations: {
|
|
2335
|
+
/** Enumerate kernel migration files bundled with this build. */
|
|
2336
|
+
discover(): IMigrationFile[];
|
|
2337
|
+
/**
|
|
2338
|
+
* Compute the apply / pending plan against the current `config_
|
|
2339
|
+
* schema_versions` ledger. Read-only; safe under `--dry-run`.
|
|
2340
|
+
*/
|
|
2341
|
+
plan(files?: IMigrationFile[]): IMigrationPlan;
|
|
2342
|
+
/**
|
|
2343
|
+
* Apply pending migrations in order. Each runs inside its own
|
|
2344
|
+
* `BEGIN/COMMIT` (per `kernel/adapters/sqlite/migrations.ts`); a
|
|
2345
|
+
* partial failure rolls back to the prior state. Returns the
|
|
2346
|
+
* applied list + backup path (when `backup: true`).
|
|
2347
|
+
*/
|
|
2348
|
+
apply(options?: IApplyOptions, files?: IMigrationFile[]): IApplyResult;
|
|
2349
|
+
/**
|
|
2350
|
+
* WAL-checkpoint + atomic file copy of the DB to `destPath`.
|
|
2351
|
+
* Caller composes the path. Returns the destination on success,
|
|
2352
|
+
* or `null` for in-memory DBs (no file to copy).
|
|
2353
|
+
*/
|
|
2354
|
+
writeBackup(destPath: string): string | null;
|
|
2355
|
+
/**
|
|
2356
|
+
* Read `PRAGMA user_version` from the underlying DB. The migrations
|
|
2357
|
+
* runner keeps that pragma in sync with the latest applied kernel
|
|
2358
|
+
* migration, so this is the canonical "current schema version"
|
|
2359
|
+
* read for `sm version --json`'s `dbSchema` field. Returns `null`
|
|
2360
|
+
* on engine quirks (non-numeric / null pragma).
|
|
2361
|
+
*/
|
|
2362
|
+
currentSchemaVersion(): number | null;
|
|
2363
|
+
};
|
|
2364
|
+
pluginMigrations: {
|
|
2365
|
+
/** Path to the plugin's `migrations/` directory, or `null` when absent. */
|
|
2366
|
+
resolveDir(plugin: IDiscoveredPlugin): string | null;
|
|
2367
|
+
/** Discover the plugin's migration files. */
|
|
2368
|
+
discover(plugin: IDiscoveredPlugin): IPluginMigrationFile[];
|
|
2369
|
+
/**
|
|
2370
|
+
* Plan against `config_schema_versions` for the plugin's
|
|
2371
|
+
* `(scope='plugin', ownerId=plugin.id)`.
|
|
2372
|
+
*/
|
|
2373
|
+
plan(plugin: IDiscoveredPlugin, files?: IPluginMigrationFile[]): IPluginMigrationPlan;
|
|
2374
|
+
/** Apply pending plugin migrations. Same per-file BEGIN/COMMIT pattern. */
|
|
2375
|
+
apply(plugin: IDiscoveredPlugin, options?: IPluginApplyOptions, files?: IPluginMigrationFile[]): IPluginApplyResult;
|
|
2376
|
+
};
|
|
2377
|
+
/**
|
|
2378
|
+
* Open a transaction. The callback receives a transactional subset
|
|
2379
|
+
* of the port; the adapter commits on resolution and rolls back on
|
|
2380
|
+
* rejection. `sm orphans reconcile / undo-rename` and `sm refresh`
|
|
2381
|
+
* are the canonical consumers.
|
|
2382
|
+
*/
|
|
2383
|
+
transaction<T>(fn: (tx: ITransactionalStorage) => Promise<T>): Promise<T>;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* `FilesystemPort` — walks roots, reads nodes, writes job files.
|
|
2388
|
+
*
|
|
2389
|
+
* Shape-only. The real adapter ships with the scan end-to-end pipeline.
|
|
2390
|
+
*/
|
|
2391
|
+
interface NodeStat {
|
|
2392
|
+
path: string;
|
|
2393
|
+
sizeBytes: number;
|
|
2394
|
+
mtimeMs: number;
|
|
2395
|
+
}
|
|
2396
|
+
interface IWalkOptions {
|
|
2397
|
+
ignore?: string[];
|
|
2398
|
+
}
|
|
2399
|
+
interface FilesystemPort {
|
|
2400
|
+
walk(roots: string[], options?: IWalkOptions): AsyncIterable<NodeStat>;
|
|
2401
|
+
readNode(path: string): Promise<string>;
|
|
2402
|
+
stat(path: string): Promise<NodeStat>;
|
|
2403
|
+
writeJobFile(path: string, content: string): Promise<void>;
|
|
2404
|
+
ensureDir(path: string): Promise<void>;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* `RunnerPort` — executes an action against a rendered job file.
|
|
2409
|
+
*
|
|
2410
|
+
* Shape-only. `ClaudeCliRunner` + `MockRunner` land with the job subsystem
|
|
2411
|
+
* (job subsystem + first summarizer).
|
|
2412
|
+
*/
|
|
2413
|
+
interface IRunOptions {
|
|
2414
|
+
timeoutMs?: number;
|
|
2415
|
+
model?: string;
|
|
2416
|
+
}
|
|
2417
|
+
interface IRunResult {
|
|
2418
|
+
reportPath: string;
|
|
2419
|
+
tokensIn: number;
|
|
2420
|
+
tokensOut: number;
|
|
2421
|
+
durationMs: number;
|
|
2422
|
+
exitCode: number;
|
|
2423
|
+
}
|
|
2424
|
+
interface RunnerPort {
|
|
2425
|
+
run(jobFilePath: string, options?: IRunOptions): Promise<IRunResult>;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* `LoggerPort` — structured logging port for the kernel.
|
|
2430
|
+
*
|
|
2431
|
+
* The kernel must NOT write to stdout/stderr directly. Anything that
|
|
2432
|
+
* would historically have been a `console.log` / `console.error` goes
|
|
2433
|
+
* through this port; the adapter (CLI, server, test harness) decides
|
|
2434
|
+
* format, level filter, and destination.
|
|
2435
|
+
*
|
|
2436
|
+
* Levels follow the conventional ordering, lowest = most verbose:
|
|
2437
|
+
*
|
|
2438
|
+
* trace < debug < info < warn < error < silent
|
|
2439
|
+
*
|
|
2440
|
+
* `silent` is a sentinel for filtering only — it never appears as a
|
|
2441
|
+
* `LogRecord.level`. Setting an adapter to `silent` disables every
|
|
2442
|
+
* method.
|
|
2443
|
+
*/
|
|
2444
|
+
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
2445
|
+
type LogMethodLevel = Exclude<LogLevel, 'silent'>;
|
|
2446
|
+
declare const LOG_LEVELS: readonly LogLevel[];
|
|
2447
|
+
declare function logLevelRank(level: LogLevel): number;
|
|
2448
|
+
declare function isLogLevel(value: unknown): value is LogLevel;
|
|
2449
|
+
/**
|
|
2450
|
+
* Parse a string into a `LogLevel`. Returns `null` for invalid input
|
|
2451
|
+
* (incl. `undefined` / `null` / empty). Case-insensitive; trims
|
|
2452
|
+
* whitespace.
|
|
2453
|
+
*/
|
|
2454
|
+
declare function parseLogLevel(value: string | undefined | null): LogLevel | null;
|
|
2455
|
+
interface LogRecord {
|
|
2456
|
+
level: LogMethodLevel;
|
|
2457
|
+
/** ISO 8601 timestamp produced at the moment the log call was made. */
|
|
2458
|
+
timestamp: string;
|
|
2459
|
+
message: string;
|
|
2460
|
+
/** Optional structured context. Caller-owned; serialization is up to the formatter. */
|
|
2461
|
+
context?: Record<string, unknown>;
|
|
2462
|
+
}
|
|
2463
|
+
interface LoggerPort {
|
|
2464
|
+
trace(message: string, context?: Record<string, unknown>): void;
|
|
2465
|
+
debug(message: string, context?: Record<string, unknown>): void;
|
|
2466
|
+
info(message: string, context?: Record<string, unknown>): void;
|
|
2467
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
2468
|
+
error(message: string, context?: Record<string, unknown>): void;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* No-op `LoggerPort`. Default when the kernel is invoked without a
|
|
2473
|
+
* logger (tests, embedded usage). Equivalent in spirit to
|
|
2474
|
+
* `InMemoryProgressEmitter`: callers that don't care get a working
|
|
2475
|
+
* implementation that does nothing.
|
|
2476
|
+
*
|
|
2477
|
+
* Every method is intentionally empty — that IS the contract of this
|
|
2478
|
+
* class. We disable `no-empty-function` for the whole file because
|
|
2479
|
+
* adding `// eslint-disable-next-line` to each method would be noise.
|
|
2480
|
+
*/
|
|
2481
|
+
|
|
2482
|
+
declare class SilentLogger implements LoggerPort {
|
|
2483
|
+
trace(): void;
|
|
2484
|
+
debug(): void;
|
|
2485
|
+
info(): void;
|
|
2486
|
+
warn(): void;
|
|
2487
|
+
error(): void;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Module-level singleton `LoggerPort`. The kernel emits warnings /
|
|
2492
|
+
* info / debug through `log.*`; the active implementation defaults to
|
|
2493
|
+
* `SilentLogger` (no output) and is swapped by the driving adapter at
|
|
2494
|
+
* boot time via `configureLogger(...)`.
|
|
2495
|
+
*
|
|
2496
|
+
* Why a singleton (vs. per-call injection):
|
|
2497
|
+
* - Logging crosses every layer; threading a `logger` argument
|
|
2498
|
+
* through every kernel function costs a lot of plumbing for a
|
|
2499
|
+
* side-channel concern.
|
|
2500
|
+
* - The active impl is a pointer; the exported `log` is a stable
|
|
2501
|
+
* proxy. Imports made before `configureLogger` runs still see the
|
|
2502
|
+
* new impl on every call — no "captured stale logger" bugs.
|
|
2503
|
+
*
|
|
2504
|
+
* Tradeoffs accepted:
|
|
2505
|
+
* - Tests must call `resetLogger()` (or replace the active impl) in
|
|
2506
|
+
* teardown to avoid cross-test bleed.
|
|
2507
|
+
* - Concurrent scans share the same logger; per-scan logging requires
|
|
2508
|
+
* reintroducing an explicit `logger` argument on the call path.
|
|
2509
|
+
*/
|
|
2510
|
+
|
|
2511
|
+
/** Stable proxy. Methods always delegate to the current `active` impl. */
|
|
2512
|
+
declare const log: LoggerPort;
|
|
2513
|
+
/** Install a logger as the active implementation. Idempotent. */
|
|
2514
|
+
declare function configureLogger(impl: LoggerPort): void;
|
|
2515
|
+
/** Restore the default `SilentLogger`. Call from test teardown. */
|
|
2516
|
+
declare function resetLogger(): void;
|
|
2517
|
+
/** Inspect the active logger. Test-only — production code uses `log`. */
|
|
2518
|
+
declare function getActiveLogger(): LoggerPort;
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* Kernel entry point. `createKernel()` returns a shell with an empty registry
|
|
2522
|
+
* and no bound ports. Driving adapters (CLI, Server, Skill) are expected to
|
|
2523
|
+
* wire adapters before invoking use cases.
|
|
2524
|
+
*/
|
|
2525
|
+
|
|
2526
|
+
interface Kernel {
|
|
2527
|
+
registry: Registry;
|
|
2528
|
+
}
|
|
2529
|
+
declare function createKernel(): Kernel;
|
|
2530
|
+
|
|
2531
|
+
export { type Confidence, DuplicateExtensionError, EXTENSION_KINDS, type ExecutionFailureReason, type ExecutionKind, type ExecutionRecord, type ExecutionRunner, type ExecutionStatus, ExportQueryError, type Extension, type ExtensionKind, type FilesystemPort, HOOK_TRIGGERS, type HistoryStats, type HistoryStatsErrorRates, type HistoryStatsExecutionsPerPeriod, type HistoryStatsPerActionRate, type HistoryStatsTokensPerAction, type HistoryStatsTopNode, type HistoryStatsTotals, type IAction, type IActionPrecondition, type ICreateFsWatcherOptions, type IDedicatedStorePersist, type IDedicatedStoreWrapper, type IDiscoveredPlugin, type IEnrichmentRecord, type IExportQuery, type IExportSubset, type IExtensionBase, type IExtractor, type IExtractorCallbacks, type IExtractorContext, type IExtractorRunRecord, type IFormatter, type IFormatterContext, type IFsWatcher, type IHook, type IHookContext, type IIssueRow, type IKvStorePersist, type IKvStoreWrapper, type ILoadedExtension, type INodeBundle, type INodeChange, type INodeCounts, type INodeFilter, type IPersistOptions, type IPersistedEnrichment, type IPluginManifest, type IPluginStorageSchema, type IPluginStore, type IProvider, type IRawNode, type IRule, type IRuleContext, type IRunOptions, type IRunResult, type IScanDelta, type ITransactionalStorage, type IWalkOptions, type IWatchBatch, type IWatchEvent, InMemoryProgressEmitter, type Issue, type IssueFix, KV_SCHEMA_KEY, type Kernel, LOG_LEVELS, type Link, type LinkKind, type LinkLocation, type LinkTrigger, type LogLevel, type LogMethodLevel, type LogRecord, type LoggerPort, type Node, type NodeKind, type NodeStat, type PluginLoaderPort, type ProgressEmitterPort, type ProgressEvent, type ProgressListener, Registry, type RenameOp, type RunScanOptions, type RunnerPort, type ScanResult, type ScanScannedBy, type ScanStats, type Severity, SilentLogger, type Stability, type StoragePort, type TExecutionMode, type TGranularity, type THookFilter, type THookTrigger, type TNodeChangeReason, type TPluginLoadStatus, type TPluginStorage, type TWatchEventKind, type TripleSplit, applyExportQuery, computeScanDelta, configureLogger, createChokidarWatcher, createKernel, detectRenamesAndOrphans, getActiveLogger, isEmptyDelta, isLogLevel, log, logLevelRank, makeDedicatedStoreWrapper, makeKvStoreWrapper, makePluginStore, mergeNodeWithEnrichments, parseExportQuery, parseLogLevel, qualifiedExtensionId, resetLogger, runExtractorsForNode, runScan, runScanWithRenames };
|